Aller au contenu

Traduction automatique

Cet article a été traduit automatiquement depuis la version originale en anglais.

Évaluer le RAG : des métriques pour chaque étape d’un système RAG en production

Partie 1 de la série Production RAG

Un système RAG avec des filtres cassés peut tourner pendant des mois avant que quelqu’un ne s’en aperçoive. Le pipeline renvoie des réponses, les tableaux de bord de latence restent au vert, et le seul signe qu’il y a un problème est que les réponses elles-mêmes sont subtilement erronées. « Subtilement erroné » ne déclenche aucune alerte.

De meilleurs logs ne suffiront pas à détecter ça. L’évaluation, oui, mais seulement si elle couvre chaque étape du pipeline avec sa propre métrique. Cet article est la référence que j’aurais aimé avoir quand j’essayais de comprendre quelles métriques comptent réellement.

Vous voulez passer directement au code ?

J’ai regroupé les métriques de cet article dans un repo compagnon exécutable : slavadubrov/rag-evals-demo. make eval exécute la suite complète — métriques de retrieval, hybride + RRF, gain du reranker, fausse exclusion des filtres, faithfulness, lost-in-the-middle, LLM-as-judge avec réduction des biais, latence — sur le corpus SciFact. make benchmark explore chunking × embedding × LLM et écrit un rapport markdown. Les notebooks 00–09 détaillent chaque métrique individuellement ; même vocabulaire que dans cet article, chiffres réels, pas de Docker (Qdrant embarqué).

TL;DR

  • L’évaluation définit le système. Une étape sans métrique est une étape qui échoue en silence.
  • Une stack d’évaluation utile couvre l’ingestion, le retrieval, l’ancrage de la génération, la conformité à l’ontologie et les signaux système. RAGAS, TruLens, DeepEval, Arize Phoenix et le TREC 2024 RAG Track vous donnent l’outillage. Ils ne choisissent pas vos métriques à votre place.
  • Pour le RAG ancré sur des métadonnées et des ontologies, l’échec le plus courant est le filtre silencieux. Un tag erroné ou un prédicat dur trop fragile fait chuter le rappel à zéro. La faithfulness peut quand même sembler correcte parce que le modèle a fidèlement répondu « Je ne sais pas. »

Les articles suivants approfondiront chaque section. Utilisez celui-ci comme index.


Partie 1 — Pourquoi commencer par l’évaluation

Le signal senior

Sur un projet RAG, le diagramme d’architecture ne devrait pas être le premier artefact. Le jeu d’évaluation devrait l’être.

Vous ne pouvez pas choisir entre BM25 et le retrieval dense, le chunking récursif et sémantique, ou Cohere Rerank et BGE tant que vous ne savez pas ce que vous optimisez. « De meilleures réponses » n’est pas une métrique. « Faithfulness ≥ 0.85 sur un golden set de 200 requêtes couvrant nos trois principales intentions, avec une latence p95 < 1.5s et un taux de fausse exclusion des filtres < 2% » est une métrique.

Définissez le harness avant d’écrire le code de retrieval. Le premier harness sera faux, et vous le réviserez. Réviser une métrique coûte bien moins cher que réviser un système déjà livré.

Trois couches, pas un seul chiffre

Le RAG moderne est un pipeline, donc l’évaluation doit aussi l’être. Aucun chiffre unique ne capture tous les modes de défaillance.

L’évaluation en production a trois couches : offline (la base de connaissances a-t-elle été préparée correctement ?), online (la bonne preuve a-t-elle été trouvée et utilisée pour cette requête ?), et post-génération (la réponse est-elle fidèle et vérifiable ?). Chaque couche pose une question différente. Si vous les écrasez en un seul score, vous pouvez rater des défaillances élémentaires, comme un bug de normalisation qui détruit le rappel.

La discipline d’évaluation en trois couches

La même séparation clarifie aussi l’évaluation online vs. offline. L’offline s’exécute sur un dataset fixe avec une vérité terrain connue. Elle est reproductible, peu coûteuse à itérer, et c’est le bon endroit pour la sélection des composants, les comparaisons A/B et les garde-fous CI. L’online s’exécute sur du trafic réel. Elle capture des signaux impossibles à simuler offline : taux de régénération, dwell time, thumbs, et dérive réelle des requêtes. Elle est bruitée et plus difficile à instrumenter correctement.

Il vous faut les deux. Le offline-only rate la dérive en production. Le online-only rend les régressions difficiles à reproduire. Faire les deux demande plus de travail, mais c’est la seule configuration qui fournisse un feedback utile avant et après le lancement.

Niveau composant vs. bout en bout

Il y a deux erreurs fréquentes. Une évaluation uniquement bout en bout vous dit que le système est cassé, mais pas où. Une évaluation uniquement par composant peut montrer que chaque partie passe, alors que le système complet échoue quand même. La solution : quelques métriques bout en bout de haut niveau pour les décisions go/no-go, plus des métriques par composant pour le diagnostic. Les métriques de retrieval détectent les régressions du retriever. Les métriques de génération détectent les régressions du générateur. La justesse de la réponse bout en bout détecte les échecs d’intégration.

Les frameworks de référence (tour d’horizon assumé)

Framework Meilleur pour Là où ça pêche
RAGAS Métriques RAG sans référence (faithfulness, answer relevancy, context precision/recall) ; le vocabulaire de facto Coût du LLM-judge ; composants du score opaques au debug ; réglages centrés anglais
ARES Juges classifieurs entraînés par pipeline ; moins d’annotations que les approches type RAGAS ; haute précision pour des systèmes proches Setup plus lourd ; il faut réellement entraîner des modèles
TruLens Fonctions de feedback composables avec une forte explicabilité ; traces OpenTelemetry ; adapté à la production Moins « batteries included » sur les métriques spécifiques au RAG que RAGAS
DeepEval Tests unitaires de style Pytest pour les sorties LLM ; G-Eval, métriques personnalisées, natif CI/CD Usage intensif du LLM-judge = pics de coût
Arize Phoenix Tracing solide et visualisation des embeddings ; détecte visuellement la dérive d’embeddings ; natif OTEL À vous de définir vos métriques
TREC 2024 RAG Track Benchmark public pour l’évaluation par nuggets (AutoNuggetizer), l’évaluation du support et la fluidité sur MS MARCO Segment v2.1 Pas un outil d’exécution ; un benchmark auquel se calibrer

Ma stack par défaut est RAGAS pour le vocabulaire de métriques, DeepEval pour les garde-fous CI, Phoenix pour le tracing en production, plus du code custom pour les métriques spécifiques à l’ontologie. Vous dépasserez tôt ou tard ce avec quoi vous commencez. Choisissez le framework qui facilite les métriques personnalisées.

Pour les benchmarks, utilisez BEIR (Thakur et al., NeurIPS 2021) pour la généralisation zero-shot en retrieval, MTEB pour la qualité générale des embeddings, MIRACL pour le retrieval multilingue, et le TREC 2024 RAG Track pour l’évaluation RAG de bout en bout.


Partie 2 — Le pipeline avec des points d’évaluation

Un système RAG de production est plus large que « embed des documents, retrieve des chunks, appeler un LLM ». Chaque étape entre l’acquisition des documents et la livraison de la réponse peut échouer.

Le pipeline RAG complet avec badges de métriques à chaque étape

Chaque étape du diagramme a au moins une métrique. Une étape sans métrique peut échouer sans que personne ne le remarque.

Les trois couloirs correspondent à l’endroit où les défaillances se produisent. Le couloir offline couvre tout ce qui arrive avant l’existence d’une requête : parsing, nettoyage, chunking, embedding, indexation. Le couloir online couvre tout ce qui se passe après l’arrivée d’une requête : rewriting, retrieval, reranking, assemblage du contexte. Le couloir post-génération couvre les vérifications après que le modèle a produit une réponse : faithfulness, vérification des citations, signaux de dérive et télémétrie de production.

