rumblr Work in progressWIP

● The AI Primer · Lesson 31 · Embeddings, the centerpiece

Retrieval

keyword search, meaning search, and combining them

You'll be able to explain BM25, hybrid search with RRF, rerankers, ColBERT, chunking

Members · open during launch 37 min14 figures and diagrams1 interactive
Guide is what to use and when. How it works builds it from scratch. Math & code adds the formulas and the Python.

The lesson in one minute

What you'll be able to explain

  1. 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.
  2. Dense retrieval (bi-encoder) finds meaning and paraphrases, but blurs exact tokens. Documents are embedded once, ahead of time.
  3. Hybrid search fuses both rankings with RRF, Σ 1/(60 + rank), using ranks not scores, and almost always beats either alone.
  4. 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.
  5. ColBERT keeps a vector per word and scores with MaxSim: close to cross-encoder precision, still precomputable, much more storage.
  6. 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.

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 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:

Figure 1 · Diagram

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.

Chapter 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.

Figure 3 · Diagram

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 0. That's the blind spot the meaning librarian covers.
Level 3: the formula and its symbols

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.

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

Figure 2 · Drawn from the lesson's code

0.0 2.5 5.0 7.5 10.0 12.5 15.0 17.5 20.0 mentions of the word in the document, f(t, d) 0 1 2 3 4 5 6 credit (before the rarity weight) Saturation: the 10th mention adds little raw count k₁ = 0.5 (ceiling 1.5) k₁ = 1.2 (ceiling 2.2) k₁ = 1.5 (ceiling 2.5) k₁ = 3.0 (ceiling 4.0) 0.5 1.0 1.5 2.0 2.5 3.0 document length ÷ average length, |d| / avgdl 0.4 0.6 0.8 1.0 1.2 1.4 1.6 1.8 credit for one mention Length normalization: long documents are discounted average length b = 0.0 b = 0.5 b = 0.75 b = 1.0

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.

Chapter 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.

Figure 4 · Diagram

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.

Chapter 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.

Figure 7 · Diagram

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

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.

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

Figure 5 · Drawn from the lesson's code

it-004 it-002 fin-006 fin-001 fin-005 hr-002 0.000 0.005 0.010 0.015 0.020 0.025 0.030 0.035 fused RRF score RRF for "what does ERR-4012 mean" (B = BM25 rank, D = dense rank) B1 D4 D1 D2 D3 D5 D6 from BM25: 1/(60 + rank) from dense: 1/(60 + rank)

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.

Figure 6 · Drawn from the lesson's code

BM25 dense hybrid hybrid + rerank I forgot my password and I'm locked out how long must a password be what does ERR-4012 mean ERR-4013 on my laptop automobile reimbursement for business driving how many vacation days do I get connect to internal systems from home suspicious email with a link per-diem for meals when traveling enroll in MFA first day checklist for a new hire match vendor bills to payments automobile reimbursement notebook computer replacement holiday allowance scam message in my inbox refund for the hotel ERR-4012 fix ERR-4013 ERR-4013 Rank of the right answer (1 = top, ✗ = not in top 10) 1 1 1 1 1 1 1 1 1 4 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 ✗ 1 1 1 ✗ 1 1 1 ✗ 1 1 1 ✗ 1 1 1 ✗ 1 1 2 1 1 1 1 1 8 1 1 1 2 1 1 paraphrases exact codes

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.

Chapter 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.

Figure 8 · Diagram

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.

Chapter 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.

Figure 10 · Diagram

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

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.

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

Figure 9 · Drawn from the lesson's code

password policy passwords must least 14 characters include number symbol passwords expire every 180 password rules MaxSim: each query word keeps its best match (score = 1.85) 1.00 0.85 −0.2 0.0 0.2 0.4 0.6 0.8 1.0 similarity

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.

Chapter 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.

Figure 12 · Diagram

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).

Figure 11 · Drawn from the lesson's code

