RAG Retrieval Evaluation — Building an Offline Eval Harness Before You Ship
You can't improve retrieval you don't measure. The offline eval harness that lets us change embeddings, chunking, and rerankers with confidence instead of vibes — with the metrics that actually predict production quality.
Key takeaways
- You can't improve retrieval you don't measure.
- The offline eval harness that lets us change embeddings, chunking, and rerankers with confidence instead of vibes — with the metrics that actually predict production quality.
RAG Retrieval Evaluation — Building an Offline Eval Harness Before You Ship#
Every RAG quality problem we've debugged traced back to retrieval, not generation. The model can only answer from what you put in its context; if the right chunk wasn't retrieved, no prompt engineering saves you. Yet most teams evaluate RAG by eyeballing a few answers. That doesn't scale and it doesn't catch regressions. Here's the offline harness that let us change embeddings, chunking, and rerankers and know whether quality moved.
Separate retrieval evaluation from generation evaluation#
The first principle: measure retrieval independently. End-to-end answer quality conflates two failures — bad retrieval and bad generation — and you can't fix what you can't isolate. Build two harnesses:
- Retrieval eval: given a query, did we fetch the chunks that contain the answer? (Metrics: recall@k, MRR, nDCG.) Fast, deterministic, cheap — no LLM call.
- Generation eval: given the right chunks, did the model produce a faithful answer? (Faithfulness, answer relevance.) Slower, needs an LLM judge.
Most of your iteration happens in (1), because retrieval is where most quality lives and it's the part you can evaluate in milliseconds.
Build a golden set#
You need labeled data: queries paired with the chunk(s) that should be retrieved. Sources:
- Mine production logs: real user queries are gold. Sample them.
- Annotate the answer location: for each query, mark which document/chunk contains the answer. This is the tedious part; 100–200 well-labeled queries beats 10,000 unlabeled ones.
- Cover the failure modes you've seen: acronyms, multi-hop questions, near-duplicate documents, time-sensitive queries.
golden = [
{"query": "how do I rotate the signing key",
"relevant_chunk_ids": ["doc_42#3", "doc_42#4"]},
{"query": "what's the default retention period",
"relevant_chunk_ids": ["doc_17#1"]},
# ...
]
The core metrics#
def recall_at_k(retrieved_ids, relevant_ids, k):
top_k = retrieved_ids[:k]
hit = len(set(top_k) & set(relevant_ids))
return hit / len(relevant_ids)
def reciprocal_rank(retrieved_ids, relevant_ids):
for i, rid in enumerate(retrieved_ids, start=1):
if rid in relevant_ids:
return 1.0 / i
return 0.0
- recall@k answers "is the answer even in the context we'll send the model?" If recall@8 is 0.7, then 30% of the time the model literally cannot answer correctly no matter how good it is. This is the single most important retrieval number.
- MRR (mean reciprocal rank) answers "how high up is the right chunk?" Matters because context-window position affects how well the model uses it — buried-in-the-middle chunks get used less.
Run it on every change#
The harness turns "I think the new embedding model is better" into a number:
def evaluate(retriever, golden, k=8):
recalls, rrs = [], []
for case in golden:
ids = retriever.search(case["query"], k=k)
recalls.append(recall_at_k(ids, case["relevant_chunk_ids"], k))
rrs.append(reciprocal_rank(ids, case["relevant_chunk_ids"]))
return {"recall@k": mean(recalls), "mrr": mean(rrs)}
We run this in CI on the golden set for every change to the retrieval stack. A PR that swaps the embedding model now shows recall@8: 0.82 → 0.89 or recall@8: 0.82 → 0.74 — and the second one doesn't merge.
What it caught that vibes missed#
- An embedding upgrade that improved average recall but tanked it specifically on acronym queries — invisible in spot checks, obvious when we sliced metrics by query type.
- A chunking change (bigger chunks) that raised recall but pushed the relevant text to the bottom of larger chunks, hurting generation faithfulness downstream. We'd have shipped it on retrieval metrics alone; the generation harness flagged it.
- A reranker that improved MRR but added 180ms p95 for a recall gain that didn't justify the latency. Now a measurable tradeoff, not a guess.
Slice, don't average#
A single aggregate recall number hides the failures that matter. Always break down by query category (factual, multi-hop, time-sensitive, rare-term). A model that's +5% on average but -20% on the multi-hop slice is a regression for your hardest users. The aggregate is for the dashboard; the slices are for the decision.
The discipline#
Retrieval quality is a measurable engineering quantity, not a feel. Build the golden set once, automate recall@k and MRR in CI, separate retrieval from generation, and slice by query type. Then every change to embeddings, chunking, or reranking becomes a number you can defend — and regressions stop reaching production disguised as improvements.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon — CrashLoopBackOff, OOMKilled, Terraform state locks, and more — plus new guides as we publish them.
Alert on Symptoms, Not Causes — SLO Burn-Rate Alerting in Practice
Cause-based alerts page you for things that don't matter and miss things that do. How we rebuilt alerting around SLO burn rates — multi-window, multi-burn-rate — and cut pages while catching more real pain.
Terraform Drift Detection in CI — Catching Out-of-Band Changes Before They Bite
State drift is silent until a deploy fails or an outage reveals it. The scheduled plan-and-diff pipeline that surfaces console hotfixes and manual edits while they're still cheap to reconcile.
More from AI
Explore more articles in this category
AI Agents and Kubernetes Remediation: Write Access Is the Easy Part
Handing an agent kubectl is a five-minute job. Proving the fix worked and did no harm is the real work, and it belongs in the wrapper, not the prompt.
Plugin4Shell: A Pinned Plugin Is Not a Verified Plugin
A SHA pin that nobody checks is a label, not a control. Plugin4Shell showed that four major coding agents never checked.
Copilot's September Bill Cliff: Included AI Credits Just Dropped
The June to August promotion ended on September 1. Included Copilot credits fell 37% on Business and 44% on Enterprise while seat prices stayed flat. Here is the arithmetic and the controls.
You might have missed
Evergreen posts worth revisiting.