primer.ml.embeddings.retrieval

Retrieval: keyword search, meaning search, and combining them

Run: python -m primer.ml.embeddings.retrieval

New to the notation (Σ, log, |d|)? primer.notation explains every symbol used here from zero.

Level 1: The practitioner's guide

In one sentence. Retrieval finds the few passages that answer a question, ranked best first, by running a keyword search and a meaning search side by side, fusing their rankings, and letting a slower, more careful model reorder the short list that survives.

When you need it. You need retrieval whenever a model must answer from material it was not trained on and that material is too big to paste into the prompt: a knowledge base of tickets, manuals, contracts or code. Anthropic's contextual retrieval post puts the line at about 200,000 tokens (about 500 pages): below that, put the whole knowledge base in the prompt and skip retrieval. Above it, retrieval quality caps answer quality, because the language model can only use what retrieval hands it (primer.agents.rag). The tell that one search method is not enough: on this lesson's 20 labeled questions, keyword search alone puts the answer in the top 3 for 75% of them and meaning search alone for 90%, and their misses never overlap. Keyword search fails on paraphrases ("automobile reimbursement" when the document says "car" and "reimbursed"); meaning search puts the article for "ERR-4012" fourth. Fused, they reach 100%.

Your options. From the cheapest to the most precise; in practice each stage feeds the next:

Option What it does What it gives you What it costs Where it lives
Keyword search (BM25) Weighs each query word by rarity, saturates repeated mentions, discounts long documents Exact matches on identifiers, codes and names, with no training Nothing for synonyms: a document sharing no words scores 0 Lucene, Elasticsearch, OpenSearch, any search engine
Dense retrieval (bi-encoder) Embeds every document once and the question at query time, then returns the nearest vectors Paraphrases, synonyms, other languages An embedding model and a vector index; exact tokens blur An embedding model plus a vector index (primer.ml.embeddings.ann)
Hybrid search (RRF) Runs both and fuses the two rankings by position, never by score The strengths of both, with no tuning Two searches per query and a merge Built into Elasticsearch and most vector databases
Reranking (cross-encoder) Reads the question and each shortlisted candidate together and reorders them Catches hard negatives: right topic, wrong answer One model pass per candidate, so it only ever sees a shortlist sentence-transformers cross-encoders, Cohere Rerank
Late interaction (ColBERT) Keeps one vector per word and matches each question word to its best document word Near cross-encoder precision with precomputed documents 50 to 200 times the vector storage ColBERT and ColBERTv2

How to choose. Start from what your questions look like and how much latency you can spend.

  • Questions full of identifiers (error codes, product codes, ticket numbers, names): keyword search is hard to beat and must be in the pipeline. Company data is full of these.
  • Questions phrased differently from the documents (paraphrases, jargon, other languages): dense retrieval. Read the model card for required prefixes (query: and passage: for the E5 family) and use the same model and settings at indexing and query time.
  • Almost always: both, fused with reciprocal rank fusion at its standard constant of 60. It is cheap, needs no tuning, and on this lesson's questions lifts recall@3 from 0.75 and 0.90 to 1.00.
  • When the top results are on the right topic but don't answer the question (a password reset guide above the password policy): add a reranker over a shortlist of 50 to 100, and measure recall and MRR with and without it. Rerankers can demote a right answer too: this lesson's fixes the hard negative and lowers MRR from 1.00 to 0.975 on the same 20 questions.
  • When one vector per document is too coarse and you can afford the storage: late interaction.
  • Whatever you pick, decide the chunking first: split on structure, keep headings with their content, add modest overlap, attach metadata. It often matters more than the choice of embedding model. Then keep a small labeled set of questions with known answer passages and measure recall@k on it.

What it costs. Keyword and dense search are the cheap, wide stages: documents are indexed once, and a question costs one embedding plus a lookup that takes milliseconds over millions of documents. Fusion is a merge of two short lists. The reranker is where money and latency go: at 10 ms per pair on a GPU, scoring a million documents takes 10,000 seconds, and a shortlist of 50 takes 0.5 s (less when the pairs are batched), which is why the shortlist is capped. Late interaction trades that latency for storage: one vector per word instead of one per document. Preparing the chunks has a price too: Anthropic reports \$1.02 per million document tokens, once, to write a short context in front of every chunk with prompt caching, for a 49% cut in top-20 retrieval failures with hybrid search and a 67% cut with reranking added (from 5.7% to 1.9% on their evaluation). The cheapest item of all, and the one most teams skip, is a labeled set of a few dozen questions with known answer passages: it is the only instrument that shows the failures below.

What breaks.

  • The right page is never found. If the passage is not in the top k, no prompt change will fix the answer. Measure recall@k of retrieval alone before touching the prompt.
  • A forgotten prefix. Index documents without the passage: prefix a model was trained with and nothing errors: vectors look normal, scores look plausible, and in this lesson's simulation recall@3 falls from 0.92 to 0.75 and MRR from 0.875 to 0.57. Only a labeled set catches it.
  • Scores added across systems. BM25 scores run from 0 to about 20 and cosine similarities from −1 to 1; add them and one system drowns the other. Fuse ranks, not scores.
  • Hard negatives. A document on the right topic that doesn't answer the question rises because it shares the words. A reranker reads the pair together; when you fine-tune an embedding model, train it on such pairs (primer.ml.embeddings.contrastive).
  • A heading cut from its fact. Fixed 40-word windows split the heading "Home internet stipend" from "50 dollars per month" in this lesson's handbook; a structure-aware chunk holds both. Parent-child retrieval searches small children and returns their whole section.
  • A reranker trusted blindly. It is a model and can be wrong: this lesson's demotes one correct answer while fixing another. Ship it only when the numbers say so.
  • A shortlist too short. Give the reranker only the top result and it cannot help. Anthropic's evaluation retrieved 150 chunks, reranked them to 20, and found that passing 20 chunks to the model beat passing 10 or 5.

In the wild. BM25 runs in any search engine: Lucene, Elasticsearch, OpenSearch. Elasticsearch's rrf retriever fuses a BM25 query with a kNN query by the same one-over-sixty-plus-rank rule (rank_constant 60 by default) with no weights to tune. Bi-encoders trace back to Sentence-BERT and Dense Passage Retrieval; the sentence-transformers library ships both bi-encoders and cross-encoder rerankers, and hosted rerankers such as Cohere Rerank take a query and a list of documents and return them ordered by relevance, cut to a top_n. Cross-encoder reranking with BERT is due to Nogueira and Cho, late interaction to ColBERT and ColBERTv2, and the E5 models are the ones trained with the query: and passage: prefixes. Anthropic's contextual retrieval puts a generated context in front of every chunk and combines it with hybrid search and reranking. The papers behind this lesson are listed at the end with their companions.

Go deeper. Level 2 builds every box of the pipeline by hand: BM25 on three one-line documents, the bi-encoder over this repo's toy embedder, reciprocal rank fusion on two ranks, a toy cross-encoder scoring a hard negative, ColBERT's MaxSim on two-number word vectors, the chunking arithmetic, and the forgotten-prefix bug measured. If you only needed to design the pipeline, you are done.

Level 2: How it works, from scratch.

Level 2: How it works, from scratch

Level 2 builds every box of that pipeline from nothing, starting in a library.

The everyday picture. You walk into a library with a question. Two librarians are on duty.

  • The keyword librarian takes your words literally. They count how often each word of your question appears in each book, give rare words far more weight than common ones, stop getting more excited after the tenth mention, and are a little suspicious of very long books that mention everything. Ask about "ERR-4012" and they find the one page that says "ERR-4012". Ask about "automobile reimbursement" and they find nothing, because the book says "car" and "reimbursed".
  • The meaning librarian understands what you mean: "automobile" is a "car", "scam" is "phishing". But they remember the gist of each book, not its serial numbers, so "ERR-4012" and "ERR-4013" blur together.

Real systems ask both and trust the books both recommend. Then an expert reads the top handful of candidates carefully, next to your question, and puts them in final order. That's the whole modern retrieval pipeline, and this lesson builds every piece of it.

Words used throughout, in plain terms:

  • Retrieval. Finding the documents that answer a question, ranked best first. In a RAG system (primer.agents.rag) the language model can only use what retrieval hands it, so retrieval quality caps answer quality.
  • Token. Here, one word after lowercasing and dropping filler words ("the", "is"): see primer.common.text.
  • Recall@k. The share of questions whose answer appears in the top k results. MRR (mean reciprocal rank) also rewards putting the answer first: an answer at rank 1 scores 1, at rank 2 scores 1/2, missing scores 0, averaged over questions.

The full pipeline, which the sections below build one box at a time:

flowchart LR Q[Question] --> K[Keyword search<br/>BM25] Q --> D[Meaning search<br/>embeddings] K --> F[Fuse the two rankings<br/>RRF] D --> F F --> S[Shortlist<br/>top 10 to 100] S --> R[Rerank each candidate<br/>with the question: cross-encoder] R --> T[Top 3 to 5 passages<br/>to the language model]

Reading it: two cheap, wide searches run side by side and see the whole collection. Their rankings are merged into one shortlist. Only the shortlist reaches the expensive, precise reranker on the right. Everything left of the shortlist must be fast because it faces millions of documents; everything right of it can be slow because it faces only a hundred. Each box below gets its own section.

1. Keyword search: BM25

The everyday picture. The keyword librarian from above: rare words count more, repetition helps less and less, and long books get a small penalty for mentioning everything.

A tiny worked example. Three one-line "documents":

Doc Words Length
d₁ cat cat dog 3
d₂ dog bird 2
d₃ fish 1

The average length is (3 + 2 + 1)/3 = 2. Search for cat. It appears in one of the three documents, so it's rare and gets a high weight (0.981, computed below); "dog" is in two, so it would get less (0.470). Only d₁ contains "cat", twice, and d₁ is a bit longer than average. Plugging in, d₁ scores 1.207 and the others score 0.

flowchart LR Q[Query words] --> IDF["Rarity weight per word<br/>IDF: rarer = heavier"] Q --> TF["Count in this document<br/>tf, with saturation"] DL["Document length vs.<br/>the average length"] --> TF IDF --> M((×)) TF --> M M --> SUM["Add up over<br/>the query's words"] SUM --> S[BM25 score]

Reading it: each query word contributes one product: how rare the word is times how much this document talks about it. The length box feeds into the count box because a mention in a short document is stronger evidence than the same mention in a long one. Words the document doesn't contain contribute zero, so a document sharing no words with the query scores exactly

  1. That's the blind spot the meaning librarian covers.
Level 3: the formula and its symbols

$$ \text{BM25}(q, d) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t, d)\,(k_1 + 1)}{f(t, d) + k_1 \left(1 - b + b\,\frac{|d|}{\text{avgdl}}\right)}, \qquad \text{IDF}(t) = \ln!\left(1 + \frac{N - n(t) + 0.5}{n(t) + 0.5}\right) $$

Symbols

Symbol Meaning here Range / typical
q the query, as a list of words
d one document, as a list of words
t ∈ q "each word t in the query"
Σ add up the following over every query word
f(t, d) how many times word t appears in document d (term frequency) 0, 1, 2, …
|d| the document's length in words
avgdl the average document length in the collection
k₁ saturation: how quickly extra mentions stop helping 1.2 to 2.0; 1.5 here
b length normalization: 0 ignores length, 1 fully normalizes 0.75
N number of documents in the collection
n(t) number of documents containing t (document frequency) 1 … N
ln natural logarithm: grows slowly, so ten times rarer is not ten times heavier
IDF(t) inverse document frequency: the word's rarity weight ≥ 0

In words: for each word of the query, multiply how rare the word is by a count of its mentions that saturates and is adjusted for document length, then add those products up.

On the example (query "cat", document d₁): N = 3, n(cat) = 1, so IDF = ln(1 + 2.5/1.5) = ln(2.667) = 0.981. f = 2, |d| = 3, avgdl = 2: the denominator is 2 + 1.5 · (1 − 0.75 + 0.75 · 3/2) = 2 + 1.5 · 1.375 = 4.0625. The count part is 2 · 2.5 / 4.0625 = 1.231. Score = 0.981 · 1.231 = 1.207.

Level 3: in Python

In Python:

import math
# 3 documents; 1 of them contains "cat"
N, n_t = 3, 1
# ln(1 + (N - n(t) + 0.5) / (n(t) + 0.5))
IDF = math.log(1 + (N - n_t + 0.5) / (n_t + 0.5))
round(IDF, 3)  # → 0.981
# f(cat, d1), |d1|, average document length
f, d_len, avgdl = 2, 3, 2
k_1, b = 1.5, 0.75
count_part = f * (k_1 + 1) / (f + k_1 * (1 - b + b * d_len / avgdl))
round(count_part, 3)  # → 1.231
# Σ over the query's only word
round(IDF * count_part, 3)  # → 1.207

BM25 credit flattens toward k1 + 1 however often a word repeats, and one mention counts less in a longer document once b is above 0

Reading it: on the left, the x-axis is how often a word appears in a document and the y-axis is the credit BM25 gives it (before the rarity weight). The dashed line is raw counting, where 20 mentions count 20 times as much as one. The BM25 curves bend over and flatten toward k₁ + 1: the tenth mention adds almost nothing, which stops keyword-stuffed pages from winning. Smaller k₁ flattens sooner. On the right, one mention in documents of different lengths: with b = 0 length is ignored; with b = 0.75 a mention in a document twice the average length counts noticeably less.

In code: BM25 precomputes each word's IDF and each document's length; BM25.scores applies the formula to every document, and BM25.search returns the top k with a non-zero score.

Why it matters: BM25 is decades old, needs no training, runs on any search engine (Elasticsearch, OpenSearch, Lucene), and is still very hard to beat on exact identifiers: product codes, error numbers, names, ticket IDs. Those are everywhere in company data.

2. Meaning search: dense retrieval with a bi-encoder

The everyday picture. The meaning librarian writes a one-line summary card for every book before anyone asks anything. When your question arrives, they write a summary card for it too and pull the books whose cards are most similar. "Automobile reimbursement" and "car mileage expense" get similar cards even though they share no words.

A tiny worked example. With this repo's toy embedding model (primer.common.embedder), the query "automobile reimbursement" and the car-mileage article share zero words, yet their vectors have cosine similarity of about 0.8, the highest in the collection. "What does ERR-4012 mean", by contrast, puts the right article only 4th: the code is one blurred word among many, and "what" and "mean" pull the vector elsewhere.

flowchart LR subgraph AHEAD["Ahead of time"] D[Each document] --> E1[Encoder] --> V[(Document vectors)] end subgraph NOW["At question time"] Q[Question] --> E2[Same encoder] --> QV[Question vector] end QV --> NN["Nearest vectors<br/>(ann.py)"] V --> NN NN --> R[Ranked documents]

Reading it: the encoder runs twice, but never on the question and a document together. That's why it's called a bi-encoder: two separate encodings. Documents are encoded once, at ingestion. A question costs one encoding plus a nearest-neighbor lookup (see primer.ml.embeddings.ann), which takes milliseconds over millions of documents. The price: the model never sees the question and the document side by side, so it can't check fine details like "does this passage answer this question, or just share its topic?".

In code: SearchEngine embeds every document once with primer.common.embedder.ConceptEmbedder into a primer.ml.embeddings.ann.FlatIndex; SearchEngine.dense embeds the question and returns the nearest documents. doc_text decides what gets indexed: the title plus the body.

Why it matters: dense retrieval handles paraphrases, synonyms and other languages, but it blurs exact tokens. Measure both kinds of queries on your own data before trusting either librarian alone.

3. Hybrid search: reciprocal rank fusion (RRF)

The everyday picture. Ask both librarians for their top-10 lists, then hold a vote. A book earns points for each list it's on, more the higher it sits. Books both librarians rank highly rise to the top. Crucially, you only compare positions, never the librarians' private scoring systems, which aren't on the same scale.

A tiny worked example. With the standard constant k = 60:

Document Dense rank BM25 rank RRF score
X 1 3 1/61 + 1/63 = 0.0164 + 0.0159 = 0.0323
Y 1 (absent) 1/61 = 0.0164

X, which both methods like, beats Y, which only one method loves.

flowchart LR Q[Question] --> B[BM25 ranking] Q --> D[Dense ranking] B --> R["For every document:<br/>add 1/(60 + rank)<br/>from each list it's on"] D --> R R --> O[Sort by total:<br/>the fused ranking]

Reading it: the two rankings are the only inputs; their raw scores are thrown away on purpose. BM25 scores run from 0 to about 20; cosine similarities from −1 to 1. Adding them would let one system drown the other, and fixing that needs tuning per collection. Ranks are always on the same scale, so RRF works out of the box.

Level 3: the formula and its symbols

$$ \text{RRF}(d) = \sum_{i=1}^{m} \frac{1}{k + \text{rank}_i(d)} $$

Symbols

Symbol Meaning here Range / typical
d a document
m number of rankings being fused 2 here (BM25 and dense)
i which ranking 1 … m
rank_i(d) d's position in ranking i (1 = top); lists d isn't on contribute nothing 1, 2, 3, …
k a damping constant: keeps rank 1 from dwarfing rank 2 60 (from the original paper)
RRF(d) the fused score small positive numbers

In words: a document's fused score is the sum, over each ranking it appears in, of one over sixty-plus-its-rank.

On the example: X: 1/(60+1) + 1/(60+3) = 0.01639 + 0.01587 = 0.0323. Y: 1/(60+1) = 0.0164.

Level 3: in Python

In Python:

k = 60
# d's position in each ranking that contains it
def RRF(ranks):
    # Σ_i 1/(k + rank_i(d))
    return sum(1 / (k + rank_i) for rank_i in ranks)
# X: 1st in one ranking, 3rd in the other
round(RRF([1, 3]), 4)  # → 0.0323
# Y: on one ranking only
round(RRF([1]), 4)  # → 0.0164

For the query what does ERR-4012 mean, the error-code article collects credit from both BM25 and dense search and fuses to a clear first place

Reading it: each bar is one document's fused score, split into the part contributed by BM25 (orange) and by dense search (blue). The error-code article it-004 is BM25's only hit and dense search's 4th, and the two contributions stack up to put it clearly first. The password-policy article that dense search wrongly ranked first gets only its blue half.

BM25 misses the paraphrase questions and dense search misses the error codes, but their misses never overlap, so the hybrid ranks every answer first

Reading it: one row per question, one column per method; the number is the rank at which the right answer appeared (✗ = not in the top 10). Look at where the colors differ. BM25's failures (the paraphrase block) are where dense search succeeds, and dense search's failures (the error-code block) are where BM25 succeeds. Because their mistakes don't overlap, fusing them fixes both: the hybrid column is all 1s.

Method recall@3 (20 questions) MRR
BM25 alone 0.75 0.75
Dense alone 0.90 0.85
Hybrid (RRF) 1.00 1.00

In code: reciprocal_rank_fusion adds up 1/(k + rank) across any number of rankings; SearchEngine.hybrid fuses SearchEngine.bm25 with SearchEngine.dense, and evaluate measures recall@k and MRR for any search method.

Why it matters: company data is full of both paraphrase-style questions and exact identifiers, so hybrid search almost always beats either method alone. It's cheap to add: most search engines and vector databases support it built in.

4. Reranking: bi-encoder vs. cross-encoder

The everyday picture. A matchmaker has two ways to work. The fast way: write an index card for each person once, then match cards, so thousands of people can be pre-filed. The careful way: sit the two people down together and watch them talk. That's far more accurate, but every pair needs its own meeting. A bi-encoder is the index card. A cross-encoder is the meeting. So you use cards to shortlist, then hold meetings only with the shortlist.

A tiny worked example: a hard negative. "What are the password rules?" has two candidate answers on the same topic. The password reset how-to (it-001) mentions "password" three times; the password policy (it-002) is the real answer. BM25 and the fused hybrid ranking both put the how-to first. A hard negative is exactly this: a document that looks relevant (right topic) but doesn't answer the question.

Our toy cross-encoder reads the question and the document together and checks which of the question's ideas the document covers:

question ideas: {password, rules} coverage phrase exact score
it-002 policy password ✓, policy/rules ✓ 2/2 = 1.0 1.0 0.5 ≈ 1.78
it-001 reset how-to password ✓, rules ✗ 1/2 = 0.5 0.0 0.5 ≈ 0.69

The score weighs the features: coverage + 0.5 × phrase + 0.25 × exact + 0.25 × cosine, where the cosine is the ordinary bi-encoder similarity (0.63 for the policy, 0.27 for the how-to). For the policy that's 1.0 + 0.5 + 0.125 + 0.16 ≈ 1.78; for the how-to, 0.5 + 0 + 0.125 + 0.07 ≈ 0.69. After reranking, the policy is first.

flowchart TB subgraph BI["Bi-encoder: two separate encodings"] direction LR Q1[Question] --> EQ[Encoder] --> VQ[vector] D1[Document] --> ED[Encoder] --> VD[vector] VQ --> COS[cosine] VD --> COS end subgraph CROSS["Cross-encoder: one joint reading"] direction LR QD["[Question] + [Document]<br/>as one input"] --> J["Encoder with attention<br/>across both texts"] --> SC[relevance score] end

Reading it: in the top box, the question and the document never meet until their vectors are compared, so document vectors can be computed ahead of time. In the bottom box they're one input, so attention (see primer.ml.attention) can connect every question word to every document word: "rules" can notice that the document says "policy" and "must". Nothing can be precomputed, because the score depends on the pair.

The cost argument. Say a cross-encoder pass takes 10 ms on a GPU. Over a 1-million-document collection that's 10,000 seconds per question. Over a shortlist of 50, it's 0.5 s, or much less when the pairs are batched. That is why the standard pipeline is retrieve wide and cheap, then rerank narrow and precise, and why you cap the shortlist to keep latency predictable.

In code: CrossEncoder.score reads one question-document pair and adds CrossEncoder.coverage, CrossEncoder.phrase and CrossEncoder.exact, with ideas grouping synonyms into ideas. retrieve_then_rerank shortlists with hybrid search, then reorders the shortlist with the cross-encoder.

