AI Harness Engineering

Embedding Benchmarks

You picked the number-one model on the leaderboard and your search got worse. This lesson explains exactly how that happens — what MTEB and BEIR actually measure, how every retrieval metric is computed by hand, why a benchmark average is the wrong number to sort on, and how to build the small honest evaluation that would have caught the regression before you shipped it.

Prerequisites: an embedding is a list of numbers for a piece of text + cosine similarity ranks things. Every metric, every formula, every benchmark is derived here from zero.
10
Chapters
9
Simulations
0
Assumed Knowledge

Chapter 0: The Leaderboard Trap

It is Tuesday. You own search for a developer documentation site — forty thousand pages of guides, API references, changelogs and error-code explanations. Search is embedding-based: every page chunk is turned into a vector, every query is turned into a vector, and the top ten chunks by cosine similarity get handed to a language model that writes the answer.

Last week you noticed your embedding model was eighteen months old. So you did the obvious, responsible, professional thing. You opened the public embedding leaderboard, sorted by the headline average, took the model at the top, re-embedded all forty thousand chunks over a weekend, and shipped it on Monday.

On Tuesday the support channel starts filling up. Not with “search is broken” — that would be easy. With the far more corrosive version: “it used to find this.”

The specific complaints, because specifics are the whole lesson. A query for ERR_TLS_CERT_ALTNAME_INVALID no longer returns the page that is literally titled ERR_TLS_CERT_ALTNAME_INVALID. A query for “v4.2 breaking changes” returns the v4.0 and v5.1 changelogs but not v4.2. A query for “webhook retries” returns a beautiful conceptual essay about idempotency and not the configuration table anyone actually wanted.

You measure. You had a small internal test set — three hundred real queries pulled from your logs, each with a hand-marked correct page. On the old model, the correct page was in the top ten for 71% of them. On the new model: 63%. You have made your product measurably worse by choosing the objectively better model.

Meanwhile the leaderboard is not lying. The old model scores 62.3 on the headline average. The new one scores 66.8. That is a large, real, reproducible gap. Both facts are true at the same time, and understanding how is the point of this entire lesson.

What the headline average actually is

The number you sorted on is a mean over datasets — not over tasks, not over your tasks, not over anything you chose. The English portion of MTEB (the Massive Text Embedding Benchmark, the standard public evaluation suite for text embedding models) aggregates 56 datasets. They are not distributed the way you would guess:

Task familyDatasetsShare of the headline average
Classification1212 ÷ 56 = 21.4%
Clustering1111 ÷ 56 = 19.6%
Retrieval1515 ÷ 56 = 26.8%
STS (sentence similarity)1010 ÷ 56 = 17.9%
Reranking44 ÷ 56 = 7.1%
Pair classification33 ÷ 56 = 5.4%
Summarization11 ÷ 56 = 1.8%

Check the arithmetic yourself: 12 + 11 + 15 + 10 + 4 + 3 + 1 = 56, and the shares sum to 100%. Now read the bold row. You run a search engine. Search is retrieval. Retrieval is 26.8% of the number you sorted on. The other 73.2% of that number is measuring things you do not do — whether a linear classifier can be fit on top of the embeddings, whether k-means recovers newsgroup categories, whether cosine similarity correlates with human judgments of sentence similarity.

Those are all reasonable things to measure. They are just not your thing. Sorting by a number that is 73% irrelevant to you is not a subtle statistical error — it is the same mistake as choosing a car by the average of its top speed, its cargo volume, and its cup-holder count.

State the mistake precisely

Here is the failure written as an equation, because once you see it written down you cannot un-see it. A leaderboard reports, for each model m, an estimate of

scorebench(m) = average over tasks t drawn from Dbench of   score(m, t)

where Dbench is the distribution of tasks the benchmark authors chose to include. What you actually care about is a single number:

score(m, tyours)   for the one task tyours that you are paid to make work

Ranking models by the first quantity to choose one for the second is valid only if your task behaves like a typical draw from Dbench. Your docs site has short keyword-shaped queries full of error codes, version numbers and API method names, over a corpus of forty thousand technical chunks written by one company in one voice. Nothing in Dbench looks like that.

The one sentence to keep. A benchmark does not tell you which model is best. It tells you which model is best on average over the benchmark's own distribution of tasks. Whether that is informative about your task is an empirical question about the distance between your task and theirs — and it is a question the leaderboard cannot answer for you, because it has never seen your corpus.

How much does the leaderboard tell you? Put a number on it

“Not perfectly correlated” is vague. Let us make it concrete with a quantity you can actually estimate: the rank correlation between benchmark score and your-task score, measured across a set of candidate models.

Suppose you take twelve candidate models, note each one's leaderboard average, and also measure each one on your three hundred real queries. Now you have twelve pairs of numbers. If the two orderings agree perfectly, the rank correlation is 1.0 and the leaderboard is a perfect proxy for you — just take the top one. If the correlation is 0.0, the leaderboard is a coin flip and you have wasted a weekend of GPU time.

Real measurements of this quantity, for a specific narrow domain against a broad benchmark, tend to land somewhere in the middle — positive, useful, and nowhere near 1.0. The practical consequence is stark: the leaderboard's top model is usually somewhere in your top half, and almost never your number one. Play with the widget below and watch what happens to the leaderboard winner's rank on your task as the two distributions drift apart.

The leaderboard’s winner versus your winner

Each dot is a candidate embedding model. Horizontal axis: its public benchmark average. Vertical axis: nDCG@10 on your three hundred queries. Slide “task similarity” from 1.0 (your task is a typical benchmark task) down to 0.0 (your task is nothing like the benchmark) and watch the cloud tilt. The orange ring is the model the leaderboard tells you to pick; the teal ring is the model that is actually best for you.

task similarity to benchmark0.75

Three things the benchmark cannot know

The gap between the leaderboard and your reality is not mysterious. It decomposes into exactly three mismatches, and every later chapter is really about one of them.

1. Your corpus. Benchmark corpora are Wikipedia, scientific abstracts, web pages, forum posts, product reviews. Yours is forty thousand chunks of one company's technical writing, dense with tokens like ERR_TLS_CERT_ALTNAME_INVALID that appeared approximately zero times during the embedding model's pretraining. A token the model has barely seen has a near-arbitrary direction in embedding space, so the similarity it produces is near-arbitrary too. Chapter 2 makes this mechanical.

2. Your queries. Benchmark retrieval queries are mostly natural-language questions — the shape of things people type into a general web search box. Yours are half questions and half fragments: pasted error strings, version numbers, half-remembered method names. A model trained to map questions near answers was never asked to map fragments near the page containing that fragment.

3. Your definition of relevant. This one is invisible and it is the most damaging. On your site, if a user pastes an error code, the relevant page is the one about that error code — not the three conceptually adjacent pages about TLS. The benchmark's notion of relevance was decided by its own annotators for its own purposes. Chapter 7 is entirely about writing your definition down.

Think of it this way. A benchmark is a standardized exam. A model's score tells you it is a good test-taker on that exam. Your production system is a job. Exam scores correlate with job performance — that is why exams exist — but nobody sane hires strictly by exam rank without an interview, and your interview is a three-hundred-query evaluation set you build yourself in an afternoon.

What went wrong, in one trace

Before we build any theory, let us close the loop on Tuesday's actual bug, because it is completely explainable with what you already know.

Your old model was small and had been fine-tuned on question-and-answer pairs where queries were often short and lexical. Your new model is larger, trained on far more data with heavy emphasis on semantic similarity — the kind of objective that makes STS and clustering scores go up. Semantic similarity training rewards abstraction: it pushes the model to represent “what this text is about” and to discard surface detail, because two paraphrases must land in nearly the same place.

For the ten datasets in the STS family, that is exactly right and it is why the score improved. For a user who pastes an exact error string, it is exactly wrong. The model has been trained to consider ERR_TLS_CERT_ALTNAME_INVALID and “a TLS certificate hostname problem” to be nearly the same point — so the page about the second one competes on equal footing with the page about the first. The abstraction that earned four leaderboard points cost you eight points of top-ten accuracy.

Nothing here is a bug. The model is doing what it was trained to do; the benchmark is measuring what it says it measures; the leaderboard is sorting correctly. Every component is behaving. The error is entirely in the inference you drew — that a higher number on their distribution implies a higher number on yours.

What this lesson gives you

By the end you will be able to do five things you cannot do right now.

SkillChapter
Read a benchmark's task list and say what geometric property of the embedding space each family actually tests1
Explain why a strong in-domain retriever collapses out of domain, and why a 1990s keyword algorithm still beats it on some corpora2
Compute nDCG@10, recall@k, MRR, MAP and Spearman by hand, from the definitions, without a library3–4
Look at a leaderboard row and immediately name the three confounds and the noise floor5–6
Build a 300-query evaluation set with a written judgment protocol and a measured agreement number7

And one habit, which matters more than all five: never adopt a retrieval model on someone else's number. The eval you build in Chapter 7 takes about a day. The regression you shipped on Monday cost you a week of trust. Chapter 8 is a playground where you can drag documents around a ranking and watch the metrics respond until the arithmetic is in your fingers.

What the mistake actually cost, in hours

Eight points of top-ten accuracy is an abstraction. Convert it into the unit your manager thinks in, because this arithmetic is what buys you the week you are about to spend building an evaluation set.

Your docs site handles about twelve thousand searches a week. The top-ten hit rate moved from 71% to 63%, so the failure rate moved from 29% to 37%:

extra failed searches = (0.37 − 0.29) × 12,000 = 0.08 × 12,000 = 960 per week

Not every failed search becomes a support ticket — most people rephrase, and some give up quietly. Say 22% of failed searches eventually produce a ticket:

960 × 0.22 = 211 extra tickets per week

And each ticket costs somebody twelve minutes to read, reproduce and answer:

211 × 12 min = 2,532 min = 2,532 ÷ 60 = 42.2 hours per week

Forty-two hours a week is one full-time person, created out of nothing by a weekend of re-embedding. Over a year it is 42.2 × 52 ≈ 2,194 hours. Set that against the cost of the evaluation set you will build in Chapter 7 — about forty hours, once — and the decision stops being a judgment call.

Keep this chain of multiplications. Retrieval metric → failed searches → tickets → hours → money. Every link is a number you can measure or estimate at your own company, and the chain is the only thing that makes a two-point nDCG argument legible to somebody who does not know what nDCG is. It is also how you justify the eval budget before the regression rather than after it.

The counterfactual: what should have happened

Rewind to Friday. Same engineer, same candidate model, same weekend. Here is the version where nothing breaks, and notice that it is not more work — it is the same work in a different order.

What you didWhat you should have done
Sorted the leaderboard by the headline averageRead the retrieval column only, and its minimum rather than its mean
Took the top rowTook three plausible rows as a shortlist
Re-embedded 40,000 chunks for the winnerEmbedded the 300 evaluation queries and their pooled candidates — a few thousand chunks, twenty minutes
Shipped Monday, measured Tuesday from the support channelMeasured Friday afternoon, shipped nothing, kept the old model

The expensive step — re-embedding the whole corpus — is the step you only take after the measurement says to. Everything in this lesson is really about earning the right to skip that step cheaply.

Three ways people talk themselves back into the trap

This mistake is not made by careless engineers. It is made by careful ones holding a reasonable-sounding argument. Here are the three arguments and where each one fails.

“It is the best average model, so on average it should be better for us too.” This confuses an average over tasks with an expectation over your task. Averages are only predictive of a new draw when the new draw comes from the same distribution. Your corpus is not a random draw from the benchmark's corpora — it is one specific, unusual point that you chose by having a business.

“It beats our current model on 51 of 56 datasets, so the odds are with us.” Closer, and still wrong in a specific way: the five it loses on are not random. They are the datasets whose relevance relation and vocabulary differ most from the training mixture, which is exactly the property your technical corpus has. The losses cluster on the cases that look like you.

“We do not have time to build an evaluation; we will monitor after shipping.” This one is about cost, so answer it with cost. Monitoring after shipping is what happened here: it cost a week of trust and the forty-two hours per week computed above until someone rolled it back. The evaluation is one person-week once. Post-hoc monitoring is not cheaper than measurement; it is measurement with the failure already paid for.

The steelman, because it exists. If you have no evaluation and no time to build one, taking a well-regarded model from the top of the retrieval column is a perfectly reasonable default — better than guessing. The error is not using the leaderboard. The error is treating a shortlist as a decision, and then re-embedding forty thousand chunks on the strength of it without ever measuring the result.
Your new model scores 4.5 points higher on the benchmark average and 8 points worse on your own three hundred queries. Which explanation is structurally correct?

Chapter 1: What a Benchmark Actually Measures

Chapter 0 left you with an uncomfortable fact: 73% of the number you sorted on was measuring something other than search. To use a benchmark well you have to know what each part of it is measuring — not by its name, but by what it does to your vectors.

That is the framing for this chapter. Every MTEB task family is a small program that takes your model's embeddings and produces a score. If you know the program, you know the property. And once you know the property, you can predict which families will and will not correlate with your job.

The reframe that makes the rest easy. A task family is not a topic, it is a probe — a fixed procedure applied to the frozen embedding matrix. Classification fits a linear model on top. Clustering runs k-means. STS takes a cosine and correlates. Retrieval does a nearest-neighbour search over a whole corpus. Four probes, four different geometric demands, one embedding space that has to satisfy all of them at once.

The eight families, and the one concrete thing each one does

MTEB defines eight task types. Seven appear in the English benchmark; the eighth, bitext mining, is inherently multilingual. Here is each one as a procedure, with a real dataset and the metric that gets reported.

1. Classification — can a linear probe find the label?

The procedure. Embed a labelled training split and a labelled test split. Freeze those vectors. Fit a logistic regression — a single linear layer followed by a softmax — on the training vectors. Report accuracy on the test vectors.

A concrete example. Banking77: 13,083 customer-service utterances from a banking chatbot, labelled with 77 fine-grained intents (“card_arrival”, “lost_or_stolen_card”, “exchange_rate”). The question being asked is: are the embeddings of “my card hasn't arrived” and “where is my new card” on the same side of some hyperplane that separates them from the other 76 intents?

python
# the classification probe, in five lines — this is essentially all of it
X_train = model.encode(train_texts)          # (10_003, 768) float32
X_test  = model.encode(test_texts)           # ( 3_080, 768) float32
clf = LogisticRegression(max_iter=1000).fit(X_train, y_train)
acc = (clf.predict(X_test) == y_test).mean()   # the reported number

What property this rewards. Linear separability, and nothing else. Notice what is absent: no distance is ever computed. The probe never asks whether two same-label points are close, only whether a hyperplane can be drawn.

The consequence almost nobody states out loud. Take a model's embeddings and multiply every vector by any invertible matrix A — rotate them, stretch one axis by a thousand, shear them. The logistic regression probe finds an equally good solution, because if w was optimal before, A-Tw is optimal after; the decision boundaries are the same sets of points. So classification accuracy is invariant to invertible linear transforms (exactly, up to the regularization term). Cosine similarity is destroyed by them. A model can therefore be excellent at classification and terrible at STS and retrieval. This is not a hypothetical — it is the mathematical reason a benchmark average is not a single quantity.

2. Clustering — does unsupervised structure match human labels?

The procedure. Embed a set of documents that carry hidden gold labels. Run mini-batch k-means with k set to the number of gold classes. Compare the resulting partition to the gold partition with V-measure, a score from 0 to 1 that averages how pure each cluster is (homogeneity) and how little each gold class is split apart (completeness).

A concrete example. ArxivClusteringP2P: arXiv paper title-plus-abstract strings, gold label = the arXiv category. Does k-means on the embeddings recover “cs.CL” versus “math.PR” versus “q-bio.NC”?

What property this rewards. Global isotropic topical structure. k-means uses Euclidean distance to centroids, which means it wants clusters that are roughly spherical and roughly equal in scale. An embedding space where all vectors crowd into a narrow cone — a real and well-documented pathology called anisotropy — can still be linearly separable (so classification is fine) while being terrible for k-means, because every pairwise distance is nearly the same.

3. Pair classification — is one threshold enough?

The procedure. You get pairs of texts and a binary label (duplicate / not duplicate). Compute the cosine similarity of each pair. Sweep every possible threshold and report the best achievable score — MTEB's main metric here is average precision computed from the cosine scores.

A concrete example. SprintDuplicateQuestions: pairs of questions from a technical support forum, labelled as asking the same thing or not.

