RAG Evaluation Metrics: Retrieval, Reranking, Generation

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.

For engineers operating or evaluating multi-stage RAG systems, this reference maps failures in document parsing, filtering, retrieval, reranking, and generation to the metric that identifies each one, then shows where that measurement belongs in a release or monitoring gate.

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.

- A useful evaluation stack covers ingestion, retrieval, generation grounding, ontology conformance, and system signals. [RAGAS](https://docs.ragas.io/), [TruLens](https://www.trulens.org/), [DeepEval](https://deepeval.com/), [Arize Phoenix](https://phoenix.arize.com/), and the [TREC 2024 RAG Track](https://trec.nist.gov/data/rag2024.html) give you tooling. They do not choose your metrics for you. - For metadata- and ontology-grounded RAG, a wrong tag or brittle hard predicate can collapse recall to zero. Standard Recall@k catches the loss when it retains the original gold set. A filter false-exclusion metric identifies the cause. Faithfulness may still score claims against incomplete context, but it cannot diagnose the filter or retrieval cause. An empty refusal may produce no statements and `NaN`, depending on the implementation.

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: Ragas, DeepEval, and TruLens.

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: the corpus and index, the retrieval path, and the answer and live trafficThe three places where a RAG system can lose evidence: the corpus and index, the retrieval path, and the answer and live traffic

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
RAGASMy selection criterion: a shared vocabulary for faithfulness, answer relevancy, and context precision/recall (metrics)LLM-judge cost; opaque score components when debugging; version changes
ARESMy selection criterion: a task-specific classifier judge is worth the training and annotation cost (paper); its reported precision is benchmark-boundHeavier setup; you have to actually train models
TruLensMy selection criterion: trace-linked feedback functions and OpenTelemetry integration are more important than a RAG metric catalog (project)Less batteries-included on RAG-specific metrics than RAGAS
DeepEvalMy selection criterion: test-runner integration and custom metrics matter more than a framework default (project)Heavy LLM-judge usage = cost spikes
Arize PhoenixMy selection criterion: tracing and embedding visualizations help investigate a drift hypothesis (project)You 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 RAG pipeline grouped into ingestion, query-time, and answer paths, with diagnostic metrics beside each stageThe RAG pipeline grouped into ingestion, query-time, and answer paths, with diagnostic metrics beside each 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 caps what chunking can do, bad chunking caps retrieval, and bad retrieval caps both reranking and 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:

  • Text extraction completeness: extracted_chars / expected_chars on a labeled sample, computed per document class. There is no canonical package — write a small harness that compares parser output against a hand-cleaned reference. Watch for missing footnotes, headers, captions.

  • OCR accuracy: CER (Character Error Rate) and WER (Word Error Rate), the standard speech/OCR metrics:

    CER=S+D+IN,WER=Sw+Dw+IwNw\text{CER} = \frac{S + D + I}{N}, \qquad \text{WER} = \frac{S_w + D_w + I_w}{N_w}

    where SS, DD, II are character-level substitutions, deletions, insertions and NN is the reference character count (subscript ww for the word version). Do not apply one CER boundary to every corpus. Calibrate it by document class and downstream answer loss. Printed text, handwriting, and multilingual material have different error profiles. Compute with jiwer (jiwer.cer(refs, hyps), jiwer.wer(refs, hyps)) or HuggingFace evaluate. For evaluation corpora, FUNSD and SROIE are public benchmarks.

    from jiwer import cer, wer
    
    refs = ["Mars has two moons, Phobos and Deimos."]
    hyps = ["Mars has two m00ns, Phobos and Deirnos."]
    
    print(f"CER = {cer(refs, hyps):.3f}")  # CER = 0.105
    print(f"WER = {wer(refs, hyps):.3f}")  # WER = 0.286
  • Table extraction fidelity: TEDS (Tree-Edit-Distance-based Similarity) measures how close a predicted HTML table tree is to the reference, normalized by the size of the larger tree. From Zhong et al., 2020 (PubTabNet):

    TEDS(Ta,Tb)=1EditDist(Ta,Tb)max(Ta,Tb)\text{TEDS}(T_a, T_b) = 1 - \frac{\text{EditDist}(T_a, T_b)}{\max(|T_a|, |T_b|)}

    TEDS uses both structure (rows, columns, spans) and cell content. TEDS-S strips the content and scores structure only. Reference implementation: PubTabNet’s teds.py (uses apted under the hood). For evaluation corpora, see PubTabNet, FinTabNet, and SciTSR. Naive parsers often fail on tables. Benchmark before trusting them.

  • Layout / structure preservation: heading order, list integrity, reading order on multi-column PDFs. Use DocLayNet for a labeled benchmark. An off-the-shelf comparison can span an element parser such as unstructured, a PDF library such as pymupdf, and a VLM parser such as docling.

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

  • Boilerplate removal accuracy: precision/recall against human-labeled boilerplate spans. Aggressive removal drops relevant content; lazy removal pollutes embeddings. Tools to compare: trafilatura, jusText, Resiliparse. Barbaresi (2021) benchmarks these head-to-head.

  • Unicode normalization: percent of documents producing identical NFC and NFKC outputs (computed with the stdlib unicodedata.normalize) is a useful drift signal. Mismatches are how zero-width joiners and lookalike characters break retrieval recall.

  • Language detection accuracy: F1 on a labeled multilingual sample. Critical for multilingual indexes. Use fasttext-langdetect (Facebook’s lid.176), lingua-py, or cld3. FLORES-200 supplies evaluation text across 200 languages, but your production language mix should determine the test slice.

  • Deduplication effectiveness (MinHash / LSH): precision/recall of your near-duplicate detector against a hand-labeled set. The underlying idea: estimate Jaccard similarity J(A,B)=ABABJ(A, B) = \frac{|A \cap B|}{|A \cup B|} between document shingle sets via kk random permutation hashes (Broder, 1997) and bucket near-duplicates with LSH banding (Indyk & Motwani, 1998). Sweep the hash count and Jaccard threshold on your corpus. Track false-merge rate (corrupts answers) separately from missed-merge rate (wastes index space). datasketch provides the implementation used below; its parameters are illustrative:

    from datasketch import MinHash, MinHashLSH
    
    def shingles(text: str, k: int = 5) -> set[str]:
        text = text.lower()
        return {text[i:i + k] for i in range(len(text) - k + 1)}
    
    def to_minhash(text: str, num_perm: int = 128) -> MinHash:
        m = MinHash(num_perm=num_perm)
        for s in shingles(text):
            m.update(s.encode("utf-8"))
        return m
    
    docs = {
        "d1": "Mars has two moons, Phobos and Deimos.",
        "d2": "Mars has two moons, Phobos and Deimos!",   # near-dup
        "d3": "Curiosity rover landed on Mars in 2012.",
    }
    
    lsh = MinHashLSH(threshold=0.8, num_perm=128)
    for did, text in docs.items():
        lsh.insert(did, to_minhash(text))
    
    print(sorted(lsh.query(to_minhash(docs["d1"]))))  # ['d1', 'd2']
  • PII scrubbing: precision and recall, computed separately per entity type (emails, SSNs, names, addresses). Recall errors create compliance risk; precision errors hurt answer quality. Set the operating point with the legal team. Candidate tools include Microsoft Presidio, scrubadub, or a fine-tuned NER model on a labeled set.

Chunking controls retrieval quality

Chunking can create a multi-point recall gap even when the embedding model stays fixed. In NVIDIA’s 2025 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:

  • Chunk coherence: coherence=cos(si,sj)withincos(si,sj)across boundary\text{coherence} = \overline{\cos(s_i, s_j)}_{\text{within}} - \overline{\cos(s_i, s_j)}_{\text{across boundary}}, where sis_i are sentence embeddings. Healthy chunks are internally similar and at-boundary dissimilar. Compute with sentence-transformers plus scikit-learn’s cosine_similarity.
  • Boundary quality: human-labeled “is this a sensible cut?” on a sample, plus a structural check that chunks don’t split tables, lists, or numbered sections.
  • Optimal chunk size: sweep token sizes (128, 256, 512, 1024) and plot Recall@k vs. size on your golden set. Pick the knee. Don’t pick whatever the tutorial said.
  • Overlap effectiveness: ablate several overlap fractions and measure Recall@k. Stop increasing overlap when the local recall curve flattens or duplication cost outweighs the gain.
  • Chunk attribution fidelity: percent of chunks that retain a verifiable source pointer (page number, section anchor, doc ID). Auditability requires this.
  • Late vs. early chunking: late chunking (Günther et al., 2024) embeds the full document then segments, preserving global context (reference implementation in jina-embeddings-v3). Contextual Retrieval (Anthropic, 2024) prepends LLM-generated context to each chunk. Both add cost. Benchmark on your corpus before adopting either one.

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

  • NER precision/recall/F1: per entity type, on a labeled subset. Standard CoNLL/MUC-style. Compute with seqeval (from seqeval.metrics import f1_score) for the BIO/IOB-tag-aware version, or scikit-learn for span-set comparisons. CoNLL-2003 and OntoNotes 5.0 are the canonical reference corpora.
  • Relation extraction F1: even more important for ontology-grounded systems. Hand-label a set stratified by relation type and document class. TACRED and DocRED are public benchmarks; candidate implementations include opennre and spaCy relation pipelines.
  • Title / heading extraction accuracy: exact-match plus normalized Levenshtein similarity (1edit_dist(a,b)max(a,b)1 - \frac{\text{edit\_dist}(a, b)}{\max(|a|, |b|)}) against ground truth — python-Levenshtein or rapidfuzz give you both in one call.
  • Hierarchical metadata preservation: percent of chunks that correctly retain their parent section, parent document, and ancestry path. This is the metric that decides whether your RAG can answer “what does the child of policy X say?” type questions.

Embedding generation

  • Model selection benchmarks: Use MTEB’s task results (nDCG@10 is the headline; the MTEB Python package lets you reproduce the leaderboard locally), BEIR for zero-shot generalization, and MIRACL for multilingual retrieval as comparison points. Treat transfer from English MTEB to a lower-resource language as a hypothesis to test on that language’s labeled set.
  • Domain-specific evaluation: do not treat a general benchmark rank as a domain result. Size a domain golden set from its coverage matrix and the uncertainty your decision can tolerate. Then re-rank candidate models on it with ranx or pytrec_eval. A domain set can reverse a leaderboard ordering, so publish the dataset slice, retrieval protocol, and confidence interval with the result.
  • Embedding drift detection: track distributional KL or model-based drift between a fixed reference window and rolling production embeddings; also measure nearest-neighbor stability for a fixed probe set. evidently and alibi-detect implement model-based and statistical detectors. Evidently’s comparative study is one vendor evaluation; compare methods on known shifts in your own embeddings.
  • Multi-vector vs. single-vector: late interaction preserves token-level representations instead of collapsing each document into one vector; ColBERT is the canonical design, with reference implementations in RAGatouille and PyLate. That richer representation increases index and retrieval cost. Compare quality, storage, and latency against a single-vector baseline on the same domain set before adopting it.

Index construction

  • Recall@k under approximation: compare the approximate-nearest-neighbour (ANN) index against an exact brute-force baseline at the same k — in FAISS, that’s IndexHNSWFlat (or IndexIVFFlat) vs. IndexFlatIP/IndexFlatL2. Set the acceptable recall loss from your downstream quality budget. The ann-benchmarks project tracks recall–QPS Pareto curves across libraries.
  • HNSW tuning: HNSW (Hierarchical Navigable Small World) is a layered proximity graph; see Malkov & Yashunin, 2018. It is implemented in hnswlib, FAISS’s IndexHNSWFlat, and most vector DBs. HNSW exposes three knobs: M (graph fan-out), efConstruction (build-time candidate width), and efSearch (query-time candidate width). Start from the library’s documented defaults, then sweep the parameters until the recall–latency curve meets your evaluation set’s requirements.
  • IVF tuning: IVF (Inverted File index — partition vectors with k-means into nlist cells, then at query time scan the nprobe nearest cells; see FAISS’s IndexIVFFlat and IndexIVFPQ). Sweep nlist and nprobe against exact-search recall and latency. Benchmark filtered queries separately because index families and vector databases implement filter traversal differently.
  • Update freshness lag: time from doc commit to retrievability. Track p50 and p99. For systems with regulatory requirements, also track the percent of queries served against stale indexes.

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

  • Query expansion quality: Recall@k uplift on your golden set, expanded query vs. raw. Predefine the minimum useful gain and its uncertainty before testing. If expansion does not clear that local gate, it does not justify its latency and cost. Classical PRF (pseudo-relevance feedback) baselines like RM3 and Bo1 are still useful sanity checks; LLM-based expansion needs to beat them.
  • HyDE evaluation: HyDE (Gao et al., 2022) generates a hypothetical answer with the LLM, embeds it, and retrieves against that. It adds generation latency and a new failure surface. Measure Recall@10 separately on in-domain, out-of-domain, and low-confidence slices, then choose whether it belongs in the default path, a fallback, or neither.
  • Multi-query generation: Recall@k union of N rewrites vs. single query. Sweep N and choose a point on your recall–latency frontier. Implementations: LangChain’s MultiQueryRetriever, LlamaIndex’s QueryFusionRetriever.
  • Intent classification accuracy: standard precision/recall/F1 per intent (compute with sklearn.metrics.classification_report), but the operative metric is routing correctness — does the right downstream pipeline get invoked?
  • Adaptive routing: Adaptive-RAG (Jeong et al., NAACL 2024) makes the case that not every query deserves the same retrieval strategy. Track router accuracy as a classification problem against a labeled set of “needs no retrieval / one-shot / iterative.”

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

My hypothesis is that hybrid retrieval plus a cross-encoder reranker can help technical, log-style, and code corpora. The gain may 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 companion SciFact notebook is one bounded test, not a general result.

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

  • ΔnDCG / ΔMRR: uplift over no-rerank, on your golden set, at the depth your application actually uses. Compute by running your retrieval metrics with and without the reranker on identical candidate sets.
  • Cross-encoder vs. bi-encoder: a bi-encoder embeds query and doc independently (one vector per side) and scores by dot product; a cross-encoder concatenates query+doc and runs a single forward pass that attends jointly across both. Cross-encoders trade a forward pass per candidate for richer query–document interaction. Reference implementation: sentence-transformers CrossEncoder. Benchmark relevance and latency on named hardware, batch size, and candidate depth; do not transfer one model or managed service’s result into another environment.
  • Listwise vs. pointwise: pointwise scores each (query, doc) pair independently; listwise scores the whole candidate list jointly so the model can compare candidates. Evaluate both on the same candidate sets. Calibrate any score threshold per model and corpus rather than treating a published example as portable.
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 often helps a basic RAG pipeline, but it is 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

Many “good retrieval, bad answer” failures start in context construction.

  • Context relevance: per-chunk relevance score from RAGAS ContextRelevance or a cross-encoder, aggregated as mean and as percent of chunks below a threshold.
  • Context utilization: of the chunks placed in context, how many were actually cited or used in the answer. Compute as cited chunksretrieved chunks\frac{|\text{cited chunks}|}{|\text{retrieved chunks}|} over a labeled sample. Set the operating threshold from answer quality and token cost rather than using a universal percentage.
  • Lost-in-the-middle detection: synthetic eval where you place the gold chunk at positions {first, middle, last} of a long context and measure answer correctness. The cited Liu et al. (TACL 2023) study reports U-shaped degradation under its long-context conditions. Treat the same pattern in a current model as a hypothesis to test. Mitigations: rerank then reorder the top-k so the highest-scored chunk is first or last (LangChain’s LongContextReorder does exactly this), or compress middle chunks aggressively. Measure with a position-stratified eval, not just an aggregate score. A worked, runnable position-stratified eval lives in notebook 06 (module: evaluation/lost_in_middle.py).
  • Context compression: report compression ratio (input tokens / output tokens) alongside answer correctness. Tools include LangChain’s ContextualCompressionRetriever and LongLLMLingua. Predefine the largest acceptable correctness loss from the application’s risk and token budget, then reject configurations that cross it.

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 judges claims against the retrieved context. It may still score claims supported by that incomplete context, but it cannot diagnose the filter or retrieval cause. An empty refusal may produce no statements and NaN, depending on the implementation; do not treat it as evidence that faithfulness approved the refusal.

The highlighted branch is the common failure: the right document exists, but the filter removes it before retrieval. Recall@k registers the drop; only the exclusion rate attributes it to the predicate.

Silent RAG failures mapped from the source corpus through filtering, ranking, and generation to the metric that identifies each sourceSilent RAG failures mapped from the source corpus through filtering, ranking, and generation to the metric that identifies each source

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:
        # demo only: the survivor set stands in for a ranked run.
        # A real harness ranks the survivors first, then slices to k.
        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 on this trade-off is planned; see the follow-ups listed at the end.


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. The following is a no-run legacy API sketch for RAGAS 0.4.3, not a copy-paste program. To execute it, pin ragas==0.4.3, install a provider client, configure its credentials, create a provider-backed RAGAS LLM, and pass it as evaluator_llm. The setup is intentionally outside the block and provider-specific; the function takes the configured object as an argument instead of implying a default. For new code, use the current collections-based API, which RAGAS documents as the migration path from the legacy metrics API:

# No-run: legacy RAGAS 0.4.3 API shape.
# Before calling this function, configure a provider-backed RAGAS LLM.
# For example, with the provider credentials already set:
# from openai import AsyncOpenAI
# from ragas.llms import llm_factory
# evaluator_llm = llm_factory("gpt-4o-mini", client=AsyncOpenAI())
from ragas import EvaluationDataset, evaluate
from ragas.metrics import (
    Faithfulness,
    LLMContextPrecisionWithoutReference,
    ResponseRelevancy,
)

def run_legacy_ragas_043(evaluator_llm):
    dataset = EvaluationDataset.from_list([
        {
            "user_input": "How many moons does Mars have?",
            "response": "Mars has two moons, Phobos and Deimos.",
            "retrieved_contexts": ["Mars has two moons named Phobos and Deimos."],
            "reference": "Mars has two moons.",
        }
    ])

    return evaluate(
        dataset,
        metrics=[Faithfulness(), ResponseRelevancy(), LLMContextPrecisionWithoutReference()],
        llm=evaluator_llm,
    )

RAGAS renamed these fields in 0.2 (questionuser_input, answerresponse, contextsretrieved_contexts, ground_truthreference). Code written against 0.1 fails in a confusing way: the old keys are dropped rather than rejected, so the error names the new columns as missing.

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 runs the same loop through two judge families — the generator’s own model and a cross-family judge (RAG_EVALS_JUDGE_MODEL) — plus a deterministic lexical baseline, so you can see where the families disagree.

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, the raw 0–1 score it emits, and balanced-accuracy results on AggreFact and RAGTruth. It publishes no default decision boundary, so picking one is your job. 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. Thakur 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

  • Answer correctness vs. ground truth: when you have it, exact match or token-F1 for short-answer tasks (evaluate.load("squad")), semantic similarity for open-ended (bert-score, embedding cosine via sentence-transformers, or RAGAS AnswerCorrectness).
  • Completeness via nuggets: a “nugget” is a single atomic piece of information that any correct answer must contain (e.g., for “When was the company founded?” the nuggets might be {year: 1994, founder: Jane Doe}). TREC’s AutoNuggetizer extracts the gold nuggets of a correct answer from a reference, then scores what fraction the system covers — strong correlation with manual evaluation across 21 topics × 45 runs at TREC 2024.
  • Refusal behavior: queries with no answer in the corpus should produce abstention, not hallucination. Track abstention precision (refusals that were correct) and abstention recall (out-of-scope queries that triggered refusal). NoMIRACL is the public benchmark; in your own domain, label a slice of out-of-scope queries and track abstention accuracy.

Post-generation verification

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

  • Entity grounding check: every named entity in the answer must appear in (or be derivable from) the retrieved context. A simple regex + exact-match check (or spaCy’s ents against a normalized context string) catches a surprising fraction of hallucinations.
  • Claim verification: extract claims, run NLI against context, fail or flag any below threshold. NLI-as-faithfulness models: cross-encoder/nli-deberta-v3-large, MoritzLaurer/DeBERTa-v3-large-mnli-fever-anli-ling-wanli. Adds latency. Worth it for high-stakes domains.
  • Self-consistency (Wang et al., ICLR 2023): sample multiple generations at temperature > 0; report agreement rate (e.g., proportion of generations that match the modal answer, or pairwise BERTScore); choose the sample count from the stability–cost curve and flag low-agreement answers for human review.
  • Confidence calibration: collect verbalized confidence (“How confident are you, 0–1?”) and compare to actual correctness on the eval set. Plot a calibration curve and report Expected Calibration Error: ECE=m=1MBmnacc(Bm)conf(Bm)\text{ECE} = \sum_{m=1}^{M} \frac{|B_m|}{n} |\text{acc}(B_m) - \text{conf}(B_m)|, where BmB_m are confidence bins. Implementations: netcal, torchmetrics.CalibrationError. A model that reports 0.9 confidence should be correct on roughly 90% of comparable cases; measure the gap instead of assuming calibration.

Part 7: Ontology-grounded RAG evaluation

The standard metrics above cover open-corpus RAG. If your RAG retrieves against a structured ontology, taxonomy, or knowledge graph, those metrics are necessary but not sufficient. Examples include products in a catalog, conditions in SNOMED, components in a BOM, and security techniques in MITRE ATT&CK. 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).

  • Mention-level precision/recall/F1: standard, against gold mention spans (compute with seqeval or a span-set comparator).
  • Disambiguation accuracy: of correctly-detected mentions, what fraction map to the right entity ID? Public references include ReFinED, REL, and GENRE; benchmarks like AIDA-CoNLL and BELB show that results vary by system and domain.
  • NIL handling: precision/recall on “entity not in ontology.” Measure over-linking to near-but-wrong entities separately from correct abstention.

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.

  • Hierarchical precision/recall/F1 (Kosmopoulos et al., 2015): credit ancestors and descendants in the ontology DAG. With P^q\hat{P}_q the predicted node plus all its ancestors and TqT_q the true node plus all its ancestors:

    hP=qP^qTqqP^q,hR=qP^qTqqTq,hF1=2hPhRhP+hRhP = \frac{\sum_q |\hat{P}_q \cap T_q|}{\sum_q |\hat{P}_q|}, \quad hR = \frac{\sum_q |\hat{P}_q \cap T_q|}{\sum_q |T_q|}, \quad hF1 = \frac{2 \cdot hP \cdot hR}{hP + hR}

    Implement with networkx on the ontology graph: augment each prediction and each label with its ancestors, then take the set overlaps above.

  • Wu-Palmer similarity between predicted and gold entity in the taxonomy (Wu & Palmer, 1994):

    WuP(c1,c2)=2depth(LCA(c1,c2))depth(c1)+depth(c2)\text{WuP}(c_1, c_2) = \frac{2 \cdot \text{depth}(\text{LCA}(c_1, c_2))}{\text{depth}(c_1) + \text{depth}(c_2)}

    where LCA is the lowest common ancestor in the taxonomy. Available out of the box in NLTK for WordNet (from nltk.corpus import wordnet as wn; wn.synset("car.n.01").wup_similarity(wn.synset("truck.n.01"))); for custom taxonomies, compute LCA with networkx.

  • Sibling/parent confusion rate: separately track confusions to siblings, parents, and children — count_sibling / total_errors, count_parent / total_errors, count_descendant / total_errors. Use reviewed examples to test whether sibling errors come from ambiguous mentions or parent errors from over-generalization.

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:

  • Schema validity rate: percent of outputs that parse and validate against the ontology schema. Validate with jsonschema or pydantic. JSONSchemaBench is the public benchmark for general structured output; for ontology-specific schemas, build your own validator.
  • Vocabulary conformance: percent of named entities in the output that are valid ontology IDs — a one-line set-membership check against the closed vocabulary.
  • Semantic conformance: a syntactically valid output can still pick the wrong-but-valid entity. Pair conformance with downstream answer correctness.

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:

  • Citation completeness: percent of factual claims with at least one verifiable citation.
  • Provenance depth: percent of citations that resolve all the way back to a source document with a stable ID, not just a chunk hash.
  • Reproducibility rate: re-running the same query at a fixed snapshot returns the same answer. Pin the model version, runtime, decoding configuration, and seed, then set the required repeat rate from the workflow’s auditability needs. Temperature zero alone does not guarantee determinism. A miss can come from generation, the serving runtime, or any upstream stage.

