Aller au contenu principal

Corrective RAG

Mis à jour le 29 juillet 2026

Quand le retrieval ne suffit pas

Le RAG que vous avez construit repose sur un pari implicite : les chunks les plus similaires à la question sont les bons. Ce pari tient souvent, mais pas toujours. Interrogez votre index sur un sujet qu’il ne couvre pas et FAISS vous rendra malgré tout les cinq vecteurs les moins éloignés, aussi hors sujet soient-ils — un moteur de similarité ne sait pas répondre « je n’ai rien ». Le modèle reçoit alors du contexte inadapté, et comme le prompt lui demande de s’appuyer dessus, il produit une réponse qui a toutes les apparences du sérieux et aucun fondement. C’est le scénario d’hallucination le plus insidieux, parce que la chaîne technique a fonctionné sans erreur.

Le Corrective RAG (CRAG) intercale une étape d’évaluation entre le retrieval et la génération. Un LLM examine chaque document retrouvé et tranche : pertinent ou non. Si le verdict est négatif, le système ne se contente pas de générer avec ce qu’il a — il bascule vers une recherche web pour aller chercher ailleurs l’information qui manque à votre corpus. Le flux compte donc quatre temps : le retrieval classique dans le vector store, le grading de chaque document par rapport à la question, la décision de déclencher ou non la recherche web dès lors qu’au moins un document est jugé non pertinent, puis la génération avec les meilleurs documents disponibles.

Le grader de documents

Tout repose sur le grader. L’astuce consiste à demander au LLM un jugement binaire au format JSON plutôt qu’une appréciation libre : {"score": "yes"} ou {"score": "no"}, rien d’autre. Le paramètre response_format={"type": "json_object"} garantit une sortie parsable, et temperature=0 élimine la variabilité — un même couple question/document doit toujours recevoir le même verdict, sans quoi le comportement de votre pipeline devient irreproductible.

from mistralai import Mistral
import json

client = Mistral(api_key=api_key)

def grade_document(question, document):
    """Évaluer si un document est pertinent pour une question."""
    prompt = f"""Vous êtes un évaluateur de pertinence. Étant donné une question
et un document, déterminez si le document contient des informations pertinentes
pour répondre à la question.

Répondez UNIQUEMENT avec un objet JSON : {{"score": "yes"}} ou {{"score": "no"}}

Question : {question}
Document : {document}

Évaluation JSON :"""

    response = client.chat.complete(
        model="mistral-large-latest",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        response_format={"type": "json_object"},
    )

    result = json.loads(response.choices[0].message.content)
    return result.get("score", "no") == "yes"

# Test
question = "Quelles sont les étapes du RAG ?"
doc_pertinent = "Le RAG se décompose en retrieval, augmentation et génération."
doc_non_pertinent = "La recette du tiramisu nécessite du mascarpone."

print(grade_document(question, doc_pertinent))      # True
print(grade_document(question, doc_non_pertinent))   # False

Le test de fin de bloc mérite qu’on s’y arrête. La recette de tiramisu illustre exactement ce que la similarité vectorielle laisse passer : si votre corpus ne parle que de cuisine et qu’on l’interroge sur le RAG, ce chunk arrivera en tête du classement faute de concurrence. Le grader, lui, ne compare pas des vecteurs mais lit le contenu, et le rejette.

Appliqué à l’ensemble des chunks retrouvés, ce jugement produit deux informations : la liste filtrée des documents à conserver, et un booléen indiquant s’il faut compléter par le web. Le seuil retenu ici est volontairement strict — un seul document écarté suffit à déclencher le fallback, au motif que le contexte est alors incomplet.

def grade_documents(question, retrieved_chunks):
    """Évaluer et filtrer les documents retrouvés."""
    relevant_docs = []
    irrelevant_count = 0

    for chunk in retrieved_chunks:
        is_relevant = grade_document(question, chunk["text"])
        if is_relevant:
            relevant_docs.append(chunk)
        else:
            irrelevant_count += 1

    need_web_search = irrelevant_count > 0

    print(f"  Pertinents: {len(relevant_docs)}/{len(retrieved_chunks)}")
    print(f"  Recherche web nécessaire: {need_web_search}")

    return relevant_docs, need_web_search

Le filet de sécurité web

Quand les documents locaux ne suffisent pas, le système va chercher ailleurs. L’implémentation ci-dessous reste volontairement simplifiée ; en production, vous brancherez une véritable API de recherche comme Tavily ou Serper, dont les résultats sourcés valent mieux qu’une réponse de modèle. Le point de conception à retenir est le format de retour : la fonction rend une liste de dictionnaires ayant la même forme que vos chunks locaux, avec un champ source valant web_search. Cette uniformité permet aux étapes suivantes d’ignorer complètement l’origine des documents, tout en conservant la trace pour l’utilisateur final.

