Part 4 · Chapter 21

Retrieval

Putting the right text in front of the model: embeddings, indexes, hybrid search, and how to know it worked.

Where we are

Chapter 20 established that the only lever you hold at inference time is the context. This chapter is about filling it well. A frontier model knows nothing after its training cutoff, nothing about your company, and nothing about what happened this morning; and even a million-token window is not big enough to hold everything you might want it to consult on every call. Retrieval is the discipline of selecting, from a large private store, the few pieces of text that this particular question needs, and handing them to the model. The tools are the ones from Chapter 2, dot products between vectors, applied at the scale of millions of documents. Chapter 22 makes retrieval one tool among several that an agent can call.

The question this chapter answers: given a question and a million documents, how do you find the right few thousand tokens fast, and how do you measure whether you found them?

Picture this

An on-call engineer at 3 a.m., pager buzzing. Somewhere in the team wiki, among four hundred pages, is the runbook that says exactly what to do. She does not read the wiki. She types three words into its search box, skims the top three results, opens one, and reads a paragraph.

Notice what made that work. The wiki was written before the incident; searching it costs seconds; a good search puts the right page in the top three; and she, not the search box, does the reading and the reasoning. If the search had returned the wrong page, she would have followed the wrong procedure with full confidence, because the page looked authoritative.

Retrieval-augmented generation is this scene with the model in the engineer's chair. The store is written ahead of time. Search is cheap and runs before every question. The model reads what search returned and reasons from it. And if search returns the wrong chunk, the model answers confidently from the wrong chunk. Most of the engineering is about that last sentence.

Map it
In the pictureIn the machineThe word we will use
The wikiDocuments split into passages, each stored with a vector and metadatacorpus, chunks, index
Three words in the search boxThe question, turned into a vector by the same model that embedded the chunksquery embedding
How the search box ranks pagesCosine similarity between query and chunk vectors, or word-overlap scoring, or bothdense retrieval, BM25, hybrid
Finding the top three without reading all four hundredA graph or partition structure that finds near neighbours in sub-linear timeapproximate nearest neighbour (ANN), vector database
Opening the best-looking result and reading closelyA second, more expensive model re-scores the shortlistreranking (cross-encoder)
Pasting the paragraph into the ticketRetrieved chunks placed in the context, with source labelsretrieval-augmented generation (RAG), grounding
"Did search find the right page?"Scoring retrieval on labelled queries, independently of the answerrecall@k, MRR, nDCG

21.1Why retrieve at all

Four reasons, each of which is a property of the machine you now know.

The alternative to retrieval is not "put it in the prompt anyway" but "fine-tune it in" (Chapter 14), and that is the wrong tool for facts: fine-tuning shapes behaviour reliably and stores facts unreliably, it cannot be updated per request, and it cannot be audited per answer. Retrieval is how facts enter; training is how behaviour enters.

21.2Embeddings built for search

Chapter 2 gave every token a vector and showed that dot products between vectors measure alignment. A retrieval embedding model does the same for a whole passage: text in, one vector out, typically 256 to 4,096 numbers, such that passages about the same thing point the same way. The model is usually a transformer of the kind you built in Part 1 with the unembedding replaced by a pooling step that averages or selects the final residual stream into one vector public.

What makes it a retrieval embedding is the training objective. Instead of next-token loss, the model is trained contrastively: given a query and the passage that answers it, pull their vectors together; given the same query and unrelated passages, push them apart. The loss is a softmax over similarities, the same softmax as Chapter 1, with the correct passage as the target:

one training example:  query q,  positive passage p⁺,  negatives p₁⁻ … pₙ⁻   (often the other passages in the batch)

scores    sᵢ = cos(embed(q), embed(pᵢ)) / τ            τ: a temperature, ≈ 0.02–0.05
loss      −ln  softmax(s)[p⁺]                          exactly the bill from Chapter 1, over passages instead of tokens
gradient  pushes embed(q) toward embed(p⁺) and away from every negative, in proportion to how much each was scored

Two consequences. First, the geometry is tuned for question-to-answer matching, not paraphrase: "why are iOS pushes failing" should land near a runbook paragraph that never contains the word "failing". Second, the negatives define what "different" means. Models trained with hard negatives (passages that look relevant but are not) discriminate finely; models trained only on random negatives learn topic but not specifics public.

The vectors are compared by cosine similarity, which is a dot product once the vectors are normalised to unit length. So a search over a million chunks is a matrix-vector product of shape [1M, D] × [D], one million dot products, a few milliseconds on a GPU. That is the brute-force baseline, and for corpora under a few hundred thousand chunks it is often all you need. Everything in §21.4 exists for when it is not.