0 50 100 150 200 word position in the handbook Where the chunkers cut: the stipend heading and its amount Eligibility Home internet stipend Equipment Security at home Working hours heading 50 dollars fixed 40-word windows structure- aware chunks

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.

Figure 14 · Interactive · computed from the lesson's code

Chunking

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).

Figure 13 · Diagram

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

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.

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.

Chapter 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.

Figure 15 · Diagram

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.

Test yourself

13 questions

Answer each one out loud or on paper before you open it. If you can explain it, you know it.

Question 1Documents "cat cat dog", "dog bird", "fish"; query "cat". What does BM25 give the first document?Think it through, then reveal

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.

Question 2A document is 1st in dense search and 3rd in BM25. What's its RRF score?Think it through, then reveal

1/61 + 1/63 ≈ 0.0323, versus 0.0164 for a document that is 1st in only one list.

Question 3Query words (1, 0) and (0, 1); document words (1, 0) and (0.6, 0.8). What's the MaxSim score?Think it through, then reveal

max(1.0, 0.6) + max(0.0, 0.8) = 1.8.

Question 4How many 50-word chunks with 10 words of overlap does a 130-word text make?Think it through, then reveal

⌈(130 − 10)/(50 − 10)⌉ = 3, starting at words 0, 40 and 80.

Question 5What goes wrong if you forget an embedding model's "passage: " prefix when indexing?Think it through, then reveal

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.

Question 6What do BM25's k₁ and b control?Think it through, then reveal

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.

Question 7Bi-encoder vs. cross-encoder: how do you combine them in one pipeline?Think it through, then reveal

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.

Question 8Why does hybrid search beat pure vector search on company data?Think it through, then reveal

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.

Question 9Why does RRF use ranks instead of scores?Think it through, then reveal

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.

Question 10What's a hard negative, and how do you fix one in retrieval?Think it through, then reveal

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).

Question 11What does ColBERT trade to get better precision than a bi-encoder?Think it through, then reveal

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.

Question 12Fixed-size vs. structure-aware chunking?Think it through, then reveal

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.

Question 13Your RAG system gives confident wrong answers. How do you tell whether retrieval is the cause?Think it through, then reveal

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.

Primary sources

The papers behind this lesson

Robertson & Zaragoza, The Probabilistic Relevance Framework: BM25 and Beyond (Foundations and Trends in Information Retrieval, 2009).

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.

The paper ↗
Cormack, Clarke & Büttcher, Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (SIGIR 2009).

Introduced RRF, Σ 1/(k + rank) with k = 60, and showed that this simple, tuning-free fusion beats more elaborate methods.

The paper ↗
Reimers & Gurevych, Sentence-BERT (2019).

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.

Read the annotated companion →The paper ↗
Karpukhin et al., Dense Passage Retrieval for Open-Domain Question Answering (2020).

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.

Read the annotated companion →The paper ↗
Khattab & Zaharia, ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT (2020).

Introduced late interaction: per-token vectors scored with MaxSim, getting near cross-encoder quality with precomputed documents.

Read the annotated companion →The paper ↗

Researcher's shelf

Further reading

  • Nogueira & Cho, Passage Re-ranking with BERT (2019), the cross-encoder reranker: https://arxiv.org/abs/1901.04085
  • Santhanam et al., ColBERTv2 (compressed late interaction): https://arxiv.org/abs/2112.01488
  • Wang et al., Text Embeddings by Weakly-Supervised Contrastive Pre-training (E5, the query/passage prefixes): https://arxiv.org/abs/2212.03533
  • Anthropic, Introducing Contextual Retrieval (contextual chunk prefixes + hybrid + reranking): https://www.anthropic.com/news/contextual-retrieval
  • sentence-transformers documentation (bi-encoders and cross-encoders): https://www.sbert.net/
  • Elasticsearch reciprocal rank fusion reference: https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html

About this lesson. This is the illustrated edition of a lesson from the open-source AI Primer. Its text, figures and numbers are generated from the Primer's source at commit 048aeaa, so the two always agree: the explanation, the code that builds it and the tests that prove it.