Les erreurs se cumulent le long de la chaîne. Un mauvais parsing limite le chunking. Un mauvais chunking limite le retrieval. Un mauvais retrieval limite le reranking. Un mauvais reranking limite la génération. La faithfulness ne mesure que la réponse finale, jamais la cause amont.


Partie 3 — Évaluation offline de l’ingestion

Beaucoup de défaillances RAG en production commencent à l’ingestion. Le système fonctionne sur des documents de test propres, puis échoue sur des PDF réels, des scans, des tableaux et des pages de corpus désordonnées.

Acquisition et parsing des documents

Ce qu’il faut mesurer :

  • Complétude de l’extraction de texte : extracted_chars / expected_chars sur un échantillon annoté, calculé par classe de document. Il n’existe pas de package canonique — écrivez un petit harness qui compare la sortie du parser à une référence nettoyée à la main. Surveillez les notes de bas de page, en-têtes, légendes manquants.
  • Précision OCR : CER (Character Error Rate) et WER (Word Error Rate), les métriques standard de la parole/OCR :

    \[ \text{CER} = \frac{S + D + I}{N}, \qquad \text{WER} = \frac{S_w + D_w + I_w}{N_w} \]

    \(S\), \(D\), \(I\) sont les substitutions, suppressions, insertions au niveau caractère et \(N\) est le nombre de caractères de la référence (indice \(w\) pour la version mot). Un CER de 1–2% est bon pour du texte imprimé ; >10% est inutilisable. Pour des documents manuscrits ou multilingues, ≤20% peut encore être exploitable. Calculez-le avec jiwer (jiwer.cer(refs, hyps), jiwer.wer(refs, hyps)) ou HuggingFace evaluate. Pour les corpus d’évaluation, FUNSD et SROIE sont les benchmarks publics.

    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
    
  • Fidélité de l’extraction de tableaux : TEDS (Tree-Edit-Distance-based Similarity) mesure à quel point un arbre HTML de tableau prédit est proche de la référence, normalisé par la taille de l’arbre le plus grand. D’après 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 utilise à la fois la structure (lignes, colonnes, spans) et le contenu des cellules ; TEDS-S retire le contenu et ne score que la structure. Implémentation de référence : le teds.py de PubTabNet (utilise apted en interne). Pour les corpus d’évaluation, voir PubTabNet, FinTabNet et SciTSR. Les parsers naïfs échouent souvent sur les tableaux ; benchmarkez avant de leur faire confiance.

  • Préservation de la mise en page / structure : ordre des titres, intégrité des listes, ordre de lecture sur des PDF en plusieurs colonnes. Utilisez DocLayNet comme benchmark annoté ; pour comparer des parsers prêts à l’emploi, unstructured, pymupdf et un parser VLM comme docling couvrent l’essentiel de l’espace de conception.

Mon avis : benchmarkez trois parsers (une baseline Tesseract, un modèle VLM-OCR et votre candidat vendor) sur un échantillon stratifié de classes de documents réels (scans propres, photos, pages riches en tableaux, multilingue, maths, manuscrit) à DPI fixe. Reportez CER/WER par classe plus TEDS pour les pages avec tableaux. Sans ça, vous devinez.

Nettoyage et normalisation

  • Précision du retrait du boilerplate : précision/rappel contre des spans de boilerplate annotés par des humains. Un retrait agressif tue du contenu pertinent ; un retrait paresseux pollue les embeddings. Outils à comparer : trafilatura, jusText, Resiliparse. Barbaresi (2021) les benchmarke en comparaison directe.
  • Normalisation Unicode : le pourcentage de documents produisant des sorties NFC et NFKC identiques (calculé avec le unicodedata.normalize de la stdlib) est un bon signal de dérive. Les mismatches sont la manière dont des zero-width joiners et des caractères visuellement similaires détruisent le rappel en retrieval.
  • Précision de la détection de langue : F1 sur un échantillon multilingue annoté. Critique pour les index multilingues. Utilisez fasttext-langdetect (le lid.176 de Facebook), lingua-py ou cld3 ; FLORES-200 est le benchmark standard pour les langues low-resource.
  • Efficacité de la déduplication (MinHash / LSH) : précision/rappel de votre détecteur de quasi-doublons contre un jeu annoté à la main. L’idée sous-jacente : estimer la similarité de Jaccard \(J(A, B) = \frac{|A \cap B|}{|A \cup B|}\) entre des ensembles de shingles de documents via \(k\) hachages par permutations aléatoires (Broder, 1997) et regrouper les quasi-doublons avec un banding LSH (Indyk & Motwani, 1998). Un MinHash standard avec 128 fonctions de hachage et un banding LSH réglé pour un seuil de Jaccard de 0.7–0.85 est le défaut raisonnable ; benchmarkez sur vos données car le bon seuil dépend du corpus. Suivez séparément le taux de faux merge (corrompt les réponses) du taux de missed merge (gaspille de l’espace d’index). datasketch est le package Python canonique :

    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']
    
  • Nettoyage des PII : précision et rappel, calculés séparément par type d’entité (emails, SSN, noms, adresses). Les erreurs de rappel créent un risque de conformité ; les erreurs de précision dégradent la qualité des réponses. Réglez le point de fonctionnement avec l’équipe juridique. Outils : Microsoft Presidio (le plus complet), scrubadub, ou un modèle NER finement ajusté sur un jeu annoté.

Chunking — l’étape qui décide discrètement du retrieval

Le chunking est l’une des décisions les plus impactantes en RAG. La mauvaise stratégie peut produire plusieurs points d’écart de rappel avec les mêmes embeddings. Les benchmarks 2024 de NVIDIA ont donné au chunking au niveau page la meilleure précision avec la variance la plus faible pour les documents paginés ; le chunking sémantique (regrouper des phrases adjacentes par similarité d’embeddings et couper sur des frontières dissemblables — implémenté dans le SemanticChunker de LangChain et le SemanticSplitterNodeParser de LlamaIndex) peut améliorer le rappel par rapport au chunking à fenêtre fixe ; le découpage récursif par caractères (essayer d’abord les sauts de paragraphe, puis les sauts de phrase, puis les sauts de mots, jusqu’à ce que chaque chunk respecte la taille cible — voir le RecursiveCharacterTextSplitter de LangChain) à 400–512 tokens avec 10–20% de chevauchement reste une bonne valeur par défaut pour du texte général.

Métriques à suivre :

  • Cohérence des chunks : \(\\text{coherence} = \\overline{\\cos(s_i, s_j)}_{\\text{within}} - \\overline{\\cos(s_i, s_j)}_{\\text{across boundary}}\), où \(s_i\) sont des embeddings de phrases. Des chunks sains sont similaires en interne et dissemblables à la frontière. Calculez avec sentence-transformers plus le cosine_similarity de scikit-learn.
  • Qualité des frontières : annotation humaine « est-ce une coupe raisonnable ? » sur un échantillon, plus une vérification structurelle que les chunks ne coupent pas des tableaux, listes ou sections numérotées (votre bug de production le plus courant).
  • Taille optimale des chunks : explorez les tailles en tokens (128, 256, 512, 1024) et tracez Recall@k en fonction de la taille sur votre golden set. Choisissez le coude. Ne reprenez pas ce qu’un tutoriel a dit.
  • Efficacité du chevauchement : ablation de la fraction de chevauchement (0%, 10%, 20%, 30%) et mesure de Recall@k. Rendements décroissants au-delà de ~20% dans la plupart des corpus.
  • Fidélité de l’attribution des chunks : pourcentage de chunks qui conservent un pointeur source vérifiable (numéro de page, ancre de section, doc ID). L’auditabilité l’exige.
  • Late vs. early chunking : le late chunking (Günther et al., 2024) embed le document complet puis segmente, ce qui préserve le contexte global (implémentation de référence dans jina-embeddings-v3). Contextual Retrieval (Anthropic, 2024) préfixe chaque chunk avec un contexte généré par LLM. Les deux ajoutent du coût. Benchmarkez sur votre corpus avant d’adopter l’un ou l’autre.

