Votre RAG vous ment : pourquoi vos métriques d’éval sont (probablement) fausses

J’ai changé mon chunking de 800 à 1200 tokens, j’ai relancé l’éval, et mon hit@1 a fait +12 %. Pendant trente secondes, j’ai cru que j’avais trouvé l’optimum. Pendant les trente suivantes, je me suis rendu compte que j’avais simplement cassé le routing entre mes corpus, et que mon éval comparait désormais des questions allemandes du MSS60 à un index français du MSS54.

Le +12 % n’était pas une amélioration. C’était un artefact. Une métrique en hausse, produite par un pipeline d’éval cassé, ressemble exactement à une métrique en hausse produite par une vraie amélioration. C’est même pour ça que personne ne s’en rend compte.

Ce billet est consacré aux trois pièges silencieux qui font que la plupart des équipes RAG prennent leurs décisions sur des métriques qui ne mesurent rien. Ce ne sont pas des cas d’école : ce sont les trois trous que j’ai bouchés dans mon propre pipeline, sur un corpus de 65 modules de documentation technique automobile (Bosch MSS54 / Siemens MSS60), après avoir compris que je m’étais menti à moi-même pendant des semaines. Le contexte est automobile, mais la mécanique est strictement transposable : remplacez MSS54/MSS60 par n’importe quel cas où vous avez deux sources techniques distinctes (doc produit v1/v2, deux entités juridiques, deux langues, deux marques). Les pièges sont les mêmes.

L’éval RAG est un système, pas un benchmark

Si vous lisez la littérature RAG depuis dix-huit mois, vous avez vu passer des milliers de mots sur le chunking, les embeddings, le hybrid search, les rerankers. Vous avez probablement lu mon propre Le RAG expliqué comme personne ne le fait. Vous avez vu deux ou trois benchmarks publics, MTEB en tête, et vous en avez peut-être tiré l’idée qu’évaluer un RAG, c’est comme évaluer un modèle : on prend un dataset, on calcule un score, on conclut.

Ce raccourci est la racine du problème. L’évaluation d’un système de recherche augmentée n’est pas un score, c’est une infrastructure. C’est un pipeline qui doit lui-même être versionné, monitoré et débugué. Une métrique RAG sans son contexte d’éval, c’est un thermomètre qu’on lit sans savoir où il est planté.

Les trois pièges qui suivent ont tous la même structure : ils produisent des chiffres qui ont l’air corrects, mais qui mesurent autre chose que ce qu’on croit mesurer. Ils sont parfaitement invisibles tant qu’on ne va pas chercher à les voir.

Le préalable invisible : la fabrication du ground truth

Tout ce qui suit suppose un fichier questions.yml déjà existant. Dans les faits, sa fabrication est souvent le goulot d’étranglement réel d’un projet RAG, celui dont personne ne parle parce qu’il est moins glamour qu’un reranker.

Mon workflow n’a rien d’original mais m’a coûté quelques erreurs avant d’arriver à sa forme actuelle. Pour chaque module du corpus, je passe le markdown source à Claude Sonnet avec une instruction qui demande cinq questions techniques précises, leur réponse de référence et trois à cinq mots-clés attendus dans une bonne réponse. Sortie en YAML structuré. Puis, étape non négociable, je relis manuellement chaque entrée, je reformule celles qui sont mal posées, je corrige les réponses de référence (souvent trop optimistes ou trop générales), j’ajuste les mots-clés. Sur mes 255 questions, environ 30 % ont été modifiées après génération, et une dizaine ajoutées à la main pour couvrir des cas que le LLM n’avait pas vus, typiquement les questions cross-corpus qui exigent de connaître les deux calculateurs.

Le piège majeur, c’est de laisser le LLM générer la question, la réponse, et ensuite juger sa propre génération. Dans cette configuration, vous mesurez la cohérence interne du modèle, pas la qualité de votre RAG. Tout score obtenu est strictement décoratif. La validation humaine sur les réponses de référence est le minimum incompressible : c’est elle qui ancre l’éval dans une vérité externe au modèle évalué.

