Un RAG naïf récupère, génère, et espère. Quand la récupération est mauvaise — requête ambiguë, documents hors-sujet — il génère quand même, et hallucine. Pour une base documentaire à laquelle des utilisateurs font confiance, c’est inacceptable.
La parade est agentique : faire du RAG une boucle qui s’auto-évalue. L’agent note la pertinence des documents, reformule sa requête s’ils sont mauvais, vérifie que sa réponse est bien ancrée dans les sources, et refuse plutôt que d’inventer. C’est le pattern Corrective RAG / Self-RAG.
L’architecture
Là où un RAG classique est une ligne droite, le RAG correctif est un graphe avec décisions. Après la récupération, on évalue ; selon le verdict, on génère ou on reformule et on relance. Après la génération, on vérifie l’ancrage.
Trois « juges » LLM jalonnent le parcours, chacun à sortie structurée binaire :
L’état
from typing_extensions import TypedDict
class Etat(TypedDict):
question: str # la question (peut être reformulée en cours de route)
question_origine: str # la question initiale, pour le juge final
documents: list[str] # les documents retenus comme pertinents
generation: str # la réponse produite
tentatives: int # compteur anti-boucle
Construire l’agent, étape par étape
Préparer le retriever
On suppose une base déjà vectorisée (chunks de la doc). Le retriever est notre porte d’entrée documentaire.
from langchain_core.vectorstores import InMemoryVectorStore
from langchain.embeddings import init_embeddings
embeddings = init_embeddings("openai:text-embedding-3-small")
store = InMemoryVectorStore.from_documents(chunks_doc, embeddings)
retriever = store.as_retriever(search_kwargs={"k": 4})Nœud recuperer
def recuperer(etat: Etat) -> dict:
docs = retriever.invoke(etat["question"])
return {"documents": [d.page_content for d in docs]}Juge n°1 — la pertinence des documents
On évalue chaque document : pertinent ou non. On ne garde que les bons. C’est la correction d’entrée : on refuse de générer sur du bruit.
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
modele = init_chat_model("openai:gpt-4o-mini")
class NotePertinence(BaseModel):
pertinent: bool = Field(description="Le document aide-t-il à répondre à la question ?")
def noter_documents(etat: Etat) -> dict:
juge = modele.with_structured_output(NotePertinence)
retenus = []
for doc in etat["documents"]:
v = juge.invoke(f"Question : {etat['question']}\nDocument : {doc}\n"
f"Ce document est-il pertinent ? Sois strict.")
if v.pertinent:
retenus.append(doc)
return {"documents": retenus}L’aiguillage : générer ou reformuler ?
S’il reste des documents pertinents, on génère. Sinon, la récupération a échoué : on reformule la question (sauf si on a épuisé nos tentatives).
from typing import Literal
from langgraph.graph import END
def decider(etat: Etat) -> Literal["generer", "reformuler", "abandonner"]:
if etat["documents"]:
return "generer"
if etat["tentatives"] >= 2: # garde-fou anti-boucle
return "abandonner"
return "reformuler"Nœud reformuler
On réécrit la requête pour la rendre plus efficace côté recherche vectorielle, puis on relancera la récupération.
def reformuler(etat: Etat) -> dict:
meilleure = modele.invoke(
f"Reformule cette question pour une recherche documentaire plus efficace "
f"(synonymes, termes techniques explicites) :\n{etat['question']}"
)
return {"question": meilleure.content, "tentatives": etat["tentatives"] + 1}Nœud generer
La génération classique du RAG : répondre uniquement à partir des documents retenus.
def generer(etat: Etat) -> dict:
contexte = "\n\n".join(etat["documents"])
reponse = modele.invoke(
f"Réponds à la question UNIQUEMENT à partir du contexte. "
f"Si le contexte ne suffit pas, dis-le.\n\n"
f"Contexte :\n{contexte}\n\nQuestion : {etat['question_origine']}"
)
return {"generation": reponse.content}Juges n°2 et n°3 — ancrage et réponse effective
Après génération, deux vérifications. Ancrage : la réponse est-elle justifiée par les documents (pas d’hallucination) ? Pertinence : répond-elle vraiment à la question d’origine ?
class NoteAncrage(BaseModel):
ancre: bool = Field(description="La réponse est-elle entièrement justifiée par les documents ?")
class NoteReponse(BaseModel):
repond: bool = Field(description="La réponse traite-t-elle bien la question posée ?")
def verifier(etat: Etat) -> Literal["ok", "regenerer", "reformuler", "abandonner"]:
# 1) ancrage (anti-hallucination)
ancrage = modele.with_structured_output(NoteAncrage).invoke(
f"Documents :\n{chr(10).join(etat['documents'])}\n\nRéponse : {etat['generation']}\n"
f"La réponse est-elle entièrement justifiée par les documents ?"
)
if not ancrage.ancre:
return "regenerer" if etat["tentatives"] < 2 else "abandonner"
# 2) la réponse traite-t-elle la question ?
rep = modele.with_structured_output(NoteReponse).invoke(
f"Question : {etat['question_origine']}\nRéponse : {etat['generation']}\n"
f"La réponse traite-t-elle la question ?"
)
if rep.repond:
return "ok"
return "reformuler" if etat["tentatives"] < 2 else "abandonner"Câbler le graphe correctif
On relie tout, en refermant les boucles correctives — et l’abandonner qui produit
un refus honnête.
from langgraph.graph import StateGraph, START
def abandonner(etat: Etat) -> dict:
return {"generation": "Je n'ai pas trouvé d'information fiable pour répondre. "
"Je préfère ne pas inventer — souhaitez-vous une escalade ?"}
g = StateGraph(Etat)
g.add_node("recuperer", recuperer)
g.add_node("noter_documents", noter_documents)
g.add_node("reformuler", reformuler)
g.add_node("generer", generer)
g.add_node("abandonner", abandonner)
g.add_edge(START, "recuperer")
g.add_edge("recuperer", "noter_documents")
g.add_conditional_edges("noter_documents", decider, {
"generer": "generer", "reformuler": "reformuler", "abandonner": "abandonner",
})
g.add_edge("reformuler", "recuperer") # ← la boucle corrective
g.add_conditional_edges("generer", verifier, {
"ok": END, "regenerer": "generer", "reformuler": "reformuler", "abandonner": "abandonner",
})
g.add_edge("abandonner", END)
rag = g.compile() L’exécuter
from langfuse.langchain import CallbackHandler
handler = CallbackHandler()
resultat = rag.invoke(
{"question": "Comment activer le SSO ?", "question_origine": "Comment activer le SSO ?",
"tentatives": 0},
config={"callbacks": [handler], "recursion_limit": 15,
"metadata": {"langfuse_tags": ["corrective-rag"]}},
)
print(resultat["generation"])
Sur une question bien couverte, le parcours est direct : récupérer → noter (tout pertinent) → générer → vérifier (ancré, répond) → fin. Sur une question mal formulée, on voit la correction opérer : documents jugés hors-sujet → reformulation → nouvelle récupération → génération réussie.
Lire la trace : la correction est visible
La trace LangFuse rend le comportement correctif lisible — et c’est précisément ce
qui permet de l’améliorer. Chaque juge est une generation ; chaque reformulation
ajoute une branche.
Évaluer : la boucle doit prouver sa valeur
Le RAG correctif ne se justifie que s’il réduit vraiment les hallucinations. On le vérifie avec l’évaluation LangFuse : un dataset de questions (dont des pièges sans réponse dans la base), et un LLM-juge de fidélité.
# sur le dataset : comparer RAG naïf vs RAG correctif
for item in dataset.items:
with item.run(run_name="corrective") as root:
out = rag.invoke({"question": item.input["q"], "question_origine": item.input["q"],
"tentatives": 0})
# le juge de fidélité doit noter 1 ; sur les pièges, on attend un refus
root.score_trace(name="refus_correct",
value=int(("n'ai pas trouvé" in out["generation"]) == item.metadata["piege"]))
C’est ce qui transforme « j’ai l’impression que ça hallucine moins » en preuve chiffrée — la seule base valable pour mettre un tel agent en production.
Bonnes pratiques & pièges
- Évaluez la pertinence document par document : ne générez jamais sur du hors-sujet.
- Bornez explicitement la boucle (compteur tentatives) avec une sortie d'abandon honnête.
- Vérifiez l'ancrage de la réponse (anti-hallucination) ET qu'elle traite la question.
- Préférez des juges binaires à sortie structurée : robustes et faciles à router.
- Refusez proprement plutôt que d'inventer : un « je n'ai pas trouvé » vaut mieux qu'un faux.
- Évaluez sur un dataset incluant des pièges sans réponse ; mesurez le taux de refus correct.
- Générer sans noter les documents : le RAG naïf qui hallucine sur une mauvaise récupération.
- Boucle de reformulation non bornée : un sujet absent fait tourner l'agent à l'infini.
- Oublier le juge d'ancrage : la réponse peut être fluide ET inventée.
- Confondre « ça a l'air mieux » et « c'est mesuré » : sans dataset, aucune preuve.
- Ignorer un taux de reformulation élevé : le vrai problème est souvent l'index, pas l'agent.
Ce qu’il faut retenir
- Le RAG correctif transforme la ligne droite en graphe avec décisions : on évalue, on corrige, on vérifie.
- Trois juges binaires jalonnent le parcours : pertinence des documents, ancrage de la réponse, réponse effective.
- La boucle de reformulation rattrape les mauvaises récupérations — toujours bornée, avec un refus honnête en sortie.
- La trace rend la correction lisible et en chiffre le surcoût.
- L’évaluation sur dataset (avec des pièges) est ce qui prouve que la fiabilité gagnée vaut le coût payé.
Vous disposez maintenant de deux architectures complètes — une qui investigue largement, une qui répond avec fiabilité. Toutes deux reposent sur les mêmes briques du parcours deep-dive. À vous de composer la vôtre.