What property this rewards. A globally calibrated similarity scale. Because a single threshold must work for the whole dataset, a model that is locally correct but whose “duplicate” cosine is 0.9 for one topic and 0.6 for another will score poorly even though it never confuses a pair within a topic. Retrieval, by contrast, only ever compares scores within one query — so retrieval does not care about global calibration at all. Same embeddings, opposite demands.

4. Reranking — order a short candidate list

The procedure. For each query you are handed a small list of candidate documents, some marked relevant and some not. Sort the list by cosine similarity to the query. Report mean average precision (and MRR) over queries. There is no corpus-wide search; the candidates are given.

A concrete example. AskUbuntuDupQuestions: for a given Ubuntu forum question, rank twenty candidate questions so that the true duplicates come first.

What property this rewards. Local ordering among plausible candidates. This is the hardest kind of discrimination — every candidate is already topically related, so the easy wins are gone. It is also the closest family to what a reranker does in a production RAG stack, which makes it a better proxy for that stage than the retrieval family is.

5. Retrieval — find the needle in the whole corpus

The procedure. Embed an entire corpus (thousands to millions of passages). Embed each query. Compute similarity between every query and every document, take the top k, and score with nDCG@10. In MTEB, this family is BEIR — the retrieval datasets are drawn from that benchmark, which Chapter 2 covers in detail.

python
# the retrieval probe — note the shapes, they are the whole story
D = model.encode(corpus)            # (5_183_000, 768) — this is 15 GB in float32
Q = model.encode(queries)           # (    6_980, 768)
D = D / np.linalg.norm(D, axis=1, keepdims=True)   # normalize -> dot == cosine
Q = Q / np.linalg.norm(Q, axis=1, keepdims=True)
for chunk in batches(Q, 64):
    S = chunk @ D.T                    # (64, 5_183_000) — 331M floats per batch
    top = np.argpartition(-S, 10, axis=1)[:, :10]

What property this rewards. Asymmetric query-to-document matching at scale. Two things make this different from every other family. First, the query and the document are not the same kind of text: a seven-word question must land near a two-hundred-word passage, so the geometry is asymmetric by design. Second, the competition is enormous — a correct document must beat five million distractors, so the metric is exquisitely sensitive to the tail of the similarity distribution in a way that a twenty-candidate reranking task never is.

6. STS — agree with human similarity judgments

The procedure. Each item is a sentence pair with a human similarity score, traditionally on a 0–5 scale. Compute cosine similarity for each pair. Report the Spearman rank correlation between your cosines and the human scores.

A concrete example. STSBenchmark: “A man is playing a guitar” / “A man plays a guitar” scores 4.8; “A man is playing a guitar” / “A woman is slicing an onion” scores 0.2.

What property this rewards. Monotone agreement with human paraphrase judgment in the symmetric, short-text, near-neighbour regime. Every pair is two sentences of comparable length and register. Nothing about this predicts behaviour on a seven-word query against a two-hundred-word passage — and that is precisely the gap that bit you in Chapter 0.

7. Summarization — the odd one out

The procedure. For each source document you have several machine-written summaries, each with a human quality rating, and several human-written reference summaries. Score each machine summary by its similarity to the references, then correlate those scores with the human ratings using Spearman.

A concrete example. SummEval, and in the English benchmark it is the only dataset in its family — one dataset carrying 1.8% of the headline average all by itself, with correspondingly enormous variance. Treat this column as noise unless you have a specific reason not to.

8. Bitext mining — the multilingual family

The procedure. Given a set of sentences in language A and a set in language B, find for each A-sentence its true translation in B by nearest neighbour. Report F1.

A concrete example. Tatoeba, across 100-plus language pairs. What it rewards is a shared cross-lingual space — the property that “the cat sat on the mat” and “le chat s'est assis sur le tapis” occupy nearly the same point. Purely English benchmarks cannot see this property at all, which is why an English-only average tells you nothing about whether a model will survive a multilingual corpus.

Task-family radar: three models, one average, three different shapes

Seven axes, one per English task family; each axis is scaled to the plausible range for that family, so a bigger polygon means a better model on that axis. Step through the models and watch the headline average — the dataset-weighted mean over all 56 datasets — barely move while the shape changes completely. Then turn on “overlay all” and look at where they actually differ.

modelA

The average, computed by hand

The widget claims two models with different shapes land on nearly the same headline number. Do not take its word for it — compute one. Model A's per-family means are: classification 76.0, clustering 46.0, pair classification 86.0, reranking 58.0, retrieval 52.0, STS 84.0, summarization 30.0. The headline average is a mean over datasets, so each family contributes with its dataset count as weight:

12×76.0 = 912    11×46.0 = 506    3×86.0 = 258    4×58.0 = 232
15×52.0 = 780    10×84.0 = 840    1×30.0 = 30
total = 912 + 506 + 258 + 232 + 780 + 840 + 30 = 3,558  →  3,558 ÷ 56 = 63.54

Now Model B: classification 73.0, clustering 44.0, pair classification 84.0, reranking 60.0, retrieval 56.0, STS 81.0, summarization 29.0.

12×73 = 876    11×44 = 484    3×84 = 252    4×60 = 240
15×56 = 840    10×81 = 810    1×29 = 29
total = 876 + 484 + 252 + 240 + 840 + 810 + 29 = 3,531  →  3,531 ÷ 56 = 63.05

So the leaderboard reads A = 63.54, B = 63.05, and A sits above B. But look at the retrieval column: B is at 56.0 and A is at 52.0. If you are building search, B is four full nDCG points better at the only thing you need, and the leaderboard has told you to pick A.

Sanity check on the leverage. How much must a model improve at retrieval to move the headline average by one point? Retrieval is 15 of 56 datasets, so you need an improvement x with 15x ÷ 56 = 1, giving x = 56 ÷ 15 = 3.73 points. A retriever that gets 3.7 nDCG points better — an enormous, headline-worthy jump for a search team — moves the number people sort by from 63.5 to 64.5. That is the compression the average performs on the signal you care about.

Concept → realization: which family should you actually read?

Map your product to columns, not to averages. This table is the practical output of this chapter.

What you are buildingRead this columnIgnore
RAG over your own docsRetrieval, then RerankingClassification, Summarization, STS
Deduplication of user submissionsPair classificationRetrieval, Clustering
Topic discovery over a support inboxClusteringRetrieval, STS
Frozen features for a downstream classifierClassificationeverything else
“Related articles” on a blogSTS and RerankingClassification
Cross-language searchBitext mining, then Retrievalthe English average entirely

Notice that no row says “read the average.” The average exists to give a single sortable column to a leaderboard, which is a user-interface requirement, not a scientific one. Every serious use of a benchmark begins by throwing that column away and reading the two or three that correspond to the probe your product actually runs.

And once you have picked the right column, you still have to know what its number means — which is why Chapters 3 and 4 build every one of these metrics from scratch. You cannot reason about a 4-point nDCG gap until you have computed nDCG with a pencil at least once.

Concept → realization: one vector, seven probes

Here is the same 768-dimensional vector going through all seven English probes, with real shapes. Reading this once removes most of the mystery about why the families disagree.

python
v = model.encode("my card hasn't arrived yet")      # (768,) float32 — ONE vector, seven fates

# 1 CLASSIFICATION — the vector is a feature row; distance is never computed
X = np.stack([...])                # (10_003, 768)  ->  LogisticRegression  ->  accuracy

# 2 CLUSTERING — Euclidean distance to k centroids; scale and shape matter
km = MiniBatchKMeans(n_clusters=20).fit(X)      # centroids (20, 768)  ->  v_measure

# 3 PAIR CLASSIFICATION — one global threshold over all pairs
sim = cos(A, B)                  # A,B both (n, 768)  ->  (n,)  ->  average precision

# 4 RERANKING — scores compared only WITHIN one query's candidate list
sc = q @ C.T                       # q (768,), C (20, 768)  ->  (20,)  ->  MAP over queries

# 5 RETRIEVAL — the same dot product against millions of competitors
sc = q @ D.T                       # D (5_183_000, 768)  ->  (5183000,)  ->  nDCG@10

# 6 STS — cosine correlated against a human 0-5 rubric
rho = spearman(cos(A, B), gold)   # (n,) vs (n,)  ->  one number

# 7 SUMMARIZATION — machine summaries scored by similarity to human references
rho = spearman([max(cos(m, r) for r in refs) for m in machine], human_ratings)

Now read the three quantities that actually differ. Probe 1 never computes a distance. Probes 3 and 6 compare similarities across different pairs, which requires a globally consistent scale. Probes 4 and 5 compare similarities only within one query, which does not. And probe 5 does it against five million competitors while probe 4 does it against twenty.

The three axes hiding behind the seven names. Does the probe use distance at all? Does it compare across items or only within one item? How many competitors must the right answer beat? Every disagreement between families — every case where a model is great at one and poor at another — is explained by one of these three, and none of them is visible in a benchmark average.

The one number the leaderboard does show you, and how to read it

Alongside the score columns, most embedding leaderboards print the model's parameter count, embedding dimension and maximum sequence length. Those three are not decoration; they are the columns that determine whether you can deploy the thing at all, and Chapter 5 is about reading them. For now, one habit: never look at a score column without looking at the dimension column in the same glance, because a 4,096-dimensional model and a 384-dimensional one are not competing in the same category, however adjacent their rows.

Why can a model score excellently on the classification family and poorly on STS and retrieval, even though all three read the same embedding vectors?

Chapter 2: BEIR and the Zero-Shot Cliff

Chapter 1 told you to read the retrieval column. This chapter tells you what is inside it, and delivers the single most useful empirical fact in embedding engineering: a retriever's in-domain victory does not transfer, and a keyword algorithm from the 1990s is still, on a large fraction of real corpora, the thing to beat.

The setup that made everyone uncomfortable

Around 2020 the dense retrieval story looked finished. DPR — a pair of BERT encoders, one for questions and one for passages, trained contrastively on question-answer data — had beaten BM25 convincingly on the benchmarks it was trained for. The narrative was clean: keyword search is lexical and brittle, embeddings are semantic and robust, the future is dense.

Then BEIR asked a question nobody had asked systematically: what happens if you take these models and, without any further training, point them at eighteen different retrieval corpora they have never seen?

That word — zero-shot, meaning the model gets no training examples from the target dataset, not even its training split — is the entire design of BEIR. It is a heterogeneity benchmark. It does not ask “how good is this retriever?” It asks “how well does whatever you learned somewhere else survive contact with somewhere new?”

Why heterogeneity is the right axis. In production you almost never get to train on your own corpus — you have no relevance labels, and by the time you collect some, the corpus has changed. So the zero-shot number is your number. BEIR measures the exact quantity that a team adopting an off-the-shelf embedding model actually experiences.

What is actually in BEIR

Eighteen public datasets across nine domains, deliberately chosen to disagree with each other about almost everything: how long the queries are, how long the documents are, how many relevant documents exist per query, and — the subtle one — what “relevant” even means.

DatasetDomainQuery looks like“Relevant” means
MS MARCOweb“what is a corn syrup”a passage a human judged as answering it
TREC-COVIDbiomedicala research topic statementa paper an expert graded 1 or 2 for that topic
NFCorpusnutritiona lay health querya linked medical article
FiQAfinancean investing question from a foruman answer post
ArguAnaargumenta whole argument paragraphthe best counter-argument
Touché-2020argumenta controversial questiona well-argued, high-quality passage
Quoraforuma questiona duplicate question
SciFactsciencea scientific claiman abstract that supports or refutes it
Climate-FEVERclaimsa climate claimevidence sentences from Wikipedia
DBPedia-entityentities“Szechwan dish food cuisine”an entity page

Stop on the ArguAna row and read it again. The query is a paragraph-length argument, and the correct answer is a paragraph that disagrees with it. A model trained to place semantically similar texts close together has been trained to do the precise opposite of what this dataset rewards — a good counter-argument shares topic and vocabulary but opposes the stance, so the naive nearest neighbour is a paraphrase, which is wrong here.

This is the deepest point in the chapter. “Relevance” is not a property of a text pair. It is a relation defined by whoever wrote the annotation guidelines. Different datasets use different relations. A single embedding geometry cannot satisfy all of them simultaneously, so a model's zero-shot score on a dataset is partly a measure of how closely that dataset's relevance relation matches the one the model was trained on. Keep this in your pocket for Chapter 7, where you write your own.

The metric BEIR chose, and why

BEIR reports nDCG@10 as its headline metric for every dataset. This is a real choice with real consequences, and the reasoning is worth spelling out.

Recall-oriented metrics like recall@100 need something close to complete relevance judgments — if a system finds a genuinely relevant document that nobody judged, recall punishes it. Precision-oriented metrics at a fixed cutoff throw away the ordering inside the cutoff. nDCG@10 sits in between: it is graded (it can use “highly relevant” versus “somewhat relevant”, which several BEIR datasets provide), it is rank-sensitive (moving the right answer from position 5 to position 1 improves it), and it only looks at ten documents, which is roughly what a real user or a real language-model context window sees.

Chapter 3 builds nDCG from nothing. For now, take it as “a number between 0 and 1 where 1 means the perfect ordering.”

The result: the cliff

Here is the shape of what BEIR found. The values below are rounded approximations of the study's reported nDCG@10 scores — use them for the shape of the story and consult the paper's tables for exact figures.

ModelMS MARCO (its own training domain)Zero-shot average over BEIR
BM25 (no training at all)≈ 0.23≈ 0.43
DPR (dense, trained on QA data)strong in-domain≈ 0.29
ANCE (dense, hard-negative mining)≈ 0.39≈ 0.41
TAS-B (dense, balanced sampling)≈ 0.41≈ 0.42
ColBERT (late interaction)≈ 0.40≈ 0.45
BM25 + cross-encoder rerank≈ 0.41≈ 0.48

Three readings, in increasing order of usefulness.

Reading one: DPR — the model that had convincingly beaten BM25 in its own setting — scores about fourteen nDCG points below an untrained keyword algorithm when moved to unfamiliar corpora. That is not a small regression. That is the difference between a working product and a broken one.

Reading two: the in-domain column does not predict the zero-shot column. TAS-B beats ANCE in-domain and they are nearly tied out of domain; BM25 is nearly last in-domain and near the top out of domain. If you rank models by MS MARCO performance and deploy on your own corpus, the ranking you used was uninformative.

Reading three — the one to act on: the best system is not a model, it is a pipeline. BM25 retrieves a hundred candidates cheaply and robustly; a cross-encoder — a model that reads the query and document together and outputs one relevance score — reorders them. That combination was the top of the table and it is still the default architecture of a serious search stack.

BM25, derived far enough to see why it refuses to die

You cannot respect the baseline until you know what it does. BM25 scores a document for a query by summing, over the query's terms, three ideas multiplied together.

score(q, d) = Σt ∈ q   IDF(t)  ×  f(t,d) · (k1+1) ÷ ( f(t,d) + k1(1 − b + b · |d| / avgdl) )

where f(t,d) is how many times term t occurs in document d, |d| is the document's length, avgdl is the average document length in the corpus, and k1 ≈ 1.2 and b ≈ 0.75 are the two tuning constants. Take the three ideas one at a time, with numbers.

Idea 1 — rare terms are worth vastly more. The inverse document frequency is

IDF(t) = ln( (N − df(t) + 0.5) ÷ (df(t) + 0.5) + 1 )

with N the number of documents and df(t) the number containing t. Run it on your docs site, N = 40,000. The error code ERR_TLS_CERT_ALTNAME_INVALID appears in df = 3 documents:

IDF = ln( (40,000 − 3 + 0.5) ÷ (3 + 0.5) + 1 ) = ln( 39,997.5 ÷ 3.5 + 1 ) = ln(11,428.86) = 9.34

Now the word “the”, appearing in df = 39,000 documents:

IDF = ln( 1,000.5 ÷ 39,000.5 + 1 ) = ln(1.0257) = 0.025

The ratio is 9.34 ÷ 0.025 ≈ 369. BM25 considers one occurrence of your error code worth about three hundred and sixty-nine occurrences of “the”, and it knows this from counting your corpus, with no training, no gradient, and no chance of having missed the token during pretraining.

That last clause is the whole reason BM25 survives. A dense model's knowledge of a token lives in weights learned from a pretraining corpus that did not contain your error codes. BM25's knowledge of a token is computed from your corpus at index time. One of these adapts to your domain for free.

Idea 2 — repetition saturates. The middle factor grows with f(t,d) but flattens. Fix a document of average length, so the bracket reduces to k1 = 1.2, and compute the term factor for several counts:

