Python & AI Tutorials Logo
LangChain & LangGraph

11. RAG conversationnel : ajouter de la mémoire à la récupération

Au chapitre 10, nous avons amélioré la qualité de récupération de notre système RAG. Mais il reste une limitation : chaque question est traitée individuellement. Lorsqu'un utilisateur demande « Quelle est votre politique de remboursement ? », notre système trouve le contenu pertinent dans les documents et répond. Lorsque la question suivante arrive, le système y répond sans aucune mémoire de la conversation précédente.

Voyons pourquoi cela pose problème dans une vraie conversation. Un utilisateur demande « Quelle est votre politique de remboursement ? » puis enchaîne avec « Est-ce que cela s'applique aussi aux produits numériques ? » Cette question de suivi suppose le contexte de la « politique de remboursement » du tour précédent, mais le texte de la question lui-même ne contient aucune information de ce type. Si nous utilisons « Est-ce que cela s'applique aussi aux produits numériques ? » directement comme requête de recherche, le retriever va extraire des informations non pertinentes liées aux « produits numériques » (par exemple, les prix ou les spécifications), et le système RAG générera une réponse qui ne correspond pas à l'intention de l'utilisateur.

Dans ce chapitre, nous allons apprendre à résoudre ce problème. Nous allons apprendre à réécrire des questions de suivi ambiguës en questions complètes, à utiliser cette technique pour construire un système de RAG conversationnel, et à gérer l'historique de conversation à mesure que les conversations s'allongent.

11.1) Réécrire les questions de suivi en questions complètes

Comme nous l'avons vu en introduction, les questions de suivi s'appuient sur le contexte de la conversation précédente, c'est pourquoi les gens ont tendance à omettre une grande partie de l'information. En conséquence, une question de suivi est souvent incomplète à elle seule. Comment résoudre cela ?

Au chapitre 8, nous avons appris à aider un LLM à comprendre le contexte de la conversation en transmettant l'historique de conversation avec chaque message. Nous pouvons appliquer la même approche ici. Nous transmettons la question de suivi avec l'historique de conversation au LLM, et nous lui demandons de la réécrire en une question complète qui reflète le contexte. Par exemple, la question de suivi « Est-ce que cela s'applique aussi aux produits numériques ? » est réécrite, avec l'historique de conversation, en « Les produits numériques sont-ils éligibles à un remboursement ? » Grâce à cette question réécrite, la recherche peut trouver les bons documents sur les politiques de remboursement des produits numériques.

Cette technique est appelée réécriture de requête (query rewriting). Créons un system prompt pour cela.

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
 
llm = ChatOpenAI(model="gpt-5-mini")
 
system_prompt = (
    "Given a chat history and the latest user question "
    "which might reference context in the chat history, "
    "formulate a standalone question "
    "which can be understood without the chat history. "
    "Do NOT answer the question, just reformulate it if needed "
    "and otherwise return it as is."
)

L'instruction centrale de ce system prompt est « réécrire la question de suivi en une question complète à l'aide de l'historique de conversation ». Deux directives spécifiques sont importantes.

Premièrement, « Do NOT answer the question, just reformulate it. » Cela indique au LLM de seulement réécrire la question, pas d'y répondre. Sans cette directive, le LLM a tendance à répondre à la question au lieu de la réécrire. Ce que nous voulons ici, ce n'est pas une réponse, mais une question complète qui peut être comprise sans l'historique de conversation.

Deuxièmement, « otherwise return it as is. » Cela indique au LLM de laisser la question inchangée si elle n'a pas besoin d'être réécrite. Sans cela, le LLM peut reformuler inutilement la question, et potentiellement en changer le sens ou la portée d'origine.

Utilisons maintenant ce system prompt pour réécrire concrètement une question de suivi.

python
messages = [
    SystemMessage(content=system_prompt),
    # Historique de conversation
    HumanMessage(content="What is your refund policy?"),
    AIMessage(content="All physical products may be returned within 30 days of purchase for a full refund."),
    # Question de suivi
    HumanMessage(content="Does that apply to digital products too?"),
]
 
