Evaluating RAG: Metrics for Every Stage of a Production RAG System

Part 1 of the Production RAG series

A RAG system with broken filters can run for months without triggering an operational alert. It still returns answers and meets its latency target, but the answers rely on incomplete evidence. Recall@k against the original gold set exposes the loss; latency and availability dashboards do not.

Evaluation can detect the failure only when each pipeline stage has its own metric. This article maps common failure modes to those metrics, from document parsing through production monitoring.

[!TIP] Want to skip ahead and run code?

The runnable slavadubrov/rag-evals-demo repository applies the metrics to SciFact. make eval runs the suite, and make benchmark compares chunking, embedding, and LLM configurations. Notebooks 00–09 isolate each metric. The demo uses embedded Qdrant, so it does not require Docker.

TL;DR

The sections follow the order of the pipeline. Start with the decision table, then use the later sections as a reference for each stage.


RAG evaluation decision table

Use this table as the starting point before choosing a framework. The right metric depends on the failure mode you are trying to catch, not on the tool name.

QuestionMetric familyUse this whenWatch out for
Did parsing preserve the source?Extraction completeness, table/figure coveragePDFs, slides, scans, and HTML pages enter the corpusClean-looking text can still drop captions, footnotes, or table structure
Did retrieval find the right evidence?Recall@k, nDCG@k, MRR, context precision/recallYou can label relevant chunks or documentsA hard metadata filter can remove the correct document before ranking starts
Did reranking improve the shortlist?Reranker uplift, Precision@1, nDCG deltaCross-encoders or LLM rankers sit after retrievalMeasure latency and cost with the quality gain
Did the answer use the evidence?Faithfulness, groundedness, citation supportThe answer cites documents or claims facts from contextFaithfulness cannot diagnose bad parsing or bad retrieval
Is the system stable in production?Drift, regeneration, fallback, p95 latency, cost per answerTraffic changes after launchProduction telemetry needs sampled human review to stay calibrated

For a shorter tool comparison, see Best RAG Evaluation Tools and Metrics in 2026.

Part 1: Define success before the architecture

Draft the eval set before the architecture diagram. It gives each later component choice a measurable target.

You cannot choose between BM25 and dense retrieval, recursive and semantic chunking, or Cohere Rerank and BGE until you know what you are optimizing. “Better answers” is not a metric. An illustrative contract is “faithfulness ≥ 0.85 on a 200-query golden set covering our top three intents, with p95 latency < 1.5s and filter false-exclusion rate < 2%.” Its numbers are placeholders; the important part is that quality, coverage, latency, and filtering have explicit gates.

Define the harness before you write the retrieval code. The first harness will be wrong, and you will revise it. Revising a metric is much cheaper than revising a system you have already shipped.

Three pipeline layers and two run modes

Modern RAG is a pipeline, so evaluation has to be a pipeline. No single number catches every failure mode.

Production evaluation has three pipeline layers. Ingestion evaluation asks whether the corpus and index preserve the source. Query-time evaluation asks whether rewriting, filtering, retrieval, reranking, and context assembly found the right evidence. Answer and production evaluation asks whether the response used that evidence and whether quality holds under live traffic. Collapse the layers into one score and a normalization bug can disappear inside an acceptable answer score.

The three places where a RAG system can lose evidence

Those layers describe where a failure happens. Offline and online describe when and against which data the check runs. Offline evaluation uses a fixed dataset with known ground truth; it is reproducible and belongs in component selection, A/B comparisons, and CI gates. Online evaluation scores sampled live traffic and captures regeneration, dwell time, explicit feedback, and real query drift. It is noisier and harder to instrument.

Each pipeline layer can contribute offline and online checks. A fixed ingestion corpus catches parser regressions before release, while freshness and parse-failure monitors cover live updates. A fixed query set measures retrieval before release, while sampled live traces expose production drift. Offline-only misses live change; online-only makes regressions hard to reproduce.

Component-level vs. end-to-end

There are two common mistakes. End-to-end-only evaluation tells you the system is broken, but not where. Component-only evaluation can show every part passing while the full system still fails. The fix is a few headline end-to-end metrics for go/no-go decisions, plus component metrics for diagnosis. Retrieval metrics catch retriever regressions. Generation metrics catch generator regressions. End-to-end answer correctness catches integration failures.

The reference frameworks (opinionated tour)