Mon avis : le chunking structurel (découpage sur les titres, tableaux et sections — implémenté par des parsers comme unstructured.io ou en parcourant l’AST que votre parser a déjà produit) est sous-utilisé. Si vos documents ont une structure, utilisez-la avant d’ajouter des heuristiques de similarité. Le découpage récursif par caractères est la baseline ; le chunking sémantique vaut surtout le surcoût sur de la prose non structurée.

Extraction et enrichissement des métadonnées

  • Précision/rappel/F1 du NER : par type d’entité, sur un sous-ensemble annoté. Standard de type CoNLL/MUC. Calculez avec seqeval (from seqeval.metrics import f1_score) pour la version aware des tags BIO/IOB, ou scikit-learn pour les comparaisons d’ensembles de spans. CoNLL-2003 et OntoNotes 5.0 sont les corpus de référence canoniques.
  • F1 d’extraction de relations : encore plus important pour les systèmes ancrés sur une ontologie. Annotez à la main 200 documents. TACRED et DocRED sont les benchmarks publics ; pour le code de production, les pipelines de relation opennre et spaCy sont de bons points de départ.
  • Précision d’extraction des titres / headings : exact-match plus similarité de Levenshtein normalisée (\(1 - \\frac{\\text{edit\\_dist}(a, b)}{\\max(|a|, |b|)}\)) contre la vérité terrain — python-Levenshtein ou rapidfuzz vous donnent les deux en un appel.
  • Préservation des métadonnées hiérarchiques : pourcentage de chunks qui conservent correctement leur section parente, document parent et chemin d’ascendance. C’est la métrique qui décide si votre RAG peut répondre à des questions du type « que dit l’enfant de la policy X ? ».

Génération d’embeddings

  • Benchmarks de sélection de modèles : MTEB pour la capacité générale (nDCG@10 est la métrique phare ; le package Python MTEB permet de reproduire localement le leaderboard), BEIR pour la généralisation zero-shot, MIRACL pour le multilingue. Les meilleurs modèles de retrieval se regroupent dans une bande étroite de nDCG@10, mais les scores anglais MTEB prédisent mal les performances sur des langues moins dotées.
  • Évaluation spécifique au domaine : ne faites pas confiance aux benchmarks généraux pour des corpus métier. Construisez un golden set métier de 200–500 paires requête/document et rerankez dessus les modèles candidats avec ranx ou pytrec_eval. J’ai souvent vu un modèle classé #5 sur MTEB battre un modèle #1 de plus de 15 points sur un domaine particulier.
  • Détection de dérive des embeddings : suivez la KL distributionnelle ou la dérive basée modèle entre une fenêtre de référence fixe et les embeddings de production glissants ; la stabilité des plus proches voisins pour un ensemble de probes fixe est le signal pratique le plus simple. evidently et alibi-detect implémentent tous deux des détecteurs de dérive statistiques et basés modèle. L’étude comparative d’Evidently privilégie par défaut la détection de dérive basée modèle.
  • Multi-vector vs. single-vector : le late-interaction (ColBERT / ColBERTv2 — voir Khattab & Zaharia, 2020 ; implémentations de référence dans RAGatouille et PyLate) gagne généralement hors domaine pour un coût de stockage 6–10× plus élevé (avec compression de type PLAID ; non compressé c’est bien plus). Ça vaut le coup quand votre corpus est loin de la distribution d’entraînement du modèle d’embedding. Sinon, restez en single-vector.

Construction de l’index

  • Recall@k sous approximation : comparez l’index approximate-nearest-neighbour (ANN) à une baseline brute-force exacte au même k — dans FAISS, c’est IndexHNSWFlat (ou IndexIVFFlat) vs. IndexFlatIP/IndexFlatL2. Visez ≥95% de recall@10 vs. flat. Le projet ann-benchmarks suit les courbes de Pareto recall–QPS entre bibliothèques.
  • Réglage HNSW : HNSW (Hierarchical Navigable Small World — un graphe de proximité multicouche ; voir Malkov & Yashunin, 2018, implémenté dans hnswlib, le IndexHNSWFlat de FAISS, et la plupart des vector DB) expose trois paramètres : M (fan-out du graphe), efConstruction (largeur des candidats à la construction), efSearch (largeur des candidats à la requête). Valeurs pragmatiques : M=16–32, efConstruction=150, efSearch démarrant à 100 puis augmenté jusqu’au plateau de rappel. Un dataset de 10M de vecteurs avec efSearch=500 peut atteindre 98% de rappel à 5ms ; efSearch=100 tombe à 85% à 1ms. Choisissez le point de rappel exigé par votre jeu d’évaluation.
  • Réglage IVF : IVF (index Inverted File — partitionner les vecteurs par k-means en cellules nlist, puis à la requête scanner les cellules les plus proches nprobe ; voir les IndexIVFFlat et IndexIVFPQ de FAISS). Utilisez nlist ≈ √N comme heuristique initiale puis ajustez nprobe à l’exécution. IVF gère généralement mieux la recherche filtrée que HNSW, ce qui compte pour les systèmes ancrés sur une ontologie avec beaucoup de prédicats de métadonnées.
  • Retard de fraîcheur des mises à jour : temps entre le commit du document et sa retrievability. Suivez p50 et p99. Pour les systèmes avec des exigences réglementaires, suivez aussi le pourcentage de requêtes servies sur des index obsolètes.

Partie 4 — Évaluation online de l’inférence

Le couloir online est l’endroit où vivent la plupart des métriques de production. Beaucoup d’équipes s’arrêtent à Recall@k. Ce n’est pas suffisant.

Compréhension et rewriting de requête

  • Qualité de l’expansion de requête : gain de Recall@k sur votre golden set, requête étendue vs. brute. Si ce n’est pas au moins +5% sur les requêtes difficiles, votre expander fait plus de mal que de bien. Les baselines classiques de PRF (pseudo-relevance feedback) comme RM3 et Bo1 restent des sanity checks utiles ; l’expansion à base de LLM doit les battre.
  • Évaluation de HyDE : HyDE (Gao et al., 2022) génère une réponse hypothétique avec le LLM, l’embed, puis retrieve à partir de là — un outil utile qui ajoute de la latence et une surface d’hallucination. Évaluez-le par le gain de Recall@10 sur les requêtes hors domaine (là où il brille) et vérifiez qu’il n’y a pas de dégradation sur les requêtes dans le domaine (là où il peut nuire). Utilisez-le en fallback quand la confiance du retrieval est faible, pas par défaut. Ancrez le tout avec un reranker cross-encoder en aval pour valider les retrievals pilotés par hypothèse.
  • Génération multi-query : union Recall@k de N réécritures vs. une seule requête. Rendements décroissants au-delà de 3–4 réécritures. Implémentations : le MultiQueryRetriever de LangChain, le QueryFusionRetriever de LlamaIndex.
  • Précision de classification d’intention : précision/rappel/F1 standard par intention (calcul avec sklearn.metrics.classification_report), mais la métrique réellement opératoire est la routing correctness — est-ce le bon pipeline aval qui est invoqué ?
  • Routage adaptatif : Adaptive-RAG (Jeong et al., NAACL 2024) montre que toutes les requêtes ne méritent pas la même stratégie de retrieval. Suivez la précision du routeur comme un problème de classification sur un jeu annoté de « pas besoin de retrieval / one-shot / itératif ».

Métriques de retrieval

Ce sont les métriques de base. Si vous ne les suivez pas, vous ne pouvez pas savoir si le retrieval s’améliore.

