RAG-evaluatiemetrics: retrieval, reranking en generation

Automatische vertaling Dit artikel is automatisch vertaald vanuit de oorspronkelijke Engelse versie.

Een RAG-systeem met defecte filters kan maanden draaien zonder een operationele alert te triggeren. Het retourneert nog steeds antwoorden en voldoet aan de latency-doelstelling, maar de antwoorden zijn gebaseerd op onvolledig bewijs. Recall@k tegen de oorspronkelijke gold set maakt het verlies zichtbaar. Dashboards voor latency en availability doen dat niet.

Voor engineers die multi-stage RAG-systemen beheren of evalueren, brengt deze reference failures in document parsing, filtering, retrieval, reranking en generation in verband met de metric die elke failure identificeert. Vervolgens laat deze reference zien waar die meting thuishoort in een release- of monitoring gate.

Wil je meteen vooruit en code uitvoeren?

De uitvoerbare repository slavadubrov/rag-evals-demo past de metrics toe op SciFact. make eval voert de suite uit en make benchmark vergelijkt configuraties voor chunking, embeddings en LLMs. Notebooks 00–09 isoleren elke metric. De demo gebruikt embedded Qdrant en vereist daarom geen Docker.

- Een bruikbare evaluation stack dekt ingestion, retrieval, generation grounding, ontology conformance en system signals. [RAGAS](https://docs.ragas.io/), [TruLens](https://www.trulens.org/), [DeepEval](https://deepeval.com/), [Arize Phoenix](https://phoenix.arize.com/) en de [TREC 2024 RAG Track](https://trec.nist.gov/data/rag2024.html) leveren tooling. Ze kiezen je metrics niet voor je. - Voor metadata- en ontology-grounded RAG kunnen een verkeerde tag of een brittle hard predicate recall tot nul reduceren. Standaard Recall@k detecteert het verlies wanneer de oorspronkelijke gold set behouden blijft. Een filter false-exclusion metric identificeert de oorzaak. Faithfulness kan claims nog steeds beoordelen tegen incomplete context, maar kan de filter- of retrieval-oorzaak niet diagnosticeren. Een lege refusal kan, afhankelijk van de implementatie, geen statements en `NaN` opleveren.

De secties volgen de volgorde van de pipeline. Begin met de decision table en gebruik de latere secties als reference voor elke stage.


RAG-evaluation decision table

Gebruik deze tabel als startpunt voordat je een framework kiest. De juiste metric hangt af van de failure mode die je wilt detecteren, niet van de naam van de tool.

VraagMetric familyGebruik dit wanneerLet op
Heeft parsing de bron behouden?Extraction completeness, table/figure coveragePDFs, slides, scans en HTML-pagina’s in de corpus komenSchijnbaar nette tekst kan captions, footnotes of table structure verliezen
Heeft retrieval het juiste bewijs gevonden?Recall@k, nDCG@k, MRR, context precision/recallJe relevante chunks of documents kunt labelenEen hard metadata filter kan het juiste document verwijderen voordat ranking begint
Heeft reranking de shortlist verbeterd?Reranker uplift, Precision@1, nDCG deltaCross-encoders of LLM rankers na retrieval worden gebruiktMeet latency en cost samen met de quality gain
Gebruikte het antwoord het bewijs?Faithfulness, groundedness, citation supportHet antwoord documents citeert of feiten uit context gebruiktFaithfulness kan slechte parsing of retrieval niet diagnosticeren
Is het systeem stabiel in productie?Drift, regeneration, fallback, p95 latency, cost per answerTraffic na launch verandertProduction telemetry heeft sampled human review nodig om gekalibreerd te blijven

Zie voor een kortere tool comparison Best RAG Evaluation Tools: Ragas, DeepEval, and TruLens.

Part 1: Definieer succes vóór de architecture

Stel de eval set op vóór het architecture diagram. Zo krijgt elke latere componentkeuze een meetbaar doel.

Je kunt niet kiezen tussen BM25 en dense retrieval, recursive en semantic chunking, of Cohere Rerank en BGE voordat je weet waarop je optimaliseert. “Betere antwoorden” is geen metric. Een illustratief contract is “faithfulness ≥ 0.85 op een golden set van 200 queries die onze drie belangrijkste intents afdekt, met p95 latency < 1,5 s en een filter false-exclusion rate < 2%.” De getallen zijn placeholders; belangrijk is dat quality, coverage, latency en filtering expliciete gates hebben.

Definieer de harness voordat je retrieval-code schrijft. De eerste harness zal verkeerd zijn en je zult die aanpassen. Een metric aanpassen is veel goedkoper dan een systeem aanpassen dat je al hebt uitgerold.

Drie pipeline-lagen en twee run modes

Moderne RAG is een pipeline, dus evaluation moet ook een pipeline zijn. Geen enkel getal detecteert elke failure mode.

Production evaluation heeft drie pipeline-lagen. Ingestion evaluation vraagt of de corpus en index de bron behouden. Query-time evaluation vraagt of rewriting, filtering, retrieval, reranking en context assembly het juiste bewijs hebben gevonden. Answer and production evaluation vraagt of de response dat bewijs heeft gebruikt en of de quality onder live traffic behouden blijft. Voeg de lagen samen tot één score en een normalisatiebug kan verdwijnen in een acceptabele answer score.

De drie plaatsen waar een RAG-systeem bewijs kan verliezen: de corpus en index, het retrieval path en het answer- en live-trafficpadDe drie plaatsen waar een RAG-systeem bewijs kan verliezen: de corpus en index, het retrieval path en het answer- en live-trafficpad

Deze lagen beschrijven waar een failure plaatsvindt. Offline en online beschrijven wanneer en tegen welke data de check wordt uitgevoerd. Offline evaluation gebruikt een vaste dataset met bekende ground truth; deze is reproduceerbaar en hoort thuis bij componentselectie, A/B-comparisons en CI-gates. Online evaluation scoort sampled live traffic en legt regeneration, dwell time, expliciete feedback en echte query drift vast. Deze evaluation is noisier en moeilijker te instrumenteren.

Elke pipeline-laag kan offline en online checks bevatten. Een vaste ingestion corpus detecteert parser regressions vóór release, terwijl freshness- en parse-failure monitors live updates afdekken. Een vaste queryset meet retrieval vóór release, terwijl sampled live traces production drift blootleggen. Alleen offline missen live veranderingen; alleen online maakt het moeilijk om regressions te reproduceren.

Component-level versus end-to-end

Er zijn twee veelvoorkomende fouten. End-to-end-only evaluation vertelt je dat het systeem defect is, maar niet waar. Component-only evaluation kan laten zien dat elk onderdeel slaagt, terwijl het volledige systeem toch faalt. De oplossing bestaat uit enkele headline end-to-end metrics voor go/no-go-beslissingen, aangevuld met component metrics voor diagnosis. Retrieval metrics detecteren retriever regressions. Generation metrics detecteren generator regressions. End-to-end answer correctness detecteert integration failures.

De reference frameworks (opinionated tour)

FrameworkSterk inZwakke punten
RAGASMijn selectiecriterium: een gedeelde vocabulary voor faithfulness, answer relevancy en context precision/recall (metrics)Kosten van de LLM-judge; opaque score components bij debugging; version changes
ARESMijn selectiecriterium: een task-specific classifier judge rechtvaardigt de training- en annotation cost (paper); de gerapporteerde precision is benchmark-boundZwaardere setup; je moet daadwerkelijk models trainen
TruLensMijn selectiecriterium: trace-linked feedback functions en OpenTelemetry integration zijn belangrijker dan een catalogus met RAG metrics (project)Minder batteries-included voor RAG-specifieke metrics dan RAGAS
DeepEvalMijn selectiecriterium: test-runner integration en custom metrics zijn belangrijker dan een framework default (project)Intensief gebruik van LLM-judges = cost spikes
Arize PhoenixMijn selectiecriterium: tracing en embedding visualizations helpen bij het onderzoeken van een drift hypothesis (project)Je brengt je eigen metric definitions mee
TREC 2024 RAG TrackPublieke benchmark voor nugget evaluation (AutoNuggetizer), support evaluation en fluency op MS MARCO Segment v2.1Geen runtime tool; een benchmark om tegen te kalibreren

Mijn default stack is RAGAS voor de metric vocabulary, DeepEval voor CI-gates, Phoenix voor production tracing, plus custom code voor ontology-specific metrics. Je groeit uiteindelijk uit elk framework waar je mee begint. Kies het framework dat custom metrics eenvoudig maakt.

Gebruik voor benchmarks BEIR (Thakur et al., NeurIPS 2021) voor zero-shot retrieval generalization, MTEB voor algemene embedding quality, MIRACL voor multilingual retrieval en de TREC 2024 RAG Track voor end-to-end RAG evaluation.


Part 2: Plaats evaluation points op de pipeline

Een production RAG-systeem is groter dan “embed documents, retrieve chunks, call an LLM.” Elke stage tussen document acquisition en answer delivery kan falen.

De RAG-pipeline gegroepeerd in ingestion-, query-time- en answer paths, met diagnostic metrics naast elke stageDe RAG-pipeline gegroepeerd in ingestion-, query-time- en answer paths, met diagnostic metrics naast elke stage

Elke stage in het diagram heeft ten minste één metric. Een stage zonder metric kan falen zonder dat iemand het merkt.

De drie lanes komen overeen met de plaatsen waar bewijs verloren kan gaan. De ingestion lane omvat parsing, cleaning, chunking, embedding en indexing. De query-time lane omvat rewriting, filtering, retrieval, reranking en context assembly. De answer- en production lane omvat faithfulness, citation verification, user signals, drift, latency en cost.

Fouten stapelen zich op verderop in de chain: slechte parsing begrenst wat chunking kan doen, slechte chunking begrenst retrieval en slechte retrieval begrenst zowel reranking als generation. Faithfulness meet alleen het uiteindelijke antwoord, nooit de upstream oorzaak.


Part 3: Ingestion evaluation

Veel production RAG-failures beginnen bij ingestion. Het systeem werkt op schone testdocumenten en faalt vervolgens op echte PDFs, scans, tabellen en rommelige corpuspagina’s.

Document acquisition en parsing

Wat je meet:

  • Text extraction completeness: extracted_chars / expected_chars op een gelabelde sample, berekend per document class. Er is geen canonical package — schrijf een kleine harness die parser output vergelijkt met een handmatig opgeschoonde reference. Let op ontbrekende footnotes, headers en captions.

  • OCR accuracy: CER (Character Error Rate) en WER (Word Error Rate), de standaard 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}

    waarbij SS, DD en II respectievelijk character-level substitutions, deletions en insertions zijn en NN het aantal reference characters is (subscript ww voor de word version). Pas niet voor elke corpus dezelfde CER-boundary toe. Kalibreer die per document class en downstream answer loss. Printed text, handwriting en multilingual material hebben verschillende error profiles. Bereken dit met jiwer (jiwer.cer(refs, hyps), jiwer.wer(refs, hyps)) of HuggingFace evaluate. Voor evaluation corpora zijn FUNSD en SROIE publieke 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) meet hoe dicht een voorspelde HTML-table tree bij de reference ligt, genormaliseerd door de omvang van de grootste tree. Uit 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 gebruikt zowel structure (rows, columns, spans) als cell content. TEDS-S verwijdert de content en scoort alleen structure. Reference implementation: PubTabNet’s teds.py (gebruikt onder de motorkap apted). Zie voor evaluation corpora PubTabNet, FinTabNet en SciTSR. Naive parsers falen vaak op tabellen. Benchmark ze voordat je ze vertrouwt.

  • Layout / structure preservation: heading order, list integrity en reading order op multi-column PDFs. Gebruik DocLayNet als gelabelde benchmark. Een off-the-shelf comparison kan een element parser zoals unstructured, een PDF-library zoals pymupdf en een VLM parser zoals docling omvatten.