f=1: (1 × 2.2) ÷ (1 + 1.2) = 2.2 ÷ 2.2 = 1.000
f=2: (2 × 2.2) ÷ (2 + 1.2) = 4.4 ÷ 3.2 = 1.375
f=3: (3 × 2.2) ÷ (3 + 1.2) = 6.6 ÷ 4.2 = 1.571
f=10: (10 × 2.2) ÷ (10 + 1.2) = 22 ÷ 11.2 = 1.964
f → ∞: the limit is k1 + 1 = 2.200

The first occurrence buys 1.000. The second buys 0.375 more. The tenth buys about 0.02 more. Keyword stuffing does not work, because the function was designed with a ceiling.

Idea 3 — long documents are discounted. The b term in the denominator scales with |d| / avgdl. For a document twice the average length:

1 − b + b · 2 = 1 − 0.75 + 1.5 = 1.75, so f=2 gives 4.4 ÷ (2 + 1.2×1.75) = 4.4 ÷ 4.1 = 1.073

against 1.375 in an average-length document. Two mentions in a short focused page beat two mentions in a long rambling one, which is the correct instinct and is why b exists.

Three ideas, two constants, zero parameters learned by gradient descent. That is the thing your seven-billion-parameter embedding model has to beat on your corpus, and on corpora full of rare identifiers it frequently does not.

Where dense wins and where it loses — and why

The zero-shot average hides a wild per-dataset spread. Explore it, then read the mechanism table below.

In-domain versus out-of-domain: the per-dataset gap

Each row is one retrieval dataset; orange is BM25, teal is a strong dense bi-encoder trained on web questions. The left-most row is the model's own training domain. Slide to inspect a dataset and read why the gap goes the way it does; press the button to add the hybrid (rank fusion of both lists), which is what you would actually ship.

inspect datasetTouché
Failure modeMechanismShows up on
Rare-token blindnessa token seen a handful of times in pretraining has a nearly arbitrary direction, so its contribution to the pooled vector is noise; BM25 computes its weight from your index insteaderror codes, gene names, part numbers, legal citations
Query-shape shifttrained on short web questions, tested on paragraph-long arguments or expert topic statements — the query encoder has never seen this length or registerArguAna, TREC-COVID
Relevance-relation shiftthe dataset's notion of relevant is not “semantically similar”ArguAna (counter-argument), Touché (argument quality)
Corpus-scale crowdinga fixed d-dimensional space has finite capacity; as the corpus grows, more documents crowd near any query direction and the true answer's margin shrinksany corpus in the millions
Vocabulary driftthe corpus uses a technical register (clinical notes, patents) that is thin in the pretraining mixtureBioASQ, legal and medical corpora

And the flip side, because dense retrieval is genuinely better at some things: it wins wherever the match is paraphrastic and the vocabulary is ordinary. Quora duplicate questions, FiQA forum answers, and anything where the user says “how do I stop my card from being charged twice” and the document says “preventing duplicate transactions” with not one shared content word. BM25 scores that pair zero. The dense model scores it high. Both behaviours are correct and neither is sufficient.

So ship both: reciprocal rank fusion, by hand

If one system is right about rare tokens and the other is right about paraphrase, the engineering answer is not to choose. It is to run both and merge the ranked lists. The standard merge is reciprocal rank fusion, which is remarkable in that it uses only ranks, never scores — so you never have to make a BM25 score commensurable with a cosine, which is impossible anyway.

RRF(d) = Σover each ranked list r   1 ÷ ( k + rankr(d) ),   with k = 60 by convention

Work an example. Three documents, two lists:

doc X: BM25 rank 1, dense rank 8 → 1/61 + 1/68 = 0.016393 + 0.014706 = 0.031099
doc Y: BM25 rank 12, dense rank 1 → 1/72 + 1/61 = 0.013889 + 0.016393 = 0.030282
doc Z: BM25 rank 3, dense rank 3 → 1/63 + 1/63 = 0.015873 + 0.015873 = 0.031746

Final order: Z, X, Y. Document Z was never first in either list, and it wins — because two independent systems both put it near the top, and that agreement is stronger evidence than one system's enthusiasm. That is the entire philosophy of fusion in one arithmetic example.

Why k = 60? It flattens the top of the curve. Without it, rank 1 would be worth 1.0 and rank 2 worth 0.5, so a single list's first place would dominate everything. With k = 60, rank 1 is worth 0.0164 and rank 2 is worth 0.0161 — a 2% difference. The constant is a deliberate statement that you do not trust any single system's precise ordering, only its rough opinion.

Concept → realization: what this changes on Monday

Three concrete actions come out of this chapter and they cost almost nothing.

1. Measure BM25 on your corpus first. It takes an afternoon with an off-the-shelf index and it gives you the number every dense model must beat. If your candidate embedding model cannot beat BM25 on your own evaluation set, you have learned something enormous for one day of work.

2. Ship the hybrid. Rank fusion of BM25 and dense costs one extra index and about twenty lines of code, and it is robust in the exact place pure-dense is fragile: rare identifiers.

3. Put a reranker at the end. The BEIR table's winner was a pipeline, not a model. Retrieve a hundred with cheap methods, rerank with a cross-encoder, keep ten. You pay one extra model call per query on a hundred short pairs, and you buy back most of the zero-shot cliff.

Score one document, end to end

The three ideas above become a single number when you actually run them. Do it once by hand, because the result is surprising and it explains a class of production bug.

Query: “webhook retry policy”. Corpus: N = 40,000 chunks, average length avgdl = 220 tokens. Document frequencies from your index: webhook in 320 documents, retry in 140, policy in 900.

IDF(webhook) = ln(39,680.5 ÷ 320.5 + 1) = ln(124.81) = 4.8268
IDF(retry)   = ln(39,860.5 ÷ 140.5 + 1) = ln(284.70) = 5.6515
IDF(policy)  = ln(39,100.5 ÷ 900.5 + 1) = ln(44.42)  = 3.7937

Candidate 1: the Webhook Retries reference page. Length 180 tokens; term counts 6, 9, 2. First the length factor, which appears in every term's denominator:

K = k1(1 − b + b · |d|/avgdl) = 1.2(0.25 + 0.75 × 180/220) = 1.2 × 0.86364 = 1.03636
termftf factor = f · 2.2 / (f + K)IDFcontribution
webhook613.2 ÷ 7.0364 = 1.87604.82689.0549
retry919.8 ÷ 10.0364 = 1.97285.651511.1493
policy24.4 ÷ 3.0364 = 1.44913.79375.4975
score = 9.0549 + 11.1493 + 5.4975 = 25.70

Candidate 2: the long Webhooks overview. Length 900 tokens; term counts 8, 1, 3. It mentions webhook more times than the reference page did.

K = 1.2(0.25 + 0.75 × 900/220) = 1.2 × 3.31818 = 3.98182
termftf factorIDFcontribution
webhook817.6 ÷ 11.9818 = 1.46894.82687.0900
retry12.2 ÷ 4.9818 = 0.44165.65152.4957
policy36.6 ÷ 6.9818 = 0.94533.79373.5862
score = 7.0900 + 2.4957 + 3.5862 = 13.17

25.70 against 13.17. The focused page wins by nearly two to one despite mentioning the head term less often, and you can point at exactly why: the length factor K went from 1.04 to 3.98, which crushed every term's contribution, and the rarest term retry — the one carrying the most IDF — appears nine times in the reference and once in the overview.

The production lesson hiding in K. Chunk size changes |d| / avgdl and therefore changes every BM25 score in your index. Re-chunking is not a neutral refactor; it silently re-tunes the lexical half of your hybrid. This is one more reason the evaluation gate in Chapter 7 must run on chunking changes and not only on model changes.
A dense retriever that beats BM25 comfortably on its own training domain scores well below BM25 on BEIR's zero-shot average. What does this most directly demonstrate?

Chapter 3: nDCG From Scratch

Two chapters have now used nDCG@10 as if it were obvious. It is not obvious. It is a designed object with four separate decisions baked into it, and every one of those decisions changes what the number rewards. In this chapter you build it from nothing, and then compute one by hand, digit by digit.

Do not skip the arithmetic. The difference between an engineer who can argue about a retrieval regression and one who cannot is entirely whether they have ever computed this by hand.

The problem: compress a ranking into one number

A retrieval system hands you an ordered list of ten documents for a query. Some are perfect, some are useful, most are junk. You need a single number so you can average over queries and compare two systems. What should that number do?

Write down the requirements first, because the formula falls out of them almost mechanically.

RequirementWhy
Some documents are more relevant than othersA page that exactly answers the question is not the same as one that mentions the topic
Position matters — earlier is betterUsers read top-down and stop; a language model with a 10-document context still weights the first ones more
Bounded in [0, 1], comparable across queriesA query with 40 relevant documents must not dominate the average over a query with 1
Only look at the top fewNobody scrolls to rank 50, and nobody judged rank 50 either

Step 1: grades, not booleans

Start with graded relevance. Each judged document gets an integer grade, conventionally

rel = 3 (perfect)   rel = 2 (highly relevant)   rel = 1 (marginally relevant)   rel = 0 (not relevant)

Some datasets only have 0/1. Some use 0–2. The machinery does not care. What matters is that you have committed to an ordering of usefulness — and that commitment is a human decision made by whoever wrote the annotation guidelines, not a property of the documents.

Step 2: the naive sum, and why it fails

The simplest score is: add up the relevance of the top ten.

naive(ranking) = Σi=1..10 reli

This satisfies requirement one and fails requirement two spectacularly. Consider two rankings of exactly the same ten documents:

A: [3, 0, 0, 0, 0, 0, 0, 0, 0, 0]  →  naive = 3
B: [0, 0, 0, 0, 0, 0, 0, 0, 0, 3]  →  naive = 3

System A puts the perfect document first. System B buries it at position ten. The metric says they are identical. Any metric that cannot distinguish these is useless for ranking, because ranking is the ordering. So we need a position-dependent weight.

Step 3: the discount — derive it from a user model

What weight? The honest way to choose is to ask what the weight means. Interpret the weight at position i as the probability that the user actually examines position i. Then

expected utility = Σi P(examines rank i) × gain(reli)

is a real quantity with a real interpretation, and any decaying function of i is a candidate model of attention. Two obvious candidates:

harmonic:   1 ÷ i      logarithmic:   1 ÷ log2(i + 1)

Compute both for ranks 1 through 10 and look at the difference:

rank ilog2(i+1)log discount 1/log2(i+1)harmonic 1/i
11.00001.00001.0000
21.58500.63090.5000
32.00000.50000.3333
42.32190.43070.2500
52.58500.38690.2000
62.80740.35620.1667
73.00000.33330.1429
83.16990.31550.1250
93.32190.30100.1111
103.45940.28910.1000

The harmonic discount says rank 10 is worth one tenth of rank 1. The logarithmic discount says it is worth 0.2891 — nearly three times as much. nDCG uses the logarithmic one, and the reason is a deliberate design tradeoff: with a harmonic discount the metric becomes overwhelmingly a measure of “did you get position one right”, and it stops being able to distinguish two systems that both missed the top slot. The gentle log discount keeps the whole top-ten window informative.

The base of the logarithm does not matter, and here is the proof. For any base b, 1/logb(x) = ln(b)/ln(x) = (ln b ÷ ln 2) × (1 ÷ log2(x)). So switching from log2 to log10 multiplies every discount by the same constant, which multiplies DCG by that constant — and since nDCG divides DCG by an ideal DCG computed with the same discounts, the constant cancels exactly. Base 2 is a convention, not a choice with consequences. The shape (logarithmic versus harmonic) is the choice with consequences.

Step 4: the gain — why 2rel − 1

Now the other half: how much is a grade-3 document worth compared to a grade-1 document? The obvious answer is “three times as much” — the linear gain, gain = rel. The standard used by the retrieval-evaluation tools that MTEB and BEIR run is the exponential gain:

gain(rel) = 2rel − 1  →  rel 0 → 0,   rel 1 → 1,   rel 2 → 3,   rel 3 → 7

Why? Because it encodes a specific editorial opinion: one perfect answer is worth more than a pile of mediocre ones. Test that claim with arithmetic. Put one grade-3 document at rank 1:

7 × 1.0000 = 7.000

Now put three grade-1 documents at ranks 1, 2 and 3 instead:

1×1.0000 + 1×0.6309 + 1×0.5000 = 2.131

Under exponential gain the single perfect document is worth more than three times the trio. Under linear gain the trio would score 2.131 against the single document's 3.000 — a far closer call. Which is right depends entirely on your product. For a question-answering front end where one correct answer ends the session, exponential is right. For a research tool where a user wants breadth, linear may be right.

Report which one you used. The same ranking can differ by several nDCG points between the two gain schemes. Comparing your linear-gain number to someone else's exponential-gain number is meaningless, and this happens constantly because most libraries default silently. When you see nDCG@10 on a leaderboard, it is almost always exponential gain with a log2 discount — but check.

Step 5: assemble DCG

Multiply gain by discount and sum over the cutoff:

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

This satisfies requirements one, two and four. It fails requirement three: it is unbounded, and a query with many relevant documents produces a bigger DCG than a query with one, no matter how well the second query was served.

Step 6: normalize — divide by the best possible

The fix is to ask: what is the largest DCG this query could possibly achieve? Sort all judged documents for the query by grade, descending, take the top k, and compute DCG of that. Call it IDCG@k, the ideal DCG. Then

nDCG@k = DCG@k ÷ IDCG@k  ∈  [0, 1]

Now every query is on the same 0-to-1 scale, 1.0 means “you produced an ordering as good as any ordering could be”, and averaging across queries is meaningful. That is the whole construction.

The full worked example — nDCG@10 with a pencil

Query: “how do I reset my password”. Your judges have graded six documents in the collection: one at grade 3, two at grade 2, three at grade 1. Everything else is grade 0.

System A returns these ten documents, in this order, with these grades:

A = [ 2, 0, 3, 1, 0, 2, 0, 1, 0, 1 ]

Step A1 — convert grades to gains using 2rel − 1:

gains = [ 3, 0, 7, 1, 0, 3, 0, 1, 0, 1 ]

Step A2 — multiply each by its discount and keep only the non-zero rows (a zero gain contributes nothing, which is why sparse relevance makes this tractable by hand):

rankgradegaindiscountcontribution
1231.000003 × 1.00000 = 3.00000
3370.500007 × 0.50000 = 3.50000
4110.430681 × 0.43068 = 0.43068
6230.356213 × 0.35621 = 1.06863
8110.315461 × 0.31546 = 0.31546
10110.289061 × 0.28906 = 0.28906

Step A3 — add them up, one at a time so you can check each partial sum:

3.00000 + 3.50000 = 6.50000
6.50000 + 0.43068 = 6.93068
6.93068 + 1.06863 = 7.99931
7.99931 + 0.31546 = 8.31477
8.31477 + 0.28906 = DCG@10 = 8.60383

Step A4 — build the ideal ranking. Sort all six judged documents by grade: one 3, two 2s, three 1s. The ideal order of grades is [3, 2, 2, 1, 1, 1] followed by zeros, so the ideal gains are [7, 3, 3, 1, 1, 1, 0, 0, 0, 0]:

rankgaindiscountcontribution
171.000007.00000
230.630931.89279
330.500001.50000
410.430680.43068
510.386850.38685
610.356210.35621
7.00000 + 1.89279 = 8.89279
8.89279 + 1.50000 = 10.39279
10.39279 + 0.43068 = 10.82347
10.82347 + 0.38685 = 11.21032
11.21032 + 0.35621 = IDCG@10 = 11.56653

Step A5 — divide.

nDCG@10 = 8.60383 ÷ 11.56653 = 0.7439

Check the division the slow way if you like: 11.56653 × 0.74 = 8.55923, leaving 0.04460, and 0.04460 ÷ 11.56653 = 0.00386, so 0.74 + 0.00386 = 0.74386. Round to 0.744.

Now move one document and watch

System B returns exactly the same six judged documents, in exactly the same positions — ranks 1, 3, 4, 6, 8, 10 — but assigns the grades differently: it puts the grade-3 document first and pushes the grade-2 documents to ranks 3 and 4.

