Evaluating Retrieval
Everything so far — chunking, hybrid, reranking, query transforms — is a knob. Without measurement, turning knobs is superstition. This chapter builds the evaluation harness that turns "I think hybrid helped" into "recall@10 went 0.71 → 0.83 on our 80-query gold set." It's the skill that defines the role.
Here's the uncomfortable truth that separates a RAG engineer from a hobbyist: you cannot improve retrieval you don't measure, and you can't measure it without a gold set. A gold set is a list of realistic queries, each labeled with the chunk(s) that actually answer it. Building one is the least glamorous and most valuable thing you'll do — 50–100 labeled queries is enough to make every downstream decision empirical instead of vibes.
The four metrics you must be able to explain
| Metric | Question it answers | Use when |
|---|---|---|
| Recall@k | Is the gold chunk in the top k? | Retrieval — did we even get it in the candidate set? |
| MRR | How high is the first relevant chunk? | Reranking / single-answer queries |
| nDCG@k | Are multiple relevant chunks ranked high, discounted by position? | Multi-relevant queries; graded relevance |
| Precision@k | What fraction of the top k is relevant? | Cost/noise — how much junk reaches the model |
The one to internalize is recall@k as the retrieval north star: it directly measures the thing that gates everything downstream — whether the answer even reaches the model. A synthesis prompt can't fix a recall miss. So you tune retrieval to maximize recall@k at a k you can afford to rerank (say recall@50), then let reranking convert that into precision@3. The two metrics correspond to the two stages.
An end-to-end "is the final answer good?" score tells you that something's wrong, never where. Measuring retrieval (recall@k) and generation (faithfulness, Ch. 9) separately localizes the fault to a stage — the master diagnostic from Chapter 2, now quantified. Track both, always, on the same gold set.
Building the gold set without dying
You don't hand-label 100 queries from scratch. The efficient path: take real user queries from logs, retrieve candidates, and have humans (or a strong LLM as a first-pass labeler, human-verified) mark which chunks are relevant. Even 50 queries stratified across your query types — identifier lookups, how-tos, multi-hop — is enough to catch regressions and compare retrievers. Stratification matters more than raw count: 50 queries covering your real distribution beat 500 all of one kind.
Build the retrieval eval harness — recall@k, MRR, nDCG
You'll implement the three core metrics from scratch over a small gold set and use them to compare two retrievers, declaring a winner with numbers. This harness is the literal deliverable of the job — the thing you carry to every RAG system you ever touch.
Setup: none — pure Python.
Step 1. Implement recall_at_k, mrr, ndcg_at_k.
Step 2. Run all three for retriever A and retriever B over the gold set.
Step 3. Print a comparison table and name the winner.
Your goal: a metrics table that makes the choice between A and B empirical, not aesthetic.
Starter code
import math
# gold: query -> set of relevant chunk ids. runs: retriever -> query -> ranked id list.
GOLD = {
"q1": {"c1"}, "q2": {"c2"}, "q3": {"c3","c9"}, "q4": {"c4"}, "q5": {"c5"},
}
RUN_A = { # retriever A: often buries gold mid-list
"q1": ["c7","c1","c3"], "q2": ["c2","c8","c0"], "q3": ["c1","c9","c3"],
"q4": ["c0","c6","c4"], "q5": ["c5","c2","c1"],
}
RUN_B = { # retriever B: gold higher on average
"q1": ["c1","c7","c3"], "q2": ["c2","c8","c0"], "q3": ["c3","c9","c1"],
"q4": ["c4","c0","c6"], "q5": ["c1","c5","c2"],
}
def recall_at_k(run, k):
hit = 0
for q, rel in GOLD.items():
hit += any(cid in rel for cid in run[q][:k])
return hit / len(GOLD)
def mrr(run):
total = 0.0
for q, rel in GOLD.items():
for i, cid in enumerate(run[q], 1):
if cid in rel: total += 1.0/i; break
return total / len(GOLD)
# TODO: implement ndcg_at_k(run, k): DCG = sum over ranked positions of rel/log2(rank+1),
# with rel=1 if the doc is in gold else 0; normalize by the ideal DCG (all gold on top).
def ndcg_at_k(run, k):
total = 0.0
for q, rel in GOLD.items():
dcg = sum((1.0 if cid in rel else 0.0)/math.log2(i+1)
for i, cid in enumerate(run[q][:k], 1))
ideal_hits = min(len(rel), k)
idcg = sum(1.0/math.log2(i+1) for i in range(1, ideal_hits+1))
total += (dcg/idcg) if idcg else 0.0
return total / len(GOLD)
for name, run in (("A", RUN_A), ("B", RUN_B)):
print(f"{name}: recall@3={recall_at_k(run,3):.2f} recall@1={recall_at_k(run,1):.2f} "
f" MRR={mrr(run):.2f} nDCG@3={ndcg_at_k(run,3):.2f}")
What you should see: A and B tie on recall@3 (both find the gold doc somewhere in the top 3), but B wins decisively on recall@1, MRR, and nDCG@3 because it puts the gold doc higher. That's the lesson: recall@3 alone would have told you A and B are equal — they are not. Rank-sensitive metrics reveal that B delivers the answer where the model will actually read it. Choosing by recall@k at the wrong k hides real quality differences.
Why this is the job: with this harness, every change you make — a new chunk size, hybrid vs. dense, adding a reranker — becomes a before/after number on a fixed gold set. You stop shipping changes because they "feel better" and start shipping them because recall@10 went up 0.06 and MRR up 0.11. That discipline is the difference between a search engineer and someone who tweaks prompts.
Going further: add graded relevance (gold docs worth 2, partially-relevant worth 1) and extend nDCG to use those gains. Then compute 95% confidence intervals via bootstrap over queries so you know whether a 0.02 gain is real or noise — the exact rigor an eval-minded interviewer probes for.
InsureBot: the 0.90 that hid a 0.62
InsureBot's team was ready to ship: their eval showed recall@5 of 0.90 across a 300-query gold set. Before launch they did one more thing — sliced the number by query type. The comfortable average shattered: FAQ-style queries scored 0.98, but questions about newly added policy documents scored just 0.62. A quarter of their real traffic was in that weak slice, invisible in the blended figure.
The gold set itself was the asset that made this catchable: real queries, each paired with the chunk that truly answers it, tagged by segment, sized big enough (300) that a slice of 70 was still stable. Recall@5 told them whether the answer was retrieved; MRR told them how high; and the slices told them where to spend the next sprint. An aggregate score without slices, they learned, is a rumor with good posture.
Acme builds a 300-query gold set tagged how-to / reference / error-code. Recall@5 comes back 0.95 / 0.91 / 0.64 — pointing the next sprint squarely at error-code retrieval, not at the parts already working.
Quiz · Chapter 8 — reasoning, not recall
- Retrievers A and B tie on recall@3 but B has much higher MRR and nDCG@3. What does this mean?
- Why is recall@k the north-star metric for the retrieval stage specifically?
- You have an end-to-end "is the answer good?" score but can't tell why answers fail. What's missing?
- You have budget to label 500 queries all of one type, or 60 stratified across your real query types. Which is better for evaluating retrieval?
- Why measure recall at a large k (e.g. recall@50) for retrieval but precision at a small k (e.g. precision@3) for the reranked output?