Vergelijk verschillende parser families, bijvoorbeeld een Tesseract-baseline, een VLM-based OCR model en je kandidaat van een vendor. Gebruik een gestratificeerde sample van echte document classes bij een vaste DPI, inclusief schone scans, foto’s, tabellen, multilingual text, wiskunde en handwriting. Rapporteer CER of WER per class en TEDS voor table pages.

Cleaning en normalization

  • Boilerplate removal accuracy: precision/recall tegen human-labeled boilerplate spans. Agressieve removal laat relevante content vallen; trage removal vervuilt embeddings. Tools om te vergelijken: trafilatura, jusText, Resiliparse. Barbaresi (2021) benchmarkt deze head-to-head.

  • Unicode normalization: het percentage documents dat identieke NFC- en NFKC-outputs produceert (berekend met de stdlib unicodedata.normalize) is een bruikbaar drift signal. Mismatches zorgen ervoor dat zero-width joiners en lookalike characters retrieval recall breken.

  • Language detection accuracy: F1 op een gelabelde multilingual sample. Kritiek voor multilingual indexes. Gebruik fasttext-langdetect (Facebook’s lid.176), lingua-py of cld3. FLORES-200 levert evaluation text voor 200 talen, maar je production language mix moet de test slice bepalen.

  • Deduplication effectiveness (MinHash / LSH): precision/recall van je near-duplicate detector tegen een handmatig gelabelde set. Het onderliggende idee: schat Jaccard similarity J(A,B)=ABABJ(A, B) = \frac{|A \cap B|}{|A \cup B|} tussen document shingle sets met kk random permutation hashes (Broder, 1997) en plaats near-duplicates in buckets met LSH banding (Indyk & Motwani, 1998). Sweep het aantal hashes en de Jaccard threshold op je corpus. Track false-merge rate (die answers corrumpeert) afzonderlijk van missed-merge rate (die indexruimte verspilt). datasketch levert de hieronder gebruikte implementation; de parameters zijn illustratief:

    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 en recall, afzonderlijk berekend per entity type (emails, SSNs, names, addresses). Recall errors creëren compliance risk; precision errors schaden answer quality. Stel het operating point vast met het legal team. Candidate tools zijn onder meer Microsoft Presidio, scrubadub of een fine-tuned NER model op een gelabelde set.

Chunking bepaalt retrieval quality

Chunking kan een recall gap op meerdere punten veroorzaken, zelfs wanneer het embedding model gelijk blijft. In NVIDIA’s 2025 vendor benchmark leverde page-level chunking de hoogste accuracy en laagste variance voor paginated documents. Beschouw dat resultaat als bewijs voor de geteste corpus, niet als universele winnaar.

Semantic chunking groepeert aangrenzende sentences op basis van embedding similarity en knipt bij dissimilar boundaries. LangChain’s SemanticChunker en LlamaIndex’s SemanticSplitterNodeParser implementeren deze strategie. Dit kan recall ten opzichte van fixed windows verbeteren wanneer topical boundaries belangrijk zijn.