Why it matters, and a warning: rerankers are models too, and can be wrong. On this repo's 20 questions, reranking fixes the hard negatives but demotes one answer ("refund for the hotel" → it prefers the car-mileage article, which talks about reimbursing trips). Measure recall@k and MRR with and without the reranker before shipping it.

5. Late interaction: ColBERT

The everyday picture. Instead of one summary card per book, keep a card for every word in the book. When your question arrives, each of your words looks for its single best-matching card in the book, and the book's score is how well your words found partners. That's more precise than one summary per book, and unlike the matchmaker's meetings, the cards can still be written ahead of time.

A tiny worked example. Two-number word vectors. The query has words (1, 0) and (0, 1); the document has words (1, 0) and (0.6, 0.8).

Query word vs. doc word 1 vs. doc word 2 best
(1, 0) 1.0 0.6 1.0
(0, 1) 0.0 0.8 0.8

Score = 1.0 + 0.8 = 1.8.

flowchart LR QT["Query: one vector<br/>per word"] --> SIM["Similarity of every<br/>query word × doc word"] DT["Document: one vector<br/>per word (precomputed)"] --> SIM SIM --> MAX["Each query word keeps<br/>its best match (max)"] MAX --> SUM["Add the maxima"] --> S[Score]

Reading it: the grid in the middle is the question-by-document table from the worked example; "max" picks the best cell in each row; "sum" adds the row winners. The document side is precomputed, as with a bi-encoder, but nothing is squeezed into one vector, so each question word gets matched on its own.

Level 3: the formula and its symbols

$$ \text{score}(q, d) = \sum_{i=1}^{|q|} \max_{j=1}^{|d|} \; q_i \cdot d_j $$

Symbols

Symbol Meaning here
|q|, |d| number of word vectors in the query and the document
q_i the vector of query word i
d_j the vector of document word j
q_i · d_j dot product: how similar those two words are
max over j the best-matching document word for query word i
Σ over i add those best matches up

In words: for each query word, find its most similar word in the document, and add up those best similarities.

On the example: max(1.0, 0.6) + max(0.0, 0.8) = 1.0 + 0.8 = 1.8.

Level 3: in Python

In Python:

# one vector per query word
q = [(1, 0), (0, 1)]
# one vector per document word
d = [(1, 0), (0.6, 0.8)]
def dot(a, b):
    return sum(a_k * b_k for a_k, b_k in zip(a, b))
# each query word's best match
[max(dot(q_i, d_j) for d_j in d) for q_i in q]  # → [1, 0.8]
# Σ_i max_j q_i · d_j
sum(max(dot(q_i, d_j) for d_j in d) for q_i in q)  # → 1.8

In ColBERT's MaxSim grid for password rules, password best matches itself at 1.00 and rules best matches policy at 0.85, for a score of 1.85

Reading it: rows are the query's words, columns the document's words, and color is the similarity of each pair. The outlined cell in each row is that row's maximum, the only number that counts. "password" finds itself (1.00), ignoring the near-identical "passwords"; "rules" finds "policy" (0.85), a synonym it could never match by spelling. The score is the sum of the outlined cells: 1.00 + 0.85 = 1.85.

In code: maxsim computes the score from two sets of word vectors; LateInteraction.token_vectors gives a text one vector per word, and LateInteraction.search ranks documents by MaxSim.

Why it matters: late interaction gets much of a cross-encoder's precision while keeping precomputed documents. The cost is storage: one vector per word instead of per document, often 50 to 200 times more. ColBERTv2 compresses those vectors to make it practical.

6. Chunking: how documents are cut before indexing

The everyday picture. You're turning a cookbook into index cards. Cut every 50 words and a recipe's title lands on one card and its oven temperature on the next. Nobody searching for that recipe finds the temperature. Cut at each recipe instead, and every card is self-contained.

A tiny worked example. A 130-word text, 50-word chunks with 10 words of overlap: windows start every 50 − 10 = 40 words, at 0, 40 and 80, giving 3 chunks (words 1–50, 41–90, 81–130). Each shares 10 words with the next, so a sentence cut at a boundary survives whole in at least one chunk, if it's short.

On this lesson's remote-work handbook, the question "home internet stipend" shows the difference. With 40-word fixed chunks, the best-matching chunk holds the heading "Home internet stipend" but the amount, "50 dollars per month", fell into the next window. With structure-aware chunks, cut at headings, the best chunk holds both.

flowchart LR DOC[Document] --> P[Parse: headings,<br/>paragraphs, tables] P --> SPLIT["Split at structure;<br/>merge small pieces<br/>up to a size limit"] SPLIT --> CTX["Prefix each chunk with<br/>its heading (context)"] CTX --> META["Attach metadata: source,<br/>section, date, access list"] META --> EMB[Embed + index]

Reading it: chunking is a small pipeline, not a single split. Parsing decides what the structure is (and is where most quality is lost on messy PDFs). Splitting respects that structure. Prefixing the heading means a chunk that says "The stipend is 50 dollars" still says which stipend: the idea behind contextual retrieval. Metadata rides along so later stages can filter by date or by who's allowed to see it (see primer.agents.rag).

Fixed 40-word windows split the stipend heading from its amount and cut through sections, while one structure-aware chunk holds both

Reading it: the colored strip in the middle is the handbook, one color per section, read left to right by word position. The brackets above it are the fixed 40-word windows; those below are the structure-aware chunks. The red marker is the "Home internet stipend" heading and the green marker the "50 dollars" amount. Above the strip they sit in different windows; below it, one chunk spans both. Notice also that the fixed windows cut straight through section boundaries.

Try it: the handbook below is cut by fixed_size_chunks, then searched with hybrid search and reranked with the cross-encoder from section 4. With the stipend question and 40-word chunks, retrieval ranks chunk 2 (the heading) first, and reranking lifts chunk 3, which holds the amount. Now pick the VPN question at 30 words with no overlap: the answer sentence is cut across chunks 6 and 7; raise the overlap to 10 words and one chunk holds it whole again. Drag top k down to 1 and watch the reranker lose its chance to help.

Parent-child retrieval. Small chunks match precisely; big chunks give the model enough context. Get both: index small children (single sentences), and when one matches, hand the model its parent (the whole section).

flowchart LR Q[Question] --> C["Search small children<br/>(sentences)"] C --> HIT[Best child] HIT --> PARENT["Look up its parent<br/>(the whole section)"] PARENT --> LLM[Give the parent<br/>to the model]

Reading it: the search happens on the left, at sentence level, where matches are sharp. The answer handed on happens on the right, at section level, where context is complete. The lookup in the middle is just a dictionary from child to parent.

Level 3: the formula and its symbols

$$ \text{number of chunks} = \left\lceil \frac{n - o}{s - o} \right\rceil $$

Symbols

Symbol Meaning here
n words in the document
s chunk size in words
o overlap in words (smaller than s)
s − o the step: how far each window moves
⌈ ⌉ "ceiling": round up to a whole number

In words: the number of chunks is the words left after the first overlap, divided by the step, rounded up.

On the example: ⌈(130 − 10)/(50 − 10)⌉ = ⌈120/40⌉ = 3.

Level 3: in Python

In Python:

import math
# words, chunk size, overlap
n, s, o = 130, 50, 10
# ⌈(n - o) / (s - o)⌉: the step is s - o
math.ceil((n - o) / (s - o))  # → 3

In code: fixed_size_chunks cuts overlapping windows of words; structure_aware_chunks cuts at headings and paragraphs and returns Chunk records that carry their heading and metadata. best_chunk picks the chunk BM25 ranks highest, and parent_child_search matches a sentence and returns its whole section. chunk_engine indexes one document's chunks so that retrieve_then_rerank runs on them, as in the widget above.

Why it matters: chunking choices often matter more than the choice of embedding model. Split on structure, keep headings with their content, add modest overlap, and attach metadata for filtering.

7. Asymmetric retrieval and the forgotten prefix

The everyday picture. A filing clerk was trained on sheets stamped "QUESTION:" or "DOCUMENT:", and files each kind in a matching drawer. Hand them an unstamped sheet and they don't complain: they file it anyway, in a drawer nobody will look in. Nobody notices until people stop finding things.

Questions are short and phrased as asks; passages are long statements. Some embedding models (the E5 family, for example) are trained to expect a "query: " prefix on questions and a "passage: " prefix on documents, so they can encode each side appropriately. Forget a prefix and every vector still looks normal (right length, plausible scores) but sits in a slightly wrong part of the space.

A tiny worked example. This lesson simulates such a model. With both prefixes, 11 of the 12 everyday questions find their answer in the top 3. Index the documents without "passage: " and the same questions fall to 9 of 12, and MRR drops from 0.875 to about 0.57. No error is raised anywhere.

flowchart LR subgraph OK["Correct"] Q1["'query: ' + question"] --> E1[Model] --> A1[aligned vectors] D1["'passage: ' + document"] --> E1 end subgraph BUG["Prefix forgotten at indexing"] Q2["'query: ' + question"] --> E2[Model] --> A2[question vectors] D2["document (no prefix)"] --> E2 --> B2[drifted document vectors] end

Reading it: the top box is how the model was trained: both sides stamped, vectors aligned. In the bottom box the documents were indexed unstamped. The model still returns vectors, and the pipeline runs, but the two sides no longer line up well. The only way to catch it is to measure recall on labeled questions, which is the habit this whole lesson argues for.

In code: PrefixedEmbedder simulates such a model: PrefixedEmbedder.encode_one strips a known prefix and encodes normally, but partly rotates the vector of any text that lacks one.

Why it matters: always read the embedding model's card for required prefixes or instructions, use the same model and settings at indexing and query time, and keep a small labeled set so silent regressions show up.

In 20 seconds

  • BM25 scores keyword matches: rare words weigh more (IDF), repeated mentions saturate (k₁), long documents are discounted (b). It's great at exact IDs and blind to synonyms.
  • Dense retrieval (bi-encoder) finds meaning and paraphrases, but blurs exact tokens. Documents are embedded once, ahead of time.
  • Hybrid search fuses both rankings with RRF, Σ 1/(60 + rank), using ranks not scores, and almost always beats either alone.
  • Retrieve, then rerank: a bi-encoder or hybrid search shortlists 50 to 100 cheaply; a cross-encoder reads each (question, candidate) pair together and reorders the shortlist precisely.
  • ColBERT keeps a vector per word and scores with MaxSim: close to cross-encoder precision, still precomputable, much more storage.
  • Chunk on structure, keep headings with content, attach metadata, and measure recall@k on labeled questions: it's how you catch silent bugs like a forgotten prefix.

Self-test questions

Documents "cat cat dog", "dog bird", "fish"; query "cat". What does BM25 give the first document? IDF(cat) = ln(1 + 2.5/1.5) = 0.981. With f = 2, |d| = 3, avgdl = 2, k₁ = 1.5 and b = 0.75 the denominator is 2 + 1.5 · 1.375 = 4.0625, so the score is 0.981 · 2 · 2.5 / 4.0625 = 1.207.

A document is 1st in dense search and 3rd in BM25. What's its RRF score? 1/61 + 1/63 ≈ 0.0323, versus 0.0164 for a document that is 1st in only one list.

Query words (1, 0) and (0, 1); document words (1, 0) and (0.6, 0.8). What's the MaxSim score? max(1.0, 0.6) + max(0.0, 0.8) = 1.8.

How many 50-word chunks with 10 words of overlap does a 130-word text make? ⌈(130 − 10)/(50 − 10)⌉ = 3, starting at words 0, 40 and 80.

What goes wrong if you forget an embedding model's "passage: " prefix when indexing? Nothing errors: vectors look normal and scores look plausible, but documents land where the model never aligned them with questions, and recall silently drops (11 of 12 to 9 of 12 in the lesson's simulation). Only an evaluation on labeled questions catches it.

What do BM25's k₁ and b control? k₁ sets saturation: how fast extra mentions of a word stop adding score (the credit per word can never exceed k₁ + 1). b sets length normalization: 0 ignores document length, 1 fully scales mentions by length relative to the average. Typical values are k₁ ≈ 1.2 to 2 and b = 0.75.

Bi-encoder vs. cross-encoder: how do you combine them in one pipeline? A bi-encoder embeds questions and documents separately, so documents are embedded once and searched in milliseconds with an ANN index; it's fast but never sees the pair together. A cross-encoder reads question and document as one input, so it's accurate but costs one model pass per pair and can't precompute anything. Combine them: retrieve the top 50 to 100 with the bi-encoder (or hybrid search), rerank that shortlist with the cross-encoder, and pass the top few to the model. Cap the shortlist to keep latency predictable.

Why does hybrid search beat pure vector search on company data? Company data is full of exact identifiers (error codes, product SKUs, ticket numbers, names) that embeddings blur, and full of paraphrases and jargon that keyword search misses. BM25 and dense search fail on different questions, so fusing their rankings with RRF recovers the answers each one misses.

Why does RRF use ranks instead of scores? BM25 scores and cosine similarities live on unrelated scales, and those scales vary by query and collection. Ranks are always comparable, so RRF needs no tuning or score normalization. The constant k = 60 keeps the very top rank from dominating.

What's a hard negative, and how do you fix one in retrieval? A document on the right topic that doesn't answer the question, like the password-reset how-to for "what are the password rules". Fix it with a reranker that reads the question and document together, and when you fine-tune an embedding model, train on hard negatives so it learns the distinction (see primer.ml.embeddings.contrastive).

What does ColBERT trade to get better precision than a bi-encoder? Storage and some query cost. It keeps one vector per word instead of one per document, often 50 to 200 times more vectors, and scores with MaxSim over word pairs, in exchange for word-level matching with precomputed documents.

Fixed-size vs. structure-aware chunking? Fixed-size windows are simple but cut through headings, sentences and tables, separating facts from their context. Structure-aware chunking splits at headings and paragraphs, keeps each heading with its content, and attaches metadata. Parent-child retrieval searches small pieces but returns their larger parent for context.

Your RAG system gives confident wrong answers. How do you tell whether retrieval is the cause? Build a small labeled set of real questions with known answer passages and measure recall@k of retrieval alone. If the right passage isn't in the top k, no prompt change will fix the answer; fix retrieval (hybrid, reranking, chunking, prefixes, domain fine-tuning). If it is there, the problem is in generation.

The papers behind this lesson

  • Robertson & Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond (Foundations and Trends in Information Retrieval, 2009). https://www.nowpublishers.com/article/Details/INR-019. The authoritative account of where BM25 comes from: the probabilistic model behind IDF, and why term-frequency saturation and length normalization take the form they do.
  • Cormack, Clarke & Büttcher, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (SIGIR 2009). https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf. Introduced RRF, Σ 1/(k + rank) with k = 60, and showed that this simple, tuning-free fusion beats more elaborate methods.
  • Reimers & Gurevych, Sentence-BERT (2019). https://arxiv.org/abs/1908.10084. Made bi-encoders practical: a siamese BERT that produces one comparable vector per sentence, turning hours of pairwise cross-encoding into milliseconds of vector search. annotated companion
  • Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering (2020). https://arxiv.org/abs/2004.04906. Showed a dual encoder trained with in-batch and BM25-mined hard negatives can beat BM25 on open-domain question answering, the template for modern dense retrievers. annotated companion
  • Khattab & Zaharia, ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT (2020). https://arxiv.org/abs/2004.12832. Introduced late interaction: per-token vectors scored with MaxSim, getting near cross-encoder quality with precomputed documents. annotated companion

Further reading

