Imagina que es tu primer día en una empresa nueva. Te dan acceso a ChatGPT corporativo y lo primero que haces, como cualquiera, es soltarle las dudas típicas de onboarding: “¿cuántos días de vacaciones tengo por año?”, “¿cuál es la política de home office?”, “¿cómo pido un reembolso?”. Y ya sabes lo que va a pasar: el modelo responde algo genérico sobre políticas laborales en el mundo, o peor, inventa un número que suena razonable pero no tiene nada que ver con tu empresa.
El problema no es que el modelo sea deficiente. El problema es que nadie le mostró los documentos internos: ese ChatGPT nunca vio tu wiki, tus PDFs de políticas ni tus guías de onboarding. LLMs como Claude o GPT fueron entrenados con información pública general, y no tiene sentido re-entrenarlos cada vez que alguien actualiza una política, ni es viable para información que cambia con frecuencia.
RAG resuelve exactamente este escenario. Antes de responder, el sistema busca los fragmentos de texto más relevantes en tu propia base de conocimiento y los inyecta en el prompt. El LLM ya no responde con lo que cree recordar del entrenamiento, sino apoyándose en documentos que sí son tuyos.
¿Qué es RAG?
RAG son las siglas de Retrieval-Augmented Generation. Cada palabra importa:
Retrieval: recuperar los fragmentos de texto más relevantes de una base de conocimiento propia.
Augmented: aumentar el prompt del LLM con ese contexto recuperado.
Generation: el LLM genera la respuesta basándose en ese contexto aumentado, no solo en su entrenamiento.
La idea central es simple: en lugar de pedirle al modelo que recuerde información que nunca aprendió, le damos la información en el momento de la pregunta.
El pipeline RAG
El pipeline RAG opera en dos fases completamente separadas. La ingesta corre una vez por documento y construye el índice de búsqueda. El query corre en tiempo real con cada pregunta y usa ese índice para responder con contexto real.
Fase de ingesta (offline)
La ingesta convierte documentos crudos en vectores buscables y los guarda en el Vector Store. Corre una vez por documento y sus resultados persisten hasta la próxima actualización.
Ingesta: de documentos a vectores
Fase de query (online)
Con cada pregunta del usuario, el pipeline usa el índice construido durante la ingesta para recuperar los fragmentos más relevantes y pasárselos al LLM como contexto.
Query: de pregunta a respuesta con contexto
Pregunta: el usuario escribe su consulta en lenguaje natural.
Embedding: la pregunta pasa por el mismo modelo de embeddings que se usó en la ingesta. Es clave que sea el mismo: ambos vectores deben vivir en el mismo espacio dimensional para que la comparación tenga sentido.
Búsqueda: el vector de la pregunta se compara contra el Vector Store con similitud coseno y se recuperan los k fragmentos más cercanos semánticamente.
Chunks: esos fragmentos traen el contexto que el LLM necesita para responder con precisión.
Prompt aumentado: la pregunta original se combina con los chunks recuperados en un prompt estructurado: primero el contexto, al final la pregunta.
LLM: el modelo recibe ese prompt y genera la respuesta apoyándose en el contexto inyectado.
Respuesta: una respuesta basada en los documentos de la empresa, no en suposiciones generales del entrenamiento.
Embeddings: el idioma de los vectores
Para buscar texto de forma semántica, primero hay que convertirlo a un formato que las computadoras puedan comparar matemáticamente. Ahí entran los embeddings
embeddings
Representaciones numéricas de texto como vectores en un espacio de alta dimensionalidad. Texto con significado similar produce vectores cercanos en ese espacio, aunque no compartan palabras exactas.
.
Un modelo de embeddings toma un fragmento de texto y lo convierte en un vector de cientos o miles de números. Lo importante no es qué significan esos números individualmente, sino cómo se relacionan entre sí: textos con significado similar producen vectores que apuntan en la misma dirección dentro de ese espacio de muchas dimensiones.
Por ejemplo, si le das a un modelo de embeddings los textos “política de vacaciones pagas” y “días de descanso remunerados”, vas a obtener dos vectores muy cercanos entre sí, aunque no compartan ninguna palabra. Esa es la potencia de los embeddings: capturan semántica, no solo sintaxis.
Similitud coseno y búsqueda vectorial
Una vez que tienes todo convertido a vectores (documentos y pregunta), el sistema necesita encontrar cuáles documentos son más relevantes. La métrica estándar es la similitud coseno: en lugar de medir la distancia entre dos vectores, mide el ángulo entre ellos. Un ángulo pequeño (vectores que apuntan en la misma dirección) indica alta similitud. La fórmula produce un valor entre -1 y 1, donde 1 es idéntico y 0 es sin relación.
Similitud coseno: vectores similares apuntan en la misma dirección
La búsqueda vectorial básica calcula la similitud coseno entre la pregunta y todos los documentos indexados y devuelve los k más similares. Esto funciona bien para miles de documentos, pero a millones de vectores, comparar uno a uno se vuelve prohibitivamente lento. Ahí entran los algoritmos Approximate Nearest Neighbor (ANN).
FAISS y los índices de búsqueda vectorial
La librería de referencia para este problema es FAISS
FAISS
Facebook AI Similarity Search. Librería de Meta para búsqueda eficiente en espacios de alta dimensionalidad. Incluye índices que encuentran vectores aproximadamente cercanos en una fracción del tiempo que tomaría la búsqueda exacta.
. Si ya exploraste los Two-Tower Models para sistemas de recomendación, ya la conociste en acción: es la que hace posible el serving vectorial a escala en ese contexto. En RAG el rol es el mismo, encontrar los vectores más similares entre miles o millones de chunks sin que la latencia se dispare.
La clave de FAISS no es la búsqueda exacta sino la aproximada: en lugar de comparar la query contra todos los vectores del índice, usa estructuras de datos que devuelven los vecinos aproximadamente más cercanos a una fracción del costo. Esa pequeña pérdida de precisión se traduce en órdenes de magnitud menos de latencia, que es justo el trade-off que queremos cuando el índice tiene millones de entradas.
Dentro de FAISS, los dos índices más usados son IVF y HNSW. Cada uno organiza el espacio vectorial de una manera distinta:
IVF e HNSW: dos formas de organizar vectores para búsqueda rápida
IVF (Inverted File Index): divide el espacio vectorial en clusters (celdas de Voronoi). Al buscar, primero identifica los clusters más cercanos a la query y solo compara contra los vectores dentro de esos clusters. Esto reduce drásticamente el número de comparaciones, con una pequeña pérdida de precisión.
HNSW (Hierarchical Navigable Small World): construye un grafo de múltiples capas. Las capas superiores tienen pocos nodos muy conectados (cobertura global, navegación rápida). Las capas inferiores tienen todos los nodos con conexiones locales (precisión alta). La búsqueda empieza en la capa superior y desciende refinando el resultado. Es más preciso que IVF en muchos casos, pero usa más memoria.
FAISS vs pgvector
FAISS vive completamente en memoria, no tiene persistencia nativa y requiere serialización manual para guardar los índices. pgvector es una extensión de PostgreSQL: los vectores viven junto con el resto de tus datos, con transacciones ACID y SQL estándar.
Característica
FAISS
pgvector
Dónde vive
En memoria (RAM)
PostgreSQL
Velocidad
Extremadamente rápido (ANN optimizado)
Rápido para volúmenes medianos
Escala
Millones a miles de millones
Hasta ~1-2M cómodamente
Persistencia
Manual (serializar a disco)
Nativa (PostgreSQL)
Transacciones
No
Sí (ACID)
Infraestructura extra
No
Necesita PostgreSQL
Ideal para
Búsqueda masiva, latencia crítica
Apps con PostgreSQL existente
Para la mayoría de casos reales (knowledge bases de empresas, wikis internas, soporte al cliente), pgvector es suficiente y mucho más simple operacionalmente. FAISS entra cuando hay decenas de millones de documentos y la latencia de búsqueda es crítica.
RAG vs MCP: no son lo mismo
Con el auge de MCP
MCP
Model Context Protocol. Protocolo abierto de Anthropic que permite a los LLMs conectarse a herramientas externas (APIs, bases de datos, sistemas de archivos) y decidir cuándo y cómo llamarlas durante una conversación.
aparece la pregunta inevitable: ¿cuál es la diferencia con RAG? Superficialmente, ambos sirven para darle más información al LLM. Pero funcionan de formas fundamentalmente distintas.
En RAG, el pipeline recupera contexto automáticamente antes de que el LLM vea la pregunta. El modelo no sabe que hay retrieval: solo recibe un prompt que ya incluye los fragmentos relevantes. La decisión de qué buscar la toma el sistema (basándose en similitud coseno), no el modelo.
En MCP, el LLM tiene acceso a herramientas y decide él mismo cuándo llamarlas y con qué parámetros. Es un modelo agéntico: tiene agencia sobre qué información buscar, cuándo buscarla y cómo interpretar el resultado.
RAG vs MCP: quién decide qué buscar
Lo más interesante es que son complementarios. Un sistema RAG puede exponerse como una tool MCP. Cuando el LLM lo considera necesario, invoca esa tool para recuperar contexto documental. Eso es una arquitectura agéntica con memoria: el modelo decide cuándo buscar y el RAG sabe cómo encontrarlo.
La teoría se entiende leyendo, pero un pipeline RAG se entiende corriéndolo. Por eso el código de esta segunda mitad no vive en el artículo: vive en rag-starter, un repositorio que puedes levantar en dos comandos y apuntar a tus propios documentos.
Levantarlo completo
bash
git clone https://github.com/jesaavedrav/rag-starter
cd rag-starter
cp .env.example .env # your OpenAI and Anthropic keys
docker compose up --build
docker compose exec api python -m rag.ingest
Abre http://localhost:8000 y pregunta lo que quieras sobre los documentos de ejemplo.
El stack es el mismo del que veníamos hablando: PostgreSQL con pgvector como Vector Store, LiteLLM para mezclar proveedores (los embeddings salen de OpenAI porque Claude no expone un endpoint de embeddings, y la generación de Claude), y FastAPI para exponerlo. Lo que sigue son las tres decisiones del repo que vale la pena explicar.
Un loader por formato, con Strategy
Un RAG real no come solo markdown. Come PDFs escaneados, wikis exportadas y notas sueltas. El problema es que cada formato no produce solo texto distinto: produce documentos con forma distinta. Un PDF tiene páginas y quieres poder citar “política de vacaciones, página 2”. Un markdown tiene encabezados y quieres cortar por sección. Un txt no tiene ninguna estructura.
Por eso el repo usa Strategy: un contrato mínimo que cada formato implementa a su manera, y un registro que elige el primero que reconoce el archivo.
python
class DocumentLoader(Protocol):
def supports(self, path: Path) -> bool: ...
def load(self, path: Path) -> Iterator[Document]: ...
DEFAULT_LOADERS: list[DocumentLoader] = [PdfLoader(), MarkdownLoader(), TextLoader()]
def loader_for(path: Path, loaders: list[DocumentLoader]) -> DocumentLoader:
for loader in loaders:
if loader.supports(path):
return loader
raise UnsupportedDocument(path)
El loader de PDF emite un documento por página, y ese número de página viaja con el chunk hasta la respuesta final:
python
class PdfLoader:
"""One document per page, so an answer can cite the page it came from."""
def supports(self, path: Path) -> bool:
return path.suffix.lower() == ".pdf"
def load(self, path: Path) -> Iterator[Document]:
reader = PdfReader(str(path))
for number, page in enumerate(reader.pages, start=1):
text = (page.extract_text() or "").strip()
if not text:
continue # Image-only pages carry nothing to embed.
yield Document(text=text, source=path.name, title=path.stem, page=number)
Cuándo Strategy es humo
Si tus tres formatos se leen igual y solo cambia cómo abres el archivo, un diccionario{'.pdf': load_pdf, '.md': load_md} alcanza y sobra. Strategy se gana su lugar cuando cada implementación tiene reglas propias de verdad, como aquí: paginar, cortar por encabezado, o no hacer nada. Meter un patrón porque hay tres casos es agregar indirección sin comprar nada.
Chunking: cuenta tokens, no caracteres
Aquí hay una trampa en la que caí yo mismo en la primera versión de este artículo. Todo el mundo recomienda chunks de 200 a 500 tokens, y después escribe un chunker que corta por caracteres. No es lo mismo: 250 caracteres son unos 60 tokens, cuatro veces menos de lo recomendado. Y si además divides por párrafos dobles, un documento escrito con saltos de línea simples no se divide nunca.
La solución es contar con el tokenizador real del modelo de embeddings, y degradar el corte en cascada: párrafos primero, oraciones si un párrafo no cabe, y tokens crudos si una oración sola es más larga que el presupuesto.
python
limit = max_tokens - overlap
pieces: list[str] = []
for paragraph in (p.strip() for p in _PARAGRAPH.split(text) if p.strip()):
pieces.extend(_fit(paragraph, encoding, limit))
chunks: list[str] = []
current: list[int] = []
for piece in pieces:
tokens = encoding.encode(piece if not current else " " + piece)
if current and len(current) + len(tokens) > max_tokens:
chunks.append(encoding.decode(current).strip())
current = current[-overlap:] if overlap else [] # carry the overlap
tokens = encoding.encode(piece)
current.extend(tokens)
El overlap es lo que evita cortar una idea a la mitad: cada chunk arranca repitiendo el final del anterior. Y como cada pieza se limita a max_tokens - overlap, ningún chunk puede pasarse del presupuesto por construcción. El repo tiene tests que verifican justo eso, porque este es exactamente el tipo de código que se rompe en silencio.
El índice que hace falta de verdad
Explicamos IVF y HNSW hace unos párrafos, así que sería raro no usarlos. Crear la tabla sin índice funciona en el tutorial y es una trampa en producción: cada consulta recorre la tabla entera comparando vectores uno por uno.
sql
CREATE TABLE IF NOT EXISTS chunks (
id BIGSERIAL PRIMARY KEY,
source TEXT NOT NULL,
title TEXT NOT NULL,
page INT,
content TEXT NOT NULL,
embedding vector(1536) NOT NULL
);
CREATE INDEX IF NOT EXISTS chunks_embedding_idx
ON chunks USING hnsw (embedding vector_cosine_ops);
vector_cosine_ops tiene que coincidir con el operador que usas al buscar. Si indexas con coseno y después ordenas por distancia euclidiana, PostgreSQL ignora el índice sin avisarte y vuelve al scan secuencial.
sql
SELECT title, source, page, content,
1 - (embedding <=> %s) AS similarity
FROM chunks
ORDER BY embedding <=> %s
LIMIT %s;
El operador <=> es la distancia coseno de pgvector: menor es más parecido. Por eso 1 - distancia da la similitud, donde 1 es idéntico y 0 es sin relación.
La respuesta, con sus fuentes
El endpoint devuelve la respuesta junto con los chunks que la sustentan, cada uno con su documento, su página y su score. La interfaz web los muestra debajo de la respuesta, y eso es deliberado: ver qué recuperó el sistema es la mejor forma de entender por qué contestó lo que contestó, y la primera herramienta cuando contesta mal.
Esto es la salida real de preguntarle “How many vacation days do I get per year?” a los documentos de ejemplo, con el campo content recortado para que entre:
json
{
"answer": "According to the TechCorp Vacation Policy (page 1), TechCorp grants **15 paid vacation days per year** to all full-time employees.",
"sources": [
{
"title": "vacation-policy",
"source": "vacation-policy.pdf",
"page": 1,
"content": "TechCorp Vacation Policy\nTechCorp grants 15 paid vacation days per year to all full-time employees...",
"similarity": 0.559
},
{
"title": "vacation-policy",
"source": "vacation-policy.pdf",
"page": 2,
"content": "Requesting Time Off\nRequests must be submitted at least 2 weeks in advance...",
"similarity": 0.474
},
{
"title": "expense-policy",
"source": "expense-policy.pdf",
"page": 1,
"content": "TechCorp Expense Policy\nTechCorp reimburses pre-approved business expenses only...",
"similarity": 0.249
},
{
"title": "expense-policy",
"source": "expense-policy.pdf",
"page": 2,
"content": "Submitting Expenses\nEquipment purchases above 500 dollars require pre-approval...",
"similarity": 0.237
}
]
}
Hay algo acá que solo se ve corriéndolo. top_k vale 4, así que el sistema devuelve cuatro chunks siempre, existan o no cuatro chunks relevantes. Los dos de la política de gastos no tienen nada que ver con vacaciones y aparecen igual, con scores de 0.25 contra el 0.56 del chunk correcto. El retrieval no decide qué es relevante: ordena por parecido y entrega los primeros de la fila. Quien distingue es el score, y por eso vale la pena mostrarlo en vez de esconderlo.
Limitaciones a tener en cuenta
El tamaño del chunk importa: chunks muy grandes diluyen la señal semántica, chunks muy pequeños pierden contexto. Entre 200 y 500 tokens es un buen punto de partida, y ahora sí son tokens.
RAG no es magia: si el documento no tiene la respuesta, el modelo puede inventarla igual. Usa temperature=0 e instrucciones explícitas de responder solo con el contexto.
Escala de pgvector: funciona bien hasta uno o dos millones de vectores. Para más, considera FAISS, Qdrant o Pinecone.
Mismo modelo de embeddings siempre: el mismo para indexar y para consultar. Mezclarlos produce resultados incoherentes, porque los vectores viven en espacios distintos.
Esto no está listo para producción: falta evaluar la calidad del retrieval, falta búsqueda híbrida para códigos y siglas, y falta filtrar por permisos. En un asistente corporativo real, no todo el mundo puede ver todos los documentos.
Dónde se va realmente el dinero en un sistema agéntico y seis palancas para recuperarlo: prompt caching y sus trampas, enrutamiento por modelo, poda de contexto e instrumentación.
Cada vez que ajustábamos el tono del chat de servicio al cliente se rompía otro flujo, y QA tardaba semanas en revisar 300 casos a mano. Por qué los tests no alcanzan con un LLM, qué evaluar en un chat que usa tools y cómo esos mismos 300 casos pasaron a correr en el CI en unos 6 minutos.
Una crítica a la moda de los agentes: la escalera de complejidad desde un prompt hasta un agente, los cuatro costos ocultos, y cuándo la autonomía sí es la respuesta correcta.