Recursive character splitting probeert eerst paragraph breaks, daarna sentence breaks en vervolgens word breaks totdat elke chunk binnen de target size past. LangChain’s RecursiveCharacterTextSplitter implementeert deze sequence. Kies kandidaatwaarden voor window en overlap die bij je document structure passen en laat de golden set vervolgens de definitieve waarden bepalen.

Te volgen metrics:

  • 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}}, waarbij sis_i sentence embeddings zijn. Gezonde chunks zijn intern similar en aan de boundary dissimilar. Bereken dit met sentence-transformers plus cosine_similarity van scikit-learn.
  • Boundary quality: human-labeled “is dit een zinvolle cut?” op een sample, plus een structural check dat chunks geen tables, lists of numbered sections splitsen.
  • Optimal chunk size: sweep token sizes (128, 256, 512, 1024) en plot Recall@k tegen size op je golden set. Kies het knee point. Kies niet zomaar wat de tutorial zegt.
  • Overlap effectiveness: ablate verschillende overlap fractions en meet Recall@k. Stop met overlap verhogen wanneer de lokale recall curve afvlakt of de duplication cost groter wordt dan de gain.
  • Chunk attribution fidelity: percentage chunks dat een verifieerbare source pointer behoudt (page number, section anchor, doc ID). Auditability vereist dit.
  • Late versus early chunking: late chunking (Günther et al., 2024) embedt eerst het volledige document en segmenteert daarna, waardoor global context behouden blijft (reference implementation in jina-embeddings-v3). Contextual Retrieval (Anthropic, 2024) voegt door een LLM gegenereerde context toe aan elke chunk. Beide voegen cost toe. Benchmark op je corpus voordat je een van beide adopteert.

Mijn mening: structural chunking (splitting op headings, tables en sections — geïmplementeerd door parsers zoals unstructured.io of door de AST te doorlopen die je parser al heeft geproduceerd) wordt te weinig gebruikt. Als je documents structure hebben, gebruik die dan voordat je similarity heuristics toevoegt. Recursive character splitting is de baseline; semantic chunking is de overhead vooral waard bij unstructured prose.

Metadata extraction en enrichment

  • NER precision/recall/F1: per entity type op een gelabelde subset. Standard CoNLL/MUC-style. Bereken dit met seqeval (from seqeval.metrics import f1_score) voor de BIO/IOB-tag-aware version, of met scikit-learn voor span-set comparisons. CoNLL-2003 en OntoNotes 5.0 zijn de canonical reference corpora.
  • Relation extraction F1: nog belangrijker voor ontology-grounded systemen. Label handmatig een set die is gestratificeerd op relation type en document class. TACRED en DocRED zijn publieke benchmarks; candidate implementations omvatten opennre en spaCy relation pipelines.
  • Title / heading extraction accuracy: exact-match plus genormaliseerde Levenshtein similarity (1edit_dist(a,b)max(a,b)1 - \frac{\text{edit\_dist}(a, b)}{\max(|a|, |b|)}) tegen ground truth — python-Levenshtein of rapidfuzz leveren beide in één call.
  • Hierarchical metadata preservation: percentage chunks dat de parent section, het parent document en het ancestry path correct behoudt. Dit is de metric die bepaalt of je RAG vragen kan beantwoorden zoals “wat zegt de child van policy X?”

Embedding generation

  • Model selection benchmarks: gebruik de task results van MTEB (nDCG@10 is de headline; met het MTEB Python package kun je de leaderboard lokaal reproduceren), BEIR voor zero-shot generalization en MIRACL voor multilingual retrieval als comparison points. Beschouw transfer van English MTEB naar een lower-resource language als een hypothesis die je op de gelabelde set van die taal moet testen.
  • Domain-specific evaluation: behandel een algemene benchmark rank niet als een domain result. Stel een domain golden set samen op basis van de coverage matrix en de onzekerheid die je beslissing kan tolereren. Rerank vervolgens kandidaat-models daarop met ranx of pytrec_eval. Een domain set kan de volgorde van een leaderboard omdraaien; publiceer daarom de dataset slice, retrieval protocol en confidence interval bij het resultaat.
  • Embedding drift detection: track distributionele KL of model-based drift tussen een vaste reference window en rolling production embeddings; meet ook nearest-neighbor stability voor een vaste probe set. evidently en alibi-detect implementeren model-based en statistical detectors. Evidently’s comparative study is één vendor evaluation; vergelijk methoden op bekende shifts in je eigen embeddings.
  • Multi-vector versus single-vector: late interaction behoudt token-level representations in plaats van elk document tot één vector te reduceren; ColBERT is het canonical design, met reference implementations in RAGatouille en PyLate. Deze rijkere representation verhoogt de index- en retrieval cost. Vergelijk quality, storage en latency met een single-vector baseline op dezelfde domain set voordat je dit adopteert.

Index construction

  • Recall@k under approximation: vergelijk de approximate-nearest-neighbour (ANN) index met een exacte brute-force baseline bij dezelfde k — in FAISS is dat IndexHNSWFlat (of IndexIVFFlat) tegenover IndexFlatIP/IndexFlatL2. Stel het toegestane recall loss vast op basis van je downstream quality budget. Het ann-benchmarks-project trackt recall–QPS Pareto-curves over libraries.
  • HNSW tuning: HNSW (Hierarchical Navigable Small World) is een layered proximity graph; zie Malkov & Yashunin, 2018. Het is geïmplementeerd in hnswlib, FAISS’s IndexHNSWFlat en de meeste vector DBs. HNSW heeft drie knobs: M (graph fan-out), efConstruction (build-time candidate width) en efSearch (query-time candidate width). Begin met de gedocumenteerde defaults van de library en sweep vervolgens de parameters totdat de recall–latency curve aan de vereisten van je evaluation set voldoet.
  • IVF tuning: IVF (Inverted File index — partitioneer vectors met k-means in nlist cells en scan tijdens query time de nprobe nearest cells; zie FAISS’s IndexIVFFlat en IndexIVFPQ). Sweep nlist en nprobe tegen exact-search recall en latency. Benchmark filtered queries afzonderlijk, omdat index families en vector databases filter traversal verschillend implementeren.
  • Update freshness lag: tijd vanaf doc commit tot retrievability. Track p50 en p99. Voor systemen met regulatory requirements track je ook het percentage queries dat tegen stale indexes wordt afgehandeld.

Part 4: Query-time evaluation

De query-time lane bevat de metrics die een retrieval path diagnosticeren. Recall@k alleen kan niet laten zien of rewriting, filtering, reranking of context assembly de failure veroorzaakte.

Query understanding en rewriting

  • Query expansion quality: Recall@k uplift op je golden set, expanded query tegenover raw. Definieer minimum useful gain en de onzekerheid daarvan vóór het testen. Als expansion die lokale gate niet haalt, rechtvaardigt deze de latency en cost niet. Klassieke PRF (pseudo-relevance feedback) baselines zoals RM3 en Bo1 zijn nog steeds nuttige sanity checks; LLM-based expansion moet ze overtreffen.
  • HyDE evaluation: HyDE (Gao et al., 2022) genereert met de LLM een hypothetical answer, embedt die en voert daar retrieval tegen uit. Dit voegt generation latency en een nieuw failure surface toe. Meet Recall@10 afzonderlijk op in-domain, out-of-domain en low-confidence slices en kies daarna of dit in het default path, een fallback of geen van beide hoort.
  • Multi-query generation: Recall@k-union van N rewrites tegenover één query. Sweep N en kies een punt op je recall–latency frontier. Implementations: LangChain’s MultiQueryRetriever, LlamaIndex’s QueryFusionRetriever.
  • Intent classification accuracy: standaard precision/recall/F1 per intent (bereken met sklearn.metrics.classification_report), maar de operationele metric is routing correctness — wordt de juiste downstream pipeline aangeroepen?
  • Adaptive routing: Adaptive-RAG (Jeong et al., NAACL 2024) betoogt dat niet elke query dezelfde retrieval strategy verdient. Track router accuracy als classification problem tegen een gelabelde set van “needs no retrieval / one-shot / iterative.”

