Chapter 4 · Embeddings and Local Vector Search
Embeddings and Similarity
- Page 7 of 22
- 23 min read
Retrieval is the step that decides which pieces of your documents a model gets to see. If it picks the wrong passage, no prompt can rescue the answer. Most retrieval systems start with the same trick: turn text into a list of numbers, so that "which passages are close to this question?" becomes arithmetic you can test. This page builds that trick from scratch for EvidenceDesk, the running project: a tiny deterministic embedder, cosine similarity, a document loader and a first in-memory search, all runnable without a model, a key or the internet. You already met vectors and cosine similarity in Maths and Statistics for AI: The Essentials with NumPy; here we use them on text.
What you will learn
- What an embedding is in application terms, and why we keep making vectors separate from searching them.
- How to build a deterministic toy embedder and test ranking with it, and where it fails.
- How to implement and test cosine similarity, and why a high score is a signal, not proof.
- How to give every passage a stable ID and metadata, and build a small local search over 13 sample documents.
- When to use a local or a hosted embedding model, and how to test "best matching passage" with five hand-labelled questions.
What an embedding is, in application terms
An embedding is a fixed-length list of numbers that a function computes from a piece of text. The function is built so that texts the function treats as similar get vectors that point in similar directions. For your application that is all it is: a numeric representation used for retrieval. It is not a summary of the text, not an explanation, and you cannot read a meaning out of the individual numbers. You compute it once for every passage, store it, compute one for the question, and compare.
That last point matters for how you test. "Is the answer good?" is a hard question. "Does the passage we expect come out first for this question?" is an ordinary unit test. Embeddings give retrieval a measurable, repeatable step.
Separate embedding generation from search
Keep two jobs apart. Generation turns text into vectors and needs a model (or our toy). Search takes vectors and returns the nearest ones, and knows nothing about text. Each can then be tested on its own: search with hand-made vectors (no model at all), generation by checking shape, length and determinism.
text --[embed]--> vector --+
+--[search]--> ranked ids and scores
question --[embed]--> vector --+In the code below, embed_all is generation and top_k is search. A third piece, LocalSearch, only wires them together. Because the embedder is just a function you pass in, you can swap the toy for a real model later without touching the search. It is the same idea as the fake client in Provider-Neutral LLM Clients: a small stand-in you control, so tests are repeatable.
The sample documents and the kit file
EvidenceDesk answers questions about the help pages of a made-up shop, Kestrel Cycles. Copy the folder corpus/ from the course materials into your project: these are the 13 sample documents of Kestrel Cycles (11 in English, 2 in Bangla). All data is synthetic.
ls corpusassembly-guide.md
battery-care.md
bike-sizing.md
gift-cards.md
privacy-notice.md
returns-policy-v2.md
returns-policy.bn.md
returns-policy.md
safety-recalls.md
service-booking.md
shipping.bn.md
shipping.md
warranty.mdEvery file starts with front matter (id, title, version, status, lang) and then sections that begin with ## . Notice returns-policy-v2.md: it is an old, superseded policy that contradicts the current one. We keep it on purpose, because real document sets contain stale pages and we want to see what retrieval does with them.
Now create the module of this page. Read it once as a whole; the next sections take it apart and run each piece. It uses only the standard library and NumPy. Note zlib.crc32 in the embedder: Python's built-in hash() for strings is salted differently in every process, so it would give different vectors on every run and break reproducible tests.
# file: evidencedesk/embeddings.py
"""Toy embeddings, cosine similarity, document loading and a tiny in-memory search."""
from __future__ import annotations
import re
import zlib
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
import numpy as np
DIM = 256
STOPWORDS = frozenset("a an and are as at be by can do for how i in is it me my of on or the to we what when you your".split())
Embedder = Callable[[str], np.ndarray]
def words(text: str) -> list[str]:
cleaned = (w.strip(".,;:!?()\"'#-") for w in text.lower().split())
return [w for w in cleaned if w and w not in STOPWORDS]
def toy_embed(text: str, dim: int = DIM) -> np.ndarray:
"""Hashed character trigrams of each word, L2-normalised. Matches letters, not meaning."""
vec = np.zeros(dim)
for word in words(text):
padded = f"#{word}#"
for i in range(len(padded) - 2):
vec[zlib.crc32(padded[i : i + 3].encode("utf-8")) % dim] += 1.0
norm = np.linalg.norm(vec)
return vec / norm if norm else vec
def cosine(a: np.ndarray, b: np.ndarray) -> float:
na, nb = np.linalg.norm(a), np.linalg.norm(b)
return float(a @ b / (na * nb)) if na and nb else 0.0
@dataclass(frozen=True)
class Passage:
id: str
doc_id: str
title: str
heading: str
text: str
version: int
status: str
lang: str
@property
def embed_text(self) -> str:
return f"{self.title}. {self.heading}. {self.text}"
def load_passages(path: Path) -> list[Passage]:
"""Parse front matter and split the body at '## ' headings; ids are '<doc id>#s<n>'."""
head, body = path.read_text(encoding="utf-8").split("---\n", 2)[1:]
meta = dict(line.split(": ", 1) for line in head.strip().splitlines())
out = []
for n, part in enumerate(re.split(r"^## ", body, flags=re.M)[1:], 1):
heading, _, text = part.partition("\n")
out.append(Passage(f"{meta['id']}#s{n}", meta["id"], meta["title"], heading.strip(), " ".join(text.split()),
int(meta["version"]), meta["status"], meta["lang"]))
return out
def load_corpus(folder: Path) -> list[Passage]:
return [p for f in sorted(folder.glob("*.md")) for p in load_passages(f)]
@dataclass(frozen=True)
class Hit:
passage_id: str
score: float
def embed_all(texts: list[str], embed: Embedder = toy_embed) -> np.ndarray:
"""Step 1: make vectors. Knows nothing about search."""
return np.vstack([embed(t) for t in texts])
def top_k(query_vec: np.ndarray, matrix: np.ndarray, ids: list[str], k: int = 3) -> list[Hit]:
"""Step 2: rank stored vectors. Knows nothing about text or models."""
scores = [cosine(query_vec, row) for row in matrix]
ranked = sorted(zip(ids, scores), key=lambda pair: (-round(pair[1], 9), pair[0]))
return [Hit(i, s) for i, s in ranked[:k]]
class LocalSearch:
"""In-memory baseline: all vectors in one numpy matrix."""
def __init__(self, passages: list[Passage], embed: Embedder = toy_embed, embed_query: Embedder | None = None):
self.passages = {p.id: p for p in passages}
self.embed_query = embed_query or embed # some models want a different prefix for questions
self.ids = [p.id for p in passages]
self.matrix = embed_all([p.embed_text for p in passages], embed)
def search(self, query: str, k: int = 3) -> list[Hit]:
return top_k(self.embed_query(query), self.matrix, self.ids, k)
def best_doc(self, query: str) -> str:
return self.passages[self.search(query, 1)[0].passage_id].doc_idIf evidencedesk/__init__.py does not exist yet, create it empty. Run every script of this page from the project folder, so that evidencedesk and corpus are found.
A tiny deterministic embedding fixture
The toy embedder in toy_embed does three things. It lower-cases the text, drops a few very common words (STOPWORDS) and cuts each remaining word into three-letter pieces (#re, ret, etu …). Each piece is hashed with crc32 to one of DIM slots, where we add 1. Finally the vector is scaled to length 1 (L2-normalised). The same text always gives the same vector, on any machine, with no download. We print only 12 slots here so the line fits; the real default is 256.
import numpy as np
from evidencedesk.embeddings import toy_embed
v = toy_embed("return a bike", dim=12) # 12 numbers so it fits on a line; the default is 256
print(np.round(v, 3))
print("length:", round(float(np.linalg.norm(v)), 3))
print("same input, same vector:", np.array_equal(toy_embed("return a bike"), toy_embed("return a bike")))[0.267 0. 0.267 0.267 0.267 0. 0.535 0.535 0. 0.267 0.267 0. ]
length: 1.0
same input, same vector: TrueSome slots hold 0.535 instead of 0.267: two three-letter pieces landed in the same slot. That is a hash collision, and with only 12 slots it is common. With 256 slots it is rarer but never zero. Real embedding models have no such artefacts, but they have their own errors.
Be honest about what this fixture is. It matches letters, not meaning. "Return" and "returning" share pieces, so they land close together. "Bike" and "bicycle" share almost nothing, so they do not. A real model learns that those two words mean nearly the same thing; our toy cannot. It exists so that you can test ranking logic with fixed, explainable vectors. Never judge retrieval quality with it.
Cosine similarity
Cosine similarity measures the angle between two vectors: 1 means the same direction, 0 means unrelated (at right angles), -1 means opposite. It ignores length, so a long passage and a short one can still point the same way. The function is short: the dot product divided by the two lengths, with 0.0 for a zero vector instead of a division by zero. If you want the derivation again, see Maths and Statistics for AI: The Essentials with NumPy. Test it on vectors whose answer you know before you trust it on text:
import numpy as np
from evidencedesk.embeddings import cosine, toy_embed
print(round(cosine(np.array([1, 0]), np.array([1, 1])), 3)) # 45 degrees apart
print(round(cosine(np.array([3, 4]), np.array([6, 8])), 3)) # same direction, different length
print(round(cosine(np.array([1, 0]), np.array([0, 1])), 3)) # at right angles
a, b, c = (toy_embed(t) for t in ["return a bike", "returning bikes", "battery charging time"])
print(round(cosine(a, b), 3), round(cosine(a, c), 3))0.707
1.0
0.0
0.676 0.069The first three lines are the known cases (45 degrees gives 1/√2 = 0.707; scaling a vector does not change the result; right angles give 0). The last line is the first useful text result: two phrasings of "return a bike" score 0.676, and an unrelated phrase scores 0.069.
Prepare documents for indexing
A vector alone is useless if you cannot say what it came from. Before embedding anything, give every passage a stable ID and keep its metadata. load_passages reads the front matter, splits the body at each ## heading and builds a Passage with the ID <document id>#s<n>. The same file always gives the same IDs, and the ID tells you the document at a glance. The metadata (version, status, lang) travels with the passage; you will need status in a moment. Splitting by heading is the simplest possible preparation; RAG Ingestion and Citation Flow treats chunking properly.
import textwrap
from pathlib import Path
from evidencedesk.embeddings import load_corpus
passages = load_corpus(Path("corpus"))
print(len(passages), "passages from", len({p.doc_id for p in passages}), "documents")
p = next(p for p in passages if p.id == "kc-returns-v3#s1")
print(p.id, p.doc_id, p.version, p.status, p.lang, p.heading, sep=" | ")
print(textwrap.fill(p.text, 80))36 passages from 13 documents
kc-returns-v3#s1 | kc-returns-v3 | 3 | current | en | Returning a bike
You can return a new, unridden bike within 30 days of delivery for a full
refund. The bike must be in its original box with all parts. We refund the price
of the bike; the delivery fee is refunded only if the bike arrived damaged.Note that embed_text joins the title, heading and text, so the heading words help the match. Keep the stored text and the embedded text in mind as two things: you may change one without the other.
A small local search baseline
Now the search. LocalSearch embeds all 36 passages into one NumPy matrix, embeds the question, and ranks every row by cosine. This is exact brute force, which is fine for a few hundred passages. Ties are broken by passage ID so the order is always the same.
from pathlib import Path
from evidencedesk.embeddings import LocalSearch, load_corpus
search = LocalSearch(load_corpus(Path("corpus")))
for hit in search.search("How long does the battery charge take?", k=3):
p = search.passages[hit.passage_id]
print(f"{hit.score:.3f} {hit.passage_id:<18} {p.heading}")0.626 kc-battery-v1#s1 Charging
0.555 kc-battery-v1#s3 Warranty
0.496 kc-battery-v1#s2 StorageThe top hit is the charging section, which is right. The three hits all come from the battery page, because the title is part of the embedded text. Now the failure case that explains why real embeddings exist:
from pathlib import Path
from evidencedesk.embeddings import LocalSearch, load_corpus
search = LocalSearch(load_corpus(Path("corpus")))
for question in ["Can I return my bike?", "Can I send my bicycle back?"]:
hit = search.search(question, k=1)[0]
p = search.passages[hit.passage_id]
print(f"{question:<30} -> {hit.score:.3f} {p.doc_id} / {p.heading}")Can I return my bike? -> 0.657 kc-returns-v3 / Returning a bike
Can I send my bicycle back? -> 0.353 kc-shipping-v1 / Delivery feeWith the word "bike" the right passage wins. With "bicycle", which means the same, the toy lands on the delivery-fee section, because almost nothing matches: the only letter piece it shares with that section (which mentions "a bike") is #bi, so the ranking is close to arbitrary. The user asked the same thing; the retriever heard something different. A model trained on large amounts of text is built to map such paraphrases close together, and that is the reason to use one.
What a similarity score cannot tell you
A score only says how close two vectors are. It does not say that the passage answers the question, that it is current, or that it is true. Two cases from the sample documents show it:
from pathlib import Path
from evidencedesk.embeddings import LocalSearch, load_corpus
search = LocalSearch(load_corpus(Path("corpus")))
for question in ["Can I return a bike after 14 days?", "Can I pay in instalments?"]:
print(question)
for hit in search.search(question, k=2):
p = search.passages[hit.passage_id]
print(f" {hit.score:.3f} {p.doc_id} (v{p.version}, {p.status}) / {p.heading}")Can I return a bike after 14 days?
0.591 kc-returns-v2 (v2, superseded) / Returning a bike
0.574 kc-returns-v3 (v3, current) / Returning a bike
Can I pay in instalments?
0.259 kc-warranty-v2 (v2, current) / Components
0.214 kc-returns-v3 (v3, current) / How refunds are paidFirst, for "Can I return a bike after 14 days?" the superseded policy (14 days) outranks the current one (30 days), by a hair. A user who trusted the top hit would get the wrong rule, with a respectable score. Second, "Can I pay in instalments?" has no answer in any document, yet retrieval still returns a ranked list. The scores are low here, but a score has no built-in "answerable" line: it depends on the embedder, the corpus and the question. Treat a score as a signal for ordering candidates. Whether a passage supports an answer is decided later, with metadata filters, citation checks and tests (the next pages start on this).
Exercise: retrieve the best matching passage
Turn "it looks right" into a test. Hand-label five questions with the document that answers each one, then assert that the best passage comes from that document. The test file below also holds the tests from the earlier sections: cosine on known vectors, determinism of the embedder, search on hand-made vectors, stable IDs, and the two lessons above, written as tests. The paraphrase gap is marked xfail(strict=True): the suite documents a known failure, and if someone makes it pass by accident, pytest tells you so you can update the label.
# file: tests/test_embeddings.py
from pathlib import Path
import numpy as np
import pytest
from evidencedesk.embeddings import LocalSearch, cosine, load_corpus, load_passages, toy_embed, top_k
CORPUS = Path(__file__).resolve().parent.parent / "corpus"
LABELS = [ # hand-labelled: question -> the document that answers it
("How long do I have to return a bike?", "kc-returns-v3"),
("How long does the battery charge take?", "kc-battery-v1"),
("What is the RD2-24 recall?", "kc-recalls-v1"),
("How tall must I be for size M?", "kc-sizing-v1"),
("Are tyres covered by warranty?", "kc-warranty-v2"),
]
def test_cosine_on_known_vectors():
a = np.array([3.0, 4.0])
assert cosine(a, a) == pytest.approx(1.0)
assert cosine(a, np.array([4.0, -3.0])) == pytest.approx(0.0)
assert cosine(a, -a) == pytest.approx(-1.0)
assert cosine(a, np.array([4.0, 3.0])) == pytest.approx(0.96)
assert cosine(a, np.zeros(2)) == 0.0
def test_toy_embed_is_deterministic_and_normalised():
v = toy_embed("Return a bike")
assert np.array_equal(v, toy_embed("Return a bike"))
assert np.linalg.norm(v) == pytest.approx(1.0)
assert not toy_embed("the of a").any() # only stop words -> zero vector
def test_top_k_uses_vectors_only():
matrix = np.array([[1.0, 0.0], [0.0, 1.0], [0.7, 0.7]])
hits = top_k(np.array([1.0, 0.1]), matrix, ["x", "y", "z"], k=2)
assert [h.passage_id for h in hits] == ["x", "z"]
def test_load_passages_gives_stable_ids_and_metadata():
ps = load_passages(CORPUS / "returns-policy.md")
assert [p.id for p in ps] == ["kc-returns-v3#s1", "kc-returns-v3#s2", "kc-returns-v3#s3"]
assert (ps[0].status, ps[0].version, ps[0].lang) == ("current", 3, "en")
ids = [p.id for p in load_corpus(CORPUS)]
assert len(ids) == len(set(ids)) == 36
@pytest.mark.parametrize("question,doc_id", LABELS)
def test_best_passage_comes_from_labelled_document(question, doc_id):
assert LocalSearch(load_corpus(CORPUS)).best_doc(question) == doc_id
@pytest.mark.xfail(strict=True, reason="toy embedder matches letters, not meaning: 'bicycle' is not 'bike'")
def test_paraphrase_is_a_known_gap():
assert LocalSearch(load_corpus(CORPUS)).best_doc("Can I send my bicycle back?") == "kc-returns-v3"
def test_high_score_is_not_proof():
top = LocalSearch(load_corpus(CORPUS)).search("Can I return a bike after 14 days?", 1)[0]
assert top.passage_id == "kc-returns-v2#s1" # the superseded policy winspython -m pytest -q tests/test_embeddings.py | sed 's/ in [0-9.]*s//'.........x. [100%]
10 passed, 1 xfailedThe result is ten passed and one expected failure. (The command removes the run time from pytest's last line, so the output stays the same on every run.) Five labelled questions is a toy; the next page measures retrieval on a larger set and shows how to count hits. What a test like this cannot show: that retrieval works for questions you did not label.
An optional local embedding model
The toy is for tests. For real meaning you need a trained model. The sentence-transformers package runs such models on your own computer. Because LocalSearch takes the embedder as an argument, the change is small. Some models need a different prefix for questions and for passages, which is why LocalSearch accepts a separate embed_query. This block is optional and is not run in this course: it needs a package install and a model download.
# not run here: needs the sentence-transformers package and a model download (hundreds of MB)
from pathlib import Path
from sentence_transformers import SentenceTransformer
from evidencedesk.embeddings import LocalSearch, load_corpus
model = SentenceTransformer("intfloat/multilingual-e5-small") # downloaded on first use, then cached
def embed_passage(text: str):
return model.encode("passage: " + text) # this model expects these prefixes
def embed_query(text: str):
return model.encode("query: " + text)
search = LocalSearch(load_corpus(Path("corpus")), embed=embed_passage, embed_query=embed_query)
print(search.best_doc("ডেলিভারির ফি কত?"))If you run it, expect the first run to download the model and later runs to use a local cache. The Bangla question asks for the delivery fee; if retrieval works, the answer should point at the shipping documents (the Bangla one, or the English one). Quality for Bangla is lower than for English, so check the result instead of assuming it.
Provider note (as of 2026-10-12):
sentence-transformerswas at version 6.1.0 (released 2026-09-18), licence Apache-2.0, for Python 3.10 or newer. Models are fetched from the Hugging Face Hub and cached on disk (default folder~/.cache/huggingface/hub, changeable withHF_HOME); on Windows, where symlinks may be unavailable, the cache may copy files and use more disk.all-MiniLM-L6-v2(Apache-2.0, 384 dimensions, about 22.7 million parameters) is English only.paraphrase-multilingual-MiniLM-L12-v2does not list Bengali among its languages, so do not use it for Bangla.multilingual-e5-small(MIT, 384 dimensions, about 117.7 million parameters, a weights file of about 471 MB) lists Bengali; its card requires the prefixesquery:andpassage:and warns that low-resource languages may perform worse. Model weights have their own licences, and sizes and versions change: check the current documentation and the model card before you download.
Hosted or local embeddings
Hosted means you send text to a provider's API and get vectors back; local means a model runs on your machine. Neither is better everywhere. The table is qualitative on purpose; measure on your own questions (the next page shows how).
| Question | Hosted API | Local model |
|---|---|---|
| Quality | Depends on the model; some are strong across languages | Depends on the model; small models are weaker, and low-resource languages may suffer |
| Latency | A network round trip per call, plus provider load | No network, but CPU or GPU time on your machine and a start-up load |
| Privacy | Your text leaves your machine; read the provider's data terms | Text stays on your machine |
| Storage | You store the vectors: dimensions x 4 bytes each as 32-bit floats (1536 dimensions is about 6 KB per passage) | Same for vectors, plus the model files on disk |
| Resources | An account, a key, network access and a budget | RAM and disk; a download; may be slow on a modest laptop |
Whichever you pick, the vectors of one model are compared only with vectors of the same model; mixing models is the topic of the next page. A hosted adapter would be one more function with the same shape as embed_passage above, and tests would use the toy or a fake, never the network.
Provider note (as of 2026-10-12): OpenAI documents
client.embeddings.create(input=..., model=...)with the vector atresponse.data[0].embedding; its docs example model istext-embedding-3-small(default length 1536), the maximum input is 8192 tokens, and adimensionsparameter shortens vectors (normalise them yourself if you shorten them manually). Google documentsclient.models.embed_content(model=..., contents=...)with vectors inresult.embeddings[i].values; example model IDs differ between its pages, so read the current page. Ollama documentsollama.embed(model=..., input=...)(REST:POST /api/embed), with L2-normalised vectors. Anthropic does not offer its own embedding model; its documentation points to Voyage AI as one provider, whose docs adviseinput_type="query"or"document"for retrieval. Model names, limits and shapes change often: check the current documentation.
Where this shows up at work
Nadia's team in Dhaka runs a support assistant over a help centre. Their first retrieval bug was exactly the one above: an old policy page ranked first, with a good-looking score, and the assistant quoted a rule that had been replaced. They did not fix it in the prompt. They added the status to the metadata, wrote a labelled question for that case, and made it a test. Teams that keep generation and search apart can change the embedding model in one place and rerun the same labelled questions to see whether it got better or worse.
Common mistakes
- Treating a high score as proof. The score is only closeness. A superseded or off-topic passage can score high. Check status, source and support before using a passage, and test with labelled questions.
- Using Python's
hash()for a toy embedder. It is salted per process, so vectors (and test results) change on every run. Use a stable hash such aszlib.crc32. - Losing the ID and metadata. A bare vector cannot tell you which document, version or language it came from. Store an ID and metadata with every passage from the start.
- Mixing vectors from different models or sizes. They live in different spaces; scores between them mean nothing. Use one model per index.
- Forgetting a model's input format. Some models need prefixes such as
query:andpassage:. Without them the results quietly get worse.
Try it yourself
- Easy: Work out by hand the cosine of
[1, 0]and[1, 1], and of[2, 0]and[5, 0]. Then check both withcosine. - Medium: Write a fake embedder from a dictionary of fixed vectors (the same idea as the fake client in Provider-Neutral LLM Clients) and use
embed_allandtop_kto show that "dog" is much closer to "cat" than "car" is. - Hard: Choose a score threshold below which the search abstains (does not answer). Try it on four questions: one clearly answerable, the 14-day question, one with no answer, and "When will my parcel arrive?". Write down what the threshold gets right and what it cannot fix.
Answers
Easy. The first is 1 / (1 x 1.414) = 0.707. The second is 1.0, because both point the same way and length is ignored.
import numpy as np
from evidencedesk.embeddings import cosine
print(round(cosine(np.array([1, 0]), np.array([1, 1])), 3))
print(round(cosine(np.array([2, 0]), np.array([5, 0])), 3))0.707
1.0Medium. The embedder is a lambda that looks the text up. No model, no download, and the ranking is fully explained by the numbers you wrote.
import numpy as np
from evidencedesk.embeddings import embed_all, top_k
VECTORS = {"cat": [1.0, 0.0], "dog": [0.9, 0.1], "car": [0.0, 1.0]}
fake_embed = lambda text: np.array(VECTORS[text])
matrix = embed_all(["dog", "car"], fake_embed)
for hit in top_k(fake_embed("cat"), matrix, ["dog", "car"], k=2):
print(hit.passage_id, round(hit.score, 3))dog 0.994
car 0.0Hard. A threshold of 0.3 happens to fit these four questions. It correctly abstains on the instalments question. But it also abstains on the parcel question, which the documents do answer (shipping), because the shipping page says "delivery", not "parcel", and the toy shares no words with it. And it lets the 14-day question through with a score of 0.591 while returning the superseded policy. A threshold can cut some noise; it cannot tell you the passage is current or that it supports the answer.
from pathlib import Path
from evidencedesk.embeddings import LocalSearch, load_corpus
search = LocalSearch(load_corpus(Path("corpus")))
THRESHOLD = 0.3
questions = [
"How long do I have to return a bike?", # answerable, right passage
"Can I return a bike after 14 days?", # answerable, but the old policy wins
"Can I pay in instalments?", # not answerable
"When will my parcel arrive?", # answerable (shipping), but no shared words
]
for q in questions:
hit = search.search(q, k=1)[0]
verdict = "answer " if hit.score >= THRESHOLD else "abstain"
print(f"{verdict} {hit.score:.3f} {hit.passage_id:<18} {q}")answer 0.555 kc-returns-v3#s1 How long do I have to return a bike?
answer 0.591 kc-returns-v2#s1 Can I return a bike after 14 days?
abstain 0.259 kc-warranty-v2#s2 Can I pay in instalments?
abstain 0.217 kc-warranty-v2#s1 When will my parcel arrive?Pass checklist:
- Easy: you got 0.707 and 1.0 and can say why length does not matter.
- Medium: your fake vectors rank "dog" first and no model was loaded.
- Hard: you named two limits of a threshold, one for a missed answerable question and one for a wrong passage that still passes. Remember that retrieved text is untrusted input, as Context Assembly and Trust Boundaries showed: a high score earns a passage no extra trust.
Summary
- An embedding is a fixed-length numeric representation used for retrieval, not a summary or an explanation.
- Keep generation (text to vectors) and search (vectors to ranking) apart so you can test each alone.
- A deterministic toy embedder with a stable hash gives repeatable ranking tests, but it matches letters, not meaning, and misses paraphrases.
- Cosine similarity is easy to implement and test on known vectors; a high score is a signal, not proof that a passage answers the question or is current.
- Give every passage a stable ID and metadata, keep the labelled mini-set as a test, and choose hosted or local embeddings by measuring on your own questions.
Next: Local Indexes and Retrieval Quality — keep the embedding model with the index, measure recall, filter by metadata and store vectors in a local database.