Implementa recuperación de contexto en conversaciones

Lectura
15 min~12 min lectura

Introducción a la Recuperación de Contexto en Conversaciones de IA

En el desarrollo de agentes de IA conversacionales avanzados, la capacidad de recordar y recuperar información de interacciones pasadas no es un lujo, sino una necesidad fundamental. Esta lección se adentra en el núcleo de la construcción de agentes con memoria persistente, específicamente en la técnica de recuperación de contexto. Mientras que una memoria simple almacena datos, la recuperación de contexto es el proceso inteligente de buscar, seleccionar y presentar los fragmentos de memoria más relevantes para el momento actual de la conversación. Es lo que transforma un agente que responde de forma aislada en un interlocutor coherente y con conocimiento acumulado.

Imagina un agente que ayuda a gestionar un proyecto. Un usuario podría preguntar: "¿Qué tareas están pendientes para el sprint actual?". Sin contexto, el agente necesitaría que el usuario redefiniera constantemente "sprint actual". Con una memoria persistente, el agente puede recordar que en la conversación de ayer se estableció que el "sprint actual" es el número 5. La recuperación de contexto es el mecanismo que, al detectar la frase "sprint actual", busca automáticamente en el historial de memoria persistente la definición más reciente de ese término y la inyecta en el contexto de la nueva consulta, permitiendo una respuesta precisa y relevante sin necesidad de aclaraciones.

En LangGraph, esta funcionalidad se integra en el flujo de trabajo del agente, orquestando la consulta a un almacén de memoria (como una base de datos vectorial) justo antes de que el modelo de lenguaje genere una respuesta. Este proceso enriquece el prompt con información histórica crucial, permitiendo al modelo operar con un entendimiento continuo. Dominar esta implementación es lo que separa a los agentes básicos de los verdaderamente autónomos y útiles en escenarios complejos y de larga duración.

Concepto Clave: Memoria, Embeddings y Búsqueda Semántica

Para implementar la recuperación de contexto, debemos entender tres pilares interconectados. Primero, la memoria persistente: un almacén externo (como ChromaDB, PostgreSQL, o Redis) que guarda las interacciones más allá del ciclo de vida de una sola ejecución del programa. Cada interacción (pregunta del usuario, respuesta del agente, resultado de una herramienta) se guarda como un documento o registro con metadatos, como una marca de tiempo y un ID de sesión.

Segundo, los embeddings o incrustaciones. Este es el corazón de la búsqueda semántica. Un embedding es una representación vectorial (una lista densa de números) de un texto que captura su significado semántico. Textos con significados similares tendrán vectores similares. Cuando guardamos una interacción en la memoria, también calculamos y almacenamos su embedding. Una analogía del mundo real sería un bibliotecario experto que, en lugar de buscar libros por título exacto, puede encontrar textos que hablen sobre "conceptos de justicia social" aunque esas palabras exactas no aparezcan en el título, porque entiende el significado profundo del contenido.

Tercero, la búsqueda por similitud. Cuando llega una nueva consulta del usuario, se calcula su embedding. Luego, el sistema consulta la memoria persistente buscando los vectores almacenados que sean más "similares" (usando métricas como la similitud del coseno) al vector de la consulta. Los fragmentos de memoria con mayor similitud semántica son los más relevantes para el contexto actual. Este trío – almacenar con embeddings y recuperar por similitud – permite que el agente recuerde no por palabras clave literales, sino por significado y relevancia contextual.

Tip Crítico: La calidad de la recuperación de contexto depende directamente de la calidad del modelo de embeddings que utilices. Modelos como `text-embedding-ada-002` (OpenAI) o `all-MiniLM-L6-v2` (Sentence Transformers) están optimizados para esta tarea. No uses el mismo modelo para generar embeddings que para el chat, ya que están entrenados para objetivos diferentes.

Cómo Funciona en la Práctica: Un Flujo Paso a Paso

Integrar la recuperación de contexto en un agente de LangGraph implica extender su grafo de flujo de decisión. El proceso sigue una secuencia lógica que se ejecuta en cada turno de conversación. Primero, el nodo de entrada recibe el nuevo mensaje del usuario y el estado actual del grafo (que contiene el historial reciente de la conversación y otras variables). Antes de decidir qué acción tomar, el grafo se desvía a un nodo especializado en recuperar contexto.