on GitHub
   1r"""
   2# Retrieval: keyword search, meaning search, and combining them
   3
   4Run: `python -m primer.ml.embeddings.retrieval`
   5
   6New to the notation (Σ, log, |d|)? `primer.notation` explains every symbol
   7used here from zero.
   8
   9## Level 1: The practitioner's guide
  10
  11**In one sentence.** Retrieval finds the few passages that answer a
  12question, ranked best first, by running a keyword search and a meaning
  13search side by side, fusing their rankings, and letting a slower, more
  14careful model reorder the short list that survives.
  15
  16**When you need it.** You need retrieval whenever a model must answer from
  17material it was not trained on and that material is too big to paste into
  18the prompt: a knowledge base of tickets, manuals, contracts or code.
  19Anthropic's contextual retrieval post puts the line at about 200,000 tokens
  20(about 500 pages): below that, put the whole knowledge base in the prompt
  21and skip retrieval. Above it, retrieval quality caps answer quality, because
  22the language model can only use what retrieval hands it (`primer.agents.rag`).
  23The tell that one search method is not enough: on this lesson's 20 labeled
  24questions, keyword search alone puts the answer in the top 3 for 75% of them
  25and meaning search alone for 90%, and their misses never overlap. Keyword
  26search fails on paraphrases ("automobile reimbursement" when the document
  27says "car" and "reimbursed"); meaning search puts the article for "ERR-4012"
  28fourth. Fused, they reach 100%.
  29
  30**Your options.** From the cheapest to the most precise; in practice each
  31stage feeds the next:
  32
  33| Option | What it does | What it gives you | What it costs | Where it lives |
  34|---|---|---|---|---|
  35| Keyword search (BM25) | Weighs each query word by rarity, saturates repeated mentions, discounts long documents | Exact matches on identifiers, codes and names, with no training | Nothing for synonyms: a document sharing no words scores 0 | Lucene, Elasticsearch, OpenSearch, any search engine |
  36| Dense retrieval (bi-encoder) | Embeds every document once and the question at query time, then returns the nearest vectors | Paraphrases, synonyms, other languages | An embedding model and a vector index; exact tokens blur | An embedding model plus a vector index (`primer.ml.embeddings.ann`) |
  37| Hybrid search (RRF) | Runs both and fuses the two rankings by position, never by score | The strengths of both, with no tuning | Two searches per query and a merge | Built into Elasticsearch and most vector databases |
  38| Reranking (cross-encoder) | Reads the question and each shortlisted candidate together and reorders them | Catches hard negatives: right topic, wrong answer | One model pass per candidate, so it only ever sees a shortlist | sentence-transformers cross-encoders, Cohere Rerank |
  39| Late interaction (ColBERT) | Keeps one vector per word and matches each question word to its best document word | Near cross-encoder precision with precomputed documents | 50 to 200 times the vector storage | ColBERT and ColBERTv2 |
  40
  41**How to choose.** Start from what your questions look like and how much
  42latency you can spend.
  43
  44- Questions full of identifiers (error codes, product codes, ticket
  45  numbers, names): keyword search is hard to beat and must be in the
  46  pipeline. Company data is full of these.
  47- Questions phrased differently from the documents (paraphrases, jargon,
  48  other languages): dense retrieval. Read the model card for required
  49  prefixes (`query: ` and `passage: ` for the E5 family) and use the same
  50  model and settings at indexing and query time.
  51- Almost always: both, fused with reciprocal rank fusion at its standard
  52  constant of 60. It is cheap, needs no tuning, and on this lesson's
  53  questions lifts recall@3 from 0.75 and 0.90 to 1.00.
  54- When the top results are on the right topic but don't answer the question
  55  (a password *reset* guide above the password *policy*): add a reranker
  56  over a shortlist of 50 to 100, and measure recall and MRR with and without
  57  it. Rerankers can demote a right answer too: this lesson's fixes the hard
  58  negative and lowers MRR from 1.00 to 0.975 on the same 20 questions.
  59- When one vector per document is too coarse and you can afford the
  60  storage: late interaction.
  61- Whatever you pick, decide the chunking first: split on structure, keep
  62  headings with their content, add modest overlap, attach metadata. It often
  63  matters more than the choice of embedding model. Then keep a small labeled
  64  set of questions with known answer passages and measure recall@k on it.
  65
  66**What it costs.** Keyword and dense search are the cheap, wide stages:
  67documents are indexed once, and a question costs one embedding plus a
  68lookup that takes milliseconds over millions of documents. Fusion is a merge
  69of two short lists. The reranker is where money and latency go: at 10 ms per
  70pair on a GPU, scoring a million documents takes 10,000 seconds, and a
  71shortlist of 50 takes 0.5 s (less when the pairs are batched), which is why
  72the shortlist is capped. Late interaction trades that latency for storage:
  73one vector per word instead of one per document. Preparing the chunks has a
  74price too: Anthropic reports \$1.02 per million document tokens, once, to
  75write a short context in front of every chunk with prompt caching, for a 49%
  76cut in top-20 retrieval failures with hybrid search and a 67% cut with
  77reranking added (from 5.7% to 1.9% on their evaluation). The cheapest item
  78of all, and the one most teams skip, is a labeled set of a few dozen
  79questions with known answer passages: it is the only instrument that shows
  80the failures below.
  81
  82**What breaks.**
  83
  84- **The right page is never found.** If the passage is not in the top k, no
  85  prompt change will fix the answer. Measure recall@k of retrieval alone
  86  before touching the prompt.
  87- **A forgotten prefix.** Index documents without the `passage: ` prefix a
  88  model was trained with and nothing errors: vectors look normal, scores
  89  look plausible, and in this lesson's simulation recall@3 falls from 0.92
  90  to 0.75 and MRR from 0.875 to 0.57. Only a labeled set catches it.
  91- **Scores added across systems.** BM25 scores run from 0 to about 20 and
  92  cosine similarities from −1 to 1; add them and one system drowns the
  93  other. Fuse ranks, not scores.
  94- **Hard negatives.** A document on the right topic that doesn't answer the
  95  question rises because it shares the words. A reranker reads the pair
  96  together; when you fine-tune an embedding model, train it on such pairs
  97  (`primer.ml.embeddings.contrastive`).
  98- **A heading cut from its fact.** Fixed 40-word windows split the heading
  99  "Home internet stipend" from "50 dollars per month" in this lesson's
 100  handbook; a structure-aware chunk holds both. Parent-child retrieval
 101  searches small children and returns their whole section.
 102- **A reranker trusted blindly.** It is a model and can be wrong: this
 103  lesson's demotes one correct answer while fixing another. Ship it only
 104  when the numbers say so.
 105- **A shortlist too short.** Give the reranker only the top result and it
 106  cannot help. Anthropic's evaluation retrieved 150 chunks, reranked them
 107  to 20, and found that passing 20 chunks to the model beat passing 10 or 5.
 108
 109**In the wild.** BM25 runs in any search engine: Lucene, Elasticsearch,
 110OpenSearch. Elasticsearch's `rrf` retriever fuses a BM25 query with a kNN
 111query by the same one-over-sixty-plus-rank rule (`rank_constant` 60 by
 112default) with no weights to tune. Bi-encoders trace back to Sentence-BERT
 113and Dense Passage Retrieval; the sentence-transformers library ships both
 114bi-encoders and cross-encoder rerankers, and hosted rerankers such as Cohere
 115Rerank take a query and a list of documents and return them ordered by
 116relevance, cut to a `top_n`. Cross-encoder reranking with BERT is due to
 117Nogueira and Cho, late interaction to ColBERT and ColBERTv2, and the E5
 118models are the ones trained with the `query: ` and `passage: ` prefixes.
 119Anthropic's contextual retrieval puts a generated context in front of every
 120chunk and combines it with hybrid search and reranking. The papers behind
 121this lesson are listed at the end with their companions.
 122
 123**Go deeper.** Level 2 builds every box of the pipeline by hand: BM25 on
 124three one-line documents, the bi-encoder over this repo's toy embedder,
 125reciprocal rank fusion on two ranks, a toy cross-encoder scoring a hard
 126negative, ColBERT's MaxSim on two-number word vectors, the chunking
 127arithmetic, and the forgotten-prefix bug measured. If you only needed to
 128design the pipeline, you are done.
 129
 130## Level 2: How it works, from scratch
 131
 132Level 2 builds every box of that pipeline from nothing, starting in a
 133library.
 134
 135**The everyday picture.** You walk into a library with a question. Two librarians are on duty.
 136
 137- **The keyword librarian** takes your words literally. They count how often
 138  each word of your question appears in each book, give *rare* words far more
 139  weight than common ones, stop getting more excited after the tenth mention,
 140  and are a little suspicious of very long books that mention everything.
 141  Ask about "ERR-4012" and they find the one page that says "ERR-4012". Ask
 142  about "automobile reimbursement" and they find nothing, because the book
 143  says "car" and "reimbursed".
 144- **The meaning librarian** understands what you *mean*: "automobile" is a
 145  "car", "scam" is "phishing". But they remember the gist of each book, not
 146  its serial numbers, so "ERR-4012" and "ERR-4013" blur together.
 147
 148Real systems ask **both** and trust the books both recommend. Then an
 149expert reads the top handful of candidates carefully, next to your question,
 150and puts them in final order. That's the whole modern retrieval pipeline,
 151and this lesson builds every piece of it.
 152
 153Words used throughout, in plain terms:
 154
 155- **Retrieval.** Finding the documents that answer a question, ranked best
 156  first. In a RAG system (`primer.agents.rag`) the language model can only use
 157  what retrieval hands it, so retrieval quality caps answer quality.
 158- **Token.** Here, one word after lowercasing and dropping filler words
 159  ("the", "is"): see `primer.common.text`.
 160- **Recall@k.** The share of questions whose answer appears in the top k
 161  results. **MRR** (mean reciprocal rank) also rewards putting the answer
 162  *first*: an answer at rank 1 scores 1, at rank 2 scores 1/2, missing scores
 163  0, averaged over questions.
 164
 165The full pipeline, which the sections below build one box at a time:
 166
 167```mermaid
 168flowchart LR
 169  Q[Question] --> K[Keyword search<br/>BM25]
 170  Q --> D[Meaning search<br/>embeddings]
 171  K --> F[Fuse the two rankings<br/>RRF]
 172  D --> F
 173  F --> S[Shortlist<br/>top 10 to 100]
 174  S --> R[Rerank each candidate<br/>with the question: cross-encoder]
 175  R --> T[Top 3 to 5 passages<br/>to the language model]
 176```
 177
 178**Reading it:** two cheap, wide searches run side by side and see the whole
 179collection. Their rankings are merged into one shortlist. Only the shortlist
 180reaches the expensive, precise reranker on the right. Everything left of the
 181shortlist must be fast because it faces millions of documents; everything
 182right of it can be slow because it faces only a hundred. Each box below gets
 183its own section.
 184
 185## 1. Keyword search: BM25
 186
 187**The everyday picture.** The keyword librarian from above: rare words count
 188more, repetition helps less and less, and long books get a small penalty for
 189mentioning everything.
 190
 191**A tiny worked example.** Three one-line "documents":
 192
 193| Doc | Words | Length |
 194|---|---|---|
 195| d₁ | cat cat dog | 3 |
 196| d₂ | dog bird | 2 |
 197| d₃ | fish | 1 |
 198
 199The average length is (3 + 2 + 1)/3 = 2. Search for **cat**. It appears in
 200one of the three documents, so it's rare and gets a high weight (0.981,
 201computed below); "dog" is in two, so it would get less (0.470). Only d₁
 202contains "cat", twice, and d₁ is a bit longer than average. Plugging in,
 203d₁ scores **1.207** and the others score 0.
 204
 205```mermaid
 206flowchart LR
 207  Q[Query words] --> IDF["Rarity weight per word<br/>IDF: rarer = heavier"]
 208  Q --> TF["Count in this document<br/>tf, with saturation"]
 209  DL["Document length vs.<br/>the average length"] --> TF
 210  IDF --> M((×))
 211  TF --> M
 212  M --> SUM["Add up over<br/>the query's words"]
 213  SUM --> S[BM25 score]
 214```
 215
 216**Reading it:** each query word contributes one product: *how rare the word
 217is* times *how much this document talks about it*. The length box feeds into
 218the count box because a mention in a short document is stronger evidence than
 219the same mention in a long one. Words the document doesn't contain
 220contribute zero, so a document sharing no words with the query scores exactly
 2210. That's the blind spot the meaning librarian covers.
 222
 223$$
 224\text{BM25}(q, d) = \sum_{t \in q} \text{IDF}(t) \cdot
 225\frac{f(t, d)\,(k_1 + 1)}{f(t, d) + k_1 \left(1 - b + b\,\frac{|d|}{\text{avgdl}}\right)},
 226\qquad
 227\text{IDF}(t) = \ln\!\left(1 + \frac{N - n(t) + 0.5}{n(t) + 0.5}\right)
 228$$
 229
 230**Symbols**
 231
 232| Symbol | Meaning here | Range / typical |
 233|---|---|---|
 234| q | the query, as a list of words | |
 235| d | one document, as a list of words | |
 236| t ∈ q | "each word t in the query" | |
 237| Σ | add up the following over every query word | |
 238| f(t, d) | how many times word t appears in document d (term frequency) | 0, 1, 2, … |
 239| \|d\| | the document's length in words | |
 240| avgdl | the average document length in the collection | |
 241| k₁ | saturation: how quickly extra mentions stop helping | 1.2 to 2.0; 1.5 here |
 242| b | length normalization: 0 ignores length, 1 fully normalizes | 0.75 |
 243| N | number of documents in the collection | |
 244| n(t) | number of documents containing t (document frequency) | 1 … N |
 245| ln | natural logarithm: grows slowly, so ten times rarer is not ten times heavier | |
 246| IDF(t) | inverse document frequency: the word's rarity weight | ≥ 0 |
 247
 248**In words:** for each word of the query, multiply how rare the word is by a
 249count of its mentions that saturates and is adjusted for document length,
 250then add those products up.
 251
 252**On the example (query "cat", document d₁):** N = 3, n(cat) = 1, so
 253IDF = ln(1 + 2.5/1.5) = ln(2.667) = **0.981**. f = 2, |d| = 3, avgdl = 2:
 254the denominator is 2 + 1.5 · (1 − 0.75 + 0.75 · 3/2) = 2 + 1.5 · 1.375 =
 2554.0625. The count part is 2 · 2.5 / 4.0625 = 1.231. Score = 0.981 · 1.231 =
 256**1.207**.
 257
 258**In Python:**
 259
 260```python
 261import math
 262# 3 documents; 1 of them contains "cat"
 263N, n_t = 3, 1
 264# ln(1 + (N - n(t) + 0.5) / (n(t) + 0.5))
 265IDF = math.log(1 + (N - n_t + 0.5) / (n_t + 0.5))
 266round(IDF, 3)  # → 0.981
 267# f(cat, d1), |d1|, average document length
 268f, d_len, avgdl = 2, 3, 2
 269k_1, b = 1.5, 0.75
 270count_part = f * (k_1 + 1) / (f + k_1 * (1 - b + b * d_len / avgdl))
 271round(count_part, 3)  # → 1.231
 272# Σ over the query's only word
 273round(IDF * count_part, 3)  # → 1.207
 274```
 275
 276![BM25 credit flattens toward k1 + 1 however often a word repeats, and one mention counts less in a longer document once b is above 0](figures/primer.ml.embeddings.retrieval.bm25_curves.svg)
 277
 278**Reading it:** on the left, the x-axis is how often a word appears in a
 279document and the y-axis is the credit BM25 gives it (before the rarity
 280weight). The dashed line is raw counting, where 20 mentions count 20 times
 281as much as one. The BM25 curves bend over and flatten toward k₁ + 1: the
 282tenth mention adds almost nothing, which stops keyword-stuffed pages from
 283winning. Smaller k₁ flattens sooner. On the right, one mention in documents
 284of different lengths: with b = 0 length is ignored; with b = 0.75 a mention
 285in a document twice the average length counts noticeably less.
 286
 287**In code:** `BM25` precomputes each word's IDF and each document's length;
 288`BM25.scores` applies the formula to every document, and `BM25.search`
 289returns the top k with a non-zero score.
 290
 291**Why it matters:** BM25 is decades old, needs no training, runs on any
 292search engine (Elasticsearch, OpenSearch, Lucene), and is still very hard to
 293beat on exact identifiers: product codes, error numbers, names, ticket IDs.
 294Those are everywhere in company data.
 295
 296## 2. Meaning search: dense retrieval with a bi-encoder
 297
 298**The everyday picture.** The meaning librarian writes a one-line summary
 299card for every book *before* anyone asks anything. When your question
 300arrives, they write a summary card for it too and pull the books whose cards
 301are most similar. "Automobile reimbursement" and "car mileage expense" get
 302similar cards even though they share no words.
 303
 304**A tiny worked example.** With this repo's toy embedding model
 305(`primer.common.embedder`), the query "automobile reimbursement" and the
 306car-mileage article share zero words, yet their vectors have cosine
 307similarity of about 0.8, the highest in the collection. "What does ERR-4012
 308mean", by contrast, puts the right article only 4th: the code is one
 309blurred word among many, and "what" and "mean" pull the vector elsewhere.
 310
 311```mermaid
 312flowchart LR
 313  subgraph AHEAD["Ahead of time"]
 314    D[Each document] --> E1[Encoder] --> V[(Document vectors)]
 315  end
 316  subgraph NOW["At question time"]
 317    Q[Question] --> E2[Same encoder] --> QV[Question vector]
 318  end
 319  QV --> NN["Nearest vectors<br/>(ann.py)"]
 320  V --> NN
 321  NN --> R[Ranked documents]
 322```
 323
 324**Reading it:** the encoder runs twice, but never on the question and a
 325document *together*. That's why it's called a **bi-encoder**: two separate
 326encodings. Documents are encoded once, at ingestion. A question costs one
 327encoding plus a nearest-neighbor lookup (see `primer.ml.embeddings.ann`),
 328which takes milliseconds over millions of documents. The price: the model
 329never sees the question and the document side by side, so it can't check
 330fine details like "does this passage answer *this* question, or just share
 331its topic?".
 332
 333**In code:** `SearchEngine` embeds every document once with
 334`primer.common.embedder.ConceptEmbedder` into a
 335`primer.ml.embeddings.ann.FlatIndex`; `SearchEngine.dense` embeds the
 336question and returns the nearest documents. `doc_text` decides what gets
 337indexed: the title plus the body.
 338
 339**Why it matters:** dense retrieval handles paraphrases, synonyms and other
 340languages, but it blurs exact tokens. Measure both kinds of queries on your
 341own data before trusting either librarian alone.
 342
 343## 3. Hybrid search: reciprocal rank fusion (RRF)
 344
 345**The everyday picture.** Ask both librarians for their top-10 lists, then
 346hold a vote. A book earns points for each list it's on, more the higher it
 347sits. Books both librarians rank highly rise to the top. Crucially, you only
 348compare *positions*, never the librarians' private scoring systems, which
 349aren't on the same scale.
 350
 351**A tiny worked example.** With the standard constant k = 60:
 352
 353| Document | Dense rank | BM25 rank | RRF score |
 354|---|---|---|---|
 355| X | 1 | 3 | 1/61 + 1/63 = 0.0164 + 0.0159 = **0.0323** |
 356| Y | 1 | (absent) | 1/61 = **0.0164** |
 357
 358X, which both methods like, beats Y, which only one method loves.
 359
 360```mermaid
 361flowchart LR
 362  Q[Question] --> B[BM25 ranking]
 363  Q --> D[Dense ranking]
 364  B --> R["For every document:<br/>add 1/(60 + rank)<br/>from each list it's on"]
 365  D --> R
 366  R --> O[Sort by total:<br/>the fused ranking]
 367```
 368
 369**Reading it:** the two rankings are the only inputs; their raw scores are
 370thrown away on purpose. BM25 scores run from 0 to about 20; cosine
 371similarities from −1 to 1. Adding them would let one system drown the other,
 372and fixing that needs tuning per collection. Ranks are always on the same
 373scale, so RRF works out of the box.
 374
 375$$
 376\text{RRF}(d) = \sum_{i=1}^{m} \frac{1}{k + \text{rank}_i(d)}
 377$$
 378
 379**Symbols**
 380
 381| Symbol | Meaning here | Range / typical |
 382|---|---|---|
 383| d | a document | |
 384| m | number of rankings being fused | 2 here (BM25 and dense) |
 385| i | which ranking | 1 … m |
 386| rank_i(d) | d's position in ranking i (1 = top); lists d isn't on contribute nothing | 1, 2, 3, … |
 387| k | a damping constant: keeps rank 1 from dwarfing rank 2 | 60 (from the original paper) |
 388| RRF(d) | the fused score | small positive numbers |
 389
 390**In words:** a document's fused score is the sum, over each ranking it
 391appears in, of one over sixty-plus-its-rank.
 392
 393**On the example:** X: 1/(60+1) + 1/(60+3) = 0.01639 + 0.01587 = **0.0323**.
 394Y: 1/(60+1) = **0.0164**.
 395
 396**In Python:**
 397
 398```python
 399k = 60
 400# d's position in each ranking that contains it
 401def RRF(ranks):
 402    # Σ_i 1/(k + rank_i(d))
 403    return sum(1 / (k + rank_i) for rank_i in ranks)
 404# X: 1st in one ranking, 3rd in the other
 405round(RRF([1, 3]), 4)  # → 0.0323
 406# Y: on one ranking only
 407round(RRF([1]), 4)  # → 0.0164
 408```
 409
 410![For the query what does ERR-4012 mean, the error-code article collects credit from both BM25 and dense search and fuses to a clear first place](figures/primer.ml.embeddings.retrieval.rrf_fusion.svg)
 411
 412**Reading it:** each bar is one document's fused score, split into the part
 413contributed by BM25 (orange) and by dense search (blue). The error-code
 414article it-004 is BM25's only hit and dense search's 4th, and the two
 415contributions stack up to put it clearly first. The password-policy article
 416that dense search wrongly ranked first gets only its blue half.
 417
 418![BM25 misses the paraphrase questions and dense search misses the error codes, but their misses never overlap, so the hybrid ranks every answer first](figures/primer.ml.embeddings.retrieval.method_ranks.svg)
 419
 420**Reading it:** one row per question, one column per method; the number is
 421the rank at which the right answer appeared (✗ = not in the top 10). Look at
 422where the colors differ. BM25's failures (the paraphrase block) are where
 423dense search succeeds, and dense search's failures (the error-code block) are
 424where BM25 succeeds. Because their mistakes don't overlap, fusing them fixes
 425both: the hybrid column is all 1s.
 426
 427| Method | recall@3 (20 questions) | MRR |
 428|---|---|---|
 429| BM25 alone | 0.75 | 0.75 |
 430| Dense alone | 0.90 | 0.85 |
 431| **Hybrid (RRF)** | **1.00** | **1.00** |
 432
 433**In code:** `reciprocal_rank_fusion` adds up 1/(k + rank) across any number
 434of rankings; `SearchEngine.hybrid` fuses `SearchEngine.bm25` with
 435`SearchEngine.dense`, and `evaluate` measures recall@k and MRR for any search
 436method.
 437
 438**Why it matters:** company data is full of both paraphrase-style questions
 439and exact identifiers, so hybrid search almost always beats either method
 440alone. It's cheap to add: most search engines and vector databases support
 441it built in.
 442
 443## 4. Reranking: bi-encoder vs. cross-encoder
 444
 445**The everyday picture.** A matchmaker has two ways to work. The fast way:
 446write an index card for each person once, then match cards, so thousands of
 447people can be pre-filed. The careful way: sit the two people down *together*
 448and watch them talk. That's far more accurate, but every pair needs its own
 449meeting. A **bi-encoder** is the index card. A **cross-encoder** is the
 450meeting. So you use cards to shortlist, then hold meetings only with the
 451shortlist.
 452
 453**A tiny worked example: a hard negative.** "What are the password rules?"
 454has two candidate answers on the same topic. The password *reset* how-to
 455(it-001) mentions "password" three times; the password *policy* (it-002) is
 456the real answer. BM25 and the fused hybrid ranking both put the how-to first.
 457A **hard negative** is exactly this: a document that looks relevant (right
 458topic) but doesn't answer the question.
 459
 460Our toy cross-encoder reads the question and the document together and
 461checks which of the question's *ideas* the document covers:
 462
 463| | question ideas: {password, rules} | coverage | phrase | exact | score |
 464|---|---|---|---|---|---|
 465| it-002 policy | password ✓, policy/rules ✓ | 2/2 = **1.0** | 1.0 | 0.5 | ≈ 1.78 |
 466| it-001 reset how-to | password ✓, rules ✗ | 1/2 = **0.5** | 0.0 | 0.5 | ≈ 0.69 |
 467
 468The score weighs the features: coverage + 0.5 × phrase + 0.25 × exact +
 4690.25 × cosine, where the cosine is the ordinary bi-encoder similarity (0.63
 470for the policy, 0.27 for the how-to). For the policy that's 1.0 + 0.5 +
 4710.125 + 0.16 ≈ 1.78; for the how-to, 0.5 + 0 + 0.125 + 0.07 ≈ 0.69.
 472After reranking, the policy is first.
 473
 474```mermaid
 475flowchart TB
 476  subgraph BI["Bi-encoder: two separate encodings"]
 477    direction LR
 478    Q1[Question] --> EQ[Encoder] --> VQ[vector]
 479    D1[Document] --> ED[Encoder] --> VD[vector]
 480    VQ --> COS[cosine]
 481    VD --> COS
 482  end
 483  subgraph CROSS["Cross-encoder: one joint reading"]
 484    direction LR
 485    QD["[Question] + [Document]<br/>as one input"] --> J["Encoder with attention<br/>across both texts"] --> SC[relevance score]
 486  end
 487```
 488
 489**Reading it:** in the top box, the question and the document never meet
 490until their vectors are compared, so document vectors can be computed ahead
 491of time. In the bottom box they're one input, so attention (see
 492`primer.ml.attention`) can connect every question word to every document
 493word: "rules" can notice that the document says "policy" and "must". Nothing
 494can be precomputed, because the score depends on the pair.
 495
 496**The cost argument.** Say a cross-encoder pass takes 10 ms on a GPU. Over a
 4971-million-document collection that's 10,000 seconds per question. Over a
 498shortlist of 50, it's 0.5 s, or much less when the pairs are batched. That
 499is why the standard pipeline is *retrieve wide and cheap, then rerank narrow
 500and precise*, and why you cap the shortlist to keep latency predictable.
 501
 502**In code:** `CrossEncoder.score` reads one question-document pair and adds
 503`CrossEncoder.coverage`, `CrossEncoder.phrase` and `CrossEncoder.exact`, with
 504`ideas` grouping synonyms into ideas. `retrieve_then_rerank` shortlists with
 505hybrid search, then reorders the shortlist with the cross-encoder.
 506
 507**Why it matters, and a warning:** rerankers are models too, and can be wrong.
 508On this repo's 20 questions, reranking fixes the hard negatives but demotes
 509one answer ("refund for the hotel" → it prefers the car-mileage article,
 510which talks about reimbursing trips). Measure recall@k and MRR with and
 511without the reranker before shipping it.
 512
 513## 5. Late interaction: ColBERT
 514
 515**The everyday picture.** Instead of one summary card per book, keep a card
 516for *every word* in the book. When your question arrives, each of your words
 517looks for its single best-matching card in the book, and the book's score is
 518how well your words found partners. That's more precise than one summary per
 519book, and unlike the matchmaker's meetings, the cards can still be written
 520ahead of time.
 521
 522**A tiny worked example.** Two-number word vectors. The query has words
 523(1, 0) and (0, 1); the document has words (1, 0) and (0.6, 0.8).
 524
 525| Query word | vs. doc word 1 | vs. doc word 2 | best |
 526|---|---|---|---|
 527| (1, 0) | 1.0 | 0.6 | **1.0** |
 528| (0, 1) | 0.0 | 0.8 | **0.8** |
 529
 530Score = 1.0 + 0.8 = **1.8**.
 531
 532```mermaid
 533flowchart LR
 534  QT["Query: one vector<br/>per word"] --> SIM["Similarity of every<br/>query word × doc word"]
 535  DT["Document: one vector<br/>per word (precomputed)"] --> SIM
 536  SIM --> MAX["Each query word keeps<br/>its best match (max)"]
 537  MAX --> SUM["Add the maxima"] --> S[Score]
 538```
 539
 540**Reading it:** the grid in the middle is the question-by-document table
 541from the worked example; "max" picks the best cell in each row; "sum" adds
 542the row winners. The document side is precomputed, as with a bi-encoder, but
 543nothing is squeezed into one vector, so each question word gets matched on
 544its own.
 545
 546$$
 547\text{score}(q, d) = \sum_{i=1}^{|q|} \max_{j=1}^{|d|} \; q_i \cdot d_j
 548$$
 549
 550**Symbols**
 551
 552| Symbol | Meaning here |
 553|---|---|
 554| \|q\|, \|d\| | number of word vectors in the query and the document |
 555| q_i | the vector of query word i |
 556| d_j | the vector of document word j |
 557| q_i · d_j | dot product: how similar those two words are |
 558| max over j | the best-matching document word for query word i |
 559| Σ over i | add those best matches up |
 560
 561**In words:** for each query word, find its most similar word in the
 562document, and add up those best similarities.
 563
 564**On the example:** max(1.0, 0.6) + max(0.0, 0.8) = 1.0 + 0.8 = **1.8**.
 565
 566**In Python:**
 567
 568```python
 569# one vector per query word
 570q = [(1, 0), (0, 1)]
 571# one vector per document word
 572d = [(1, 0), (0.6, 0.8)]
 573def dot(a, b):
 574    return sum(a_k * b_k for a_k, b_k in zip(a, b))
 575# each query word's best match
 576[max(dot(q_i, d_j) for d_j in d) for q_i in q]  # → [1, 0.8]
 577# Σ_i max_j q_i · d_j
 578sum(max(dot(q_i, d_j) for d_j in d) for q_i in q)  # → 1.8
 579```
 580
 581![In ColBERT's MaxSim grid for password rules, password best matches itself at 1.00 and rules best matches policy at 0.85, for a score of 1.85](figures/primer.ml.embeddings.retrieval.maxsim_grid.svg)
 582
 583**Reading it:** rows are the query's words, columns the document's words,
 584and color is the similarity of each pair. The outlined cell in each row is
 585that row's maximum, the only number that counts. "password" finds itself
 586(1.00), ignoring the near-identical "passwords"; "rules" finds "policy"
 587(0.85), a synonym it could never match by spelling. The score is the sum of
 588the outlined cells: 1.00 + 0.85 = 1.85.
 589
 590**In code:** `maxsim` computes the score from two sets of word vectors;
 591`LateInteraction.token_vectors` gives a text one vector per word, and
 592`LateInteraction.search` ranks documents by MaxSim.
 593
 594**Why it matters:** late interaction gets much of a cross-encoder's precision
 595while keeping precomputed documents. The cost is storage: one vector per
 596*word* instead of per document, often 50 to 200 times more. ColBERTv2
 597compresses those vectors to make it practical.
 598
 599## 6. Chunking: how documents are cut before indexing
 600
 601**The everyday picture.** You're turning a cookbook into index cards. Cut
 602every 50 words and a recipe's title lands on one card and its oven
 603temperature on the next. Nobody searching for that recipe finds the
 604temperature. Cut at each recipe instead, and every card is self-contained.
 605
 606**A tiny worked example.** A 130-word text, 50-word chunks with 10 words of
 607overlap: windows start every 50 − 10 = 40 words, at 0, 40 and 80, giving
 608**3 chunks** (words 1–50, 41–90, 81–130). Each shares 10 words with the next,
 609so a sentence cut at a boundary survives whole in at least one chunk, if
 610it's short.
 611
 612On this lesson's remote-work handbook, the question "home internet stipend"
 613shows the difference. With 40-word fixed chunks, the best-matching chunk
 614holds the heading "Home internet stipend" but the amount, "50 dollars per
 615month", fell into the next window. With structure-aware chunks, cut at
 616headings, the best chunk holds both.
 617
 618```mermaid
 619flowchart LR
 620  DOC[Document] --> P[Parse: headings,<br/>paragraphs, tables]
 621  P --> SPLIT["Split at structure;<br/>merge small pieces<br/>up to a size limit"]
 622  SPLIT --> CTX["Prefix each chunk with<br/>its heading (context)"]
 623  CTX --> META["Attach metadata: source,<br/>section, date, access list"]
 624  META --> EMB[Embed + index]
 625```
 626
 627**Reading it:** chunking is a small pipeline, not a single split. Parsing
 628decides what the structure *is* (and is where most quality is lost on messy
 629PDFs). Splitting respects that structure. Prefixing the heading means a
 630chunk that says "The stipend is 50 dollars" still says *which* stipend: the
 631idea behind *contextual retrieval*. Metadata rides along so later stages can
 632filter by date or by who's allowed to see it (see `primer.agents.rag`).
 633
 634![Fixed 40-word windows split the stipend heading from its amount and cut through sections, while one structure-aware chunk holds both](figures/primer.ml.embeddings.retrieval.chunk_boundaries.svg)
 635
 636**Reading it:** the colored strip in the middle is the handbook, one color
 637per section, read left to right by word position. The brackets above it are
 638the fixed 40-word windows; those below are the structure-aware chunks. The
 639red marker is the "Home internet stipend" heading and the green marker the
 640"50 dollars" amount. Above the strip they sit in different windows; below
 641it, one chunk spans both. Notice also that the fixed windows cut straight
 642through section boundaries.
 643
 644**Try it:** the handbook below is cut by `fixed_size_chunks`, then searched
 645with hybrid search and reranked with the cross-encoder from section 4. With
 646the stipend question and 40-word chunks, retrieval ranks chunk 2 (the
 647heading) first, and reranking lifts chunk 3, which holds the amount. Now pick
 648the VPN question at 30 words with no overlap: the answer sentence is cut
 649across chunks 6 and 7; raise the overlap to 10 words and one chunk holds it
 650whole again. Drag top k down to 1 and watch the reranker lose its chance to
 651help.
 652
 653<div class="viz" data-viz="rag-pipeline" aria-label="Chunking, retrieval and reranking explorer"></div>
 654
 655**Parent-child retrieval.** Small chunks match precisely; big chunks give the
 656model enough context. Get both: index small *children* (single sentences),
 657and when one matches, hand the model its *parent* (the whole section).
 658
 659```mermaid
 660flowchart LR
 661  Q[Question] --> C["Search small children<br/>(sentences)"]
 662  C --> HIT[Best child]
 663  HIT --> PARENT["Look up its parent<br/>(the whole section)"]
 664  PARENT --> LLM[Give the parent<br/>to the model]
 665```
 666
 667**Reading it:** the search happens on the left, at sentence level, where
 668matches are sharp. The answer handed on happens on the right, at section
 669level, where context is complete. The lookup in the middle is just a
 670dictionary from child to parent.
 671
 672$$
 673\text{number of chunks} = \left\lceil \frac{n - o}{s - o} \right\rceil
 674$$
 675
 676**Symbols**
 677
 678| Symbol | Meaning here |
 679|---|---|
 680| n | words in the document |
 681| s | chunk size in words |
 682| o | overlap in words (smaller than s) |
 683| s − o | the step: how far each window moves |
 684| ⌈ ⌉ | "ceiling": round up to a whole number |
 685
 686**In words:** the number of chunks is the words left after the first
 687overlap, divided by the step, rounded up.
 688
 689**On the example:** ⌈(130 − 10)/(50 − 10)⌉ = ⌈120/40⌉ = **3**.
 690
 691**In Python:**
 692
 693```python
 694import math
 695# words, chunk size, overlap
 696n, s, o = 130, 50, 10
 697# ⌈(n - o) / (s - o)⌉: the step is s - o
 698math.ceil((n - o) / (s - o))  # → 3
 699```
 700
 701**In code:** `fixed_size_chunks` cuts overlapping windows of words;
 702`structure_aware_chunks` cuts at headings and paragraphs and returns `Chunk`
 703records that carry their heading and metadata. `best_chunk` picks the chunk
 704BM25 ranks highest, and `parent_child_search` matches a sentence and returns
 705its whole section. `chunk_engine` indexes one document's chunks so that
 706`retrieve_then_rerank` runs on them, as in the widget above.
 707
 708**Why it matters:** chunking choices often matter more than the choice of
 709embedding model. Split on structure, keep headings with their content, add
 710modest overlap, and attach metadata for filtering.
 711
 712## 7. Asymmetric retrieval and the forgotten prefix
 713
 714**The everyday picture.** A filing clerk was trained on sheets stamped
 715"QUESTION:" or "DOCUMENT:", and files each kind in a matching drawer. Hand
 716them an unstamped sheet and they don't complain: they file it anyway, in a
 717drawer nobody will look in. Nobody notices until people stop finding things.
 718
 719Questions are short and phrased as asks; passages are long statements. Some
 720embedding models (the E5 family, for example) are trained to expect a
 721`"query: "` prefix on questions and a `"passage: "` prefix on documents, so
 722they can encode each side appropriately. Forget a prefix and every vector
 723still looks normal (right length, plausible scores) but sits in a slightly
 724wrong part of the space.
 725
 726**A tiny worked example.** This lesson simulates such a model. With both
 727prefixes, 11 of the 12 everyday questions find their answer in the top 3.
 728Index the documents *without* "passage: " and the same questions fall to 9
 729of 12, and MRR drops from 0.875 to about 0.57. No error is raised anywhere.
 730
 731```mermaid
 732flowchart LR
 733  subgraph OK["Correct"]
 734    Q1["'query: ' + question"] --> E1[Model] --> A1[aligned vectors]
 735    D1["'passage: ' + document"] --> E1
 736  end
 737  subgraph BUG["Prefix forgotten at indexing"]
 738    Q2["'query: ' + question"] --> E2[Model] --> A2[question vectors]
 739    D2["document (no prefix)"] --> E2 --> B2[drifted document vectors]
 740  end
 741```
 742
 743**Reading it:** the top box is how the model was trained: both sides
 744stamped, vectors aligned. In the bottom box the documents were indexed
 745unstamped. The model still returns vectors, and the pipeline runs, but the
 746two sides no longer line up well. The only way to catch it is to measure
 747recall on labeled questions, which is the habit this whole lesson argues for.
 748
 749**In code:** `PrefixedEmbedder` simulates such a model:
 750`PrefixedEmbedder.encode_one` strips a known prefix and encodes normally, but
 751partly rotates the vector of any text that lacks one.
 752
 753**Why it matters:** always read the embedding model's card for required
 754prefixes or instructions, use the *same* model and settings at indexing and
 755query time, and keep a small labeled set so silent regressions show up.
 756
 757## In 20 seconds
 758- **BM25** scores keyword matches: rare words weigh more (IDF), repeated
 759  mentions saturate (k₁), long documents are discounted (b). It's great at
 760  exact IDs and blind to synonyms.
 761- **Dense retrieval** (bi-encoder) finds meaning and paraphrases, but blurs
 762  exact tokens. Documents are embedded once, ahead of time.
 763- **Hybrid search** fuses both rankings with **RRF**, Σ 1/(60 + rank), using
 764  ranks not scores, and almost always beats either alone.
 765- **Retrieve, then rerank:** a bi-encoder or hybrid search shortlists 50 to
 766  100 cheaply; a cross-encoder reads each (question, candidate) pair together
 767  and reorders the shortlist precisely.
 768- **ColBERT** keeps a vector per word and scores with MaxSim: close to
 769  cross-encoder precision, still precomputable, much more storage.
 770- **Chunk on structure**, keep headings with content, attach metadata, and
 771  **measure recall@k** on labeled questions: it's how you catch silent bugs
 772  like a forgotten prefix.
 773
 774## Self-test questions
 775
 776**Documents "cat cat dog", "dog bird", "fish"; query "cat". What does BM25
 777give the first document?**
 778IDF(cat) = ln(1 + 2.5/1.5) = 0.981. With f = 2, |d| = 3, avgdl = 2, k₁ = 1.5
 779and b = 0.75 the denominator is 2 + 1.5 · 1.375 = 4.0625, so the score is
 7800.981 · 2 · 2.5 / 4.0625 = 1.207.
 781
 782**A document is 1st in dense search and 3rd in BM25. What's its RRF score?**
 7831/61 + 1/63 ≈ 0.0323, versus 0.0164 for a document that is 1st in only one
 784list.
 785
 786**Query words (1, 0) and (0, 1); document words (1, 0) and (0.6, 0.8). What's
 787the MaxSim score?**
 788max(1.0, 0.6) + max(0.0, 0.8) = 1.8.
 789
 790**How many 50-word chunks with 10 words of overlap does a 130-word text make?**
 791⌈(130 − 10)/(50 − 10)⌉ = 3, starting at words 0, 40 and 80.
 792
 793**What goes wrong if you forget an embedding model's "passage: " prefix when
 794indexing?**
 795Nothing errors: vectors look normal and scores look plausible, but documents
 796land where the model never aligned them with questions, and recall silently
 797drops (11 of 12 to 9 of 12 in the lesson's simulation). Only an evaluation
 798on labeled questions catches it.
 799
 800**What do BM25's k₁ and b control?**
 801k₁ sets saturation: how fast extra mentions of a word stop adding score
 802(the credit per word can never exceed k₁ + 1). b sets length normalization:
 8030 ignores document length, 1 fully scales mentions by length relative to the
 804average. Typical values are k₁ ≈ 1.2 to 2 and b = 0.75.
 805
 806**Bi-encoder vs. cross-encoder: how do you combine them in one pipeline?**
 807A bi-encoder embeds questions and documents separately, so documents are
 808embedded once and searched in milliseconds with an ANN index; it's fast but
 809never sees the pair together. A cross-encoder reads question and document as
 810one input, so it's accurate but costs one model pass per pair and can't
 811precompute anything. Combine them: retrieve the top 50 to 100 with the
 812bi-encoder (or hybrid search), rerank that shortlist with the cross-encoder,
 813and pass the top few to the model. Cap the shortlist to keep latency
 814predictable.
 815
 816**Why does hybrid search beat pure vector search on company data?**
 817Company data is full of exact identifiers (error codes, product SKUs,
 818ticket numbers, names) that embeddings blur, and full of paraphrases and
 819jargon that keyword search misses. BM25 and dense search fail on *different*
 820questions, so fusing their rankings with RRF recovers the answers each one
 821misses.
 822
 823**Why does RRF use ranks instead of scores?**
 824BM25 scores and cosine similarities live on unrelated scales, and those
 825scales vary by query and collection. Ranks are always comparable, so RRF
 826needs no tuning or score normalization. The constant k = 60 keeps the very
 827top rank from dominating.
 828
 829**What's a hard negative, and how do you fix one in retrieval?**
 830A document on the right topic that doesn't answer the question, like the
 831password-reset how-to for "what are the password rules". Fix it with a
 832reranker that reads the question and document together, and when you
 833fine-tune an embedding model, train on hard negatives so it learns the
 834distinction (see `primer.ml.embeddings.contrastive`).
 835
 836**What does ColBERT trade to get better precision than a bi-encoder?**
 837Storage and some query cost. It keeps one vector per word instead of one per
 838document, often 50 to 200 times more vectors, and scores with MaxSim over
 839word pairs, in exchange for word-level matching with precomputed documents.
 840
 841**Fixed-size vs. structure-aware chunking?**
 842Fixed-size windows are simple but cut through headings, sentences and
 843tables, separating facts from their context. Structure-aware chunking splits
 844at headings and paragraphs, keeps each heading with its content, and attaches
 845metadata. Parent-child retrieval searches small pieces but returns their
 846larger parent for context.
 847
 848**Your RAG system gives confident wrong answers. How do you tell whether
 849retrieval is the cause?**
 850Build a small labeled set of real questions with known answer passages and
 851measure recall@k of retrieval alone. If the right passage isn't in the top k,
 852no prompt change will fix the answer; fix retrieval (hybrid, reranking,
 853chunking, prefixes, domain fine-tuning). If it is there, the problem is in
 854generation.
 855
 856## The papers behind this lesson
 857
 858- **Robertson & Zaragoza, *The Probabilistic Relevance Framework: BM25 and
 859  Beyond* (Foundations and Trends in Information Retrieval, 2009).**
 860  https://www.nowpublishers.com/article/Details/INR-019. The authoritative
 861  account of where BM25 comes from: the probabilistic model behind IDF, and
 862  why term-frequency saturation and length normalization take the form they
 863  do.
 864- **Cormack, Clarke & Büttcher, *Reciprocal Rank Fusion outperforms Condorcet
 865  and individual Rank Learning Methods* (SIGIR 2009).**
 866  https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf. Introduced RRF,
 867  Σ 1/(k + rank) with k = 60, and showed that this simple, tuning-free fusion
 868  beats more elaborate methods.
 869- **Reimers & Gurevych, *Sentence-BERT* (2019).**
 870  https://arxiv.org/abs/1908.10084. Made bi-encoders practical: a siamese
 871  BERT that produces one comparable vector per sentence, turning hours of
 872  pairwise cross-encoding into milliseconds of vector search.
 873  [annotated companion](../../../papers/sentence-bert.html)
 874- **Karpukhin et al., *Dense Passage Retrieval for Open-Domain Question
 875  Answering* (2020).** https://arxiv.org/abs/2004.04906. Showed a dual
 876  encoder trained with in-batch and BM25-mined hard negatives can beat BM25
 877  on open-domain question answering, the template for modern dense
 878  retrievers. [annotated companion](../../../papers/dpr.html)
 879- **Khattab & Zaharia, *ColBERT: Efficient and Effective Passage Search via
 880  Contextualized Late Interaction over BERT* (2020).**
 881  https://arxiv.org/abs/2004.12832. Introduced late interaction: per-token
 882  vectors scored with MaxSim, getting near cross-encoder quality with
 883  precomputed documents. [annotated companion](../../../papers/colbert.html)
 884
 885## Further reading
 886- Nogueira & Cho, *Passage Re-ranking with BERT* (2019), the cross-encoder reranker: https://arxiv.org/abs/1901.04085
 887- Santhanam et al., *ColBERTv2* (compressed late interaction): https://arxiv.org/abs/2112.01488
 888- Wang et al., *Text Embeddings by Weakly-Supervised Contrastive Pre-training* (E5, the query/passage prefixes): https://arxiv.org/abs/2212.03533
 889- Anthropic, *Introducing Contextual Retrieval* (contextual chunk prefixes + hybrid + reranking): https://www.anthropic.com/news/contextual-retrieval
 890- sentence-transformers documentation (bi-encoders and cross-encoders): https://www.sbert.net/
 891- Elasticsearch reciprocal rank fusion reference: https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html
 892"""
 893
 894from __future__ import annotations
 895
 896import math
 897import re
 898from collections import Counter
 899from dataclasses import dataclass, field
 900from typing import Callable, Sequence
 901
 902import numpy as np
 903
 904from primer._show import banner, say, table, takeaway
 905from primer.common.corpus import DOCS, LABELED_QUERIES, Doc
 906from primer.common.embedder import WORD_TO_CONCEPT, ConceptEmbedder
 907from primer.common.text import tokenize
 908from primer.ml.embeddings.ann import FlatIndex
 909
 910# ---------------------------------------------------------------------------
 911# 1. BM25
 912# ---------------------------------------------------------------------------
 913
 914
 915class BM25:
 916    """Okapi BM25 over pre-tokenized documents.
 917
 918    Args:
 919        docs_tokens: one list of words per document.
 920        k1: term-frequency saturation (credit per word never exceeds k1 + 1).
 921        b: length normalization (0 = ignore length, 1 = fully normalize).
 922    """
 923
 924    def __init__(self, docs_tokens: list[list[str]], k1: float = 1.5, b: float = 0.75):
 925        self.k1, self.b = k1, b
 926        self.docs_tokens = [list(d) for d in docs_tokens]
 927        self.N = len(self.docs_tokens)
 928        self.doc_len = np.array([len(d) for d in self.docs_tokens], dtype=float)
 929        self.avgdl = float(self.doc_len.mean()) if self.N else 0.0
 930        # Term frequencies per document: the f(t, d) in the formula.
 931        self.tf = [Counter(d) for d in self.docs_tokens]
 932        # Document frequency n(t): in how many documents does each word appear?
 933        df: Counter[str] = Counter()
 934        for d in self.docs_tokens:
 935            df.update(set(d))
 936        self.df = dict(df)
 937        # The "+1 inside the log" (Lucene's variant) keeps IDF positive even for
 938        # words in more than half the documents; the original could go negative.
 939        self.idf = {t: math.log(1 + (self.N - n + 0.5) / (n + 0.5)) for t, n in self.df.items()}
 940
 941    def scores(self, query_tokens: list[str]) -> np.ndarray:
 942        """BM25 score of every document for the query, shape (N,)."""
 943        out = np.zeros(self.N)
 944        # The length-dependent part of the denominator, one value per document.
 945        norm = self.k1 * (1 - self.b + self.b * self.doc_len / (self.avgdl or 1.0))
 946        for t in query_tokens:
 947            idf = self.idf.get(t)
 948            if idf is None:
 949                continue  # no document contains t: it contributes nothing
 950            f = np.array([tf.get(t, 0) for tf in self.tf], dtype=float)
 951            out += idf * f * (self.k1 + 1) / (f + norm)
 952        return out
 953
 954    def search(self, query_tokens: list[str], k: int = 10) -> list[tuple[int, float]]:
 955        """[(doc index, score)] for the top k documents with a non-zero score."""
 956        s = self.scores(query_tokens)
 957        order = [i for i in np.argsort(-s, kind="stable") if s[i] > 0][:k]
 958        return [(int(i), float(s[i])) for i in order]
 959
 960
 961# ---------------------------------------------------------------------------
 962# 2. Reciprocal rank fusion
 963# ---------------------------------------------------------------------------
 964
 965
 966def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
 967    """Fuse several rankings: each document scores Σ 1/(k + rank). Sorted best first."""
 968    fused: dict[str, float] = {}
 969    for ranking in rankings:
 970        for rank, doc_id in enumerate(ranking, start=1):
 971            fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + rank)
 972    return sorted(fused.items(), key=lambda kv: (-kv[1], kv[0]))
 973
 974
 975# ---------------------------------------------------------------------------
 976# 3. One engine, three ways to search
 977# ---------------------------------------------------------------------------
 978
 979# Paraphrases share no words with their answers (keyword search's blind spot);
 980# exact codes are one rare token among many (dense search's blind spot).
 981PARAPHRASE_QUERIES: list[tuple[str, set[str]]] = [
 982    ("automobile reimbursement", {"fin-001"}),
 983    ("notebook computer replacement", {"it-008"}),
 984    ("holiday allowance", {"hr-001"}),
 985    ("scam message in my inbox", {"it-007"}),
 986    ("refund for the hotel", {"fin-002"}),
 987]
 988EXACT_CODE_QUERIES: list[tuple[str, set[str]]] = [
 989    ("ERR-4012", {"it-004"}),
 990    ("fix ERR-4013", {"it-005"}),
 991    ("ERR-4013", {"it-005"}),
 992]
 993EVAL_QUERIES: list[tuple[str, set[str]]] = LABELED_QUERIES + PARAPHRASE_QUERIES + EXACT_CODE_QUERIES
 994
 995
 996def doc_text(doc: Doc) -> str:
 997    """What gets indexed: the title matters, so it's searched along with the body."""
 998    return f"{doc.title}. {doc.text}"
 999
