How to Test if RAG Is Retrieving the Right Context
Tom PiaggioCo-Founder at Autonoma
To test if RAG is retrieving the right context, isolate retrieval from generation and test it as its own step: build a labeled set of query-to-expected-chunk-ID pairs, run only the retriever against it (no LLM call involved), and assert on hit rate/recall@k, MRR, and context precision/recall. Gate these metrics in CI so a regression in retrieval fails the build before it ever reaches a user as a confidently wrong answer.
Somewhere on your team right now, someone is tuning a prompt that doesn't need tuning. The RAG feature gave a wrong answer, the eval flagged it, and the fix everyone reaches for is prompt engineering: rewrite the system message, add a stricter instruction, try a different model. Three iterations later the answer is still wrong, because the prompt was never the problem. The retriever pulled the wrong chunk, handed it to the generator, and the generator did exactly what it was asked: summarize the document it was given, faithfully, confidently, and completely irrelevantly.
This is the failure mode almost nobody isolates. Teams test the RAG pipeline end to end, watch a bad answer come out, and start debugging three layers too high. The generation step passed its own quiet test the whole time: given garbage in, it produced a fluent, on-topic-sounding summary of garbage. The actual defect lives in the vector search, and it will keep producing wrong answers no matter how many times you rewrite the prompt around it.
Isolate Retrieval From Generation
The fix starts with a reframe most teams never make: retrieval is not a language model problem. It is a classic information-retrieval problem, the same one search engines have been testing for decades, with metrics that were established long before anyone put an LLM on top of a vector index. You do not need an LLM in the loop to test whether your retriever found the right document. You need the retriever, a set of queries with known-correct answers, and a way to compare a ranked list of chunk IDs against the ones you expected.
Strip the generator out and retrieval testing turns fast, cheap, and deterministic: no model call, no LLM-as-judge grading a summary. A test that once cost seconds and cents now runs in milliseconds, on every commit, without a second thought.
Cut the pipeline at the retriever's output. Everything left of the checkpoint is a fast, deterministic information-retrieval test. Everything right of it is a different problem with a different testing discipline.
A thin wrapper is all this requires: a function that takes a query string and returns a ranked list of chunk IDs, nothing else. No prompt template, no generator call, no output parsing. Here's that wrapper, built to sit in front of whatever vector store you're actually using:
"""A thin retrieval wrapper: query string in, ranked chunk IDs out.This is the entire surface the retrieval test suite talks to, and it isdeliberately the smallest thing that can be tested. There is no prompttemplate here, no generator call, no output parsing, no LLM client anywhere inthe import graph. Retrieval is an information-retrieval problem, so it getstested as one: deterministic input, deterministic ranked output, no model inthe loop and no API key required.The default index is a self-contained in-memory cosine-similarity index overthe help-center corpus in `src/corpus.py`, so this repo runs on a cleancheckout with nothing but numpy and pytest installed. Everything about yourreal store lives behind one method (`_search`) at the adapter seam below."""from __future__ import annotationsfrom dataclasses import dataclassfrom typing import Iterable, Sequenceimport numpy as npfrom src.corpus import HELP_CENTER_CHUNKS, Chunk, chunk_textfrom src.embedding import EMBEDDING_MODEL_VERSION, HashingEmbedder# The retrieval window the labeled test set is scored against.DEFAULT_TOP_K = 5@dataclass(frozen=True)class RetrieverConfig: """Test-visible retriever configuration. `embedding_model_version` is the important field. The article's point is that an embedding model bump between index time and query time silently reshuffles every neighborhood in the index, and nothing about the failure looks like a failure: no exception, no error log, just worse answers. So the version is an explicit, pinnable value that the regression suite asserts against the version recorded in the labeled fixture. Bump the model without re-measuring your baseline and CI tells you, instead of a customer. """ embedding_model_version: str = EMBEDDING_MODEL_VERSION top_k: int = DEFAULT_TOP_Kclass Retriever: """Ranked chunk IDs for a query. That is the whole contract.""" def __init__( self, chunks: Iterable[Chunk] | None = None, config: RetrieverConfig | None = None, ) -> None: self.config = config or RetrieverConfig() self._chunks: list[Chunk] = list(chunks if chunks is not None else HELP_CENTER_CHUNKS) if not self._chunks: raise ValueError("Retriever needs a non-empty corpus") documents = [chunk_text(chunk) for chunk in self._chunks] self._embedder = HashingEmbedder(documents, version=self.config.embedding_model_version) self._chunk_ids: list[str] = [chunk["chunk_id"] for chunk in self._chunks] # Rows are already L2-normalized, so a dot product is cosine similarity. self._matrix: np.ndarray = self._embedder.embed_all(documents) @property def embedding_model_version(self) -> str: """The pinned embedding model version backing this index.""" return self._embedder.version def retrieve(self, query: str, k: int | None = None) -> list[str]: """Return up to `k` chunk IDs, most similar first. Chunk IDs only. Not documents, not scores, not a prompt, not an answer. Everything the metrics in `src/metrics/` need is the ranked ID list, and keeping the return type this narrow is what makes retrieval testable in isolation from generation. """ window = self.config.top_k if k is None else k if window <= 0 or not query or not query.strip(): return [] return self._search(query, window) # ------------------------------------------------------------------ # ADAPTER SEAM # ------------------------------------------------------------------ # Everything above this line is store-agnostic and is what your test suite # should depend on. Everything below is the one method you replace to point # at a real vector store. Keep the signature and the tests do not change: # # Pinecone: # matches = index.query(vector=embed(query), top_k=k)["matches"] # return [match["id"] for match in matches] # # Weaviate: # response = collection.query.near_vector(embed(query), limit=k) # return [obj.properties["chunk_id"] for obj in response.objects] # # Qdrant: # hits = client.search(collection_name="chunks", # query_vector=embed(query), limit=k) # return [hit.payload["chunk_id"] for hit in hits] # # pgvector: # cursor.execute("SELECT chunk_id FROM chunks " # "ORDER BY embedding <=> %s LIMIT %s", (embed(query), k)) # return [row[0] for row in cursor.fetchall()] # # FAISS: # _, indices = index.search(embed(query).reshape(1, -1), k) # return [self._chunk_ids[i] for i in indices[0] if i != -1] # # When you swap this out, pin the real embedding model version in # RetrieverConfig and in tests/fixtures/labeled_queries.json so the suite # keeps failing loudly on an upstream model upgrade. # ------------------------------------------------------------------ def _search(self, query: str, k: int) -> list[str]: """In-memory cosine similarity over the corpus. Replace with your store.""" query_vector = self._embedder.embed(query) if not query_vector.any(): return [] scores = self._matrix @ query_vector # Sort by descending score, breaking ties on chunk ID so the ranking is # byte-stable across runs and platforms. An unstable tiebreak is a # flaky retrieval test waiting to happen. order = sorted( range(len(self._chunk_ids)), key=lambda index: (-float(scores[index]), self._chunk_ids[index]), ) return [self._chunk_ids[index] for index in order[:k] if scores[index] > 0.0] def titles_for(self, chunk_ids: Sequence[str]) -> list[str]: """Titles for a list of chunk IDs. Used only to print readable reports.""" lookup = {chunk["chunk_id"]: chunk["title"] for chunk in self._chunks} return [lookup.get(chunk_id, "<unknown chunk>") for chunk_id in chunk_ids]
How to Evaluate RAG Retrieval Quality: 4 Metrics That Catch Real Bugs
Retrieval has a small set of metrics that have been tested against exactly this kind of problem for decades, and each one catches a distinct failure a naive "did it work" check misses.
Hit Rate (Recall@k)
Hit rate, also called recall@k, asks the bluntest possible question: is the chunk you actually needed anywhere in the top-k results at all? It's a binary pass or fail per query, averaged across the test set. A hit rate of 0.65 at k=5 means over a third of your queries never surface the right document, no matter how good the generator's writing is. It's the first gate, and the one most teams stop at, which is a mistake: a chunk buried at rank 9 still counts as a "hit" at k=10 while being functionally invisible to a generator's context window.
"""Hit rate, also known as recall@k, plus its own tests.The bug it catches: the retriever never surfaces the right chunk at all. If theneeded chunk is not in the top-k, no amount of prompt engineering downstream cansave the answer, because the information was never in the context window.Hit rate is binary per query: the expected chunk is either somewhere in thetop-k or it is not. Averaged across a labeled set, that is your headlineretrieval number, and it is also where most teams stop, which is the mistakethis repo is built around. A chunk sitting at rank 9 counts as a full hit atk=10 while being functionally invisible to a generator whose context windowonly fits the top 3. See `src/metrics/mrr.py` for the metric that catches that.Run this file on its own: pytest src/metrics/hit_rate.py -v"""from __future__ import annotationsfrom typing import Collection, Sequenceimport pytestfrom src.metrics.common import dedupe, require_expected, require_paralleldef hit_rate( retrieved_ids: Sequence[str], expected_ids: Collection[str], k: int | None = None,) -> float: """1.0 if any expected chunk appears in the top-k retrieved IDs, else 0.0. `k=None` scores the full retrieved list as given. """ expected = require_expected(expected_ids) window = dedupe(retrieved_ids)[: k if k is not None else None] return 1.0 if expected.intersection(window) else 0.0def recall_at_k( retrieved_ids: Sequence[str], expected_ids: Collection[str], k: int,) -> float: """Recall@k: the IR-literature name for the same metric as `hit_rate`. Both are kept because both names show up in RAG evaluation write-ups and people reasonably assume they measure different things. They do not: this delegates. The graded question ("how many of the chunks I needed did I actually get?") is a different metric, and it lives in `src/metrics/context_precision_recall.py` as `context_recall`. """ return hit_rate(retrieved_ids, expected_ids, k=k)def mean_hit_rate( retrieved_ids: Sequence[Sequence[str]], expected_ids: Sequence[Collection[str]], k: int | None = None,) -> float: """Hit rate averaged over a labeled set. `retrieved_ids[i]` is the ranked list for query i and `expected_ids[i]` is that query's set of known-correct chunk IDs. """ require_parallel(retrieved_ids, expected_ids) scores = [ hit_rate(retrieved, expected, k=k) for retrieved, expected in zip(retrieved_ids, expected_ids) ] return sum(scores) / len(scores)# ----------------------------------------------------------------------# Tests# ----------------------------------------------------------------------RANKED_TEN = [f"chunk-{position:02d}" for position in range(1, 11)]def test_expected_chunk_at_rank_one_is_a_hit(): assert hit_rate(RANKED_TEN, {"chunk-01"}, k=5) == 1.0def test_chunk_ranked_low_still_counts_as_a_hit(): """The edge case the whole article hinges on. The same retrieval is a pass at k=10 and a miss at k=5, and the chunk never moved. Hit rate cannot tell you the difference between rank 1 and rank 9, which is why hit rate alone is not enough to gate a build on. """ assert hit_rate(RANKED_TEN, {"chunk-09"}, k=10) == 1.0 assert hit_rate(RANKED_TEN, {"chunk-09"}, k=5) == 0.0def test_missing_chunk_is_not_a_hit(): assert hit_rate(RANKED_TEN, {"chunk-99"}, k=10) == 0.0def test_any_one_expected_chunk_is_enough(): """Hit rate is satisfied by a single relevant chunk, however many are needed. Two of the three needed chunks are absent and hit rate still reports a perfect score. That gap is what `context_recall` measures. """ expected = {"chunk-02", "chunk-77", "chunk-88"} assert hit_rate(RANKED_TEN, expected, k=5) == 1.0def test_recall_at_k_is_hit_rate_under_another_name(): for k in (1, 3, 5, 10): assert recall_at_k(RANKED_TEN, {"chunk-04"}, k) == hit_rate(RANKED_TEN, {"chunk-04"}, k=k)def test_empty_retrieval_is_a_miss_not_an_error(): assert hit_rate([], {"chunk-01"}, k=5) == 0.0def test_duplicate_ids_are_collapsed_before_the_window_is_applied(): """A store echoing the same chunk four times has retrieved one chunk. Six rows come back but only three distinct chunks, so at k=3 the window is chunk-07, chunk-01, chunk-02 and the expected chunk is a hit. Counting rows instead of chunks would have called this a miss and sent someone off to debug a retriever that was working. """ noisy = ["chunk-07"] * 4 + ["chunk-01", "chunk-02"] assert hit_rate(noisy, {"chunk-02"}, k=3) == 1.0 assert hit_rate(noisy, {"chunk-01"}, k=2) == 1.0 assert hit_rate(noisy, {"chunk-02"}, k=2) == 0.0def test_empty_expected_set_raises(): with pytest.raises(ValueError): hit_rate(RANKED_TEN, set())def test_mean_hit_rate_matches_the_worked_example(): """Five of six labeled queries surface their chunk inside the top 10.""" ranks = [1, 2, 1, 9, 2, None] retrieved = [RANKED_TEN for _ in ranks] expected = [ {f"chunk-{rank:02d}"} if rank is not None else {"chunk-99"} for rank in ranks ] assert mean_hit_rate(retrieved, expected, k=10) == pytest.approx(5 / 6) assert round(mean_hit_rate(retrieved, expected, k=10), 2) == 0.83 # The same six queries at k=5: the rank-9 query drops out and the headline # number falls, without a single chunk changing position. assert mean_hit_rate(retrieved, expected, k=5) == pytest.approx(4 / 6)def test_mean_hit_rate_rejects_misaligned_inputs(): with pytest.raises(ValueError): mean_hit_rate([RANKED_TEN], [{"chunk-01"}, {"chunk-02"}])
Mean Reciprocal Rank (MRR)
Mean Reciprocal Rank fixes exactly that blind spot. MRR was standardized in the TREC question-answering track long before RAG existed, further evidence that retrieval is a decades-old IR problem, not a new one invented by LLMs. MRR doesn't just ask whether the right chunk showed up, it asks how high up: it's the average of one over the rank of the first relevant result, so a chunk at rank 1 scores a full point and one limping in at rank 9 scores barely a tenth of a point. This is the metric that catches "technically in top-10, so hit rate called it a pass, but ranked ninth and the generator's context window only fits the top 3." Hit rate tells you the chunk existed. MRR tells you whether it mattered.
"""Mean Reciprocal Rank, plus its own tests.The bug it catches: the right chunk is in the results but buried. Hit rate callsrank 1 and rank 9 the same pass. MRR does not. It averages one over the rank ofthe first relevant result, so rank 1 scores a full point and rank 9 scoresbarely a tenth of one, which is roughly how much use a generator whose contextwindow fits three chunks will get out of it.MRR predates RAG by decades: it was standardized in the TREC question-answeringtrack, which is the strongest available argument that retrieval quality is anold, solved-enough measurement problem rather than something new that arrivedwith LLMs.Run this file on its own: pytest src/metrics/mrr.py -v"""from __future__ import annotationsfrom typing import Collection, Sequenceimport pytestfrom src.metrics.common import dedupe, require_expected, require_parallelfrom src.metrics.hit_rate import mean_hit_ratedef reciprocal_rank( retrieved_ids: Sequence[str], expected_ids: Collection[str], k: int | None = None,) -> float: """1 / rank of the first relevant chunk, or 0.0 if none is in the top-k. Ranks are 1-based, so a relevant chunk at the top of the list scores 1.0. A miss scores 0.0 rather than being dropped from the average: a query whose answer is nowhere in the results is a real, countable failure, and skipping it would let a retriever improve its MRR by returning nothing at all. """ expected = require_expected(expected_ids) window = dedupe(retrieved_ids)[: k if k is not None else None] for position, chunk_id in enumerate(window, start=1): if chunk_id in expected: return 1.0 / position return 0.0def mean_reciprocal_rank( retrieved_ids: Sequence[Sequence[str]], expected_ids: Sequence[Collection[str]], k: int | None = None,) -> float: """MRR over a labeled set. `retrieved_ids[i]` is the ranked chunk-ID list for query i, and `expected_ids[i]` is that query's set of known-correct chunk IDs. """ require_parallel(retrieved_ids, expected_ids) scores = [ reciprocal_rank(retrieved, expected, k=k) for retrieved, expected in zip(retrieved_ids, expected_ids) ] return sum(scores) / len(scores)# ----------------------------------------------------------------------# Tests# ----------------------------------------------------------------------RANKED_TEN = [f"chunk-{position:02d}" for position in range(1, 11)]def _labeled_set_with_ranks(ranks: Sequence[int | None]): """Build parallel (retrieved, expected) sequences placing the relevant chunk at a given rank per query. `None` means the relevant chunk is not in the list at all.""" retrieved = [RANKED_TEN for _ in ranks] expected = [{f"chunk-{rank:02d}"} if rank is not None else {"chunk-99"} for rank in ranks] return retrieved, expecteddef test_rank_one_earns_full_credit(): assert reciprocal_rank(RANKED_TEN, {"chunk-01"}) == 1.0def test_rank_two_earns_half_credit(): assert reciprocal_rank(RANKED_TEN, {"chunk-02"}) == pytest.approx(0.5)def test_rank_nine_earns_barely_a_tenth(): """The failure MRR exists to surface, and hit rate cannot see.""" assert reciprocal_rank(RANKED_TEN, {"chunk-09"}) == pytest.approx(1 / 9) assert reciprocal_rank(RANKED_TEN, {"chunk-09"}) < 0.12def test_a_miss_scores_zero(): assert reciprocal_rank(RANKED_TEN, {"chunk-99"}) == 0.0def test_the_highest_ranked_relevant_chunk_wins(): """Reciprocal rank looks at the first relevant hit, not the best case.""" assert reciprocal_rank(RANKED_TEN, {"chunk-03", "chunk-07"}) == pytest.approx(1 / 3)def test_k_cutoff_turns_a_low_rank_into_a_miss(): assert reciprocal_rank(RANKED_TEN, {"chunk-09"}, k=5) == 0.0def test_empty_retrieval_scores_zero(): assert reciprocal_rank([], {"chunk-01"}) == 0.0def test_duplicates_do_not_shift_ranks(): """Rank is a position among distinct chunks, not among rows returned.""" noisy = ["chunk-05", "chunk-05", "chunk-05", "chunk-02"] assert reciprocal_rank(noisy, {"chunk-02"}) == pytest.approx(0.5)def test_empty_expected_set_raises(): with pytest.raises(ValueError): reciprocal_rank(RANKED_TEN, [])def test_mean_reciprocal_rank_rejects_misaligned_inputs(): with pytest.raises(ValueError): mean_reciprocal_rank([RANKED_TEN], [{"chunk-01"}, {"chunk-02"}])def test_article_worked_example_six_queries(): """The exact scenario in the article's labeled-set diagram. Six labeled queries, whose expected chunk landed at ranks 1, 2, 1, 9 and 2, with the sixth a miss outside the top ten. Hit rate @10 reads 5/6 = 0.83 and calls four of those five equally fine. MRR reads 0.52, because the rank-9 query (the exact-identifier lookup) and the miss drag it down. Two numbers, same six queries, and only one of them tells you the retriever has a ranking problem. """ ranks = [1, 2, 1, 9, 2, None] retrieved, expected = _labeled_set_with_ranks(ranks) mrr = mean_reciprocal_rank(retrieved, expected, k=10) expected_mrr = (1 + 0.5 + 1 + 1 / 9 + 0.5 + 0) / 6 assert mrr == pytest.approx(expected_mrr) assert round(mrr, 2) == 0.52 assert round(mean_hit_rate(retrieved, expected, k=10), 2) == 0.83 assert mrr < mean_hit_rate(retrieved, expected, k=10)
Context Precision and Recall
Context precision measures the opposite kind of waste: of everything you retrieved, how much of it was actually relevant? Retrieve 10 chunks and only 2 are on-topic, and you've diluted the generator's context with 8 chunks of noise, exactly the situation where a model starts blending irrelevant details into an answer that reads confident and is quietly wrong. Context recall asks the complementary question: did you retrieve everything needed to answer the query fully, or just part of it? A retriever that finds one relevant paragraph out of three needed for a complete answer will produce a partial, misleadingly confident response, and neither hit rate nor MRR will flag it, because technically, a relevant chunk did show up.
"""Context precision and context recall, plus their own tests.Two metrics, two distinct bugs, and conflating them is how teams end up applyingthe wrong fix:`context_precision` is the share of the chunks you retrieved that were actuallyrelevant. The bug it catches is noisy over-retrieval. Pull ten chunks where twoare on topic and you have handed the generator eight chunks of noise, which isexactly the condition under which a model starts blending unrelated details intoan answer that reads confident and is quietly wrong. Low precision with healthyrecall means tighten k or add a reranker.`context_recall` is the share of the chunks you needed that you actually got.The bug it catches is the partial answer. A retriever that finds one of the threeparagraphs required for a complete answer produces a response that isincomplete and does not sound incomplete, and neither hit rate nor MRR flags it,because technically a relevant chunk did show up. Low recall with healthyprecision usually means chunk boundaries are splitting one answer into piecesyou are only grabbing one of.Both are set metrics over whatever list you hand them, which matters: precisionshould be scored over the chunks you actually paste into the prompt, whilerecall should be scored over the full retrieval window. See`tests/test_retrieval_regression.py` for how those two windows differ.Run this file on its own: pytest src/metrics/context_precision_recall.py -v"""from __future__ import annotationsfrom typing import Collection, Sequenceimport pytestfrom src.metrics.common import dedupe, require_expected, require_paralleldef context_precision( retrieved_ids: Sequence[str], expected_ids: Collection[str],) -> float: """Share of retrieved chunks that are relevant. Catches noisy context. Truncate `retrieved_ids` to the context window you actually send to the generator before calling this. Precision over a list you never used tells you nothing about what diluted the prompt. """ expected = require_expected(expected_ids) retrieved = dedupe(retrieved_ids) if not retrieved: return 0.0 relevant = sum(1 for chunk_id in retrieved if chunk_id in expected) return relevant / len(retrieved)def context_recall( retrieved_ids: Sequence[str], expected_ids: Collection[str],) -> float: """Share of needed chunks that were retrieved. Catches partial answers.""" expected = require_expected(expected_ids) retrieved = set(dedupe(retrieved_ids)) return len(expected.intersection(retrieved)) / len(expected)def mean_context_precision( retrieved_ids: Sequence[Sequence[str]], expected_ids: Sequence[Collection[str]],) -> float: """Context precision averaged over a labeled set.""" require_parallel(retrieved_ids, expected_ids) scores = [ context_precision(retrieved, expected) for retrieved, expected in zip(retrieved_ids, expected_ids) ] return sum(scores) / len(scores)def mean_context_recall( retrieved_ids: Sequence[Sequence[str]], expected_ids: Sequence[Collection[str]],) -> float: """Context recall averaged over a labeled set.""" require_parallel(retrieved_ids, expected_ids) scores = [ context_recall(retrieved, expected) for retrieved, expected in zip(retrieved_ids, expected_ids) ] return sum(scores) / len(scores)# ----------------------------------------------------------------------# Tests# ----------------------------------------------------------------------def test_all_retrieved_chunks_relevant_is_perfect_precision(): retrieved = ["refund-policy-window", "refund-processing-time"] expected = {"refund-policy-window", "refund-processing-time"} assert context_precision(retrieved, expected) == 1.0def test_noise_dilutes_precision(): """Retrieve ten, only two on topic: the article's dilution example.""" retrieved = [f"chunk-{position:02d}" for position in range(1, 11)] expected = {"chunk-03", "chunk-07"} assert context_precision(retrieved, expected) == pytest.approx(0.2) # The same retrieval trimmed to the two relevant chunks scores perfectly. assert context_precision(["chunk-03", "chunk-07"], expected) == 1.0def test_partial_coverage_shows_up_in_recall(): """One of the three paragraphs an answer needs: a confident half-answer.""" retrieved = ["cancel-subscription-steps", "shipping-rates", "api-rate-limits"] expected = { "cancel-subscription-steps", "cancel-subscription-proration", "cancel-subscription-data-retention", } assert context_recall(retrieved, expected) == pytest.approx(1 / 3)def test_full_coverage_is_perfect_recall(): expected = {"csv-export-orders", "csv-export-limits", "csv-export-scheduling"} retrieved = ["csv-export-limits", "csv-export-orders", "csv-export-scheduling"] assert context_recall(retrieved, expected) == 1.0def test_precision_and_recall_diverge_and_that_is_the_point(): """Two failures that need opposite fixes, and only these metrics tell them apart.""" expected = {"invoice-lookup-by-number", "invoice-download-pdf"} # Too much: both needed chunks plus six chunks of noise. Recall is perfect, # precision is poor. Fix by tightening k or reranking, not by re-indexing. over_retrieved = list(expected) + [f"noise-{position}" for position in range(6)] assert context_recall(over_retrieved, expected) == 1.0 assert context_precision(over_retrieved, expected) == pytest.approx(0.25) # Too little: one needed chunk and nothing else. Precision is perfect, # recall is halved. Fix the chunking, not the ranking. under_retrieved = ["invoice-lookup-by-number"] assert context_precision(under_retrieved, expected) == 1.0 assert context_recall(under_retrieved, expected) == pytest.approx(0.5)def test_recall_ignores_rank_entirely(): """Both metrics are set metrics: reordering cannot change either one.""" expected = {"api-error-codes", "api-rate-limits"} forward = ["api-error-codes", "api-rate-limits", "api-key-rotation"] backward = list(reversed(forward)) assert context_recall(forward, expected) == context_recall(backward, expected) assert context_precision(forward, expected) == context_precision(backward, expected)def test_empty_retrieval_scores_zero_on_both(): expected = {"team-invite-members"} assert context_precision([], expected) == 0.0 assert context_recall([], expected) == 0.0def test_duplicate_chunks_do_not_inflate_the_denominator(): expected = {"shipping-tracking"} noisy = ["shipping-tracking", "shipping-tracking", "shipping-rates"] assert context_precision(noisy, expected) == pytest.approx(0.5)def test_empty_expected_set_raises(): with pytest.raises(ValueError): context_precision(["chunk-01"], set()) with pytest.raises(ValueError): context_recall(["chunk-01"], set())def test_means_over_a_labeled_set(): retrieved = [["a", "b"], ["a", "x"]] expected = [{"a", "b"}, {"a", "b"}] assert mean_context_precision(retrieved, expected) == pytest.approx(0.75) assert mean_context_recall(retrieved, expected) == pytest.approx(0.75)def test_means_reject_misaligned_inputs(): with pytest.raises(ValueError): mean_context_precision([["a"]], [{"a"}, {"b"}]) with pytest.raises(ValueError): mean_context_recall([["a"]], [{"a"}, {"b"}])
Be honest about what each one is actually telling you, because conflating them is how teams end up chasing the wrong fix. A low hit rate means your retriever isn't finding the right document at all: check your embedding model, your chunking, your index. A low MRR with a decent hit rate means the right document is in there but buried: check your ranking or reranking step. Low precision with a fine recall means you're retrieving too much noise: tighten k or add a reranker. Low recall with fine precision means you're retrieving too little: the chunk boundaries may be splitting an answer across pieces you're only grabbing one of.
How to Test Vector Search Accuracy Directly
Underneath all four metrics sits one direct question: for a given query, does the top-k set from your vector search actually contain the chunk you know is relevant? That's a narrower, more mechanical test than the aggregate metrics above, and it's worth running on its own because the failure modes at this layer are specific and not that hard to catch once you know where to look.
An embedding model mismatch between index-time and query-time is the most common one: someone bumps the model version for new documents without re-indexing the old ones, and now half the index lives in a different vector space than the other half, with similarity scores meaningless across the split. Chunk boundaries are the second: a chunking strategy that splits one answer across two chunks means neither scores high enough to surface alone. An ANN index like HNSW or IVF is deliberately approximate, trading recall for speed, so accuracy has a ceiling below 100% by design, not by bug. Metadata filters are a quieter failure: a filter meant to narrow results to the right tenant can silently exclude the correct chunk if tagged wrong at ingestion, leaving the query looking like it had no good match.
The blind spot worth calling out on its own: vector search is built to match semantic meaning, and it's famously bad at exact-identifier lookups. Ask it to find "the invoice for order 48291" and a dense retriever may confidently return three different invoices, because "order," "invoice," and a nearby-looking number cluster close together in embedding space while the literal string 48291 carries almost no semantic weight. If your application needs exact-ID lookups, that's a job for a keyword or hybrid search layer, not a pure semantic retriever, and testing for this means including exact-match queries in your labeled set on purpose, not assuming semantic similarity covers them.
Building a Labeled Retrieval Test Set
None of the metrics above mean anything without a labeled set: queries paired with the chunk ID (or IDs) you already know are the right answer. This is the part almost nothing written about RAG evaluation actually walks through, because most teams assume they need a dedicated labeling effort before they can start, and then never start.
You don't need a labeling team, you need to mine data you already have. Real user queries pulled from production logs are the highest-signal source: the actual distribution of questions, not a set someone imagined in a meeting. Support tickets are the second source, the ticket is the query and the document the agent linked is the expected chunk, a free label. The third source works when the first two are thin: ask an LLM to generate a couple of plausible questions per chunk, then have a human spend thirty seconds confirming or rejecting each pair before it enters the set. That confirmation step matters, an unverified LLM-generated set just tests whether your retriever agrees with the model that wrote the questions, which is circular.
Thirty to fifty labeled queries is enough to start gating a build on. It won't cover every edge case in your domain, but it's enough to catch the regressions that matter: an index rebuild that silently drops documents, an embedding model swap that reshuffles the neighborhood, a chunking change that splits answers differently than before.
Hit rate treats rank 1 and rank 9 as equal passes. MRR does not. The exact-identifier query (invoice #48291, buried at rank 9) is exactly the failure MRR is built to surface and hit rate alone would hide.
Gating the CI Build on Retrieval Metrics
A labeled set only earns its keep once it's wired into a build that fails when retrieval regresses. The pattern that works is a full pytest suite that loads the labeled fixture, runs every query through the retriever, computes the aggregate metrics across the whole set, and asserts each one against a threshold.
# ----------------------------------------------------------------------# The gates# ----------------------------------------------------------------------def test_hit_rate_meets_threshold(run): """Is the needed chunk in the top-k at all? Catches the retriever missing the document outright.""" assert run.hit_rate >= MIN_HIT_RATE, ( f"hit rate@{RETRIEVAL_K} regressed to {run.hit_rate:.3f}, " f"floor is {MIN_HIT_RATE}\n\n{run.report()}" )def test_mean_reciprocal_rank_meets_threshold(run): """How high did it rank? Catches a chunk technically retrieved and practically unused, buried below the generator's context window.""" assert run.mrr >= MIN_MRR, ( f"MRR regressed to {run.mrr:.3f}, floor is {MIN_MRR}\n\n{run.report()}" )def test_context_precision_meets_threshold(run): """How much of what we sent was relevant? Catches noisy over-retrieval diluting the prompt.""" assert run.context_precision >= MIN_CONTEXT_PRECISION, ( f"context precision@{PROMPT_CONTEXT_K} regressed to " f"{run.context_precision:.3f}, floor is {MIN_CONTEXT_PRECISION}\n\n{run.report()}" )def test_context_recall_meets_threshold(run): """Did we retrieve everything the answer needed? Catches partial, confidently incomplete answers.""" assert run.context_recall >= MIN_CONTEXT_RECALL, ( f"context recall@{RETRIEVAL_K} regressed to {run.context_recall:.3f}, " f"floor is {MIN_CONTEXT_RECALL}\n\n{run.report()}" )
Set thresholds on regression, not on an imagined ideal. A brand-new retriever rarely starts at a hit rate of 0.95, and gating the very first CI run at a number nobody has actually hit yet just means the build never goes green and the check gets disabled within a month. Measure your current baseline honestly, set the threshold a few points below it as a floor, and ratchet the floor up over time as the system improves. The goal of the gate is "did this change make retrieval worse," not "is retrieval perfect," and those are different bars with very different failure costs if you confuse them. The same ratchet-the-floor pattern applies to generation-side evals, covered in how to run LLM evals in CI/CD.
name: retrieval regression# Gate every pull request on retrieval quality. The suite is deterministic and# needs no API key, no vector database, and no network, so it runs in seconds on# a stock runner and belongs on every PR rather than on a nightly cron.on: pull_request: push: branches: [main]jobs: retrieval-metrics: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: # Pinned, not "3.x". A silent interpreter bump is one more way for a # baseline to stop meaning what it meant when it was measured. python-version: "3.12" cache: pip - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Run the retrieval regression suite # Fails the build when hit rate, MRR, context precision, or context # recall drops below the thresholds in tests/test_retrieval_regression.py. # The failure output carries a per-query rank report, so a red build tells # you which queries stopped ranking instead of just which number moved. run: pytest -v
The other way a gate gets disabled within a month is quieter than a threshold nobody can hit: it stops matching the code. Chunking moves, the retriever is swapped, the feature around it is rewritten, and the labeled set keeps asserting against a shape the system no longer has. On the behavioral half of this problem that upkeep is automated rather than scheduled: Autonoma's Diffs Agent reads each pull request's diff and adds, updates, or deprecates the end-to-end cases the change affects, which is the same instinct as ratcheting a floor applied to the tests themselves instead of the numbers. Your labeled fixture still needs a human. The cases above it don't.
When the gate fails, resist the reflex to touch the prompt. A retrieval-metric failure means the problem is upstream of the generator: check whether the index was rebuilt, whether the embedding model version changed, whether a chunking change shifted boundaries, or whether new documents landed with the wrong metadata tags. Fix the retrieval layer, rerun the labeled set, and only then look at generation if the retrieval metrics are back to baseline and the answer is still wrong.
How Autonoma Catches the Failure Retrieval Metrics Miss
Here's what a broken retriever looks like from the outside, because it never looks broken. The user asks a specific question. The retriever, silently, hands the generator the wrong chunk, close enough in embedding space to seem plausible, wrong enough to make the answer false. The generator does its job faithfully: it summarizes what it was given, fluently, in well-formed sentences, often citing a source. No exception fires. Nothing appears in an error log. The response looks, by every surface signal, like a good answer. It is confidently, cleanly wrong, and the one metric that would have caught it (hit rate on a labeled set, computed before this query ever reached a real user) was simply never run.
That failure mode has a second layer most retrieval metrics can't see at all: even a retriever that scores perfectly on hit rate and MRR only proves the right chunk was returned, not that the user actually got the right experience in the product. The retrieved chunk can be exactly correct and the feature can still be broken, because the answer renders in the wrong panel, the citation link 404s, the streaming response cuts off mid-sentence, or the "no results found" empty state never fires when it should have. That gap between "retrieval was correct" and "the user's screen showed the correct thing" is behavioral, not a retrieval metric at all, and it's the layer Autonoma tests directly. Our Planner reads the application code around the retrieval feature and plans end-to-end cases from it, including the database state each case needs, and the Executor drives the real UI to confirm the retrieval win actually showed up where the user was looking.
Retrieval Metrics at a Glance
Metric
What it measures
Bug it catches
Starting threshold
Hit rate / recall@k
Is the chunk in top-k at all
Retriever misses the doc entirely
0.80+ at k=5
MRR
How high the chunk ranked
Buried at rank 8-9, unused by generator
0.60+
Context precision
Share of retrieved chunks that are relevant
Noisy context diluting the prompt
0.70+
Context recall
Share of needed info actually retrieved
Partial, incomplete answers
0.75+
These starting thresholds are opinionated defaults, not an industry standard. Treat them as a floor to beat on day one, then replace them with your own measured baseline.
Retrieval is, if anything, more deterministic than generation, which is why it's worth asserting on this hard. Same query, same index, same chunks, no sampling temperature to shrug off. The real non-determinism has three sources: re-indexing changes what's in the store, an approximate ANN index trades exactness for speed by design, and an embedding model version bump silently reshuffles the neighborhood since old and new vectors aren't comparable. Handle all three the same way: assert on chunk IDs with exact equality where a query has one correct answer, use threshold assertions on the aggregate metrics rather than expecting every query to pass in isolation, and pin the embedding model version in your test config so an upstream upgrade shows up as a CI failure instead of a support ticket.
If retrieval is solid but you're still seeing bad answers, the fault likely sits one layer over: our guide to testing for AI hallucinations covers faithfulness checks for when the generator drifts from even a correctly retrieved chunk. Once retrieval is gated, testing a full RAG pipeline covers the end-to-end layer above this one, and RAG evaluation metrics goes deeper on the broader metric set.
Put in order, the whole method is short enough to hold in your head. Isolate retrieval from generation so a failure has one address instead of two. Label a small set of real queries with the chunks that should answer them. Compute hit rate and MRR on that set, add context precision and recall when partial answers are the complaint, and gate the aggregate rather than any single query. Set the threshold below your measured baseline and ratchet it, pin the embedding model version, and treat a red build as an index or chunking problem until the metrics say otherwise. That gets you a retriever you can trust to return the right chunk.
What it does not get you is a feature you can trust, and the difference is worth being blunt about because it decides where Autonoma belongs. Every number above is computed on what the retriever handed back, never on what the user saw. Autonoma sits above that boundary: it computes no retrieval metric and ranks no chunks, it drives the running application and asserts the retrieved answer rendered, linked, and fell back the way it was supposed to. Keep the labeled set as your retrieval gate. Add a behavioral gate above it, because a perfect hit rate and a broken feature are entirely compatible.
Frequently Asked Questions
Thirty to fifty labeled query-to-chunk pairs is enough to start gating a CI build on. It won't cover every edge case, but it's enough to catch real regressions: an index rebuild that drops documents, an embedding model swap that reshuffles rankings, or a chunking change that splits answers differently. Grow the set over time by adding a case every time a real production query fails.
There's no universal number, because it depends on your document set and query difficulty, but 0.80 or higher at k=5 is a reasonable starting bar for most applications. More important than hitting an absolute number is measuring your own baseline honestly and gating on regression from that baseline, not on an imagined ideal you haven't actually achieved yet.
Both, but test retrieval separately first. Isolating retrieval from generation turns a slow, expensive, LLM-in-the-loop test into a fast, deterministic, information-retrieval test that runs on every commit. Once retrieval is solid and gated in CI, layer end-to-end pipeline tests on top to catch faithfulness and answer-quality issues that only show up when generation is involved.
Not reliably. Dense vector search is built to match semantic meaning, not literal strings, so a query for a specific ID or exact term can return semantically similar but factually wrong results, because the surrounding language clusters closely in embedding space even when the specific identifier doesn't match. If your application needs exact lookups, add a keyword or hybrid search layer, and include exact-match queries in your labeled test set specifically to catch this.
Mine one instead of waiting for someone to build it. Real user queries from production logs paired with the documents users ultimately engaged with are the highest-signal source. Support tickets are a second source, the ticket is the query and whatever document resolved it is the label. A third option is generating candidate questions per chunk with an LLM and having a human spend a few seconds confirming or rejecting each one before it enters the set, which keeps the labels honest instead of circular.
The most common causes are an embedding model mismatch between when documents were indexed and when queries are run, chunk boundaries that split a single answer across pieces, an approximate ANN index trading recall for speed, metadata filters silently excluding the correct document, and semantic search's known weakness on exact-identifier or keyword-style queries.