def web_search_fallback(question, n_results=3):
    """Recherche web de secours quand les documents locaux sont insuffisants."""
    # En production, utilisez une API de recherche (Tavily, Serper, etc.)
    # Ici, un exemple simplifié avec l'API Mistral agents

    prompt = f"""Recherchez sur le web des informations pour répondre à cette question :
{question}

Fournissez une réponse factuelle basée sur les résultats de recherche."""

    response = client.chat.complete(
        model="mistral-large-latest",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.1,
    )

    # Retourner comme un "document" web
    return [{
        "text": response.choices[0].message.content,
        "source": "web_search",
    }]

Le pipeline assemblé

La classe CorrectiveRAG reprend la structure du pipeline standard en y insérant le grading. Le k=4 par défaut est un peu plus généreux que les trois chunks habituels, ce qui se comprend : puisque le filtrage va en écarter une partie, mieux vaut partir avec de la marge. La méthode ask orchestre les quatre temps et affiche chacun d’eux, ce qui vous permet de suivre en direct le raisonnement du système — combien de documents ont survécu au grading, si le fallback s’est déclenché. Le dictionnaire de retour expose d’ailleurs web_search_used : c’est un indicateur précieux à surveiller en production, car un taux de recours au web anormalement élevé ne signale pas un défaut du CRAG mais une lacune de votre corpus.

class CorrectiveRAG:
    """Pipeline Corrective RAG avec évaluation et fallback web."""

    def __init__(self, chunks, embeddings, index):
        self.chunks = chunks
        self.embeddings = embeddings
        self.index = index

    def retrieve(self, question, k=4):
        """Étape 1 : retrieval standard."""
        response = client.embeddings.create(
            model="mistral-embed",
            inputs=[question],
        )
        query_vec = np.array([response.data[0].embedding], dtype=np.float32)
        distances, indices = self.index.search(query_vec, k)

        results = []
        for dist, idx in zip(distances[0], indices[0]):
            if idx >= 0:
                results.append({
                    "text": self.chunks[idx]["text"],
                    "source": self.chunks[idx].get("source", "local"),
                })
        return results

    def grade(self, question, documents):
        """Étape 2 : évaluer la pertinence."""
        relevant = []
        for doc in documents:
            if grade_document(question, doc["text"]):
                relevant.append(doc)
        need_web = len(relevant) < len(documents)
        return relevant, need_web

    def generate(self, question, documents):
        """Étape 3 : générer la réponse."""
        context = "\n\n---\n\n".join([d["text"] for d in documents])
        prompt = f"""Contexte :\n{context}\n\nQuestion : {question}"""

        response = client.chat.complete(
            model="mistral-large-latest",
            messages=[
                {"role": "system", "content": "Répondez en vous basant sur le contexte."},
                {"role": "user", "content": prompt},
            ],
            temperature=0.1,
        )
        return response.choices[0].message.content

    def ask(self, question, k=4):
        """Pipeline complet."""
        print(f"\n=== Question : {question} ===")

        # 1. Retrieval
        print("1. Retrieval...")
        docs = self.retrieve(question, k=k)

        # 2. Grading
        print("2. Grading...")
        relevant_docs, need_web = self.grade(question, docs)

        # 3. Web search fallback si nécessaire
        if need_web:
            print("3. Fallback web search...")
            web_docs = web_search_fallback(question)
            relevant_docs.extend(web_docs)

        # 4. Génération
        print("4. Génération...")
        answer = self.generate(question, relevant_docs)

        return {
            "answer": answer,
            "sources_used": len(relevant_docs),
            "web_search_used": need_web,
        }

# Utilisation
crag = CorrectiveRAG(chunks, embeddings, index)
result = crag.ask("Comment Mistral implémente-t-il les embeddings ?")

print(f"\nRéponse : {result['answer']}")
print(f"Web search utilisé : {result['web_search_used']}")

Cette fiabilité a un prix qu’il faut assumer en connaissance de cause : avec k=4, chaque question déclenche quatre appels de grading avant même la génération. La latence perçue par l’utilisateur augmente d’autant, et la facture aussi. L’arbitrage se pose différemment selon l’usage — sur un assistant juridique ou médical, la vérification est non négociable ; sur un moteur de recherche interne où l’utilisateur voit les sources et juge lui-même, elle l’est moins.

Points clés à retenir

  • Le Corrective RAG ajoute une évaluation de pertinence après le retrieval
  • Un LLM sert de “grader” pour juger chaque document : pertinent ou non
  • Si des documents sont jugés non pertinents, un fallback vers la recherche web se déclenche
  • Cette approche réduit significativement les hallucinations dues à un contexte inadapté
  • Le grading ajoute de la latence mais améliore fortement la fiabilité des réponses