1000
1001class SearchEngine:
1002    """Keyword, dense and hybrid search over the same documents.
1003
1004    Every method takes a question and returns a ranked list of doc ids, so
1005    the methods can be compared, evaluated and fused on equal terms.
1006    """
1007
1008    def __init__(
1009        self,
1010        docs: Sequence[Doc] = DOCS,
1011        embedder: ConceptEmbedder | None = None,
1012        depth: int = 10,
1013        passage_prefix: str = "",
1014        query_prefix: str = "",
1015    ):
1016        self.docs = list(docs)
1017        self.ids = [d.id for d in self.docs]
1018        self.depth = depth  # how deep each list goes before fusion
1019        # Some embedding models expect "passage: " on documents and "query: " on
1020        # questions. Forgetting either is a silent bug; see PrefixedEmbedder.
1021        self.passage_prefix, self.query_prefix = passage_prefix, query_prefix
1022        self.keyword = BM25([tokenize(doc_text(d)) for d in self.docs])
1023        self.embedder = embedder or ConceptEmbedder()
1024        self.vectors = FlatIndex(self.embedder.dim)  # 20 docs: exact search is the right index
1025        self.vectors.add(self.embedder.encode([passage_prefix + doc_text(d) for d in self.docs]))
1026
1027    def bm25(self, query: str, k: int = 10) -> list[str]:
1028        return [self.ids[i] for i, _ in self.keyword.search(tokenize(query), k)]
1029
1030    def dense(self, query: str, k: int = 10) -> list[str]:
1031        ids, _ = self.vectors.search(self.embedder.encode(self.query_prefix + query), k)
1032        return [self.ids[i] for i in ids]
1033
1034    def hybrid(self, query: str, k: int = 10) -> list[str]:
1035        # Fuse ranks, not scores: BM25 scores are unbounded, cosines live in [-1, 1].
1036        fused = reciprocal_rank_fusion([self.bm25(query, self.depth), self.dense(query, self.depth)])
1037        return [doc_id for doc_id, _ in fused[:k]]
1038
1039
1040def evaluate(search: Callable[[str, int], list[str]], queries: Sequence[tuple[str, set[str]]], k: int = 3) -> dict[str, float]:
1041    """recall@k (share of questions with an answer in the top k) and MRR.
1042
1043    Each question here has one relevant document, so recall@k per question
1044    is 0 or 1. MRR averages 1/rank of the first relevant hit (0 if none in
1045    the top k).
1046    """
1047    hits, rr = 0, 0.0
1048    for query, relevant in queries:
1049        ranking = search(query, k)
1050        first = next((rank for rank, doc_id in enumerate(ranking, 1) if doc_id in relevant), None)
1051        hits += first is not None
1052        rr += 1 / first if first else 0.0
1053    return dict(recall=hits / len(queries), mrr=rr / len(queries))
1054
1055
1056# ---------------------------------------------------------------------------
1057# 4. Reranking with a (toy) cross-encoder
1058# ---------------------------------------------------------------------------
1059
1060
1061def ideas(text: str) -> list[str]:
1062    """The distinct 'ideas' in a text: a synonym group if the word has one, else the word itself.
1063
1064    "password rules" -> ['credential', 'policy']; "ERR-4012" -> ['err-4012'].
1065    """
1066    seen: dict[str, None] = {}  # a dict keeps first-seen order, unlike a set
1067    for tok in tokenize(text):
1068        seen.setdefault(WORD_TO_CONCEPT.get(tok, tok), None)
1069    return list(seen)
1070
1071
1072class CrossEncoder:
1073    """A toy cross-encoder: it reads the question and one document *together*.
1074
1075    A real cross-encoder is a transformer that takes "[question] [SEP]
1076    [document]" as one input, so attention can compare every question word
1077    with every document word, and it outputs a single relevance score. Ours
1078    imitates that with features that can only be computed when both texts
1079    are in hand:
1080
1081    * coverage: the share of the question's ideas that the document contains
1082      (does it answer *all* of the question, or just share its topic?)
1083    * phrase: the share of adjacent question-idea pairs that also sit next to
1084      each other in the document (word order, which a pooled vector loses)
1085    * exact: the share of question words that appear verbatim (synonym groups
1086      are coarse; the literal word "vacation" is stronger evidence than any
1087      time-off word)
1088    * cosine: the bi-encoder similarity, as a weak tie-breaker
1089
1090    What it cannot do, like the real thing, is precompute anything per
1091    document: every (question, document) pair costs one full scoring pass,
1092    which `pairs_scored` counts.
1093    """
1094
1095    def __init__(self, embedder: ConceptEmbedder | None = None):
1096        self.embedder = embedder or ConceptEmbedder()
1097        self.pairs_scored = 0
1098
1099    def coverage(self, query: str, doc: Doc) -> float:
1100        q, d = ideas(query), set(ideas(doc_text(doc)))
1101        return sum(i in d for i in q) / len(q) if q else 0.0
1102
1103    def phrase(self, query: str, doc: Doc) -> float:
1104        q = ideas(query)
1105        pairs = list(zip(q, q[1:]))
1106        if not pairs:
1107            return 0.0
1108        d = ideas(doc_text(doc))
1109        doc_pairs = set(zip(d, d[1:]))
1110        return sum(p in doc_pairs for p in pairs) / len(pairs)
1111
1112    def exact(self, query: str, doc: Doc) -> float:
1113        q, d = tokenize(query), set(tokenize(doc_text(doc)))
1114        return sum(t in d for t in q) / len(q) if q else 0.0
1115
1116    def score(self, query: str, doc: Doc) -> float:
1117        self.pairs_scored += 1
1118        cosine = float(self.embedder.encode(query) @ self.embedder.encode(doc_text(doc)))
1119        return self.coverage(query, doc) + 0.5 * self.phrase(query, doc) + 0.25 * self.exact(query, doc) + 0.25 * cosine
1120
1121
1122def retrieve_then_rerank(
1123    engine: SearchEngine, query: str, k: int = 5, shortlist: int = 10, judge: CrossEncoder | None = None
1124) -> list[str]:
1125    """Stage 1: cheap, wide hybrid search. Stage 2: expensive, precise reranking of the shortlist."""
1126    judge = judge or CrossEncoder(engine.embedder)
1127    by_id = {d.id: d for d in engine.docs}
1128    candidates = engine.hybrid(query, k=shortlist)
1129    scored = [(judge.score(query, by_id[c]), -rank, c) for rank, c in enumerate(candidates)]
1130    # Ties keep the first-stage order (-rank), so reranking never scrambles equals.
1131    return [c for _, _, c in sorted(scored, reverse=True)[:k]]
1132
1133
1134# ---------------------------------------------------------------------------
1135# 5. Late interaction (ColBERT)
1136# ---------------------------------------------------------------------------
1137
1138
1139def maxsim(query_vecs: np.ndarray, doc_vecs: np.ndarray) -> float:
1140    """ColBERT's late-interaction score: for each query word, its best match in the document, summed.
1141
1142    query_vecs (n_q, d) @ doc_vecs.T (d, n_d) -> (n_q, n_d) word-vs-word
1143    similarities; max over the document axis; sum over the query axis.
1144    """
1145    return float((query_vecs @ doc_vecs.T).max(axis=1).sum())
1146
1147
1148class LateInteraction:
1149    """ColBERT-style retrieval: keep one vector per word instead of one per document.
1150
1151    Documents can still be encoded ahead of time (unlike a cross-encoder),
1152    but nothing is squeezed into a single pooled vector, so each question
1153    word gets to find its own best partner in the document.
1154    """
1155
1156    def __init__(self, embedder: ConceptEmbedder | None = None):
1157        self.embedder = embedder or ConceptEmbedder()
1158
1159    def token_vectors(self, text: str) -> np.ndarray:
1160        toks = tokenize(text)
1161        return self.embedder.encode(toks) if toks else np.zeros((0, self.embedder.dim))
1162
1163    def search(self, query: str, docs: Sequence[Doc], k: int = 10) -> list[str]:
1164        q = self.token_vectors(query)
1165        scores = [(maxsim(q, self.token_vectors(doc_text(d))), d.id) for d in docs]
1166        return [doc_id for _, doc_id in sorted(scores, key=lambda s: -s[0])[:k]]
1167
1168
1169# ---------------------------------------------------------------------------
1170# 6. Chunking
1171# ---------------------------------------------------------------------------
1172
1173# A longer, multi-section document for the chunking lessons. Markdown
1174# headings mark the structure a good chunker should respect.
1175HANDBOOK = """# Remote work handbook
1176
1177## Eligibility
1178Most roles can work remotely up to three days a week. Your manager approves the schedule, and roles that
1179handle physical equipment or visitors may need to be on site more often. Agree your remote days at the
1180start of each quarter.
1181
1182## Home internet stipend
1183Remote staff need a reliable home internet connection. The home internet stipend helps cover the monthly
1184cost of broadband, fibre or cable service, and home internet upgrades needed for video calls are also
1185eligible when your current connection is too slow for daily work. Mobile phone plans and hotspot
1186devices are not eligible. Claim it with the monthly expense report. The stipend is 50 dollars per month.
1187
1188## Equipment
1189Everyone receives a laptop, a headset and one external monitor. A second monitor can be requested for
1190design and engineering roles. Laptop monitors from home are allowed if they connect over USB-C or HDMI.
1191Return all equipment when you leave the company.
1192
1193## Security at home
1194Lock your screen whenever you step away. Use the VPN on any network you do not control, including home
1195Wi-Fi shared with others. Never print confidential documents at home.
1196
1197## Working hours
1198Core hours are 10:00 to 15:00 in your local time zone. Outside core hours, set your status so teammates
1199know when to expect a reply.
1200"""
1201
1202
1203@dataclass(frozen=True)
1204class Chunk:
1205    """A piece of a document, plus the metadata that travels with it into the index.
1206
1207    Metadata is what makes filters possible later: by date, by department,
1208    and above all by who is allowed to see it (see primer.agents.rag).
1209    """
1210
1211    text: str
1212    source: str
1213    section: str
1214    updated: str = ""
1215    acl: frozenset[str] = field(default_factory=lambda: frozenset({"everyone"}))
1216
1217
1218def fixed_size_chunks(text: str, size: int = 50, overlap: int = 10) -> list[str]:
1219    """Split every `size` words, each window overlapping the previous by `overlap` words.
1220
1221    Simple and structure-blind: a heading can land at the end of one window
1222    and the fact it introduces at the start of the next. Overlap softens but
1223    doesn't fix that.
1224    """
1225    words = text.split()
1226    step = size - overlap
1227    # Stop once a window would start inside the previous window's overlap:
1228    # the previous window already reached the end.
1229    starts = range(0, max(1, len(words) - overlap), step)
1230    return [" ".join(words[s : s + size]) for s in starts]
1231
1232
1233def _sections(text: str) -> list[tuple[str, str]]:
1234    """[(heading, body)] for each '## ' section of a markdown document."""
1235    parts = re.split(r"^## +(.+)$", text, flags=re.M)
1236    # re.split with a capture group returns [preamble, heading1, body1, heading2, body2, ...]
1237    return [(parts[i].strip(), parts[i + 1].strip()) for i in range(1, len(parts), 2)]
1238
1239
1240def structure_aware_chunks(
1241    text: str, source: str, max_words: int = 120, updated: str = "", acl: frozenset[str] = frozenset({"everyone"})
1242) -> list[Chunk]:
1243    """Split on the document's own structure: sections, then paragraphs, never across a heading.
1244
1245    Each chunk starts with its section heading, so a chunk that says "The
1246    stipend is 50 dollars per month" still says *which* stipend. (Prefixing
1247    extra context like this is the idea behind contextual retrieval.)
1248    """
1249    chunks = []
1250    for heading, body in _sections(text):
1251        paragraphs = [p.replace("\n", " ") for p in body.split("\n\n") if p.strip()]
1252        current: list[str] = []
1253        for p in paragraphs:
1254            if current and len(" ".join(current + [p]).split()) > max_words:
1255                chunks.append(Chunk(f"{heading}: " + " ".join(current), source, heading, updated, acl))
1256                current = []
1257            current.append(p)
1258        if current:
1259            chunks.append(Chunk(f"{heading}: " + " ".join(current), source, heading, updated, acl))
1260    return chunks
1261
1262
1263def best_chunk(query: str, chunks: Sequence[str]) -> str:
1264    """The single chunk BM25 ranks highest for the question."""
1265    index = BM25([tokenize(c) for c in chunks])
1266    return chunks[index.search(tokenize(query), k=1)[0][0]]
1267
1268
1269def parent_child_search(query: str, text: str) -> str:
1270    """Search small children (single sentences), return their parent (the whole section).
1271
1272    Small chunks match precisely; big chunks give the model enough context
1273    to answer. Parent-child retrieval gets both.
1274    """
1275    children: list[tuple[str, str]] = []  # (sentence, parent section text)
1276    for heading, body in _sections(text):
1277        parent = f"## {heading}\n{body}"
1278        for sentence in re.split(r"(?<=\.)\s+", body.replace("\n", " ")):
1279            children.append((sentence, parent))
1280    return dict(children)[best_chunk(query, [s for s, _ in children])]
1281
1282
1283# Questions about the handbook, each with the one sentence that answers it.
1284# Chosen so the interactive widget shows both failures: a boundary that cuts
1285# the answer sentence in two, and a first-stage ranking the reranker corrects.
1286CHUNKING_QUESTIONS: list[tuple[str, str]] = [
1287    ("What is the stipend amount in dollars?", "The stipend is 50 dollars per month."),
1288    ("Do I need the VPN on shared Wi-Fi?", "Use the VPN on any network you do not control, including home Wi-Fi shared with others."),
1289    ("What time are core hours?", "Core hours are 10:00 to 15:00 in your local time zone."),
1290    ("Can I get a second monitor?", "A second monitor can be requested for design and engineering roles."),
1291]
1292
1293
1294def chunk_engine(chunks: Sequence[str]) -> SearchEngine:
1295    """A search engine over one document's chunks, deep enough that every chunk gets a rank.
1296
1297    Each chunk becomes a titleless `Doc` with id "chunk-<position>", so the
1298    whole retrieve-then-rerank pipeline runs on chunks exactly as it runs on
1299    articles.
1300    """
1301    docs = [Doc(f"chunk-{i}", "", c, "", "") for i, c in enumerate(chunks)]
1302    return SearchEngine(docs, depth=len(docs))
1303
1304
1305def viz_data() -> dict:
1306    """The numbers the site's chunking, retrieval and reranking widget shows.
1307
1308    The widget cuts the chunks itself (its chunker mirrors
1309    `fixed_size_chunks`), but it can't embed text, so for every chunk size,
1310    overlap and question the slider can reach, this precomputes the hybrid
1311    ranking with its fused scores and the cross-encoder's score for every
1312    chunk. The widget reranks any top k from those.
1313    """
1314    sizes, overlaps = [20, 30, 40, 60], [0, 5, 10]
1315    judge = CrossEncoder()
1316    runs = {}
1317    for size in sizes:
1318        for overlap in overlaps:
1319            engine = chunk_engine(fixed_size_chunks(HANDBOOK, size, overlap))
1320            position = {doc_id: i for i, doc_id in enumerate(engine.ids)}
1321            runs[f"{size}/{overlap}"] = [
1322                {
1323                    # Exactly what engine.hybrid fuses, kept with its scores so the widget can show them.
1324                    "retrieval": [[position[d], round(s, 5)] for d, s in reciprocal_rank_fusion(
1325                        [engine.bm25(q, engine.depth), engine.dense(q, engine.depth)])],
1326                    "rerank": [round(judge.score(q, d), 4) for d in engine.docs],
1327                }
1328                for q, _ in CHUNKING_QUESTIONS
1329            ]
1330    return {
1331        "rag-pipeline": {
1332            "text": HANDBOOK,
1333            "sizes": sizes,
1334            "overlaps": overlaps,
1335            "max_k": 8,
1336            "questions": [{"question": q, "answer": a} for q, a in CHUNKING_QUESTIONS],
1337            "runs": runs,
1338        }
1339    }
1340
1341
1342# ---------------------------------------------------------------------------
1343# 7. Asymmetric retrieval: query and passage prefixes
1344# ---------------------------------------------------------------------------
1345
1346
1347class PrefixedEmbedder(ConceptEmbedder):
1348    """Simulates an embedding model trained to see "query: " or "passage: " before every input.
1349
1350    Models such as E5 are trained that way so one network can encode short
1351    questions and long passages differently. Give it text without the prefix
1352    and it still returns a perfectly normal-looking unit vector, but from a
1353    region of space it never learned to align. We simulate that drift by
1354    partly rotating un-prefixed vectors with a fixed random rotation R:
1355
1356        v' = cos(θ)·v + sin(θ)·R·v, then rescaled to length 1
1357
1358    With θ = 60°, half the signal survives, yet nothing errors.
1359    """
1360
1361    PREFIXES = ("query: ", "passage: ")
1362
1363    def __init__(self, drift_degrees: float = 60.0, **kwargs):
1364        super().__init__(**kwargs)
1365        theta = np.radians(drift_degrees)
1366        self._cos, self._sin = np.cos(theta), np.sin(theta)
1367        # A random orthogonal matrix (a rotation or reflection) from the QR decomposition of noise.
1368        self._R, _ = np.linalg.qr(np.random.default_rng(7).standard_normal((self.dim, self.dim)))
1369
1370    def encode_one(self, text: str) -> np.ndarray:
1371        for prefix in self.PREFIXES:
1372            if text.startswith(prefix):
1373                return super().encode_one(text[len(prefix) :])
1374        v = super().encode_one(text)
1375        drifted = self._cos * v + self._sin * (self._R @ v)
1376        return drifted / np.linalg.norm(drifted)
1377
1378
1379# ---------------------------------------------------------------------------
1380# 8. Figures
1381# ---------------------------------------------------------------------------
1382
1383
1384def _answer_rank(ranking: list[str], relevant: set[str]) -> int | None:
1385    return next((r for r, d in enumerate(ranking, 1) if d in relevant), None)
1386
1387
1388def figures() -> dict:
1389    """Plot this lesson's data. matplotlib is imported here, and only here,
1390    so the lesson itself needs nothing beyond NumPy."""
1391    import matplotlib
1392
1393    matplotlib.use("Agg")
1394    import matplotlib.pyplot as plt
1395    from matplotlib.patches import Rectangle
1396
1397    BM25_C, DENSE_C, HYB_C, MUTED, HOT, GOOD = "#d97706", "#2563eb", "#7c3aed", "#9ca3af", "#dc2626", "#059669"
1398    figs = {}
1399
1400    # --- 1. BM25: saturation and length normalization ----------------------
1401    fig, (a1, a2) = plt.subplots(1, 2, figsize=(10, 3.8))
1402    f = np.arange(0, 21)
1403    shown = f <= 6  # the raw count keeps climbing; draw it up to the chart's top, not through the title
1404    a1.plot(f[shown], f[shown], "--", color=MUTED, label="raw count")
1405    for k1, shade in [(0.5, 0.45), (1.2, 0.7), (1.5, 1.0), (3.0, 0.3)]:
1406        # With b = 0, BM25's per-word credit is f·(k1+1)/(f+k1): plot it straight from the class.
1407        index = BM25([["w"] * int(n) for n in f[1:]] + [["x"]], k1=k1, b=0.0)
1408        credit = [0.0] + [index.scores(["w"])[i] / index.idf["w"] for i in range(len(f) - 1)]
1409        a1.plot(f, credit, color=BM25_C, alpha=shade, lw=2, label=f"k₁ = {k1} (ceiling {k1 + 1})")
1410    a1.set_ylim(0, 6)
1411    a1.set_xlabel("mentions of the word in the document, f(t, d)")
1412    a1.set_ylabel("credit (before the rarity weight)")
1413    a1.set_title("Saturation: the 10th mention adds little")
1414    a1.legend(frameon=False, fontsize=8)
1415    lengths = np.linspace(0.25, 3, 60)  # |d| / avgdl
1416    for b, shade in [(0.0, 0.35), (0.5, 0.65), (0.75, 1.0), (1.0, 0.5)]:
1417        credit = 1 * 2.5 / (1 + 1.5 * (1 - b + b * lengths))  # one mention, k1 = 1.5
1418        a2.plot(lengths, credit, color=BM25_C, alpha=shade, lw=2, label=f"b = {b}")
1419    a2.axvline(1, color=MUTED, ls=":")
1420    a2.text(1.03, 1.55, "average length", color=MUTED, fontsize=8)
1421    a2.set_xlabel("document length ÷ average length, |d| / avgdl")
1422    a2.set_ylabel("credit for one mention")
1423    a2.set_title("Length: long documents are discounted")
1424    a2.legend(frameon=False, fontsize=8)
1425    fig.tight_layout()
1426    figs["bm25_curves"] = fig
1427
1428    engine = SearchEngine(DOCS)
1429
1430    # --- 2. RRF on the error-code question ----------------------------------
1431    q = "what does ERR-4012 mean"
1432    b_rank, d_rank = engine.bm25(q, 10), engine.dense(q, 10)
1433    fused = reciprocal_rank_fusion([b_rank, d_rank])[:6]
1434    names = [d for d, _ in fused]
1435    from_b = [1 / (60 + b_rank.index(d) + 1) if d in b_rank else 0 for d in names]
1436    from_d = [1 / (60 + d_rank.index(d) + 1) if d in d_rank else 0 for d in names]
1437    fig, ax = plt.subplots(figsize=(6.6, 3.6))
1438    x = np.arange(len(names))
1439    ax.bar(x, from_b, color=BM25_C, label="from BM25: 1/(60 + rank)")
1440    ax.bar(x, from_d, bottom=from_b, color=DENSE_C, label="from dense: 1/(60 + rank)")
1441    for xi, d in zip(x, names):
1442        tags = [f"B{b_rank.index(d) + 1}" if d in b_rank else "", f"D{d_rank.index(d) + 1}" if d in d_rank else ""]
1443        ax.text(xi, from_b[xi] + from_d[xi] + 0.0006, " ".join(t for t in tags if t), ha="center", fontsize=8)
1444    ax.set_ylim(0, max(a + b for a, b in zip(from_b, from_d)) * 1.2)  # room for the rank labels
1445    ax.set_xticks(x, names)
1446    ax.set_ylabel("fused RRF score")
1447    ax.set_title(f'RRF for "{q}" (B = BM25 rank, D = dense rank)')
1448    ax.legend(frameon=False, fontsize=8)
1449    figs["rrf_fusion"] = fig
1450
1451    # --- 3. Where each method ranks the answer ------------------------------
1452    methods = {
1453        "BM25": engine.bm25,
1454        "dense": engine.dense,
1455        "hybrid": engine.hybrid,
1456        "hybrid + rerank": lambda qq, k: retrieve_then_rerank(engine, qq, k),
1457    }
1458    grid = np.array(
1459        [[_answer_rank(m(qq, 10), rel) or 11 for m in methods.values()] for qq, rel in EVAL_QUERIES], dtype=float
1460    )
1461    fig, ax = plt.subplots(figsize=(7.2, 8))
1462    ax.imshow(np.minimum(grid, 11), cmap="RdYlGn_r", vmin=1, vmax=11, aspect="auto")
1463    for i in range(grid.shape[0]):
1464        for j in range(grid.shape[1]):
1465            ax.text(j, i, "✗" if grid[i, j] > 10 else int(grid[i, j]), ha="center", va="center", fontsize=9)
1466    ax.set_xticks(range(len(methods)), list(methods))
1467    ax.xaxis.tick_top()
1468    ax.set_yticks(range(len(EVAL_QUERIES)), [qq for qq, _ in EVAL_QUERIES], fontsize=8)
1469    n1, n2 = len(LABELED_QUERIES), len(LABELED_QUERIES) + len(PARAPHRASE_QUERIES)
1470    for y in (n1 - 0.5, n2 - 0.5):
1471        ax.axhline(y, color="black", lw=1.5)
1472    ax.text(3.6, (n1 + n2) / 2 - 0.5, "paraphrases", rotation=-90, va="center", fontsize=8)
1473    ax.text(3.6, (n2 + len(EVAL_QUERIES)) / 2 - 0.5, "exact codes", rotation=-90, va="center", fontsize=8)
1474    ax.set_title("Rank of the right answer (1 = top, ✗ = not in top 10)", pad=28)
1475    fig.tight_layout()
1476    figs["method_ranks"] = fig
1477
1478    # --- 4. MaxSim grid ------------------------------------------------------
1479    li = LateInteraction()
1480    qtoks = tokenize("what are the password rules")
1481    policy = next(d for d in DOCS if d.id == "it-002")
1482    dtoks = tokenize(doc_text(policy))[:14]
1483    sims = li.token_vectors(" ".join(qtoks)) @ li.embedder.encode(dtoks).T
1484    fig, ax = plt.subplots(figsize=(8.4, 2.6))
1485    im = ax.imshow(sims, cmap="Blues", vmin=-0.2, vmax=1)
1486    for i, j in enumerate(sims.argmax(1)):
1487        ax.add_patch(Rectangle((j - 0.5, i - 0.5), 1, 1, fill=False, edgecolor=HOT, lw=2.5))
1488        ax.text(j, i, f"{sims[i, j]:.2f}", ha="center", va="center", fontsize=8, color="white")
1489    ax.set_xticks(range(len(dtoks)), dtoks, rotation=45, ha="right", fontsize=8)
1490    ax.set_yticks(range(len(qtoks)), qtoks)
1491    ax.set_title(f"MaxSim: each query word keeps its best match (score = {sims.max(1).sum():.2f})")
1492    fig.colorbar(im, ax=ax, fraction=0.02, label="similarity")
1493    figs["maxsim_grid"] = fig
1494
1495    # --- 5. Chunk boundaries on the handbook --------------------------------
1496    words = HANDBOOK.split()
1497    section_starts = [i for i, w in enumerate(words) if w == "##"]
1498    headings = [h for h, _ in _sections(HANDBOOK)]
1499    bounds = section_starts + [len(words)]
1500    heading_at = next(i for i in range(len(words)) if words[i : i + 3] == ["Home", "internet", "stipend"])
1501    amount_at = next(i for i in range(len(words)) if words[i : i + 2] == ["50", "dollars"])
1502    fig, ax = plt.subplots(figsize=(10, 3))
1503    cmap = plt.get_cmap("Pastel1")
1504    for n, (s, e, heading) in enumerate(zip(bounds, bounds[1:], headings)):
1505        ax.add_patch(Rectangle((s, -0.3), e - s, 0.6, color=cmap(n)))
1506        ax.text((s + e) / 2, 0, heading, ha="center", va="center", fontsize=7)
1507    size, overlap = 40, 5
1508    for n, s in enumerate(range(0, max(1, len(words) - overlap), size - overlap)):
1509        y = 0.55 + 0.18 * (n % 2)  # alternate heights so overlapping windows stay visible
1510        ax.plot([s, min(s + size, len(words))], [y, y], color=MUTED, lw=3)
1511    for n, (s, e) in enumerate(zip(bounds, bounds[1:])):
1512        y = -0.55 - 0.18 * (n % 2)
1513        ax.plot([s, e], [y, y], color=HYB_C, lw=3)
1514    ax.axvline(heading_at, color=HOT, lw=2)
1515    ax.axvline(amount_at, color=GOOD, lw=2)
1516    # Each label sits on its own line; an opaque box above the line keeps the words whole.
1517    on_line = dict(ha="center", fontsize=8, zorder=3, bbox=dict(facecolor="white", edgecolor="none", pad=1))
1518    ax.text(heading_at, 1.02, "heading", color=HOT, **on_line)
1519    ax.text(amount_at, 1.02, "50 dollars", color=GOOD, **on_line)
1520    ax.text(-2, 0.63, "fixed\n40-word\nwindows", ha="right", va="center", fontsize=8, color="#4b5563")
1521    ax.text(-2, -0.63, "structure-\naware\nchunks", ha="right", va="center", fontsize=8, color=HYB_C)
1522    ax.set_xlim(-30, len(words) + 2)
1523    ax.set_ylim(-1.0, 1.15)
1524    ax.set_yticks([])
1525    ax.set_xlabel("word position in the handbook")
1526    ax.set_title("Where the chunkers cut: the stipend heading and its amount")
1527    for side in ("left", "right", "top"):
1528        ax.spines[side].set_visible(False)
1529    figs["chunk_boundaries"] = fig
1530
1531    return figs
1532
1533
1534# ---------------------------------------------------------------------------
1535# 9. Narrated walkthrough
1536# ---------------------------------------------------------------------------
1537
1538
1539def demo() -> None:
1540    banner("1. BM25 by hand: three tiny documents, query 'cat'")
1541    toy = [["cat", "cat", "dog"], ["dog", "bird"], ["fish"]]
1542    index = BM25(toy)
1543    table(
1544        ["doc", "words", "BM25('cat')"],
1545        [(f"d{i + 1}", " ".join(d), s) for i, (d, s) in enumerate(zip(toy, index.scores(["cat"])))],
1546        floatfmt=".3f",
1547    )
1548    say(f"IDF(cat) = ln(1 + 2.5/1.5) = {index.idf['cat']:.3f}; IDF(dog) = {index.idf['dog']:.3f}: rarer words weigh more.")
1549
1550    engine = SearchEngine(DOCS)
1551    banner("2. The two librarians disagree")
1552    for q in ("what does ERR-4012 mean", "automobile reimbursement"):
1553        print(f"{q!r}")
1554        print(f"   BM25   : {engine.bm25(q, 3) or '(nothing: no shared words)'}")
1555        print(f"   dense  : {engine.dense(q, 3)}")
1556        print(f"   hybrid : {engine.hybrid(q, 3)}")
1557    print()
1558
1559    banner("3. Reciprocal rank fusion, worked example (k = 60)")
1560    fused = dict(reciprocal_rank_fusion([["X", "a", "b"], ["c", "d", "X"], ["Y"]]))
1561    say(f"X at ranks 1 and 3: 1/61 + 1/63 = {fused['X']:.4f}. Y at rank 1 only: 1/61 = {fused['Y']:.4f}.")
1562
1563    banner("4. Measured on 20 labeled questions")
1564    rows = []
1565    for name, fn in [
1566        ("BM25", engine.bm25),
1567        ("dense", engine.dense),
1568        ("hybrid (RRF)", engine.hybrid),
1569        ("hybrid + rerank", lambda q, k: retrieve_then_rerank(engine, q, k)),
1570        ("late interaction", lambda q, k: LateInteraction().search(q, DOCS, k)),
1571    ]:
1572        r = evaluate(fn, EVAL_QUERIES, k=3)
1573        rows.append((name, r["recall"], r["mrr"]))
1574    table(["method", "recall@3", "MRR"], rows, floatfmt=".3f")
1575    takeaway("BM25 and dense search fail on different questions; fusing them with RRF fixes both.")
1576
1577    banner("5. A hard negative, fixed by reranking")
1578    q = "what are the password rules"
1579    say(f"{q!r}: hybrid ranks {engine.hybrid(q, 3)}, but the answer is it-002 (the policy).")
1580    judge = CrossEncoder()
1581    by_id = {d.id: d for d in DOCS}
1582    table(
1583        ["doc", "coverage", "phrase", "exact", "score"],
1584        [
1585            (d, judge.coverage(q, by_id[d]), judge.phrase(q, by_id[d]), judge.exact(q, by_id[d]), judge.score(q, by_id[d]))
1586            for d in ("it-002", "it-001")
1587        ],
1588        floatfmt=".2f",
1589    )
1590    say(f"After reranking: {retrieve_then_rerank(engine, q, 3)}.")
1591
1592    banner("6. Chunking the remote-work handbook")
1593    fixed = best_chunk("home internet stipend", fixed_size_chunks(HANDBOOK, size=40, overlap=5))
1594    smart = best_chunk("home internet stipend", [c.text for c in structure_aware_chunks(HANDBOOK, "remote-work-handbook")])
1595    say(f"Best fixed-size chunk: ...{fixed[-110:]}  (amount present: {'50 dollars' in fixed})")
1596    say(f"Best structure-aware chunk: ...{smart[-110:]}  (amount present: {'50 dollars' in smart})")
1597
1598    banner("7. The forgotten prefix: a silent bug")
1599    for label, pp in [("both prefixes", "passage: "), ("passage prefix forgotten", "")]:
1600        e = SearchEngine(DOCS, embedder=PrefixedEmbedder(), passage_prefix=pp, query_prefix="query: ")
1601        r = evaluate(e.dense, LABELED_QUERIES, k=3)
1602        print(f"{label:26s} recall@3 {r['recall']:.3f}   MRR {r['mrr']:.3f}")
1603    print()
1604    takeaway("Nothing errored. Only measuring recall on labeled questions reveals the bug.")
1605
1606
1607if __name__ == "__main__":
1608    demo()
Level 3: the code, function by function.
class BM25: on GitHub
916class BM25:
917    """Okapi BM25 over pre-tokenized documents.
918
919    Args:
920        docs_tokens: one list of words per document.
921        k1: term-frequency saturation (credit per word never exceeds k1 + 1).
922        b: length normalization (0 = ignore length, 1 = fully normalize).
923    """
924
925    def __init__(self, docs_tokens: list[list[str]], k1: float = 1.5, b: float = 0.75):
926        self.k1, self.b = k1, b
927        self.docs_tokens = [list(d) for d in docs_tokens]
928        self.N = len(self.docs_tokens)
929        self.doc_len = np.array([len(d) for d in self.docs_tokens], dtype=float)
930        self.avgdl = float(self.doc_len.mean()) if self.N else 0.0
931        # Term frequencies per document: the f(t, d) in the formula.
932        self.tf = [Counter(d) for d in self.docs_tokens]
933        # Document frequency n(t): in how many documents does each word appear?
934        df: Counter[str] = Counter()
935        for d in self.docs_tokens:
936            df.update(set(d))
937        self.df = dict(df)
938        # The "+1 inside the log" (Lucene's variant) keeps IDF positive even for
939        # words in more than half the documents; the original could go negative.
940        self.idf = {t: math.log(1 + (self.N - n + 0.5) / (n + 0.5)) for t, n in self.df.items()}
941
942    def scores(self, query_tokens: list[str]) -> np.ndarray:
943        """BM25 score of every document for the query, shape (N,)."""
944        out = np.zeros(self.N)
945        # The length-dependent part of the denominator, one value per document.
946        norm = self.k1 * (1 - self.b + self.b * self.doc_len / (self.avgdl or 1.0))
947        for t in query_tokens:
948            idf = self.idf.get(t)
949            if idf is None:
950                continue  # no document contains t: it contributes nothing
951            f = np.array([tf.get(t, 0) for tf in self.tf], dtype=float)
952            out += idf * f * (self.k1 + 1) / (f + norm)
953        return out
954
955    def search(self, query_tokens: list[str], k: int = 10) -> list[tuple[int, float]]:
956        """[(doc index, score)] for the top k documents with a non-zero score."""
957        s = self.scores(query_tokens)
958        order = [i for i in np.argsort(-s, kind="stable") if s[i] > 0][:k]
959        return [(int(i), float(s[i])) for i in order]