Au-delà de cette première vague, questions.yml doit vivre. Quand une vraie requête en prod met le système en difficulté, elle entre dans le fichier après annotation. Quand un module s’ajoute au corpus, il vient avec ses cinq questions. Un dataset d’éval figé qui ne grossit jamais ment de plus en plus avec le temps, exactement comme une test suite logicielle qu’on n’enrichit plus dérive en couverture sans qu’aucun voyant rouge ne s’allume.

Piège n°1 : Le routing multi-corpus, ou comment évaluer MSS54 sur l’index MSS60 sans s’en rendre compte

Tant que vous avez un seul corpus, vous êtes à l’abri. C’est l’écrasante majorité des tutoriels RAG : un dossier de PDFs, un index, un script de retrieval, point.

Le problème commence à l’instant exact où vous avez deux corpus distincts. Pour moi, ce fut le moment où, après avoir ingéré 39 modules de la documentation Siemens MSS60 (le calculateur des BMW M3 E92 et M5 E60), j’ai ajouté un deuxième corpus pour le Bosch MSS54 (M3 E46, S54 et al). Deux corpus, deux index, deux univers techniques qui parlent de moteurs apparentés mais pas identiques.

Voici ce qui se passe par défaut, dans 99 % des pipelines RAG que j’ai vus en production : votre script d’éval charge questions.yml (255 questions chez moi), construit un client vectoriel, et lance les requêtes. Toutes les requêtes. Contre le même index. Celui qui était instancié au moment du démarrage du script.

Résultat : vos questions MSS54 sont évaluées contre l’index MSS60. Le retriever fait son travail, retourne des chunks, le LLM génère une réponse, le juge attribue un score. Tout fonctionne. Sauf que le contenu retourné n’a aucun rapport avec la question posée : il vient simplement du mauvais corpus. Et comme le LLM est poli, il va répondre quelque chose de cohérent en apparence, qui obtient un score médiocre mais pas catastrophique. Vos métriques chutent légèrement, vous concluez que votre chunking est sous-optimal, vous passez deux semaines à itérer sur le chunking.

Vous avez itéré deux semaines sur le mauvais problème.

La solution est conceptuellement simple, mais elle exige de réécrire la boucle d’éval : chaque question doit être évaluée contre son index natif, identifié par son doc_id. Dans mon code, c’est _build_doc_id_config_map() dans eval/run_eval.py, une fonction qui scanne ./documents/ au démarrage, construit une table de routage doc_id → config.yml du module, et qui est consultée pour chaque question. Sans elle, mes métriques étaient des moyennes pondérées de comparaisons qui n’avaient pas de sens.

L’extension qui complique encore le tableau : les questions cross-corpus. Une question du type « quelles différences entre la stratégie de pilotage du couple sur le MSS54 et sur le MSS60 ? » a un doc_id de référence, mais elle doit légitimement croiser les deux index. Dans mon serveur (serve.py), c’est _COMPARE_RE qui détecte le pattern et active hint_corpus_all = True. L’éval doit reproduire ce comportement, sinon elle pénalise injustement les questions de comparaison qui sont précisément celles où le RAG montre sa vraie valeur.

Le piège est insidieux parce qu’il échelle linéairement avec le nombre de corpus. Avec deux corpus, environ 50 % de vos questions sont routées correctement par hasard. Avec dix corpus, c’est 10 %. Et personne ne le voit, parce que les métriques continuent à sortir, à bouger, à donner l’illusion de mesurer quelque chose.

Piège n°2 : Pas d’historique = pas de progression mesurable

Une fois le routing corrigé, on respire un peu. Les chiffres sont enfin « vrais ». On peut donc itérer sur le chunking, comparer les modèles d’embedding, ajuster le top-k. Sauf qu’on découvre alors un deuxième mensonge, plus subtil : on n’a aucune base de comparaison.

Vous changez votre chunking de 800 à 1200 tokens. Vous relancez run_eval.py. Vous voyez faithfulness = 7.4 / completeness = 6.8. Mieux que la fois d’avant ? Vous n’en savez rien, parce que la fois d’avant n’est nulle part. Elle est dans la fenêtre de votre terminal, déjà fermée, ou dans un screenshot que vous avez collé dans Notion il y a trois semaines. Vous itérez à l’aveugle.

