A large document corpus passes through layered retrieval and reranking filters into a small evidence set and bounded response while noisy documents are rejected
Technical Skills

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.

Sep 1, 202624 min readMuhammad FarooqLast reviewed: Sep 1, 2026

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.

Production-oriented RAG architecture separating document ingestion, dense and sparse retrieval with metadata filters, reranking, context selection, grounded generation, answer or abstain outcomes, and retrieval versus generation evaluation
Retrieval failure is not generation failure. Preserve intermediate evidence so each layer can be measured and repaired independently.

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.

Eleven synthetic runbook queries, nine fictional documents, relevance labels, metadata, and hand-authored dense-score proxiesexamples/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}
    }
  ]
}
Python 3.12 standard-library retrieval simulator and evaluator with deterministic UTF-8/LF stdoutexamples/rag_retrieval_eval.py
from __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=BLOCKED

Corpus 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.

Minimal document and chunk contract
FieldWhy it mattersFailure if omitted
document_id / chunk_idStable provenance and evaluation labelsResults cannot be reproduced or corrected precisely
version / updated_atDistinguishes current and stale evidenceContradictory procedures can enter context
statusSupports active/deprecated filteringSuperseded guidance competes with current guidance
service / environmentNarrows evidence to the requested scopeSemantically similar but invalid procedures rank
access_scopeParticipates in authorization-aware retrievalUnauthorized evidence may reach generation
source / checksumSupports lineage and change detectionIndex 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.

Retrieval signals and boundaries
SignalUseful forDoes not guarantee
DenseParaphrases and semantic similarityCorrectness, freshness, authorization, or exact identifiers
SparseExact terms, codes, names, and rare phrasesSemantic equivalence or complete recall
Metadata filterVersion, environment, service, and scope constraintsCorrect metadata or authorization completeness
HybridCombining complementary candidate evidenceUniversal 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 taxonomy for a grounded answer path
FailureObservable conditionLikely repair boundary
Corpus failureCorrect source is absent, stale, or unparsableSource ownership, ingestion, or version policy
Retrieval failureRelevant chunk never enters candidatesQuery representation, index, filters, or candidate depth
Ranking failureRelevant chunk is present but buriedFusion, reranker, labels, or ranking features
Context-assembly failureRelevant ranked chunk is dropped by budget policyDeduplication, diversity, ordering, or token budget
Generation failureCorrect evidence is selected but answer contradicts itPrompt/model/application response handling
Grounding failureAnswer adds claims unsupported by selected evidenceClaim-level support validation and abstention
No-answer failureSystem answers without minimum evidenceSupport 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 interpretation
MetricQuestionImportant limitation
Recall@KDid relevant evidence survive retrieval?Needs a defensible relevant set and chosen K
Precision@KHow much selected context is labeled useful?Can penalize unlabeled but relevant evidence
Hit RateDid at least one relevant result appear?Ignores rank and additional evidence
MRRHow early is the first relevant result?Ignores later relevant results
nDCG@KIs graded relevance ordered well?Depends on grades, gain, discount, and K
No-answer accuracyDid 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.

Synthetic baseline versus candidate evidence
MeasureBaselineCandidate
Recall@30.80000.9000
Precision@30.28330.8500
MRR0.53330.9000
nDCG@30.60240.9000
No-answer accuracy1.00000.0000
Deprecated selections30
ReleaseReferenceBLOCKED

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 boundary for the worked example
Evidence / artifactWhat it demonstratesWhat it does not prove
Synthetic corpusVersion, status, service, environment, and distractor boundariesProduction corpus quality or domain completeness
Synthetic query fixtureExact, paraphrase, filter, stale, distractor, and no-answer casesReal user distribution or satisfaction
Deterministic retrieverInspectable candidate, filter, lexical, and threshold behaviorReal embedding quality or vector-database performance
Rules-based rerankerA separately versioned ordering stageUniversal reranker quality or optimal scoring
Evaluator stdoutExact metrics, regressions, and a fixture policy decisionProduction latency, scale, security, or benchmark performance
Architecture diagramLayer ownership and diagnostic boundariesA deployed production topology
Primary documentationSource-specific capabilities, definitions, and research findingsUniversal 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.

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.

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.