FrameworkBest atWhere it falls down
RAGASReference-free RAG metrics (faithfulness, answer relevancy, context precision/recall); the de facto vocabularyLLM-judge cost; opaque score components when debugging; English-centric defaults
ARESTrained classifier judges per pipeline; fewer annotations than RAGAS-style approaches; high precision for close systemsHeavier setup; you have to actually train models
TruLensComposable feedback functions with strong explainability; OpenTelemetry traces; production-friendlyLess batteries-included on RAG-specific metrics than RAGAS
DeepEvalPytest-style unit tests for LLM outputs; G-Eval, custom metrics, CI/CD-nativeHeavy LLM-judge usage = cost spikes
Arize PhoenixStrong tracing and embedding visualization; spots embedding drift visually; OTEL-nativeYou bring your own metric definitions
TREC 2024 RAG TrackPublic benchmark for nugget evaluation (AutoNuggetizer), support evaluation, and fluency on MS MARCO Segment v2.1Not a runtime tool; a benchmark to calibrate against

My default stack is RAGAS for the metric vocabulary, DeepEval for CI gates, Phoenix for production tracing, plus custom code for ontology-specific metrics. You will outgrow whatever you start with. Pick the framework that makes custom metrics easy.

For benchmarks, use BEIR (Thakur et al., NeurIPS 2021) for zero-shot retrieval generalization, MTEB for general embedding quality, MIRACL for multilingual retrieval, and the TREC 2024 RAG Track for end-to-end RAG evaluation.


Part 2: Map evaluation points onto the pipeline

A production RAG system is larger than “embed documents, retrieve chunks, call an LLM.” Every stage between document acquisition and answer delivery can fail.

The full RAG pipeline with metric badges at every stage

Each stage in the diagram has at least one metric. A stage with no metric can fail without anyone noticing.

The three lanes match where evidence can be lost. The ingestion lane covers parsing, cleaning, chunking, embedding, and indexing. The query-time lane covers rewriting, filtering, retrieval, reranking, and context assembly. The answer and production lane covers faithfulness, citation verification, user signals, drift, latency, and cost.

Errors compound down the chain. Bad parsing limits chunking. Bad chunking limits retrieval. Bad retrieval limits reranking. Bad reranking limits generation. Faithfulness only measures the final answer, never the upstream cause.


Part 3: Ingestion evaluation

Many production RAG failures start in ingestion. The system works on clean test documents, then fails on real PDFs, scans, tables, and messy corpus pages.

Document acquisition and parsing

What to measure:

Compare distinct parser families, for example a Tesseract baseline, a VLM-based OCR model, and your vendor candidate. Use a stratified sample of real document classes at a fixed DPI, including clean scans, photos, tables, multilingual text, math, and handwriting. Report CER or WER for each class and TEDS for table pages.

Cleaning and normalization

Chunking controls retrieval quality

Chunking can create a multi-point recall gap even when the embedding model stays fixed. In NVIDIA’s 2024 vendor benchmark, page-level chunking produced the highest accuracy and lowest variance for paginated documents. Treat that result as evidence for the tested corpus rather than a universal winner.

Semantic chunking groups adjacent sentences by embedding similarity and cuts at dissimilar boundaries. LangChain’s SemanticChunker and LlamaIndex’s SemanticSplitterNodeParser implement this strategy. It can improve recall over fixed windows when topical boundaries matter.

Recursive character splitting tries paragraph breaks, then sentence breaks, then word breaks until each chunk fits the target size. LangChain’s RecursiveCharacterTextSplitter implements the sequence. Choose candidate window and overlap values that fit your document structure, then let the golden set decide the final values.

Metrics to track:

My opinion: structural chunking (splitting on headings, tables, and sections — implemented by parsers like unstructured.io or by walking the AST your parser already produced) is underused. If your documents have structure, use it before adding similarity heuristics. Recursive character splitting is the baseline; semantic chunking is worth the overhead mainly on unstructured prose.

Metadata extraction and enrichment

Embedding generation

Index construction


Part 4: Query-time evaluation

The query-time lane contains the metrics that diagnose a retrieval path. Recall@k alone cannot show whether rewriting, filtering, reranking, or context assembly caused the failure.

Query understanding and rewriting

Retrieval metrics

These are the baseline metrics. If you do not track them, you cannot tell whether retrieval is improving.

