Python & AI Tutorials Logo
LangChain & LangGraph

11. RAG conversazionale: aggiungere la memoria al recupero

Nel Capitolo 10 abbiamo migliorato la qualità del recupero del nostro sistema RAG. Ma rimane ancora un limite: ogni domanda viene trattata individualmente. Quando un utente chiede "Qual è la vostra politica di rimborso?", il nostro sistema trova il contenuto rilevante nei documenti e risponde. Quando arriva la domanda successiva, il sistema risponde senza alcun ricordo della conversazione precedente.

Vediamo perché questo è un problema in una conversazione reale. Un utente chiede "Qual è la vostra politica di rimborso?" e poi prosegue con "Vale anche per i prodotti digitali?" Questa domanda di follow-up presuppone il contesto della "politica di rimborso" del turno precedente, ma il testo della domanda in sé non contiene alcuna informazione del genere. Se usiamo direttamente "Vale anche per i prodotti digitali?" come query di ricerca, il retriever recupererà informazioni irrilevanti relative ai "prodotti digitali" (per esempio, prezzi o specifiche), e il sistema RAG genererà una risposta che non corrisponde all'intento dell'utente.

In questo capitolo impareremo come risolvere questo problema. Impareremo a riscrivere domande di follow-up ambigue trasformandole in domande complete, useremo questa tecnica per costruire un sistema di RAG conversazionale, e vedremo come gestire la cronologia della conversazione man mano che le conversazioni si allungano.

11.1) Riscrivere le domande di follow-up in domande complete

Come abbiamo visto nell'introduzione, le domande di follow-up si basano sul contesto della conversazione precedente, quindi le persone tendono a omettere gran parte delle informazioni. Di conseguenza, una domanda di follow-up è spesso incompleta da sola. Come possiamo risolvere questo problema?

Nel Capitolo 8 abbiamo imparato come aiutare un LLM a comprendere il contesto della conversazione passando la cronologia della conversazione insieme a ogni messaggio. Possiamo applicare lo stesso approccio qui. Passiamo la domanda di follow-up insieme alla cronologia della conversazione all'LLM, e gli chiediamo di riscriverla in una domanda completa che rifletta il contesto. Per esempio, il follow-up "Vale anche per i prodotti digitali?" viene riscritto, insieme alla cronologia della conversazione, in "I prodotti digitali sono idonei per un rimborso?" Con questa domanda riscritta, la ricerca può trovare i documenti giusti sulle politiche di rimborso per i prodotti digitali.

Questa tecnica si chiama query rewriting (riscrittura delle query). Creiamo un system prompt per questo scopo.

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'istruzione centrale di questo system prompt è "riscrivi la domanda di follow-up in una domanda completa usando la cronologia della chat." Due direttive specifiche sono importanti.

Primo, "Do NOT answer the question, just reformulate it." Questo dice all'LLM di riscrivere solo la domanda, non di rispondere. Senza questa direttiva, l'LLM tende a rispondere alla domanda invece di riscriverla. Ciò che vogliamo qui non è una risposta, ma una domanda completa che possa essere compresa senza la cronologia della chat.

Secondo, "otherwise return it as is." Questo dice all'LLM di lasciare la domanda invariata se non ha bisogno di essere riscritta. Senza questo, l'LLM potrebbe riformulare inutilmente la domanda, cambiandone potenzialmente il significato o l'ambito originale.

Ora usiamo questo system prompt per riscrivere effettivamente una domanda di follow-up.

python
messages = [
    SystemMessage(content=system_prompt),
    # Cronologia della chat
    HumanMessage(content="What is your refund policy?"),
    AIMessage(content="All physical products may be returned within 30 days of purchase for a full refund."),
    # Domanda di follow-up
    HumanMessage(content="Does that apply to digital products too?"),
]
 
response = llm.invoke(messages)
print(response.content)

Output:

Are digital products eligible for a refund?

L'LLM ha letto la cronologia della conversazione, ha riconosciuto che la domanda riguardava la "politica di rimborso", e l'ha riscritta in una domanda completa. Effettuare la ricerca con questa domanda riscritta restituirà documenti che corrispondono all'intento dell'utente.

Nella prossima sezione, integreremo questo passaggio di riscrittura nella pipeline RAG, in modo che riscrittura, recupero e generazione della risposta avvengano tutti in un'unica chiamata.

11.2) Costruire il RAG conversazionale

Nella sezione precedente abbiamo imparato come riscrivere le domande di follow-up in domande complete passando la cronologia della conversazione all'LLM. Ora integreremo questo passaggio di riscrittura nella pipeline RAG per costruire un RAG conversazionale in cui riscrittura → recupero → generazione della risposta avvengano tutti in un'unica chiamata.

