Vladimir Karpukhin, Barlas Oǧuz, Sewon Min, Patrick Lewis, Ledell Wu, Sergey Edunov, Danqi Chen, Wen-tau Yih (Facebook AI, UW, Princeton) — arXiv:2004.04906, EMNLP 2020

Dense Passage Retrieval and the Craft of Negatives

Two BERT towers, a dot product, and a loss you could write on a napkin. The paper's real contribution is not the architecture — it is a careful, empirical answer to the question what should this model be told is wrong?

Prerequisites: a dot product and what softmax does. BM25, contrastive losses, ANN indexes, and every flavour of negative sampling are built from zero.
10
Chapters
5
Interactive Sims
21M
Wikipedia Passages
+19
Top-20 Points vs BM25

Chapter 0: The BM25 Ceiling

Here is a question: "Who is the bad guy in Lord of the Rings?"

The answer is written down. It is sitting in English Wikipedia, in a sentence that reads, roughly: "Sala Baker is a New Zealand actor and stuntman, best known for portraying the villain Sauron in the Lord of the Rings trilogy." A human who read that sentence would answer instantly.

Now build a system that finds that sentence. You cannot read 21 million passages per question — you get one shot at a shortlist. So you need a retriever: a function that takes the question and returns, say, the twenty most promising passages out of the whole encyclopaedia. Everything downstream depends on those twenty. If the Sala Baker passage is not among them, no reader, no matter how good, will produce the answer.

For twenty years the default retriever has been BM25 — a term-frequency scoring function we will derive properly in Chapter 1. For now, all you need is its one governing rule: a passage scores well when it contains the words of the question, weighted so rare words count more.

Read the question again. Read the sentence again. Count the shared words.

question:  who · is · the · bad · guy · in · lord · of · the · rings
passage:  sala · baker · … · portraying · the · villain · sauron · in · the · lord · of · the · rings · trilogy

The rare, load-bearing words of the question are bad guy. The rare, load-bearing word of the passage is villain. They mean the same thing, and to BM25 they are as related as bad guy and photosynthesis: not a little related, not weakly related. Exactly zero.

The only terms that do match — lord, rings, in, the, of — are shared by every one of the ten thousand Wikipedia passages that mention the franchise. So the passage you need is buried in a crowd it cannot be distinguished from, and the one signal that would separate it is invisible.

This is the lexical gap, and it is not a bug in BM25. BM25 is a nearly optimal answer to the question it was designed for: given that the user typed these exact tokens, which documents contain them in a statistically surprising quantity? It has no representation in which "bad guy" and "villain" could be close, because its representation has one dimension per term and different terms are, by construction, orthogonal directions. No amount of tuning the weights on orthogonal axes will make two of them point the same way.

Where the retriever sits, and why it caps everything

Open-domain question answering means answering a question with no passage supplied — the whole corpus is the context. Since 2017 the standard shape has been two stages, sometimes called retrieve-then-read:

Stage 1 — Retriever
question → top-k passages out of 21,015,324. Must be fast: this runs over the entire corpus, per question.
↓ a shortlist of k passages, typically k = 20 or 100
Stage 2 — Reader
A BERT-style model reads all k passages jointly with the question and selects an answer span. Slow but only runs on k passages.
Answer
"Sauron"

The arithmetic of this pipeline is brutal and worth doing by hand, because it explains why a retrieval paper was the one that moved the field.

Let R@k be the fraction of questions whose top-k shortlist contains at least one passage that actually holds the answer — the top-k retrieval accuracy. Let A be the reader's accuracy given that a correct passage is in front of it. The reader cannot invent facts it never saw, so the end-to-end accuracy is bounded:

end-to-end accuracy  ≤  R@k × A

Plug in real numbers. On Natural Questions — real Google search queries paired with Wikipedia answers — BM25 achieves R@20 = 59.1%. Suppose your reader is a strong one, right 70% of the time when the evidence is present:

0.591 × 0.70  =  0.4137  →  41.4% end-to-end, at best

Now swap in DPR's retriever, R@20 = 78.4%, and change nothing else:

0.784 × 0.70  =  0.5488  →  54.9% end-to-end, at best

Thirteen and a half points of end-to-end headroom, bought without touching the reader, without a bigger model, without more answer supervision. And notice the shape of the multiplication: retrieval accuracy is a multiplicative prefactor on everything the rest of your system does. A reader improvement of 5 points is worth 0.591 × 0.05 = 3.0 points end-to-end if the retriever is BM25. A retrieval improvement of 5 points is worth 0.70 × 0.05 = 3.5 points. Retrieval gains are worth more, and they are compounding: they raise the value of every future reader improvement too.

Why the first stage is the one that matters. Errors in a two-stage pipeline are not symmetric. A reader mistake is recoverable — a better reader fixes it later. A retrieval miss is information-theoretically terminal: the evidence never entered the system, so no downstream component can recover it. That asymmetry is why "the retriever is a solved problem, use BM25" was, in hindsight, the most expensive assumption in open-domain QA.

See the gap before we close it

Before any machinery, play with the failure. The simulation below runs four real questions against a small candidate pool, and ranks the pool twice: once by term overlap (a faithful miniature of BM25) and once by position in a learned semantic space (a faithful miniature of DPR). The gold passage — the one that actually contains the answer — is marked. Two of the four questions favour dense retrieval. Two favour BM25, and those two matter just as much.

The lexical gap, both directions

Pick a question. The left column ranks by matched query terms (the matched terms are listed under each passage). The right column ranks by learned similarity. Watch where the gold passage lands in each. Toggle stemming to see how much of the gap is fixable with classical tricks — the answer is: some of it, and never the important part.

Three things to take from playing with it. First, on question 1 the gold passage is not merely ranked low by term overlap — it scores the same as passages that are about something else entirely, because the only terms it shares with the question are the ones everything in the franchise shares. Second, stemming and stopword removal help a little and never bridge bad guy to villain: they normalise word forms, not word meanings. Third, on questions 3 and 4 the term-overlap column is not just adequate, it is better: a rare literal string like "Thoros of Myr" is exactly the kind of evidence a sparse index handles perfectly and a 768-dimensional average has every reason to smear.

What the pre-2020 consensus said

The idea of embedding questions and passages into a shared vector space was not new in 2020. It had been tried repeatedly, and the received wisdom was discouraging: dense retrieval underperformed BM25 unless you first spent enormous compute on a retrieval-specific pretraining task.

The strongest version of that claim came from ORQA (Lee et al., 2019), which introduced the Inverse Cloze Task — take a sentence out of a passage, treat the sentence as a pseudo-question and the remaining passage as its pseudo-answer, and pretrain a dual encoder to match them across millions of examples. ORQA showed this worked. It also showed it was expensive, and it left two structural problems the paper itself named: the ICT objective is a proxy for the real task, and its context encoder is never fine-tuned on real question–passage pairs, so it is likely suboptimal.

DPR's opening claim is deliberately deflationary, and it is the reason the paper landed:

The claim. "We show that... it is possible to train a better dense retrieval model using only pairs of questions and passages (or answers), without additional pretraining." No inverse cloze task, no new architecture, no new loss function. Two off-the-shelf BERT-base encoders, a dot product, the standard negative-log-likelihood loss, and roughly 1,000 to 60,000 training pairs per dataset. What the paper contributes instead is a training recipe — and the load-bearing part of that recipe is the choice of negatives.

That is why this lesson is titled the way it is. The dual encoder in Chapter 2 will take you fifteen minutes to understand and an afternoon to implement. Chapter 4, on negatives, is the paper.

The four numbers to hold

NumberWhat it is
21,015,324Wikipedia passages in the index — the December 2018 dump, split into disjoint 100-word chunks
59.1% → 78.4%Top-20 retrieval accuracy on Natural Questions: BM25 → DPR. The headline, and a 19.3-point jump
41.5End-to-end exact-match on Natural Questions, versus 33.3 for ORQA and 40.4 for REALM — both of which used far more pretraining compute
1,000Training question–passage pairs at which DPR already overtakes BM25 on NQ top-20. The model is not learning language; it is learning a projection

A first look at the whole method

You can hold the entire system in your head right now, and it will make the next nine chapters feel like filling in detail rather than accumulating machinery.

Offline, once
Push all 21M passages through a passage encoder. Store 21M vectors of 768 floats. Build an approximate-nearest-neighbour index over them.
↓ 64.5 GB of vectors, 8.8 GPU-hours to produce
Online, per question
Push the question through a different encoder. One vector, 768 floats. Retrieve the top-k passages by dot product.
↓ roughly 1 millisecond, ~1000 questions per second per server
Training, the part that is actually hard
For each question, one positive passage and a carefully constructed pile of negatives. Softmax over their similarities; maximise the log-probability of the positive.

Everything interesting is in the third box. The first two are engineering; the third is where the paper's ablation lives, and where every follow-up paper for the next three years chose to attack.

Roadmap

Chapters 1–3 — build the machine
Derive BM25 and see exactly what it cannot do → the dual encoder and why the towers must not talk → the loss, derived from "which of these is the gold one?"
Chapters 4–6 — the craft
Negatives: random, BM25-hard, in-batch, false. Then the lineage that industrialised them — ANCE, RocketQA, Contriever. Then the mirror problem: where do positives come from?
Chapters 7–9 — ship it and interrogate it
FAISS, quantisation, and the full serving path with latencies → every number including the one where DPR loses → the loss computed by hand on a 3×3 batch, to a final decimal.

What exactly is a "passage"?

The unit of retrieval sounds like a detail and is not. DPR splits every Wikipedia article into disjoint 100-word chunks, and both halves of that phrase were chosen against real alternatives.

UnitCount over WikipediaWhy not
Whole article~5.1MAn article on the United States contains the answer to thousands of unrelated questions. One vector cannot represent all of them, and the reader cannot fit 40,000 words in its context
Section~20MWildly variable length — a two-sentence stub and a 3,000-word section get one vector each. Length variance is poison for a fixed-size representation
Sentence~150MToo little context: "He was born there in 1889" is unresolvable alone, and the index grows 7×
100-word chunk21,015,324Enough context to contain a self-standing fact; short enough that one vector is a fair summary; uniform enough that BM25's length normalisation becomes a no-op

Two consequences worth carrying. First, a fixed chunk size means the length term in BM25 collapses to 1 for every passage (Chapter 1), so the sparse baseline is being run in its simplest configuration. Second, disjoint chunks mean a fact that straddles a boundary is split in half and may be unfindable by either half. Overlapping windows fix this at the cost of a larger index and duplicate results; most production systems overlap by 10–25%, and DPR does not.

Each chunk is prefixed with its article title before encoding: title [SEP] passage_text. A 100-word chunk lifted from the middle of an article often never names its own subject — it says "he", "the band", "the treaty" — and the title restores it. The paper measures this as worth about a point.

The metric, and what it hides

DPR reports top-k retrieval accuracy: the fraction of questions for which at least one of the top-k passages contains the answer string. Not a graded relevance judgement, not nDCG. Just: is the answer text in there?

This is the right metric for this pipeline — it is exactly the condition under which the downstream reader has a chance — and it is generous in a specific way you should know about. A passage containing "1969" for an unrelated reason counts as a success. Chapter 6 shows how the same leniency, applied at training time, manufactures bad labels. The measurement and the supervision share a flaw, which is why improvements measured this way are slightly overstated in both directions and, mercifully, in the same direction for every system being compared.

MetricWhat it asksUsed here?
Top-k accuracyIs the answer string in the top k?Yes — matches what the reader needs
Recall@kWhat fraction of all relevant passages did we get?No — the reader only needs one
MRR@10How high is the first relevant result?Used by MS MARCO, and by ANCE in Chapter 5
nDCG@10Graded relevance, position-discountedUsed by BEIR; needs graded labels DPR's datasets do not have

Three ways the pipeline fails

When an open-domain QA system returns the wrong answer, it failed in exactly one of three places, and the fix is different for each. Being able to tell them apart is most of debugging one of these systems.

Failure A — retrieval miss
No passage in the top-k contains the answer. Unrecoverable. Diagnose by checking top-k accuracy on your eval; fix by improving the retriever or raising k
Failure B — reader selects the wrong passage
The evidence was present but the reader preferred a distractor. Fix with a better reader or a cross-encoder reranker between the two stages
Failure C — wrong span in the right passage
Correct passage, wrong extraction. The cheapest of the three to fix, and usually the smallest bucket

Measure the three separately before optimising anything. Teams routinely spend a quarter on Failure C when 40% of their errors are Failure A, and the giveaway is exactly the number this paper improved: top-k accuracy.

How open-domain QA arrived at this shape

The retrieve-then-read pipeline was not obvious. It is the residue of four years of the field trying alternatives and discarding them, and knowing the sequence makes DPR's contribution feel like the next necessary step rather than an arbitrary one.

YearSystemWhat it proposedWhat it left broken
2017DrQAEstablished the shape: TF-IDF retriever over all of Wikipedia, then a neural reader. The first system to answer arbitrary questions from an unrestricted corpusThe retriever was a bag of words and everybody knew it. Reader improvements dominated the research agenda for three years
2019ORQAMade retrieval learned and end-to-end trainable, using Inverse Cloze pretraining to get the dual encoder off the groundExpensive pretraining; the passage encoder was frozen afterwards; the pretraining objective is a proxy for the real task
2020REALMPretrained a language model with a retriever in the loop, backpropagating through retrieval by periodically refreshing an index of the corpusEnormous pretraining cost. It also introduced the asynchronous index refresh that ANCE would reuse in Chapter 5
2020DPRShowed that none of that pretraining was necessary: two stock BERTs, real question–passage pairs, and the right negativesA static negative-mining step, and an index that must be fully rebuilt on every model change
2020RAGReplaced the extractive reader with a generator conditioned on the retrieved passages, using DPR's index directlyEverything downstream of retrieval — and it inherits every retrieval failure exactly
2021Fusion-in-DecoderEncoded each retrieved passage separately and fused them in the decoder, letting k grow to 100 cheaplyReader cost linear in k returns, from the other direction

Read the "left broken" column and one theme recurs: everything before DPR tried to fix retrieval with more pretraining, and DPR fixed it with better supervision on the same architecture. That is a pattern worth noticing beyond this paper, because it recurs whenever a field has an expensive default and an unexamined data pipeline.

One user query, end to end

Concreteness before formalism. Here is what actually happens when a person types a question, with the shapes attached, so that the abstractions in later chapters have something to hang on.

The person types
"who is the bad guy in lord of the rings" — 39 characters, no punctuation, lowercase, exactly as a search box receives it
↓ string
The question tower
WordPiece → 11 token ids → BERT-base → (11, 768) → keep row 0 → one vector of 768 floats. This vector is the entire query. Nothing else about the question survives
↓ (768,)
The index
21,015,324 stored vectors, each also (768,), produced months ago by the passage tower. Search returns 20 ids and 20 scalars
↓ [id 4,109,882 → 71.4] [id 19,220,441 → 68.9] …
The reader
Fetches those 20 passages as text, reads each with the question, and emits a span: "Sauron"

Two things to sit with. The query is a single 768-dimensional vector — about 3 kilobytes — and every subtlety of what the person meant must survive that compression. And the passage vectors were computed before the question existed, which is the constraint Chapter 2 shows generates the entire architecture.

drill Compute your own retrieval ceiling before reading on.

Setup. Your production system reports 31% end-to-end answer accuracy. You measure two things separately: top-20 retrieval accuracy is 62%, and when you hand the reader a passage you know contains the answer, it is right 68% of the time.

Do this before continuing: (1) compute the ceiling implied by those two numbers; (2) decide whether the 31% is explained; (3) say which component to work on and what the maximum gain from a perfect version of it would be.

Worked answer. The ceiling is 0.62 × 0.68 = 0.4216, so 42.2%. You are at 31%, which is 11 points below the ceiling — so the two measured components do not explain the gap and there is a third failure somewhere (span selection, answer normalisation, or a mismatch between the passages you evaluated the reader on and the ones retrieval actually returns). Now the counterfactuals: a perfect reader with the same retriever caps at 62%; a perfect retriever with the same reader caps at 68%. Both are worth roughly the same at the ceiling, but retrieval improvements are cheaper here, because 62% top-20 is far from what a tuned dense retriever achieves while 68% reader accuracy is already respectable. The instructive part is the 11-point unexplained gap: measure it before optimising anything, because it is often the largest single bucket and it is invisible in the two headline numbers.

The vocabulary, fixed now

Six words get used in precise senses for the rest of this lesson. Pin them here and nothing later will be ambiguous.

TermPrecise meaning in this lesson
PassageOne indexable unit — a disjoint 100-word chunk of a Wikipedia article, with its title prepended. Not a document, not a sentence
RetrieverA function from a question to a ranked shortlist over the whole corpus. Must be cheap enough to run against 21M passages per query
Positive / goldThe one passage designated correct for a question during training. Chapter 6 is about how that designation is made and how often it is wrong
NegativeAny passage placed in the loss's denominator for a question. Chapter 4 is about which ones to choose
Top-k accuracyFraction of questions whose top-k shortlist contains the answer string somewhere. The metric every number in this lesson reports
IndexThe 21M stored passage vectors plus the search structure over them. The thing that must be rebuilt whenever the passage encoder changes

The paper's three claims, numbered

Hold them separately, because they are usually collapsed into "DPR beat BM25" and two of the three are more interesting than that.

Claim 1 — no special pretraining is needed
A dual encoder fine-tuned on ordinary question–passage pairs beats a Lucene BM25 system by a large margin on top-20 accuracy, with no Inverse Cloze Task and no retrieval-oriented pretraining stage
Claim 2 — higher retrieval accuracy converts into better answers
Swapping the retriever, holding the reader design fixed, produces new state of the art on multiple open-domain QA benchmarks — 41.5 exact match on Natural Questions
Claim 3 — the recipe is the contribution
An extensive ablation isolating which choices mattered. The answer is: the negatives, by a wide margin. Everything else is stock

Claim 3 is why this lesson has a Chapter 4 that is longer than its architecture chapter. A paper whose main result is an ablation is telling you where the field's leverage is, and for the next three years everybody who read it correctly went and worked on negatives.

The index is a photograph, not a window

One detail with consequences: the corpus is the December 2018 English Wikipedia dump. Frozen. Every number in this paper is about a world that stopped in 2018.

That is correct methodology — a fixed corpus is what makes results comparable across papers and years — and it quietly encodes an assumption that breaks the moment you deploy: the index is a snapshot, and snapshots go stale. A question about an event from last week cannot be answered, and the system will not say so; it will confidently return the twenty most similar 2018 passages, exactly as the fixed-head classifier confidently names a class it was never taught.

Staleness sourceSparse indexDense index
New document arrivesAppend to posting lists, searchable in microsecondsOne encoder pass, then a graph insert. Feasible, but HNSW quality degrades under sustained inserts
Document editedRewrite its postingsRe-encode and replace the vector; the old vector must be found and removed
Model improvedNot a conceptEvery vector is invalid. Full rebuild — Chapter 7
The world changedNeither index knows. This is a data-freshness problem, not a retrieval oneSame

Worth holding as you read the results: the 78.4% is measured against a corpus that certainly contains the answer, for questions that were filtered to be answerable. Production traffic satisfies neither condition.

A sizing exercise worth doing before you believe any of this

One vector per passage. Sit with how strange that is.

A 100-word English passage is roughly 600 bytes of UTF-8 text. Its DPR embedding is 768 float32 values, which is 3,072 bytes. The summary is five times larger than the thing it summarises — and it contains enormously less: you cannot reconstruct a single word of the passage from its vector.

RepresentationBytes per passageWhole corpusCan you recover the text?
Raw text≈ 600≈ 12.6 GBTrivially — it is the text
Sparse BM25 postings≈ 400 (amortised)≈ 8 GBBag of words, yes; order, no
DPR vector (fp32)3,07264.56 GBNo — not one word

So the index is five times the size of the corpus and strictly less informative about its content. Why is that a good trade?

Because the vector is not a compression of the passage. It is a compression of the set of questions the passage answers, arranged so that the questions themselves land nearby. It has thrown away everything about what the text says and kept only its position in a space where proximity means relevance. That is a different object from a summary, and it is the whole conceptual move of this paper.

