Omar Khattab, Matei Zaharia (Stanford) — arXiv:2004.12832, SIGIR 2020 · with ColBERTv2, arXiv:2112.01488

ColBERT: Late Interaction and Multi-Vector Retrieval

A single vector has to decide what a document is about before it has heard the question. Late interaction refuses to decide — it keeps one vector per token, and lets the query pick.

Prerequisites: dot products and what a transformer produces (one vector per input token). Cosine similarity, the inverted index, contrastive training, and quantization are all built from zero.
10
Chapters
8
Interactive Sims
39.7
MRR@10 (v2)
20 GiB
Compressed Index

Chapter 0: The Document That Is About Four Things

Here is a paragraph. Read it once, and then we will do something slightly cruel to it.

"The Mariana Trench is the deepest known part of any ocean. Its lowest point, Challenger Deep, plunges to about 10,935 metres below sea level. The trench forms where the Pacific Plate subducts beneath the smaller Mariana Plate, a convergent boundary that also produces the volcanic arc to the west. Despite crushing pressure near 1,100 atmospheres, the trench hosts amphipods, xenophyophores, and microbial mats. In 1960 the bathyscaphe Trieste, crewed by Jacques Piccard and Don Walsh, reached the bottom — a feat not repeated until 2012."

That paragraph is about four things, not one. It is about depth (a number in metres). It is about plate tectonics (subduction, a convergent boundary). It is about biology (amphipods, microbial mats). It is about exploration history (Trieste, 1960, Piccard and Walsh).

Now the cruel part. The standard way to make that paragraph searchable with a neural model is to run it through a transformer, which gives you one vector per token, and then pool those vectors — average them, or take the vector at the [CLS] position — down to a single vector of, say, 768 numbers. That single vector goes into the index. At query time you embed the question the same way and rank by cosine similarity.

Ask yourself what that single vector can possibly be. It has to serve the question "how deep is the Mariana Trench?" and the question "who first reached the bottom of the Mariana Trench?" and the question "what plate subducts at the Mariana Trench?" — with the same 768 numbers, chosen months before any of those questions existed.

The single-vector bargain, stated plainly. You are asking the encoder to summarise the document before it knows the question. Everything the summary drops is unrecoverable at query time, because at query time all you have left is the summary. The compression is not lossy in a graceful way — it is lossy in a way that is chosen by the training distribution and then frozen.

How much does pooling actually cost? Let us derive it

"Pooling loses information" is the kind of sentence that gets nodded at and never checked. So check it. We will build the smallest model of the situation that still has teeth.

Suppose a document's tokens fall into a small number of aspects — topics that the encoder represents in different directions of embedding space. To make the arithmetic clean, suppose those directions are orthogonal: perpendicular, so that a vector pointing along one contributes nothing at all along another. This is an idealisation, and we will loosen it in a moment, but it makes the mechanism visible.

Give our paragraph three aspects and count tokens: 5 tokens carry the depth content ("deepest", "Challenger", "Deep", "10,935", "metres"), 10 carry the tectonics content, and 10 carry the biology and history content. Total n = 25. Represent each token as a unit vector along its aspect's axis. Write the three axes as u (depth), v (tectonics), w (life and history).

Mean pooling adds all 25 unit vectors and divides by 25:

p = (5 u + 10 v + 10 w) / 25 = 0.20 u + 0.40 v + 0.40 w

Its length, since the axes are orthogonal, is the square root of the sum of the squared coordinates:

‖p‖ = √(0.20² + 0.40² + 0.40²) = √(0.04 + 0.16 + 0.16) = √0.36 = 0.60

Now the query. "How deep is the Mariana Trench" is a pure depth question, so its pooled vector is the unit vector u. Cosine similarity is the dot product of the two, each divided by its own length:

cos(q, p) = (u · p) / (‖u‖ · ‖p‖) = 0.20 / (1 × 0.60) = 0.3333

Sit with that number. The document contains the exact answer — the string "10,935 metres" is right there — and the retrieval score is one third of the maximum. Not because the encoder is weak. Because the arithmetic of averaging says so.

Worse, that score has a competitor. Imagine a thin, unhelpful passage that talks about ocean depth for all 25 of its tokens and never gets to a number: "The ocean is very deep in places. Depths vary. Deep water is dark and cold…" Its pooled vector is exactly u, so its cosine is 1.0. The vacuous passage outranks the one with the answer, three to one.

The dilution law. For a document with n tokens of which k lie on the query's axis and the remaining tokens split across other aspects with counts n1, n2, …, the mean-pooled cosine to that query is

cos = (k/n) / √( (k/n)² + Σj (nj/n)² )

Notice what is not in that expression: the quality of the encoder, the size of the embedding, the amount of training data. The penalty is a property of the pooling operation, and no amount of model scale removes it. It removes it only in the trivial sense that a better encoder makes the aspects less orthogonal — which is to say, blurrier.

But real aspects are not orthogonal — does the law survive?

The derivation assumed the three aspect directions are perpendicular, which is the friendliest possible assumption for making a point and therefore the one you should distrust. Real embedding spaces are blurrier: "depth" and "tectonics" both live in a broadly geological region and their directions overlap. Does the penalty go away?

Redo it with overlap. Let every pair of aspect axes have cosine ρ = 0.3 — substantial, realistic blur. The pooled vector is unchanged, p = 0.20u + 0.40v + 0.40w, but its length is no longer a plain sum of squares, because the cross terms stop vanishing:

‖p‖² = 0.20² + 0.40² + 0.40² + 2ρ(0.20·0.40 + 0.20·0.40 + 0.40·0.40)
     = 0.36 + 2(0.3)(0.08 + 0.08 + 0.16) = 0.36 + 0.192 = 0.552
‖p‖ = √0.552 = 0.7430

And the numerator picks up the same cross terms, because u now has a component along v and w:

u · p = 0.20 + 0.40ρ + 0.40ρ = 0.20 + 0.12 + 0.12 = 0.44
cos(q, p) = 0.44 / 0.7430 = 0.5922

Better — 0.59 instead of 0.33. Blur helps, and it helps for a reason that is not encouraging: the off-topic tokens now partially count as on-topic, because the model can no longer fully distinguish the aspects. You bought retrieval score with representational precision.

And the competitor is unchanged. The vacuous all-depth passage still pools to exactly u and still scores 1.000. So even at ρ = 0.3 the passage containing the answer is penalised 41% for the crime of containing other facts. Push ρ to 1 — all aspects identical, the model distinguishing nothing — and the penalty finally vanishes, along with the model.

Aspect overlap ρPooled cosine for the informative passagePenalty vs the vacuous oneWhat ρ costs
0.0 (crisp)0.3333−67%Nothing — but maximum dilution
0.3 (realistic)0.5922−41%Aspects partly indistinguishable
0.60.7597−24%Fine distinctions collapsing
1.0 (degenerate)1.00000%Everything is one aspect; the model is useless

That table is the honest form of the dilution law. There is a real knob, it does trade off against the penalty, and turning it far enough to remove the penalty destroys the thing you were trying to build. This is not a bug you can tune out. It is a property of summarising before you know the question.

What a retrieval miss costs downstream

One reason this matters more now than it did in 2018: retrieval stopped being the product and became a component. In a retrieval-augmented pipeline, the retriever fetches k passages and a language model answers from them. If the answer is not in the k passages, the language model has three options and all of them are bad — say it does not know, answer from parametric memory (unverifiable), or confabulate.

So the retrieval failure rate is a lower bound on the pipeline's failure rate, and the whole system's trustworthiness is capped by a number that comes out of an averaging operation you probably never inspected. A 41% score penalty on the passage that contains the answer is not an abstraction; it is the probability that the answer is not in the prompt.

Two escapes, and why each is a compromise

Escape 1: chunk the document. Split the paragraph into three passages, one per aspect. Now the depth passage is 5 tokens all on u, its pooled vector is u, its cosine is 1.0, and the problem evaporates. This works, and it is exactly why every retrieval-augmented pipeline you have ever configured has a chunk_size parameter that someone spent a week tuning.

It works, and it costs. Chunk too small and you sever the coreference: the chunk that says "it plunges to 10,935 metres" no longer contains the word "Trench", so a lexical or semantic match on the entity fails. Chunk too large and you are back to dilution. There is no setting that is right for all queries, because the right chunk boundary depends on the query, which you do not have at index time. Chunking is dilution management, not a fix.

Escape 2: store several vectors per document. Cluster the token embeddings into, say, 8 groups and store 8 vectors. This is real progress — and it raises the obvious question: if 8 is better than 1, what about 80? What about one vector per token, and no clustering decision at all?

That is ColBERT. The name is short for Contextualized Late Interaction over BERT, and every word in it is load-bearing. Contextualized: each token's vector depends on the whole passage, so "deep" in "Challenger Deep" is a different vector from "deep" in "deep learning". Late: the query and the document are encoded separately and only meet at the very end. Interaction: what happens when they meet is not a single dot product but a small matrix of them.

See the dilution

Before any of the machinery, play with the failure. The simulation below is the arithmetic above, made draggable. Add off-topic tokens to the document and watch the single-vector score collapse while the late-interaction score does not move at all.

Aspect dilution: one vector vs one vector per token

The document has 5 tokens on the query's topic (warm). Add tokens on two other topics and watch what happens. The dashed line is the late-interaction score, normalised so that 1.0 means "every query token found a perfect match somewhere in the document". Then hit "split into chunks" to see what a RAG pipeline is really buying.

Tectonics 10
Bio + history 10

Three things worth noticing while you drag. First, the single-vector curve is steep near the left: the first few off-topic tokens hurt the most, because they are the ones that first pull the pooled direction off the query axis. Second, the late-interaction line is exactly flat — adding irrelevant material to a document costs it nothing, which is the property you actually want from a search engine. Third, chunking restores the score by throwing away context, which you will pay for on a different query.

What information is actually being destroyed

It is worth being precise about the loss, because "information" is a slippery word. A transformer produces a matrix: n token vectors of dimension d. For a 25-token passage with d = 768 that is 19,200 numbers. Pooling replaces it with 768. So the raw compression ratio is 25 to 1.

But raw count is not the interesting part. The interesting part is addressability. In the matrix, the fact "this document says 10,935 metres" lives at a known row — row 12, say — and a query can go and look at row 12 specifically. After pooling, that fact has been added into a sum with 24 other facts. It is still in there in a weak sense, the way a single voice is in there in a recording of a crowd, but nothing at query time can isolate it.

RepresentationNumbers storedCan a query address one fact?Decision made at
Token matrix (n × 768)19,200 for n=25Yes — look at the rowQuery time
8 cluster vectors6,144Partly — if the clustering happened to separate itIndex time (clustering)
Mean-pooled vector768No — facts are summed togetherIndex time (pooling)
Bag of words (BM25)~25 term idsYes — exact term lookupQuery time

Read the last row carefully, because it is the joke that runs through this whole lesson. BM25 — the classical bag-of-words scoring function that has been the default in Lucene and Elasticsearch since the 1990s — already has the property we are chasing. It stores one entry per term, it never pools, and a query about depth goes and looks up the posting list for "deep" without disturbing anything else. Its weakness is not architecture, it is vocabulary: it can only match the exact strings it stored, so "how deep" fails to match "bathymetric depth of".

The claim this paper will make, so you can hold it to account. Keep BM25's per-term addressability. Replace exact string matching with soft matching in a learned space. Precompute the document side so that query time is a matrix multiply rather than a transformer forward pass. The result — ColBERT — reaches MRR@10 = 0.360 on the MS MARCO passage task against BM25's 0.187, at roughly 1/170th the query latency of a BERT cross-encoder that scores about the same. ColBERTv2 pushes that to 0.397 while shrinking the index by 7×.

Why this matters more in 2020 than it did in 2015

One more piece of context before the machinery, because it explains why the paper is shaped the way it is.

By 2019 the retrieval field had a very effective and very embarrassing result: take BM25's top 1,000 passages, concatenate each one with the query, run the pair through BERT, and read off a relevance score. This is a cross-encoder, and it worked enormously well — MRR@10 jumped from 0.187 to 0.347 on MS MARCO. It was also, as the ColBERT paper's own measurements show, about 10.7 seconds of GPU time per query. Not per thousand queries. Per query.

So the field had one method that was accurate and unusable, and one method that was fast and shallow, and the interesting question became: what exactly is the cross-encoder doing that costs so much, and how much of it can you precompute? Chapter 1 answers that question by laying the options out on a single axis.

A 25-token passage has 5 tokens about depth and 20 about unrelated aspects. Why does mean pooling score it far below a vacuous 25-token passage that is entirely about depth but contains no facts?

Chapter 1: The Interaction Spectrum

Every neural retrieval system answers the same two questions, and its entire cost profile falls out of the answers.

Question one: how rich is the representation? One vector per document, or one per token, or no fixed representation at all because the document is re-read every time?

Question two: how late does the query meet the document? Do they meet inside the transformer, with every query token attending to every document token through twelve layers? Or do they meet at the very end, in a similarity computation over frozen vectors?

Those two questions are really one question, because a representation you can precompute is a representation the query did not participate in. That is the whole tension. The ColBERT paper draws it as four boxes; we will walk them in order of decreasing cost.

Level 1 — the cross-encoder (all-to-all interaction)

Concatenate query and passage into one sequence, feed it to BERT, read a relevance score off the [CLS] vector. Formally:

s(q, d) = w BERT( [CLS] q1…qm [SEP] d1…dn [SEP] )[CLS]

Every query token attends to every document token in every layer. If the passage says "Challenger Deep" and the query says "how deep", the attention mechanism can build a representation of the relationship between them: not just that both mention depth, but that this passage answers this question. Nothing in a precomputed representation can do that, because the relationship did not exist at index time.

The cost is brutal and it is easy to see why. Let us count it, because the number is the whole argument. BERT-base has about 110M parameters, and a forward pass costs roughly two floating-point operations per parameter per token (one multiply, one add):

FLOPs per passage ≈ 2 × 110×106 × 256 tokens ≈ 5.6 × 1010 = 56 GFLOPs

Multiply by 1,000 candidate passages and you get 5.6 × 1013 FLOPs per query. The paper measures 97 TFLOPs, which is the same order — the difference is attention's quadratic term and the exact sequence length. On the hardware of 2020 that came out at 10,700 ms per query. You can run this offline. You cannot run it in a search box.

Where the cross-encoder's cost actually goes

The two-FLOPs-per-parameter rule is a shortcut, and it is worth spending five minutes doing the count properly, because the answer contradicts what most people assume.

BERT-base has d = 768, twelve layers, and a feed-forward inner size of 4d = 3,072. Per token per layer, the matrix multiplies are:

Q, K, V and output projections: 4 × d² = 4 × 589,824 = 2,359,296 MACs
Feed-forward (two layers, d→4d→d): 8 × d² = 4,718,592 MACs
subtotal per token per layer: 12d² = 7,077,888 MACs

Then the part everyone worries about — attention itself, which is quadratic in sequence length. Each token attends to all L tokens, computing a score (d MACs) and a weighted sum (d MACs):

2 × L × d = 2 × 256 × 768 = 393,216 MACs per token per layer

Divide: 393,216 / (7,077,888 + 393,216) = 5.3%. At 256 tokens, attention is a twentieth of the cost. The other nineteen twentieths are ordinary dense layers being applied once per token per layer.

total = 12 layers × 256 tokens × 7,471,104 MACs = 2.295×1010 MACs = 45.9 GFLOPs

Notice this is less than the 56 GFLOPs the rule of thumb gave, and the gap is instructive: of BERT-base's 110M parameters, about 24M are the wordpiece embedding table, which is a lookup rather than a multiply. Only the 85M encoder parameters do arithmetic. Rules of thumb that count all parameters over-charge every model with a large vocabulary.