B = [ 3, 0, 2, 2, 0, 1, 0, 1, 0, 1 ]  →  gains = [ 7, 0, 3, 3, 0, 1, 0, 1, 0, 1 ]
7×1.00000 = 7.00000
3×0.50000 = 1.50000  →  running total 8.50000
3×0.43068 = 1.29204  →  running total 9.79204
1×0.35621 = 0.35621  →  running total 10.14825
1×0.31546 = 0.31546  →  running total 10.46371
1×0.28906 = 0.28906  →  DCG@10 = 10.75277

The ideal ranking is unchanged — it depends only on the judgments, not on the system — so IDCG@10 = 11.56653 again:

nDCG@10 = 10.75277 ÷ 11.56653 = 0.9296

0.744 versus 0.930. An eighteen-point gap, produced entirely by which of the relevant documents sits at rank one. Both systems returned the same six documents in the same six slots. This is what “rank sensitive” means, quantitatively.

Hold on to systems A and B. In Chapter 4 you will compute recall@5, recall@10, MRR and MAP for both of them, and every single one of those metrics will return exactly the same value for A and for B. nDCG is the only one of the five that can see the difference you just computed.
Anatomy of a DCG: where the score comes from

The bars are the per-rank contributions of a ranking; the curve behind them is the discount. Slide to move the single grade-3 document up and down the list — everything else stays where it is — and watch DCG, IDCG and nDCG update. Press the button to switch the gain scheme between exponential (2rel−1) and linear (rel) and see how much the same ranking is worth under each convention.

rank of the grade-3 document3

The two things nDCG cannot see

Every metric has a blind spot and honesty requires naming them.

1. Unjudged documents count as zero. nDCG has no category for “nobody looked at this.” If your new model surfaces a genuinely excellent document that no annotator ever graded, the metric treats it as junk and your score goes down for being better. This is pooling bias, and it is not hypothetical: benchmark judgments were pooled from the systems that existed when the dataset was built, so a system that behaves differently from all of them is systematically punished.

observed nDCG = true nDCG − (credit lost to relevant-but-unjudged documents)

The correction term grows with how novel your system is. This is one concrete mechanism by which an old benchmark quietly favours models that resemble old models.

2. The cutoff hides everything below it. nDCG@10 cannot distinguish a system that puts the answer at rank 11 from one that puts it at rank 900. If your pipeline reranks the top 100, that distinction matters enormously — a document at rank 11 will be recovered by the reranker and one at rank 900 will not. Report recall@100 alongside nDCG@10 whenever a reranker exists downstream. Chapter 4 builds recall.

Concept → realization: the twelve-line implementation

python
import math

def dcg(grades, k=10):
    # grades[i] is the relevance of the doc your system put at rank i+1
    return sum((2**g - 1) / math.log2(i + 2)      # i is 0-based -> rank i+1 -> log2(i+2)
               for i, g in enumerate(grades[:k]))

def ndcg_at_k(ranked_grades, all_grades, k=10):
    # all_grades = every judged grade for this query, system-independent
    ideal = sorted(all_grades, reverse=True)
    idcg  = dcg(ideal, k)
    if idcg == 0: return 0.0      # no relevant docs at all -> undefined, report 0 or skip
    return dcg(ranked_grades, k) / idcg

# our worked example, to the digit
A = [2,0,3,1,0,2,0,1,0,1]; judged = [3,2,2,1,1,1]
print(ndcg_at_k(A, judged))    # 0.7438...

Two details in that code are where real bugs live. First, math.log2(i + 2) — the off-by-one that turns rank 1 into log2(2) = 1. Get it wrong and rank 1 becomes log2(1) = 0 and you divide by zero. Second, idcg == 0: queries with no relevant documents in the collection have an undefined nDCG. Scoring them as 0 drags your average down by however many such queries you have; skipping them changes the denominator. Decide explicitly and write the decision down, because two teams making different choices will report different numbers for the same system.

Two systems return the same six relevant documents at the same six rank positions, but system B has the single grade-3 document at rank 1 while system A has it at rank 3. What does nDCG@10 do, and why?

Chapter 4: Recall, MRR, MAP and Spearman

nDCG is the headline, but it is not the only number on a benchmark row, and it is often not the right number for your product. Four more metrics do the rest of the work. All four are three lines of arithmetic, all four are computed here by hand, and each one has exactly one situation where it is the correct choice.

Recall@k — did the answer make it into the window?

Recall@k is the fraction of all relevant documents that appear anywhere in the top k. Order inside the window is invisible to it.

recall@k = |{relevant documents in the top k}| ÷ |{all relevant documents}|

Take system A from Chapter 3: A = [2, 0, 3, 1, 0, 2, 0, 1, 0, 1], with six relevant documents in the collection. Treat any grade ≥ 1 as relevant.

top 5 contain grades at ranks 1, 3, 4 → 3 relevant found → recall@5 = 3 ÷ 6 = 0.500
top 10 contain ranks 1, 3, 4, 6, 8, 10 → 6 found → recall@10 = 6 ÷ 6 = 1.000

And system B = [3, 0, 2, 2, 0, 1, 0, 1, 0, 1]? Its relevant documents also sit at ranks 1, 3, 4, 6, 8, 10.

recall@5(B) = 3 ÷ 6 = 0.500    recall@10(B) = 6 ÷ 6 = 1.000

Identical. Recall cannot see the eighteen-point nDCG gap, because recall was never designed to look at order.

When recall@k is exactly the right metric. When something downstream re-reads the window. If you retrieve 100 candidates and a cross-encoder reranks them, the retriever's only job is to get the answer into the 100 — the ordering will be redone. Measure that stage with recall@100 and measure the reranker with nDCG@10. Using nDCG@10 to evaluate a first-stage retriever whose output is about to be reordered is measuring work that will be thrown away.

Precision@k, and why retrieval papers mostly skip it

Precision@k is the fraction of the top k that is relevant: for system A, precision@10 = 6 ÷ 10 = 0.600. The problem is the denominator. If a query has only one relevant document in the whole collection, the best achievable precision@10 is 1 ÷ 10 = 0.100 — a perfect system scores 0.1 and looks broken. Precision@k is not comparable across queries with different numbers of relevant documents, which is exactly the normalization problem nDCG solved with IDCG.

MRR — how fast to the first good thing

Mean reciprocal rank finds the position of the first relevant document and takes one over it, then averages across queries.

RR(query) = 1 ÷ rank of the first relevant document   (0 if none in the window)
MRR = (1 ÷ Q) Σq RR(q)

Three queries, worked:

queryranks of relevant docsfirst relevantRR
q11, 3rank 11 ÷ 1 = 1.0000
q24, 5, 9rank 41 ÷ 4 = 0.2500
q3none in top 100.0000
MRR = (1.0000 + 0.2500 + 0.0000) ÷ 3 = 1.2500 ÷ 3 = 0.4167

Notice how violently nonlinear MRR is. Moving a query's first hit from rank 2 to rank 1 gains 1 − 0.5 = 0.500. Moving it from rank 10 to rank 9 gains 1/9 − 1/10 = 0.1111 − 0.1000 = 0.0111 — forty-five times less. MRR is essentially a measure of “is the answer at the top” with a small consolation prize for everything else.

That makes it the correct metric for navigational queries, where exactly one document is the answer and the user is done as soon as they see it: “reset password page”, “invoice #88213”, “the docs for fetchWithRetry”. It is the wrong metric for research-style queries where a user wants five good sources.

For systems A and B: both have a relevant document at rank 1, so RR = 1.0 for both, and MRR cannot see the gap either.

MAP — precision measured at every hit

Average precision walks down the ranking, and every time it steps onto a relevant document it records the precision at that exact position. Then it averages those recorded values over the total number of relevant documents.

AP = (1 ÷ R) Σi : doc at rank i is relevant   precision@i

where R is the number of relevant documents for the query. MAP is the mean of AP over queries. Work it for system A, whose relevant documents sit at ranks 1, 3, 4, 6, 8, 10 with R = 6:

hit at rank irelevant docs seen so farprecision@i
111 ÷ 1 = 1.00000
322 ÷ 3 = 0.66667
433 ÷ 4 = 0.75000
644 ÷ 6 = 0.66667
855 ÷ 8 = 0.62500
1066 ÷ 10 = 0.60000
sum = 1.00000 + 0.66667 + 0.75000 + 0.66667 + 0.62500 + 0.60000 = 4.30834
AP = 4.30834 ÷ 6 = 0.7181

Now system B. Its relevant documents are at ranks 1, 3, 4, 6, 8, 10 — the same positions. Every precision value is the same. AP(B) = 0.7181. Identical again.

The result worth memorizing. Four metrics, one ranking pair, one verdict:
metricsystem Asystem Bcan it see the difference?
recall@50.5000.500no
recall@101.0001.000no
MRR1.0001.000no
MAP0.7180.718no
nDCG@100.7440.930yes
The reason is structural, not accidental: recall, MRR and MAP all treat relevance as binary. A and B differ only in which grade sits in which slot, and a binary metric has erased grades before it starts. Only a graded metric can express “the perfect answer is at the top instead of third.”

Which one, when

Your situationMetricBecause
One correct document per query; user stops when foundMRRrewards getting it to rank 1 above all else
First stage feeding a rerankerrecall@100ordering will be redone; only membership matters
Many relevant docs, binary judgments, order mattersMAPuses every hit position, no grades needed
Graded judgments, top-of-list matters mostnDCG@10the only one that reads grades and position together
RAG where a model reads the top 5recall@5 and nDCG@5the window must contain it, and position still shifts model attention

Report at least two. A single number is a summary of a summary, and the pair (recall@k, nDCG@k) tells you whether a regression was a retrieval failure (the document fell out of the window) or an ordering failure (it is still there but buried). Those two failures have completely different fixes.

Spearman — the metric for the STS family

Retrieval metrics all score a ranked list. The STS family scores something different: a set of sentence pairs, each with a human similarity rating, against your model's cosine similarities. The reported number is the Spearman rank correlation, and understanding why rank correlation is a small but important piece of judgment.

Definition. Spearman's ρ is simply the ordinary Pearson correlation computed on the ranks rather than the values. When there are no ties it collapses to a formula you can evaluate in your head:

ρ = 1 − 6 Σdi2 ÷ ( n(n2 − 1) )

where di is the difference between the two ranks of item i.

Where that formula comes from, in four lines. Let x and y be the two rank vectors, each a permutation of 1..n. Their means are equal, both (n+1)/2, and their sums of squared deviations are equal, both V = n(n2−1)/12. Write S for the cross term Σ(x−x̄)(y−ȳ). Then

Σd2 = Σ((x−x̄) − (y−ȳ))2 = V + V − 2S = 2V − 2S

and since Pearson on equal-variance vectors is ρ = S ÷ V, substituting S = V − Σd2/2 gives

ρ = 1 − Σd2 ÷ (2V) = 1 − 6Σd2 ÷ (n(n2−1))

No magic. It is Pearson with the rank structure substituted in.

Spearman, worked on six sentence pairs

Six pairs, human gold similarity on the 0–5 scale, and your model's cosine:

pairgoldgold rankcosinecosine rankdd2
14.810.91100
24.220.88200
33.530.724−11
42.040.793+11
51.250.55500
60.460.41600
Σd2 = 0 + 0 + 1 + 1 + 0 + 0 = 2    n = 6    n(n2−1) = 6 × 35 = 210
ρ = 1 − (6 × 2) ÷ 210 = 1 − 12 ÷ 210 = 1 − 0.0571 = 0.9429

The model swapped exactly one adjacent pair — it thought pair 4 was more similar than pair 3 — and was otherwise in perfect agreement, and Spearman reports 0.943.

Why not Pearson? Compute it and see

Pearson on the raw numbers measures linear agreement. The gold scores live on 0–5; the cosines live, in practice, in a compressed band around 0.4–0.95. Compute Pearson on the same six pairs.

gold mean = (4.8+4.2+3.5+2.0+1.2+0.4) ÷ 6 = 16.1 ÷ 6 = 2.6833
cosine mean = (0.91+0.88+0.72+0.79+0.55+0.41) ÷ 6 = 4.26 ÷ 6 = 0.7100

Deviations, products and squares:

pairdx = gold − 2.6833dy = cos − 0.71dx·dydx2dy2
1+2.1167+0.200.423334.480280.0400
2+1.5167+0.170.257832.300280.0289
3+0.8167+0.010.008170.666940.0001
4−0.6833+0.08−0.054670.466940.0064
5−1.4833−0.160.237332.200280.0256
6−2.2833−0.300.685005.213610.0900
Σdx·dy = 1.55700    Σdx2 = 15.32833    Σdy2 = 0.19100
r = 1.55700 ÷ √(15.32833 × 0.19100) = 1.55700 ÷ √2.92771 = 1.55700 ÷ 1.71106 = 0.9099

Pearson 0.910, Spearman 0.943. Pearson is lower, and it is lower for a reason that has nothing to do with the model's ordering quality: cosine similarity is a squashed, nonlinear function of human similarity. A model whose cosines run 0.41 to 0.91 is penalized by Pearson for not spreading its scores linearly across the gold range, even if it orders every pair correctly.

Why STS is scored with Spearman. You want to measure whether the model agrees with humans about which pair is more similar, not whether its similarity scale happens to be affinely aligned with an arbitrary 0–5 rubric. Rank correlation is invariant to any monotone transform of either variable, so it measures the thing you meant. That invariance also means Spearman cannot tell you whether your cosines are calibrated — if you need to threshold similarity in production ("call it a duplicate above 0.85"), a high Spearman is no guarantee that any fixed threshold works.
Pearson versus Spearman under a monotone squash

Twelve sentence pairs. The horizontal axis is the human gold score, the vertical axis is the model cosine. Slide “squash” to compress the cosines into a narrow band — a monotone transform that never changes which pair the model thinks is more similar — and watch Pearson fall while Spearman does not move. Then add rank noise and watch both fall together, because that is a real ordering error.

monotone squash0.00
rank noise0.00

Concept → realization: the metric contract

Whenever you report a retrieval number, four facts have to travel with it or the number is not reproducible. Write them next to the score in your dashboard, not in a document nobody opens.

FactExampleWhat breaks without it
Cutoff k@10nDCG@10 and nDCG@100 differ by many points
Gain scheme2rel − 1linear gain gives a different number on the same ranking
Relevance threshold for binary metricsrel ≥ 1rel ≥ 2 can halve your recall
Handling of queries with no relevant docsskippedscoring them 0 changes the mean

Every one of these has silently caused a team to believe a model was better or worse than it was. They are free to fix — four strings in a log line.

MAP across queries, and why it is an area under a curve

The single-ranking AP above is one query. MAP averages AP across the query set, and the multi-query version is worth computing once because the treatment of missing relevant documents is where people get it wrong.

queryR (relevant in collection)ranks of relevant docs foundAP
q121, 3(1/1 + 2/3) ÷ 2 = 1.66667 ÷ 2 = 0.83333
q244, 5, 9(1/4 + 2/5 + 3/9) ÷ 4 = 0.98333 ÷ 4 = 0.24583
q32none in the window0 ÷ 2 = 0.00000
MAP = (0.83333 + 0.24583 + 0.00000) ÷ 3 = 1.07917 ÷ 3 = 0.3597

Look at q2 carefully. Four relevant documents exist and only three were found, so the sum has three terms but the denominator is 4. The missing document contributes a silent zero. That is deliberate: AP is a recall-aware precision, and a system that finds three of four is not allowed to score as if four of four were the target.

Why AP is the area under the precision-recall curve. Walk down the ranking and plot precision against recall. Recall only changes when you hit a relevant document, and each such hit increases recall by exactly 1/R. So the area under that step curve is

area = Σk precision@k × Δrecall@k = Σrelevant k precision@k × (1 ÷ R)

which is exactly the AP formula. The average precision is not an average of anything arbitrary — it is a Riemann sum, and the “average” in the name is the 1/R factor.

Hit rate: the metric your product manager will actually ask for

One more, because it is the number people quote in RAG discussions under a dozen different names. Hit rate@k (also success@k, or answer-in-context rate) is the fraction of queries with at least one relevant document in the top k.

hit rate@k = |{queries with ≥ 1 relevant doc in the top k}| ÷ |queries|

On 300 evaluation queries, if 213 have at least one relevant document in the top 5:

hit rate@5 = 213 ÷ 300 = 0.710

Two properties make it useful and one makes it dangerous. It is interpretable — 71% of the time the evidence was in the window, so 29% of the time the generator could not possibly have been right. And when every query has exactly one relevant document (R = 1), hit rate@k equals recall@k, which is why the two are so often confused. The danger: with R > 1 it saturates almost immediately, because finding one document out of six scores the same as finding all six. Use it as a ceiling report, never as the thing you optimize.