Retrieval metrics

Dit zijn de baseline metrics. Als je ze niet trackt, kun je niet bepalen of retrieval verbetert.

MetricWat deze meetWanneer gebruiken
Recall@kfractie van de relevante documents van een query die in de top k worden geretourneerdgebruik wanneer het missen van enig deel van de relevante set belangrijk is
Precision@kpercentage van de top-k dat relevant isnuttig wanneer de context window de bottleneck is
MRRgemiddelde van 1/rank van het eerste relevante documentwanneer users alleen naar top-1 of top-3 kijken
nDCG@kposition-discounted gain, gewogen naar relevance gradesstandaard retrieval metric voor graded relevance
MAPgemiddelde over queries van average precisionwanneer de volledige ranked list belangrijk is
Hit Rate@kof ten minste één relevant document in de top k staataverage het binaire resultaat over queries als snelle sanity metric
Coveragepercentage golden docs dat ooit over alle queries heen is opgehaalddetecteert systematische gaten in de index

De formules ter referentie (binary relevance met relevante set RqR_q voor query qq en reli=1\text{rel}_i = 1 als het iie retrieved document in RqR_q zit):

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}}

Voor graded relevance geldt reli{0,1,2,}\text{rel}_i \in \{0, 1, 2, \dots\}; binary nDCG is het speciale geval dat in de code hieronder wordt gebruikt. MAP is het gemiddelde over queries van APq=1Rqi:reli=1Precision@i\text{AP}_q = \frac{1}{|R_q|}\sum_{i: \text{rel}_i = 1} \text{Precision@}i. Zie Manning, Raghavan, Schütze, Introduction to Information Retrieval, hoofdstuk 8, voor de afleidingen.

Gebruik voor production code ranx, pytrec_eval of ir_measures — deze implementeren de volledige TREC metric family en verwerken graded relevance correct. Stel release targets vast op basis van een realistische golden set, downstream answer quality en de cost van een miss. Neem thresholds niet over uit een tutorial.

De test harness hiervoor is kort. Je kunt die al vanuit een notebook uitvoeren voordat je überhaupt een vector database hebt gekozen.

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

Dat is je retrieval CI-gate. Koppel deze aan een coverage-driven fast subset op elke PR en voer de volledige golden set uit in de tragere release gate. Blokkeer een merge wanneer een vooraf geregistreerde metric zijn regression budget overschrijdt.

De companion repo pinnt de exacte getallen hierboven (Recall@5 = 0.750, MRR = 0.625, nDCG@5 = 0.627) als unit test in tests/test_retrieval_metrics.py; notebook 01 sweept Recall@k / MRR / nDCG over een echte SciFact-index en de production-shaped harness staat in evaluation/retrieval.py.

Hybrid retrieval en reciprocal rank fusion

BM25 is een sparse lexical scorer die exact-term matching, term weighting en length normalization combineert. Deze is beschikbaar in rank_bm25, Elasticsearch, OpenSearch en de meeste search engines.

Reciprocal Rank Fusion (Cormack, Clarke en Buettcher, SIGIR 2009) combineert BM25- en dense rankings op basis van positie. De oorspronkelijke k=60-setting is een bruikbare baseline. RRF is score-agnostic, waardoor de cross-lane normalization die lineaire interpolation vereist, vermeden wordt. Test daarnaast een convex combination en tune α wanneer je een voldoende grote labeled set hebt om een stabiele delta te schatten.

Mijn hypothesis is dat hybrid retrieval plus een cross-encoder reranker kan helpen bij technical, log-style en code corpora. De gain kan klein zijn op sterk semantic corpora. Meet tegen de dense-only en sparse-only lanes, want een slechte fusion configuration kan slechter presteren dan beide inputs afzonderlijk. De companion SciFact notebook is één afgebakende test, geen algemeen resultaat.

De implementation past in enkele regels.

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

Let op wat RRF niet doet: het kijkt nooit naar de raw similarity scores. Een dense retriever die cosine 0.98 retourneert en een BM25 lane met score 17.4 zijn niet rechtstreeks vergelijkbaar. Als je ze normaliseert met z-scores of min-max scaling, kun je de lane met de hoogste variance in die batch bevoordelen.

RRF gebruikt alleen rank. Als een retriever een document op positie 2 plaatst, is die vote 1 / (60 + 2) waard, ongeacht de raw score die daartoe leidde.

Hybrid + RRF op SciFact: notebook 02 vergelijkt dense versus BM25 versus RRF met per-query deltas. De production-shaped fuser staat in retrieval/hybrid_rrf.py; tests/test_rrf.py pint de canonical d3 / d2 / d1-ordering op k=60.

Reranking

  • ΔnDCG / ΔMRR: uplift ten opzichte van no-rerank, op je golden set en op de depth die je applicatie daadwerkelijk gebruikt. Bereken je retrieval metrics met en zonder reranker op identieke candidate sets.
  • Cross-encoder versus bi-encoder: een bi-encoder embedt query en document onafhankelijk (één vector per zijde) en scoort met dot product; een cross-encoder concateneert query+doc en voert één forward pass uit die gezamenlijk over beide attendt. Cross-encoders ruilen een forward pass per candidate in voor rijkere query–document interaction. Reference implementation: sentence-transformers CrossEncoder. Benchmark relevance en latency op benoemde hardware, batch size en candidate depth; draag het resultaat van een model of managed service niet over naar een andere environment.
  • Listwise versus pointwise: pointwise scoort elk (query, doc)-pair onafhankelijk; listwise scoort de volledige candidate list gezamenlijk, zodat het model candidates kan vergelijken. Evalueer beide op dezelfde candidate sets. Kalibreer een eventuele score threshold per model en corpus in plaats van een gepubliceerd voorbeeld als portable te behandelen.
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}")

Een reranker helpt vaak bij een basic RAG-pipeline, maar is geen gegarandeerde winst. Meet ΔPrecision@1 en ΔnDCG op je golden set en behoud de reranker alleen als de gain zijn latency- en cost budget haalt. Vergelijk die gemeten gain met kleinere retrieval changes voordat je de volgende optimalisatie kiest.

ΔnDCG en ΔPrecision@1 van een cross-encoder op SciFact: notebook 03; module: retrieval/reranker.py.

Context construction en lost-in-the-middle