Why this matters for the argument. If attention were the cost, you could attack it with a sparse or linear attention variant and keep the cross-encoder. It is not. The cost is depth × width, applied per token, and the only way to remove it is to stop running the network at query time. That is not an optimisation; it is an architectural decision, and it is the one late interaction makes.

Level 2 — the single-vector bi-encoder (no interaction)

The opposite extreme. Two independent encoders, one per side. Every passage in the corpus is encoded once, offline, into a single vector. At query time you encode the query once and take dot products.

s(q, d) = Eq · Ed,   Eq, Ed ∈ R768

This is DPR (Dense Passage Retrieval, Karpukhin et al. 2020) and its many descendants. The query-time cost per passage is one 768-dimensional dot product: 1,536 FLOPs. Against the cross-encoder's 56 GFLOPs, that is a factor of about 36 million. And because the score is a plain inner product, you can hand the whole corpus to an approximate nearest neighbour index and never touch most of it.

What you gave up is Chapter 0's whole story: the passage was summarised before the question arrived.

Level 3 — late interaction (the middle)

Here is the move. Encode both sides independently — so the document side is still precomputable, still offline, still indexable. But do not pool. Keep the full matrix of token vectors. Then, at query time, let every query token look at every document token — not through attention, which would require running a transformer, but through a plain similarity computation over the frozen vectors.

Sq,d = Σi ∈ [1..|Eq|]  maxj ∈ [1..|Ed|]  Eqi · Edj

That is the entire ColBERT scoring function, and Chapter 2 does nothing but unpack it. Read it in English: for each query token, find the document token it matches best, and add up those best matches.

The word "late" now has a precise meaning. Interaction between query and document is deferred until after both have been encoded — so it can happen on cheap frozen vectors, and the expensive part (encoding the corpus) happens once, offline, forever.

Why this is not an obvious compromise. The naive expectation is that a method halfway between a cross-encoder and a bi-encoder gives you halfway the quality. It does not. ColBERT matches or beats a BERT-base cross-encoder on MS MARCO (0.360 vs 0.347 MRR@10) at roughly 1/170th of the latency. The reason is that most of what the cross-encoder computes is not needed for ranking: twelve layers of query-document co-attention mostly re-derive "these tokens are about the same thing", which a good token embedding already encodes. The interaction that matters is shallow. It just has to be per-token.

Level 2.5 — the few-vector middle, and why it is not enough

Between one vector and one per token there is an obvious compromise nobody should skip past: store a handful. Two 2020 papers did exactly that.

Poly-encoders (Humeau et al.) keep m learned "code" vectors per query context and attend over the document representation; ME-BERT keeps m = 8 vectors per document, taken from the first m token positions, and scores with a max over the eight. Both sit between DPR and ColBERT on every axis, and both improve on DPR.

So why did the field keep going to n vectors? Because m vectors do not remove the decision, they shrink it. Something still has to choose which 8 summaries to keep, at index time, without the query. ME-BERT's choice — the first 8 positions — is arbitrary and systematically wrong for any passage whose key fact appears late. A learned clustering is better and is still a frozen commitment: the clustering objective was fitted to a query distribution, and Chapter 9 is about what happens when the distribution moves.

Vectors per passageWho chooses what they containWhenRecoverable at query time?
1The pooling operationIndex timeNo
8 (ME-BERT, clustered)A heuristic or a clustering objectiveIndex timeOnly within a cluster
n (ColBERT)Nobody — nothing is discardedNeverYes

The row that matters is "when". Every design above the last one has a step where information is thrown away in ignorance of the query. n vectors is the unique point on this spectrum where that step does not exist — which is why the interesting engineering question stopped being "how many vectors" and became "how cheaply can we store all of them".

Level 4 — the query-document interaction models that came before

For completeness, and because it explains why late interaction felt surprising in 2020: there was already a family of models — KNRM, ConvKNRM, DRMM — that built a query-document similarity matrix and ran a small neural network over it. Structurally, that is late interaction. What they lacked was contextualized embeddings: their token vectors were static word2vec-style vectors, so "deep" had one vector regardless of context. Their MS MARCO numbers sat around 0.198 to 0.290 MRR@10. ColBERT is that architecture with BERT's contextual embeddings dropped in, plus the engineering to make it a retriever rather than a re-ranker.

The spectrum, with numbers attached

Cross-encoderLate interaction (ColBERT)Single-vector bi-encoderBM25
Document representationNone — re-encoded per queryn vectors, 128 dims each1 vector, 768 dimsSparse term counts
Precomputable offline?NoYesYesYes
Query-time work per passageA full BERT pass (≈56 GFLOPs)An m×n dot-product matrix (≈0.6 MFLOPs)One dot product (1.5 kFLOPs)Posting-list traversal
Can the query address one fact?Yes, through attentionYes, through MaxSimNoYes, exact terms only
Soft (semantic) matching?YesYesYesNo
MS MARCO dev MRR@100.347 (BERT-base)0.360 / 0.397 (v2)0.330 (ANCE) – 0.347 (TAS-B)0.187
Latency, top-1000 (2020 GPU)10,700 ms61 ms re-rank / ~450 ms end-to-end~10–50 ms~10–40 ms (CPU)
Index size, MS MARCO154 GiB → 20 GiB (v2)~13 GiB (fp16)~1 GiB

The column that should stop you is the ColBERT one. It is not the best on any single row. It is second-best on every row, and that turns out to be a very rare and very valuable place to stand.

The spectrum, drawn as interaction graphs

Left column is query tokens, right column is document tokens. The edges are what actually gets computed at query time. Step through to see how the edge count — and therefore the cost — changes, and what each design can and cannot express.

Look at the middle panel in "compare all three". The bold edges — the ones MaxSim actually keeps — number exactly one per query token. Everything else in that matrix is computed and then discarded by the max. That observation is what makes Chapter 6's pruning possible: if you could guess which edges will win, you would not have to compute the rest.

The engineering consequence nobody states out loud

There is a practical asymmetry that decides which of these you can actually deploy, and it is not quality and it is not latency. It is where the cost lands.

DesignOffline cost (indexing)Online cost (per query)What breaks first at scale
Cross-encoderZeroEnormous, and linear in candidatesYour GPU bill, immediately
Late interactionOne BERT pass per passage, plus a large indexSmall, and linear in candidatesStorage — hence Chapters 5 and 6
Single vectorOne BERT pass per passage, small indexTinyQuality, out of domain — hence Chapter 9
BM25Cheap countingTinyVocabulary mismatch

Offline cost is the cost you can throw hardware at overnight. Online cost is the cost you pay in front of a user with a 200-millisecond budget. Moving work from the second column to the first is the single most valuable move in systems design, and late interaction is a very pure instance of it: it moves twelve transformer layers offline and leaves one matrix multiply online.

Three questions that classify any retrieval architecture

Every system in this lesson — and every system published since — is fully determined by three answers. It is worth carrying the checklist, because it turns a confusing zoo of acronyms into three yes-or-no questions.

1. What is the smallest thing that must touch the query?
A whole transformer (cross-encoder) · a 32×n similarity matrix (ColBERT) · one dot product (bi-encoder) · a posting-list walk (BM25). This single answer determines your latency and your serving hardware.
2. What is stored per document, and who decided what it contains?
Nothing · n token vectors, decided by nobody · m cluster vectors, decided by a clustering objective · 1 pooled vector, decided by the pooling operation · sparse term weights, decided by an MLM head. This determines your index size and your out-of-domain behaviour.
3. Can a query address one piece of the document without disturbing the rest?
This is the question that predicts Chapter 9. BM25: yes, by exact term. ColBERT: yes, by MaxSim row. SPLADE: yes, by vocabulary entry. Single vector: no.

Ask them of anything new and you will know roughly what its BEIR number looks like before you read the table. A method whose answer to question 3 is "no" is buying quality from its training distribution and will hand some of it back when the distribution changes.

Concept check — answer before reading on. If late interaction moves the encoder offline, why can it not simply also move the similarity matrix offline?  …  Because the matrix depends on the query, and there are infinitely many queries. Anything that touches the query must be online. The design question is always: what is the smallest possible thing that has to touch the query? For a cross-encoder, the answer is "the whole model". For ColBERT, it is "a 32×n matrix of dot products". For a bi-encoder, it is "one dot product". That is the entire spectrum, restated.
What precisely makes late interaction "late", and why does it matter for cost?

Chapter 2: MaxSim, By Hand

Here is the scoring function again. By the end of this chapter you will have computed every number in it with a calculator, and you will know why each design choice inside it is the way it is.

Sq,d = Σi=1|Eq|  maxj=1..|Ed|  Eqi · Edj

Three operations, in order: a matrix of dot products, a maximum along each row, and a sum of those maxima. Call it MaxSim — the paper's name for the per-query-token maximum-similarity operator.

Setting up a case we can compute completely

Real ColBERT embeddings are 128-dimensional, which is not something you can multiply on paper. So we shrink to four dimensions and give each one a name. This is a fiction — real embedding dimensions have no names — but every operation we perform is the real one, and the conclusions transfer.

DimensionWhat it means in our toy
1depth magnitude — "how far down"
2water / ocean
3place-name-ness — this token names a specific location
4numeric quantity

The query is "how deep is the mariana trench". After the tokenizer, three tokens carry content. Their (unnormalised) encoder outputs:

q1 "deep"     = [ 9, 2, 0, 2 ]  ‖q1‖ = √(81+4+0+4) = √89 = 9.4340
q2 "mariana" = [ 1, 3, 9, 0 ]  ‖q2‖ = √(1+9+81+0) = √91 = 9.5394
q3 "trench"   = [ 5, 5, 6, 0 ]  ‖q3‖ = √(25+25+36+0) = √86 = 9.2736

Read those and check they make sense. "deep" is almost pure depth with a little water and a little number-ness (it appears next to measurements). "mariana" is overwhelmingly a place name. "trench" straddles all three: it is a depth feature, it is oceanic, and it is part of a proper noun.

Document A is the passage we want: "Challenger Deep plunges 10935 metres". Five tokens:

a1 "challenger" = [ 4, 3, 9, 0 ]  ‖a1‖ = √106 = 10.2956
a2 "deep"       = [ 9, 3, 2, 1 ]  ‖a2‖ = √95 = 9.7468
a3 "plunges"    = [ 8, 2, 1, 2 ]  ‖a3‖ = √73 = 8.5440
a4 "10935"      = [ 4, 0, 0, 9 ]  ‖a4‖ = √97 = 9.8489
a5 "metres"     = [ 2, 0, 0, 9 ]  ‖a5‖ = √85 = 9.2195

Notice the contextualization already at work in the numbers. The document's "deep" is not the same vector as the query's "deep": in "Challenger Deep" it has picked up place-name mass (dimension 3 went from 0 to 2), because BERT read the capital letter and the preceding word. That is the "C" in ColBERT doing its job before we have computed a single similarity.

Step 1 — the similarity matrix, one cell at a time

ColBERT normalises every embedding to unit length, so the dot product is the cosine. We will do it the long way — raw dot product, then divide by the two lengths — so you can see both halves.

Cell (1,1): q1 "deep" against a1 "challenger".

q1 · a1 = 9×4 + 2×3 + 0×9 + 2×0 = 36 + 6 + 0 + 0 = 42
‖q1‖ × ‖a1‖ = 9.4340 × 10.2956 = 97.129
cos = 42 / 97.129 = 0.4324

Cell (1,3): q1 "deep" against a3 "plunges". This is the interesting one.

q1 · a3 = 9×8 + 2×2 + 0×1 + 2×2 = 72 + 4 + 0 + 4 = 80
‖q1‖ × ‖a3‖ = 9.4340 × 8.5440 = 80.604
cos = 80 / 80.604 = 0.9925

Stop and appreciate what just happened. The query word "deep" matched the document word "plunges" at 0.9925 — higher than it matched the document's own literal "deep" (0.9679, computed next). There is no string overlap between "deep" and "plunges" at all. BM25 would score that pair exactly zero. This one number is the entire case for neural retrieval, and it is happening at the level of a single token pair, where you can see it.

Cell (1,2): q1 "deep" against a2 "deep".

q1 · a2 = 9×9 + 2×3 + 0×2 + 2×1 = 81 + 6 + 0 + 2 = 89
‖q1‖ × ‖a2‖ = 9.4340 × 9.7468 = 91.951
cos = 89 / 91.951 = 0.9679

The identical surface form scores lower than the paraphrase, because context pulled the document's "deep" toward the place name. Late interaction does not reward string equality. It rewards being the right vector.

Cell (2,1): q2 "mariana" against a1 "challenger".

q2 · a1 = 1×4 + 3×3 + 9×9 + 0×0 = 4 + 9 + 81 + 0 = 94
‖q2‖ × ‖a1‖ = 9.5394 × 10.2956 = 98.214
cos = 94 / 98.214 = 0.9571

Two different proper nouns naming the same place, matched at 0.957. Again, no lexical overlap.

Grinding through the remaining ten cells the same way gives the full 3 × 5 matrix. Every entry below was produced by exactly the arithmetic shown above.

a1 challengera2 deepa3 plungesa4 10935a5 metresrow max
q1 deep0.43240.96790.99250.58120.41390.9925
q2 mariana0.95710.38720.28220.04260.02270.9571
q3 trench0.93220.79670.70680.21900.11700.9322

Step 2 — the row maxima

For each query token, scan its row and keep the single largest entry. Row 1's largest is 0.9925 at "plunges". Row 2's is 0.9571 at "challenger". Row 3's is 0.9322, also at "challenger".

Note that row 2 and row 3 both chose column 1. Nothing forbids that — MaxSim is not a matching, it is an independent argmax per row. Two query tokens are allowed to be satisfied by the same document token, and often are, because a proper noun carries several query concepts at once.

Step 3 — the sum

Sq,A = 0.9925 + 0.9571 + 0.9322 = 2.8818

Three query tokens, so the maximum possible score is 3.0. We are at 2.88, or 96% of a perfect match. The passage answers the question, and the score says so.

What the sum means, dimensionally. Each term is a cosine in [−1, 1], so S lives in [−|q|, |q|]. It is not a probability and it is not normalised by document length. That second point is the important one: the number of document tokens n appears only inside the max, never in the count of summed terms. A 300-token document and a 30-token document produce sums over the same number of query rows. So MaxSim has no built-in length bias — which is exactly the thing BM25 needs an explicit length-normalisation term (the b parameter) to fix.

The control: a passage that is topically close but says nothing

A scoring function that gives a good passage a high number is not yet evidence of anything. It has to give a bad passage a lower one. So here is document B, the kind of passage that lives all over the web: "Pacific waters are cold below". Same length, same topic area, zero information.

b1 "pacific" = [ 2, 8, 5, 0 ]  ‖b1‖ = √93 = 9.6437
b2 "waters"  = [ 3, 9, 0, 0 ]  ‖b2‖ = √90 = 9.4868
b3 "are"     = [ 1, 1, 0, 0 ]  ‖b3‖ = √2 = 1.4142
b4 "cold"    = [ 2, 6, 0, 0 ]  ‖b4‖ = √40 = 6.3246
b5 "below"   = [ 7, 4, 0, 0 ]  ‖b5‖ = √65 = 8.0623

One cell in full, so you can check the rest: (1,5): q1 "deep" against b5 "below".