Okapi BM25 over pre-tokenized documents.

Arguments:

  • docs_tokens: one list of words per document.
  • k1: term-frequency saturation (credit per word never exceeds k1 + 1).
  • b: length normalization (0 = ignore length, 1 = fully normalize).
BM25(docs_tokens: list[list[str]], k1: float = 1.5, b: float = 0.75) on GitHub
925    def __init__(self, docs_tokens: list[list[str]], k1: float = 1.5, b: float = 0.75):
926        self.k1, self.b = k1, b
927        self.docs_tokens = [list(d) for d in docs_tokens]
928        self.N = len(self.docs_tokens)
929        self.doc_len = np.array([len(d) for d in self.docs_tokens], dtype=float)
930        self.avgdl = float(self.doc_len.mean()) if self.N else 0.0
931        # Term frequencies per document: the f(t, d) in the formula.
932        self.tf = [Counter(d) for d in self.docs_tokens]
933        # Document frequency n(t): in how many documents does each word appear?
934        df: Counter[str] = Counter()
935        for d in self.docs_tokens:
936            df.update(set(d))
937        self.df = dict(df)
938        # The "+1 inside the log" (Lucene's variant) keeps IDF positive even for
939        # words in more than half the documents; the original could go negative.
940        self.idf = {t: math.log(1 + (self.N - n + 0.5) / (n + 0.5)) for t, n in self.df.items()}
N
avgdl
tf
df
idf
def scores(self, query_tokens: list[str]) -> numpy.ndarray: on GitHub
942    def scores(self, query_tokens: list[str]) -> np.ndarray:
943        """BM25 score of every document for the query, shape (N,)."""
944        out = np.zeros(self.N)
945        # The length-dependent part of the denominator, one value per document.
946        norm = self.k1 * (1 - self.b + self.b * self.doc_len / (self.avgdl or 1.0))
947        for t in query_tokens:
948            idf = self.idf.get(t)
949            if idf is None:
950                continue  # no document contains t: it contributes nothing
951            f = np.array([tf.get(t, 0) for tf in self.tf], dtype=float)
952            out += idf * f * (self.k1 + 1) / (f + norm)
953        return out

