Un RAG qui marche en démo et plante en prod, c'est la norme. Voici pourquoi.
Erreur 1 — Chunks trop grands (ou trop petits)
Le chunking est l'étape la plus sous-estimée du pipeline RAG. La plupart des tutoriels utilisent chunk_size=1000 avec un chunk_overlap=200 et passent à la suite. En prod, c'est là que tout se casse.
Le problème : un chunk trop grand noie l'information pertinente dans du bruit. Un chunk trop petit perd le contexte nécessaire à la réponse.
La règle : votre chunk doit contenir exactement une idée autonome — ni plus, ni moins.
from langchain.text_splitter import RecursiveCharacterTextSplitter
# ❌ Taille arbitraire
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
# ✅ Adapté au contenu : paragraphes sémantiques
splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n\n", "\n", ".", "?", "!"],
)
Pour les documents juridiques ou techniques, j'utilise un chunking par section avec des titres H2/H3 comme séparateurs primaires. Résultat : +34% de précision sur les réponses citées.
Erreur 2 — Ignorer le re-ranking
La recherche vectorielle dense (cosine similarity) est excellente pour trouver des documents sémantiquement proches. Mais elle est aveugle aux nuances syntaxiques.
Un système en prod a besoin d'un pipeline hybride :
- Dense retrieval (FAISS / pgvector) — top 20 candidats
- BM25 sparse retrieval — top 20 candidats par keywords
- Cross-encoder re-ranking — fusionne et réordonne les 40 résultats
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def rerank(query: str, candidates: list[str]) -> list[str]:
pairs = [(query, doc) for doc in candidates]
scores = reranker.predict(pairs)
ranked = sorted(zip(scores, candidates), reverse=True)
return [doc for _, doc in ranked[:5]]
Sur un cabinet juridique client : les recherches passées de 40 min à 28 secondes — et le nombre de réponses incorrectes a chuté de 60%.
Erreur 3 — Pas de validation des réponses
Le plus dangereux : un RAG qui invente des sources. Ou pire, qui répond avec confiance sur un document qu'il n'a pas trouvé.
La solution : forcer le modèle à citer ses sources avec un score de confiance, et rejeter toute réponse sans source valide.
from pydantic import BaseModel
import instructor
import openai
class RAGResponse(BaseModel):
answer: str
sources: list[str] # chunk IDs obligatoires
confidence: float # 0.0 → 1.0
client = instructor.from_openai(openai.OpenAI())
def query_with_validation(question: str, context: str) -> RAGResponse:
return client.chat.completions.create(
model="gpt-4o",
response_model=RAGResponse,
messages=[
{"role": "system", "content": f"Context:\n{context}"},
{"role": "user", "content": question},
],
)
Si confidence < 0.7 ou si sources est vide → on renvoie "Je n'ai pas trouvé de réponse dans la base documentaire" plutôt qu'une hallucination.
Le bonus qui évite la régression silencieuse
Les trois erreurs ci-dessus corrigées, un piège reste : un RAG qui se dégrade sans que personne ne s'en aperçoive. Un changement de modèle d'embedding, une mise à jour de la base documentaire mal indexée, ou un simple drift du contenu source suffisent à faire chuter la qualité des réponses en silence.
La parade : logger systématiquement, pour chaque requête en production, le score de confiance retourné par le modèle, les chunk IDs cités, et un échantillon aléatoire des réponses pour revue manuelle hebdomadaire.
import logging
def log_rag_query(question: str, response: RAGResponse) -> None:
logging.info({
"event": "rag_query",
"question": question,
"confidence": response.confidence,
"sources_count": len(response.sources),
"low_confidence": response.confidence < 0.7,
})
Sur ce même cabinet juridique, ce monitoring a permis de détecter en moins de 48h une régression causée par une mise à jour de la base documentaire mal ré-indexée — avant qu'un seul utilisateur ne s'en plaigne. Sans ces logs, le problème serait resté invisible pendant des semaines.
Le piège de la mise à jour de la base documentaire
Un point qui n'apparaît dans aucun tutoriel RAG : que se passe-t-il le jour où un document source est modifié ou supprimé ? La réponse naïve — tout ré-indexer à chaque changement — fonctionne pour 50 documents, devient inutilisable pour 5 000.
La solution qui tient à l'échelle : indexer par empreinte de contenu, pas par nom de fichier, pour ne ré-embedder que ce qui a réellement changé.
import hashlib
def get_content_hash(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
def sync_document(doc_id: str, content: str, vector_store) -> None:
new_hash = get_content_hash(content)
existing_hash = vector_store.get_metadata(doc_id, "content_hash")
if existing_hash == new_hash:
return # Contenu identique, on ne touche à rien
# Contenu modifié : supprimer les anciens chunks, ré-embedder, ré-indexer
vector_store.delete(filter={"doc_id": doc_id})
chunks = splitter.split_text(content)
vector_store.add(chunks, metadata={"doc_id": doc_id, "content_hash": new_hash})
Sur une base de 2 000 documents avec ~30 mises à jour par semaine, cette approche évite de ré-embedder inutilement les 1 970 documents inchangés à chaque synchronisation — un gain direct en coût d'API d'embedding et en temps de traitement, et surtout la garantie qu'un document jamais modifié n'est jamais silencieusement dégradé par un re-chunking différent d'une exécution à l'autre.
TL;DR
| Problème | Fix | | -------------------- | ------------------------------ | | Chunks arbitraires | Chunking sémantique par idée | | Dense-only retrieval | Pipeline hybride + re-ranking | | Pas de validation | Pydantic + scores de confiance |
Ces trois ajustements ont doublé la satisfaction des utilisateurs sur tous mes déploiements RAG. La complexité ajoutée est minime — le gain, lui, est massif.