The pair to put on your dashboard. hit rate@k answers “could the answer have been found?” and nDCG@k answers “was it easy to find?” The first bounds your product; the second explains the experience inside that bound. Every retrieval regression is a movement in one of the two, and knowing which one tells you whether to look at the index or at the ranker.
You are evaluating a first-stage retriever whose top 100 results will be reordered by a cross-encoder before anything is shown. Which metric should drive that stage, and why?

Chapter 5: Reading a Leaderboard Honestly

You now know what the tasks measure and how the metrics are computed. This chapter is about the leaderboard itself as an artifact: a sorted table of numbers, each of which is an estimate with an error bar that is never printed, produced by models that differ in ways the table does not show.

There are five things to check before you believe a row. Four are confounds and one is a noise floor. Every one of them has cost somebody a quarter.

Confound 1: the average hides the variance

Two models can share a headline average and have completely different risk profiles. Consider two models both averaging 63.5 across 56 datasets:

meanstd across datasetsworst datasetdatasets below 40
Model S (steady)63.56.148.20
Model V (volatile)63.514.721.47

Model V has seven datasets where it collapses. If one of those seven happens to resemble your corpus, you have adopted a model that scores 21 on your job while the leaderboard promised 63.5. The mean gave you no warning, because a mean is a first moment and catastrophe lives in the tail.

What to do instead. Read the per-dataset columns for the families you care about and look at the minimum, not the mean. A model's worst score in your family is a much better predictor of your downside than its average across all families. This is the same reasoning that makes p99 latency more useful than mean latency — and for the same reason: you experience the tail, not the average.

Why later benchmark versions changed the aggregation. Taking a plain mean over heterogeneous scores lets whichever tasks have the widest score spread dominate the ranking, purely because they have more room to move. The successor benchmark (MMTEB, which scales the same idea to 500-plus tasks and 250-plus languages) moved to a rank-based aggregation — a Borda-style count over per-task rankings — specifically so that a task where all models score between 80 and 82 counts the same as one where they score between 10 and 60.

Confound 2: model size

The leaderboard sorts by score. It does not sort by cost, and the cost differences between adjacent rows are often two orders of magnitude. Make it concrete for a five-million-chunk corpus.

Index memory. A dense index stores one vector per chunk:

5,000,000 × 384 dims × 4 bytes (float32) = 7,680,000,000 bytes = 7.68 GB
5,000,000 × 1,024 dims × 4 bytes = 20,480,000,000 bytes = 20.48 GB
5,000,000 × 4,096 dims × 4 bytes = 81,920,000,000 bytes = 81.92 GB

The 4,096-dimensional model may be two points better on the leaderboard. It also does not fit in the memory of the machine you were planning to use, which converts a two-point gain into a re-architecture. (Quantizing to int8 divides each of those by four — 81.92 GB becomes 20.48 GB — at a small and measurable accuracy cost that you should measure rather than assume.)

Encoding throughput. A 110-million-parameter encoder and a 7-billion-parameter encoder differ by roughly 64× in parameters and, in practice, by a similar factor in tokens per second per GPU. Re-embedding five million chunks is an afternoon with one and a multi-day job with the other — and you will re-embed every time you change the model, so this cost recurs.

Query latency. The document side is amortized; the query side is not. Every user query runs the encoder in the request path. A large encoder can add tens of milliseconds before the vector search even starts.

The comparison the leaderboard will not draw for you. Score per gigabyte, and score per millisecond. A model that is 1.5 points worse and four times smaller is usually the correct engineering choice, and it will never be at the top of a table sorted by score. Some models publish Matryoshka-style embeddings that can be truncated to a shorter prefix with graceful degradation — take a 1,024-dimensional model down to 256 dimensions, keep most of the quality, and quarter the index. When that option exists, evaluate the truncated dimension you will actually deploy, not the full one on the leaderboard.

Confound 3: sequence length

Every encoder has a maximum input length in tokens — commonly 512 for the BERT-derived family, 8,192 for the modern long-context ones. Text beyond the limit is silently truncated. No exception, no warning, just a shorter vector than you thought.

The benchmark cannot catch this, because benchmark passages are short. MS MARCO passages average roughly 55 words; almost nothing in the retrieval family approaches 512 tokens. So a 512-token model and an 8,192-token model are evaluated on identical inputs and the limit never binds.

Now your corpus. Suppose you chunk at 1,000 characters with 200 characters of overlap, and your text averages about 4 characters per token:

1,000 characters ÷ 4 ≈ 250 tokens per chunk — comfortably under 512, fine

But your API reference pages are code-heavy, and code tokenizes far worse — often 2 to 2.5 characters per token because identifiers split into pieces:

1,000 characters ÷ 2.2 ≈ 455 tokens, and a 1,400-character chunk ≈ 636 tokens — truncated

The truncated portion is invisible: the chunk still embeds, still gets indexed, still gets retrieved occasionally. It simply cannot be found by anything that was said in its second half. This produces exactly the kind of bug that takes three weeks to diagnose, because the symptom is “search is a bit worse for some pages” and there is no error anywhere.

The five-minute check. Tokenize your entire corpus with the model's own tokenizer and print the percentiles of chunk length in tokens. If p95 is above the model's limit, either re-chunk or pick a different model. This costs one script and it catches a silent failure that no benchmark will ever show you.

Confound 4: the model was evaluated with a prompt you are not using

Several strong embedding families are asymmetric by convention: they were trained with a short instruction prefix distinguishing queries from documents, and their benchmark numbers were produced with those prefixes in place. The E5 family uses "query: " and "passage: "; the BGE family uses an instruction on the query side; instruction-tuned embedders take a task description as an argument.

python
# what the benchmark ran
q = model.encode("query: how do I reset my password")
d = model.encode("passage: To reset your password, open Settings ...")

# what you shipped, because the prefixes felt like boilerplate
q = model.encode("how do I reset my password")
d = model.encode("To reset your password, open Settings ...")
# -> several nDCG points gone, no error, no warning, no log line

This is the single most common way a team fails to reproduce a published number. The model is not broken and the benchmark is not lying — you are running a different function than the one that was measured. Read the model card, and reproduce one benchmark dataset locally before you trust your integration.

The same applies to pooling (mean-pooling the token vectors versus taking the CLS position) and to normalization (whether vectors are L2-normalized before the dot product). Every one of these is a silent behavioural switch that changes your scores while producing no error.

Confound 5 — and the important one: the noise floor

Every score on a leaderboard is a sample mean over a finite set of queries. It therefore has a standard error, and differences smaller than that error are noise. The leaderboard does not print the error, so you must estimate it.

The arithmetic. Per-query nDCG@10 values are spread widely — many queries score 0 or 1, few land in the middle — so a standard deviation around σ = 0.35 is typical. On a dataset with n = 500 queries:

SE of the mean = σ ÷ √n = 0.35 ÷ √500 = 0.35 ÷ 22.36 = 0.01565 → 1.57 nDCG points

Comparing two models by their independent means, the difference has a larger error:

SE of the difference = √(0.015652 + 0.015652) = 0.01565 × √2 = 0.02214 → 2.21 points
95% interval = 1.96 × 2.21 = ±4.34 points

Read that again: on a 500-query dataset, a four-point gap between two models is inside the noise. Most of the gaps you are agonizing over on a leaderboard are smaller than four points.

The fix: pair your comparisons

There is an enormous amount of signal being thrown away above. The two models are being run on the same queries, so the query-to-query difficulty variation — which is most of that σ = 0.35 — is common to both and should cancel. Compute the per-query difference and take the mean of that instead.

di = nDCGA(qi) − nDCGB(qi),   report mean(d) with the standard error of d

Because the two systems agree on most queries, most di are exactly zero and the spread collapses. A typical paired standard deviation is σd = 0.15:

SE = 0.15 ÷ √500 = 0.15 ÷ 22.36 = 0.00671 → 0.67 points, 95% interval = ±1.31 points

The same 500 queries went from resolving 4.3 points to resolving 1.3 points — a 3.3× improvement in sensitivity, for zero additional data, purely by not discarding the pairing. This is the highest-return change you can make to an evaluation harness.

How many queries do you need?

Turn it around. You want to reliably detect a 3-point difference (δ = 0.03). For a paired two-sided test at 95% confidence with 80% power, the required sample size is

n = (zα/2 + zβ)2 · σd2 ÷ δ2 = (1.96 + 0.84)2 · σd2 ÷ δ2
(2.80)2 = 7.84    with σd = 0.15:   7.84 × 0.0225 ÷ 0.0009 = 7.84 × 25 = 196 queries
with a noisier σd = 0.20:   7.84 × 0.04 ÷ 0.0009 = 7.84 × 44.44 = 349 queries

Two hundred to three hundred and fifty queries. That is the entire answer to “how big does my evaluation set need to be”, and it is a number you can reach in a day of annotation. Chapter 7 is about spending that day well.

The noise floor: when is a gap real?

Two models, a true 3-point difference, and the 95% interval around each estimate. Slide the number of queries and watch the intervals shrink; press the button to switch between comparing independent means and comparing per-query paired differences. The verdict line tells you whether a gap this size is distinguishable from noise at this sample size.

queries in the eval set200
true gap (nDCG points)3.0

One more trap: 56 chances to be surprised

If you compare two models across 56 datasets and test each one for significance at the 5% level, then even if the models were identical you would expect

56 × 0.05 = 2.8 datasets showing a “significant” difference by chance alone

So “model X beats model Y on three datasets” is precisely what pure noise looks like. If you want to make per-dataset claims, correct for multiple comparisons or, more practically, ignore per-dataset differences entirely and only make claims about the family means you care about.

The five-question checklist

QuestionIf the answer is bad
What is the minimum score in my task family, not the mean?the model has a collapse mode you have not found yet
What does this model cost per query and per gigabyte of index?a two-point gain becomes a re-architecture
What is the token-length p95 of my chunks under this model's tokenizer?silent truncation
Does this model need query/passage prefixes or a specific pooling?you are running a different function than the one measured
How many queries produced this number, and is the gap larger than 2 × its standard error?you are chasing noise

When the normal approximation is a lie: bootstrap instead

Everything above assumed the mean of per-query differences is approximately normal. With 300 queries that is usually fine, but per-query nDCG is a lumpy distribution — a spike at 0, a spike at 1, and a scatter in between — so it is worth knowing the assumption-free alternative.

The bootstrap replaces the formula with resampling. You have 300 paired differences. Draw 300 of them with replacement, take the mean, and write it down. Do that ten thousand times. You now have a distribution of ten thousand plausible means, and the 2.5th and 97.5th percentiles of that distribution are your 95% interval — no normality, no standard-error formula, no degrees of freedom.

python
import random, statistics
d = [ndcg_new[q] - ndcg_old[q] for q in queries]     # 300 paired differences
means = []
for _ in range(10_000):
    sample = [random.choice(d) for _ in d]           # resample WITH replacement
    means.append(sum(sample) / len(sample))
means.sort()
lo, hi = means[250], means[9750]                     # the 2.5th and 97.5th percentiles

Two details that matter. Resample queries, not individual judgments — the query is your unit of independence, and resampling within a query pretends you have more data than you do. And resample the differences, not each system separately, for exactly the pairing reason above.

The sign test: the comparison that assumes almost nothing

Sometimes you do not even want to average, because a single catastrophic query can drag a mean around. Then count instead. On each query, does the new system win, lose, or tie?

300 queries → 62 wins, 38 losses, 200 ties

Ties carry no information about direction, so discard them and ask: out of the 100 queries where something changed, is 62 wins more than a coin flip would give? Under the null hypothesis the count of wins is a binomial with n = 100 and p = 0.5:

mean = 100 × 0.5 = 50    standard deviation = √(100 × 0.5 × 0.5) = √25 = 5
z = (62 − 50) ÷ 5 = 12 ÷ 5 = 2.40  →  two-sided p ≈ 0.016

So the new system wins significantly more often than it loses. Notice what this test does not need: no distributional assumption, no variance estimate, not even the size of each win. It answers a narrower question — “does it win more often?” rather than “is it better on average?” — and those two questions genuinely can disagree.

When they disagree, that is the finding. A system that wins on 62 queries and loses on 38, yet has a negative mean difference, is a system that makes many small improvements and a few disasters. That profile is usually worse for a product than the reverse, because users remember the disasters. Report both the paired mean and the win/loss/tie counts — the pair is far more informative than either alone.

Reporting a comparison so nobody can misread it

One template, five lines, and it prevents most of the arguments this chapter is about. Every retrieval comparison you publish internally should look like this.

text
candidate  bge-style-v2      vs incumbent  e5-style-v1
eval       golden-v4 · 312 queries · pooled top-10 from 3 systems · judged 2026-07
metric     nDCG@10 · gain 2^rel-1 · rel>=1 for binary · 6 queries with no relevant docs skipped
result     paired mean +0.021  (95% CI +0.008 to +0.034)   ·  wins 71 / losses 44 / ties 197
secondary  recall@100 +0.004 (CI -0.006 to +0.014)  ·  hit rate@5 +0.018  ·  worst query -0.44

Read what each line prevents. Line 2 stops anyone comparing against a different judgment version. Line 3 stops the gain-scheme and threshold ambiguity from Chapter 4. Line 4 gives a paired interval and the win/loss counts, so a reader can see both the average and the shape. Line 5 shows that recall barely moved — so this is an ordering win, not a retrieval win — and names the single worst regression, which is the query somebody will inevitably ask about.

That last field matters more than it looks. A mean improvement with a −0.44 worst case is a system that broke something badly for somebody. Find that query, look at it, and decide deliberately whether you are willing to trade it. A report that shows only the mean lets you ship that trade without noticing you made it.

You evaluate two embedding models on the same 500-query set and see a 2-point nDCG@10 difference. What is the right conclusion?

Chapter 6: Contamination and Leaderboard Overfitting

Everything so far assumed the benchmark was measuring honestly and you were reading it carelessly. This chapter reverses that. Even read perfectly, a public benchmark degrades over time — not because anyone cheats, but because of two mechanisms that operate automatically the moment a leaderboard becomes something people optimize.

The two mechanisms are different, they compound, and both are quantifiable.

Mechanism 1: contamination — the test distribution is in the training set

Contamination is when material from the evaluation appears, in some form, in the training data. For embedding models it comes in three strengths, and only the strongest one feels like cheating.

Strength 1: training on the official training split. Almost every retrieval dataset ships a train split and a test split. MS MARCO, Natural Questions, HotpotQA, FEVER, Quora — these are the workhorses of open embedding training. It is entirely legitimate to train on a train split. But BEIR's whole premise is zero-shot evaluation, and a model trained on MS MARCO triples is not zero-shot on MS MARCO's test set, nor really on anything derived from the same annotation process.

of the 15 retrieval datasets in the English benchmark, a substantial fraction have public training splits

The consequence is not that the numbers are fake. It is that the numbers on those datasets measure in-domain performance while presenting themselves as zero-shot, and in-domain performance is exactly the quantity Chapter 2 showed does not transfer.

Strength 2: synthetic data shaped like the benchmark. A now-standard recipe is to prompt a large language model to generate query-passage training pairs. If you generate them in the style of the benchmark's tasks — short web-style questions over Wikipedia passages, duplicate-question pairs, claim-evidence pairs — you have not touched a single test item, and you have still trained specifically on the benchmark's distribution. This is the most common form today and it is nearly undetectable from the outside.

Strength 3: the test items themselves. Benchmark corpora are public documents on the internet. Pretraining corpora are scraped from the internet. The intersection is not empty. Nobody intends this and everybody has some of it.

Put a number on the inflation

Model the effect simply. Let c be the fraction of evaluation items that are contaminated, m the accuracy on those (near-memorized, so high), and g the true generalization accuracy on clean items. The observed score is the mixture:

observed = c · m + (1 − c) · g

Take a model whose true generalization is g = 0.60, whose memorized items score m = 0.97, with c = 25% contamination:

observed = 0.25 × 0.97 + 0.75 × 0.60 = 0.2425 + 0.4500 = 0.6925

The leaderboard says 69.25. Your corpus, which contains none of the contaminated items, delivers 60.00. A 9.25-point gap, and every number in that calculation is plausible.

Invert it to check: given observed 0.6925 with known c = 0.25 and m = 0.97,

g = (0.6925 − 0.25 × 0.97) ÷ 0.75 = (0.6925 − 0.2425) ÷ 0.75 = 0.4500 ÷ 0.75 = 0.60