response = llm.invoke(messages)
print(response.content)

Sortie :

Are digital products eligible for a refund?

Le LLM a lu l'historique de conversation, reconnu que la question portait sur la « politique de remboursement », et l'a réécrite en une question complète. Une recherche effectuée avec cette question réécrite renverra des documents qui correspondent à l'intention de l'utilisateur.

Dans la section suivante, nous allons intégrer cette étape de réécriture dans le pipeline RAG afin que la réécriture, la récupération et la génération de réponse se produisent toutes en un seul appel.

11.2) Construire un RAG conversationnel

Dans la section précédente, nous avons appris à réécrire les questions de suivi en questions complètes en transmettant l'historique de conversation au LLM. Maintenant, nous allons intégrer cette étape de réécriture dans le pipeline RAG pour construire un RAG conversationnel où réécriture → récupération → génération de réponse se produisent toutes en un seul appel.

LangChain fournit des utilitaires de chaîne pour construire un RAG conversationnel (create_history_aware_retriever, create_retrieval_chain, etc.), mais ces fonctions se trouvent dans le package langchain-classic, dont la prise en charge prend fin en décembre 2026. La documentation officielle de LangChain recommande désormais d'utiliser plutôt des agents.

Nous allons donc utiliser des agents pour implémenter le RAG conversationnel dans ce chapitre. Les agents sont traités en détail dans la partie V (chapitres 15 à 17), nous ne présenterons donc ici que ce qui est nécessaire à notre implémentation du RAG conversationnel.

11.2.1) Les composants d'agent que nous utiliserons ici

Au chapitre 5, nous avons brièvement abordé le concept central des agents. Lorsque le LLM analyse la requête d'un utilisateur et décide quel outil utiliser, le système exécute cette décision. À l'époque, nous avions implémenté ce processus manuellement, mais LangChain fournit des API qui le rendent bien plus simple. Voici une brève présentation des trois composants que nous allons utiliser.

@tool : un décorateur qui convertit une fonction Python ordinaire en un outil que l'agent peut utiliser. L'agent sélectionne et appelle de manière autonome l'outil approprié parmi ses outils enregistrés, en fonction de la requête de l'utilisateur.

create_agent : une fonction qui prend un LLM, une liste d'outils et un system prompt pour créer un agent. Elle gère en interne le flux décision-exécution de l'agent.

InMemorySaver : un checkpointer qui gère automatiquement l'historique de conversation. Il organise les conversations par thread_id, de sorte que lorsque l'agent est invoqué avec le même thread_id, il charge automatiquement l'historique de conversation précédent.

11.2.2) Créer l'outil de récupération

Tout d'abord, transformons la recherche dans le vector store que nous avons construite au chapitre 10 en un outil que l'agent peut utiliser.

python
from langchain.tools import tool
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# Se connecter au vector store construit au chapitre 10
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
@tool
def retrieve_context(query: str):
    """Recherche dans les documents le contenu pertinent par rapport à la requête."""
    retrieved_docs = vector_store.similarity_search(query, k=3)
    serialized = "\n\n".join(
        f"Source: {doc.metadata['source']}\nContent: {doc.page_content}"
        for doc in retrieved_docs
    )
    return serialized

Le décorateur @tool convertit la fonction retrieve_context en un outil que l'agent peut utiliser. L'agent décide de manière autonome s'il doit appeler cet outil en fonction de la question de l'utilisateur.

11.2.3) Créer l'agent

Nous transmettons l'outil de récupération, un system prompt et un checkpointer à create_agent pour créer l'agent.

python
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver  # Installé automatiquement avec langchain
 
