Home / AI & GenAI / Vector Databases

AI & GenAIHow-toAdvanced

Measure RAG retrieval properly: recall@k, MRR and a golden set that catches regressions

Stop judging RAG by the final answer alone. Build a golden question set, compute recall@k, MRR and nDCG against your retriever, compare chunking and reranker changes, and fail CI on regressions.

Deepak Bhardwaj7 min read

Applies toRAG pipelines on any vector store (pgvector, Azure AI Search, Qdrant, Pinecone and others), Python 3.10+

When to use this

Users say the assistant "misses things", and nobody can say whether that's the model or the search. Or you're changing a chunk size, an embedding model or a reranker, and you have no numbers to show whether it helped.

Retrieval failures limit everything after them: if the right chunk never reaches the model, no prompt can recover it. So measure retrieval by itself. It's also fast and cheap to test, because no LLM call is needed.

Step 1: build a golden set

A golden set is a list of real questions, each with the documents that should be found.

{"id": "q001", "query": "How do I rotate the VPN gateway certificate?", "relevant": ["runbook-vpn-cert", "kb-1042"], "tags": ["networking"]}
{"id": "q002", "query": "error 9002 transaction log full", "relevant": ["sql-log-full"], "tags": ["exact-term", "database"]}
{"id": "q003", "query": "who approves production firewall changes", "relevant": ["policy-change-mgmt"], "tags": ["policy"]}

How to build a useful one:

Practice Why
Take questions from real logs and tickets Invented questions are too tidy
Start with 50-100, then grow it Enough to see change without a big effort
Include hard ones: exact codes, acronyms, multi-part These fail most often
Include questions with no answer Tests whether you retrieve junk
Tag by topic and type An average can hide a broken area
Add every production failure you find The set improves over time
Have a subject expert review the labels Bad labels produce false results

Match at the level of document or stable section ID, not chunk ID. Chunk IDs change whenever you re-chunk, and that would invalidate the set just when you need to compare.

Warning

Real questions may contain personal or sensitive data. Clean them before you add them to a repository, and apply the same access rules as the source data. See PII redaction.

Step 2: choose metrics

Metric Question it answers Good for
Hit rate@k Was at least one right document in the top k? A simple health signal
Recall@k What share of the right documents were in the top k? Multi-document questions
Precision@k What share of the top k was relevant? Context noise, and cost
MRR How high was the first right result? Whether reranking helps
nDCG@k How good was the whole ranking? Graded relevance

Choose k to match what your app really sends to the model. If you pass 5 chunks, report recall@5. A good recall@50 doesn't help you if only 5 get used, though it tells you a reranker has something to work with.

Step 3: compute them

This has no dependency on any vector store. You supply a retrieve function:

import json, math, statistics
from typing import Callable

def load_golden(path):
    with open(path, encoding="utf-8") as f:
        return [json.loads(line) for line in f if line.strip()]

def dedupe(doc_ids):
    """Several chunks can come from one document; keep first-seen order."""
    return list(dict.fromkeys(doc_ids))

def recall_at_k(ranked, relevant, k):
    return len(set(ranked[:k]) & relevant) / len(relevant)

def hit_at_k(ranked, relevant, k):
    return float(bool(set(ranked[:k]) & relevant))

def reciprocal_rank(ranked, relevant):
    for i, doc in enumerate(ranked, start=1):
        if doc in relevant:
            return 1.0 / i
    return 0.0

def ndcg_at_k(ranked, relevant, k):
    dcg = sum(1 / math.log2(i + 1) for i, d in enumerate(ranked[:k], 1) if d in relevant)
    ideal = sum(1 / math.log2(i + 1) for i in range(1, min(len(relevant), k) + 1))
    return dcg / ideal if ideal else 0.0

