Python & AI Tutorials Logo
LangChain & LangGraph

11. RAG conversacional: añadir memoria a la recuperación

En el Capítulo 10 mejoramos la calidad de recuperación de nuestro sistema RAG. Pero todavía queda una limitación: cada pregunta se trata de forma individual. Cuando un usuario pregunta "¿Cuál es vuestra política de reembolsos?", nuestro sistema encuentra el contenido relevante en los documentos y responde. Cuando llega la siguiente pregunta, el sistema la responde sin memoria de la conversación anterior.

Veamos por qué esto es un problema en una conversación real. Un usuario pregunta "¿Cuál es vuestra política de reembolsos?" y luego añade "¿Eso aplica también a los productos digitales?" Esta pregunta de seguimiento asume el contexto de la "política de reembolsos" del turno anterior, pero el texto de la pregunta en sí no contiene esa información. Si usamos "¿Eso aplica también a los productos digitales?" directamente como consulta de búsqueda, el retriever extraerá información irrelevante relacionada con "productos digitales" (por ejemplo, precios o especificaciones), y el sistema RAG generará una respuesta que no coincide con la intención del usuario.

En este capítulo aprenderemos a resolver este problema. Aprenderemos a reescribir preguntas de seguimiento ambiguas en preguntas completas, usaremos esa técnica para construir un sistema de RAG conversacional, y veremos cómo gestionar el historial de conversación a medida que las conversaciones se hacen más largas.

11.1) Reescribir preguntas de seguimiento en preguntas completas

Como vimos en la introducción, las preguntas de seguimiento se apoyan en el contexto de la conversación previa, así que las personas tienden a omitir gran parte de la información. Como resultado, una pregunta de seguimiento suele estar incompleta por sí sola. ¿Cómo podemos resolver esto?

En el Capítulo 8 aprendimos cómo ayudar a un LLM a comprender el contexto de la conversación pasando el historial de conversación junto con cada mensaje. Podemos aplicar el mismo enfoque aquí. Pasamos la pregunta de seguimiento junto con el historial de conversación al LLM, y le pedimos que la reescriba en una pregunta completa que refleje el contexto. Por ejemplo, la pregunta de seguimiento "¿Eso aplica también a los productos digitales?" se reescribe, junto con el historial de conversación, en "¿Son los productos digitales elegibles para un reembolso?" Con esta pregunta reescrita, la búsqueda puede encontrar los documentos correctos sobre las políticas de reembolso para productos digitales.

Esta técnica se llama reescritura de consultas (query rewriting). Vamos a crear un system prompt para ella.

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

La instrucción central de este system prompt es "reescribe la pregunta de seguimiento en una pregunta completa usando el historial de chat". Dos directivas concretas son importantes.

Primero, "Do NOT answer the question, just reformulate it." Esto le indica al LLM que solo reescriba la pregunta, no que la responda. Sin esta directiva, el LLM tiende a responder la pregunta en lugar de reescribirla. Lo que queremos aquí no es una respuesta, sino una pregunta completa que se pueda entender sin el historial de chat.

Segundo, "otherwise return it as is." Esto le indica al LLM que deje la pregunta sin cambios si no necesita reescritura. Sin esto, el LLM podría reformular innecesariamente la pregunta, alterando potencialmente su significado o alcance original.

Ahora usemos este system prompt para reescribir realmente una pregunta de seguimiento.

python
messages = [
    SystemMessage(content=system_prompt),
    # Historial de 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."),
    # Pregunta de seguimiento
    HumanMessage(content="Does that apply to digital products too?"),
]
 
response = llm.invoke(messages)
print(response.content)

Salida:

Are digital products eligible for a refund?

El LLM leyó el historial de conversación, reconoció que la pregunta era sobre la "política de reembolsos" y la reescribió en una pregunta completa. Buscar con esta pregunta reescrita devolverá documentos que coinciden con la intención del usuario.

En la siguiente sección, integraremos este paso de reescritura en el pipeline de RAG para que la reescritura, la recuperación y la generación de respuestas ocurran en una sola llamada.

11.2) Construir RAG conversacional

En la sección anterior aprendimos cómo reescribir preguntas de seguimiento en preguntas completas pasando el historial de conversación al LLM. Ahora integraremos este paso de reescritura en el pipeline de RAG para construir un RAG conversacional donde la reescritura → la recuperación → la generación de respuestas ocurran en una sola llamada.