By hand

Three passage vectors in a 4-dimensional embedding space (real ones have hundreds of dimensions; the arithmetic is identical), already unit length, and a query.

q          = [ 0.70,  0.50,  0.10,  0.50 ]                                   "queue growing, workers fine"
p₁ (queue) = [ 0.80,  0.40,  0.00,  0.45 ]     q·p₁ = 0.56 + 0.20 + 0.00 + 0.225 = 0.985
p₂ (crash) = [ 0.30,  0.85,  0.20,  0.40 ]     q·p₂ = 0.21 + 0.425 + 0.02 + 0.20 = 0.855
p₃ (email) = [ 0.10,  0.10,  0.98,  0.10 ]     q·p₃ = 0.07 + 0.05 + 0.098 + 0.05 = 0.268

ranking: p₁ (0.985) > p₂ (0.855) > p₃ (0.268)

The queue runbook wins by 0.13 over the crash-loop runbook. That margin is small, and it is typical: the top two candidates in real searches are often separated by a few hundredths, which is why a reranker (§21.5) that reads both passages closely earns its cost. The email runbook is far away, as it should be; the third coordinate it lives on is one the query barely touches.

21.3Chunking: what is a unit of retrieval?

A runbook is a document; a vector is one point. Embedding a whole ten-page document into one vector averages away the paragraph that answers the question. So documents are split into chunks, each embedded separately, and the chunk, not the document, is what gets retrieved. How to split is a real decision with real failure modes.

Static view of the widget. A 100-word runbook paragraph chunked at 20 words with 5 overlap gives 7 chunks; the question "how far can I scale the pool?" matches the last chunk, which contains "12 to 24 replicas" but not the "if workers are healthy" condition two chunks earlier. At 60 words the condition and the number are in one chunk, but so is the unrelated draining-queue paragraph. Storage duplication rises from 1.0× at zero overlap to 1.4× at heavy overlap.

The trade-off has three corners. Small chunks (a sentence or two) give precise matches and cheap contexts, but the answer is often severed from the condition, table header, or definition that makes it correct. Large chunks (a page) keep context but dilute the vector, pull unrelated text into the prompt, and cost tokens on every question. Overlap between adjacent chunks softens the boundary problem at the cost of storing text twice. Published practice clusters around 200 to 800 tokens per chunk with 10 to 20 percent overlap, split at paragraph or heading boundaries rather than fixed word counts where the source has structure inferred from vendor guidance and RAG benchmark papers rather than a single canonical study.

Two refinements are worth knowing. Contextual chunking prepends a short description of the parent document to each chunk before embedding ("From the push-queue depth runbook: …"), so a chunk that says "scale from 12 to 24" also carries what it is about. Parent-child retrieval embeds small chunks for matching but returns the larger surrounding section for the context. Both attack the same problem: the best unit for matching is smaller than the best unit for reading.

21.4Finding neighbours without looking at everything

One million dot products per query is fine. One billion is not, and neither is one million when you need ten thousand queries a second. A vector database is a store of vectors plus an index that answers "which stored vectors are nearest to this one" without touching them all. The answers are approximate: the index sometimes returns the second-nearest instead of the nearest. In exchange it is fifty to a thousand times faster public (ANN benchmark suites report these ranges across index types).

The index most systems use is HNSW, a hierarchical graph of near neighbours. Each stored vector is a node linked to a handful of its nearest neighbours. To search, start anywhere, look at the current node's neighbours, move to whichever is closest to the query, and repeat until no neighbour improves. Because each hop moves toward the query, a few dozen hops cross a million-node graph. A small upper layer of long-range links lets the first hops jump across the whole space before descending into the local neighbourhood.

Static view of the widget. Two hundred points, each linked to its five nearest neighbours, twelve of them also linked in a coarse layer. A search for a random query walks two or three coarse hops then four to eight base hops, computing forty to seventy distances instead of two hundred, and usually lands on the true nearest neighbour, occasionally on a near miss.

The other family, IVF (inverted file), partitions the space into a few thousand cells by clustering, stores each vector in its cell, and at query time searches only the handful of cells nearest the query. It is simpler than HNSW and easier to keep on disk, at some cost in recall. Both are commonly combined with product quantisation, which compresses each vector to a few bytes so a billion of them fit in memory; the same trade of precision for size as weight quantisation in Chapter 27.

