“I cannot get into my account” and “How do I reset my password?” may be looking for the same help article without sharing many keywords. Embedding-based search can help with that gap. Yet a ranking algorithm will still return a first-place result when none of its four FAQs is relevant. That boundary is easy to miss when building a local knowledge system.
Instead of a chatbot, this exercise uses Ollama, a text embedding model and Python to display candidate IDs, scores and source text. It suits help-center editors, internal-documentation teams and developers who can run a small script. Use fictional data first. There is no vector database, paid API or answer-generating chat model in the retrieval step.
Four FAQs separate retrieval from a supported answer
The fixtures cover F01 password resets, F02 billing addresses, F03 task exports and F04 workspace closure. Every rule is invented for teaching, not a real product's service policy. Keep one topic per entry, with a stable ID and title. Combining everything into a single paragraph makes a retrieved result harder to trace.
Prepare three question types: a paraphrase such as “I cannot sign in and forgot my password”; a clear request such as “How can I download tasks as CSV?”; and an out-of-scope request such as “Can you repair my printer?” The first two have human evaluation targets F01 and F03. The third has no supported answer. These are test labels, not guaranteed model rankings. Record the actual top two rather than selecting only a successful screenshot.
Choose the text model without mixing generations
Download the appropriate installer from Ollama's official website and start the application. In Windows PowerShell, check the commands and pull the model below. Python 3.10 or later must also be installed. The script uses only its standard library and requires no pip installation. If commands are unavailable, reopen the terminal after installation and check the installation path; do not copy arbitrary privilege-elevation scripts.
ollama --version
ollama pull embeddinggemma:300m
ollama list
py -3 --version
This guide deliberately uses embeddinggemma:300m, the earlier text-only generation. Its tag page lists approximately 622 MB, a 2K context window and Ollama 0.11.10 or later. Download size is not runtime memory consumption or a performance guarantee. Do not apply the later EmbeddingGemma 2's multimodal features, context length or licensing description to this tag. The model uses Gemma terms; an open-source runtime does not make every model MIT-licensed.
The script calls only 127.0.0.1:11434. Pulling the model requires network access; after download, this retrieval code does not call a cloud model. Keep the default loopback binding rather than exposing the service through 0.0.0.0 or a public tunnel. If cloud features must be disabled, configure local-only mode using the official FAQ and restart Ollama. That setting does not mean the entire computer can no longer access the internet.
One request embeds the question and source entries
Create a separate practice folder and save the following as UTF-8 faq_search.py. It sends only the four fixtures and the question to a fixed local URL, prints to the terminal and does not modify business documents. It disables environment proxies and refuses HTTP redirects to avoid accidentally sending this request elsewhere. This is not a security audit of the whole machine.
Queries use task: search result | query: and documents use title: title | text: source, following the original model card's retrieval formatting. Both sides use the same model in one batch. Setting truncate to False makes an overlong input fail instead of silently losing its ending; 2,048 tokens does not mean 2,048 Chinese characters. The code leaves dimensions at the model default and validates vector counts, dimensions and numbers before ranking.
import json
import math
import sys
import urllib.error
import urllib.request
MODEL = "embeddinggemma:300m"
URL = "http://127.0.0.1:11434/api/embed"
DOCS = [
[
"F01",
"Reset password",
"Choose Forgot password on the sign-in page. Receive the reset link at your verified email. Check spam before contacting an administrator."
],
[
"F02",
"Change billing address",
"A workspace administrator can change the address for the next bill in billing settings. Contact support about an already issued bill."
],
[
"F03",
"Export tasks",
"Workspace administrators can export CSV from the tasks page. The export contains only tasks they can access in the current workspace."
],
[
"F04",
"Close workspace",
"Only the owner can request workspace closure. Export tasks that must be retained and confirm with members before closing."
]
]
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def embed(texts):
data = json.dumps({
"model": MODEL, "input": texts, "truncate": False
}, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
URL, data=data, headers={"Content-Type": "application/json"},
method="POST"
)
opener = urllib.request.build_opener(
urllib.request.ProxyHandler({}), NoRedirect()
)
with opener.open(req, timeout=120) as response:
vectors = json.load(response)["embeddings"]
if not isinstance(vectors, list) or len(vectors) != len(texts):
raise ValueError("Embedding count mismatch")
dimension = None
normalized = []
for vector in vectors:
if not isinstance(vector, list) or not vector:
raise ValueError("Empty or invalid vector")
if dimension is None:
dimension = len(vector)
if len(vector) != dimension:
raise ValueError("Embedding dimension mismatch")
if any(type(x) not in (int, float) or not math.isfinite(x)
for x in vector):
raise ValueError("Invalid vector number")
norm = math.sqrt(sum(x * x for x in vector))
if not math.isfinite(norm) or norm == 0:
raise ValueError("Invalid vector norm")
normalized.append([x / norm for x in vector])
return normalized
def search(question):
if not question.strip():
raise ValueError("Question must not be empty")
texts = ["task: search result | query: " + question]
texts += [f"title: {title} | text: {text}"
for _, title, text in DOCS]
vectors = embed(texts)
query, documents = vectors[0], vectors[1:]
ranked = [
(sum(a * b for a, b in zip(query, vector)), doc)
for vector, doc in zip(documents, DOCS)
]
return sorted(ranked, key=lambda item: (-item[0], item[1][0]))[:2]
if __name__ == "__main__":
try:
if len(sys.argv) != 2:
raise ValueError('Usage: python faq_search.py "question"')
for score, (doc_id, title, text) in search(sys.argv[1]):
print(f"{doc_id}\t{score:.4f}\t{title}\n{text}\n")
except (ValueError, KeyError, TypeError,
urllib.error.URLError, TimeoutError) as exc:
print("Search failed: check local service, model and inputs ("
+ type(exc).__name__ + ")", file=sys.stderr)
raise SystemExit(1)
Run these commands from the script's folder. Initial loading may take longer than subsequent requests. If the local service is not running, start the application rather than replacing the URL with an unfamiliar online “free endpoint.”
py -3 .\faq_search.py "I cannot sign in and forgot my password"
py -3 .\faq_search.py "How can I download tasks as CSV?"
py -3 .\faq_search.py "Can you repair my printer?"
Two candidates are not two ready-to-send answers
Each result prints the FAQ ID, cosine similarity to four decimal places, title and original text. The script normalizes vectors and computes cosine similarity with their dot product. Do not relabel 0.8 as “80% accuracy” or copy a universal passing threshold from a demonstration: scores depend on the model, corpus and questions.
Pay particular attention to the printer question. If all fixture vectors return successfully, the program still lists two candidates because it ranks; it has no rejection mechanism. A business answering system would need separate out-of-scope tests, threshold calibration and a human fallback. This guide stops at showing candidates to a reviewer. For export questions, read the administrator and permission restrictions instead of promising that everyone can export based on the title alone.
We checked syntax and simulated API responses for request formatting, ranking and error handling. We did not download the model or run embedding inference, so there are no claimed real scores, latency measurements or Chinese-language hit rates here. After running it yourself, record question, human target, actual top two, usefulness and reason. That record is more informative than a single highest score.
Ask AI for evaluation questions, not new service policies
A text assistant such as Claude can propose evaluation questions for the fictional FAQs. It helps design the test set; it is not part of the local retrieval path. Real internal content needs authorization and redaction before external sharing. A local script does not keep a separate cloud conversation local when you paste documents into it.
Given these four fictional FAQs, write eight retrieval test questions: four paraphrases, two similar requests with different constraints, and two questions not answered by the material. Return question, expected FAQ ID or “no answer,” and the human-review rationale. Do not add capabilities, promises, processing deadlines or similarity scores. Reference only F01–F04. State any ambiguity.
Review every proposed label yourself. “Can an ordinary member export tasks from all workspaces?” must not become “F03 allows this”: F03 supplies restrictions that contradict that request. Retrieving related material and establishing that the material supports an action are separate decisions. Keep unrelated questions in the test set rather than testing only keywords already present in the source.
Troubleshoot the symptom before expanding the system
- Connection failure: check that Ollama is running on its local port. Do not launch a second server when a background service already owns the port.
- Missing model or load failure: inspect the full tag in ollama list, download completion and available resources. A chat model is not an interchangeable embedding model.
- Long-input error: this is the intended protection with truncation disabled. Split by independent topic and preserve source IDs, rather than deleting restrictions at the end.
- Dimension mismatch or invalid numbers: stop and inspect the model and call. Do not pad vectors or remove dimensions just to obtain a score.
- High score, unsupported content: revisit source text and test labels. Relevance cannot authorize an operation.
The exercise recomputes all four documents for clarity; it is not a large-scale indexing architecture. When expanding, consider a cache that records the full model identifier, document version, prefixes and dimensions. Rebuild affected embeddings after document or model changes instead of mixing vectors from different versions. Protect embeddings as business data rather than assuming they are irreversibly anonymous.
Before handoff, check source traceability, recorded paraphrase results, explicitly unsupported out-of-scope requests, scores not presented as accuracy, and no automatically sent answers. Only then decide whether to add a generation model. With four FAQs, understanding the retrieval boundary is more useful than starting with a complex RAG stack.