The fingerprint you can actually look for

You cannot audit a model's training data. You can look at the shape of its gains. Suppose a new model improves the retrieval family by 4.5 points over the previous generation. Split the 15 retrieval datasets into those with public training splits (say 9 of them) and those without (6):

gain on the 9 with training splits: +7.2    gain on the 6 without: +0.4
reported family gain = (9 × 7.2 + 6 × 0.4) ÷ 15 = (64.8 + 2.4) ÷ 15 = 67.2 ÷ 15 = +4.48 points

The headline says “+4.5 nDCG on retrieval”. Your corpus has no training split and never will, so you are on the +0.4 branch. This is not a hypothetical — it is precisely the shape of the disappointment in Chapter 0, and you can check for it yourself by reading the per-dataset columns and sorting them by whether a training split exists.

The generalized rule. When a model's improvement is concentrated on the datasets it could have trained on and flat on the ones it could not, the improvement is adaptation, not capability. Adaptation does not travel to your corpus. Capability does. The per-dataset table tells you which one you bought.

Mechanism 2: leaderboard overfitting — the winner's curse

The second mechanism requires no contamination at all. It happens even if every model is trained honestly on clean data, and it is pure statistics.

Suppose a thousand teams each build a model. Every model's true quality is identical — call it 60.0. The benchmark measures each one with independent noise of standard deviation 1.0 point. Now sort by measured score and look at the top row.

The top row is not the best model. It is the model whose noise draw was luckiest. The expected maximum of n independent standard normal draws grows like

E[max of n draws] ≈ √(2 ln n)
n = 1,000: √(2 × 6.9078) = √13.8155 = 3.72 standard errors

That leading term overshoots slightly; the standard refinement subtracts a correction term:

3.72 − (ln ln 1000 + ln 4π) ÷ (2 × 3.72) = 3.72 − (1.933 + 2.531) ÷ 7.43 = 3.72 − 0.60 = 3.12

So with a 1-point standard error, the model at the top of a thousand-entry leaderboard is expected to be about 3.1 points above its own true quality — entirely from selection, with no misconduct anywhere. For a hundred entries the inflation is about 2.4 points; for ten, about 1.5.

This is why the top of a leaderboard regresses. When you take the number-one model and measure it on a fresh evaluation, it drops — not because it got worse, but because you removed the selection that put it there. The effect is called regression to the mean, and it means the expected disappointment is largest for exactly the model you are most tempted to pick.

And this analysis was for a single round. Real leaderboards run continuously: teams submit, see the score, adjust, resubmit. That feedback loop is adaptive overfitting, and it inflates faster than the one-shot calculation above, because information about the test set leaks into every subsequent design decision — architecture choices, data mixtures, pooling strategies, which of five checkpoints to publish.

Reported score versus what you will actually get

The bar decomposes a reported benchmark score into three parts: the model’s true generalization ability, the lift from contaminated items, and the lift from selection at the top of a crowded leaderboard. Slide the contamination fraction and the number of competing submissions, and watch the gap open between what the table says and what your corpus will deliver.

contaminated fraction of eval0.25
competing submissions1000

Goodhart, stated precisely for benchmarks

“When a measure becomes a target, it ceases to be a good measure.” The precise version for embedding benchmarks is worth writing out, because it tells you exactly which claims survive.

ClaimStill valid?Why
“Model X scores 66.8 on this benchmark”yesit is a reproducible measurement of a defined procedure
“Model X is better than model Y at these 56 datasets”mostlysubject to the noise floor of Chapter 5
“Model X is a better embedding model”nothe benchmark has become a training target; the score now measures adaptation as well as capability
“Model X will be better on my corpus”nonever followed, even before contamination — see Chapter 0

Note the first row. Benchmark scores remain perfectly good measurements. What they have lost is their predictive validity — the property that made them useful for choosing. Those are different things and conflating them is the entire error.

What the community does about it, and what you should do

The field's responses are structural and worth knowing, because they tell you which benchmark generation you are reading.

ResponseWhat it fixesWhat it does not
Scale up: hundreds of tasks, many languages (MMTEB)harder to overfit everything at once; dilutes any single contaminated setthe popular subsets still get targeted
Rank-based aggregation (Borda) instead of score meansstops wide-spread tasks from dominating the orderingcontamination on individual tasks
Held-out or private test splitsdirect contamination and adaptive overfittingrequires trust in the holder; cannot be audited
Datasets built after model training cutoffspretraining leakage specificallyexpires the moment the next generation trains
Your own private eval setall of the above, for your taskcosts you a day and must be maintained

The last row is the only one you control, and it is the only one guaranteed to be uncontaminated, because you made it out of your own logs and never published it. Everything in Chapter 7 is about building it.

The three-line audit you can run this week. (1) Read the candidate model's card and list which benchmark datasets appear in its training data. (2) Look at its per-dataset retrieval scores and check whether the gains cluster on exactly those datasets. (3) Measure it on 200 of your own queries. If step 3 disagrees with the leaderboard, step 3 is right, because step 3 is the only measurement of the thing you actually deploy.

The benchmark half-life

Both mechanisms grow with time, which means a benchmark's usefulness has a shape you can reason about.

Age of the benchmarkWhat the score mostly measuresHow to use it
0–6 monthscapability — almost nobody has trained toward it yettrust the ordering; it is genuine signal
6–18 monthscapability plus adaptation to the popular subsetsread per-dataset, discount the datasets with training splits
18 months and beyondlargely adaptation; the top rows cluster within the noise flooruse it to shortlist, never to decide; check whether a newer generation exists

This is why benchmark generations exist at all. When the top twenty entries sit within two points of each other — well inside the four-point noise band from Chapter 5 — the instrument has stopped resolving differences and someone builds a harder one. Knowing which generation you are reading is part of reading it.

The canary set: an eval that cannot have been trained on

There is one construction that defeats pretraining leakage completely, and it costs nothing beyond discipline: build your evaluation from documents that did not exist when the model was trained.

1. Find the cutoff
Model cards state a training data cutoff. Take it, then subtract three months for safety, because scrapes lag.
2. Take only newer documents
Pages created after that date: recent changelogs, new features, new error codes, new product names. Your docs site produces these continuously.
3. Take only newer queries
Log queries from after the same date, so neither side of the pair could have been memorized.
4. Compare against the full set
If a model scores far worse on the canary slice than on your general set, the gap is a direct estimate of how much of its score was memory.

Suppose a candidate model scores 0.68 nDCG@10 on your general evaluation and 0.59 on the post-cutoff canary slice, while your incumbent scores 0.65 and 0.64. Read those four numbers carefully:

candidate: 0.68 → 0.59, a drop of 0.09     incumbent: 0.65 → 0.64, a drop of 0.01

The candidate looked three points better and is five points worse on documents it cannot have seen. The canary slice found in one afternoon what a quarter of production would have found the slow way.

The canary expires, and that is fine. Once the next model generation trains, today's canary documents are in its pretraining data and the slice loses its power. Rebuild it every few months from the newest content. Your product ships new documentation constantly, so the raw material is free.

How far can adaptive rounds push it?

The winner's-curse calculation assumed a single round of a thousand submissions. Real development is iterative: try ten variants, keep the best, try ten more informed by what you learned. How much can that compound?

An upper bound is easy and useful. Ten rounds of ten variants, in the worst case where every variant is an independent draw, is no worse than selecting the best of one hundred:

√(2 ln 100) = √(2 × 4.605) = √9.210 = 3.03, refined to about 2.37 standard errors

So the compounding is bounded, and bounded by a number smaller than the thousand-submission case. The real danger is not the arithmetic — it is that the variants are not independent. Each round's design choices are informed by the previous round's test-set feedback, which is precisely the information leak that makes a held-out set stop being held out.

Auditing a model card in ten minutes

You cannot inspect training data, but model cards and papers usually list dataset names, and that is enough for the audit that matters. Here is the checklist, in order of how much it tells you per minute spent.

Look forWhat it meansYour move
Named benchmark datasets in the training mixtureThose benchmark rows are in-domain, not zero-shotDiscount them; read the remaining rows
“Synthetic data generated for retrieval tasks” with no detailDistribution-level adaptation you cannot quantifyRely entirely on your own eval
A stated training-data cutoffYou can build a canary slice from newer documentsDo exactly that (below)
Required query/document prefixes or instructionsReproducing the published number needs themReproduce one dataset locally before trusting anything
Per-dataset scores published, not just averagesThe author is being transparent; you can compute the fingerprintSort by “has a public training split” and compare the two groups
No training data described at allNo audit is possibleTreat every published number as unverified

The workhorse datasets to watch for — the ones whose training splits appear in nearly every open embedding recipe — are MS MARCO, Natural Questions, HotpotQA, FEVER, Quora duplicate questions, and various StackExchange dumps. When a model's training list contains those and its benchmark gains are concentrated on the corresponding test sets, you have found the fingerprint from the previous section without needing any privileged information.

What a benchmark still buys you

Six sections of caveats can read as “benchmarks are worthless.” They are not, and being precise about their remaining value is part of using them well.

Still trueWhy it survives contamination and selection
The field has genuinely improvedThe gap between a 2020 model and a current one is far larger than any plausible inflation; a 20-point move is not a winner's curse
A model scoring near the bottom is genuinely weakSelection inflates the top; nothing inflates the middle or the bottom
Coarse capability differences are realMultilingual versus English-only, 512 versus 8,192 tokens, instruction-tuned versus not — these show up as large, structural gaps
The per-dataset tables are dataEach cell is a reproducible measurement; it is the aggregate and the ordering at the top that lose meaning
New task families expose new blind spotsEvery benchmark generation has found real failures nobody knew about, which is the actual point of building them

So the correct posture is neither trust nor dismissal. A benchmark is a coarse filter with a resolution of maybe five points and a systematic bias toward whatever it has been optimized against. Use it at that resolution: to cut twenty candidates down to three. Then switch instruments.

A thousand teams submit models of genuinely identical quality to a leaderboard whose measurement noise has a standard error of 1 point. How far above its true quality is the top-ranked model expected to sit, and why?

Chapter 7: Building Your Own Evaluation

Everything so far has been diagnosis. This chapter is the cure, and it is smaller than you fear: roughly three hundred queries, a written definition of relevance, one week of one person's time, and a script. After that you never have to guess about an embedding model again, because you can measure.

We will build it in six steps, with the arithmetic for each so you can plan the week honestly.

Step 1: choose the queries — and stratify them

Your queries come from your logs. That part is easy. The mistake is sampling them uniformly at random, because query traffic is brutally skewed: a small number of head queries account for a large share of volume, while the tail — the long list of queries asked once ever — is where search actually fails.

Suppose your traffic splits like this and you sample 300 queries proportionally to volume:

SegmentShare of volumeDistinct queriesProportional sampleStratified sample
Head (top 100 queries)30%1009060
Torso40%~8,000120120
Tail (asked once)30%~200,00090120

Proportional sampling puts 90 of your 300 slots on a set of only 100 distinct queries, most of which already work — they are head queries precisely because people found what they wanted and kept asking. You have spent 30% of your budget measuring the part of the system that is not broken.

Stratify instead: fix a quota per segment, over-weight the tail, and add a fourth bucket that pure sampling will never give you — the known failures. Zero-result queries, queries followed immediately by a reformulation, sessions that ended without a click. Those are your product's actual bug reports, and thirty of them in an evaluation set is worth three hundred head queries.

Include the queries that embarrassed you. Every search team has a folder of screenshots from angry colleagues. Those belong in the golden set permanently. A metric that cannot go down when a known-bad case regresses is not protecting you from anything.

Step 2: pool the candidates — you cannot judge the whole corpus

For each query, relevance is defined over the entire corpus — forty thousand chunks. Judging all of them is 12 million judgments and is not happening. The standard technique is pooling: run every system you are considering, take each one's top k, and judge the union.

Concretely, with three systems (BM25, your current model, the candidate model) and k = 10:

3 systems × 10 results = 30 slots per query
with roughly 40% overlap between systems → about 18 unique documents per query
300 queries × 18 = 5,400 judgments

Add a 20% double-judged subset for measuring agreement (Step 4) and budget the time:

5,400 × 1.2 = 6,480 judgments  ×  20 seconds each = 129,600 seconds
129,600 ÷ 3,600 = 36 hours ≈ one person-week

That is the whole price. One person-week buys you a permanent, private, uncontaminated instrument that answers every future model question in an hour.

Pooling has the bias you met in Chapter 3. A document nobody in the pool retrieved is unjudged, and unjudged counts as zero. So a genuinely novel fourth system will be under-measured against this set. The fix is procedural, not statistical: whenever you add a candidate system, pool and judge its new documents before you compare. Judging 200 extra documents is an hour. Skipping it silently penalizes exactly the system that behaves differently — which is usually the interesting one.

Step 3: write the judgment protocol before anyone judges anything

This is the step teams skip and it is the step that determines whether the numbers mean anything. “Is this document relevant?” is not a question — it is four different questions that different annotators will answer differently. Write down the grades, with anchor examples from your own corpus.

GradeDefinitionAnchor example (docs site)
3 — perfectAnswers the query completely and directly; the user is doneQuery “webhook retry policy” → the Webhook Retries reference section with the backoff table
2 — highly relevantContains the answer but the user must read around it or combine two factsThe Webhooks overview page, which mentions retries in one paragraph
1 — marginalSame topic, does not answer; a reasonable second resultThe Webhook Signature Verification page
0 — not relevantDifferent topic, or same words in a different senseThe Retries section of the outbound HTTP client guide

Then answer the edge cases in writing, because they will come up on the first afternoon and every annotator will decide differently in the moment:

Edge caseRule
Correct but for an old version of the productGrade 1. It is the right topic and the wrong answer.
Correct but in a language the user did not useGrade 0 for this product. Write down that it is a policy choice.
A duplicate chunk of an already-graded documentSame grade. Deduplicate at index time, not at judgment time.
The query is ambiguous (two readings)Grade for the more common reading; flag the query for a separate ambiguity report.
The document is relevant only because it links to the answerGrade 1. A link is not an answer.

This document is your relevance relation — the same object that Chapter 2 showed differs between BEIR datasets and is the deepest reason scores do not transfer. Writing it down converts an unspoken assumption into a testable artifact.

Step 4: measure agreement, because your labels are not ground truth

Two annotators will disagree. You need to know how much, because disagreement is the resolution limit of your entire evaluation: no system difference smaller than the label noise is measurable.

Raw agreement is not enough, because two annotators who both say “not relevant” 90% of the time will agree 82% of the time by pure chance. The correction is Cohen's kappa:

κ = (po − pe) ÷ (1 − pe)

where po is the observed agreement and pe is the agreement expected if both annotators labelled independently at their own base rates. Kappa is 1 for perfect agreement, 0 for chance-level, and negative for systematic disagreement.

Worked example one. Two annotators double-judge 100 query-document pairs as relevant / not:

B: relevantB: notA total
A: relevant30838
A: not75562
B total3763100
po = (30 + 55) ÷ 100 = 0.850
pe = (0.38 × 0.37) + (0.62 × 0.63) = 0.1406 + 0.3906 = 0.5312
κ = (0.850 − 0.5312) ÷ (1 − 0.5312) = 0.3188 ÷ 0.4688 = 0.680

On the conventional scale, 0.61–0.80 is “substantial”. This is a usable golden set.

Worked example two — the trap. Now a different pair of annotators, on a pool where almost nothing is relevant:

B: relevantB: notA total
A: relevant336
A: not29294
B total595100
po = (3 + 92) ÷ 100 = 0.950
pe = (0.06 × 0.05) + (0.94 × 0.95) = 0.0030 + 0.8930 = 0.8960
κ = (0.950 − 0.8960) ÷ (1 − 0.8960) = 0.0540 ÷ 0.1040 = 0.519

Ninety-five percent raw agreement, and a kappa of 0.52 — worse than the pair who agreed only 85% of the time. Almost all of that 95% is two people independently saying “no” to obviously irrelevant documents, which is not evidence that they share a definition of relevance. Whenever you hear “our annotators agree 95% of the time”, ask for the kappa.

What to do when kappa is low. Do not add more annotators — add more protocol. Pull twenty disagreements, sit the annotators down together, and find which edge case they are resolving differently. It is almost always one specific rule that was never written. Add it to the protocol, re-judge, re-measure. Kappa is a diagnostic for your guidelines, not a grade for your people.
Agreement, chance, and kappa