Metric Ce qu’elle mesure Quand l’utiliser
Recall@k pourcentage de requêtes où au moins un doc pertinent est dans le top k la métrique de retrieval la plus importante pour le RAG ; si elle est basse, rien en aval ne compte
Precision@k pourcentage du top-k qui est pertinent utile quand la fenêtre de contexte est le goulot d’étranglement
MRR moyenne de 1/rang du premier doc pertinent quand les utilisateurs ne regardent que le top-1 ou top-3
nDCG@k gain à décote de position pondéré par des grades de pertinence métrique de retrieval standard pour la pertinence graduée
MAP moyenne sur les requêtes de la précision moyenne quand toute la liste ordonnée vous importe
Hit Rate@k version binaire de Recall@k métrique rapide de sanity check
Coverage pourcentage des golden docs effectivement retrouvés sur l’ensemble des requêtes détecte les trous systématiques de l’index

Les formules, pour référence (pertinence binaire avec ensemble pertinent \(R_q\) pour la requête \(q\), et \(\\text{rel}_i = 1\) si le \(i\)-ème document retrouvé est dans \(R_q\)) :

\[ \\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} \]
\[ \\text{RR}_q = \\frac{1}{\\text{rang du premier document pertinent}}, \\quad \\text{MRR} = \\frac{1}{|Q|} \\sum_{q \\in Q} \\text{RR}_q \]
\[ \\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}} \]

Pour la pertinence graduée, \(\\text{rel}_i \\in \\{0, 1, 2, \\dots\\}\) ; le nDCG binaire est le cas particulier utilisé dans le code ci-dessous. MAP est la moyenne sur les requêtes de \(\\text{AP}_q = \\frac{1}{|R_q|}\\sum_{i: \\text{rel}_i = 1} \\text{Precision@}i\). Voir Manning, Raghavan, Schütze, Introduction to Information Retrieval, chapitre 8, pour les dérivations.

Pour le code de production, utilisez ranx, pytrec_eval ou ir_measures — ils implémentent toute la famille de métriques TREC et gèrent correctement la pertinence graduée. Cibles de départ raisonnables : Recall@10 ≥ 0.85, MRR ≥ 0.6, nDCG@10 ≥ 0.7. Elles doivent être fixées contre un golden set réaliste, pas tirées d’un tutoriel.

Le harness de test pour ça est court. Vous pouvez l’exécuter depuis un notebook avant même d’avoir choisi une vector database.

from math import log2
from statistics import mean

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

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

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

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

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

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

Voilà votre garde-fou CI pour le retrieval. Branchez-le sur un golden set de 200 requêtes et exécutez-le à chaque PR. Si l’un des trois chiffres régresse, bloquez le merge et corrigez la régression.

Le repo compagnon fige exactement les chiffres ci-dessus (Recall@5 = 0.750, MRR = 0.625, nDCG@5 = 0.627) comme test unitaire dans tests/test_retrieval_metrics.py ; le notebook 01 explore Recall@k / MRR / nDCG sur un index SciFact réel, et le harness orienté production se trouve dans evaluation/retrieval.py.

Retrieval hybride et reciprocal rank fusion

BM25 (le scoreur lexical sparse classique de Robertson & Walker, 1994 — matching exact des termes avec pondération type TF-IDF et normalisation de longueur, disponible dans rank_bm25, Elasticsearch/OpenSearch, et la plupart des moteurs de recherche) plus fusion dense via Reciprocal Rank Fusion (Cormack, Clarke, Buettcher, SIGIR 2009) avec k=60 est un très bon défaut. RRF est agnostique au score, donc il évite les problèmes de normalisation de score qui arrivent avec l’interpolation linéaire. Si vous avez plus de 50 paires de requêtes annotées, essayez une combinaison convexe et ajustez α. Hybride plus un reranker cross-encoder bat généralement le retrieval dense-only ou sparse-only sur les corpus techniques, de logs et de code. Sur des corpus très sémantiques, le gain peut être faible. Mesurez sur vos données ; une mauvaise configuration de fusion peut sous-performer par rapport au dense-only.

L’implémentation tient en quelques lignes.

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

Notez ce que RRF ne fait pas : il ne regarde jamais les scores bruts de similarité. Un retriever dense qui renvoie un cosinus à 0.98 et une branche BM25 qui renvoie un score à 17.4 ne sont pas directement comparables. Si vous les normalisez avec des z-scores ou un min-max scaling, vous pouvez finir par favoriser la branche avec la plus forte variance dans ce batch.

RRF n’utilise que le rang. Si un retriever place un document en position 2, ce vote vaut 1 / (60 + 2), quel que soit le score brut qui a produit ce rang.

Hybride + RRF sur SciFact : le notebook 02 compare dense vs BM25 vs RRF avec des deltas par requête. Le fuser orienté production est dans retrieval/hybrid_rrf.py ; tests/test_rrf.py fige l’ordre canonique d3 / d2 / d1 à k=60.

Reranking

  • ΔnDCG / ΔMRR : la seule métrique honnête pour un reranker — le gain par rapport à l’absence de rerank, sur votre golden set, à la profondeur réellement utilisée par votre application. Calculez en exécutant vos métriques de retrieval avec et sans reranker sur des ensembles de candidats identiques.
  • Cross-encoder vs. bi-encoder : un bi-encoder embed la requête et le document indépendamment (un vecteur de chaque côté) et score par produit scalaire ; un cross-encoder concatène requête+document et exécute un seul forward pass avec attention conjointe sur les deux. Les cross-encoders gagnent presque toujours sur la pertinence, au prix d’un forward pass par candidat. Implémentation de référence : sentence-transformers CrossEncoder. Dans les benchmarks publiés, BGE-reranker-v2-m3 atteint ~80ms par 100 candidats sur GPU et ~350ms sur CPU, et égale Cohere Rerank en qualité sans coût récurrent. Prenez ces chiffres comme des ordres de grandeur — votre matériel et votre batch size les feront varier.
  • Listwise vs. pointwise : le pointwise score chaque paire (requête, document) indépendamment ; le listwise score toute la liste de candidats conjointement pour que le modèle optimise directement un objectif de ranking. Le listwise (BGE, ZeRank-2 avec sorties calibrées) gagne généralement sur nDCG ; le pointwise est plus facile à seuiller. Les probabilités calibrées de ZeRank-2 permettent d’utiliser de simples seuils score > 0.7 ; les scores bruts BGE/MiniLM nécessitent un réglage par 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}")

Un reranker est souvent l’ajout le plus rentable dans un pipeline RAG basique. Sur la plupart des corpus que j’ai vus, en ajouter un déplace Precision@1 de 15–40%. Si votre RAG n’en a pas, ajoutez-en un avant de perdre du temps sur des ajustements plus fins du retrieval.

ΔnDCG et ΔPrecision@1 d’un cross-encoder sur SciFact : notebook 03 ; module : retrieval/reranker.py.

Construction du contexte et lost-in-the-middle

C’est ici que viennent beaucoup des échecs « bon retrieval, mauvaise réponse ».

  • Pertinence du contexte : score de pertinence par chunk à partir de RAGAS ContextRelevancy ou d’un cross-encoder, agrégé en moyenne et en pourcentage de chunks sous un seuil.
  • Utilisation du contexte : parmi les chunks placés dans le contexte, combien ont réellement été cités ou utilisés dans la réponse. Calculez comme \(\\frac{|\\text{cited chunks}|}{|\\text{retrieved chunks}|}\) sur un échantillon annoté. Une faible utilisation (< 30%) signifie que vous payez des tokens inutiles.
  • Détection de lost-in-the-middle : évaluation synthétique où vous placez le chunk gold aux positions {premier, milieu, dernier} d’un long contexte et mesurez la justesse de la réponse. La dégradation en U est réelle et documentée dans Liu et al. (TACL 2023). Les modèles modernes font mieux que ceux de 2023, mais le biais persiste. Atténuations : reranker puis réordonner le top-k pour que le chunk au meilleur score soit premier ou dernier (le LongContextReorder de LangChain fait exactement cela), ou compresser agressivement les chunks du milieu. Mesurez avec une évaluation stratifiée par position, pas seulement avec un score agrégé. Une évaluation stratifiée par position, détaillée et exécutable, se trouve dans le notebook 06 (module : evaluation/lost_in_middle.py).
  • Compression du contexte : reportez le ratio de compression (tokens d’entrée / tokens de sortie) avec la justesse de la réponse. Outils : le ContextualCompressionRetriever de LangChain, LongLLMLingua. Si la compression fait baisser la justesse de plus de 2 points, vous êtes allé trop loin.

