LlamaIndex Plus LangGraph: Un patrón limpio para agentes RAG
Cómo crear una base de conocimientos con LlamaIndex y exponerla a un agente de LangGraph como herramienta, explicando la persistencia, Chroma y el modo exclusivo de recuperador.
Fuentes: RAG Without the Guesswork: A Standardized LangGraph + LlamaIndex Pattern.
El punto ciego y la división del trabajo
Un agente de LangGraph solo conoce lo que su LLM aprendió durante el entrenamiento. Las preguntas sobre una política de precios interna, el informe financiero del último trimestre o un manual terminado la semana pasada reciben o bien una admisión de ignorancia o, peor aún, una fabricación segura.
Retrieval-Augmented Generation (RAG) cierra esa brecha. En el momento de la consulta, recupera fragmentos relevantes de sus propios documentos y los pasa al LLM como contexto antes de que responda, de modo que el modelo lee en lugar de adivinar. RAG es una capacidad añadida a un único agente. No se trata de un nuevo patrón de orquestación, por lo que los supervisores, los subgrafos y Send no intervienen en este proceso. Lo único que se requiere es cierta familiaridad con la estructura de un agente de LangGraph y con cómo proporcionarle herramientas.
Es posible construir el pipeline manualmente dentro de LangGraph: escribe tu propio código para el procesamiento en fragmentos, las llamadas a funciones de embedding, el índice vectorial y la recuperación de información. Muchos equipos lo hacen, aunque se trata de una cantidad considerable de trabajo para algo que ya cuenta con soluciones existentes. LlamaIndex fue creado precisamente para asumir esa tarea. Es, sin duda, la opción más madura y popular para el manejo de datos en las aplicaciones de IA, ya que abarca la carga, el procesamiento en fragmentos, los embedding, el indexado y la recuperación. Por su parte, la orquestación, es decir, el manejo del estado, el flujo de control y el razonamiento multietapa, corresponde a LangGraph.
De esta división surge la estructura que utilizan la mayoría de los sistemas RAG en producción: LlamaIndex crea y proporciona la base de conocimientos. LangGraph la trata como una herramienta y decide cuándo invocarla.
Un modelo mental para LlamaIndex
Considere a LlamaIndex como un bibliotecario para su LLM. El LLM se asemeja a un brillante asistente de investigación que ha leído gran parte de Internet pero que nunca ha abierto los archivos de su empresa, y nadie puede pedirle que memorice un volumen de 10,000 páginas. Por eso interviene el bibliotecario: lee cada documento, lo descompone en pequeñas tarjetas de índice buscables (fragmentos), almacena esas tarjetas en un lugar donde la búsqueda por tema sea rápida (un índice vectorial) y obtiene solo las 3 a 5 tarjetas más relevantes cada vez que el asistente hace una pregunta. El asistente nunca memoriza nada; solo reciben algunos fragmentos y el razonamiento se realiza sobre ellos.
El proceso en cinco etapas
Cualquier sistema RAG, independientemente del framework, pasa por las mismas cinco etapas, y LlamaIndex ofrece una abstracción clara para cada una de ellas.
1. LOAD → Read raw files (PDF, Word, web pages, databases) into Documents
2. CHUNK → Split Documents into small, retrievable Nodes
3. EMBED → Convert each Node's text into a vector (a list of numbers
representing meaning)
4. STORE → Save those vectors in a Vector Index for fast lookup
5. RETRIEVE → At query time, embed the user's question, find the most
similar Nodes, and return them as context
Las primeras cuatro etapas se ejecutan una sola vez, al crear la base de conocimientos, y se denominan indexación. La quinta se lleva a cabo cada vez que un usuario hace una consulta, y se llama consultas. El tiempo de indexación frente al tiempo de consulta es probablemente el modelo mental más importante en RAG, y vale la pena tenerlo en cuenta.
El vocabulario
Documentoes un fragmento sin procesar de contenido original, como un PDF, una página web o una fila de base de datos, junto con sus metadatos. Es la unidad completa y sin procesar.Nodoes un fragmento extraído de unDocumento. Los modelos LLM y los modelos de embedding funcionan mejor con texto conciso y específico que con documentos completos, por lo que LlamaIndex divide cadaDocumentoen varios objetosNode. Los nodos transmiten los metadatos a su nodo padre, de modo que cualquier fragmento puede rastrearse hasta su origen.SimpleDirectoryReaderes el cargador habitual: úsalo apuntando a una carpeta y leerá PDFs, texto, archivos de Word y otros formatos, generando uno o más objetosDocumentpor archivo.VectorStoreIndexes la clase principal de indexación. Al recibir objetosDocumentoNode, los incorpora y crea un índice vectorial buscable, ya sea en memoria o dentro de una base de datos vectorial externa si está configurada.Settingses un objeto de configuración global. El modelo LLM y el modelo de embedding se configuran una sola vez en él, y todos los componentes los utilizan, por lo que no es necesario pasarlos en cada llamada.QueryEnginees el componente al que realmente se hacen las preguntas. Proviene de.as_query_engine()en un índice, y una sola llamada incrusta la consulta, ejecuta una búsqueda de similitud y proporciona el contexto recuperado al LLM para obtener una respuesta final.Retrieverproviene de.as_retriever()en un índice y solo se ocupa del paso de búsqueda. Devuelve los objetosNodecoincidentes en bruto y no genera ninguna respuesta, lo cual es adecuado para casos en los que la recuperación y la generación deben ser pasos separados y controlables. Esa es exactamente la situación cuando entra en juego LangGraph.
El pipeline se construye primero por sí mismo, sin LangGraph.
Un pipeline funcional en unas diez líneas
Primero se realiza la instalación.
# Core package + OpenAI LLM and embedding integrations (the common starting setup)
pip install llama-index-core llama-index-llms-openai llama-index-embeddings-openai
# Readers for common file types (PDF, Word, etc.)
pip install llama-index-readers-file pypdf
LlamaIndex se entrega intencionalmente en muchos paquetes pequeños. llama-index-core contiene la lógica del framework, mientras que la elección del LLM, el modelo de embedding, la base de datos vectorial y el lector de archivos se encuentran en paquetes separados que solo se instalan si es necesario. Esto mantiene las instalaciones ligeras.
Un pequeño archivo de configuración establece los valores predeterminados globales.
# ── llamaindex_config.py ─────────────────────────────────────
import os
from llama_index.core import Settings
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
# Settings is global - configure once, used everywhere in LlamaIndex
Settings.llm = OpenAI(
model="gpt-4o-mini", # Used for generating final answers from retrieved context
temperature=0.1, # Low temperature: factual, not creative
)
Settings.embed_model = OpenAIEmbedding(
model="text-embedding-3-small", # Used to convert text into vectors
)
# Controls how documents are split into Nodes (chunks)
Settings.chunk_size = 512 # Max tokens per chunk
Settings.chunk_overlap = 50 # Overlap between consecutive chunks, to preserve context across boundaries
Sobre chunk_size: los chunks demasiado grandes contienen tanta información que la recuperación se vuelve imprecisa, ya que un chunk obtenido podría ser solo el 10% relevante. Los chunks demasiado pequeños eliminan el contexto circundante que el LLM necesita para comprender un hecho. Es común y razonable comenzar con 512 tokens y una superposición de 50 tokens, ajustándolo luego según los documentos involucrados.
Luego está la propia base de conocimientos:
# ── build_knowledge_base.py ──────────────────────────────────
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
# ── LOAD: Read all files in a folder into Document objects ──
documents = SimpleDirectoryReader("./data").load_data()
print(f"Loaded {len(documents)} documents")
# ── CHUNK + EMBED + STORE: all three happen inside this one call ──
# VectorStoreIndex automatically:
# 1. Splits each Document into Nodes (using Settings.chunk_size)
# 2. Embeds each Node (using Settings.embed_model)
# 3. Stores the vectors in an in-memory index
index = VectorStoreIndex.from_documents(documents, show_progress=True)
# ── RETRIEVE + GENERATE: ask a question ──────────────────────
query_engine = index.as_query_engine(
similarity_top_k=3, # Retrieve the 3 most relevant chunks for each query
)
response = query_engine.query("What is our refund policy for enterprise customers?")
print(response)
Aproximadamente diez líneas son suficientes para crear un sistema RAG funcional, que representa el “hello world” de este framework y una buena demostración de por qué existe. Hacerlo a mano implicaría necesitar un divisor personalizado, un bucle que invoque al modelo de incrustación, una búsqueda de vecino más cercano y un plantilla de prompt.
Almacenamiento persistente del índice
Cada fragmento pasa por el modelo de incrustación durante la creación del índice, lo cual consume dinero y tiempo; por eso, reconstruirlo cada vez que se inicia la aplicación es un desperdicio. Guardar el índice en disco y cargarlo posteriormente evita esto.
# ── Save the index after building it ─────────────────────────
index.storage_context.persist(persist_dir="./storage")
# ── Load it back later without re-embedding anything ──────────
from llama_index.core import StorageContext, load_index_from_storage
storage_context = StorageContext.from_defaults(persist_dir="./storage")
index = load_index_from_storage(storage_context)
storage_context.persist() almacena los datos vectoriales, los metadatos de los nodos y el almacenamiento de documentos en una carpeta local, mientras que load_index_from_storage() recrea el índice idéntico a partir de esos archivos sin realizar re-incrustaciones ni llamadas a la API. Sigue la misma filosofía de crear una vez, persistir y cargar de forma económica que un checkpointer de LangGraph.
Una base de datos vectorial real
Un índice en memoria es adecuado para prototipos. En entornos de producción se suele necesitar una base de datos vectorial dedicada, diseñada para millones de vectores, lecturas concurrentes y filtrado de metadatos a gran escala. Chroma es un ejemplo de esto.
# ── Using Chroma as a persistent, production-grade vector store ──
# pip install llama-index-vector-stores-chroma chromadb
import chromadb
from llama_index.vector_stores.chroma import ChromaVectorStore
from llama_index.core import StorageContext, VectorStoreIndex
chroma_client = chromadb.PersistentClient(path="./chroma_db")
chroma_collection = chroma_client.get_or_create_collection("my_knowledge_base")
vector_store = ChromaVectorStore(chroma_collection=chroma_collection)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
# Build the index directly into Chroma
index = VectorStoreIndex.from_documents(
documents,
storage_context=storage_context
)
# Later, in a different process, reconnect without re-indexing:
index = VectorStoreIndex.from_vector_store(vector_store=vector_store)
El intercambio se asemeja a los puntos de control. Se sustituye un único objeto (el almacenamiento en memoria de ChromaVectorStore, al igual que MemorySaver da paso a SqliteSaver) y el motor de consultas así como la lógica de recuperación siguen siendo los mismos. Pinecone, Qdrant, Weaviate y la mayoría de las otras bases de datos vectoriales que soporta LlamaIndex funcionan de la misma manera.
Conectando los dos frameworks
Esto es lo esencial. Los frameworks hablan lenguajes diferentes: un QueryEngine de LlamaIndex no es una herramienta de LangGraph, ya que un agente de LangGraph espera herramientas creadas con el decorador @tool de LangChain.
El puente es sencillo. Defina una función regular de Python, añada @tool sobre ella y haga que su cuerpo llame a query_engine.query(...). Esa es toda la integración necesaria. LangGraph no necesita saber nada sobre los detalles internos de LlamaIndex, ya que considera que se trata de una herramienta que recibe una cadena de texto y devuelve otra cadena, al igual que cualquier otra herramienta.
# ── MODULE 3: TOOLS (LlamaIndex-backed) ─────────────────────
from langchain_core.tools import tool
# The query_engine built in Part B - created once, at startup
# (In a real app, you'd load this from persisted storage, not rebuild it every time)
@tool
def search_knowledge_base(query: str) -> str:
"""Search the internal knowledge base for company policies, product
documentation, and internal procedures. Use this whenever the user asks
a question that might be answered by internal company documents rather
than general knowledge.
Args:
query: A natural-language question to search for.
Returns:
A synthesized answer based on the most relevant retrieved documents.
"""
response = query_engine.query(query)
return str(response)
La documentación es tan importante como lo es para cualquier herramienta. El LLM del agente la lee para decidir cuándo llamar a esta herramienta, en lugar de responder con su propio conocimiento o llamar a otra herramienta.
El agente completo
He aquí el plantilla completa del agente con una herramienta RAG respaldada por LlamaIndex integrada en el módulo de herramientas. Nada más cambia.
# ============================================================
# LANGGRAPH + LLAMAINDEX RAG AGENT — COMPLETE TEMPLATE
# Extends: Part 1 (core structure)
# ============================================================
# ── MODULE 1: IMPORTS & CONFIGURATION ───────────────────────
import os
from typing import Literal
# LangChain / LangGraph imports (the orchestration layer)
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage, BaseMessage
from langchain_core.tools import tool
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode
from langgraph.checkpoint.memory import MemorySaver
# LlamaIndex imports (the retrieval / data layer)
from llama_index.core import (1
Settings, SimpleDirectoryReader, VectorStoreIndex,
StorageContext, load_index_from_storage
)
from llama_index.llms.openai import OpenAI as LlamaOpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
# LangGraph's chat model - used by the agent's reasoning
llm = ChatOpenAI(model="gpt-4o", temperature=0)
# LlamaIndex's model config - used internally by the query engine
# Note: these are SEPARATE from the LangGraph llm above. Each framework
# manages its own model instances; they don't share state.
Settings.llm = LlamaOpenAI(model="gpt-4o-mini", temperature=0.1)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
Settings.chunk_size = 512
# ── MODULE 2: STATE ──────────────────────────────────────────
class State(MessagesState):
pass # messages field inherited; extend if your agent needs more
# ── MODULE 3: TOOLS (RAG-backed) ─────────────────────────────
# Build or load the LlamaIndex knowledge base ONCE, at startup
PERSIST_DIR = "./storage"
if os.path.exists(PERSIST_DIR):
# Reload existing index - no re-embedding, fast startup
storage_context = StorageContext.from_defaults(persist_dir=PERSIST_DIR)
index = load_index_from_storage(storage_context)
else:
# First run - build the index and persist it
documents = SimpleDirectoryReader("./data").load_data()
index = VectorStoreIndex.from_documents(documents, show_progress=True)
index.storage_context.persist(persist_dir=PERSIST_DIR)
query_engine = index.as_query_engine(similarity_top_k=3)
@tool
def search_knowledge_base(query: str) -> str:
"""Search internal company documents for policies, product specs,
procedures, and other domain-specific information. Use this for any
question that requires knowledge specific to this organization rather
than general world knowledge."""
response = query_engine.query(query)
return str(response)
tools = [search_knowledge_base]
llm_with_tools = llm.bind_tools(tools)
tool_node = ToolNode(tools)
# ── MODULE 4: NODES ──────────────────────────────────────────
def agent_node(state: State) -> dict:
"""The reasoning node. Decides whether to answer directly or
search the knowledge base first."""
system_prompt = SystemMessage(content=(
"You are a helpful assistant with access to an internal knowledge base. "
"Use the search_knowledge_base tool when the user asks about company-specific "
"information. For general questions, answer directly."
))
messages = [system_prompt] + state["messages"]
response = llm_with_tools.invoke(messages)
return {"messages": [response]}
# ── MODULE 5: ROUTING ────────────────────────────────────────
def should_continue(state: State) -> Literal["tools", "__end__"]:
last_message = state["messages"][-1]
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
return "tools"
return "__end__"
# ── MODULE 6: GRAPH ASSEMBLY ─────────────────────────────────
graph_builder = StateGraph(State)
graph_builder.add_node("agent", agent_node)
graph_builder.add_node("tools", tool_node)
graph_builder.add_edge(START, "agent")
graph_builder.add_conditional_edges(
"agent", should_continue,
{"tools": "tools", "__end__": END}
)
graph_builder.add_edge("tools", "agent")
graph = graph_builder.compile(checkpointer=MemorySaver())
# ── MODULE 7: ENTRYPOINT ──────────────────────────────────────
if __name__ == "__main__":
config = {"configurable": {"thread_id": "session-001"}}
print("RAG agent ready. Ask about your documents, or anything else.\n")
while True:
user_text = input("You: ").strip()
if not user_text or user_text.lower() == "exit":
break
response = graph.invoke(
{"messages": [HumanMessage(content=user_text)]},
config=config
)
print(f"Agent: {response['messages'][-1].content}\n")
Qué sucede con una consulta
Seguir el rastro de una única solicitud deja clara la división del trabajo.
User: "What's our policy on remote work?"
↓
[agent_node] — LangGraph's LLM reads the message, recognizes this needs
internal info, decides to call search_knowledge_base
↓
[tools] — ToolNode executes search_knowledge_base("What's our policy on remote work?")
↓
Inside the tool: query_engine.query(...) runs —
this is 100% LlamaIndex, invisible to LangGraph:
1. Embeds the query
2. Searches the vector index for the 3 closest chunks
3. Feeds those chunks + the question to Settings.llm
4. Returns a synthesized answer string
↓
[agent_node] — LangGraph's LLM receives the tool's string result,
and crafts the final response shown to the user
↓
Response to user
El límite entre ambos es claro. LangGraph nunca se encuentra con un Node, una representación embedding o un vector, sino únicamente con una herramienta que devuelve texto, sin diferencia alguna con search_web o calculate. LlamaIndex, por su parte, nunca se topa con messages, state ni thread_id; solo recibe una cadena de consulta y genera una respuesta. Cada framework aprovecha sus puntos fuertes, y una sola llamada a función constituye la conexión entre ellos.
Modo solo recuperador para mayor control
El patrón query_engine.query() es la integración más sencilla y adecuada para la mayoría de los casos. A veces se necesita más control, por ejemplo cuando el propio modelo LLM del agente de LangGraph debe redactar la respuesta final, utilizando el prompt del sistema y el contexto de la conversación existentes, y no los Settings.llm internos de LlamaIndex, que generan una respuesta separada e incompleta.
En ese caso, utilice un Retriever en lugar de un QueryEngine. Este solo realiza el paso de búsqueda y devuelve fragmentos en bruto; lo que suceda a continuación depende del agente.
# ── Retriever-only tool: returns raw chunks, not a synthesized answer ──
retriever = index.as_retriever(similarity_top_k=3)
@tool
def retrieve_documents(query: str) -> str:
"""Retrieve relevant document excerpts from the internal knowledge base.
Returns raw excerpts for you to read and reason over yourself -
use this when you need to cite specific sources or combine information
from multiple documents."""
nodes = retriever.retrieve(query)
# Format each retrieved chunk with its source for transparency
formatted_chunks = []
for i, node in enumerate(nodes):
source = node.metadata.get("file_name", "unknown source")
formatted_chunks.append(f"[Excerpt {i+1} from {source}]\n{node.text}")
return "\n\n---\n\n".join(formatted_chunks)
Con esta configuración, la llamada al LLM del propio agent_node, que utiliza llm_with_tools junto con el historial completo de la conversación y el prompt del sistema, lee los fragmentos brutos y escribe la respuesta. Esto tiene beneficios reales: el agente puede indicar de qué documento proviene un hecho, puede mezclar los fragmentos recuperados con la conversación anterior, y los usuarios escuchan una voz coherente y no dos LLMs con estilos diferentes.
¿Cuál elegir?
Elige QueryEngine para una integración más sencilla, cuando es suficiente que el LLM de LlamaIndex genere una respuesta basada en la recuperación de información como una cadena autónoma. Esto es adecuado para la mayoría de los chatbots y asistentes.
Elige Retriever cuando se necesitan citas de fuentes, cuando el LLM principal del agente debe mantener un tono y estilo de razonamiento constante tanto en las respuestas obtenidas como en las que no lo son, o cuando los fragmentos recuperados deben combinarse con otros resultados de herramientas antes de obtener la respuesta final.
Referencia de palabras clave
Carga e indexación. Documento es una fuente cargada, ya sea un archivo o una página. Nodo es un fragmento de ella, la unidad recuperable, con metadatos que apuntan a su origen. SimpleDirectoryReader("./path").load_data() carga todos los archivos compatibles de una carpeta como objetos Documento. Settings es el singleton de configuración global, y Settings.llm, Settings.embed_model y Settings.chunk_size se establecen una sola vez para todos los componentes. VectorStoreIndex.from_documents(documents) divide en fragmentos, incrusta y almacena todo en una sola llamada.
Consultas. index.as_query_engine(similarity_top_k=3) genera un QueryEngine que obtiene los fragmentos de mayor similitud (top-k) y también escribe una respuesta sintetizada. index.as_retriever(similarity_top_k=3) crea un Retriever que solo obtiene esos fragmentos de mayor similitud, lo cual es adecuado para configuraciones en las que su propio LLM se encarga de la síntesis. query_engine.query("...") ejecuta todo el proceso de recuperación seguido de generación y devuelve un objeto Response, mientras que str(response) extrae el texto. retriever.retrieve("...") solo realiza la recuperación y devuelve objetos Node que exponen los atributos .text y .metadata.
Persistencia. index.storage_context.persist(persist_dir="./storage") guarda el índice para que no sea necesario volver a incrustarlo al reiniciar. StorageContext.from_defaults(persist_dir="./storage") combinado con load_index_from_storage(...) lo vuelve a cargar. ChromaVectorStore, PineconeVectorStore y QdrantVectorStore son servidores backend externos que reemplazan el almacén en memoria predeterminado, siguiendo el mismo principio que los mecanismos de punto de control de LangGraph.
El puente. Una función @tool cuyo cuerpo llama a query_engine.query(...) constituye toda la integración: LangGraph percibe un herramienta ordinaria y LlamaIndex permanece encapsulado. También existen dos configuraciones separadas de LLM. Settings.llm es el modelo interno de LlamaIndex, utilizado únicamente dentro del motor de consultas, mientras que el llm de LangGraph del agente se encarga del razonamiento principal. Ambos funcionan de forma independiente y no comparten historial ni estado.
¿Lo necesita?
- Si el agente solo necesita conocimientos generales, matemáticas o búsquedas en la web en tiempo real, no se requiere LlamaIndex, y el template básico con herramientas estándar es suficiente.
- Si debe responder a partir de un conjunto fijo de documentos como políticas, manuales o informes que rara vez cambian, envolver un
QueryEnginecomo herramienta es la opción adecuada.
Retriever.interrupt() de LangGraph y coloque la llamada de recuperación en un nodo que espere la aprobación, al igual que con cualquier herramienta sensible.Dos frameworks y una conexión
La lección fundamental es que dotar a un agente de conocimientos del mundo real no es un problema de LangGraph. Es un problema de datos, y un marco de trabajo basado en datos es la herramienta adecuada para ello. Un sistema multiagente complejo está en realidad formado por varios grafos de agente individual combinados, y el acceso al conocimiento sigue el mismo principio modular.
LlamaIndex se encarga del indexado y la recuperación, mientras que LangGraph se ocupa del razonamiento y la orquestación. Toda la conexión se logra mediante una función decorada con @tool. Eso no es una limitación, sino el nivel adecuado de acoplamiento, ya que cada marco de trabajo funciona en su ámbito y ambos lados pueden actualizarse de forma independiente. Se puede cambiar la base de datos vectorial sin tocar el grafo, y se puede añadir un patrón de supervisor sin afectar a la lógica de recuperación.
Leer más
- MCP para agentes de LangGraph, nivel por nivel: servidores, transportes y el puente adaptador — Aprende el Protocolo de Contexto de Modelo desde las roles de host, cliente y servidor hasta un servidor FastMCP, stdio vs HTTP, langchain-mcp-adapters y los problemas comunes en la producción con LangGraph.
- Crea ShopBot: un agente de soporte de LangGraph con memoria, herramientas y aprobación de reembolsos — Construye un agente de soporte al cliente en LangGraph módulo por módulo: estado, herramientas, nodos, enrutamiento, resúmenes dinámicos y una interrupción que espera la aprobación del reembolso.