A live 2×2 confusion matrix between two annotators over 100 double-judged pairs. Slide the prevalence of “relevant” and the rate at which they disagree, and watch raw agreement stay high while kappa collapses. The bar underneath shows how much of the observed agreement is explained by chance alone.

prevalence of “relevant”0.38
disagreement rate0.15

Step 5: scale the labelling with a judge you have calibrated

Five thousand judgments is one person-week. Fifty thousand is not. The modern answer is a language model as a relevance judge — and the honest version of that answer requires exactly the machinery you just built.

Have the model judge the same double-judged subset your humans did, and compute the same kappa. Suppose you get:

human ↔ human: κ = 0.680     model ↔ human: κ = 0.610

The model agrees with a human nearly as well as a second human does. That is the standard that licenses using it at scale — not “the outputs looked reasonable.” Below that bar, use the model only to pre-filter: let it grade everything, have humans review only the items it grades 2 or 3, which is where the metric's mass lives.

And keep a human-only slice forever. If the judge model changes — a version bump, a prompt edit — your entire history of scores shifts under you, and only the frozen human slice will tell you.

Step 6: the minimum viable RAG evaluation

For a retrieval-augmented generation stack, evaluate two layers separately. This is the single most useful structural decision, because it tells you which half is broken.

Layer 1 — retrieval
300 queries, pooled graded judgments. Report recall@k (did the answer enter the window) and nDCG@k (is it near the top). Cheap, fast, deterministic, runs in seconds.
↓ if and only if layer 1 passes
Layer 2 — generation
Same queries, judged answers: faithful to the retrieved context, complete, correctly abstains when the context lacks the answer. Slow, expensive, noisy.

The reason for the ordering is arithmetic. If recall@5 is 0.72, then 28% of queries cannot be answered correctly no matter how good the generator is — the evidence was never in the context. Tuning prompts against that ceiling is wasted work. Fix the retriever first; the retrieval layer is also a hundred times cheaper to evaluate, so you can run it on every commit.

Step 7: turn it into a gate

An evaluation you run when you remember is not protection. Wire it into the change process for anything that touches retrieval — the embedding model, the chunking strategy, the index parameters, the query rewriting, the reranker.

python
# the regression gate — paired, because Chapter 5
d = [ndcg(new, q) - ndcg(old, q) for q in golden_queries]   # len 300
mean_d = sum(d) / len(d)
se     = stdev(d) / len(d)**0.5
lo, hi = mean_d - 1.96*se, mean_d + 1.96*se

if hi < 0:      fail("significant regression")          # whole interval below zero
elif lo > 0:    pass_("significant improvement")
else:          warn(f"inconclusive: {mean_d:+.3f} +/- {1.96*se:.3f}")

# and always, separately, the cases you are not allowed to break
assert all(rank_of_gold(new, q) <= 3 for q in must_never_break)

Note the third branch. Most changes will be inconclusive, and that is the correct and honest output — it means the change is smaller than your instrument can resolve, so ship or do not ship on other grounds (cost, latency, simplicity) rather than pretending the number decided.

And note the last line. The must_never_break list is a handful of queries whose answers are non-negotiable — your top support articles, your pricing page, the error codes that generate tickets. They get a hard assertion, not a statistical test. An average can absorb a catastrophe on one query; a business cannot.

The whole chapter in one paragraph. Three hundred stratified queries, pooled candidates from every system you compare, a written grade rubric with anchors from your corpus, a measured kappa on a double-judged fifth, two layers reported separately, paired significance on every change, and a small list of queries that are allowed to fail exactly never. One person-week to build. It will outlive three embedding models.

Growing the set without corrupting it

A golden set is not a one-time artifact; it decays. Your corpus changes, your product adds features, and your query mix drifts. Three rules keep it honest as it grows.

Rule 1 — version everything, and re-score history when you change it. The judgments are the denominator of every nDCG you have ever recorded (that is what IDCG is). Adding documents or upgrading grades silently invalidates your history. Tag the judgment set with a version, store it, and when you bump the version, re-run every archived system against the new one before comparing.

Rule 2 — add queries on a schedule, not on a whim. Adding a query because a model failed it makes the set an adversarial collection against that model, and your gate stops representing your traffic. Add quarterly, sampling by the same stratification you used originally, plus any new must-never-break cases.

Rule 3 — never publish it. The instant your evaluation set is public, it is training data for somebody, and the contamination story of Chapter 6 becomes your story. Share the aggregate numbers; keep the queries and judgments internal.

The anti-patterns, named

Anti-patternWhy it breaks
Judging with the retrieved order visibleAnnotators anchor on rank; a document at position 1 gets graded higher than the same document at position 9. Shuffle before judging.
Letting the model author judge their own model's resultsGrades drift toward whatever the model returned. Judge documents per query, blind to which system produced them.
Judging only the top 5Your pool is shallower than your metric's cutoff, so every position 6–10 counts as 0 and every system looks worse than it is.
Using click logs as ground truthClicks measure what your current ranker showed, so the labels encode the system you are trying to improve. Useful as a signal, fatal as a label.
One annotator, no kappaYou have no idea whether the labels are reproducible, so you cannot say what your instrument can resolve.
Reporting a single mean with no intervalChapter 5. Most of the gaps you will report are inside the noise.

The click-log row deserves a second sentence because it is the most tempting shortcut in the list. Click data is abundant, free and continuously refreshed — and it is generated entirely from documents your current system chose to show. A document your retriever never surfaces gets zero clicks, so it looks irrelevant, so any model trained or evaluated on clicks learns to reproduce the current system. It is a feedback loop with your own ranker, which is the one thing an evaluation must not be.

Your two annotators agree on 95% of double-judged pairs, but Cohen's kappa is only 0.52. What does this mean?

Chapter 8: The nDCG Playground

This is the payoff chapter. Everything you derived in Chapters 3 and 4 is now a thing you can grab with your fingers. Ten documents, graded 0 to 3 by an imaginary annotator, in a ranking you control. Drag any document to any position and every metric recomputes live.

The goal is not to play. The goal is to build the intuition that lets you look at a two-point nDCG difference in a pull request and know, without computing anything, roughly what changed in the ranking.

How to use it. Press and hold any row, drag it up or down, release. The rank numbers stay put; the documents move between them. The horizontal bar inside each row is that position's contribution to DCG — gain times discount — so you can see the score being assembled. The cutoff slider changes k, which changes both DCG and IDCG.
Drag the ranking, watch the metrics

Ten documents with fixed relevance grades (3 = perfect, 0 = junk). Drag to reorder. Every metric from Chapters 3 and 4 updates on release: DCG, IDCG, nDCG at your chosen cutoff, plus recall@5, recall@10, MRR and MAP so you can watch which ones move and which ones refuse to.

cutoff k10

Six experiments, and what each one proves

Experiment 1 — the ideal order. Press “ideal order.” nDCG reads exactly 1.000, because DCG now equals IDCG by construction. This is the definition made visible: nDCG is not “how good is this ranking” in the abstract, it is “how close is this ranking to the best one possible for this query's judgments.” A query where only one marginal document exists can still score 1.000.

Experiment 2 — move only the grade-3 document. Put it at rank 1, note the number, then drag it to rank 2, 3, 4, and so on, changing nothing else. Watch the drops:

rank of the grade-3 docits contribution (gain 7 × discount)lost versus rank 1
17 × 1.0000 = 7.0000
27 × 0.6309 = 4.4165−2.5835
37 × 0.5000 = 3.5000−3.5000
57 × 0.3869 = 2.7080−4.2920
107 × 0.28906 = 2.0235−4.9765

Most of the damage happens in the first two steps. Moving from rank 1 to rank 2 costs 2.58 of the 4.98 total possible loss — 52% of the whole penalty is incurred by one position. That is the practical meaning of the logarithmic discount, and it is why reranking the top three is worth so much more engineering than reranking positions 7 through 10.

Experiment 3 — A versus B, the chapter-3 result, live. Press “system A”, read every number, then press “system B.” nDCG@10 jumps from 0.744 to 0.930. Now look at the other four readouts: recall@5, recall@10, MRR and MAP do not move at all. You proved this with arithmetic in Chapter 4; watching it happen is what makes it stick.

Experiment 4 — shrink the cutoff. Set the ranking to something mediocre, then slide k from 10 down to 3. Two things change at once and it is worth separating them. DCG shrinks because you are summing fewer terms. IDCG also shrinks, because the ideal ranking is also truncated at k. So nDCG can move in either direction: if your good documents are early it goes up as k shrinks, and if they are late it collapses.

The reporting consequence. nDCG@3 and nDCG@10 are not comparable and neither is “stricter.” They answer different questions: nDCG@3 asks “is the very top right” and nDCG@10 asks “is the page right.” Choose the k that matches how many documents your downstream consumer actually reads — if your language model gets five chunks, evaluate at 5.

Experiment 5 — the reverse order. Press “worst order.” The number is not 0. It is well above zero, because all the relevant documents are still in the window, just at the bottom, and the logarithmic discount at rank 10 is still 0.289 rather than something near zero. nDCG@10 near 0.4 does not mean “40% correct”; on a window that contains every relevant document it can mean “perfectly backwards.” Metrics are not percentages, and the floor of a metric is a number you should know for your own data before you interpret any score.

Experiment 6 — swap two zeros. Drag a grade-0 document from rank 6 to rank 7 and back. Nothing moves. Every metric is completely blind to the ordering of irrelevant documents — their gain is 20 − 1 = 0, so their discount is multiplied by nothing. Two systems can produce visibly different result pages and score identically if they only disagree about junk. When you are debugging a “why did the score not change” mystery, this is usually the answer.

What to take to work

Three calibrations that this widget installs, which are hard to get any other way.

ObservationWhat it means when you see it in a report
nDCG moved, recall did notan ordering change: the same documents, better arranged. Usually a reranker or a scoring tweak.
recall moved, nDCG barely dida retrieval change: new documents entered the window near the bottom. Usually chunking, the index, or a hybrid weight.
both moved a lot in the same directiona genuinely different retriever — or a bug in your judgment join. Check that document IDs still match before celebrating.

That third row is not a joke. The most common cause of a spectacular overnight metric jump is an identifier mismatch that silently turned every judgment into a miss, or a fresh pooling pass that added judgments the old run never had. When a number moves more than you can explain mechanically, suspect the harness before the model.

Experiment 7 — where the ideal ranking comes from

One thing the widget quietly demonstrates every time you press a button: the IDCG readout never changes. Drag documents all day and it sits at 11.567 (for k = 10). That is not a bug and it is the most commonly misunderstood part of the metric.

IDCG is computed from the judgments, not from your ranking. It is the DCG of the best ordering that any system could possibly produce for this query given what the annotators graded. It changes only when the judgments change or the cutoff changes. Two consequences follow immediately.

First: if you re-judge your golden set — add documents, upgrade a grade from 1 to 2 — every historical nDCG number you have recorded becomes incomparable, because the denominator moved. Version your judgments and re-score old systems when you change them.

Second: a query with a single grade-1 document has an IDCG of exactly 1.000, so putting that one marginal document at rank 1 scores a perfect 1.000. nDCG is graded on a curve, per query. A dataset full of easy queries will report high nDCG for every system and discriminate between none of them, which is one more reason to look at per-query distributions rather than only the mean.

What the playground is lying about

Every simulation simplifies, and being explicit about how is part of teaching honestly. Three ways this one is kinder than reality.

The playground assumesRealityConsequence
All ten documents are judgedMost retrieved documents are unjudged and silently counted as grade 0Your real nDCG is an underestimate, and more so for novel systems (the pooling bias of Chapter 3)
The judgments are correctTwo annotators agree at a kappa around 0.68 (Chapter 7)Label noise puts a ceiling on what any comparison can resolve
One queryYou average over hundreds, and per-query values are wildly bimodalThe mean hides the distribution; a mean move of 2 points can be four queries collapsing and forty improving slightly

None of these make the metric useless. They make it a measurement with an error bar and a bias, which is what every measurement is. The mistake is treating a number computed to four decimal places as though it were exact.

Read the diff: three real scenarios

Practice the diagnosis. Each row is a change you shipped and the metric movement it produced. Cover the right column and work out the cause before reading it.

