
Building Production-Ready RAG Systems: Retrieval, Reranking, Evaluation, and Failure Modes
Engineer production-ready RAG with testable retrieval, hybrid search, reranking, context selection, evaluation, abstention, and grounded generation boundaries.
A RAG response is not reliable merely because it sounds correct. The evidence path can fail before generation begins: the corpus may be stale, parsing may drop structure, chunking may separate a condition from its instruction, retrieval may miss the right document, ranking may bury it, or context assembly may discard it. A production-oriented design records each boundary and evaluates retrieval separately from generation. The executable example in this guide uses a fictional engineering-runbook corpus, deterministic lexical logic, and fixture-provided dense-score proxies. It calls no model, embedding service, vector database, or network and makes no production benchmark claim.
Why toy RAG demos hide retrieval failures
A fluent answer collapses several independently fallible stages into one impression.
The familiar prototype—embed documents, retrieve a few chunks, place them in a prompt, and display the response—can demonstrate a flow. It cannot by itself explain why an answer succeeded. A model may answer from parametric memory even when retrieval failed, copy a stale chunk faithfully, or produce a plausible sentence from irrelevant context. The final text hides which evidence entered and which evidence was lost.
Production debugging requires named intermediate records: corpus and index versions, normalized query, applied filters, candidate identifiers and scores, reranked order, selected context, abstention decision, generated claims, and cited evidence. With those records, a missing document is a retrieval defect, a buried document is a ranking defect, and a contradiction despite correct context is a generation defect.
Retrieval-augmented generation does not eliminate hallucinations. It adds an inspectable external-evidence path that can improve grounding only when ingestion, retrieval, context assembly, generation, and validation behave as intended.
Tips
- Evaluate the retriever before asking whether the generated prose looks useful.
- Preserve document and chunk identifiers through context assembly.
- Make insufficient evidence a valid outcome rather than an exceptional UI state.
The production RAG pipeline
Separate document preparation, query-time retrieval, bounded generation, and evaluation.
On the document side, parsing and normalization preserve useful structure before chunks inherit version, source, status, service, environment, and access metadata. Dense representations and sparse terms are indexing choices, not proof that a document is relevant.
On the query side, normalization must retain exact identifiers such as API-504 while adding any authorized tenant, product, environment, and version constraints. Candidate retrieval seeks recall; merging and reranking improve ordering; context selection applies a token and evidence budget; generation consumes only the selected evidence; and support checks decide whether to answer or abstain.
The evaluation side uses a versioned corpus, query set, relevance labels, metrics, slices, and release policy. Retrieval evaluation asks whether evidence survived and where it ranked. Generation evaluation asks whether the answer used that evidence correctly. These are complementary experiments.
Worked example — synthetic engineering runbook retrieval
A deterministic simulator makes the retrieval policy inspectable without pretending to run embeddings.
The fictional corpus contains active and deprecated database failover procedures, production and staging API timeout guides, cache invalidation, deployment rollback, authentication-incident, observability, and release-checklist documents. Eleven queries cover exact identifiers, paraphrases, metadata filters, stale competition, distractors, a no-answer request, and an ambiguous metadata constraint.
Every dense_proxy value is hand-authored fixture data. It is a deterministic score proxy used to exercise ranking code; it is not an embedding, cosine similarity measurement, vector-database result, provider output, or production observation. The baseline ranks these proxies directly. The candidate removes deprecated documents, applies fixture metadata filters, combines the proxy with lexical overlap, adds deterministic identifier and service bonuses, and selects the top three above an educational threshold.
The fixture intentionally avoids a perfect candidate. It improves two answerable queries and removes stale selections, but answers an unsupported payroll-key question and loses a relevant authentication guide when an ambiguous environment filter conflicts with document metadata. The critical no-answer regression blocks release.
examples/rag_retrieval_fixture.json{
"dataset_version": "runbook-retrieval-fixture-v1",
"retriever_versions": {
"baseline": "dense-proxy-baseline-v1",
"candidate": "hybrid-filter-rerank-v1"
},
"release_policy_version": "retrieval-slice-gate-v1",
"top_k": 3,
"documents": [
{
"id": "db-failover-v1",
"title": "Database failover procedure DB-F01",
"text": "Legacy production database failover: promote the standby, then update the old proxy target.",
"service": "database",
"environment": "production",
"status": "deprecated",
"version": "1"
},
{
"id": "db-failover-v2",
"title": "Database failover runbook DB-F01",
"text": "For production primary database loss, declare the incident, verify replica health, promote the approved standby, update routing, and validate writes before switchback.",
"service": "database",
"environment": "production",
"status": "active",
"version": "2"
},
{
"id": "api-timeout-prod",
"title": "Production API timeout guide API-504",
"text": "For API-504 in production, inspect upstream latency, request traces, connection-pool saturation, and bounded retry telemetry before changing timeouts.",
"service": "api",
"environment": "production",
"status": "active",
"version": "3"
},
{
"id": "api-timeout-staging",
"title": "Staging API timeout test guide API-504",
"text": "For API-504 in staging, confirm the test dependency, synthetic load profile, and staging gateway configuration.",
"service": "api",
"environment": "staging",
"status": "active",
"version": "2"
},
{
"id": "cache-invalidation",
"title": "Cache invalidation procedure CACHE-17",
"text": "For CACHE-17, purge only the affected namespace, verify origin health, warm critical keys, and monitor miss rate.",
"service": "cache",
"environment": "production",
"status": "active",
"version": "4"
},
{
"id": "deployment-rollback",
"title": "Deployment rollback procedure",
"text": "To roll back a production deployment, freeze further releases, select the last approved artifact, deploy it, run smoke checks, and record the decision.",
"service": "deployment",
"environment": "production",
"status": "active",
"version": "5"
},
{
"id": "auth-incident",
"title": "Authentication incident guide AUTH-22",
"text": "For AUTH-22 or suspected session theft, revoke affected sessions, rotate credentials through the approved workflow, preserve audit evidence, and escalate.",
"service": "authentication",
"environment": "production",
"status": "active",
"version": "6"
},
{
"id": "observability-alerts",
"title": "Observability alert review",
"text": "Review alert ownership, dashboard links, trace sampling, and escalation notes before changing thresholds.",
"service": "observability",
"environment": "shared",
"status": "active",
"version": "2"
},
{
"id": "release-checklist",
"title": "Release readiness checklist",
"text": "Confirm approvals, artifact identity, change record, smoke tests, monitoring, and rollback ownership before release.",
"service": "deployment",
"environment": "production",
"status": "active",
"version": "3"
}
],
"queries": [
{
"id": "q01",
"slice": "exact_identifier",
"query": "DB-F01 production failover procedure",
"filters": {"service": "database", "environment": "production"},
"relevance": {"db-failover-v2": 2},
"no_answer": false,
"critical": true,
"dense_proxy": {"db-failover-v1": 0.94, "db-failover-v2": 0.82, "deployment-rollback": 0.55}
},
{
"id": "q02",
"slice": "paraphrase",
"query": "What should we do when the primary data store stops accepting writes?",
"filters": {"service": "database", "environment": "production"},
"relevance": {"db-failover-v2": 2},
"no_answer": false,
"critical": false,
"dense_proxy": {"db-failover-v1": 0.81, "deployment-rollback": 0.75, "observability-alerts": 0.68, "db-failover-v2": 0.61}
},
{
"id": "q03",
"slice": "exact_identifier",
"query": "API-504 production troubleshooting",
"filters": {"service": "api", "environment": "production"},
"relevance": {"api-timeout-prod": 2},
"no_answer": false,
"critical": true,
"dense_proxy": {"api-timeout-staging": 0.91, "api-timeout-prod": 0.84, "observability-alerts": 0.52}
},
{
"id": "q04",
"slice": "metadata_filter",
"query": "Investigate API timeout during a staging load test",
"filters": {"service": "api", "environment": "staging"},
"relevance": {"api-timeout-staging": 2},
"no_answer": false,
"critical": true,
"dense_proxy": {"api-timeout-prod": 0.93, "observability-alerts": 0.77, "api-timeout-staging": 0.69}
},
{
"id": "q05",
"slice": "exact_identifier",
"query": "CACHE-17 affected namespace recovery",
"filters": {"service": "cache", "environment": "production"},
"relevance": {"cache-invalidation": 2},
"no_answer": false,
"critical": false,
"dense_proxy": {"cache-invalidation": 0.86, "observability-alerts": 0.58, "api-timeout-prod": 0.53}
},
{
"id": "q06",
"slice": "paraphrase",
"query": "Return production to the last approved application artifact",
"filters": {"service": "deployment", "environment": "production"},
"relevance": {"deployment-rollback": 2},
"no_answer": false,
"critical": true,
"dense_proxy": {"release-checklist": 0.88, "deployment-rollback": 0.74, "db-failover-v2": 0.57}
},
{
"id": "q07",
"slice": "security_runbook",
"query": "AUTH-22 suspected stolen session response",
"filters": {"service": "authentication", "environment": "production"},
"relevance": {"auth-incident": 2},
"no_answer": false,
"critical": true,
"dense_proxy": {"auth-incident": 0.89, "observability-alerts": 0.55, "api-timeout-prod": 0.51}
},
{
"id": "q08",
"slice": "stale_competition",
"query": "database failover switchback validation",
"filters": {"service": "database", "environment": "production"},
"relevance": {"db-failover-v2": 2},
"no_answer": false,
"critical": true,
"dense_proxy": {"db-failover-v1": 0.9, "db-failover-v2": 0.71, "release-checklist": 0.62}
},
{
"id": "q09",
"slice": "distractor",
"query": "API latency remains high after the upstream service recovered",
"filters": {"service": "api", "environment": "production"},
"relevance": {"api-timeout-prod": 2},
"no_answer": false,
"critical": false,
"dense_proxy": {"observability-alerts": 0.83, "api-timeout-staging": 0.64, "api-timeout-prod": 0.49}
},
{
"id": "q10",
"slice": "no_answer",
"query": "How do we rotate payroll encryption keys?",
"filters": {},
"relevance": {},
"no_answer": true,
"critical": true,
"dense_proxy": {"auth-incident": 0.46, "deployment-rollback": 0.31, "observability-alerts": 0.29}
},
{
"id": "q11",
"slice": "ambiguous_metadata",
"query": "Review shared authentication sessions after suspicious access",
"filters": {"service": "authentication", "environment": "shared"},
"relevance": {"auth-incident": 2},
"no_answer": false,
"critical": false,
"dense_proxy": {"auth-incident": 0.79, "observability-alerts": 0.61, "api-timeout-prod": 0.48}
}
]
}
examples/rag_retrieval_eval.pyfrom __future__ import annotations
import json
import math
import re
import sys
from collections import defaultdict
from pathlib import Path
TOKEN_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")
BASELINE_THRESHOLD = 0.50
CANDIDATE_THRESHOLD = 0.20
def tokens(value: str) -> set[str]:
return set(TOKEN_PATTERN.findall(value.casefold()))
def lexical_score(query: str, document: dict) -> float:
query_tokens = tokens(query)
document_tokens = tokens(document["title"] + " " + document["text"])
return len(query_tokens & document_tokens) / max(1, len(query_tokens))
def matches_filters(document: dict, filters: dict) -> bool:
return all(document.get(key) == value for key, value in filters.items())
def baseline_rank(query: dict, documents: list[dict], top_k: int) -> list[dict]:
ranked = [
{"id": document["id"], "score": query["dense_proxy"].get(document["id"], 0.0)}
for document in documents
]
ranked.sort(key=lambda item: (-item["score"], item["id"]))
return [item for item in ranked if item["score"] >= BASELINE_THRESHOLD][:top_k]
def candidate_rank(query: dict, documents: list[dict], top_k: int) -> list[dict]:
query_tokens = tokens(query["query"])
identifiers = {token for token in query_tokens if "-" in token or any(char.isdigit() for char in token)}
ranked = []
for document in documents:
if document["status"] == "deprecated" or not matches_filters(document, query["filters"]):
continue
document_tokens = tokens(document["title"] + " " + document["text"])
dense_proxy = query["dense_proxy"].get(document["id"], 0.0)
lexical = lexical_score(query["query"], document)
identifier_bonus = 0.18 if identifiers & document_tokens else 0.0
rerank_bonus = 0.08 if query["filters"].get("service") == document["service"] else 0.0
score = (0.62 * dense_proxy) + (0.30 * lexical) + identifier_bonus + rerank_bonus
ranked.append({"id": document["id"], "score": score})
ranked.sort(key=lambda item: (-item["score"], item["id"]))
return [item for item in ranked if item["score"] >= CANDIDATE_THRESHOLD][:top_k]
def ndcg(selected_ids: list[str], relevance: dict[str, int], top_k: int) -> float:
def dcg(grades: list[int]) -> float:
return sum((2**grade - 1) / math.log2(index + 2) for index, grade in enumerate(grades))
actual = [relevance.get(document_id, 0) for document_id in selected_ids[:top_k]]
ideal = sorted(relevance.values(), reverse=True)[:top_k]
ideal_score = dcg(ideal)
return dcg(actual) / ideal_score if ideal_score else 1.0 if not selected_ids else 0.0
def evaluate(name: str, queries: list[dict], documents: list[dict], top_k: int) -> dict:
ranker = baseline_rank if name == "baseline" else candidate_rank
per_query = []
totals = defaultdict(float)
answerable = 0
no_answer_cases = 0
no_answer_correct = 0
stale_selected = 0
document_by_id = {document["id"]: document for document in documents}
for query in queries:
selected = ranker(query, documents, top_k)
selected_ids = [item["id"] for item in selected]
if query["no_answer"]:
no_answer_cases += 1
correct = not selected_ids
no_answer_correct += int(correct)
per_query.append({"id": query["id"], "slice": query["slice"], "passed": correct, "selected": selected_ids})
continue
answerable += 1
relevant = set(query["relevance"])
relevant_selected = [document_id for document_id in selected_ids if document_id in relevant]
recall = len(relevant_selected) / len(relevant)
precision = len(relevant_selected) / max(1, len(selected_ids))
reciprocal_rank = next((1 / (index + 1) for index, document_id in enumerate(selected_ids) if document_id in relevant), 0.0)
normalized_dcg = ndcg(selected_ids, query["relevance"], top_k)
hit = bool(relevant_selected)
stale_selected += sum(document_by_id[document_id]["status"] == "deprecated" for document_id in selected_ids)
totals["recall"] += recall
totals["precision"] += precision
totals["mrr"] += reciprocal_rank
totals["ndcg"] += normalized_dcg
totals["hits"] += int(hit)
per_query.append({"id": query["id"], "slice": query["slice"], "passed": hit, "selected": selected_ids})
return {
"per_query": per_query,
"recall": totals["recall"] / answerable,
"precision": totals["precision"] / answerable,
"mrr": totals["mrr"] / answerable,
"ndcg": totals["ndcg"] / answerable,
"hit_rate": totals["hits"] / answerable,
"no_answer_accuracy": no_answer_correct / no_answer_cases,
"stale_selected": int(stale_selected),
}
def main() -> None:
fixture_path = Path(__file__).with_name("rag_retrieval_fixture.json")
fixture = json.loads(fixture_path.read_text(encoding="utf-8"))
baseline = evaluate("baseline", fixture["queries"], fixture["documents"], fixture["top_k"])
candidate = evaluate("candidate", fixture["queries"], fixture["documents"], fixture["top_k"])
baseline_by_id = {item["id"]: item for item in baseline["per_query"]}
candidate_by_id = {item["id"]: item for item in candidate["per_query"]}
regressions = [
query["id"]
for query in fixture["queries"]
if baseline_by_id[query["id"]]["passed"] and not candidate_by_id[query["id"]]["passed"]
]
critical_regressions = [
query["id"] for query in fixture["queries"] if query["id"] in regressions and query["critical"]
]
improved = [
query["id"]
for query in fixture["queries"]
if not baseline_by_id[query["id"]]["passed"] and candidate_by_id[query["id"]]["passed"]
]
release = (
"PASS"
if candidate["recall"] >= baseline["recall"]
and candidate["no_answer_accuracy"] >= baseline["no_answer_accuracy"]
and not critical_regressions
else "BLOCKED"
)
lines = [
"SYNTHETIC EDUCATIONAL FIXTURE — deterministic score proxies; no production benchmark",
f"dataset={fixture['dataset_version']}",
f"baseline={fixture['retriever_versions']['baseline']}",
f"candidate={fixture['retriever_versions']['candidate']}",
f"release_policy={fixture['release_policy_version']}",
f"queries={len(fixture['queries'])}",
f"baseline_recall_at_3={baseline['recall']:.4f}",
f"candidate_recall_at_3={candidate['recall']:.4f}",
f"baseline_precision_at_3={baseline['precision']:.4f}",
f"candidate_precision_at_3={candidate['precision']:.4f}",
f"baseline_mrr={baseline['mrr']:.4f}",
f"candidate_mrr={candidate['mrr']:.4f}",
f"baseline_ndcg_at_3={baseline['ndcg']:.4f}",
f"candidate_ndcg_at_3={candidate['ndcg']:.4f}",
f"baseline_hit_rate={baseline['hit_rate']:.4f}",
f"candidate_hit_rate={candidate['hit_rate']:.4f}",
f"baseline_no_answer_accuracy={baseline['no_answer_accuracy']:.4f}",
f"candidate_no_answer_accuracy={candidate['no_answer_accuracy']:.4f}",
f"baseline_stale_selections={baseline['stale_selected']}",
f"candidate_stale_selections={candidate['stale_selected']}",
f"improved={','.join(improved) or 'none'}",
f"regressed={','.join(regressions) or 'none'}",
f"critical_regressions={','.join(critical_regressions) or 'none'}",
f"candidate_release={release}",
]
sys.stdout.buffer.write("\n".join(lines).encode("utf-8"))
if __name__ == "__main__":
main()
Expected output
SYNTHETIC EDUCATIONAL FIXTURE — deterministic score proxies; no production benchmark
dataset=runbook-retrieval-fixture-v1
baseline=dense-proxy-baseline-v1
candidate=hybrid-filter-rerank-v1
release_policy=retrieval-slice-gate-v1
queries=11
baseline_recall_at_3=0.8000
candidate_recall_at_3=0.9000
baseline_precision_at_3=0.2833
candidate_precision_at_3=0.8500
baseline_mrr=0.5333
candidate_mrr=0.9000
baseline_ndcg_at_3=0.6024
candidate_ndcg_at_3=0.9000
baseline_hit_rate=0.8000
candidate_hit_rate=0.9000
baseline_no_answer_accuracy=1.0000
candidate_no_answer_accuracy=0.0000
baseline_stale_selections=3
candidate_stale_selections=0
improved=q02,q09
regressed=q10,q11
critical_regressions=q10
candidate_release=BLOCKEDCorpus and document contracts
Retrieval quality cannot exceed the evidence that ingestion preserves.
Define a document contract before choosing a vector store. At minimum, preserve a stable document ID, source URI or owner, version, updated timestamp, status, access scope, service or product, environment, section identity, and a content checksum. Parsing failures and unsupported formats should create visible ingestion records rather than silently disappearing.
Normalization should preserve headings, lists, table relationships, code blocks, warning labels, and links when those structures carry meaning. A table cell detached from its header can become misleading; a rollback command detached from its prerequisite can become unsafe. Store lineage from source document to chunk so corrections and deletions can be propagated.
Corpus quality is operational work. Assign ownership for deprecation, review cadence, duplicate resolution, access changes, and index rebuilding. A newer index over stale source documents is still stale.
| Field | Why it matters | Failure if omitted |
|---|---|---|
| document_id / chunk_id | Stable provenance and evaluation labels | Results cannot be reproduced or corrected precisely |
| version / updated_at | Distinguishes current and stale evidence | Contradictory procedures can enter context |
| status | Supports active/deprecated filtering | Superseded guidance competes with current guidance |
| service / environment | Narrows evidence to the requested scope | Semantically similar but invalid procedures rank |
| access_scope | Participates in authorization-aware retrieval | Unauthorized evidence may reach generation |
| source / checksum | Supports lineage and change detection | Index state cannot be reconciled with source |
Chunking is an information-retrieval decision
There is no universal optimal token count.
Chunk at semantic and structural boundaries: headings, procedures, warnings, table units, code plus explanation, and policy sections. Inherit document metadata and add section-level identity. Overlap can preserve continuity, but excessive overlap creates near-duplicates that consume candidate and context budgets.
Chunks that are too small lose conditions, actor, environment, and version context. Chunks that are too large blend unrelated instructions, weaken lexical specificity, increase reranker cost, and crowd the context window. Tables and code require format-aware parsing; fixed character slicing can split the evidence a query needs.
Token sizes sometimes used in examples are starting hypotheses, not standards. Evaluate alternatives on representative queries and relevant-chunk labels, then inspect failure cases by document type.
Common Mistakes
- Choosing one chunk size because a framework uses it by default.
- Discarding headings before embedding or keyword indexing.
- Duplicating the same evidence through uncontrolled overlap.
- Evaluating answers without checking whether the relevant chunk survived chunking.
Metadata, version, and access boundaries
Filtering can exclude invalid evidence, but it is not authorization by itself.
Useful filters include document and version identity, source, updated timestamp, service, product, environment, access scope, section, status, and deprecated flag. In the fixture, metadata removes the legacy DB-F01 procedure and separates production from staging API guidance.
Metadata can also fail. A user may supply an ambiguous environment, a document may be mislabeled, or a new version may omit inherited fields. Query q11 demonstrates that a strict filter can remove the only relevant authentication guide. Filter decisions therefore belong in telemetry and evaluation.
Authorization must be enforced by trusted application and storage boundaries using the caller's identity and policy—not merely by asking the model to respect metadata. Filters reduce the eligible corpus only after access has been established.
Tips
- Log applied filter keys and policy versions without logging sensitive content by default.
- Test missing, conflicting, and stale metadata as separate slices.
- Define whether deprecated evidence is excluded, review-only, or available for explicit historical queries.
Dense, sparse, and hybrid retrieval
Semantic and lexical signals solve different parts of the search problem.
Dense retrieval compares learned vector representations and can match paraphrases whose words differ. Sparse retrieval uses lexical signals such as term frequency and can preserve exact identifiers, product names, error codes, and uncommon terminology. Neither signal guarantees semantic correctness or operational validity.
Hybrid retrieval combines candidate sets or rankings from complementary retrievers. Elastic documents reciprocal rank fusion as one way to merge full-text and vector results. Anthropic's contextual-retrieval work likewise combines embedding and BM25 signals in a particular evaluated setup. Those are source-specific implementations and results, not a law that hybrid search always wins.
The local simulator performs no embedding. Its dense proxy is supplied by the fixture, while lexical overlap is calculated from visible tokens. This separation prevents an educational score from being mislabeled as a real model output.
| Signal | Useful for | Does not guarantee |
|---|---|---|
| Dense | Paraphrases and semantic similarity | Correctness, freshness, authorization, or exact identifiers |
| Sparse | Exact terms, codes, names, and rare phrases | Semantic equivalence or complete recall |
| Metadata filter | Version, environment, service, and scope constraints | Correct metadata or authorization completeness |
| Hybrid | Combining complementary candidate evidence | Universal improvement for every corpus and query slice |
Candidate generation should favor recall
The first-stage retriever should preserve plausible evidence for later ranking.
Candidate generation is usually broader than final context selection. Dense, sparse, and filtered searches may each contribute documents. Merge by stable chunk ID, retain raw scores and source rank, and avoid comparing unrelated score scales without calibration or rank-based fusion.
A low-recall first stage creates an unrecoverable failure: a reranker cannot promote a document it never receives. Evaluate candidate recall at the configured depth before optimizing final answer style. Inspect exact-identifier, paraphrase, stale-version, metadata, and distractor slices separately.
Increasing candidate depth may improve recall but raises latency, memory, and reranker cost. The right depth depends on measured corpus and query behavior; no fixture threshold here is proposed as an industry standard.
Reranking is a separate stage
Generate candidates broadly, then spend more work ordering a smaller set.
A reranker evaluates a query against a limited candidate set and produces a new ordering. Provider implementations may use cross-encoders or other learned models; the executable fixture uses transparent rules so every score component can be inspected.
The candidate adds lexical overlap, an exact-identifier bonus, and a service-match bonus after metadata filtering. That is educational engineering synthesis, not a claim that the formula is generally optimal. Its value is diagnostic: each feature and threshold is versioned and can be ablated on the same queries.
Reranking adds latency and cost, and candidate size changes both. Define timeout and fallback behavior: preserve the first-stage order, abstain, or route to review depending on consequence. Do not silently return an arbitrary partial order after a reranker failure.
Tips
- Record first-stage and reranked positions for every selected chunk.
- Evaluate reranker gains only on candidates that actually contain relevant evidence.
- Test timeout, empty-response, and malformed-score behavior.
Context-budget selection is not top-k copying
More retrieved chunks can add contradiction and noise.
Context assembly chooses evidence under a bounded budget. Deduplicate overlapping chunks, prefer current versions, retain necessary prerequisites, diversify across required subquestions, and preserve provenance. A high-scoring chunk can still be excluded if it duplicates stronger evidence or violates access and status policy.
Retrieving more chunks is not automatically better. Duplicate evidence consumes tokens, stale versions create contradiction, irrelevant context distracts generation, and ordering can change which instruction the model follows. Record both reranked candidates and final selected context so a budget drop is not misdiagnosed as retrieval failure.
A practical policy may reserve space for system instructions and the answer, cap evidence per source, and require a minimum support score. These are application choices to evaluate, not universal constants.
Diagnose retrieval and generation failures separately
The correct fix depends on the layer that lost or misused evidence.
A single end-to-end answer score cannot identify these boundaries. Preserve candidate IDs, ranks, selected IDs, and generated citations. When an evaluator flags an unsupported claim, first check whether supporting evidence existed and was selected before changing the prompt.
The distinction also prevents wasted iteration. Prompt changes cannot recover a filtered-out document, and a larger vector index cannot correct a generator that contradicts supplied evidence.
| Failure | Observable condition | Likely repair boundary |
|---|---|---|
| Corpus failure | Correct source is absent, stale, or unparsable | Source ownership, ingestion, or version policy |
| Retrieval failure | Relevant chunk never enters candidates | Query representation, index, filters, or candidate depth |
| Ranking failure | Relevant chunk is present but buried | Fusion, reranker, labels, or ranking features |
| Context-assembly failure | Relevant ranked chunk is dropped by budget policy | Deduplication, diversity, ordering, or token budget |
| Generation failure | Correct evidence is selected but answer contradicts it | Prompt/model/application response handling |
| Grounding failure | Answer adds claims unsupported by selected evidence | Claim-level support validation and abstention |
| No-answer failure | System answers without minimum evidence | Support threshold, policy, or review routing |
No-answer and abstention are product behaviors
Insufficient evidence should not be converted into confident prose.
Define a support gate before generation or before final delivery. If retrieval is empty, scores are below a calibrated boundary, evidence conflicts, access removes required sources, or citations do not support required claims, return an explicit insufficient-evidence state or route to review.
Evaluate no-answer cases separately because answerable-query recall can improve while false answers increase. In the fixture, the baseline abstains on the unsupported payroll encryption-key query. The candidate's lower threshold and lexical match to the authentication guide select irrelevant evidence, so no-answer accuracy falls from 1.0000 to 0.0000.
Those numbers describe one authored no-answer case, not an estimated production rate. A real dataset needs more cases, domain review, prevalence-aware sampling, and separate costs for false answers and unnecessary abstentions.
Retrieval metrics answer different questions
No single aggregate is sufficient.
Recall@K asks what fraction of labeled relevant evidence survived in the top K. Precision@K asks how much of the retrieved set is labeled relevant. Hit Rate asks whether at least one relevant result appeared. Mean Reciprocal Rank rewards placing the first relevant result earlier. nDCG is useful when relevance is graded and order matters.
Metrics depend on relevance labels, query distribution, corpus snapshot, K, and judgment completeness. Precision may look low when unlabeled but useful documents exist; recall cannot be calculated honestly when the relevant set is unknown; MRR ignores additional relevant evidence after the first; and nDCG depends on the chosen gain scheme.
The fixture reports all five for answerable queries and no-answer accuracy separately. Its exact decimals are deterministic results of eleven synthetic records, not benchmark percentages.
| Metric | Question | Important limitation |
|---|---|---|
| Recall@K | Did relevant evidence survive retrieval? | Needs a defensible relevant set and chosen K |
| Precision@K | How much selected context is labeled useful? | Can penalize unlabeled but relevant evidence |
| Hit Rate | Did at least one relevant result appear? | Ignores rank and additional evidence |
| MRR | How early is the first relevant result? | Ignores later relevant results |
| nDCG@K | Is graded relevance ordered well? | Depends on grades, gain, discount, and K |
| No-answer accuracy | Did unsupported queries abstain? | Needs representative negative cases |
Slice analysis and release gates
Aggregate improvement does not erase a critical regression.
The baseline achieves Recall@3 of 0.8000; the candidate reaches 0.9000. Candidate MRR and nDCG also improve, and deprecated-document selections fall from three to zero. Queries q02 and q09 move from misses to hits.
Two regressions remain visible. Query q11 applies an ambiguous shared-environment filter and loses the relevant production authentication guide. Query q10 selects unrelated authentication evidence for an unsupported payroll-key request, dropping no-answer accuracy from 1.0000 to 0.0000.
The fixture policy requires candidate recall not to regress, no-answer accuracy not to regress, and zero critical regressions. These are declared educational policy choices for this fixture—not universal production thresholds. Because q10 is critical, the candidate release is BLOCKED despite stronger aggregate answerable-query metrics.
| Measure | Baseline | Candidate |
|---|---|---|
| Recall@3 | 0.8000 | 0.9000 |
| Precision@3 | 0.2833 | 0.8500 |
| MRR | 0.5333 | 0.9000 |
| nDCG@3 | 0.6024 | 0.9000 |
| No-answer accuracy | 1.0000 | 0.0000 |
| Deprecated selections | 3 | 0 |
| Release | Reference | BLOCKED |
Design the evaluation dataset before tuning
Gold relevance labels and query coverage are engineering assets.
A retrieval test collection needs a corpus snapshot, query set, relevance judgments, and documented label provenance. Include straightforward cases, paraphrases, exact identifiers, ambiguity, stale-version conflicts, metadata constraints, no-answer requests, and adversarial distractors. Preserve historical failures instead of replacing them with easier examples.
Gold relevance labels should be separated from model-generated judgments. Domain reviewers can adjudicate which documents are necessary, useful, stale, or invalid. If an LLM judge proposes labels, version and calibrate it against human decisions; do not silently treat generated labels as ground truth.
Keep development and holdout queries separate, review leakage from prompts and synthetic generation, and update labels when corpus versions change. Retrieval evaluation is empirical; a tiny fixture demonstrates mechanics but cannot estimate real traffic quality.
Observability and provenance
Log enough to reproduce the evidence path without copying sensitive documents by default.
Per query, record a request or run ID, corpus and index version, query-representation version, applied access and metadata filters, retrieved chunk IDs, raw source ranks and scores, reranked scores, selected context IDs, stage latency, and answer, abstention, or review outcome. Preserve code and policy versions that produced the decision.
Aggregate retrieval metrics by meaningful slices, plus empty-retrieval rate, stale-selection rate, filter-elimination rate, no-answer accuracy, and latency by stage. Averages should link back to inspectable query records.
Document IDs and scores are usually safer telemetry than full text, but identifiers can still be sensitive. Apply access controls, redaction, retention, and purpose limitation. Do not log retrieved document contents, prompts, or generated answers by default merely because they help debugging.
- request_id and corpus/index version
- query representation and filter policy version
- candidate IDs, source ranks, and raw scores
- reranked scores and selected context IDs
- latency by parsing, retrieval, reranking, generation, and validation
- abstention, review, and release-gate outcomes
Security and data boundaries
Retrieved documents are untrusted input.
Access-control filtering must occur in trusted application and data layers with tenant and user identity. Metadata helps express scope but does not make authorization complete. Test cross-tenant identifiers, missing scope, stale permissions, and cache keys that omit access context.
A retrieved document may contain malicious instructions, poisoned content, sensitive data, or stale policy. OWASP notes that RAG does not fully mitigate prompt injection and includes modified repository documents as an indirect-injection scenario. Treat document text as data, separate it from system authority, constrain tools and side effects, validate outputs, and preserve source provenance.
Schemas and system prompts do not neutralize prompt injection by themselves. Defense requires layered controls around ingestion trust, access, retrieval, tool authorization, output handling, monitoring, and incident response.
Common Mistakes
- Using a prompt instruction as the only tenant boundary.
- Caching results without access scope in the cache key.
- Allowing retrieved text to authorize tools or side effects.
- Logging sensitive evidence content without a retention and redaction policy.
- Keeping deprecated or poisoned documents searchable after revocation.
What the evidence demonstrates — and does not prove
Every artifact supports a narrow claim.
The fixture does not prove production retrieval quality, real embedding quality, vector-database performance, production latency, scale, user satisfaction, security completeness, or universal optimal thresholds. It does not call an LLM and cannot measure generated-answer groundedness.
Its useful result is narrower: a candidate can improve answerable retrieval aggregates and still be blocked by a critical no-answer regression. The evidence path makes that tradeoff reproducible.
| Evidence / artifact | What it demonstrates | What it does not prove |
|---|---|---|
| Synthetic corpus | Version, status, service, environment, and distractor boundaries | Production corpus quality or domain completeness |
| Synthetic query fixture | Exact, paraphrase, filter, stale, distractor, and no-answer cases | Real user distribution or satisfaction |
| Deterministic retriever | Inspectable candidate, filter, lexical, and threshold behavior | Real embedding quality or vector-database performance |
| Rules-based reranker | A separately versioned ordering stage | Universal reranker quality or optimal scoring |
| Evaluator stdout | Exact metrics, regressions, and a fixture policy decision | Production latency, scale, security, or benchmark performance |
| Architecture diagram | Layer ownership and diagnostic boundaries | A deployed production topology |
| Primary documentation | Source-specific capabilities, definitions, and research findings | Universal architecture laws or guaranteed outcomes |
Production hardening and next steps
Move from a deterministic teaching fixture to measured system boundaries deliberately.
Replace proxies with versioned retriever outputs only after assembling representative queries and relevance labels. Compare dense, sparse, and hybrid candidates on the same corpus snapshot. Calibrate filters, thresholds, reranker depth, and context policy by slice, then add generation and claim-support evaluation as a separate stage.
Add ingestion failure records, deletion propagation, access-aware cache tests, bounded timeouts, reranker fallback policy, structured telemetry, privacy review, and incident feedback. Release decisions should name the policy and case evidence rather than rely on a dashboard average.
This guide owns retrieval-system engineering. The related evaluation guide owns the broader repeatable release gate; the agent guide owns bounded tool orchestration; the architecture-selection guide helps decide whether agentic behavior is necessary; and the Python guide covers typed validation and service foundations.
- Build the broader release gate: version datasets, graders, slices, regressions, and policy.
- Bound tool-using execution: separate model proposals from state, tools, authorization, and side effects.
- Choose the architecture first: use deterministic, agentic, or hybrid control where it fits.
- Strengthen Python foundations: apply validation, tests, failure handling, and operational boundaries.
Tips
- Freeze corpus, queries, labels, and retrieval configuration for every comparison.
- Measure first-stage recall before reranker quality.
- Keep no-answer behavior and stale-document selection as explicit slices.
- Preserve selected evidence IDs through generation and validation.
- Block critical regressions even when aggregate metrics improve.
- State what every experiment does not establish.
FAQ
Does RAG eliminate hallucinations?
No. RAG supplies external evidence, but corpus, retrieval, context assembly, generation, and grounding can each fail. Evaluate those stages separately and support abstention when evidence is insufficient.
Is hybrid retrieval always better than dense retrieval?
No. Dense and sparse signals can be complementary, especially for paraphrases versus exact identifiers, but the result depends on corpus, queries, fusion, filters, and evaluation labels. Compare candidates on representative slices.
Why evaluate retrieval separately from the final answer?
Because a correct answer can hide failed retrieval and an incorrect answer can occur despite correct evidence. Candidate IDs, ranks, selected context, and generation checks identify the layer that needs repair.
Are the fixture dense scores real embeddings?
No. They are explicitly hand-authored deterministic score proxies. The executable uses no model, embedding API, network, vector database, or external package.
Why is the synthetic candidate blocked?
It improves answerable-query retrieval and removes deprecated selections, but it regresses the critical no-answer case q10 and an ambiguous metadata case q11. The fixture policy forbids a no-answer regression and critical regressions.
Sources
Primary and authoritative sources reviewed for this article.
- OpenAI API documentation — Vector embeddings
Reviewed for embeddings as numerical text representations used in search and relatedness tasks.
- OpenAI API documentation — Retrieval
Reviewed for vector-store search, chunking, attributes, filtering, ranking options, and retrieval results.
- OpenAI API documentation — Evaluation best practices
Reviewed for task-specific datasets, logging, automated scoring, and continuous evaluation.
- Anthropic engineering — Contextual Retrieval
Reviewed for source-specific experiments combining contextual embeddings, BM25, and reranking, plus exact-term retrieval considerations.
- Elastic documentation — Hybrid search
Reviewed for combining full-text and vector search and reciprocal rank fusion as an Elastic-supported approach.
- Cohere documentation — Rerank models
Reviewed for reranking a supplied document set by relevance to a query and model context boundaries.
- Stanford Introduction to Information Retrieval — Evaluation
Reviewed for test collections, relevance judgments, precision, recall, and ranked-retrieval evaluation foundations.
- Lewis et al. — Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks
Reviewed as the original RAG research reference for combining parametric generation with retrieved non-parametric memory.
- OWASP GenAI Security Project — Prompt Injection
Reviewed for indirect prompt injection through retrieved documents and the limitation that RAG does not fully mitigate prompt injection.
Conclusion
Production-ready RAG begins by making the evidence path observable. Version the corpus, preserve structure and metadata, retrieve for recall, rerank transparently, assemble bounded context, validate provenance, and allow abstention. Then evaluate retrieval before generation so a missing document is not disguised as a prompt problem. The synthetic candidate improves Recall@3 from 0.8000 to 0.9000 and is still correctly blocked because it answers without support. Continue with the broader LLM evaluation pipeline to connect retrieval evidence to a governed release process.