RAG: Conceptos básicos y terminología
Este notebook es la introducción a todos los demás recetarios. No implementarás nada aquí, sino que construirás el modelo mental que asume cada recetario posterior. Si ya estás familiarizado con RAG en otros frameworks, siéntete libre de omitir este.
Lo que aprenderás
Al final de este notebook, deberías poder responder, con tus propias palabras:
- ¿Qué problema resuelve RAG y dónde encaja la recuperación en el panorama?
- ¿Qué son los documentos, los chunks, los embeddings y un vector store?
- ¿Qué es la búsqueda híbrida y qué combina RRF?
- ¿Por qué el toolkit expone la ingesta como un
Pipelinecomponible de partes intercambiables? - ¿Cómo orquesta el
QueryEnginela recuperación en el momento de la consulta?
Configuración
Las dos celdas de código en este notebook (demostración de embeddings + demostración de RRF) solo necesitan que se instale este espacio de trabajo del recetario:
cd search/cookbooks
uv sync
La demostración de embeddings también necesita un MISTRAL_API_KEY en tu entorno (un archivo .env en la raíz del recetario se carga automáticamente).
1. ¿Por qué RAG? El problema que resuelve la recuperación
Los modelos de lenguaje grandes son excelentes para razonar sobre texto, pero tienen dos debilidades persistentes:
- No conocen tus documentos (documentos internos, contratos, PDFs científicos, tickets de clientes…).
- Sus datos de entrenamiento tienen una fecha de corte y, para mantener datos en tiempo real, necesitas alimentarlos con datos en tiempo real.
La Generación Aumentada por Recuperación (RAG) tiene dos componentes de IA:
- Un retriever encuentra los pocos pasajes de tu corpus que son más relevantes para la pregunta de un usuario.
- A un LLM se le dan esos pasajes como contexto y se le pide que responda usándolos.
El LLM no alucina de la nada porque ahora tiene evidencia fundamentada, y puedes obtener conocimiento actualizado simplemente reingiriendo documentos u obteniéndolos de fuentes en vivo.
2. Dos pipelines, un almacén compartido
Para buscar documentos, primero necesitas indexarlos en el vector store. Hay dos pipelines, y se encuentran en el medio en el vector store.
| Pipeline | Cuándo se ejecuta | Entrada | Salida |
|---|---|---|---|
| Ingesta | Offline / por lotes, cuando los documentos cambian | Archivos sin procesar (PDFs, etc.) | Chunks indexados en Vespa |
| Búsqueda | Online, en cada consulta de usuario | Una cadena de consulta | Una lista clasificada de chunks |
La ingesta es la mitad lenta, costosa y de mucha escritura (OCR, embedding, indexación). La búsqueda es la mitad rápida y de mucha lectura (incrustar la consulta, búsqueda híbrida, devolver los k principales). No comparten nada en tiempo de ejecución excepto el vector store.
El toolkit refleja esta división:
- La ingesta se construye alrededor de la clase
Pipeline(loader → extractor → splitter → embedder → store). - La búsqueda se construye alrededor de la clase
QueryEngine(retriever(s) → rewriter/reranker opcional).
3. La ingesta de un vistazo — el Pipeline
Cada script de ingesta en este repositorio tiene el mismo aspecto. Aquí está, tomado directamente de 01-quickstart/ingest.py:
pipeline = Pipeline(
loader=FilesystemFileLoader(),
extractor=MistralOCRExtractor(client=mistral_client),
text_splitter=MarkdownTextSplitter(MarkdownTextSplitterConfig(chunk_size=5048, chunk_overlap=50)),
embedder=MistralEmbedder(client=mistral_client),
stores=vector_store,
)
await pipeline.run(documents=[Path("my.pdf")], use_checkpoint=False)
El Pipeline es solo un orquestador que conecta cinco ranuras intercambiables. Cada ranura tiene un trabajo:
| Ranura | Trabajo | Ejemplo de 01-quickstart |
|---|---|---|
loader |
¿Dónde están los archivos? Obtener el archivo sin procesar. | FilesystemFileLoader |
extractor |
Convertir archivos en texto limpio. | MistralOCRExtractor (compatible con OCR, devuelve una Document de páginas markdown) |
text_splitter |
Dividir el texto en chunks lo suficientemente pequeños para incrustar y lo suficientemente precisos para recuperar. | MarkdownTextSplitter (respeta la estructura markdown, superposición de 50 caracteres entre chunks) |
embedder |
Convertir cada chunk en un vector de longitud fija. | MistralEmbedder (a través de la API de embeddings de Mistral) |
stores |
Dónde escribir los chunks indexados. | VespaSearchIndex |
Cada recetario posterior es una pequeña variación de esta forma:
02-advanced-indexing/ingest.pyañade un directorio de checkpoint y un callback de progreso.ingest_from_s3.pyintercambiaFilesystemFileLoaderporFileLoader(S3BlobStorage(...)).ingest_with_metadata.pypasa un argumentochunk_enrichers=[CustomMetadataEnricher()]adicional.ingest_with_summaries.pypasachunk_enrichers=[SummaryEnricher(...)].
4. Embeddings — convirtiendo texto en geometría
Un embedding es una lista de floats de longitud fija (generalmente 1024 números) que representa el significado de un chunk como un punto en un espacio de alta dimensión. Toda la magia de la búsqueda vectorial reside en una propiedad:
Los textos con significado similar aterrizan cerca en este espacio, y lo hacen basándose en el contexto en el que aparecen las palabras, no en las palabras mismas.
Esa única propiedad hace dos cosas a la vez:
- Dos pasajes con palabras diferentes pero el mismo significado terminan cerca — por ejemplo, "una pitón bola enrollada alrededor de una rama de árbol" y "la serpiente constrictora se envolvió alrededor de la percha" casi no comparten vocabulario pero son vecinos cercanos en el espacio de embeddings. Esto es lo que te permite encontrar chunks relevantes incluso cuando la fraseología del usuario no coincide con el texto indexado.
- Dos pasajes con la misma palabra pero diferentes significados terminan muy separados — "una pitón bola enrollada alrededor de una rama" y "un script de Python que raspa una API" comparten el token superficial
pythonpero viven en regiones completamente diferentes del espacio, porque el contexto circundante le dice al modelo que estos son conceptos no relacionados.
El mismo modelo de embedding se usa en el momento de la ingesta (para incrustar cada chunk antes de almacenarlo) y en el momento de la consulta (para incrustar la consulta del usuario). Esa simetría es lo que hace que la geometría sea comparable en ambos lados.
La siguiente celda de código llama directamente a la API de embedding de Mistral en un pequeño corpus que mezcla los dos sentidos de la palabra "python", luego emite dos consultas que extraen dos subconjuntos diferentes del mismo corpus.
"""Same word, two meanings. what 'context' really means for embeddings.
We call the Mistral embedding API directly (no toolkit wrapper) on a small
corpus where the token ``python`` appears in every entry, but with two
completely different meanings: half the sentences are about the snake, the
other half about the programming language. We then issue two queries — one
biological, one software-engineering — and rank the corpus by cosine
similarity to each. The two queries pull out *different* subsets of the
corpus, even though the literal word overlap is identical.
"""
import os
import numpy as np
from dotenv import load_dotenv
from mistralai.client import Mistral
load_dotenv()
client = Mistral(
api_key=os.environ["MISTRAL_API_KEY"],
server_url=os.getenv("MISTRAL_API_URL"),
)
def get_text_embedding(text: str) -> list[float]:
response = client.embeddings.create(model="mistral-embed", inputs=text)
return response.data[0].embedding
corpus = [
# python the snake
"Pythons are large, non-venomous constrictor snakes native to tropical Africa and Southeast Asia.",
"A ball python coiled itself around a low branch deep in the rainforest.",
"Reticulated pythons can grow over six metres long and are among the largest reptiles on Earth.",
# python the programming language
"Python is one of the most popular programming languages for data science and machine learning.",
"We wrote a small Python script that scrapes the API and dumps the results to a CSV file.",
"Django and FastAPI are mature Python web frameworks used in production by major companies.",
]
corpus_labels = ["snake"] * 3 + ["language"] * 3
queries = ["snakes in the tropical rainforest", "scripting language for data analysis"]
corpus_vectors = np.array([get_text_embedding(text) for text in corpus])
query_vectors = np.array([get_text_embedding(q) for q in queries])
def cosine(a: np.ndarray, b: np.ndarray) -> float:
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
for query, query_vec in zip(queries, query_vectors):
print(f"Query: {query!r}")
ranked = sorted(zip(corpus, corpus_vectors), key=lambda kv: -cosine(query_vec, kv[1]))
for text, vec in ranked:
print(f" {cosine(query_vec, vec):+.3f} {text}")
print()
Query: 'snakes in the tropical rainforest'
+0.745 A ball python coiled itself around a low branch deep in the rainforest.
+0.703 Pythons are large, non-venomous constrictor snakes native to tropical Africa and Southeast Asia.
+0.659 Reticulated pythons can grow over six metres long and are among the largest reptiles on Earth.
+0.557 Python is one of the most popular programming languages for data science and machine learning.
+0.548 Django and FastAPI are mature Python web frameworks used in production by major companies.
+0.529 We wrote a small Python script that scrapes the API and dumps the results to a CSV file.
Query: 'scripting language for data analysis'
+0.744 Python is one of the most popular programming languages for data science and machine learning.
+0.691 We wrote a small Python script that scrapes the API and dumps the results to a CSV file.
+0.661 Django and FastAPI are mature Python web frameworks used in production by major companies.
+0.584 Pythons are large, non-venomous constrictor snakes native to tropical Africa and Southeast Asia.
+0.563 A ball python coiled itself around a low branch deep in the rainforest.
+0.560 Reticulated pythons can grow over six metres long and are among the largest reptiles on Earth.
Lo que acaba de pasar. Cada línea en el corpus contiene la palabra python, por lo que un retriever de palabras clave ingenuo (BM25) las clasificaría todas idénticamente para cualquiera de las consultas. Pero el modelo de embedding codifica el contexto circundante — enrollada alrededor de una rama, selva tropical, Django, script, archivo CSV — y las dos consultas aterrizan en dos vecindarios diferentes del espacio vectorial:
"snakes in the tropical rainforest"lleva las tres oraciones de reptiles a la cima."scripting language for data analysis"lleva las tres oraciones de programación a la cima.
Esa es la propiedad en la que se basa el resto del toolkit: en el momento de la recuperación, los embeddings convierten "encontrar pasajes sobre X" en "encontrar los vectores más cercanos al embedding de X", donde "sobre X" incluye contexto que las palabras literales no capturan.
5. El vector store y la búsqueda híbrida
El vector store es la base de datos que contiene cada chunk + su embedding y responde a las consultas de recuperación. Estos recetarios usan Vespa, lo cual es interesante precisamente porque no es solo una base de datos vectorial: para cada chunk almacena
- el texto del chunk — indexado para la búsqueda por palabras clave BM25 (la puntuación de relevancia clásica tf-idf),
- el vector de embedding del chunk — indexado para la búsqueda de vecinos más cercanos aproximados (ANN),
- los metadatos del chunk — para filtrar en el momento de la consulta (
filename,page_number, campos personalizados que adjuntas a través de unChunkEnricher).
Esto es lo que hace posible la búsqueda híbrida en una sola consulta: cada chunk se compara tanto por palabra clave como por similitud semántica, y las dos listas clasificadas se fusionan en una sola.
¿Por qué molestarse con ambos?
Cada señal falla de manera diferente:
- BM25 gana cuando el usuario escribe un término exacto que solo importa en unos pocos documentos — códigos de producto, mensajes de error, entidades nombradas, acrónimos. Los embeddings tienden a suavizarlos mapeándolos cerca de vecinos genéricos.
- La búsqueda vectorial gana cuando el usuario parafrasea o pregunta a un nivel de abstracción superior — "tratamientos para el cáncer" que coinciden con un chunk que solo contiene "quimioterapia" y "radioterapia" pero nunca la palabra "cáncer".
Realmente quieres ambos. La pregunta es: ¿cómo combinas dos listas clasificadas con escalas de puntuación completamente diferentes (BM25 produce números positivos ilimitados, la similitud coseno está en [-1, 1])?
La respuesta es Reciprocal Rank Fusion (RRF): olvida las puntuaciones brutas, mira solo el rango de cada chunk en cada lista y usa una fórmula simple.
$$ \text{RRF}(d) = \sum_{r \in \text{retrievers}} \frac{1}{k + \text{rank}_r(d)} $$
con k típicamente 60. Un chunk obtiene una puntuación alta al estar cerca de la cima de al menos una de las listas. Si está cerca de la cima de ambas, aún mejor.
6. El lado de la búsqueda — QueryEngine y retrievers
Una vez que los documentos están indexados, el lado de la búsqueda es mucho más pequeño. El toolkit expone un orquestador, QueryEngine, que toma una lista de retrievers y (opcionalmente) componentes de postprocesamiento como rewriters y rerankers.
La configuración más simple posible — la de 01-quickstart/search.py — es un retriever, sin rewriter, sin reranker:
query_engine = QueryEngine(
retriever=[VectorRetriever(client=vector_store, embedder=embedder)],
)
result = await query_engine.search(query="What is the main topic?", top_k=5)
VectorRetriever emite una consulta híbrida (BM25 + ANN + RRF), no una consulta vectorial pura. El nombre es histórico; trátalo como "el retriever estándar".
03-advanced-search enriquece este pipeline en tiempo de consulta con tres clases que encontrarás allí:
LLMQueryRewriter— reformula la pregunta del usuario antes de la recuperación.LLMQueryExtension— genera varias subconsultas y fusiona sus resultados.LLMReRanker— reordena los candidatos top-k pidiendo a un LLM que los puntúe.
7. Glosario — las palabras que verás en todas partes
| Término | Definición en una línea | Primer recetario que lo usa |
|---|---|---|
Document |
El objeto de nivel superior del toolkit después de la extracción; lleva metadatos y una lista de pages. |
01-quickstart |
DocumentChunk |
Una pequeña porción de un Document producido por el splitter; la unidad de recuperación. |
01-quickstart |
| Embedding | Un vector de float de longitud fija que representa el significado de un chunk o consulta. | 01-quickstart |
| BM25 | Puntuación de relevancia clásica basada en palabras clave; excelente para términos exactos. | 01-quickstart (bajo híbrido) |
| ANN | Búsqueda de Vecinos Más Cercanos Aproximados sobre embeddings; la mitad "vectorial" de híbrido. | 01-quickstart (bajo híbrido) |
| Hybrid search | Una sola consulta que combina BM25 + ANN a través de RRF. | 01-quickstart |
| RRF | Reciprocal Rank Fusion; la fórmula que fusiona dos listas clasificadas en una. | 01-quickstart (bajo híbrido) |
Pipeline |
El orquestador de ingesta: loader → extractor → splitter → embedder → store. | 01-quickstart |
QueryEngine |
El orquestador de búsqueda: retrievers (+ rewriter/reranker opcional). | 01-quickstart |
ChunkEnricher |
Un hook que adjunta metadatos personalizados a cada chunk antes de incrustar. | 02-advanced-indexing |
| Checkpointing | Registrar qué documentos ya están indexados para que un fallo no los reingiera. | 02-advanced-indexing |
LLMQueryRewriter |
Reformula la pregunta del usuario con un LLM antes de la recuperación. | 03-advanced-search |
LLMQueryExtension |
Descompone una consulta en N subconsultas y fusiona sus resultados. | 03-advanced-search |
LLMReRanker |
Reordena los resultados top-k pidiendo a un LLM que los puntúe. | 03-advanced-search |
| Match phase / Ranking phase | Recuperación en dos etapas de Vespa: selección de candidatos vs. ordenación. | 04-evaluation |
| Precision@k / Recall@k / F1@k | Métricas estándar de calidad de recuperación, calculadas contra un conjunto de datos de verdad fundamental. | 04-evaluation |
8. Dónde ir después
Ahora tienes el modelo mental. Ejecuta los siguientes recetarios en orden.