C’est le moment où la majorité des équipes RAG construisent, en urgence et mal, leur propre système d’historisation. Un CSV qui grossit, un Google Sheet partagé, une fonction save_run_to_db() greffée au bout du script. Tout le monde redécouvre la même chose après six mois : il faut un système de stockage append-only, versionné, qui archive chaque run avec son contexte complet (modèle d’embedding, chunking, modèle de génération, date, configuration du retriever), et qui calcule automatiquement le diff avec le run précédent.

Chez moi, c’est eval_history.jsonl : un fichier append-only, une ligne par run, qui contient toutes les métriques agrégées plus le détail par question. À chaque nouvelle exécution, run_eval.py compare aux derniers résultats archivés et affiche un diff « régressions / progressions » : quelles questions étaient bonnes et sont devenues mauvaises, et inversement. Pas une moyenne globale, mais une liste de cas concrets à investiguer.

L’effet de bord, et c’est lui qui m’a convaincu : un run qui dégrade brutalement la couverture par mots-clés sur un module spécifique signale presque toujours qu’un chunk a « disparu » dans le bruit vectoriel. C’est exactement le mécanisme de semantic collapse : plus les chunks grossissent (ou plus le corpus s’étend), plus leurs embeddings se rapprochent les uns des autres dans l’espace vectoriel : le signal discriminant se dilue dans le contexte, et le retriever finit par ne plus distinguer le chunk pertinent du chunk seulement plausible. Sans historique, ce phénomène est invisible jusqu’à ce qu’un utilisateur final vous remonte que « le bot ne trouve plus les infos sur les sondes lambda ». Avec historique, le diff vous le dit avant la prod.

Une métrique sans son historique ne mesure rien. Elle ne fait que dire où vous êtes, pas si vous progressez. Et dans un système où chaque commit peut affecter la qualité de retrieval, ne mesurer que la position c’est se condamner à naviguer sans gouvernail.

Trois diffs réels que je n’aurais jamais vus sans cet historique

Pour rendre tout ça concret, voici trois diffs effectivement remontés par mon pipeline d’éval ces dernières semaines. Aucun n’aurait été visible dans une approche « un run, un score ».

Diff 1 : La régression silencieuse du chunking (run du 30/04 vs run du 24/04). Après avoir changé mon chunk_size de 800 à 1200 tokens, le hit@1 global est passé de 0.67 à 0.60. Sept points de moins, c’est désagréable mais pas catastrophique, et un esprit pressé y aurait vu un simple bruit de mesure. Le diff par question, lui, raconte une histoire précise : EVT-01 (délai de transmission CAN pour l’angle AÖ) passe de rr=1.0 à rr=0.5, MM-01 (Momentenmanagement) perd 13 points de couverture par mots-clés. Les deux questions touchent à des tableaux numériques courts et denses. En agrandissant les chunks, j’avais noyé le signal discriminant dans du contexte verbeux, exactement le mécanisme décrit dans Semantic collapse. Sans le diff par question, j’aurais probablement attribué la baisse à « un peu de variance » et persisté dans la mauvaise direction.

Diff 2 : Le gain massif du fix routing multi-corpus (run du 09/05 vs run du 05/05). Après avoir branché _build_doc_id_config_map() dans la boucle d’éval, le hit@1 MSS54 saute de 0.73 à 0.80, le faithfulness passe de 7.43 à 8.07. Ce ne sont pas des variations marginales : ce sont sept points de hit@1 récupérés en corrigeant une seule fonction de plomberie. L’enseignement est brutal : pendant des semaines, mon « score » mesurait un mélange de qualité réelle et de bruit de routage. Tout ce que j’avais optimisé entre-temps reposait sur des comparaisons mal cadrées. Une seule question reste résistante après ce fix, EDKSI-01 (cinématique de la papillon motorisée), avec un faithfulness qui monte (6→7) mais un hit_1 toujours à false. C’est désormais le seul cas que je sais devoir investiguer manuellement, au lieu de tâtonner globalement.