LangChain proporciona utilidades de cadenas para construir RAG conversacional (create_history_aware_retriever, create_retrieval_chain, etc.), pero estas funciones están en el paquete langchain-classic, que llega al fin de su soporte en diciembre de 2026. La documentación oficial de LangChain ahora recomienda usar agentes en su lugar.

Por tanto, usaremos agentes para implementar RAG conversacional en este capítulo. Los agentes se cubren en detalle en la Parte V (Capítulos 15–17), así que aquí solo presentaremos lo necesario para nuestra implementación de RAG conversacional.

11.2.1) Componentes del agente que usaremos aquí

En el Capítulo 5 echamos un breve vistazo al concepto central de los agentes. Cuando el LLM analiza la petición de un usuario y decide qué herramienta usar, el sistema ejecuta esa decisión. En aquel momento implementamos este proceso manualmente, pero LangChain proporciona APIs que lo hacen mucho más sencillo. Aquí tienes una breve introducción a los tres componentes que usaremos.

@tool: Un decorador que convierte una función normal de Python en una herramienta que el agente puede usar. El agente selecciona y llama de forma autónoma a la herramienta adecuada entre sus herramientas registradas según la petición del usuario.

create_agent: Una función que toma un LLM, una lista de herramientas y un system prompt para crear un agente. Gestiona internamente el flujo de decisión-ejecución del agente.

InMemorySaver: Un checkpointer que gestiona automáticamente el historial de conversación. Organiza las conversaciones por thread_id, de modo que cuando se invoca el agente con el mismo thread_id, carga automáticamente el historial de la conversación anterior.

11.2.2) Crear la herramienta de recuperación

Primero, convirtamos la búsqueda en el vector store que construimos en el Capítulo 10 en una herramienta que el agente pueda usar.

python
from langchain.tools import tool
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# Conecta al vector store construido en el Capítulo 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):
    """Busca en los documentos contenido relevante para la consulta."""
    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

El decorador @tool convierte la función retrieve_context en una herramienta que el agente puede usar. El agente decide de forma autónoma si llamar a esta herramienta según la pregunta del usuario.

11.2.3) Crear el agente

Pasamos la herramienta de recuperación, un system prompt y un checkpointer a create_agent para crear el agente.

python
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver  # Se instala automáticamente 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: El LLM que usará el agente.
  • tools: La lista de herramientas disponibles para el agente. Registramos la herramienta de recuperación de documentos (retrieve_context) que creamos arriba.
  • system_prompt: Las instrucciones de comportamiento del agente. Le indica al agente que use la herramienta de recuperación para responder preguntas sobre políticas de la empresa, y que diga que no lo sabe cuando el contexto recuperado carezca de información relevante.
  • checkpointer: Gestiona automáticamente el historial de conversación. InMemorySaver() almacena las conversaciones en memoria, gestionando automáticamente el historial de conversación que gestionábamos manualmente en el Capítulo 8.

No

Pregunta del usuario

Agente

Historial de conversación
(InMemorySaver)

¿Llamada a herramienta?

Ejecución de la
herramienta retrieve_context

Respuesta final

Cuando el agente recibe una pregunta del usuario, consulta el historial de conversación y decide si es necesaria una búsqueda de documentos en el vector store. Si lo es, llama a la herramienta retrieve_context para obtener los documentos relevantes y genera una respuesta a través del LLM. El historial de conversación lo gestiona automáticamente InMemorySaver.

11.2.4) Ejecutar una conversación de varios turnos

Ejecutemos una conversación real de dos turnos para verificar que maneja correctamente las preguntas de seguimiento.

python
# thread_id es un identificador que distingue conversaciones
# Usar el mismo thread_id continúa la misma conversación
thread_config = {"configurable": {"thread_id": "1"}}
 
# --- Turno 1: Una pregunta 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 seguimiento que depende del 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)

Salida:

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.

En el segundo turno, pasamos "Does that apply to digital products too?" pero el agente reconoció a partir del historial de conversación que se trataba de un seguimiento sobre la política de reembolsos, y recuperó con precisión la sección de productos digitales de los documentos de política de reembolsos.