q1 · b5 = 9×7 + 2×4 + 0 + 0 = 63 + 8 = 71
‖q1‖ × ‖b5‖ = 9.4340 × 8.0623 = 76.060
cos = 71 / 76.060 = 0.9335
b1 pacificb2 watersb3 areb4 coldb5 belowrow max
q1 deep0.37370.50280.82450.50280.93350.9335
q2 mariana0.77180.33150.29650.33150.24700.7718
q3 trench0.89450.68200.76250.68200.73560.8945
Sq,B = 0.9335 + 0.7718 + 0.8945 = 2.5998  <  2.8818 = Sq,A

A wins by 0.282. The margin comes almost entirely from row 2 — "mariana" found a 0.957 match in A and only a 0.772 match in B — which is the correct reason. The specific entity is what distinguishes the two passages, and MaxSim's row structure is what let that single piece of evidence show up in the total.

Now compare against the two obvious alternatives

Why max? Why not the sum of the whole matrix, or the mean? These are not rhetorical questions; they are ablations, and the toy is small enough that we can run them.

Alternative 1: sum every cell (SumSim). Add all fifteen entries.

A: (0.4324+0.9679+0.9925+0.5812+0.4139) + (0.9571+0.3872+0.2822+0.0426+0.0227) + (0.9322+0.7967+0.7068+0.2190+0.1170)
   = 3.3879 + 1.6918 + 2.7717 = 7.8514
B: (0.3737+0.5028+0.8245+0.5028+0.9335) + (0.7718+0.3315+0.2965+0.3315+0.2470) + (0.8945+0.6820+0.7625+0.6820+0.7356)
   = 3.1373 + 1.9783 + 3.7566 = 8.8722

The ranking inverts. The vacuous passage wins by a full point. And the reason is exactly visible in the numbers: B's cells are uniformly mediocre — nothing below 0.24, lots around 0.7 — while A's are polarised, with four cells under 0.25 dragging its total down. Summing rewards diffuse topical similarity. Taking the max rewards specific evidence and ignores everything else.

This is worth naming, because it is the single most common failure of naive similarity aggregation: a document that is vaguely about everything beats a document that is precisely about one thing. The max operator is what makes the score insensitive to how much irrelevant material a passage contains — which is Chapter 0's dilution problem, solved at the operator level rather than by chunking.

Alternative 2: pool both sides and take one cosine. Average the three normalised query vectors, average the five normalised document vectors, renormalise, and take a single dot product. The arithmetic is long but mechanical; the results are:

cos(q̄, Ā) = 0.8013    cos(q̄, B̄) = 0.7637

Pooling gets the ranking right here — A still wins. But look at the margin. Under MaxSim the gap is 0.282 on a scale where A scores 2.882, a relative margin of 9.8%. Under pooling the gap is 0.038 on a scale where A scores 0.801, a relative margin of 4.7%. Pooling did not destroy the signal; it halved it. Halve a signal enough times in a corpus of nine million passages and some of those nine million will slip above the right answer by noise alone.

AggregationScore AScore BRankingRelative marginWhat it is sensitive to
MaxSim (ColBERT)2.88182.5998A > B, correct9.8%The best evidence per query token
Sum of all cells7.85148.8722B > A, wrongAverage topical similarity, document length
Mean-pool both sides0.80130.7637A > B, correct4.7%The document's overall centre of mass

Why the max is also the right thing for learning

There is a second argument for max that has nothing to do with ranking quality, and it is about gradients.

Differentiate the max with respect to the document embeddings. The maximum of a set is a piecewise-linear function: its derivative is 1 with respect to the winning element and exactly 0 with respect to all the others. So during training, for query token i, gradient flows to exactly one document token — the argmax — and to nothing else.

∂S / ∂Edj = Eqi  if j = argmaxj' (Eqi · Edj'),  0 otherwise

The consequence is a hard assignment: training pushes each query term toward its single best-matching document term and leaves the rest of the document alone. That is structurally the same thing an inverted index does when it looks up one posting list and ignores the other thirty thousand. MaxSim is a soft, learned, differentiable version of exact term matching — and Chapter 9 will argue that this inductive bias is precisely why it survives a change of domain.

Concept check — answer before reading on. If two query tokens both pick the same document token as their argmax, is that double-counting evidence?  …  Formally yes, and the paper accepts it. Consider the alternative: forcing a one-to-one assignment turns scoring into a bipartite matching problem (the Hungarian algorithm, cubic in the number of tokens) which you cannot run over millions of documents. Empirically the double-counting is benign, because query tokens that share an argmax are usually near-duplicates of each other ("mariana" and "trench" both pointing at "challenger"), so what looks like double counting is really one concept expressed twice in the query. It is a deliberate trade of a small modelling impurity for several orders of magnitude of speed.

Play with the matrix

MaxSim matrix explorer

The exact 3×5 matrices from this chapter. Tap any cell to see its dot product worked out. Switch documents to compare A and B, and switch aggregation to watch the ranking flip when you replace max with sum.

Document:
Aggregate:
Tap a cell to see the arithmetic.

The realization: what this looks like in tensors

Everything above is one matrix multiply, one reduction, and one sum. Here it is with shapes, because a formula you cannot allocate memory for is not yet understood.

python — MaxSim, exactly as scoredimport torch

# Eq: query embeddings, one row per query position, L2-normalised
Eq = model.encode_query("how deep is the mariana trench")   # (32, 128)

# Ed: document embeddings, one row per surviving document token
Ed = index.lookup(doc_id)                                 # (n, 128), n ~ 73

S     = Eq @ Ed.T                                        # (32, n)  every pair
best  = S.max(dim=1).values                              # (32,)    one per query row
score = best.sum()                                        # ()       a scalar

Three lines. Now count the work. The matrix multiply is 32 × n × 128 multiply-accumulates, which is two FLOPs each:

FLOPs per document = 2 × 32 × 73 × 128 = 597,760 ≈ 0.6 MFLOPs

Over 1,000 candidate documents that is 0.6 GFLOPs. Hold that number next to the cross-encoder's 56 GFLOPs per document, or 56 TFLOPs for the same 1,000. The ratio is about 105.

And here is the detail that reorders your intuition about where the time goes. Encoding the query itself — one BERT-base forward pass over 32 tokens — costs roughly 2 × 110×106 × 32 ≈ 7 GFLOPs. That is more than ten times the cost of scoring all thousand documents. In a ColBERT re-ranker, the expensive part of the query is the query. The paper's measured figure of about 7 × 109 FLOPs per query is essentially the query encoder alone, with MaxSim as a rounding error on top.

The engineering consequence. If scoring is nearly free and encoding the query is the bill, then making ColBERT faster is not about optimising MaxSim — it is about not doing MaxSim on nine million documents. That is a candidate-generation problem, and it is what Chapter 6 is entirely about.

Batching it properly

One more realization detail, because it is where naive implementations lose their speed. Candidate documents have different lengths, so you cannot stack them into a clean (B, n, 128) tensor without padding, and padding a 20-token passage out to 180 wastes 89% of the multiply. Two production patterns:

PatternHowWhen it wins
Pad + maskStack to (B, nmax, 128), set padded similarities to −∞ before the maxSmall batches, uniform lengths, simple GPU kernels
Flat concat + segment maxConcatenate all candidates' tokens into one (T, 128) tensor, do one (32 × 128) @ (128 × T) matmul, then take the max within each document's token spanLarge batches with ragged lengths — this is what ColBERT actually does

The flat version turns a thousand small matrix multiplies into one large one, which is the difference between 5% and 80% GPU utilisation. It also means the intermediate S is a single (32, T) tensor with T ≈ 73,000 for a thousand candidates — about 9 MB in fp32, which fits comfortably in cache-friendly territory.

Summing every cell of the similarity matrix instead of taking the row maxima flipped the ranking so that the vacuous passage B beat the informative passage A. Why?

Chapter 3: Marks, Masks, and 128 Dimensions

MaxSim is four lines of tensor code. Everything else in the ColBERT paper is the machinery that produces the two matrices it consumes — and every piece of that machinery is a decision with a measurable consequence. This chapter walks them in the order the data flows.

One BERT, two roles

The first surprise: ColBERT does not use two encoders. It uses one BERT whose parameters are shared between the query side and the document side. This halves the parameter count and, more importantly, guarantees the two sides land in the same space from initialisation onward — you are not asking two independently-initialised networks to negotiate a common coordinate system through gradient descent alone.

But shared weights create an immediate problem. If the same network encodes both sides, then the string "mariana trench" produces the identical vector whether it arrived as a question or as a passage. And those two situations call for different behaviour: a query token should behave like a request for evidence; a document token should behave like a piece of evidence.

The fix is two special tokens, [Q] and [D], prepended right after [CLS]. Implementationally they are two of BERT's unused vocabulary slots ([unused0] and [unused1]), so no vocabulary surgery is needed — they are ordinary embedding rows that were never trained and are now given a job.

Query:    [CLS] [Q] q1 q2 … ql [MASK] [MASK] … [MASK]  (padded to Nq = 32)
Document: [CLS] [D] d1 d2 … dn  (truncated at 180)

Because self-attention is global, the marker at position 1 is visible to every other position in every layer. It acts as a mode switch that conditions all 32 or 180 output vectors. One token, two behaviours, zero extra parameters beyond a single 768-dimensional embedding row.

Query augmentation: padding with [MASK] on purpose

Now the strangest design choice in the paper, and the one worth the most thought.

Queries are short. "how deep is the mariana trench" is seven wordpieces. ColBERT pads every query to a fixed Nq = 32 positions — and pads not with a neutral [PAD] token that gets masked out, but with [MASK], the token BERT was pretrained to fill in.

Those mask positions are not discarded. They go through BERT, get contextualized, get projected, get normalised, and take part in MaxSim exactly like real query tokens.

Why this works. BERT's pretraining objective was: given a sentence with holes, predict what belongs in the holes. So the final-layer vector at a [MASK] position is, by construction, BERT's representation of what would plausibly go here. Feed it "[Q] how deep is the mariana trench [MASK] [MASK] …" and those mask vectors drift toward things like "metres", "ocean", "floor", "depth", "pacific". ColBERT gets query expansion — the classical IR technique of adding related terms to a query — for free, learned, and differentiable, out of a pretraining objective that was designed for something else entirely.

The paper calls this query augmentation and its ablation removes it: MRR@10 falls by roughly one and a half points. For a change that adds no parameters and costs only a longer (but fixed) query tensor, that is an excellent trade.

There is a second, quieter benefit. Because Nq is fixed at 32, the MaxSim sum always has exactly 32 terms — for a three-word query and for a twenty-word query alike. Scores are therefore on a comparable scale across queries, which matters when you set thresholds or compare a query's top score against a floor. Variable-length sums would make every threshold query-dependent.

And a third: the mask positions give the model somewhere to put re-weighting behaviour. If a query term is unimportant, the model can learn to make its vector short… except it cannot, because everything is normalised to unit length. What it can do instead is make the vector point somewhere that matches almost nothing, so its row max stays low. The masks give it 25 extra rows to spend on whatever the training signal says is useful.

What a [MASK] row actually converges to

"BERT fills in the blank" is a story, not a mechanism. Here is the mechanism, in one paragraph of attention.

At layer 1 the vector at a mask position is the [MASK] input embedding plus its positional embedding — identical content at every mask slot, differing only by position. Self-attention then lets that position read every real query token. Because the mask's own content carries no information, its attention output is dominated by whatever the query tokens contribute, so by the final layer the vector is a function of the query context and the slot index. Twenty-five slots, one shared context, twenty-five different positional starting points: the model has room to place twenty-five distinct query-conditioned probes.

What it uses them for is decided by the loss, and the loss only ever says "make relevant passages score higher". So the training pressure on a mask row is: point somewhere that matches good passages and not bad ones. Empirically the rows behave like soft expansion terms — near a plausible related word — and like importance re-weighting, since a row that points into empty space contributes a low row max and effectively abstains.

Query positionWhat it isIts MaxSim row typically finds
0–1[CLS] [Q]A generic high match on almost anything — near-constant across passages, so it barely affects ranking
2–8The real query wordpiecesThe lexically or semantically corresponding document token — the evidence you would expect
9–3123 [MASK] rowsRelated terms not typed by the user ("metres", "floor", "pressure"), and abstentions on rows the model found no use for
The cost of abstention is exactly zero, and that is the design. A mask row that finds nothing useful still contributes its row max to the sum — but it contributes roughly the same amount to every passage, because it is matching noise. A constant added to every candidate's score cannot change a ranking. So unhelpful expansion slots are free, and helpful ones are pure gain. That asymmetry is why padding to 32 is not a gamble.

Why 32 and 180, and what those constants really are

Both limits are fitted to MS MARCO and both become the model's failure mode elsewhere, so it is worth knowing what they were fitted to.

MS MARCO queries are Bing search queries: about six words, or roughly seven to eight wordpieces, with a long thin tail. Nq = 32 covers essentially all of them with room for expansion. MS MARCO passages average around 56 words, or roughly 76 wordpieces; 180 covers the overwhelming majority, and anything longer is truncated.

ConstantWhat it costs when too smallWhat it costs when too large
Nq = 32Query terms silently dropped — Chapter 9's ArguAna failureQuery encoding is O(Nq²) in attention and every MaxSim row is another 128-dim comparison; the whole query-time budget scales with it
doc limit = 180Evidence past the cut is unreachable — Chapter 9's NFCorpus and Touché failuresIndex size scales linearly. Going to 512 nearly triples your storage bill

Neither number is a law. Both are the shape of one dataset, hardened into an architecture. When you deploy late interaction on documents that are not web passages, these are the first two things to change and the last two things anyone thinks to check.

One encoder or two?

DPR uses two entirely separate BERTs — one for questions, one for passages — on the argument that questions and passages are different kinds of text and deserve different parameters. ColBERT shares one. Both choices are defensible and the tradeoff is worth naming.

Shared encoder + [Q]/[D] markers (ColBERT)Two separate encoders (DPR)
Parameters110M + 98,304 for the projection220M
Do the two sides share a space?By construction, from initialisationOnly if training succeeds in aligning them
Can they specialise?Only through the marker token's conditioningFully — every weight can differ
Training stabilityBetter — one set of weights, one gradient pathTwo towers can drift; needs careful warm-up
DeploymentOne model file; the query encoder is the document encoderTwo models, two versions to keep in sync with the index

The last row is the one that bites in production. If your query encoder and your document encoder are separate artefacts, then re-training one and not the other silently corrupts every score, and nothing crashes. Sharing weights makes that mistake unrepresentable.

Counting the model

Before we look at storage again, count what has to be loaded at query time:

BERT-base encoder + embeddings: 109.5×106 parameters
projection W (768 × 128, no bias): 98,304
total: 109.6M parameters ≈ 219 MB in fp16

That is the entire model. Everything else — the 20 GiB index, the 134 MB centroid table — is data, not weights. It is worth internalising that ratio: the artefact that took a week of GPUs to train is a hundredth the size of the artefact that took an afternoon to build from it.

Punctuation filtering

On the document side, ColBERT deletes the embeddings of punctuation symbols after encoding. Not before — after. The punctuation still participates in BERT's attention (a full stop is real evidence about sentence boundaries), it just does not get stored in the index or offered to MaxSim.

Two reasons, one economic and one about failure modes.

The economic one: punctuation is roughly 7% of wordpiece tokens in MS MARCO passages. Deleting it deletes 7% of the index, which is measured in tens of gigabytes. The paper reports no measurable quality loss.

The failure-mode one is more interesting. Remember that a query's 25 [MASK] rows are looking for something to match. A comma's embedding is a low-information vector that sits near the centre of the space, so it has middling similarity with almost everything — and middling similarity to 25 mask rows adds up. Filtering punctuation removes a class of cheap, meaningless matches that MaxSim would otherwise happily bank.

The projection down to 128

BERT-base emits 768 numbers per token. ColBERT puts a single linear layer on top — no bias in the usual implementation, no activation function — that maps 768 to m = 128, then L2-normalises the result.