Diff 3 : L’asymétrie entre corpus (run MSS60 du 07/05 vs run MSS54 du 05/05). À configuration identique (hybrid + rerank + top_k=5), MSS60 plafonne à faithfulness=6.25 / completeness=5.83, contre 7.43 / 6.91 sur MSS54. Les deux questions qui sombrent (DTH-03 sur la diagnose des sondes lambda et LFR-01 sur la régulation de pression de carburant) renvoient à des informations dispersées sur plusieurs modules MSS60, typique d’une documentation source moins consolidée. Sans l’historique, j’aurais pu croire que mon pipeline avait régressé. Avec l’historique, je sais que ce n’est pas mon pipeline qui pose problème, c’est le corpus MSS60 qui a besoin d’un travail éditorial plus poussé sur les cross-references markdown. Le diagnostic change du tout au tout.

Aucune de ces trois découvertes ne sort d’un score agrégé. Toutes les trois sortent du delta par question entre deux runs successifs. C’est exactement ce que eval_history.jsonl rend possible et que les approches « un score, un point » rendent impossible.

Piège n°3 : Hit@k et MRR sans LLM-as-judge mesurent la mauvaise chose

Les deux premiers pièges concernent l’infrastructure d’éval. Le troisième concerne ce qu’on évalue. Et là, on touche au cœur du malentendu.

La quasi-totalité des benchmarks publics RAG mesurent hit@k (le bon document est-il dans les k premiers résultats) et MRR (où est-il, en moyenne). Ces métriques sont issues du monde de la recherche d’information classique, où l’on suppose qu’il existe une « réponse canonique » identifiée par un doc_id et que la tâche du système consiste à la retrouver.

Le problème : un RAG n’est pas un moteur de recherche. C’est un système qui génère une réponse en langage naturel à partir de documents récupérés. La question pertinente n’est pas « est-ce que le bon chunk est dans le top-3 », c’est « est-ce que la réponse finale est correcte ». Et ces deux questions ne sont pas équivalentes.

Un système peut atteindre hit@1 = 0.95 et produire des réponses fausses dans 30 % des cas, parce que le LLM ignore le bon chunk au profit d’un mauvais. Inversement, un système avec hit@1 = 0.6 peut produire des réponses correctes 90 % du temps, parce que le LLM sait composer une réponse correcte à partir de trois chunks moyennement pertinents.

D’où la nécessité d’un double critère mesuré directement sur la réponse générée :

  • Faithfulness : la réponse est-elle fidèle aux chunks récupérés ? (Détecte les hallucinations.)
  • Completeness : la réponse couvre-t-elle bien la question posée ? (Détecte les réponses correctes mais partielles.)

Chez moi, ces deux scores sont attribués par Haiku (Claude Haiku 4.5), sur une échelle de 0 à 10 pour chacun. Le prompt du juge reçoit la question, la réponse de référence (depuis questions.yml), la réponse générée par le système, et les chunks utilisés. Il retourne deux entiers et une justification courte.

Côté coût, sur 255 questions, environ 0,15 $ par run complet. Avec un rythme réaliste de quinze à vingt runs par mois en phase d’itération active, on est sur 2 à 3 $/mois. Activez en plus le prompt caching d’Anthropic sur le prompt système du juge (qui ne change pas entre questions) et vous divisez encore par trois. Il n’y a aucune excuse économique pour s’en passer.

L’objection technique habituelle : « le juge LLM est biaisé ». Oui. Mais (a) il est biaisé de manière stable, ce qui fait que les comparaisons relatives entre runs restent valides, et (b) il est moins biaisé qu’un humain fatigué qui annote 255 questions un vendredi soir. Le juge LLM n’est pas la vérité, il est un proxy reproductible, ce qui est exactement ce qu’on demande à un outil de mesure.

Cas où on peut s’en passer : pendant l’itération chunking en local, l’éval légère (sans juge) suffit. Chez moi, c’est ce que fait POST /eval/run dans serve.py, métriques rapides via SSE, sans coût LLM, pour les boucles de feedback courtes. Cas où il est obligatoire : avant tout merge en main, avant tout déploiement, et systématiquement quand on change un composant majeur (embedding, retriever, modèle de génération).

« Et Ragas, LangSmith, DeepEval dans tout ça ? »