BM25 score of every document for the query, shape (N,).

def search(self, query_tokens: list[str], k: int = 10) -> list[tuple[int, float]]: on GitHub
955    def search(self, query_tokens: list[str], k: int = 10) -> list[tuple[int, float]]:
956        """[(doc index, score)] for the top k documents with a non-zero score."""
957        s = self.scores(query_tokens)
958        order = [i for i in np.argsort(-s, kind="stable") if s[i] > 0][:k]
959        return [(int(i), float(s[i])) for i in order]

[(doc index, score)] for the top k documents with a non-zero score.

def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]: on GitHub
967def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
968    """Fuse several rankings: each document scores Σ 1/(k + rank). Sorted best first."""
969    fused: dict[str, float] = {}
970    for ranking in rankings:
971        for rank, doc_id in enumerate(ranking, start=1):
972            fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + rank)
973    return sorted(fused.items(), key=lambda kv: (-kv[1], kv[0]))

Fuse several rankings: each document scores Σ 1/(k + rank). Sorted best first.

PARAPHRASE_QUERIES: list[tuple[str, set[str]]] = [('automobile reimbursement', {'fin-001'}), ('notebook computer replacement', {'it-008'}), ('holiday allowance', {'hr-001'}), ('scam message in my inbox', {'it-007'}), ('refund for the hotel', {'fin-002'})]
EXACT_CODE_QUERIES: list[tuple[str, set[str]]] = [('ERR-4012', {'it-004'}), ('fix ERR-4013', {'it-005'}), ('ERR-4013', {'it-005'})]
EVAL_QUERIES: list[tuple[str, set[str]]] = [("I forgot my password and I'm locked out", {'it-001'}), ('how long must a password be', {'it-002'}), ('what does ERR-4012 mean', {'it-004'}), ('ERR-4013 on my laptop', {'it-005'}), ('automobile reimbursement for business driving', {'fin-001'}), ('how many vacation days do I get', {'hr-001'}), ('connect to internal systems from home', {'it-003'}), ('suspicious email with a link', {'it-007'}), ('per-diem for meals when traveling', {'fin-002'}), ('enroll in MFA', {'it-006'}), ('first day checklist for a new hire', {'hr-003'}), ('match vendor bills to payments', {'fin-005'}), ('automobile reimbursement', {'fin-001'}), ('notebook computer replacement', {'it-008'}), ('holiday allowance', {'hr-001'}), ('scam message in my inbox', {'it-007'}), ('refund for the hotel', {'fin-002'}), ('ERR-4012', {'it-004'}), ('fix ERR-4013', {'it-005'}), ('ERR-4013', {'it-005'})]
def doc_text(doc: primer.common.corpus.Doc) -> str: on GitHub
997def doc_text(doc: Doc) -> str:
998    """What gets indexed: the title matters, so it's searched along with the body."""
999    return f"{doc.title}. {doc.text}"

What gets indexed: the title matters, so it's searched along with the body.

class SearchEngine: on GitHub
1002class SearchEngine:
1003    """Keyword, dense and hybrid search over the same documents.
1004
1005    Every method takes a question and returns a ranked list of doc ids, so
1006    the methods can be compared, evaluated and fused on equal terms.
1007    """
1008
1009    def __init__(
1010        self,
1011        docs: Sequence[Doc] = DOCS,
1012        embedder: ConceptEmbedder | None = None,
1013        depth: int = 10,
1014        passage_prefix: str = "",
1015        query_prefix: str = "",
1016    ):
1017        self.docs = list(docs)
1018        self.ids = [d.id for d in self.docs]
1019        self.depth = depth  # how deep each list goes before fusion
1020        # Some embedding models expect "passage: " on documents and "query: " on
1021        # questions. Forgetting either is a silent bug; see PrefixedEmbedder.
1022        self.passage_prefix, self.query_prefix = passage_prefix, query_prefix
1023        self.keyword = BM25([tokenize(doc_text(d)) for d in self.docs])
1024        self.embedder = embedder or ConceptEmbedder()
1025        self.vectors = FlatIndex(self.embedder.dim)  # 20 docs: exact search is the right index
1026        self.vectors.add(self.embedder.encode([passage_prefix + doc_text(d) for d in self.docs]))
1027
1028    def bm25(self, query: str, k: int = 10) -> list[str]:
1029        return [self.ids[i] for i, _ in self.keyword.search(tokenize(query), k)]
1030
1031    def dense(self, query: str, k: int = 10) -> list[str]:
1032        ids, _ = self.vectors.search(self.embedder.encode(self.query_prefix + query), k)
1033        return [self.ids[i] for i in ids]
1034
1035    def hybrid(self, query: str, k: int = 10) -> list[str]:
1036        # Fuse ranks, not scores: BM25 scores are unbounded, cosines live in [-1, 1].
1037        fused = reciprocal_rank_fusion([self.bm25(query, self.depth), self.dense(query, self.depth)])
1038        return [doc_id for doc_id, _ in fused[:k]]

Keyword, dense and hybrid search over the same documents.

Every method takes a question and returns a ranked list of doc ids, so the methods can be compared, evaluated and fused on equal terms.