Veel “good retrieval, bad answer”-failures beginnen bij context construction.

  • Context relevance: per-chunk relevance score van RAGAS ContextRelevance of een cross-encoder, geaggregeerd als mean en als percentage chunks onder een threshold.
  • Context utilization: van de chunks die in de context zijn geplaatst, hoeveel zijn daadwerkelijk geciteerd of in het antwoord gebruikt? Bereken dit als cited chunksretrieved chunks\frac{|\text{cited chunks}|}{|\text{retrieved chunks}|} op een gelabelde sample. Stel de operating threshold vast op basis van answer quality en token cost in plaats van een universeel percentage te gebruiken.
  • Lost-in-the-middle detection: synthetic eval waarbij je de gold chunk op posities {first, middle, last} van een lange context plaatst en answer correctness meet. De geciteerde studie Liu et al. (TACL 2023) rapporteert U-shaped degradation onder de eigen long-context conditions. Behandel hetzelfde patroon in een actueel model als een hypothesis die je moet testen. Mitigations: rerank en reorder de top-k zodat de hoogst scorende chunk first of last staat (LangChain’s LongContextReorder doet precies dit), of compress middle chunks agressief. Meet met een position-stratified eval, niet alleen met een aggregate score. Een uitgewerkte, uitvoerbare position-stratified eval staat in notebook 06 (module: evaluation/lost_in_middle.py).
  • Context compression: rapporteer compression ratio (input tokens / output tokens) naast answer correctness. Tools zijn onder andere LangChain’s ContextualCompressionRetriever en LongLLMLingua. Definieer vooraf het grootste aanvaardbare correctness loss op basis van het risk- en token budget van de applicatie en wijs configurations af die dit overschrijden.

Part 5: De filter false-exclusion rate

Deze metric krijgt een eigen sectie omdat aggregate retrieval scores een miss niet aan de filter kunnen toeschrijven.

Een hard metadata filter zoals tenant_id = X AND product = Y AND locale = en-US kan effective recall tot nul reduceren. Correct geïmplementeerde Recall@k detecteert het verlies omdat de denominator de oorspronkelijke relevant-document set blijft. De metric vertelt niet of de filter, retriever of ranker de miss veroorzaakte. Faithfulness beoordeelt claims tegen de retrieved context. De metric kan claims die door die incomplete context worden ondersteund nog steeds goed scoren, maar kan de filter- of retrieval-oorzaak niet diagnosticeren. Een lege refusal kan, afhankelijk van de implementatie, geen statements en NaN opleveren; behandel dit niet als bewijs dat faithfulness de refusal heeft goedgekeurd.

De gemarkeerde branch is de gebruikelijke failure: het juiste document bestaat, maar de filter verwijdert het voordat retrieval begint. Recall@k registreert de drop; alleen de exclusion rate schrijft die aan de predicate toe.

Stille RAG-failures, gemapt van de source corpus via filtering, ranking en generation naar de metric die elke bron identificeertStille RAG-failures, gemapt van de source corpus via filtering, ranking en generation naar de metric die elke bron identificeert

De metric

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

Deze query-level definition telt catastrophic exclusions: geen enkel relevant document overleeft. Voor queries met meerdere gold documents legt standaard Recall@k partial loss nog steeds bloot; voeg een per-document exclusion rate toe als die grens belangrijk is. Om beide rates te berekenen heb je nodig: (a) ground-truth doc IDs voor elke eval query en (b) instrumentation die de toegepaste filter predicates logt, niet alleen de uiteindelijke results. Stel het target vast op basis van de cost van het uitsluiten van een geldig antwoord en het confidence interval van je production sample.

Hier is een werkende implementation. Deze vergelijkt correcte standaard recall met een invalid evaluator die relevance opnieuw definieert na 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

De helft van de queries verliest zijn gold doc door de filter, waardoor correcte Recall@10 daalt naar 50%. Die score detecteert het symptoom, maar kan het niet toeschrijven. De false-exclusion rate laat zien dat de predicate twee answers verwijderde voordat de retriever draaide. De bewust invalid evaluator rapporteert 100%, uitsluitend omdat deze die failures uit zijn gold set verwijdert. Geen enkel model kan een document herstellen dat is weggefilterd.

De bovenstaande rate van 50% wordt als unit test gereproduceerd in de companion repo: tests/test_filter_exclusion.py::test_50_percent_exclusion_rate. Notebook 04 voert dit uit op SciFact met synthetic metadata, zodat je een echte filter recall naar nul kunt zien brengen; de runtime metric (met predicate-precision/recall companion) staat in evaluation/filter_exclusion.py.

Companion metric: predicate precision en recall

Wanneer filtering dynamic is (bijvoorbeeld wanneer een LLM filter predicates uit de query extraheert), behandel je de predicate extractor als een classification model en evalueer je die ook zo. Meet predicate precision en recall tegen een labeled set van (query, correct predicate) pairs. Een predicate error rate vertaalt zich niet rechtstreeks naar hetzelfde point loss in retrieval recall; meet hoe vaak die errors een gold document uitsluiten. Zodra een hard filter het gold document verwijdert, helpt geen enkele hoeveelheid reranking.

Soft boost versus hard filter

Deze metric dwingt een design decision af. Gebruik hard filters wanneer correctness binair is: legal jurisdiction, ACL boundaries, published-versus-draft. Gebruik soft boosts wanneer relevance graded is: locale preference, recency, version. Zonder exclusion-rate measurement is de verkeerde keuze moeilijk te zien.

De decision rule, meetbaar:

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.

Kies ε op basis van de harm van een false exclusion, het voordeel van extra precision en de omvang van de evaluation sample. Een dedicated post over deze trade-off is gepland; zie de follow-ups aan het einde.


Part 6: Generation evaluation

Retrieval metrics vertellen je dat het systeem correct zou kunnen antwoorden. Ze vertellen niet dat het dat heeft gedaan. Generation metrics dichten dat gat.

Faithfulness en groundedness

RAGAS faithfulness splitst het antwoord op in atomic claims (korte, self-contained factual statements) en verifieert elke claim tegen de retrieved context via een LLM judge:

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

Het percentage supported claims is de score. De structure is nuttiger dan één enkel getal, omdat deze laat zien welke claims niet worden ondersteund. Production code staat in het package ragas. Het volgende is een no-run legacy API sketch voor RAGAS 0.4.3, geen copy-paste-programma. Om dit uit te voeren pin je ragas==0.4.3, installeer je een provider client, configureer je de credentials, maak je een provider-backed RAGAS LLM aan en geef je die door als evaluator_llm. De setup staat bewust buiten het block en is provider-specific; de function ontvangt het geconfigureerde object als argument in plaats van een default te impliceren. Gebruik voor nieuwe code de current collections-based API, die RAGAS documenteert als migration path vanuit de 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 hernoemde deze fields in 0.2 (questionuser_input, answerresponse, contextsretrieved_contexts, ground_truthreference). Code die tegen 0.1 is geschreven faalt op een verwarrende manier: de oude keys worden verwijderd in plaats van afgewezen, waardoor de error de nieuwe columns als missing benoemt.

Hieronder staat dezelfde loop uitgeschreven met een deterministic stand-in judge, zodat je de vorm end-to-end kunt zien.

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)

De structure is belangrijk. In productie wordt verify_claim een NLI model of een LLM call. De rest van de harness blijft hetzelfde: extract, verify, aggregate.

End-to-end claim extraction + verification op generated SciFact answers: notebook 05; module: evaluation/faithfulness.py. De repo voert dezelfde loop uit via twee judge families — het eigen model van de generator en een cross-family judge (RAG_EVALS_JUDGE_MODEL) — plus een deterministic lexical baseline, zodat je kunt zien waar de families het oneens zijn.

Een purpose-built alternative voor LLM-as-judge is HHEM-2.1-Open (Hughes Hallucination Evaluation Model, Vectara), een classifier die is fine-tuned voor hallucination detection. De model card documenteert het checkpoint, de raw 0–1 score die het model produceert en balanced-accuracy-results op AggreFact en RAGTruth. Er is geen default decision boundary gepubliceerd, dus die keuze is aan jou. Beschouw dit als model-card evidence, niet als garantie op je corpus: kalibreer de threshold op lokale labels en vergelijk deze vóór deployment met je gekozen judge.

Atomic-fact evaluation