Question légitime, et il faut la traiter de front : ces outils existent, ils sont sérieux, et ils ne résolvent pourtant aucun des trois pièges décrits ci-dessus. Pas parce qu’ils sont mauvais, mais parce qu’ils répondent à un problème adjacent.

Ragas fournit un catalogue de métriques RAG bien pensées (faithfulness, context_precision, context_recall, answer_relevancy) avec leurs prompts de juge LLM intégrés. C’est précieux : ça vous évite d’écrire vos propres prompts d’éval. Mais Ragas ne sait rien de votre topologie corpus → index. Si vous lui passez un dataset de 255 questions et un retriever, il calculera les métriques sur ce que le retriever lui renvoie. Si le retriever interroge le mauvais index, Ragas calculera très consciencieusement des métriques sur des chunks non pertinents. Le piège n°1 est en amont de Ragas, pas dedans.

LangSmith est excellent pour le tracing : voir, sur chaque appel, quels chunks ont été récupérés, quel prompt a été envoyé au LLM, quelle réponse est revenue. C’est l’outil que vous voulez en debug. Mais LangSmith ne vous force pas à versionner vos runs en append-only avec leur contexte complet (modèle d’embedding, chunking, retriever config). Il peut le faire si vous instrumentez correctement, mais le défaut est d’agréger par session, pas par run d’éval reproductible. Le piège n°2 n’est pas résolu par défaut, il dépend de votre discipline d’instrumentation.

DeepEval, Promptfoo, TruLens : mêmes catégories. Bons outils pour l’observabilité et les métriques unitaires, pas conçus pour gérer le routage multi-corpus ni pour calculer automatiquement le diff régressions/progressions entre deux runs successifs.

La règle générale : ces frameworks fournissent les briques (juges LLM, traces, métriques). Le pipeline d’éval, c’est-à-dire la façon dont les briques sont assemblées pour produire des chiffres sur lesquels on peut prendre une décision, reste à votre charge. C’est précisément ce que les trois sections précédentes décrivent. Vous pouvez parfaitement implémenter le routing doc_id → index, l’historique append-only et le diff par question en utilisant Ragas comme juge LLM. C’est même probablement ce que je ferais si je devais réécrire mon code aujourd’hui.

L’architecture qui ment moins

En assemblant les trois corrections, on obtient un pipeline d’éval dont la forme générale est la suivante :

Les trois zones colorées correspondent exactement aux trois pièges traités plus haut : le routing (rouge) répare le piège n°1, l’append-only + diff (bleu) répare le piège n°2, le juge LLM (orange) répare le piège n°3. Si l’une des trois est absente, le reste produit des chiffres qui mentent.

Détaillé étape par étape :

  1. Chargement de questions.yml : un fichier YAML qui contient pour chaque question : l’énoncé, la réponse de référence, le doc_id cible, et les mots-clés à vérifier.
  2. Construction de la table de routage doc_id → config au démarrage, par scan de ./documents/. C’est la garantie contre le piège n°1.
  3. Génération RAG parallèle (8 workers chez moi) : chaque question est embeddée avec input_type=query, le retriever interroge l’index correspondant à son doc_id, le LLM génère la réponse. Le mode hybride BM25 + vectoriel avec rerank Cohere est appliqué systématiquement.
  4. Évaluation par juge LLM (Haiku) sur deux axes faithfulness/completeness.
  5. Calcul des métriques agrégées : hit@1, MRR, KW coverage, faithfulness moyen, completeness moyenne.
  6. Archivage append-only dans eval_history.jsonl avec contexte complet (modèles, chunking, date, durée).
  7. Diff avec le run précédent : régressions et progressions, listées par question.
  8. Export CSV et HTML pour analyse manuelle.

Le code fait à peu près 800 lignes, et c’est probablement le composant le plus utile de tout mon pipeline. Plus utile que mon chunking, plus utile que mon choix d’embedding, plus utile que mon reranker. Parce que c’est lui qui me dit si tout le reste fait son travail.

Ce que cette approche ne capture toujours pas

L’honnêteté intellectuelle exige de noter ce qu’on continue à ne pas mesurer, même avec ce pipeline.

