AI Engineer Dojo Contents
AI Search / RAG Engineer · Chapter 8

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

MetricQuestion it answersUse when
Recall@kIs the gold chunk in the top k?Retrieval — did we even get it in the candidate set?
MRRHow high is the first relevant chunk?Reranking / single-answer queries
nDCG@kAre multiple relevant chunks ranked high, discounted by position?Multi-relevant queries; graded relevance
Precision@kWhat 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.

Component evals beat end-to-end evals for debugging

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.

Try it · ~30 min

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).
Worked solution
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.

Case study

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.

Running case · Acme Vault

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

  1. Retrievers A and B tie on recall@3 but B has much higher MRR and nDCG@3. What does this mean?
  2. Why is recall@k the north-star metric for the retrieval stage specifically?
  3. You have an end-to-end "is the answer good?" score but can't tell why answers fail. What's missing?
  4. 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?
  5. 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?

← Back Continue →

AI Search / RAG Engineer — New-Grad Edition · AI Engineer Dojo · aiengineerdojo.com