Este nodo de recuperación toma el último mensaje del usuario (y a veces un resumen del estado reciente) y lo convierte en una consulta de búsqueda. Esta consulta se envía al retriever, un componente que encapsula la lógica de conexión a la base de datos vectorial, cálculo del embedding de la consulta y ejecución de la búsqueda por similitud. El retriever devuelve una lista de los "k" fragmentos de memoria más relevantes (por ejemplo, los 5 más similares). Estos fragmentos, que son interacciones pasadas, se formatean entonces como texto legible.

Finalmente, estos fragmentos recuperados se insertan en el estado del grafo, típicamente en una variable como "contexto_recuperado" o se añaden directamente al historial de mensajes con una etiqueta especial (e.g., "[Memoria Relevante]: ..."). El flujo del grafo continúa entonces hacia el nodo que invoca al modelo de lenguaje (LLM). El LLM recibe ahora un prompt que incluye no solo la conversación inmediata, sino también estos fragmentos de memoria recuperados, instruyéndole a usarlos para informar su respuesta. De esta manera, el agente puede referirse a hechos, preferencias o detalles establecidos previamente, manteniendo la coherencia a largo plazo.

Código en Acción: Implementación Completa con LangGraph y ChromaDB

A continuación, presentamos un ejemplo funcional y completo que construye un agente con memoria persistente y recuperación de contexto, utilizando LangGraph, OpenAI, ChromaDB como almacén vectorial, y Sentence Transformers para los embeddings.

Configuración Inicial y Definición del Estado

from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated, List
import operator
from datetime import datetime
from langchain_openai import ChatOpenAI
from langchain_chroma import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain.schema import Document
from langchain_core.messages import HumanMessage, AIMessage

# 1. Definir el Estado del Grafo
class AgentState(TypedDict):
    messages: Annotated[List, operator.add]  # Historial de mensajes de la conversación
    contexto_recuperado: str  # Fragmentos de memoria relevantes para este turno
    session_id: str  # Identificador único de la sesión/conversación

# 2. Inicializar componentes clave
llm = ChatOpenAI(model="gpt-4-turbo", temperature=0)
embedding_model = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
persistent_client = Chroma(
    collection_name="historial_conversaciones",
    embedding_function=embedding_model,
    persist_directory="./chroma_db"  # Los datos se guardan en disco
)

Nodo de Recuperación de Contexto

# 3. Función para el Nodo de Recuperación de Contexto
def nodo_recuperar_contexto(state: AgentState):
    """Consulta la memoria persistente con el último mensaje del usuario."""
    messages = state['messages']
    session_id = state['session_id']
    
    # El último mensaje es la consulta actual
    ultimo_mensaje = messages[-1].content if messages else ""
    
    # Buscar en ChromaDB los fragmentos más relevantes para esta sesión
    # Filtramos por session_id en los metadatos para aislar la memoria de esta conversación.
    docs_recuperados = persistent_client.similarity_search_with_relevance_scores(
        query=ultimo_mensaje,
        k=4,  # Número de fragmentos a recuperar
        filter={"session_id": session_id}  # Filtro crítico por sesión
    )
    
    # Formatear los fragmentos recuperados como texto para el prompt
    contexto_formateado = ""
    for doc, score in docs_recuperados:
        # doc.metadata puede contener 'timestamp', 'type' (user/assistant), etc.
        contexto_formateado += f"[Memoria previa - Similitud: {score:.2f}]: {doc.page_content}\n"
    
    # Si no hay memoria recuperada, establecer un placeholder
    if not contexto_formateado:
        contexto_formateado = "No se encontró contexto relevante en conversaciones anteriores."
    
    # Devolver el estado actualizado con el contexto recuperado
    return {"contexto_recuperado": contexto_formateado}