Ed = Normalize( BERT(d) · W ),   W ∈ R768×128  (98,304 parameters)

Two things are happening in that one line and they are worth separating.

The projection is about storage. Run the arithmetic. At fp16, one 768-dimensional vector costs 1,536 bytes; one 128-dimensional vector costs 256 bytes. MS MARCO's 8.8 million passages produce roughly 6.4 × 108 token embeddings after punctuation filtering (about 73 per passage). So:

at 768 dims: 6.4×108 × 1,536 B = 9.83×1011 B ≈ 916 GiB
at 128 dims: 6.4×108 × 256 B   = 1.64×1011 B ≈ 153 GiB

916 gigabytes is not an index, it is a decision to abandon the project. 153 GiB is expensive but survivable on 2020 hardware, and Chapter 5 gets it to 20. The projection is what made the method exist.

The normalisation is about the scoring function. Once every row has unit length, the dot product equals the cosine, so every cell of the similarity matrix lies in [−1, 1] and every MaxSim score lies in [−32, 32]. That bounding does three jobs: it stops long-vector documents from winning by magnitude (the exact failure mode that "vector norms encode frequency" would otherwise cause), it makes the max operator compare like with like, and — the one nobody mentions until they need it — it makes quantization tractable, because you know in advance the range every number lives in. Chapter 5 depends on that.

The paper ablates m. The shape of the result is that quality degrades gracefully — roughly 0.349 MRR@10 at m = 128, still around 0.343 at m = 48, around 0.339 at m = 24 — while storage falls linearly. Read that carefully, because it sets up the next design generation: dimension reduction buys storage at a real, if small, quality cost. Quantization, as we will see, buys storage at almost no quality cost. Given the choice, you quantize.

Walk the whole thing in shapes

python — the full encode path, with every shape# ---------- OFFLINE, once per passage ----------
ids  = tokenizer("[D] Challenger Deep plunges 10935 metres")   # (n0,)   n0 = 9
H    = bert(ids)                                                # (n0, 768)
E    = H @ W                                                    # (n0, 128)
E    = E / E.norm(dim=-1, keepdim=True)                      # (n0, 128) unit rows
E    = E[~is_punctuation(ids)]                                 # (n, 128)  n = 8
index.put(doc_id, E.half())                                   # 8 x 256 B = 2,048 B

# ---------- ONLINE, once per query ----------
ids  = tokenizer("[Q] how deep is the mariana trench")          # (7,)
ids  = pad(ids, 32, value=MASK_ID)                              # (32,)  25 [MASK]s appended
Eq   = normalize(bert(ids) @ W)                                # (32, 128)

# ---------- SCORING, per candidate ----------
S     = Eq @ Ed.T                                              # (32, n)
score = S.max(dim=1).values.sum()                             # ()

Two numbers from that listing are worth memorising because they anchor everything downstream. A query is 32 × 128 = 4,096 floats, or 8 KB in fp16 — small enough to broadcast to every shard of a distributed index. A passage is n × 128, about 2 KB in fp16 for a short one and 18 KB for a 180-token one. Multiply the second by the size of your corpus and you have Chapter 5's problem.

Encoder pipeline — toggle the design choices

Both towers, with live tensor shapes and live byte counts. Turn query augmentation off and watch the query tensor shrink (and the expansion vectors vanish). Turn punctuation filtering off and watch the index grow. Drag the projection dimension and watch the corpus-scale storage number move.

Dim m 128

What the design does not include, and why

Not in ColBERTWhy it was left out
A learned scoring network over the similarity matrix (as in KNRM)It would be query-dependent work per document, and it would break the pruning arguments in Chapter 6. MaxSim's simplicity is what makes it prunable
Cross-attention between towersThat is a cross-encoder. The whole point is that the document side must be precomputable
Per-token weights or importance scoresNormalisation removes magnitude as a channel; the model expresses importance through direction instead. SPLADE, in Chapter 8, makes the opposite choice
An activation on the projectionA linear map followed by normalisation is already sufficient to change the geometry; a nonlinearity here adds parameters and cost with no reported benefit
Document-length normalisationMaxSim sums over query rows, not document tokens, so it is already length-invariant by construction (Chapter 2)
Why does ColBERT pad queries with [MASK] rather than with a [PAD] token that gets masked out of the computation?

Chapter 4: Triples, Then a Teacher

We have an architecture that produces two matrices and a scoring function that turns them into a number. Nothing so far says what those numbers should be. This chapter is about the training signal — and it is the single place where ColBERTv2 differs most from ColBERT, because the architecture between the two versions is unchanged. Same markers, same masks, same 128 dimensions, same MaxSim. What changed was the supervision and the storage. That fact is itself a result: it says the 2020 architecture was not the bottleneck.

Version 1: triples and a two-way softmax

ColBERT trains on MS MARCO's official triples: a query q, a passage d+ that a human marked relevant, and a passage d sampled from BM25's top-1000 for that query. The loss is a softmax cross-entropy over exactly two scores:

L = −log  exp(Sq,d+) / ( exp(Sq,d+) + exp(Sq,d) )

Work an instance. Suppose the current model scores the positive at S+ = 21.4 and the negative at S = 20.9 (remember these are sums of up to 32 cosines, so the natural scale is tens, not units).

p+ = e21.4 / (e21.4 + e20.9) = 1 / (1 + e−0.5) = 1 / (1 + 0.60653) = 0.62246
L = −ln(0.62246) = 0.4741

Notice we never had to exponentiate 21.4 — only the difference matters, which is why implementations subtract the max before exponentiating. Notice also what the loss wants: it is minimised as the gap S+ − S goes to infinity. It has an opinion about order and no opinion at all about how much better the positive should be.

In-batch negatives: free supervision from the batch

One triple gives one negative. But if you are already encoding a batch of B queries and B positives on a GPU, you can compute all B × B scores for almost nothing — the extra work is the MaxSim matrices, which Chapter 2 showed are a rounding error next to the encoder passes.

Every off-diagonal cell is a query paired with some other query's positive: a plausible negative, free. The loss becomes a B-way softmax per row, with the diagonal as the target.

Lin-batch = −(1/B) Σi=1B log  exp(Si,i) / Σj=1B exp(Si,j)

For B = 64 you get 63 negatives per query instead of 1, at a marginal cost of computing a 64 × 64 grid of MaxSim scores. This is the same trick CLIP uses on image-text pairs, and it has the same limitation: a random other query's positive is usually easy to reject. If the batch contains "how deep is the mariana trench" and "how to bake sourdough", distinguishing them teaches the model almost nothing, because it already can.

Let us make "teaches almost nothing" quantitative, because it is the hinge of the whole chapter.

Working an in-batch loss by hand, twice

Take B = 3 and a score grid where each query strongly prefers its own positive — the easy-negative regime:

S = [ 22.1  19.4  18.0 ]
    [ 18.6  21.7  19.1 ]
    [ 17.9  18.8  22.4 ]

Row 1, subtracting the row max of 22.1 before exponentiating:

e0 = 1.000000,  e−2.7 = 0.067206,  e−4.1 = 0.016573  →  sum = 1.083779
p11 = 1 / 1.083779 = 0.922697  →  ℓ1 = −ln(0.922697) = 0.0804

Row 2, max 21.7:

e−3.1 = 0.045049,  e0 = 1,  e−2.6 = 0.074274  →  sum = 1.119323
p22 = 0.893395  →  ℓ2 = 0.1127

Row 3, max 22.4:

e−4.5 = 0.011109,  e−3.6 = 0.027324,  e0 = 1  →  sum = 1.038433
p33 = 0.962988  →  ℓ3 = 0.0377
L = (0.0804 + 0.1127 + 0.0377) / 3 = 0.0770

Now change one number. Make query 1's hardest negative genuinely competitive — S12 = 22.0 instead of 19.4, a passage that nearly answers the question:

e0 = 1.000000,  e−0.1 = 0.904837,  e−4.1 = 0.016573  →  sum = 1.921410
p11 = 0.520452  →  ℓ1 = −ln(0.520452) = 0.6529

One row's loss went from 0.080 to 0.653, an eightfold increase, from a single hard negative. The gradient magnitude follows: in a softmax cross-entropy the gradient with respect to the score of negative j is exactly pij, so a negative the model assigns 0.067 probability contributes a hundredth of the push that one at 0.47 does.

The arithmetic behind every hard-negative paper ever written. A batch of 64 random negatives, each with probability around 0.001, contributes about as much gradient in total as one hard negative at 0.06. Scaling the batch is a linear investment in a term that decays exponentially with how easy the negatives are. Mining is not an optimisation; it is the only way to keep the loss informative once the model is any good.
The hard-negative principle. Gradient signal is concentrated in the examples the model currently gets wrong. An easy negative contributes a near-zero term to the softmax denominator and therefore a near-zero gradient. This is why every serious retrieval training recipe eventually becomes a mining recipe: use the current model to retrieve, take what it wrongly ranks highly, and train on that. It is also why the next problem is unavoidable.

The problem with hard negatives: most of them are not negative

Mine hard negatives from BM25's or your own model's top-k and you will collect passages that look relevant. Some of them look relevant because they are.

MS MARCO's annotation protocol produced roughly one marked-relevant passage per query out of a corpus of 8.8 million. That is not a claim that only one passage answers the question — it is an artefact of how much a human was asked to read. RocketQA (Qu et al., 2021) made this concrete by re-annotating top-retrieved unlabelled passages and finding a large share of them genuinely relevant; their fix was to run a cross-encoder over candidate negatives and throw out the ones it scored highly.

So the pairwise loss, applied to mined negatives, is doing something worse than being uninformative. It is asserting a falsehood. Take a query with two genuinely good answers, A and B, where A happens to be the labelled one. The loss says: drive p(B) toward zero. The model complies. It learns to distinguish a correct answer from a correct answer.

Version 2's answer: let a cross-encoder grade the exam

ColBERTv2's supervision is knowledge distillation from a cross-encoder. Not the 10.7-second one from Chapter 1 — a small MiniLM cross-encoder, which is still far too slow to serve but perfectly fine to run offline over a training set.

The loop, in order:

1. Retrieve
Index the corpus with the current ColBERT model. For each training query, retrieve the top passages and sample w = 64 of them. These are hard, because your own model chose them.
2. Grade
Run the cross-encoder teacher over all 64 (q, d) pairs. It sees the query and the passage jointly, so it can tell "answers the question" from "is about the topic". Output: 64 real-valued scores.
3. Match the distribution
Softmax the teacher's 64 scores into a target distribution. Softmax the student's 64 MaxSim scores. Minimise the KL divergence between them, plus the in-batch cross-entropy term.
4. Repeat
The improved student retrieves better candidates, which are harder, which makes the teacher's grading more informative. Re-index and go around again.

Working the KL divergence by hand

KL divergence — short for Kullback–Leibler — measures how much a distribution q differs from a reference distribution p. Written out:

KL(p ‖ q) = Σi pi · ln( pi / qi )

Shrink the 64 passages to 4 so we can compute every number. Teacher scores: 8.2, 5.1, 4.7, 1.3. Subtract the max and exponentiate:

e0 = 1.000000   e−3.1 = 0.045049   e−3.5 = 0.030197   e−6.9 = 0.001005
sum = 1.076252
p = [ 0.9292 , 0.0419 , 0.0281 , 0.0009 ]

Student MaxSim scores on the same four: 21.4, 20.9, 18.6, 15.2.

e0 = 1.000000   e−0.5 = 0.606531   e−2.8 = 0.060810   e−6.2 = 0.002035
sum = 1.669376
q = [ 0.5990 , 0.3633 , 0.0364 , 0.0012 ]

Now term by term. This is where the shape of the signal becomes visible.

Passageteacher pistudent qiln(pi/qi)contribution pi·ln(pi/qi)
1 (labelled positive)0.92920.5990ln(1.5512) = +0.4390+0.4079
2 (mined "negative")0.04190.3633ln(0.1152) = −2.1609−0.0904
30.02810.0364ln(0.7703) = −0.2609−0.0073
40.00090.0012ln(0.7662) = −0.2662−0.0002
KL(p ‖ q)0.3100

Two observations that people trip over. First, individual terms can be negative — only the total is guaranteed non-negative, and it is zero only when the two distributions are identical. Second, the term sizes are weighted by p, the teacher's belief. The teacher is confident about passage 1 and its disagreement there dominates the loss. It barely cares about passage 4, and neither does the gradient.

What this loss says that the pairwise loss cannot. The teacher does not say "passage 2 is wrong". It says "passage 2 deserves 0.042 of the probability mass, about one twenty-second of passage 1". If passage 2 is a genuine second answer, that is true, and the student is being taught something correct. Compare the pairwise loss on the same pair: it drives p(passage 2) toward 0 with no ceiling, teaching the model that a correct answer is incorrect. That is the "denoised supervision" in ColBERTv2's abstract, and it is the larger half of its 3.7-point MRR gain.

An aside: why ColBERT has no temperature and CLIP needs one

If you have met contrastive learning through CLIP or SimCSE you will be waiting for a temperature — a learned scalar τ that divides the similarities before the softmax. ColBERT does not have one, and the reason is a nice piece of dimensional reasoning.

A single-vector model's score is one cosine. Two plausible passages might differ by 0.03 — say 0.81 versus 0.78. Feed that straight into a softmax and you get probabilities of 0.5075 and 0.4925: essentially no signal, and a loss that barely moves. Hence a temperature, typically 1/τ between 20 and 100, which turns 0.03 into a usable 0.6 to 3.0 logit gap.

MaxSim sums 32 cosines. Two passages that differ by 0.03 per matching token differ by up to 0.96 in the total, and in practice good and mediocre passages separate by several points on a scale that runs to 32. Check what that does to a softmax:

a 1-point MaxSim gap → odds ratio e1 = 2.72×
a 3-point gap → e3 = 20.1×

The score is already on a scale where differences of interest are order-one, so the identity function is a perfectly good temperature. Summing over the query axis did the sharpening that CLIP has to learn a parameter for. It is a small thing, and it is the kind of small thing that saves a hyperparameter sweep.

The remaining half of the loss is the in-batch cross-entropy from earlier, kept because it supplies negatives from outside the 64-passage neighbourhood — the distillation term teaches fine ranking among near-misses, the in-batch term keeps the model from collapsing everything unrelated into the same region.

Two supervision signals, side by side

Left: the B×B in-batch score grid, diagonal is the target. Right: the teacher's distribution against the student's over four candidates, with the live KL. Press "gradient step" to move the student toward the target and watch which loss does what to passage 2 — the false negative.

Run the pairwise loss for twenty steps and watch passage 2's bar go to the floor while the teacher's bar for it stays at 0.042. That gap is a model being trained to believe something false. Then run distillation and watch the student's whole shape settle onto the teacher's.

What it bought, in numbers

SystemSupervisionMS MARCO dev MRR@10
BM25None — term statistics0.187
ColBERT (v1)Triples, BM25 negatives, pairwise softmax0.360
SPLADEv2Distillation + hard negatives (sparse model, Chapter 8)0.368
RocketQAv2Denoised hard negatives, single vector0.388
ColBERTv2Cross-encoder distillation + mined negatives + in-batch0.397

Read the ColBERT rows together: identical architecture, +3.7 MRR@10. Then read the RocketQAv2 row: a single-vector model with equally careful supervision reaches 0.388, only 0.9 behind. In-domain, on the dataset everybody trains on, careful supervision matters more than representation richness. Hold that thought until Chapter 9, where the two models go somewhere they have never been and the ordering changes.

The realization: what the training step actually costs