Le biais du juge. Haiku peut halluciner ses jugements, surévaluer certains styles de réponse, sous-évaluer les réponses laconiques mais correctes. La parade : audit manuel d’un échantillon (10 % des questions) à chaque release, pour s’assurer que le juge ne dérive pas. C’est lent, mais c’est le seul vrai garde-fou.

L’absence de ground truth sur le raisonnement multi-step. Si la bonne réponse exige de combiner trois chunks de trois modules différents, le juge ne peut pas dire si le raisonnement combinatoire était correct, seulement si la conclusion l’était. C’est une limite intrinsèque à toute évaluation par sortie finale.

La dérive des modèles. Un même run d’éval sur Claude Sonnet 4.6 et Claude Sonnet 4.7 ne donne pas les mêmes scores, même avec les mêmes embeddings et le même chunking. Cela signifie que le juge lui-même doit être versionné dans eval_history.jsonl, et qu’une régression apparente peut être un effet du juge, pas du système évalué. C’est un méta-problème qui mérite probablement un article entier.

La représentativité de questions.yml. On n’évalue jamais que ce qu’on a mis dans le dataset. Si les vraies questions des utilisateurs en prod sont structurellement différentes (vocabulaire, longueur, intention), tous les efforts d’éval sont à côté de la cible. Et il y a un facteur aggravant : la qualité du corpus en amont. Si vos PDFs sont mal parsés et arrivent dans l’index sous forme de bouillie typographique, aucune métrique d’éval, aussi sophistiquée soit-elle, ne rattrapera ça. La parade : collecter périodiquement un échantillon de vraies requêtes en prod et l’injecter dans questions.yml après annotation, et auditer la qualité du parsing en parallèle.

L’évaluation RAG n’est pas une métrique, c’est un système de mesure

On voudrait que l’évaluation d’un système RAG ressemble à celle d’un modèle de classification : un dataset, un score, une décision. Cette analogie est dangereuse parce qu’elle masque le fait qu’un système RAG est un pipeline composite dont chaque étage peut introduire ses propres erreurs, et dont l’évaluation doit donc elle-même être un pipeline.

Si vous ne savez pas, pour chaque métrique que vous regardez :

  • contre quel index la requête a effectivement été routée,
  • quel était le score sur le run précédent et combien il a bougé,
  • si la réponse générée a été jugée par un humain ou par un proxy LLM stable,

alors vous ne mesurez pas la qualité de votre RAG. Vous fabriquez de la conviction. Et la conviction sans mesure, dans un système où chaque commit peut affecter la qualité sémantique du retrieval, c’est exactement ce qui produit les RAG qui s’effondrent silencieusement en prod, six mois après le déploiement initial.

Le mauvais réflexe est de répondre à ce constat par « c’est trop compliqué, on fera plus tard ». Le bon est de réaliser que l’infrastructure d’éval n’est pas un luxe d’équipe mature : c’est le socle minimum pour que le reste ait du sens. Construite tôt, elle vous épargne des semaines d’itération aveugle. Construite tard, elle révèle des mois de fausses convictions.

Quinze dollars pour six mois d’itération sérieuse en appels LLM-juge, un fichier jsonl append-only, une fonction qui scanne vos corpus au démarrage. Ce n’est pas une révolution. C’est simplement le coût d’arrêter de se mentir.

Pour aller plus loin sur les pièges adjacents : Semantic collapse, Du RAG au CAG, Le modèle d’embedding que personne ne choisit vraiment et Transformer un PDF complexe en markdown vraiment RAG-ready.


Écrivez quelques éclats d'âme...

Dans l'ombre vacillante d'une chandelle, où les murmures du vent se mêlent aux secrets d'un vieux parchemin, je vous invite à tisser une toile de mots. Écrivez quelques éclats d'âme – rêve, étoile, abîme, étreinte, brume – et laissez-les danser sur la page, comme des lucioles dans une nuit d'encre. Que diriez-vous de les entrelacer dans une phrase, un souffle, une histoire ?

S’abonner
Notification pour
guest
0 Commentaires
Le plus ancien
Le plus récent Le plus populaire