Partie 5 — Le taux de fausse exclusion des filtres

Cette métrique mérite sa propre section parce que la plupart des équipes l’ignorent, et qu’elle provoque de vrais incidents en production.

Un filtre dur de métadonnées comme tenant_id = X AND product = Y AND locale = en-US peut faire tomber le rappel effectif à zéro sans modifier les métriques standard de retrieval. Le document gold est exclu avant le début du ranking. Recall@k est calculé sur l’ensemble de candidats survivants, donc il peut sembler correct. La faithfulness est calculée sur le contexte récupéré, donc elle peut aussi sembler correcte ; le modèle a fidèlement répondu « Je ne sais pas. »

La branche rouge dans l’arbre est le mode de défaillance courant : le bon document existe, mais le filtre le retire avant le retrieval.

Taxonomie des échecs silencieux avec la métrique qui détecte chaque mode

La métrique

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

Pour la calculer, il vous faut (a) des doc IDs de vérité terrain pour chaque requête d’évaluation et (b) une instrumentation qui logge les prédicats de filtre appliqués, pas seulement les résultats finaux. Une cible raisonnable est < 2% sur le trafic de production. Si le taux est plus élevé, votre logique de filtrage détruit le rappel.

Voici une implémentation fonctionnelle qui montre aussi pourquoi cet échec est invisible dans un harness de retrieval écrit naïvement.

# 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

La moitié des requêtes perdent leur document gold à cause du filtre. Le harness de rappel naïf rapporte 50% et vous accusez le retriever. Le taux d’exclusion montre le vrai problème : c’est un bug de prédicat. Deux requêtes ont vu leur réponse supprimée avant même que le retriever ne s’exécute. Aucun modèle ne peut récupérer un document qui a déjà été filtré.

Le taux de 50% ci-dessus est reproduit comme test unitaire dans le repo compagnon : tests/test_filter_exclusion.py::test_50_percent_exclusion_rate. Le notebook 04 l’exécute sur SciFact avec des métadonnées synthétiques pour que vous puissiez voir un vrai filtre mettre le rappel à zéro ; la métrique runtime (avec le compagnon predicate-precision/recall) se trouve dans evaluation/filter_exclusion.py.

Métrique compagne : précision et rappel des prédicats

Quand le filtrage est dynamique (par exemple, un LLM extrait des prédicats de filtre depuis la requête), traitez l’extracteur de prédicats comme un modèle de classification et évaluez-le comme tel. Précision/rappel des prédicats contre un jeu annoté de paires (requête, prédicat correct). Si votre extracteur se trompe 8% du temps et applique des filtres durs, vous avez un plafond dur de rappel autour de 92%, et aucun reranking ne vous aidera.

Soft boost vs. hard filter

Cette métrique force une décision de conception. Utilisez des hard filters quand la correction est binaire : juridiction légale, frontières ACL, publié vs. brouillon. Utilisez des soft boosts quand la pertinence est graduée : préférence de locale, récence, version. Sans mesure du taux d’exclusion, il est difficile de voir que vous avez fait le mauvais choix.

La règle de décision, mesurable :

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.

Un ε dans la plage 1–2% est raisonnable ; plus bas pour les domaines à fort enjeu. Un article dédié de cette série ira plus loin sur ce compromis.


Partie 6 — Évaluation de la génération

Les métriques de retrieval vous disent que le système pourrait répondre correctement. Elles ne vous disent pas qu’il l’a effectivement fait. Les métriques de génération couvrent cet écart.

Faithfulness et groundedness

La faithfulness de RAGAS décompose la réponse en revendications atomiques (des énoncés factuels courts et autonomes), puis vérifie chacune d’elles par rapport au contexte récupéré via un LLM judge :

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

Le pourcentage de claims supportées est le score. La structure est plus utile que n’importe quel chiffre unique, parce qu’elle vous dit quelles claims ne sont pas supportées. Le code de production se trouve dans le package ragas — l’usage ressemble à ceci :

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)

Ci-dessous, la même boucle déroulée avec un judge déterministe de substitution pour que vous puissiez voir la forme complète de bout en bout.

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)

La structure compte. En production, verify_claim devient un modèle NLI ou un appel LLM. Le reste du harness reste inchangé : extraire, vérifier, agréger.

Extraction + vérification de claims de bout en bout sur des réponses SciFact générées : notebook 05 ; module : evaluation/faithfulness.py. Le repo exécute aussi un vérificateur cross-family de style HHEM dans la même boucle pour que vous puissiez voir quelle famille de judges est d’accord avec quelle autre.

Une alternative conçue spécifiquement au LLM-as-judge est HHEM-2.1-Open (Hughes Hallucination Evaluation Model, Vectara), un classifieur de 600 MB fine-tuné pour la détection d’hallucinations. Le seuil par défaut est généralement 0.5 (>0.5 = factuel, ≤0.5 = halluciné), mais calibrez-le sur votre propre jeu annoté. Il s’exécute sur CPU et surperforme, selon les rapports, les judges LLM génériques sur AggreFact et RAGTruth. Les versions commerciales plus récentes HHEM-2.3 et FaithJudge se situent sur la frontière de Pareto actuelle du leaderboard Vectara. Re-benchmarkez avant de vous engager ; les leaderboards dérivent.

Évaluation par faits atomiques

FActScore (Min et al., EMNLP 2023) décompose les générations longues en faits atomiques, retrieve de la preuve pour chaque fait, annote chacun supported / not-supported, puis rapporte la fraction supportée :

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

Implémentation de référence : shmsw25/FActScore. Ça fonctionne bien pour les biographies, résumés et autres sorties longues. Attention : des faits triviaux répétés peuvent gonfler le score, et les attaques « MontageLie » (faits vrais dans un ordre trompeur) peuvent le contourner. VeriScore gère les claims avec modificateurs nécessaires ; le filtre Core aide à éviter le fact-padding.

Précision des citations

Suivez la précision des citations (les spans cités supportent réellement la claim) et le rappel des citations (les claims qui devraient être citées le sont) :

\[ \\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}|} \]

La support evaluation du TREC 2024 RAG Track est le standard académique. Upadhyay et al. (SIGIR 2025) rapportent que GPT-4o est d’accord avec des juges humains 56% du temps lors d’une évaluation manuelle from scratch, montant à 72% avec post-édition des prédictions LLM. C’est utile comme multiplicateur de force, pas comme remplacement de l’évaluation humaine dans des contextes à fort enjeu. Pour une approximation automatisée, ALCE (Gao et al., EMNLP 2023) implémente précision/rappel des citations avec vérification basée NLI.

Justesse de la réponse, complétude, refus

  • Justesse de la réponse vs. vérité terrain : quand vous l’avez, exact match ou token-F1 pour les tâches à réponse courte (evaluate.load("squad")), similarité sémantique pour les réponses ouvertes (bert-score, cosinus d’embeddings via sentence-transformers, ou AnswerCorrectness de RAGAS).
  • Complétude via les nuggets : un « nugget » est une unité atomique d’information que toute bonne réponse doit contenir (par exemple, pour « Quand l’entreprise a-t-elle été fondée ? », les nuggets peuvent être {year: 1994, founder: Jane Doe}). L’AutoNuggetizer de TREC extrait les nuggets gold d’une bonne réponse depuis une référence, puis score la fraction couverte par le système — forte corrélation avec l’évaluation manuelle sur 21 sujets × 45 runs au TREC 2024.
  • Comportement de refus : les requêtes sans réponse dans le corpus doivent produire une abstention, pas une hallucination. Suivez la précision de l’abstention (les refus qui étaient corrects) et le rappel de l’abstention (les requêtes hors périmètre qui ont déclenché un refus). NoMIRACL est le benchmark public ; dans votre domaine, annotez une tranche de requêtes hors périmètre et suivez la précision de l’abstention.