python — one ColBERTv2 training step, with shapes# q: B queries; D: B x w candidate passages, w = 64 per query
Eq = encode_query(q)                       # (B, 32, 128)
Ed = encode_doc(D.flatten())                # (B*w, n, 128)

# MaxSim for every (query, its own 64 candidates) pair
S  = torch.einsum('bqh,bwdh->bwqd', Eq, Ed)  # (B, w, 32, n)
S  = S.max(dim=-1).values.sum(dim=-1)      # (B, w) student scores

# teacher scores were precomputed offline, once
p  = torch.softmax(teacher_scores, dim=-1)  # (B, w)
loss_kd = torch.nn.functional.kl_div(
              torch.log_softmax(S, dim=-1), p,
              reduction='batchmean')

# in-batch: every query against every other query's positive
S_ib = torch.einsum('bqh,cdh->bcqd', Eq, Ed[:, 0])
S_ib = S_ib.max(dim=-1).values.sum(dim=-1)   # (B, B)
loss_ib = torch.nn.functional.cross_entropy(S_ib, torch.arange(B))

loss = loss_kd + loss_ib

The teacher never appears in that step. Its 64 scores per query were computed once, offline, and cached — which is what makes distillation from an expensive model affordable. You pay a cross-encoder's price on the training set (hundreds of thousands of queries) instead of on the corpus (millions of passages) or the traffic (billions of queries).

Why does distilling from a cross-encoder help more than simply mining harder negatives with the pairwise loss?

Chapter 5: Residual Compression

Time to confront the bill. ColBERT stores one vector per token. That is the source of everything good about it and the entire reason it was, for two years, a method people admired and did not deploy.

The bill, computed from scratch

MS MARCO's passage collection has 8.8 million passages. After wordpiece tokenization and punctuation filtering, they average about 73 stored tokens each. So:

Nemb = 8.8×106 passages × 73 tokens = 6.42×108 embeddings

Six hundred forty million vectors. Each is 128 dimensions. At 16-bit floats:

bytes per embedding = 128 × 2 = 256 B
total = 6.42×108 × 256 = 1.643×1011 B = 164.3 GB = 153 GiB

The paper reports 154 GiB, so our arithmetic is honest. Now put that number in context. A 2020-era server with 192 GB of RAM can hold it and nothing else. Sharding it across machines is possible and it means your retrieval index costs more in hardware than your language model does. Meanwhile BM25's index for the same corpus is under a gigabyte.

The problem restated precisely. ColBERT is not expensive because it is neural. A single-vector dense index on the same corpus is about 13 GiB in fp16. ColBERT is expensive because it stores 73 vectors where the bi-encoder stores 1. The multiplier is the method. You cannot remove it — that is late interaction — so you have to make each vector far cheaper.

The observation that makes compression possible

Here is the empirical fact ColBERTv2 leans on: the token embeddings are not spread uniformly over the 128-dimensional unit sphere. They cluster, tightly, into regions that correspond to specific token senses. The vector for "deep" in an oceanography passage sits very near the vector for "deep" in ten thousand other oceanography passages. Contextualization moves it a little. It does not move it far.

That is not surprising once you say it out loud. There are only about 30,000 wordpieces in BERT's vocabulary, and context perturbs each one within a neighbourhood. 640 million vectors drawn from roughly 30,000 sense-clusters means each cluster holds tens of thousands of near-duplicates. Storing each of them at full precision is storing the same information tens of thousands of times.

The scheme: a centroid plus a small correction

Run k-means over a sample of the corpus embeddings to get C centroids. Then for every embedding v:

1. Assign
Find the nearest centroid ct. Store the integer t. This costs ⌈log2 C⌉ bits.
2. Subtract
Compute the residual r = v − ct. Because ct is close to v, r is small: a correction, not a vector.
3. Quantize
Squash each of the 128 dimensions of r into b bits — b = 2 means 4 buckets per dimension, with cutoffs placed at the quantiles of the empirical residual distribution so each bucket is equally likely.
4. Reconstruct on demand
v̂ = ct + dequantize(r̂). One table lookup and 128 additions. Then re-normalise and score.

How many centroids? ColBERT's implementation scales the count with the square root of the collection: roughly 16√Nemb, rounded up to a power of two. For our corpus:

16 × √(6.42×108) = 16 × 25,338 = 405,408  →  219 = 524,288 centroids
centroid ID width = 19 bits
centroid table = 524,288 × 128 × 2 B = 134 MB  (0.08% of the fp16 index — free)

Now the storage arithmetic, all the way down

bits per embedding = 19 (centroid ID) + 128 × 2 (residual) = 19 + 256 = 275 bits = 34.375 B
total = 6.42×108 × 34.375 = 2.207×1010 B = 22.1 GB = 20.6 GiB

Against 153 GiB, that is a factor of

256 / 34.375 = 7.45×

and it lands right on the paper's reported figure of about 20 GiB and its claimed 6–10× range. Run the same arithmetic at b = 1 bit per dimension:

19 + 128 = 147 bits = 18.375 B  →  6.42×108 × 18.375 = 1.180×1010 B = 11.0 GiB  (13.9×)

And now the comparison that should genuinely surprise you. Per passage:

RepresentationBytes per passageMS MARCO total
ColBERT, 128 dims, fp1673 × 256 = 18,688 B153 GiB
DPR-style single vector, 768 dims, fp323,072 B25.2 GiB
ColBERTv2, 2-bit residuals73 × 34.375 = 2,509 B20.6 GiB
Single vector, 768 dims, fp161,536 B12.6 GiB
BM25 inverted index (Lucene)~110 B~0.9 GiB

A compressed multi-vector index is smaller than an uncompressed single-vector index, and within 1.6× of a half-precision one. The thing everybody knew was too big to deploy stopped being too big to deploy, and the architecture never changed.

The fair caveat. Single-vector indexes compress too — product quantization takes a DPR index from 12.6 GiB to about 1 GiB at some cost in recall. The honest comparison is not "ColBERTv2 beats DPR on storage" but "ColBERTv2 moved from the wrong side of a deployability threshold to the right side of it". A 20 GiB index fits in RAM on a single commodity machine. A 154 GiB one does not.

Why two bits is enough — the error budget

Two bits per dimension sounds recklessly small. Reason about it rather than trusting it.

What is being quantized is not the embedding, it is the residual. Suppose (representatively — the exact figures depend on the corpus) that in a given dimension the raw embedding values have a standard deviation around 0.09, while the residuals after centroid subtraction have a standard deviation around 0.02. Four buckets placed at the residual quantiles reduce the per-dimension reconstruction error to roughly 0.008.

Across 128 dimensions the squared error accumulates:

‖v̂ − v‖² ≈ 128 × 0.008² = 128 × 6.4×10−5 = 0.0082
‖v̂ − v‖ ≈ 0.091

Now convert that into what actually matters — the change in a similarity. For two unit vectors, the relation between distance and cosine is exact:

‖v̂ − v‖² = ‖v̂‖² + ‖v‖² − 2(v̂ · v) = 2 − 2cos  →  cos = 1 − ‖v̂ − v‖²/2
cos(v, v̂) = 1 − 0.0082/2 = 0.9959

So each stored embedding sits about 0.996 cosine from its true self. Each cell of the similarity matrix is perturbed by a few thousandths. Summed over 32 query rows, the MaxSim score wobbles by roughly 32 × 0.004 ≈ 0.13, on scores that live around 20 — well under 1%.

And crucially the perturbation is unbiased and shared: every document is degraded by about the same amount, so the ranking is nearly untouched. Quantization noise only flips a pair when their true scores were within 0.13 of each other, in which case you were choosing between two nearly-equivalent passages anyway.

Doing the quantizer properly

The 0.008 above was an estimate. The bucket boundaries are not arbitrary, and the exact answer is a solved problem worth knowing, because it tells you precisely what the second bit buys.

Residuals in a given dimension are close to zero-mean Gaussian with standard deviation σ ≈ 0.02. With b = 2 bits you get four reconstruction levels, and the optimal placement is the classical Lloyd–Max quantizer: boundaries at the conditional-mean midpoints, levels at the conditional means of each region. For a Gaussian, the equiprobable boundaries sit at the quartiles:

±0.6745σ and 0  →  ±0.0135 and 0

and the reconstruction levels are the conditional means of the four regions. For the outer regions, using the standard normal density φ:

E[X | X > 0.6745σ] = σ · φ(0.6745)/0.25 = σ · 0.31777/0.25 = 1.2711σ = 0.0254
E[X | 0 < X < 0.6745σ] = σ · (φ(0) − φ(0.6745))/0.25 = σ · (0.39894 − 0.31777)/0.25 = 0.3247σ = 0.0065

So the four stored values per dimension are −0.0254, −0.0065, +0.0065, +0.0254 — and the two bits say which. The distortion of the optimal 4-level Gaussian quantizer is a tabulated constant, 0.1175σ², giving a root-mean-square error of

√0.1175 · σ = 0.3428 × 0.02 = 0.00686

slightly better than our rough 0.008. Propagate it through the 128 dimensions and the cosine relation:

‖v̂ − v‖² = 128 × 0.00686² = 0.00602  →  cos(v, v̂) = 1 − 0.00602/2 = 0.9970

Now run the same machinery at b = 1. Two levels, the optimal Gaussian one-bit quantizer has distortion (1 − 2/π)σ² = 0.3634σ²:

RMS = √0.3634 · σ = 0.6028 × 0.02 = 0.01206
‖v̂ − v‖² = 128 × 0.01206² = 0.01862  →  cos = 1 − 0.00931 = 0.9907
Bits per dimensionLevelsDistortion factorCosine fidelityIndex size
120.3634σ²0.990711.0 GiB
240.1175σ²0.997020.6 GiB
4 (for comparison)160.009497σ²0.9997639.7 GiB

Read down the fidelity column. The second bit cuts the squared error by 3.1× for 1.9× the space, and that buys a visible point of MRR. Bits three and four cut it a further 12× for another 1.9× the space and buy nothing measurable, because at cosine 0.997 the quantization error is already an order of magnitude below the score differences the ranking has to resolve. Two bits is not a compromise between one and four. It is the point where the curve goes flat.

The other half of the bill: building the index

Compression is not free at index time, and the arithmetic is worth seeing because it decides how often you can afford to re-index.

Assigning every embedding to its nearest centroid is, naively, a matrix multiply of 6.42×108 vectors against 524,288 centroids in 128 dimensions:

2 × 6.42×108 × 5.24×105 × 128 = 8.6×1016 FLOPs

On an accelerator sustaining 100 TFLOP/s that is about 860 seconds — fourteen minutes of pure arithmetic, which is astonishingly cheap for what it is. In practice it takes longer, because at these shapes the bottleneck is memory bandwidth rather than multipliers, and because the k-means itself must run first (on a sample of a few million embeddings, not the full set — centroids estimated from a sample are statistically indistinguishable from centroids estimated from everything).

The real cost of indexing is the part that is not the compression: one BERT forward pass over all 8.8 million passages. At 45.9 GFLOPs per 256-token passage that is 4×1017 FLOPs, an order of magnitude more than the clustering. Compression is a rounding error on an indexing job you were already paying for.

Why quantizing the raw vector instead would fail. Try it: 2 bits per dimension applied to v directly gives 4 reconstruction levels across the full range of the coordinate, a per-dimension error near 0.045 rather than 0.008. Then ‖v̂ − v‖² ≈ 128 × 0.002 = 0.26, so cos(v, v̂) ≈ 0.87 — a catastrophic 13-point similarity error that would swamp the real differences between documents. The centroid is doing the heavy lifting; the two bits are only paying for the last few percent. This is the same principle as residual coding in audio and video, and the same principle as a residual connection in a network: predict most of the signal cheaply, then spend your bits only on what the prediction missed.

Budget it yourself

Storage budget calculator

Every number here is the arithmetic above, live. Move the corpus size to your corpus, the tokens per document to your documents, and see which bars cross the RAM lines. The dashed lines are 64 GB and 256 GB — roughly "one good server" and "one expensive server".

Documents 8.8M
Tokens / doc 73
Dim m 128
Res. bits 2

Push the document count to 100 million and watch every uncompressed bar leave the chart. Push tokens per document to 180 — ColBERT's truncation limit — and watch how much of your budget is spent on long documents you probably should have chunked. The calculator is the design conversation.

What compression costs in quality

The paper's ablation is unglamorous and that is the point: 2-bit residuals cost a few tenths of an MRR point against uncompressed embeddings; 1-bit costs closer to a full point. Compare that against dimension reduction from Chapter 3, where going from 128 to 24 dimensions — a 5.3× storage saving — costs about a full point too.

Route to a smaller indexSavingApproximate MRR@10 costVerdict
Project to m = 24 instead of 1285.3×~1.0Expensive per byte saved
2-bit residual compression7.45×a few tenthsThe right lever
1-bit residual compression13.9×~1.0Use when RAM is the binding constraint
Both (m = 48, 2-bit)~20×~1.0–1.5Viable for very large corpora

The general lesson generalises well past this paper. When you need to shrink a learned representation, first ask what structure the representation already has. Dimension reduction throws away directions blindly. Residual coding exploits the fact that the vectors were clustered all along, so it pays for information you actually have rather than information you assumed was uniform.

ColBERTv2 quantizes each dimension of the residual to 2 bits rather than quantizing the embedding itself. Why does that work when direct 2-bit quantization would not?

Chapter 6: PLAID — Serving It Fast

A 20 GiB index fits in memory. That solves storage and leaves the other half of the deployment problem completely open: you still cannot run MaxSim against 8.8 million passages inside a search box.

Chapter 2 measured MaxSim at 0.6 MFLOPs per document. Against the whole corpus that is 5.3 TFLOPs per query — the wrong side of every latency budget, and that is before you decompress 640 million embeddings to do it. Retrieval has to be a funnel: cheap filters first, expensive scoring only on survivors.

ColBERT v1 built that funnel with an approximate nearest-neighbour index (faiss) over individual token embeddings: for each of the 32 query rows, find the k nearest document tokens, collect the passages those tokens belong to, then score those passages exactly. It worked — about 450 ms end-to-end per query — and it was awkward, because a general-purpose vector index knows nothing about the structure MaxSim actually has.

PLAID (Santhanam et al., 2022) is the engine written after that structure was understood. Its central realization is one sentence long, and it comes straight out of Chapter 5.

The PLAID insight. Chapter 5 stored every embedding as a centroid plus a small correction, and proved the correction is small — cosine fidelity about 0.996. So the centroid alone is already a good approximation of the embedding. Which means you can compute an approximate MaxSim score using nothing but centroid IDs, without decompressing a single residual. Compression stopped being only a storage trick and became the ranking prefilter.

Stage 1 — candidate generation

The index keeps an inverted list per centroid: centroid t → the passages containing at least one token assigned to t. This is, structurally, an inverted index — except the "terms" are 524,288 learned clusters rather than 30,000 vocabulary items.

Encode the query into its (32, 128) matrix and multiply against the centroid table:

Scent = Eq · C,   (32, 128) × (128, 524288) → (32, 524288)
FLOPs = 2 × 32 × 128 × 524,288 = 4.3 × 109

4.3 GFLOPs — the same order as encoding the query itself, and a millisecond or two of GPU time. For each query row take the top nprobe centroids (1 to 4, depending on how deep a result list you need) and union their passage lists. The result is a candidate set of roughly 104 to 105 passages out of 8.8 million: three orders of magnitude gone, using one matrix multiply.

Stage 2 — centroid interaction

Now score every candidate approximately. For candidate passage d, replace each of its token embeddings by its centroid and compute MaxSim:

q,d = Σi maxj ∈ d ( Eqi · ct(j) )

