Automatische vertaling
Dit artikel is automatisch vertaald vanuit de oorspronkelijke Engelse versie.
RAG evalueren: metrics voor elke fase van een production RAG-systeem
Deel 1 van de Production RAG-serie
Een RAG-systeem met kapotte filters kan maanden draaien voordat iemand het merkt. De pipeline retourneert antwoorden, de latency-dashboards blijven groen, en het enige teken dat er iets mis is, is dat de antwoorden zelf subtiel fout zijn. "Subtiel fout" triggert geen pager.
Betere logs gaan dit niet vangen. Evaluatie wel, maar alleen als die elke fase van de pipeline afdekt met een eigen metric. Dit artikel is de referentie die ik graag had gehad toen ik probeerde uit te zoeken welke metrics echt tellen.
Wil je direct vooruit springen en code draaien?
Ik heb de metrics uit dit artikel verpakt in een uitvoerbare companion repo: slavadubrov/rag-evals-demo. make eval draait de volledige suite — retrieval metrics, hybrid + RRF, reranker uplift, filter false-exclusion, faithfulness, lost-in-the-middle, LLM-as-judge met bias-mitigatie, latency — op het SciFact-corpus. make benchmark swept chunking × embedding × LLM en schrijft een markdown-rapport. Notebooks 00–09 lopen elke metric afzonderlijk door; zelfde vocabulaire als in dit artikel, echte cijfers, geen Docker (embedded Qdrant).
TL;DR
- Evaluatie definieert het systeem. Een fase zonder metric is een fase die stil faalt.
- Een bruikbare evaluatiestack dekt ingestie, retrieval, grounding van generatie, ontology-conformance en systemsignalen. RAGAS, TruLens, DeepEval, Arize Phoenix en de TREC 2024 RAG Track geven je tooling. Ze kiezen je metrics niet voor je.
- Voor metadata- en ontology-grounded RAG is de meest voorkomende failure de stille filter. Een fout tag of een fragiele harde predicate laat recall naar nul instorten. Faithfulness kan er dan nog steeds goed uitzien omdat het model trouw zei: "I don't know."
De vervolgartikelen gaan diep in op afzonderlijke secties. Gebruik deze als index.
Deel 1 — Waarom evaluatie eerst komt
Het senior-signaal
Bij een RAG-project zou het architectuurdiagram niet het eerste artefact moeten zijn. De eval set wel.
Je kunt niet kiezen tussen BM25 en dense retrieval, recursive en semantic chunking, of Cohere Rerank en BGE totdat je weet wat je optimaliseert. "Betere antwoorden" is geen metric. "Faithfulness ≥ 0.85 op een golden set van 200 queries die onze top drie intents afdekt, met p95-latency < 1.5s en filter false-exclusion rate < 2%" is een metric.
Definieer de harness voordat je de retrieval-code schrijft. De eerste harness zal fout zijn, en je zult hem herzien. Een metric herzien is veel goedkoper dan een systeem herzien dat je al hebt uitgerold.
Drie lagen, niet één getal
Moderne RAG is een pipeline, dus evaluatie moet ook een pipeline zijn. Geen enkel getal vangt elke failure mode.
Production-evaluatie heeft drie lagen: offline (is de knowledge base correct voorbereid?), online (is het juiste bewijs voor deze query gevonden en gebruikt?), en post-generation (is het antwoord faithful en verifieerbaar?). Elke laag stelt een andere vraag. Gooi ze samen in één score en je kunt basisfouten missen, zoals een normalisatiebug die recall sloopt.
Diezelfde opsplitsing maakt ook online versus offline evaluatie duidelijk. Offline draait tegen een vaste dataset met bekende ground truth. Het is reproduceerbaar, goedkoop om op te itereren, en de juiste plek voor componentselectie, A/B-vergelijkingen en CI-gates. Online draait tegen live traffic. Het vangt signalen die je offline niet kunt faken: regeneration rate, dwell time, thumbs en echte query drift. Het is noisy en moeilijker goed te instrumenteren.
Je hebt beide nodig. Alleen offline mist live drift. Alleen online maakt regressies moeilijk reproduceerbaar. Beide doen is meer werk, maar het is de enige setup die je bruikbare feedback geeft voor én na launch.
Op componentniveau versus end-to-end
Er zijn twee veelgemaakte fouten. Alleen end-to-end evalueren vertelt je dat het systeem stuk is, maar niet waar. Alleen componenten evalueren kan laten zien dat elk onderdeel slaagt terwijl het volledige systeem nog steeds faalt. De oplossing is een paar headline end-to-end metrics voor go/no-go-beslissingen, plus componentmetrics voor diagnose. Retrieval metrics vangen retriever-regressies. Generation metrics vangen generator-regressies. End-to-end correctness van antwoorden vangt integratiefouten.
De referentiekaders (opiniërende tour)
| Framework | Beste in | Waar het tekortschiet |
|---|---|---|
| RAGAS | Reference-free RAG-metrics (faithfulness, answer relevancy, context precision/recall); de facto vocabulaire | LLM-judge-kosten; ondoorzichtige scorecomponenten bij debuggen; Engels-centrische defaults |
| ARES | Getrainde classifier judges per pipeline; minder annotaties dan RAGAS-achtige benaderingen; hoge precisie voor vergelijkbare systemen | Zwaardere setup; je moet daadwerkelijk modellen trainen |
| TruLens | Composable feedback functions met sterke explainability; OpenTelemetry traces; production-vriendelijk | Minder batteries-included voor RAG-specifieke metrics dan RAGAS |
| DeepEval | Pytest-achtige unit tests voor LLM-outputs; G-Eval, custom metrics, CI/CD-native | Zwaar LLM-judge-gebruik = kostenpieken |
| Arize Phoenix | Sterke tracing en embedding-visualisatie; spot embedding drift visueel; OTEL-native | Je moet je eigen metric-definities meebrengen |
| TREC 2024 RAG Track | Publieke benchmark voor nugget-evaluatie (AutoNuggetizer), support-evaluatie en fluency op MS MARCO Segment v2.1 | Geen runtime-tool; een benchmark om tegen te kalibreren |
Mijn default stack is RAGAS voor het metric-vocabulaire, DeepEval voor CI-gates, Phoenix voor production tracing, plus custom code voor ontology-specifieke metrics. Je groeit uiteindelijk uit wat je ook kiest om mee te beginnen. Kies het framework dat custom metrics makkelijk maakt.
Voor benchmarks gebruik je BEIR (Thakur et al., NeurIPS 2021) voor zero-shot retrievalgeneralisatie, MTEB voor algemene embeddingkwaliteit, MIRACL voor meertalige retrieval, en de TREC 2024 RAG Track voor end-to-end RAG-evaluatie.
Deel 2 — De pipeline met evaluatiepunten
Een production RAG-systeem is groter dan "embed documents, retrieve chunks, call an LLM." Elke fase tussen documentacquisitie en answer delivery kan falen.
Elke fase in het diagram heeft minstens één metric. Een fase zonder metric kan falen zonder dat iemand het merkt.
De drie lanes komen overeen met waar failures optreden. De offline lane dekt alles voordat er een query bestaat: parsing, cleaning, chunking, embedding, indexing. De online lane dekt alles nadat een query binnenkomt: rewriting, retrieval, reranking, context assembly. De post-generation lane dekt controles nadat het model een antwoord heeft geschreven: faithfulness, citation verification, drift-signalen en production telemetry.
Fouten stapelen zich op door de keten heen. Slechte parsing beperkt chunking. Slechte chunking beperkt retrieval. Slechte retrieval beperkt reranking. Slechte reranking beperkt generatie. Faithfulness meet alleen het eindantwoord, nooit de bovenstroomse oorzaak.
Deel 3 — Evaluatie van offline-ingestie
Veel production RAG-failures beginnen bij ingestie. Het systeem werkt op schone testdocumenten, en faalt dan op echte PDF's, scans, tabellen en rommelige corpuspagina's.
Documentacquisitie en parsing
Wat je moet meten:
- Volledigheid van tekstextractie:
extracted_chars / expected_charsop een gelabelde sample, berekend per documentklasse. Er is geen canoniek package — schrijf een kleine harness die parser-output vergelijkt met een handmatig opgeschoonde referentie. Let op ontbrekende voetnoten, headers en captions. -
OCR-accuratesse: CER (Character Error Rate) en WER (Word Error Rate), de standaardmetrics uit speech/OCR:
\[ \text{CER} = \frac{S + D + I}{N}, \qquad \text{WER} = \frac{S_w + D_w + I_w}{N_w} \]waarbij \(S\), \(D\), \(I\) character-level substitutions, deletions en insertions zijn en \(N\) het aantal referentie-characters is (subscript \(w\) voor de woordversie). CER 1–2% is goed voor geprinte tekst; >10% is onbruikbaar. Voor handgeschreven of meertalig materiaal kan ≤20% nog werkbaar zijn. Bereken met
jiwer(jiwer.cer(refs, hyps),jiwer.wer(refs, hyps)) of HuggingFaceevaluate. Voor evaluatiecorpora zijn FUNSD en SROIE de 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.077 print(f"WER = {wer(refs, hyps):.3f}") # WER = 0.286 -
Fidelity van tabelextractie: TEDS (Tree-Edit-Distance-based Similarity) meet hoe dicht een voorspelde HTML-tabelboom bij de referentie ligt, genormaliseerd door de grootte van de grootste boom. Uit Zhong et al., 2020 (PubTabNet):
\[ \text{TEDS}(T_a, T_b) = 1 - \frac{\text{EditDist}(T_a, T_b)}{\max(|T_a|, |T_b|)} \]TEDS gebruikt zowel structuur (rijen, kolommen, spans) als celinhoud; TEDS-S verwijdert de inhoud en scoort alleen structuur. Referentie-implementatie: PubTabNet's
teds.py(gebruiktaptedonder de motorkap). Voor evaluatiecorpora, zie PubTabNet, FinTabNet en SciTSR. Naive parsers falen vaak op tabellen; benchmark voordat je ze vertrouwt. -
Behoud van layout / structuur: heading-volgorde, list-integriteit, reading order in meerkoloms-PDF's. Gebruik DocLayNet voor een gelabelde benchmark; voor een off-the-shelf parservergelijking dekken
unstructured,pymupdfen een VLM-parser zoalsdoclinghet grootste deel van de design space.
Mijn take: benchmark drie parsers (een Tesseract-baseline, een VLM-OCR-model en je vendor-kandidaat) op een gestratificeerde sample van echte documentklassen (schone scans, foto's, pagina's met veel tabellen, meertalig, wiskunde, handschrift) bij vaste DPI. Rapporteer CER/WER per klasse plus TEDS voor tabelpagina's. Zonder dat ben je aan het gokken.
Cleaning en normalisatie
- Nauwkeurigheid van boilerplate removal: precision/recall tegen door mensen gelabelde boilerplate spans. Agressieve verwijdering vernietigt relevante content; luie verwijdering vervuilt embeddings. Tools om te vergelijken:
trafilatura,jusText,Resiliparse. Barbaresi (2021) benchmarkt deze direct tegen elkaar. - Unicode-normalisatie: percentage documenten dat identieke NFC- en NFKC-outputs produceert (berekend met de stdlib
unicodedata.normalize) is een nuttig driftsignaal. Mismatches zijn hoe zero-width joiners en lookalike characters retrieval recall slopen. - Nauwkeurigheid van taaldetectie: F1 op een gelabelde meertalige sample. Kritisch voor meertalige indexes. Gebruik
fasttext-langdetect(Facebook'slid.176),lingua-pyofcld3; FLORES-200 is de standaardbenchmark voor low-resource-talen. -
Effectiviteit van deduplicatie (MinHash / LSH): precision/recall van je near-duplicate detector tegen een handmatig gelabelde set. Het onderliggende idee: schat Jaccard-similarity \(J(A, B) = \frac{|A \cap B|}{|A \cup B|}\) tussen document-shingle-sets via \(k\) random permutation hashes (Broder, 1997) en bucket near-duplicates met LSH-banding (Indyk & Motwani, 1998). Standaard MinHash met 128 hash functions en LSH-banding afgestemd op een Jaccard-threshold van 0.7–0.85 is de default; benchmark op je eigen data omdat de juiste threshold corpus-specifiek is. Track false-merge rate (corrumpeert antwoorden) apart van missed-merge rate (verspilt indexruimte).
datasketchis het canonieke Python-package: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(lsh.query(to_minhash(docs["d1"]))) # ['d1', 'd2'] -
PII-scrubbing: precision en recall, apart berekend per entity type (emails, SSN's, namen, adressen). Recall-fouten creëren compliance-risico; precision-fouten schaden answer quality. Stel het operating point af met het legal team. Tools: Microsoft Presidio (de meest complete),
scrubadub, of een fine-tuned NER-model op een gelabelde set.
Chunking — de fase die retrieval stilletjes bepaalt
Chunking is een van de beslissingen met de hoogste impact in RAG. De verkeerde strategie kan met dezelfde embeddings een recall-gap van meerdere punten opleveren. NVIDIA's benchmarks uit 2024 gaven page-level chunking de hoogste accuratesse met de laagste variantie voor gepagineerde documenten; semantic chunking (aangrenzende zinnen clusteren op embedding-similarity en knippen op niet-gelijkende grenzen — geïmplementeerd in LangChain's SemanticChunker en LlamaIndex's SemanticSplitterNodeParser) kan recall verbeteren ten opzichte van fixed-window chunking; recursive character splitting (eerst proberen op paragraafgrenzen, dan zinsgrenzen, dan woordgrenzen, totdat elke chunk in de doelgrootte past — zie LangChain's RecursiveCharacterTextSplitter) op 400–512 tokens met 10–20% overlap blijft een goede default voor algemene tekst.
Metrics om te tracken:
- Chunk coherence: \(\\text{coherence} = \\overline{\\cos(s_i, s_j)}_{\\text{within}} - \\overline{\\cos(s_i, s_j)}_{\\text{across boundary}}\), waarbij \(s_i\) sentence embeddings zijn. Gezonde chunks zijn intern vergelijkbaar en op de grens niet-gelijkend. Bereken met
sentence-transformersplusscikit-learn'scosine_similarity. - Boundary quality: door mensen gelabelde "is dit een zinnige cut?" op een sample, plus een structurele check dat chunks geen tabellen, lists of genummerde secties splitsen (je meest voorkomende production bug).
- Optimale chunk size: sweep token-groottes (128, 256, 512, 1024) en plot Recall@k versus grootte op je golden set. Kies de knee. Kies niet wat de tutorial zei.
- Effectiviteit van overlap: ablate overlap-fractie (0%, 10%, 20%, 30%) en meet Recall@k. Afnemende meeropbrengst voorbij ~20% in de meeste corpora.
- Fidelity van chunk-attributie: percentage chunks dat een verifieerbare source pointer behoudt (paginanummer, sectie-anchor, doc ID). Auditability vereist dit.
- Late versus early chunking: late chunking (Günther et al., 2024) embed het volledige document en segmenteert daarna, waardoor globale context behouden blijft (referentie-implementatie in
jina-embeddings-v3). Contextual Retrieval (Anthropic, 2024) prepend LLM-generated context aan elke chunk. Beide voegen kosten toe. Benchmark op je corpus voordat je een van beide adopteert.
Mijn mening: structural chunking (splitsen op headings, tabellen en secties — geïmplementeerd door parsers zoals unstructured.io of door de AST te doorlopen die je parser al produceerde) wordt te weinig gebruikt. Als je documenten structuur hebben, gebruik die dan voordat je similarity-heuristieken toevoegt. Recursive character splitting is de baseline; semantic chunking is de overhead vooral waard op ongestructureerde prose.
Metadata-extractie en enrichment
- NER precision/recall/F1: per entity type, op een gelabelde subset. Standaard CoNLL/MUC-stijl. Bereken met
seqeval(from seqeval.metrics import f1_score) voor de BIO/IOB-tag-aware-versie, of scikit-learn voor span-set-vergelijkingen. CoNLL-2003 en OntoNotes 5.0 zijn de canonieke referentiecorpora. - Relation extraction F1: nog belangrijker voor ontology-grounded systemen. Label 200 documenten met de hand. TACRED en DocRED zijn de publieke benchmarks; voor production-code zijn
opennreenspaCyrelation-pipelines redelijke startpunten. - Nauwkeurigheid van title / heading extraction: exact-match plus genormaliseerde Levenshtein-similarity (\(1 - \\frac{\\text{edit\\_dist}(a, b)}{\\max(|a|, |b|)}\)) tegen ground truth —
python-Levenshteinofrapidfuzzgeven je beide in één call. - Behoud van hiërarchische metadata: percentage chunks dat correct hun parent section, parent document en ancestry path behoudt. Dit is de metric die bepaalt of je RAG vragen kan beantwoorden van het type "wat zegt het child van policy X?"
Embedding-generatie
- Benchmarks voor modelselectie: MTEB voor algemene capability (nDCG@10 is de headline; met het MTEB Python package kun je de leaderboard lokaal reproduceren), BEIR voor zero-shot generalisatie, MIRACL voor meertaligheid. Top-retrievalmodellen clusteren in een smalle nDCG@10-band, maar Engelse MTEB-scores voorspellen prestaties op low-resource-talen slecht.
- Domeinspecifieke evaluatie: vertrouw algemene benchmarks niet voor domeincorpora. Bouw een domein-golden-set van 200–500 query/doc-paren en rerank kandidaatmodellen daarop met
ranxofpytrec_eval. Ik heb herhaaldelijk gezien dat een model dat #5 staat op MTEB een model dat #1 staat met 15+ punten verslaat op een specifiek domein. - Detectie van embedding drift: track distributionele KL of model-based drift tussen een vaste reference window en rollende production embeddings; nearest-neighbor stability voor een vaste probe set is het simpelste praktische signaal.
evidentlyenalibi-detectimplementeren beide model-based en statistische drift-detectors. Evidently's comparative study geeft de voorkeur aan model-based drift detection als default. - Multi-vector versus single-vector: late-interaction (ColBERT / ColBERTv2 — zie Khattab & Zaharia, 2020; referentie-implementaties in RAGatouille en PyLate) wint doorgaans out-of-domain, tegen 6–10× de opslagkosten (met PLAID-achtige compressie; ongecomprimeerd is het veel groter). Het waard als je corpus ver afligt van de trainingsdistributie van het embeddingmodel. Anders: blijf bij single-vector.
Indexconstructie
- Recall@k onder approximatie: vergelijk de approximate-nearest-neighbour (ANN)-index met een exacte brute-force-baseline bij dezelfde k — in FAISS is dat
IndexHNSWFlat(ofIndexIVFFlat) versusIndexFlatIP/IndexFlatL2. Mik op ≥95% recall@10 versus flat. Het projectann-benchmarkstrackt recall–QPS-Pareto-curves over libraries heen. - HNSW-tuning: HNSW (Hierarchical Navigable Small World — een gelaagde proximity graph; zie Malkov & Yashunin, 2018, geïmplementeerd in
hnswlib, FAISS'sIndexHNSWFlat, en de meeste vector DB's) heeft drie knobs:M(graph fan-out),efConstruction(candidate width tijdens build),efSearch(candidate width tijdens query). Pragmatische defaults: M=16–32, efConstruction=150, efSearch beginnend op 100 en omhoog tunen totdat recall plateaut. Een dataset met 10M vectors en efSearch=500 haalt misschien 98% recall op 5ms; efSearch=100 zakt naar 85% op 1ms. Kies het recall-punt dat je evaluatieset vereist. - IVF-tuning: IVF (Inverted File index — partitioneer vectors met k-means in
nlistcells, en scan bij query-tijd denprobedichtstbijzijnde cells; zie FAISS'sIndexIVFFlatenIndexIVFPQ). Gebruiknlist≈ √N als startheuristiek en tunenprobetijdens runtime. IVF handelt gefilterde zoekopdrachten over het algemeen efficiënter af dan HNSW, wat telt voor ontology-grounded systemen met veel metadata-predicates. - Update freshness lag: tijd van doc commit tot retrievability. Track p50 en p99. Voor systemen met regulatory requirements, track ook het percentage queries dat tegen stale indexes wordt geserveerd.
Deel 4 — Evaluatie van online-inferentie
De online lane is waar de meeste production-metrics leven. Veel teams stoppen bij Recall@k. Dat is niet genoeg.
Query understanding en rewriting
- Kwaliteit van query-expansie: Recall@k-uplift op je golden set, expanded query versus raw. Als het niet minstens +5% oplevert op moeilijke queries, schaadt je expander meer dan hij helpt. Klassieke PRF-baselines (pseudo-relevance feedback) zoals RM3 en Bo1 zijn nog steeds nuttige sanity checks; LLM-based expansie moet ze verslaan.
- HyDE-evaluatie: HyDE (Gao et al., 2022) genereert een hypothetisch antwoord met de LLM, embed dat, en retrieve't daartegen — een nuttige tool die latency en een hallucination-surface toevoegt. Evalueer via Recall@10-uplift op out-of-domain queries (waar het uitblinkt) en bevestig dat er geen degradatie is op in-domain queries (waar het kan schaden). Gebruik het als fallback als retrieval confidence laag is, niet als default. Veranker met een cross-encoder-reranker downstream om hypothetical-driven retrievals te valideren.
- Multi-query-generatie: Recall@k-union van N rewrites versus enkele query. Afnemende meeropbrengst na 3–4 rewrites. Implementaties: LangChain's
MultiQueryRetriever, LlamaIndex'sQueryFusionRetriever. - Nauwkeurigheid van intentclassificatie: standaard precision/recall/F1 per intent (berekenen 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) maakt duidelijk dat niet elke query dezelfde retrievalstrategie verdient. Track router-accuratesse als classificatieprobleem tegen een gelabelde set van "heeft geen retrieval nodig / one-shot / iteratief."
Retrieval metrics
Dit zijn de basismetrics. Als je ze niet trackt, kun je niet zien of retrieval verbetert.
| Metric | Wat het meet | Wanneer te gebruiken |
|---|---|---|
| Recall@k | percentage queries waarbij een relevant doc in top k zit | de belangrijkste retrieval metric voor RAG; als die laag is, telt downstream niets |
| Precision@k | percentage van top-k dat relevant is | nuttig wanneer het context window de bottleneck is |
| MRR | gemiddelde van 1/rank van het eerste relevante doc | wanneer gebruikers alleen naar top-1 of top-3 kijken |
| nDCG@k | positie-gedisconteerde gain gewogen naar relevantieklassen | standaard retrievalmetric voor graded relevance |
| MAP | gemiddelde over queries van average precision | wanneer je om de volledige gerankte lijst geeft |
| Hit Rate@k | binaire versie van Recall@k | snelle sanity metric |
| Coverage | percentage golden docs dat ooit wordt opgehaald over alle queries | vangt systematische gaten in de index |
De formules, ter referentie (binaire relevantie met relevante set \(R_q\) voor query \(q\), en \(\\text{rel}_i = 1\) als het \(i\)-de opgehaalde doc in \(R_q\) zit):
Voor graded relevance geldt \(\\text{rel}_i \\in \\{0, 1, 2, \\dots\\}\); binaire nDCG is het special case dat in de code hieronder wordt gebruikt. MAP is het gemiddelde over queries van \(\\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 afleidingen.
Gebruik voor production-code ranx, pytrec_eval of ir_measures — die implementeren de volledige TREC-metricfamilie en gaan correct om met graded relevance. Redelijke starttargets: Recall@10 ≥ 0.85, MRR ≥ 0.6, nDCG@10 ≥ 0.7. Die moeten worden afgestemd op een realistische golden set, niet uit een tutorial worden overgenomen.
De test harness hiervoor is kort. Je kunt hem vanuit een notebook draaien 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 hem aan een golden set van 200 queries en run hem op elke PR. Als een van de drie getallen achteruitgaat, blokkeer dan de merge en fix de regressie.
De companion repo pint de exacte cijfers hierboven (Recall@5 = 0.750, MRR = 0.625, nDCG@5 = 0.627) als unit test in tests/test_retrieval_metrics.py; notebook 01 swept Recall@k / MRR / nDCG over een echte SciFact-index, en de production-vormige harness staat in evaluation/retrieval.py.
Hybrid retrieval en reciprocal rank fusion
BM25 (de klassieke sparse lexical scorer uit Robertson & Walker, 1994 — exact-term matching met TF-IDF-achtige weighting en length normalization, beschikbaar in rank_bm25, Elasticsearch/OpenSearch en de meeste zoekmachines) plus dense fusion via Reciprocal Rank Fusion (Cormack, Clarke, Buettcher, SIGIR 2009) met k=60 is een sterke default. RRF is score-agnostic, dus het omzeilt de score-normalisatieproblemen die bij lineaire interpolatie horen. Als je 50+ gelabelde queryparen hebt, probeer dan een convex combination en tune α. Hybrid plus een cross-encoder-reranker verslaat meestal dense-only of sparse-only retrieval op technische, log-achtige en code-corpora. Op sterk semantische corpora kan de winst klein zijn. Meet op je eigen data; een slechte fusion-config kan slechter presteren dan dense-only.
De implementatie past in een paar 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
Merk op wat RRF niet doet: het kijkt nooit naar de ruwe similarity-scores. Een dense retriever die cosine 0.98 retourneert en een BM25-lane die score 17.4 retourneert, zijn niet direct vergelijkbaar. Als je ze normaliseert met z-scores of min-max scaling, kun je eindigen met een voorkeur voor de lane met de hoogste variantie in die batch.
RRF gebruikt alleen rank. Als een retriever een document op positie 2 zet, is die stem 1 / (60 + 2) waard, ongeacht de ruwe score die dat opleverde.
Hybrid + RRF op SciFact: notebook 02 vergelijkt dense versus BM25 versus RRF met per-query delta's. De production-vormige fuser staat in retrieval/hybrid_rrf.py; tests/test_rrf.py pint de canonieke d3 / d2 / d1-ordening op k=60.
Reranking
- ΔnDCG / ΔMRR: de enige eerlijke reranker-metric — uplift ten opzichte van geen rerank, op je golden set, op de diepte die je applicatie daadwerkelijk gebruikt. Bereken door je retrieval metrics met en zonder reranker te draaien op identieke candidate sets.
- Cross-encoder versus bi-encoder: een bi-encoder embed query en doc onafhankelijk (één vector per kant) en scoort met dot product; een cross-encoder concateneert query+doc en draait één forward pass die gezamenlijk over beide attend. Cross-encoders winnen bijna altijd op relevantie, ten koste van een forward pass per candidate. Referentie-implementatie:
sentence-transformersCrossEncoder. In gepubliceerde benchmarks haalt BGE-reranker-v2-m3 ~80ms per 100 candidates op GPU en ~350ms op CPU, en matcht Cohere Rerank op kwaliteit zonder doorlopende kosten. Zie die cijfers als orde van grootte — je hardware en batch size verschuiven ze. - Listwise versus pointwise: pointwise scoort elk (query, doc)-paar onafhankelijk; listwise scoort de hele candidate-lijst gezamenlijk zodat het model direct een ranking objective kan optimaliseren. Listwise (BGE, ZeRank-2 met gekalibreerde outputs) wint doorgaans op nDCG; pointwise is makkelijker te thresholden. De gekalibreerde probabilities van ZeRank-2 laten je simpele
score > 0.7-thresholds gebruiken; ruwe BGE/MiniLM-scores vereisen tuning per corpus.
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 is vaak de waardevolste toevoeging aan een basale RAG-pipeline. Op de meeste corpora die ik heb gezien, verhoogt het toevoegen ervan Precision@1 met 15–40%. Als je RAG er geen heeft, voeg er dan een toe voordat je tijd steekt in kleinere retrieval-tweaks.
ΔnDCG en ΔPrecision@1 van een cross-encoder op SciFact: notebook 03; module: retrieval/reranker.py.
Contextconstructie en lost-in-the-middle
Hier komen veel failures vandaan van het type "goede retrieval, slecht antwoord".
- Context relevance: per-chunk relevance-score uit RAGAS
ContextRelevancyof een cross-encoder, geaggregeerd als gemiddelde en als percentage chunks onder een threshold. - Context utilization: van de chunks die in de context zijn geplaatst, hoeveel zijn er daadwerkelijk geciteerd of gebruikt in het antwoord. Bereken als \(\\frac{|\\text{cited chunks}|}{|\\text{retrieved chunks}|}\) over een gelabelde sample. Lage utilization (< 30%) betekent dat je betaalt voor tokens die je niet nodig hebt.
- Detectie van lost-in-the-middle: synthetische eval waarbij je de gold chunk op posities {eerste, midden, laatste} in een lange context plaatst en answer correctness meet. De U-vormige degradatie is echt en gedocumenteerd in Liu et al. (TACL 2023). Moderne modellen doen het beter dan modellen uit 2023, maar de bias blijft bestaan. Mitigaties: rerank en reorder daarna top-k zodat de hoogst gescoorde chunk als eerste of laatste staat (LangChain's
LongContextReorderdoet precies dit), of comprimeer middle chunks agressief. Meet met een position-stratified eval, niet alleen met een geaggregeerde 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: LangChain's
ContextualCompressionRetriever, LongLLMLingua. Als compressie correctness met meer dan 2 punten verlaagt, ben je te ver gegaan.
Deel 5 — De filter false-exclusion rate
Deze metric krijgt een eigen sectie omdat de meeste teams hem overslaan, en hij echte production-failures veroorzaakt.
Een hard metadatafilter zoals tenant_id = X AND product = Y AND locale = en-US kan effectieve recall naar nul laten zakken zonder dat de standaard retrieval metrics veranderen. Het gold doc wordt uitgesloten voordat ranking begint. Recall@k wordt berekend over de overlevende candidate set, dus het kan er prima uitzien. Faithfulness wordt berekend tegen de opgehaalde context, dus dat kan er ook prima uitzien; het model zei trouw: "I don't know."
De rode tak in de boom is de veelvoorkomende failure: het juiste document bestaat, maar het filter verwijdert het vóór retrieval.
De metric
filter_false_exclusion_rate =
(# queries where gold doc was excluded by metadata filter) /
(# queries with at least one gold doc)
Om die te berekenen heb je nodig: (a) ground-truth doc ID's voor elke eval-query en (b) instrumentatie die de toegepaste filter predicates logt, niet alleen de eindresultaten. Een redelijk target is < 2% op production traffic. Als de rate hoger is, sloopt je filterlogica recall.
Hier is een werkende implementatie die ook laat zien waarom deze failure onzichtbaar is voor een naïef geschreven retrieval harness.
# A small worked example that drops recall to zero silently.
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 — silently 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%
# The trap: a recall harness that only iterates over the SURVIVORS will
# either skip the empty-gold queries or report perfect recall on a doomed set.
def naive_recall_over_survivors(queries, docs, k=10):
recalls = []
for q in queries:
survivors = [d for d in docs if q["filter"](d)][:k]
survivor_ids = {d["id"] for d in survivors}
denom = len(q["gold"] & {d["id"] for d in docs})
if denom == 0:
continue # silently drops the query
visible_gold = q["gold"] & survivor_ids
recalls.append(len(visible_gold) / denom)
return sum(recalls) / len(recalls) if recalls else 0.0
print(f"naive recall (filtered universe) = {naive_recall_over_survivors(queries, docs):.2%}")
# naive recall (filtered universe) = 50.00%
assert rate == 0.5
De helft van de queries verliest zijn gold doc door het filter. De naïeve recall-harness rapporteert 50% en je geeft de retriever de schuld. De exclusion rate laat het echte probleem zien: dit is een predicate bug. Twee queries hadden hun antwoord verwijderd voordat de retriever draaide. Geen enkel model kan een document terughalen dat is weggefilterd.
De rate van 50% hierboven wordt gereproduceerd als unit test in de companion repo: tests/test_filter_exclusion.py::test_50_percent_exclusion_rate. Notebook 04 draait dit op SciFact met synthetische metadata zodat je een echt filter recall ziet nullen; de runtime-metric (met companion metric voor predicate-precision/recall) staat in evaluation/filter_exclusion.py.
Companion metric: predicate precision en recall
Wanneer filtering dynamisch is (bijvoorbeeld wanneer een LLM filter predicates uit de query extraheert), behandel de predicate extractor dan als een classificatiemodel en evalueer het ook zo. Predicate precision/recall tegen een gelabelde set van (query, correcte predicate)-paren. Als je extractor 8% van de tijd fout zit en harde filters toepast, heb je een harde bovengrens op recall rond 92%, en geen enkele reranking helpt.
Soft boost versus hard filter
Deze metric dwingt een ontwerpbeslissing af. Gebruik harde filters wanneer correctness binair is: juridische jurisdictie, ACL-grenzen, gepubliceerd-versus-draft. Gebruik soft boosts wanneer relevantie gradueel is: locale-voorkeur, recency, versie. Zonder exclusion-rate-metingen is de verkeerde keuze moeilijk te zien.
De beslisregel, 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.
ε in de range 1–2% is redelijk; lager voor high-stakes domeinen. Een aparte post in deze serie gaat dieper in op deze trade-off.
Deel 6 — Generatie-evaluatie
Retrieval metrics vertellen je dat het systeem correct zou kunnen antwoorden. Ze vertellen je niet dat het dat ook heeft gedaan. Generation metrics dekken dat gat.
Faithfulness en groundedness
RAGAS faithfulness splitst het antwoord op in atomic claims (korte, op zichzelf staande feitelijke statements) en verifieert vervolgens elke claim tegen de opgehaalde context via een LLM-judge:
Het percentage ondersteunde claims is de score. De structuur is nuttiger dan welk enkel getal dan ook, omdat ze je vertelt welke claims niet worden ondersteund. Production-code staat in het package ragas — gebruik ziet er zo uit:
from datasets import Dataset
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision
samples = Dataset.from_dict({
"question": ["How many moons does Mars have?"],
"answer": ["Mars has two moons, Phobos and Deimos."],
"contexts": [["Mars has two moons named Phobos and Deimos."]],
"ground_truth": ["Mars has two moons."],
})
result = evaluate(samples, metrics=[faithfulness, answer_relevancy, context_precision])
print(result)
Hieronder staat dezelfde loop uitgeschreven met een deterministische 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 structuur is wat telt. In production 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 + verificatie op gegenereerde SciFact-antwoorden: notebook 05; module: evaluation/faithfulness.py. De repo draait in dezelfde loop ook een HHEM-achtige cross-family verifier zodat je kunt zien welke judge-familie met welke andere overeenstemt.
Een purpose-built alternatief voor LLM-as-judge is HHEM-2.1-Open (Hughes Hallucination Evaluation Model, Vectara), een classifier van 600 MB die is fine-tuned voor detectie van hallucinations. De default-threshold is meestal 0.5 (>0.5 = feitelijk, ≤0.5 = gehallucineerd), maar kalibreer op je eigen gelabelde set. Het draait op CPU en presteert naar verluidt beter dan generieke LLM judges op AggreFact en RAGTruth. De nieuwere commerciële HHEM-2.3 en FaithJudge zitten op dit moment op de Pareto frontier in Vectara's leaderboard. Benchmark opnieuw voordat je commit; leaderboards driften.
Atomic-fact-evaluatie
FActScore (Min et al., EMNLP 2023) splitst long-form generations op in atomic facts, haalt bewijs per feit op, labelt elk feit als supported / not-supported, en rapporteert de ondersteunde fractie:
Referentie-implementatie: shmsw25/FActScore. Het werkt goed voor biografieën, samenvattingen en andere long-form outputs. Let op: repetitieve triviale feiten kunnen de score opblazen, en "MontageLie"-aanvallen (ware feiten in misleidende volgorde) kunnen het omzeilen. VeriScore kan omgaan met claims met noodzakelijke modifiers; de Core-filter helpt fact-padding voorkomen.
Citation-accuratesse
Track citation precision (geciteerde spans ondersteunen de claim daadwerkelijk) en citation recall (claims die een citaat zouden moeten hebben, hebben dat ook):
De support evaluation van de TREC 2024 RAG Track is de academische standaard. Upadhyay et al. (SIGIR 2025) rapporteren dat GPT-4o in 56% van de gevallen overeenkomt met menselijke judges op handmatige beoordeling vanaf nul, oplopend tot 72% met post-editing van LLM-predicties. Dat is nuttig als force multiplier, niet als vervanging van menselijke beoordeling in high-stakes contexten. Voor een geautomatiseerde benadering implementeert ALCE (Gao et al., EMNLP 2023) citation precision/recall met NLI-based verificatie.
Answer correctness, completeness, refusal
- Answer correctness versus ground truth: wanneer je die hebt, exact match of token-F1 voor short-answer-taken (
evaluate.load("squad")), semantic similarity voor open-ended taken (bert-score, embedding cosine viasentence-transformers, of RAGASAnswerCorrectness). - Completeness via nuggets: een "nugget" is één atomair stukje informatie dat elk correct antwoord moet bevatten (bijvoorbeeld, voor "When was the company founded?" kunnen de nuggets
{year: 1994, founder: Jane Doe}zijn). TREC's AutoNuggetizer extraheert de gold nuggets van een correct antwoord uit een referentie en scoort vervolgens welk deel het systeem afdekt — sterke correlatie met handmatige evaluatie over 21 topics × 45 runs bij TREC 2024. - Refusal behavior: queries zonder antwoord in het corpus moeten abstention opleveren, niet 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 domein een slice van out-of-scope queries en track abstention accuracy.
Verificatie na generatie
De goedkoopste reliability-winst komt vaak van deterministische post-checks, niet van grotere modellen.
- Entity grounding check: elke named entity in het antwoord moet voorkomen in (of afleidbaar zijn uit) de opgehaalde context. Een simpele regex + exact-match-check (of
spaCy'sentstegen een genormaliseerde contextstring) vangt een verrassend groot deel van hallucinations. - Claim verification: extraheer claims, draai NLI tegen context, fail of flag alles onder de threshold. NLI-as-faithfulness-modellen:
cross-encoder/nli-deberta-v3-large,MoritzLaurer/DeBERTa-v3-large-mnli-fever-anli-ling-wanli. Voegt latency toe. De moeite waard voor high-stakes domeinen. - Self-consistency (Wang et al., ICLR 2023): sample N=5 generations bij temperature > 0; rapporteer agreement rate (bijvoorbeeld aandeel generations dat overeenkomt met het modale antwoord, of pairwise BERTScore); flag antwoorden met lage agreement voor human review.
- Confidence calibration: verzamel uitgesproken confidence ("How confident are you, 0–1?") en vergelijk die met feitelijke correctness op de eval set. Plot een calibration curve en rapporteer Expected Calibration Error: \(\\text{ECE} = \\sum_{m=1}^{M} \\frac{|B_m|}{n} |\\text{acc}(B_m) - \\text{conf}(B_m)|\), waarbij \(B_m\) confidence bins zijn. Implementaties:
netcal,torchmetrics.CalibrationError. Een model dat 0.9 zegt zou 90% van de tijd gelijk moeten hebben. Dat is bijna nooit zo.
Deel 7 — Evaluatie van ontology-grounded RAG
De standaardmetrics hierboven dekken open-corpus RAG. Ontology-grounded systemen hebben meer nodig. Als je RAG retrieve't tegen een gestructureerde ontology, taxonomy of knowledge graph (producten in een catalogus, condities in SNOMED, componenten in een BOM, security techniques in MITRE ATT&CK), dan zijn standaard RAG-metrics noodzakelijk maar onvoldoende. Je moet ook de ontology-laag meten.
Accuratesse van entity linking
De eerste taak is het mappen van een mention uit een query op een ontology-entity ("Aspirin" → wikidata:Q18216, "the 737" → aircraft:Boeing_737).
- Mention-level precision/recall/F1: standaard, tegen gold mention spans (berekenen met
seqevalof een span-set comparator). - Disambiguation-accuratesse: welk deel van correct gedetecteerde mentions wordt gemapt op de juiste entity ID? Publieke referenties zijn onder meer ReFinED, REL en GENRE; benchmarks zoals AIDA-CoNLL en BELB rapporteren end-to-end F1 in de 60–90%-range, afhankelijk van systeem en domein.
- NIL-handling: precision/recall op "entity niet in ontology." Hier falen de meeste production EL-systemen stilletjes. Ze over-linken naar een bijna-goede maar foutieve entity in plaats van abstention.
Hiërarchie-aware evaluatie
Gewone accuratesse behandelt "voorspeld Sedan terwijl de waarheid Hatchback is" hetzelfde als "voorspeld Sedan terwijl de waarheid Submarine is." Die fouten zijn niet gelijk.
-
Hiërarchische precision/recall/F1 (Kosmopoulos et al., 2015): geef credit aan ancestors en descendants in de ontology-DAG. Met \(\\hat{P}_q\) de voorspelde node plus al zijn ancestors en \(T_q\) de ware node plus al zijn ancestors:
\[ hP = \\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} \]Te implementeren in ~30 regels met
networkxop de ontology-graph; ziehierarchical-classifier-metricsvoor een referentie. -
Wu-Palmer similarity tussen voorspelde en gold entity in de taxonomy (Wu & Palmer, 1994):
\[ \\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. Out of the box beschikbaar in NLTK voor WordNet (
from nltk.corpus import wordnet as wn; wn.synset("car.n.01").wup_similarity(wn.synset("truck.n.01"))); voor custom taxonomies bereken je LCA metnetworkx. -
Sibling/parent confusion rate: track verwarringen naar siblings, parents en children apart —
count_sibling / total_errors,count_parent / total_errors,count_descendant / total_errors. Sibling-confusions betekenen meestal ambigue mentions; parent-confusions betekenen dat het model omhoog hedge't in de hiërarchie.
Filter false-exclusion rate (opnieuw, nu kritisch)
In ontology-grounded systemen komen harde filters vaak uit de ontology zelf ("retrieve alleen docs die met categorie X getagd zijn"). De exclusion-rate metric (gedefinieerd in Deel 5) wordt een primair correctness-signaal. Een verkeerde categorievoorspelling kan recall stilletjes op nul zetten.
Conformance van constrained generation
Wanneer je output aan een ontology moet voldoen (elke entity-naam in het antwoord moet een geldig ontology-member zijn; elke predicate moet uit een gesloten vocabulaire komen), meet dan:
- Schema validity rate: percentage outputs dat parse't en valideert tegen het ontology-schema. Valideer met
jsonschemaofpydantic. JSONSchemaBench is de publieke benchmark voor algemene structured output; voor ontology-specifieke schema's bouw je je eigen validator. - Vocabulary conformance: percentage named entities in de output dat geldige ontology-ID's zijn — een eendelige set-membership-check tegen het gesloten vocabulaire.
- Semantic conformance: validity is noodzakelijk maar onvoldoende. 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) kunnen je naar ~100% schema validity brengen tegen bescheiden latency-kosten. Volgens JSONSchemaBench leidt Guidance momenteel de efficiency × coverage × quality Pareto frontier.
Auditability
Voor ontology-grounded systemen waarvan antwoorden review krijgen:
- Citation completeness: percentage feitelijke claims met minstens één verifieerbaar citaat.
- Provenance depth: percentage citaten dat helemaal terug resolve't naar een source document met een stabiele ID, niet alleen een chunk-hash.
- Reproducibility rate: dezelfde query opnieuw draaien op een vast snapshot geeft hetzelfde antwoord terug (afgezien van temperature). Als dit bij temp=0 niet ~100% is, heb je elders in de pipeline non-determinism.
Deel 8 — Evaluatie op systeemniveau
Holistische answer quality
- LLM-as-judge (Zheng et al., NeurIPS 2023): de dominante aanpak. G-Eval (een LLM-judge-protocol waarin het model zijn eigen chain-of-thought-rubric genereert vóór het scoren) genereert de rubric automatisch vanuit een natural-language criterium, en scoort dan met log-prob-weighted output. Sterke menselijke alignment met judges van GPT-4-klasse.
- Pairwise preference: presenteer de judge antwoord A versus antwoord B; registreer de voorkeur. Vermijdt calibratieproblemen met absolute scores. Ongeveer 80% human-judge-overeenkomst op GPT-4-niveau, wat overeenkomt met human-human-overeenkomst.
LLM-as-judge heeft echte biases:
- Position bias: judges geven de voorkeur aan het eerste of tweede antwoord ongeacht kwaliteit. Mitigatie: randomiseer de volgorde, of run beide volgordes en neem het gemiddelde.
- Verbosity bias: judges geven de voorkeur aan langere antwoorden. Het onderzoek uit 2025–2026 is genuanceerder. Moderne instruction-tuned judges bestraffen filler in length-controlled tests, maar belonen echte volledigheid op truncation pairs. Zelfs dan: vertel de judge expliciet hoe lengte behandeld moet worden, en overweeg length-controlled win rates.
- Self-preference bias: GPT-4 geeft de voorkeur aan GPT-4-outputs; de bias correleert met output perplexity (judges prefereren tekst die voor hen vertrouwd voelt). Mitigatie: gebruik een andere judge-familie dan het systeem dat wordt geëvalueerd. Gebruik nooit een model om zichzelf te beoordelen.
Praktisch recept: GPT-4o of Claude als judge, gerandomiseerde volgorde, gemaskeerde modelidentiteiten, expliciet lengtebeleid in de rubric, en meerdere runs middelen. Voor high-stakes evals gebruik je twee judges en analyseer je de meningsverschillen.
Schema-Guided Reasoning voor judges
Free-form judge-output is de belangrijkste reden dat judge-runs lastig reproduceerbaar zijn. Twee runs tegen hetzelfde antwoord kunnen verschillende scores geven, niet omdat de judge van mening veranderde, maar omdat hij zijn redenering anders organiseerde. De oplossing is de judge in een gestructureerde rubric te dwingen — wat ik Schema-Guided Reasoning (SGR) noem: definieer de redeneringsstappen als een Pydantic-schema, run met constrained decoding (Outlines, XGrammar, vLLM's structured outputs, OpenAI's response_format), en de judge moet elk veld in volgorde emitten. Geen overgeslagen stappen, geen verborgen bias richting langere antwoorden.
Voor RAG-eval splitst het schema het oordeel op in expliciete, auditeerbare velden 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
Drie dingen veranderen zodra de judge tot deze vorm wordt beperkt. De score is herleidbaar uit de gestructureerde velden (len(supported) / len(extracted)), zodat position bias en verbosity bias minder ruimte krijgen. Meningsverschillen tussen twee judges worden diagnoseerbaar — je kunt precies zien welke claim elke judge heeft geflagd. En omdat de rubric het schema is, kun je die versioneren als code: een wijziging aan de rubric is een Pydantic-diff, geen prompt rewrite.
Dit werkt voor elke judge op basis van een rubric, niet alleen faithfulness. Pairwise preference, citation support en refusal correctness profiteren allemaal van dezelfde behandeling.
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) hangt drie frontier-tier-modellen — gpt-5-mini, claude-haiku-4-5, gemini-2.5-flash — in een roterende-judge pairwise A/B zodat elk model de andere twee beoordeelt, en self-preference numeriek zichtbaar wordt.
Latency en kosten
- p50, p95, p99 op elke pipelinestap. p95 is voor de meeste applicaties het juiste SLO-doel (service level objective); p99 is waarop je alarmeert.
- Time-to-first-token versus totale generation time. Gebruikers geven om TTFT voor streaming UX.
- Stage breakdown: retrieval, reranking, generation, post-processing. De grootste p95-pieken zijn bijna altijd rerankers die op CPU draaien.
- Totale $/query = embedding + retrieval + rerank + generation + geamortiseerde storage. Track p50 en p99; de long tail is waar het budget naartoe gaat.
- Cache hit rates op embedding-cache-, retrieval-cache- en KV-cache-niveau. Een cache hit rate van 30%+ is meestal haalbaar voor repetitieve workloads en is de goedkoopste losse kostenoptimalisatie.
Per-fase p50/p95/p99 met een stage breakdown zit ingebouwd in notebook 08 en de runner op evaluation/latency.py; het benchmark-rapport combineert latency met faithfulness in één matrix die je opnieuw kunt draaien met make benchmark.
A/B-testing
- Eenheid van randomisatie: per gebruiker of per sessie, nooit per query (dezelfde gebruiker die inconsistente kwaliteit ziet is erger dan elk systeem afzonderlijk).
- Primaire, guardrail- en exploratory-metrics: leg ze vooraf vast. Primair is meestal een satisfaction-proxy (thumbs / regenerations / dwell). Guardrails zijn latency en kosten. Exploratory metrics zijn al het andere.
- Sample size: doe een power-analyse vóór launch. De meeste RAG A/B-tests hebben te weinig power, verklaren valse winsten en shippen regressies.
Deel 9 — Constructie van testsets
Een metric is slechts zo goed als de testset waarop hij draait. Als je golden set drie intents afdekt en production traffic twaalf intents beslaat, dan is je Recall@10-getal een meting van drie intents in vermomming. Erger nog: een testset die overfit op makkelijke vragen ("What is the company's refund policy?") zal stilletjes een systeem goedkeuren dat faalt op de moeilijke ("Refund eligibility for a partial cancellation under the 2023 EU Digital Services Act, billed in EUR, originating in Ireland?"). Het getal stijgt, het dashboard wordt groen, en het systeem wordt stuk uitgerold.
Hetzelfde probleem raakt ground truth. Als SME's de voor de hand liggende docs labelden maar de long-tail relevante misten, dan zal Recall@k een retriever die ze wél vond te weinig credit geven. Je optimaliseert richting de labels, niet richting de waarheid.
Dus de juiste volgorde is: bouw eerst de testset die de echte distributie en echte moeilijkheid vastlegt; kies vervolgens metrics die gevoelig zijn voor de failure modes die je belangrijk vindt; tune als derde het systeem.
Synthetische querygeneratie
Gebruik een LLM om vragen 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 vraag die beide vereist.
- Adversarial: genereer vragen met distractor entities, near-duplicate phrasing, ambiguë mentions.
RAGAS heeft ingebouwde vraagtypeverdeling (reasoning, conditional, multi-context); recenter werk zoals DataMorgana genereert diversere synthetische benchmarks via multi-axis categorizaties van gebruiker/vraag. Synthetische data is nuttig voor cold starts en coverage testing. Ze kan echte user queries niet vervangen.
Constructie van golden datasets
De sterkste dataset is door mensen gecureerd.
- Sample echte user queries (of gesimuleerde als je pre-launch bent) gestratificeerd naar intent.
- Laat SME's elke vraag beantwoorden en aangeven welke doc(s) het antwoord bevatten.
- Mik op minimaal 200–500 queries; coverage is belangrijker dan grootte.
- Cureer elk kwartaal opnieuw. Distributies driften.
Adversarial testsets
- Counterfactuals: verwissel sleutelentiteiten in de query. Haalt het systeem de juiste chunks op voor de verwisselde query?
- Distractors: queries waarbij het corpus een plausibel maar fout antwoord bevat dat niet opgehaald mag worden. Dit is wat RGB (Chen et al., AAAI 2024) stresstest: noise robustness, negative rejection, information integration en counterfactual robustness.
- Negatie en quantifiers: queries met "not," "except" en "only." Dense retrievers hebben hier vaak moeite mee.
- Out-of-scope: queries zonder antwoord in het corpus. Het systeem moet "I don't know" zeggen, niet hallucineren. NoMIRACL hoort hier thuis. De meeste production-modellen hebben expliciete evaluatie voor abstention nodig.
Coverage en continue evaluatie
- Bouw een coverage-matrix: query intent × documenttype × ontology-tak. Mik op ≥1 query per cel. Lege cellen zijn ongemonitorde gebieden waar regressies zich verbergen.
- Regressionsuite draait op elke PR, op een kleine snelle subset (~50 queries).
- Volledige eval draait 's nachts of op release candidates, op de volledige golden set.
- Drift-eval draait wekelijks op een rollende sample van production queries (waarbij thumbs-down-queries zwaarder wegen).
Deel 10 — Production monitoring
De eval suite die je uitrolt beschrijft het systeem bij launch. Daarna verandert production traffic.
Impliciete en expliciete feedback
- Click-through / open rate op geciteerde bronnen (als je UI die laat zien).
- Dwell time op het antwoord.
- Regeneration rate: percentage antwoorden dat de gebruiker opnieuw vraagt of door het systeem laat herdoen. In de meeste producten het sterkste impliciete ontevredenheidssignaal.
- Copy / share / export-rates — sterk positief signaal.
- Follow-up-patronen: patronen als "Are you sure?" of "But what about X?" wijzen op wantrouwen.
- Thumbs up/down met optionele reason categories (fout, incompleet, off-topic, schadelijk, traag). Inline edits, als je UI ze toestaat, zijn het feedbacksignaal met de hoogste informatiedichtheid dat er is.
Drift-detectie
- Query drift: track de distributie van query embeddings versus een reference window met KL divergence, MMD of een model-based detector. Alarmeer op verschuiving, debug daarna per segment.
- Embedding drift: pin een probe set van vaste documenten; embed ze periodiek opnieuw en meet cosine ten opzichte van de oorspronkelijke embeddings. Zelfs kleine drift tussen providermodelversies breekt retrieval stilletjes. Versioned embedding storage (immutabele snapshots per versie) is de goedkoopste mitigatie.
- Performance drift: track production-equivalent metrics (regeneration rate per intent) over de tijd. Plotselinge sprongen betekenen dat er iets kapot is; langzame drifts betekenen dat de wereld is veranderd.
Shadow evaluation en human-in-the-loop
Draai het kandidaatsysteem parallel aan production, vergelijk outputs offline, en serveer ze niet aan gebruikers. Dat vangt regressies vóór launch. Het kost extra inference, maar heeft geen impact op klanten.
Voor human-in-the-loop (HITL)-review:
- Sample low-confidence outputs in een review queue.
- Sample 1–2% van alle production traffic willekeurig voor blinde review.
- Weeg thumbs-down outputs zwaar.
- Gebruik gereviewde outputs om de golden set uit te breiden.
De minimale guardrail-set
Alarmeer in deze prioriteitsvolgorde op:
- Faithfulness/HHEM-score onder threshold op een rollende production sample.
- p95-latency boven SLO.
- Filter false-exclusion rate boven threshold (sample-based).
- Regeneration rate boven baseline + 2σ.
- Kosten/query boven budget.
Als een alert afgaat zonder corresponderende code- of modelwijziging, heb je waarschijnlijk drift. Gaat hij af na een wijziging, dan heb je waarschijnlijk een regressie. Hoe dan ook krijg je een signaal voordat supporttickets binnenkomen.
Kanttekeningen
- Targets zijn illustratief, niet universeel. "Recall@10 ≥ 0.85" en "filter false-exclusion < 2%" zijn redelijke defaults uit systemen waar ik aan heb gewerkt. Kalibreer op je domein, risico's en gebruikersverwachtingen. Een medische RAG met 95% faithfulness is niet veilig; een brainstorm-assistent-RAG met 70% waarschijnlijk wel.
- Het framework-landschap beweegt snel. Specifieke cijfers (BGE-latency, MTEB-topscores, HHEM-versies, namen van RAGAS-metrics) zijn correct op het moment van schrijven in mei 2026 en zullen driften. Benchmark opnieuw voordat je commit.
- Overeenkomstscijfers van LLM-as-judge hebben een asterisk. De 80%-figuur voor GPT-4-versus-mens komt uit MT-Bench / Chatbot Arena-condities. Op niche-domeinen en adversarial cases daalt de overeenkomst scherp. Gebruik judges als force multiplier, niet als vervanging van spot-checking.
- Vendor-uplifts uit benchmarks 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. Ga 30 minuten per week met je team zitten en lees 50 willekeurige production-antwoorden. De metrics schalen die gewoonte; ze vervangen haar niet.
Binnenkort in deze serie
Dit was de index. Dit zijn de vervolgartikelen die ik plan:
- Soft Boosts vs. Hard Filters: een deep dive in filter false-exclusion rate, met code, echte production-voorbeelden en een besliskader.
- Chunking Is de verborgen variabele: een gecontroleerd experiment over recursive, semantic, late en structural chunking op drie corpora.
- Reranker-selectie in 2026: BGE versus Cohere versus ZeRank versus actuele cross-encoder-modellen, head-to-head op kosten, latency en uplift.
- Ontology-Grounded RAG: een end-to-end walkthrough: de volledige evaluatieharness bouwen voor een entity-grounded retrievalsysteem.
- LLM-as-Judge zonder de self-preference-valkuil: praktische recepten voor onbevooroordeelde geautomatiseerde evaluatie.
- Online evaluatie in production: instrumentatiepatronen, alertingbeleid en de dashboards die echte regressies vangen.
Belangrijkste takeaways
- Begin met de eval set, niet met de architectuur. Definieer in cijfers wat "beter" betekent voordat je het systeemontwerp kiest.
- Gebruik drie evaluatielagen. Offline corpus en index. Online retrieval en generatie. Verificatie na generatie plus production telemetry. Elke laag vangt een andere klasse failures.
- Track de filter false-exclusion rate. Een verkeerde predicate of fragiel hard filter zet recall op nul voordat ranking begint, en standaard retrieval metrics zien dat niet.
- Faithfulness meet de laatste schakel in de keten. Het kan geen parsingbug, chunkingbug, embedding drift of filter exclusion detecteren. Elke fase heeft zijn eigen metric nodig.
- Hybrid retrieval met RRF is de sterke default. Score-agnostic, immuun voor normalisatierampen, k=60 uit het oorspronkelijke paper van Cormack. Hybrid plus een cross-encoder-reranker verslaat in de meeste corpora elke lane afzonderlijk.
- Voeg een reranker toe voordat je iets anders tuned. Op de meeste corpora verschuift die Precision@1 met 15–40%, meer uplift dan elke andere losse wijziging.
- LLM-as-judge heeft echte biases. Positie, breedsprakigheid, self-preference. Randomiseer volgorde, maskeer identiteiten, gebruik nooit een model om zichzelf te beoordelen, en gebruik twee judges voor high-stakes evals.
- Production drift. Shadow eval, HITL-queues en rollende production samples houden de eval suite van launch relevant terwijl traffic verandert.
Referenties
Frameworks en benchmarks
- Es et al., Ragas: Automated Evaluation of Retrieval Augmented Generation, 2023.
- RAGAS-documentatie en GitHub.
- Saad-Falcon et al., ARES: An Automated Evaluation Framework for Retrieval-Augmented Generation Systems, NAACL 2024.
- TruLens, DeepEval, Arize Phoenix.
- Thakur et al., BEIR: A Heterogenous Benchmark for Zero-shot Evaluation of Information Retrieval Models, NeurIPS 2021.
- MTEB Leaderboard.
- TREC 2024 RAG Track.
- Pradeep et al., Initial Nugget Evaluation Results for the TREC 2024 RAG Track with the AutoNuggetizer Framework, 2024.
Retrieval en ranking
- Cormack, Clarke, Buettcher, Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods, SIGIR 2009.
- Gao et al., Precise Zero-Shot Dense Retrieval Without Relevance Labels (HyDE), 2022.
- Jeong et al., Adaptive-RAG: Learning to Adapt Retrieval-Augmented Large Language Models through Question Complexity, NAACL 2024.
- Anthropic, Introducing Contextual Retrieval, september 2024.
- Günther et al., Late Chunking: Contextual Chunk Embeddings Using Long-Context Embedding Models, 2024.
- Sarthi et al., RAPTOR: Recursive Abstractive Processing for Tree-Organized Retrieval, 2024.
Generatie, faithfulness, judges
- Min et al., FActScore: Fine-grained Atomic Evaluation of Factual Precision in Long Form Text Generation, EMNLP 2023.
- Liu et al., Lost in the Middle: How Language Models Use Long Contexts, TACL 2023.
- Chen et al., Benchmarking Large Language Models in Retrieval-Augmented Generation (RGB), AAAI 2024.
- Vectara, HHEM-2.1-Open hallucination evaluation model.
- Zheng et al., Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena, NeurIPS 2023.
- Upadhyay et al., Support Evaluation for the TREC 2024 RAG Track: Comparing Human versus LLM Judges, SIGIR 2025.
- Thakur et al., NoMIRACL: Knowing When You Don't Know for Robust Multilingual Retrieval-Augmented Generation, 2023.
- Geng et al., JSONSchemaBench: A Rigorous Benchmark of Structured Outputs for Language Models, 2025.
- Kosmopoulos et al., Evaluation Measures for Hierarchical Classification: a unified view and novel approaches, 2015.
Drift en production
- Evidently, Embedding drift detection methods compared.
Companion-code
slavadubrov/rag-evals-demo— uitvoerbare harness voor elke metric in dit artikel op het SciFact-corpus, plus een benchmark-sweep voor chunking × embedding × LLM. Notebooks 00–09, unit tests die de uitgewerkte voorbeelden hierboven vastzetten, en een embedded-Qdrant-index zodat het zonder Docker draait.