# 4. Función para Guardar Interacción en Memoria
def nodo_guardar_en_memoria(state: AgentState):
    """Almacena el último intercambio (user + assistant) en la memoria persistente."""
    messages = state['messages']
    session_id = state['session_id']
    
    # Necesitamos al menos un mensaje del usuario y uno del asistente para guardar un ciclo.
    if len(messages) >= 2:
        user_msg = messages[-2]  # Penúltimo mensaje (usuario)
        assistant_msg = messages[-1]  # Último mensaje (asistente)
        
        # Crear un documento para la pregunta del usuario
        doc_user = Document(
            page_content=f"Usuario: {user_msg.content}",
            metadata={
                "session_id": session_id,
                "timestamp": datetime.now().isoformat(),
                "type": "user"
            }
        )
        # Crear un documento para la respuesta del asistente
        doc_assistant = Document(
            page_content=f"Asistente: {assistant_msg.content}",
            metadata={
                "session_id": session_id,
                "timestamp": datetime.now().isoformat(),
                "type": "assistant"
            }
        )
        
        # Añadir a la base de datos vectorial persistente
        persistent_client.add_documents([doc_user, doc_assistant])
    
    return state  # El estado no cambia, solo se produce un efecto secundario (guardar)

Nodo del Asistente y Construcción del Grafo

# 5. Nodo del Asistente que utiliza el Contexto Recuperado
def nodo_asistente(state: AgentState):
    """Invoca al LLM con el historial de mensajes y el contexto recuperado."""
    messages = state['messages']
    contexto = state['contexto_recuperado']
    
    # Construir un prompt de sistema que instruya al LLM a usar la memoria recuperada
    prompt_sistema = f"""Eres un asistente útil con memoria de conversaciones pasadas.
A continuación, se te proporciona información relevante recuperada de interacciones anteriores en esta misma sesión. ÚSALA para dar una respuesta coherente y contextualizada.

--- CONTEXTO RECUPERADO DE CONVERSACIONES PASADAS ---
{contexto}
--------------------------------------------

Responde a la última pregunta del usuario considerando el historial de mensajes inmediato y el contexto recuperado arriba."""
    
    # Preparar la lista de mensajes para el LLM: Prompt de sistema + historial conversacional
    messages_for_llm = [{"role": "system", "content": prompt_sistema}] + messages
    
    # Invocar al LLM
    response = llm.invoke(messages_for_llm)
    
    # Añadir la respuesta del asistente al estado
    return {"messages": [AIMessage(content=response.content)]}

# 6. Construcción del Grafo de Flujo de Trabajo
graph_builder = StateGraph(AgentState)

# Añadir nodos
graph_builder.add_node("recuperar_contexto", nodo_recuperar_contexto)
graph_builder.add_node("asistente", nodo_asistente)
graph_builder.add_node("guardar_memoria", nodo_guardar_en_memoria)

# Definir el flujo: Recuperar -> Asistente -> Guardar
graph_builder.set_entry_point("recuperar_contexto")
graph_builder.add_edge("recuperar_contexto", "asistente")
graph_builder.add_edge("asistente", "guardar_memoria")
graph_builder.add_edge("guardar_memoria", END)

# Compilar el grafo
graph = graph_builder.compile()

# 7. Función de Invocación para el Usuario
def conversar_con_agente(session_id: str, input_usuario: str, estado_previo: dict = None):
    """Función principal para interactuar con el agente con memoria."""
    if estado_previo is None:
        # Estado inicial para una nueva conversación
        estado_inicial = {
            "messages": [HumanMessage(content=input_usuario)],
            "contexto_recuperado": "",
            "session_id": session_id
        }
    else:
        # Continuar desde un estado previo, añadiendo el nuevo mensaje
        estado_previo['messages'].append(HumanMessage(content=input_usuario))
        estado_inicial = estado_previo
    
    # Ejecutar el grafo
    final_state = graph.invoke(estado_inicial)
    
    # La respuesta del asistente ya está en final_state['messages']
    respuesta = final_state['messages'][-1].content
    
    # Devolver la respuesta y el estado actualizado para continuar la conversación
    return respuesta, final_state

# Ejemplo de uso
if __name__ == "__main__":
    session = "sesion_usuario_123"
    
    # Turno 1
    print("Usuario: Me llamo Carlos y mi proyecto se llama 'Aurora'.")
    resp1, estado1 = conversar_con_agente(session, "Me llamo Carlos y mi proyecto se llama 'Aurora'.")
    print(f"Asistente: {resp1}")
    
    # Turno 2 (El agente debería recordar el nombre y el proyecto)
    print("\nUsuario: ¿Cuál es el estado del proyecto?")
    resp2, estado2 = conversar_con_agente(session, "¿Cuál es el estado del proyecto?", estado1)
    print(f"Asistente: {resp2}")  # Respuesta ideal: "Carlos, para el proyecto 'Aurora', necesito más detalles..."

