Débogage de la récupération RAG : Recall@K, BM25, fusion hybride et réclassement
Une petite expérience menée sur un pipeline RAG qui a classé le bon fragment en quatrième position, montrant comment le fractionnement, BM25, la fusion des classements et le reclassement modifient Recall@3 et MRR.
Sources: How to Fix a RAG System That Keeps Retrieving the Wrong Context
Une question à laquelle le récupérateur a mal répondu
On prend un petit pipeline RAG et on lui pose une question qui devrait être simple : pendant combien de temps un lien de téléchargement CSV reste-il utilisable ? La documentation donne la réponse : un lien de téléchargement CSV final reste actif pendant sept jours. Pourtant, le récupérateur classe ce passage en quatrième position, et comme seuls les trois premiers extraits sont transmis au modèle de langage, celui-ci n’obtient jamais la question.
Ce qui rend cette erreur irritante, c’est que les trois premiers résultats ne semblent pas erronés. L’un décrit le traitement des exports CSV, un autre traite des exports de grande taille, et un troisième explique comment annuler un export. À première vue, on pourrait croire que le récupérateur fonctionne correctement. Ce n’est pas le cas : il a trouvé des passages sur le sujet adéquat, mais aucun ne contenait l’information nécessaire à la réponse à la question.
La réaction habituelle face à une mauvaise réponse générée par un RAG est de chercher une solution en amont : réécrire la demande, ajouter des instructions ou blâmer le modèle. Ajuster la demande est satisfaisant car trois lignes modifiées suffisent à changer immédiatement le résultat. Ici, cela n’aurait servi à rien. Ce n’est pas le modèle qui a sélectionné ces documents, c’est le système de récupération, et aucune demande ne peut faire réapparaître un passage qui n’a jamais été inclus dans la fenêtre de contexte.
Cela devient d’autant plus important à mesure que le système se développe. Au-delà de la phase de démonstration, une mauvaise récupération des informations constitue une faille difficile à détecter à partir de la réponse finale. Le modèle peut sembler très sûr de lui, le contexte peut paraître vaguement pertinent, mais la véritable cause réside à plusieurs étapes en amont. Une meilleure pratique consiste à distinguer les causes de l’échec : absence de preuves, segmentation insuffisante, classement déficient ou problème lors de la génération. L’ordre de débogage devient alors le suivant :
Une mauvaise réponse : il faut d’abord examiner la récupération des données, puis l’évaluer, ensuite la corriger, et ce n’est qu’après que l’on s’intéresse à la génération.
Une petite expérience montre à quel point les étapes précédentes sont importantes.
Un corpus suffisamment petit pour être lu
L’expérience a utilisé un ensemble de documentation fictif portant sur les limites de vitesse, les sauvegardes, les exportations CSV, les erreurs API, l’authentification, la surveillance et les journaux d’audit. Il n’était pas impressionnant, et c’était intentionnel : suffisamment petit pour qu’une personne puisse lire chaque passage récupéré, mais suffisamment complexe pour que plusieurs extraits puissent sembler pertinents pour une même question. Chaque extrait a reçu une identifiant unique :
chunks = [
{
"id": "audit_03",
"source": "audit-logs",
"text": (
"Enterprise audit logs are retained for 365 days "
"before automatic deletion."
),
},
{
"id": "errors_07",
"source": "api-errors",
"text": (
"NX-204 means the requested resource exists but is not "
"available in the caller's current region."
),
},
{
"id": "exports_01",
"source": "csv-exports",
"text": (
"CSV exports run asynchronously and appear in the exports "
"panel when processing completes."
),
},
{
"id": "exports_04",
"source": "csv-exports",
"text": (
"A completed CSV download link remains active for seven days."
),
},
]
Cet ID ressemble à une méthode de comptabilité, mais il permet d’éviter un raccourci problématique par la suite. L’ensemble comprenait également 12 requêtes d’évaluation, chacune étiquetée avec les parties véritablement pertinentes :
eval_cases = [
{
"query": "How long are enterprise audit logs kept?",
"relevant": {"audit_03"},
},
{
"query": "What does error NX-204 mean?",
"relevant": {"errors_07"},
},
{
"query": "How long is a CSV export link usable?",
"relevant": {"exports_04"},
},
]
Douze requêtes constitueraient un benchmark de performance ridicule, mais ce n’était pas l’objectif. L’objectif était d’avoir suffisamment de cas pour déceler quand la correction silencieuse d’une requête endommageait une autre. Même un petit ensemble d’évaluation suffit à ébranler la confiance que procure la réexécution manuelle des mêmes trois questions.
Une ligne de référence délibérément simple
La ligne de référence utilisait TF-IDF, une SVD tronquée et la similarité cosinus. Un projet réel choisirait probablement un modèle d’incorporation moderne, mais son ajout introduirait une autre variable sans aider à résoudre le problème en question. Le système de récupération n’avait pas besoin d’être sophistiqué ; il devait simplement être quelque chose qui puisse être endommagé, examiné et mesuré.
import numpy as np
from sklearn.decomposition import TruncatedSVD
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.preprocessing import normalize
class LsaRetriever:
def __init__(self, chunks, dims=16):
self.chunks = chunks
self.tfidf = TfidfVectorizer(
stop_words="english",
ngram_range=(1, 2),
sublinear_tf=True,
)
term_matrix = self.tfidf.fit_transform(
chunk["text"] for chunk in chunks
)
# The corpus is tiny. SVD doesn't need dimensions it cannot use.
dims = min(
dims,
term_matrix.shape[0] - 1,
term_matrix.shape[1] - 1,
)
if dims < 1:
raise ValueError("Need more text to build the LSA index.")
self.svd = TruncatedSVD(
n_components=dims,
random_state=0,
)
self.index = normalize(
self.svd.fit_transform(term_matrix)
)
def search(self, query, limit=None):
query_vec = self.tfidf.transform([query])
query_vec = normalize(self.svd.transform(query_vec))
similarity = cosine_similarity(
query_vec,
self.index,
)[0]
ranked = np.argsort(similarity)[::-1]
if limit is not None:
ranked = ranked[:limit]
return [
(self.chunks[i], float(similarity[i]))
for i in ranked
]
La première étape de segmentation a divisé la documentation phrase par phrase. La question posée au format CSV a ensuite produit ceci :
1. 0.879 exports_01
CSV exports run asynchronously and appear in the exports panel...
2. 0.843 exports_02
Large exports are split into multiple compressed files.
3. 0.830 exports_03
Users can cancel an export while it is still queued...
4. 0.772 exports_04
A completed CSV download link remains active for seven days.
Le score maximal de 0,879 est presque amusant avec le recul, car on dirait que le système sait exactement ce qu’il fait. Puis on lit le fragment obtenu, et il ne répond à rien. C’est le moment d’arrêter à considérer un score de similarité comme une garantie.
L’échec se produit avant la génération. Le système de récupération choisit un contexte plausible mais inutile, et le LLM ne voit que ce qui survit au critère top-k.
Afficher ce qui est revenu
Avant de modifier les modèles, la taille des fragments ou les stratégies de récupération, un petit outil qui affiche ce que le système de récupération renvoie s’avère très utile.
def show_hits(retriever, query, limit=5):
print(f"\n{query}\n")
for position, (chunk, score) in enumerate(
retriever.search(query, limit),
start=1,
):
print(
f"{position:>2}. {score:.3f} "
f"{chunk['id']} ({chunk['source']})"
)
print(f" {chunk['text']}\n")
De tout ce qui existe dans l’expérience, cet outil d’aide est le plus utilisé. Une fois les extraits affichés, les échecs se classent en différents types : le document correct peut être présent tandis que la section erronée l’emporte, ou bien deux ou trois extraits peuvent être des copies presque identiques. Dans d’autres cas, la requête et le passage présentent un vocabulaire suffisamment similaire pour obtenir une bonne note, alors que le passage est inutile comme preuve. Des chiffres simples masquent chacun de ces cas à moins que quelqu’un ne lise le texte.
Cela souligne également quelque chose de facile à négliger : le système de récupération prend une décision cruciale. Tout ce qui survit à cette étape devient la version de la réalité pour le modèle, et cela mérite une analyse plus approfondie que ce qui est généralement fait.
Évaluation par identifiant, et non par correspondance de chaîne
Une première version de l’évaluation comportait une astuce qui a ensuite été supprimée. Elle vérifiait la présence d’une chaîne de référence dans le texte de l’extrait :
if answer_hint in chunk["text"]:
...
Pratique, mais peu fiable. Un léger décalage dans les limites des segments, un changement de ponctuation ou une paraphrase peuvent faire en sorte qu’une récupération correcte paraisse erronée. Pire encore, cela lie l’évaluation à la formulation d’une réponse synthétique plutôt qu’au jugement de pertinence qui est essentiel. C’est pourquoi les segments disposent d’ID dès le départ, permettant à l’évaluateur de les vérifier directement :
def evaluate_retriever(retriever, cases, k=3):
recall_scores = []
reciprocal_ranks = []
for case in cases:
hits = retriever.search(case["query"])
relevant = case["relevant"]
relevant_positions = [
position
for position, (chunk, _) in enumerate(hits, start=1)
if chunk["id"] in relevant
]
found_in_top_k = sum(
position <= k
for position in relevant_positions
)
recall_scores.append(
found_in_top_k / len(relevant)
)
reciprocal_ranks.append(
1 / relevant_positions[0]
if relevant_positions
else 0.0
)
return {
f"recall@{k}": float(np.mean(recall_scores)),
"mrr": float(np.mean(reciprocal_ranks)),
}
Deux indicateurs ont été suivis. Recall@K indique si les preuves nécessaires entrent dans la fenêtre de contexte, tandis que Mean Reciprocal Rank permet de distinguer un fragment correct en position un d’un fragment caché en position huit. Résultats de référence : Recall@1 de 0,750, Recall@3 de 0,917, MRR de 0,854.
Le chiffre préoccupant est le Recall@3. Avec un contexte prévu de trois fragments, certaines questions perdaient déjà les informations nécessaires pour obtenir une réponse correcte avant même que la génération ne commence. Ajuster le prompt du système à ce stade ne servirait à rien.
Taille des fragments : un coupable partiel
Les fragments au niveau de phrase étaient les principaux suspects, et ils en étaient en partie responsables. Avec des fragments de deux phrases, le Recall@1 est passé de 0,750 à 0,917, le Recall@3 a atteint 1,000 et le MRR a grimpé à 0,958. Les fragments de trois phrases ont donné des résultats presque identiques. Les pages entières ont obtenu des scores parfaits, ce qui est agréable pendant environ cinq secondes.
Le problème, c’est que les pages étaient minuscules. Récupérer une page entière rendait presque impossible de manquer la réponse, ce qui améliorait partiellement la récupération car chaque unité contenait plus de contenu du corpus. Mais cela a un prix : des blocs plus gros consomment du contexte, incluent des phrases non pertinentes et laissent moins de place pour des preuves distinctes. De plus, un système de récupération qui renverrait toujours un manuel complet obtiendrait également de très bons résultats en termes de rappel.
Ainsi, la leçon n’était pas liée à une taille idéale des blocs. Elle résidait plutôt dans le fait que la segmentation initiale au niveau de la phrase était trop agressive, ce qui constitue une conclusion moins brillante mais tout de même utilisable. Cela suscite également des doutes quant à des paramètres similaires.
chunk_size = 500
chunk_overlap = 50
Rien n’est anormal avec ces chiffres en soi. Ils ne devraient simplement pas être utilisés en production parce qu’un tutoriel les a utilisés par hasard. La segmentation fait partie du processus de récupération, et elle a besoin de preuves comme tout le reste.
Les termes exacts transmettent le sens
L’exemple CSV contenait un autre indice : chaque passage bien classé était sémantiquement proche des exportations, ce qui indiquait que le système de récupération n’avait pas mal interprété le domaine. Il ne faisait simplement pas assez de distinction entre les passages qui s’y trouvaient. La documentation technique améliore cela, car des chaînes de caractères exactes transmettent une quantité énorme de sens. Un code d’erreur comme NX-204 n’est pas simplement sémantiquement similaire à un code voisin, et il en va de même pour customer_id, un point d’entrée, un numéro de version ou un nom de fonction.
BM25 a obtenu la note plate de 1,000 pour Recall@1, Recall@3 et MRR. Il a surpassé la référence sémantique, a performé mieux que ce à quoi tout le monde s’attendait et, ce qui est agaçant, a été parfait sur cet ensemble très restreint. Un titre du type « Les embeddings sont morts » serait tentant, mais les données ne le confirment pas. Ce que montrent ces résultats, c’est que cet ensemble d’évaluation a été particulièrement utile pour une terminologie précise. Un ensemble composé de questions reformulées pourrait choisir un autre gagnant. La leçon à retenir est l’importance cruciale de la correspondance lexicale dans ce corpus, ce qui justifie d’ailleurs la création d’un ensemble d’évaluation.
Récupération hybride avec fusion de classements
Abandonner la récupération sémantique semblait encore prématuré. Les deux méthodes échouent de manières différentes, c’est pourquoi leurs classements ont été combinés au moyen de la Fusion de rangs réciproques. Un détail d’implémentation est important : chaque outil de récupération ne propose qu’un nombre limité de candidats. Classer un corpus très vaste deux fois et fusionner tout cela en Python constituerait une approche de conception étrange à reproduire.
from collections import defaultdict
def fuse_rankings(vector_hits, bm25_hits, rrf_k=60):
fused = defaultdict(float)
chunks_by_id = {}
for hits in (vector_hits, bm25_hits):
for rank, (chunk, _) in enumerate(hits, start=1):
chunk_id = chunk["id"]
chunks_by_id[chunk_id] = chunk
fused[chunk_id] += 1 / (rrf_k + rank)
ranked_ids = sorted(
fused,
key=fused.get,
reverse=True,
)
return [
(chunks_by_id[chunk_id], fused[chunk_id])
for chunk_id in ranked_ids
]
Le conteneur qui l’entoure est simple :
def hybrid_search(query, lsa, bm25, candidate_k=20):
vector_hits = lsa.search(query, limit=candidate_k)
bm25_hits = bm25.search(query, limit=candidate_k)
return fuse_rankings(vector_hits, bm25_hits)
La récupération hybride a porté le MRR à 0,958 et a corrigé l’erreur de Recall@3 initiale. Elle a tout de même perdu face à BM25. L’expérience n’a été ajustée que lorsque l’architecture plus complexe a gagné, car cela aurait contredit son objectif. Plus de composants ne signifie pas automatiquement plus de pertinence.
Un réordonneur bon marché en complément
La récupération doit effectuer des recherches étendues, il est donc logique qu’elle soit peu coûteuse. Une fois qu’il y a 10 ou 20 candidats plausibles, un contrôle de pertinence plus ciblé devient réalisable. Dans cette expérience locale, ce contrôle était assuré par un évaluateur de n-grammes de caractères. Il s’agit d’un réordonneur expérimental bon marché et non d’une solution recommandée pour la production. Un véritable système devrait envisager l’utilisation d’un encodage croisé appris.
def cheap_local_rerank(query, candidates, limit=5):
"""
Good enough for this experiment.
I'd use a learned reranker for a real deployment.
"""
candidate_text = [
chunk["text"]
for chunk, _ in candidates
]
tfidf = TfidfVectorizer(
analyzer="char_wb",
ngram_range=(3, 5),
min_df=1,
)
matrix = tfidf.fit_transform(
[query, *candidate_text]
)
relevance = cosine_similarity(
matrix[0],
matrix[1:],
)[0]
reranked = sorted(
zip(candidates, relevance),
key=lambda row: row[1],
reverse=True,
)
return [
(chunk, float(score))
for ((chunk, _), score) in reranked[:limit]
]
Le pipeline aboutit à une forme simple à deux étapes.
La procédure consiste d’abord à sélectionner un ensemble large de candidats, puis à les réclasser de manière à ce que seuls les éléments les plus pertinents parviennent au LLM. Après réclassification, le petit ensemble de test a obtenu une note parfaite de 1,000 pour Recall@1, Recall@3 et MRR. Ce chiffre ne convient pas à une diapositive annonçant que le problème de récupération des données a été résolu, car douze requêtes aident au développement mais prouvent peu de chose quant à la fiabilité générale. Néanmoins, l’expérience a rempli son objectif. Voici la comparaison entre chaque étape :
Si l’histoire avait été rédigée avant l’expérience, chaque ligne s’aurait améliorée de manière ordonnée. Ce ne fut pas le cas. BM25, l’option simple, a terminé à égalité avec le pipeline final. La recherche hybride a amélioré la base sémantique de départ sans atteindre la première place. De plus gros blocs ont résolu un problème tout en soulevant une nouvelle question concernant le budget de contexte. Ce désordre rend les résultats plus crédibles et a révélé quelque chose de concret à propos de cette tâche : la terminologie exacte comptait beaucoup. Cela vaut mieux que de gagner un concours de popularité des architectures.
La question initiale était :
How long is a CSV export link usable?
Au moment des changements, le classement était le suivant :
1. CSV exports run asynchronously...
2. Large exports are split...
3. Users can cancel an export...
4. A completed CSV download link remains active for seven days.
Par la suite :
1. A completed CSV download link remains active for seven days.
2. Users can cancel an export while it is still queued...
3. CSV exports run asynchronously...
Les documents sources sont restés les mêmes, tout comme la question, et aucun LLM plus performant n’était nécessaire. Ce qui a changé, c’est les preuves.
Une méthode de débogage pour les réponses RAG incorrectes
Cette différence détermine la manière de réagir lorsque la réponse RAG est erronée. Lorsque les preuves nécessaires n’atteignent jamais le contexte, il faut examiner le processus de récupération. Lorsque les preuves sont clairement présentes mais que le modèle produit néanmoins du contenu absurde, c’est alors qu’il convient d’analyser la génération du texte, les prompts, l’ordre du contexte et le comportement du modèle. Il existe également un troisième type d’échec : la bonne réponse entre bien dans le contexte mais se trouve enfouie sous une pile de textes peu liés entre eux. Il s’agit alors d’un problème de classement ou de construction du contexte, et classer tout cela sous l’appellation de « hallucination » le cache.
Le flux pratique vérifie d’abord les preuves obtenues, puis détermine si le problème provient de la récupération ou de la génération. C’est ce point de décision qui est essentiel. Lorsque le taux de rappel@3 est faible, il n’a aucun sens de passer une heure à reformuler « répondez uniquement en utilisant le contexte fourni » de six manières de plus en plus strictes, car le contexte est déjà erroné.
Les modifications des prompts semblent toujours plus rapides : vous modifiez, relancez l’opération et obtenez immédiatement une nouvelle réponse. Le travail de récupération est plus lent et moins satisfaisant : il faut lire des fragments, évaluer leur pertinence, vérifier les classements, gérer les frontières entre ces fragments, et parfois constater qu’un système de récupération lexical traditionnel est supérieur à des solutions plus sophistiquées. Cependant, ce dernier enseigne des méthodes plus fiables.
La question la plus utile n’est pas de savoir pourquoi le LLM a donné une mauvaise réponse. C’est plutôt de déterminer si le LLM a bien obtenu les informations dont il avait besoin. Cela semble presque trop évident pour qu’on s’y attarde, pourtant on l’oublie souvent.