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.

Fig.01 · Graphe correctif
pertinent hors-sujet reformule récupère évalue génère END reformule
L'évaluation des documents pilote le flux : pertinent → on génère ; hors-sujet → on reformule et on relance la récupération. La boucle est bornée.

Trois « juges » LLM jalonnent le parcours, chacun à sortie structurée binaire :

Fig.02 · Les trois juges
avant Juge docs pertinents ?
après Juge ancrage halluciné ?
après Juge réponse répond ?
Pertinence des documents, ancrage de la réponse, et réponse effective à la question. Chaque juge est un verdict binaire qui oriente le graphe.

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.

Fig.03 · Trace d'une correction
TRACE · agent.invoke() 2.4 s · $0.0031 GENERATION · décision gpt-4o-mini · 412 tok SPAN · chercher_doc() 84 ms GENERATION · réponse finale 356 tok
Sur une requête corrigée, la trace montre deux cycles de récupération séparés par une reformulation, puis les juges d'ancrage et de réponse. On mesure le surcoût de la correction.

É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

Bonnes pratiques
  • É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.
Pièges à éviter
  • 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.

#cas-pratique#rag#self-rag#evaluation#langfuse