Here is the trick that makes it nearly free: every quantity inside that expression has already been computed. Eqi · ct is exactly cell (i, t) of the Scent matrix from stage 1. Approximate scoring is a gather and a max over an existing table — no dot products at all, no memory reads of residual data.

Rank the candidates by the approximate score and keep the top ndocs. PLAID's published configuration uses ndocs = 256 for k = 10, 1024 for k = 100, 4096 for k = 1000.

Stage 3 — centroid pruning

A refinement that sounds minor and is not. Most of the 524,288 centroids are irrelevant to any given query — a query about ocean depth has essentially zero similarity to the centroids covering cooking, tax law, or Python syntax. PLAID computes, for each centroid, its maximum similarity to any of the 32 query rows, and discards every centroid below a threshold tcs (around 0.4 to 0.5).

keep centroid t ⇔  maxi=1..32 Scent[i, t] ≥ tcs

This routinely removes well over 90% of centroids from further consideration, which shrinks the gather in stage 2 by the same factor and shrinks the amount of index data that has to be touched at all. Pruned centroids contribute their similarity as a floor value rather than being read.

The safety argument is the same error budget as Chapter 5's. A centroid whose best query similarity is below 0.4 cannot become a row maximum, because real matches score 0.8 and up and the residual can only move a similarity by a few thousandths. Pruning is not a heuristic gamble; it is a bound.

Stage 4 — decompress and score exactly

The survivors — typically ndocs/4, so about 64 passages for a top-10 query — get the full treatment. Look up their compressed residuals, reconstruct v̂ = ct + dequantize(r̂), renormalise, run the real MaxSim from Chapter 2, sort, return k.

Sixty-four passages at 0.6 MFLOPs each is 38 MFLOPs. Nothing. The expensive exact scoring, the thing the whole method is about, is the cheapest stage in the pipeline.

The four-stage funnel

Press "run query" to step a query through PLAID. Watch the candidate count fall by three orders of magnitude before a single residual is decompressed, and watch where the milliseconds actually go. The stage timings are an illustrative decomposition of the paper's reported ~58 ms total for k = 10 on a GPU.

Depth k:

What each stage is actually load-bearing for

Four stages is a suspicious number — pipelines accrete stages the way codebases accrete flags. So delete each one in turn and see what breaks. This is the fastest way to understand why the design is the shape it is.

Remove…What happensTherefore the stage exists to…
Stage 1 (candidate generation)You must score all 8.8M passages. 5.3 TFLOPs and 23 GB of memory traffic per queryReduce the candidate set by three orders of magnitude with one matrix multiply
Stage 2 (centroid interaction)You decompress all 40,000 candidates: 105 MB of scattered reads and 2.9M residual reconstructions per queryRank cheaply enough that decompression is only paid on the winners
Stage 3 (centroid pruning)Stage 2's gather runs over all 524,288 centroids instead of the ~5% that matterShrink the working set so stage 2 stays in cache
Stage 4 (exact MaxSim)You return the centroid-approximate ranking. Quality drops by roughly the residual magnitude — small, but systematically biased toward passages whose tokens sit near a centroidRemove the approximation exactly where it would show, on the handful of documents you are about to return

Read the last row carefully, because it is a general pattern in approximate systems. The approximation is used where it is cheap and its errors are averaged over thousands of candidates; it is not used at the very top of the ranking, where a single error is the answer the user sees. Approximate broadly, exact narrowly. Every well-built retrieval funnel does this, and most badly-built ones fail because they are approximate all the way to the top.

The real currency is memory traffic, not FLOPs

Chapter 2 established that MaxSim's arithmetic is nearly free. So if the arithmetic is free, what exactly is PLAID saving? Count bytes moved instead of operations performed, and the pipeline's shape suddenly makes sense.

Suppose stage 1 produces 40,000 candidate passages of 73 tokens each. Two ways to proceed:

ApproachBytes read from the indexAlso required
Decompress everything, score exactly40,000 × 73 × 36 B = 105 MB2.9M residual reconstructions (128 additions each)
Centroid interaction, then decompress 6440,000 × 73 × 4 B = 11.7 MB of centroid ids,
then 64 × 73 × 36 B = 0.17 MB of residuals
A gather and a segmented max

Nine times less data moved, and the decompression step — which is pure memory-bound work, not something a bigger GPU fixes — runs on 0.16% as many embeddings. At a realistic 50 GB/s of effective random-access bandwidth, 105 MB is about 2 ms of pure streaming in the ideal case and considerably worse in the real one, because passage data is scattered across a 20 GiB array and every access is a cache miss.

This is why the stage ordering is what it is. The cheap stage is not cheap because it does less arithmetic. It is cheap because it reads a 4-byte integer per token instead of a 36-byte record, and because the thing it reads is an index into a 134 MB centroid table that sits comfortably in cache.

How this compares to the classical version of the same idea

Inverted-index search solved this problem decades ago with WAND and BlockMax-WAND: maintain an upper bound on the score any document in a block of postings could achieve, and skip the whole block when the bound falls below the current k-th best score. It is worth naming the difference precisely, because it is the one place PLAID gives something up.

BlockMax-WAND (BM25)PLAID (late interaction)
What is prunedBlocks of postings whose max possible contribution is too smallCentroids whose max query similarity is below tcs; then candidates below the top ndocs
GuaranteeExact — the returned top-k is provably identical to the unpruned resultApproximate — bounded by the residual magnitude, not proven
Why the differenceBM25's term contributions are known constants, so a true upper bound exists per blockMaxSim's contribution depends on the query, and the bound comes from the compression error rather than from stored statistics
Observed effect2–10× faster, zero quality change2.5–45× faster, sub-0.1 MRR change

PLAID's approximation is empirically almost free, but "almost" is the honest word and it is worth knowing which one you have. If you are building a system where a retrieval result must be reproducible and defensible — legal discovery, regulated search — the exactness column is not a footnote.

What to tune when you are over budget

KnobEffect on latencyEffect on qualityReach for it when
nprobe (centroids per query token)Roughly linear in candidate countRecall of the candidate set — the hard ceilingRecall@k is fine but latency is not
ndocs (survivors of centroid interaction)Linear in stage 3–4 workSmall until it drops below ~10×kFirst thing to cut; usually the cheapest win
tcs (centroid pruning threshold)Shrinks the gather in stage 2Nothing until it approaches real match similaritiesStage 2 dominates your profile
Residual bitsHalves stage 4 memory traffic~1 MRR point going 2→1You are RAM-bound, not compute-bound
Document truncation lengthLinear in everythingLoses whatever was past the cutYour documents are long and your queries are not

What it delivered

EngineHardwareLatency, k = 10, MS MARCOSpeedup vs vanilla ColBERTv2
ColBERT v1 (faiss token ANN)GPU~450 ms end-to-end
ColBERTv2 (own engine)GPUhundreds of ms1× (baseline)
PLAID ColBERTv2GPU~58 ms2.5–7×
PLAID ColBERTv2CPUlow hundreds of ms9–45×

The CPU column is the one that changed who could use this. 45× on a CPU means late interaction stopped requiring a GPU in the serving path, which for most teams is the difference between "interesting paper" and "thing we can put behind an API".

Concept check. Why is centroid interaction a good prefilter, when the usual complaint about approximate scoring is that it introduces errors correlated with the thing you are trying to measure?  …  Because the approximation error is bounded and small by construction — the residual, whose magnitude the compression scheme chose. Contrast this with an ANN index over pooled document vectors, where the prefilter's notion of relevance is structurally different from the final scorer's: a pooled vector can rank a multi-aspect passage low (Chapter 0) and the exact scorer never gets to see it. PLAID's prefilter is a noisy version of the true score. Pooling-based prefilters are a different score wearing a disguise, and their recall failures are systematic rather than random.

The realization: what a shard actually holds

python — the four stages, with the data each one touches# Loaded once, resident in RAM:
#   centroids   (524288, 128) fp16          -> 134 MB
#   codes       (642_000_000,) uint32       -> centroid id per embedding
#   residuals   (642_000_000, 32) uint8     -> 128 dims x 2 bits, packed
#   ivf         centroid -> passage id list -> the inverted index

Eq = encode_query(q)                                # (32, 128)
Sc = Eq @ centroids.T                              # (32, 524288)   4.3 GFLOPs

# STAGE 3 (applied first as a filter): centroid pruning
live = Sc.max(dim=0).values >= t_cs                 # ~5% of centroids survive

# STAGE 1: candidate generation
top  = Sc[:, live].topk(nprobe, dim=1).indices      # (32, nprobe)
cand = ivf.union(top)                                 # ~40,000 passage ids

# STAGE 2: centroid interaction - a GATHER, not a matmul
approx = segment_max_sum(Sc, codes[cand])            # (40000,) approximate scores
short  = approx.topk(ndocs).indices                   # 256 passages

# STAGE 4: decompress the few survivors and score exactly
Ed    = decompress(centroids[codes[short]], residuals[short])
final = maxsim(Eq, Ed).topk(k)                        # the answer

Look at what stage 2 is, in that listing. It is a gather over an already-computed table followed by a segmented max. There is no floating-point multiply anywhere in the stage that eliminates 99.4% of the candidates. That is the whole art of retrieval engineering: arrange things so the step that discards the most does the least.

PLAID's second stage scores tens of thousands of candidate passages without performing any new dot products. How?

Chapter 7: The Tradeoff Table

Seven chapters of mechanism. Now the engineering question: given a corpus, a latency budget, and a machine, which of these do you actually build? That question deserves real numbers, and real numbers need metrics you can compute rather than nod at. So we start by earning the vertical axis.

MRR@10, computed by hand

Mean Reciprocal Rank at 10 asks one question per query: where in your top ten did the first relevant document appear? Score it as 1 divided by that rank, or zero if it never appeared. Average over queries.

MRR@10 = (1/|Q|) Σq 1 / rankq  (rankq = position of the first relevant result, or ∞ beyond 10)

Five queries, with the first relevant document landing at ranks 1, 3, 2, 22, and 5:

1/1 = 1.0000   1/3 = 0.3333   1/2 = 0.5000   rank 22 → 0   1/5 = 0.2000
sum = 2.0333  →  MRR@10 = 2.0333 / 5 = 0.4067

Now translate the numbers we have been quoting. BM25's 0.187 on MS MARCO does not mean "18.7% correct". It means the average reciprocal rank is 0.187 — and since the reciprocal rank is at most 1, an MRR of 0.187 puts a hard ceiling of 18.7% on how often the right passage was ranked first. ColBERTv2's 0.397 raises that ceiling to 39.7%.

Why this specific metric decides RAG pipelines. If you feed the top 3 passages to a language model, what matters is whether the answer is in those three. MRR is dominated by exactly that region — the difference between rank 1 and rank 2 is worth 0.5 to MRR, while the difference between rank 20 and rank 30 is worth nothing at all. A retriever that improves MRR@10 from 0.19 to 0.40 roughly doubles the fraction of prompts that contain the answer, which propagates straight into every downstream number you care about.

nDCG@10, computed by hand

MRR only looks at the first hit, which is wrong when several documents are relevant to different degrees. nDCG — Normalized Discounted Cumulative Gain — fixes both: it uses graded relevance and it discounts by position.

DCG@k = Σi=1k (2reli − 1) / log2(i + 1)

Suppose your top five results have relevance grades 3, 0, 2, 3, 1 on a 0–3 scale. Term by term:

i=1: (23−1)/log22 = 7 / 1.0000 = 7.0000
i=2: (20−1)/log23 = 0 / 1.5850 = 0.0000
i=3: (22−1)/log24 = 3 / 2.0000 = 1.5000
i=4: (23−1)/log25 = 7 / 2.3219 = 3.0147
i=5: (21−1)/log26 = 1 / 2.5850 = 0.3868
DCG@5 = 11.9015

To normalise, compute the DCG of the best possible ordering of the same grades — 3, 3, 2, 1, 0:

7/1.0000 + 7/1.5850 + 3/2.0000 + 1/2.3219 + 0/2.5850
= 7.0000 + 4.4164 + 1.5000 + 0.4307 + 0 = 13.3471
nDCG@5 = 11.9015 / 13.3471 = 0.8917

The exponential gain term is what makes nDCG useful for graded judgements: moving a grade-3 document from rank 4 to rank 2 is worth much more than moving a grade-1 document the same distance. BEIR, in Chapter 9, reports nDCG@10 because its eighteen datasets use graded relevance from many different annotation protocols and it needed one comparable number.

The third metric, which nobody publishes and everybody needs

Recall@k — the fraction of relevant documents that appear anywhere in the top k — is the metric that decides whether a first-stage retriever is usable in a pipeline. If you plan to re-rank the top 1,000 with a cross-encoder, then nothing outside those 1,000 can ever be recovered, so Recall@1000 is a hard ceiling on the entire system.

SystemMS MARCO dev MRR@10Recall@50Recall@1000
BM250.187~0.59~0.857
ColBERT (v1)0.3600.968
ColBERTv20.3970.8680.984

Compute one by hand so the number means something. Suppose across four queries the relevant documents found in the top 50 were 2 of 3, 1 of 1, 0 of 2, and 4 of 4:

Recall@50 = (2/3 + 1/1 + 0/2 + 4/4) / 4 = (0.6667 + 1.0000 + 0.0000 + 1.0000) / 4 = 2.6667 / 4 = 0.6667

Note the third query: a complete miss contributes a hard zero and drags the mean down by 0.25 on its own. Recall averages are dominated by total failures, which is exactly the right sensitivity for a first stage — a query where you retrieved nothing useful is a query the rest of the pipeline cannot save.

The Recall@1000 column is the one that changed pipelines. BM25 loses about 14% of answers before any neural model gets a look; ColBERTv2 loses under 2%. If your architecture is "cheap first stage, expensive re-ranker", the first stage's recall is your ceiling, and 0.857 versus 0.984 is a very expensive 12.7 points to leave on the table.

Everything on one page

SystemIndex, MS MARCO 8.8MBytes / passageQuery latencyMRR@10BEIR avg nDCG@10Serving
BM25~0.9 GiB~11010–40 ms CPU0.1870.440Lucene, any laptop
DPR / single vector, fp1612.6 GiB1,53610–50 ms0.311–0.3470.375–0.415faiss / HNSW
Single vector + product quantization~1–2 GiB~128–2565–20 ms−1 to −3 pts−1 to −3 ptsfaiss IVF-PQ
SPLADEv2 (Ch 8)~2–5 GiB~250–60050–500 ms CPU0.368~0.47Inverted index
ColBERT v1154 GiB18,688~450 ms end-to-end0.3600.453faiss + custom
ColBERTv2 + PLAID20 GiB2,509~58 ms GPU0.397~0.50PLAID engine
BM25 + BERT cross-encoder~0.9 GiB + model~11010,700 ms GPU0.3470.476GPU fleet

Some numbers are approximate and depend on the exact checkpoint, hardware, and engine; the orders of magnitude are what the table is for. Read it column by column and the shape of the design space appears.

Storage column: a 20× spread from BM25 to ColBERTv2 and a 170× spread from BM25 to ColBERT v1. Compression collapsed the range, and that collapse is the reason late interaction stopped being exotic.

Latency column: two clusters, separated by two orders of magnitude. Everything precomputed sits at tens to hundreds of milliseconds. The cross-encoder sits alone at ten seconds. There is nothing in between, because there is nothing in between architecturally — either the corpus was encoded offline or it was not.

Quality columns: and here is the interesting disagreement. Ordered by MS MARCO MRR@10, the ranking is ColBERTv2 > SPLADE > ColBERT > cross-encoder > single vector > BM25. Ordered by BEIR average, it is ColBERTv2 ≈ SPLADE > cross-encoder > ColBERT > BM25 > single vector. BM25 moves from last to fifth and DPR falls to the bottom. Chapter 9 is entirely about why.

