Audit de la récupération RAG en ce qui concerne les fragments obsolètes, la dérive et le contexte bruité.
Un audit Python pour la récupération RAG : requêtes idéales, seuils de pertinence par cosinus, vérifications de dérive Kolmogorov-Smirnov et efficacité contextuelle, ainsi que la manière de lire le rapport.
Sources: I Built a RAG System That Audits Itself. Here’s How (With Code).
Le type silencieux d’échec du RAG
Imaginez un bot de support qui indique à un client que le délai de retour est de 30 jours alors que la durée réelle est de 14 jours. Le document de politique a été modifié en janvier. L’index d’incorporation a été mis à jour pour la dernière fois en octobre. Personne n’est lésé et aucune action en justice n’est intentée, mais ce qui est inquiétant, c’est que personne ne sait depuis combien de temps cette réponse erronée est diffusée. Une semaine ? Trois mois ? Sans moyen de vérifier, on ne peut le savoir.
C’est cet échec qui mérite l’attention ici, et ce n’est pas le cas dramatique où un modèle hallucine ou divague. C’est le cas silencieux. Les tableaux de bord affichent des indicateurs positifs, et une petite partie des utilisateurs est induite en erreur depuis des mois sans qu’aucun avertissement ne soit émis.
La plupart des équipes qui développent des systèmes RAG mesurent le mauvais indicateur. Elles se demandent simplement si la réponse finale est convaincante, sans s’interroger sur la qualité du matériel qui a servi à la générer. Cela est principalement dû au fait que personne ne leur a fourni d’outil simple pour cela. Ce qui suit en est un : un script Python qui audite automatiquement la couche de récupération des données, construit à partir de bibliothèques open source et d’un fichier JSON contenant des requêtes de test, nécessitant environ 30 minutes de configuration et ne requérant ni plateforme d’observation ni formation en statistiques.
Portez votre attention sur la récupération des données, pas sur le modèle
Lorsqu’un système RAG ne fonctionne pas correctement, on a tendance à blâmer le modèle : prompt incorrect, paramètre de température inadapté, version obsolète. En ajustant le prompt ou en passant à un modèle plus récent, les choses s’améliorent pendant quelques semaines, avant que le problème ne réapparaisse, car la véritable cause réside ailleurs. La chaîne de traitement dans les systèmes en production fonctionne généralement de cette manière :
Un document change, mais l’index d’incorporation n’est pas mis à jour. Un fragment obsolète est récupéré, le LLM répond avec assurance à partir d’un contexte dépassé, la réponse est fausse, et personne ne peut expliquer pourquoi.
Le modèle ne ment pas. Il fait exactement ce qui lui a été demandé, à savoir produire une réponse à partir des fragments fournis. Donnez-lui des fragments vieux de six mois, et la réponse le sera également, présentée de manière fluide et avec une grammaire irréprochable.
Le problème réside dans la méthode d’évaluation habituelle, qui est de type bout en bout : une requête est envoyée et la réponse obtenue est évaluée. RAGAS, parmi les frameworks d’évaluation les plus largement adoptés, a été conçu autour de l’ensemble du processus et évalue la précision du contexte, la fidélité et la pertinence de la réponse (Es et al., 2023, EACL 2024). Il s’agit d’un framework solide. Cependant, il évalue le système dans son ensemble, et non uniquement la couche de récupération, de sorte qu’un score positif global peut masquer une dégradation lente de la récupération lorsque le modèle a parfois de la chance et compense ainsi les défauts.
Un second problème se cache derrière celui-ci. Liu et al. ont rapporté en 2024 que les modèles de langage performent nettement moins bien lorsque les informations pertinentes se trouvent au milieu d’un contexte long, même pour des modèles entraînés spécifiquement pour des entrées longues. Ainsi, la récupération de cinq segments parmi lesquels trois ne sont que partiellement pertinents nuit activement au système, et les tokens consacrés à ceux-ci affectent négativement la qualité des réponses. Aucun de ces problèmes n’apparaît à moins que la couche de récupération ne soit examinée directement, ce qui est bien l’intention.
Trois modes de défaillance que les métriques standard manquent
Empoisonnement des segments
Le terme « chunk poisoning » n’est pas issu d’une étude scientifique. Il décrit des blocs de texte qui semblent pertinents car leur formulation coïncide avec la requête, ce qui leur vaut une haute position dans les recherches vectorielles, alors qu’ils ne contiennent pas la réponse. Une cause fréquente est une segmentation qui place les mots-clés dans un bloc et le contenu réel dans un autre. Le bloc riche en mots-clés obtient de bons scores en termes de similarité cosinus, tandis que le bloc utile n’apparaît pas parmi les résultats principaux, ce qui amène le modèle à générer une réponse plausible mais incorrecte.
Contenu obsolète
Supposons qu’une FAQ ait mis à jour sa politique de remboursement en janvier et que l’index ait été mis à jour pour la dernière fois en octobre. Le bloc contenant l’ancienne politique continue de figurer parmi les résultats pour les questions de remboursement et est toujours considéré comme « pertinent » par le modèle. Les utilisateurs se retrouvent ainsi en désaccord avec l’équipe d’assistance, alors que les indicateurs ne montrent rien d’anormal.
Dérive des embeddings
Progressif et cumulatif, c’est le plus dangereux des trois. Un modèle d’incorporation enregistre le corpus tel qu’il était au moment de l’indexation. Chaque modification de document, chaque nouveau terme qui entre dans le domaine et chaque changement dans la manière dont les utilisateurs formulent leurs requêtes affaiblit légèrement la précision de cette capture instantanée. La similarité cosinus moyenne peut chuter de 0,82 à 0,74 en six mois. Rien ne se brise, aucune alerte n’est déclenchée, et la dégradation n’apparaît que lorsque les réponses deviennent visiblement moins bonnes, auquel moment le système est déjà en retard de plusieurs mois.
Le bruit présent dans le contexte a également un coût. Avec cinq segments dans la fenêtre et trois d’entre eux irrelevants, des tokens sont gaspillés et la qualité en souffre, car le contenu situé au milieu d’un contexte long reçoit moins d’attention que le contenu situé aux extrémités, comme l’ont documenté Liu et al.
Que fait l’audit
Trois vérifications correspondent à ces trois modes de défaillance.
- Relevance et nombre de résultats. Pour chaque requête de l’ensemble de test, le script récupère les K meilleurs fragments et calcule la similarité cosinus entre l’embedding de la requête et celui de chaque fragment. Il vérifie également si les fragments censés contenir la réponse sont bien apparus. S’ils ne le sont pas, la requête est marquée comme non pertinente.
- Dérive par rapport à une ligne de référence. Chaque exécution enregistre sa distribution des scores de similarité, et les exécutions ultérieures comparent cette nouvelle distribution avec la ligne de référence enregistrée à l’aide du test de Kolmogorov-Smirnov. Si les deux distributions diffèrent de manière significative, une alerte de dérive est déclenchée. Il n’est pas nécessaire de suivre les calculs : une valeur p inférieure à 0,05 indique simplement qu’un changement s’est produit et mérite une analyse.
L’audit génère un rapport JSON structuré. Un job cron hebdomadaire suffit, en se concentrant sur le taux d’indicateurs et l’analyse des écarts. La plupart des systèmes RAG n’ont jamais disposé d’un tel niveau de surveillance.
Le jeu de requêtes idéal
Avant tout code, un jeu de requêtes idéal est nécessaire : un fichier JSON contenant des requêtes représentatives, chacune associée aux IDs des fragments qui devraient figurer dans ses résultats.
[
{
"query": "What is the refund policy for digital products?",
"expected_chunk_ids": ["faq_doc_chunk_12", "faq_doc_chunk_13"],
"notes": "Customer FAQ, policy updated 2024-Q1"
},
{
"query": "How do I reset my API key?",
"expected_chunk_ids": ["api_docs_chunk_07"],
"notes": "API documentation, stable"
}
]
La taille est importante. Vingt requêtes semblaient suffisantes au début, mais ce n’était pas le cas, car le test de dérive nécessite plus de points de données ; 20 donnent une distribution trop rare pour être fiable. Considérez 50 comme un minimum, et 100 ou plus comme le seuil à partir duquel les signaux de dérive deviennent significatifs.
Commencez par sélectionner les requêtes des utilisateurs les plus fréquentes dans les journaux d’application. Effectuez manuellement la recherche pour chacune d’elles, examinez les résultats et notez quels identifiants de blocs se trouvent dans les positions les plus élevées. Ce travail est fastidieux et prend quelques heures, mais c’est la seule étape qui ne peut pas être automatisée, et tout le reste en dépend. Certaines habitudes aident :
- Répartissez les requêtes sur différentes catégories de sujets plutôt que de les regrouper autour d’un seul document.
- Ajoutez des requêtes qui ne devraient renvoyer aucun résultat, car des exemples négatifs permettent de détecter les résultats faux positifs.
- Mettre à jour l’ensemble doré lorsque le corpus change de manière significative, et non de façon continue. Il s’agit d’une référence stable et non d’un ensemble de données en temps réel.
La structure du projet et le script principal
Le répertoire est organisé comme suit :
rag-retrieval-audit/
├── audit/
│ ├── __init__.py # Package exports
│ ├── rag_audit.py # Main audit logic — all three checks
│ └── config.py # All thresholds and model settings
├── examples/
│ ├── golden_queries.json # Sample golden set (10 queries)
│ └── run_audit.py # End-to-end demo — runs without an existing collection
├── tests/
│ └── test_audit.py # Unit tests for all scoring functions
├── requirements.txt # sentence-transformers, chromadb, scipy, numpy
└── README.md # Setup, usage, how to read the report
Voici la structure de rag_audit.py, le fichier qui contient les trois vérifications. Il s’agit d’un plan d’orientation et non de la mise en œuvre complète.
# rag_audit.py — structure overview
# Full implementation: https://github.com/satyam671/rag-retrieval-audit
# ── CONFIGURATION (tune to your pipeline)
EMBEDDING_MODEL = "all-MiniLM-L6-v2" # must match your index
TOP_K = 5
RELEVANCE_THRESHOLD = 0.70
DRIFT_P_THRESHOLD = 0.05
EFFICIENCY_THRESHOLD = 0.40
# ── CHECK 1: Are the right chunks coming back?
def score_relevance(query_embedding, chunk_embeddings, expected_ids, retrieved_ids):
"""Cosine similarity per chunk + expected chunk hit/miss against golden set."""
...
# ── CHECK 2: Has retrieval quality shifted over time?
def detect_drift(current_sims, baseline_path=None):
"""Two-sample KS test comparing current similarity distribution to baseline."""
...
# ── CHECK 3: How much of the context window is signal?
def score_efficiency(retrieved_docs, answer):
"""Token overlap between retrieved chunks and the LLM answer."""
...
# ── RUNNER
def run_audit(golden_set_path, collection_name, chroma_persist_dir,
baseline_path=None, answers_path=None, output_path="rag_audit_report.json"):
"""Runs all three checks, writes a structured JSON report, saves the baseline."""
...
Le fichier README du script explique comment le cloner, le pointer vers une collection ChromaDB et le lancer, y compris comment remplacer par un autre stockage vectoriel.
Examen détaillé de chaque vérification
Évaluation de la pertinence par blocs
La vérification de pertinence effectue deux opérations que les gens oublient souvent lorsqu’ils la conçoivent eux-mêmes.
First, elle calcule la similarité cosinus directement à partir des embeddings et n’utilise pas les valeurs de distance retournées par ChromaDB. La métrique par défaut de ChromaDB est L2, et non la similarité cosinus ; les deux ne sont pas interchangeables. En les mélanger, on obtient des scores qui semblent plausibles mais mesurent en réalité autre chose.
Deuxièmement, cela confirme que les IDs de chunk attendus pour l’ensemble doré figuraient parmi les top-K, ce qui permet de détecter le phénomène de contamination des chunks. Une similarité moyenne de 0,75 semble acceptable, jusqu’à ce que l’on sache que le chunk censé répondre à la requête a obtenu un score de 0,62 et s’est classé sixième, juste au-delà du seuil. Le modèle n’a jamais obtenu la bonne réponse, alors que les valeurs de similarité semblaient encore acceptables.
La différence pratique entre l’activation et la non-activation est claire dans le code.
# WITHOUT the relevance gate — standard approach most teams use
def build_context_naive(query, collection, top_k=5):
results = collection.query(
query_embeddings=[embed(query)],
n_results=top_k,
include=["documents"]
)
# Pass everything back, no quality check
return "\n\n".join(results["documents"][0])
# WITH the relevance gate - what the audit tells you to build
def build_context_gated(query, collection, model, top_k=5, threshold=0.70):
q_emb = model.encode([query], normalize_embeddings=True)[0]
results = collection.query(
query_embeddings=[q_emb.tolist()],
n_results=top_k,
include=["documents", "embeddings", "ids"]
)
passed_chunks = []
for i, chunk_emb in enumerate(results["embeddings"][0]):
sim = cosine_sim(q_emb, np.array(chunk_emb))
if sim >= threshold:
passed_chunks.append(results["documents"][0][i])
# Empty context is better than wrong context
return "\n\n".join(passed_chunks)
Dans la version naïve, les fragments les mieux classés sont pris en compte quel que soit leur qualité. Dans la version filtrée, tout fragment qui n’a pas mérité sa place est retenu. Une réponse du type « Je n’ai pas suffisamment d’informations pour y répondre » dans un contexte vide vaut mieux qu’une réponse erronée mais assurée basée sur du bruit.
Détection de la dérive des embeddings
La vérification de la dérive repose sur le test de Kolmogorov-Smirnov. Il compare la forme de la distribution de similarité actuelle à une référence enregistrée et signale toute divergence significative. Pourquoi des distributions et non des scores moyens ? Parce qu’un seul nombre cache trop d’informations. Le score moyen pourrait rester stable à 0,78 tandis que la distribution sous-jacente s’aplatit et s’étend, ce qui signifie que certaines requêtes se détériorent fortement tandis que d’autres compensent. Le test KS permet de détecter les changements de distribution que les moyennes manquent de repérer.
Le graphique d’exemple montre l’évolution de l’incorporation dans un système RAG en production six mois après des mises à jour du corpus sans réindexation : une statistique KS de 0,21 et une valeur p de 0,003.
Une précaution : le test nécessite suffisamment de données pour être fiable. Avec 15 requêtes, les valeurs p fluctuent et génèrent une confiance fausse, tandis que 50 requêtes ou plus fournissent un signal stable. C’est une autre raison pour laquelle la taille de l’ensemble d’entraînement est plus importante qu’il n’y paraît.
Efficacité de la fenêtre de contexte
C’est le test le plus difficile des trois, avec des limites réelles. La superposition au niveau des mots entre les fragments récupérés et la réponse du modèle n’est qu’un indicateur approximatif. Lorsqu’un modèle réécrit fortement son texte, comme le font les modèles de type GPT-4, cette superposition sous-estime la quantité réelle de contexte utilisée.
Néanmoins, il permet de détecter des problèmes systémiques. Si les scores d’efficacité restent constamment en dessous de 40 %, quelque chose dans la chaîne de traitement ne fonctionne pas correctement : les blocs de données sont trop gros, la valeur de top-K est trop élevée, ou la récupération inclut du texte lié au sujet mais qui ne répond pas à la requête. Cette vérification n’est pas précise, mais elle indique la bonne direction, ce qui suffit pour lancer une enquête.
Lecture du rapport
Le résultat est un fichier JSON, et ces champs méritent une attention particulière :
flag_ratereprésente la fraction des requêtes ayant déclenché une condition d’échec. Une valeur supérieure à 20 % indique que la récupération nécessite des améliorations, et une hausse de 8 % d’une semaine à 22 % la suivante signale qu’un changement s’est produit.drift_analysis.drift_detectedest le paramètre booléen à surveiller. S’il vauttrue, il faut examinermean_deltapour déterminer la tendance. Une valeur négative indique que la similarité diminue, ce qui est le signal d’un réindexage nécessaire.drift_analysis.ks_statisticaugmente avec l’ampleur du décalage de la distribution. Une valeur inférieure à 0,10 indique probablement du bruit. Une valeur supérieure à 0,20 représente un véritable problème, quel que soit le résultat du p-value.query_results[i].relevance.expected_chunk_hitcorrespond à la vérité absolue pour chaque requête. Une valeurfalsesignifie que le fragment censé répondre à la requête n’a pas fait partie des K meilleurs. Si cela se produit pour plus de 15 % des éléments du jeu de référence, il faut réindexer.
L’exemple est lancé avec une seule commande, comme indiqué ici.
python -m examples.run_audit
Un feuillet récapitulatif synthétise l’audit en une seule vue d’ensemble.
Exécutez-le chaque semaine et conservez toutes les valeurs de référence. La tendance entre les différentes exécutions en dit plus que n’importe quelle exécution isolée.
Une leçon sur la taille de l’échantillon
La détection de dérive sur un ensemble de référence composé de 15 requêtes a produit des valeurs p dénuées de sens. Le test fonctionnait techniquement, mais de manière peu fiable ; il était facile de perdre deux jours à soupçonner un problème dans la mise en œuvre statistique avant de réaliser que les données étaient simplement insuffisantes.
Avec moins de 50 requêtes, les chiffres relatifs à la dérive ne sont pas fiables ; il convient donc de créer d’abord l’ensemble de données et d’activer la vérification de la dérive par la suite. Un corpus couvrant plusieurs domaines thématiques distincts nécessite un ensemble de référence par domaine, audité séparément, car une distribution globale peut masquer un déclin dans un seul domaine qui serait visible s’il était examiné isolément.
Les limites de cette approche
Le script vérifie que les bons fragments sont récupérés. Il ne juge pas si les limites des fragments sont bien placées, si la taille des fragments convient aux types de requêtes, ou si le fractionnement sémantique devrait remplacer le fractionnement à taille fixe.
Ces questions sont importantes. Les benchmarks d’NVIDIA sur cinq ensembles de données, tels que résumés dans une analyse FloTorch, ont montré que la meilleure taille de fragment varie en fonction du type de requête : les requêtes factuelles atteignent un pic entre 256 et 512 tokens, tandis que les requêtes analytiques nécessitent 1024 tokens ou plus. Toute taille de fragment unique représente un compromis, et un ensemble idéal combinant les deux types de requêtes aboutit en moyenne à un manque d’adéquation fondamental. Tester directement la stratégie de fractionnement, et comparer le fractionnement sémantique au fractionnement à taille fixe à l’aide de chiffres réels, constitue une tâche distincte.
Références
- La publication RAGAS sur l’évaluation automatisée de la génération améliorée par la récupération d’informations (arXiv 2309.15217, 2023 ; EACL 2024).
- L’étude sur le comportement des modèles de langage dans des contextes longs, qualifiée de « perdue au milieu », publiée dans le volume 12 de TACL en 2024.
- La recherche appliquée de NVIDIA sur les stratégies de segmentation par blocs pour différents types de documents, datant de 2024.