Vérification post-génération

Les gains de fiabilité les moins chers viennent souvent de post-vérifications déterministes, pas de modèles plus gros.

  • Vérification d’ancrage des entités : chaque entité nommée de la réponse doit apparaître dans (ou être dérivable du) contexte récupéré. Un simple contrôle regex + exact-match (ou spaCy avec ents contre une chaîne de contexte normalisée) attrape une fraction surprenante des hallucinations.
  • Vérification de claims : extraire les claims, exécuter une NLI contre le contexte, échouer ou signaler toute claim sous le seuil. Modèles NLI-as-faithfulness : cross-encoder/nli-deberta-v3-large, MoritzLaurer/DeBERTa-v3-large-mnli-fever-anli-ling-wanli. Ajoute de la latence. Ça vaut le coup dans les domaines à fort enjeu.
  • Self-consistency (Wang et al., ICLR 2023) : échantillonner N=5 générations à température > 0 ; rapporter le taux d’accord (par exemple, proportion de générations qui correspondent à la réponse modale, ou BERTScore pairwise) ; signaler les réponses à faible accord pour revue humaine.
  • Calibration de la confiance : collecter une confiance verbalisée (« À quel point êtes-vous confiant, 0–1 ? ») et la comparer à la justesse réelle sur le jeu d’évaluation. Tracer une courbe de calibration et reporter l’Expected Calibration Error : \(\\text{ECE} = \\sum_{m=1}^{M} \\frac{|B_m|}{n} |\\text{acc}(B_m) - \\text{conf}(B_m)|\), où \(B_m\) sont des bins de confiance. Implémentations : netcal, torchmetrics.CalibrationError. Un modèle qui dit 0.9 devrait avoir raison 90% du temps. Ce n’est presque jamais le cas.

Partie 7 — Évaluation du RAG ancré sur une ontologie

Les métriques standard ci-dessus couvrent le RAG open-corpus. Les systèmes ancrés sur une ontologie ont besoin de plus. Si votre RAG retrieve contre une ontologie structurée, une taxonomie ou un graphe de connaissances (produits dans un catalogue, conditions dans SNOMED, composants dans une BOM, techniques de sécurité dans MITRE ATT&CK), les métriques RAG standard sont nécessaires mais insuffisantes. Vous devez aussi mesurer la couche ontologique.

Précision de l’entity linking

La première tâche consiste à mapper une mention dans la requête à une entité de l’ontologie (« Aspirin » → wikidata:Q18216, « the 737 » → aircraft:Boeing_737).

  • Précision/rappel/F1 au niveau mention : standard, contre des spans gold de mentions (calcul avec seqeval ou un comparateur d’ensembles de spans).
  • Précision de désambiguïsation : parmi les mentions correctement détectées, quelle fraction est mappée vers le bon entity ID ? Références publiques : ReFinED, REL et GENRE ; des benchmarks comme AIDA-CoNLL et BELB rapportent des F1 bout en bout dans une plage de 60–90% selon le système et le domaine.
  • Gestion du NIL : précision/rappel sur « entité absente de l’ontologie ». C’est là que la plupart des systèmes EL de production échouent discrètement. Ils sur-lient vers une entité proche mais fausse au lieu de s’abstenir.

Évaluation aware de la hiérarchie

Une précision brute traite « prédit Sedan alors que la vérité est Hatchback » comme « prédit Sedan alors que la vérité est Submarine ». Ces erreurs ne se valent pas.

  • Précision/rappel/F1 hiérarchiques (Kosmopoulos et al., 2015) : créditer ancêtres et descendants dans le DAG de l’ontologie. Avec \(\\hat{P}_q\) le nœud prédit plus tous ses ancêtres et \(T_q\) le nœud vrai plus tous ses ancêtres :

    \[ 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} \]

    Implémentable en ~30 lignes avec networkx sur le graphe de l’ontologie ; voir hierarchical-classifier-metrics pour une référence.

  • Similarité Wu-Palmer entre entité prédite et entité gold dans la taxonomie (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)} \]

    où LCA est le plus bas ancêtre commun dans la taxonomie. Disponible out of the box dans NLTK pour WordNet (from nltk.corpus import wordnet as wn; wn.synset("car.n.01").wup_similarity(wn.synset("truck.n.01"))) ; pour des taxonomies custom, calculez LCA avec networkx.

  • Taux de confusion sibling/parent : suivez séparément les confusions vers les siblings, parents et enfants — count_sibling / total_errors, count_parent / total_errors, count_descendant / total_errors. Les confusions entre siblings signifient généralement des mentions ambiguës ; les confusions vers le parent signifient que le modèle remonte prudemment dans la hiérarchie.

Taux de fausse exclusion des filtres (bis, maintenant critique)

Dans les systèmes ancrés sur une ontologie, les hard filters viennent souvent de l’ontologie elle-même (« ne retrieve que les docs taggés avec la catégorie X »). La métrique de taux d’exclusion (définie en Partie 5) devient un signal primaire de correction. Une mauvaise prédiction de catégorie peut silencieusement réduire le rappel à zéro.

Conformité de la génération contrainte

Quand votre sortie doit respecter une ontologie (chaque nom d’entité dans la réponse doit être un membre valide de l’ontologie ; chaque prédicat doit venir d’un vocabulaire fermé), mesurez :

  • Taux de validité du schéma : pourcentage de sorties qui se parsent et valident contre le schéma de l’ontologie. Validez avec jsonschema ou pydantic. JSONSchemaBench est le benchmark public pour les structured outputs en général ; pour des schémas spécifiques à une ontologie, construisez votre propre validateur.
  • Conformité du vocabulaire : pourcentage d’entités nommées dans la sortie qui sont des IDs d’ontologie valides — un simple test d’appartenance à un set contre le vocabulaire fermé.
  • Conformité sémantique : la validité est nécessaire mais insuffisante. Une sortie syntaxiquement valide peut choisir la mauvaise entité tout en restant valide. Couplez la conformité à la justesse de la réponse en aval.

Les frameworks de constrained decoding (Outlines, XGrammar, Guidance, OpenAI Structured Outputs) permettent d’atteindre ~100% de validité de schéma pour un coût de latence modeste. D’après JSONSchemaBench, Guidance mène actuellement sur le front de Pareto efficacité × couverture × qualité.

Auditabilité

Pour les systèmes ancrés sur une ontologie dont les réponses sont soumises à revue :

  • Complétude des citations : pourcentage de claims factuelles ayant au moins une citation vérifiable.
  • Profondeur de provenance : pourcentage de citations qui se résolvent jusqu’à un document source avec un ID stable, pas seulement un hash de chunk.
  • Taux de reproductibilité : relancer la même requête sur un snapshot fixe renvoie la même réponse (à température près). Si ce n’est pas ~100% avec temp=0, vous avez de la non-déterminisme ailleurs dans le pipeline.

Partie 8 — Évaluation au niveau système

Qualité globale des réponses

  • LLM-as-judge (Zheng et al., NeurIPS 2023) : l’approche dominante. G-Eval (un protocole de LLM-judge où le modèle génère sa propre grille chain-of-thought avant de scorer) génère automatiquement la rubrique à partir d’un critère en langage naturel, puis score avec une sortie pondérée par log-prob. Forte alignement humain avec des juges de classe GPT-4.
  • Préférence pairwise : présenter au judge la réponse A vs. la réponse B ; enregistrer sa préférence. Évite les problèmes de calibration des scores absolus. Environ 80% d’accord humain-judge au niveau GPT-4, ce qui correspond à l’accord humain-humain.