LangChain fornisce utility di catena per costruire RAG conversazionale (create_history_aware_retriever, create_retrieval_chain, ecc.), ma queste funzioni si trovano nel pacchetto langchain-classic, che raggiungerà la fine del supporto a dicembre 2026. La documentazione ufficiale di LangChain ora raccomanda di usare gli agenti.

Useremo quindi gli agenti per implementare il RAG conversazionale in questo capitolo. Gli agenti sono trattati in dettaglio nella Parte V (Capitoli 15–17), quindi qui introdurremo solo ciò che è necessario per la nostra implementazione del RAG conversazionale.

11.2.1) Componenti dell'agente che useremo qui

Nel Capitolo 5 abbiamo dato una breve occhiata al concetto fondamentale degli agenti. Quando l'LLM analizza la richiesta di un utente e decide quale strumento utilizzare, il sistema esegue tale decisione. All'epoca abbiamo implementato questo processo manualmente, ma LangChain fornisce delle API che lo rendono molto più semplice. Ecco una breve introduzione ai tre componenti che useremo.

@tool: Un decoratore che converte una normale funzione Python in uno strumento che l'agente può utilizzare. L'agente seleziona e chiama autonomamente lo strumento appropriato tra quelli registrati in base alla richiesta dell'utente.

create_agent: Una funzione che prende un LLM, un elenco di strumenti e un system prompt per creare un agente. Gestisce internamente il flusso decisione-esecuzione dell'agente.

InMemorySaver: Un checkpointer che gestisce automaticamente la cronologia della conversazione. Organizza le conversazioni per thread_id, quindi quando l'agente viene invocato con lo stesso thread_id, carica automaticamente la cronologia della conversazione precedente.

11.2.2) Creare lo strumento di recupero

Per prima cosa, trasformiamo la ricerca nel vector store che abbiamo costruito nel Capitolo 10 in uno strumento che l'agente può utilizzare.

python
from langchain.tools import tool
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# Connessione al vector store costruito nel Capitolo 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):
    """Cerca nei documenti contenuti rilevanti per la query."""
    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

Il decoratore @tool converte la funzione retrieve_context in uno strumento che l'agente può utilizzare. L'agente decide autonomamente se chiamare questo strumento in base alla domanda dell'utente.

11.2.3) Creare l'agente

Passiamo lo strumento di recupero, un system prompt e un checkpointer a create_agent per creare l'agente.

python
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver  # Installato automaticamente con 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: L'LLM che l'agente utilizzerà.
  • tools: L'elenco degli strumenti disponibili per l'agente. Registriamo lo strumento di recupero dei documenti (retrieve_context) che abbiamo creato sopra.
  • system_prompt: Le istruzioni comportamentali dell'agente. Dice all'agente di usare lo strumento di recupero per rispondere alle domande sulle politiche aziendali, e di dire che non lo sa quando il contesto recuperato manca di informazioni rilevanti.
  • checkpointer: Gestisce automaticamente la cronologia della conversazione. InMemorySaver() memorizza le conversazioni in memoria, gestendo automaticamente la cronologia della conversazione che abbiamo gestito manualmente nel Capitolo 8.

No

Domanda dell'utente

Agente

Cronologia della conversazione
(InMemorySaver)

Chiamata a uno strumento?

esecuzione dello strumento
retrieve_context

Risposta finale

Quando l'agente riceve una domanda dall'utente, consulta la cronologia della conversazione e decide se è necessaria una ricerca dei documenti nel vector store. In tal caso, chiama lo strumento retrieve_context per recuperare i documenti rilevanti e genera una risposta tramite l'LLM. La cronologia della conversazione è gestita automaticamente da InMemorySaver.

11.2.4) Eseguire una conversazione a più turni

Eseguiamo una conversazione effettiva a due turni per verificare che gestisca correttamente le domande di follow-up.

python
# thread_id è un identificatore che distingue le conversazioni
# Usare lo stesso thread_id continua la stessa conversazione
thread_config = {"configurable": {"thread_id": "1"}}
 
# --- Turno 1: Una domanda completa ---
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)
 
# --- Turno 2: Un follow-up che dipende dal Turno 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)

Output:

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.
 
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.

Nel secondo turno, abbiamo passato "Vale anche per i prodotti digitali?" ma l'agente ha riconosciuto dalla cronologia della conversazione che si trattava di un follow-up sulla politica di rimborso, e ha recuperato con precisione la sezione sui prodotti digitali dai documenti della politica di rimborso.

