Retrieval metrics
Retrieval quality is binary at the component level: the right chunk is either in the context window or it is not. If the retriever misses a relevant chunk, the generator cannot use it — no amount of prompt engineering recovers from absent context. Measuring retrieval independently from generation isolates the first failure surface.
Recall@k
recall@k measures the fraction of all relevant documents that appear in the top-k results.
recall@k = |relevant docs in top-k| / |total relevant docs|
A query has 3 relevant chunks in the corpus. The retriever returns 10 results. 2 of the 3 relevant chunks appear in those 10 results.
recall@10 = 2 / 3 = 0.667
In RAG, recall@k is the most important retrieval metric. If a relevant chunk is not retrieved, it cannot appear in the context window. Typical RAG systems target recall@10 >= 0.9 or recall@20 >= 0.95 — retrieve wide enough to capture nearly all relevant information, then let a reranker (L11) or the generator sort out precision.
Precision@k
precision@k measures the fraction of the top-k results that are actually relevant.
precision@k = |relevant docs in top-k| / k
Same example: 10 results, 2 are relevant.
precision@10 = 2 / 10 = 0.2
Low precision means the context window contains noise — irrelevant chunks that consume tokens without helping the answer. High recall with low precision is the usual starting point; reranking and tighter top-k selection improve precision.
Mean Reciprocal Rank (MRR)
MRR measures how high the first relevant result appears. For a single query, the reciprocal rank is 1 / rank_of_first_relevant_result. MRR averages this across all queries.
MRR = (1/Q) * sum(1 / rank_i for i in 1..Q)
Three queries with the first relevant result at positions 1, 3, and 7:
MRR = (1/3) * (1/1 + 1/3 + 1/7) = (1/3) * (1.0 + 0.333 + 0.143) = 0.492
MRR penalizes systems that bury the best result deep in the list. It is most useful when you care about the single best answer — less useful in RAG where multiple relevant chunks contribute to the answer.
nDCG@k (Normalized Discounted Cumulative Gain)
nDCG@k accounts for graded relevance (not just binary relevant/irrelevant) and the position of each result. A highly relevant document at position 1 contributes more than the same document at position 10.
DCG@k = sum(relevance_i / log2(i + 1) for i in 1..k)
nDCG@k = DCG@k / IDCG@k
where IDCG@k is the DCG of the ideal ranking (all relevant documents sorted by relevance at the top).
Worked example with graded relevance (0 = irrelevant, 1 = relevant, 2 = highly relevant):
A retriever returns 5 results with relevance scores [2, 0, 1, 1, 0].
DCG@5 = 2/log2(2) + 0/log2(3) + 1/log2(4) + 1/log2(5) + 0/log2(6)
= 2/1.0 + 0/1.585 + 1/2.0 + 1/2.322 + 0/2.585
= 2.0 + 0.0 + 0.5 + 0.431 + 0.0
= 2.931The ideal ranking for these relevance scores is [2, 1, 1, 0, 0]:
IDCG@5 = 2/log2(2) + 1/log2(3) + 1/log2(4) + 0/log2(5) + 0/log2(6)
= 2.0 + 0.631 + 0.5 + 0.0 + 0.0
= 3.131nDCG@5 = 2.931 / 3.131 = 0.936
A perfect ranking gives nDCG@k = 1.0. The closer to 1.0, the better the ranking approximates the ideal ordering.
Hit rate
The simplest metric: did at least one relevant document appear in the top-k?
hit_rate@k = 1 if any relevant doc in top-k, else 0
Averaged across queries, hit rate gives the percentage of queries where retrieval found something useful. It is less informative than recall (it does not distinguish "found 1 of 5 relevant chunks" from "found all 5") but useful as a high-level pass/fail signal.
Code: compute metrics on a labeled set
import numpy as np
def recall_at_k(retrieved_ids: list, relevant_ids: set, k: int) -> float:
top_k = set(retrieved_ids[:k])
if not relevant_ids:
return 0.0
return len(top_k & relevant_ids) / len(relevant_ids)
def precision_at_k(retrieved_ids: list, relevant_ids: set, k: int) -> float:
top_k = retrieved_ids[:k]
if k == 0:
return 0.0
return sum(1 for doc in top_k if doc in relevant_ids) / k
def reciprocal_rank(retrieved_ids: list, relevant_ids: set) -> float:
for i, doc_id in enumerate(retrieved_ids):
if doc_id in relevant_ids:
return 1.0 / (i + 1)
return 0.0
def ndcg_at_k(retrieved_ids: list, relevance_map: dict, k: int) -> float:
dcg = 0.0
for i, doc_id in enumerate(retrieved_ids[:k]):
rel = relevance_map.get(doc_id, 0)
dcg += rel / np.log2(i + 2)
ideal_rels = sorted(relevance_map.values(), reverse=True)[:k]
idcg = sum(rel / np.log2(i + 2) for i, rel in enumerate(ideal_rels))
if idcg == 0:
return 0.0
return dcg / idcg
labeled_set = [
{
"query": "How does backpropagation work?",
"retrieved": ["d3", "d1", "d7", "d4", "d2", "d9", "d5", "d8", "d6", "d10"],
"relevant": {"d1", "d4", "d5"},
"graded": {"d1": 2, "d4": 2, "d5": 1},
},
{
"query": "What is a transformer?",
"retrieved": ["d2", "d6", "d1", "d8", "d3", "d5", "d10", "d7", "d4", "d9"],
"relevant": {"d2", "d3"},
"graded": {"d2": 2, "d3": 1},
},
{
"query": "Explain gradient descent",
"retrieved": ["d8", "d10", "d5", "d1", "d7", "d3", "d9", "d2", "d6", "d4"],
"relevant": {"d1", "d7", "d9"},
"graded": {"d1": 2, "d7": 1, "d9": 1},
},
{
"query": "What is attention masking?",
"retrieved": ["d4", "d2", "d9", "d6", "d1", "d10", "d7", "d3", "d5", "d8"],
"relevant": {"d4", "d6"},
"graded": {"d4": 2, "d6": 1},
},
{
"query": "How do embeddings represent meaning?",
"retrieved": ["d7", "d3", "d5", "d2", "d10", "d1", "d8", "d9", "d4", "d6"],
"relevant": {"d3", "d5", "d10"},
"graded": {"d3": 2, "d5": 2, "d10": 1},
},
]
for k in [5, 10]:
recalls = [recall_at_k(q["retrieved"], q["relevant"], k) for q in labeled_set]
precisions = [precision_at_k(q["retrieved"], q["relevant"], k) for q in labeled_set]
print(f"Mean recall@{k}: {np.mean(recalls):.3f}")
print(f"Mean precision@{k}: {np.mean(precisions):.3f}")
mrrs = [reciprocal_rank(q["retrieved"], q["relevant"]) for q in labeled_set]
print(f"MRR: {np.mean(mrrs):.3f}")
ndcgs = [ndcg_at_k(q["retrieved"], q["graded"], 5) for q in labeled_set]
print(f"Mean nDCG@5: {np.mean(ndcgs):.3f}")
hit_rates = [1 if any(d in q["relevant"] for d in q["retrieved"][:5]) else 0 for q in labeled_set]
print(f"Hit rate@5: {np.mean(hit_rates):.3f}")Building a labeled retrieval test set
A retrieval test set is a collection of (query, relevant_document_ids) pairs. The queries should represent real user questions; the relevant documents are the chunks a correct retrieval should return.
How many queries do you need?
- 50-100 queries are enough for directional evaluation — identifying regressions, comparing two retriever configurations, or catching obvious failure modes. At this scale, a 5% recall change across the set is visible but may not be statistically significant.
- 500+ queries provide statistical significance for A/B comparisons between retriever variants. At 500 queries, a paired t-test or bootstrap confidence interval can reliably detect a 2-3% recall difference at p < 0.05.
- Start with failure cases. The most valuable labeled queries are the ones your current system gets wrong. Trawl through user queries where the generated answer was poor and label the correct source chunks for each.
Labeling strategies:
- Manual labeling. A domain expert reads each query, finds the correct chunks in the corpus, and records their IDs. Gold standard but expensive — budget 2-5 minutes per query.
- LLM-assisted labeling. Use a strong model (GPT-4, Claude) to generate candidate relevant chunks for each query, then have a human verify. Cuts labeling time by 50-70% while maintaining quality.
- Synthetic queries. Generate questions from your chunks using an LLM: "Given this passage, write 3 questions this passage would answer." The chunk that generated the question is automatically labeled as relevant. This bootstraps a test set quickly but may not reflect real user query distribution.
Offline vs. online evaluation
Offline evaluation runs against the labeled test set. It measures retrieval quality in isolation — before any user sees the system. Use it for:
- Comparing retriever configurations (embedding model A vs. B, with/without reranking)
- Regression testing after index changes (new chunks, different chunking strategy)
- Tuning parameters (top-k, similarity threshold, RRF weights)
Online evaluation measures retrieval quality in production, typically through proxy signals:
- Click-through on citations. If the generated answer includes source citations and users click through, the cited chunks were likely relevant.
- Thumbs-up/down on answers. A thumbs-down does not pinpoint whether retrieval or generation failed, but a pattern of thumbs-down on a query cluster suggests a retrieval gap.
- Retrieval-to-generation coupling. Log the retrieved chunks alongside the generated answer. When a human reviews a bad answer, they can tag whether the correct source was retrieved but misused (generation problem) or never retrieved at all (retrieval problem).
The distinction between retrieval evaluation and end-to-end evaluation matters. Recall@k tells you whether the retriever found the right chunks. It does not tell you whether the model produced a correct answer from those chunks — that is generation evaluation, covered in L17.