The consequence you will feel in production. Because the vector holds no text, the index cannot serve results on its own — you always need a separate store mapping passage ids back to strings (Chapter 7's "hydrate" step). Teams discover this on their first build, usually by getting back twenty integers and realising they have nothing to show a user.
A team improves their reader from 70% to 78% accuracy-given-evidence while leaving BM25 (R@20 = 59.1%) in place. A rival team leaves the reader at 70% and swaps BM25 for DPR (R@20 = 78.4%). Which statement is best supported?

Chapter 1: Sparse vs Dense, Honestly

To understand precisely what dense retrieval buys, you have to first understand precisely what BM25 is — not as a black box labelled "the baseline", but as a specific vector space with specific properties. Let us derive it, because the derivation is where its ceiling becomes visible.

Building BM25 from three ideas

Start with the crudest possible scorer: count how many query terms appear in the passage. Query "irish sea between england ireland", passage about the Irish Sea — four matches. Passage about Irish literature — one match. Useful, and immediately broken in three ways.

Problem 1: not all terms are equally informative. The term the appears in essentially every passage, so matching on it tells you nothing. The term Thoros appears in perhaps a dozen passages out of 21 million, so matching on it tells you almost everything. We need a weight that rises as a term gets rarer. That weight is inverse document frequency:

IDF(t) = ln( (N − n(t) + 0.5) / (n(t) + 0.5) + 1 )

where N is the number of passages in the corpus and n(t) is how many of them contain term t. Let us feel it with real magnitudes. With N = 21,015,324:

Termn(t) — passages containing itIDF(t)
the≈ 20,000,000ln(1.0508) = 0.050
sea≈ 150,000ln(140.1) = 4.942
ireland≈ 60,000ln(351.3) = 5.862
thoros≈ 12ln(1,681,226) = 14.335

Check the first row by hand so the formula stops being decoration. n = 2.0×107, N = 2.1015×107. The numerator is 2.1015×107 − 2.0×107 + 0.5 = 1,015,324.5. The denominator is 2.0×107 + 0.5. The ratio is 0.050766. Add 1 and take the log: ln(1.050766) = 0.04952. And the last row: (21,015,324 − 12 + 0.5)/12.5 = 1,681,225, plus 1, log = 14.3352. The point is the spread: matching "thoros" is worth roughly 290 times as much as matching "the".

Problem 2: repetition saturates. A passage that says "sea" ten times is more about the sea than one that says it once — but not ten times more. The tenth occurrence adds almost nothing. So we pass the raw term frequency f(t,D) through a saturating function:

tf-component = f(t,D) · (k1 + 1) / ( f(t,D) + k1 )   (before length normalisation)

With the standard k1 = 1.2, walk the curve: f = 1 gives 2.2/2.2 = 1.000; f = 2 gives 4.4/3.2 = 1.375; f = 5 gives 11/6.2 = 1.774; f = 20 gives 44/21.2 = 2.075; f → ∞ approaches k1+1 = 2.2. The first occurrence buys you 1.000. The next nineteen occurrences together buy you 1.075. That is the saturation, and it is the entire reason BM25 beats naive term counting.

Problem 3: long passages match everything. A 2,000-word passage contains more of every term by accident. So we normalise by length relative to the corpus average, with a knob b controlling how aggressively:

BM25(q,D) = ∑t ∈ q IDF(t) · f(t,D)(k1+1) / ( f(t,D) + k1(1 − b + b · |D| / avgdl) )

with the near-universal defaults k1 = 1.2, b = 0.75. DPR's corpus is chunked into fixed 100-word passages, so |D| = avgdl = 100 for every passage and the length term collapses to exactly 1 — a small, quietly important detail: fixed-length chunking removes one of BM25's three ideas, and equally removes one of its failure modes.

A BM25 score, computed by hand

Question: "who is the bad guy in lord of the rings". After stopword removal the scoring terms are bad, guy, lord, rings. Two candidate passages, with |D| = avgdl so the denominator is f + 1.2:

TermIDFPA (Sala Baker / villain Sauron)PB (LOTR film trilogy box office)
bad3.9f = 0f = 0
guy5.1f = 0f = 0
lord4.6f = 2f = 3
rings5.4f = 2f = 3

Passage A, the one that answers the question:

lord: 4.6 × 2(2.2)/(2 + 1.2) = 4.6 × 4.4/3.2 = 4.6 × 1.375 = 6.325
rings: 5.4 × 1.375 = 7.425
bad, guy: 0 + 0
BM25(q, PA) = 13.750

Passage B, which is about ticket sales and answers nothing:

lord: 4.6 × 3(2.2)/(3 + 1.2) = 4.6 × 6.6/4.2 = 4.6 × 1.5714 = 7.229
rings: 5.4 × 1.5714 = 8.486
BM25(q, PB) = 15.715

The wrong passage wins, by 14%, and it wins for the right reason under BM25's own logic: it says "Lord of the Rings" one more time. The two terms that would have decided the question — bad guy against villain — contributed a total of zero to both scores. There is no setting of k1, b, or the IDF formula that fixes this, because the fix would require the term villain in the passage to contribute to the query term bad, and BM25's sum runs over query terms matching themselves.

State it geometrically and it becomes airtight. BM25 is a similarity in a vector space with one dimension per vocabulary term. A passage is a vector with about 100 non-zero entries out of a vocabulary of hundreds of thousands. The basis vectors are the terms, and distinct basis vectors are orthogonal by definition. "Villain" and "bad guy" are different coordinates, so their inner product is exactly 0. Every classical IR improvement — stemming, stopwords, IDF variants, field weighting, query expansion — is a reweighting or a merging of coordinates in that same fixed basis. None of them can rotate the basis, because the basis is the vocabulary, and the vocabulary is given.

The dense alternative, in one sentence

Learn a function that maps text into R768, where the axes are not terms but whatever directions turn out to be useful, and where the map is trained so that a question lands near the passage that answers it.

That is the whole idea. Note carefully what changed and what did not:

Sparse (BM25)Dense (DPR)
Dimensions|V| ≈ 105–106, one per term768, one per learned direction
Non-zeros per passage≈ 100 (sparse)768 (dense) — hence the names
Basis meaningFixed: a termLearned: no single term, no interpretable label
SimilarityWeighted term overlapDot product of two learned vectors
WeightsFormula with 2 hyperparameters220M trained parameters
Training data neededNoneQuestion–passage pairs — as few as 1,000 to beat BM25 on NQ
Index structureInverted index — term → posting listFlat or graph-based ANN over 21M vectors
Adding one documentAppend to a few posting lists: microsecondsOne encoder pass, then an index insert — and after a model update, re-encode all 21M

The last row is where a great many production systems have quietly stayed sparse, and Chapter 7 gives that its due weight.

Where BM25 still wins — four cases, with the mechanism

A lesson that only shows you the winning cases has taught you a marketing slide. Here are the four regimes where the sparse index is genuinely the better tool, each with the reason it happens.

1. Rare literal strings. The DPR paper's own example: "Who plays Thoros of Myr in Game of Thrones?" BM25 ranks the correct passage first, because Thoros has IDF ≈ 14.3 and occurs in about a dozen passages — the term itself is nearly a unique identifier. DPR ranks a passage about a different Game of Thrones character above it. The mechanism: a 768-dimensional vector is an average of a passage's content, and averaging is lossy in exactly the way that hurts rare tokens. A single unusual proper noun in a 100-word passage contributes roughly 1% of the pooled representation, whereas in the sparse space it contributes an entire high-IDF coordinate.

2. Exact-match constraints. Numbers, dates, model codes, error strings, chemical formulae. "ORA-01555" and "ORA-01552" are one character apart, which is one coordinate apart in the sparse space and, for a subword-tokenised transformer trained on natural language, frequently indistinguishable in the dense one.

3. Out-of-domain corpora. A dense retriever is a learned function, so it inherits the distribution it was trained on. Move it to legal contracts, clinical notes, or a private codebase and its geometry is a guess. BM25's "geometry" is recomputed from the new corpus's own term statistics, for free, on ingest. This is not a small effect — it is the reason the BEIR benchmark exists, and the reason Contriever (Chapter 5) is an important paper.

4. Questions written while looking at the passage. This one is subtle and it produces the single most interesting number in DPR's results table. On SQuAD, BM25 beats DPR: 68.8% versus 63.2% at top-20. SQuAD's annotators wrote each question with the paragraph in front of them, so they naturally reused its vocabulary. That gives an artificially high lexical overlap between question and gold passage — precisely the regime BM25 was built for. Chapter 6 shows this is not the only thing wrong with SQuAD as a retrieval benchmark, but it is the main one.

The honest summary, and the one you should carry into a design review. Dense retrieval wins when the question and the answer say the same thing in different words. Sparse retrieval wins when they say it in the same rare words. Real query traffic contains both, in proportions that depend entirely on your product — which is why the correct production answer, then and now, is usually both.

Hybrid: adding the two scores

DPR itself reports a hybrid. Retrieve a large candidate pool with each system, then rerank the union with a linear combination of the two scores:

score(q,p) = BM25(q,p) + λ · sim(q,p),   λ = 1.1

The effect is exactly what the four cases above predict — it helps most where the two systems fail on different questions, and it helps least where one of them is simply better:

Dataset (top-20)BM25DPRHybridReading
Natural Questions59.178.476.6Hybrid hurts — BM25 drags a strong retriever down
TriviaQA66.979.479.8Marginal gain
WebQuestions55.073.271.5Hybrid hurts again
CuratedTREC70.979.885.2+5.4 — the two systems are failing on different questions
SQuAD68.863.271.5The one dataset where DPR loses outright; hybrid rescues it

Look at the NQ row for a moment. Naive intuition says combining a 78.4 system with a 59.1 system should land between them or above the better one. It lands below the better one. Fusing scores is not free: you are adding a noisy opinion to a good one, and with a fixed λ you cannot tell, per query, which opinion to trust. That observation is the seed of the entire learned-reranking literature.

Concept check. Why does hybrid help most on CuratedTREC (+5.4) and hurt on Natural Questions (−1.8)?  …  CuratedTREC is tiny — about 1,125 training questions — so DPR is data-starved and makes many errors that BM25 does not; the two error sets overlap little, and a union with reranking recovers a lot. NQ has about 59,000 training questions, so DPR's errors are mostly the genuinely hard ones that BM25 also misses; there is little complementary signal left, and the BM25 term mainly injects lexical noise into an otherwise good ranking.

The sparse vector, written out

Abstract claims about orthogonality become obvious once you write the vectors down. Take a toy vocabulary of eight terms and index them as coordinates 1 through 8:

V = [ bad, guy, lord, rings, villain, sauron, box, office ]

The question "who is the bad guy in lord of the rings", after stopword removal, is the vector (with IDF weights in place of raw counts):

q = ( 3.9 , 5.1 , 4.6 , 5.4 , 0 , 0 , 0 , 0 )

Passage A, the one that answers it — "portraying the villain Sauron in the Lord of the Rings trilogy":

pA = ( 0 , 0 , 1.375 , 1.375 , 1.0 , 1.0 , 0 , 0 )

Passage B, about box office receipts, saying "Lord of the Rings" three times:

pB = ( 0 , 0 , 1.571 , 1.571 , 0 , 0 , 1.0 , 1.0 )

Now take the inner products, coordinate by coordinate. For pA: coordinates 1 and 2 contribute 3.9×0 + 5.1×0 = 0; coordinates 3 and 4 contribute 4.6×1.375 + 5.4×1.375 = 13.75; coordinates 5 and 6 contribute 0×1.0 + 0×1.0 = 0. Total 13.75. For pB: 4.6×1.571 + 5.4×1.571 = 15.71.

Look at coordinates 5 and 6. The passage has mass there — villain and sauron, the two words that make it the right answer — and the query has zero. Their product is zero. It is not "small because the words are only loosely related". It is zero because multiplication by zero is zero, and the query has zero in that coordinate because the user did not type that word.

What a dense encoder changes, in the same notation. EQ and EP map both texts into R768, where there is no "villain coordinate" and no "bad guy coordinate". Instead there is some direction — call it dimension 412, it has no name and nobody knows what it means — on which both villain and bad guy place positive mass, because the training data told the model they play the same role. The inner product is then non-zero for the right reason. The basis moved from "words someone typed" to "distinctions that helped the loss".

Why earlier dense attempts failed

People had been embedding documents into low-dimensional spaces since the 1990s. Knowing why those attempts did not displace BM25 tells you what the missing ingredient actually was — and it was not "deep learning".

ApproachHow it built the spaceWhy it lost to BM25
LSA / LSI (1990)Truncated SVD of the term–document matrixUnsupervised and linear. Captures topic co-occurrence, not "answers". Two passages about Lord of the Rings are near each other whether or not either names Sauron
LDA (2003)Probabilistic topic modelSame problem, sharper: it is explicitly a topic model, and topical similarity is precisely the easy half of retrieval
Averaged word2vec / GloVe (2013–2015)Mean of static word vectorsA bag of words with no order and no context: "dog bites man" and "man bites dog" are identical. And no task supervision — the space was fitted to co-occurrence, not to relevance
ORQA (2019)BERT dual encoder + Inverse Cloze pretrainingDid not lose — it won. But it needed a large pretraining stage and left the passage encoder unfinetuned
DPR (2020)BERT dual encoder + contrastive fine-tuning on real pairs with mined negatives

Read the "why" column downwards and one word repeats: unsupervised. Every pre-2019 method built its geometry from co-occurrence statistics, which give you topical similarity for free and answer-hood never. The two ingredients DPR combines are contextual pretraining (so "villain" and "bad guy" are already near each other before you start) and contrastive fine-tuning against confusable negatives (so the space learns the difference between "same topic" and "answers this"). Neither alone is sufficient, which is exactly what Chapter 4's ablation table shows from the inside.

How sensitive is BM25 to its two knobs?

Before declaring the baseline beaten, it is fair to ask whether it was tuned. BM25 has exactly two hyperparameters, and it is worth knowing how much they can move.

k1 controls term-frequency saturation. Take a passage where a query term appears 3 times, and vary k1 in the tf component f(k1+1)/(f + k1):

k1tf component at f = 1at f = 3at f = 10Behaviour
0.01.0001.0001.000Pure binary — presence only, repetition ignored entirely
0.61.0001.3331.509Saturates fast
1.21.0001.5711.964The default
3.01.0002.0003.077Nearly linear in count over the useful range

Check the k1 = 1.2, f = 3 cell: 3 × 2.2 / (3 + 1.2) = 6.6/4.2 = 1.571. And k1 = 3.0, f = 10: 10 × 4 / 13 = 3.077.

b controls length normalisation, and DPR's fixed 100-word chunks make it inert: |D|/avgdl = 1 for every passage, so 1 − b + b·1 = 1 regardless of b. The entire second hyperparameter has no effect on this corpus.

So was the baseline fairly tuned? One knob is dead by construction and the other moves scores by tens of percent while rarely reordering the top of the list — because reordering requires the relative tf pattern between two passages to flip, and a monotone reweighting of counts mostly preserves order. This is the honest reason the paper does not agonise over BM25 tuning: the failure in Chapter 0 was a zero in a coordinate, and no reweighting of the other coordinates can repair a zero.

The other sparse family: query likelihood

BM25 is not the only classical scorer, and it is worth knowing its main rival exists so "sparse retrieval" does not collapse into one formula in your head. Query-likelihood language models score a passage by the probability it would generate the query:

score(q, D) = ∑t ∈ q log [ λ · P(t | D) + (1 − λ) · P(t | corpus) ]

The second term is smoothing: it stops a single missing query term from sending the whole product to zero, and it plays the same role IDF plays in BM25 — terms that are common in the corpus contribute little because the background term already explains them.

Different derivation, different intuition, near-identical behaviour in practice, and identical failure on the lexical gap: P("bad" | passage about Sauron) is whatever the background says, because the word is not there. The limitation is not BM25's; it belongs to the term basis, and every member of the family inherits it.

Measure your own lexical gap in an afternoon

Whether dense retrieval will help you is an empirical question about your query distribution, and it is cheap to answer before building anything.

# For each (query, gold passage) pair in your eval set:
def idf_coverage(query, gold, idf):
    terms = [t for t in tokenize(query) if t not in STOPWORDS]
    if not terms: return 1.0
    hit = sum(idf[t] for t in terms if t in tokenize(gold))
    return hit / sum(idf[t] for t in terms)   # IDF-weighted, not raw overlap

Plot the histogram. The mass near 1.0 is the population BM25 will serve well. The mass below about 0.4 is your lexical gap — queries whose most informative terms simply do not occur in the passage that answers them.

Shape of your histogramWhat it meansWhat to do
Mass piled near 1.0Your queries reuse document vocabulary. Possibly because they were written from the documents — see Chapter 6BM25 is strong here. Check whether your eval is realistic before concluding anything
Broad, centred near 0.6A real mixture. Both systems will win different queriesHybrid. This is most real products
Heavy mass below 0.4A genuine lexical gap — jargon, paraphrase, cross-lingual, or a large vocabulary mismatch between users and documentsDense retrieval is where your points are

Fusing two rankings, worked

Chapter 1 asserted that hybrid fusion is not free. Here is why, with numbers, on five candidate passages.

PassageBM25 scoreBM25 rankDPR scoreDPR rankRelevant?
P115.7142.14No
P2 (gold)13.8271.41Yes
P312.1358.02No
P49.4450.33No
P56.2518.75No

Linear fusion, score = BM25 + 1.1 × DPR. For P1: 15.7 + 1.1×42.1 = 15.7 + 46.31 = 62.01. For P2: 13.8 + 1.1×71.4 = 13.8 + 78.54 = 92.34. P2 wins comfortably — here.

Now change one thing that has nothing to do with relevance: suppose this corpus's BM25 scores run ten times larger, as they do on a corpus with different length statistics, so P1 scores 157 and P2 scores 138. Recompute: P1 = 157 + 46.31 = 203.31, P2 = 138 + 78.54 = 216.54. Still P2, but the margin has collapsed from 49% of the smaller score to 6.5%. One more factor of two on the BM25 side and the ranking flips, with no change in either system's judgement.

That is the defect in one line. λ = 1.1 is not a property of the method; it is a property of the ratio between two score scales, one of which depends on your corpus statistics and the other on your model's learned norms. Retrain the encoder and λ is wrong. Change corpus and λ is wrong. A published λ transfers to your system approximately never.

Reciprocal rank fusion avoids the problem by discarding the scores entirely and using only positions:

RRF(p) = ∑systems 1 / (60 + ranksystem(p))

For P1: 1/61 + 1/64 = 0.016393 + 0.015625 = 0.032018. For P2: 1/62 + 1/61 = 0.016129 + 0.016393 = 0.032522. P2 wins — and it wins by the same amount no matter what scale either system's scores are on, today or after any retraining. The constant 60 damps the influence of the very top ranks so that one system cannot dominate on a single confident hit.

The cost of that robustness is real: RRF cannot express "BM25 was extremely confident about this one", because it has thrown that information away. In practice the calibration-freedom wins, which is why RRF is the more common production choice despite being the cruder idea.

BM25 is not language-neutral either

One more limitation of the sparse basis, easy to miss because English hides it. BM25's coordinates are tokens, and tokens come from a tokeniser, and tokenisers are language-specific.

LanguageWhat breaksConsequence for term matching
GermanCompounds: Donaudampfschifffahrt is one tokenA query for Dampfschiff matches nothing without decompounding
Chinese, JapaneseNo whitespaceYou must segment first, and segmentation errors become retrieval errors
Arabic, Finnish, TurkishRich morphology — dozens of surface forms per lemmaEach inflection is its own coordinate unless you stem well
Any cross-lingual queryQuery and document share no tokens at allScore is exactly zero. The lexical gap becomes total

The last row is the lexical gap taken to its limit, and it is where dense retrieval's advantage is least arguable: a multilingual encoder places "villain" and "Bösewicht" near each other for the same reason it places "villain" and "bad guy" near each other, and no amount of term engineering can produce a non-zero score between two disjoint vocabularies.

The cleverest classical patch, and exactly where it breaks

There is one more sparse technique worth knowing, because it is the closest classical IR ever came to bridging the lexical gap, and its failure mode is instructive.

Pseudo-relevance feedback — RM3 is the best-known variant — runs in two passes. First retrieve the top ten passages with plain BM25. Then assume they are relevant, mine their highest-IDF terms, add those terms to the query with learned weights, and retrieve again.

q = { bad, guy, lord, rings } →  top-10 pass 1
expansion terms mined: { sauron, frodo, tolkien, villain, gandalf }
q′ = { bad, guy, lord, rings, 0.4·sauron, 0.3·villain, … } →  retrieve again

If villain makes it into the expansion set, the second pass finds the Sala Baker passage and the lexical gap is bridged — with no learning and no neural network. This genuinely works, and it is why BM25+RM3 rather than plain BM25 is the honest sparse baseline in modern papers.

Now the failure, which is structural. The expansion terms come from the top ten of the first pass, so RM3 can only bridge a gap when a document already found by term matching happens to contain the bridging word. Chapter 0's failure was that the first pass returns ten box-office and plot-summary passages. If none of them says "villain", the term never enters the expansion set and the second pass repeats the first.

SituationRM3 outcome
A bridging term appears in an already-retrieved passageWorks — a real, free improvement
The bridging term appears only in passages the first pass missedCannot help — it never sees the word
The first pass is dominated by one off-topic sense of an ambiguous queryQuery drift — expansion amplifies the wrong sense and results get worse
The general shape. RM3 borrows semantics from the corpus at query time, one hop at a time, and only along paths that term matching already opened. A dense retriever borrows semantics from a pretraining corpus at training time, across every path at once, whether or not any document in your index makes the connection explicit. That is the difference between a patch and a change of basis, and it is why the gap closed only when the basis moved.
A colleague proposes fixing the "bad guy" / "villain" failure by adding a synonym-expansion step to BM25: for every query term, also search for its WordNet synonyms. Why does this not resolve the underlying limitation?

Chapter 2: The Dual Encoder

We want a function that scores how well a passage answers a question. The most accurate such function is obvious and unusable, so let us start there — understanding why it is unusable is what forces the architecture.

The model you would build if compute were free

Concatenate the question and the passage, feed the pair into a single BERT, and let every question token attend to every passage token through twelve layers of self-attention. Read a relevance score off the [CLS] position. This is a cross-encoder, and it is meaningfully better than anything we are about to build, because it can perform reasoning that requires seeing both texts at once: "the question asks when, and this passage's only date is in a sentence about a different person."

Now price it. To retrieve for one question you must score every passage in the corpus:

21,015,324 passages × 1 BERT-base forward pass each

One BERT-base forward pass on a 128-token input costs roughly 2 × 110×106 params × 128 tokens ≈ 2.8×1010 FLOPs — call it 28 GFLOPs. Multiply:

2.1×107 × 2.8×1010 = 5.9×1017 FLOPs  per question

On an accelerator sustaining 150 TFLOP/s that is 3,930 seconds — 65 minutes per question, on a full GPU. And it cannot be precomputed, because the passage representation depends on the question. Cross-encoders are not slow; they are categorically the wrong shape for first-stage retrieval. They reappear in Chapter 5 as rerankers over a shortlist of 100, where 100 × 28 GFLOPs = 2.8 TFLOPs = about 19 ms is perfectly reasonable.

The single architectural constraint that generates everything else. For retrieval to be tractable, the passage representation must not depend on the question. That is the whole requirement. It forces the two texts to be encoded independently, it forces the interaction between them to be something cheap you can compute 21 million times per query, and it is why the resulting family is called bi-encoders or dual encoders: two encoders that never see each other's input.

The architecture, with every tensor shape

DPR is two BERT-base-uncased models, trained jointly but with entirely separate parameters. Call them EQ for questions and EP for passages. Here is the exact data flow — and this is the part you should be able to reproduce from memory, because the shapes are the model.

The passage tower, run offline over the whole corpus:

StageObjectShape / type
0. RawWikipedia article textString, millions of characters
1. ChunkDisjoint 100-word blocks21,015,324 strings, ~100 words each
2. Prefixtitle [SEP] passage_textString — the title is prepended, and this is worth about +1 point
3. TokeniseWordPiece ids + attention mask(Lp,) with Lp ≤ 256, typically ~140
4. Embedtoken + position + segment embeddings(Lp, 768)
5. Encode12 transformer layers, 12 heads, FFN width 3072(Lp, 768)
6. PoolTake the [CLS] position only — index 0(768,)
7. Storefloat32 vector in the index3,072 bytes per passage

The question tower, run online, once per question:

StageObjectShape / type
1. Raw"who is the bad guy in lord of the rings"String, ~10 words
2. TokeniseWordPiece ids(Lq,), Lq typically 8–20
3. EncodeA different BERT-base, its own 110M parameters(Lq, 768)
4. Pool[CLS](768,)

The interaction, and this is all of it:

sim(q, p) = EQ(q)T EP(p)  =  ∑i=1768 qi pi

A dot product. No projection head, no MLP, no normalisation, no learned temperature. The paper tried alternatives and reports that L2 distance performs comparably while cosine similarity performs worse; a plain inner product is the choice, and Chapter 3 explains why the missing normalisation is doing real work.

Feel the cost inversion

Here is the number that makes bi-encoders viable, and it is worth computing rather than reading. Scoring one question against the entire corpus, once the passage vectors exist:

21,015,324 passages × 768 dims × 2 FLOPs (one multiply, one add) = 3.23×1010 FLOPs

That is 32 GFLOPs to brute-force score the whole of Wikipedia — almost exactly the cost of a single BERT forward pass on one passage. Put differently: the compute you would have spent looking at passage number one under a cross-encoder is the compute the bi-encoder spends looking at all twenty-one million.

The trade, stated precisely. A cross-encoder has 21M × (deep interaction). A bi-encoder has 21M × (one dot product) + 1 × (deep encoding). By pushing all the depth to the offline side and leaving only a bilinear form online, the per-query cost drops by a factor of about 18 million. What you give up is exactly the deep interaction — the model can never compare a question token to a passage token. Everything it wants to say about the passage must be compressed into 768 numbers before it knows what will be asked.

Explore the two shapes

Bi-encoder vs cross-encoder — where the compute goes

Toggle between the two architectures and watch what moves between the offline and online halves of the diagram. The shapes on the wires are real; the cost readouts use the 28 GFLOP / 1,536 FLOP figures derived above. "Shared tower" ties the two sets of weights, which is what Contriever does — the diagram shows what changes and what does not.

Why two towers and not one?

Nothing forces EQ and EP to be different networks. Tying them halves the parameter count and is what several later models do. DPR unties them. Three reasons, in ascending order of importance.

The distributions genuinely differ. A question is a short interrogative fragment: "who is the bad guy in lord of the rings", ten tokens, no verb agreement with the answer, often ungrammatical, frequently containing a question word that appears nowhere in the passage. A passage is 100 words of declarative encyclopaedic prose with a title. Asking one set of weights to be excellent at both is a real constraint, and untied towers simply remove it.

The two sides are used asymmetrically at inference. The passage tower runs 21 million times, offline, once. The question tower runs once per user request, on the latency path. Untied towers let you make different engineering choices per side — distil the question tower for speed, keep the passage tower large, quantise only the passage outputs.

Untying changes what the loss can express. With shared weights, sim(q,p) is symmetric-ish: the model is pressured toward "these two texts are about the same thing." With separate towers the model can learn an asymmetric relation — "this passage answers that question" — which is not the same relation and is not symmetric. A passage about Sauron answers "who is the bad guy in LOTR"; the string "who is the bad guy in LOTR" does not answer anything about Sauron.

And the counter-evidence, because it exists: Contriever ties the towers and works extremely well, and several strong modern embedders (E5, BGE, GTE) use a single encoder with different instruction prefixes — "query: " and "passage: " — to recover the asymmetry inside one set of weights. That is a strictly better trade when you want one model that also serves symmetric tasks like clustering and similarity. DPR's untied choice was right for DPR's single task; it is not a law.

Realization note. If you implement this, the untied version is two AutoModel.from_pretrained("bert-base-uncased") objects, both trainable, both in your optimiser, both saved in your checkpoint. 220M parameters, ~880 MB in fp32 for weights alone, and Adam's two moment buffers push optimiser state to ~1.76 GB on top. That is why the paper's batch size of 128 required eight 32 GB V100s: the memory is dominated not by the model but by the activations of 128 questions plus 256 passages at up to 256 tokens each, all held for the backward pass.

Why [CLS], and what pooling throws away

Stage 6 in both tables is the quiet violence of the architecture: a passage of ~140 token vectors, each 768-dimensional, becomes a single 768-dimensional vector. The tensor goes from (140, 768) to (768,) — a 140× compression, performed by selecting one position.

Why is that not insane? Because BERT's [CLS] token is not a token of the text at all: it is a slot that attends to everything and is attended by everything, and during pretraining it is the position from which next-sentence-prediction is read. It is, by design, the position whose job is to summarise. Fine-tuning then reshapes what "summarise" means for this task.

Alternatives exist and matter. Mean pooling — averaging all token vectors — is what Contriever, E5, and most modern embedders use, and it is generally a little better out of the box, particularly without fine-tuning, because an untuned [CLS] from vanilla BERT is a famously poor sentence representation. Max pooling preserves peak activations. And no pooling at all is ColBERT: keep every token vector, and define similarity as a sum of per-token maxima. ColBERT recovers a great deal of the lexical precision we lost in Chapter 1, at roughly 100× the index size. Chapter 9 returns to it.

What pooling costs you, concretely: after stage 6 there is no way for the model to say "this passage contains the exact string Thoros of Myr." That fact has been averaged into a direction. Chapter 1's failure case is a direct consequence of stage 6, not of the training data or the loss.

Reading the model as one matrix multiply

At serving time, once every passage is encoded, the entire retriever is:

# offline, once — the index
P = encode_passages(corpus)          # (21_015_324, 768) float32  →  64.5 GB

# online, per question
q = E_Q(question)[0]                 # (768,)   the [CLS] row
scores = P @ q                       # (21_015_324,)
top_k = np.argpartition(-scores, k)[:k]  # (k,)  →  20 passage ids

Four lines. The research is entirely in how E_Q and encode_passages got their weights, and Chapters 3 and 4 are about nothing else.

Follow one passage all the way through

Abstractions hide arithmetic errors, so take a real chunk and walk it. Passage: "Sala Baker is a New Zealand actor and stuntman, best known for portraying the villain Sauron in the Lord of the Rings trilogy directed by Peter Jackson." Its article title is "Sala Baker".

StepResultShape
Prefix the titleSala Baker [SEP] Sala Baker is a New Zealand actor…string
WordPiece[CLS] sala baker [SEP] sala baker is a new zealand… [SEP](34,) ids
Pad to the batch maxids + attention mask of 1s and 0s(2, 256) for a batch of 2
Embedding lookup + positionstoken + position + segment, summed(2, 256, 768)
12 transformer layerscontextualised token states(2, 256, 768)
Slice position 0h[:, 0, :] — the [CLS] row(2, 768)

Notice the WordPiece step, because it is where Chapter 1's rare-string problem is born. "Thoros" is not in BERT's 30,522-token vocabulary, so it becomes something like th ##oro ##s — three fragments, each of which appears in thousands of unrelated words. The sparse index kept "thoros" as a single coordinate with IDF 14.3. The dense encoder has already dissolved it into common subwords before layer 1, and then averages the whole passage into one vector. Two lossy steps, both applied to exactly the signal that mattered most.

The memory arithmetic of one training step

People are surprised that DPR needed eight 32 GB V100s to run batch size 128, given that the model is only 220M parameters. The model is not the problem.

ItemArithmeticMemory (fp32)
Weights220M × 4 bytes0.88 GB
Gradients220M × 4 bytes0.88 GB
Adam moments (2×)2 × 220M × 4 bytes1.76 GB
Question activations128 seqs × 256 tok × 768 × 12 layers × ~10 tensors × 4 B≈ 12 GB
Passage activations256 seqs × 256 tok × 768 × 12 × ~10 × 4 B≈ 24 GB
Similarity matrix128 × 256 × 4 bytes0.00013 GB

Two readings. First, activations dominate by a factor of ten, and the passage side dominates the activations because there are twice as many passages as questions once you add a hard negative each. Second — look at the last row. The object this entire paper is about, the similarity matrix holding 32,768 comparisons, is 131 kilobytes. Negatives are free in memory as well as in compute; what is expensive is the encoder passes that produced the vectors.

Which immediately explains why gradient checkpointing and mixed precision are standard in every serious implementation: they attack the 36 GB of activations, and every gigabyte recovered goes straight into a larger batch, which goes straight into more negatives.

Truncation, and the passages you silently lose

Sequences are capped at 256 tokens. A 100-word English passage tokenises to roughly 130–160 WordPiece tokens, so the cap almost never bites — which is precisely why fixed-length chunking was worth doing. Had DPR indexed whole sections, a meaningful fraction would have been truncated, and the truncated tail is invisible to the model but still present in the passage a user is shown. Silent truncation is one of the most common bugs in home-built retrieval systems: the index and the display disagree about what the document says.

Where the 28 GFLOPs actually go

The per-passage cost has been used repeatedly, so it is worth opening. One BERT-base layer, at sequence length L = 128 and hidden size d = 768, does two things:

ComponentArithmeticFLOPs per layer
Q, K, V projections3 × 2 × L × d × d4.53×108
Attention scores + weighted sum2 × 2 × L2 × d5.03×107
Output projection2 × L × d × d1.51×108
Feed-forward (d → 4d → d)2 × 2 × L × d × 4d1.21×109
Total per layer1.86×109
× 12 layers2.2×1010 — the ~28 GFLOP estimate, give or take the embedding lookup and layer norms

Two observations that pay off later. The feed-forward network is 65% of the cost, not attention — at L = 128 the quadratic term is only 2.7% of a layer, which is why "attention is quadratic" is the wrong worry at passage length and the right worry at document length. And the whole thing is linear in L, so the choice of 100-word chunks is directly a choice about your encoding budget: doubling chunk length roughly doubles the 8.8 hours in Chapter 7.

Why 768 dimensions?

Because that is BERT-base's hidden size and DPR added no projection. But it is worth asking what the number costs, because it is the one architectural knob that changes your index by an order of magnitude.

dIndex size (21M passages, fp32)Brute-force scan FLOPs/queryTypical quality
12810.8 GB5.4 GFLOPsNoticeably worse without special training
25621.5 GB10.8 GFLOPsClose to 768 if trained for it
76864.6 GB32.3 GFLOPsDPR's choice
102486.1 GB43.0 GFLOPsMarginal gains; the index cost is real

Memory and scan cost are both exactly linear in d, while retrieval quality is emphatically not — it saturates. That asymmetry is the entire motivation for Matryoshka representation learning, which trains one model whose first 64, 128, 256… dimensions are each independently usable, so you can pick your point on this table after training rather than before.

The interaction spectrum

DPR sits at one end of a well-defined spectrum, and seeing all four positions at once makes the trade legible.

FamilyWhat is stored per passageInteractionIndex for 21MQuality
Sparse (BM25)~100 term weightsTerm matchSmallBaseline
Bi-encoder (DPR)One 768-d vectorOne dot product64.6 GBStrong
Late interaction (ColBERT)One vector per token (~140)Sum of per-query-token maxima≈ 9 TB uncompressedStronger
Cross-encoderNothing — recomputed per pairFull joint self-attentionn/aStrongest, unusable for first-stage

Read down the "what is stored" column: it is a ladder of how much you refuse to throw away before the question arrives. DPR throws away everything except one direction. That is why it is fast and why Chapter 1's rare-string failure exists, and the two facts are not separable — they are the same decision.

What pooling choice actually does, on a tiny example

Take a four-token passage whose contextual vectors are 2-dimensional, so you can hold the whole thing in your head:

[CLS] = (0.8, 0.1),  "villain" = (0.2, 0.9),  "sauron" = (0.3, 0.8),  "trilogy" = (0.7, 0.2)

[CLS] pooling gives (0.8, 0.1) — whatever that position learned to summarise. Mean pooling over the three content tokens gives ((0.2+0.3+0.7)/3, (0.9+0.8+0.2)/3) = (0.4, 0.633).

Now score against a question vector q = (0.1, 0.9), which points in the "antagonist" direction:

[CLS]: 0.1×0.8 + 0.9×0.1 = 0.17
mean: 0.1×0.4 + 0.9×0.633 = 0.610

Mean pooling wins here, and the reason generalises: the signal lives in two of the tokens, and averaging keeps it while an untuned [CLS] — which vanilla BERT trained for next-sentence prediction, not for summarising content — may be pointing anywhere. Fine-tuning teaches [CLS] to point where it should, which is why DPR can use it and why an off-the-shelf BERT [CLS] embedding is famously terrible.

And the reverse case, which is Chapter 1's rare-string failure in miniature: suppose "sauron" is the only informative token and the other three are boilerplate near (0.7, 0.2). The mean is dominated three-to-one by boilerplate and the one informative direction is diluted to a quarter of its magnitude. Averaging is a low-pass filter, and rare, decisive tokens are exactly the high-frequency content it removes.

PoolingGood atBad atUsed by
[CLS]Task-specific summaries after fine-tuningAnything zero-shot; the untuned position is near-uselessDPR, SBERT (as an option)
MeanRobustness, zero-shot, short textsLong passages where one token is decisiveContriever, E5, BGE, most modern embedders
MaxPreserving peaksNoisy; one outlier dimension dominatesRare in retrieval
None (all tokens)Rare strings, exact matchingIndex size — roughly 100×ColBERT

The title, and what it is worth

Prepending the article title is a one-line change worth about a point, which is a large return for a line. The mechanism is easy to see once you look at a real chunk.

Chunk 4 of the Sala Baker article might read: "He also performed stunts in Xena: Warrior Princess and appeared in several other productions filmed in New Zealand during the same period." Nowhere does it say who "he" is. As a standalone passage this is nearly unretrievable — there is no question it can answer, because its subject is missing.

without title:  "He also performed stunts in…" →  subject unknown
with title:  "Sala Baker [SEP] He also performed stunts in…" →  resolvable

Two general lessons. First, chunking destroys coreference, and every chunk after the first in an article is full of pronouns whose antecedents were left behind. Any cheap way to reinject context — the title, the section heading, a one-line summary — buys accuracy for almost nothing. Second, this is the same problem BM25 has, and prepending the title helps it too; it is a corpus-preparation improvement rather than a dense-retrieval one, and honest comparisons give it to both systems.

Padding waste, and the batching detail that doubles your throughput

One implementation fact that is invisible in the equations and very visible in a profiler. Transformers process a batch as a rectangle: every sequence is padded to the longest one in the batch, and the padded positions still cost full attention and feed-forward compute.

Questions are short and highly variable — 6 tokens for "who is sauron", 30 for a verbose one. Batch them naively and:

BatchLongestReal tokensComputed tokensWaste
32 questions, random order30≈ 384 (mean 12)32 × 30 = 96060%
32 questions, length-bucketed≈ 14 per bucket≈ 384≈ 44814%

Sorting by length before batching — bucketing — costs one sort and recovers most of the difference. On the 21M-passage encoding job in Chapter 7 this is worth hours, because passage lengths vary too once you account for short final chunks of articles.

The same effect at serving time is subtler and worth knowing: if your inference server batches concurrent requests, one unusually long query inflates the compute for every request batched with it, so your p99 latency is partly a function of other people's queries. Length-bucketed batching fixes it there too.

Making the online tower cheaper

The two towers are used with wildly different frequencies — the passage tower runs 21 million times offline and the question tower runs once per user request, on the latency path. Untied weights let you optimise them separately, and the standard moves are asymmetric:

TowerOptimisationWhy it is safe here
Question (online)Distil to 6 layers, or to a smaller hidden size with a projection back to 768Questions are short and simple; most of BERT's capacity is unused on a 10-token interrogative
Question (online)Quantise to int8, compile, cache by query stringThe output is one vector; small numerical error is far below the ranking gaps
Passage (offline)Keep it large; run in fp16 with big batchesRuns once, off the latency path. Quality here is permanently baked into the index

Note the constraint that survives all of this: whatever you do to the question tower, its output must still live in the same 768-dimensional space as the index. Distillation here is not "make a smaller model"; it is "make a cheaper map into an existing, frozen geometry", which is a much easier target and is why it works so well.

A team replaces DPR's dot product with a small two-layer MLP applied to the concatenation [EQ(q); EP(p)], reasoning that a learned scorer must beat a fixed bilinear form. What breaks?

Chapter 3: The Objective

We have two towers and a dot product. Now: what should the number they produce be trained to do?

The naive answer is "be large for matching pairs." Follow it honestly and it collapses in one line. If the only term in the loss is −sim(q, p+), then gradient descent will make both vectors longer without bound — scale every embedding by 10 and every similarity multiplies by 100 — and the resulting model, whose vectors all point the same way with enormous norm, scores every passage identically high. The loss goes to −∞ and the retriever is useless. This failure has a name, representational collapse, and the cure is structural, not a regularisation trick.

Reframe the task as a multiple-choice question

Here is the move. Instead of asking the model to produce a calibrated relevance score, ask it to win a competition. Present it with one question and n+1 passages, exactly one of which is the gold. Ask: which one?

Now the training instance is a classification problem with n+1 classes, and there is a completely standard tool for that: treat the similarities as logits and apply softmax cross-entropy. Written out, with the gold passage first:

L(q, p+, p1, …, pn) = − log  exp(sim(q, p+)) / [ exp(sim(q, p+)) + ∑j=1n exp(sim(q, pj)) ]

That is DPR's entire objective. It is the negative log-likelihood of the positive passage under a softmax over the candidate set, and if you have met InfoNCE or the CLIP loss, this is the same functional form with two differences we will come back to: DPR uses no temperature, and DPR's loss is computed in one direction only.

Notice immediately what the denominator did. Because the score is now normalised across candidates, growing all your norms multiplies every term in the numerator and denominator by the same factor — the loss is invariant to a shared rescaling. Collapse is no longer an optimum. The only way to lower this loss is to make the gold's similarity large relative to the others.

The negatives are not a regularisation detail. They are the loss. Delete the sum in the denominator and the objective is degenerate. Every bit of information about what "not relevant" means — the entire negative half of the concept of relevance — enters the model through the specific passages you chose to put in that sum. Chapter 4 exists because of this sentence.

Derive the gradient, and read what it says

Write s0 = sim(q, p+) and sj = sim(q, pj). Let

pk = exp(sk) / ∑m exp(sm)   so   L = −log p0

Differentiate. For the gold's own score, using ∂log p0/∂s0 = 1 − p0:

∂L / ∂s0 = p0 − 1  (negative, so gradient descent raises s0)

And for any negative j ≥ 1, using ∂log p0/∂sj = −pj:

∂L / ∂sj = pj  (positive, so gradient descent lowers sj)

Compact form: ∂L/∂sk = pk − yk, where y is the one-hot label. That is the familiar softmax-cross-entropy gradient, and here it says something specific and important:

Each negative's influence is exactly its own softmax probability. A negative the model already scores far below the positive has pj ≈ 0 and therefore contributes nothing to the update — it occupies a slot in the batch, consumes memory, costs a forward pass, and teaches the model nothing. A negative the model nearly prefers has large pj and dominates. The softmax is an automatic hard-negative miner: it silently reweights the candidate set toward whatever the model currently finds confusing. Which is wonderful — and it also means that if you feed it 255 candidates that are all obviously wrong, you have paid for 255 negatives and received the gradient of about one.

Worked example: one positive, two negatives

Take a question and three candidate passages, with these raw dot products (real DPR similarities land in this range):

s = [ p+: 6.00 ,   hard negative: 4.50 ,   random negative: 1.20 ]

Exponentiate, subtracting the max for numerical stability (which changes nothing, since softmax is shift-invariant):

e6.00−6.00 = 1.000000
e4.50−6.00 = e−1.50 = 0.223130
e1.20−6.00 = e−4.80 = 0.008230
sum = 1.231360

So the probabilities are:

p = [ 1.000000/1.231360 , 0.223130/1.231360 , 0.008230/1.231360 ] = [ 0.81211 , 0.18121 , 0.00668 ]

And the loss is −log(0.81211) = log(1.231360) = 0.20812.

Now read the gradient. ∂L/∂s = p − y = [−0.18789, +0.18121, +0.00668]. The hard negative receives 0.18121 of push-away and the random negative receives 0.00668. Their ratio:

0.18121 / 0.00668 = 27.1×  —  and as a share of all negative gradient: 96.4% vs 3.6%

One negative is doing 96% of the work of teaching this model what "wrong" looks like. Hold that number; Chapter 4 scales it to 255 negatives and it becomes the argument for everything that follows.

Why not a triplet loss?

The obvious competitor, and the standard in metric learning before this, is the triplet or margin loss:

Ltriplet = max( 0 ,  m − sim(q, p+) + sim(q, p) )

which says "the positive must beat this negative by at least a margin m, and once it does, stop caring." DPR compares the two and reports NLL is better. Three reasons, and they are all visible in the formulas.

One negative at a time. Triplet loss compares the positive to a single negative per term. To use 255 negatives you sum 255 independent hinge terms, each with equal weight regardless of difficulty. NLL normalises over all of them jointly, so the weighting is automatic — that pj factor we just derived.

The margin is a hyperparameter you must guess. m is in the units of your unnormalised dot products, whose scale the model is free to change during training. A margin that is meaningful at initialisation is meaningless after ten epochs of norm growth.

Hinge losses go flat. Once a triplet satisfies the margin, its gradient is exactly zero — the example is discarded. In NLL a satisfied negative contributes a small but nonzero pj, so the ranking keeps being refined. Practically, triplet training needs its own hard-negative mining pipeline just to keep the loss from becoming all zeros; the softmax gets that for free.

Two things DPR deliberately does not do

No temperature. CLIP, CLAP, SimCLR and most contrastive models divide the logits by a learned temperature τ before the softmax, because their similarities are cosines and therefore trapped in [−1, 1] — a range far too narrow to produce a peaked softmax. DPR does not normalise, so its dot products are free to occupy any range, and the model can sharpen its own distribution by growing the norms of its embeddings. The norm growth is the temperature, learned implicitly and per-example rather than as one global scalar. This is why the paper found cosine similarity worse: cosine throws away the norm, which throws away the model's only means of expressing confidence.

Make it concrete. Scale both towers' outputs by α. Then every similarity scales by α2, and the softmax over [6.00, 4.50, 1.20] with α2 = 2 becomes a softmax over [12.0, 9.0, 2.4]:

e0 = 1, e−3 = 0.049787, e−9.6 = 0.0000677 → sum 1.049855
p0 = 0.95251,  loss = 0.048655  (down from 0.20812)

The ranking is unchanged — scaling all logits by a positive constant cannot reorder them — but the loss fell by 77% with no improvement in retrieval whatsoever. Gradient descent will happily take that. It is a real and slightly uncomfortable property of the unnormalised objective: some of your training loss reduction is confidence inflation rather than learning. It is also, in a system whose only job is ranking, harmless.

No symmetric term. CLIP averages a text→image loss and an image→text loss because both retrieval directions are products. DPR computes only question→passage. There is no passage→question term, for a simple reason: the columns of the batch have no well-defined gold. Passage j's "correct question" is question j, yes — but in a batch of 128, several other questions may be equally well answered by passage j, and more importantly, "given this passage, which of these 128 questions did a user ask?" is not a task anyone deploys. DPR's loss matches DPR's product. Chapter 9 walks the matrix and you will see exactly which entries are used and which are ignored.

The loss floor you should monitor

A model that cannot tell the gold from the negatives assigns them all equal probability 1/(n+1), giving loss log(n+1). This is your sanity baseline, and it is the single most useful number to print during the first epoch:

Negatives nCandidates n+1Chance-level loss = ln(n+1)
782.079
31323.466
1271284.852
2552565.545 — DPR's headline configuration

If your loss sits pinned at 5.545 and never moves, the model is guessing and something is structurally wrong — a detached gradient, a shuffled label, a similarity matrix transposed. If it drops to 0.3 in fifty steps, your negatives are trivial and you are learning nothing useful. A healthy DPR run starts near ln(256) and settles somewhere in the 0.3–1.5 range, and the interesting question is always which negatives are keeping it up there.

Realization note — the whole loss in five lines of PyTorch. q = E_Q(questions).cls gives (B, 768); p = E_P(passages).cls gives (M, 768) where M is however many passages are in the batch; scores = q @ p.T gives (B, M); labels = torch.arange(B) if the gold for question i sits at column i; loss = F.cross_entropy(scores, labels). That is it. Every design decision in the rest of this lesson is a decision about which rows go into p.

The gradient, derived rather than asserted

The result ∂L/∂sk = pk − yk is quoted everywhere and derived rarely. It takes four lines and it is worth having.

Write Z = ∑m esm, so pk = esk/Z and L = −s0 + log Z.

Differentiate the second term first. Since ∂Z/∂sk = esk:

∂(log Z)/∂sk = (1/Z) · esk = pk

Now the first term. −s0 depends on sk only when k = 0, contributing −1 there and 0 elsewhere — which is exactly −yk for the one-hot label y. Adding them:

∂L/∂sk = pk − yk

Two facts fall out immediately. The gradients over a row sum to zero (∑pk = 1 = ∑yk), so the loss can only ever redistribute score between candidates — it is a purely relative objective, which is why the shared-rescaling invariance from earlier exists. And a candidate's influence is its assigned probability, which is the sentence Chapter 4 is built on.

Push one step further, to the embeddings themselves. Since sk = q · pk:

∂L/∂q = ∑k (pk − yk) pkvec = −p0vec + ∑k pk pkvec

Read it as a physical statement: the question vector is pulled toward the gold passage with force 1, and pushed away from a probability-weighted average of all candidates including the gold. In the limit where the model is already perfect (p0 = 1) the two terms cancel exactly and nothing moves. In the limit where one negative dominates, the update is almost exactly "move away from that one passage."

Numerical stability, and the bug that eats an afternoon

DPR's similarities are unnormalised dot products of 768-dimensional vectors whose norms grow during training. Scores of 20, 40, 60 are entirely ordinary late in a run. Now:

e60 = 1.14×1026  (fine)    e90 = 1.2×1039  (overflows float32, whose max is 3.4×1038)

The fix is the log-sum-exp trick, which is what every worked example in this lesson has quietly used: subtract the row maximum before exponentiating. Since

log ∑m esm = c + log ∑m esm − c  for any c

choosing c = maxm sm makes the largest exponent exactly 0, so nothing can overflow, and the most negative exponents underflow harmlessly to 0. F.cross_entropy does this internally; a hand-rolled -log(exp(pos)/exp(all).sum()) does not, and it will produce nan after a few thousand steps in a way that looks like a data bug and is not.

InfoNCE, and the ceiling on what the loss can tell you

This objective has a second life in representation learning under the name InfoNCE, where it is derived as a lower bound on the mutual information between the two views:

I(q ; p)  ≥  log(n+1) − L

The practical content of that inequality is a warning about measurement. The bound saturates at log(n+1): with 256 candidates you can never certify more than log 256 = 5.545 nats of mutual information, no matter how good your model is. So a loss of 0.05 with 8 candidates and a loss of 0.05 with 256 candidates are not comparable quantities, and neither are two training runs with different batch sizes. If you compare loss curves across batch sizes without dividing through by the chance-level floor, you will reach the wrong conclusion — a bigger batch has a higher floor and will look worse for a long time while being better.

When one positive is not enough

The loss as written assumes exactly one correct answer among the candidates. Real questions frequently have several. The standard extension keeps the same shape and masks the known extras out of the denominator:

scores = q @ p.T                       # (B, M)
scores = scores.masked_fill(other_positives, -1e4)
loss = F.cross_entropy(scores, labels)   # the designated gold only

Where other_positives is a boolean (B, M) tensor marking every cell you know to be relevant except the designated one. Setting those cells to a large negative number removes them from the softmax entirely, so they neither receive gradient nor inflate the denominator. Chapter 6 explains why you will want this more often than you expect: the moment two questions in your batch are about the same entity, you have manufactured this situation yourself.

The road not taken: normalise, then add a temperature

DPR uses raw dot products. Almost every embedding model trained since 2022 does the opposite: L2-normalise both sides and divide by a small temperature. Since you will meet both, it is worth seeing exactly what changes.

With normalisation, sim(q,p) = cos(q,p) ∈ [−1, 1]. Now compute a softmax over three candidates at cosines 0.75, 0.65, 0.15 — a realistic spread for a trained model — with no temperature:

e0.75 = 2.1170, e0.65 = 1.9155, e0.15 = 1.1618
∑ = 5.1943  →  p = ( 0.4076 , 0.3688 , 0.2237 ),  L = 0.8974

That is nearly uniform. The correct answer holds 40.8% and the loss sits close to the chance floor of log 3 = 1.0986 even though the model has ranked everything correctly. There is almost no gradient pressure and nothing distinguishes a good model from a mediocre one. Cosine similarities are too narrow to produce a usable softmax on their own — and this, not any subtlety about angles, is the concrete reason temperature exists.

Now divide by τ = 0.05, equivalently multiply by 20:

scores → (15.0, 13.0, 3.0);  subtract 15: e0 = 1, e−2 = 0.135335, e−12 = 6.14×10−6
∑ = 1.135341 → p = ( 0.8808 , 0.1192 , 0.0000054 ),  L = 0.126933

The same model, the same ranking, and a loss seven times smaller with a sharply concentrated gradient on the one confusable negative. Nothing about the representation changed; you changed how sharply the loss reads it.

DPR: raw dot productModern: cosine / τ
Score rangeUnbounded; grows with trainingFixed to [−1, 1] before scaling
Sharpness controlImplicit — the model grows its normsExplicit — one scalar τ, often learned
Per-example sharpnessPossible — norm can vary per inputNo — one global τ
Failure modeNorms drift; loss falls without ranking improvingτ mis-set → loss pinned at log M, or saturated at 0
Index implicationsNeeds inner-product searchCosine and inner product coincide once normalised — slightly simpler indexing
The trade, stated once. An unnormalised dot product hands the model a per-example confidence dial (its norm) and hands you an uninterpretable loss curve. A normalised score plus a temperature takes the dial away from the model and gives it to you as one number you can log, tune, and reason about. Modern practice overwhelmingly prefers the second, and DPR's own ablation — cosine underperforming dot product — is best read as "cosine without a temperature underperforms", not as evidence against normalisation itself.

Why a classification loss trains a ranking system

A fair objection: you deploy a ranker, but you train a classifier. Why should minimising cross-entropy improve top-20 accuracy?

Because minimising −log p0 means maximising p0, and p0 exceeds one half exactly when s0 exceeds the log-sum-exp of every other candidate — a smooth surrogate for the maximum. So the loss is a differentiable relaxation of "the gold's score is the largest", which is precisely top-1 accuracy within the candidate set. Cross-entropy is a soft-max in the literal sense: a smooth stand-in for the hard max that ranking metrics ask about.

What it does not optimise is position beyond the top of the list. Once the gold wins its row, additional loss reduction goes into widening a margin nobody measures. This is why the correlation between training loss and retrieval accuracy is strong early and weak late, and why the honest stopping criterion is a held-out retrieval metric rather than the loss curve.

One gradient step, computed all the way

The formulas are done. Take one actual step so the geometry stops being metaphorical. Work in two dimensions with one positive and one negative:

q = (2.0, 1.0),   p+ = (2.5, 1.0),   p = (1.0, 2.0)

Similarities: s+ = 2.0×2.5 + 1.0×1.0 = 6.0; s = 2.0×1.0 + 1.0×2.0 = 4.0.

Softmax over (6.0, 4.0): subtract 6, giving e0 = 1 and e−2 = 0.135335, sum 1.135335. So p = (0.880797, 0.119203) and L = log(1.135335) = 0.126928.

Now the gradient with respect to q, using the formula derived above:

∂L/∂q = (p+prob − 1) p+ + pprob p
= (−0.119203)(2.5, 1.0) + (0.119203)(1.0, 2.0)
= (−0.298008, −0.119203) + (0.119203, 0.238406) = (−0.178805, +0.119203)

Look at what that simplifies to. Factoring out the shared 0.119203:

∂L/∂q = −pprob · ( p+ − p ) = −0.119203 × (1.5, −1.0)

The update direction is exactly the vector from the negative to the positive, scaled by the negative's own probability. With one positive and one negative, gradient descent moves the question along the line connecting them, and the step size is the negative's softmax mass. If the negative were hopeless (probability 0.001), the step would be a thousandth as long. That is Chapter 4's entire thesis, visible in two dimensions.

Take the step with learning rate 1:

q′ = q − ∂L/∂q = (2.0 + 0.178805, 1.0 − 0.119203) = (2.178805, 0.880797)

Recompute. s+ = 2.178805×2.5 + 0.880797×1.0 = 5.447013 + 0.880797 = 6.327810. s = 2.178805×1.0 + 0.880797×2.0 = 2.178805 + 1.761594 = 3.940399. The gap widened from 2.000 to 2.387.

New loss: e−2.387411 = 0.091868, sum 1.091868, L = log(1.091868) = 0.087890. Down from 0.126928, a 31% reduction, from one step on one training pair. Do this 58,880 times per epoch for 40 epochs and you have DPR.

Two things this makes concrete. First, the question vector moved and the passage vectors would move too — the gradient flows into both towers, so the positive is drawn toward the question at the same time. Second, the step made the gap wider without making either vector meaningfully longer: the norm of q went from √5 = 2.236 to √(4.747 + 0.776) = 2.350. That is the healthy case. When a training run instead lowers its loss by inflating norms while the angles stop improving, you get the Chapter 3 pathology — falling loss, flat retrieval accuracy.

What to stop on

Three chapters have now given reasons why the training loss is a poor progress signal: it is invariant to shared rescaling, it is exponentially sensitive to a margin whose units the model controls, and its floor depends on batch size. Collect the consequence.

SignalComparable across batch sizes?Comparable across runs?Use for early stopping?
Training lossNo — the floor is log MNo — depends on norm scaleNo
Loss / log MRoughlySomewhatOnly as a smoke test
In-batch accuracyNo — harder with a bigger batchSomewhatNo — saturates far too early
Held-out top-20 against the full indexYesYesYes — the only honest one

The catch is that the honest signal requires re-encoding your evaluation corpus at every checkpoint, which is expensive. The standard compromise: evaluate against a fixed 100k-passage subsample of the index containing every gold passage plus a random remainder. It is not the real number, it moves with the real number, and it costs seconds instead of hours.

Should the loss run in both directions?

CLIP averages an image→text loss and a text→image loss. DPR uses question→passage only. The difference is not an oversight, and thinking it through sharpens what the objective is for.

The symmetric version would add a term over the columns of the score matrix: for each passage, a softmax over all questions in the batch, with the label being its own question.

Lsym = ½ [ ℓrow(S) + ℓcol(S) ]  versus   LDPR = ℓrow(S)
Row term (question → passage)Column term (passage → question)
The task it trainsRetrieval — exactly what you deploy"Which of these questions did this passage answer?" — a task nobody deploys
Are the labels sound?Yes, one designated gold per questionOften not — a passage may answer several questions in the batch
Effect on the geometryPulls questions toward their answersAdds a symmetry pressure that fights the asymmetric relation from Chapter 2

The column term is right for CLIP because both directions are products — people search images with text and text with images — and because an image–caption pair is genuinely symmetric. A question and its answer passage are not: the passage answers the question, and the question does not answer the passage.

The rule to carry. Add a loss term for a direction if and only if you (a) deploy it or (b) believe the relation is genuinely symmetric. Otherwise the extra term is an unrequested regulariser pulling your geometry toward a symmetry the task does not have — and it will do so using labels that are less reliable than the ones you started with.

What a healthy loss curve looks like

PhaseLoss (M = 256, floor 5.545)What is being learned
Step 0≈ 5.545Nothing — the model is at chance, as it should be
First few hundred steps5.5 → 2.0Coarse topical structure. This drop is fast and means little
End of epoch 1≈ 1.0Topic discrimination is largely solved; in-batch accuracy is already high
Epochs 2–401.0 → 0.3, slowlyThe hard negatives. This is where retrieval accuracy is actually earned
Late, if it keeps falling fast< 0.1Suspicious — check the embedding norms and evaluate against the real index

The shape that matters is the long, slow second half. A run that reaches 0.3 in one epoch has not trained faster; it has been given an easier exam, and the ablation table's 47.0 is what that looks like when measured properly.

During training you log the loss and see it drop steadily from 5.5 to 0.9, but top-20 retrieval accuracy on a held-out set barely improves. Which explanation is most consistent with the mathematics of this objective?

Chapter 4: The Craft of Negatives

This is the chapter. Everything before it was scaffolding; everything after it is consequence.

Recall the loss from Chapter 3. The numerator is fixed by the data — the gold passage is whatever the annotation says it is. The denominator is chosen by you. Every question in your training set comes with a pile of passages you have declared to be wrong, and the model's entire concept of "wrong" is the empirical distribution of that pile. Change the pile and you change what the model learns, more than you change it by swapping BERT for RoBERTa or 768 dimensions for 1024.

Say it as sharply as possible. A retriever trained with random negatives learns to answer the question "is this passage about roughly the right topic?" A retriever trained with hard negatives learns to answer "among the passages about the right topic, does this one contain the answer?" Those are different functions. The first one is easy and useless at deployment, because at deployment every candidate the ANN index returns is already about roughly the right topic. Your training distribution has to look like your inference distribution, and the inference distribution is the top of the ranking, not a uniform draw from Wikipedia.

Type 1: random negatives — and why they stop teaching

The simplest choice: sample n passages uniformly from the 21 million. Cheap, unbiased, trivially parallel. Let us compute exactly how informative they are.

Suppose a given question has roughly 200 passages in Wikipedia that are meaningfully on-topic — other paragraphs about Lord of the Rings characters, say. The probability that one uniform draw lands on-topic is:

200 / 21,015,324 = 9.52×10−6

Draw seven negatives, as one of DPR's ablation settings does. The expected number of on-topic negatives per question is 7 × 9.52×10−6 = 6.7×10−5. Over an entire epoch of 58,880 NQ questions, the expected count of on-topic random negatives across the whole dataset is 58,880 × 6.7×10−53.9. Four. In an entire epoch.

So a model trained on random negatives has, over its whole training life, essentially never been asked to distinguish two passages about the same subject. It learns the coarse topical geometry of Wikipedia extremely well, and stops. Now recall Chapter 3's gradient result: a negative's influence is its softmax probability pj. Once the model has learned topics, every random negative sits far below the positive, its pj is near zero, and the training signal vanishes — not because the model is good, but because the exam got too easy.

DPR's number for this configuration: top-20 accuracy of 47.0 on NQ. BM25 gets 59.1. Random negatives produce a dense retriever meaningfully worse than the twenty-year-old baseline.

Type 2: BM25 hard negatives

The fix is almost embarrassingly direct. Run BM25 on the question. Take the top-ranked passages. Throw away any that contain the answer string. What is left is a set of passages that are lexically excellent matches and factually wrong — exactly the confusions a deployed retriever will face.

For "who is the bad guy in lord of the rings", BM25's top passages are all about the franchise: the film's box office, the Two Towers plot summary, Tolkien's biography, a list of Middle-earth locations. Every one shares heavy vocabulary with the question. None contains "Sauron" in an answering role. These are the passages the model must learn to rank below the Sala Baker passage, and no amount of topical geometry will do it.

DPR's number: adding BM25 negatives moves top-20 from 47.0 to 50.0 in the seven-negatives-no-in-batch setting. A real gain, and a modest one — because seven negatives is seven negatives. The explosion comes when you combine them with the next idea.

Type 3: in-batch negatives — the free lunch, priced exactly

Here is the observation, and it is one of those ideas that is obvious the instant you see the matrix.

You are training on a batch of B questions. To compute the loss you must encode B questions and their B gold passages. Now look at what you are holding: a (B, 768) matrix of question vectors and a (B, 768) matrix of passage vectors. One matrix multiply gives you every pairwise similarity:

S = Q PT  ∈ RB×B,   Sij = sim(qi, pj)

The diagonal Sii holds the B positives. The off-diagonal holds B2 − B entries, every one of which is a question paired with a passage that is not its gold. Those are negatives. You did not fetch them, encode them, or store them. They were already in your GPU.

Price the lunch. With B = 128 and one gold each:

QuantityExplicit negativesIn-batch negatives
Negatives per question127127
Question encodings per step128128
Passage encodings per step128 × 128 = 16,384128
Passage FLOPs per step (at 28 GFLOPs each)4.62×1014 = 462 TFLOPs3.61×1012 = 3.6 TFLOPs
Similarity FLOPs (128×128×1536)2.5×107 = 0.000025 TFLOPs
Ratio128× fewer forward passes for identical negatives

Check the middle row by hand. 128 × 128 = 16,384 passage encodings; at 2.816×1010 FLOPs each that is 16,384 × 2.816×1010 = 4.613×1014. On an accelerator sustaining 150 TFLOP/s that is 3.08 seconds of forward compute per training step, before the backward pass. The in-batch version needs 128 × 2.816×1010 = 3.60×1012 FLOPs, which is 24 milliseconds. Same 16,384 similarity scores either way.

And the similarity matrix itself is nothing: 128 × 128 × 1,536 FLOPs = 2.5×107, seven orders of magnitude below the encoding cost. Put another way, the marginal cost of one additional in-batch negative is 1,536 FLOPs versus 2.8×1010 for a freshly encoded one — a factor of 18.3 million.

The reuse identity. Every passage in the batch is simultaneously a positive (for its own question) and a negative (for the other B−1). B encodings produce B positives and B(B−1) negatives. This is not a trick specific to retrieval — it is the same accounting that makes CLIP, SimCLR and every modern contrastive model trainable, and it is the reason "what batch size?" is a modelling question in this literature rather than a memory question.

The combination that produced the headline

Now put the two together, and read the tensor shapes carefully because the arithmetic surprises people.

Take a batch of B = 128 questions. For each, include its gold passage and one BM25 hard negative. The passage side of the batch is now 256 rows, not 128:

Q ∈ R128×768,   P ∈ R256×768,   S = Q PT ∈ R128×256

Each row of S has 256 entries: one positive and 255 negatives. Break down where those 255 come from:

SourceCountHardness for this particular question
Its own BM25 hard negative1Genuinely hard — same topic, same vocabulary, no answer
Other questions' gold passages127Mostly easy, but real answer-bearing prose — better distractors than random Wikipedia
Other questions' BM25 negatives127Topically random with respect to this question — effectively easy
Total255Of which exactly one is hard

Read the last column and something uncomfortable appears: you added 128 hard negatives to the batch, but each question only receives one of them as a hard negative. The other 127 hard negatives are hard for someone else and easy for you. The hardness does not pool. And yet:

Negative typeIn-batch?Negatives per questionNQ top-20NQ top-100
RandomNo747.064.3
BM25No750.063.3
Gold (other questions')No742.663.1
GoldYes751.169.1
GoldYes3152.170.8
GoldYes12755.873.0
Gold + 1 BM25Yes31 + 3265.077.3
Gold + 2 BM25Yes31 + 6464.576.4
Gold + 1 BM25Yes127 + 12865.878.0

(These are the paper's ablation runs, which use a shorter training schedule than the headline configuration — that is why the absolute numbers sit below 78.4. The relative ordering is the point.)

Now do the accounting that turns this table into a design principle. Going from 7 in-batch negatives to 127 — adding 120 negatives — buys 55.8 − 51.1 = 4.7 points. Adding one BM25 hard negative to the 31-negative setting buys 65.0 − 52.1 = 12.9 points. Per negative:

120 easy negatives → 4.7 points  ⇒  0.039 points each
1 hard negative → 12.9 points  ⇒  12.9 points each
ratio ≈ 330×
The thesis of the chapter, quantified. One well-chosen negative is worth several hundred randomly chosen ones. This is Chapter 3's gradient identity showing up in a results table: influence is proportional to softmax probability, easy negatives have probability near zero, and a hundred negatives with probability near zero still sum to near zero. You are not buying negatives. You are buying gradient, and only confusable negatives are selling any.

Explore the matrix

In-batch negatives — the similarity matrix, live

Rows are questions, columns are passages. The outlined diagonal cells are positives; every other cell is a negative that cost you 1,536 FLOPs. Turn on BM25 hard negatives to double the columns. The readout below computes the real accounting for whatever batch size you pick, and the bar chart on the right shows row 1's softmax — watch how much of the negative gradient one hard cell absorbs.

batch size B B = 8

The dose-response curve, and where it turns

Look again at two adjacent rows of the ablation table:

Gold + 1 BM25 negative, 31 in-batch: 65.0
Gold + 2 BM25 negatives, 31 in-batch: 64.5

Doubling the hard negatives makes it worse. If hard negatives are worth 330× a random one, why does the second one hurt?

Because "the top BM25 passage that does not contain the answer string" and "the second-ranked BM25 passage that does not contain the answer string" are not drawn from the same distribution as far as correctness goes. As you walk down the BM25 list, you are walking into passages that are increasingly likely to be relevant but unlabelled. Which brings us to the failure mode that has cost the field more accuracy than any other.

Type 4: false negatives — the poison in the well

A false negative is a passage you placed in the denominator that actually answers the question. The label says "wrong". The world says "right". And the loss believes the label.

Watch what it does to the gradient. Return to the worked example from Chapter 3: scores [gold 6.00, hard negative 4.50, random 1.20], loss 0.20812. Now add a fourth candidate — a passage that also correctly answers the question, which the model (correctly!) scores high at 5.80, but which we have labelled a negative:

s = [ 6.00 , 4.50 , 1.20 , 5.80 ]
e0 = 1.000000, e−1.50 = 0.223130, e−4.80 = 0.008230, e−0.20 = 0.818731
sum = 2.050091
p = [ 0.487783 , 0.108838 , 0.004014 , 0.399364 ]
L = −log(0.487783) = 0.71788  (was 0.20812)

Three separate harms, all visible in those numbers.

The loss inflated 3.4× for a model that did nothing wrong. Your training curve now reports failure where there was success, and if you are early-stopping or tuning on it, you are tuning on noise.

39.9% of the gradient now pushes a correct passage away. That is not a missing update; it is an update pointing in the wrong direction, with more force than the genuine hard negative (10.9%) receives. The model is being actively taught that a correct answer is wrong.

The damage generalises. Embeddings are shared across examples. Pushing this question away from this passage moves a whole region of the space, degrading every other question that lives nearby.

Now the crucial asymmetry, and the reason this chapter is called a craft rather than a procedure. How likely is a false negative? It depends entirely on where you drew from.

Negative sourceRough false-negative rateWhy
Uniform random from 21M≈ 255 × 50/21M ≈ 0.06% per question-batchIf a question has ~50 answer-bearing passages, a uniform draw almost never finds one
Other questions' golds (in-batch)Low, but not negligibleTwo NQ questions about the same entity do collide — and NQ has many
BM25 top-1, answer-string filteredModerateThe answer-string filter catches the obvious cases
BM25 top-50, or ANN top-50Very high — RocketQA's manual audit found roughly 70% of unlabelled top-retrieved passages actually contained the answerYou are sampling from exactly the region where correct passages live

Check the first row's arithmetic: 50 relevant passages out of 21,015,324 is a rate of 2.38×10−6; across 255 in-batch negatives the expected count is 255 × 2.38×10−6 = 6.1×10−4. Six in ten thousand. Utterly safe.

The central tension of negative mining, in one sentence. The informativeness of a negative and the probability that it is secretly a positive are the same quantity viewed from two sides. A negative is informative precisely when the model finds it plausible; the model finds it plausible precisely when it looks like an answer; it looks like an answer largely because it often is one. You cannot turn one dial up without turning the other up. Every advance in Chapter 5 is an attempt to break that coupling — and none of them breaks it for free.

Why the answer-string filter is not enough

DPR's filter for BM25 negatives is: exclude any top passage that contains the answer string. This is a real defence and it is leaky in both directions.

It misses paraphrases. Question: "when did the United States enter World War II?" Answer string: "1941". A passage saying "the attack on Pearl Harbor brought America into the war the following December" answers the question, contains no "1941", survives the filter, and becomes a hard negative. The model is taught that the single best paraphrastic answer is wrong — which is exactly the capability you were building a dense retriever to obtain.

It over-rejects on common strings. Answer "France" excludes every passage mentioning France for any reason, including genuinely good hard negatives. Answers that are years, small integers, or common nouns are the worst offenders. Question "how many players are on a basketball team?" with answer "5" excludes every passage containing the character 5.

Both leaks are direct consequences of using string matching to approximate semantic relevance, which is the exact thing Chapter 0 told you does not work. The pipeline that trains a model to escape lexical matching is itself built on lexical matching. Chapter 5's RocketQA is the paper that noticed and paid to fix it.

A practitioner's decision table

SituationWhat to doWhy
First training run, no infrastructureIn-batch negatives, largest batch you can fitFree, safe, gets you to a working model. Expect to land near BM25
Model works but confuses same-topic passagesAdd exactly one BM25 hard negative per questionThe single highest-return change in the whole recipe: about +13 points in the ablation
Tempted to add 5 hard negativesDo not, unless you can denoise them2 was already worse than 1. You are mining deeper into false-negative territory
Have spare compute and a cross-encoderMine ANN negatives, filter with the cross-encoder (Chapter 5)Breaks the hardness/poison coupling, at the cost of a second model
Loss stuck near ln(B)Check the label indices and whether the matrix is transposedThe chance-level floor is a diagnostic, not a starting point
Loss near zero after 100 stepsYour negatives are trivialYou are training a topic classifier; it will not survive contact with an ANN index

Why the denominator prevents collapse — with numbers

Chapter 3 asserted that a positives-only objective collapses. Watch it happen.

Suppose every question and every passage maps to the same unit direction u scaled by α. Then sim(q,p) = α2 for every pair. With a positives-only objective L = −sim(q, p+) = −α2, and gradient descent drives α → ∞. Loss → −∞; retrieval accuracy is exactly chance, because every passage ties.

Now put the denominator back. Every candidate scores α2, so:

p0 = eα2 / ( (n+1) eα2 ) = 1/(n+1),   L = log(n+1)

The α has cancelled completely. Collapse now sits at the chance-level loss — the worst value the model can achieve rather than the best — and every gradient step moves away from it. That cancellation is the entire structural reason contrastive objectives work, and it is why the negatives are load-bearing rather than decorative.

Counting the negatives that actually count

"How many negatives?" is the wrong question, and there is a better one you can compute. Given the softmax probabilities of the negatives, define the participation ratio:

Neff = ( ∑j≥1 pj )2 / ∑j≥1 pj2

which equals n when all negatives are equally weighted and approaches 1 when a single one dominates. It answers: how many negatives is this loss actually distinguishing between?

Case A — two negatives, one hard (the Chapter 3 example, p = 0.18121 and 0.00668):

∑pj = 0.18789,  ∑pj2 = 0.032837 + 0.0000446 = 0.032882
Neff = 0.035303 / 0.032882 = 1.07

Case B — 255 easy negatives at 0.00152 each:

∑pj = 0.3873,  ∑pj2 = 255 × (0.00152)2 = 5.89×10−4
Neff = 0.14999 / 0.000589 = 254.6

Read that pair carefully, because it is more interesting than it first looks. The easy batch has 255 effective negatives and the hard batch has 1.07 — and the hard batch trains a better model. The participation ratio measures diversity, not usefulness. What 255 easy negatives deliver is a diffuse push away from all of Wikipedia at once, which after a few thousand steps is information the model already has. What one hard negative delivers is a specific, sharp instruction about a distinction the model is currently getting wrong.

The diagnostic worth logging. Print max off-diagonal softmax probability every hundred steps. Early in training it will be near 1/M — nothing is distinguishable. In healthy mid-training with hard negatives it settles somewhere around 0.05–0.25: the model is still being challenged. If it falls below 0.01 and stays there, your negatives have gone stale and your batch is teaching nothing, which is precisely the observation that motivated ANCE.

The negative pipeline, in code

Everything in this chapter is about twenty lines of data preparation. Here it is, with the decisions marked.

def build_batch(questions, bm25_index, k_hard=1):
    qs, ps = [], []
    seen = set()                                # DECISION 3: dedup by passage id
    for q in questions:
        qs.append(q.text)
        if q.gold_id in seen: continue          # same passage twice = an
        seen.add(q.gold_id)                       # unsatisfiable constraint
        ps.append(q.gold_passage)                   # the positive

        cands = bm25_index.search(q.text, top_k=100)
        hard  = [c for c in cands
                 if not contains_answer(c, q.answers)  # DECISION 1: leaky filter
                 and c.id != q.gold_id]
        ps.extend(hard[:k_hard])                    # DECISION 2: k_hard = 1, not 4
    return qs, ps                                 # (B,) and (<= B*(1+k_hard),)

Decision 1 is Chapter 4's leaky answer-string filter — it misses paraphrases and over-rejects common strings. Decision 2 is the dose-response result: one, not four. Decision 3 is the quiet one that bites people. If two questions in a batch share a gold passage, that passage appears in the batch once as a positive and once as a negative, and the loss is being asked to make one cell simultaneously the largest and the smallest in its column. It is an unsatisfiable constraint, it produces a persistent noise floor in the loss, and on datasets like Natural Questions — where popular entities generate many questions — it happens often enough to matter.

What each negative type actually teaches

NegativeThe question it teaches the model to answerCostFalse-negative risk
Random from the corpus"Is this passage about the right subject?"One encoder pass (or free, in-batch)Negligible
Other questions' golds"Is this the right subject, among well-formed answer-bearing prose?"Free — already in the batchLow
BM25 top-1, filtered"Among passages with the same words, does this one answer?"A BM25 index and one lookup per questionModerate
ANN top-k from the model itself"Among passages I currently rank highest, which is right?"Repeated full-corpus encodingHigh — needs denoising

Read the middle column top to bottom: each row is a strictly harder question, and each row is closer to the question the model will actually be asked at serving time. That is the whole progression, and the rest of this lesson is the engineering required to climb it without poisoning yourself on the way up.

A negative curriculum

One question the ablation table cannot answer: should the negatives be the same throughout training? Modern pipelines say no, and the reasoning follows directly from the gradient identity.

At initialisation the model is random. Every negative is equally confusing, so hard negatives are wasted — the model cannot yet tell a hard one from a random one, and the loss is near log M regardless. By mid-training the topical structure is learned, and now random negatives contribute nothing while hard ones carry everything. Staging follows:

Phase 1 — warm up
In-batch negatives only, large batch. Teaches the coarse geometry: which passages are about which subjects. Cheap, stable, no mining infrastructure
↓ when in-batch accuracy passes ~0.9
Phase 2 — static hard negatives
Add one BM25 negative per question. This is DPR. The model learns the answer-bearing distinction within a topic
↓ when max off-diagonal probability falls below ~0.02
Phase 3 — mined, denoised negatives
Mine from the model's own ANN index, filter with a cross-encoder, repeat. This is ANCE plus RocketQA, and it is where the last few points live

The transition signal in each arrow is a measurement, not a step count. That matters: the right moment to escalate is when the current negatives have stopped producing gradient, which the max-off-diagonal-probability diagnostic tells you directly.

Does batch size have a scaling law?

Fit the in-batch rows of the ablation table and see. Negatives 7, 31, 127 give 51.1, 52.1, 55.8:

Negatives nln nTop-20Δ per doubling of n
71.94651.1
313.43452.1+0.46 points
1274.84455.8+1.82 points

The per-doubling gain is small and, over this range, not even monotone — three points is far too few to claim a law. What the numbers do support is a bound: extrapolating the best-case rate of ~1.8 points per doubling, you would need to go from 127 to about 4,000 negatives to buy the ten points that one BM25 hard negative delivered. That is five doublings of batch size, which is a cluster, against one BM25 lookup per question, which is an afternoon.

The rule of thumb this justifies. Spend your first unit of effort on negative quality and your second on negative quantity. Quantity scales logarithmically and costs hardware; quality scales sharply and costs a data pipeline. Every paper in Chapter 5 is a bet on that ordering, and none of them bet the other way.

Instrumenting the negatives

Four things you can log from the score matrix, at no cost, that tell you what your negatives are doing:

MetricComputed from S (B×M)Reading
In-batch accuracy(S.argmax(1) == labels).mean()Saturates near 1.0 well before real retrieval is good — a debugging signal, not a decision signal
Max off-diagonal probabilityP.masked_fill(diag, 0).max()Below 0.01 → your negatives are exhausted; escalate the curriculum
Positive–hardest-negative marginS[i,gold] - S[i].topk(2).values[1]The quantity the loss is actually widening. Track its distribution, not its mean
Mean embedding normq.norm(dim=1).mean()Unbounded growth means loss is falling via scale, not ranking (Chapter 3)
drill Derive how many hard negatives you can afford before poisoning yourself.

Setup. You mine hard negatives from the top of a ranked list. Empirically, the probability that the passage at rank r is a false negative (relevant but unlabelled) rises with depth: model it as f(r) = 0.15 + 0.01r for r = 1..20. Each true hard negative contributes +g of value; each false negative contributes −3g, because Chapter 9's arithmetic shows the misdirected gradient is roughly three times the magnitude of an honest one.

Do this: write the expected value of taking the top-k negatives and find the k that maximises it.

Worked answer. The expected value of rank r is (1 − f(r))·g − f(r)·3g = g(1 − 4f(r)). This is positive while f(r) < 0.25, i.e. while 0.15 + 0.01r < 0.25, i.e. r < 10. So ranks 1 through 9 add value and every rank from 10 onward subtracts it. The optimum is k = 9 under these assumptions, and pushing to k = 20 gives back most of what ranks 1–9 earned. Two things to take away. First, the optimum is finite and interior — "more hard negatives" is never unconditionally right, which is precisely what DPR's 1-versus-2 result shows empirically. Second, the optimum depends entirely on f(r), your false-negative curve, so denoising shifts the optimum outward: halve f(r) and the break-even moves from rank 10 to rank 35. That is why RocketQA can use thousands of negatives and DPR could only use one.

The strangest row in the table

Go back to the ablation and look at a comparison that should stop you:

Random negatives, no in-batch, n = 7: 47.0
Gold negatives, no in-batch, n = 7: 42.6
Gold negatives, in-batch, n = 7: 51.1

Other questions' gold passages, used as explicit negatives, are worse than random Wikipedia passages — and the very same passages, used as in-batch negatives, are better than both. Same texts. Same count. Opposite verdicts.

The most consistent reading has two parts, and both are worth internalising because they generalise well beyond this paper.

Gold passages are a biased sample of the corpus. The ~59,000 designated positives in Natural Questions are not a uniform draw from 21 million passages. They are the passages that happen to answer the kind of question people type into Google: densely factual, entity-heavy, well-formed, drawn from popular articles. Train the model to push away only those, and it learns to discriminate against a narrow distribution it will never face at inference. Random negatives, whatever their weakness, at least sample the distribution the model must actually rank against. Your negatives are a claim about what the model will see at serving time, and a biased claim is worse than a weak one.

In-batch negatives are not just cheaper — they are structurally different. When passage j is question j's positive and question i's negative in the same step, one embedding receives both forces at once. It must be close to one question and far from another, simultaneously, from a single vector. That is a joint constraint on the geometry, and it is impossible to express when the negatives come from a separate pool that is never anyone's positive. The reuse is not an optimisation of the same objective; it changes what the objective can say.

Do not read this row as "gold negatives are bad." Read it as: a negative's value depends on the distribution it is drawn from and on the role it plays elsewhere in the batch, not on the text itself. That is why "which negatives?" cannot be answered by staring at examples, and why the paper needed a table.

Every failure mode on one page

SymptomCauseFix
Loss pinned at ln M foreverLabels misaligned with columns, or the score matrix transposedAssert S[i, labels[i]] is the diagonal on a hand-built batch of 2
Loss near 0 within a few hundred stepsNegatives are trivial — you are training a topic classifierAdd one BM25 hard negative per question
Loss falls, retrieval flatNorm inflation, or in-batch discrimination that does not transfer to the corpusLog mean embedding norm; evaluate against the real index, not the batch
Adding hard negatives made it worseFalse negatives — you mined too deepDenoise with a cross-encoder, or reduce to one negative
Persistent noise floor in the lossDuplicate passages in the batch: one cell must be both largest and smallest in its columnDeduplicate by passage id when sampling
Good on the dev set, bad in productionEvaluation queries written from documents (Chapter 6), or a domain shiftRebuild the eval from real query logs

The gradient-accumulation trap

One gotcha that catches nearly everyone the first time, and it follows directly from what in-batch negatives are.

You want batch 128 and your GPU fits 32. The standard trick is gradient accumulation: run four micro-batches of 32, accumulate the gradients, then step. For ordinary supervised training this is mathematically identical to a batch of 128.

For contrastive training it is not. The loss is computed within each micro-batch, so each question sees only the 31 other passages in its own micro-batch. Accumulating gradients across four such losses does not create a 128-way softmax; it creates four 32-way softmaxes.

True batch 1284 × 32 accumulated
Negatives per question12731
Chance-level lossln 128 = 4.852ln 32 = 3.466
Optimiser steps per epochN/128N/128 — identical
Ablation-table equivalent55.852.1

Nearly four points of accuracy, silently lost, with a loss curve that looks better because its floor is lower. The tell is exactly that: if accumulation "improved" your loss, you shrank your negative pool.

The real fixes are the ones from Chapter 5. Cross-GPU all-gather increases the pool without increasing per-device memory. A MoCo-style queue holds old embeddings at 3 KB each instead of full activations. Or gradient checkpointing, which trades compute for the activation memory that was the binding constraint in the first place. Accumulation solves a different problem, and reaching for it here is a category error.

A team increases the number of BM25 hard negatives per question from 1 to 4 and retrieval accuracy drops. They then discover their answer-string filter had a bug and was disabled. Which explanation best accounts for the drop?

Chapter 5: ANCE, RocketQA, Contriever

DPR left the field with a clear, uncomfortable insight and an obvious open problem. The insight: negatives are the lever. The problem: DPR's best negatives came from BM25, a model with no idea what the dense retriever currently finds confusing, computed once before training started and never updated.

Three papers over the following eighteen months attacked that from three different angles. Together they are the reason modern retrievers exist, and each one is a different answer to Chapter 4's tension.

ANCE: mine negatives with the model you are training

ANCE (Xiong et al., 2020 — Approximate nearest neighbour Negative Contrastive Estimation) begins with a theoretical argument that sharpens Chapter 3's gradient identity into a statement about variance.

Here is the argument in the form you can verify. Consider a training step where the positive scores 6.0 and you have 255 negatives, all easy, all scoring around 0.0. The denominator:

e6.0 = 403.4288,   255 × e0.0 = 255
sum = 658.4288,   p+ = 403.4288/658.4288 = 0.6127

Total negative gradient mass is 1 − 0.6127 = 0.3873, spread across 255 negatives, so each carries an average of 0.00152. Compare the hard-negative case from Chapter 3, where a single negative carried 0.18121. One hard negative delivers 119× the gradient of one easy negative, and the entire easy batch of 255 delivers about twice what that single hard negative does — while costing 255 slots.

ANCE's framing: the loss is an expectation over a negative distribution, and sampling from a distribution that concentrates on uninformative points gives an estimator with terrible variance relative to its cost. The fix is to sample negatives from the distribution that actually matters at inference — the top of the model's own ranking over the whole corpus.

Which sounds impossible. To retrieve the model's current top-k for a question you must have every passage encoded by the current model, and the model changes every step. Encoding 21 million passages takes hours. You cannot do it per step, or per hundred steps.

ANCE's engineering answer: give up on freshness, on purpose. Run two processes. The Trainer does ordinary gradient steps. The Inferencer runs in parallel on separate hardware: it takes the latest checkpoint, re-encodes the whole corpus, rebuilds the ANN index, and publishes it. The Trainer always mines negatives from whatever index is currently published — which was built by a checkpoint that is k steps old. The negatives are stale, and staleness is accepted as the price of globality.

Price the refresh. Re-encoding an 8.8M-passage corpus at 28 GFLOPs per passage:

8.8×106 × 2.816×1010 = 2.48×1017 FLOPs

On four accelerators sustaining 150 TFLOP/s each — 600 TFLOP/s aggregate — that is 2.48×1017 / 6.0×1014 = 413 seconds of pure compute, call it 10–15 minutes with data loading and the ANN build. Meanwhile the Trainer is doing steps. If a step takes 0.4 s, the index is roughly 2,000 steps stale by the time it is replaced.

That staleness has a real cost, and it is worth naming precisely: a passage that was the model's top-ranked confusion 2,000 steps ago may now be correctly ranked low. The negative that was hard is now easy, and its gradient contribution has decayed back toward the useless regime. The refresh interval is therefore a genuine tradeoff — refresh too often and the Inferencer becomes your bottleneck and your dominant compute cost; refresh too rarely and you are back to static negatives with extra machinery.

Hard-negative mining over training time

The top lane is the Trainer, running steps continuously. The bottom lane is the Inferencer, re-encoding the corpus in blocks; each completed block publishes a new index (the vertical markers). The curve is the effective hardness of the negatives the Trainer is currently seeing — it jumps at each refresh and decays as the model outgrows the index. Compare the three strategies and drag the refresh interval.

refresh every 2000 steps

ANCE's reported result on MS MARCO passage ranking is MRR@10 = 0.330 on the dev set, against BM25's 0.187 and a DPR-style statically-mined baseline in the low 0.30s. The paper also shows the shape that matters more than the number: models trained on static negatives plateau, while ANN-mined negatives keep improving, because the negative distribution keeps tracking the model.

RocketQA: more negatives, and cleaner ones

RocketQA (Qu et al., 2020) attacks the same problem from the data-quality side, with three changes that each address a specific defect in Chapter 4.

1. Cross-batch negatives. In-batch negatives are limited by what fits in one GPU's memory. But data-parallel training already has A GPUs each holding B passages. Add an all-gather: every GPU broadcasts its passage embeddings to every other. Now each question sees A × B − 1 negatives instead of B − 1.

8 GPUs × 512 passages = 4,096 candidates per question  vs  512 in-batch

The cost is one all-gather of (B, 768) float tensors per step — at B = 512 that is 512 × 768 × 4 bytes = 1.57 MB per GPU, trivial on NVLink or InfiniBand. The embeddings are already computed; you are only moving them. This is the in-batch free lunch extended across the whole cluster, and it is now standard in every large embedding training run.

2. Denoised hard negatives. This is the one that resolves Chapter 4's tension, and it does so by spending money. Train a cross-encoder — the expensive question-and-passage-together model from Chapter 2 that you can never deploy for retrieval. Use it to score the top-ranked candidates mined by the bi-encoder. Keep as hard negatives only those the cross-encoder confidently rates as not relevant; discard the ambiguous ones entirely.

The audit that motivates this is the striking part. RocketQA's authors manually examined top-retrieved passages that were not labelled positive, and report that roughly 70% of them actually contained the answer. Seventy percent of your hardest negatives were positives. Chapter 4 predicted the direction of that number; the size of it is what makes denoising mandatory rather than optional.

3. Data augmentation. Run the cross-encoder over unlabelled questions and high-confidence retrieved passages, and promote the confident ones to training positives. This is knowledge distillation wearing a data-engineering hat: an accurate, slow model manufactures supervision for a fast, deployable one.

Results: MS MARCO dev MRR@10 0.370, and NQ top-20 of 82.7 against DPR's 78.4.

The pattern worth extracting. All three RocketQA tricks share one shape: use a model you cannot deploy to improve the training signal of a model you can. The cross-encoder never runs at serving time. It runs once, offline, to decide which negatives are honest and which unlabelled pairs are really positives. This is now the dominant recipe for training retrievers — every strong modern embedder (E5, BGE, GTE) has a cross-encoder or an LLM somewhere in its data pipeline, doing exactly this job.

Contriever: invent the positives too

DPR, ANCE, and RocketQA all assume labelled question–passage pairs exist. Contriever (Izacard et al., 2021) asks the harder question: what if you have none at all?

Its answer is independent cropping. Take a single document. Sample two random contiguous spans from it, independently. Declare them a positive pair.

document → span A (tokens 40–120), span B (tokens 300–390) →  positive pair

The assumption is that two spans from the same document are about the same thing, which is weak, frequently wrong, and — averaged over hundreds of millions of documents — sufficient. The spans are sampled independently so they may overlap, may not, and the model cannot solve the task by matching a shared boundary.

Note what has happened to Chapter 4's tension. There are no labels, therefore no false negatives in the DPR sense — only pairs that are noisy in both directions. The craft has moved from "which negatives?" to "what counts as a positive?", and the answer comes from document structure rather than from annotators.

Contriever's other choices are all in service of scale:

ChoiceContrieverDPRWhy the difference
PositivesTwo random crops of one documentAnnotated or distantly-supervised goldRemoves the annotation bottleneck entirely
NegativesIn-batch plus a MoCo momentum queue of 131,072In-batch + 1 BM25The queue holds embeddings from thousands of past batches, giving a huge negative pool at negligible memory
TowersShared encoder for both sidesTwo independent BERTsWith cropped spans, both sides have the same distribution, so untying buys nothing
PoolingMean over tokens[CLS]Without supervised fine-tuning, an untuned [CLS] is a poor summary; the mean is more robust

The MoCo queue deserves a sentence, because it is the third distinct answer to "where do negatives come from". Keep a FIFO buffer of the last 131,072 passage embeddings produced by a slowly-updated momentum copy of the encoder. Every batch enqueues its passages and dequeues the oldest. Negatives are drawn from the queue. The cost is 131,072 × 768 × 4 bytes = 402 MB of memory and no extra forward passes at all. The staleness problem returns — queued embeddings are old — and the momentum encoder exists precisely to keep them from drifting too fast to be usable.

The headline result is a zero-shot one: on BEIR, a benchmark of fifteen retrieval datasets from domains nobody trained on, Contriever beats BM25 on recall@100 for the majority of them — the first time an unsupervised dense retriever had done so. That directly attacks Chapter 1's third BM25 advantage, out-of-domain robustness.

The three answers, side by side

DPR (2020)ANCE (2020)RocketQA (2020)Contriever (2021)
Core ideaBM25 hard negatives + in-batchNegatives from the model's own ANN indexCross-batch + cross-encoder denoisingNo labels: cropped spans + MoCo queue
Negatives per question255Top-k from 21M, refreshed~4,096~131,072 (queued)
Extra machineryA BM25 indexA parallel Inferencer + repeated corpus encodingA trained cross-encoderA momentum encoder + queue
SolvesTopical-only negativesNegatives that stop being hardFalse negativesNeeding labels at all
IntroducesFalse negatives from BM25Staleness; large recurring computeA second model to train and trustNoisy positives; weaker in-domain

Read the last row down the columns. Nobody removed the tension; each paper moved it somewhere it could be afforded. That is what "craft" means here — a set of trades whose right resolution depends on the compute, the labels, and the corpus you actually have.

The all-gather, priced

RocketQA's cross-batch negatives are the cheapest of its three ideas and the one you should copy first, so it is worth seeing the communication cost written down.

Data-parallel training already replicates the model across A GPUs, each processing its own B passages. After the passage tower runs, GPU a holds a (B, 768) float tensor. An all-gather makes every GPU hold the full (A·B, 768):

QuantityArithmeticValue
Bytes sent per GPU (fp32)512 × 768 × 41.57 MB
Bytes received per GPU7 × 1.57 MB11.0 MB
Time at 100 GB/s interconnect11.0×106 / 1.0×10110.11 ms
Negatives gained per question8×512 − 1 − (512 − 1)+3,584
Similarity matrix512 × 4,096 × 4 bytes8.4 MB

A tenth of a millisecond, against a training step that takes hundreds. You have octupled the negative pool for free, because — exactly as in the in-batch case — the embeddings already existed and you are only moving them.

The one real subtlety: the gathered embeddings must stay in the autograd graph, or the gradient flows only through your local shard and you have silently turned off seven eighths of the signal. Frameworks provide a differentiable all-gather for precisely this reason, and forgetting it is one of the classic silent bugs in distributed contrastive training — the loss looks fine, the model is simply worse than it should be.

ANCE's argument, stated as variance

It is worth having the theoretical claim in a form you can check, because "hard negatives are better" is folklore and ANCE turned it into an estimator argument.

The exact objective sums over the whole corpus. You cannot afford that, so you replace it with a sample — which makes the training gradient a random variable whose mean should be the true gradient and whose variance determines how many steps you need. Now recall that a negative's contribution is weighted by pj, which is near zero for almost every passage in the corpus. So the true gradient is dominated by a tiny, high-probability subset, and uniform sampling almost never draws from it.

Concretely: if 100 passages out of 21 million carry 90% of the negative gradient mass, then a uniform sample of 255 hits one of them with probability

1 − (1 − 100/21,015,324)255 ≈ 255 × 4.76×10−6 = 0.12%

Roughly one batch in 800 contains any of the passages that matter. The estimator is unbiased and its variance is enormous, which shows up in practice as a loss that stops decreasing while retrieval accuracy stops improving. Sampling from the model's own top-k inverts that probability to nearly 1, at the price of the entire Inferencer apparatus.

Contriever's cropping, and the momentum encoder

Two details of Contriever repay a closer look, because both reappear in every modern unsupervised embedder.

Independent cropping. Sample the two span lengths independently, and their start positions independently, from the same document. They may overlap, may be adjacent, may be far apart. Independence matters: if you always took adjacent halves, the model could exploit sentence-boundary continuity and learn a much easier task than "same topic".

The MoCo momentum encoder. A queue of 131,072 old embeddings is only useful if those embeddings are still comparable to what the current model produces. If the encoder changes quickly, a queue entry from 5,000 steps ago lives in a different geometry and is noise. The fix is to generate queue entries with a slowly-moving copy of the encoder:

θmomentum ← m · θmomentum + (1 − m) · θonline,   m ≈ 0.999

With m = 0.999 the momentum weights are an exponential moving average with a time constant of about 1,000 steps, so the geometry drifts slowly enough that a queue spanning thousands of steps remains coherent. It is the same staleness problem ANCE solved with a refresh schedule, solved instead with a low-pass filter — and the queue costs 131,072 × 768 × 4 = 402 MB and zero extra forward passes.

Choosing among the four

Do you have labelled query–passage pairs?
No → Contriever-style: crop spans from your own documents, shared encoder, mean pooling, a queue. Then fine-tune on whatever labels you later acquire
↓ yes
Fewer than ~50k pairs, one or two GPUs?
DPR: in-batch + exactly one BM25 hard negative. This is still the right first system in 2026
↓ more, or a cluster
Multiple GPUs already?
→ add cross-batch negatives first. It is a one-line all-gather and it is the best return per line of code in the whole chapter
↓ still short of target
Can you afford a second model?
→ train a cross-encoder and denoise your hard negatives (RocketQA). Then, if you still have compute, add ANCE-style periodic ANN mining on top

The ordering matters: denoising before ANN mining, not after. ANCE mines from exactly the region where false negatives live, so adding it to an undenoised pipeline amplifies the poison faster than it adds signal. Chapter 4's tension is not a metaphor — it is an ordering constraint on your roadmap.

What the recipe became

By 2023 the three ideas in this chapter had fused into a single standard pipeline, and every strong open-weight embedder is a variation on it. Seeing the whole shape at once tells you where DPR's parts ended up.

Stage 1 — weakly supervised contrastive pretraining
Hundreds of millions of scraped pairs (titles and bodies, questions and answers, cropped spans), enormous batches, in-batch negatives only. This is Contriever's idea at industrial scale
Stage 2 — supervised fine-tuning with mined hard negatives
Real labelled pairs, one to eight mined hard negatives each, denoised by a cross-encoder. This is DPR plus RocketQA
Stage 3 — distillation from a reranker
Instead of a 0/1 label, match the cross-encoder's score margins. The teacher's opinion about how much better the positive is becomes the target

Stage 3 deserves a note, because it is the cleanest resolution of Chapter 4's tension anyone has found. Rather than asking the cross-encoder a binary question ("is this negative honest?") and discarding the ambiguous cases, you ask it a graded one and fit the whole distribution — typically with a margin-MSE loss that matches the student's score difference to the teacher's:

L = ( [sstudent(q,p+) − sstudent(q,p)] − [steacher(q,p+) − steacher(q,p)] )2

A false negative is no longer a catastrophe. If the teacher also thinks the "negative" is relevant, the target margin is near zero and the student is simply told these two passages are comparable — which is true. The labelling error has been converted from a wrong instruction into a soft, approximately correct one.

DPR's partWhere it lives in the modern pipeline
Dual encoder + dot productUnchanged. Still the architecture, usually with mean pooling and a shared tower
NLL over one positive and n negativesUnchanged, now with normalisation and a temperature
In-batch negativesUnchanged, scaled from 128 to tens of thousands via cross-GPU gather
BM25 hard negativesSuperseded by ANN mining from the model itself, then denoised or distilled
Answer-string filteringSuperseded by a cross-encoder in the loop

The two things that were genuinely new after 2021

Generated queries. If labelled pairs are the bottleneck, generate them: hand a passage to a language model and ask for a question it answers. This inverts the mining direction — instead of searching for the passage that matches a query, you manufacture the query that matches a passage, which is trivially correct by construction and free of the BM25 circularity from Chapter 6. The failure mode moves too: generated queries are fluent, well-formed and lexically close to their passage, which is exactly SQuAD's problem in a new costume, so the generator has to be prompted toward realistic, terse, underspecified user language.

Instructions. Prefixing "query: " and "passage: " lets one shared encoder serve the asymmetric relation DPR needed two towers for, and richer instructions ("Retrieve a scientific paper that supports this claim") let one model serve many retrieval relations at once. The asymmetry moved out of the parameters and into the input.

Where this leaves DPR in 2026. Every component has been improved and none has been replaced. If you build a retriever today you will use a better encoder, mean pooling, a temperature, cross-GPU negatives and a distilled teacher — and the training loop will still be "encode both sides, take a dot product, softmax over the batch, and agonise over which passages go in the denominator." That is the paper's real legacy: it identified which knob mattered, and the field has been turning that knob ever since.

What each successor cost to run

Papers report accuracy and rarely report price. The price is what decides which of these you can actually adopt, so here it is side by side, in units of "how much extra compute per unit of training compute".

MethodExtra computeExtra memoryExtra models to trainEngineering surface
DPR (in-batch + BM25)A BM25 index build, onceNoneNoneSmall — one offline mining script
Cross-batch (RocketQA)~0.1 ms of all-gather per step(A·B, 768) per GPU — megabytesNoneTiny — one differentiable all-gather
MoCo queue (Contriever)One momentum-encoder forward per batch402 MB for 131k entriesNone (a weight copy, not a model)Small
ANCEA full corpus re-encode every refresh — often 20–60% of total training computeA second ANN index residentNoneLarge — a second process, a publish protocol, staleness tuning
Cross-encoder denoisingA cross-encoder forward per mined candidateModestOne — and it must be goodLarge — a whole second training pipeline

Read the "engineering surface" column against the accuracy gains. Cross-batch negatives are nearly free in every column and were worth real points. ANCE and denoising each roughly double the complexity of your training system. That ordering — free things first, expensive things only when the free ones are exhausted — is the same ordering the decision flow gave, arrived at from cost rather than from accuracy, which is a good sign it is right.

The staleness question, answered properly

ANCE's refresh interval is the one hyperparameter here that people get badly wrong, so it is worth stating the principle rather than a number.

Refresh when the negatives have stopped being hard, and not before. Concretely: track the mean softmax probability assigned to your mined negatives. Immediately after a refresh it will be high — the negatives were selected precisely because the model ranks them near the top. As training proceeds the model learns to reject them and the probability decays. When it approaches the level of an ordinary in-batch negative, the mining has expired and a refresh will pay for itself. When it has not, a refresh buys almost nothing and costs a full corpus encode.

refresh when  mean p(mined negative)  ≈  mean p(in-batch negative)

This turns a guessed schedule into a measured trigger, and it automatically adapts: early in training the model changes fast and refreshes should be frequent; late in training it barely moves and refreshes can be rare. A fixed interval gets both ends wrong.

The other half of the system: reranking

RocketQA trains a cross-encoder to clean its training data, and the same model has a second life at serving time. It is worth pricing, because "add a reranker" is usually a better first move than "improve the retriever" once your retriever is decent.

StageCandidatesModelCostWhat it fixes
Retrieve21,015,324 → 100Bi-encoder + ANN~4 msRecall. If the passage is not here, nothing later can help
Rerank100 → 20Cross-encoder100 × 28 GFLOPs = 2.8 TFLOPs ≈ 19 msPrecision at the top — the ordering the retriever got roughly right

The division of labour is clean and follows directly from Chapter 2. The bi-encoder's weakness is that it compressed each passage before seeing the question; the cross-encoder's weakness is that it cannot look at 21 million passages. Put the cheap one first to get recall, and the expensive one second to get precision, and each covers the other's failure exactly.

And note where the ceiling sits: reranking can only reorder what retrieval returned. If R@100 is 85.4%, no reranker on earth pushes end-to-end recall past 85.4%. This is Chapter 0's multiplicative bound again, one level down — which is why the two-stage split does not eliminate the need for a good retriever, it just changes what "good" means from "ranks perfectly" to "recalls at 100".

Measuring your own false-negative rate

Chapter 4's drill showed that the optimal number of hard negatives depends entirely on f(r), your false-negative curve. Here is how to measure it rather than guess.

StepWhat to do
1Take 50 training questions. For each, retrieve the top 20 with your current model
2Drop the labelled positive. What remains are your candidate hard negatives
3Judge them — by hand, or with a cross-encoder or an LLM you trust. Record for each rank whether the passage actually answers the question
4Plot the fraction relevant against rank. That curve is f(r)
5Mine to the depth where f(r) crosses your break-even, from the drill in Chapter 4

Fifty questions gives a noisy curve and it is enough to distinguish "f(1) is 10%" from "f(1) is 60%", which is the decision you are actually making. RocketQA's roughly-70% figure is what this procedure returns on Natural Questions; there is no reason to assume your corpus behaves the same way, in either direction.

Reading these four papers, in order

If you go to the sources, this order minimises confusion, and each one has a single thing worth taking away.

ReadForThe one thing
1. DPRSections 3 and 5.2The negatives ablation. Everything else in the paper is scaffolding for that table
2. ContrieverThe cropping objectiveThat a positive pair can be manufactured from document structure alone — no annotator, no labels
3. ANCEThe variance argument and the Inferencer designThat negative sampling is an estimator problem, and that staleness is an acceptable price for globality
4. RocketQAThe denoising sectionThe ~70% audit, and the pattern of using an undeployable model to clean the deployable one's training signal

Read in that order they tell one continuous story: negatives are the lever (DPR) → so are positives, and both can be synthesised (Contriever) → the right negatives are the model's own top-ranked errors (ANCE) → and those are exactly the ones most likely to be mislabelled, so pay a second model to check (RocketQA). Each paper is an answer to the problem the previous one created.

You adopt ANCE-style mining and set the index refresh to every 200 steps instead of every 2,000, reasoning that fresher negatives are strictly better. Training throughput collapses and final accuracy is no better. What went wrong?

Chapter 6: Positives Are Subtle Too

Four chapters on negatives can leave the impression that the numerator of the loss is settled. It is not. Where the positive comes from determines what the model believes "relevant" means, and DPR's five datasets get their positives in three quite different ways — which turns out to explain the single anomalous row in the results table.

Three sources of positives

Gold, from an annotator. In Natural Questions, a human was shown a real Google query and the Wikipedia page that Google returned, and asked to highlight a long answer — the paragraph containing the answer — and a short answer span within it. That paragraph, chunked, is a genuine positive: a person judged that this text answers this question.

Distant supervision. TriviaQA, WebQuestions and CuratedTREC ship question–answer pairs with no passage attached. DPR manufactures positives: run BM25 for the question, take the top 100 passages, and select the highest-ranked one that contains the answer string. If no passage in the top 100 contains the answer, the question is discarded.

Question written from the passage. SQuAD inverts the whole process: the annotator was given a paragraph and asked to write a question it answers. The passage is a positive by construction — and the question is a function of the passage, which is a very different statistical object.

Read that list again with Chapter 4's eyes. The second one — distant supervision — selects positives using BM25, and the third one produces questions that share vocabulary with the passage by construction. Both of them push lexical overlap into the training signal of a model whose entire purpose is to stop depending on lexical overlap.

The circularity, named. If your positives are chosen by BM25, then your training set contains only the question–passage pairs BM25 could already find. The very pairs a dense retriever exists to recover — the ones with no lexical overlap, the "bad guy"/"villain" cases — are systematically absent from training, because BM25 never surfaced them into the top 100 for the answer-string filter to check. Distant supervision quietly re-imports the sparse prior into the dense model.

Failure mode 1: the spurious positive

Answer-string matching says a passage is relevant if it contains the answer text. That is a necessary condition, not a sufficient one, and the gap is large.

Take: "When did the Beatles release Abbey Road?" Answer: "1969". Now run BM25 over Wikipedia and scan the top 100 for a passage containing "1969". Candidates that qualify:

PassageContains "1969"?Actually answers?
"Abbey Road is the eleventh studio album by the Beatles, released on 26 September 1969…"YesYes
"Abbey Road Studios… In 1969 the studio installed its first eight-track…"YesNo — different fact, same year, same topic
"The Beatles' final public performance took place on the roof… in January 1969…"YesNo

If the second row ranks higher under BM25 than the first — entirely possible, since it contains both "Abbey Road" and "1969" and mentions the studio repeatedly — it becomes the designated positive. The model is now trained to place this question next to a passage about recording equipment.

Short, common answers make this far worse. Answers that are years, single digits, country names, or common nouns collide constantly. "How many players are on a basketball team?" with answer "5" will string-match essentially any passage containing the digit.

The harm is the mirror image of a false negative, and it is arguably worse. A false negative pushes the model away from something correct. A spurious positive pulls the model toward something incorrect — it corrupts the numerator, which receives the full ∂L/∂s0 = p0 − 1 gradient, the single largest term in the update.

Failure mode 2: filtering removes the hard questions

DPR discards any question whose answer does not appear in BM25's top 100. On Natural Questions the training set goes from 79,168 questions to 58,880:

1 − 58,880/79,168 = 0.2563  →  25.6% of the training data removed

Which 25.6%? Precisely the questions where BM25 failed. Which is precisely the population dense retrieval was invented to serve. The training set has been filtered by the baseline you are trying to beat, so the hardest quarter of the distribution — the "bad guy"/"villain" cases — is gone before the first gradient step.

Survivorship bias, in a training pipeline. This does not make DPR's results wrong — evaluation is on the full dev set, so nothing is being hidden. It makes them conservative, and it identifies a free improvement that later work took: recover those questions with a better positive-mining pass (a cross-encoder, an LLM, or an already-trained dense retriever), and you get back a quarter of your data, weighted toward exactly the examples that carry the most information. RocketQA's third trick is a version of this.

Failure mode 3: the question is a function of the passage

SQuAD deserves its own section because it produces the only row in DPR's table where the paper loses, and the explanation is entirely about positives.

A SQuAD annotator reads: "The Amazon rainforest, also known in English as Amazonia, covers most of the Amazon basin of South America. This basin encompasses 7,000,000 square kilometres, of which 5,500,000 square kilometres are covered by the rainforest." They write: "How many square kilometres of the Amazon basin are covered by rainforest?"

Count the overlap. Square kilometres, Amazon, basin, covered, rainforest — five content terms, all present, several of them rare. A question written this way is close to a copy of the passage with one span replaced by a wh-word. It is the ideal input for a term-matching retriever.

Compare a Natural Questions query, which is a real thing a real person typed into Google before knowing any answer existed: "who is the bad guy in lord of the rings". No passage was in front of them. The vocabulary is theirs, not the encyclopaedia's.

Natural QuestionsSQuAD
Question originReal Google search, written before seeing any passageWritten while reading the target paragraph
Lexical overlap with goldLow — often the key concept is worded differentlyHigh by construction
BM25 top-2059.168.8
DPR top-2078.463.2
Article coverageBroadOnly ~500 Wikipedia articles

The last row compounds the first. SQuAD's paragraphs come from a few hundred articles, so training questions cluster in a tiny slice of the corpus while evaluation retrieves over all 21 million. The dense model learns a geometry fitted to that slice, and the paper says so explicitly: the distribution is biased toward a very small set of Wikipedia articles, which is out of distribution for the full-corpus training setup.

The lesson to take into your own evaluation. A retrieval benchmark measures your retriever and the process that produced its questions. If your evaluation queries were written by someone looking at the document — which is exactly how most internal "golden set" evaluations get built, because it is the cheapest way to make one — you have constructed a benchmark that structurally favours lexical matching, and you will conclude that dense retrieval does not help you. Write your evaluation queries from user intent, or from real logs, or you are measuring the wrong thing.

Which positive, when there are many?

One more subtlety, and it links straight back to Chapter 4. Most questions have several correct passages. Wikipedia states the Abbey Road release date in the album article, the discography article, and the 1969-in-music article. DPR designates one of them the positive.

The other correct passages are now, from the loss's point of view, unlabelled. If one of them lands in the batch — as another question's gold, or as a mined hard negative — it enters the denominator as a negative. Multi-positive questions manufacture false negatives automatically. The two problems are one problem viewed from opposite ends, and the modern fix is the same in both directions: a multi-positive loss that masks out all known-relevant passages from the denominator rather than treating exactly one as correct.

Concretely, in the (B, M) score matrix from Chapter 4, instead of F.cross_entropy(scores, labels) you build a boolean mask of every (question, passage) pair known to be relevant, set those entries to −∞ except the designated positive, and only then take the softmax. Three lines, and it removes an entire class of self-inflicted gradient error.

A checklist for building positives

CheckWhy
Were the queries written before or after seeing the document?After → your benchmark favours BM25 and will understate dense retrieval
Is answer-string matching your relevance criterion?Then short and common answers are generating spurious positives; sample 50 and read them
How many training examples did the mining pipeline discard?Discards concentrate on the hardest questions — the ones worth the most
Does any question have more than one correct passage?Then you need a multi-positive mask, or you are training against your own labels
Was the positive selected by the system you are trying to beat?Then your training distribution is that system's success set, and you inherit its blind spots
Do the training passages cover the whole index?SQuAD's ~500 articles versus 21M passages is the extreme case, and it cost 15 points

The positive-mining pipeline, in code

Chapter 4 showed the negative side. Here is the positive side, with its two leaks marked, because most teams write this function without noticing either.

def mine_positive(question, answers, bm25_index):
    cands = bm25_index.search(question, top_k=100)   # LEAK 1: BM25 chooses
    for c in cands:                                    # the candidate pool
        if any(a in c.text for a in answers):        # LEAK 2: string match
            return c                                  # stands in for relevance
    return None                                      # question is DISCARDED

Leak 1 means the positive can only ever be a passage BM25 already ranked in its top 100. Any question whose answer is phrased differently from the question is invisible to this function, so it is discarded (the 25.6%) rather than learned from. Leak 2 means "contains the answer text" stands in for "answers the question", which is the spurious-positive problem above.

The single highest-value upgrade, and the one RocketQA formalises: replace the loop body with a cross-encoder score, keep the highest-scoring candidate above a threshold, and widen the candidate pool by unioning BM25's top 100 with a dense retriever's top 100. Both leaks shrink at once, and the discard rate falls.

How often is a spurious positive actually chosen?

Worth a rough estimate, because it calibrates how much to care. Consider a question whose answer is a four-digit year. Of the 100 BM25 candidates, suppose 30 contain that year somewhere — unsurprising for a topically-related set of passages about the same subject — and only 3 of those 30 state it as the answer to this question. The pipeline picks the highest BM25-ranked of the 30. The probability that this is one of the 3 correct ones, if BM25's ranking were uninformative about correctness, is 3/30 = 10%.

BM25's ranking is not uninformative, so the real rate is much better than 10%. But the estimate makes the shape clear: short, common answers have a high spurious-positive rate and long, distinctive answers have a low one. If your domain's answers are entity names or long phrases you can mostly ignore this. If they are numbers, dates, yes/no, or single common words, string matching is close to useless as a relevance criterion and you need a model in that loop.

Answer typeExampleSpurious-positive risk
Long distinctive phrase"Treaty of Westphalia"Very low — the string is nearly a unique identifier
Proper noun"Sauron"Low
Year"1969"High — every related passage mentions nearby years
Small integer"5"Severe — matches almost anything
Common noun"water"Severe

Masking the extras, concretely

The multi-positive fix from Chapter 3 needs a relevance map. Build it once, offline, from whatever you know:

# relevant[qid] = set of passage ids known to answer this question
mask = torch.zeros(B, M, dtype=torch.bool)
for i, qid in enumerate(batch_qids):
    for j, pid in enumerate(batch_pids):
        if pid in relevant[qid] and j != gold_col[i]:
            mask[i, j] = True                     # a known positive in a negative slot
scores = (q @ p.T).masked_fill(mask, -1e4)
loss   = F.cross_entropy(scores, gold_col)

Where does relevant come from? Three sources, in increasing order of quality: answer-string matches within your training corpus; the union of every gold passage across questions that share an answer and a topic; and a cross-encoder's high-confidence judgements. Even the cheapest version removes real gradient noise, because the most common false negative in Natural Questions is another paragraph of the same article.

The asymmetry between the two kinds of label error

Spurious positiveFalse negative
What it isA wrong passage designated as the goldA right passage placed in the denominator
Gradient it producesp0 − 1 — the largest single term, pulling toward a wrong passagepj — large only if the model already ranks it highly, which it will
Where it comes fromAnswer-string matching, short answersDeep hard-negative mining, multi-positive questions
DetectionSample 50 positives and read them. Cheap and nobody does itCross-encoder audit of mined negatives
MitigationCross-encoder scoring instead of string matchingDenoising (RocketQA) or a multi-positive mask
The one-hour experiment that pays for itself. Before touching your architecture, sample fifty (question, designated-positive) pairs from your training set and read them. Count how many the positive genuinely answers. Teams that do this routinely find rates in the 60–80% range and are shocked; the model was never going to exceed that ceiling by a wide margin, and no amount of hard-negative engineering addresses it.

The inverse move: generate the question

Every problem in this chapter comes from starting with a question and hunting for its passage. Turn it around and most of them evaporate.

Take a passage. Ask a generative model: what question does this answer? The resulting pair is a positive by construction. There is no BM25 in the loop, so Leak 1 is gone. There is no answer-string matching, so Leak 2 is gone. There is no discard rate, so the 25.6% survivorship bias is gone. And you can generate a pair for every passage in your corpus, including the ones no annotator would ever have looked at.

Mine the positive (DPR)Generate the query
Directionquestion → search for passagepassage → write question
Correctness of the pairApproximate — string match stands in for relevanceCorrect by construction, up to generator quality
CoverageOnly passages BM25 surfaces; 25.6% of questions discardedEvery passage in the corpus
CostOne BM25 lookup per questionOne generation per passage — far more expensive at 21M passages
Failure modeSpurious positives, lexical circularityUnrealistic queries: fluent, well-formed, and lexically too close to the source

Look at the last row against Chapter 6's whole argument. A model asked to write a question about a passage does what a SQuAD annotator did: it reuses the vocabulary in front of it. Unprompted, you regenerate the exact bias you were trying to escape — a training set of high-overlap pairs that teaches lexical matching. The mitigations are the obvious ones and they are not optional: prompt for short, underspecified, colloquial queries; sample with high temperature for diversity; and filter with round-trip consistency, keeping the generated query only if a retriever actually finds the source passage for it.

The pattern underneath both halves of this chapter. Whether you mine positives or generate them, you are choosing a proxy for relevance — string overlap, BM25 rank, a generator's willingness, a cross-encoder's score. The model then learns the proxy, exactly and faithfully, including its biases. There is no such thing as training on relevance; you train on whatever measured thing you substituted for it, and most of the craft is knowing which substitution you made.

What good looks like: a positives audit

A concrete, repeatable procedure. It takes an hour and it is the highest-value hour in the project.

StepWhat to doWhat the result tells you
1Sample 50 (question, designated positive) pairs uniformly from training
2For each, mark: answers, related but does not answer, or unrelatedThe "answers" rate is your label ceiling
3For the failures, record why: wrong sense of the answer string, right topic wrong fact, or truncated at a chunk boundaryPoints at whether to fix the matcher, the ranker, or the chunker
4Separately sample 20 discarded questionsAre you throwing away the hardest quarter, or genuinely unanswerable ones?
5Sample 20 mined hard negatives and mark whether each actually answersYour false-negative rate — the number that decides how deep you may mine (Chapter 4's drill)

Steps 4 and 5 are the ones nobody does, and they are the ones that most often change the plan. A 70% label ceiling means no architectural change can take you past roughly 70%; a 60% false-negative rate at rank 5 means your hard-negative pipeline is subtracting value and the fix is a cross-encoder, not a bigger batch.

How to build an evaluation set that is not lying to you

Chapter 6's whole argument reduces to one operational instruction, so make it explicit and checkable.

Source of eval queriesBias it introducesVerdict
Real user query logsWhatever your current system's users have learned to type — a real bias, but the one you are actually servingBest available
Users asked to describe tasks, without seeing documentsSlight formality shift; no lexical leakageGood
Support tickets, forum posts, search-box abandonmentsSkewed toward failures — which is often exactly the population you want to fixGood, with the skew acknowledged
Annotators reading a document and writing a questionVocabulary leakage from document to query — the SQuAD effectSystematically favours BM25. Use only if nothing else exists, and say so in the report
A language model reading a document and writing a questionSame leakage, plus fluency the real users do not haveSame caveat, unless heavily prompted and filtered

A cheap sanity check if you suspect leakage: compute the IDF-coverage histogram from Chapter 1 on your eval set and on a sample of real query logs. If the eval histogram is shifted right, you have built a lexical-matching benchmark and any dense retriever will look worse on it than it will in production.

The whole chapter as one decision table

You have…Get positives by…Watch out for…
Human relevance judgementsUsing them directlyMulti-positive questions — mask the extras out of the denominator
Question–answer pairs, no passagesDistant supervision: highest-ranked retrieved passage containing the answerShort or common answers; the discard rate; BM25 circularity
Click logsClicked results as positivesPosition bias — users click what was shown first, so you will learn your current ranker
Only documentsGenerated queries, or cropped-span pairs (Contriever)Lexical leakage from generation; noisy pairs from cropping
Documents with structureTitles, section headings, anchor text, FAQ pairsRegister mismatch — headings are not how users ask

Note that every row has a "watch out for" entry, and every one of those entries is a way that the proxy differs from relevance. There is no clean source. The discipline is knowing which distortion you accepted and measuring whether it matters, which is exactly the discipline the SQuAD row in Chapter 8 demonstrates.

Chunking is a positives decision too

Chapter 0 presented 100-word chunks as an indexing choice. It is equally a labelling choice, and the interaction is worth naming.

Suppose a question's evidence spans a chunk boundary: the entity is named at the end of chunk 3 and the fact is stated at the start of chunk 4. Now every option is bad. Chunk 3 contains the subject but not the answer — and it will be a high-scoring, answer-free hard negative, forever. Chunk 4 contains the answer string, so distant supervision designates it the positive, but read alone it says "he was born there in 1889" and cannot actually be used. You have simultaneously manufactured a spurious positive and a poisoned hard negative from one boundary.

Chunking choiceEffect on positivesEffect on negatives
Disjoint 100-word (DPR)Boundary-straddling facts become unusable positivesAdjacent chunks become permanent, unfixable hard negatives
Overlapping windowsFewer split facts; some questions get several near-duplicate positivesNear-duplicate chunks appear as negatives for each other — mask them, or the loss fights itself
Semantic / paragraph chunksBetter-formed positivesHigh length variance, which a fixed-size vector handles poorly

The middle row's warning is the one people hit in practice. Overlap by 50% and every chunk has two near-identical neighbours that the model is being asked to rank apart on evidence that is genuinely the same. Deduplicate by content hash, or add near-duplicates to the multi-positive mask.

Click logs, and the bias that trains your own ranker

The most tempting positive source in any product with traffic is clicks: the user searched, saw ten results, clicked one. Surely that is a relevance judgement.

It is a relevance judgement conditioned on having been shown, and the conditioning is where the damage lives. Users examine high positions far more often than low ones, so click rate confounds relevance with position.

PositionExamination probability (typical)Click rate for an equally relevant result
11.000.30
20.650.195
50.200.06
100.080.024

Work the consequence. Two passages are equally relevant; your current ranker happens to put one at position 1 and the other at position 5. Over 1,000 impressions each, the first collects 300 clicks and the second collects 60. Train on clicks as positives and the first appears in your training set five times as often — not because it is better, but because your current ranker preferred it.

The result is a feedback loop, and it is the most common way a search system quietly stops improving. The model learns to reproduce the ranker that generated the logs. Its offline metrics improve, because the evaluation is built from the same logs. Real relevance does not move. The system has learned to agree with its own past self.

The standard correction is inverse propensity weighting: weight each click by the reciprocal of the probability that its position was examined.

w(click at position k) = 1 / P(examined | k)

A click at position 5 now counts 1/0.20 = 5.0 while a click at position 1 counts 1/1.00 = 1.0, which exactly cancels the ratio computed above. The remaining problem is estimating P(examined | k), which needs either result randomisation on a slice of traffic or an intervention like swapping adjacent positions — both of which cost a little user experience to buy an unbiased training signal.

Click-log pathologyEffect on the retrieverMitigation
Position biasLearns the current rankerInverse propensity weighting; randomisation slices
Presentation biasNever sees anything the ranker did not surface — the same trap as BM25-mined positivesExplore a small share of traffic beyond the top-k
Attractiveness biasLearns clickable titles rather than answersUse dwell time or task completion, not raw clicks
No-click sessionsDiscarded, so hard queries vanish — the 25.6% problem againTreat abandonment as a negative signal about the whole result set
Your team builds an internal retrieval eval by asking engineers to open random documents and write a question each one answers. BM25 scores 0.81 recall@10, your fine-tuned dense retriever scores 0.72, and the team concludes dense retrieval is not worth shipping. What is the strongest objection?

Chapter 7: Index and Serve

A trained dual encoder is not a retrieval system. It is half of one. The other half is 64.5 gigabytes of float vectors and a search structure over them, and the engineering there determines whether your beautiful +19 points ever reaches a user.

Step 1: encode the corpus

Every one of the 21,015,324 passages goes through EP exactly once. Cost:

2.1015×107 passages × 2.816×1010 FLOPs = 5.92×1017 FLOPs = 592 PFLOPs

The paper reports this took 8.8 hours on 8 GPUs. Sanity-check that against the theoretical floor: eight V100s at a realistic 30 TFLOP/s each is 240 TFLOP/s aggregate, so 5.92×1017 / 2.4×1014 = 2,467 seconds = 41 minutes. The measured time is 12.8× the floor, implying roughly 8% utilisation.

That gap is not incompetence, and understanding it is genuinely useful. Passages are short (~140 tokens), so kernels are small and launch overhead is significant; tokenisation of 21M strings on CPU competes with the GPU for the data pipeline; the encoding runs in fp32 in the original implementation; and every output vector must be moved to host memory and written to disk, which at 3,072 bytes × 21M is 64.5 GB of I/O. Encoding jobs at this scale are almost always data-pipeline bound, not FLOP bound, and the fix is batching, fp16, and more CPU workers, not more GPUs.

The output:

P ∈ R21,015,324 × 768 in float32 →  21,015,324 × 768 × 4 bytes = 64.56 GB

Step 2: choose an index

Retrieval is now maximum inner-product search (MIPS): given q, find the k rows of P with the largest q · p. Three families, and the choice is a three-way trade between memory, latency, and recall.

Flat / brute force (faiss.IndexFlatIP). Compute all 21M dot products. Exact, no build time, no tuning. Cost per query in FLOPs is 32 GFLOPs, as computed in Chapter 2 — but that is not the binding constraint. The binding constraint is memory bandwidth: you must stream all 64.56 GB through the arithmetic units, and at 1 TB/s of HBM that alone is 64.6 ms per query, before any other work. That is fine for offline evaluation and far too slow for a product at any real QPS.

Graph-based (IndexHNSWFlat). HNSW builds a navigable small-world graph over the vectors: each node links to a few dozen neighbours, arranged in layers so that search descends from coarse to fine. A query walks the graph greedily, touching a few thousand vectors instead of 21 million. The paper reports the resulting system serving 995 questions per second, against 23.7 q/s for the Lucene BM25 baseline — a 42× throughput advantage that surprises people who assume dense retrieval is the slow option.

The price is memory and build time. The graph edges must be stored alongside the vectors; the paper reports an index of roughly 151 GB and about 8.5 hours to build on a single high-memory server. Compare the BM25 index: about 0.5 hours to build and a small fraction of the memory. The index is where dense retrieval is expensive, not the query.

Quantised (IndexIVFPQ). If 151 GB is not available, compress. Product quantisation splits each 768-dimensional vector into m subvectors and replaces each subvector with the id of its nearest centroid from a learned codebook of 256:

768 dims → 96 subvectors of 8 dims → 96 bytes per passage
21,015,324 × 96 bytes = 2.02 GB  (a 32× reduction from 64.56 GB)

Scoring works directly on the codes: precompute, per query, a 96 × 256 table of partial inner products, then a passage's score is 96 table lookups and adds — no floating-point multiplies at all. You lose exactness, typically a few points of recall at these compression rates, and you gain the ability to hold the whole index in one machine's RAM.

IndexMemoryBuildLatencyRecallUse when
Flat (exact)64.6 GB0~65 ms (bandwidth-bound)100%Offline eval, ground truth, small corpora
HNSW~151 GB~8.5 h~1 ms (995 q/s reported)~98–99%Latency matters and RAM is available
IVF-PQ (m=96)~2 GB~1 h~2–5 ms~90–96%Memory-constrained, or many shards per host
BM25 invertedSmall~0.5 h~42 ms (23.7 q/s reported)n/aInstant updates, no training, exact-match queries

Step 3: the full serving path, with a latency budget

Here is the request, end to end, with realistic per-stage costs. This is the diagram to have in your head during a design review.

1 — Tokenise  ·  ~0.1 ms
"who is the bad guy in lord of the rings" → WordPiece ids, shape (14,)
2 — Question encoder  ·  ~3 ms
BERT-base forward on 14 tokens → (14, 768) → [CLS] → (768,). ~3 GFLOPs. Batch concurrent requests here
↓ one float32 vector, 3,072 bytes
3 — ANN search  ·  ~1 ms
HNSW walk over 21M vectors → 20 passage ids + scores. This is the step everyone assumes is the bottleneck; it is not
↓ 20 integers
4 — Hydrate  ·  ~2 ms
Fetch the 20 passage texts from a key-value store. The vector index stores no text — a detail that surprises people on their first build
↓ 20 × ~100 words
5 — Reader  ·  ~20 ms
A BERT reader over 20 (question, passage) pairs → span logits → answer. This dominates the budget
Total  ·  ~26 ms
Of which retrieval is 4 ms, or 15%

Read the proportions. The retriever — the component this entire paper is about — is 15% of the latency. Reading twenty passages costs five times as much as finding them. This is why every production system fights over k: dropping from k = 100 to k = 20 cuts the dominant cost by 80%, and the whole value of a better retriever is that you can afford a smaller k for the same recall.

The recall-versus-k trade, made concrete. BM25 needs k = 100 to reach 73.7% recall on NQ. DPR reaches 78.4% at k = 20. So DPR is not merely more accurate — it lets the reader process five times fewer passages while still seeing the evidence more often. Reader cost is linear in k, so the retriever improvement translates into an 80% cut in the dominant latency term. Retrieval quality buys serving cost, which is the argument that actually wins the design review.

The operational cost nobody warns you about

You retrain the encoder. Maybe you added RocketQA-style denoised negatives and gained three points. Now what?

Every one of the 21,015,324 stored vectors was produced by the old EP. They are now meaningless: your new EQ lives in a different space, and dot products across the two are noise. You must re-encode the entire corpus (8.8 GPU-hours), rebuild the index (8.5 hours), and swap it — ideally without downtime, which means holding two indexes, which means 302 GB of RAM.

model update ⇒ 21,015,324 re-encodings + full index rebuild + a blue/green swap

Compare BM25: a model update is not a concept, because there is no model. A new document is appended to a handful of posting lists in microseconds and is searchable immediately.

OperationBM25 / inverted indexDense / ANN index
Add one documentMicroseconds, immediately searchableOne encoder pass (~3 ms) + graph insert; HNSW degrades under many inserts
Delete one documentTombstone in the posting listTombstone plus periodic rebuild — graph edges pointing at it remain
Change the scorerEdit two constants, no reindexRe-encode all 21M and rebuild
New language or domainWorks immediately on the new term statisticsNeeds training data, or a model that generalises (Contriever)
Explain a ranking to a user"These query terms matched, with these weights""The dot product was 6.2." No further decomposition available

None of this argues against dense retrieval. It argues that the +19 points has an operational price tag, and that the price is paid in index lifecycle rather than query latency. Teams that budget only for the second one get an unpleasant surprise on their first model update.

Sharding, and why it is easy here

64.56 GB of vectors does not fit in one GPU, and 151 GB of HNSW does not fit comfortably in many single hosts. Split the corpus across N shards; each host holds 21M/N vectors and its own index. A query is broadcast to all shards; each returns its local top-k; a coordinator merges the N×k results and takes the global top-k.

This is exactly correct — not an approximation — for one specific reason: the score is a plain dot product, computed independently per passage. There is no cross-passage normalisation anywhere in the scoring function, so a passage's score does not depend on which shard it landed in, and merging local top-k lists yields the true global top-k. Had DPR used a softmax over the corpus, or any score that normalises across candidates, sharding would have required a second communication round. The architectural simplicity of Chapter 2 pays off again here.

Product quantisation, derived

Compressing 64.56 GB to 2 GB sounds like magic. It is a very simple idea applied 96 times.

Step 1 — split. Cut each 768-dimensional vector into m = 96 contiguous subvectors of 8 dimensions each. A passage is now 96 little vectors instead of one big one.

Step 2 — learn a codebook per slice. For slice s, run k-means with k = 256 over the 21 million 8-dimensional subvectors that occupy that slice. You now have 256 representative 8-dimensional centroids for slice s. Storage for all codebooks: 96 slices × 256 centroids × 8 dims × 4 bytes = 786 KB. Negligible, and shared across the whole corpus.

Step 3 — encode. Replace each subvector by the index of its nearest centroid: one byte, since 256 = 28. A passage is now 96 bytes.

3,072 bytes → 96 bytes  ⇒   21,015,324 × 96 = 2.02 GB  (32× compression)

Step 4 — score without decompressing. This is the elegant part. The inner product decomposes over slices:

q · p = ∑s=196 q(s) · p(s) ≈ ∑s=196 q(s) · cs, codes(p)

Given a query, precompute the table T[s][c] = q(s) · cs,c for all 96 slices and all 256 centroids — that is 96 × 256 = 24,576 eight-dimensional dot products, about 393,000 FLOPs, done once per query. Then scoring any passage is 96 table lookups and 95 additions. No multiplications at all. This is called asymmetric distance computation: the query stays exact, only the database is quantised, which is why the accuracy loss is far smaller than compressing both sides.

Cost per passage, compared honestly: 1,536 FLOPs of exact float arithmetic against 96 byte-indexed lookups. Both are cheap; the difference that matters is that 2 GB fits in cache-friendly memory on one machine and 64.56 GB does not.

Sharding arithmetic

Suppose you cannot find a 151 GB machine, or you want redundancy. Split into N shards:

N shardsVectors per shardfp32 per shardFan-out per queryMerge work
121,015,32464.56 GB1 requestnone
45,253,83116.14 GB4 parallel requestsmerge 4 × 20 = 80 results
161,313,4584.04 GB16 parallel requestsmerge 16 × 20 = 320 results

Latency is the maximum over shards, not the sum, so fan-out costs you tail latency rather than mean latency — and with 16 shards you are exposed to the slowest of 16 machines on every request, which is the classic tail-amplification problem. The merge itself is trivial: 320 scores, one partial sort.

The correctness argument bears repeating because it is not automatic. Merging local top-k gives the true global top-k only because the score is computed per passage with no cross-passage normalisation. Had DPR normalised scores within a candidate set — a softmax over the corpus, say — a passage's score would depend on which shard it landed in, and local top-k lists would not compose. Simple scoring functions shard trivially; clever ones do not.

The cost of a model update, in hours and dollars

StepWorkTimeRough cost at $2/GPU-hour
Train the encoders40 epochs on 58,880 questions, 8 GPUs~1 day~$400
Re-encode the corpus21,015,324 passages, 8 GPUs8.8 h~$140
Build the ANN indexHNSW over 21M vectors, 1 high-memory host8.5 h~$20
Hold two indexes for a swap2 × 151 GB residentduration of rolloutmemory, not compute
Total per shipped model~1.7 days~$560

The compute is not the painful part — $560 is nothing. The painful part is the 1.7-day cycle time, which sets how fast your team can iterate, and the operational rule it implies: you cannot A/B test two retrievers cheaply, because each arm needs its own full index. Teams that want fast retrieval iteration either shrink the corpus for development or keep a small representative shard permanently indexed under both models.

A rollout runbook

1 — Freeze and version
Pin the encoder checkpoint hash into the index metadata. A query encoder must refuse to serve against an index built by a different checkpoint. This single check prevents the worst outage in this system: silently mismatched spaces, which returns plausible-looking garbage
2 — Encode and build offline
Produce the new index alongside the old. Verify vector count and dimensionality; spot-check 100 known queries against the old system
3 — Shadow
Serve from the old index, run the new one in parallel, log both rankings. Compare top-k overlap and, if you have them, click-through or answer-correctness signals
4 — Atomic swap, keep the old one warm
Flip the pointer, do not delete the old index for a week. Rollback must be a pointer flip, not a rebuild — otherwise your recovery time is 17 hours

Latency is a distribution, not a number

The 26 ms budget in the flow above is a mean, and means are how retrieval systems get shipped and then get paged about. Every stage has a tail, and the tails compose badly.

Stagep50p99What creates the tail
Question encoding3 ms15 msBatching on the server: your request waits for a batch window to fill
ANN search1 ms8 msHNSW walk length varies by query; a query in a dense region of the graph touches far more nodes
Passage hydration2 ms25 ms20 independent key-value reads — you wait for the slowest of 20
Reader20 ms45 msSequence-length variation across the 20 passages

The hydration row is the instructive one and it catches people every time. Twenty independent lookups means your p50 is one lookup's p50 but your latency is the maximum of twenty draws. If a single lookup has a 1% chance of taking 25 ms, the probability that none of twenty does is 0.9920 = 0.818 — so 18% of requests hit at least one slow read. A per-lookup p99 becomes a per-request p82. The fix is a batched multi-get, or storing the passage text alongside the vector so the two travel together.

Caching, and why it works better here than you expect

Query distributions are heavily skewed — a small set of questions accounts for a large share of traffic. Three cache layers, in increasing order of hit rate and decreasing order of usefulness:

CacheKeySavesInvalidated by
Query embeddingNormalised query string3 ms of encoderA new question encoder
Retrieval resultQuery string + k3 + 1 + 2 = 6 msA new index or a new encoder
Full answerQuery stringAll 26 msAnything at all

Note the invalidation column: it is the index-lifecycle problem from earlier, propagating outward. A model update does not just invalidate 64.56 GB of vectors; it invalidates every cache derived from them, so your first minutes after a swap run at 0% hit rate and full cost. Capacity-plan for the cold cache, not the warm one.

A hybrid serving architecture

Chapter 1 concluded that the right production answer is usually both retrievers. Here is what that costs to run.

Fan out in parallel
Send the query to the dense service (encode + ANN, ~4 ms) and the sparse service (Lucene, ~42 ms) at the same time. Latency is the max, so about 42 ms — the sparse side is now your critical path, which surprises everyone
↓ two ranked lists, ~100 each
Fuse
Either a score combination (needs the two score scales reconciled — they are not comparable out of the box) or reciprocal rank fusion, which uses only ranks and therefore needs no calibration at all
↓ ~150 unique candidates
Rerank (optional)
A cross-encoder over the top 50: 50 × 28 GFLOPs ≈ 1.4 TFLOPs ≈ 10 ms on a GPU. This is where the cross-encoder from Chapter 2 finally becomes affordable

Two practical notes. DPR's λ = 1.1 score combination requires both scores to live on comparable scales, and they do not — BM25 is unbounded and corpus-dependent, dot products are unbounded and model-dependent — so any fixed λ is a per-corpus constant you must re-tune. Reciprocal rank fusion sidesteps this entirely by using only positions, which is why it is the more common production choice despite being cruder.

Failure modes to alarm on

SymptomLikely causeCheck
Results are plausible but subtly wrong for every queryQuery encoder and index built by different checkpointsThe version pin in the runbook. This is the outage that looks like a quality regression
Recall drops after adding documentsHNSW graph degradation from many inserts without a rebuildCompare against a brute-force scan on a sample
Latency fine, throughput collapsesIndex swapped in but not resident — you are paging 151 GB from diskResident memory versus index size
One shard returns nothingPartial index build; the coordinator merged an empty list silentlyPer-shard result counts, alarmed on zero
Scores all near identicalEmbedding collapse from a bad training run (Chapter 3)Variance of scores across a random query set

Cheaper storage before you reach for quantisation

Product quantisation is powerful and it is not the first thing to try. Two simpler moves come first, and both are nearly free in quality.

fp16. Halve the bytes per dimension. The index goes from 64.56 GB to 32.28 GB, and the accuracy cost is essentially nil — fp16 has about 3 decimal digits of precision, and your dot products are sums of 768 terms whose ranking gaps are far larger than that. This is the free 2×, and many systems never bother.

int8 with a per-vector scale. Store each dimension as one byte plus a float scale per vector: 21,015,324 × (768 + 4) bytes = 16.2 GB, a 4× reduction. Reconstruct as scale × int8. Typical recall loss is under a point, and integer dot products are fast on modern hardware.

StorageBytes per vector21M passagesTypical recall costComplexity
fp323,07264.56 GBNone
fp161,53632.28 GB≈ 0None
int8 + scale77216.22 GB< 1 pointLow
PQ, m = 96962.02 GBa few pointsCodebook training
Binary (1 bit/dim)962.02 GBLarge without retrainingNeeds a model trained for it

The ordering to work down: fp16, then int8, then a smaller d if your model supports it, then PQ. Reaching for PQ while still storing fp32 elsewhere in the stack is a common and slightly embarrassing sequencing error.

Two-stage retrieval within the index itself

One more idea that costs nothing and is widely used: search a compressed index for a generous candidate set, then rescore that small set with the exact vectors.

1 — Coarse
Search the 2 GB PQ index for the top 1,000. Fast, cache-friendly, approximate
↓ 1,000 ids
2 — Exact rescore
Load 1,000 fp32 vectors (3 MB) from disk or a secondary store, compute 1,000 exact dot products (1.5 MFLOPs), re-sort, keep 20

The quantisation error only has to preserve membership in the top 1,000, not the ordering within it, and membership is a far weaker requirement. Most of PQ's accuracy cost disappears, for 3 MB of reads and about a microsecond of arithmetic. If you take one implementation trick from this chapter, take this one.

What to alarm on, and at what threshold

SignalHealthyPage whenWhy it matters
Index / encoder version matchEqualEver unequalSilent, total quality loss with no error and no latency change
Vector count in the served index= corpus sizeOff by more than 0.1%A partial build shipped; some documents are simply unfindable
Mean top-1 scoreStable within a few percentShifts by more than 10% day over dayDistribution shift in queries, or a corrupted index segment
Score variance across a fixed probe setStableCollapses toward zeroEmbedding collapse, or a served checkpoint that never converged
p99 end-to-end latencyUnder SLOOver, or a rising trendUsually hydration fan-out or a cold cache after a swap
Cache hit rateSteadyDrops to zero unexpectedlySomething invalidated the index without an announced rollout

The first row is worth the whole table. A version mismatch between the query encoder and the passage index produces results that are syntactically valid, latency-normal, and semantically random. There is no exception, no partial degradation, and no metric except relevance itself will notice. Pin the version, assert it at startup, and refuse to serve on mismatch.

"Why not just use a vector database?"

You will be asked this, so it is worth being precise about what a managed vector store does and does not remove from the list above.

ConcernA vector database handles itStill yours
ANN index construction and tuningYes — and this is the main valueChoosing recall versus latency for your product
Sharding, replication, failoverYesCapacity planning against 64.56 GB × replicas
Incremental inserts and deletesYes, with tombstones and background compactionKnowing that recall drifts between compactions
Metadata filters and hybrid scoringUsuallyWhether the filter runs before or after the ANN search — a large recall difference nobody documents clearly
Storing the passage textUsually, as a payloadKeeping it consistent with the vectors
Re-encoding 21M passages after a model changeNoEntirely yours — 8.8 GPU-hours, every time
Encoder/index version pinningNoYours, and it is the outage from the runbook
Choosing what a passage isNoYours — Chapter 0, and it caps everything

Read the bottom three rows. A vector database removes the distributed-systems work, which is real and worth paying for. It removes none of the work this lesson has been about: the chunking, the encoder, the index lifecycle, and the version discipline. "We use a vector database" answers a storage question and leaves every retrieval-quality question exactly where it was.

Your dense retrieval service meets its latency SLA comfortably. Six weeks later the team ships a retrained encoder that is 3 points better offline, and the rollout takes two days of engineering and a period of degraded results. What did the original design most likely fail to budget for?

Chapter 8: The Numbers, Including the Bad One

Five datasets, two metrics, and one row that the paper could have hidden and did not. Let us read all of it.

The training setup, so the numbers mean something

SettingValueWhy it is what it is
EncodersTwo BERT-base-uncased, 110M each, both trainableChapter 2 — untied towers for an asymmetric relation
Embedding dim768 ([CLS])BERT-base's hidden size; no projection head at all
Batch size128 questionsChapter 4 — the batch is the negative pool
Negatives1 gold + 1 BM25 hard per question, in-batch shared → 255 per questionThe single highest-return line in the recipe
OptimiserAdam, lr 1e-5, linear schedule with warm-up, dropout 0.1Standard BERT fine-tuning; nothing exotic
Epochs40 for the large datasets, 100 for the small onesCuratedTREC has 1,125 questions — it needs the passes
Hardware8 × 32 GB V100Memory is dominated by activations for 128 + 256 sequences
Corpus21,015,324 passages of 100 words, from the Dec 2018 English WikipediaFixed-length chunks, title prepended

The training-set sizes matter for reading the results, because they vary by a factor of fifty:

DatasetTrain questions (after filtering)What the questions are
Natural Questions58,880 (from 79,168)Real Google queries, gold long-answer paragraphs
TriviaQA60,413Trivia-enthusiast questions; positives by distant supervision
WebQuestions2,474Freebase-entity questions from Google Suggest
CuratedTREC1,125TREC QA track questions; the smallest set
SQuAD v1.170,096Questions written while reading the paragraph

Retrieval accuracy — the table

DatasetBM25 @20DPR @20Hybrid @20BM25 @100DPR @100Hybrid @100
Natural Questions59.178.476.673.785.483.8
TriviaQA66.979.479.876.785.085.2
WebQuestions55.073.271.571.181.481.1
CuratedTREC70.979.885.284.189.192.9
SQuAD68.863.271.580.077.281.3

Four wins, one loss, and the loss is the informative one. Chapter 6 explained it fully: SQuAD's questions were written by people reading the answer paragraph, which manufactures the lexical overlap BM25 thrives on, and its passages come from only a few hundred articles, which puts the training distribution badly out of line with a 21-million-passage index. The paper states both reasons plainly rather than dropping the row.

Why publishing the SQuAD row is the most useful thing in the results section. A table of five wins would have told you dense retrieval is better. A table with one loss tells you the conditions under which it is better — and those conditions are checkable against your own data before you spend a quarter building anything. That is the difference between a benchmark result and an engineering result.

Read the curve, not the point

Top-k retrieval accuracy, and what k costs you

Drag k. The two circled points on each curve at k = 20 and k = 100 are the paper's reported numbers; the line between and around them is a monotone log-interpolation drawn to show shape, not measured data. The readout converts the retrieval number into the end-to-end ceiling from Chapter 0 (with a reader that is 70% accurate given evidence) and into reader cost, which is linear in k.

k = 20

Two readings worth making explicitly. First, the curves converge as k grows: at k = 100 the gap on NQ is 11.7 points, at k = 20 it is 19.3. A better retriever helps most exactly where you need it most, at small k, which is where reader cost lives. Second, on SQuAD the DPR curve sits below BM25 across the whole range — this is not a crossover artifact of one k, it is a genuinely worse retriever on that distribution.

How little data DPR needs

The paper's most under-quoted result: train on 1,000 NQ examples and DPR already beats BM25 on top-20. One thousand.

Why is that possible, when the model has 220M parameters? Because almost none of them are being learned from those thousand examples. BERT arrived already knowing English — that "villain" and "bad guy" occupy nearby regions is a fact about the pretraining corpus, not about your labels. What the thousand examples teach is a much smaller thing: which of the many similarity relations BERT encodes corresponds to "answers", and how to read it out of the [CLS] position. That is closer to fitting a projection than to learning a representation.

Realization note. This is why the practical advice for a new domain is almost never "collect a million pairs". It is: take a strong pretrained encoder, collect one to five thousand honest pairs, mine one hard negative each, train for a few epochs, and measure. If that does not beat BM25 on your data, the problem is your pairs or your evaluation protocol (Chapter 6), not your data volume.

End-to-end question answering

Retrieval accuracy is instrumental. The product metric is exact-match on the final answer string:

SystemNQ (EM)TriviaQA (EM)Pretraining cost
BM25 + BERT reader26.547.1None beyond BERT
ORQA33.345.0Inverse Cloze Task over Wikipedia
REALM40.4End-to-end retrieval-augmented LM pretraining
DPR41.556.8None
DPR + BM25 hybrid39.057.9None

DPR beats REALM on NQ without REALM's retrieval-oriented pretraining, which is the claim from Chapter 0 finally cashed. On the two smallest datasets, WebQuestions and CuratedTREC, DPR does not lead — REALM comes out ahead, and that is exactly the pattern you would predict: expensive pretraining buys the most where task supervision is scarcest, and 1,125 training questions is scarce.

Also note the sign flip in the last row: hybrid helps TriviaQA (+1.1) and hurts NQ (−2.5), matching the retrieval-level result from Chapter 1. Fusion is not a free improvement; it is a bet that the two systems fail differently.

Cross-dataset generalisation

A dense retriever trained on Natural Questions and evaluated on WebQuestions and CuratedTREC — without seeing a single training example from either — loses only a few points against models trained directly on those datasets, and still comfortably beats BM25 on them. Two implications.

The optimistic one: the model is learning a general "this passage answers this question" relation, not memorising NQ's topics. The cautionary one, which the BEIR benchmark would make painful two years later: all three of those datasets are Wikipedia-based factoid QA. Generalising across three samples of the same distribution is a much weaker claim than generalising to legal contracts or biomedical abstracts. Contriever exists because the second claim turned out to be false for early dense retrievers.

What the ablations settled

QuestionFindingChapter
Which similarity function?Dot product; L2 comparable; cosine is worse — normalising discards the norm, and the norm is the model's implicit confidence3
Which loss?NLL over the candidate set beats triplet — automatic hardness weighting, no margin to guess3
Which negatives?In-batch + one BM25 hard negative. Two hard negatives is worse than one4
Is retrieval pretraining needed?No. Plain BERT plus a good recipe beats ICT-pretrained ORQA0
Does prepending the title help?Yes, about a point — the title is a compressed topic label the chunk often lacks2
How much data?1,000 pairs beats BM25 on NQ; returns continue but flatten8

Reading the errors, not just the averages

The paper includes a qualitative analysis, and it is the part that generalises furthest, because it names which questions each system gets right. Two representative cases, both from the paper:

QuestionWhat the gold passage saysBM25DPRMechanism
"Who is the bad guy in Lord of the Rings?""…portraying the villain Sauron…"MissesFindsSemantic bridge from "bad guy" to "villain" — a relation the sparse basis cannot express
"Who plays Thoros of Myr in Game of Thrones?""Paul Kaye… Thoros of Myr…"FindsMissesA rare string with IDF ~14.3 is a near-unique key in the sparse index and a subword fragment after pooling

These two rows are the whole argument of Chapter 1 in concrete form, and they are also a design instruction: the errors are complementary, which is why hybrid retrieval helps at all, and why it helps most exactly where each system is weak.

What top-k accuracy does not tell you

Every number in this chapter counts a retrieval as successful if some returned passage contains the answer string. Three ways that overstates things, all of which you will meet in your own evaluation:

Presence is not evidence. A passage containing "1969" for an unrelated reason counts. The reader then has to distinguish real evidence from coincidental string presence, and some of the gap between retrieval accuracy (78.4%) and end-to-end EM (41.5%) is exactly that.

One-passage answers are assumed. Questions needing two passages combined — multi-hop questions — are scored as if a single passage sufficed. HotpotQA exists because this class of question defeats every single-vector retriever, and DPR's benchmarks mostly avoid it.

Unanswerable questions are absent. The datasets are filtered so that every question has an answer in Wikipedia. A production system receives questions with no answer anywhere, and neither the metric nor the training objective has anything to say about them — the softmax will confidently rank something first, exactly as the fixed-head classifier from Chapter 0 always names a class.

Instrument the pipeline, not just the retriever. Report three numbers, not one: top-k accuracy (did the evidence arrive?), reader accuracy given correct evidence (could it be used?), and end-to-end EM. Their product structure from Chapter 0 tells you immediately which one to work on, and it stops the recurring failure where a team optimises the number that is already the highest.

The ablations you should run on your own data

DPR's ablations are a template. Six experiments, in the order of return per unit of effort, each answering a question you cannot answer by reasoning:

#ExperimentWhat it tells youCost
1BM25 baseline on your evalWhether you have a lexical-gap problem at all. If BM25 is at 90% you may be doneAn afternoon
2Read 50 training positives by handYour label ceiling (Chapter 6). Nothing else matters if this is 65%An hour
3In-batch only, largest batch that fitsYour floor with zero mining infrastructureOne training run
4+ 1 BM25 hard negativeThe single highest-return change; expect the largest jump hereOne run + a BM25 index
5+ 2 and + 4 hard negativesWhether your false-negative rate has already bitten. If 2 is worse than 1, denoise before scalingTwo runs
6Train on 1k / 5k / 20k / all pairsWhere your data curve flattens — and whether more annotation is worth buyingFour short runs

Note that experiments 1, 2 and 5 are diagnostics rather than improvements. They are also the three most often skipped, and each of them can invalidate months of modelling work in under a day.

Where these numbers sit in 2026

DPR's 78.4 top-20 was a landmark in 2020. Putting it beside what came after is not a way of diminishing it — it is the clearest picture of what the negative-mining lineage was actually worth.

SystemYearNQ top-20MS MARCO MRR@10The one change
BM25199459.10.187
DPR202078.4In-batch + 1 BM25 hard negative
ANCE20200.330Negatives mined from the model's own index
RocketQA202082.70.370Cross-batch negatives + cross-encoder denoising
Contriever2021No labels at all; the first unsupervised retriever to beat BM25 across BEIR

Read the gaps. BM25 to DPR is +19.3 on NQ — the paradigm shift. DPR to RocketQA is +4.3 — three more years of negative engineering. The first jump came from having hard negatives at all; everything after came from having better ones, and the returns were exactly the diminishing kind you would predict from Chapter 4's dose-response curve.

The comparison to make honestly. These numbers are not directly comparable across rows — different corpora, different reader stacks, different evaluation years, and MS MARCO and NQ measure different things. Treat the table as a shape, not a leaderboard. The shape is: one large discontinuity in 2020, then steady incremental gains from data engineering, with no further architectural revolution in the first-stage retriever. That is a mature field, and it is why the interesting frontier moved to what you do with the retrieved passages.

What generalisation actually looked like, later

Chapter 8's cross-dataset result — NQ-trained DPR transferring to WebQuestions and CuratedTREC — reads as encouraging until you notice all three are Wikipedia factoid QA. BEIR, published a year later, evaluated dense retrievers on eighteen datasets spanning scientific claim verification, biomedical search, financial question answering, argument retrieval and duplicate-question detection.

The finding that mattered: a dense retriever trained on MS MARCO frequently lost to BM25 on datasets far from its training distribution, sometimes badly. Chapter 1's third BM25 advantage was not a footnote; at the time it was the single largest practical objection to dense retrieval, and it is what Contriever, and then the instruction-tuned multi-domain embedders, were built to answer.

The lesson for your own work is the same one Chapter 6 gives from the label side: a dense retriever is a fitted function, and its quality is a statement about a distribution, not about text in general. Measure on your own corpus, with your own queries, before believing any number in this chapter applies to you.

The reader, briefly, because the end-to-end numbers depend on it

Every EM number in this chapter came through a reader, so a paragraph on what it is prevents mis-attributing credit.

DPR's reader takes the k retrieved passages and does two things at once. A passage selection head scores each passage's [CLS] to pick which one holds the answer, and a span extraction head predicts start and end token positions within each passage. Both are linear layers on top of a BERT encoder that reads each (question, passage) pair. The final answer is the best span from the best-scoring passage.

ComponentInputOutputCost at k = 20
Encoderk × (question [SEP] passage)k × (L, 768)20 × 28 GFLOPs = 0.56 TFLOPs
Selection headk [CLS] vectorsk scoresNegligible
Span headsk × (L, 768)2 × k × L logitsNegligible

Note the reader is itself a cross-encoder — the architecture Chapter 2 ruled out for retrieval. That is the whole point of the two-stage design: the expensive joint model becomes affordable once something cheap has narrowed 21 million candidates down to 20. Improving the retriever lets you shrink k, which shrinks the only cost that scales.

Reproducing this, if you want to

WhatWhereRough cost
The full pipelineThe authors' reference implementation, released with the paper
Pretrained encoders + the 21M-passage indexDistributed with that release; also mirrored in the Transformers ecosystem~65 GB download
Retraining the encoders8 GPUs, 40 epochs on NQ~1 GPU-day
Re-encoding the corpus8 GPUs8.8 hours
A scaled-down replicationA 500k-passage subset, one GPU, batch 32, 5k pairsAn evening — and it reproduces the BM25 comparison qualitatively

The last row is the one worth doing. Half a million passages fits comfortably in one GPU's memory as an exact index (500,000 × 768 × 4 = 1.5 GB), so you can skip FAISS tuning entirely and still watch the in-batch-only model land near BM25 and the plus-one-hard-negative model pull clearly ahead. That single before-and-after is the whole paper, at a scale you can run tonight.

What the paper did not test

Reading a results section well includes noticing the experiments that are absent. Five, each of which became somebody's next paper.

UntestedWhy it mattersWho answered it
Out-of-domain corporaAll five datasets are Wikipedia factoid QA. Nothing here shows the model transfers to law, medicine, or codeBEIR (2021), and the answer was often "it does not"
Multi-hop questionsA single vector cannot represent "the passage that combines with another passage"Multi-hop retrievers; still not fully solved
Unanswerable questionsEvery dataset is filtered so an answer exists. Production traffic is notAbstention and calibration work; still an open weakness
Larger encodersBoth towers are BERT-base. Does the recipe scale with model size?Yes, and later embedders are much larger — but the negatives still dominate
Non-EnglishEnglish onlymDPR, multilingual E5, and the cross-lingual case from Chapter 1

None of these is a criticism — a paper that tested all five would have been five papers. The point is a reading habit: the scope of the experiments is the scope of the claim, and the most common way to be burned by a benchmark result is to apply it outside the distribution it was measured on.

How many evaluation questions do you need?

Every improvement in this chapter is a difference between two percentages measured on a finite sample. It is worth knowing when such a difference means anything.

Top-k accuracy is a proportion, so on n independent questions its standard error is

SE = √( p(1−p) / n )

At p = 0.78 and n = 1,000: p(1−p) = 0.1716, divided by 1,000 is 1.716×10−4, square root 0.0131. So a single measured number of 78.0% carries a 95% interval of roughly ±2.6 points. And comparing two systems on different question samples inflates it by √2:

SEdiff = √(2 × 1.716×10−4) = 0.0185 →  95% interval ±3.6 points

Which means a 2-point improvement measured this way is indistinguishable from noise. That number should be sobering: a great deal of published and internal retrieval progress lives inside that band.

The fix is not a bigger eval set — reaching ±1 point at 95% would need n = 1.962 × 0.1716 / 0.0126,600 questions. The fix is to evaluate both systems on the same questions and analyse only the disagreements, which is McNemar's test. If the two systems agree on 880 of 1,000 questions and disagree on 120, the standard error of the difference is

SEpaired = √(b + c) / n = √120 / 1000 = 0.01096 →  95% interval ±2.1 points

Better, from the same data, purely by not throwing away the pairing. And the practical instruction that follows is the useful part: keep your eval questions fixed across experiments, and report the count of questions won and lost, not just the delta.

ComparisonWhat to reportRule of thumb at n = 1,000
Two systems, same questionsWins / losses / ties, plus McNemarDifferences under ~2 points are weak evidence
Two systems, different samplesBoth intervals, and an apologyDifferences under ~4 points are noise
One system across timeSame fixed eval set, alwaysAny change of eval invalidates the history
Apply this to the table above. The BM25-to-DPR gap on Natural Questions is 19.3 points — far outside any plausible interval, so it is unambiguously real. The DPR-to-RocketQA gap is 4.3 points, which is real but far less dramatic, and the hybrid's 1.8-point loss on NQ is close to the edge of what a 1,000-question eval can resolve. Reading a results table well means knowing which rows you are allowed to draw conclusions from.

Reporting retrieval results honestly

A short checklist, assembled from every trap this lesson has walked through. If a retrieval result — yours or someone else's — does not answer these, you cannot tell what it means.

QuestionWhy it changes the interpretationChapter
How were the eval queries written?Written from the document → the benchmark favours lexical matching and understates dense retrieval6
What counts as a hit — answer string, or judged relevance?String presence is lenient and inflates every system by a similar amount0
How many eval questions, and were both systems run on the same ones?Sets the noise floor; unpaired comparison at n = 1,000 cannot resolve 3 points8
Was the sparse baseline plain BM25 or BM25+RM3?RM3 closes part of the lexical gap for free; plain BM25 is a softer baseline1
Exact or approximate index?An ANN index costs a point or two of recall that has nothing to do with the model7
What k, and what does k cost downstream?Gains at k = 100 are worth far less than the same gains at k = 208
In-domain or out-of-domain?Dense retrievers are fitted functions; transfer is a separate claim requiring separate evidence1, 8
Apply it to this lesson. DPR's numbers pass most of these: real Google queries for NQ, a fixed evaluation across systems, a Lucene BM25 baseline, an explicitly published loss on SQuAD, and both k = 20 and k = 100 reported. They do not answer the out-of-domain question — and that is precisely the gap BEIR walked into a year later. A results table is a set of answered questions; the unanswered ones are where the next paper comes from.
On Natural Questions the DPR–BM25 gap is 19.3 points at k = 20 and 11.7 points at k = 100. Why does the advantage shrink as k grows, and why does that make the small-k gap the more valuable one?

Chapter 9: By Hand, End to End

One training step, computed entirely by hand, to a final decimal. If you can reproduce this on paper you understand the objective, and if you cannot, some part of Chapters 3 and 4 has not landed yet.

The setup

A batch of three questions with their three gold passages. No BM25 hard negatives yet — pure in-batch. The towers have produced six 768-dimensional vectors; we only need the nine dot products they induce.

QuestionIts gold passage
1"who is the bad guy in lord of the rings"Sala Baker / villain Sauron
2"who directed the lord of the rings films"Peter Jackson filmography
3"what is the body of water between england and ireland"The Irish Sea

The similarity matrix S = Q PT, shape (3, 3). Rows are questions, columns are passages. Raw dot products — unnormalised, no temperature, exactly as DPR computes them:

S =  [ 6.0  4.5  1.2 ]
     [ 2.1  5.4  3.0 ]
     [ 0.9  2.4  4.8 ]

Before computing anything, read it. The diagonal (6.0, 5.4, 4.8) holds the positives and is the largest entry in each row — the model is already roughly right. The entry S12 = 4.5 is the interesting one: question 1 is about Lord of the Rings, and so is passage 2, so the model finds it plausible. That is a genuine hard negative, arriving free from the batch. S13 = 1.2 is the Irish Sea passage, which shares nothing with question 1 — an easy negative.

Row 1, in full

The label for row 1 is column 1. Subtract the row maximum (6.0) before exponentiating; softmax is shift-invariant, so this changes nothing except the numerical stability:

e6.0−6.0 = e0 = 1.000000
e4.5−6.0 = e−1.5 = 0.223130
e1.2−6.0 = e−4.8 = 0.008230
∑ = 1.231360
p1 = ( 0.812110 , 0.181209 , 0.006683 )
L1 = −log(0.812110) = log(1.231360) = 0.208118

Verify the log by hand if you like: log(1.2) = 0.182322, and 1.231360/1.2 = 1.026133, whose log is 0.025796. Sum: 0.208118. Good.

Row 2

Label is column 2, the maximum is 5.4:

e2.1−5.4 = e−3.3 = 0.036883
e5.4−5.4 = 1.000000
e3.0−5.4 = e−2.4 = 0.090718
∑ = 1.127601
p2 = ( 0.032709 , 0.886838 , 0.080453 )
L2 = log(1.127601) = 0.120093

Row 3

Label is column 3, the maximum is 4.8:

e0.9−4.8 = e−3.9 = 0.020242
e2.4−4.8 = e−2.4 = 0.090718
e4.8−4.8 = 1.000000
∑ = 1.110960
p3 = ( 0.018220 , 0.081657 , 0.900123 )
L3 = log(1.110960) = 0.105224

The batch loss

L = (0.208118 + 0.120093 + 0.105224) / 3 = 0.433435 / 3 = 0.144478

Compare it to the chance-level floor for three candidates, log 3 = 1.098612. The model is at 13% of the guessing loss, so it is clearly learning. Compare it also to zero: the remaining 0.144 is almost entirely row 1's 0.208, which is almost entirely the single cell S12 = 4.5.

Where the gradient goes

Using ∂L/∂Sij = (pij − yij)/3 — the division by 3 because we averaged over the batch:

Row∂L/∂S  (×3, i.e. before averaging)Where the negative pressure lands
1(−0.187890, +0.181209, +0.006683)96.4% on the Peter Jackson cell, 3.6% on the Irish Sea cell
2(+0.032709, −0.113162, +0.080453)71.1% on the Irish Sea cell, 28.9% on the Sala Baker cell
3(+0.018220, +0.081657, −0.099877)81.8% on the Peter Jackson cell

Check row 1's split: 0.181209 / (0.181209 + 0.006683) = 0.181209/0.187892 = 0.96444. Ninety-six percent of everything this question teaches the model about "wrong" comes from one cell. That single number is Chapter 4 in miniature: the batch contributed two negatives and delivered the gradient of approximately one.

Now plant a false negative and watch it break

Extend the batch to four passages by adding one that also correctly answers question 1 — a different Wikipedia paragraph naming Sauron as the antagonist — but which is labelled as question 4's gold, not question 1's. The model scores it 5.8 for question 1, correctly recognising it as relevant. Row 1 becomes [6.0, 4.5, 1.2, 5.8]:

e0 = 1.000000, e−1.5 = 0.223130, e−4.8 = 0.008230, e−0.2 = 0.818731
∑ = 2.050091
p1 = ( 0.487783 , 0.108838 , 0.004014 , 0.399364 )
L1 = log(2.050091) = 0.717884  (was 0.208118 — a 3.45× increase)

Nothing about the model got worse. Its similarity for a genuinely correct passage was high, which is the behaviour you want. The label disagreed, and the loss punished it for being right. Nearly 40% of the update now pushes question 1 away from a passage that answers it — more force than the honest hard negative receives (10.9%).

The whole of Chapter 4 lives in the gap between 0.208 and 0.718. One mislabelled cell tripled the loss and redirected the majority of the negative gradient into actively unlearning a correct association. Now recall RocketQA's audit: about 70% of unlabelled top-retrieved passages actually contained the answer. Mine your hard negatives from the top of the ranking without denoising and you are not planting one bad cell. You are planting most of them.

The same computation, in code

import torch, torch.nn.functional as F

S = torch.tensor([[6.0, 4.5, 1.2],
                  [2.1, 5.4, 3.0],
                  [0.9, 2.4, 4.8]])       # (B, M) = Q @ P.T
labels = torch.arange(3)                        # gold of question i is column i
loss = F.cross_entropy(S, labels)              # tensor(0.1445)

# the gradient split, per row
P = S.softmax(dim=1)
G = P - F.one_hot(labels, 3).float()      # row 0: [-0.1879, 0.1812, 0.0067]

Five lines, and every number in this chapter falls out of them. When you build this yourself, print G for one batch and look at where the mass sits. If it is spread evenly across all columns, your negatives are too easy. If one non-diagonal column dominates every row, go and read that passage — it is either a superb hard negative or a false one, and only your eyes can tell.

What came after: the two directions the field took

DPR compressed each passage into one vector. Two families of successors relaxed exactly one of its constraints each.

ColBERT — relax the pooling. Keep every token vector instead of pooling to one, and define similarity as a sum over query tokens of the maximum similarity against any passage token (the MaxSim late-interaction operator). This restores the lexical precision that Chapter 2's stage 6 destroyed — the Thoros-of-Myr failure largely goes away, because a rare token can now match a rare token directly. The price is the index: instead of one 768-dim vector per passage you store one per token, roughly a hundred times more, which ColBERTv2 then attacks with residual compression and the PLAID engine. It is the same trade as always: more of the interaction retained, more of the index paid for.

SPLADE — relax the density. Keep a sparse, vocabulary-sized output, but learn which terms to activate, so a passage about villains can light up the "bad guy" coordinate. This gives you a learned representation that still runs on an inverted index, inheriting decades of sparse-retrieval infrastructure. The lexical basis stops being a prison once the model gets to decide what goes in it.

And downstream, RAG: DPR's retriever wired to a generator, with the retrieved passages placed in the generator's context. That paper shares authors with this one and uses this index directly. Every retrieval-augmented system you have used inherits the loss you just computed by hand — which means it also inherits the negative-sampling decisions of whoever trained its embedder.

The cheat sheet

SymbolMeaningShape / value
EQ, EPQuestion and passage encoders — independent BERT-base110M parameters each
q, pPooled [CLS] embeddings(768,) each
sim(q,p)Relevance scoreqTp — dot product, no normalisation, no temperature
BBatch size (questions)128
MPassages in the batch2B = 256 with one hard negative each
SSimilarity matrix(B, M) = (128, 256); B positives, B(M−1) = 32,640 negatives
nNegatives per questionM − 1 = 255
LLossMean over rows of −log softmax(S)i,gold(i); floor is log M = 5.545
∂L/∂SijGradient(pij − yij)/B — influence equals softmax probability
kPassages returned20 or 100; reader cost is linear in k
NumberWhat it is
21,015,324Passages in the index; 64.56 GB of fp32 vectors
78.4 / 85.4DPR top-20 / top-100 on Natural Questions, versus BM25's 59.1 / 73.7
63.2 vs 68.8SQuAD top-20 — the row DPR loses, and the most instructive one
41.5End-to-end exact match on NQ, beating REALM's 40.4 with no retrieval pretraining
255Negatives per question: 127 other golds + 128 BM25 hard negatives
1Hard negatives per question in the best configuration. Two was worse
18,300,000×Cost ratio of an encoded negative (28 GFLOPs) to an in-batch one (1,536 FLOPs)
~70%Share of unlabelled top-retrieved passages that RocketQA's audit found actually contained the answer
995 vs 23.7Questions per second: DPR with HNSW versus Lucene BM25
8.8 h + 8.5 hCorpus encoding on 8 GPUs, plus FAISS index build — the real cost of a model update

Where to go from here

If you want…Go to
The bi-encoder idea before it met retrievalSentence-BERT and SimCSE
Late interaction — keep every token vectorColBERT and ColBERTv2
What negative mining became at scaleE5, BGE and the weak-supervision era
Shrinking the 64.5 GB indexMatryoshka representation learning
The generator bolted onto this retrieverRAG and our RAG gleam
The contrastive objective in generalContrastive learning and CLIP
The encoder both towers are made ofBERT
Serving vectors in productionVector databases and vector embeddings

Build it yourself — the weekend recipe

StepWhat to doThe decision that matters
1. PairsA few thousand honest (query, passage) pairs from your own domainChapter 6: were the queries written before seeing the passage? If not, your eval is measuring BM25's home turf
2. TowersTwo AutoModel instances, or one shared with "query:"/"passage:" prefixesUntie only if your two sides genuinely differ in distribution
3. PoolingMean pooling is the safer default now; [CLS] if you fine-tune hardChapter 2: pooling is where rare-token precision dies
4. LossF.cross_entropy(q @ p.T, arange(B))Print log(M) first and confirm your loss starts there
5. BatchThe largest that fits; add cross-GPU all-gather if you have more than oneThe batch is the negative pool. Deduplicate by passage id when sampling
6. Hard negativesExactly one BM25 negative per question, answer-filteredThe single highest-return line. Do not add a second without denoising
7. DenoiseIf you have a cross-encoder, drop negatives it rates as relevantChapter 5: this is what separates DPR from RocketQA's +4 points
8. IndexIndexFlatIP while your corpus is under a million; HNSW or IVF-PQ beyondDo not tune an approximate index before you have exact numbers to compare against
9. EvaluateRecall@20 and recall@100, against BM25 on the same splitIf you cannot beat BM25 at k=100 you have a data problem, not a model problem
10. BudgetTime the full re-encode + rebuild before you shipChapter 7: the index lifecycle, not query latency, is what makes dense retrieval expensive

References

  1. Karpukhin, V., Oǧuz, B., Min, S., Lewis, P., Wu, L., Edunov, S., Chen, D., Yih, W. "Dense Passage Retrieval for Open-Domain Question Answering," EMNLP 2020 — arXiv:2004.04906. The paper this lesson is built on.
  2. Xiong, L. et al. "Approximate Nearest Neighbor Negative Contrastive Learning for Dense Text Retrieval" (ANCE), ICLR 2021 — arXiv:2007.00808. Asynchronous index refresh and the variance argument for global negatives.
  3. Qu, Y. et al. "RocketQA: An Optimized Training Approach to Dense Passage Retrieval for Open-Domain Question Answering," NAACL 2021 — arXiv:2010.08191. Cross-batch negatives, cross-encoder denoising, data augmentation.
  4. Izacard, G. et al. "Unsupervised Dense Information Retrieval with Contrastive Learning" (Contriever), 2021 — arXiv:2112.09118. Independent cropping, MoCo queue, zero-shot BEIR.
  5. Khattab, O. & Zaharia, M. "ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT," SIGIR 2020 — arXiv:2004.12832; and Santhanam, K. et al. "ColBERTv2," 2021 — arXiv:2112.01488.
  6. Lewis, P. et al. "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks" (RAG), NeurIPS 2020 — arXiv:2005.11401. DPR's index, wired to a generator.
  7. Lee, K., Chang, M.-W., Toutanova, K. "Latent Retrieval for Weakly Supervised Open Domain Question Answering" (ORQA / Inverse Cloze Task), ACL 2019 — arXiv:1906.00300. The pretraining approach DPR showed was unnecessary.
  8. Guu, K. et al. "REALM: Retrieval-Augmented Language Model Pre-Training," ICML 2020 — arXiv:2002.08909. The strongest pretraining-heavy baseline DPR beats on NQ.
  9. Johnson, J., Douze, M., Jégou, H. "Billion-scale similarity search with GPUs" (FAISS), 2017 — arXiv:1702.08734. The index behind every number in Chapter 7.
  10. Malkov, Y. & Yashunin, D. "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs," 2016 — arXiv:1603.09320. HNSW.
  11. Thakur, N. et al. "BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models," 2021 — arXiv:2104.08663. Where the out-of-domain claim from Chapter 1 gets measured properly.
  12. Kwiatkowski, T. et al. "Natural Questions: A Benchmark for Question Answering Research," TACL 2019. The dataset whose gold long answers make DPR's positives honest.
Cross-domain bridge
Negative sampling is the same problem as choosing what a classifier competes against
Word2vec's skip-gram with negative sampling, noise-contrastive estimation for language models, and DPR's in-batch negatives are one idea in three costumes: a full softmax over a huge output space is intractable, so you approximate the normalising constant with a sample — and the quality of your approximation is entirely determined by the sampling distribution. Word2vec discovered it needed the unigram distribution raised to the 0.75 power, not uniform, for exactly the reason Chapter 4 gives: uniform samples are too easy and contribute no gradient. DPR discovered it needed BM25's top-1 rather than uniform, for the same reason. If you have tuned a negative-sampling distribution in a recommender or a graph-embedding model, you have already met this problem — see our contrastive learning and embedding layers lessons for the same mathematics with different nouns.
"What I cannot create, I do not understand."
Two AutoModel objects, one @, one F.cross_entropy, and a thousand honest pairs. You can have a dense retriever that beats BM25 on your own corpus before the weekend is over — and the moment you add a single BM25 hard negative per question, you will feel Chapter 4 rather than read it.
Exit gate — teach it back before you leave.

Without scrolling up: (1) explain why BM25 scores the "villain Sauron" passage below a box-office passage, in terms of the basis of its vector space; (2) write the DPR loss and say where each negative comes from in a batch of 128 with one BM25 negative each — give the count and the breakdown; (3) compute the loss of a 2×2 batch whose similarities are 5.0 on the diagonal and 3.0 off it; (4) explain why two BM25 hard negatives scored worse than one; (5) state the operational cost of retraining EP and why it, not query latency, is the reason teams stay on BM25. If any of the five stalls, its chapter is one tap away.

A second worked example: the 2×2 batch

Smaller, so you can do it entirely in your head after a little practice. Two questions, two golds, diagonal similarity 5.0 and off-diagonal 3.0:

S = [ 5.0  3.0 ]
     [ 3.0  5.0 ]

Row 1: subtract the max, 5.0. e0 = 1, e−2 = 0.135335. Sum = 1.135335.

p1 = ( 0.880797 , 0.119203 )   L1 = log(1.135335) = 0.126928

Row 2 is the mirror image, so L2 = 0.126928 and the batch loss is 0.126928. The chance-level floor is log 2 = 0.693147, so the model is at 18.3% of guessing.

Now change one thing: raise the gap from 2.0 to 4.0 by pushing the diagonal to 7.0. Then e−4 = 0.018316, sum = 1.018316, L = 0.018150 — a 7× reduction in loss from a 2-point change in a single similarity. This is the exponential sensitivity of the objective to the margin, and it is why loss values are hard to compare across runs while retrieval accuracy is not: loss is an exponential function of a gap whose units the model itself controls.

What to print during a training run

QuantityHealthy valueWhat it means when it is wrong
loss at step 0≈ log M (5.545 for M = 256)Much lower → label leakage or a transposed matrix. Much higher → the labels are wrong
loss after one epoch0.3 – 1.5Near log M → nothing is learning. Near 0 → your negatives are trivial
max off-diagonal softmax p0.05 – 0.25 mid-training< 0.01 → negatives have gone stale; mine harder ones
mean embedding normGrowing slowly, then flatGrowing without bound → the model is buying loss with scale, not ranking
fraction of rows where the gold is argmaxRising toward 0.95+This is in-batch accuracy — a free proxy for retrieval that needs no index
top-20 on a held-out set with the full indexThe only number that countsDiverging from in-batch accuracy → your batch is not representative of the corpus

The last two rows deserve emphasis. In-batch accuracy is cheap and misleading: distinguishing a gold from 255 batch-mates is a far easier task than distinguishing it from 21 million passages, so it saturates near 1.0 long before real retrieval is good. Track it for debugging, never for decisions.

Five things to remember when everything else fades

1 — The basis is the whole story
BM25's coordinates are words, so different words are orthogonal and "villain" cannot help "bad guy". A learned basis is the fix, and it costs you exact-string precision
2 — Independence is what makes it tractable
The passage vector must not depend on the question, or nothing precomputes. Everything else — the pooling loss, the ANN index, the sharding — follows from that one constraint
3 — A negative's influence is its softmax probability
So easy negatives are free and worthless, and one confusable negative is worth hundreds of random ones. Chapter 4 is one line of calculus, applied honestly
4 — Hardness and poison are the same quantity
A negative is informative exactly when it looks like an answer, which is exactly when it might be one. Denoise before you mine deeper
5 — The index, not the query, is the cost
995 queries per second is easy. Seventeen hours to ship a model change is the thing that shapes how your team works

A design exercise, before you close the tab

You have been asked to build search over 4 million internal engineering documents. Users are engineers typing things like "why does the ingest job OOM on large parquet files" and "ORA-01555 during nightly rollup". You have no labelled pairs, two GPUs, and six weeks. Work through it against the chapters.

QuestionChapterThe answer this lesson supports
Should you build dense retrieval at all?1Measure the IDF-coverage histogram first. Error strings and product codes are exactly BM25's strength, so a large part of this traffic may already be served well — and the paraphrastic half ("why does it run out of memory") is where your points are
What is the retrieval unit?0Fixed chunks, and overlap them — engineering docs put the symptom and the cause in adjacent paragraphs, so disjoint chunks will split your best evidence in half
Where do positives come from?6You have no labels. Generate queries from passages, filter by round-trip consistency, and prompt hard for terse colloquial phrasing. Do not write an eval set by reading documents and inventing questions — that eval will tell you BM25 wins
Where do negatives come from?4In-batch first. Then exactly one BM25 hard negative per query. Do not build ANN mining in six weeks with two GPUs
Which index?74M passages × 768 × 4 = 12.3 GB. That fits in RAM, so use exact inner-product search. Approximate indexes are a solution to a problem you do not have
What ships?1, 7Hybrid, fused by reciprocal rank, with the sparse leg as the safety net for exact strings. Budget the re-encode time into your release process from day one

Notice that four of the six answers are decisions about data and evaluation, and none is a decision about architecture. That distribution is not an accident of this exercise; it is the distribution the DPR paper itself found, and it is the reason its ablation section is more valuable than its method section.

The three sentences to keep

One. BM25 fails on "bad guy" versus "villain" because its coordinates are words, and different words are orthogonal — a zero that no reweighting can repair. A learned basis fixes it and gives up exact-string precision in exchange.
Two. In a softmax over candidates, a negative's influence is its assigned probability. So a hundred obvious negatives teach nothing and one confusable negative teaches almost everything — and the paper's ablation puts that ratio at roughly 330 to 1.
Three. A negative is informative exactly when the model finds it plausible, and it finds it plausible largely because it is often actually relevant. Hardness and mislabelling are the same quantity from two sides, which is why every successor paper is an attempt to buy one without the other.

One complete training step, in code

Everything in ten chapters, assembled. This is the loop; every earlier chapter is a decision inside it.

for batch in loader:                                  # CH 4: batch = the negative pool
    q_ids, p_ids, gold_col = batch                      # (B,L) (M,L) (B,)

    q = E_Q(**q_ids).last_hidden_state[:, 0]           # CH 2: [CLS] → (B, 768)
    p = E_P(**p_ids).last_hidden_state[:, 0]           # (M, 768), M = 2B

    if world_size > 1:                                 # CH 5: cross-batch negatives
        p = all_gather_with_grad(p)                     # (A*M, 768) — grad MUST flow
        gold_col = gold_col + rank * M                  # reindex into the gathered block

    S = q @ p.T                                       # CH 2: raw dot product, no temperature
    S = S.masked_fill(known_positives, -1e4)           # CH 6: multi-positive mask
    loss = F.cross_entropy(S, gold_col)                 # CH 3: NLL, logsumexp inside

    loss.backward(); opt.step(); opt.zero_grad()

    if step % 100 == 0:                                # CH 4: the diagnostics that matter
        P = S.softmax(1)
        log(loss=loss, floor=math.log(S.shape[1]),
            inbatch_acc=(S.argmax(1) == gold_col).float().mean(),
            max_offdiag=P.masked_fill(is_gold, 0).max(),
            qnorm=q.norm(dim=1).mean())

Fourteen lines of substance. The research of the last five years is almost entirely in two places: what loader put into p_ids, and what known_positives knows. Neither is in the model.

drill Predict the loss before you compute it, three times.

For each of the following, say what the loss should be and whether the run is healthy, then check.

(a) B = 64, one gold each, no hard negatives. At step 0 the loss reads 4.159. Healthy?  …  The floor is ln 64 = 4.1589. That is exactly chance, which is precisely right at step 0 — an untrained model should be at the floor. Healthy.

(b) Same configuration, and at step 0 the loss reads 0.42.  …  A random model cannot be right 66% of the time on a 64-way choice. Something is leaking the answer: most often the gold passage appears identically in the question's own input, or labels was built from the sorted order rather than the batch order and happens to align. Stop and check the batch by hand.

(c) B = 128 with one hard negative each, so M = 256. After ten epochs the loss is 0.02 and top-20 on a held-out index is 61%.  …  Loss 0.02 means the gold takes 98% of the mass against 255 candidates — the in-batch task is solved — while real retrieval is mediocre. Your negatives no longer resemble what the model faces against 21 million passages. This is exactly the condition ANCE was built for: escalate to mined negatives, and check max_offdiag, which will be near zero.

If you remember one table

QuestionThe answerWhere it came from
Why does BM25 fail?Its coordinates are words, so different words are orthogonal — an exact zeroCh 1, the hand-computed 13.75 versus 15.71
Why two towers, never one joint model?The passage vector must not depend on the question, or nothing precomputesCh 2, the 65-minutes-per-question calculation
Why does the loss need negatives?Without a denominator the optimum is collapse; with one, collapse sits at the chance-level floorCh 4, the α that cancels
Which negative matters?The one the model currently finds plausible — influence equals softmax probabilityCh 3, the 96.4% split
How many hard negatives?One, until you can denoise. Two was measurably worseCh 4, 65.0 versus 64.5
Why is this expensive to run?Not the query — the index. A model change costs a full re-encode and rebuildCh 7, 8.8 + 8.5 hours
When does it lose?Rare literal strings, out-of-domain corpora, and queries written from the documentCh 1 and Ch 8, the SQuAD row
Which single sentence best captures why DPR mattered more than its architecture suggests?