Pourquoi RAG échoue en production : segmentation en blocs, embeddings, récupération et évaluation.
Pourquoi RAG échoue en production une fois la démonstration terminée : limites des blocs de données, points aveugles des embeddings, limites top-K, données obsolètes et l’étape d’évaluation que les équipes omettent.
Sources: RAG Without the Hype: What Retrieval-Augmented Generation Actually Takes to Work in Production
Le tutoriel standard sur RAG se déroule de manière linéaire. Un PDF est chargé et divisé en fragments, ces fragments sont intégrés et stockés dans une base de données vectorielle, puis une question de l’utilisateur est intégrée, comparée aux fragments les plus proches et insérée dans un prompt. La réponse est précise, et on a l’impression que la partie difficile est terminée.
En situation réelle, c’est différent. Les questions concernent plusieurs documents. Le contexte pertinent est coupé aux limites des fragments. La récupération fournit des passages qui sont proches de la requête sur le plan sémantique mais incorrects sur le plan contextuel, avec les bons mots mais le mauvais sens. Le modèle répond alors avec assurance à partir de matériel obsolète, contradictoire ou manquant d’une précision qui se trouvait trois phrases avant la limite du fragment. Le pire, c’est que la sortie semble correcte, et rien n’indique que la récupération a échoué.
Il est facile de créer une démonstration RAG, mais ce n’est pas le cas pour une version qui fonctionne de manière fiable sur un corpus réel avec des utilisateurs réels. Cette différence provient de trois facteurs : la manière dont les documents sont divisés en blocs, la façon dont les embeddings gèrent les nuances, et la manière dont le système de recherche détermine ce qui est pertinent. Chacun de ces éléments présente des modes de défaillance faciles à manquer et coûteux à diagnostiquer une fois le système lancé.
La segmentation en blocs, où l’information s’échappe
Par défaut, le tutoriel utilise une segmentation récursive des caractères avec une taille fixe et un certain chevauchement.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200
)
chunks = splitter.split_documents(documents)
Cela suffit amplement pour une démonstration. Cependant, cela échoue lorsque la structure du document contient des significations que le comptage des caractères ignore, et ce de trois manières courantes.
- Contexte fragmenté. Une spécification peut indiquer que la valeur maximale supportée est de 500, et que cette limite ne s’applique qu’en mode haute disponibilité avec la configuration de basculement principal activée. Si le comptage s’arrête à 1 000 caractères, ce chiffre peut se trouver dans un bloc et la condition correspondante dans un autre. Une personne se renseignant sur la limite de haute disponibilité récupère le bloc indiquant 500 sans aucune précision. La réponse existe bien dans le corpus, mais le fractionnement en blocs a rompu ce lien.
- Titres orphelins. Un titre tel que « Configuration de sécurité » et le premier paragraphe qui suit, indiquant que le TLS est requis pour chaque connexion, peuvent se retrouver dans des parties différentes. Un outil de récupération qui trouve le paragraphe ne dispose pas de contexte de titre, et l’encadrement de la phrase ne précise pas qu’elle fait partie de la configuration de sécurité.
- Tables et listes endommagées. Les outils de segmentation à taille fixe coupent souvent une table au milieu d’une ligne ou une liste au milieu d’un élément. Un en-tête de colonne dans une partie et les données dans une autre rendent l’un comme l’autre illisibles seuls.
La segmentation de niveau professionnel repose généralement sur trois techniques.
Segmentation tenant compte de la structure : elle se fait aux limites naturelles du document, c’est-à-dire aux titres, paragraphes et sections, plutôt qu’en fonction du nombre de caractères. Markdown peut être segmenté aux titres de deuxième ou troisième niveau, tandis qu’HTML le peut aux balises de titre, de paragraphe ou de section.
from langchain.text_splitter import MarkdownHeaderTextSplitter
headers_to_split_on = [
("#", "document_title"),
("##", "section"),
("###", "subsection"),
]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
chunks = splitter.split_text(markdown_document)
# Each chunk now includes header metadata: {"section": "Security Configuration"}
Enrichissement du contexte parent : chaque morceau est stocké avec le chemin du titre qui y mène. Lorsque le morceau est récupéré, la demande reçoit les titres des sections et des sous-sections à côté du texte, fournissant au modèle une structure que l’espace d’incorporation ne contient pas.
Éclatement en morceaux parents et enfants : des morceaux plus petits sont conservés pour une précision de récupération, ainsi que des morceaux parents plus grands qui les contiennent. La récupération correspond au petit morceau, et la demande reçoit le morceau parent. Les petits morceaux incorporent des idées spécifiques avec plus de précision, tandis que les morceaux parents fournissent les informations environnantes.
# Small child chunk for retrieval
child_chunk = "The maximum supported value is 500."
# Parent chunk injected into prompt when child is retrieved
parent_chunk = """
## High Availability Configuration
When operating in high-availability mode with primary failover enabled,
the following limits apply:
- Maximum supported value: 500
- Minimum heartbeat interval: 100ms
- Failover timeout: 30 seconds
"""
Cette approche coûte plus cher à mettre en œuvre et à stocker, mais elle fournit des réponses bien meilleures pour les questions nécessitant un contexte environnant, ce qui concerne la plupart des questions réelles.
Les embeddings et les limites de la similarité
Les embeddings placent le texte dans un espace vectoriel où les significations similaires se trouvent proches les unes des autres, et la récupération renvoie les fragments les plus proches de la requête. Cela fonctionne tant que la proximité sémantique constitue un substitut fiable pour la pertinence. Cela échoue lorsque ces deux aspects divergent.
- Négation. Les phrases indiquant qu’un système ne prend pas en charge IPv6 et celles indiquant qu’il le fait obtiennent des vecteurs presque identiques, puisque toutes deux traitent du support IPv6. La négation ne modifie que légèrement le vecteur. Une question concernant IPv6 peut faire apparaître les deux fragments ou le mauvais, et le modèle doit résoudre une contradiction ou répondre à partir du passage incorrect sans s’en rendre compte.
- Désaccord de vocabulaire. Un utilisateur écrit « login » tandis que la documentation utilise les termes « authentication » et « sign-in ». Ces termes sont généralement suffisamment proches pour permettre une recherche efficace, mais des cas particuliers surviennent lorsque le jargon de l’utilisateur diffère de l’usage décrit dans la documentation.
- Spécificité. Une question concernant la manière de configurer le pool de connexions et une autre portant sur le délai d’attente par défaut du pool se trouvent très proches l’une de l’autre, ce qui fait que les deux retournent le même ensemble de fragments d’information. Lorsque la valeur du délai d’attente se trouve dans un paragraphe parmi plusieurs autres traitant des pools, le meilleur fragment n’est pas toujours placé en premier.
Trois correctifs permettent de surmonter ces limites.
Recherche hybride combine la récupération dense, qui prend en compte le sens, avec la récupération sparsa telle que BM25 ou TF-IDF, qui prend en compte les termes exacts. Une requête concernant un numéro de version, une clé de configuration ou un code d’erreur est mieux traitée par la correspondance de mots-clés que par la similarité sémantique. De nombreuses bases de données vectorielles offrent désormais nativement une recherche hybride, et l’exemple ci-dessous utilise une combinaison de méthodes sparsas et densas dans Qdrant.
from qdrant_client import QdrantClient
from qdrant_client.models import SparseVector, NamedSparseVector
# Many vector databases now support hybrid search natively
# This example uses Qdrant's sparse + dense hybrid search
results = client.query_points(
collection_name="docs",
prefetch=[
models.Prefetch(
query=sparse_query_vector, # BM25 sparse vector
using="sparse",
limit=20,
),
models.Prefetch(
query=dense_query_vector, # Embedding dense vector
using="dense",
limit=20,
),
],
query=models.FusionQuery(fusion=models.Fusion.RRF), # Reciprocal rank fusion
limit=5,
)
Expansion des requêtes : cette étape enrichit la question avant qu’elle ne soit intégrée. Une requête concernant l’authentification peut être élargie en y ajoutant des termes tels que « connexion », « identification », « authentification » et « vérification d’identité », ce qui la place dans une zone plus riche du espace de représentation. Une petite appel à un LLM peut générer ces variations, ou bien une carte de synonymes statique peut couvrir les termes propres au domaine.
Sélection du modèle d’embedding : ce critère mérite également une attention particulière. De nombreuses équipes optent par défaut pour des modèles généraux d’OpenAI tels que text-embedding-ada-002 ou text-embedding-3-small. Pour des corpus médicaux, juridiques ou techniques, un modèle spécifique au domaine ou affiné fonctionne souvent beaucoup mieux. Lorsque la récupération des informations reste insuffisante malgré une bonne segmentation des données, il est avantageux de comparer différentes options sur un échantillon représentatif de requêtes.
Problèmes de récupération qui apparaissent à grande échelle
Le découpage en blocs et les embeddings déterminent si le contenu peut être trouvé, tandis que la récupération décide s’il est effectivement localisé pour une requête donnée. Certaines erreurs ne se manifestent que avec un corpus important ou des requêtes variées.
Choix de K. La plupart des systèmes récupèrent les K meilleurs blocs en fonction de leur similarité et les incluent tous dans le prompt. Un K trop petit manque des informations essentielles lorsque la réponse nécessite le soutien de plusieurs blocs. Un K trop élevé remplit la fenêtre de contexte de blocs peu pertinents qui affaiblissent le signal ; de plus, dans les prompts longs, cela peut faire en sorte que le bloc véritablement pertinent dépasse la portée de l’attention effective du modèle.
La méthode pratique consiste à récupérer un nombre excessif de résultats, par exemple les 20 ou 30 meilleurs candidats, puis à les réclasser. Un encodeur croisé prend en compte à la fois la requête et les fragments pour évaluer directement le degré de pertinence, au lieu de comparer deux embeddings calculés indépendamment. Cette approche est plus lente car les embeddings des fragments ne peuvent pas être précalculés, mais elle permet de distinguer beaucoup mieux les résultats pertinents de ceux qui le sont seulement partiellement. La récupération approximative rapide réduit le champ des résultats, et l’encodeur croisé sélectionne enfin l’ensemble final.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
# Retrieve broad candidate set
candidates = vector_store.similarity_search(query, k=20)
# Re-rank with cross-encoder
pairs = [(query, chunk.page_content) for chunk in candidates]
scores = reranker.predict(pairs)
# Sort by re-ranking score, take top 5
reranked = sorted(zip(scores, candidates), reverse=True)[:5]
final_chunks = [chunk for _, chunk in reranked]
Dépréciation des données. Un stockage de vecteurs intégré il y a six mois contient des documents qui ont depuis été modifiés, sans que le système d’incorporation ne s’en aperçoive. La recherche restitue alors le passage obsolète avec une grande fiabilité, et le modèle répond en se basant sur celui-ci. La solution réside dans le filtrage des métadonnées : il faut enregistrer la date de dernière modification de chaque fragment et filtrer selon une plage de dates avant ou après la recherche de similarité. Pour une documentation activement maintenue, les fragments plus anciens qu’un seuil défini sont supprimés ou classés en bas de liste.
from datetime import datetime, timedelta
# Filter to chunks updated in the last 90 days
cutoff = datetime.now() - timedelta(days=90)
results = vector_store.similarity_search_with_filter(
query=query,
filter={"last_modified": {"$gte": cutoff.isoformat()}},
k=10
)
Fausse confiance. Un modèle répond en se basant sur ce qu’il reçoit, même lorsque les fragments récupérés ne sont pas pertinents pour la question. Il ne sait pas que la récupération des informations a été insuffisante. Cela cause le plus de dommages à la confiance des utilisateurs, car la réponse semble fiable alors qu’elle est fausse. La solution consiste à mettre en place un filtre de pertinence avant la génération : soit on évalue les fragments récupérés et on empêche la génération lorsque la meilleure note est en deçà d’un seuil, soit on indique au modèle, dans le prompt système, qu’il manque d’informations, puis on vérifie qu’il utilise correctement cette solution de secours.
SYSTEM_PROMPT = """
You are a documentation assistant. Answer questions using only the provided context.
If the context does not contain enough information to answer the question confidently,
respond with: "I don't have sufficient information in the available documentation to
answer this question reliably."
Do not speculate or answer from general knowledge when documentation context is insufficient.
"""
Évaluation, l’étape que les équipes sautent
Un système RAG sans évaluation présente une qualité inconnue. Il peut performer bien sur certaines requêtes et mal sur d’autres, et la seule façon de le savoir est grâce à des plaintes des utilisateurs. L’évaluation comporte trois parties.
Qualité de la récupération. Pour des requêtes de test disposant de documents sources corrects connus, mesurez la fréquence à laquelle le document approprié apparaît parmi les K meilleurs résultats obtenus. Le taux de rappel à 5 résultats vérifie si le fragment pertinent figure parmi les cinq premiers résultats, tandis que le rang réciproque moyen indique sa position dans la liste. Un ensemble de test composé de 100 à 200 requêtes représentatives doit être exécuté avant le lancement.
Fidélité de la réponse. La réponse générée reflète-t-elle le contexte récupéré sans ajouter des affirmations que les fragments ne contiennent pas ? Une configuration où un LLM agit en juge peut évaluer cela en recevant la requête, les fragments récupérés ainsi que la réponse, puis en notant dans quelle mesure le contexte soutient la réponse.
FAITHFULNESS_PROMPT = """
Given the following context and answer, rate whether the answer is fully
supported by the context on a scale of 1-5.
Context: {context}
Answer: {answer}
Score (1=not supported, 5=fully supported):
"""
# Automate across your evaluation set
scores = []
for query, context, answer in eval_set:
score = judge_model.complete(
FAITHFULNESS_PROMPT.format(context=context, answer=answer)
)
scores.append(score)
print(f"Average faithfulness: {sum(scores)/len(scores):.2f}")
Correctitude de bout en bout. Pour les requêtes ayant des réponses connues, le système produit-il la bonne réponse ? C’est la partie la plus difficile à automatiser et aussi la plus importante. Même un ensemble de 50 questions conçu manuellement et dont les réponses ont été vérifiées, exécuté avant et après chaque modification des paramètres de segmentation, d’encodage ou de récupération, permet de détecter les dégradations.
L’ensemble d’évaluation fonctionne comme un ensemble de tests de dégradation. Un nouveau modèle d’encodage, une taille de segment différente ou une modification du prompt du système devraient chacun entraîner un changement mesurable des scores. Lorsqu’une modification améliore la fidélité moyenne mais diminue le taux de récupération, ou l’inverse, ce compromis doit être une décision consciente et non un déploiement silencieux.
Le pipeline est le produit
Le pipeline pédagogique comporte quatre étapes et ne dispose d’aucun mécanisme de gestion des erreurs. Le pipeline de production compte six étapes, avec une détection des échecs à chaque étape, des métadonnées ajoutées tout au long du processus et une évaluation avant chaque publication.
Les échecs sont prévisibles. Le découpage qui ignore la structure fait perdre du contexte aux extrémités. La recherche basée uniquement sur les embeddings rate les correspondances exactes et gère mal les négations. La recherche sans réclassement renvoie un top K affaibli qui masque le meilleur fragment. La génération sans filtre de pertinence produit des réponses erronées mais affirmées à partir d’un contexte faible.
Rien de tout cela n’est insoluble, et chaque correction a un coût connu. Le découpage tenant compte de la structure nécessite de comprendre le format du document. La recherche hybride implique l’utilisation à la fois d’index denses et de index spars. Le réclassement augmente le temps de réponse de la recherche. L’évaluation exige la création et la maintenance d’un ensemble de test.
Les équipes dont les systèmes RAG fonctionnent correctement prennent ces décisions intentionnellement avant le lancement, et non en réaction à des réponses erronées. Les tutoriels omettent les parties difficiles car une démonstration n’en a pas besoin, mais un système fiable, si.