Metric movementMost likely cause
recall@100 flat, nDCG@10 up 4 pointsYou improved ranking, not retrieval. A reranker, a scoring change, or a query rewrite that reorders the same candidate set.
recall@100 up 6 points, nDCG@10 up 0.5New relevant documents entered the window near the bottom. Usually smaller chunks or a hybrid weight change. The reranker downstream will convert this into user-visible gain; the retrieval-stage number is the honest one.
hit rate@5 flat, nDCG@5 down 3 pointsThe answer is still in the window but has moved down inside it. Often a model that abstracts more (Chapter 0's exact failure) or a chunking change that split the answer across two chunks.
Everything up 15 points overnightNot a model change. Check the judgment join, the document identifiers, and whether someone re-pooled. Metrics do not move that much for real reasons.

Experiment 8 — where the reranker earns its keep

Set the ranking to something realistic — press “shuffle” until the good documents are spread around ranks 3 to 9 — and then read the contribution column down the list. Those numbers are the exact budget a reranker is competing for.

A reranker's job is to permute the ten documents already in the window. The most it can possibly win is the difference between your current DCG and the best arrangement of those same ten:

headroom = DCG(best arrangement of these ten) − DCG(current arrangement)

Press “ideal order” to see the first term, then your arrangement to see the second. If the gap is small, a reranker cannot help you and your problem is retrieval — the right documents are not in the window at all. If the gap is large, a reranker is the cheapest available improvement, because it changes no index and re-embeds nothing.

The diagnostic in one line. Compute nDCG@10 of your system and nDCG@10 of the perfect reordering of your own top ten. The second number is the ceiling a reranker can reach; the distance between them is the size of the prize. Teams routinely spend a quarter on a reranker when that distance was two points.

Predict before you drag

The fastest way to install the intuition is to guess first. Set the widget to “system A” and answer these three before touching anything; the numbers are all computable from the discount table in Chapter 3.

QuestionWork it outAnswer
Swap ranks 1 and 3 (the grade-2 and the grade-3 documents). What does DCG do?rank 1 goes 3×1.000 → 7×1.000 (+4.000); rank 3 goes 7×0.500 → 3×0.500 (−2.000)+2.000, so 8.604 → 10.604
Move the grade-1 document at rank 10 up to rank 9. What happens?rank 9 is grade 0, so the two swap: 1×0.30103 replaces 1×0.28906+0.012 — almost nothing
Move the grade-0 document at rank 2 to rank 10. What happens?every relevant document below it shifts up one ranka real gain: the biggest single-swap improvement available

The third row is the one worth internalizing. Removing an irrelevant document from a high position is not neutral — it promotes everything beneath it. That is why deduplication and junk filtering often buy more nDCG than a better retriever: they are not adding good documents, they are removing the ones that were pushing good documents down.

Experiment 9 — collapse the grades and watch nDCG go blind too

Chapter 4 established that recall, MRR and MAP cannot separate systems A and B because they are binary. It is worth proving that this is a property of binary judgments, not of those particular formulas — because nDCG on binary judgments goes blind in exactly the same way.

Take system A and flatten every grade to relevant / not relevant, so a grade-3 and a grade-1 both become 1:

A graded: [2, 0, 3, 1, 0, 2, 0, 1, 0, 1]  →  A binary: [1, 0, 1, 1, 0, 1, 0, 1, 0, 1]

Now every gain is 21 − 1 = 1, so the DCG is just the sum of the discounts at the relevant positions — ranks 1, 3, 4, 6, 8, 10:

1.00000 + 0.50000 + 0.43068 + 0.35621 + 0.31546 + 0.28906 = DCG@10 = 2.89141

The ideal binary ranking puts all six relevant documents in the first six positions:

1.00000 + 0.63093 + 0.50000 + 0.43068 + 0.38685 + 0.35621 = IDCG@10 = 3.30467
nDCG@10 = 2.89141 ÷ 3.30467 = 0.8749

And system B? Its relevant documents sit at the same six ranks, so its binary grade vector is identical and its nDCG is 0.8749 as well. Under binary judgments, nDCG joins the other four metrics in reporting that two systems eighteen points apart are the same system.

The real conclusion of Chapters 3, 4 and 8. The power to distinguish A from B never lived in the formula — it lived in the judgments. Graded relevance is what makes graded metrics able to see anything. If your annotation protocol only asks “relevant: yes or no”, then computing nDCG instead of MAP buys you nothing but decimal places. Spend the effort on the four-grade rubric in Chapter 7 first; the metric choice is downstream of it.

This also explains a benchmark detail you will now notice. Several BEIR datasets ship only binary judgments, and their nDCG@10 columns are therefore doing less work than the graded ones — they are, in effect, a discounted recall. When you compare two models on such a dataset, you are measuring where the relevant documents landed, never which relevant document landed first.

Choosing k for a RAG stack, concretely

Before the table, one framing sentence that saves arguments. The cutoff is not a property of the metric and it is not a matter of taste — it is a description of your product. Whoever or whatever consumes the ranking reads a fixed number of documents, and k is that number. Everything below is just that rule applied to the four shapes a retrieval consumer usually takes.

The cutoff slider is not a toy setting; it is a decision you have to make once and then live with. Match it to what your generator actually reads.

Your pipelineReport atReason
Top 5 chunks go into the prompt, no rerankernDCG@5 and hit rate@5Position 6 is invisible to the product, so measuring it is measuring nothing
Top 100 retrieved, cross-encoder keeps 5recall@100 for stage one, nDCG@5 for stage twoTwo stages, two jobs, two metrics — a single number cannot tell you which stage regressed
A human scrolls a result page of 10nDCG@10The classic web-search setting the metric was designed for
An agent loops, re-querying until satisfiedhit rate@k over the whole session, plus queries-per-answerThe unit of success is the session, not the single retrieval call

The last row is worth a sentence because it is increasingly the common case and it breaks the framing of this whole lesson in an interesting way. When a system can retry, a bad first retrieval costs latency rather than correctness, and the metric you care about shifts from “was it right” to “how many attempts did it take.” Everything derived here still applies per call — you still need graded judgments, paired comparisons and a private eval — but the number you report to the business becomes a cost, not an accuracy.

In the playground you drag a grade-0 document from rank 6 to rank 7. Every metric stays exactly the same. Why?

Chapter 9: Connections and What to Do Monday

We started with a regression you shipped on a Monday and could not explain. You can explain it completely now, and more usefully, you can prevent the next one. This chapter assembles the pieces into a procedure, marks the limits of what any benchmark can do, and points at where to go next.

The decision procedure

Every embedding model choice, in order. Each step is cheap and each one can end the process.

0. Write down your task
Which probe are you actually running — retrieval, reranking, clustering, classification, deduplication? This single sentence decides which benchmark column exists for you.
1. Build the eval before choosing anything
300 stratified queries, a written grade rubric, pooled judgments, a measured kappa. One person-week. It outlives every model you will try.
2. Measure BM25 on your corpus
The honest baseline. Free, needs no GPU, adapts its term weights to your vocabulary. Every dense candidate must beat it or justify itself some other way.
3. Shortlist by the right column, not the average
Read the family you need, look at its minimum and not its mean, filter by index size, encoder cost, sequence limit and prefix requirements. Three to five candidates, not twenty.
4. Run your eval, paired
Per-query differences against the incumbent, with a 95% interval. Report recall@k and nDCG@k separately so you know whether a change is retrieval or ordering.
5. Ship the pipeline, not the model
Hybrid retrieval fused by rank, then a cross-encoder reranker on the top hundred. This is what topped the zero-shot tables and it is still the answer.
↻ re-run the gate on every retrieval change, forever

Benchmark or your eval? Both, for different jobs

Public benchmarkYour evaluation set
Answers“is the field improving, and which models are worth trying?”“should I ship this change?”
Task distributiontheirsyours
Contamination riskhigh and risingzero, if you never publish it
Relevance definitionsomeone else's, undocumented to youwritten by you, with anchors
Costfreeone person-week, then near zero
Good usegenerating a shortlist of three to five candidateschoosing among them and gating every future change
Bad usesorting by the average and taking row onepublishing it, which makes it contaminable

The benchmark is a screening instrument and your eval is the decision instrument. Using either one for the other's job is the error this whole lesson has been about.

The limits of a single vector

One honest caveat about the entire dense-embedding paradigm, because it bounds what any benchmark can promise.

When you compress a document into one fixed-length vector, you have committed to a fixed information budget. A document that answers many different questions must place all of those answers in one point. As the corpus grows, more documents crowd into any given direction, and the margin between the right answer and the distractors shrinks — this is the mechanism behind the observed difficulty of dense retrieval at very large index sizes. It is not a training problem you can fix with more data; it is a capacity constraint.

The architectural answers are worth knowing because they show up on the same leaderboards:

ApproachIdeaCost
Late interaction (ColBERT)keep one vector per token and score with a max-similarity sum, so the match can be localized to specific wordsindex is 10–100× larger
Learned sparse (SPLADE)predict a weight over the whole vocabulary, keeping BM25's exact-match strength with learned term expansioninverted index, slower queries
Document expansion (doc2query)generate likely queries for each document and index them alongside the textone generation pass per document
Hybrid + reranklet each method do what it is good at, fuse by rank, reorder with a cross-encodertwo indexes, one extra model call
Matryoshka embeddingstrain so that truncating the vector degrades gracefully, letting you trade dimensions for memory after the factnone, if the model supports it

Every one of these is a statement that a single 768-number summary of a document is not always enough. Whenever your evaluation says a strong model is failing on your corpus and you cannot fix it with prompts or chunking, one of these is usually the structural answer.

Six things to remember when everything else fades

1. A leaderboard reports an average over its task distribution. Your task is one point, and it need not lie anywhere near that distribution.

2. The task families demand different geometry — classification only needs linear separability, which is invariant to transforms that destroy cosine similarity. So the average is not a quantity.

3. In-domain performance does not predict zero-shot performance, and BM25 — whose term weights are computed from your corpus — is still the baseline to beat on anything full of rare identifiers.

4. nDCG is the only common retrieval metric that reads grades and position together. Recall, MRR and MAP are binary and will happily report that two visibly different rankings are identical.

5. Pair your comparisons. The same 500 queries resolve 4.3 points unpaired and 1.3 points paired, for no extra data.

6. The top of a crowded leaderboard is inflated by selection even when nobody cheats, and by contamination when the benchmark's training splits are in everybody's training mixture. Your private eval is the only instrument immune to both.

Keep exploring

Vector Embeddings — what these vectors are and how text becomes one
Similarity Metrics — cosine, dot product, Euclidean, and when each is right
Text Chunking — the decision upstream of every retrieval score in this lesson
Embedding Layers — where a token vector comes from inside the model
Vector Databases — indexes, approximate search, and the recall you lose to speed
RAG — the pipeline these metrics are protecting
Multimodal RAG — the same evaluation problem when documents are images and audio
Evaluation Statistics — confidence intervals, bootstrap and significance in depth
Evaluation Plots — how to show a metric difference so it cannot be misread
Experiment Design — power, sample size and stratification beyond the sketch in Chapter 5
The Metrics Ladder — connecting a retrieval metric to a product number an executive cares about
GenAI Evaluation — judging the generation layer once retrieval is fixed
AI Evaluation — the broader evaluation toolkit for model-backed products
Regression Testing for ML — turning the gate in Chapter 7 into infrastructure
Agent Evaluation — the same discipline when the system takes actions
Contrastive Learning & CLIP — the training objective that produced most of these embedders
BERT — the encoder nearly every text embedding model descends from

“What I cannot create, I do not understand.” You can now build this: sample three hundred stratified queries from your logs, pool the top ten from BM25 and every candidate model, write a four-grade rubric with anchor documents from your own corpus, double-judge a fifth of it and compute a kappa, then score every candidate with nDCG@10 and recall@100 computed from the definitions — gains, discounts, ideal ranking, paired differences, ninety-five percent interval — and let that number, not a leaderboard, decide what ships.

References

  1. Muennighoff, Tazi, Magne, Reimers. “MTEB: Massive Text Embedding Benchmark.” EACL, 2023. arXiv:2210.07316
  2. Thakur, Reimers, Rücklé, Srivastava, Gurevych. “BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models.” NeurIPS Datasets and Benchmarks, 2021. arXiv:2104.08663
  3. Enevoldsen et al. “MMTEB: Massive Multilingual Text Embedding Benchmark.” 2025. arXiv:2502.13595
  4. Järvelin, Kekäläinen. “Cumulated Gain-based Evaluation of IR Techniques.” ACM Transactions on Information Systems 20(4), 2002. The paper that introduced DCG and nDCG.
  5. Robertson, Zaragoza. “The Probabilistic Relevance Framework: BM25 and Beyond.” Foundations and Trends in Information Retrieval 3(4), 2009.
  6. Reimers, Gurevych. “Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks.” EMNLP, 2019. arXiv:1908.10084
  7. Karpukhin et al. “Dense Passage Retrieval for Open-Domain Question Answering.” EMNLP, 2020. arXiv:2004.04906
  8. Khattab, Zaharia. “ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT.” SIGIR, 2020. arXiv:2004.12832
  9. Santhanam et al. “ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction.” NAACL, 2022. arXiv:2112.01488
  10. Formal et al. “SPLADE v2: Sparse Lexical and Expansion Model for Information Retrieval.” 2021. arXiv:2109.10086
  11. Nogueira, Lin. “Document Expansion by Query Prediction.” 2019. arXiv:1904.08375
  12. Hofstätter et al. “Efficiently Teaching an Effective Dense Retriever with Balanced Topic Aware Sampling.” SIGIR, 2021. arXiv:2104.06967
  13. Wang et al. “Text Embeddings by Weakly-Supervised Contrastive Pre-training” (the E5 family). 2022. arXiv:2212.03533
  14. Xiao et al. “C-Pack: Packed Resources For General Chinese Embeddings” (the BGE family). 2023. arXiv:2309.07597
  15. Ni et al. “Large Dual Encoders Are Generalizable Retrievers” (GTR). 2021. arXiv:2112.07899
  16. Su et al. “One Embedder, Any Task: Instruction-Finetuned Text Embeddings” (INSTRUCTOR). 2022. arXiv:2212.09741
  17. Kusupati et al. “Matryoshka Representation Learning.” NeurIPS, 2022. arXiv:2205.13147
  18. Reimers, Gurevych. “The Curse of Dense Low-Dimensional Information Retrieval for Large Index Sizes.” ACL, 2021. arXiv:2012.14210
  19. Sciavolino et al. “Simple Entity-Centric Questions Challenge Dense Retrievers.” EMNLP, 2021. arXiv:2109.08535
  20. Bajaj et al. “MS MARCO: A Human Generated MAchine Reading COmprehension Dataset.” 2016. arXiv:1611.09268
  21. Conneau, Kiela. “SentEval: An Evaluation Toolkit for Universal Sentence Representations.” LREC, 2018. arXiv:1803.05449
  22. Cohen, J. “A Coefficient of Agreement for Nominal Scales.” Educational and Psychological Measurement 20(1), 1960. The source of the kappa in Chapter 7.

The one-page runbook

Print this. It is the whole lesson compressed to the things you do rather than the things you know.

WhenDoChapter
Before evaluating any modelWrite down which probe your product runs, in one sentence1
Before evaluating any modelSample 300 stratified queries; over-weight the tail; include known failures7
Before judging anythingWrite the four grade definitions with anchor documents and the edge-case rules7
While judgingDouble-judge 20%; compute kappa; if it is below 0.6, fix the protocol, not the people7
First measurementBM25 on your corpus. This is the number to beat.2
ShortlistingRead the family column and its minimum; check index size, encoder cost, token limit, required prefixes1, 5
Before believing a numberVerify the token-length p95 against the model's limit; reproduce one public dataset locally5
Every comparisonPaired per-query differences with a 95% interval; report win/loss/tie too5
Every candidateScore the post-cutoff canary slice and compare the drop against the incumbent's drop6
Every reportState k, the gain scheme, the relevance threshold, and the empty-query policy3, 4
Every retrieval change, foreverRun the gate; hard-assert the must-never-break queries7
Whenever a number jumps implausiblySuspect the harness before the model: identifiers, joins, re-pooling8

The three sentences to say in a meeting

Most of this lesson is technique. This part is politics, and it is what actually stops the bad decision.

When somebody says “this model is number one on the leaderboard”: “That average is about a quarter retrieval and three quarters things we do not do. What is its score on the retrieval family, and what is its worst dataset in that family?”

When somebody shows a two-point improvement: “Is that paired, and how many queries? On five hundred queries an unpaired two-point gap is inside the noise, and a paired one is not — so which is it?”

When somebody wants to ship on a public number: “We can measure it on our own three hundred queries this afternoon. If it agrees with the leaderboard we have lost an afternoon; if it disagrees we have saved a quarter.”

None of the three requires you to win an argument about architecture. Each one replaces an opinion with a measurement that somebody can go and take, which is the only kind of argument that ends.

Glossary of everything this lesson defined

TermOne-line definitionCh
MTEBA benchmark aggregating many embedding tasks across eight families; its English portion averages 56 datasets0
BEIREighteen heterogeneous retrieval datasets evaluated strictly zero-shot, scored with nDCG@102
ProbeThe fixed procedure a task family runs on frozen embeddings — a linear fit, k-means, a cosine, a search1
Zero-shotThe model gets no training examples from the target dataset, not even its training split2
BM25A lexical scorer combining inverse document frequency, saturating term frequency and length normalization2
Reciprocal rank fusionMerging ranked lists by summing 1/(k + rank), using only positions and never scores2
GainThe value of a relevance grade, usually 2rel − 13
DiscountThe positional weight 1/log2(i+1), a stand-in for the chance a user examines rank i3
DCG / IDCG / nDCGDiscounted sum of gains; the same for the best possible ordering; their ratio3
Pooling biasUnjudged documents count as irrelevant, systematically penalizing systems unlike those in the judgment pool3
recall@kFraction of all relevant documents appearing in the top k; blind to order4
MRRMean of 1 / (rank of the first relevant document)4
MAPMean over queries of the area under the precision-recall curve4
Hit rate@kFraction of queries with at least one relevant document in the top k4
SpearmanPearson correlation computed on ranks; invariant to any monotone transform4
Paired comparisonAveraging per-query differences instead of comparing two independent means5
BootstrapResampling queries with replacement to get an interval with no distributional assumption5
Sign testTesting whether wins exceed losses under a binomial null, ignoring effect size5
ContaminationEvaluation material present in training, in three strengths: train splits, synthetic imitation, direct leakage6
Winner's curseThe top of a leaderboard is inflated because it was selected as the maximum of many noisy draws6
Canary sliceAn evaluation built only from documents created after the model's training cutoff6
Golden setYour private, stratified, hand-judged evaluation queries7
Judgment protocolThe written grade definitions and edge-case rules that make judgments reproducible7
Cohen's kappaAgreement corrected for the agreement two annotators would reach by chance7
Regression gateAn automated paired comparison plus hard assertions, run on every retrieval change7

Where embedding evaluation is heading

Four shifts are visible right now, and each one changes what a benchmark can tell you.

Instructions become part of the input. Instruction-tuned embedders take a task description alongside the text — “represent this document for retrieval of counter-arguments” — which means the relevance relation stops being baked into the weights and becomes an argument at call time. That is exactly the fix for the ArguAna problem in Chapter 2. It also means the benchmark must specify the instruction, and two evaluations of the same model with different instructions are measuring different functions.

Context windows grow. When an encoder accepts eight thousand tokens rather than five hundred, the chunking decision that dominated your retrieval quality starts to dissolve. It does not disappear — a single vector summarizing eight thousand tokens is a harder compression problem, not an easier one — but the silent-truncation failure of Chapter 5 becomes rarer, and the capacity argument of the previous section becomes sharper.

Benchmarks get bigger and rank-aggregated. Hundreds of tasks across hundreds of languages, aggregated by rank rather than by mean, with per-family views promoted over the headline column. This dilutes contamination and stops wide-spread tasks from dominating — and it does not change a single thing about Chapter 0, because your task is still one point.

The judge becomes a model. Relevance labelling at scale increasingly runs through a language model, which makes the calibration step of Chapter 7 — measure the judge against humans with the same kappa you would use for a second annotator — the load-bearing part of the whole pipeline. A cheap judge with an unmeasured agreement rate is not an evaluation; it is a second model's opinion wearing an evaluation's clothes.

What does not change. Every one of those four shifts moves the benchmark. None of them moves your corpus, your queries, or your definition of relevant. Whatever generation of leaderboard you are reading in five years, the procedure in this chapter still starts at step 1: write down your task, then measure it.
You have shortlisted three embedding models from a public leaderboard. What is the correct next step, and why is it the correct one?