Part 8: System-level evaluation

Holistic answer quality

  • LLM-as-judge (Zheng et al., NeurIPS 2023): a scalable model-based evaluation approach. G-Eval (Liu et al., EMNLP 2023) derives a rubric from a natural-language criterion. It then scores with log-prob-weighted output. Agreement depends on the judge, task, prompt, and calibration set.
  • Pairwise preference: present judge with answer A vs. answer B; record preference. This avoids absolute-score calibration issues. MT-Bench reported GPT-4 judge agreement above 80% with both human preferences and human–human agreement under its benchmark conditions; do not transfer that rate to another domain without calibration.

LLM-as-judge has real biases:

  • Position bias: judges prefer the first or second answer regardless of quality. Mitigation: randomize order, or run both orders and average.
  • Verbosity bias: judges can confound length with quality. A 2026 controlled study found heterogeneous expansion-pair behavior. Three judges preferred longer answers, Claude preferred concise answers, and GPT-4o was approximately neutral. All five performed well on truncation controls. Those results are benchmark-bound, so tell your judge how to treat completeness and filler, then report length-controlled performance on your own rubric.
  • Self-preference bias: GPT-4 prefers GPT-4 outputs; the bias correlates with output perplexity (judges prefer text that’s familiar to them). Mitigation: use a different judge family from the system being evaluated. Do not use a model to judge itself.

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 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 and self-preference shows up as a number.