agent = create_agent(
    model="gpt-5-mini",
    tools=[retrieve_context],
    system_prompt=(
        "You are a helpful assistant that answers questions about company policies. "
        "Use the retrieve_context tool to search for relevant information. "
        "If the retrieved context does not contain relevant information, "
        "say that you don't know. "
        "Keep the answer concise, three sentences maximum."
    ),
    checkpointer=InMemorySaver(),
)
  • model : le LLM que l'agent utilisera.
  • tools : la liste des outils dont dispose l'agent. Nous enregistrons l'outil de récupération de documents (retrieve_context) que nous avons créé ci-dessus.
  • system_prompt : les instructions de comportement de l'agent. Il indique à l'agent d'utiliser l'outil de récupération pour répondre aux questions sur les politiques de l'entreprise, et de dire qu'il ne sait pas lorsque le contexte récupéré ne contient pas d'informations pertinentes.
  • checkpointer : gère automatiquement l'historique de conversation. InMemorySaver() stocke les conversations en mémoire, en gérant automatiquement l'historique de conversation que nous gérions manuellement au chapitre 8.

Oui

Non

Question de l'utilisateur

Agent

Historique de conversation
(InMemorySaver)

Appel d'outil ?

Exécution de l'outil
retrieve_context

Réponse finale

Lorsque l'agent reçoit une question de l'utilisateur, il consulte l'historique de conversation et décide si une recherche de documents dans le vector store est nécessaire. Si c'est le cas, il appelle l'outil retrieve_context pour récupérer les documents pertinents et génère une réponse à l'aide du LLM. L'historique de conversation est géré automatiquement par InMemorySaver.

11.2.4) Exécuter une conversation à plusieurs tours

Exécutons une véritable conversation à deux tours pour vérifier qu'elle gère correctement les questions de suivi.

python
# thread_id est un identifiant qui distingue les conversations
# Utiliser le même thread_id poursuit la même conversation
thread_config = {"configurable": {"thread_id": "1"}}
 
# --- Tour 1 : une question complète ---
response1 = agent.invoke(
    {"messages": [{"role": "user", "content": "What is your refund policy?"}]},
    thread_config,
)
print("Q: What is your refund policy?")
print("A:", response1["messages"][-1].content)
 
# --- Tour 2 : une question de suivi qui dépend du tour 1 ---
response2 = agent.invoke(
    {"messages": [{"role": "user", "content": "Does that apply to digital products too?"}]},
    thread_config,
)
print("\nQ: Does that apply to digital products too?")
print("A:", response2["messages"][-1].content)

Sortie :

Q: Does that apply to digital products too?
A: Digital products (software licenses, e-books, online courses) are non-refundable
once the download or access link has been activated.
However, if you experience technical issues preventing access, you can contact support
within 7 days for a replacement or refund.
 
Q: What is your refund policy?
A: All physical products may be returned within 30 days of purchase for a full refund.
The original receipt or order confirmation email is required, and items must be in
their original packaging and unused condition.
After 30 days, returns are accepted for store credit only.

Au deuxième tour, nous avons transmis « Est-ce que cela s'applique aussi aux produits numériques ? », mais l'agent a reconnu à partir de l'historique de conversation qu'il s'agissait d'une question de suivi concernant la politique de remboursement, et a récupéré avec précision la section sur les produits numériques dans les documents de la politique de remboursement.

Attendez — pour cet agent, nous n'avons ajouté aucune étape de réécriture de requête comme celle de la section 11.1. Alors comment la question de suivi a-t-elle été traitée correctement ? Lorsque le LLM appelle un outil (une fonction décorée avec @tool), il génère lui-même les arguments de l'outil. Cela inclut la requête utilisateur transmise à retrieve_context — comme le LLM dispose de l'intégralité de l'historique de conversation, il a reformulé la question de suivi en une question complète et autonome avant d'effectuer l'appel. Nous n'avons mis en place aucune étape de réécriture dédiée, et pourtant la réécriture de requête s'est produite dans le cadre du processus d'appel d'outil.

Notez également que nous n'avons pas eu à gérer l'historique de conversation manuellement — InMemorySaver le gère automatiquement par thread_id.

La section suivante traite du problème qui survient à mesure que les conversations s'allongent et que l'historique s'agrandit, ainsi que de la façon de le résoudre.

11.3) Gérer des conversations plus longues

Le RAG conversationnel que nous avons construit fonctionne bien au début, mais des problèmes peuvent survenir à mesure que les conversations s'allongent. Comme nous l'avons appris au chapitre 8, les LLM ont une taille d'entrée maximale qu'ils peuvent traiter en un seul appel. Le system prompt, l'historique de conversation, les documents récupérés et la question de l'utilisateur doivent tous tenir dans cette limite.

