Débogage des échecs silencieux des RAG : récupération, découpage, filtres et évaluations
Les réponses RAG peuvent sembler correctes tout en étant fausses. Un guide axé sur Python pour suivre le processus de récupération, le découpage, les filtres de métadonnées, les évaluations, la recherche hybride et l’ancrage.
Sources: RAG Is Failing Quietly: A Debugging Playbook for Python Teams
Les échecs que personne ne voit
Les bugs les plus agaçants des systèmes RAG ne sont pas ceux qui font du bruit. Les échecs audibles sont faciles à identifier : le bot admet ne rien avoir trouvé, la base de données vectorielle est hors service, une tâche d’incorporation a échoué, ou la réponse arrive sans citations.
Les échecs silencieux coûtent plus cher. Les documents sont récupérés, la réponse semble cohérente, les citations semblent légitimes et le tableau de bord indique une récupération réussie. Puis un expert du domaine lit la réponse et affirme qu’elle est incorrecte. C’est généralement à ce moment-là qu’une équipe se rend compte qu’elle a débogué le mauvais niveau.
RAG n’est pas une seule fonctionnalité. C’est une série de choix qui s’accumulent. Quelqu’un décide de la manière dont une question est interprétée et réécrite, de l’endroit où les documents sont séparés, du moteur de recherche à utiliser, des métadonnées qui restreignent les candidats, du classement à nouveau effectué sur la liste restreinte, de ce qui finit par figurer dans l’instruction, du comportement du modèle lorsque les preuves sont insuffisantes, et de savoir si la réponse écrite découle réellement de ce qui a été fourni. Se concentrer uniquement sur l’instruction finale signifie arriver après que les dommages ont déjà été causés.
Le pipeline en cours de débogage
Un système réaliste ressemble davantage à ce qui suit qu’au diagramme ordonné présenté sur une diapositive :
flowchart LR
A[User question] --> B[Query rewrite]
B --> C[Retriever]
C --> D[Reranker]
D --> E[Evidence pack]
E --> F[Answer generator]
F --> G[Verifier]
G --> H[Final answer]
C --> I[Trace log]
D --> I
E --> I
F --> I
G --> I
Toute boîte peut tomber en panne. Le générateur en porte la responsabilité car c’est l’étape la plus visible, mais de nombreux défauts commencent en amont. Le récupérateur a pu sélectionner la période incorrecte. Le réordonneur a pu promouvoir un fragment similaire qui n’a aucune importance. Le paquet de preuves a pu omettre le seul paragraphe utile en raison d’un budget de tokens trop restreint. Une bonne débogage consiste à rendre chaque étape visible.
Échec 1 : un texte similaire n’est pas une preuve utile
La recherche vectorielle détecte la similarité sémantique, ce qui n’est pas toujours pertinent pour la tâche. Prenons un utilisateur qui demande si les clients entreprises peuvent obtenir un remboursement après la mise à jour de la politique du deuxième trimestre. Le récupérateur peut renvoyer un fragment sur la politique de remboursement simplement parce qu’il mentionne les termes « entreprises », « remboursement » et « politique ». Ce fragment peut être obsolète, incomplet ou sans rapport avec le deuxième trimestre.
Une trace utile indique l’ID du document récupéré, sa version, sa date d’entrée en vigueur, le score de similarité, le score de réclassement, les filtres de métadonnées appliqués et le fait que la réponse finale l’ait cité ou non. Si les journaux ne mentionnent que un score comme 0,83, l’erreur ne peut pas être diagnostiquée.
Échec 2 : le découpage a détruit le sens
Le découpage n’est pas une simple question de stockage ; il influence directement la qualité de la réponse. Une politique de remboursement peut être structurée en critères d’éligibilité, exceptions, annexes spécifiques à l’entreprise, dates d’application et différences régionales. Si l’outil de découpage sépare la date d’application des exceptions, la réponse peut citer le paragraphe relatif aux critères d’éligibilité tout en omettant la condition qui modifie le résultat.
Les blocs de texte de meilleure qualité comportent un ID de document stable, un en-tête de section, une version, une date, suffisamment de texte adjacent pour conserver le sens, ainsi qu’un lien vers l’original. Rien de tout cela ne justifie l’utilisation de blocs trop volumineux, qui ne font qu’ajouter du bruit. L’objectif est de disposer de blocs permettant de conserver l’endroit où une décision prend forme.
Échec n°3 : absence de filtres de métadonnées
De nombreux systèmes RAG commencent par une recherche sémantique pure. Cela fonctionne tant que le même sujet apparaît uniquement au sein de versions, de produits, de locataires, de régions ou de groupes d’accès spécifiques. Une question concernant la politique actuelle de remboursement de l’entreprise ne devrait pas obliger le système de recherche à examiner tous les documents de remboursement jamais rédigés par l’entreprise ; il devrait d’abord appliquer des filtres.
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class SearchFilters:
product: str | None
customer_tier: str | None
region: str | None
as_of: date
permission_group: str
def build_filters(user_context: dict) -> SearchFilters:
return SearchFilters(
product=user_context.get("product"),
customer_tier=user_context.get("tier"),
region=user_context.get("region"),
as_of=date.today(),
permission_group=user_context["permission_group"],
)
L’ordre est important. Appliquez les filtres de permission et de fraîcheur avant de demander au modèle de raisonner. Il est risqué de récupérer du contexte privé ou obsolète en espérant que la demande le résoudra.
Échec 4 : un ensemble d’évaluation de scénarios problématiques
Le moyen le plus rapide d’améliorer un système RAG est d’utiliser un petit ensemble d’évaluation soigneusement choisi. Évitez les milliers d’exemples ; commencez plutôt avec une trentaine d’exemples reflétant les cas réels où le système échoue :
- des questions dont la réponse n’est pas dans le corpus
- des questions nécessitant deux sources
- des questions où une ancienne politique entre en conflit avec une nouvelle
- des questions ayant des formulations similaires mais concernant des produits différents
- des questions dépendant d’un tableau
- des questions pour lesquelles la réponse correcte est de faire appel à un humain
Un petit outil de récupération pourrait ressembler à ceci :
from dataclasses import dataclass
@dataclass(frozen=True)
class RagCase:
question: str
required_doc_ids: set[str]
forbidden_doc_ids: set[str]
def evaluate_retrieval(cases: list[RagCase], retrieve) -> dict:
total = len(cases)
hit = 0
leaked_forbidden = 0
for case in cases:
results = retrieve(case.question)
retrieved_ids = {item["doc_id"] for item in results}
if case.required_doc_ids & retrieved_ids:
hit += 1
if case.forbidden_doc_ids & retrieved_ids:
leaked_forbidden += 1
return {
"cases": total,
"required_hit_rate": hit / total,
"forbidden_leak_rate": leaked_forbidden / total,
}
C’est délibérément simple. Il ne garantit pas la qualité des réponses, mais il détecte de nombreux échecs de récupération avant même que le modèle ne voie le contexte.
Échec 5 : juger les réponses sans les preuves
Une réponse RAG doit être évaluée en fonction des preuves réellement fournies, et non en fonction de l’ensemble du corpus. Cette distinction est importante pour trois raisons. Si la bonne réponse existe dans la base de connaissances mais n’a pas été récupérée, on ne peut pas s’attendre à ce que le générateur la connaisse, ce qui constitue un échec de récupération. Si les bonnes preuves ont été récupérées mais que la réponse les ignore, c’est un échec de génération. Si la réponse cite une source qui ne soutient pas l’affirmation, c’est un échec de fondement. Des étiquettes sont nécessaires pour chacun de ces cas.
@dataclass(frozen=True)
class AnswerEval:
question: str
answer: str
evidence_doc_ids: set[str]
expected_claims: set[str]
def simple_claim_check(eval_case: AnswerEval) -> dict:
answer_lower = eval_case.answer.lower()
missing = [
claim
for claim in eval_case.expected_claims
if claim.lower() not in answer_lower
]
return {
"passed": len(missing) == 0,
"missing_claims": missing,
"evidence_count": len(eval_case.evidence_doc_ids),
}
Les procédures de production peuvent ajouter une révision humaine ou un juge basé sur un LLM pour les allégations plus subtiles, mais même une vérification sommaire des allégations vaut mieux qu’un simple accord vague.
Un suivi amélioré
Chaque réponse doit laisser derrière elle un suivi structuré :
import time
import uuid
from dataclasses import dataclass, field
@dataclass
class RagTrace:
run_id: str = field(default_factory=lambda: str(uuid.uuid4()))
started_at: float = field(default_factory=time.time)
query: str = ""
rewritten_query: str | None = None
filters: dict = field(default_factory=dict)
retrieved: list[dict] = field(default_factory=list)
evidence_doc_ids: list[str] = field(default_factory=list)
prompt_tokens: int = 0
completion_tokens: int = 0
verifier_result: str | None = None
latency_ms: int | None = None
def finish_trace(trace: RagTrace) -> RagTrace:
trace.latency_ms = int((time.time() - trace.started_at) * 1000)
return trace
Avec cela, les questions pratiques trouvent des réponses. La bonne collection a-t-elle été consultée ? Les filtres ont-ils supprimé les documents corrects ? Le système de recherche a-t-il mis en évidence la source attendue ? Le dossier contenant les preuves comportait-il trop de contexte ? Le vérificateur a-t-il remarqué des citations manquantes ? La latence provenait-elle de la recherche, de la génération ou de la vérification ? Sans réponses à ces questions, il n’y a pas encore de système RAG, seulement une invite accompagnée d’une boîte de recherche.
La recherche hybride est souvent la solution la moins efficace
Les équipes passent souvent trop rapidement d’une recherche vectorielle simple à un système RAG agent. Il convient d’essayer d’abord une recherche hybride. La recherche sémantique se révèle utile lorsque l’utilisateur et le document utilisent des mots différents. La recherche par mots-clés est préférable lorsque la précision des chaînes de caractères littéraux est importante, comme pour un identifiant, un code d’erreur, un nom de champ ou une clause de texte juridique. Les systèmes réels ont souvent besoin des deux approches.
def hybrid_rank(vector_results: list[dict], keyword_results: list[dict]) -> list[dict]:
scores: dict[str, float] = {}
items: dict[str, dict] = {}
for rank, item in enumerate(vector_results, start=1):
doc_id = item["doc_id"]
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (rank + 10)
items[doc_id] = item
for rank, item in enumerate(keyword_results, start=1):
doc_id = item["doc_id"]
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (rank + 10)
items[doc_id] = item
return sorted(
items.values(),
key=lambda item: scores[item["doc_id"]],
reverse=True,
)
Cette méthode de fusion basée sur le rang réciproque n’est pas sophistiquée, mais elle est utile. Elle récompense les documents présents dans les deux listes de résultats tout en laissant à chacun des systèmes de recherche la possibilité de mettre en évidence quelque chose d’important.
Quand la recherche agente est avantageuse
Le RAG agente convient aux problèmes nécessitant une investigation approfondie. Parmi les cas pertinents, on trouve ceux où la première analyse manque souvent de preuves suffisantes, les questions qui nécessitent de consulter plusieurs documents, les workflows qui doivent choisir entre des requêtes SQL, des recherches vectorielles et des appels API, les modèles qui doivent déterminer s’ils disposent de preuves suffisantes, ainsi que les processus qui bénéficient d’une nouvelle tentative avec une requête différente.
Les cas peu adaptés sont les réponses simples à des FAQ, les fonctionnalités d’autocomplétion à faible latence, les recherches dans des documents stables, ainsi que tout scénario où chaque appel supplémentaire au modèle est trop coûteux. Le problème doit justifier l’utilisation de la recherche agente. Si un filtre de métadonnées peut le résoudre, utilisez ce filtre.
Une liste de contrôle pour la mise en production
Avant de blâmer le modèle, vérifiez ce qui suit :
- Les documents sont-ils versionnés ?
- Les permissions sont-elles appliquées avant la récupération ?
- Les documents obsolètes sont-ils exclus par défaut ?
- Les segments respectent-ils le sens plutôt que simplement le nombre de caractères ?
- Les termes exacts sont-ils pris en compte par la recherche par mots-clés ?
- Les tableaux et les images sont-ils gérés séparément ?
- Les évaluations prennent-elles en compte les cas sans réponse ?
- Les identifiants des documents récupérés sont-ils enregistrés ?
- Les sources citées sont-elles vérifiées ?
- La qualité de la réponse est-elle évaluée indépendamment de la qualité de la récupération ?
La plupart des échecs liés à RAG ne sont pas mystérieux. Ils ne sont simplement pas observés.
Où la chaîne de preuves se brise
RAG donne aux systèmes LLM l’impression d’être ancrés dans la réalité, mais cet ancrage ne se produit pas automatiquement. Le modèle ne peut utiliser que les éléments de preuve qu’il reçoit. Le système de récupération ne peut trouver que ce qui a été correctement indexé. L’index ne peut fournir que les métadonnées qui ont été conservées. Un évaluateur ne peut détecter que les échecs que quelqu’un a pris la peine de décrire. Ce travail est moins spectaculaire qu’une démonstration, mais c’est là que la confiance se construit.
Ainsi, lorsque un système RAG échoue discrètement, la question du prompt n’est pas le bon point de départ. La meilleure question est : où la chaîne des preuves s’est-elle rompue ?