MetricWhat it measuresWhen to use
Recall@kfraction of a query’s relevant documents returned in top kuse when missing any part of the relevant set matters
Precision@kpercent of top-k that are relevantuseful when context window is the bottleneck
MRRaverage of 1/rank of the first relevant docwhen users only look at the top-1 or top-3
nDCG@kposition-discounted gain weighted by relevance gradesstandard retrieval metric for graded relevance
MAPmean over queries of average precisionwhen you care about the entire ranked list
Hit Rate@kwhether at least one relevant document appears in top kaverage the binary result across queries for a quick sanity metric
Coveragepercent of golden docs ever retrieved across all queriescatches systematic gaps in the index

The formulas, for reference (binary relevance with relevant set RqR_q for query qq, and reli=1\text{rel}_i = 1 if the ii-th retrieved doc is in RqR_q):

Recall@k=Rq{d1,,dk}Rq,Precision@k=Rq{d1,,dk}k\text{Recall@k} = \frac{|R_q \cap \{d_1, \dots, d_k\}|}{|R_q|}, \quad \text{Precision@k} = \frac{|R_q \cap \{d_1, \dots, d_k\}|}{k} RRq=1rank of first relevant doc,MRR=1QqQRRq\text{RR}_q = \frac{1}{\text{rank of first relevant doc}}, \quad \text{MRR} = \frac{1}{|Q|} \sum_{q \in Q} \text{RR}_q DCG@k=i=1k2reli1log2(i+1),nDCG@k=DCG@kIDCG@k\text{DCG@k} = \sum_{i=1}^{k} \frac{2^{\text{rel}_i} - 1}{\log_2(i + 1)}, \quad \text{nDCG@k} = \frac{\text{DCG@k}}{\text{IDCG@k}}

For graded relevance, reli{0,1,2,}\text{rel}_i \in \{0, 1, 2, \dots\}; binary nDCG is the special case used in the code below. MAP is the mean over queries of APq=1Rqi:reli=1Precision@i\text{AP}_q = \frac{1}{|R_q|}\sum_{i: \text{rel}_i = 1} \text{Precision@}i. See Manning, Raghavan, Schütze, Introduction to Information Retrieval, chapter 8 for derivations.

For production code, use ranx, pytrec_eval, or ir_measures — they implement the entire TREC metric family and handle graded relevance correctly. Set release targets against a realistic golden set, downstream answer quality, and the cost of a miss. Do not inherit thresholds from a tutorial.

The test harness for these is short. You can run it from a notebook before you’ve even chosen a vector database.

from math import log2
from statistics import mean

# synthetic gold set: query_id -> set of relevant doc ids
gold = {
    "q1": {"d3"},
    "q2": {"d7", "d2"},
    "q3": {"d11"},
    "q4": {"d5"},
}

# ranked retrieval results: query_id -> ranked list of doc ids (top-10)
runs = {
    "q1": ["d8", "d3", "d1", "d4", "d2", "d9", "d6", "d10", "d12", "d13"],
    "q2": ["d2", "d6", "d4", "d7", "d1", "d3", "d8", "d11", "d5", "d9"],
    "q3": ["d11", "d2", "d3", "d4", "d1", "d6", "d7", "d8", "d10", "d12"],
    "q4": ["d1", "d2", "d3", "d6", "d8", "d9", "d10", "d12", "d13", "d14"],
}

def recall_at_k(ranked, gold_set, k):
    if not gold_set:
        return 0.0
    hit = sum(1 for d in ranked[:k] if d in gold_set)
    return hit / len(gold_set)

def reciprocal_rank(ranked, gold_set):
    # MRR contribution per query: 1/rank of the first relevant doc.
    for rank, d in enumerate(ranked, start=1):
        if d in gold_set:
            return 1.0 / rank
    return 0.0

def ndcg_at_k(ranked, gold_set, k):
    # binary relevance: rel ∈ {0, 1}
    gains = [1.0 if d in gold_set else 0.0 for d in ranked[:k]]
    dcg = sum(g / log2(i + 2) for i, g in enumerate(gains))
    # ideal DCG: all gold docs ranked first, capped by k
    n_gold_in_topk = min(k, len(gold_set))
    idcg = sum(1.0 / log2(i + 2) for i in range(n_gold_in_topk))
    return dcg / idcg if idcg else 0.0

K = 5
print(f"Recall@{K}: {mean(recall_at_k(runs[q], gold[q], K) for q in gold):.3f}")
print(f"MRR:       {mean(reciprocal_rank(runs[q], gold[q]) for q in gold):.3f}")
print(f"nDCG@{K}:  {mean(ndcg_at_k(runs[q], gold[q], K) for q in gold):.3f}")
# Recall@5: 0.750
# MRR:       0.625
# nDCG@5:    0.627

