Retrieval by Meaning
The question the printer row cannot answer
Chapter 37 left a question written down on purpose. Ask her "what did I say the
printer's address was" and the search finds the row. Ask "what is the thing in the
hall that eats paper" and it finds nothing at all, because LIKE compares
runs of characters, and not one character run in that sentence appears in the row that
answers it. The database knows. The retriever cannot see that it knows.
Every keyword system has that ceiling, and no amount of tuning raises it. Add stopwords, add stemming, add the full-text index from chapter 37's note, and the machine still only matches words a person happened to guess. A house assistant gets asked about the thing in the hall, the noisy box in the garage, the app that keeps waking her up. Those questions are normal English. They are also unanswerable by spelling.
What arrives in this chapter is the other half of retrieval. A model reads a sentence and hands back a fixed-length list of numbers chosen so that sentences about similar things get similar lists. Once a sentence is a list of numbers, "how close are these two sentences" becomes arithmetic you can do by hand, and the whole question of matching words disappears. Hence the rule this chapter builds toward: store a vector beside every fact, and rank by the angle between the question's vector and each stored one.
The embedding model is small, about 270 MB, and separate from her chat model.
Pull it once with ollama pull nomic-embed-text. It sits beside
llama3.2:3b in the same Ollama service she has been calling since
volume 1, on the same port, with a different endpoint. Nothing leaves the machine.
A sentence becomes 768 numbers
# glados/embeddings.py
import json
import urllib.request
EMBED_URL = "http://localhost:11434/api/embeddings"
EMBED_MODEL = "nomic-embed-text"
def embed(text: str, model: str = EMBED_MODEL) -> list[float]:
payload = json.dumps({"model": model, "prompt": text}).encode()
req = urllib.request.Request(
EMBED_URL, data=payload, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())["embedding"]
if __name__ == "__main__":
line = "The office printer is on 192.168.1.40."
vec = embed(line)
print(f"dimensions: {len(vec)}")
print("first four:", [round(x, 4) for x in vec[:4]])
print("same text, same vector:", vec == embed(line))
$ uv run python glados/embeddings.py
dimensions: 768
first four: [0.0271, -0.6194, -3.4283, -0.2117]
same text, same vector: True
The four numbers on your screen will differ from the four printed here, and that is
fine: nothing in this chapter reads an individual coordinate. Two facts about that
run do matter. The dimension is 768 for this model and it never varies, whether you
embed one word or four hundred, because the model always folds its input into the
same number of slots. And the second call returns a list identical to the first, so
embed is a function of text and model with no clock and no randomness
in it. That is what makes storing the result on disk sane.
The endpoint is /api/embeddings, not the /api/generate she
has been talking to. Generation samples the next token repeatedly and streams words
back. Embedding runs the input through the model once and hands you the internal
numbers it computed, no sampling and no reply. Same server, same HTTP habits you
already have, entirely different job.
Two lists of the same length are two arrows from the origin. Multiply them slot by slot and add up the products: that single number is the dot product, and it is large when the two arrows lean the same way. Divide it by both arrows' lengths and you cancel out how long they are, leaving only how much they agree in direction. That ratio is the cosine similarity: 1 for exactly the same direction, 0 for right angles, negative for opposite.
Take a = [2, 1, 0] and b = [4, 2, 0]. Slot by slot:
2×4 = 8 and 1×2 = 2 and 0×0 = 0, so the dot product is 10. The
length of a is the square root of 4 + 1 + 0, and of b the square
root of 16 + 4 + 0, and those two roots multiply to exactly 10. Ten over ten is 1.
Arrow b is twice as long as a and points the same way, and cosine
reports them as identical, which is what you want from a similarity that should not
care whether a fact was written in five words or fifty.
def cosine(a: list[float], b: list[float]) -> float:
if len(a) != len(b):
raise ValueError(f"vector length mismatch: {len(a)} vs {len(b)}")
dot = sum(x * y for x, y in zip(a, b))
norm_a = sum(x * x for x in a) ** 0.5
norm_b = sum(y * y for y in b) ** 0.5
if norm_a == 0.0 or norm_b == 0.0:
return 0.0
return dot / (norm_a * norm_b)
if __name__ == "__main__":
a, b, c, d = [2, 1, 0], [4, 2, 0], [0, 0, 3], [1, 3, 0]
print(f"a vs b (same direction, twice as long): {cosine(a, b):.3f}")
print(f"a vs d (leaning apart): {cosine(a, d):.3f}")
print(f"a vs c (right angles): {cosine(a, c):.3f}")
$ uv run python glados/embeddings.py
a vs b (same direction, twice as long): 1.000
a vs d (leaning apart): 0.707
a vs c (right angles): 0.000
Those three numbers are exact and you can check them on paper, which is the reason to test the function on toy vectors before pointing it at 768 dimensions you cannot picture. 0.707 is the cosine of 45 degrees. Nothing in the code knows or cares that the lists are three long; swap in two embeddings and the same twelve lines run 768 multiplications and return a number in the same range. No library, no index, no magic, and the guard on the first line is the one that saves an afternoon later in this chapter.
Vectors that survive the power button
import sqlite3
from array import array
from pathlib import Path
DB_PATH = Path("glados/data/memory.db")
def add_vector_columns(conn: sqlite3.Connection) -> None:
cols = {row[1] for row in conn.execute("PRAGMA table_info(knowledge)")}
if "embedding" not in cols:
conn.execute("ALTER TABLE knowledge ADD COLUMN embedding BLOB")
if "embed_model" not in cols:
conn.execute("ALTER TABLE knowledge ADD COLUMN embed_model TEXT")
conn.commit()
def pack(vec: list[float]) -> bytes:
return array("f", vec).tobytes()
def unpack(blob: bytes) -> list[float]:
arr = array("f")
arr.frombytes(blob)
return list(arr)
def backfill(conn: sqlite3.Connection, model: str = EMBED_MODEL) -> int:
rows = conn.execute(
"SELECT id, content FROM knowledge"
" WHERE embedding IS NULL OR embed_model IS NULL OR embed_model != ?",
(model,),
).fetchall()
for row_id, content in rows:
conn.execute(
"UPDATE knowledge SET embedding = ?, embed_model = ? WHERE id = ?",
(pack(embed(content, model=model)), model, row_id),
)
conn.commit()
return len(rows)
if __name__ == "__main__":
conn = sqlite3.connect(DB_PATH)
add_vector_columns(conn)
n = backfill(conn)
blob = conn.execute(
"SELECT embedding FROM knowledge WHERE embedding IS NOT NULL LIMIT 1"
).fetchone()[0]
print(f"embedded {n} row(s) with {EMBED_MODEL}")
print(f"bytes per vector: {len(blob)}")
print(f"round trip similarity: {cosine(unpack(blob), unpack(blob)):.3f}")
$ uv run python glados/embeddings.py
embedded 3 row(s) with nomic-embed-text
bytes per vector: 3072
round trip similarity: 1.000
768 numbers at four bytes each is 3,072 bytes per row, exactly, and that number is
the honest price of this chapter. A thousand stored facts cost about three megabytes
of vectors on top of the sentences themselves. array("f", vec) writes
single-precision floats, half the width of Python's own, and the round-trip check
proves the truncation costs nothing that matters: the recovered vector still scores
1.000 against itself at three decimals.
Two design decisions are hiding in backfill. It selects only rows that
lack a vector or carry one from a different model, so running it on every startup
costs one query when nothing changed and re-embeds exactly the stale rows when you
switch models. And it stores embed_model beside each vector, because a
list of 768 floats carries no record of where it came from. That column looks like
bookkeeping right up until stage 5, where it becomes the difference between an
answer and a crash.
The other cost is time. Every fact costs one model call before it can ever be found, so a knowledge base of two thousand rows means two thousand calls the first time you run this. On a laptop that is a couple of minutes, on the Jetson a little more. After that it is one call per new fact and one per question, which is why the vectors live in the database instead of being recomputed at query time.
def semantic_search(conn: sqlite3.Connection, question: str, limit: int = 3,
floor: float = 0.0, model: str = EMBED_MODEL) -> list[dict]:
q = embed(question, model=model)
hits: list[dict] = []
for content, confidence, blob in conn.execute(
"SELECT content, confidence, embedding FROM knowledge"
" WHERE embedding IS NOT NULL"
):
score = cosine(q, unpack(blob))
if score >= floor:
hits.append({"content": content, "confidence": confidence,
"score": score})
hits.sort(key=lambda h: (h["score"], h["confidence"]), reverse=True)
return hits[:limit]
if __name__ == "__main__":
question = "what is the thing in the hall that eats paper"
for hit in semantic_search(conn, question, limit=5):
print(f" {hit['score']:.2f} {hit['content']}")
$ uv run python glados/embeddings.py # scores will differ on your machine
0.71 The printer jams on thick paper.
0.63 The office printer is on 192.168.1.40.
0.29 The morning routine starts the coffee maker at 07:00.
There it is: the question chapter 37 could not answer, answered, with a top hit that shares exactly one word with the question and that word is "paper". The model has read enough English to place "printer" and "eats paper" near each other in its 768-dimensional space, and the arithmetic in stage 2 reports the result as 0.71.
Look at the third line before you celebrate. The coffee maker has nothing to do with
the hall, and it still scored 0.29, because cosine similarity always returns a
number for every pair of vectors. A keyword search that finds nothing returns an
empty list and tells you so. A vector search that finds nothing returns your whole
database, sorted, with the least irrelevant row at the top. That is what
floor is for, and picking its value is a measurement you make on your
own data, not a constant to copy.
# glados/knowledge.py -- the retriever swaps; the boundary does not
from glados.embeddings import semantic_search
def knowledge_context(conn: sqlite3.Connection, line: str,
limit: int = 3, floor: float = 0.45) -> str:
try:
hits = semantic_search(conn, line, limit=limit, floor=floor)
except OSError: # Ollama down: fall back to spelling
hits = search(conn, keywords(line), limit=limit)
if not hits:
return ""
lines = ["Relevant knowledge (confidence in brackets, 1.0 means the user said so):"]
for hit in hits:
lines.append(f" - [{hit['confidence']:.1f}] {hit['content']}")
return "\n".join(lines)
if __name__ == "__main__":
print(build_prompt("You are GLaDOS. Be sardonic and brief.", conn,
"what is the thing in the hall that eats paper"))
$ uv run python glados/knowledge.py
You are GLaDOS. Be sardonic and brief.
Memory context:
Known facts about the user:
- user_name: Kaleb
Relevant knowledge (confidence in brackets, 1.0 means the user said so):
- [0.6] The printer jams on thick paper.
- [1.0] The office printer is on 192.168.1.40.
$ uv run python labs/wire_core.py # her reply; yours will differ
GLaDOS: The printer. It is in the hall, it eats paper, and it is at 192.168.1.40.
You are welcome.
build_prompt did not change. Neither did the persona engine, the memory
loader, or the voice loop. One function body swapped its candidate generator and the
whole assistant got better at being asked things, which is the payoff chapter 14
predicted when it drew a single boundary between memory and mouth and refused to let
anything downstream know where the lines came from.
The except OSError is not decoration. Embedding is the first retrieval
step in this book that needs a service to be up, so a stopped Ollama would otherwise
turn every question into a traceback. Falling back to the keyword path degrades her
answers without silencing her, and the old code earns its keep as the thing that
still works when the new code cannot run.
Why this works: the same three moves, a new generator
Chapter 37 named retrieval's three moves: generate candidates, score them, keep the top
few. Nothing about that changed today. The generator went from a WHERE
clause that returns matching rows to a full scan that returns everything, the scorer
went from an assigned confidence to a measured angle, and the cap is still
LIMIT by another name. Keeping those in separate functions is what made
the swap a one-line edit instead of a rewrite.
A full scan sounds reckless and is not. A thousand rows at 768 dimensions is 768,000 multiplications per question, which pure Python does in a few hundredths of a second, well under the time the chat model spends on its first token. Vector databases and approximate-nearest-neighbour indexes exist for the case where rows number in the millions and the scan stops being free. A house assistant will not get there. Reach for the index when you have measured a scan that hurts.
One honest limit deserves saying plainly, because it is the failure mode people trip over after they fall in love with semantic search. Similarity is not truth. A high score means two sentences sit near each other in a model's space, and near can mean "answers this question", or "is about the same topic and contradicts it", or "uses the same vocabulary about a different room". The confidence column from chapter 37 still carries the provenance, the floor still throws out the weak matches, and she can still be confidently wrong about something she retrieved perfectly.
SQLite has an extension, sqlite-vec, that stores vectors in a virtual
table and does the distance work in C, and Postgres has pgvector with
real approximate indexes behind it. Database Zero's volume 7 takes both apart if you
want the mechanism; you will not need either to finish this book. What you would gain
is speed at a scale you do not have. What you would lose is the twelve-line
cosine you can read.
The chat model is already loaded, and Ollama will happily embed with it, so skipping the extra pull looks like a free saving:
hits = semantic_search(conn, question, model="llama3.2:3b") # BUG
$ uv run python glados/embeddings.py
Traceback (most recent call last):
File "glados/embeddings.py", line 118, in <module>
for hit in semantic_search(conn, question, model="llama3.2:3b"):
File "glados/embeddings.py", line 96, in semantic_search
score = cosine(q, unpack(blob))
^^^^^^^^^^^^^^^^^^^^^^
File "glados/embeddings.py", line 41, in cosine
raise ValueError(f"vector length mismatch: {len(a)} vs {len(b)}")
ValueError: vector length mismatch: 3072 vs 768
Read the two numbers. The question came back as 3,072 floats because that is
llama3.2:3b's internal width, and the stored rows are 768 because they were embedded
by nomic-embed-text. The guard on line one of cosine caught it on the
first row, and the fix is to embed questions and facts with the same model, which
backfill already enforces on the storage side.
Now imagine the guard is not there and the two models happened to share a width. The
arithmetic would run, every score would be noise, and the ranking would be a shuffle
that looks like a working search. Two models produce two unrelated coordinate systems,
so slot 41 means one thing in one and something else in the other, and comparing them
is like comparing a latitude with a temperature. That is the whole reason stage 3
writes embed_model next to every vector, and the durable fix is to filter
on it:
for content, confidence, blob in conn.execute(
"SELECT content, confidence, embedding FROM knowledge"
" WHERE embedding IS NOT NULL AND embed_model = ?", (model,)
):
With that clause the mismatched call returns an empty list instead of a crash, which is safer and quieter and therefore worse to debug. Both behaviours are defensible; what is not defensible is a database of vectors that cannot say which model made them.
Checkpoint, and the price of meaning
- I can compute a cosine similarity by hand for two three-number vectors and say why doubling one of them leaves the answer unchanged.
- I can explain why the embedding endpoint returns 768 numbers for a word and 768 for a paragraph, and what that fixed width buys the storage layer.
- I can state the byte cost of one stored vector and the call cost of adding one fact, and say when each is paid.
- I know why a vector search never returns an empty result, and what the floor does about it.
- I can say what
embed_modelprotects against, and describe the bug that occurs when two models share a dimension. - I can name a question keyword search answers better than embeddings do.
Exercise 1 — measure your own floor. Write ten questions
your knowledge base can answer and ten it cannot, print the top score for each with
floor=0.0, and pick the cut that separates the two groups.
Sort all twenty top scores and look for the gap. Answerable questions usually land above 0.6 and unanswerable ones below 0.4, with a contested band between where you have to decide whether a wrong retrieval or a missing one hurts her more. Write the number you chose into the default argument with a comment naming the day you measured it, because the next embedding model you try will move it.
Exercise 2 — chunk a long document. Store a 600-word page of house notes as one row, ask it three specific questions, then split it into paragraphs, store each as its own row, and ask the same three again.
The single row scores mediocre on everything: one vector has to stand for six topics at once, so it points somewhere in the middle and is near none of them. Split into paragraphs and the right one climbs while the others fall out of the floor. The retrieved text is shorter too, so her prompt carries the paragraph you needed instead of the page it lived on. Chunk size is the parameter nobody warns you about and it moves answer quality more than the model does.
Exercise 3 — find where keywords still win. Run both
retrievers over the same questions and hunt for one the old LIKE path
answers and the new one misses.
Ask for an exact string: an IP address, a serial number, an MQTT topic like
home/lights/living_room. Embeddings blur those into a general sense of
"network-ish text" and rank three unrelated addresses as equally close, while
LIKE '%192.168.1.40%' hits the one row and nothing else. The fix is to
run both and merge the results, keeping any row that either retriever ranked first,
which costs one extra query and covers both failure modes.
She can now be asked about things in words she was never told. Every fact she has, though, arrived through a keyboard or a microphone: somebody described the world to her and she filed the description. The camera sitting unused on her chassis is a second way in, and turning its pixels into a sentence she can store takes one more adapter.