SearchEngine( docs: Sequence[primer.common.corpus.Doc] = [Doc(id='it-001', title='How to reset your password', text="If you forgot your password or your account is locked, go to the self-service portal, choose 'Forgot password', verify with your authenticator app, and set a new password. The reset link expires after 15 minutes.", department='IT', updated='2026-03-02', acl=frozenset({'everyone'})), Doc(id='it-002', title='Password policy', text='Passwords must be at least 14 characters and include a number and a symbol. Passwords expire every 180 days and the last 10 passwords cannot be reused. This policy applies to all employees and contractors.', department='IT', updated='2025-11-20', acl=frozenset({'everyone'})), Doc(id='it-003', title='Setting up the VPN', text='Install the AnyConnect client from the software center, sign in with your company credentials, and approve the two-factor prompt. Use the VPN for all remote access to internal systems.', department='IT', updated='2026-01-15', acl=frozenset({'everyone'})), Doc(id='it-004', title='Error ERR-4012: VPN tunnel failed', text='ERR-4012 means the VPN tunnel could not be established, usually because the client is out of date. Update AnyConnect to version 5.1 or later and reboot.', department='IT', updated='2026-04-10', acl=frozenset({'everyone'})), Doc(id='it-005', title='Error ERR-4013: certificate expired', text="ERR-4013 means your device certificate expired. Open the software center and run 'Renew device certificate', then reconnect.", department='IT', updated='2026-04-10', acl=frozenset({'everyone'})), Doc(id='it-006', title='Setting up two-factor authentication', text='Download the authenticator app, scan the QR code on the security page, and enter the six digit code to finish enrolling in MFA. Two-factor is required for email and VPN.', department='IT', updated='2025-09-01', acl=frozenset({'everyone'})), Doc(id='it-007', title='Reporting phishing emails', text="If an email looks suspicious, do not click links. Use the 'Report phishing' button in Outlook. Security reviews every report within one business day.", department='IT', updated='2026-02-11', acl=frozenset({'everyone'})), Doc(id='it-008', title='Requesting a new laptop', text="Laptops are refreshed every three years. Submit a hardware request in the IT portal with your manager's approval. Standard devices are MacBook Pro or ThinkPad X1.", department='IT', updated='2025-12-05', acl=frozenset({'everyone'})), Doc(id='it-009', title='Printer troubleshooting', text='If printing fails, check the toner and paper tray, then remove and re-add the printer from settings. Floor printers are named by building and floor.', department='IT', updated='2024-06-30', acl=frozenset({'everyone'})), Doc(id='fin-001', title='Car mileage expense', text='Employees who use a personal vehicle for business driving are reimbursed at the standard mileage rate. Log each trip with date, distance and purpose, and submit the expense within 30 days.', department='Finance', updated='2026-01-05', acl=frozenset({'everyone'})), Doc(id='fin-002', title='Travel policy (2026)', text='Book flights and hotels through the travel portal. Economy class is required for flights under six hours. Meals are covered by a daily per-diem of 75 dollars.', department='Finance', updated='2026-01-01', acl=frozenset({'everyone'})), Doc(id='fin-003', title='Travel policy (2023, superseded)', text='Book flights through the travel agency by phone. Business class is allowed for flights over four hours. Meals are covered by a daily per-diem of 60 dollars.', department='Finance', updated='2023-01-01', acl=frozenset({'everyone'})), Doc(id='fin-004', title='Submitting expense receipts', text='Upload receipts to the expense tool within 30 days. Receipts are required for any expense over 25 dollars. Reimbursements are paid with the next payroll run.', department='Finance', updated='2025-10-12', acl=frozenset({'everyone'})), Doc(id='fin-005', title='Vendor invoice reconciliation', text='At quarter end, match each vendor invoice to its payment record. List every mismatch with the invoice number, amount and vendor, and send the reconciliation to the controller.', department='Finance', updated='2026-03-31', acl=frozenset({'finance'})), Doc(id='fin-006', title='Q3 revenue forecast', text='Q3 revenue is forecast at 41 million dollars, up 12 percent, driven by enterprise renewals.', department='Finance', updated='2026-07-01', acl=frozenset({'exec', 'finance'})), Doc(id='hr-001', title='Paid time off', text='Full-time employees accrue 20 days of PTO per year. Request vacation in the HR portal at least two weeks ahead. Unused PTO up to 5 days rolls over.', department='HR', updated='2026-01-01', acl=frozenset({'everyone'})), Doc(id='hr-002', title='Sick leave', text='Employees receive 10 paid sick days per year. No manager approval is needed for sick leave, but notify your team as early as possible.', department='HR', updated='2025-08-15', acl=frozenset({'everyone'})), Doc(id='hr-003', title='New hire onboarding', text='On your first day, collect your laptop from IT, complete security training, and set up two-factor authentication. Your manager will schedule orientation sessions for week one.', department='HR', updated='2026-02-01', acl=frozenset({'everyone'})), Doc(id='hr-004', title='Salary bands and bonus', text='Salary bands are reviewed every April. The annual bonus target is 10 percent of base pay, paid in March based on company and individual performance.', department='HR', updated='2026-04-01', acl=frozenset({'exec', 'hr'})), Doc(id='hr-005', title='Parental leave', text='Primary caregivers receive 16 weeks of paid parental leave; secondary caregivers receive 6 weeks. Leave can start up to two weeks before the expected birth or adoption date.', department='HR', updated='2025-05-20', acl=frozenset({'everyone'}))], embedder: primer.common.embedder.ConceptEmbedder | None = None, depth: int = 10, passage_prefix: str = '', query_prefix: str = '') on GitHub
1009    def __init__(
1010        self,
1011        docs: Sequence[Doc] = DOCS,
1012        embedder: ConceptEmbedder | None = None,
1013        depth: int = 10,
1014        passage_prefix: str = "",
1015        query_prefix: str = "",
1016    ):
1017        self.docs = list(docs)
1018        self.ids = [d.id for d in self.docs]
1019        self.depth = depth  # how deep each list goes before fusion
1020        # Some embedding models expect "passage: " on documents and "query: " on
1021        # questions. Forgetting either is a silent bug; see PrefixedEmbedder.
1022        self.passage_prefix, self.query_prefix = passage_prefix, query_prefix
1023        self.keyword = BM25([tokenize(doc_text(d)) for d in self.docs])
1024        self.embedder = embedder or ConceptEmbedder()
1025        self.vectors = FlatIndex(self.embedder.dim)  # 20 docs: exact search is the right index
1026        self.vectors.add(self.embedder.encode([passage_prefix + doc_text(d) for d in self.docs]))
docs
ids
depth
keyword
embedder
vectors
def bm25(self, query: str, k: int = 10) -> list[str]: on GitHub
1028    def bm25(self, query: str, k: int = 10) -> list[str]:
1029        return [self.ids[i] for i, _ in self.keyword.search(tokenize(query), k)]
def dense(self, query: str, k: int = 10) -> list[str]: on GitHub
1031    def dense(self, query: str, k: int = 10) -> list[str]:
1032        ids, _ = self.vectors.search(self.embedder.encode(self.query_prefix + query), k)
1033        return [self.ids[i] for i in ids]
def hybrid(self, query: str, k: int = 10) -> list[str]: on GitHub
1035    def hybrid(self, query: str, k: int = 10) -> list[str]:
1036        # Fuse ranks, not scores: BM25 scores are unbounded, cosines live in [-1, 1].
1037        fused = reciprocal_rank_fusion([self.bm25(query, self.depth), self.dense(query, self.depth)])
1038        return [doc_id for doc_id, _ in fused[:k]]
def evaluate( search: Callable[[str, int], list[str]], queries: Sequence[tuple[str, set[str]]], k: int = 3) -> dict[str, float]: on GitHub
1041def evaluate(search: Callable[[str, int], list[str]], queries: Sequence[tuple[str, set[str]]], k: int = 3) -> dict[str, float]:
1042    """recall@k (share of questions with an answer in the top k) and MRR.
1043
1044    Each question here has one relevant document, so recall@k per question
1045    is 0 or 1. MRR averages 1/rank of the first relevant hit (0 if none in
1046    the top k).
1047    """
1048    hits, rr = 0, 0.0
1049    for query, relevant in queries:
1050        ranking = search(query, k)
1051        first = next((rank for rank, doc_id in enumerate(ranking, 1) if doc_id in relevant), None)
1052        hits += first is not None
1053        rr += 1 / first if first else 0.0
1054    return dict(recall=hits / len(queries), mrr=rr / len(queries))

recall@k (share of questions with an answer in the top k) and MRR.

Each question here has one relevant document, so recall@k per question is 0 or 1. MRR averages 1/rank of the first relevant hit (0 if none in the top k).

def ideas(text: str) -> list[str]: on GitHub
1062def ideas(text: str) -> list[str]:
1063    """The distinct 'ideas' in a text: a synonym group if the word has one, else the word itself.
1064
1065    "password rules" -> ['credential', 'policy']; "ERR-4012" -> ['err-4012'].
1066    """
1067    seen: dict[str, None] = {}  # a dict keeps first-seen order, unlike a set
1068    for tok in tokenize(text):
1069        seen.setdefault(WORD_TO_CONCEPT.get(tok, tok), None)
1070    return list(seen)

The distinct 'ideas' in a text: a synonym group if the word has one, else the word itself.

"password rules" -> ['credential', 'policy']; "ERR-4012" -> ['err-4012'].

class CrossEncoder: on GitHub
1073class CrossEncoder:
1074    """A toy cross-encoder: it reads the question and one document *together*.
1075
1076    A real cross-encoder is a transformer that takes "[question] [SEP]
1077    [document]" as one input, so attention can compare every question word
1078    with every document word, and it outputs a single relevance score. Ours
1079    imitates that with features that can only be computed when both texts
1080    are in hand:
1081
1082    * coverage: the share of the question's ideas that the document contains
1083      (does it answer *all* of the question, or just share its topic?)
1084    * phrase: the share of adjacent question-idea pairs that also sit next to
1085      each other in the document (word order, which a pooled vector loses)
1086    * exact: the share of question words that appear verbatim (synonym groups
1087      are coarse; the literal word "vacation" is stronger evidence than any
1088      time-off word)
1089    * cosine: the bi-encoder similarity, as a weak tie-breaker
1090
1091    What it cannot do, like the real thing, is precompute anything per
1092    document: every (question, document) pair costs one full scoring pass,
1093    which `pairs_scored` counts.
1094    """
1095
1096    def __init__(self, embedder: ConceptEmbedder | None = None):
1097        self.embedder = embedder or ConceptEmbedder()
1098        self.pairs_scored = 0
1099
1100    def coverage(self, query: str, doc: Doc) -> float:
1101        q, d = ideas(query), set(ideas(doc_text(doc)))
1102        return sum(i in d for i in q) / len(q) if q else 0.0
1103
1104    def phrase(self, query: str, doc: Doc) -> float:
1105        q = ideas(query)
1106        pairs = list(zip(q, q[1:]))
1107        if not pairs:
1108            return 0.0
1109        d = ideas(doc_text(doc))
1110        doc_pairs = set(zip(d, d[1:]))
1111        return sum(p in doc_pairs for p in pairs) / len(pairs)
1112
1113    def exact(self, query: str, doc: Doc) -> float:
1114        q, d = tokenize(query), set(tokenize(doc_text(doc)))
1115        return sum(t in d for t in q) / len(q) if q else 0.0
1116
1117    def score(self, query: str, doc: Doc) -> float:
1118        self.pairs_scored += 1
1119        cosine = float(self.embedder.encode(query) @ self.embedder.encode(doc_text(doc)))
1120        return self.coverage(query, doc) + 0.5 * self.phrase(query, doc) + 0.25 * self.exact(query, doc) + 0.25 * cosine

A toy cross-encoder: it reads the question and one document together.

A real cross-encoder is a transformer that takes "[question] [SEP] [document]" as one input, so attention can compare every question word with every document word, and it outputs a single relevance score. Ours imitates that with features that can only be computed when both texts are in hand:

  • coverage: the share of the question's ideas that the document contains (does it answer all of the question, or just share its topic?)
  • phrase: the share of adjacent question-idea pairs that also sit next to each other in the document (word order, which a pooled vector loses)
  • exact: the share of question words that appear verbatim (synonym groups are coarse; the literal word "vacation" is stronger evidence than any time-off word)
  • cosine: the bi-encoder similarity, as a weak tie-breaker

What it cannot do, like the real thing, is precompute anything per document: every (question, document) pair costs one full scoring pass, which pairs_scored counts.

CrossEncoder(embedder: primer.common.embedder.ConceptEmbedder | None = None) on GitHub
1096    def __init__(self, embedder: ConceptEmbedder | None = None):
1097        self.embedder = embedder or ConceptEmbedder()
1098        self.pairs_scored = 0
embedder
def coverage(self, query: str, doc: primer.common.corpus.Doc) -> float: on GitHub
1100    def coverage(self, query: str, doc: Doc) -> float:
1101        q, d = ideas(query), set(ideas(doc_text(doc)))
1102        return sum(i in d for i in q) / len(q) if q else 0.0
def phrase(self, query: str, doc: primer.common.corpus.Doc) -> float: on GitHub
1104    def phrase(self, query: str, doc: Doc) -> float:
1105        q = ideas(query)
1106        pairs = list(zip(q, q[1:]))
1107        if not pairs:
1108            return 0.0
1109        d = ideas(doc_text(doc))
1110        doc_pairs = set(zip(d, d[1:]))
1111        return sum(p in doc_pairs for p in pairs) / len(pairs)
def exact(self, query: str, doc: primer.common.corpus.Doc) -> float: on GitHub
1113    def exact(self, query: str, doc: Doc) -> float:
1114        q, d = tokenize(query), set(tokenize(doc_text(doc)))
1115        return sum(t in d for t in q) / len(q) if q else 0.0
def score(self, query: str, doc: primer.common.corpus.Doc) -> float: on GitHub
1117    def score(self, query: str, doc: Doc) -> float:
1118        self.pairs_scored += 1
1119        cosine = float(self.embedder.encode(query) @ self.embedder.encode(doc_text(doc)))
1120        return self.coverage(query, doc) + 0.5 * self.phrase(query, doc) + 0.25 * self.exact(query, doc) + 0.25 * cosine
def retrieve_then_rerank( engine: SearchEngine, query: str, k: int = 5, shortlist: int = 10, judge: CrossEncoder | None = None) -> list[str]: on GitHub
1123def retrieve_then_rerank(
1124    engine: SearchEngine, query: str, k: int = 5, shortlist: int = 10, judge: CrossEncoder | None = None
1125) -> list[str]:
1126    """Stage 1: cheap, wide hybrid search. Stage 2: expensive, precise reranking of the shortlist."""
1127    judge = judge or CrossEncoder(engine.embedder)
1128    by_id = {d.id: d for d in engine.docs}
1129    candidates = engine.hybrid(query, k=shortlist)
1130    scored = [(judge.score(query, by_id[c]), -rank, c) for rank, c in enumerate(candidates)]
1131    # Ties keep the first-stage order (-rank), so reranking never scrambles equals.
1132    return [c for _, _, c in sorted(scored, reverse=True)[:k]]

Stage 1: cheap, wide hybrid search. Stage 2: expensive, precise reranking of the shortlist.

def maxsim(query_vecs: numpy.ndarray, doc_vecs: numpy.ndarray) -> float: on GitHub
1140def maxsim(query_vecs: np.ndarray, doc_vecs: np.ndarray) -> float:
1141    """ColBERT's late-interaction score: for each query word, its best match in the document, summed.
1142
1143    query_vecs (n_q, d) @ doc_vecs.T (d, n_d) -> (n_q, n_d) word-vs-word
1144    similarities; max over the document axis; sum over the query axis.
1145    """
1146    return float((query_vecs @ doc_vecs.T).max(axis=1).sum())

ColBERT's late-interaction score: for each query word, its best match in the document, summed.

query_vecs (n_q, d) @ doc_vecs.T (d, n_d) -> (n_q, n_d) word-vs-word similarities; max over the document axis; sum over the query axis.

class LateInteraction: on GitHub
1149class LateInteraction:
1150    """ColBERT-style retrieval: keep one vector per word instead of one per document.
1151
1152    Documents can still be encoded ahead of time (unlike a cross-encoder),
1153    but nothing is squeezed into a single pooled vector, so each question
1154    word gets to find its own best partner in the document.
1155    """
1156
1157    def __init__(self, embedder: ConceptEmbedder | None = None):
1158        self.embedder = embedder or ConceptEmbedder()
1159
1160    def token_vectors(self, text: str) -> np.ndarray:
1161        toks = tokenize(text)
1162        return self.embedder.encode(toks) if toks else np.zeros((0, self.embedder.dim))
1163
1164    def search(self, query: str, docs: Sequence[Doc], k: int = 10) -> list[str]:
1165        q = self.token_vectors(query)
1166        scores = [(maxsim(q, self.token_vectors(doc_text(d))), d.id) for d in docs]
1167        return [doc_id for _, doc_id in sorted(scores, key=lambda s: -s[0])[:k]]

ColBERT-style retrieval: keep one vector per word instead of one per document.

Documents can still be encoded ahead of time (unlike a cross-encoder), but nothing is squeezed into a single pooled vector, so each question word gets to find its own best partner in the document.

1157    def __init__(self, embedder: ConceptEmbedder | None = None):
1158        self.embedder = embedder or ConceptEmbedder()
embedder
def token_vectors(self, text: str) -> numpy.ndarray: on GitHub
1160    def token_vectors(self, text: str) -> np.ndarray:
1161        toks = tokenize(text)
1162        return self.embedder.encode(toks) if toks else np.zeros((0, self.embedder.dim))
def search( self, query: str, docs: Sequence[primer.common.corpus.Doc], k: int = 10) -> list[str]: on GitHub
1164    def search(self, query: str, docs: Sequence[Doc], k: int = 10) -> list[str]:
1165        q = self.token_vectors(query)
1166        scores = [(maxsim(q, self.token_vectors(doc_text(d))), d.id) for d in docs]
1167        return [doc_id for _, doc_id in sorted(scores, key=lambda s: -s[0])[:k]]
HANDBOOK = '# Remote work handbook\n\n## Eligibility\nMost roles can work remotely up to three days a week. Your manager approves the schedule, and roles that\nhandle physical equipment or visitors may need to be on site more often. Agree your remote days at the\nstart of each quarter.\n\n## Home internet stipend\nRemote staff need a reliable home internet connection. The home internet stipend helps cover the monthly\ncost of broadband, fibre or cable service, and home internet upgrades needed for video calls are also\neligible when your current connection is too slow for daily work. Mobile phone plans and hotspot\ndevices are not eligible. Claim it with the monthly expense report. The stipend is 50 dollars per month.\n\n## Equipment\nEveryone receives a laptop, a headset and one external monitor. A second monitor can be requested for\ndesign and engineering roles. Laptop monitors from home are allowed if they connect over USB-C or HDMI.\nReturn all equipment when you leave the company.\n\n## Security at home\nLock your screen whenever you step away. Use the VPN on any network you do not control, including home\nWi-Fi shared with others. Never print confidential documents at home.\n\n## Working hours\nCore hours are 10:00 to 15:00 in your local time zone. Outside core hours, set your status so teammates\nknow when to expect a reply.\n'
@dataclass(frozen=True)
class Chunk: on GitHub
1204@dataclass(frozen=True)
1205class Chunk:
1206    """A piece of a document, plus the metadata that travels with it into the index.
1207
1208    Metadata is what makes filters possible later: by date, by department,
1209    and above all by who is allowed to see it (see primer.agents.rag).
1210    """
1211
1212    text: str
1213    source: str
1214    section: str
1215    updated: str = ""
1216    acl: frozenset[str] = field(default_factory=lambda: frozenset({"everyone"}))

A piece of a document, plus the metadata that travels with it into the index.

Metadata is what makes filters possible later: by date, by department, and above all by who is allowed to see it (see primer.agents.rag).

Chunk( text: str, source: str, section: str, updated: str = '', acl: frozenset[str] = <factory>)
text: str
source: str
section: str
updated: str = ''
acl: frozenset[str]
def fixed_size_chunks(text: str, size: int = 50, overlap: int = 10) -> list[str]: on GitHub
1219def fixed_size_chunks(text: str, size: int = 50, overlap: int = 10) -> list[str]:
1220    """Split every `size` words, each window overlapping the previous by `overlap` words.
1221
1222    Simple and structure-blind: a heading can land at the end of one window
1223    and the fact it introduces at the start of the next. Overlap softens but
1224    doesn't fix that.
1225    """
1226    words = text.split()
1227    step = size - overlap
1228    # Stop once a window would start inside the previous window's overlap:
1229    # the previous window already reached the end.
1230    starts = range(0, max(1, len(words) - overlap), step)
1231    return [" ".join(words[s : s + size]) for s in starts]

Split every size words, each window overlapping the previous by overlap words.

Simple and structure-blind: a heading can land at the end of one window and the fact it introduces at the start of the next. Overlap softens but doesn't fix that.

def structure_aware_chunks( text: str, source: str, max_words: int = 120, updated: str = '', acl: frozenset[str] = frozenset({'everyone'})) -> list[Chunk]: on GitHub
1241def structure_aware_chunks(
1242    text: str, source: str, max_words: int = 120, updated: str = "", acl: frozenset[str] = frozenset({"everyone"})
1243) -> list[Chunk]:
1244    """Split on the document's own structure: sections, then paragraphs, never across a heading.
1245
1246    Each chunk starts with its section heading, so a chunk that says "The
1247    stipend is 50 dollars per month" still says *which* stipend. (Prefixing
1248    extra context like this is the idea behind contextual retrieval.)
1249    """
1250    chunks = []
1251    for heading, body in _sections(text):
1252        paragraphs = [p.replace("\n", " ") for p in body.split("\n\n") if p.strip()]
1253        current: list[str] = []
1254        for p in paragraphs:
1255            if current and len(" ".join(current + [p]).split()) > max_words:
1256                chunks.append(Chunk(f"{heading}: " + " ".join(current), source, heading, updated, acl))
1257                current = []
1258            current.append(p)
1259        if current:
1260            chunks.append(Chunk(f"{heading}: " + " ".join(current), source, heading, updated, acl))
1261    return chunks