Espera — para este agente no añadimos ningún paso de reescritura de consultas como el de la Sección 11.1. Entonces, ¿cómo se procesó correctamente la pregunta de seguimiento? Cuando el LLM llama a una herramienta (una función decorada con @tool), genera los argumentos de la herramienta por sí mismo. Eso incluye la consulta del usuario que se pasa a retrieve_context — como el LLM tiene todo el historial de la conversación a la vista, reescribió la pregunta de seguimiento como una pregunta completa y autónoma antes de hacer la llamada. Nunca configuramos un paso de reescritura dedicado, pero aun así la reescritura de consultas ocurrió como parte del proceso de llamada a herramientas.

Ten en cuenta también que no tuvimos que gestionar el historial de conversación manualmente — InMemorySaver lo gestiona automáticamente por thread_id.

La siguiente sección cubre el problema que surge a medida que las conversaciones se hacen más largas y el historial crece, junto con cómo resolverlo.

11.3) Gestionar conversaciones más largas

El RAG conversacional que construimos funciona bien al principio, pero pueden surgir problemas a medida que las conversaciones se hacen más largas. Como aprendimos en el Capítulo 8, los LLM tienen un tamaño máximo de entrada que pueden procesar en una sola llamada. El system prompt, el historial de conversación, los documentos recuperados y la pregunta del usuario deben caber todos dentro de este límite.

A medida que las conversaciones se hacen más largas, el historial de conversación ocupa más tokens, llegando finalmente a superar el tamaño máximo de entrada y haciendo que las llamadas a la API fallen. Los costes también aumentan con cada llamada, ya que se factura por token. Esto significa que necesitamos gestionar el tamaño de nuestro historial de conversación.

En el Capítulo 8 resolvimos este problema con una ventana deslizante (sliding window): mantener solo los N mensajes más recientes y descartar los más antiguos. El mismo concepto se aplica en un entorno de agentes. create_agent admite middleware, que es un paso de procesamiento que puede modificar los mensajes antes de que se llame al LLM. Podemos usar middleware para recortar el historial antiguo.

11.3.1) Limitar el historial con middleware

El decorador @before_model funciona de forma similar al decorador @tool que vimos en la Sección 11.2. Igual que @tool convierte una función en una herramienta que el agente puede usar, @before_model convierte una función en middleware que se ejecuta antes de cada llamada al LLM. El middleware convertido se activa registrándolo en el parámetro 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:
    """Elimina los mensajes antiguos antes de cada llamada al LLM."""
    messages = state["messages"]
    # Si hay suficientemente pocos mensajes, no hace nada
    if len(messages) <= 10:
        return None
    # Mantiene solo el mensaje de sistema (el primero) y los 10 mensajes más recientes
    return {
        "messages": [
            RemoveMessage(id=REMOVE_ALL_MESSAGES),
            messages[0],     # Mensaje de sistema
            *messages[-10:], # Últimos 10 mensajes (5 turnos)
        ]
    }

AgentState es un objeto que contiene los datos de estado del agente, con state["messages"] conteniendo la lista de mensajes de conversación hasta ahora. El valor de retorno del middleware determina cómo se modifica esta lista de conversación.

  • Devolver None deja los datos de estado del agente existentes sin cambios.
  • Devolver un diccionario aplica su contenido a la lista de mensajes existente. En el código anterior, RemoveMessage(id=REMOVE_ALL_MESSAGES) primero elimina todos los mensajes existentes, y luego vuelve a añadir solo el mensaje de sistema y los 10 mensajes más recientes. Como resultado, solo estos mensajes se pasan al LLM.

Registra este middleware con el 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 el middleware
)

Este es el mismo agente de la Sección 11.2 con middleware=[trim_old_messages] añadido. Ahora, por muy larga que se haga la conversación, solo se pasan al LLM los mensajes recientes.

11.3.2) El compromiso de la ventana deslizante

Cuando se recortan los mensajes antiguos, el agente ya no puede referirse a su contenido. Si un usuario menciona algo que preguntó hace diez turnos, el agente no tiene forma de conocer ese contexto. Esta es una limitación fundamental del enfoque de la ventana deslizante.

Cuando es necesario preservar el contenido de conversación más antiguo, una alternativa es reemplazar los mensajes antiguos con un resumen generado por el LLM en lugar de eliminarlos. LangChain proporciona SummarizationMiddleware para este propósito, que cubriremos en la Parte V (a partir del Capítulo 15) cuando profundicemos en las arquitecturas de agentes y grafos.