Le LLM-as-judge a de vrais biais :

  • Biais de position : les juges préfèrent la première ou la seconde réponse indépendamment de la qualité. Atténuation : randomiser l’ordre, ou exécuter les deux ordres puis faire la moyenne.
  • Biais de verbosité : les juges préfèrent les réponses plus longues. La recherche 2025–2026 est plus nuancée. Les juges modernes instruction-tunés pénalisent le remplissage dans les tests à longueur contrôlée mais récompensent la vraie complétude sur des paires tronquées. Malgré tout, dites explicitement au judge comment traiter la longueur, et envisagez des win rates à longueur contrôlée.
  • Biais d’auto-préférence : GPT-4 préfère les sorties GPT-4 ; le biais corrèle avec la perplexité de sortie (les juges préfèrent le texte qui leur est familier). Atténuation : utiliser une famille de judge différente de celle du système évalué. N’utilisez jamais un modèle pour se juger lui-même.

Recette pratique : GPT-4o ou Claude comme judge, ordre randomisé, identités des modèles masquées, politique explicite sur la longueur dans la rubrique, et moyenne sur plusieurs runs. Pour les évaluations à fort enjeu, utilisez deux juges et analysez les désaccords.

Schema-Guided Reasoning pour les juges

La sortie libre des juges est la principale raison pour laquelle les runs de jugement sont difficiles à reproduire. Deux runs sur la même réponse peuvent donner des scores différents non pas parce que le judge a changé d’avis, mais parce qu’il a organisé son raisonnement différemment. La solution consiste à forcer le judge dans une rubrique structurée — ce que j’appelle Schema-Guided Reasoning (SGR) : définir les étapes de raisonnement comme un schéma Pydantic, exécuter avec constrained decoding (Outlines, XGrammar, structured outputs de vLLM, response_format d’OpenAI), et le judge doit émettre chaque champ dans l’ordre. Pas d’étapes sautées, pas de biais caché en faveur des réponses plus longues.

Pour l’évaluation RAG, le schéma décompose le jugement en champs explicites et auditables, au lieu de laisser le modèle sauter directement à un chiffre :

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

Trois choses changent une fois que le judge est contraint à cette forme. Le score devient reconstructible à partir des champs structurés (len(supported) / len(extracted)), donc le biais de position et le biais de verbosité ont moins d’espace pour opérer. Les désaccords entre deux juges deviennent diagnostiquables — vous pouvez voir exactement quelle claim chaque juge a signalée. Et comme la rubrique est le schéma, vous pouvez la versionner comme du code : un changement de rubrique est un diff Pydantic, pas une réécriture de prompt.

Ça fonctionne pour n’importe quel judge basé sur une rubrique, pas seulement la faithfulness. La préférence pairwise, le support des citations et la justesse du refus bénéficient tous du même traitement.

Un harness G-Eval / pairwise / biais de position / juges cross-family se trouve dans le notebook 07 ; module : evaluation/llm_judge.py. Le benchmark sweep (make benchmark dans le repo) branche trois modèles de classe frontier — gpt-5-mini, claude-haiku-4-5, gemini-2.5-flash — dans un A/B pairwise à judge tournant, afin que chaque modèle juge les deux autres, ce qui fait apparaître numériquement l’auto-préférence.

Latence et coût

  • p50, p95, p99 à chaque étape du pipeline. p95 est la bonne cible SLO (service level objective) pour la plupart des applications ; p99 est ce sur quoi vous alertez.
  • Time-to-first-token vs. temps total de génération. Les utilisateurs se soucient du TTFT pour l’UX streaming.
  • Décomposition par étape : retrieval, reranking, génération, post-processing. Les plus gros pics p95 viennent presque toujours de rerankers exécutés sur CPU.
  • Total $/query = embedding + retrieval + rerank + génération + amortissement du stockage. Suivez p50 et p99 ; la longue traîne est là où part le budget.
  • Taux de cache hit aux niveaux embedding cache, retrieval cache et KV-cache. Un taux >30% est généralement atteignable sur des charges répétitives et c’est l’optimisation de coût la moins chère.

Le p50/p95/p99 par étape avec décomposition est intégré dans le notebook 08 et le runner à evaluation/latency.py ; le rapport de benchmark combine latence et faithfulness dans une matrice unique que vous pouvez relancer avec make benchmark.

Tests A/B

  • Unité de randomisation : par utilisateur ou par session, jamais par requête (un même utilisateur qui voit une qualité incohérente, c’est pire que l’un ou l’autre système seul).
  • Métriques primaires, garde-fous, exploratoires : pré-enregistrez-les. La primaire est généralement un proxy de satisfaction (thumbs / régénérations / dwell). Les garde-fous sont la latence et le coût. Les métriques exploratoires sont tout le reste.
  • Taille d’échantillon : faites une power analysis avant de lancer. La plupart des tests A/B RAG sont sous-dimensionnés, déclarent de faux gains et livrent des régressions.

Partie 9 — Construction du jeu de test