Split on the document's own structure: sections, then paragraphs, never across a heading.

Each chunk starts with its section heading, so a chunk that says "The stipend is 50 dollars per month" still says which stipend. (Prefixing extra context like this is the idea behind contextual retrieval.)

def best_chunk(query: str, chunks: Sequence[str]) -> str: on GitHub
1264def best_chunk(query: str, chunks: Sequence[str]) -> str:
1265    """The single chunk BM25 ranks highest for the question."""
1266    index = BM25([tokenize(c) for c in chunks])
1267    return chunks[index.search(tokenize(query), k=1)[0][0]]

The single chunk BM25 ranks highest for the question.

CHUNKING_QUESTIONS: list[tuple[str, str]] = [('What is the stipend amount in dollars?', 'The stipend is 50 dollars per month.'), ('Do I need the VPN on shared Wi-Fi?', 'Use the VPN on any network you do not control, including home Wi-Fi shared with others.'), ('What time are core hours?', 'Core hours are 10:00 to 15:00 in your local time zone.'), ('Can I get a second monitor?', 'A second monitor can be requested for design and engineering roles.')]
def chunk_engine(chunks: Sequence[str]) -> SearchEngine: on GitHub
1295def chunk_engine(chunks: Sequence[str]) -> SearchEngine:
1296    """A search engine over one document's chunks, deep enough that every chunk gets a rank.
1297
1298    Each chunk becomes a titleless `Doc` with id "chunk-<position>", so the
1299    whole retrieve-then-rerank pipeline runs on chunks exactly as it runs on
1300    articles.
1301    """
1302    docs = [Doc(f"chunk-{i}", "", c, "", "") for i, c in enumerate(chunks)]
1303    return SearchEngine(docs, depth=len(docs))

A search engine over one document's chunks, deep enough that every chunk gets a rank.

Each chunk becomes a titleless Doc with id "chunk-", so the whole retrieve-then-rerank pipeline runs on chunks exactly as it runs on articles.

def viz_data() -> dict: on GitHub
1306def viz_data() -> dict:
1307    """The numbers the site's chunking, retrieval and reranking widget shows.
1308
1309    The widget cuts the chunks itself (its chunker mirrors
1310    `fixed_size_chunks`), but it can't embed text, so for every chunk size,
1311    overlap and question the slider can reach, this precomputes the hybrid
1312    ranking with its fused scores and the cross-encoder's score for every
1313    chunk. The widget reranks any top k from those.
1314    """
1315    sizes, overlaps = [20, 30, 40, 60], [0, 5, 10]
1316    judge = CrossEncoder()
1317    runs = {}
1318    for size in sizes:
1319        for overlap in overlaps:
1320            engine = chunk_engine(fixed_size_chunks(HANDBOOK, size, overlap))
1321            position = {doc_id: i for i, doc_id in enumerate(engine.ids)}
1322            runs[f"{size}/{overlap}"] = [
1323                {
1324                    # Exactly what engine.hybrid fuses, kept with its scores so the widget can show them.
1325                    "retrieval": [[position[d], round(s, 5)] for d, s in reciprocal_rank_fusion(
1326                        [engine.bm25(q, engine.depth), engine.dense(q, engine.depth)])],
1327                    "rerank": [round(judge.score(q, d), 4) for d in engine.docs],
1328                }
1329                for q, _ in CHUNKING_QUESTIONS
1330            ]
1331    return {
1332        "rag-pipeline": {
1333            "text": HANDBOOK,
1334            "sizes": sizes,
1335            "overlaps": overlaps,
1336            "max_k": 8,
1337            "questions": [{"question": q, "answer": a} for q, a in CHUNKING_QUESTIONS],
1338            "runs": runs,
1339        }
1340    }

The numbers the site's chunking, retrieval and reranking widget shows.

The widget cuts the chunks itself (its chunker mirrors fixed_size_chunks), but it can't embed text, so for every chunk size, overlap and question the slider can reach, this precomputes the hybrid ranking with its fused scores and the cross-encoder's score for every chunk. The widget reranks any top k from those.

1348class PrefixedEmbedder(ConceptEmbedder):
1349    """Simulates an embedding model trained to see "query: " or "passage: " before every input.
1350
1351    Models such as E5 are trained that way so one network can encode short
1352    questions and long passages differently. Give it text without the prefix
1353    and it still returns a perfectly normal-looking unit vector, but from a
1354    region of space it never learned to align. We simulate that drift by
1355    partly rotating un-prefixed vectors with a fixed random rotation R:
1356
1357        v' = cos(θ)·v + sin(θ)·R·v, then rescaled to length 1
1358
1359    With θ = 60°, half the signal survives, yet nothing errors.
1360    """
1361
1362    PREFIXES = ("query: ", "passage: ")
1363
1364    def __init__(self, drift_degrees: float = 60.0, **kwargs):
1365        super().__init__(**kwargs)
1366        theta = np.radians(drift_degrees)
1367        self._cos, self._sin = np.cos(theta), np.sin(theta)
1368        # A random orthogonal matrix (a rotation or reflection) from the QR decomposition of noise.
1369        self._R, _ = np.linalg.qr(np.random.default_rng(7).standard_normal((self.dim, self.dim)))
1370
1371    def encode_one(self, text: str) -> np.ndarray:
1372        for prefix in self.PREFIXES:
1373            if text.startswith(prefix):
1374                return super().encode_one(text[len(prefix) :])
1375        v = super().encode_one(text)
1376        drifted = self._cos * v + self._sin * (self._R @ v)
1377        return drifted / np.linalg.norm(drifted)

Simulates an embedding model trained to see "query: " or "passage: " before every input.

Models such as E5 are trained that way so one network can encode short questions and long passages differently. Give it text without the prefix and it still returns a perfectly normal-looking unit vector, but from a region of space it never learned to align. We simulate that drift by partly rotating un-prefixed vectors with a fixed random rotation R:

v' = cos(θ)·v + sin(θ)·R·v, then rescaled to length 1

With θ = 60°, half the signal survives, yet nothing errors.

PrefixedEmbedder(drift_degrees: float = 60.0, **kwargs) on GitHub
1364    def __init__(self, drift_degrees: float = 60.0, **kwargs):
1365        super().__init__(**kwargs)
1366        theta = np.radians(drift_degrees)
1367        self._cos, self._sin = np.cos(theta), np.sin(theta)
1368        # A random orthogonal matrix (a rotation or reflection) from the QR decomposition of noise.
1369        self._R, _ = np.linalg.qr(np.random.default_rng(7).standard_normal((self.dim, self.dim)))
PREFIXES = ('query: ', 'passage: ')
def encode_one(self, text: str) -> numpy.ndarray: on GitHub
1371    def encode_one(self, text: str) -> np.ndarray:
1372        for prefix in self.PREFIXES:
1373            if text.startswith(prefix):
1374                return super().encode_one(text[len(prefix) :])
1375        v = super().encode_one(text)
1376        drifted = self._cos * v + self._sin * (self._R @ v)
1377        return drifted / np.linalg.norm(drifted)
def figures() -> dict: on GitHub
1389def figures() -> dict:
1390    """Plot this lesson's data. matplotlib is imported here, and only here,
1391    so the lesson itself needs nothing beyond NumPy."""
1392    import matplotlib
1393
1394    matplotlib.use("Agg")
1395    import matplotlib.pyplot as plt
1396    from matplotlib.patches import Rectangle
1397
1398    BM25_C, DENSE_C, HYB_C, MUTED, HOT, GOOD = "#d97706", "#2563eb", "#7c3aed", "#9ca3af", "#dc2626", "#059669"
1399    figs = {}
1400
1401    # --- 1. BM25: saturation and length normalization ----------------------
1402    fig, (a1, a2) = plt.subplots(1, 2, figsize=(10, 3.8))
1403    f = np.arange(0, 21)
1404    shown = f <= 6  # the raw count keeps climbing; draw it up to the chart's top, not through the title
1405    a1.plot(f[shown], f[shown], "--", color=MUTED, label="raw count")
1406    for k1, shade in [(0.5, 0.45), (1.2, 0.7), (1.5, 1.0), (3.0, 0.3)]:
1407        # With b = 0, BM25's per-word credit is f·(k1+1)/(f+k1): plot it straight from the class.
1408        index = BM25([["w"] * int(n) for n in f[1:]] + [["x"]], k1=k1, b=0.0)
1409        credit = [0.0] + [index.scores(["w"])[i] / index.idf["w"] for i in range(len(f) - 1)]
1410        a1.plot(f, credit, color=BM25_C, alpha=shade, lw=2, label=f"k₁ = {k1} (ceiling {k1 + 1})")
1411    a1.set_ylim(0, 6)
1412    a1.set_xlabel("mentions of the word in the document, f(t, d)")
1413    a1.set_ylabel("credit (before the rarity weight)")
1414    a1.set_title("Saturation: the 10th mention adds little")
1415    a1.legend(frameon=False, fontsize=8)
1416    lengths = np.linspace(0.25, 3, 60)  # |d| / avgdl
1417    for b, shade in [(0.0, 0.35), (0.5, 0.65), (0.75, 1.0), (1.0, 0.5)]:
1418        credit = 1 * 2.5 / (1 + 1.5 * (1 - b + b * lengths))  # one mention, k1 = 1.5
1419        a2.plot(lengths, credit, color=BM25_C, alpha=shade, lw=2, label=f"b = {b}")
1420    a2.axvline(1, color=MUTED, ls=":")
1421    a2.text(1.03, 1.55, "average length", color=MUTED, fontsize=8)
1422    a2.set_xlabel("document length ÷ average length, |d| / avgdl")
1423    a2.set_ylabel("credit for one mention")
1424    a2.set_title("Length: long documents are discounted")
1425    a2.legend(frameon=False, fontsize=8)
1426    fig.tight_layout()
1427    figs["bm25_curves"] = fig
1428
1429    engine = SearchEngine(DOCS)
1430
1431    # --- 2. RRF on the error-code question ----------------------------------
1432    q = "what does ERR-4012 mean"
1433    b_rank, d_rank = engine.bm25(q, 10), engine.dense(q, 10)
1434    fused = reciprocal_rank_fusion([b_rank, d_rank])[:6]
1435    names = [d for d, _ in fused]
1436    from_b = [1 / (60 + b_rank.index(d) + 1) if d in b_rank else 0 for d in names]
1437    from_d = [1 / (60 + d_rank.index(d) + 1) if d in d_rank else 0 for d in names]
1438    fig, ax = plt.subplots(figsize=(6.6, 3.6))
1439    x = np.arange(len(names))
1440    ax.bar(x, from_b, color=BM25_C, label="from BM25: 1/(60 + rank)")
1441    ax.bar(x, from_d, bottom=from_b, color=DENSE_C, label="from dense: 1/(60 + rank)")
1442    for xi, d in zip(x, names):
1443        tags = [f"B{b_rank.index(d) + 1}" if d in b_rank else "", f"D{d_rank.index(d) + 1}" if d in d_rank else ""]
1444        ax.text(xi, from_b[xi] + from_d[xi] + 0.0006, " ".join(t for t in tags if t), ha="center", fontsize=8)
1445    ax.set_ylim(0, max(a + b for a, b in zip(from_b, from_d)) * 1.2)  # room for the rank labels
1446    ax.set_xticks(x, names)
1447    ax.set_ylabel("fused RRF score")
1448    ax.set_title(f'RRF for "{q}" (B = BM25 rank, D = dense rank)')
1449    ax.legend(frameon=False, fontsize=8)
1450    figs["rrf_fusion"] = fig
1451
1452    # --- 3. Where each method ranks the answer ------------------------------
1453    methods = {
1454        "BM25": engine.bm25,
1455        "dense": engine.dense,
1456        "hybrid": engine.hybrid,
1457        "hybrid + rerank": lambda qq, k: retrieve_then_rerank(engine, qq, k),
1458    }
1459    grid = np.array(
1460        [[_answer_rank(m(qq, 10), rel) or 11 for m in methods.values()] for qq, rel in EVAL_QUERIES], dtype=float
1461    )
1462    fig, ax = plt.subplots(figsize=(7.2, 8))
1463    ax.imshow(np.minimum(grid, 11), cmap="RdYlGn_r", vmin=1, vmax=11, aspect="auto")
1464    for i in range(grid.shape[0]):
1465        for j in range(grid.shape[1]):
1466            ax.text(j, i, "✗" if grid[i, j] > 10 else int(grid[i, j]), ha="center", va="center", fontsize=9)
1467    ax.set_xticks(range(len(methods)), list(methods))
1468    ax.xaxis.tick_top()
1469    ax.set_yticks(range(len(EVAL_QUERIES)), [qq for qq, _ in EVAL_QUERIES], fontsize=8)
1470    n1, n2 = len(LABELED_QUERIES), len(LABELED_QUERIES) + len(PARAPHRASE_QUERIES)
1471    for y in (n1 - 0.5, n2 - 0.5):
1472        ax.axhline(y, color="black", lw=1.5)
1473    ax.text(3.6, (n1 + n2) / 2 - 0.5, "paraphrases", rotation=-90, va="center", fontsize=8)
1474    ax.text(3.6, (n2 + len(EVAL_QUERIES)) / 2 - 0.5, "exact codes", rotation=-90, va="center", fontsize=8)
1475    ax.set_title("Rank of the right answer (1 = top, ✗ = not in top 10)", pad=28)
1476    fig.tight_layout()
1477    figs["method_ranks"] = fig
1478
1479    # --- 4. MaxSim grid ------------------------------------------------------
1480    li = LateInteraction()
1481    qtoks = tokenize("what are the password rules")
1482    policy = next(d for d in DOCS if d.id == "it-002")
1483    dtoks = tokenize(doc_text(policy))[:14]
1484    sims = li.token_vectors(" ".join(qtoks)) @ li.embedder.encode(dtoks).T
1485    fig, ax = plt.subplots(figsize=(8.4, 2.6))
1486    im = ax.imshow(sims, cmap="Blues", vmin=-0.2, vmax=1)
1487    for i, j in enumerate(sims.argmax(1)):
1488        ax.add_patch(Rectangle((j - 0.5, i - 0.5), 1, 1, fill=False, edgecolor=HOT, lw=2.5))
1489        ax.text(j, i, f"{sims[i, j]:.2f}", ha="center", va="center", fontsize=8, color="white")
1490    ax.set_xticks(range(len(dtoks)), dtoks, rotation=45, ha="right", fontsize=8)
1491    ax.set_yticks(range(len(qtoks)), qtoks)
1492    ax.set_title(f"MaxSim: each query word keeps its best match (score = {sims.max(1).sum():.2f})")
1493    fig.colorbar(im, ax=ax, fraction=0.02, label="similarity")
1494    figs["maxsim_grid"] = fig
1495
1496    # --- 5. Chunk boundaries on the handbook --------------------------------
1497    words = HANDBOOK.split()
1498    section_starts = [i for i, w in enumerate(words) if w == "##"]
1499    headings = [h for h, _ in _sections(HANDBOOK)]
1500    bounds = section_starts + [len(words)]
1501    heading_at = next(i for i in range(len(words)) if words[i : i + 3] == ["Home", "internet", "stipend"])
1502    amount_at = next(i for i in range(len(words)) if words[i : i + 2] == ["50", "dollars"])
1503    fig, ax = plt.subplots(figsize=(10, 3))
1504    cmap = plt.get_cmap("Pastel1")
1505    for n, (s, e, heading) in enumerate(zip(bounds, bounds[1:], headings)):
1506        ax.add_patch(Rectangle((s, -0.3), e - s, 0.6, color=cmap(n)))
1507        ax.text((s + e) / 2, 0, heading, ha="center", va="center", fontsize=7)
1508    size, overlap = 40, 5
1509    for n, s in enumerate(range(0, max(1, len(words) - overlap), size - overlap)):
1510        y = 0.55 + 0.18 * (n % 2)  # alternate heights so overlapping windows stay visible
1511        ax.plot([s, min(s + size, len(words))], [y, y], color=MUTED, lw=3)
1512    for n, (s, e) in enumerate(zip(bounds, bounds[1:])):
1513        y = -0.55 - 0.18 * (n % 2)
1514        ax.plot([s, e], [y, y], color=HYB_C, lw=3)
1515    ax.axvline(heading_at, color=HOT, lw=2)
1516    ax.axvline(amount_at, color=GOOD, lw=2)
1517    # Each label sits on its own line; an opaque box above the line keeps the words whole.
1518    on_line = dict(ha="center", fontsize=8, zorder=3, bbox=dict(facecolor="white", edgecolor="none", pad=1))
1519    ax.text(heading_at, 1.02, "heading", color=HOT, **on_line)
1520    ax.text(amount_at, 1.02, "50 dollars", color=GOOD, **on_line)
1521    ax.text(-2, 0.63, "fixed\n40-word\nwindows", ha="right", va="center", fontsize=8, color="#4b5563")
1522    ax.text(-2, -0.63, "structure-\naware\nchunks", ha="right", va="center", fontsize=8, color=HYB_C)
1523    ax.set_xlim(-30, len(words) + 2)
1524    ax.set_ylim(-1.0, 1.15)
1525    ax.set_yticks([])
1526    ax.set_xlabel("word position in the handbook")
1527    ax.set_title("Where the chunkers cut: the stipend heading and its amount")
1528    for side in ("left", "right", "top"):
1529        ax.spines[side].set_visible(False)
1530    figs["chunk_boundaries"] = fig
1531
1532    return figs

Plot this lesson's data. matplotlib is imported here, and only here, so the lesson itself needs nothing beyond NumPy.

def demo() -> None: on GitHub
1540def demo() -> None:
1541    banner("1. BM25 by hand: three tiny documents, query 'cat'")
1542    toy = [["cat", "cat", "dog"], ["dog", "bird"], ["fish"]]
1543    index = BM25(toy)
1544    table(
1545        ["doc", "words", "BM25('cat')"],
1546        [(f"d{i + 1}", " ".join(d), s) for i, (d, s) in enumerate(zip(toy, index.scores(["cat"])))],
1547        floatfmt=".3f",
1548    )
1549    say(f"IDF(cat) = ln(1 + 2.5/1.5) = {index.idf['cat']:.3f}; IDF(dog) = {index.idf['dog']:.3f}: rarer words weigh more.")
1550
1551    engine = SearchEngine(DOCS)
1552    banner("2. The two librarians disagree")
1553    for q in ("what does ERR-4012 mean", "automobile reimbursement"):
1554        print(f"{q!r}")
1555        print(f"   BM25   : {engine.bm25(q, 3) or '(nothing: no shared words)'}")
1556        print(f"   dense  : {engine.dense(q, 3)}")
1557        print(f"   hybrid : {engine.hybrid(q, 3)}")
1558    print()
1559
1560    banner("3. Reciprocal rank fusion, worked example (k = 60)")
1561    fused = dict(reciprocal_rank_fusion([["X", "a", "b"], ["c", "d", "X"], ["Y"]]))
1562    say(f"X at ranks 1 and 3: 1/61 + 1/63 = {fused['X']:.4f}. Y at rank 1 only: 1/61 = {fused['Y']:.4f}.")
1563
1564    banner("4. Measured on 20 labeled questions")
1565    rows = []
1566    for name, fn in [
1567        ("BM25", engine.bm25),
1568        ("dense", engine.dense),
1569        ("hybrid (RRF)", engine.hybrid),
1570        ("hybrid + rerank", lambda q, k: retrieve_then_rerank(engine, q, k)),
1571        ("late interaction", lambda q, k: LateInteraction().search(q, DOCS, k)),
1572    ]:
1573        r = evaluate(fn, EVAL_QUERIES, k=3)
1574        rows.append((name, r["recall"], r["mrr"]))
1575    table(["method", "recall@3", "MRR"], rows, floatfmt=".3f")
1576    takeaway("BM25 and dense search fail on different questions; fusing them with RRF fixes both.")
1577
1578    banner("5. A hard negative, fixed by reranking")
1579    q = "what are the password rules"
1580    say(f"{q!r}: hybrid ranks {engine.hybrid(q, 3)}, but the answer is it-002 (the policy).")
1581    judge = CrossEncoder()
1582    by_id = {d.id: d for d in DOCS}
1583    table(
1584        ["doc", "coverage", "phrase", "exact", "score"],
1585        [
1586            (d, judge.coverage(q, by_id[d]), judge.phrase(q, by_id[d]), judge.exact(q, by_id[d]), judge.score(q, by_id[d]))
1587            for d in ("it-002", "it-001")
1588        ],
1589        floatfmt=".2f",
1590    )
1591    say(f"After reranking: {retrieve_then_rerank(engine, q, 3)}.")
1592
1593    banner("6. Chunking the remote-work handbook")
1594    fixed = best_chunk("home internet stipend", fixed_size_chunks(HANDBOOK, size=40, overlap=5))
1595    smart = best_chunk("home internet stipend", [c.text for c in structure_aware_chunks(HANDBOOK, "remote-work-handbook")])
1596    say(f"Best fixed-size chunk: ...{fixed[-110:]}  (amount present: {'50 dollars' in fixed})")
1597    say(f"Best structure-aware chunk: ...{smart[-110:]}  (amount present: {'50 dollars' in smart})")
1598
1599    banner("7. The forgotten prefix: a silent bug")
1600    for label, pp in [("both prefixes", "passage: "), ("passage prefix forgotten", "")]:
1601        e = SearchEngine(DOCS, embedder=PrefixedEmbedder(), passage_prefix=pp, query_prefix="query: ")
1602        r = evaluate(e.dense, LABELED_QUERIES, k=3)
1603        print(f"{label:26s} recall@3 {r['recall']:.3f}   MRR {r['mrr']:.3f}")
1604    print()
1605    takeaway("Nothing errored. Only measuring recall on labeled questions reveals the bug.")