GLaDOS Vol 9 · Sharper Senses
ch 85 / 99
Chapter 85

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.

◆ Note — what she needs running

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

▣ Build · stage 1 — ask the model for coordinates
# 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.

∑ Math Interlude — closeness measured as an angle

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.

vectora list of numbers of fixed length; here, 768 of them
dimensionhow many numbers are in one vector
dot productmultiply two vectors slot by slot, add the results, get one number
norma vector's length: square every number, add, take the square root
cosine similaritydot product divided by both norms; 1 same direction, 0 at right angles
▣ Build · stage 2 — twelve lines of arithmetic
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

▣ Build · stage 3 — 3,072 bytes in a BLOB column
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.

▣ Build · stage 4 — the question that used to return nothing
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.

▣ Build · stage 5 — one boundary, one changed line
# 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.

◆ Note — when the scan really does stop being free

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.

⚠ Worked failure — two models, two spaces

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

✓ Checkpoint — what you can now do
  • 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_model protects 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.
⚡ Exercises — try first, then reveal
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.