Cost, in units your finance team recognises

Storage per million documents, at the 73-tokens-per-document assumption, converted to a rough monthly RAM cost at commodity prices (call it $3 per GB-month for in-memory serving):

SystemGB per 1M docs10M docs100M docsMonthly RAM at 100M docs
BM250.111.1 GB11 GB~$33
Single vector fp161.5415.4 GB154 GB~$460
ColBERTv2, 2-bit2.5125.1 GB251 GB~$750
ColBERT v1, fp1618.7187 GB1.87 TB~$5,600

At 100 million documents ColBERTv2 costs about $300 a month more than a single-vector index and about $4,850 a month less than uncompressed ColBERT. Put that next to the cost of one engineer spending a week tuning chunk sizes to work around dilution, and the comparison stops being close.

Throughput, which is not the same question as latency

Latency is what one user waits. Throughput is what your fleet costs. They come apart badly here, and the difference decides architectures.

Take PLAID's 58 ms. Naively that is 1 / 0.058 = 17 queries per second on one GPU, which sounds ruinous. But 58 ms is single-stream latency, and most of it is spent waiting rather than computing: the query encoder is a tiny 32-token batch, and the centroid matmul is memory-bound. Batch eight queries together and almost all of that waiting is shared. Realistic sustained throughput lands nearer 100–150 QPS per accelerator, with latency rising to perhaps 80 ms.

1M queries at 120 QPS = 8,333 GPU-seconds = 2.3 GPU-hours ≈ $3–5 at 2026 spot prices

Now the same arithmetic for the cross-encoder. 10,700 ms per query, batched perhaps 4× more efficiently, call it 2,700 ms of GPU time per query:

1M queries = 750,000 GPU-seconds = 208 GPU-hours ≈ $300–500

A hundredfold difference in serving cost for two systems that score within 0.05 MRR of each other. And BM25, for completeness: roughly 20 ms of one CPU core, so a 16-core machine sustains around 800 QPS and a million queries costs cents.

SystemSingle-query latencySustained QPS per nodeCost per 1M queries
BM2510–40 ms~800 (16-core CPU)cents
Single vector + HNSW10–50 ms~500–2,000 (CPU)cents to ~$1
ColBERTv2 + PLAID58–80 ms~100–150 (GPU)~$3–5
Cross-encoder over 1,0002.7–10.7 s< 1~$300–500

Two lessons hide in that table. The first is that latency and cost rank the systems in the same order but on wildly different scales — a 4× latency gap is a 100× cost gap. The second is that ColBERTv2 is the only row where a GPU is required and the cost is still small enough that most products would not notice it. That is a narrow and valuable position.

Which one should you build?

SituationBuildBecause
Corpus under ~1M docs, queries look like your training data, latency is criticalSingle vector, or single vector + BM25 hybridThe dilution penalty is real but small at this scale, and operational simplicity is worth points
Domain-specific corpus, no training data, queries unlike anything publicBM25 first, then ColBERTv2Chapter 9: this is exactly where single vectors lose to a 1990s algorithm and late interaction does not
Long, multi-aspect documents you cannot cleanly chunkColBERTv2Chapter 0's dilution law is worst here, and MaxSim is invariant to it
You are already running Elasticsearch and cannot add a GPUSPLADEChapter 8: it is a learned model that ships as an inverted index
Quality is everything, latency budget is seconds, candidate set is smallAny retriever + cross-encoder re-rankerAll-to-all interaction is still the quality ceiling
Billions of documentsSingle vector + PQ for stage one, ColBERTv2 for stage twoStorage dominates at that scale; use late interaction as a re-ranker over a few thousand candidates

Notice that last row, because it is the most common production answer and it dissolves the framing of this whole chapter. These are not competitors. ColBERTv2 as a re-ranker over 1,000 candidates costs 0.6 GFLOPs and about 20 ms, gives you most of a cross-encoder's quality, and needs no index of its own if you fetch and encode on the fly. Late interaction is a stage, and you can put it wherever the budget allows.

Hybrid retrieval, and the fusion arithmetic

One more pattern, because it is nearly free and almost always helps: run BM25 and a neural retriever in parallel and fuse the two ranked lists. The standard method is Reciprocal Rank Fusion, which ignores the scores entirely (they are not comparable) and uses only the ranks:

RRF(d) = Σsystems s 1 / (k + ranks(d)),   k = 60 by convention

Take a document ranked 3rd by BM25 and 15th by ColBERT:

RRF = 1/(60+3) + 1/(60+15) = 0.015873 + 0.013333 = 0.029206

against a document ranked 1st by BM25 and nowhere in ColBERT's top list:

RRF = 1/(60+1) + 0 = 0.016393

The second document wins on one system and loses overall. That is the whole design: the constant k = 60 flattens the top of each list so a single system's confident first place cannot outvote broad agreement, which makes the fusion robust to one system being catastrophically wrong on a query. It works because BM25 and neural retrievers fail on different queries — BM25 on paraphrase, neural on rare entities — so their errors are close to uncorrelated.

Your pipeline retrieves 1,000 candidates cheaply and re-ranks them with an expensive cross-encoder. Which first-stage metric is the binding constraint on end-to-end quality?

Chapter 8: SPLADE — The Inverted Index Strikes Back

Chapter 0 pointed out, half as a joke, that BM25 already has the property late interaction is chasing: it stores evidence per term, it never pools, and a query addresses exactly the terms it cares about. This chapter takes that observation seriously, because a whole research line did.

First, respect the data structure

An inverted index maps each term to a posting list of the documents containing it, with a weight per posting. Scoring a query means walking a few posting lists and accumulating. It is worth listing what forty years of engineering bought that structure, because "just use vectors" quietly discards all of it.

PropertyWhat it means in practice
Query cost is independent of corpus sizeCost scales with posting list length, not with N. Adding documents about cooking does not slow down queries about oceanography
Postings compress to 1–2 bytesDelta encoding plus variable-byte or PForDelta. This is why BM25's whole index is under a gigabyte
Dynamic pruning with exact guaranteesWAND and BlockMax-WAND skip entire blocks of postings that provably cannot enter the top k. Not approximate — exact top-k, faster
Trivially shardable and incrementally updatableAdd a document, append to a few posting lists. Compare with re-training or re-clustering a vector index
Runs on a CPUNo accelerator in the serving path. This is an organisational property as much as a technical one
InterpretableYou can print why a document matched. Try that with 128 anonymous dimensions

Against that, BM25 has exactly one weakness: vocabulary mismatch. It matches strings. A query for "how deep" cannot match a passage saying "bathymetric depth", and a passage about "automobiles" is invisible to a query about "cars".

The SPLADE thesis. The inverted index is not the problem. The terms are. So keep the index and learn the terms: let a transformer decide, for every document, which vocabulary entries it should be indexed under and with what weight — including entries that do not appear in its text.

The mechanism, from BERT's own output head

BERT was pretrained with a masked language modelling head: a small network that maps each position's hidden state to a score for every one of the 30,522 wordpieces in the vocabulary. SPLADE reuses it exactly as-is.

wij = transform(hi) Ej + bj,   i = input position,  j = vocabulary term

where transform is the head's dense layer with GELU and LayerNorm, E is BERT's (tied) input embedding matrix, and b is the output bias. For an input of n positions this gives an (n, 30522) matrix of scores — every position's opinion about every vocabulary word.

Now collapse the position axis into a single sparse vector:

wj = Σi=1n log( 1 + ReLU( wij ) )

That is the whole representation. One number per vocabulary entry, most of them exactly zero.

Working it by hand

Take the passage "the mariana trench is deep" and look at what two of its positions vote for. Suppose the MLM head produces these raw scores (a handful of the 30,522 columns):

Vocabulary termfrom position "trench"from position "deep"
trench3.10.4
deep1.82.9
ocean0.91.1
mariana2.40.1
cooking−2.0−3.4

Apply log(1 + ReLU(·)) to each and add down the columns:

trench:  ln(1+3.1) + ln(1+0.4) = 1.4110 + 0.3365 = 1.7475
deep:    ln(1+1.8) + ln(1+2.9) = 1.0296 + 1.3610 = 2.3906
ocean:   ln(1+0.9) + ln(1+1.1) = 0.6419 + 0.7419 = 1.3838
mariana: ln(1+2.4) + ln(1+0.1) = 1.2238 + 0.0953 = 1.3191
cooking: ln(1+0)  + ln(1+0)  = 0 + 0 = 0.0000

Two things to see. First, "ocean" has weight 1.3838 and the word "ocean" is not in the passage. The document will be indexed under "ocean" and will be retrievable by a query containing it. This is learned document expansion, produced by a head that was trained for something else entirely.

Second, "cooking" is exactly zero — not small, zero. ReLU produces true zeros, so the vector is structurally sparse and the term simply has no posting. That distinction matters enormously: a dense vector with small values still costs 4 bytes per dimension. A sparse vector with a structural zero costs nothing at all.

Why log(1 + ReLU(x)) and not something simpler

The two functions are doing two separate jobs and it is worth pulling them apart.

ReLU creates the sparsity. Any term the model thinks does not belong gets a negative score and lands on exactly zero. Without it you would have a dense 30,522-dimensional vector, which is far worse than a 768-dimensional one.

The logarithm creates saturation, and this is where SPLADE quietly rediscovers something BM25 knew in 1994. Consider a term appearing many times. BM25's term-frequency component is

tf · (k1 + 1) / (tf + k1),   k1 = 1.2 typically

Evaluate it at three frequencies:

tf = 1:  1 × 2.2 / 2.2   = 1.000
tf = 5:  5 × 2.2 / 6.2    = 1.774
tf = 20: 20 × 2.2 / 21.2 = 2.075

Twenty occurrences are worth 2.08 times one occurrence, not twenty times. Now the log:

ln(1+1) = 0.693   ln(1+5) = 1.792   ln(1+20) = 3.045

Twenty occurrences are worth 4.4 times one. Different constants, same shape: strongly concave, sharply rewarding the first occurrence, flattening after. Both are answers to the same problem — repetition is weak evidence of extra relevance and strong evidence of a keyword-stuffed document. SPLADE did not copy BM25's formula; it arrived at the same functional form because the underlying statistics of language have not changed.

Making it sparse enough to be fast

Nothing so far forces sparsity beyond what ReLU happens to produce, and a model with no pressure will happily light up thousands of terms. SPLADE adds an explicit regulariser whose form is chosen to approximate retrieval cost rather than to be mathematically tidy:

FLOPS = Σj=1|V| ( āj )²,   āj = (1/N) Σd in batch wj(d)

Read the square. The cost of a term during retrieval is roughly proportional to the probability that it appears in the query times the probability it appears in a document — a product, hence a square when both come from the same distribution. So this penalty charges quadratically for terms that are common in many documents, which is exactly where the long posting lists come from. An L1 penalty would charge linearly and would prefer to shave many rare terms rather than one very common one; the FLOPS penalty gets the priority right.

Two coefficients are used, λq for queries and λd for documents, with the query one larger because the number of query terms multiplies everything downstream. Typical outcomes: tens of non-zero terms in a query, a few hundred in a document, out of 30,522.

Scoring, and why it fits Lucene

s(q, d) = Σj wjq · wjd

A sparse dot product. Which is precisely what an inverted index computes — walk the posting lists for the query's non-zero terms, accumulate the products. The only change from a BM25 deployment is that the weight stored in each posting comes from a neural network instead of from a term-frequency formula. WAND still works. Compression still works. Sharding still works. The CPU still works.

The result. SPLADEv2 reaches MRR@10 = 0.368 on MS MARCO with an index measured in single-digit gigabytes, and SPLADE++ pushes past 0.38. On BEIR it comfortably beats BM25 and single-vector models and sits in the same band as ColBERTv2. A learned model, served by a 1990s data structure.

Scoring a query against a document, by hand

Encode the query "how deep is the mariana trench" the same way and you get a sparse vector of its own — also expanded. Suppose it comes out as:

Termquery weightdocument A weightproduct
deep1.922.39064.5900
trench1.551.74752.7086
mariana1.711.31912.2557
ocean0.611.38380.8441
metres0.331.100.3630
depth0.880 — not in the document's expansion0
how0.1400
challenger01.620
s(q, A) = 4.5900 + 2.7086 + 2.2557 + 0.8441 + 0.3630 = 10.7614

Look at the "ocean" row. That term appears in neither the query text nor the document text. Both sides expanded to it independently, and it contributes 0.84 — nearly 8% of the total score — through a posting list neither text would have entered under BM25. Expansion-to-expansion matching is where a large share of SPLADE's gain over BM25 comes from, and it is completely invisible if you only look at the surface strings.

Now score the same pair with BM25. The document text is "Challenger Deep plunges 10935 metres"; the query's content terms are "deep", "mariana", "trench". Exactly one matches. Two thirds of the query's evidence contributes nothing, and the passage that contains the answer is scored on a single term.

The efficiency problem expansion creates

Every one of those expansion terms is a posting, and postings are what an inverted index walks. Count them.

BM25: ~60 distinct terms per passage × 8.8×106 = 5.3×108 postings
SPLADE: ~200 non-zero terms per passage × 8.8×106 = 1.8×109 postings

Three and a half times the postings is survivable. The problem is which postings. Expansion does not distribute uniformly across the vocabulary — models expand toward common, generic terms, because those are the ones the MLM head finds plausible everywhere. So the posting lists that grow are precisely the ones that were already longest, and query latency is dominated by the longest list you must traverse.

This is what the FLOPS regulariser's square is defending against, and it is why SPLADE's practical story is a whole efficiency literature: static pruning of the fattest lists, impact quantization so postings compress, and guided traversal that uses a BM25 index to bound the search. Left untuned, a learned sparse retriever can be an order of magnitude slower than the BM25 it was meant to replace, while running on the identical data structure.

The general shape of this failure. A model that is free to place mass anywhere will place it where the loss is lowest, and the loss knows nothing about your posting lists. Any time you learn a representation that a classical data structure has to serve, you must put the data structure's cost into the objective — which is exactly what the FLOPS term is. Optimising quality and then hoping the infrastructure copes is how you get a research result that nobody deploys.

ColBERT and SPLADE, side by side

ColBERT / late interactionSPLADE / learned sparse
What is kept per documentn vectors, one per position1 vector, one entry per vocabulary term
Dimensions128, latent, anonymous30,522, explicit, each is a word
SparsityDense within each token vector~1% non-zero
MatchingSoft — cosine in a learned spaceExact term match, over an expanded term set
Handles paraphrase byNearby embeddings ("deep" ↔ "plunges")Expansion (index the document under "deep" too)
Keeps positional information?Yes — one row per positionNo — positions are summed away
Interpretable?NoYes — print the terms and weights
IndexCustom (PLAID), ~20 GiBLucene, ~2–5 GiB
Serving hardwareGPU preferred, CPU viable post-PLAIDCPU
MS MARCO MRR@100.3970.368–0.380

The sharpest row is "keeps positional information". SPLADE aggregates over positions, so the passage's "deep" from "Challenger Deep" and a different passage's "deep" from "deep learning" both become a weight on the same vocabulary entry. Context is not lost entirely — the two passages will expand to different neighbouring terms, which distinguishes them — but the representation is fundamentally a bag of learned words. ColBERT keeps every position separate and pays for it in bytes.

The unification worth carrying away. These two methods look nothing alike and are doing the same two things. Both use a contextual encoder to decide what a document is about. Both refuse to commit to a single summary, keeping instead a structure that a query can address selectively — ColBERT by position, SPLADE by vocabulary entry. The quality gains over BM25 come from the encoder. The efficiency comes from the addressable structure. Whether that structure is a dense multi-vector array or a sparse inverted index is an infrastructure decision, and the empirical scores say it is close to a wash.