Latency and cost

  • p50, p95, p99 at every pipeline stage. Choose the SLO percentile and alert threshold from the user journey, traffic volume, and error budget.
  • Time-to-first-token vs. total generation time. Users care about TTFT for streaming UX.
  • Stage breakdown: retrieval, reranking, generation, post-processing. Use the trace to locate the tail instead of assuming which stage caused it; record reranker device and batch size when comparing runs.
  • Total $/query = embedding + retrieval + rerank + generation + storage amortized. Track p50 and p99; the long tail is where the budget goes.
  • Cache hit rates at the embedding cache, retrieval cache, and KV-cache levels. Set separate targets from observed repetition, invalidation policy, and the cost avoided at each layer.

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

  • Unit of randomization: choose the unit from the estimand, carryover, and interference. Use per-user or per-session assignment when repeated exposure can change behavior or create inconsistent UX. Per-query assignment is defensible only when those effects are negligible and the analysis models repeated observations.
  • Primary, guardrails, exploratory metrics: pre-register them. Choose the primary measure from the product outcome; satisfaction proxies include thumbs, regenerations, and dwell. Treat latency and cost as guardrails when they constrain the experience.
  • Sample size: power-analyze before launching from the minimum effect worth detecting, baseline variance, assignment unit, and stopping rule.

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:

  • Per-chunk: “Generate 3 questions a user might ask that this chunk answers.”
  • Multi-hop: sample two chunks, generate a question requiring both.
  • Adversarial: generate questions with distractor entities, near-duplicate phrasing, ambiguous mentions.

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

  • Counterfactuals: swap key entities in the query. Does the system retrieve the right chunks for the swapped query?
  • Distractors: queries where the corpus contains a plausible-but-wrong answer that should not be retrieved. This is what RGB (Chen et al., AAAI 2024) stress-tests: noise robustness, negative rejection, information integration, and counterfactual robustness.
  • Negation and quantifiers: queries with “not,” “except,” and “only.” Dense retrievers often struggle with these.
  • Out-of-scope: queries with no answer in the corpus. The system should say “I don’t know,” not hallucinate. NoMIRACL lives here. Evaluate abstention explicitly on your production query types.