FActScore (Min et al., EMNLP 2023) splitst long-form generations op in atomic facts, haalt evidence op per fact, labelt elke supported / not-supported en rapporteert de supported fraction:

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

Reference implementation: shmsw25/FActScore. Dit werkt goed voor biographies, summaries en andere long-form outputs. Let op: repetitieve triviale facts kunnen de score opblazen en “MontageLie”-attacks (ware facts in misleidende volgorde) kunnen de metric omzeilen. VeriScore verwerkt claims met noodzakelijke modifiers; de Core-filter helpt fact-padding voorkomen.

Citation accuracy

Track citation precision (geciteerde spans ondersteunen de claim daadwerkelijk) en citation recall (claims die geciteerd zouden moeten worden, zijn geciteerd):

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}|}

De TREC 2024 RAG Track definieert een reproduceerbaar support evaluation-protocol. Thakur et al. (SIGIR 2025) rapporteren dat GPT-4o in 56% van de gevallen overeenkomt met human judges bij manual assessment from scratch, oplopend tot 72% met post-editing van LLM predictions. Dat is onder hun conditions nuttig als force multiplier, niet als vervanging van human assessment in high-stakes contexts. Voor een automated approximation implementeert ALCE (Gao et al., EMNLP 2023) citation precision/recall met NLI-based verification.

Answer correctness, completeness, refusal

  • Answer correctness versus ground truth: wanneer je die hebt, exact match of token-F1 voor short-answer tasks (evaluate.load("squad")), semantic similarity voor open-ended answers (bert-score, embedding cosine via sentence-transformers of RAGAS AnswerCorrectness).
  • Completeness via nuggets: een “nugget” is één atomic piece of information dat elk correct antwoord moet bevatten (bijvoorbeeld voor “Wanneer is het bedrijf opgericht?” kunnen de nuggets {year: 1994, founder: Jane Doe} zijn). TREC’s AutoNuggetizer extraheert de gold nuggets van een correct antwoord uit een reference en scoort vervolgens welk deel het systeem dekt — sterke correlatie met manual evaluation over 21 topics × 45 runs bij TREC 2024.
  • Refusal behavior: queries waarvoor geen answer in de corpus staat, moeten abstention produceren, geen hallucination. Track abstention precision (refusals die correct waren) en abstention recall (out-of-scope queries die refusal triggerden). NoMIRACL is de publieke benchmark; label in je eigen domain een slice van out-of-scope queries en track abstention accuracy.

Post-generation verification

De goedkoopste reliability gains komen vaak uit deterministic post-checks, niet uit grotere models.

  • Entity grounding check: elke named entity in het antwoord moet voorkomen in (of afleidbaar zijn uit) de retrieved context. Een eenvoudige regex + exact-match check (of ents van spaCy tegen een normalized context string) detecteert een verrassend groot deel van hallucinations.
  • Claim verification: extraheer claims, voer NLI uit tegen de context en fail of flag claims onder de threshold. NLI-as-faithfulness models: cross-encoder/nli-deberta-v3-large, MoritzLaurer/DeBERTa-v3-large-mnli-fever-anli-ling-wanli. Dit voegt latency toe. Voor high-stakes domains is dit de moeite waard.
  • Self-consistency (Wang et al., ICLR 2023): sample multiple generations bij temperature > 0; rapporteer agreement rate (bijvoorbeeld het aandeel generations dat overeenkomt met het modal answer, of pairwise BERTScore); kies het aantal samples op basis van de stability–cost curve en flag answers met lage agreement voor human review.
  • Confidence calibration: verzamel verbalized confidence (“How confident are you, 0–1?”) en vergelijk die met de werkelijke correctness op de eval set. Plot een calibration curve en rapporteer 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)|, waarbij BmB_m confidence bins zijn. Implementations: netcal, torchmetrics.CalibrationError. Een model dat 0.9 confidence rapporteert, zou in ongeveer 90% van vergelijkbare gevallen correct moeten zijn; meet de gap in plaats van calibration aan te nemen.

Part 7: Ontology-grounded RAG evaluation

De standaardmetrics hierboven dekken open-corpus RAG. Als je RAG retrieval uitvoert tegen een structured ontology, taxonomy of knowledge graph, zijn die metrics noodzakelijk maar niet voldoende. Voorbeelden zijn products in een catalog, conditions in SNOMED, components in een BOM en security techniques in MITRE ATT&CK. Je moet ook de ontology layer meten.

Entity linking accuracy

De eerste task is een query mention mappen naar een ontology entity (“Aspirin” → wikidata:Q18216, “the 737” → aircraft:Boeing_737).

  • Mention-level precision/recall/F1: standaard, tegen gold mention spans (bereken met seqeval of een span-set comparator).
  • Disambiguation accuracy: welk deel van de correct gedetecteerde mentions wordt naar het juiste entity ID gemapt? Publieke references zijn onder meer ReFinED, REL en GENRE; benchmarks zoals AIDA-CoNLL en BELB laten zien dat resultaten per systeem en domain verschillen.
  • NIL handling: precision/recall op “entity not in ontology.” Meet over-linking naar near-but-wrong entities afzonderlijk van correcte abstention.

Hierarchy-aware evaluation

Plain accuracy behandelt “Sedan voorspellen terwijl de waarheid Hatchback is” hetzelfde als “Sedan voorspellen terwijl de waarheid Submarine is.” Die errors zijn niet gelijk.

  • Hierarchical precision/recall/F1 (Kosmopoulos et al., 2015): geef credit voor ancestors en descendants in de ontology DAG. Met P^q\hat{P}_q de predicted node plus al diens ancestors en TqT_q de true node plus al diens 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}

    Implementeer dit met networkx op de ontology graph: verrijk elke prediction en elk label met zijn ancestors en bereken vervolgens de bovenstaande set overlaps.

  • Wu-Palmer similarity tussen predicted en gold entity in de 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)}

    waarbij LCA de lowest common ancestor in de taxonomy is. Beschikbaar out of the box in NLTK voor WordNet (from nltk.corpus import wordnet as wn; wn.synset("car.n.01").wup_similarity(wn.synset("truck.n.01"))); bereken voor custom taxonomies LCA met networkx.

  • Sibling/parent confusion rate: track confusions naar siblings, parents en children afzonderlijk — count_sibling / total_errors, count_parent / total_errors, count_descendant / total_errors. Gebruik reviewed examples om te testen of sibling errors afkomstig zijn van ambiguous mentions of parent errors van over-generalization.

Filter false-exclusion rate (opnieuw, nu cruciaal)

In ontology-grounded systemen komen hard filters vaak uit de ontology zelf (“retrieve alleen documents met category X”). De exclusion-rate metric (gedefinieerd in Part 5) wordt een primaire correctness signal. Een verkeerde category prediction kan recall tot nul reduceren; de exclusion rate schrijft dat verlies aan de filter toe.

Constrained generation conformance

Wanneer je output aan een ontology moet voldoen (elke entity name in het antwoord moet een geldig ontology member zijn; elke predicate moet uit een closed vocabulary komen), meet je:

  • Schema validity rate: percentage outputs dat parseert en valideert tegen het ontology schema. Valideer met jsonschema of pydantic. JSONSchemaBench is de publieke benchmark voor general structured output; bouw voor ontology-specific schemas je eigen validator.
  • Vocabulary conformance: percentage named entities in de output dat geldige ontology IDs zijn — een one-line set-membership check tegen de closed vocabulary.
  • Semantic conformance: een syntactisch geldige output kan nog steeds de verkeerde maar geldige entity kiezen. Combineer conformance met downstream answer correctness.