Errores Comunes y Cómo Evitarlos

1. Contaminación de Contexto entre Sesiones: El error más grave es no filtrar la búsqueda en la memoria por un identificador de sesión. Si no se hace, el agente de un usuario podría recuperar y revelar fragmentos de conversaciones de otros usuarios. Solución: Siempre incluye un campo session_id en los metadatos de cada documento almacenado y úsalo como filtro obligatorio en cada consulta al retriever, como se muestra en el código con `filter={"session_id": session_id}`.

2. Sobrecarga del Prompt con Memoria Irrelevante: Recuperar demasiados fragmentos (un 'k' muy alto) o fragmentos con baja similitud semántica puede saturar el contexto del LLM con información ruidosa, degradando la calidad de la respuesta y aumentando costes. Solución: Ajusta el parámetro `k` (entre 3 y 7 suele ser óptimo) y considera establecer un umbral de similitud mínima. No incluyas fragmentos cuya puntuación de similitud esté por debajo de, por ejemplo, 0.7.

3. Falta de Metadatos Ricos: Almacenar solo el texto sin metadatos estructurados limita severamente la capacidad de filtrar y gestionar la memoria. No poder buscar por ventana de tiempo o tipo de mensaje es una limitación importante. Solución: Diseña un esquema de metadatos robusto desde el inicio. Incluye siempre: `session_id`, `timestamp`, `type` (user/assistant/tool), `source`, y cualquier otro campo relevante para tu dominio (ej. `project_id`, `user_id`).

4. No Gestionar la Longitud del Contexto del LLM: Los modelos tienen límites de tokens. Si el historial de conversación más los fragmentos recuperados excede este límite, la invocación fallará. Solución: Implementa una estrategia de ventana deslizante para el historial de mensajes (mantén solo los N últimos intercambios) y una lógica de resumen o truncamiento para los fragmentos recuperados muy largos. Prioriza la información más reciente y relevante.

5. Embeddings de Baja Calidad o Inconsistentes: Usar un modelo de embeddings genérico o diferente al usado para indexar la memoria produce búsquedas defectuosas. Cambiar el modelo de embeddings después de haber poblado la base de datos invalida toda la memoria previa. Solución: Estandariza un modelo de embeddings de alta calidad para tareas semánticas y nunca lo cambies sin volver a indexar (recalcular embeddings) toda tu base de datos existente. Documenta el modelo utilizado como parte de la configuración del sistema.

Checklist de Dominio

  • He definido un esquema de estado para mi grafo que incluye una variable específica para el contexto recuperado.
  • He seleccionado e integrado un almacén de vectores persistente (ChromaDB, Weaviate, Pinecone) y un modelo de embeddings adecuado para mi dominio y volumen de datos.
  • Cada documento en mi memoria persistente incluye metadatos obligatorios, siendo el session_id el más crítico para el aislamiento y privacidad.
  • Mi nodo de recuperación filtra las búsquedas por sesión y limita el número de resultados (k) para optimizar costo y relevancia.
  • El prompt de mi sistema instruye explícitamente al LLM sobre cómo utilizar los fragmentos de memoria recuperados que se le inyectan.
  • He implementado un nodo o función que se encarga de guardar de forma atomizada cada intercambio relevante (pregunta-respuesta) en la memoria persistente.
  • He considerado y aplicado estrategias para manejar los límites de tokens del LLM, como el truncamiento o resumen de la memoria recuperada si es necesario.
  • He probado el flujo completo con conversaciones multi-turno, verificando que el agente hace referencias correctas a información establecida en turnos anteriores.
De lección a portfolio

Convertí esta lección en una habilidad visible para entrevistas.

Guardá el curso, completá los ejercicios y conectá esta habilidad con una ruta de empleo, data, IA, programación o marketing.

Newsletter Cursalo

Recibí rutas y cursos nuevos

Sumate para recibir recursos orientados a empleo y portfolio.

  • Rutas de empleo
  • Cursos prácticos
  • Portfolio y entrevistas

Sin spam. También podés entrar con tu cuenta para guardar progreso. Iniciá sesión