What a vector database adds beyond the index: storing the chunk text and metadata next to the vector, filtering by metadata before or during search (only this tenant's documents, only pages updated this year), incremental updates without rebuilding, and persistence. What it does not add: any understanding of the text. The index is a geometric structure over whatever vectors the embedding model produced. A bad embedding model indexed perfectly still retrieves badly.

The Claude API does not offer an embeddings endpoint; Anthropic's documentation points to third-party embedding providers such as Voyage AI public. The code in this chapter uses a local stand-in for the embedding step so it runs anywhere, and says where a real model goes.

21.5Hybrid search and reranking

Dense retrieval matches meaning and misses exact strings. Ask for "PayloadEncodeError" and a semantic embedding may return the general crash-loop runbook and the general error-handling page in either order, because to it the token is just a rare identifier. Ask a keyword index and it returns the one chunk containing that exact word instantly. The keyword method every search engine used before embeddings is BM25: score a chunk by how many query words it contains, weighted so that rare words count more (inverse document frequency) and long chunks do not win just by being long. It needs no model, no GPU, and no training, and it is embarrassingly hard to beat on queries with names, error codes, and identifiers public.

Hybrid search runs both and combines the rankings. The combination can weight the two scores, or, more robustly, use reciprocal rank fusion: each method contributes 1 / (60 + rank) for each chunk it ranked, and the sums are sorted. Fusion by rank sidesteps the fact that a BM25 score of 12.3 and a cosine of 0.83 are not on the same scale.

Static view of the widget. For "queue keeps growing but workers look fine", BM25 ranks the scaling runbook first because it contains "workers" and "scale"; dense ranks the crash-loop runbook first because the situation resembles it; at α = 0.5 the queue-depth runbook, which both methods rate well, comes out on top.

After the shortlist comes the reranker. A bi-encoder embeds the query and the chunk separately and compares vectors: fast, because chunk vectors are precomputed, but blind to interactions between the two texts. A cross-encoder feeds the query and the chunk together through a transformer and outputs a single relevance score: attention (Chapter 3) can now run between query tokens and chunk tokens, so it sees that "workers look fine" negates the crash-loop runbook. It is far more accurate and far too slow to run over a million chunks, so it runs over the top twenty to a hundred from the first stage. Two-stage retrieval, cheap-then-expensive, is the standard shape public.

One more lever before the index is touched: query rewriting. A user's message is rarely a good search query. "it's happening again, same as last week" retrieves nothing. A cheap model call can turn the conversation into two or three explicit queries ("push queue depth alert growing", "push worker restarts"), each run separately, results merged. Chapter 22's agents do this naturally by making retrieval a tool the model calls with a query it wrote.

21.6Evaluate retrieval on its own

A RAG system has two components that can fail: retrieval and generation. If you only measure final answers, you cannot tell which one failed, and you will tune the wrong one. So retrieval gets its own eval, and it is cheap to build: a list of questions, each labelled with the chunk or document that answers it, and a handful of metrics that need no model call.

Static view of the widget. Ten labelled queries against the ten runbooks. At k = 1, recall is 0.8 and MRR 0.9: two queries have their gold runbook in second place, including "queue growing, workers fine", which the pipeline ranks the crash-loop runbook first for. At k = 3 recall is 1.0 and each question costs about 270 tokens of context.

Here is the whole pipeline, chunking to metrics, in one script that runs without any API. The embedding is a signed hashing trick over IDF-weighted words: a crude bag-of-words vector, chosen so every step is visible. A real bi-encoder replaces one function.

$ .venv/bin/python code/ch21/rag_local.py
10 docs → 21 chunks (60 words, overlap 15)
embedding matrix (21, 512), each row unit length

recall@k over 10 labelled queries
               @1     @3
dense        0.80   1.00
bm25         0.90   1.00
hybrid       0.80   1.00

query: 'queue keeps growing but workers look fine'
  0.0328  02-worker-crash-loop.md  …# Runbook: push workers in a crash loop Symptoms: push-worker restart …
  0.0323  01-push-queue-depth.md  …# Runbook: push-queue depth alert Alert fires when the push queue dept…
  0.0317  01-push-queue-depth.md  …and needs no action. A growing queue means producers are outpacing the…

Read the last three lines. The top hit is the wrong runbook by a margin of 0.0005, because the crash-loop page mentions queue depth and workers too. The right page is second and third. At k = 3 the model would see both and could tell them apart; at k = 1 it would confidently explain how to quarantine a malformed payload to an engineer whose workers are fine. That is the failure mode of §21.7 reproduced on ten documents, and the fix is any of: a reranker that reads "workers look fine", a better embedding model, or simply a larger k with a model instructed to say which excerpt applies.

21.7How RAG fails

The failure modes are few and specific, and each maps onto a component.

SymptomUsually meansFix lives in
Confident answer from the wrong documentRetrieval returned a near miss and the model trusted itreranker, hybrid, higher k with instructions to choose, retrieval eval
"The documents don't mention this" when they doChunk boundary cut the answer from its context, or query phrasing did not matchchunking, contextual chunks, query rewriting
Right chunk retrieved, ignored in the answerLost in the middle: relevant chunk buried among many; long contexts attend unevenly publicfewer, better chunks; put the best first and last; reranking
Answers that were right last month are wrong nowStale index: documents changed, vectors did notincremental re-indexing, freshness metadata, version filters
Correct facts, wrong tenantMetadata filter missing or applied after top-kfilter inside the index query, per-tenant namespaces
Citations to chunks that say something elseModel blended retrieved text with θ's priorstrict grounding instructions, quote-then-answer formats, citation checking

Notice that half the table is retrieval and half is what the model does with what it was given. The second half is why grounding instructions matter: "answer only from the excerpts, cite the file, say if they do not cover it" turns a fluent guesser into a careful reader, and the API's citation features (Chapter 20) can make the citation a structured object the application can verify rather than a string it hopes is right.

Long context or retrieval?

With million-token windows, the question "why not just send everything?" is fair, and the answer is a calculation, not a principle.

Back of the envelope
corpus            400 runbook pages × 600 tokens          =  240k tokens
questions per day 2,000

send everything   240k input tokens per question × 2,000  =  480M input tokens/day
                  at a frontier input price of a few $/M   ≈  $1,000–2,500 per day, before caching
                  prefill latency for 240k tokens           ≈  several seconds per question
                  plus: the model must find the paragraph among 240k tokens on every call

retrieve top-3    3 × 300 tokens + question               ≈  1k input tokens per question
                  2,000 × 1k                               =  2M input tokens/day, ≈ $5–10
                  plus: one vector search, ≈ milliseconds

prompt caching    changes the first case: a cached 240k prefix costs a fraction to re-read (Ch 20)
                  but the cache is per exact prefix and must be rebuilt when any page changes

Retrieval wins by two to three orders of magnitude on cost and by seconds on latency when the corpus is large and questions are narrow. Long context wins when the corpus is small enough to cache, changes rarely, and questions need the whole thing at once (summarise this contract; find every inconsistency in this codebase). Many systems do both: retrieve a few thousand tokens of the most relevant material and also keep a cached, stable reference document in the prefix.

21.8Builder's bench: Dispatch v2 reads the runbooks

Dispatch gains a memory it does not have to carry in θ or in every prompt. The ten runbooks live in code/ch21/runbooks/; the local pipeline chunks, indexes, and searches them; the model gets the top three excerpts and strict instructions.

# code/ch21/dispatch_rag.py (excerpt)
import rag_local as R          # chunks, embeddings, bm25, hybrid — built on import
import anthropic
client = anthropic.Anthropic()
SYSTEM = """You are Dispatch, the on-call assistant for Postbox. Answer using ONLY the runbook
excerpts provided. Cite the runbook file name in square brackets after each claim. If the
excerpts do not cover the question, say so."""

def answer(question, k=3):
    hits = R.hybrid(question, k)
    context = "\n\n".join(f"[{R.owners[i]}]\n{R.chunks[i]}" for i, _ in hits)
    prompt = f"Runbook excerpts:\n\n{context}\n\nQuestion: {question}"
    r = client.messages.create(model="claude-opus-5", max_tokens=1024, system=SYSTEM,
                               messages=[{"role": "user", "content": prompt}])
    return "".join(b.text for b in r.content if b.type == "text"), [R.owners[i] for i, _ in hits]
(example output — retrieval is real and local; the model's text differs per run)
Growing depth with healthy workers means producers are outpacing the pool [01-push-queue-depth.md].
Check the inbound event rate for a single-tenant spike, then scale the worker pool from 12 to 24
replicas with scale-workers; the change takes about 90 seconds [01-push-queue-depth.md]
[07-scaling-workers.md]. Do not go above 48 replicas: the broker allows 50 connections and two are
reserved for monitoring [07-scaling-workers.md]. Scale back down after the incident
[07-scaling-workers.md].

retrieved: ['02-worker-crash-loop.md', '01-push-queue-depth.md', '07-scaling-workers.md']

The retrieved list includes the crash-loop runbook, the near miss from §21.6. With three excerpts and the grounding instruction, the model can see that the crash-loop page does not apply and answer from the other two, citing each. Had k been 1, the answer would have been about quarantining payloads. Retrieval quality and generation instructions are one system; the eval of Chapter 23 measures them together, and the retrieval eval of §21.6 measures the half you can fix cheaply.

21.9Beacon's numbers

QuantityTypical published valuesEvidence
Embedding dimension256 – 4,096; 1,024 commonpublic (open embedding model cards)
Embedding model size100 M – 8 B parameterspublic
ANN speed-up over brute force at ≈ 95% recall50 – 1,000×public (ANN-Benchmarks)
Chunk size in practice200 – 800 tokensinferred
Reranker shortlist20 – 100 candidatesinferred
How frontier assistants retrieve internallyWeb search and file search exist as server-side tools; index internals are not disclosedpublic for the tools, unknown for internals
Break it

Embed whole documents, not chunks. One vector per runbook averages its five procedures into a blur. "iOS 403" lands near the APNs runbook but also near the FCM one, because both are "push failure documents"; the model receives ten pages and the answer is somewhere in them. Recall at the document level looks fine and the answers get worse, which is why retrieval evals label chunks.

Use dense retrieval only, on a corpus full of identifiers. Error codes, ticket numbers, hostnames, and function names are rare tokens with no learned meaning; a semantic embedding treats "PayloadEncodeError" and "InvalidProviderToken" as similar noise. Queries containing them retrieve by topic instead of by string, and the exact match that BM25 would have found instantly is missed. Hybrid search exists for this.

Skip the retrieval eval and tune on final answers. Every change to chunking, embedding, k, or the prompt moves the answer score, and you cannot tell which. Teams in this state tune prompts for weeks to compensate for a retriever returning the wrong chunk 30% of the time. Ten labelled queries and recall@k reveal it in an afternoon.

Retrieve fifty chunks "to be safe". Recall rises, and so does the token bill, the prefill latency, and the chance that the right chunk sits in the middle of forty-nine wrong ones and is under-attended. Published long-context tests show accuracy falling when the relevant passage is mid-context public. More is not safer past the point where the reranker has done its job.

Never re-index. Runbooks change; vectors do not. Six months later Dispatch confidently cites a procedure that was replaced, with a valid-looking citation to a file whose content has moved on. Freshness is metadata the index must carry and the pipeline must act on.

Rebuild the model

Say it back. The model cannot know what happened after its cutoff or what is in your private store, so facts enter through the context, and retrieval is how you pick which few thousand tokens to send. Documents are split into chunks sized for matching, and each chunk becomes a vector from an embedding model trained contrastively to pull questions toward the passages that answer them. A query becomes a vector the same way and is compared by cosine similarity, one dot product per chunk; for corpora too large or traffic too heavy for that, a graph or partition index finds near neighbours in sub-linear time at a small cost in exactness. Keyword search with BM25 catches the exact strings that meaning-vectors miss, and fusing the two rankings beats either. A cross-encoder reranker reads the shortlist closely and reorders it. The retrieved chunks go into the prompt with source labels and instructions to answer only from them. Retrieval is evaluated on its own with labelled queries and recall@k before anyone looks at answers, because its failures, wrong chunk, severed context, buried passage, stale index, are cheap to find there and expensive to find later. Long context is the alternative when the corpus is small and stable enough to cache; retrieval wins by orders of magnitude when it is not. Dispatch now reads its runbooks this way.

documentsoffline chunk§21.3 embed§21.2 indexANN + BM25 questionper call rewrite§21.5 embed top-k, fusehybrid rerankcross-enc. model + excerptsgrounded, cited evaluate the top row alone with recall@k before evaluating the answer (§21.6)
What is the whole chapter in one line? Offline: chunk, embed, index. Per call: rewrite, embed, search both ways, fuse, rerank, hand the excerpts to the model with instructions to stay inside them.
Exercises
  1. By hand. Three unit vectors p₁ = [0.6, 0.8, 0], p₂ = [0, 0.6, 0.8], p₃ = [0.8, 0, 0.6] and a query q = [0.7, 0.7, 0.14] (normalise it first). Rank the three by cosine similarity. Then apply reciprocal rank fusion with a BM25 ranking of p₃, p₁, p₂ and give the fused order. Which passage moved, and why?
  2. Calculation. A corpus of 5 million chunks embedded at 1,024 dimensions in 2-byte floats. How many gigabytes is the vector store? Brute-force search is one dot product per chunk; at 10¹² multiply-adds per second on one GPU, how long does one query take, and how many queries per second is that? If product quantisation compresses each vector to 64 bytes, what is the store size, and what does the compression cost you in the ranking?
  3. Code. In rag_local.py, change the chunk size to 20 words with no overlap and re-run the recall table. Then add a fifth runbook query of your own whose answer straddles a chunk boundary and show its rank before and after restoring overlap. Finally, replace embed with a call to any embedding model you have access to and report the new recall@1.
Further reading