A colleague asks, “The customer wants three extra pages. What is the process?” Your procedure library calls that a change request. Local semantic search can connect the question to the relevant instructions without requiring the colleague to guess the document title.
This walkthrough builds a terminal search tool with FastEmbed and a small BGE embedding model. It returns three ranked procedures with their original text. The model actually ran on a CPU for this article; it did not generate answers or call a hosted inference service.
Fictional example: Cedar Lane Design is an invented web studio. All twelve procedures and ten test questions below are sample material, not client records, Jason’s personal business policies or evidence of customer outcomes. The featured image is an AI-generated conceptual illustration.
What the model contributes
An embedding is an array of numbers used to compare texts. Here each procedure title and body becomes a 384-dimensional vector. A question becomes another vector, and the program ranks the stored procedures by similarity. The result is a list of references for someone to read.
FastEmbed’s documentation introduces its ONNX-based embedding workflow. This example uses BAAI/bge-small-en-v1.5 through Qdrant’s quantized ONNX model repository. Its model card lists an MIT license. FastEmbed itself uses Apache-2.0.
The inspected FastEmbed v0.8.1 implementation maps this model name to that ONNX repository, lists English text with a 512-token limit and normalizes its output vectors. With unit-length vectors, their dot product is cosine similarity. The test checks that normalization instead of assuming it for every embedding model.
Install and pin the actual model files
The executed environment was Windows 11, Python 3.14.3, FastEmbed 0.8.1, ONNX Runtime 1.30.0, NumPy 2.5.3, tokenizers 0.23.2 and huggingface-hub 1.33.0. A GPU, vector database and hosted model account are unnecessary for this example.
Create a fresh working directory and run these PowerShell commands:
python -m venv .venv
.venv\Scripts\python.exe -m pip install --only-binary=:all: "fastembed==0.8.1"
The direct package pin still permits dependency changes. The full installed version snapshot is below; save it as requirements-tested.txt and use .venv\Scripts\python.exe -m pip install --only-binary=:all: -r requirements-tested.txt in a fresh environment when reproducing this Windows run. It is a version snapshot, not a package-hash lockfile, and binary availability on other platforms can differ.
Tested dependency versions — requirements-tested.txt
anyio==4.15.1
certifi==2026.7.22
charset-normalizer==3.5.2
click==8.5.0
colorama==0.4.6
fastembed==0.8.1
filelock==4.0.9
flatbuffers==25.12.19
fsspec==2026.9.0
h11==0.16.0
hf-xet==1.6.0
httpcore==1.0.9
httpx==0.28.1
huggingface_hub==1.33.0
idna==3.20
loguru==0.7.3
mmh3==5.3.1
numpy==2.5.3
onnxruntime==1.30.0
packaging==26.3
pillow==12.3.0
protobuf==7.36.2
py_rust_stemmers==0.1.8
PyYAML==6.0.3
requests==2.34.2
tokenizers==0.23.2
tqdm==4.70.1
typing_extensions==4.16.0
urllib3==2.8.0
win32_setctime==1.2.0
Save this downloader as download_model.py. It selects an exact model revision, downloads eight named files into model/ and records their SHA-256 values. The files totaled 67,412,843 bytes in this run; that is model-download size, not total installation size or RAM usage. Downloads need internet access. Public unauthenticated downloads can be rate-limited; this script uses one download worker and the ordinary HTTP download path.
Complete model downloader — download_model.py
"""Download the exact public ONNX model snapshot used by the article."""
import hashlib
import json
import os
from pathlib import Path
os.environ["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = "1"
os.environ["HF_HUB_DISABLE_XET"] = "1"
from huggingface_hub import snapshot_download
REPOSITORY = "Qdrant/bge-small-en-v1.5-onnx-Q"
REVISION = "aa8f8b060edb00e03bfdd08813a2949946c8ba55"
FILES = ["README.md", "config.json", "model_optimized.onnx", "ort_config.json",
"special_tokens_map.json", "tokenizer.json", "tokenizer_config.json", "vocab.txt"]
if __name__ == "__main__":
folder = Path("model")
snapshot_download(repo_id=REPOSITORY, revision=REVISION,
local_dir=folder, allow_patterns=FILES, token=False, max_workers=1)
manifest = {"repository": REPOSITORY, "revision": REVISION, "files": {}}
for name in FILES:
data = (folder / name).read_bytes()
manifest["files"][name] = {"bytes": len(data),
"sha256": hashlib.sha256(data).hexdigest()}
Path("model-manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
print(json.dumps({"revision": REVISION, "files": len(FILES),
"bytes": sum(f["bytes"] for f in manifest["files"].values())}))
.venv\Scripts\python.exe download_model.py
The pinned revision is aa8f8b060edb00e03bfdd08813a2949946c8ba55. Keep the model, tokenizer, dependency snapshot and test results together. Naming the model alone does not identify the exact files used in a result.
Start with short, owned procedures
Save this as procedures.json. Each entry has an ID, title and body. The example embeds the title and body together and leaves the ID available for attribution. These are short procedures, so each fits in one passage.
Complete fictional procedure library — procedures.json
[
{"id":"kickoff","title":"Start a website project","text":"At kickoff, collect the client's goals, intended audience and required pages. Assign one client contact to consolidate feedback. Record scope and decision owners in the project brief."},
{"id":"copy","title":"Collect page copy","text":"Ask the client to supply final page text in the shared content folder. Mark missing sections clearly. Placeholder copy must be replaced or explicitly approved before launch."},
{"id":"scope","title":"Request work outside the agreed scope","text":"Log requests for additional pages or features as a change request. The project lead estimates the work and obtains written client approval before changing the delivery plan."},
{"id":"logo","title":"Request usable brand assets","text":"Request the client's logo as SVG or another original vector file, along with brand colors and fonts. A small screenshot of the logo is not a suitable production source."},
{"id":"revisions","title":"Consolidate design feedback","text":"Collect comments into one numbered revision list from the designated client contact. Resolve contradictory requests before the designer starts the next revision round."},
{"id":"launch","title":"Approve public launch","text":"Do not publish the website until the named client approver has given written signoff on the launch checklist. A preview link or a casual positive comment does not replace that approval."},
{"id":"access","title":"Share domain access","text":"Ask the domain owner to invite the project lead with the minimum required permissions. Do not ask the client to email their registrar password. Confirm the intended domain before any DNS change."},
{"id":"invoice","title":"Handle an invoice status question","text":"Check the invoice number against the billing system and confirm whether payment is recorded. Route discrepancies to the office contact. Do not infer payment from a customer's message alone."},
{"id":"accessibility","title":"Review keyboard navigation","text":"Before launch, navigate links, buttons and forms using the keyboard. Check visible focus and descriptive field labels. Record issues for repair; this check alone is not a complete accessibility audit."},
{"id":"backup","title":"Prepare a recovery copy","text":"Before a site update, save a current backup of the website files and database. Record its location and the restoration steps. A successful download alone does not demonstrate that restoration works."},
{"id":"meeting","title":"Record a client meeting","text":"After a client meeting, save decisions, named owners and due dates in the project notes. Send the agreed action list through the existing client communication channel."},
{"id":"images","title":"Approve supplied campaign photos","text":"Confirm the client has permission to use supplied photos in the intended campaign. Record the source and usage restrictions. Finding an image on a public website does not establish reuse permission."}
]
For a real pilot, use procedures that already have an owner and an accepted revision. Exclude information the intended readers cannot access. If your source is a Word document, the MarkItDown review-bundle walkthrough shows why conversion itself needs checking before the text enters a search collection.
Build a search result, not an automatic decision
Save the implementation as search_sops.py. It loads the local model explicitly, selects the CPU provider and returns the original procedure text alongside the score. It rejects blank queries and texts beyond 512 tokens before the model silently truncates them. Query length includes its instruction prefix and special tokens.
The BGE model card recommends a retrieval instruction for short queries, with no instruction added to passages. This implementation adds that instruction explicitly. In the inspected FastEmbed base methods used by this model, query_embed and passage_embed forward to embed; their names alone do not add the BGE instruction.
Complete local search tool — search_sops.py
"""Rank a small fictional procedure library with a local embedding model."""
import argparse
import json
from pathlib import Path
import numpy as np
from fastembed import TextEmbedding
from tokenizers import Tokenizer
MODEL = "BAAI/bge-small-en-v1.5"
QUERY_PREFIX = "Represent this sentence for searching relevant passages: "
class ProcedureSearch:
def __init__(self, documents, model_dir="model"):
if not documents or len({d["id"] for d in documents}) != len(documents):
raise ValueError("Provide nonempty documents with unique IDs")
self.documents = documents
self.tokenizer = Tokenizer.from_file(str(Path(model_dir) / "tokenizer.json"))
self.tokenizer.no_truncation()
self.tokenizer.no_padding()
self.model = TextEmbedding(MODEL, specific_model_path=str(model_dir),
local_files_only=True,
providers=["CPUExecutionProvider"], threads=2)
texts = [d["title"] + "\n" + d["text"] for d in documents]
self.check_lengths(texts)
self.vectors = np.array(list(self.model.embed(texts)), dtype=np.float32)
def check_lengths(self, texts):
for text in texts:
if len(self.tokenizer.encode(text).ids) > 512:
raise ValueError("Text exceeds 512 tokens; split it before embedding")
def search(self, query, k=3):
if not query.strip():
raise ValueError("Query must not be blank")
if not 1 <= k <= len(self.documents):
raise ValueError("k must be between 1 and the document count")
text = QUERY_PREFIX + query.strip()
self.check_lengths([text])
vector = np.array(next(self.model.embed([text])), dtype=np.float32)
# This model's FastEmbed implementation normalizes both vectors.
scores = self.vectors @ vector
order = sorted(range(len(scores)),
key=lambda i: (-float(scores[i]), self.documents[i]["id"]))[:k]
return [{**self.documents[i], "similarity": round(float(scores[i]), 6)}
for i in order]
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("query")
parser.add_argument("--documents", default="procedures.json")
parser.add_argument("--model-dir", default="model")
parser.add_argument("--top", type=int, default=3)
args = parser.parse_args()
documents = json.loads(Path(args.documents).read_text("utf-8"))
engine = ProcedureSearch(documents, args.model_dir)
print(json.dumps({"notice": "Ranked references, not an answer or approval.",
"results": engine.search(args.query, args.top)}, indent=2))
.venv\Scripts\python.exe search_sops.py "The customer wants three extra pages. What is the process?"
The first two results from the executed example were scope: 0.610862 and copy: 0.610387. The correct scope-change procedure ranked first, but the difference was tiny. Displaying both titles and source passages gives the reader something useful to judge; a single confident-looking answer would conceal that ambiguity.
Test the questions you can—and cannot—answer
Save the questions below as queries.json. Eight have a designated supporting procedure; two deliberately have none. These labels were written before the first ranking run. The questions and procedures were authored together, so this is a small diagnostic fixture set, not an independent business benchmark.
Complete evaluation questions — queries.json
[
{"query":"The customer wants three extra pages. What is the process?","expected":"scope"},
{"query":"The logo looks blurry when enlarged. What file should I request?","expected":"logo"},
{"query":"Can I make the site public without written signoff?","expected":"launch"},
{"query":"How should the customer give us control of their domain?","expected":"access"},
{"query":"Two stakeholders asked for opposite design changes. What now?","expected":"revisions"},
{"query":"How do we recover if a website update goes wrong?","expected":"backup"},
{"query":"A user cannot use a mouse. What should we check?","expected":"accessibility"},
{"query":"The client says they already paid. Can we mark the bill settled?","expected":"invoice"},
{"query":"When are staff wages paid?","expected":null},
{"query":"How long is the hardware warranty?","expected":null}
]
Save this evaluator as evaluate_search.py. It checks the saved model hashes, actual CPU execution provider, dimensions, normalization, preserved source text, rejected inputs and unchanged corpus. It records every result. During model loading and search it enables Hugging Face offline mode and blocks Python socket connections.
Complete model and retrieval checks — evaluate_search.py
"""Exercise the actual downloaded model and record all fixture rankings."""
import hashlib
import json
import os
import platform
import socket
from importlib.metadata import version
from pathlib import Path
from unittest.mock import patch
os.environ["HF_HUB_OFFLINE"] = "1"
from search_sops import ProcedureSearch, QUERY_PREFIX
import numpy as np
def must_reject(callback):
try:
callback()
except ValueError:
return
raise AssertionError("Expected input rejection")
if __name__ == "__main__":
documents = json.loads(Path("procedures.json").read_text("utf-8"))
queries = json.loads(Path("queries.json").read_text("utf-8"))
source_hash = hashlib.sha256(Path("procedures.json").read_bytes()).hexdigest()
manifest = json.loads(Path("model-manifest.json").read_text("utf-8"))
for name, info in manifest["files"].items():
assert hashlib.sha256((Path("model") / name).read_bytes()).hexdigest() == info["sha256"]
checks = ["downloaded model file hashes match manifest"]
# This catches Python socket connections; it is not an OS network sandbox.
with patch.object(socket.socket, "connect", side_effect=AssertionError("Network attempted")) as connect, \
patch.object(socket.socket, "connect_ex", side_effect=AssertionError("Network attempted")) as connect_ex:
engine = ProcedureSearch(documents)
assert engine.model.model.model.get_providers() == ["CPUExecutionProvider"]
checks.append("actual ONNX session uses CPUExecutionProvider")
assert engine.vectors.shape == (12, 384) and np.isfinite(engine.vectors).all()
checks.append("twelve finite 384-dimensional document vectors")
assert np.allclose(np.linalg.norm(engine.vectors, axis=1), 1, atol=1e-5)
checks.append("document vectors are unit normalized")
query_vector = next(engine.model.embed([QUERY_PREFIX + queries[0]["query"]]))
assert np.isclose(np.linalg.norm(query_vector), 1, atol=1e-5)
checks.append("query vector is unit normalized")
ranks = []
for case in queries:
hits = engine.search(case["query"])
assert len(hits) == 3
assert [h["similarity"] for h in hits] == sorted([h["similarity"] for h in hits], reverse=True)
for hit in hits:
source = next(d for d in documents if d["id"] == hit["id"])
assert all(hit[k] == source[k] for k in ["id", "title", "text"])
ranks.append({**case, "hits": hits,
"rank": next((i + 1 for i,h in enumerate(hits) if h["id"] == case["expected"]), None)})
checks.append("all ten queries return three sorted source-preserving results")
for query in ["", " ", "word " * 600]:
must_reject(lambda q=query: engine.search(q))
checks.append("blank and oversized queries rejected")
for k in [0, 13]:
must_reject(lambda n=k: engine.search("project", n))
checks.append("out-of-range result limits rejected")
must_reject(lambda: engine.check_lengths(["word " * 600]))
checks.append("oversized document rejected before truncation")
must_reject(lambda: ProcedureSearch([]))
must_reject(lambda: ProcedureSearch([documents[0], documents[0]]))
checks.append("empty corpus and duplicate document IDs rejected")
assert connect.call_count == 0 and connect_ex.call_count == 0
checks.append("no Python socket connection during model loading and search")
assert hashlib.sha256(Path("procedures.json").read_bytes()).hexdigest() == source_hash
checks.append("source corpus unchanged")
known = [r for r in ranks if r["expected"] is not None]
evidence = {
"python": platform.python_version(), "platform": platform.platform(),
"versions": {p:version(p) for p in ["fastembed", "onnxruntime", "numpy", "tokenizers", "huggingface-hub"]},
"model_repository": manifest["repository"], "model_revision": manifest["revision"],
"corpus_sha256": source_hash, "checks_passed": len(checks), "checks": checks,
"known_queries": len(known), "top1_hits": sum(r["rank"] == 1 for r in known),
"top3_hits": sum(r["rank"] is not None for r in known),
"unsupported_queries": len(ranks) - len(known), "results": ranks,
"limits": "Small authored fictional fixture set, selected before the first ranking run; not a held-out business benchmark. No generative model, production server, privacy certification or load test. Socket guard is Python-level only."
}
Path("test-evidence.json").write_text(json.dumps(evidence, indent=2), encoding="utf-8")
print(json.dumps({k:evidence[k] for k in ["checks_passed", "known_queries", "top1_hits", "top3_hits", "unsupported_queries"]}))
for result in ranks:
print(json.dumps({"query":result["query"], "expected":result["expected"], "rank":result["rank"],
"top3":[[h["id"],h["similarity"]] for h in result["hits"]]}))
.venv\Scripts\python.exe evaluate_search.py
Executed result: twelve implementation checks passed. All eight questions with a designated procedure ranked that procedure first. Both unsupported questions still returned three results. No Python socket connection was attempted during the guarded model-loading and search section. This is evidence about this execution path; the guard is not an operating-system network sandbox or a privacy certification.
| Question | Expected source | Top result | Similarity |
|---|---|---|---|
| The customer wants three extra pages. What is the process? | scope | scope | 0.6109 |
| The logo looks blurry when enlarged. What file should I request? | logo | logo | 0.7330 |
| Can I make the site public without written signoff? | launch | launch | 0.7746 |
| How should the customer give us control of their domain? | access | access | 0.7230 |
| Two stakeholders asked for opposite design changes. What now? | revisions | revisions | 0.6368 |
| How do we recover if a website update goes wrong? | backup | backup | 0.7720 |
| A user cannot use a mouse. What should we check? | accessibility | accessibility | 0.6351 |
| The client says they already paid. Can we mark the bill settled? | invoice | invoice | 0.6317 |
| When are staff wages paid? | No supporting procedure | scope | 0.5319 |
| How long is the hardware warranty? | No supporting procedure | accessibility | 0.4870 |
The wage question retrieved a scope-change procedure. The warranty question retrieved keyboard-navigation guidance. The program did what it was written to do: rank the nearest available text. It has no rule that establishes whether the library actually contains an answer.
A score of 0.610862 is not a 61% probability of correctness. BGE’s model card cautions against treating an arbitrary similarity threshold as a universal relevance rule. Do not derive an auto-answer threshold from these ten examples. Before adding such behavior, collect representative questions, near misses and unsupported requests from the intended workflow, label them, and evaluate a separate set after choosing your rule.
How to use this in a business pilot
Start with staff search: show three source cards and let the person choose the relevant procedure. “Can I publish without written signoff?” retrieved the launch procedure, whose text prohibits that action. Retrieval of a related document is not permission to perform the action.
Keep the corpus small enough to inspect, assign owners to its documents and rebuild vectors when the source, model or query instructions change. This terminal example recomputes document vectors each time it starts. A persistent application should store vectors with document revisions and model identity so updates do not leave stale results. At this scale, the in-memory matrix keeps the mechanism easy to examine; no scale or latency claim is being made.
Use exact lookup for identifiers such as invoice numbers and domain names when identity matters. Add chunking with parent-document attribution before processing long manuals; this example rejects them instead. Permission filtering must happen before results reach a user or an agent. The local JSON file has no multi-user authorization layer.
If you later connect search to an agent, return sources as reference data and retain any separate approval steps for business actions. The read-only MCP service catalog walkthrough illustrates that tool boundary, although this article does not implement or test an MCP integration.
This test establishes that a specific small embedding model can rank this fictional procedure library locally. It does not establish superiority over keyword search, another model or a hosted service. No generative model, production traffic, multilingual corpus or measured business savings were tested.
Jason Ead publishes AlgoThoughts AI News and connects its practical engineering work with My Biz Heroes: business websites, automation, workflows, AI tools and training. Read Jason’s profile for the publication’s business connection.
Research and tool-executed example tests: October 2, 2026. All business data is fictional. Illustration generated for this article; it is not a product screenshot or evidence of a customer deployment.