Coverage and continuous evaluation

  • Build a coverage matrix: query intent × document type × ontology branch. Aim for ≥1 query per cell. Empty cells are unmonitored regions where regressions hide.
  • Run a bounded, fast regression subset on every PR and the full suite on a slower schedule.
  • Schedule the full golden-set eval from release cadence and evaluation cost; release candidates are a natural gate.
  • Schedule drift evaluation from traffic volume, expected change, and risk. Use a rolling production sample and stratify by feedback rather than silently changing the target distribution.

Part 10: Production monitoring

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

Implicit and explicit feedback

  • Click-through / open rate on cited sources (if your UI exposes them).
  • Dwell time on the answer.
  • Regeneration rate: percent of answers the user re-asks or asks the system to redo. Treat it as one dissatisfaction signal and calibrate it against reviewed conversations.
  • Copy / share / export rates — strong positive signal.
  • Follow-up patterns: “Are you sure?” or “But what about X?” patterns suggest distrust.
  • Thumbs up/down with optional reason categories (wrong, incomplete, off-topic, harmful, slow). Inline edits, when your UI allows them, usually carry the most information of any feedback signal.

Drift detection

  • Query drift: track query embedding distribution vs. a reference window using KL divergence, MMD, or a model-based detector. Alert on shift, then segment-debug.
  • Embedding drift: pin a probe set of fixed documents; periodically re-embed and measure cosine to the original embeddings. Even small drift between provider model versions can silently break retrieval. Versioned embedding storage (immutable per-version snapshots) is the cheapest mitigation.
  • Performance drift: track production-equivalent metrics (regeneration rate by intent) over time. Sudden jumps mean something broke; slow drifts mean the world changed.

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:

  • Sample low-confidence outputs into a review queue.
  • Include a random sample of production traffic for blind review; set its rate from traffic volume, risk, and reviewer capacity.
  • Weight thumbs-down outputs heavily.
  • Use reviewed outputs to extend the golden set.

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

  • Targets are local, not universal. Any number labeled illustrative in this guide is an example configuration or worked result, not a release threshold. Calibrate thresholds to your domain, stakes, evaluation-set uncertainty, and user expectations.
  • The framework space moves fast. HHEM versions, RAGAS metric names, model cards, and leaderboard order can drift after publication. Recheck the linked source and re-benchmark before committing.
  • LLM-as-judge agreement numbers come with asterisks. The 80% GPT-4-vs-human figure is from MT-Bench / Chatbot Arena conditions. On niche domains and adversarial cases, agreement drops sharply. Use judges as a force multiplier, not a replacement for spot-checking.
  • Vendor benchmark uplifts are often not independently reproducible. Reproduce on your own data before believing a number, especially for newer rerankers and OCR systems.
  • No metric is a substitute for looking at outputs. Schedule blind review of a random production sample according to traffic, risk, and reviewer capacity. The metrics scale that habit; they do not replace it.

Coming up in this series

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

  • Soft Boosts vs. Hard Filters: a deep dive on filter false-exclusion rate, with code, real production examples, and a decision framework.
  • Chunking Is the Hidden Variable: a controlled experiment across recursive, semantic, late, and structural chunking on three corpora.
  • Reranker Selection in 2026: BGE vs. Cohere vs. ZeRank vs. current cross-encoder models, head-to-head on cost, latency, and uplift.
  • Ontology-Grounded RAG: An End-to-End Walkthrough: building the full evaluation harness for an entity-grounded retrieval system.
  • LLM-as-Judge Without the Self-Preference Trap: practical recipes for unbiased automated evaluation.
  • Online Evaluation in Production: instrumentation patterns, alerting policies, and the dashboards that catch real regressions.

References

Frameworks and benchmarks

Retrieval and ranking

Generation, faithfulness, judges

Drift and production

Companion code

  • slavadubrov/rag-evals-demo — runnable harness for every metric in this article on the SciFact corpus, plus a chunking × embedding × LLM benchmark sweep. Notebooks 00–09, unit tests that pin the worked examples above, and an embedded-Qdrant index so it runs without Docker.