Ranking Quality
Measure result order with MRR, MAP, nDCG and ERR, avoid the traps in computing them, compare rankers with paired tests, and tune on a separate split.
SPACED REPETITION · 15 practice questions
Make this lesson stick.
Try 3 questions now. No account needed. Sample answers aren't saved.
or sign in to practice all 15The checklist that outranked the how-to
You run search for a cloud platform's developer docs. The same ranked list feeds the search box on the docs site and the in-product assistant. Last month the team replaced BM25 with hybrid retrieval plus a cross-encoder reranker. The offline report looked great: MRR@10 up sharply and recall@10 no worse. It shipped.
Two weeks later, support traces three production outages to the same search. Each customer typed rotate api key without downtime, opened the first result and did what it said. The first result was the Security checklist: "Rotate API keys every 90 days." It says when, not how. So they revoked the old key, created a new one, and every running service lost access until the next deploy. The page they needed, Rotate API keys with zero downtime (create a second key, deploy it everywhere, then revoke the first), sat right below it at rank 2.
The labels were not the problem. A docs writer had graded this query: 3 for the how-to, 2 for the API-key reference (it says an account can hold two active keys), 1 for the checklist, 0 for the rest. Here are the logged rankings and the two numbers the report used:
GRADES = { # a docs writer's labels for "rotate api key without downtime"
"rotate-keys": 3, # Rotate API keys with zero downtime: the steps
"api-keys": 2, # API keys reference: an account can hold two active keys
"security-checklist": 1, # "Rotate keys every 90 days", no steps
"webhook-secret": 0, "ssh-keys": 0, "maintenance": 0, "billing-api": 0,
}
old = ["webhook-secret", "maintenance", "security-checklist", "ssh-keys",
"api-keys", "billing-api", "rotate-keys"] # BM25
new = ["security-checklist", "rotate-keys", "api-keys", "webhook-secret",
"ssh-keys", "maintenance", "billing-api"] # hybrid + reranker
want = ["rotate-keys", "api-keys", "security-checklist", "webhook-secret",
"ssh-keys", "maintenance", "billing-api"] # what the reader needed
def recall_at_k(ranking, grades, k, min_grade=1):
relevant = {d for d, g in grades.items() if g >= min_grade}
return len(relevant & set(ranking[:k])) / len(relevant)
def reciprocal_rank(ranking, grades, k=10, min_grade=1):
"""1 / rank of the first item with grade >= min_grade in the top k; 0 if none."""
for rank, doc in enumerate(ranking[:k], start=1):
if grades.get(doc, 0) >= min_grade:
return 1 / rank
return 0.0
for name, ranking in [("old", old), ("new", new), ("want", want)]:
print(f"{name:5} recall@10 {recall_at_k(ranking, GRADES, 10):.2f} "
f"RR {reciprocal_rank(ranking, GRADES):.2f}")
old recall@10 1.00 RR 0.33
new recall@10 1.00 RR 1.00
want recall@10 1.00 RR 1.00
Predict first: the report said new beats old, and it does. But would precision@3, hit rate@1 or average precision have told you that new is worse than want?
Check your answer
No. Every one of them scores new and want identically. The report counted grade 1 and above as relevant, which is the default in both ranx and trec_eval. So both lists have "a relevant document" at ranks 1, 2 and 3: precision@3 = 1, hit rate@1 = 1, average precision = 1. The labels knew the checklist was a 1 and the how-to a 3, and every binary metric threw that away.
Two ways to see the problem, both in this lesson. Count only grade 2 and up as relevant, and the reciprocal rank of new drops to 0.5 (Move 1). Or use the grades themselves: nDCG@3 gives new 0.817 and want 1.000 (Move 3).
A ranking metric is a model of its reader: how far they read, when they stop, and how much a better document is worth to them. Pick the metric that reads your list the way your reader does. Feed it labels that carry the difference you care about. Then compute it exactly as defined, because the tools disagree more often than you would think. Each move below starts from a symptom in your numbers:
| # | Symptom in your metrics | Move |
|---|---|---|
| 1 | One answer is enough, and you can't tell rank 1 from rank 3 | Reward the first hit: RR and MRR |
| 2 | Questions need several documents, and one hit already scores 1.0 | Reward every hit: AP and MAP |
| 3 | Some relevant documents are worth far more than others | Grade the gains, discount the ranks: nDCG |
| 4 | Readers stop at the first great result, yet nDCG prefers a page of near-misses | Model the reader who stops: ERR |
| 5 | A new retriever loses offline and wins with users | Find the holes in your labels |
| 6 | Two tools report two numbers for the same run | Compute it the way it is defined |
| 7 | "+0.02 nDCG@10", and nobody knows whether it is real | Compare rankers query by query |
| 8 | A reranker has knobs, and you tune them on the queries you report | Tune on one split, report on another |
This lesson assumes the set metrics (precision@k, recall@k, hit rate@k) and a labeled query set, both from Retrieval Metrics. The code runs on Python 3.12 with numpy, scikit-learn, ranx 0.3.21 and pytrec_eval 0.5.10 (the pytrec-eval-terrier package, Python bindings for NIST's trec_eval). Later blocks reuse the functions and data from earlier ones.
Before any metric: who reads the list, and how deep?
Precision@k, recall@k and hit rate@k are set metrics. They ask which items made the top k and ignore the order inside it. Rank metrics, the subject of this lesson, also ask where each item sits. In the failure taxonomy of RAG Architecture & Implementation, they watch the ranked out bucket: relevant chunks that were found, then beaten by others.
Predict first: a reranker takes the first stage's top 100 and reorders them. You report recall@100 before and after. What can that number do?
Check your answer
Nothing. The same 100 items go in and come out, so recall@100 is identical by construction. This is not a thought experiment. In Move 7's run on a real dataset, a cross-encoder reorders the dense top 100 and recall@100 reads 0.315 before and after, while nDCG@10 moves from 0.318 to 0.343. Recall at the pool size is the reranker's ceiling (Reranking Models), so it cannot grade the reranker's work. Measure at the depth your reader consumes.
The reader decides both the metric and the depth:
| Who reads the ranking | How they read it | Measure first | Depth |
|---|---|---|---|
| A person after one answer (a how-to, a known page) | Top down, stops at the first good result | RR/MRR and hit rate@1; ERR@k if labels are graded | The first screen |
| A person comparing several results | Scans a few, values the best ones most | nDCG@k, ERR@k | The first screen, often 10 |
| An LLM reading a context set | Reads every chunk it is given | recall@k_context and hit rate@k_context; nDCG@k_context when grades differ | k_context, usually 3–10 |
| A reranker reading a candidate pool | Scores every candidate | recall@k_candidates, its ceiling | k_candidates, usually 50–200 |
| An agent step that uses the top result | Rank 1 only | hit rate@1 | 1 |
| A reviewer who must find everything (legal hold, audit) | Reads deep | recall at depth, AP | As deep as they read |
⚠️ "RAG chatbot, so use MRR" is a popular shortcut, and MRR is usually the wrong first metric there. The generator reads the whole context set, so first ask whether the right chunks got in: recall@k_context, and the gold-in-prompt rate after assembly (Context Augmentation). A rank metric earns its place in RAG when:
- labels are graded and slots are scarce, so it matters which relevant chunk gets one of the three slots;
- a token budget cuts the context at a variable depth;
- a person also reads the list, as in the hook.
Two habits for the rest of the lesson. Always write the depth: nDCG@10, never "nDCG", because nDCG@3 and nDCG@100 of the same run answer different questions. And write the reader next to the metric in your evaluation config, so the next person who changes k knows what they are changing.
Move 1: Reward the first hit — RR and MRR
Symptom: one answer is enough, and your metric can't tell whether it came first or third.
The reciprocal rank (RR) of a query is 1 / rank of the first relevant item. MRR@k averages it over queries and scores 0 when nothing relevant is in the top k. This is the definition the earlier lessons used. It is also what the MS MARCO passage ranking task evaluates with, at k = 10.
| First relevant at rank | 1 | 2 | 3 | 4 | 5 | 10 | 11 or later (MRR@10) |
|---|---|---|---|---|---|---|---|
| RR | 1 | 0.5 | 0.333 | 0.25 | 0.2 | 0.1 | 0 |
The curve is steep at the top and flat below. Moving from rank 1 to rank 2 costs 0.5; moving from rank 10 to rank 20 costs 0.05 (under MRR@20). That is the metric working as intended for a reader who stops at the first good result. It also has consequences you must handle yourself.
def mean_rr(runs, qrels, k=10, min_grade=1):
"""MRR@k over EVERY labeled query. A query with no hit in the top k,
or missing from the run, scores 0 and stays in the denominator."""
return sum(reciprocal_rank(runs.get(q, []), grades, k, min_grade)
for q, grades in qrels.items()) / len(qrels)
print(reciprocal_rank(new, GRADES, min_grade=2), reciprocal_rank(want, GRADES, min_grade=2))
qrels = {"q1": {"a": 1}, "q2": {"b": 1}, "q3": {"c": 1}}
runs = {"q1": ["a", "x"], "q2": ["x", "b"], "q3": ["x", "y"]} # q3 finds nothing
print(round(mean_rr(runs, qrels), 3), round(mean_rr(runs, {q: qrels[q] for q in ("q1", "q2")}), 3))
0.5 1.0
0.5 0.75
Keep the misses. q3 found nothing, so it scores 0 and MRR = (1 + 0.5 + 0) / 3 = 0.5. Drop it "because that's a retrieval failure, not a ranking one" and MRR becomes 0.75: you are now measuring a system that never fails. A query missing from the run (a timeout, a crash) also scores 0. The function averages over the labeled queries, not over whatever came back. A query with no relevant document in the corpus at all is a different case, handled in Move 3.
Choose the threshold on purpose. The first line printed shows that with min_grade=2, new gets 0.5 and want gets 1.0. With the default threshold of 1, both got 1.0. Chapelle et al. saw the same effect in a commercial search engine's logs (CIKM 2009, Table 3). RR computed with "good" documents counted as relevant agreed far less with click behavior than RR counting only the top two grades: a correlation of 0.172 against 0.366 with the clicks' maximum reciprocal rank. When partial answers can pass for answers, set the threshold above them.
Predict: on three queries, the first relevant item sits at ranks 1, 1 and 10. What is the MRR, and what is the average rank?
Check your answer
MRR = (1 + 1 + 0.1) / 3 = 0.7. Its inverse, 1 / 0.7 ≈ 1.43, is not a rank anybody saw: the mean rank is 4. MRR is a mean of reciprocals, so never translate it back into "users find the answer at about position 1.4". Report the distribution next to it: the share of queries at rank 1, at ranks 2–3, at 4–10, and misses.
Polishing versus rescuing. On 100 labeled queries, change P moves 10 queries from rank 2 to rank 1, which adds 10 × 0.5 / 100 = 0.05 to MRR. Change Q moves 10 queries from rank 30 (0 under MRR@10) to rank 3, which adds 10 × 0.333 / 100 = 0.033. MRR prefers P. On a results page where rank 2 is still on screen, Q is probably the change users notice: it turns ten dead ends into answers, and it raises hit rate@10 by 0.10 while P leaves hit rate@10 unchanged. Report MRR next to hit rate@k, and look at which queries moved.
MRR fits a single-answer reader. It ignores everything after the first hit, and with binary labels it cannot tell a passing mention from the answer. The next two moves fix one weakness each.
Move 2: Reward every hit — AP and MAP
Symptom: the question needs several documents, and your metric says 1.0 as soon as the first one arrives.
A compliance assistant gets "Do our EU and US rules both require us to delete chat logs?" A complete answer needs three chunks: the EU retention rule, the US retention rule and the chat-log policy. MRR is 1.0 as soon as the EU rule ranks first, whether or not the other two ever show up.
Average precision (AP) of one query: at every rank where a relevant item appears, take the precision at that rank. Sum those precisions and divide by R, the number of relevant items for the query, retrieved or not. MAP is the mean of AP over queries.
def average_precision(ranking, grades, k=None, min_grade=1):
"""Sum of precision@rank at each relevant hit, divided by ALL relevant items."""
relevant = {d for d, g in grades.items() if g >= min_grade}
hits, total = 0, 0.0
for rank, doc in enumerate(ranking[:k], start=1):
if doc in relevant:
hits += 1
total += hits / rank
return total / len(relevant) if relevant else 0.0
needs = {"eu-retention": 1, "us-retention": 1, "chat-log-policy": 1}
ranking = ["eu-retention", "x1", "us-retention", "x2", "x3",
"chat-log-policy", "x4", "x5", "x6", "x7"]
print(round(average_precision(ranking, needs), 3), reciprocal_rank(ranking, needs))
needs["retention-2026-update"] = 1 # a fourth relevant chunk, never retrieved
print(round(average_precision(ranking, needs), 3), recall_at_k(ranking, needs, 10))
0.722 1.0
0.542 0.75
The relevant hits sit at ranks 1, 3 and 6, with precisions 1/1, 2/3 and 3/6. So AP = (1 + 0.667 + 0.5) / 3 = 0.722, while RR is a flat 1.0. Then a fourth relevant chunk turns up: a 2026 update to the retention rule that the system never retrieved. AP drops to 2.167 / 4 = 0.542 and recall@10 to 0.75.
⚠️ The denominator is R, not the hits you found. A common hand-rolled version divides by the number of relevant items retrieved. It gives 0.722 in both cases and never notices the missing update. AP folds recall into one number only because R counts what you missed.
AP@k is not one thing. ranx's map@k and trec_eval's map_cut_k divide by R (checked in the ranx 0.3.21 source and on these examples). The widely copied apk function from the Kaggle-era ml_metrics package divides by min(R, k). Take R = 4 with three relevant items filling the top 3: the first convention gives 0.75, the second 1.0. Neither is wrong. They answer different questions ("how much of what you needed?" against "how much of what fit in k?"). Say which one you report, and never compare numbers from the two.
Two more facts:
- With exactly one relevant item per query, AP equals RR and MAP equals MRR, because each precision sum has a single term, 1 / rank.
- AP is top-heavy too. A relevant item at rank 1 adds a full 1 / R, while the first relevant item at rank 10 adds only 0.1 / R.
Where it fits: exhaustive search, where the reader needs everything and reads deep (e-discovery, patent search, compliance review). It also works as a one-number summary across depths when you tune the order of a long list. For a RAG context set, recall@k_context answers the question more directly: are all the needed chunks in? And MAP is binary, like MRR, so it still cannot see the hook's problem.
Move 3: Grade the gains, discount the ranks — nDCG
Symptom: some relevant documents are worth far more than others, and your metric can't tell them apart. That was the hook.
Discounted cumulative gain (DCG) adds up the value of each result, discounted by how far down it sits:
DCG@k = sum over ranks i = 1..k of gain(grade_i) / log2(i + 1)
gain = grade linear: trec_eval's ndcg, ranx's ndcg, scikit-learn
= 2**grade - 1 exponential: ranx's ndcg_burges, LightGBM and XGBoost defaults
IDCG@k = DCG@k of the ideal ordering of ALL judged items for the query
nDCG@k = DCG@k / IDCG@k (1.0 = the best order the labels allow)
The discount at each rank:
| Rank | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 / log2(rank + 1) | 1.000 | 0.631 | 0.500 | 0.431 | 0.387 | 0.356 | 0.333 | 0.315 | 0.301 | 0.289 |
Here is new from the hook at k = 3, with linear gain:
| Rank | Document | Grade | Discount | Contribution |
|---|---|---|---|---|
| 1 | security-checklist | 1 | 1.000 | 1.000 |
| 2 | rotate-keys | 3 | 0.631 | 1.893 |
| 3 | api-keys | 2 | 0.500 | 1.000 |
DCG@3 = 3.893. The ideal order is 3, 2, 1, so IDCG@3 = 3 + 2 × 0.631 + 1 × 0.5 = 4.762, and nDCG@3 = 3.893 / 4.762 = 0.817.
import math
def dcg(gains, k):
return sum(g / math.log2(rank + 1) for rank, g in enumerate(gains[:k], start=1))
def ndcg_at_k(ranking, grades, k, gain="linear"):
"""The ideal comes from ALL judged items, not just the retrieved ones."""
to_gain = (lambda g: g) if gain == "linear" else (lambda g: 2 ** g - 1)
got = [to_gain(grades.get(doc, 0)) for doc in ranking] # unjudged counts as 0
ideal = sorted((to_gain(g) for g in grades.values()), reverse=True)
return dcg(got, k) / dcg(ideal, k) if dcg(ideal, k) > 0 else None
for name, ranking in [("old", old), ("new", new), ("want", want)]:
print(f"{name:5} nDCG@3 linear {ndcg_at_k(ranking, GRADES, 3):.3f} "
f"exponential {ndcg_at_k(ranking, GRADES, 3, 'exp'):.3f}")
old nDCG@3 linear 0.105 exponential 0.053
new nDCG@3 linear 0.817 exponential 0.736
want nDCG@3 linear 1.000 exponential 1.000
Check a hand-rolled metric against a library before you trust it:
import pytrec_eval
from ranx import Qrels, Run, evaluate
def as_run(ranking): # both tools want scores, higher = better
return {d: float(len(ranking) - i) for i, d in enumerate(ranking)}
run = {"q": as_run(new)}
print(evaluate(Qrels({"q": GRADES}), Run(run), ["ndcg@3", "ndcg_burges@3"]))
print(pytrec_eval.RelevanceEvaluator({"q": GRADES}, {"ndcg_cut_3"}).evaluate(run))
{'ndcg@3': np.float64(0.8174935137996165), 'ndcg_burges@3': np.float64(0.7363636171343382)}
{'q': {'ndcg_cut_3': 0.8174935137996165}}
Why it works. The gain says what a document is worth. The discount says how likely the reader is to get that far. Dividing by the ideal turns "how much value" into "how close to the best this query allows", so a query with one great document and a query with ten can share an average.
The gain is a product decision. Linear gain says a grade 3 is worth 1.5 times a grade 2. Exponential gain says 7 / 3, about 2.3 times.
Predict: the judged documents are a (grade 3), b and c (grade 2 each). System P returns [b, c, x] and system Q returns [a, x, y]. Which wins on nDCG@3?
Check your answer
It depends on the gain. With linear gain, P scores 3.262 / 5.262 = 0.620 and Q scores 3 / 5.262 = 0.570, so P wins. With exponential gain, P scores 4.893 / 10.393 = 0.471 and Q scores 7 / 10.393 = 0.674, so Q wins. Under linear gain, two strong answers beat the single best one; under exponential gain, the best one wins. (ranx gives the same four numbers.) Choose the gain from what your grades mean: if a 3 is "the answer" and a 2 is "useful background", exponential says so. Write the gain in the report, and never compare nDCG values computed with different gains.
The 1.0 that missed the answer
Suppose a ranker returns [security-checklist, webhook-secret, ssh-keys], and your evaluation builds the ideal from those three results:
from sklearn.metrics import ndcg_score
shown = ["security-checklist", "webhook-secret", "ssh-keys"]
grades_shown = [GRADES[d] for d in shown] # [1, 0, 0]
wrong_ideal = dcg(sorted(grades_shown, reverse=True), 3) # ideal of what came back
print(round(dcg(grades_shown, 3) / wrong_ideal, 3), round(ndcg_at_k(shown, GRADES, 3), 3))
print(ndcg_score([grades_shown], [[3, 2, 1]], k=3)) # sklearn, shown items only
1.0 0.21
1.0
The home-made version sorted what came back (1, 0, 0) and called it ideal. So a list that missed both the how-to and the reference scores a perfect 1.0. With the ideal built from every judged document (3, 2, 1), it scores 0.21. scikit-learn's ndcg_score (version 1.9.1) builds the ideal from the y_true you pass, so it makes the same mistake if you hand it only the retrieved items. Pass every judged document, giving the unretrieved ones the lowest scores, and it also returns 0.21. ranx and trec_eval build the ideal from the qrels, which is what you want.
What else to know about nDCG
- The discount is gentle. At rank 10 an item still earns 0.289 of its rank-1 value, so rank 1 is worth only 3.5 times rank 10, where RR says 10 times. That was deliberate. Järvelin & Kekäläinen introduced cumulated gain with graded judgments (SIGIR 2000), and in the extended version (ACM TOIS, 2002) they explain that the discount should fall "not too steeply (e.g., as division by rank) to allow for user persistence". Their original formula left the first ranks undiscounted: with base 2, ranks 1 and 2 counted in full. The
1/log2(rank + 1)form that tools use today follows Burges et al. (ICML 2005). - Scores compare only on the same queries and labels. The ideal normalizes each query, but query difficulty and the number of relevant documents still set the level. The same all-MiniLM-L6-v2 model scores nDCG@10 of 0.318 on NFCorpus (Move 7) and 0.645 on SciFact (Retrieval Metrics). Neither number is "good" or "poor" on its own. Compare systems on the same set.
- A query with no relevant documents has no nDCG. Its ideal DCG is 0, so nDCG is 0/0.
ndcg_at_kabove returnsNone, while ranx and trec_eval quietly return 0 (checked), which drags the average down for queries no ranking could fix. Decide explicitly: take unanswerable queries out of the ranking metrics and track them where they belong, as abstention cases (Grounding & Hallucination Control). Do not confuse them with Move 1's misses. A query whose relevant document exists but was not retrieved scores 0 and stays. - Near-duplicates inflate it. Three copies of a relevant chunk earn three gains, though the reader learns nothing from the second and third. Diversity-aware metrics such as α-nDCG (Clarke et al., SIGIR 2008) discount repeated information. In a RAG pipeline, the practical fix is deduplicating before assembly (Context Augmentation).
Move 4: Model the reader who stops — ERR
Symptom: your readers stop at the first great result, yet nDCG prefers a page of near-misses.
Chapelle et al. made the case with an example (CIKM 2009). Compare a list of 20 "good" documents with a list of one "perfect" document followed by 19 bad ones. DCG prefers the first list: grading bad to perfect as 0 to 4, with exponential gain, DCG@20 scores them 21.1 and 15.0. But a reader who wants the destination page clicks the perfect result, is satisfied and stops. They never see the 19 bad ones. On the first list, they wade through partial answers. The paper names DCG's weakness: a document at a given position "has always the same gain and discount independently of the documents shown above it".
The cascade model (Craswell et al., WSDM 2008) describes that reader. They scan from the top. At each result they are satisfied with a probability that depends on its grade, and then they stop; otherwise they move on. Expected reciprocal rank (ERR) is the expected value of 1 / rank at the point where they stop:
R(grade) = (2**grade - 1) / 2**max_grade chance that this result satisfies the reader
ERR@k = sum over r = 1..k of (1/r) * R_r * product over i < r of (1 - R_i)
The formula and a brute-force simulation of that reader should agree:
import random
def err_at_k(ranking, grades, k, max_grade=3):
"""Expected reciprocal rank: a reader scans down and stops at rank r
with probability R = (2**grade - 1) / 2**max_grade."""
p_reach, total = 1.0, 0.0
for rank, doc in enumerate(ranking[:k], start=1):
R = (2 ** grades.get(doc, 0) - 1) / 2 ** max_grade
total += p_reach * R / rank
p_reach *= 1 - R
return total
def simulate(ranking, grades, readers=200_000, max_grade=3, seed=0):
"""The same thing, by brute force: send simulated readers down the list."""
rng, total = random.Random(seed), 0.0
for _ in range(readers):
for rank, doc in enumerate(ranking, start=1):
if rng.random() < (2 ** grades.get(doc, 0) - 1) / 2 ** max_grade:
total += 1 / rank
break
return total / readers
for name, ranking in [("old", old), ("new", new), ("want", want)]:
print(f"{name:5} ERR@10 {err_at_k(ranking, GRADES, 10):.3f} simulated {simulate(ranking, GRADES):.3f}")
grades = {"A": 3, "B": 2, "C": 2, "D": 2, "E": 2}
one_perfect = ["A", "junk1", "junk2", "junk3", "junk4"]
four_partial = ["B", "C", "D", "E", "A"]
for name, ranking in [("one perfect", one_perfect), ("four partial", four_partial)]:
print(f"{name:12} nDCG@5 {ndcg_at_k(ranking, grades, 5):.3f} ERR@5 {err_at_k(ranking, grades, 5):.3f}")
old ERR@10 0.176 simulated 0.176
new ERR@10 0.521 simulated 0.521
want ERR@10 0.902 simulated 0.901
one perfect nDCG@5 0.435 ERR@5 0.875
four partial nDCG@5 0.911 ERR@5 0.591
Read the output:
- The simulation matches the formula to within 0.001. ERR really is the average of 1 / rank at the point where a simulated reader stops, which also makes the simulation a good unit test for your implementation.
- On the hook, ERR@10 separates
newfromwantsharply: 0.521 against 0.902, a wider gap than nDCG@3's 0.817 against 1.000. The how-to satisfies 7 readers in 8. Innew, the checklist stands in front of it: one reader in eight stops there and never sees the how-to (the outage), and everyone else reaches it at rank 2, which halves the 1 / rank they earn. - On the two five-item lists, the metrics disagree. nDCG@5 prefers four partial answers (0.911 against 0.435). ERR@5 prefers the single perfect one (0.875 against 0.591). Which is right depends on the reader. For a person who wants the one page, trust ERR. For an LLM that reads all five chunks, four useful chunks may well beat one great chunk plus four pieces of junk, and ERR's stopping reader is the wrong model.
Three facts before you use it:
- ERR is not normalized. With
max_grade = 3, a perfect document at rank 1 scores 0.875, and even an endless list of perfect documents reaches only 7 × ln(8/7) ≈ 0.935. Compare ERR only at the same grade scale and depth. - RR is ERR's special case. If every relevant result satisfies the reader for certain (R = 1) and every other result never does (R = 0), the reader stops at the first relevant item and ERR equals RR, as Chapelle et al. point out. The default mapping is gentler: with
max_grade = 1, a relevant result satisfies only half the readers. ERR is Move 1 generalized to graded, uncertain satisfaction. - The evidence comes from one search engine's clicks. On a commercial engine's logs, Chapelle et al. found that ERR correlated better with click metrics than DCG, nDCG, AP or RR, with the largest gap on easy, frequent queries. That is a finding about web searchers, not a law about your readers. Rank-biased precision (Moffat & Zobel, 2008) models a simpler reader, who moves on with a fixed probability whatever they have just seen.
Neither ranx 0.3.21 nor trec_eval computes ERR. The ten-line function above is enough, and the simulation tests it.
Move 5: Find the holes in your labels
Symptom: a new retriever loses offline, then wins with users.
Labels come from judging the documents that earlier systems returned (building them is covered in Retrieval Metrics). A new retriever, such as a new embedding model, learned sparse retrieval or a query rewriter, surfaces documents that nobody judged. ranx and trec_eval treat an unjudged document as not relevant, so the new system gets no credit for the relevant documents it finds. One way to see how much that matters is to score the candidate a second way, on a condensed list: drop the unjudged documents and let the judged ones move up.
def judged_at_k(ranking, grades, k=10):
"""Share of the top k that has any label at all."""
return sum(doc in grades for doc in ranking[:k]) / k
def condensed(ranking, grades):
"""Drop unjudged documents; the judged ones move up."""
return [doc for doc in ranking if doc in grades]
labels = {"A": 3, "B": 2, "C": 1, "D": 0, "E": 0, "F": 0, "G": 0, "H": 0, "I": 0, "J": 0}
current = ["B", "A", "D", "C", "E", "F", "G", "H", "I", "J"]
candidate = ["A", "U1", "U2", "B", "U3", "C", "U4", "D", "E", "F"] # U = never labeled
print("judged@10 ", judged_at_k(current, labels), judged_at_k(candidate, labels))
for name, view in [("holes as 0", lambda r: r), ("condensed ", lambda r: condensed(r, labels))]:
print(name, round(ndcg_at_k(view(current), labels, 10), 3), round(ndcg_at_k(view(candidate), labels, 10), 3))
labels |= {"U1": 2, "U2": 0, "U3": 1, "U4": 0} # label the holes
print("labeled ", round(ndcg_at_k(current, labels, 10), 3), round(ndcg_at_k(candidate, labels, 10), 3))
judged@10 1.0 0.6
holes as 0 0.908 0.886
condensed 0.908 1.0
labeled 0.711 0.965
The last three rows are nDCG@10 with linear gain. The candidate puts four unlabeled documents in its top 10, so only 60% of its top 10 has labels. With the holes counted as not relevant, it loses, 0.886 against 0.908. On the condensed list, it wins, 1.000 against 0.908 (the current system has no holes, so its score does not change). Two estimates, two different winners. Label those four documents, two of which turn out to be relevant, and the candidate wins, 0.965 against 0.711. The current system's score fell too: the two new relevant documents join the ideal, and they are documents it failed to find.
Predict: is the true score always somewhere between the two estimates?
Check your answer
No. A hole that turns out relevant adds gain to the list and raises the ideal. Take a list whose top 9 are the three judged relevant documents in the ideal order followed by six judged zeros, with one hole at rank 10:
graded = {"A": 3, "B": 2, "C": 1} | {d: 0 for d in "DEFGHI"}
top10 = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "U1"]
print(round(ndcg_at_k(top10, graded, 10), 3), round(ndcg_at_k(condensed(top10, graded), graded, 10), 3))
print(round(ndcg_at_k(top10, graded | {"U1": 3}, 10), 3)) # the hole was the best document
1.0 1.0
0.89
Both estimates say 1.000. If the hole is a grade-3 document, the ideal now wants it at rank 1, and the true nDCG@10 is 0.890, below both. The opposite happens too: with X judged 0 and A judged 3, the list [U1, X, A] scores 0.500 with holes as 0 and 0.631 condensed, and 0.920 once U1 turns out to be a grade 3. Counting holes as 0 gives a true lower bound only for metrics the labels do not normalize: precision@k, hit rate@k, RR, DCG@k and ERR@k cannot fall when a hole gets its label. nDCG, AP and recall divide by something the labels define, so new relevant labels move the denominator too.
What to do about holes:
- Measure them. Report judged@k next to every rank metric. When a candidate's judged@10 is well below the baseline's, its score rests on the assumption that every hole is irrelevant. It is an estimate, not a measurement.
- Score them both ways. Report the condensed-list score next to the holes-as-0 score. Sakai (SIGIR 2007) found condensed-list metrics a better answer to incomplete labels than bpref, a measure that Buckley & Voorhees (SIGIR 2004) designed for the purpose. The condensed score is never below the holes-as-0 score, but neither one bounds the truth, and the baseline's score can move too (0.908 became 0.711 above). When the two estimates pick different winners, as here, the holes decide the comparison.
- Fill them. Before comparing, label the top k of every system under test, not only the baseline's, then score all of them again. LLM judges make this cheap. Thomas et al. at Microsoft Bing reported that LLMs can label relevance at close to human quality, and UMBRELA (Upadhyay et al., 2024) is an open-source reproduction built to help assess the TREC 2024 RAG Track. Check any LLM judge against a human-labeled sample of your own data before you rely on it. For graded labels, measure agreement with weighted kappa (or Krippendorff's alpha) rather than raw percent agreement.
Watch for datasets that list only positives. Many BEIR qrels, including NFCorpus's (Move 7), list only relevant documents, so every unlisted document counts as not relevant. Others, such as TREC-COVID's, also list judged non-relevant documents. When labels were pooled from many different systems' results, as in TREC, treating the rest as not relevant is a tolerable assumption. NFCorpus's labels come from NutritionFacts.org's links to the papers it cites, so they miss relevant abstracts that no page linked to. They were not collected from any system in Move 7, so the gaps do not favor one of those systems by construction, but a system that finds an unlinked relevant abstract still gets no credit for it. Positives-only labels are riskiest on your own 50 queries, labeled from one system's top 5: every other system is then scored against that one system's view of the corpus.
Move 6: Compute it the way it is defined
Symptom: two tools report two numbers for the same run.
Most disagreements are not bugs in the tools. They are different defaults meeting your data. Here are three you will hit:
import pytrec_eval
from ranx import Qrels, Run, evaluate
qrels = {"q1": {"rotate-keys": 1}, "q2": {"sso-setup": 1}}
trec = pytrec_eval.RelevanceEvaluator(qrels, {"recip_rank"})
# 1. Two chunks tie at the top.
tied = {"q1": {"rotate-keys": 0.91, "security-checklist": 0.91}, "q2": {"sso-setup": 0.8}}
print("tie pytrec_eval", trec.evaluate(tied)["q1"]["recip_rank"],
"| ranx", evaluate(Qrels(qrels), Run(tied), "mrr", return_mean=False)[0])
# 2. The run lost a query (a timeout, say).
partial = {"q1": {"rotate-keys": 0.9}}
res = trec.evaluate(partial)
print("missing pytrec_eval mean", sum(v["recip_rank"] for v in res.values()) / len(res),
"| ranx", evaluate(Qrels(qrels), Run(partial), "mrr", make_comparable=True))
# 3. Ranks written into the score field (1 = best).
as_ranks = {"q1": {"rotate-keys": 1, "api-keys": 2, "sso-setup": 3}, "q2": {"sso-setup": 1}}
print("ranks pytrec_eval", trec.evaluate(as_ranks)["q1"]["recip_rank"])
tie pytrec_eval 0.5 | ranx 1.0
missing pytrec_eval mean 1.0 | ranx 0.5
ranks pytrec_eval 0.3333333333333333
- Ties. Both tools re-sort each query's results by score. trec_eval, and so pytrec_eval, breaks ties by document id; in our tests it put ids in reverse alphabetical order, whatever order you passed. So
security-checklistcomes beforerotate-keys. ranx kept the order you inserted in this two-document tie, but only because the tie group is small: Retrieval Metrics found larger groups reordered. Same run, RR 0.5 or 1.0, so treat tie order in either tool as undefined. Ties are more common than you think. Duplicate documents get identical scores, sigmoid outputs saturate at 1.0, and pytrec_eval compared scores at single precision in our tests, so scores that agree to about seven significant digits also tie. On Move 7's dense run, 39 of the 323 test queries have a tie among their top-10 scores (NFCorpus contains some abstracts twice), and nDCG@10 comes out 0.3179 in pytrec_eval against 0.3182 in ranx. Before scoring, rewrite each query's scores aslen(results) - rankin the order you actually served. Then both tools report 0.3182. - Missing queries. pytrec_eval returns results only for queries present in the run, so averaging its output silently drops the query that timed out: 1.0 instead of 0.5. ranx refuses to evaluate (it raises an
AssertionError) unless you passmake_comparable=True, which scores the missing query 0. Either way, average over your qrels' query ids. - Direction. Both tools read the score as "higher is better". Put ranks (1 = best) or distances in the score field and the list is scored upside down: RR 0.333 instead of 1.0. Negate distances from an L2 index, or from Qdrant's Euclid metric, before scoring.
Four quieter ones:
- Cut-off. trec_eval's
recip_ranklooks at the whole run; ranx'smrr@10stops at 10. On Move 7's dense run, that is 0.512 against 0.508. MS MARCO-style MRR@10 is the second. - Relevance threshold. Both tools count grade 1 and up as relevant for binary metrics by default. In ranx you write
mrr@10-l2; in pytrec_eval you passrelevance_level=2. - Gain. trec_eval's
ndcgand ranx'sndcguse linear gain. ranx'sndcg_burges, LightGBM's defaultlabel_gainand XGBoost's defaultndcg_exp_gainuse2**grade - 1. scikit-learn'sndcg_scoreis linear and by default averages over tied scores: a relevant and an irrelevant item tied at the top score 0.815, rather than 1.0 or 0.631. - Averaging. Every number in this lesson is a macro average, where each query counts once. Weighting by traffic answers a different question (how the average search went) and lets frequent queries hide the long tail. Report per-slice numbers either way.
💡 Hand-check one query per metric. Before trusting a harness, compute one query by hand, as in Move 3's table, and assert that the tool agrees to four decimals. Then feed it a tie, a missing query and a query with no relevant documents on purpose. It takes ten minutes.
Move 7: Compare rankers query by query
Symptom: "+0.02 nDCG@10", and nobody knows whether it is real.
An average hides what decides whether a difference is real: how consistently it shows up across queries. Here is a real comparison on NFCorpus (Boteva et al., ECIR 2016) in its BEIR version. It has 3,633 medical abstracts and 323 test queries taken from NutritionFacts.org pages. The labels are graded (2 and 1) and come from the site's links, not from human assessors. A dev split of 324 queries is used in Move 8. There are three systems: BM25 (rank_bm25's BM25Okapi with its defaults, k1 = 1.5 and b = 0.75), dense retrieval with all-MiniLM-L6-v2, and the MiniLM-L6 cross-encoder reranking the dense top 100. It takes a few minutes on a laptop CPU, mostly for the reranker.
import re
import numpy as np
import pandas as pd
from huggingface_hub import hf_hub_download
from rank_bm25 import BM25Okapi
from sentence_transformers import CrossEncoder, SentenceTransformer
def hf(repo, path):
return hf_hub_download(repo, path, repo_type="dataset")
corpus = pd.read_parquet(hf("BeIR/nfcorpus", "corpus/corpus-00000-of-00001.parquet"))
queries = pd.read_parquet(hf("BeIR/nfcorpus", "queries/queries-00000-of-00001.parquet"))
query_text = dict(zip(queries["_id"], queries["text"]))
doc_ids = corpus["_id"].tolist()
docs = (corpus["title"] + ". " + corpus["text"]).tolist()
def load_qrels(split):
"""{query_id: {doc_id: grade}} from BEIR's TSV (grades 1 and 2 here)."""
df = pd.read_csv(hf("BeIR/nfcorpus-qrels", f"{split}.tsv"), sep="\t", dtype=str)
qrels = {}
for q, d, grade in df.itertuples(index=False):
qrels.setdefault(q, {})[d] = int(grade)
return qrels
def tokenize(text):
return re.findall(r"\w+", text.lower())
bm25 = BM25Okapi([tokenize(d) for d in docs])
bi = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
ce = CrossEncoder("cross-encoder/ms-marco-MiniLM-L6-v2")
doc_vecs = bi.encode(docs, batch_size=64, normalize_embeddings=True)
def build_runs(qrels, k_candidates=100):
"""Each run is {query_id: {doc_id: score}}, higher = better.
pools keeps (doc_id, cosine, reranker score) for the dense top k_candidates."""
runs, pools = {"bm25": {}, "dense": {}, "rerank": {}}, {}
for q in sorted(qrels):
text = query_text[q]
lex = bm25.get_scores(tokenize(text))
top = np.argsort(-lex)[:k_candidates]
runs["bm25"][q] = {doc_ids[i]: float(lex[i]) for i in top}
cos = doc_vecs @ bi.encode(text, normalize_embeddings=True)
top = np.argsort(-cos)[:k_candidates]
runs["dense"][q] = {doc_ids[i]: float(cos[i]) for i in top}
ce_scores = ce.predict([(text, docs[i]) for i in top], batch_size=100)
pools[q] = [(doc_ids[i], float(cos[i]), float(s)) for i, s in zip(top, ce_scores)]
runs["rerank"][q] = {d: s for d, _, s in pools[q]}
return runs, pools
qrels = {split: load_qrels(split) for split in ("dev", "test")}
runs, pools = {}, {}
for split in qrels:
runs[split], pools[split] = build_runs(qrels[split])
print(len(docs), "abstracts;", {split: len(q) for split, q in qrels.items()}, "labeled queries")
from ranx import Qrels, Run, evaluate
METRICS = ["ndcg@10", "mrr@10", "map@100", "recall@100"]
for name, run in runs["test"].items():
scores = evaluate(Qrels(qrels["test"]), Run(run), METRICS)
print(f"{name:7}", " ".join(f"{m} {v:.3f}" for m, v in scores.items()))
3633 abstracts; {'dev': 324, 'test': 323} labeled queries
bm25 ndcg@10 0.306 mrr@10 0.509 map@100 0.138 recall@100 0.242
dense ndcg@10 0.318 mrr@10 0.508 map@100 0.144 recall@100 0.315
rerank ndcg@10 0.343 mrr@10 0.561 map@100 0.165 recall@100 0.315
ranx's ndcg@10 uses linear gain and the 1/log2(rank + 1) discount, so a grade-2 abstract is worth twice a grade-1 one; this is also how BEIR reports nDCG@10 (it calls pytrec_eval's ndcg_cut). Your last digit may differ on other hardware. Three things to notice:
- The recall@100 column is the "Before any metric" point in real data: reranking leaves it at 0.315.
- MRR@10 calls BM25 and dense a tie (0.509 against 0.508), while nDCG@10 gives dense a lead of 0.012.
- The reranker adds 0.025 to nDCG@10 and 0.053 to MRR@10.
Which of these differences would survive a fresh sample of queries? Compare per query. Both systems answered the same queries, so one query's difficulty affects both scores, and taking the difference cancels it.
from scipy.stats import binomtest
rng = np.random.default_rng(0)
def per_query(qrels, run, metric="ndcg@10"):
"""One score per query, in sorted query order, via ranx."""
assert set(run) == set(qrels), "score every labeled query"
r = Run(run)
evaluate(Qrels(qrels), r, metric)
return np.array([r.scores[metric][q] for q in sorted(qrels)])
def randomization_p(a, b, n=20_000):
"""Paired, two-sided: flip the sign of each query's difference at random
and count how often the mean moves at least as far as the one observed."""
diff = a - b
null = (rng.choice([-1.0, 1.0], size=(n, len(diff))) * diff).mean(axis=1)
return float((np.abs(null) >= abs(diff.mean()) - 1e-12).mean())
def bootstrap_ci(a, b, n=10_000):
"""95% interval for the mean difference, resampling queries with replacement."""
diff = a - b
means = diff[rng.integers(0, len(diff), size=(n, len(diff)))].mean(axis=1)
return np.percentile(means, [2.5, 97.5])
for metric in ("ndcg@10", "mrr@10"):
s = {name: per_query(qrels["test"], run, metric) for name, run in runs["test"].items()}
for a, b in [("dense", "bm25"), ("rerank", "dense")]:
diff = s[a] - s[b]
up, down, same = (diff > 0).sum(), (diff < 0).sum(), (diff == 0).sum()
lo, hi = bootstrap_ci(s[a], s[b])
print(f"{metric} {a} - {b}: {diff.mean():+.3f} up/down/same {up}/{down}/{same} "
f"sign p={binomtest(up, up + down).pvalue:.3f} "
f"randomization p={randomization_p(s[a], s[b]):.3f} 95% CI [{lo:+.3f}, {hi:+.3f}]")
ndcg@10 dense - bm25: +0.012 up/down/same 117/102/104 sign p=0.344 randomization p=0.290 95% CI [-0.010, +0.034]
ndcg@10 rerank - dense: +0.025 up/down/same 116/90/117 sign p=0.081 randomization p=0.004 95% CI [+0.008, +0.042]
mrr@10 dense - bm25: -0.001 up/down/same 76/63/184 sign p=0.309 randomization p=0.971 95% CI [-0.038, +0.035]
mrr@10 rerank - dense: +0.053 up/down/same 60/48/215 sign p=0.290 randomization p=0.001 95% CI [+0.022, +0.085]
Read it line by line:
- Dense against BM25: dense is better on 117 queries and worse on 102. The randomization test gives p = 0.29, and the 95% interval runs from −0.010 to +0.034, which includes zero. On 323 queries you cannot tell them apart. The near-even split does say that each system wins on different queries, which is the situation Hybrid Retrieval Systems exploits.
- Reranker against dense: +0.025 with p = 0.004 and an interval from +0.008 to +0.042. The gain is real and modest. Report the interval, not just the p-value, because it tells you how big the gain might plausibly be.
- The sign test is weak. It counts only ups and downs (116 against 90) and ignores their size, so the same data gives it p = 0.081. (For 0/1 per-query outcomes such as hit rate there is no size to ignore, and the sign test is exactly McNemar's test, the one Evaluation & Quality Metrics uses.) Smucker, Allan & Carterette compared significance tests on TREC runs (CIKM 2007). They found "little practical difference between the randomization, bootstrap, and t tests", while the Wilcoxon and sign tests "have a poor ability to detect significance" and can produce false detections. Use the paired randomization test or the paired t-test for the p-value, and the bootstrap for the interval. ranx's
compare()runs the randomization test (stat_test="fisher") or the paired t-test ("student", its default) across several runs at once. - MRR@10 changed on only a third of the queries (108 of 323). The other 215 kept their first relevant rank. The reranker's MRR gain is concentrated, which is worth knowing when you read the queries that moved.
How many queries do you need? Here the per-query differences between reranker and dense have a standard deviation of 0.157. To detect a true gain δ with a two-sided test at the 5% level and 80% power, you need roughly n = (2.8 × sd / δ)² queries: (2.8 × 0.157 / 0.025)² ≈ 310. As a check, we drew 50 of the 323 test queries at random, 4,000 times. In about 11% of the draws the reranker looked no better than dense, and only about 15% of the draws reached p < 0.05 (paired t-test, which agrees with the randomization test here: p = 0.005 on all 323 queries). A 50-query set cannot see a gain of this size. Evaluation & Quality Metrics covers sizing an evaluation set, and online checks (A/B tests, interleaving) that confirm offline wins with real users.
⚠️ Twenty tries make a winner. Test 20 configurations that are all really equal to the baseline, each at p < 0.05, and the chance that at least one "wins" is 1 − 0.95²⁰ ≈ 64% if the tests are independent. Pick the configuration on one split and run one test on another (Move 8), or correct for the number of tests.
Move 8: Tune on one split, report on another
Symptom: the reranker has knobs, and you tune them on the same queries you report.
Rerankers come with knobs: the pool size (Reranking Models sizes one on SciFact), an instruction for LLM rerankers, a score cut-off, and how much to trust the first stage. The recipe for all of them:
- Fix the target first: the metric and depth your reader uses, plus the ceiling (the pool's recall).
- Tune on dev; report on test, once.
- Report the gain over the untuned reranker with an interval, and read the queries that got worse.
The knob here is a blend of the reranker's score with the first stage's cosine. Raw logits and cosines live on different scales, so both are min-max normalized per query first; Hybrid Retrieval Systems shows what happens when you skip that. The target is nDCG@10 with linear gain, as in Move 7.
def minmax(x):
x = np.asarray(x, dtype=float)
span = x.max() - x.min()
return (x - x.min()) / span if span else np.zeros_like(x)
def blended(pools, lam):
"""lam = weight on the reranker; both scores min-max normalized per query."""
run = {}
for q, pool in pools.items():
dense = minmax([c for _, c, _ in pool])
rerank = minmax([s for _, _, s in pool])
run[q] = {d: float(lam * r + (1 - lam) * c)
for (d, _, _), r, c in zip(pool, rerank, dense)}
return run
lams = [round(x * 0.1, 1) for x in range(11)]
for split in ("dev", "test"):
row = {lam: evaluate(Qrels(qrels[split]), Run(blended(pools[split], lam)), ["ndcg@10", "mrr@10"])
for lam in lams}
print(split, "nDCG@10", " ".join(f"{lam}:{m['ndcg@10']:.3f}" for lam, m in row.items()))
print(split, "MRR@10 ", " ".join(f"{lam}:{m['mrr@10']:.3f}" for lam, m in row.items()))
if split == "dev":
chosen = max(lams, key=lambda lam: row[lam]["ndcg@10"])
print("chosen on dev: lam =", chosen)
test_scores = {lam: per_query(qrels["test"], blended(pools["test"], lam)) for lam in (chosen, 1.0)}
lo, hi = bootstrap_ci(test_scores[chosen], test_scores[1.0])
print(f"test, blend {chosen} - reranker alone: {(test_scores[chosen] - test_scores[1.0]).mean():+.3f} "
f"randomization p={randomization_p(test_scores[chosen], test_scores[1.0]):.3f} 95% CI [{lo:+.3f}, {hi:+.3f}]")
dev nDCG@10 0.0:0.301 0.1:0.309 0.2:0.313 0.3:0.322 0.4:0.328 0.5:0.334 0.6:0.338 0.7:0.340 0.8:0.339 0.9:0.333 1.0:0.328
dev MRR@10 0.0:0.502 0.1:0.511 0.2:0.512 0.3:0.527 0.4:0.536 0.5:0.539 0.6:0.548 0.7:0.550 0.8:0.552 0.9:0.548 1.0:0.546
chosen on dev: lam = 0.7
test nDCG@10 0.0:0.318 0.1:0.323 0.2:0.327 0.3:0.334 0.4:0.342 0.5:0.345 0.6:0.351 0.7:0.350 0.8:0.348 0.9:0.348 1.0:0.343
test MRR@10 0.0:0.508 0.1:0.512 0.2:0.514 0.3:0.523 0.4:0.532 0.5:0.535 0.6:0.549 0.7:0.551 0.8:0.551 0.9:0.568 1.0:0.561
test, blend 0.7 - reranker alone: +0.007 randomization p=0.035 95% CI [+0.001, +0.014]
Here λ = 0 is dense alone and λ = 1 is the reranker alone. Read it:
- Dev picks λ = 0.7 (nDCG@10 0.340, against 0.328 for the reranker alone and 0.301 for dense alone).
- Test confirms it, barely: 0.350 against 0.343, a gain of 0.007 with p = 0.035 and an interval from +0.001 to +0.014. It is probably real, and tiny.
- Tuning on test flatters you. On test, λ = 0.6 scores 0.351, and you would have reported a number no fresh sample would reproduce. The gap is small here only because the curve is flat near its peak.
- The metric picks the knob. MRR@10 peaks at λ = 0.8 on dev. On test, it rates λ = 0.7 slightly below the reranker alone (0.551 against 0.561). If your reader wants the one best answer, the blend is not worth a knob. If they read a top 10 with graded labels, it is a small free gain. Decide the metric before you look at the sweep.
Is +0.007 worth shipping? The blend costs nothing at query time, because both scores already exist. But it adds one more setting that must be re-tuned whenever either model changes. That maintenance cost belongs in the decision next to the interval.
Your turn. A teammate reranks only the dense top 10 and reports "reranking hurts: MAP@100 falls from 0.144 to 0.125". nDCG@10 went from 0.318 to 0.333. What is going on, and what should they report?
Check your answer
The reranked run contains only 10 documents per query, so it earns nothing at ranks 11–100, while the dense run still collects precision from relevant documents down there. MAP@100 is comparing a 10-document list with a 100-document list. Compare at a depth both runs return: at MAP@10 the reranked run wins, 0.125 against 0.112. Or evaluate what production actually serves, the reranked 10 followed by the rest of the dense list: its MAP@100 is 0.157, against 0.144. The general rule: a metric deeper than the run penalizes the shorter run for documents it never tried to rank.
Final round: no label on the problem
Real reports don't say which move they need. Find the reader first, then the symptom.
Challenge 1: the form filler
An agent step searches the product catalog and copies a value (a plan's price, an API rate limit) from the top result into a form. After an embedding-model upgrade, the team reports nDCG@10 up by 0.04, yet the rate of wrong form values hasn't changed.
Check your answer
The reader reads rank 1 only, so nDCG@10 credits ranks 2 to 10 that nothing reads. Measure hit rate@1 with a strict threshold (only the document that states the value counts), plus the end-to-end rate of correct form values. The upgrade may have improved ranks 2–10 and left rank 1 alone, which is exactly what the unchanged error rate suggests.
Challenge 2: the quiet improvement
A teammate removes 40 of 300 labeled queries "where retrieval failed completely, probably label noise", and MRR@10 rises from 0.58 to 0.67.
Check your answer
The arithmetic alone explains the rise: the 40 removed queries scored 0, so the same rankings now average 0.58 × 300 / 260 ≈ 0.67. Nothing got better. Misses score 0 and stay (Move 1); removing them measures a system that never fails. Read the 40 queries instead. Fix genuine label errors, with a second judge. Move queries that have no answer in the corpus to your abstention evaluation. Keep the rest, since they are the most useful queries you have. Also check that none of them are queries the run simply lost (Move 6), which pytrec_eval would have skipped without a word.
Challenge 3: the notebook and CI disagree
A notebook reports nDCG@10 = 0.91 for a new retriever. The CI job, using ranx on the same queries, reports 0.47.
Check your answer
The notebook almost certainly builds the ideal from the retrieved items, perhaps by calling scikit-learn's ndcg_score with only the retrieved documents (Move 3). That rewards a system for missing relevant documents. Then check the rest of Move 6's list: gain, threshold, cut-off, ties and missing queries. Hand-compute one query and see which of the two numbers it matches.
Challenge 4: the league table
Two teams report nDCG@10 for their retrievers. The support team's hybrid retriever scores 0.71 on 400 support tickets. The docs team's dense retriever scores 0.46 on 250 documentation questions. Leadership proposes putting the support team's retriever behind docs search, since it "scores 50% higher".
Check your answer
The two numbers are not comparable (Move 3). They come from different queries, different labels and possibly different gains, thresholds and grading guidelines, and nDCG's level depends on all of them. Run both retrievers on the docs team's 250 questions, with the same gain, depth and threshold, and compare them query by query with a paired test (Move 7). Check judged@10 first: the support retriever will surface documentation pages nobody labeled (Move 5). Only that comparison says which one is better for docs.
Cheat sheet: symptom → move → trap
| Symptom | Reach for | Trap to avoid | Report |
|---|---|---|---|
| A change you can see leaves the metric flat | The reader's metric at the reader's depth | Recall at the pool size cannot grade a reranker | metric@k with the reader named |
| One answer is enough | RR / MRR@k, hit rate@1 | Dropping misses; 1/MRR read as a rank | MRR@k with hit rate@k and the rank distribution |
| The answer needs several documents | AP / MAP, recall@k_context | Dividing by the hits found; mixing R and min(R, k) | MAP with its convention, recall@k |
| Some relevant documents beat others | nDCG@k with graded labels | Ideal from retrieved items only; mixing gains | nDCG@k with gain and threshold |
| Readers stop at the first great result | ERR@k | Using it for an LLM that reads everything | ERR@k with the grade scale |
| A new system loses offline | judged@k and a condensed-list score, then label the holes | Reading either estimate as a bound on the truth | judged@k next to every score |
| Tools disagree | Tie-breaking, missing queries, score direction | Averaging a tool's output instead of the qrels | One hand-checked query per metric |
| "+0.02, ship it?" | Paired randomization test and bootstrap interval | Sign test on graded scores, 50 queries, twenty tries | Mean difference, 95% interval, ups and downs |
| Tuning a reranker | Dev split for the knob, test for one report | Picking the knob on the test set | Test gain over the untuned reranker, with interval |
Before moving on, take one ranking your system produces and write down: who reads it and how deep, the metric and k, the gain and the relevance threshold, how misses and unanswerable queries are handled, which tool computes it and how that tool breaks ties. Compute one query by hand and make the tool agree.
How would you measure it on your own data? Take real queries from your logs, sliced by type, and split them into dev and test. Size the test split with Move 7's arithmetic: with per-query differences as spread out as NFCorpus's, seeing a gain of 0.025 takes about 300 test queries. Grade the top 10 of every system you compare on a 0–3 scale, with a written definition of each grade (humans, or an LLM judge checked against a human sample). Tune on dev. On test, report the reader's metric at the reader's depth, judged@k, and a paired test with a bootstrap interval, per slice. Then read the ten queries that got worse.
Next: Retrieval Metrics for the set metrics and building labels, Reranking Models for the stage these metrics most often tune, and Generation Quality for judging what the model writes once the right chunks are in its prompt.