def evaluate(golden, retrieve: Callable[[str, int], list[str]], k=5, fetch=20):
    per_query = []
    for item in golden:
        relevant = set(item["relevant"])
        if not relevant:
            continue          # score no-answer questions separately
        ranked = dedupe(retrieve(item["query"], fetch))
        per_query.append({
            "id": item["id"],
            "tags": item.get("tags", []),
            "hit": hit_at_k(ranked, relevant, k),
            "recall": recall_at_k(ranked, relevant, k),
            "mrr": reciprocal_rank(ranked, relevant),
            "ndcg": ndcg_at_k(ranked, relevant, k),
            "top": ranked[:k],
        })
    summary = {m: round(statistics.mean(q[m] for q in per_query), 3)
               for m in ("hit", "recall", "mrr", "ndcg")}
    return summary, per_query

Adapter for pgvector, as an example:

import psycopg

def make_retriever(conn, embed):
    def retrieve(query, n):
        vector = embed(query)
        rows = conn.execute(
            """SELECT doc_id FROM chunks
               ORDER BY embedding <=> %s::vector LIMIT %s""",
            (str(vector), n),
        ).fetchall()
        return [r[0] for r in rows]
    return retrieve

Fetch more chunks than k, because deduplicating by document shortens the list.

Step 4: read the failures

The average tells you whether there's a problem. The failing queries tell you why.

summary, rows = evaluate(golden, retrieve, k=5)
print(summary)

for row in sorted(rows, key=lambda r: r["recall"])[:15]:
    print(row["id"], row["recall"], row["top"])

Break it down by tag too, so a good average can't hide a weak area:

from collections import defaultdict

by_tag = defaultdict(list)
for row in rows:
    for tag in row["tags"]:
        by_tag[tag].append(row["recall"])

for tag, values in sorted(by_tag.items()):
    print(f"{tag:15} n={len(values):3} recall={statistics.mean(values):.2f}")

Then match the pattern to a fix:

Pattern Likely cause Try
Exact codes and product names fail Pure vector search misses literal terms Hybrid search
High recall@50, low recall@5 Right document found but ranked too low A reranker
Low recall even at 50 Bad chunks, bad embeddings or missing content Chunking, ingestion
Fails with filters only Filter too strict, or post-filtering Filtered search
Multi-part questions fail One query can't match both parts Query decomposition
Scores vary between runs Approximate index tuned for speed HNSW tuning

Step 5: compare changes fairly

Change one thing at a time, and compare per query, not just on averages. With small sets, differences can be pure noise.

import random

def paired_bootstrap(base_rows, new_rows, metric="recall", n=5000, seed=7):
    rng = random.Random(seed)
    base = {r["id"]: r[metric] for r in base_rows}
    new = {r["id"]: r[metric] for r in new_rows}
    diffs = [new[i] - base[i] for i in base if i in new]
    means = sorted(
        statistics.mean(rng.choices(diffs, k=len(diffs))) for _ in range(n)
    )
    return {
        "mean_diff": round(statistics.mean(diffs), 3),
        "ci_95": (round(means[int(n * .025)], 3), round(means[int(n * .975)], 3)),
        "better": sum(d > 0 for d in diffs),
        "worse": sum(d < 0 for d in diffs),
    }

If the 95% interval includes zero, you haven't shown an improvement. Also look at which queries got worse, as a net gain might still break something critical. Measure latency and cost at the same time, because a small recall gain may not justify a large increase in either.

Step 6: make it a CI gate

Store a baseline with the code, and fail the build on a real drop:

import json, sys

baseline = json.load(open("eval/baseline.json"))
summary, _ = evaluate(golden, retrieve, k=5)

failed = [m for m in ("recall", "mrr")
          if summary[m] < baseline[m] - 0.02]

print(summary)
if failed:
    print("Regression in:", failed)
    sys.exit(1)

Run it whenever the embedding model, chunking, index settings or retrieval code changes. Use a fixed snapshot of the index for repeatable results, or expect some drift when content changes.

Caution

Don't update the baseline just to get a build to pass. Require a person to review the change, and to say why it's acceptable. Otherwise, your gate slowly lowers itself.

After retrieval: measure answers separately

Retrieval metrics don't tell you if the model used the context correctly. Run these as a separate stage, using LLM-as-judge evaluation. That way, you can tell where a failure comes from:

Retrieval Answer Problem is in
Good Good Nothing
Good Bad Prompt or model
Bad Bad Retrieval
Bad Good Model knowledge, which is risky: check citations

Prevent it next time