RAG en production : 5 pièges qui m'ont coûté 3 semaines
J'avais prévu 4 jours pour mettre en prod un RAG qui interroge les 107 livres de mon catalogue. J'ai mis 25 jours. La cause n'était ni le code, ni le vector store, ni le LLM. C'étaient cinq pièges qui n'apparaissent jamais dans les tutoriels d'un weekend et qui flinguent ton RAG en silence une fois qu'il y a de vrais utilisateurs derrière. Voici les cinq, avec les logs réels, le temps qu'ils m'ont coûté, et les fixes qui tiennent la route.
Le contexte en une minute
Le projet : un chatbot public qui répond à des questions sur l'univers de mes romans — intrigue, personnages, références techniques des thrillers. Stack : text-embedding-3-small pour les embeddings, Qdrant Cloud pour le vector store, Claude Sonnet 4.5 pour la génération, Next.js côté front. 4 200 chunks indexés au départ, provenant de 107 ebooks convertis en texte brut.
Premier test en local : ça marche impeccable. Je déploie. Premier utilisateur pose une question : « Dans quel livre apparaît le personnage d'Elena Vasquez ? » Réponse du bot : « Je n'ai aucune information sur ce personnage. » Elena Vasquez est la protagoniste principale de trois livres, mentionnée 847 fois. Là je comprends qu'un RAG qui passe les tests unitaires peut être totalement cassé en prod.
Piège 1 — Le chunking sémantique qui tue le sens
Premier pattern que j'ai copié-collé de la doc LangChain : RecursiveCharacterTextSplitter avec chunk_size=1000, chunk_overlap=200. C'est le default de 90% des tutoriels. C'est aussi une horreur sur un livre.
Résultat concret : un chunk contenait la fin du chapitre 12 (révélation identité Elena) et le début du chapitre 13 (scène sans lien). L'embedding de ce chunk était un mélange flou des deux, et le cosine similarity avec « Qui est Elena Vasquez ? » tombait à 0,31 — sous le seuil par défaut.
Le fix : chunker par unité sémantique (paragraphe ou section), pas par caractère. J'ai écrit un splitter custom de 30 lignes qui découpe sur les sauts de paragraphe doubles et respecte les frontières de chapitre.
def split_by_paragraph(text: str, max_tokens: int = 400) -> list[str]:
paras = text.split("\n\n")
chunks, current = [], []
current_tokens = 0
for p in paras:
p_tokens = len(p.split())
if current_tokens + p_tokens > max_tokens and current:
chunks.append("\n\n".join(current))
current, current_tokens = [p], p_tokens
else:
current.append(p)
current_tokens += p_tokens
if current:
chunks.append("\n\n".join(current))
return chunksRésultat : de 4 200 chunks à 6 800 chunks, mais le rappel sur les 50 questions de test est passé de 62% à 91%. Coût du piège : 4 jours.
Piège 2 — Les metadata qu'on oublie d'indexer
Deuxième semaine, question utilisateur : « Quelle est la fin du livre L'Algorithme de Babel ? » Le RAG retourne… un chapitre aléatoire du milieu d'un autre livre. Cause : les embeddings ne savent pas dans quel livre ils vivent. Le chunk le plus similaire sémantiquement était dans un autre thriller qui parle aussi d'algorithmes.
Le fix : injecter systématiquement les metadata dans le texte indexé et dans le filtre de recherche.
# Avant (buggy)
chunk_text = "Le personnage d'Elena pénètre dans le serveur..."
# Après
chunk_text = f"[Livre : L'Algorithme de Babel — Chapitre 14] {chunk_text}"
# Et dans la recherche
qdrant.search(
collection_name="livres",
query_vector=embedding,
query_filter=Filter(must=[FieldCondition(key="book_slug", match=MatchValue(value="algorithme-de-babel"))])
if book_hint else None,
limit=10,
)Résultat : les questions avec un titre de livre explicite passent de 48% de précision à 94%. Coût du piège : 2 jours.
Piège 3 — Les embeddings périmés après une mise à jour du corpus
Troisième semaine : je réimporte les livres après une correction de typos. Le nouveau script recalcule les embeddings et remplace les anciens dans Qdrant par un upsert sur les mêmes IDs. Sauf que mon script de chunking avait légèrement changé entre-temps, ce qui a produit 230 nouveaux chunks et supprimé 190 anciens sans le vouloir. Résultat : pour certaines questions, le bot répondait en citant des passages qui n'existaient plus.
Le fix : versionner la collection entière, pas les chunks individuels. À chaque rebuild, je crée une nouvelle collection Qdrant livres_v23, j'y indexe tout, je valide avec un jeu de 50 questions de test, puis je swap l'alias livres → livres_v23. L'ancienne reste en fallback 7 jours.
# Swap atomique via alias
qdrant.update_collection_aliases(
change_aliases_operations=[
ChangeAliasesOperation(
create_alias=CreateAliasOperation(
create_alias=CreateAlias(
collection_name="livres_v23",
alias_name="livres",
)
)
)
]
)Coût : 3 jours de migration + 1 jour de mise en place du pipeline versionné. Jamais eu le problème depuis. Coût du piège : 4 jours.
Piège 4 — Les queries utilisateurs qui ne ressemblent à rien
Quatrième semaine, je regarde les logs : 34% des requêtes utilisateurs sont des fragments — « Elena », « serveur NSA », « fin de Babel », parfois juste « ? ». L'embedding de « Elena » tout seul matche des dizaines de chunks parce que le mot est partout. Le top-10 retourne du bruit.
Le fix : une étape de reformulation avant la recherche. Un petit appel à Claude Haiku (coût : 0,0002 $ par requête) qui transforme la query utilisateur en une phrase bien formée.
def reformulate_query(raw: str, history: list[str]) -> str:
prompt = f"""Reformule cette question pour une recherche sémantique dans un catalogue de romans.
Historique récent : {history[-3:]}
Question : {raw}
Réponds avec une phrase complète, sans explications."""
return claude_haiku(prompt).strip()Résultat : les questions courtes ont un rappel qui passe de 34% à 79%. Le coût additionnel par requête : 0,0002 $. Le coût additionnel de latence : 180 ms. Les deux sont acceptables.
Piège 5 — Le cache qui ment
Dernier piège, le plus sournois : j'avais mis un cache Redis à 24h sur les résultats de recherche pour économiser des appels embeddings. Tout marchait. Jusqu'au jour où j'ai corrigé une coquille dans un chunk (un nom de personnage mal orthographié) et que le bot a continué pendant 16 heures à retourner l'ancienne version. Le cache ne connaissait pas la nouvelle indexation.
| Problème | Cause | Symptôme visible |
|---|---|---|
| Cache stale | Pas d'invalidation sur rebuild | Réponses qui ne reflètent pas le corpus actuel |
| Cache incohérent | Clé basée sur query brute | Reformulation différente ne hit pas le cache |
| Cache silencieux | Pas de logging sur hit/miss | Impossible de mesurer l'efficacité |
Le fix : clé de cache qui inclut la version de la collection, TTL réduit à 30 minutes, et un compteur Prometheus sur hit/miss par collection.
cache_key = f"rag:{collection_version}:{hash(reformulated_query)}"
cache.set(cache_key, results, ttl=1800)Ce que je fais maintenant avant tout RAG en prod
- 1.Chunking par unité sémantique — jamais
RecursiveCharacterTextSplitteravec les defaults - 2.Metadata injectées dans le texte ET dans le filtre de recherche
- 3.Collections versionnées + swap atomique d'alias
- 4.Reformulation Claude Haiku avant la recherche quand la query est courte
- 5.Cache avec clé versionnée et TTL court, invalidé au rebuild
Le coût total des 5 pièges : 21 jours sur 4 prévus. Le gain une fois résolus : le rappel moyen sur 200 questions test est passé de 58% à 87%. Aucun de ces 5 pièges n'est sorcier — ils sont juste systématiquement absents des tutoriels parce que ce sont des problèmes qu'on ne rencontre qu'avec des vrais utilisateurs.
Si tu construis un RAG pour un vrai produit et que tu veux le détail des architectures d'agents LLM qui exploitent ces retrievals proprement, j'ai écrit un guide complet :
Soutenez mon travail sur Patreon
Accès anticipé aux articles, contenu exclusif, et la satisfaction de soutenir un auteur indépendant.
Rejoindre — à partir de 3€/mois