Aspetta — per questo agente non abbiamo aggiunto alcun passaggio di riscrittura della query come quello della Sezione 11.1. Allora, come è stata elaborata correttamente la domanda di follow-up? Quando il LLM chiama un tool (una funzione decorata con @tool), genera autonomamente gli argomenti del tool. Questo include la query utente passata a retrieve_context — poiché il LLM ha davanti a sé l'intera cronologia della conversazione, ha riscritto la domanda di follow-up in una domanda completa e autonoma prima di effettuare la chiamata. Non abbiamo predisposto un passaggio di riscrittura dedicato, eppure la riscrittura della query è avvenuta come parte del processo di chiamata del tool.

Nota anche che non abbiamo dovuto gestire manualmente la cronologia della conversazione — InMemorySaver la gestisce automaticamente per ogni thread_id.

La prossima sezione tratta il problema che sorge man mano che le conversazioni si allungano e la cronologia diventa più grande, insieme a come risolverlo.

11.3) Gestire conversazioni più lunghe

Il RAG conversazionale che abbiamo costruito funziona bene all'inizio, ma possono sorgere problemi man mano che le conversazioni si allungano. Come abbiamo imparato nel Capitolo 8, gli LLM hanno una dimensione massima di input che possono elaborare in una singola chiamata. Il system prompt, la cronologia della conversazione, i documenti recuperati e la domanda dell'utente devono tutti rientrare in questo limite.

Man mano che le conversazioni si allungano, la cronologia della conversazione occupa più token, finendo per superare la dimensione massima di input e causando il fallimento delle chiamate API. Anche i costi aumentano a ogni chiamata, dato che la fatturazione è per token. Questo significa che dobbiamo gestire la dimensione della nostra cronologia della conversazione.

Nel Capitolo 8 abbiamo risolto questo problema con una finestra scorrevole (sliding window): mantenendo solo gli N messaggi più recenti e scartando quelli più vecchi. Lo stesso concetto si applica in un ambiente di agenti. create_agent supporta i middleware, ovvero un passaggio di elaborazione che può modificare i messaggi prima che l'LLM venga chiamato. Possiamo usare i middleware per tagliare la cronologia più vecchia.

11.3.1) Limitare la cronologia con i middleware

Il decoratore @before_model funziona in modo simile al decoratore @tool che abbiamo visto nella Sezione 11.2. Proprio come @tool converte una funzione in uno strumento che l'agente può utilizzare, @before_model converte una funzione in un middleware che viene eseguito prima di ogni chiamata all'LLM. Il middleware convertito viene attivato registrandolo nel parametro middleware di 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:
    """Rimuove i vecchi messaggi prima di ogni chiamata all'LLM."""
    messages = state["messages"]
    # Se ci sono abbastanza pochi messaggi, non fare nulla
    if len(messages) <= 10:
        return None
    # Mantieni solo il system message (primo) e i 10 messaggi più recenti
    return {
        "messages": [
            RemoveMessage(id=REMOVE_ALL_MESSAGES),
            messages[0],     # System message
            *messages[-10:], # Ultimi 10 messaggi (5 turni)
        ]
    }

AgentState è un oggetto che contiene i dati di stato dell'agente, con state["messages"] che contiene l'elenco dei messaggi della conversazione fino a quel momento. Il valore di ritorno del middleware determina come questo elenco di conversazione viene modificato.

  • Restituire None lascia invariati i dati di stato esistenti dell'agente.
  • Restituire un dizionario applica il suo contenuto all'elenco di messaggi esistente. Nel codice qui sopra, RemoveMessage(id=REMOVE_ALL_MESSAGES) elimina prima tutti i messaggi esistenti, poi riaggiunge solo il system message e i 10 messaggi più recenti. Di conseguenza, solo questi messaggi vengono passati all'LLM.

Registra questo middleware con l'agente:

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],  # Registra il middleware
)

Questo è lo stesso agente della Sezione 11.2 con l'aggiunta di middleware=[trim_old_messages]. Ora, non importa quanto si allunghi la conversazione, solo i messaggi recenti vengono passati all'LLM.

11.3.2) Il compromesso della finestra scorrevole

Quando i vecchi messaggi vengono tagliati, l'agente non può più fare riferimento al loro contenuto. Se un utente tira in ballo qualcosa che aveva chiesto dieci turni prima, l'agente non ha modo di conoscere quel contesto. Questa è una limitazione fondamentale dell'approccio a finestra scorrevole.

Quando è necessario preservare il contenuto delle conversazioni più vecchie, un'alternativa è sostituire i vecchi messaggi con un riassunto generato dall'LLM invece di eliminarli. LangChain fornisce SummarizationMiddleware a questo scopo, che tratteremo nella Parte V (dal Capitolo 15 in poi) quando approfondiremo le architetture di agenti e grafi.