Where each one still hurts

MethodThe failure you will actually hitMitigation
SPLADEExpansion terms include common words, producing very long posting lists and CPU latencies that can exceed BM25's by 10×Stronger FLOPS regularisation, static pruning, impact quantization, guided traversal
SPLADEVocabulary is fixed at BERT's 30,522 wordpieces; a domain term that tokenises into fragments is represented badlyDomain-adapted tokenizer, or continued pretraining
ColBERTv2Index is still 4–10× a single-vector index; and updates require re-clustering when the corpus driftsPeriodic re-indexing; use as a re-ranker over a cheaper first stage
ColBERTv2Queries longer than 32 wordpieces are truncated — a hard structural limitRaise Nq at the cost of latency, or split long queries
BothNeither can express "not" or logical structure; both are similarity accumulatorsFilters and metadata predicates outside the scorer
SPLADE's document vector has a non-zero weight on the term "ocean" for a passage that never contains the word. Where does that weight come from, and why is it valuable?

Chapter 9: Why Late Interaction Travels

Every number in this lesson so far came from MS MARCO. That is the dataset the models were trained on, and reporting quality on your training distribution is the weakest possible evidence about a retriever, because the entire promise of a retriever is that you point it at your corpus with your queries.

In 2021, BEIR (Thakur et al.) made this measurable: eighteen retrieval datasets across nine task types — fact checking, biomedical search, question answering, argument retrieval, duplicate detection, entity retrieval, citation prediction — all evaluated strictly zero-shot. Train on MS MARCO, evaluate everywhere else, report nDCG@10.

The result that reset the field

SystemMS MARCO MRR@10 (in domain)BEIR average nDCG@10 (zero-shot)
BM250.187 (last place)0.440
DPR~0.3110.375 — below BM25
ANCE0.3300.405 — below BM25
TAS-B0.3470.415 — below BM25
ColBERT0.3600.453 — above BM25
BM25 + cross-encoder re-rank0.3470.476

Three dense single-vector models, each of which beats BM25 by 12 to 16 MRR points on MS MARCO, lose to it on average across eighteen other datasets. A scoring function from the 1990s with no learned parameters beat the neural retrievers on the task the neural retrievers were sold for.

ColBERT, trained on exactly the same data with exactly the same encoder, does not lose. That is the observation this chapter has to explain.

Mechanism 1 — the summary was written for the wrong reader

Return to Chapter 0's bargain. A single-vector model must compress a passage into 768 numbers at index time. Compression is only possible because some information is discarded, and which information gets discarded is decided by the training objective.

Train on MS MARCO and the objective is: be discriminative for short, factoid, web-search queries typed by people using Bing. The encoder learns, correctly, that for that query distribution the important thing about a passage is its topical gist and its named entities, and that fine distinctions between claim types, methodological details, or numerical values rarely decide a MS MARCO ranking. It compresses along those axes.

Now point it at SciFact, where the query is a scientific claim and the task is to find the abstract that supports or refutes it. Or FiQA, where the query is a financial question and relevance turns on the exact instrument being discussed. The dimensions the encoder learned to discard are precisely the ones that now carry the signal.

And it cannot be fixed at query time. This is the part that makes it structural rather than a tuning problem. The information was destroyed at index time, before the new query distribution existed. No clever prompting, no query rewriting, no re-ranking of the retrieved set can recover a distinction that is not present in the stored vector. The only fix is to re-encode the corpus with a better encoder — which is exactly what the 2023-era instruction-tuned embedders did, at the cost of orders of magnitude more training data.

Mechanism 2 — late interaction degrades into term matching

The second mechanism is more specific, and it is a lovely piece of reasoning about what happens to an embedding model outside its training vocabulary.

Suppose a BEIR query contains a token the model has essentially never trained on — a drug name, a protein, a ticker symbol, an obscure API. What does BERT produce at that position? The contextual embedding is built from the input wordpiece embedding plus position plus whatever the surrounding context contributes. For a token the model has no learned semantics for, the input embedding term dominates and the output is largely a function of the surface form.

Now consider a document containing the same rare token. Its contextual embedding is built the same way, from the same input embedding, in a broadly similar context. The two vectors will be close — not because the model understands the term, but because it is the same wordpiece running through the same network.

MaxSim gives that query token its own row, and its row max finds the document token with the same surface form, scoring near 1. The rare term contributes a full unit of evidence to the sum. Late interaction degrades gracefully into exact term matching exactly where semantics fail. It inherits BM25's robustness for free, without a single line of lexical code.

A pooled vector cannot do this. The rare token is one of n contributions to an average, so it moves the summary vector by roughly 1/n — about 1.4% for a 73-token passage — and that displacement is then compared against the query's own averaged displacement. The signal survives, technically, at a strength of about one part in seventy against everything else in the document.

Concept check. Does this mean ColBERT is just BM25 with extra steps?  …  No, and the distinction is worth precision. BM25 only does exact matching; ColBERT does exact matching as a limiting case of soft matching. Chapter 2's matrix showed both behaviours in one grid: "deep" matched "plunges" at 0.9925 (pure semantics, BM25 scores this 0) while a rare shared token would match itself at nearly 1.0 (pure lexicality, which a pooled vector dilutes). The architecture spans the two regimes and picks per token, per query, at query time. That is the whole trick, and it is why the BEIR column moves.

Mechanism 3 — addressable capacity

The simplest argument, saved for last because it is the least interesting on its own. A 73-token passage at 128 dimensions is 9,344 numbers against a single vector's 768: about 12× the capacity.

But capacity alone explains nothing — you could give DPR 9,344 dimensions and it would not reach ColBERT's BEIR score. What matters is that the capacity is addressable: organised so that a query token can read the part it needs without being averaged against the rest. A 9,344-dimensional pooled vector is still one summary. Seventy-three 128-dimensional vectors are seventy-three separately retrievable pieces of evidence. Structure beats size.

Dataset by dataset, including where it loses

BEIR zero-shot, per dataset

nDCG@10 as reported in the BEIR paper, on a representative subset. Toggle to in-domain MS MARCO to see the ordering reverse. Values are approximate to the second decimal and vary by checkpoint; the pattern is the point.

DatasetWhat it asksBM25DPRColBERTCross-encoder
TREC-COVIDBiomedical search on COVID literature0.6560.3320.6770.757
SciFactVerify a scientific claim from abstracts0.6650.3180.6710.688
FiQAFinancial opinion question answering0.2360.1120.3170.347
QuoraDuplicate question detection0.7890.2480.8540.825
DBPediaEntity retrieval0.3130.2630.3920.409
HotpotQAMulti-hop question answering0.6030.3910.5930.707
NFCorpusMedical information retrieval0.3250.1890.3050.350
ArguAnaRetrieve a counter-argument0.3150.1750.2330.311
Touché-2020Argument retrieval on debate topics0.3670.1310.2020.271

Look at the DPR column all the way down. It loses to BM25 on every single row, often catastrophically — 0.112 against 0.236 on FiQA, 0.248 against 0.789 on Quora. That is not a model that generalises weakly; that is a model whose representation is specific to the distribution it was fit on.

Where late interaction loses, and exactly why

Two rows deserve mechanistic explanations rather than shrugs, because a lesson that only explains the wins is advertising.

ArguAna (0.233 vs BM25's 0.315). The task: given an argument, retrieve its best counter-argument. Two structural problems. First, ArguAna's queries are entire paragraphs, and ColBERT truncates queries at 32 wordpieces — most of the query is thrown away before scoring. That is not a subtle modelling issue, it is a hard architectural limit being hit head-on. Second, MaxSim rewards similarity, and a counter-argument is topically similar but stance-opposite; nothing in the objective distinguishes agreement from disagreement.

Touché-2020 (0.202 vs BM25's 0.367). Long argumentative documents, truncated at ColBERT's 180-token document limit, on a task where relevance means argument quality rather than topical match. Also, Touché is known to have shallow and idiosyncratic judgements that happen to favour long documents — which BM25's length normalisation, tuned over decades on exactly this kind of collection, handles well.

Both failures share a root: a fixed window. 32 query positions and 180 document positions were chosen for MS MARCO's short queries and short passages. Out of domain, those constants stop being generous defaults and start being the model.

The honest 2026 footnote. BEIR's headline — "dense retrieval loses to BM25 out of domain" — was a statement about the single-vector models of 2021, trained on MS MARCO alone. Modern single-vector embedders trained on hundreds of millions of diverse pairs with careful instruction tuning close most of that gap and beat BM25 comfortably on BEIR. The mechanism in this chapter survives unchanged: pooling still destroys addressability, and a single vector is still a summary written before the question. What changed is that a wide enough training distribution can teach the summary to keep the aspects that matter across many domains. Late interaction gets that robustness structurally, for free, from a much smaller training run — which is why a 33M-parameter late-interaction model can still hold its own against embedders many times its size.

The principle, stated so it outlives the paper

Strip away retrieval and the finding is about system design in general.

Decisions made early are made in ignorance
Pooling decides which aspects of a document matter at index time — before the query, and therefore before the distribution the system will actually face. Every such decision is a bet on the training distribution.
Deferred decisions cost storage
Keeping every token's vector means keeping 73× the data. Deferring is never free; the currency is space, and sometimes latency.
And storage is the cheapest thing to buy back
Chapters 5 and 6: residual compression bought back 7.45× and centroid pruning bought back the latency. Quality lost to a premature decision cannot be bought back at all.

That asymmetry is the whole lesson. Late interaction wins the generalisation argument not because MaxSim is clever — it is three lines of tensor code — but because it declines to commit. The engineering work of ColBERTv2 and PLAID is then entirely about making the cost of not committing affordable, and it turns out that cost is compressible while lost information is not.

Where the idea went

DirectionWhat it took from ColBERTWhat it did with it
ColBERTv2 + PLAIDThe architecture, unchangedDistillation, residual compression, centroid-pruned serving. 154 GiB → 20 GiB, seconds → 58 ms
Small late-interaction modelsThe MaxSim operator33M-parameter checkpoints that match single-vector models an order of magnitude larger on BEIR — the inductive bias substitutes for scale
ColPali and visual late interactionMaxSim over any bag of vectorsEncode a document page image into patch embeddings with a vision-language model and MaxSim against query tokens. No OCR, no layout parsing — the page is the index
Fixed-dimensional encodings (MUVERA and successors)The scoring function as a targetMap a multi-vector set to a single long vector whose inner product approximates MaxSim, so ordinary ANN infrastructure can serve late interaction
Multilingual and domain variantsThe whole recipeLate interaction transfers across languages and specialised corpora for the same reason it transfers across BEIR
Learned sparse (Chapter 8)The premise, independentlyDeferred, addressable representations served by an inverted index instead of a vector store

The ColPali row is the one that shows the idea was never about text. MaxSim needs two bags of vectors and nothing else. Give it image patches instead of wordpieces and it retrieves pages by their visual content, because "keep one vector per piece and let the query choose" was never a statement about language.

Connections

Where to go from here on this site:

If you want…Go to
The encoder underneath all of this, from zeroBERT — masked language modelling, the [CLS] token, and the MLM head that Chapter 8 reuses
How contrastive training shapes an embedding spaceSimCSE — in-batch negatives, temperature, alignment and uniformity
What retrieval is forRAG — the paper that made retrieval a component of generation
The index that stores all these vectorsVector Databases — HNSW, IVF, product quantization, the structures PLAID competes with
Embeddings from first principlesVector Embeddings
How retrieval quality is measured and gamedEmbedding Benchmarks — MTEB, BEIR, and what the leaderboards hide
Putting it together in a production pipelineRAG (Gleam) and Multimodal RAG

The cheat sheet

SymbolMeaningValue in ColBERT
EqQuery embedding matrixR32×128, L2-normalised rows
EdDocument embedding matrixRn×128, n ≤ 180, punctuation removed
NqFixed query length after augmentation32
mProjection dimension128 (ablated at 96, 48, 24)
Sq,dMaxSim scoreSum over 32 rows of the row max; range [−32, 32]
CNumber of k-means centroids≈ 16√Nemb, rounded to a power of two (219 for MS MARCO)
bResidual bits per dimension2 (or 1)
tcsCentroid pruning threshold (PLAID)≈ 0.4–0.5
nprobeCentroids probed per query token1–4
ndocsCandidates surviving centroid interaction256 / 1024 / 4096 for k = 10 / 100 / 1000
wPassages sampled per query for distillation64

The four equations, in order.

1. Encode:   E = Normalize( BERT([Q or D] …) · W ),  W ∈ R768×128
2. Score:    Sq,d = Σi maxj Eqi · Edj
3. Train:    L = KL( softmax(teacher) ‖ softmax(S) ) + CE(in-batch)
4. Store:    v → ( argmint ‖v − ct‖ , quantize2-bit(v − ct) )

References

  1. Khattab, O. & Zaharia, M. "ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT." SIGIR 2020. arXiv:2004.12832
  2. Santhanam, K., Khattab, O., Saad-Falcon, J., Potts, C. & Zaharia, M. "ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction." NAACL 2022. arXiv:2112.01488
  3. Santhanam, K., Khattab, O., Potts, C. & Zaharia, M. "PLAID: An Efficient Engine for Late Interaction Retrieval." CIKM 2022. arXiv:2205.09707
  4. Formal, T., Piwowarski, B. & Clinchant, S. "SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking." SIGIR 2021. arXiv:2107.05720
  5. Formal, T., Lassance, C., Piwowarski, B. & Clinchant, S. "SPLADE v2: Sparse Lexical and Expansion Model for Information Retrieval." 2021. arXiv:2109.10086
  6. Formal, T. et al. "From Distillation to Hard Negative Sampling: Making Sparse Neural IR Models More Effective." SIGIR 2022. arXiv:2205.04733
  7. Karpukhin, V. et al. "Dense Passage Retrieval for Open-Domain Question Answering." EMNLP 2020. arXiv:2004.04906
  8. Thakur, N., Reimers, N., Rücklé, A., Srivastava, A. & Gurevych, I. "BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models." NeurIPS Datasets 2021. arXiv:2104.08663
  9. Nogueira, R. & Cho, K. "Passage Re-ranking with BERT." 2019. arXiv:1901.04085
  10. Qu, Y. et al. "RocketQA: An Optimized Training Approach to Dense Passage Retrieval for Open-Domain Question Answering." NAACL 2021. arXiv:2010.08191
  11. Xiong, L. et al. "Approximate Nearest Neighbor Negative Contrastive Learning for Dense Text Retrieval" (ANCE). ICLR 2021. arXiv:2007.00808
  12. Hofstätter, S. et al. "Efficiently Teaching an Effective Dense Retriever with Balanced Topic Aware Sampling" (TAS-B). SIGIR 2021. arXiv:2104.06967
  13. Faysse, M. et al. "ColPali: Efficient Document Retrieval with Vision Language Models." 2024. arXiv:2407.01449
  14. Devlin, J., Chang, M.-W., Lee, K. & Toutanova, K. "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding." NAACL 2019. arXiv:1810.04805
  15. Nguyen, T. et al. "MS MARCO: A Human Generated MAchine Reading COmprehension Dataset." 2016. arXiv:1611.09268
  16. Robertson, S. & Zaragoza, H. "The Probabilistic Relevance Framework: BM25 and Beyond." Foundations and Trends in Information Retrieval, 2009.
  17. Cormack, G. V., Clarke, C. L. A. & Buettcher, S. "Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods." SIGIR 2009.
DPR beats BM25 by 12 MRR points on MS MARCO and loses to it on the BEIR average. What is the mechanism?