Constrained decoding frameworks (Outlines, XGrammar, Guidance, OpenAI Structured Outputs) zijn ontworpen om schema validity af te dwingen. JSONSchemaBench vergelijkt efficiency, coverage en quality over implementations. Voer de cases opnieuw uit die bij je schemas en serving backend passen, omdat coverage en latency van beide afhangen.

Auditability

Voor ontology-grounded systemen waarvan answers worden gereviewd:

  • Citation completeness: percentage factual claims met ten minste één verifieerbare citation.
  • Provenance depth: percentage citations dat volledig terugverwijst naar een source document met een stable ID, niet alleen naar een chunk hash.
  • Reproducibility rate: dezelfde query opnieuw uitvoeren op een vaste snapshot retourneert hetzelfde antwoord. Pin de model version, runtime, decoding configuration en seed en stel de vereiste repeat rate vast op basis van de auditability needs van de workflow. Temperature zero alleen garandeert geen determinism. Een miss kan afkomstig zijn van generation, de serving runtime of elke upstream stage.

Part 8: System-level evaluation

Holistic answer quality

  • LLM-as-judge (Zheng et al., NeurIPS 2023): een schaalbare model-based evaluation approach. G-Eval (Liu et al., EMNLP 2023) leidt een rubric af uit een natural-language criterion. Daarna wordt gescoord met log-prob-weighted output. Agreement hangt af van de judge, task, prompt en calibration set.
  • Pairwise preference: presenteer de judge answer A versus answer B en registreer de preference. Dit vermijdt calibration issues bij absolute scores. MT-Bench rapporteerde onder zijn benchmark conditions GPT-4-judge agreement van meer dan 80% met zowel human preferences als human–human agreement; draag dit percentage niet over naar een ander domain zonder calibration.

LLM-as-judge heeft echte biases:

  • Position bias: judges prefereren het eerste of tweede antwoord ongeacht quality. Mitigation: randomize order of voer beide orders uit en average.
  • Verbosity bias: judges kunnen length verwarren met quality. Een 2026 controlled study vond heterogeneous expansion-pair behavior. Drie judges verkozen langere answers, Claude verkoos concise answers en GPT-4o was ongeveer neutral. Alle vijf presteerden goed op truncation controls. Deze resultaten zijn benchmark-bound; geef je judge daarom aan hoe completeness en filler moeten worden behandeld en rapporteer length-controlled performance op je eigen rubric.
  • Self-preference bias: GPT-4 prefereert GPT-4 outputs; de bias correleert met output perplexity (judges prefereren text die voor hen familiar is). Mitigation: gebruik een andere judge family dan het systeem dat wordt geëvalueerd. Gebruik een model niet om zichzelf te beoordelen.

Practical recipe: selecteer een judge op human-labeled calibration data, randomize answer order, mask model identities en vermeld het length policy in de rubric. Herhaal cases alleen wanneer de extra samples uncertainty materieel reduceren. Vergelijk voor high-stakes evaluations judges uit verschillende model families en analyseer disagreements tegen human labels.

Schema-Guided Reasoning voor judges

Free-form output is één bron van variatie in judge runs. Twee runs op hetzelfde answer kunnen de rubric anders organiseren en verschillende scores produceren. Schema-Guided Reasoning (SGR) maakt die rubric expliciet: definieer de evaluation stages als een Pydantic schema en gebruik vervolgens constrained output via Outlines, XGrammar, vLLM structured outputs of OpenAI response_format, zodat elke run dezelfde fields in dezelfde volgorde retourneert.

Voor RAG eval splitst het schema de judgment op in expliciete, auditable fields in plaats van het model direct naar een getal te laten springen:

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

De structured fields maken de score reconstrueerbaar als len(supported) / len(extracted) en tonen precies over welke claims twee judges het oneens waren. Het Pydantic model maakt een rubric change bovendien zichtbaar als code diff. Constrained output garandeert de shape, niet een unbiased verdict; position randomization, cross-family judges en human calibration blijven dus nodig.

Dit werkt voor elke rubric-based judge, niet alleen voor faithfulness. Pairwise preference, citation support en refusal correctness profiteren allemaal van dezelfde aanpak.

Een G-Eval / pairwise / position-bias / cross-family judge harness staat in notebook 07; module: evaluation/llm_judge.py. De benchmark sweep (make benchmark in de repo) koppelt drie models (gpt-5-mini, claude-haiku-4-5, gemini-2.5-flash) aan een rotating-judge pairwise A/B, zodat elk model de andere twee judges en self-preference als getal zichtbaar wordt.

Latency en cost

  • p50, p95, p99 op elke pipeline stage. Kies de SLO percentile en alert threshold op basis van de user journey, traffic volume en error budget.
  • Time-to-first-token versus totale generation time. Users geven voor streaming UX om TTFT.
  • Stage breakdown: retrieval, reranking, generation, post-processing. Gebruik de trace om de tail te lokaliseren in plaats van aan te nemen welke stage de oorzaak is; leg reranker device en batch size vast bij het vergelijken van runs.
  • Totale $/query = embedding + retrieval + rerank + generation + storage amortized. Track p50 en p99; de long tail verbruikt het budget.
  • Cache hit rates op embedding cache-, retrieval cache- en KV-cache-niveau. Stel separate targets vast op basis van observed repetition, invalidation policy en de cost avoided op elke layer.

Per-stage p50/p95/p99 met een stage breakdown is ingebouwd in notebook 08 en de runner op evaluation/latency.py; het benchmark report combineert latency met faithfulness in één matrix die je opnieuw kunt uitvoeren met make benchmark.

A/B-testing

  • Unit of randomization: kies de unit op basis van estimand, carryover en interference. Gebruik per-user of per-session assignment wanneer repeated exposure behavior kan veranderen of inconsistente UX kan creëren. Per-query assignment is alleen verdedigbaar wanneer die effecten verwaarloosbaar zijn en de analysis repeated observations modelleert.
  • Primary, guardrails, exploratory metrics: preregistreer ze. Kies de primary measure op basis van de product outcome; satisfaction proxies zijn onder meer thumbs, regenerations en dwell. Behandel latency en cost als guardrails wanneer deze de experience begrenzen.
  • Sample size: voer vóór launch een power analysis uit op basis van het minimum effect worth detecting, baseline variance, assignment unit en stopping rule.

Part 9: Test set construction

Een metric is slechts zo goed als de test set waarop deze draait. Als je golden set drie intents afdekt en production traffic twaalf intents bevat, meet Recall@10 alleen die drie intents. Erger nog: een test set die overfit op eenvoudige vragen (“Wat is het refundbeleid van het bedrijf?”) kan een systeem goedkeuren dat faalt op moeilijke vragen (“Wat zijn de voorwaarden voor refund bij een gedeeltelijke cancellation onder de EU Digital Services Act van 2023, gefactureerd in EUR en afkomstig uit Ierland?”). De aggregate score stijgt, terwijl het systeem nog steeds op een belangrijk deel van production traffic faalt.

Hetzelfde probleem geldt voor ground truth. Als SMEs de obvious docs labelden maar de relevante long tail misten, zal Recall@k een retriever onderwaarderen die ze wel degelijk heeft gevonden. Je optimaliseert voor de labels, niet voor de waarheid.

Bouw de test set eerst rond de echte query distribution en difficulty. Kies daarna metrics die reageren op de bedoelde failure modes en tune het systeem daarop.

Synthetic query generation