Une métrique n’est bonne qu’à hauteur du jeu de test sur lequel elle tourne. Si votre golden set couvre trois intentions alors que le trafic de production en couvre douze, votre Recall@10 mesure en réalité trois intentions déguisées. Pire, un jeu de test qui sur-apprend aux questions faciles (« What is the company's refund policy? ») validera discrètement un système qui échoue sur les questions difficiles (« Refund eligibility for a partial cancellation under the 2023 EU Digital Services Act, billed in EUR, originating in Ireland? »). Le chiffre monte, le tableau de bord passe au vert, et le système est livré cassé.

Le même problème touche la vérité terrain. Si les SMEs ont annoté les documents évidents mais ont raté ceux pertinents de longue traîne, Recall@k sous-créditera un retriever qui les a réellement trouvés. Vous optimisez vers les labels, pas vers la vérité.

Le bon ordre est donc : construire d’abord le jeu de test qui capture la distribution réelle et la vraie difficulté ; choisir ensuite les métriques sensibles aux modes de défaillance qui vous importent ; ajuster le système en troisième.

Génération de requêtes synthétiques

Utilisez un LLM pour générer des questions à partir de votre corpus :

  • Par chunk : « Generate 3 questions a user might ask that this chunk answers. »
  • Multi-hop : échantillonner deux chunks, générer une question qui exige les deux.
  • Adversarial : générer des questions avec des entités distractrices, un phrasé quasi-duplicata, des mentions ambiguës.

RAGAS intègre une distribution par type de question (raisonnement, conditionnel, multi-contexte) ; des travaux plus récents comme DataMorgana génèrent des benchmarks synthétiques plus divers via des catégorisations multi-axes utilisateur/question. Les données synthétiques sont utiles pour les cold starts et les tests de couverture. Elles ne peuvent pas remplacer les vraies requêtes utilisateurs.

Construction du golden dataset

Le jeu de données le plus solide est construit par des humains.

  1. Échantillonnez des requêtes utilisateurs réelles (ou simulées si pré-lancement) stratifiées par intention.
  2. Demandez à des SMEs de répondre à chaque question et d’identifier quel(s) document(s) contiennent la réponse.
  3. Visez 200–500 requêtes minimum ; la couverture importe plus que la taille.
  4. Re-curate tous les trimestres. Les distributions dérivent.

Jeux de tests adversariaux

  • Contre-factuels : échangez les entités clés dans la requête. Le système retrieve-t-il les bons chunks pour la requête modifiée ?
  • Distracteurs : requêtes où le corpus contient une réponse plausible mais fausse qui ne doit pas être récupérée. C’est ce que RGB (Chen et al., AAAI 2024) stress-teste : robustesse au bruit, rejet du négatif, intégration d’information et robustesse contrefactuelle.
  • Négation et quantificateurs : requêtes contenant « not », « except » et « only ». Les retrievers denses peinent souvent là-dessus.
  • Hors périmètre : requêtes sans réponse dans le corpus. Le système doit répondre « Je ne sais pas », pas halluciner. NoMIRACL est dans cette catégorie. La plupart des modèles de production ont besoin d’une évaluation explicite de l’abstention.

Couverture et évaluation continue

  • Construisez une matrice de couverture : intention de requête × type de document × branche d’ontologie. Visez ≥1 requête par case. Les cases vides sont des zones non surveillées où les régressions se cachent.
  • La suite de régression s’exécute à chaque PR, sur un petit sous-ensemble rapide (~50 requêtes).
  • L’évaluation complète s’exécute chaque nuit ou sur les release candidates, sur tout le golden set.
  • L’évaluation de dérive s’exécute chaque semaine sur un échantillon glissant de requêtes de production (avec un poids plus fort sur les requêtes thumbs-down).

Partie 10 — Monitoring en production

La suite d’évaluation que vous livrez décrit le système au lancement. Le trafic de production change ensuite.

Feedback implicite et explicite

  • Click-through / open rate sur les sources citées (si votre UI les expose).
  • Dwell time sur la réponse.
  • Taux de régénération : pourcentage de réponses que l’utilisateur redemande ou demande au système de refaire. Le signal implicite de mécontentement le plus fort dans la plupart des produits.
  • Taux de copy / share / export — fort signal positif.
  • Patterns de suivi : des formulations comme « Are you sure? » ou « But what about X? » suggèrent une défiance.
  • Thumbs up/down avec catégories de raison optionnelles (faux, incomplet, hors sujet, dangereux, lent). Les éditions inline, quand votre UI le permet, sont le signal de feedback le plus riche qui soit.

Détection de dérive

  • Dérive des requêtes : suivez la distribution des embeddings de requête vs. une fenêtre de référence avec divergence KL, MMD ou un détecteur basé modèle. Alertez sur déplacement, puis débuggez par segmentation.
  • Dérive des embeddings : fixez un ensemble de probes de documents ; ré-embedez-les périodiquement et mesurez le cosinus par rapport aux embeddings d’origine. Même une faible dérive entre versions de modèles provider casse silencieusement le retrieval. Le stockage versionné des embeddings (snapshots immuables par version) est la mitigation la moins chère.
  • Dérive de performance : suivez dans le temps des métriques équivalentes à la production (par exemple, taux de régénération par intention). Les sauts soudains signifient qu’un élément s’est cassé ; les dérives lentes signifient que le monde a changé.

Shadow evaluation et human-in-the-loop

Exécutez le système candidat en parallèle de la production, comparez les sorties offline, et ne les servez pas aux utilisateurs. Ça attrape les régressions avant le lancement. Ça coûte un supplément d’inférence, mais sans impact client.

Pour la revue human-in-the-loop (HITL) :

  • Échantillonnez les sorties à faible confiance dans une file de revue.
  • Échantillonnez aléatoirement 1–2% de tout le trafic de production pour revue à l’aveugle.
  • Pesez fortement les sorties thumbs-down.
  • Utilisez les sorties revues pour étendre le golden set.

L’ensemble minimal de garde-fous

Alertez sur les points suivants, dans cet ordre de priorité :

  1. Score Faithfulness/HHEM sous le seuil sur un échantillon glissant de production.
  2. Latence p95 au-dessus du SLO.
  3. Taux de fausse exclusion des filtres au-dessus du seuil (sur échantillon).
  4. Taux de régénération au-dessus de la baseline + 2σ.
  5. Coût/requête au-dessus du budget.

Si une alerte se déclenche sans changement de code ni de modèle correspondant, vous avez probablement de la dérive. Si elle se déclenche après un changement, vous avez probablement une régression. Dans les deux cas, vous obtenez un signal avant l’arrivée des tickets support.


Réserves

  • Les cibles sont illustratives, pas universelles. « Recall@10 ≥ 0.85 » et « fausse exclusion des filtres < 2% » sont des valeurs par défaut raisonnables issues de systèmes sur lesquels j’ai travaillé. Calibrez selon votre domaine, vos enjeux et les attentes utilisateurs. Un RAG médical à 95% de faithfulness n’est pas sûr ; un RAG d’assistance au brainstorming à 70% l’est probablement.
  • L’espace des frameworks évolue vite. Les chiffres spécifiques (latence BGE, meilleurs scores MTEB, versions HHEM, noms de métriques RAGAS) sont exacts à la date de rédaction en mai 2026 et dériveront. Re-benchmarkez avant de vous engager.
  • Les chiffres d’accord du LLM-as-judge ont des astérisques. Le chiffre de 80% GPT-4-vs-human vient des conditions MT-Bench / Chatbot Arena. Sur des domaines de niche et des cas adversariaux, l’accord chute fortement. Utilisez les juges comme multiplicateurs de force, pas comme substituts au spot-checking.
  • Les gains des benchmarks vendors sont souvent difficiles à reproduire indépendamment. Reproduisez sur vos propres données avant de croire un chiffre, surtout pour les rerankers récents et les systèmes OCR.
  • Aucune métrique ne remplace la lecture des sorties. Asseyez-vous avec votre équipe 30 minutes par semaine et lisez 50 réponses de production aléatoires. Les métriques permettent de passer à l’échelle de cette habitude ; elles ne la remplacent pas.

À venir dans cette série

Ceci était l’index. Voici les articles de suivi que je prévois :

  • Soft Boosts vs. Hard Filters : une plongée approfondie sur le taux de fausse exclusion des filtres, avec code, vrais exemples de production et cadre de décision.
  • Chunking Is the Hidden Variable : une expérience contrôlée entre chunking récursif, sémantique, late et structurel sur trois corpus.
  • Reranker Selection in 2026 : BGE vs. Cohere vs. ZeRank vs. modèles cross-encoder actuels, en comparaison directe sur coût, latence et gain.
  • Ontology-Grounded RAG: An End-to-End Walkthrough : construction du harness d’évaluation complet pour un système de retrieval ancré sur les entités.
  • LLM-as-Judge Without the Self-Preference Trap : recettes pratiques pour une évaluation automatisée sans biais.
  • Online Evaluation in Production : patterns d’instrumentation, politiques d’alerte et tableaux de bord qui détectent les vraies régressions.

Points clés à retenir

  1. Commencez par le jeu d’évaluation, pas par l’architecture. Définissez numériquement ce que signifie « meilleur » avant de choisir le design du système.
  2. Utilisez trois couches d’évaluation. Corpus et index offline. Retrieval et génération online. Vérification post-génération plus télémétrie de production. Chacune attrape une classe d’échec différente.
  3. Suivez le taux de fausse exclusion des filtres. Un prédicat erroné ou un hard filter fragile met le rappel à zéro avant le début du ranking, et les métriques standard de retrieval ne le verront pas.
  4. La faithfulness mesure le dernier maillon de la chaîne. Elle ne peut pas détecter un bug de parsing, de chunking, une dérive d’embeddings ou une exclusion par filtre. Chaque étape a besoin de sa propre métrique.
  5. Le retrieval hybride avec RRF est le défaut robuste. Agnostique au score, immunisé contre les catastrophes de normalisation, k=60 d’après l’article original de Cormack. Hybride plus un reranker cross-encoder bat généralement chaque branche prise seule sur la plupart des corpus.
  6. Ajoutez un reranker avant d’optimiser quoi que ce soit d’autre. Sur la plupart des corpus, il déplace Precision@1 de 15–40%, soit plus de gain que tout autre changement isolé.
  7. Le LLM-as-judge a de vrais biais. Position, verbosité, auto-préférence. Randomisez l’ordre, masquez les identités, n’utilisez jamais un modèle pour se juger lui-même, et utilisez deux juges sur les évaluations à fort enjeu.
  8. La production dérive. Shadow eval, files HITL et échantillons glissants de production gardent la suite d’évaluation du lancement pertinente à mesure que le trafic change.

Références

Frameworks et benchmarks

Retrieval et ranking

Génération, faithfulness, judges

Dérive et production

Code compagnon

  • slavadubrov/rag-evals-demo — harness exécutable pour chaque métrique de cet article sur le corpus SciFact, plus un benchmark sweep chunking × embedding × LLM. Notebooks 00–09, tests unitaires qui figent les exemples travaillés ci-dessus, et un index Qdrant embarqué pour une exécution sans Docker.