À mesure que les conversations s'allongent, l'historique de conversation occupe davantage de tokens, finissant par dépasser la taille d'entrée maximale et provoquant l'échec des appels d'API. Les coûts augmentent également à chaque appel puisque vous êtes facturé par token. Cela signifie que nous devons gérer la taille de notre historique de conversation.

Au chapitre 8, nous avons résolu ce problème avec une fenêtre glissante (sliding window) : ne conserver que les N messages les plus récents et écarter les plus anciens. Le même concept s'applique dans un environnement d'agent. create_agent prend en charge les middleware, c'est-à-dire une étape de traitement qui peut modifier les messages avant l'appel du LLM. Nous pouvons utiliser un middleware pour élaguer l'ancien historique.

11.3.1) Limiter l'historique avec un middleware

Le décorateur @before_model fonctionne de manière similaire au décorateur @tool que nous avons vu à la section 11.2. Tout comme @tool convertit une fonction en un outil que l'agent peut utiliser, @before_model convertit une fonction en un middleware qui s'exécute avant chaque appel du LLM. Le middleware ainsi converti est activé en l'enregistrant dans le paramètre middleware de create_agent.

python
from langchain.agents import create_agent, AgentState
from langchain.agents.middleware import before_model
from langchain.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES
 
@before_model
def trim_old_messages(state: AgentState, runtime) -> dict | None:
    """Supprime les anciens messages avant chaque appel du LLM."""
    messages = state["messages"]
    # S'il y a suffisamment peu de messages, ne rien faire
    if len(messages) <= 10:
        return None
    # Conserver uniquement le message système (le premier) et les 10 messages les plus récents
    return {
        "messages": [
            RemoveMessage(id=REMOVE_ALL_MESSAGES),
            messages[0],     # Message système
            *messages[-10:], # Les 10 derniers messages (5 tours)
        ]
    }

AgentState est un objet qui contient les données d'état de l'agent, où state["messages"] contient la liste des messages de conversation accumulés jusqu'à présent. La valeur de retour du middleware détermine comment cette liste de conversation est modifiée.

  • Renvoyer None laisse les données d'état de l'agent existantes inchangées.
  • Renvoyer un dictionnaire applique son contenu à la liste de messages existante. Dans le code ci-dessus, RemoveMessage(id=REMOVE_ALL_MESSAGES) supprime d'abord tous les messages existants, puis rajoute uniquement le message système et les 10 messages les plus récents. En conséquence, seuls ces messages sont transmis au LLM.

Enregistrez ce middleware auprès de l'agent :

python
agent = create_agent(
    model="gpt-5-mini",
    tools=[retrieve_context],
    system_prompt=(
        "You are a helpful assistant that answers questions about company policies. "
        "Use the retrieve_context tool to search for relevant information. "
        "If the retrieved context does not contain relevant information, "
        "say that you don't know. "
        "Keep the answer concise, three sentences maximum."
    ),
    checkpointer=InMemorySaver(),
    middleware=[trim_old_messages],  # Enregistrer le middleware
)

Il s'agit du même agent que celui de la section 11.2, avec middleware=[trim_old_messages] ajouté. Désormais, quelle que soit la longueur de la conversation, seuls les messages récents sont transmis au LLM.

11.3.2) Le compromis de la fenêtre glissante

Lorsque les anciens messages sont élagués, l'agent ne peut plus faire référence à leur contenu. Si un utilisateur évoque quelque chose qu'il a demandé dix tours plus tôt, l'agent n'a aucun moyen de connaître ce contexte. C'est une limitation fondamentale de l'approche par fenêtre glissante.

Lorsque l'ancien contenu de conversation doit être préservé, une alternative consiste à remplacer les anciens messages par un résumé généré par le LLM au lieu de les supprimer. LangChain fournit SummarizationMiddleware à cette fin, que nous aborderons dans la partie V (à partir du chapitre 15) lorsque nous approfondirons les architectures d'agents et de graphes.