Gebruik een LLM om questions uit je corpus te genereren:

  • Per-chunk: “Generate 3 questions a user might ask that this chunk answers.”
  • Multi-hop: sample twee chunks en genereer een question waarvoor beide nodig zijn.
  • Adversarial: genereer questions met distractor entities, near-duplicate phrasing en ambiguous mentions.

RAGAS heeft een ingebouwde question-type distribution (reasoning, conditional, multi-context). DataMorgana genereert configureerbare synthetic benchmarks over user- en question categories. Synthetic data is nuttig voor cold starts en coverage testing. Het kan real user queries niet vervangen.

Golden dataset construction

Human-curated data verankert de golden set.

  1. Sample echte user queries (of simulated queries als je nog vóór launch bent), gestratificeerd op intent.
  2. Laat SMEs elke question beantwoorden en identificeren welke doc(s) het antwoord bevatten.
  3. Bepaal de omvang op basis van de coverage matrix en het confidence interval dat nodig is voor release decisions; coverage is belangrijker dan een geleend aantal queries.
  4. Re-curate wanneer release cadence, drift signals, domain risk en annotation capacity dat rechtvaardigen.

Adversarial test sets

  • Counterfactuals: verwissel key entities in de query. Haalt het systeem voor de gewijzigde query de juiste chunks op?
  • Distractors: queries waarbij de corpus een plausible-but-wrong answer bevat die niet retrieved mag worden. Dit is wat RGB (Chen et al., AAAI 2024) stress-test: noise robustness, negative rejection, information integration en counterfactual robustness.
  • Negation en quantifiers: queries met “not”, “except” en “only”. Dense retrievers hebben hier vaak moeite mee.
  • Out-of-scope: queries zonder answer in de corpus. Het systeem moet “I don’t know” zeggen en niet hallucineren. NoMIRACL hoort hier thuis. Evalueer abstention expliciet op je production query types.

Coverage en continuous evaluation

  • Bouw een coverage matrix: query intent × document type × ontology branch. Streef naar ≥1 query per cell. Lege cells zijn niet-gemonitorde regions waar regressions verborgen blijven.
  • Draai een bounded, fast regression subset op elke PR en de volledige suite volgens een trager schedule.
  • Plan de volledige golden-set eval op basis van release cadence en evaluation cost; release candidates zijn een natuurlijke gate.
  • Plan drift evaluation op basis van traffic volume, verwachte change en risk. Gebruik een rolling production sample en stratify op feedback in plaats van de target distribution stilzwijgend te veranderen.

Part 10: Production monitoring

De eval suite die je shipt beschrijft het systeem bij launch. Production traffic verandert daarna.

Impliciete en expliciete feedback

  • Click-through / open rate op cited sources (als je UI die toont).
  • Dwell time op het answer.
  • Regeneration rate: percentage answers dat de user opnieuw vraagt of laat redo’en. Behandel dit als één dissatisfaction signal en kalibreer het tegen reviewed conversations.
  • Copy / share / export rates — sterke positive signals.
  • Follow-up patterns: patronen zoals “Are you sure?” of “But what about X?” wijzen op distrust.
  • Thumbs up/down met optionele reason categories (wrong, incomplete, off-topic, harmful, slow). Inline edits dragen, wanneer je UI die toestaat, doorgaans de meeste information van alle feedback signals.

Drift detection

  • Query drift: track query embedding distribution tegenover een reference window met KL divergence, MMD of een model-based detector. Alert bij shift en debug daarna per segment.
  • Embedding drift: pin een probe set van fixed documents; embed periodiek opnieuw en meet cosine tegenover de original embeddings. Zelfs kleine drift tussen provider model versions kan retrieval stilzwijgend breken. Versioned embedding storage (immutable per-version snapshots) is de goedkoopste mitigation.
  • Performance drift: track production-equivalent metrics (regeneration rate per intent) door de tijd. Plotselinge jumps betekenen dat iets stuk is; langzame drifts betekenen dat de wereld is veranderd.

Shadow evaluation en human-in-the-loop

Draai het candidate system parallel aan production, vergelijk outputs offline en serve ze niet aan users. Dit detecteert regressions vóór launch. Het kost extra inference, maar heeft geen customer impact.

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

  • Sample low-confidence outputs naar een review queue.
  • Voeg een random sample van production traffic toe voor blind review; stel de rate vast op basis van traffic volume, risk en reviewer capacity.
  • Weeg thumbs-down outputs zwaar.
  • Gebruik reviewed outputs om de golden set uit te breiden.

De minimale guardrail set

Alerteer op deze signals, in deze priority order:

  1. Faithfulness/HHEM score onder threshold op een rolling production sample.
  2. p95 latency boven de SLO.
  3. Filter false-exclusion rate boven threshold (sample-based).
  4. Regeneration rate buiten een lokaal gekalibreerde control band die rekening houdt met window size, traffic, seasonality en false-alert budget.
  5. Cost/query boven budget.

Als een alert afgaat zonder overeenkomstige code- of model change, heb je waarschijnlijk met drift te maken. Als de alert na een change afgaat, heb je waarschijnlijk een regression. In beide gevallen krijg je een signal voordat support tickets binnenkomen.


Caveats

  • Targets zijn lokaal, niet universeel. Elk getal dat in deze guide als illustratief wordt aangeduid, is een voorbeeldconfiguration of worked result, geen release threshold. Kalibreer thresholds op je domain, stakes, evaluation-set uncertainty en user expectations.
  • De framework space verandert snel. HHEM versions, RAGAS metric names, model cards en leaderboard order kunnen na publicatie veranderen. Controleer de gelinkte source en benchmark opnieuw voordat je je commit.
  • LLM-as-judge agreement numbers hebben voetnoten. Het cijfer van 80% GPT-4-versus-human komt uit MT-Bench / Chatbot Arena conditions. Op niche domains en adversarial cases daalt agreement sterk. Gebruik judges als force multiplier, niet als vervanging voor spot-checking.
  • Vendor benchmark uplifts zijn vaak niet onafhankelijk reproduceerbaar. Reproduceer op je eigen data voordat je een getal gelooft, vooral voor nieuwere rerankers en OCR-systemen.
  • Geen enkele metric vervangt het bekijken van outputs. Plan blind review van een random production sample op basis van traffic, risk en reviewer capacity. De metrics schalen die gewoonte; ze vervangen haar niet.

Coming up in deze series

Dit was de index. De follow-ups die ik plan:

  • Soft Boosts versus Hard Filters: een deep dive over filter false-exclusion rate, met code, echte production examples en een decision framework.
  • Chunking Is the Hidden Variable: een controlled experiment over recursive, semantic, late en structural chunking op drie corpora.
  • Reranker Selection in 2026: BGE versus Cohere versus ZeRank versus actuele cross-encoder models, head-to-head op cost, latency en uplift.
  • Ontology-Grounded RAG: An End-to-End Walkthrough: de volledige evaluation harness bouwen voor een entity-grounded retrieval system.
  • LLM-as-Judge Without the Self-Preference Trap: praktische recipes voor unbiased automated evaluation.
  • Online Evaluation in Production: instrumentation patterns, alerting policies en de dashboards die echte regressions detecteren.

References

Frameworks en benchmarks

Retrieval en ranking

Generation, faithfulness en judges

Drift en productie

Companion code

  • slavadubrov/rag-evals-demo — uitvoerbare harness voor elke metric in dit article op de SciFact corpus, plus een chunking × embedding × LLM benchmark sweep. Notebooks 00–09, unit tests die de uitgewerkte examples hierboven pinnen en een embedded-Qdrant index, zodat de code zonder Docker draait.