That is your retrieval CI gate. Wire it to a coverage-driven fast subset on every PR and run the full golden set on the slower release gate. Block a merge when a preregistered metric crosses its regression budget.

The companion repo pins the exact numbers above (Recall@5 = 0.750, MRR = 0.625, nDCG@5 = 0.627) as a unit test in tests/test_retrieval_metrics.py; notebook 01 sweeps Recall@k / MRR / nDCG over a real SciFact index, and the production-shaped harness lives in evaluation/retrieval.py.

Hybrid retrieval and reciprocal rank fusion

BM25 is a sparse lexical scorer that combines exact-term matching, term weighting, and length normalization. It is available in rank_bm25, Elasticsearch, OpenSearch, and most search engines.

Reciprocal Rank Fusion (Cormack, Clarke, and Buettcher, SIGIR 2009) combines BM25 and dense rankings by position. The original k=60 setting is a useful baseline. RRF is score-agnostic, which avoids the cross-lane normalization required by linear interpolation. With a labeled set large enough to estimate a stable delta, also test a convex combination and tune α.

Hybrid retrieval plus a cross-encoder reranker often improves technical, log-style, and code corpora. The gain can be small on heavily semantic corpora. Measure against the dense-only and sparse-only lanes because a poor fusion configuration can underperform either input.

The implementation fits in a few lines.

from collections import defaultdict

# two retrieval lanes: dense embeddings and BM25.
dense  = ["d3", "d7", "d1", "d4", "d2", "d9", "d10"]
sparse = ["d2", "d3", "d8", "d1", "d11", "d4", "d6"]

def rrf(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
    """Reciprocal Rank Fusion (Cormack et al., SIGIR 2009).

    score(d) = sum over rankings of 1 / (k + rank(d))
    Score-agnostic: only rank position matters. k=60 is the canonical default.
    """
    scores: dict[str, float] = defaultdict(float)
    for ranking in rankings:
        for rank, doc in enumerate(ranking, start=1):
            scores[doc] += 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda kv: kv[1], reverse=True)

fused = rrf([dense, sparse], k=60)
for doc, score in fused[:5]:
    print(f"{doc}  score={score:.5f}")
# d3  score=0.03252   <- rank 1 dense, rank 2 sparse
# d2  score=0.03178   <- rank 5 dense, rank 1 sparse
# d1  score=0.03150

Note what RRF doesn’t do: it never looks at the raw similarity scores. A dense retriever returning cosine 0.98 and a BM25 lane returning score 17.4 are not directly comparable. If you normalize them with z-scores or min-max scaling, you can end up favoring the lane with the highest variance in that batch.

RRF uses rank only. If a retriever puts a document at position 2, that vote is worth 1 / (60 + 2), regardless of the raw score that produced it.

Hybrid + RRF on SciFact: notebook 02 compares dense vs BM25 vs RRF with per-query deltas. The production-shaped fuser is in retrieval/hybrid_rrf.py; tests/test_rrf.py pins the canonical d3 / d2 / d1 ordering at k=60.

Reranking

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")

query = "How do I rotate database credentials in production?"
candidates = [
    "Production database credentials are rotated via Vault every 30 days.",
    "The new logo was unveiled at the all-hands meeting.",
    "To rotate prod DB creds, run the `rotate-secrets` GitHub Action.",
]

scores = reranker.predict([(query, c) for c in candidates])
ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
for doc, score in ranked:
    print(f"{score:+.3f}  {doc}")

A reranker is a high-leverage candidate for a basic RAG pipeline, not a guaranteed win. Measure its ΔPrecision@1 and ΔnDCG on your golden set, then keep it only if the gain clears its latency and cost budget. Compare that measured gain with smaller retrieval changes before choosing the next optimization.

ΔnDCG and ΔPrecision@1 from a cross-encoder on SciFact: notebook 03; module: retrieval/reranker.py.

Context construction and lost-in-the-middle

This is where many “good retrieval, bad answer” failures come from.


Part 5: The filter false-exclusion rate

This metric gets its own section because aggregate retrieval scores cannot attribute a miss to the filter.

A hard metadata filter like tenant_id = X AND product = Y AND locale = en-US can drop effective recall to zero. Correctly implemented Recall@k catches the loss because its denominator remains the original relevant-document set. It does not tell you whether the filter, retriever, or ranker caused the miss. Faithfulness can still look fine because it evaluates the answer against the incomplete retrieved context; the model faithfully said “I don’t know.”

