Pourquoi votre RAG échoue (et comment le corriger)
Votre RAG fonctionne en démo mais échoue en production. Les réponses sont hors sujet, incomplètes, ou pire, inventées. Vous n'êtes pas seul : 70% des projets RAG n'atteignent jamais la production selon les retours d'expérience de la communauté ML.
Ce guide identifie les causes réelles d'échec et propose des solutions concrètes pour chacune.
Cause 1 : Vos données sont le problème
Symptômes
- Le RAG trouve des documents mais les réponses sont incorrectes
- Les mêmes questions donnent des réponses contradictoires
- L'information existe mais n'est jamais retrouvée
Diagnostic
def diagnose_data_quality(documents: List[Document]):
issues = []
for doc in documents:
# Documents trop courts (< 100 mots)
if len(doc.content.split()) < 100:
issues.append(f"Document trop court: {doc.id}")
# Documents dupliqués
if is_near_duplicate(doc, documents):
issues.append(f"Doublon détecté: {doc.id}")
# Contenu obsolète
if doc.last_updated < datetime.now() - timedelta(days=365):
issues.append(f"Contenu obsolète: {doc.id}")
# Métadonnées manquantes
if not doc.metadata.get("source") or not doc.metadata.get("title"):
issues.append(f"Métadonnées incomplètes: {doc.id}")
return issuesProblèmes courants et solutions
| Problème | Impact | Solution |
|---|---|---|
| Documents dupliqués | Bruit dans les résultats | Déduplication par hash + similarité |
| Contenu obsolète | Réponses incorrectes | Pipeline de rafraîchissement |
| Format inconsistant | Embeddings de mauvaise qualité | Normalisation avant indexation |
| Données bruitées | Contexte pollué | Nettoyage (HTML, headers, footers) |
Solution : Pipeline de nettoyage
class DataCleaner:
def clean(self, raw_content: str) -> str:
content = raw_content
# 1. Supprimer le HTML/Markdown superflu
content = self.strip_formatting(content)
# 2. Normaliser les espaces et sauts de ligne
content = self.normalize_whitespace(content)
# 3. Supprimer les headers/footers répétitifs
content = self.remove_boilerplate(content)
# 4. Détecter et corriger l'encodage
content = self.fix_encoding(content)
# 5. Valider la qualité minimale
if not self.is_quality_sufficient(content):
raise LowQualityContentError(content)
return content
def is_quality_sufficient(self, content: str) -> bool:
words = content.split()
# Au moins 50 mots
if len(words) < 50:
return False
# Ratio mots uniques > 30%
if len(set(words)) / len(words) < 0.3:
return False
return TrueCause 2 : Votre chunking est inadapté
Symptômes
- Les réponses sont incomplètes (information coupée)
- Le contexte manque de cohérence
- Trop de chunks non pertinents remontent
Le problème du chunking naïf
text = "La fonction authenticate() vérifie les credentials. Elle retourne"Stratégies de chunking
| Stratégie | Cas d'usage | Taille recommandée |
|---|---|---|
| Par paragraphe | Documentation, articles | 300-500 tokens |
| Par section | Docs techniques structurées | 500-1000 tokens |
| Sémantique | Contenu varié | Variable |
| Par phrase | FAQ, Q&A | 50-100 tokens |
Solution : Chunking sémantique
from langchain.text_splitter import RecursiveCharacterTextSplitter
class SemanticChunker:
def __init__(self, embedding_model):
self.embedder = embedding_model
self.base_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n\n", "\n", ". ", "! ", "? ", "; ", ", ", " "]
)
def chunk(self, document: str) -> List[str]:
# 1. Split initial
raw_chunks = self.base_splitter.split_text(document)
# 2. Fusionner les chunks sémantiquement proches
merged_chunks = self.merge_similar_chunks(raw_chunks)
# 3. Ajouter le contexte parent
contextualized = self.add_context(merged_chunks, document)
return contextualized
def merge_similar_chunks(self, chunks: List[str], threshold=0.85) -> List[str]:
"""Fusionne les chunks consécutifs très similaires"""
embeddings = self.embedder.embed(chunks)
merged = []
current = chunks[0]
for i in range(1, len(chunks)):
similarity = cosine_similarity(embeddings[i-1], embeddings[i])
if similarity > threshold:
current += " " + chunks[i]
else:
merged.append(current)
current = chunks[i]
merged.append(current)
return merged
def add_context(self, chunks: List[str], full_doc: str) -> List[str]:
"""Ajoute un résumé du document parent à chaque chunk"""
summary = self.summarize(full_doc)
return [f"[Context: {summary}]\n\n{chunk}" for chunk in chunks]Cause 3 : Vos embeddings ne capturent pas le sens
Symptômes
- Recherches par mots-clés fonctionnent mieux que la recherche vectorielle
- Documents similaires ont des scores très différents
- La recherche rate des synonymes évidents
Diagnostic des embeddings
def diagnose_embeddings(queries: List[str], expected_docs: List[str], vector_store):
issues = []
for query, expected in zip(queries, expected_docs):
results = vector_store.search(query, top_k=10)
result_ids = [r.id for r in results]
if expected not in result_ids:
# Document attendu pas dans le top 10
issues.append({
"query": query,
"expected": expected,
"got": result_ids[:3],
"issue": "Document pertinent non trouvé"
})
elif result_ids.index(expected) > 3:
# Document attendu trop bas dans le ranking
issues.append({
"query": query,
"expected": expected,
"rank": result_ids.index(expected),
"issue": "Ranking incorrect"
})
return issuesProblèmes d'embeddings courants
| Problème | Cause | Solution |
|---|---|---|
| Vocabulaire technique ignoré | Modèle généraliste | Fine-tuner ou modèle spécialisé |
| Langue mal supportée | Modèle anglophone | Modèle multilingue (E5, BGE-M3) |
| Requêtes courtes mal matchées | Asymétrie query/doc | Modèles asymétriques |
| Sémantique domaine-spécifique | Hors distribution | Fine-tuning sur votre corpus |
Solution : Choix et adaptation du modèle
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("intfloat/multilingual-e5-large")
from sentence_transformers import InputExample, losses
train_examples = [
InputExample(texts=["erreur connexion OAuth", doc_oauth_troubleshooting]),
InputExample(texts=["configurer SSL Spring", doc_ssl_configuration]),
# ... plus d'exemples de votre domaine
]
model.fit(
train_objectives=[(train_dataloader, losses.MultipleNegativesRankingLoss(model))],
epochs=3,
warmup_steps=100
)Cause 4 : Votre retrieval manque de précision
Symptômes
- Beaucoup de documents retournés mais peu pertinents
- Le bon document est noyé dans le bruit
- Résultats très différents pour des requêtes similaires
Le problème du top-K fixe
results = vector_store.search(query, top_k=5)Solutions de retrieval avancées
class AdaptiveRetriever:
def __init__(self, vector_store, keyword_store, reranker):
self.vector = vector_store
self.keyword = keyword_store
self.reranker = reranker
def retrieve(self, query: str, min_docs=3, max_docs=10) -> List[Document]:
# 1. Recherche hybride large
vector_results = self.vector.search(query, top_k=max_docs * 2)
keyword_results = self.keyword.search(query, top_k=max_docs * 2)
# 2. Fusion RRF
candidates = self.rrf_fusion(vector_results, keyword_results)
# 3. Reranking
reranked = self.reranker.rerank(query, candidates)
# 4. Filtrage adaptatif par score
threshold = self.compute_adaptive_threshold(reranked)
filtered = [doc for doc in reranked if doc.score > threshold]
# 5. Garantir un minimum de résultats
if len(filtered) < min_docs:
filtered = reranked[:min_docs]
return filtered[:max_docs]
def compute_adaptive_threshold(self, docs: List[Document]) -> float:
"""Seuil dynamique basé sur la distribution des scores"""
scores = [doc.score for doc in docs]
mean = sum(scores) / len(scores)
std = (sum((s - mean) ** 2 for s in scores) / len(scores)) ** 0.5
# Garder les docs à plus de 0.5 std au-dessus de la moyenne
return mean + 0.5 * stdAjouter un reranker
from sentence_transformers import CrossEncoder
class Reranker:
def __init__(self, model_name="cross-encoder/ms-marco-MiniLM-L-12-v2"):
self.model = CrossEncoder(model_name)
def rerank(self, query: str, documents: List[Document]) -> List[Document]:
# Scorer chaque paire query-document
pairs = [(query, doc.content[:512]) for doc in documents]
scores = self.model.predict(pairs)
# Attacher les scores et trier
for doc, score in zip(documents, scores):
doc.rerank_score = float(score)
return sorted(documents, key=lambda d: d.rerank_score, reverse=True)Impact mesuré du reranker :
- Precision@5 sans reranker : 62%
- Precision@5 avec reranker : 84%
- Latence ajoutée : 50-80ms
Cause 5 : Votre prompt est mal conçu
Symptômes
- Le LLM ignore le contexte fourni
- Réponses génériques malgré des documents spécifiques
- Hallucinations fréquentes
Prompts problématiques
prompt = f"Réponds à cette question: {query}\nDocuments: {docs}"
prompt = f"Voici des documents:\n{docs}\n\nQuestion: {query}"
prompt = f"""Tu es un assistant utile et bienveillant. Tu dois toujours
être poli et respectueux. Tu peux utiliser les documents suivants mais
tu peux aussi utiliser tes connaissances générales. N'hésite pas à
demander des clarifications si nécessaire.
Documents: {docs}
Question: {query}"""Solution : Prompt structuré et contraignant
class PromptBuilder:
SYSTEM = """Tu es un assistant technique qui répond UNIQUEMENT à partir des documents fournis.
RÈGLES STRICTES:
1. Utilise UNIQUEMENT les informations des documents ci-dessous
2. Si l'information n'est pas dans les documents, réponds "Je n'ai pas trouvé cette information dans la documentation."
3. Cite tes sources avec [1], [2], etc.
4. Sois concis et précis
5. N'invente JAMAIS d'information"""
def build(self, query: str, documents: List[Document]) -> str:
# Formater les documents avec numéros
docs_formatted = []
for i, doc in enumerate(documents, 1):
docs_formatted.append(
f"[{i}] Source: {doc.metadata.get('title', 'Unknown')}\n"
f"{doc.content[:1500]}"
)
context = "\n\n---\n\n".join(docs_formatted)
return f"""{self.SYSTEM}
DOCUMENTS DE RÉFÉRENCE:
{context}
QUESTION DE L'UTILISATEUR:
{query}
RÉPONSE (avec citations [1], [2], etc.):"""Techniques anti-hallucination
class HallucinationGuard:
def __init__(self, llm):
self.llm = llm
def generate_with_verification(self, query: str, docs: List[Document]) -> str:
# 1. Générer la réponse
response = self.generate(query, docs)
# 2. Extraire les affirmations factuelles
claims = self.extract_claims(response)
# 3. Vérifier chaque affirmation contre les documents
verified_claims = []
for claim in claims:
if self.verify_claim(claim, docs):
verified_claims.append(claim)
else:
# Marquer comme non vérifié ou supprimer
verified_claims.append(f"[Non vérifié] {claim}")
# 4. Reconstruire la réponse
return self.rebuild_response(verified_claims)
def verify_claim(self, claim: str, docs: List[Document]) -> bool:
"""Vérifie si une affirmation est supportée par les documents"""
prompt = f"""L'affirmation suivante est-elle supportée par les documents?
Affirmation: {claim}
Documents:
{self.format_docs(docs)}
Réponds uniquement par OUI ou NON."""
verdict = self.llm.generate(prompt, max_tokens=5)
return "OUI" in verdict.upper()Cause 6 : Votre infrastructure ne tient pas la charge
Symptômes
- Latence acceptable en dev, catastrophique en prod
- Timeouts fréquents
- Coûts qui explosent
Goulots d'étranglement typiques
| Composant | Latence typique | Goulot fréquent |
|---|---|---|
| Embedding query | 50-100ms | API externe saturée |
| Vector search | 10-50ms | Index non optimisé |
| Reranking | 50-100ms | Modèle trop gros |
| LLM generation | 1-5s | Cold start, queue |
| Total | 1.5-6s |
Solutions d'optimisation
class OptimizedRAG:
def __init__(self):
# 1. Cache multi-niveaux
self.query_cache = LRUCache(maxsize=10000)
self.embedding_cache = LRUCache(maxsize=50000)
self.response_cache = TTLCache(maxsize=5000, ttl=3600)
# 2. Batch processing pour embeddings
self.embedding_batch = BatchProcessor(
max_batch_size=32,
max_wait_ms=50
)
# 3. Connection pooling
self.vector_store = VectorStore(
connection_pool_size=20,
max_retries=3
)
async def answer(self, query: str) -> str:
# Check response cache
cache_key = self.hash_query(query)
if cache_key in self.response_cache:
return self.response_cache[cache_key]
# Parallel retrieval + embedding
embedding_task = self.get_or_compute_embedding(query)
normalized_query = self.normalize_query(query)
embedding = await embedding_task
docs = await self.vector_store.search_async(embedding)
# Streaming response
response = await self.llm.generate_stream(query, docs)
self.response_cache[cache_key] = response
return response
async def get_or_compute_embedding(self, text: str):
if text in self.embedding_cache:
return self.embedding_cache[text]
embedding = await self.embedding_batch.add(text)
self.embedding_cache[text] = embedding
return embeddingMétriques à monitorer
from prometheus_client import Histogram, Counter, Gauge
embedding_latency = Histogram('rag_embedding_seconds', 'Embedding latency')
retrieval_latency = Histogram('rag_retrieval_seconds', 'Retrieval latency')
rerank_latency = Histogram('rag_rerank_seconds', 'Reranking latency')
generation_latency = Histogram('rag_generation_seconds', 'LLM generation latency')
cache_hits = Counter('rag_cache_hits_total', 'Cache hit count', ['cache_type'])
empty_results = Counter('rag_empty_results_total', 'Queries with no results')
hallucination_detected = Counter('rag_hallucinations_total', 'Detected hallucinations')
vector_store_connections = Gauge('rag_vectorstore_connections', 'Active connections')
llm_queue_size = Gauge('rag_llm_queue_size', 'Pending LLM requests')Checklist de debugging
Quand votre RAG échoue, suivez cette checklist :
1. Vérifier les données
- [ ] Documents bien indexés ? (count, sample)
- [ ] Pas de doublons massifs ?
- [ ] Métadonnées présentes ?
- [ ] Contenu nettoyé ?
2. Vérifier le retrieval
- [ ] La requête problématique retourne des documents ?
- [ ] Les documents retournés sont pertinents ?
- [ ] Le reranker améliore-t-il le ranking ?
3. Vérifier le prompt
- [ ] Le contexte est bien formaté ?
- [ ] Les instructions sont claires ?
- [ ] Le LLM utilise bien le contexte ?
4. Vérifier l'infrastructure
- [ ] Latences par composant normales ?
- [ ] Pas d'erreurs dans les logs ?
- [ ] Resources suffisantes ?
Conclusion
Les échecs RAG ont presque toujours des causes identifiables :
- Données : nettoyez, dédupliquez, enrichissez
- Chunking : adaptez la stratégie au contenu
- Embeddings : choisissez le bon modèle, fine-tunez si nécessaire
- Retrieval : hybride + reranking systématique
- Prompt : structuré, contraignant, avec anti-hallucination
- Infrastructure : cache, batch, monitoring
Le RAG parfait n'existe pas du premier coup. Instrumentez, mesurez, itérez. Chaque amélioration de 5% de précision se traduit par une meilleure expérience utilisateur.
Pour l'architecture complète d'un RAG production-ready : RAG en production : architecture simple qui fonctionne vraiment
Pour une introduction au RAG : RAG en 2025 : définition, architecture et cas d'usage