9. Construye tu primer sistema RAG
Todas las aplicaciones que hemos construido hasta ahora dependían únicamente del conocimiento preentrenado del LLM. Por eso no podía responder preguntas sobre información que el LLM nunca aprendió, como los documentos internos de tu empresa o manuales de productos.
RAG (Retrieval-Augmented Generation, Generación Aumentada por Recuperación) resuelve este problema. Cuando llega una pregunta del usuario, primero recupera documentos relevantes, luego pasa el contenido recuperado junto con la pregunta al LLM para que pueda responder basándose en ese contenido. Estás combinando la capacidad de razonamiento del LLM con el conocimiento de tus documentos.
En este capítulo, construiremos un pipeline RAG completo desde la preparación de documentos (carga, fragmentación, embedding) hasta la generación de respuestas basadas en recuperación. El sistema terminado recupera documentos relevantes cuando llega una pregunta, luego los pasa junto con la pregunta al LLM para que responda basándose en ese contenido de documento. Responde con precisión cuando la información está en los documentos, y honestamente dice "No sé" cuando no lo está—esta es la esencia de un RAG confiable.
9.1) Comprender RAG
9.1.1) El problema: Los LLMs no conocen tus datos
Los LLMs se entrenan con datos de internet como Wikipedia, artículos de noticias y código público. No conocen los documentos internos de tu empresa ni el contrato que recibiste ayer. Por lo tanto, no pueden responder preguntas como:
- "¿Cuál es la política de vacaciones de nuestra empresa?"
- "Resume el informe de ventas de este trimestre"
- "¿Cuáles son los términos de reembolso en el contrato que acabo de recibir?"
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-5-mini")
# Pregunta sobre un documento privado que el LLM nunca ha visto
response = llm.invoke("¿Cuál es la política de reembolso de Acme Corp?")
print(response.content)Salida:
No tengo información específica sobre la política de reembolso de Acme Corp. Te recomendaría
consultar su sitio web oficial o contactar directamente a su equipo de atención al cliente
para obtener la información más precisa y actualizada.En este ejemplo, el LLM honestamente dice que no lo sabe. (O podría alucinar una respuesta que suene plausible.)
Pero, ¿qué pasaría si proporcionáramos el documento de política de reembolso junto con la pregunta? El LLM daría una respuesta precisa basada en el contenido proporcionado. Esta es la idea central detrás de RAG.
9.1.2) ¿Cómo deberíamos proporcionar el documento?
El enfoque más simple es copiar y pegar el documento completo en el prompt. Esto en realidad funciona bien para documentos cortos.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-5-mini")
# En realidad, esto sería mucho más largo, pero asumamos que lo siguiente es el documento completo
document_text = """
Política de Reembolso (Vigente desde enero de 2026):
- Reembolso completo dentro de los 30 días de la compra con recibo original.
- Después de 30 días, solo crédito en tienda.
- Los productos digitales no son reembolsables después de la descarga.
- Los artículos defectuosos pueden devolverse en cualquier momento para un reembolso completo.
"""
response = llm.invoke(
f"""Por favor responde basándote en el siguiente documento:
{document_text}
Pregunta: ¿Cuál es la política de reembolso para productos digitales?"""
)
print(response.content)Salida:
Los productos digitales no son reembolsables después de la descarga.
...Esto funciona bien para documentos cortos. Pero, ¿qué pasa si el documento es muy grande? Esto causa los siguientes problemas graves:
1. Límites de ventana de contexto: Los LLMs tienen un número limitado de tokens que pueden procesar a la vez. Para GPT-5-mini, son 400K tokens. Sin embargo, toda la documentación de tu empresa puede superar fácilmente esto. Incluso si cabe, las respuestas se vuelven más lentas y menos precisas a medida que el contexto se alarga.
2. Costo: Las APIs de LLM cobran por token. Enviar el documento completo cuando solo se necesitan uno o dos párrafos hace que los costos se disparen.
3. Degradación de precisión: Cuando incluyes el documento completo, la información que realmente necesitas queda enterrada en contenido irrelevante. La atención del LLM se desvía por información no relacionada, degradando la calidad de la respuesta.
RAG resuelve los tres problemas recuperando y proporcionando solo las partes relevantes del documento.
9.1.3) Idea central: Recuperar partes relevantes y proporcionarlas con la pregunta
La esencia de RAG es simple: Antes de enviar la pregunta al LLM, primero encuentra las partes relevantes de tus documentos y proporciónalas junto con la pregunta.
Así es como funciona:
- El usuario hace una pregunta.
- El sistema recupera (Retrieval) contenido relevante del almacén de documentos.
- El contenido recuperado se añade (Augmentation) al prompt junto con la pregunta.
- El LLM genera (Generation) una respuesta basada en el contenido recuperado.
Estos tres pasos son de donde RAG (Retrieval-Augmented Generation) obtiene su nombre.
9.1.4) ¿Cómo recuperamos contenido relevante? (Limitaciones de la coincidencia de palabras clave)
El paso de recuperación es crucial para RAG. Necesitas proporcionar contenido relevante para obtener respuestas adecuadas. Entonces, ¿cómo recuperamos contenido relacionado con la pregunta?
El método más simple es la coincidencia de palabras clave: encontrar documentos que contengan palabras de la pregunta. Por ejemplo, si alguien pregunta "¿Cuál es la política de reembolso para productos digitales?" buscarías documentos que contengan las palabras "reembolso", "digitales" y "productos".
Pero la coincidencia de palabras clave tiene una debilidad crítica: solo puede encontrar coincidencias exactas de palabras.
Digamos que tienes un documento de política de reembolso con este contenido:
"Reembolso completo disponible dentro de los 30 días de la compra."
¿Qué pasa cuando un usuario pregunta "¿Cómo recupero mi dinero?" Este documento no será recuperado. El documento no contiene la frase "recuperar mi dinero". Los humanos entienden que "reembolso" y "recuperar mi dinero" tienen el mismo significado, pero la búsqueda por palabras clave solo coincide palabras, por lo que no lo encuentra.
La búsqueda por palabras clave solo coincide palabras. Incluso cuando el significado es el mismo, si las palabras difieren, no lo encontrará.
La solución es la búsqueda semántica. Y lo que hace esto posible son los embeddings.
9.1.5) Embeddings: Convertir texto en vectores numéricos
Los embeddings representan el significado del texto como una lista de números (un vector). Cuando ingresas texto en un modelo de embedding, lo convierte en un vector de cientos a miles de números.
from langchain_openai import OpenAIEmbeddings
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Embed una sola oración
vector = embeddings_model.embed_query("¿Cómo recupero mi dinero?")
print(f"Dimensiones del vector: {len(vector)}")
print(f"Primeros 5 valores: {vector[:5]}")Salida:
Dimensiones del vector: 1536
Primeros 5 valores: [0.0123, -0.0456, 0.0789, -0.0234, 0.0567]Dimensión es el número de valores que componen el vector. El modelo text-embedding-3-small representa todo el texto como 1,536 números.
¿Por qué necesitamos tantos números? Porque cada dimensión captura diferentes aspectos del significado:
- Algunas dimensiones podrían distinguir "acción/estado"
- Otras podrían representar grados de "concreto/abstracto"
- Otras más podrían indicar sentimiento "positivo/negativo"
- ... (1,536 características semánticas—aunque en realidad no podemos interpretar qué representa cada dimensión)
Así como las coordenadas 2D (x, y) representan un punto en un plano, un vector de 1,536 dimensiones representa un punto en un "espacio de significado" de 1,536 dimensiones. Más dimensiones permiten distinciones más finas en el significado.
Significados similares se ubican cerca en el espacio de significado. "Método de reembolso" y "recuperar dinero" usan palabras diferentes, pero como tienen significados similares, se colocan cerca en el espacio de significado.
9.1.6) Búsqueda semántica: Significado similar, distancia más cercana
Una vez que has convertido tanto documentos como consultas en vectores, puedes encontrar los documentos más relevantes midiendo la similitud entre vectores. Esto se llama búsqueda semántica — buscar por similitud semántica en lugar de coincidencia de palabras clave.
La medida de similitud más común es la similitud de coseno, que mide el ángulo entre dos vectores. Cuando los vectores apuntan en direcciones similares, la similitud es mayor. Más cerca de 1.0 significa significado muy similar, mientras que más cerca de 0 significa baja relevancia.
Calculemos esto realmente:
from langchain_openai import OpenAIEmbeddings
import numpy as np
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Embed la consulta y dos documentos candidatos
query_vec = embeddings_model.embed_query("¿Cuál es el período de reembolso?")
doc1_vec = embeddings_model.embed_query("Reembolso completo disponible dentro de los 30 días de la compra.") # Relacionado
doc2_vec = embeddings_model.embed_query("Nuestra oficina está ubicada en el centro de Seattle.") # No relacionado
def cosine_similarity(a, b):
"""Calcula la similitud de coseno entre dos vectores."""
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
sim1 = cosine_similarity(query_vec, doc1_vec)
sim2 = cosine_similarity(query_vec, doc2_vec)
print(f"Consulta vs doc 'reembolso': {sim1:.4f}")
print(f"Consulta vs doc 'oficina': {sim2:.4f}")Salida:
Consulta vs doc 'reembolso': 0.6415
Consulta vs doc 'oficina': 0.1706(Los valores reales pueden variar según el modelo)
El documento de reembolso obtiene una puntuación mucho más alta. El modelo de embedding entiende que "período de reembolso" y "reembolso completo dentro de 30 días" están semánticamente relacionados. Esta es la búsqueda semántica, y es el mecanismo central de RAG.
9.1.7) Descripción general del pipeline RAG
Combinando los conceptos que hemos aprendido se crea el siguiente pipeline RAG:
El pipeline consiste en dos fases:
Ingesta de conocimiento (realizada una vez inicialmente, o cuando cambian los documentos):
- Carga de documentos: Extraer datos de texto de varias fuentes (Markdown, PDF, etc.).
- División de texto (Fragmentación): Dividir documentos largos en fragmentos más pequeños para mejorar la precisión de recuperación y cumplir con los límites de entrada del LLM.
- Conversión a vectores (Embedding): Usar un modelo de embedding para convertir fragmentos en vectores numéricos basados en significado.
- Almacenamiento de vectores: Almacenar los vectores convertidos y el texto original en una base de datos vectorial (indexación).
Recuperación y generación de respuestas (realizada para cada pregunta del usuario):
- Embedding de pregunta: Convertir la pregunta del usuario en un vector numérico usando el mismo modelo usado durante la ingesta.
- Búsqueda por similitud (Recuperación): Extraer los top-K fragmentos que están semánticamente más cerca del vector de pregunta de la base de datos vectorial.
- Aumento del prompt: Combinar la pregunta original con los fragmentos recuperados para aumentar el prompt.
- Generación de respuesta: El LLM hace referencia a los fragmentos proporcionados para generar una respuesta fundamentada.
9.2) Carga y fragmentación de documentos
Esta sección cubre los primeros dos pasos de la fase de ingesta de conocimiento del pipeline RAG:
- Carga de documentos: Leer datos de texto de archivos
- División de texto (Fragmentación): Dividir datos de texto en piezas pequeñas y buscables
En la siguiente sección (9.3), aprenderemos cómo convertir estos fragmentos en vectores y almacenarlos.
9.2.1) Cargar documentos desde archivos
El primer paso en un pipeline RAG es cargar documentos en objetos Python. LangChain proporciona cargadores de documentos — clases que soportan una variedad de formatos de archivo. Los principales cargadores son:
TextLoader: Archivos de texto plano (.txt) y Markdown (.md)PyPDFLoader: Archivos PDF (.pdf), cargados página por páginaCSVLoader: Archivos CSV (.csv), con cada fila cargada como un documento separadoUnstructuredMarkdownLoader: Archivos Markdown (.md), con conciencia estructural (encabezados, listas, etc.)
Independientemente del cargador que uses, el resultado siempre se devuelve como una lista de objetos Document. Cada Document tiene dos atributos clave:
page_content: El contenido de texto del documentometadata: Un diccionario que contiene meta-información como ruta de archivo y número de página
En este tutorial, usaremos TextLoader para cargar archivos Markdown.
Preparar documentos de muestra
Primero, creemos algunos documentos de muestra con los que trabajar. Crea una carpeta data/docs/ en tu proyecto y añade los siguientes archivos:
mkdir -p data/docsCrea data/docs/refund_policy.md:
# Política de Reembolso
**Fecha de vigencia**: 1 de enero de 2026
## Devoluciones estándar
Todos los productos físicos pueden devolverse dentro de los 30 días de la compra para un reembolso completo.
Se requiere el recibo original o el correo electrónico de confirmación del pedido. Los artículos deben estar en su
empaque original y en condición sin usar.
Después de 30 días, las devoluciones se aceptan solo para crédito en tienda. El crédito en tienda no expira.
## Productos digitales
Los productos digitales (licencias de software, libros electrónicos, cursos en línea) no son reembolsables
una vez que se ha activado el enlace de descarga o acceso. Si experimentas problemas técnicos
que impiden el acceso, contacta a soporte dentro de 7 días para un reemplazo o reembolso.
## Artículos defectuosos
Los artículos defectuosos pueden devolverse en cualquier momento para un reembolso completo o reemplazo.
Por favor incluye una descripción del defecto. Los costos de envío para devoluciones defectuosas
están cubiertos por la empresa.
## Servicios de suscripción
Las suscripciones mensuales pueden cancelarse en cualquier momento. Los reembolsos son prorrateados según
los días restantes en el ciclo de facturación. Las suscripciones anuales pueden reembolsarse completamente
dentro de los primeros 14 días. Después de 14 días, no hay reembolso disponible pero el acceso continúa hasta el final del período de facturación.Crea data/docs/shipping_info.md:
# Información de envío
## Envío nacional
Envío estándar (5-7 días hábiles): Gratis en pedidos superiores a $50, de lo contrario $5.99.
Envío exprés (2-3 días hábiles): $12.99.
Envío nocturno (siguiente día hábil): $24.99.
## Envío internacional
Los pedidos internacionales se envían por correo aéreo rastreado. Los tiempos de entrega varían según
el destino, típicamente 10-21 días hábiles. Los costos de envío internacional se
calculan al finalizar la compra según el peso y el destino.
Los aranceles aduaneros e impuestos de importación son responsabilidad del comprador y no están incluidos en el costo de envío.
## Seguimiento de pedidos
Todos los pedidos incluyen un número de seguimiento enviado por correo electrónico dentro de las 24 horas del envío.
Rastrea tu pedido a través del enlace de seguimiento en tu correo electrónico o a través del sitio web del transportista.
## Paquetes perdidos o dañados
Si tu paquete se pierde o llega dañado, contacta a soporte dentro de 48 horas.
Enviaremos un reemplazo sin costo adicional. Para artículos dañados, por favor
proporciona fotos del daño y el empaque.Ahora carga estos archivos usando TextLoader:
from pathlib import Path
from langchain_community.document_loaders import TextLoader
# Carga todos los archivos .md del directorio data/docs
docs_dir = Path("data/docs")
for md_file in docs_dir.glob("*.md"):
loader = TextLoader(str(md_file), encoding="utf-8")
docs = loader.load()
if docs: # Verifica que el archivo no esté vacío
doc = docs[0] # Archivo único = Document único
print(f"Archivo: {doc.metadata['source']}")
print(f"Longitud: {len(doc.page_content)} caracteres")
print(f"Vista previa: {doc.page_content[:80]}...")
print()Salida:
Archivo: data/docs/refund_policy.md
Longitud: 1166 caracteres
Vista previa: # Política de Reembolso
...
Archivo: data/docs/shipping_info.md
Longitud: 972 caracteres
Vista previa: # Información de envío
...Nota:
TextLoadertoma una sola ruta de archivo como entrada, pero devuelveList[Document]para una interfaz consistente con otros cargadores. (Por ejemplo,PDFLoaderdevuelve múltiples Documents — uno por página.)
9.2.2) Dividir documentos en fragmentos: Fragmentación
Los dos documentos anteriores son intencionalmente cortos para los propósitos de este tutorial. En aplicaciones reales, a menudo trabajarás con documentos que tienen cientos o miles de páginas de largo. Si embedes un documento completo como un solo vector, miles de conceptos se comprimen en uno — haciendo imposible recuperar con precisión lo que realmente necesitas.
La fragmentación es el proceso de dividir documentos en piezas pequeñas y significativas. El objetivo es simple: cuando un usuario hace una pregunta, solo los párrafos específicos directamente relevantes para la respuesta deben recuperarse — no el documento completo.
El tamaño del fragmento afecta directamente tanto la recuperación como la calidad de la respuesta:
- Demasiado grande: Múltiples temas se mezclan en un fragmento, haciendo que los embeddings sean menos precisos y la recuperación más difícil. Incluso cuando se encuentra el fragmento correcto, el contenido irrelevante se pasa al LLM, degradando la calidad de la respuesta.
- Demasiado pequeño: El LLM puede no recibir suficiente información para responder correctamente. Por ejemplo, si solo se recupera la oración "El envío estándar es $5.99", el LLM no puede saber que esto solo aplica a pedidos menores de $50.
- Justo: Cada fragmento cubre un tema con suficiente contexto, permitiendo recuperación y respuestas precisas.
9.2.3) Controlar el tamaño y superposición de fragmentos
Para dividir documentos en fragmentos, necesitas un divisor de texto. Un divisor de texto es una herramienta de LangChain que divide documentos largos en piezas más pequeñas. Elegir el divisor correcto es importante.
RecursiveCharacterTextSplitter: Intenta múltiples separadores en orden jerárquico para preservar tanto contexto como sea posible. El divisor más ampliamente usado para propósitos generales.CharacterTextSplitter: Divide en un solo separador (predeterminado:\n\n). Adecuado para documentos con estructura simple.MarkdownHeaderTextSplitter: Divide en encabezados Markdown (#,##). Efectivo cuando quieres preservar la estructura de tabla de contenidos del documento.
¿Por qué es efectivo RecursiveCharacterTextSplitter?
Este divisor funciona intentando separadores de la unidad más grande a la más pequeña para encontrar el mejor punto de división. El orden predeterminado es el siguiente (puede cambiarse mediante el parámetro separators):
párrafo (\n\n) → salto de línea (\n) → palabra ( )
Siempre intenta dividir en la unidad significativa más grande primero. Si un párrafo excede chunk_size, recurre a saltos de línea, luego palabras. Porque siempre encuentra el punto de división más natural en lugar de cortar arbitrariamente en medio de una palabra, los fragmentos resultantes tienen más probabilidades de contener información semánticamente completa.
Parámetros clave
chunk_size: El número máximo de caracteres por fragmento. Por ejemplo,chunk_size=400significa que ningún fragmento excederá 400 caracteres.chunk_overlap: El número de caracteres superpuestos entre fragmentos adyacentes. Por ejemplo,chunk_overlap=80significa que los últimos 80 caracteres de un fragmento se repiten al inicio del siguiente.separators: La lista de separadores usados para dividir el texto, intentados en orden de prioridad. Si dividir en el separador actual excederíachunk_size, se intenta el siguiente separador para evitar excederchunk_size.
¿Qué es la superposición y por qué se necesita?
La superposición significa que los fragmentos adyacentes comparten algo de contenido — el final de un fragmento se incluye al inicio del siguiente.
La razón de esto es asegurar que cada fragmento pueda sostenerse por sí mismo con suficiente contexto. Al leer una pieza de un documento sin ningún conocimiento de lo que vino antes, puede ser difícil entender por qué se menciona cierto contenido. La superposición mantiene el final de un fragmento fluyendo hacia el siguiente, de modo que cualquier fragmento que se recupere, el contenido se lee naturalmente.
Ahora dividamos realmente un documento:
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# Carga el documento
loader = TextLoader("data/docs/refund_policy.md", encoding="utf-8")
docs = loader.load()
# Configura el divisor
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " "],
)
chunks = text_splitter.split_documents(docs)
print(f"Dividido en {len(chunks)} fragmentos\n")
for i, chunk in enumerate(chunks):
print(f"--- Fragmento {i} (fuente: {chunk.metadata['source']}) ---")
print(f"Longitud: {len(chunk.page_content)} caracteres")
print(chunk.page_content[:120])
print()Salida:
Dividido en 4 fragmentos
--- Fragmento 0 (fuente: data/docs/refund_policy.md) ---
Longitud: 374 caracteres
# Política de Reembolso
...
--- Fragmento 1 (fuente: data/docs/refund_policy.md) ---
Longitud: 267 caracteres
## Productos digitales
...
--- Fragmento 2 (fuente: data/docs/refund_policy.md) ---
Longitud: 204 caracteres
## Artículos defectuosos
...
--- Fragmento 3 (fuente: data/docs/refund_policy.md) ---
Longitud: 315 caracteres
## Servicios de suscripción
...Nota: En el ejemplo anterior, no ocurrió superposición. Esto es porque cada párrafo se dividió limpiamente basándose en el primer separador (
\n##) mientras se mantenía dentro delchunk_size. La superposición solo ocurre cuando un párrafo específico es más largo que elchunk_sizey debe dividirse en dos o más piezas.
9.3) Almacenamiento y recuperación de vectores con ChromaDB
9.3.1) ¿Qué es un vector store?
Un vector store (también llamado base de datos vectorial) es una base de datos optimizada para almacenar y buscar datos usando vectores de embedding. A diferencia de una base de datos tradicional donde consultas por valores exactos de campo (SELECT * FROM products WHERE category = 'electronics'), un vector store encuentra los elementos con el significado más similar a tu consulta.
En RAG, el vector store contiene fragmentos de documentos junto con sus embeddings. Cuando un usuario hace una pregunta, la pregunta se convierte en un vector, y el vector store recupera los fragmentos con los vectores más similares.
9.3.2) Elegir un vector store y configurar ChromaDB
Los vector stores populares incluyen ChromaDB, Pinecone, Weaviate y pgvector (extensión de PostgreSQL). Difieren en modelo de alojamiento (local vs. nube), escala y complejidad operacional. Para este libro, usaremos ChromaDB — es de código abierto, se ejecuta completamente en tu máquina local sin configuración de servidor, y es útil no solo para desarrollo sino también para cargas de trabajo de producción pequeñas a medianas.
ChromaDB puede usarse de varias maneras:
- Modo local (pip): Instálalo como una biblioteca de Python y úsalo inmediatamente. Puedes almacenar y cargar datos en un directorio local sin ninguna infraestructura de servidor separada.
- Servidor independiente (Docker): Ejecuta ChromaDB como un proceso de servidor separado. Útil cuando múltiples aplicaciones necesitan compartir el mismo vector store.
- Servicio en la nube administrado (Chroma Cloud): Usa ChromaDB como un servicio en la nube. Chroma Cloud maneja el alojamiento, escalado y mantenimiento, permitiéndote entregar un servicio estable sin carga de gestión de infraestructura.
Instalemos ChromaDB usando pip:
pip install chromadb langchain-chromachromadb es la biblioteca central del vector store, y langchain-chroma es un paquete de integración que te permite usar ChromaDB directamente dentro de la biblioteca LangChain.
9.3.3) Elegir el modelo de embedding
Lo primero que debes decidir es qué modelo de embedding usar. Los vectores de embedding solo pueden compararse cuando son generados por el mismo modelo. Por lo tanto, debes usar el mismo modelo de embedding tanto para almacenar documentos como para consultar.
OpenAI proporciona los siguientes modelos de embedding:
| Modelo | Dimensiones | Notas |
|---|---|---|
text-embedding-3-small | 1536 | Buen equilibrio de calidad y costo |
text-embedding-3-large | 3072 | Mayor calidad, mayor costo |
Para este libro, usaremos el modelo text-embedding-3-small de OpenAI. Ofrece alta eficiencia a bajo costo, haciéndolo una elección práctica para búsqueda general, RAG y proyectos conscientes del costo.
from langchain_openai import OpenAIEmbeddings
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Verifica que funciona
test_vector = embedding_model.embed_query("prueba")
print(f"Dimensiones del embedding: {len(test_vector)}")Salida:
Dimensiones del embedding: 1536Nota de costo: Las llamadas a la API de embedding son mucho más baratas que las llamadas al LLM, pero sí incurren en costos. Al almacenar documentos en la base de datos (indexación), se requiere una llamada a la API por fragmento, y cuando un usuario hace una pregunta (recuperación), se requiere una llamada a la API para la pregunta. Para precios actuales, consulta la página de precios de OpenAI.
9.3.4) Almacenar fragmentos en ChromaDB
Ahora juntemos todo. Cargaremos documentos, los dividiremos en fragmentos, embederemos los fragmentos y los almacenaremos junto con sus vectores de embedding en ChromaDB.
# ingest.py - Pipeline de ingesta completo
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# Paso 1: Cargar documentos
# DirectoryLoader: escanea un directorio y carga archivos coincidentes.
# La carga real se delega al cargador especificado en loader_cls.
loader = DirectoryLoader(
"data/docs/", glob="**/*.md",
loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"Cargados {len(documents)} documentos")
# Paso 2: Dividir en fragmentos
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"Creados {len(chunks)} fragmentos")
# Paso 3: Crear modelo de embedding
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Paso 4: Crear vector store e ingerir fragmentos
vector_store = Chroma.from_documents(
documents=chunks,
embedding=embedding_model,
persist_directory="data/chroma_db",
collection_name="company_docs",
)
print(f"Almacenados {len(chunks)} fragmentos en ChromaDB en data/chroma_db/")Salida:
Cargados 2 documentos
Creados 8 fragmentos
Almacenados 8 fragmentos en ChromaDB en data/chroma_db/El método Chroma.from_documents() realiza dos tareas en una sola llamada:
- Pasa los fragmentos proporcionados mediante el parámetro
documentsa través del modelo de embedding para obtener vectores de embedding. - Almacena cada fragmento junto con su vector de embedding en ChromaDB.
9.3.5) Cargar un vector store persistido
En la sección anterior, almacenamos documentos en el vector store. Esta operación de almacenamiento solo necesita realizarse una vez inicialmente (o cuando cambian los documentos). Después de eso, simplemente puedes cargar el vector store persistido y usarlo directamente.
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# Carga un vector store persistido — no se necesita re-embedding
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
print(f"Cargado vector store con {len(vector_store.get()['ids'])} fragmentos")Salida:
Cargado vector store con 8 fragmentosAhora puedes comenzar a buscar inmediatamente simplemente cargando el vector store persistido, sin necesidad de re-embeder tus documentos.
9.3.6) Búsqueda por similitud
Con el vector store cargado, ahora puedes buscar fragmentos que sean semánticamente similares a una consulta. El parámetro top-K especifica cuántos resultados devolver:
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
# Busca fragmentos relacionados con una pregunta
query = "¿Puedo devolver un producto digital?"
results = vector_store.similarity_search(query, k=2)
print(f"Consulta: {query}")
print(f"Encontrados {len(results)} resultados\n")
for i, doc in enumerate(results):
print(f"--- Resultado {i + 1} (fuente: {doc.metadata['source']}) ---")
print(doc.page_content[:200])
print()Salida:
Consulta: ¿Puedo devolver un producto digital?
Encontrados 2 resultados
--- Resultado 1 (fuente: data/docs/refund_policy.md) ---
## Productos digitales
...
--- Resultado 2 (fuente: data/docs/refund_policy.md) ---
# Política de Reembolso
**Fecha de vigencia**: 1 de enero de 2026
## Devoluciones estándar
...Para esta consulta, el fragmento de productos digitales fue recuperado con la similitud más alta, seguido por el fragmento de política de reembolso.
También puedes recuperar resultados con sus puntuaciones de similitud usando similarity_search_with_score:
results_with_scores = vector_store.similarity_search_with_score(query, k=2)
for doc, score in results_with_scores:
# ChromaDB devuelve distancia (menor = más similar)
print(f"Puntuación: {score:.4f} | Fuente: {doc.metadata['source']}")
print(f" {doc.page_content[:200]}...")
print()Salida:
Puntuación: 0.5942 | Fuente: data/docs/refund_policy.md
## Productos digitales
...
Puntuación: 0.9577 | Fuente: data/docs/refund_policy.md
# Política de Reembolso
...Nota que ChromaDB usa puntuaciones de distancia (menor es más similar), no puntuaciones de similitud (mayor es más similar). El fragmento de productos digitales tiene la distancia más baja de 0.5942, haciéndolo el resultado más relevante.
9.4) Construir la cadena RAG completa
Ahora construiremos un sistema RAG completo: recuperar documentos relevantes primero, luego pasarlos junto con la pregunta al LLM para generar respuestas basadas en la información proporcionada.
9.4.1) Diseñar la plantilla de prompt
La parte más importante de la plantilla de prompt es instruir al LLM para que responda basándose solo en el contexto proporcionado. Sin esta instrucción, el LLM puede ignorar los resultados de búsqueda y fabricar respuestas basadas en sus datos de entrenamiento.
from langchain_core.prompts import ChatPromptTemplate
rag_prompt = ChatPromptTemplate.from_messages([
("system",
"Eres un representante de servicio al cliente. "
"Responde la pregunta del usuario usando SOLO el contexto proporcionado. "
"Si el contexto no contiene suficiente información para responder, "
"di \"No tengo suficiente información para responder esa pregunta.\"\n\n"
"Contexto:\n{context}"),
("human", "{question}"),
])El mensaje del sistema obliga al LLM a responder usando solo el contexto proporcionado. Crucialmente, la instrucción de decir "No tengo suficiente información" cuando el contexto es insuficiente previene que el LLM fabrique respuestas plausibles pero no respaldadas.
9.4.2) Construir la cadena RAG
Ahora tenemos todos los componentes listos. Solo necesitamos conectar el retriever, la plantilla de prompt y el LLM.
El sistema RAG completado funcionará de la siguiente manera:
- Recibir la pregunta del usuario
- Recuperar fragmentos relevantes del vector store
- Pasar los fragmentos y la pregunta a la plantilla de prompt para generar el prompt
- Generar una respuesta con el LLM
Conectemos la cadena RAG usando el operador LCEL | del Capítulo 6.
# rag_chain.py - Pipeline RAG completo
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
def format_docs(docs):
"""Une documentos recuperados en una sola cadena de contexto."""
return "\n\n---\n\n".join(doc.page_content for doc in docs)
def build_rag_chain():
"""Construye y devuelve la cadena RAG completa."""
# Carga el vector store
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
# Crea un retriever (k=3 significa devolver los 3 fragmentos principales)
retriever = vector_store.as_retriever(search_kwargs={"k": 3})
# Define el prompt
rag_prompt = ChatPromptTemplate.from_messages([
("system",
"Eres un representante de servicio al cliente. "
"Responde la pregunta del usuario usando SOLO el contexto proporcionado. "
"Si el contexto no contiene suficiente información para responder, "
"di \"No tengo suficiente información para responder esa pregunta.\"\n\n"
"Contexto:\n{context}"),
("human", "{question}"),
])
# Inicializa el LLM
llm = ChatOpenAI(model="gpt-5-mini")
# Compone la cadena usando LCEL
rag_chain = (
{"context": retriever | format_docs, "question": lambda x: x}
| rag_prompt
| llm
| StrOutputParser()
)
return rag_chain
if __name__ == "__main__":
chain = build_rag_chain()
answer = chain.invoke("¿Puedo devolver un producto digital?")
print(answer)Salida:
Los productos digitales no son reembolsables una vez que se ha activado el enlace de descarga o acceso.
Si experimentas problemas técnicos que impiden el acceso, contacta a soporte dentro de 7 días para un reemplazo o reembolso.Desglosemos la composición de la cadena paso a paso:
rag_chain = (
{"context": retriever | format_docs, "question": lambda x: x}
| rag_prompt
| llm
| StrOutputParser()
)Cuando llamas chain.invoke("¿Puedo devolver un producto digital?"), esto es lo que sucede:
- Paso de diccionario:
retriever | format_docs: Busca en el vector store con la pregunta y combina los fragmentos en una sola cadenalambda x: x: Pasa la pregunta sin cambios- Resultado:
{"context": "fragmentos recuperados (combinados en una sola cadena)", "question": "¿Puedo devolver un producto digital?"}
rag_prompt: Llena los marcadores de posición{context}y{question}en la plantilla de prompt con los valores del diccionariollm: Envía el prompt completado al LLMStrOutputParser(): Extrae solo el texto de la respuesta del LLM
Para más detalles sobre cómo funciona LCEL, consulta el Capítulo 6.
9.4.3) Probar con preguntas respondibles y no respondibles
Un sistema RAG debe manejar tanto preguntas que puede responder (la información existe en los documentos) como preguntas que no puede responder (la información no está en los documentos). Probemos ambos escenarios:
# test_rag.py - Prueba la cadena RAG con varias preguntas
from rag_chain import build_rag_chain
chain = build_rag_chain()
test_questions = [
# Respondibles — la información está en los documentos
"¿Cuál es la política de reembolso para productos físicos?",
"¿Cuánto cuesta el envío exprés?",
"¿Puedo devolver un artículo defectuoso después de 6 meses?",
# No respondibles — la información NO está en los documentos
"¿Cuál es la política de vacaciones de los empleados?",
"¿Qué lenguajes de programación se usan?",
]
for question in test_questions:
print(f"P: {question}")
answer = chain.invoke(question)
print(f"R: {answer}\n")
print("-" * 60)Salida:
P: ¿Cuál es la política de reembolso para productos físicos?
R: Todos los productos físicos pueden devolverse dentro de los 30 días de la compra para un reembolso completo. ...
------------------------------------------------------------
P: ¿Cuánto cuesta el envío exprés?
R: El envío exprés (2–3 días hábiles) cuesta $12.99.
------------------------------------------------------------
P: ¿Puedo devolver un artículo defectuoso después de 6 meses?
R: Sí. Los artículos defectuosos pueden devolverse en cualquier momento para un reembolso completo o reemplazo. ...
------------------------------------------------------------
P: ¿Cuál es la política de vacaciones de los empleados?
R: No tengo suficiente información para responder esa pregunta.
------------------------------------------------------------
P: ¿Qué lenguajes de programación se usan?
R: No tengo suficiente información para responder esa pregunta.
------------------------------------------------------------Los resultados demuestran exactamente el comportamiento que queremos:
- Preguntas respondibles: Proporcionan respuestas precisas basadas en los documentos recuperados. El LLM no añade información que no esté contenida en los documentos.
- Preguntas no respondibles: Responden con "No tengo suficiente información para responder esa pregunta." El LLM identifica correctamente que el contexto recuperado carece de información relevante y se niega a fabricar una respuesta.
Este es el poder de RAG. Tu LLM responde preguntas sobre tus datos y honestamente admite cuando no sabe. Cada respuesta está respaldada por documentos, haciendo que el sistema sea mucho más confiable que un LLM estándar.