The red branch in the tree is the common failure: the right document exists, but the filter removes it before retrieval.

Silent failure taxonomy with the metric that catches each mode

The metric

filter_false_exclusion_rate =
    (# queries where all gold docs were excluded by metadata filter) /
    (# queries with at least one gold doc)

This query-level definition counts catastrophic exclusions: no relevant document survives. For multi-gold queries, standard Recall@k still exposes partial loss; add a per-document exclusion rate if that boundary matters. To compute either rate, you need (a) ground-truth doc IDs for each eval query and (b) instrumentation that logs the filter predicates applied, not just the final results. Set the target from the cost of excluding a valid answer and the confidence interval of your production sample.

Here’s a working implementation. It compares correct standard recall with an invalid evaluator that redefines relevance after filtering.

# A small worked example where hard filters remove relevant documents.
docs = [
    {"id": "d1", "tenant": "acme",   "locale": "en-US"},
    {"id": "d2", "tenant": "acme",   "locale": "en-GB"},
    {"id": "d3", "tenant": "globex", "locale": "en-US"},
    {"id": "d4", "tenant": "acme",   "locale": "en-US"},
    {"id": "d5", "tenant": "acme",   "locale": "de-DE"},
]

queries = [
    # the gold doc lives in en-GB but the dynamic filter forced en-US
    {"qid": "q1", "gold": {"d2"}, "filter": lambda d: d["locale"] == "en-US"},
    # the gold doc is correctly within the tenant filter
    {"qid": "q2", "gold": {"d4"}, "filter": lambda d: d["tenant"] == "acme"},
    # the gold doc is in a different tenant and gets dropped
    {"qid": "q3", "gold": {"d3"}, "filter": lambda d: d["tenant"] == "acme"},
    # the gold doc passes the filter (de-DE locale match)
    {"qid": "q4", "gold": {"d5"}, "filter": lambda d: d["locale"] == "de-DE"},
]

def filter_false_exclusion_rate(queries, docs):
    n_with_gold, n_excluded = 0, 0
    for q in queries:
        if not q["gold"]:
            continue
        n_with_gold += 1
        survivors = {d["id"] for d in docs if q["filter"](d)}
        if not (q["gold"] & survivors):
            n_excluded += 1
    return n_excluded / n_with_gold if n_with_gold else 0.0

rate = filter_false_exclusion_rate(queries, docs)
print(f"filter_false_exclusion_rate = {rate:.2%}")
# filter_false_exclusion_rate = 50.00%

# Correct Recall@k keeps the original gold set as its denominator.
def standard_recall_at_k(queries, docs, k=10):
    recalls = []
    for q in queries:
        survivors = [d for d in docs if q["filter"](d)][:k]
        survivor_ids = {d["id"] for d in survivors}
        recalls.append(len(q["gold"] & survivor_ids) / len(q["gold"]))
    return sum(recalls) / len(recalls) if recalls else 0.0

print(f"standard recall@10 = {standard_recall_at_k(queries, docs):.2%}")
# standard recall@10 = 50.00%

# INVALID: rebuilding the gold set after filtering changes the question.
# It drops queries whose relevant documents did not survive, then scores 100%.
def invalid_recall_over_filtered_gold(queries, docs, k=10):
    recalls = []
    all_doc_ids = {d["id"] for d in docs}
    for q in queries:
        all_survivors = {d["id"] for d in docs if q["filter"](d)}
        filtered_gold = q["gold"] & all_doc_ids & all_survivors
        if not filtered_gold:
            continue
        top_k_ids = set(list(all_survivors)[:k])
        recalls.append(len(filtered_gold & top_k_ids) / len(filtered_gold))
    return sum(recalls) / len(recalls) if recalls else 0.0

invalid = invalid_recall_over_filtered_gold(queries, docs)
print(f"INVALID recall (filtered gold) = {invalid:.2%}")
# INVALID recall (filtered gold) = 100.00%

assert rate == 0.5
assert standard_recall_at_k(queries, docs) == 0.5
assert invalid == 1.0

Half the queries lose their gold doc to the filter, so correct Recall@10 falls to 50%. That score catches the symptom but cannot attribute it. The false-exclusion rate shows that the predicate removed two answers before the retriever ran. The deliberately invalid evaluator reports 100% only because it discards those failures from its gold set. No model can recover a document that was filtered out.

The 50% rate above is reproduced as a unit test in the companion repo: tests/test_filter_exclusion.py::test_50_percent_exclusion_rate. Notebook 04 runs it on SciFact with synthetic metadata so you can watch a real filter zero out recall; the runtime metric (with predicate-precision/recall companion) is in evaluation/filter_exclusion.py.

Companion metric: predicate precision and recall

When filtering is dynamic (for example, an LLM extracts filter predicates from the query), treat the predicate extractor as a classification model and evaluate it as one. Measure predicate precision and recall against a labeled set of (query, correct predicate) pairs. A predicate error rate does not map directly to the same point loss in retrieval recall; measure how often those errors exclude a gold document. Once a hard filter removes the gold document, no amount of reranking helps.

Soft boost vs. hard filter

This metric forces a design decision. Use hard filters when correctness is binary: legal jurisdiction, ACL boundaries, published-versus-draft. Use soft boosts when relevance is graded: locale preference, recency, version. Without exclusion-rate measurement, the wrong choice is hard to see.

The decision rule, measurable:

For each filter predicate F:
  hard_recall_F  = retrieval_recall@k with F as a hard filter
  soft_recall_F  = retrieval_recall@k with F as a +0.X rerank boost
  hard_precision = relevant_in_top_k / k under hard filter
  soft_precision = relevant_in_top_k / k under soft boost
  exclusion_rate = % of queries where the gold doc was filtered out (hard)

Use hard filter only if exclusion_rate < ε AND hard_precision >> soft_precision.
Otherwise prefer soft boost.

Choose ε from the harm of a false exclusion, the benefit of added precision, and the size of the evaluation sample. A dedicated post in this series goes deeper on this trade-off.


Part 6: Generation evaluation

Retrieval metrics tell you the system could answer correctly. They do not tell you it did. Generation metrics cover that gap.

Faithfulness and groundedness

RAGAS faithfulness decomposes the answer into atomic claims (short, self-contained factual statements), then verifies each against the retrieved context via an LLM judge:

faithfulness=claims supported by contexttotal claims\text{faithfulness} = \frac{|\text{claims supported by context}|}{|\text{total claims}|}

The percent of supported claims is the score. The structure is more useful than any single number, because it tells you which claims are unsupported. Production code lives in the ragas package — usage looks like:

from datasets import Dataset
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision

samples = Dataset.from_dict({
    "question": ["How many moons does Mars have?"],
    "answer":   ["Mars has two moons, Phobos and Deimos."],
    "contexts": [["Mars has two moons named Phobos and Deimos."]],
    "ground_truth": ["Mars has two moons."],
})

result = evaluate(samples, metrics=[faithfulness, answer_relevancy, context_precision])
print(result)

Below is the same loop unrolled with a deterministic stand-in judge so you can see the shape end-to-end.

def extract_claims(answer: str) -> list[str]:
    # Production: an LLM call that decomposes the answer.
    # Demo: split on sentence-final punctuation.
    return [c.strip() for c in answer.replace("?", ".").replace("!", ".").split(".") if c.strip()]

def verify_claim(claim: str, context: str) -> bool:
    # Production: an NLI (natural-language inference) model or LLM judge.
    # Demo: a deterministic stand-in so the example runs offline.
    entailed_pairs = {
        "Mars has two moons": True,
        "Phobos and Deimos orbit Mars": True,
        "Mars has a thick atmosphere": False,  # unsupported by context
        "Curiosity landed in 2012": True,
    }
    for k, v in entailed_pairs.items():
        if k.lower() in claim.lower() or claim.lower() in k.lower():
            return v
    words = [w.lower() for w in claim.split() if len(w) > 3]
    return all(w in context.lower() for w in words) if words else False

context = (
    "Mars has two moons, Phobos and Deimos. NASA's Curiosity rover "
    "landed on Mars in 2012."
)
answer = (
    "Mars has two moons. Phobos and Deimos orbit Mars. "
    "Mars has a thick atmosphere. Curiosity landed in 2012."
)

claims = extract_claims(answer)
verdicts = [(c, verify_claim(c, context)) for c in claims]
faithfulness = sum(1 for _, ok in verdicts if ok) / len(verdicts)
for c, ok in verdicts:
    print(f"  [{'✓' if ok else '✗'}] {c}")
print(f"faithfulness = {faithfulness:.2f}")
# faithfulness = 0.75   (one unsupported claim about the atmosphere)

The structure matters. In production, verify_claim becomes an NLI model or an LLM call. The rest of the harness stays the same: extract, verify, aggregate.

End-to-end claim extraction + verification on generated SciFact answers: notebook 05; module: evaluation/faithfulness.py. The repo also runs an HHEM-style cross-family verifier in the same loop so you can see which judge family agrees with which.

A purpose-built alternative to LLM-as-judge is HHEM-2.1-Open (Hughes Hallucination Evaluation Model, Vectara), a classifier fine-tuned for hallucination detection. Its model card documents the checkpoint, default decision boundary, and results on AggreFact and RAGTruth. Treat those as model-card evidence, not a guarantee on your corpus: calibrate the threshold on local labels and compare it with your chosen judge before deployment.

Atomic-fact evaluation

FActScore (Min et al., EMNLP 2023) decomposes long-form generations into atomic facts, retrieves evidence per fact, labels each supported / not-supported, and reports the supported fraction:

FActScore=supported atomic factstotal atomic facts\text{FActScore} = \frac{|\text{supported atomic facts}|}{|\text{total atomic facts}|}

Reference implementation: shmsw25/FActScore. It works well for biographies, summaries, and other long-form outputs. Watch out: repetitive trivial facts can inflate the score, and “MontageLie” attacks (true facts in deceptive order) can defeat it. VeriScore handles claims with necessary modifiers; the Core filter helps prevent fact-padding.

Citation accuracy

Track citation precision (cited spans actually support the claim) and citation recall (claims that should be cited, are):

cite_precision=cited spans that support a claimcited spans,cite_recall=claims with at least one supporting cited spanclaims that should be cited\text{cite\_precision} = \frac{|\text{cited spans that support a claim}|}{|\text{cited spans}|}, \quad \text{cite\_recall} = \frac{|\text{claims with at least one supporting cited span}|}{|\text{claims that should be cited}|}

The TREC 2024 RAG Track defines a reproducible support evaluation protocol. Upadhyay et al. (SIGIR 2025) report GPT-4o agreeing with human judges 56% of the time on manual assessment from scratch, rising to 72% with post-editing of LLM predictions. That is useful as a force multiplier under their conditions, not as a replacement for human assessment in high-stakes contexts. For an automated approximation, ALCE (Gao et al., EMNLP 2023) implements citation precision/recall with NLI-based verification.

Answer correctness, completeness, refusal

Post-generation verification

The cheapest reliability gains often come from deterministic post-checks, not larger models.


Part 7: Ontology-grounded RAG evaluation

The standard metrics above cover open-corpus RAG. Ontology-grounded systems need more. If your RAG retrieves against a structured ontology, taxonomy, or knowledge graph (products in a catalog, conditions in SNOMED, components in a BOM, security techniques in MITRE ATT&CK), standard RAG metrics are necessary but not sufficient. You also need to measure the ontology layer.

Entity linking accuracy

The first task is mapping a query mention to an ontology entity (“Aspirin” → wikidata:Q18216, “the 737” → aircraft:Boeing_737).

Hierarchy-aware evaluation

Plain accuracy treats “predicted Sedan when truth is Hatchback” the same as “predicted Sedan when truth is Submarine.” Those errors are not equal.

Filter false-exclusion rate (reprise, now critical)

In ontology-grounded systems, hard filters often come from the ontology itself (“only retrieve docs tagged with category X”). The exclusion-rate metric (defined in Part 5) becomes a primary correctness signal. A wrong category prediction can zero out recall; the exclusion rate attributes that loss to the filter.

Constrained generation conformance

When your output must conform to an ontology (every entity name in the answer must be a valid ontology member; every predicate must come from a closed vocabulary), measure:

Constrained decoding frameworks (Outlines, XGrammar, Guidance, OpenAI Structured Outputs) are designed to enforce schema validity. JSONSchemaBench compares efficiency, coverage, and quality across implementations. Re-run its cases that match your schemas and serving backend because coverage and latency depend on both.

Auditability

For ontology-grounded systems where answers face review:


Part 8: System-level evaluation

Holistic answer quality

LLM-as-judge has real biases:

Practical recipe: select a judge on human-labeled calibration data, randomize answer order, mask model identities, and state the length policy in the rubric. Repeat cases only when the added samples materially reduce uncertainty. For high-stakes evaluations, compare judges from different model families and analyze disagreements against human labels.

Schema-Guided Reasoning for judges

Free-form output is one source of variation in judge runs. Two runs against the same answer may organize the rubric differently and produce different scores. Schema-Guided Reasoning (SGR) makes that rubric explicit: define the evaluation stages as a Pydantic schema, then use constrained output through Outlines, XGrammar, vLLM structured outputs, or OpenAI response_format so every run returns the same fields in the same order.

For RAG eval the schema decomposes the judgment into explicit, auditable fields rather than letting the model jump straight to a number:

from pydantic import BaseModel, Field
from typing import Literal

class FaithfulnessJudgment(BaseModel):
    extracted_claims: list[str] = Field(
        description="Atomic factual claims in the answer, one per item."
    )
    supported_claims: list[str] = Field(
        description="Subset of extracted_claims that are entailed by the context."
    )
    unsupported_claims: list[str] = Field(
        description="Subset that is NOT entailed by the context."
    )
    failure_mode: Literal[
        "none", "fabrication", "overgeneralization", "wrong_entity", "stale_fact"
    ]
    score: float = Field(ge=0.0, le=1.0)
    rationale: str

The structured fields make the score recoverable as len(supported) / len(extracted) and show exactly which claims two judges disagreed about. The Pydantic model also makes a rubric change visible as a code diff. Constrained output guarantees the shape, not an unbiased verdict, so position randomization, cross-family judges, and human calibration still apply.

This works for any rubric-based judge, not just faithfulness. Pairwise preference, citation support, and refusal correctness all benefit from the same treatment.

A G-Eval / pairwise / position-bias / cross-family judge harness lives in notebook 07; module: evaluation/llm_judge.py. The benchmark sweep (make benchmark in the repo) wires three frontier-tier models — gpt-5-mini, claude-haiku-4-5, gemini-2.5-flash — into a rotating-judge pairwise A/B so every model judges the other two, surfacing self-preference numerically.

Latency and cost

Per-stage p50/p95/p99 with a stage breakdown is built into notebook 08 and the runner at evaluation/latency.py; the benchmark report combines latency with faithfulness in a single matrix you can re-run with make benchmark.

A/B testing


Part 9: Test set construction

A metric is only as good as the test set it runs on. If your golden set covers three intents and production traffic spans twelve, Recall@10 measures only those three intents. Worse, a test set that overfits to easy questions (“What is the company’s refund policy?”) can approve a system that fails on the hard ones (“Refund eligibility for a partial cancellation under the 2023 EU Digital Services Act, billed in EUR, originating in Ireland?”). The aggregate score rises while the system still fails an important part of production traffic.

The same problem hits ground truth. If SMEs labeled the obvious docs but missed the long-tail relevant ones, Recall@k will under-credit a retriever that actually found them. You optimize toward the labels, not toward the truth.

Build the test set around the real query distribution and difficulty first. Then choose metrics that respond to the target failure modes and tune the system against them.

Synthetic query generation

Use an LLM to generate questions from your corpus:

RAGAS has built-in question-type distribution (reasoning, conditional, multi-context). DataMorgana generates configurable synthetic benchmarks across user and question categories. Synthetic data is useful for cold starts and coverage testing. It cannot replace real user queries.

Golden dataset construction

Human-curated data anchors the golden set.

  1. Sample real user queries (or simulated ones if pre-launch) stratified by intent.
  2. Have SMEs answer each question and identify which doc(s) contain the answer.
  3. Size the set from the coverage matrix and the confidence interval needed for release decisions; coverage matters more than a borrowed query count.
  4. Re-curate when release cadence, drift signals, domain risk, and annotation capacity justify it.

Adversarial test sets

Coverage and continuous evaluation


Part 10: Production monitoring

The eval suite you ship describes the system at launch. Production traffic changes after that.

Implicit and explicit feedback

Drift detection

Shadow evaluation and human-in-the-loop

Run the candidate system in parallel with production, compare outputs offline, and do not serve them to users. This catches regressions before launch. It costs extra inference, but it has no customer impact.

For human-in-the-loop (HITL) review:

The minimum guardrail set

Alert on these, in priority order:

  1. Faithfulness/HHEM score below threshold on a rolling production sample.
  2. p95 latency above SLO.
  3. Filter false-exclusion rate above threshold (sample-based).
  4. Regeneration rate outside a locally calibrated control band that accounts for window size, traffic, seasonality, and false-alert budget.
  5. Cost/query above budget.

If an alert fires without a corresponding code or model change, you likely have drift. If it fires after a change, you likely have a regression. Either way, you get a signal before support tickets arrive.


Caveats


Coming up in this series

This was the index. The follow-ups I am planning:


References

Frameworks and benchmarks

Retrieval and ranking

Generation, faithfulness, judges

Drift and production

Companion code