RAG para agentes
Pipeline completo de RAG: estrategias de chunking, embeddings, recuperación híbrida, re-ranking, patrones avanzados (iterativo, self-RAG, RAG correctivo, RAG agéntico). Evaluación con recall y fidelidad. RAG estándar frente a RAG agéntico.
01Objetivos de aprendizaje
Al finalizar esta clase, los estudiantes serán capaces de:
- Explicar la motivación principal detrás de la Generación Aumentada por Recuperación (RAG) y sus ventajas frente a depender únicamente del conocimiento paramétrico.
- Diseñar e implementar un pipeline de indexación qué cubra el chunking de documentos, la generación de embeddings y el almacenamiento vectorial.
- Comparar estrategias de recuperación densa, dispersa e híbrida, y seleccionar la adecuada para un caso de uso dado.
- Describir técnicas avanzadas de RAG, incluyendo re-ranking, transformación de consultas y recuperación en múltiples pasos.
- Distinguir entre RAG estándar y RAG agéntico, donde el agente decide de forma autónoma cuándo y qué recuperar.
- Evaluar sistemas RAG utilizando métricas apropiadas de fidelidad, relevancia y completitud.
- Construir un pipeline RAG funcional en Python usando embeddings y un almacén vectorial.
021. Fundamentos de RAG
El problema que RAG resuelve
Los Modelos de Lenguaje de Gran Escala tienen una limitación fundamental: su conocimiento está congelado en el momento del entrenamiento. Un LLM entrenado con datos hasta enero de 2024 no sabe nada sobre eventos posteriores a esa fecha. Además, los LLM no tienen acceso a datos privados, propietarios o específicos de un dominio, a menos que estos estuvieran en el corpus de entrenamiento.
Consideremos una analogía. Un LLM es como una persona que leyó millones de libros hace años y recuerda la mayor parte de lo que leyó, pero no ha leído nada desde entonces. Puede discutir sobre historia, ciencia y literatura con una fluidez impresionante, pero no puede decirte que ocurrió ayer, que dicen las políticas internas de tu empresa ni que contiéné el documento que escribiste la semana pasada. Su conocimiento es vasto pero estático.
Esto genera varios problemas concretos:
- Alucinación: Cuando se les pregunta sobre información que no está en sus datos de entrenamiento, los LLM pueden generar respuestas que suenan plausibles pero son incorrectas, en lugar de admitir su desconocimiento. Si se le pregunta a un modelo sobre un artículo publicado después de su fecha de corte de entrenamiento, puede fabricar una cita convincente pero completamente ficticia.
- Obsolescencia: La información cambia con el tiempo. Los datos de entrenamiento se vuelven obsoletos. Un modelo entrenado en 2023 no conoce los cambios en APIs, nuevas regulaciones o cambios de liderazgo ocurridos en 2024.
- Falta de procedencia: Los LLM no pueden citar fuentes para sus afirmaciones porque no "saben" de dónde proviene su conocimiento. El conocimiento está difuso entre miles de millones de parámetros, sin trazabilidad hacia documentos específicos.
- Sin acceso a datos privados: Los documentos empresariales, notas personales y bases de datos propietarias no están disponibles para el modelo. Esta es la limitación más crítica para aplicaciones empresariales.
Idea clave: El problema fundamental que RAG resuelve no es que los LLM sean poco inteligentes, sino que están desinformados sobre cualquier cosa fuera de sus datos de entrenamiento. RAG otorga a los LLM la capacidad de consultar información, transformándolos de examinados a libro cerrado en investigadores a libro abierto.
La solución RAG
La Generación Aumentada por Recuperación, introducida por Lewis et al. (2020), aborda estas limitaciones combinando un componente de recuperación con uno de generación. La idea es elegantemente simple:
- Recuperar: Dada una consulta, buscar en una base de conocimiento externa para encontrar documentos o pasajes relevantes.
- Aumentar: Insertar la información recuperada en el prompt del LLM como contexto adicional.
- Generar: El LLM genera su respuesta condicionada tanto por la consulta como por el contexto recuperado.
Interactive · RAG Pipeline: Indexacion y Recuperacion
Pipeline RAG
De documento a respuesta
Indexar, recuperar, re-rankear y aumentar la generación. Los dos primeros pasos viven offline; los demás corren cada vez que llega una consulta.
Chunking
Trocear los documentos en piezas semánticamente coherentes.
La analogía aquí es la de un estudiante realizando un examen a libro abierto frente a uno a libro cerrado. En un examen a libro cerrado (LLM estándar), el estudiante debe confiar enteramente en lo que memorizó. En un examen a libro abierto (RAG), el estudiante puede consultar sus apuntes y libros de texto. El estudiante aún necesita inteligencia para comprender la pregunta, encontrar la información correcta y sintetizar una respuesta coherente, pero ya no está limitado por las lagunas de su memoria.
El artículo original de RAG (Lewis et al., 2020) propuso dos variantes:
- RAG-Sequence: Los mismos documentos recuperados se utilizan para generar toda la secuencia de salida. Es como consultar una referencia y luego escribir toda tu respuesta basándote en ella.
- RAG-Token: Diferentes documentos pueden recuperarse y usarse para diferentes tokens en la salida. Es como consultar diferentes referencias para distintas partes de tu respuesta.
En la práctica, la mayoría de los sistemas RAG modernos utilizan la variante de secuencia (o una versión simplificada) porque es más sencilla y funciona suficientemente bien para la mayoría de las aplicaciones.
Por qué RAG importa para los agentes
Para los agentes de IA, RAG no se trata sólo de responder preguntas. Habilita un modo de operación fundamentalmente diferente:
- Acciones fundamentadas: Un agente puede consultar documentación antes de llamar a una API, reduciendo errores. En lugar de adivinar los parámetros de una API, recupera la especificación real.
- Conocimiento dinámico: Un agente puede recuperar información actualizada en lugar de depender de los datos de entrenamiento. Un agente financiero puede consultar los precios de acciones del día en lugar de alucinar cifras obsoletas.
- Especialización en dominios: Al recuperar de corpus específicos de un dominio, un agente de propósito general puede realizar tareas especializadas. El mismo agente puede responder preguntas sobre derecho fiscal, guías médicas o documentación de software, según el corpus al que tenga acceso.
- Transparencia: Las fuentes recuperadas pueden citarse, haciendo auditable el razonamiento del agente. Un usuario puede verificar las afirmaciones del agente consultando las fuentes citadas.
Concepto erróneo común: "RAG es sólo un buscador sofisticado." No. RAG combina búsqueda con razonamiento. Un motor de búsqueda devuelve documentos; RAG lee esos documentos, sintetiza la información y genera una respuesta coherente que aborda directamente la pregunta del usuario. El paso de generación es lo que hace poderoso a RAG.
032. El pipeline de indexación
Antes de que pueda producirse la recuperación, los documentos deben procesarse y almacenarse en un formato consultable. Este es el pipeline de indexación: el paso de preparación offline que ocurre antes de que se procese cualquier consulta. Configurar correctamente el pipeline de indexación es crítico: si entran datos de mala calidad, salen resultados de mala calidad. Si los documentos están mal fragmentados o mal representados como embeddings, ninguna sofisticación en la recuperación producirá buenos resultados.
Paso 1: Carga de documentos
Los documentos vienen en muchos formatos: PDFs, páginas web, archivos Markdown, registros de bases de datos, correos electrónicos, archivos de código, y más. El primer paso es normalizarlos a texto plano.
from pathlib import Path
def load_text_file(path: str) -> str:
"""Load a plain text or markdown file."""
return Path(path).read_text(encoding="utf-8")
def load_documents(directory: str, extensions: tuple = (".txt", ".md")) -> list[dict]:
"""Load all documents from a directory.
This simple loader handles text and markdown files. In production,
you would use specialized loaders for different formats:
- PDF: PyPDF2, pdfplumber, or unstructured
- DOCX: python-docx
- HTML: BeautifulSoup
- Code: tree-sitter for syntax-aware parsing
"""
documents = []
for path in Path(directory).rglob("*"):
if path.suffix in extensions:
documents.append({
"content": path.read_text(encoding="utf-8"),
"source": str(path),
"filename": path.name,
})
return documentsPara sistemas en producción, librerías como LangChain y LlamaIndex proporcionan cargadores de documentos para docenas de formatos (PDF, DOCX, HTML, Notion, Confluence, Google Docs, etc.). El desafío clave en la carga de documentos es preservar la estructura: una tabla PDF, un bloque de código o una lista con viñetas tienen estructura que se pierde al convertirse a texto plano. Los cargadores sofisticados intentan preservar esta estructura, lo que mejora la calidad del chunking y la recuperación posteriores.
Paso 2: Chunking
Los documentos son típicamente demasiado largos para representarlos como un único vector o para incluirlos en el contexto del LLM como una única pieza de contexto recuperado. El chunking divide los documentos en fragmentos más pequeños y semánticamente coherentes.
¿Por qué no representar documentos enteros como embeddings? Por dos razones. Primero, los modelos de embedding tienen una longitud máxima de entrada (típicamente 512 tokens). Segundo, incluso si pudieran manejar entradas más largas, el embedding de un documento largo sería un "promedio difuso" de todos los temas del documento, dificultando la correspondencia con consultas específicas. Un informe de 50 páginas sobre finanzas corporativas podría discutir ingresos, gastos, plantilla, estrategia y riesgos. Un único embedding para todo el documento sería una mezcla vaga de todos estos temas. El chunking permite que cada tema tenga su propio embedding, haciendo la recuperación más precisa.
La analogía es un índice al final de un libro de texto. El índice no tiene una única entrada para el libro entero; tiene entradas para temas específicos en páginas específicas. El chunking crea el equivalente de esas entradas específicas del índice.
Estrategias de chunking
Chunking de tamaño fijo: Dividir el texto en fragmentos de N caracteres o tokens con solapamiento opcional.
def fixed_size_chunks(
text: str, chunk_size: int = 500, overlap: int = 50
) -> list[str]:
"""Split text into fixed-size chunks with overlap.
The overlap parameter is important: without it, a sentence that
happens to fall right at a chunk boundary would be split in half,
with the first part in one chunk and the second in the next.
Overlap ensures that boundary sentences appear in both chunks,
so at least one chunk contains the complete sentence.
Args:
text: The input text to chunk.
chunk_size: Maximum number of characters per chunk.
overlap: Number of characters to overlap between chunks.
Returns:
List of text chunks.
"""
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunk = text[start:end]
chunks.append(chunk.strip())
start = end - overlap
return [c for c in chunks if c] # Remove empty chunksChunking basado en oraciones: Dividir en los límites de oraciones, agrupando oraciones hasta que el fragmento alcance un tamaño objetivo. Esto evita el problema de cortar oraciones por la mitad.
import re
def sentence_based_chunks(
text: str, max_chunk_size: int = 500
) -> list[str]:
"""Split text into chunks at sentence boundaries.
This preserves sentence integrity -- no sentence is ever split
across two chunks. Sentences are grouped together until the
chunk reaches the target size, then a new chunk begins.
The trade-off: chunk sizes are variable. Some chunks may be
much shorter than max_chunk_size (if a single sentence is very
long), while others may approach it closely.
"""
# Simple sentence splitting (production systems use spaCy or nltk)
sentences = re.split(r'(?<=[.!?])\s+', text)
chunks = []
current_chunk = []
current_size = 0
for sentence in sentences:
sentence_size = len(sentence)
if current_size + sentence_size > max_chunk_size and current_chunk:
chunks.append(" ".join(current_chunk))
current_chunk = []
current_size = 0
current_chunk.append(sentence)
current_size += sentence_size
if current_chunk:
chunks.append(" ".join(current_chunk))
return chunksDivisión recursiva por caracteres: Dividir primero por párrafos, luego por oraciones y finalmente por palabras si es necesario. Este es el enfoque que usa el RecursiveCharacterTextSplitter de LangChain. La idea es utilizar el límite más natural posible: los párrafos son mejores que las oraciones, que a su vez son mejores que posiciones arbitrarias de caracteres.
Chunking semántico: Usar un modelo de embedding para encontrar puntos de ruptura naturales donde cambia el tema. Se calculan los embeddings de cada oración y se divide donde la similitud entre oraciones consecutivas cae por debajo de un umbral. Esto produce los fragmentos más coherentes pero es computacionalmente costoso.
Chunking consciente de la estructura del documento: Para documentos estructurados (HTML, Markdown, código), usar la propia estructura del documento (encabezados, secciones, funciones) para definir los límites de los fragmentos. Este suele ser el mejor enfoque cuando la estructura del documento está disponible; un archivo Markdown con encabezados ## Titulo proporciona límites naturales para los fragmentos.
Elección de una estrategia de chunking
| Estrategia | Ventajas | Desventajas | Mejor para |
|---|---|---|---|
| Tamaño fijo | Simple, predecible | Puede cortar a mitad de oración | Prototipado |
| Basado en oraciones | Preserva la integridad de las oraciones | Los tamaños varían | Texto general |
| Recursivo | Buen equilibrio entre coherencia y tamaño | Más complejo | Sistemas en producción |
| Semántico | Fragmentos más coherentes | Costoso, lento | Recuperación de alta calidad |
| Consciente de estructura | Respeta la organización del documento | Dependiente del formato | Documentos estructurados |
Consideraciones sobre el tamaño del fragmento
El tamaño del fragmento es un hiperparámetro crítico, una de las decisiones con mayor impacto que se toman en un sistema RAG. Hay que pensar en ello como la granularidad del índice:
- Demasiado pequeño (< 100 tokens): Los fragmentos carecen de contexto suficiente. Un fragmento que dice "La respuesta es 42" es inútil sin saber que pregunta se estaba respondiendo. Los fragmentos recuperados pueden ser fragmentos que no tienen sentido por sí solos.
- Demasiado grande (> 1000 tokens): Los fragmentos pueden contener múltiples temas, reduciendo la precisión de la recuperación. Cuando se busca "crecimiento de ingresos", se obtiene un fragmento que también habla de plantilla, gastos y estrategia. Esto diluye la información relevante y desperdicia espacio en la ventana de contexto.
- Punto óptimo (200-500 tokens): Generalmente funciona bien para la mayoría de las aplicaciones. Esto es suficiente para contener un pensamiento o párrafo completo, manteniéndose enfocado en un único tema.
Idea clave: El tamaño del fragmento debe ajustarse empíricamente para cada conjunto de datos y caso de uso específico. No existe un valor óptimo universal. Algunos equipos ejecutan experimentos sistemáticos variando el tamaño de 100 a 1000 tokens y miden la calidad de la recuperación (precisión y recall) para encontrar el punto óptimo para sus datos.
Paso 3: Generación de embeddings
Cada fragmento se convierte en una representación vectorial densa utilizando un modelo de embedding. Este es el paso que habilita la búsqueda semántica: conceptos similares obtienen vectores similares, independientemente de las palabras exactas utilizadas.
from sentence_transformers import SentenceTransformer
def generate_embeddings(
chunks: list[str], model_name: str = "all-MiniLM-L6-v2"
) -> list[list[float]]:
"""Generate embeddings for a list of text chunks.
Each chunk is converted into a fixed-size vector (e.g., 384
dimensions for MiniLM) that captures its semantic meaning.
Similar texts get similar vectors, enabling similarity search.
Args:
chunks: List of text strings to embed.
model_name: Name of the sentence-transformers model to use.
Returns:
List of embedding vectors.
"""
model = SentenceTransformer(model_name)
embeddings = model.encode(chunks, show_progress_bar=True)
return embeddings.tolist()Elección de un modelo de embedding
La elección del modelo de embedding afecta significativamente la calidad de la recuperación. Esta elección suele tener más impacto que la elección del LLM para la generación. Consideraciones clave:
- Dimensionalidad: Más dimensiones capturan más matices pero requieren más almacenamiento y cómputo. Valores comunes: 384 (MiniLM), 768 (BERT-base), 1024 (modelos grandes), 1536 (OpenAI text-embedding-3-small), 3072 (text-embedding-3-large).
- Objetivo de entrenamiento: Los modelos entrenados en tareas de recuperación (por ejemplo, con aprendizaje contrastivo sobre pares consulta-documento) generalmente superan a los modelos entrenados con otros objetivos.
- Especificidad de dominio: Los modelos de propósito general pueden tener un rendimiento inferior en dominios especializados (legal, médico, científico). Los modelos ajustados (fine-tuned) pueden ayudar.
- Soporte multilingüe: Si el corpus es multilingüe, utilizar un modelo de embedding multilingüe.
Modelos de embedding populares (a fecha de 2024-2025):
| Modelo | Dimensiones | Código abierto | Notas |
|---|---|---|---|
| all-MiniLM-L6-v2 | 384 | Sí | Rápido, bueno para prototipado |
| BGE-large-en-v1.5 | 1024 | Sí | Buen rendimiento en MTEB |
| E5-mistral-7b-instruct | 4096 | Sí | Ajustado con instrucciones, estado del arte |
| text-embedding-3-small | 1536 | No (OpenAI) | Buena relación calidad-coste |
| text-embedding-3-large | 3072 | No (OpenAI) | Mayor calidad de OpenAI |
| Cohere embed-v3 | 1024 | No (Cohere) | Soporta compresión |
La tabla de clasificación MTEB (Massive Text Embedding Benchmark) (Muennighoff et al., 2023) proporciona comparaciones exhaustivas de modelos de embedding en muchas tareas. Conviene siempre consultar la tabla de clasificación para los modelos más recientes antes de elegir.
Pruébalo tú mismo: Toma la misma oración y genérala como embedding con dos modelos diferentes (por ejemplo, MiniLM y BGE-large). Luego genera el embedding de una oración semánticamente similar y otra semánticamente diferente. Compara las similitudes del coseno. Verás que los mejores modelos producen mayores diferencias entre pares similares y disimilares.
Paso 4: Almacenamiento vectorial
Los embeddings y sus fragmentos de texto asociados se almacenan en una base de datos vectorial. Aquí es donde residen los datos indexados y donde se ejecutan las consultas de recuperación.
import chromadb
def build_index(
chunks: list[str],
sources: list[str],
collection_name: str = "documents",
) -> chromadb.Collection:
"""Build a vector index from text chunks.
ChromaDB handles embedding generation internally using its
default model, so we only need to provide the text chunks.
The 'cosine' distance metric is used for similarity search,
which measures the angle between vectors (direction similarity)
rather than their absolute distance.
Args:
chunks: List of text chunks.
sources: List of source identifiers (one per chunk).
collection_name: Name for the vector collection.
Returns:
A ChromaDB collection with the indexed documents.
"""
client = chromadb.Client()
collection = client.create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"}, # Use cosine similarity
)
# ChromaDB generates embeddings automatically using its default model
collection.add(
documents=chunks,
metadatas=[{"source": src} for src in sources],
ids=[f"chunk_{i}" for i in range(len(chunks))],
)
return collection043. Búsqueda por similitud vectorial
Similitud del coseno
La métrica de similitud más común para embeddings. Mide el coseno del ángulo entre dos vectores, con un rango de -1 (opuestos) a 1 (idénticos).
Imagina dos flechas que parten del origen. La similitud del coseno mide cuánto apuntan en la misma dirección, independientemente de su longitud. Dos flechas cortas y dos flechas largas que apuntan en la misma dirección tienen la misma similitud del coseno.
import numpy as np
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Compute cosine similarity between two vectors.
The formula: cos(theta) = (A . B) / (|A| * |B|)
Where A . B is the dot product (sum of element-wise products)
and |A| is the magnitude (Euclidean norm) of vector A.
"""
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8))Propiedades:
- Invariante a la magnitud del vector (solo importa la dirección). Esto es importante porque los modelos de embedding pueden producir vectores de diferentes magnitudes para diferentes entradas, pero nos interesa la dirección semántica, no la magnitud.
- Funciona bien para embeddings normalizados. La mayoría de los modelos de embedding producen vectores aproximadamente normalizados.
- La mayoría de los modelos de embedding están entrenados teniendo en cuenta la similitud del coseno.
Otras métricas de distancia
- Distancia euclidiana (L2): Mide la distancia en línea recta entre vectores. Sensible a la magnitud. Dos vectores que apuntan en la misma dirección pero tienen magnitudes muy diferentes tendrán una distancia euclidiana grande a pesar de ser semánticamente similares.
- Producto escalar (producto interno): Similar a la similitud del coseno para vectores normalizados. Más rápido de calcular porque omite el paso de normalización.
- Distancia de Manhattan (L1): Suma de diferencias absolutas. Más robusta ante valores atípicos.
En la práctica, la similitud del coseno y el producto escalar se utilizan en la gran mayoría de los sistemas RAG. La diferencia entre ambos es insignificante cuando los vectores están normalizados.
Vecinos más cercanos aproximádos (ANN)
La búsqueda exacta de vecinos más cercanos tiene complejidad O(n): se debe comparar la consulta contra cada vector almacenado. Para una colección de 1 millón de documentos, eso supone 1 millón de cálculos de similitud por consulta. Esto es demasiado lento para aplicaciones interactivás.
Los algoritmos de vecinos más cercanos aproximados (ANN) sacrifican una pequeña cantidad de precisión a cambio de un rendimiento drásticamente mejor. En lugar de garantizar las mejores correspondencias absolutas, encuentran correspondencias que muy probablemente son las mejores (típicamente el 95-99% de las veces).
HNSW (Hierarchical Navigable Small World): El algoritmo ANN más popular, utilizado por Chroma, Qdrant y pgvector. Construye un grafo multicapa donde las capas superiores proporcionan navegación gruesa y las inferiores búsqueda detallada. Es como hacer zoom en un mapa: la capa superior es el nivel de país, la siguiente es la región, luego la ciudad y finalmente el barrio. Cada capa reduce el espacio de búsqueda. Recall típico: 95-99% con una aceleración de 10-100x sobre la búsqueda exhaustíva.
IVF (Inverted File Index): Agrupa los vectores en clusters y solo busca en los clusters más cercanos. Utilizado por FAISS. Es como organizar una biblioteca por secciones: cuando buscas un libro de ciencia, solo buscas en la sección de ciencia, no en toda la biblioteca. Rápido pero menos preciso que HNSW.
Cuantización por producto (PQ): Comprime los vectores dividiéndolos en sub-vectores y cuantizando cada uno. Reduce dramáticamente el uso de memoria a costa de cierta precisión. Útil cuando se tienen millones de vectores y memoria limitada.
# Example: FAISS with IVF + PQ for large-scale search
import faiss
import numpy as np
def build_faiss_index(
embeddings: np.ndarray, nlist: int = 100, m: int = 8
) -> faiss.Index:
"""Build a FAISS index with IVF and Product Quantization.
This combines two techniques:
- IVF (Inverted File): Clusters vectors into nlist groups.
At query time, only the nearest clusters are searched.
- PQ (Product Quantization): Compresses each vector into m
sub-vectors, reducing memory by ~16x.
Together, they enable searching millions of vectors in
milliseconds using modest hardware.
Args:
embeddings: Matrix of embeddings (n_docs x dim).
nlist: Number of clusters for IVF.
m: Number of sub-quantizers for PQ.
Returns:
Trained FAISS index.
"""
dim = embeddings.shape[1]
# Create the index: IVF with PQ compression
quantizer = faiss.IndexFlatL2(dim)
index = faiss.IndexIVFPQ(quantizer, dim, nlist, m, 8)
# Train the index on the data -- IVF needs to learn cluster
# centers, and PQ needs to learn quantization codebooks
index.train(embeddings)
index.add(embeddings)
return index
def search_faiss(
index: faiss.Index, query_embedding: np.ndarray, top_k: int = 5
) -> tuple[np.ndarray, np.ndarray]:
"""Search the FAISS index for nearest neighbors.
Returns:
Tuple of (distances, indices) arrays.
"""
query = query_embedding.reshape(1, -1)
distances, indices = index.search(query, top_k)
return distances[0], indices[0]Concepto erróneo común: "La búsqueda aproximada implica resultados imprecisos." En la práctica, los algoritmos ANN encuentran los verdaderos vecinos más cercanos entre el 95 y el 99% de las veces. El 1-5% de los casos en que fallan son típicamente empates cercanos donde el resultado omitido era casi tan relevante como el devuelto. Para aplicaciones RAG, este nivel de precisión es más que suficiente.
054. Estrategias de recuperación
Recuperación densa
La recuperación densa utiliza representaciones vectoriales aprendidas (embeddings) tanto para consultas como para documentos. La similitud se calcula en el espacio de embeddings. Este es el enfoque "estándar" que hemos estado discutiendo.
Ventajas:
- Captura el significado semántico más allá de las coincidencias exactas de palabras. "Perro" y "can" tendrán embeddings similares aunque no compartan caracteres.
- Funciona entre idiomas con modelos multilingües. Una consulta en español puede recuperar documentos en inglés.
- Maneja parafraseos de forma natural. "¿Cómo soluciono este error?" y "¿Cuál es la solución a este problema?" coincidirán con documentos similares.
Desventajas:
- Requiere un modelo de embedding (añade latencia y coste).
- Puede fallar en coincidencias exactas de palabras clave que importan. Si un usuario busca "error ERR-4032", la recuperación densa podría encontrar documentos sobre errores en general en lugar del código de error específico.
- La calidad del embedding depende en gran medida del modelo y del dominio. Un modelo entrenado en texto web puede no funcionar bien para documentos legales.
Recuperación dispersa
La recuperación dispersa utiliza técnicas tradicionales de recuperación de información basadas en frecuencia de términos y frecuencia inversa de documento. La idea clave: una palabra que aparece frecuéntemente en un documento pero raraménte en el corpus es una señal fuerte de relevancia.
BM25 es el algoritmo de recuperación dispersa más utilizado. Ha sido la columna vertebral de los motores de búsqueda web durante décadas y sigue siendo competitivo incluso en la era de la recuperación neuronal.
import math
from collections import Counter
class BM25:
"""A simple BM25 implementation for sparse retrieval.
BM25 (Best Matching 25) ranks documents based on:
1. Term Frequency (TF): How often does the query term appear
in this document? More occurrences = more relevant.
2. Inverse Document Frequency (IDF): How rare is this term
across all documents? Rare terms are more informative.
3. Document Length Normalization: Longer documents naturally
contain more terms, so we normalize for length.
The name "BM25" comes from it being the 25th in a series of
ranking functions explored by Robertson et al. in the 1990s.
Parameters:
- k1 controls term frequency saturation. Higher k1 means
additional term occurrences have more impact. Default: 1.5.
- b controls document length normalization. b=1 means full
normalization, b=0 means no normalization. Default: 0.75.
"""
def __init__(self, documents: list[str], k1: float = 1.5, b: float = 0.75):
self.k1 = k1
self.b = b
self.documents = documents
self.doc_count = len(documents)
# Tokenize and compute statistics
self.doc_tokens = [doc.lower().split() for doc in documents]
self.doc_lengths = [len(tokens) for tokens in self.doc_tokens]
self.avg_doc_length = sum(self.doc_lengths) / self.doc_count
# Compute document frequency for each term
self.df = {}
for tokens in self.doc_tokens:
for token in set(tokens): # set() to count each term once per doc
self.df[token] = self.df.get(token, 0) + 1
def _idf(self, term: str) -> float:
"""Compute inverse document frequency for a term.
IDF is high for rare terms and low for common terms.
The formula includes smoothing (+0.5) to avoid division
by zero and the +1 to ensure IDF is always positive.
"""
df = self.df.get(term, 0)
return math.log((self.doc_count - df + 0.5) / (df + 0.5) + 1)
def score(self, query: str, doc_index: int) -> float:
"""Compute BM25 score for a query-document pair.
The score is the sum of each query term's contribution:
IDF * (TF * (k1 + 1)) / (TF + k1 * (1 - b + b * dl/avgdl))
where TF is term frequency, dl is document length, and
avgdl is average document length.
"""
query_tokens = query.lower().split()
doc_tokens = self.doc_tokens[doc_index]
doc_length = self.doc_lengths[doc_index]
tf_counts = Counter(doc_tokens)
score = 0.0
for term in query_tokens:
if term not in tf_counts:
continue
tf = tf_counts[term]
idf = self._idf(term)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (
1 - self.b + self.b * doc_length / self.avg_doc_length
)
score += idf * numerator / denominator
return score
def search(self, query: str, top_k: int = 5) -> list[tuple[int, float]]:
"""Search for the most relevant documents.
Returns:
List of (document_index, score) tuples sorted by score descending.
"""
scores = [(i, self.score(query, i)) for i in range(self.doc_count)]
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]Ventajas:
- Excelente en coincidencia exacta de palabras clave. Buscar "ERR-4032" encuentra documentos que contienen exactamente esa cadena.
- No se necesita modelo de embedding; configuración más rápida y sin requisitos de GPU.
- Rápido, interpretable y bien comprendido tras décadas de investigación.
- Maneja bien términos raros y nombres propios. Los modelos densos a menudo tienen dificultades con nombres de entidades y códigos que no han visto durante el entrenamiento.
Desventajas:
- Sin comprensión semántica ("perro" y "can" son términos completamente diferentes).
- Sensible a la discordancia de vocabulario. Si el documento dice "automóvil" y la consulta dice "coche", BM25 no los emparejará.
- Sin capacidad multilingüe.
Recuperación híbrida
La recuperación híbrida combina recuperación densa y dispersa para obtener lo mejor de ambos mundos. La idea clave: la recuperación densa captura la semántica mientras que la dispersa captura las coincidencias exactas. Ninguna sola es perfecta, pero juntas cubren las debilidades de la otra.
Un enfoque común es la Fusión de Rangos Recíprocos (RRF), que combina rankings de múltiples sistemas de recuperación:
def reciprocal_rank_fusion(
rankings: list[list[tuple[str, float]]],
k: int = 60,
) -> list[tuple[str, float]]:
"""Combine multiple rankings using Reciprocal Rank Fusion.
RRF is elegant in its simplicity: it assigns a score of
1/(k + rank) to each document in each ranking, then sums
the scores across rankings.
The k parameter (default 60) is a smoothing constant that
prevents top-ranked documents from dominating. Without it,
the #1 result from one system would always beat the #2 result
from both systems combined.
Example: If document X is ranked #1 by dense retrieval and
#3 by sparse retrieval:
- Dense score: 1/(60+1) = 0.0164
- Sparse score: 1/(60+3) = 0.0159
- Total: 0.0323
If document Y is ranked #2 by both:
- Dense score: 1/(60+2) = 0.0161
- Sparse score: 1/(60+2) = 0.0161
- Total: 0.0323
Both documents score equally, which makes intuitive sense:
being good in both systems is as valuable as being great
in one and decent in the other.
Args:
rankings: List of ranked lists, each containing (doc_id, score) tuples.
k: Constant to prevent high scores for top-ranked documents (default: 60).
Returns:
Fused ranking as a list of (doc_id, fused_score) tuples.
"""
fused_scores: dict[str, float] = {}
for ranking in rankings:
for rank, (doc_id, _) in enumerate(ranking):
if doc_id not in fused_scores:
fused_scores[doc_id] = 0.0
fused_scores[doc_id] += 1.0 / (k + rank + 1)
# Sort by fused score descending
fused = sorted(fused_scores.items(), key=lambda x: x[1], reverse=True)
return fusedLa recuperación híbrida es particularmente efectiva porque captura tanto la similitud semántica (de la recuperación densa) como las coincidencias exactas de palabras clave (de la recuperación dispersa). En benchmarks, la recuperación híbrida supera consistentemente a cualquiera de los enfoques por separado, típicamente entre un 5 y un 15% en métricas estándar de recuperación de información.
Idea clave: En sistemas RAG en producción, la recuperación híbrida debería ser tu enfoque predeterminado. Cuesta ligeramente más que cualquiera de los enfoques por separado (se ejecutan dos pasadas de recuperación), pero la mejora en calidad es sustancial y consistente. Es como llevar cinturón y tirantes a la vez: la redundancia en la recuperación es una ventaja, no un defecto.
065. Técnicas avanzadas de RAG
Re-ranking
El paso inicial de recuperación optimiza para el recall (encontrar todos los documentos relevantes). Esto significa que usa métodos rápidos pero imprecisos (vecinos más cercanos aproximados, BM25). Un re-ranker luego optimiza para la precisión puntuando cada documento recuperado de forma más cuidadosa.
Los re-rankers de tipo cross-encoder procesan la consulta y el documento juntos a través de un transformer, produciendo una puntuación de relevancia. A diferencia de los bi-encoders (que generan embeddings de consulta y documento por separado), los cross-encoders ven ambos textos simultáneamente y pueden capturar interacciones detalladas entre ellos. Esto es mucho más preciso pero demasiado costoso para todo el corpus; ejecutar un cross-encoder sobre un millón de documentos tardaría horas.
El enfoque en dos etapas (recuperación rápida + re-ranking preciso) es como un proceso de contratación: la revisión de currículos (rápida, imprecisa) reduce el grupo de miles a docenas, y luego las entrevistas (lentas, precisas) identifican a los mejores candidatos de ese grupo más reducido.
from sentence_transformers import CrossEncoder
def rerank_documents(
query: str,
documents: list[str],
model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2",
top_k: int = 5,
) -> list[tuple[str, float]]:
"""Re-rank retrieved documents using a cross-encoder.
The cross-encoder processes each (query, document) pair through
a transformer model, producing a relevance score. This is
significantly more accurate than bi-encoder similarity because
the model can attend to interactions between query terms and
document terms.
Typical pipeline:
1. Initial retrieval: Get 20-50 candidates (fast, approximate)
2. Re-ranking: Score all candidates with cross-encoder (slow, precise)
3. Return top-k after re-ranking
Args:
query: The search query.
documents: List of candidate documents from initial retrieval.
model_name: Cross-encoder model to use.
top_k: Number of top documents to return.
Returns:
List of (document, score) tuples sorted by relevance.
"""
model = CrossEncoder(model_name)
pairs = [(query, doc) for doc in documents]
scores = model.predict(pairs)
scored_docs = list(zip(documents, scores))
scored_docs.sort(key=lambda x: x[1], reverse=True)
return scored_docs[:top_k]Transformación de consultas
A veces la consulta del usuario no es adecuada para la recuperación directa. El usuario puede hacer una pregunta de alto nivel cuando los documentos relevantes contienen detalles de bajo nivel, o puede usar una terminología diferente a la de los documentos. Las técnicas de transformación de consultas mejoran la recuperación modificando la consulta antes de buscar.
HyDE (Hypothetical Document Embeddings): Generar una respuesta hipotética a la consulta y luego usar su embédding para la recuperación. La hipótesis suele estar más cerca en el espacio de embéddings del documento real que la pregunta original.
Esta es una observación ingeniosa. Consideremos la pregunta "¿Qué causa las auroras boreales?" El embedding de esta pregunta estará en el "espacio de preguntas". Pero los documentos que responden esta pregunta están en el "espacio de respuestas": contienen afirmaciones como "Las partículas cargadas del viento solar interactúan con el campo magnético de la Tierra..." HyDE cierra esta brecha generando primero una respuesta hipotética y luego buscando documentos similares a esa respuesta.
def hyde_retrieval(query: str, llm_call, retriever) -> list[str]:
"""Hypothetical Document Embedding retrieval.
1. Ask the LLM to generate a hypothetical answer.
2. Use the hypothetical answer's embedding for retrieval.
3. Return the retrieved real documents.
The hypothetical answer does not need to be correct -- it just
needs to be in the right semantic neighborhood. Even an
inaccurate hypothesis will use the right terminology and
concepts, making it a better retrieval query than the original
question.
Reference: Gao et al., 2023 - "Precise Zero-Shot Dense Retrieval
without Relevance Labels" (ACL 2023).
"""
# Step 1: Generate hypothetical answer
hypothesis = llm_call(
prompt=f"Write a short paragraph that would answer this question: {query}"
)
# Step 2: Use the hypothetical answer for retrieval
results = retriever.search(hypothesis, top_k=5)
return resultsDescomposición de consultas: Dividir una consulta compleja en subconsultas más simples y recuperar para cada una. Esto es particularmente útil para preguntas multifacéticas.
DECOMPOSE_PROMPT = """Break the following complex question into 2-4 simpler
sub-questions that, when answered together, would answer the original question.
Original question: {query}
Sub-questions:
1."""
def decompose_and_retrieve(query: str, llm_call, retriever) -> list[str]:
"""Decompose a complex query and retrieve for each sub-query.
Example: "Compare the performance and cost of GPT-4 and Claude 3.5"
might decompose into:
1. "What is the performance of GPT-4 on standard benchmarks?"
2. "What is the performance of Claude 3.5 on standard benchmarks?"
3. "What is the pricing of GPT-4?"
4. "What is the pricing of Claude 3.5?"
Each sub-query retrieves different documents, and together they
provide the information needed to answer the original question.
"""
# Generate sub-questions
response = llm_call(prompt=DECOMPOSE_PROMPT.format(query=query))
sub_queries = [q.strip() for q in response.split("\n") if q.strip()]
# Retrieve for each sub-query, deduplicating results
all_results = []
seen = set()
for sub_query in sub_queries:
results = retriever.search(sub_query, top_k=3)
for doc in results:
if doc not in seen:
all_results.append(doc)
seen.add(doc)
return all_resultsStep-back prompting: Formular una versión más general de la pregunta para recuperar un contexto más amplio. Por ejemplo, "¿Cuál fue la tasa de crecimiento del PIB de Vietnam en el tercer trimestre de 2024?" podría generalizarse a "¿Cuál es el rendimiento económico reciente de Vietnam?" para recuperar documentos que proporcionen contexto más allá del número específico.
Recuperación en múltiples pasos
Para preguntas complejas, un único paso de recuperación puede no encontrar toda la información necesaria. La recuperación en múltiples pasos utiliza los resultados de una recuperación para informar la siguiente, recopilando iterativamente información hasta que el agente tiene suficiente para responder.
def iterative_retrieval(
query: str, llm_call, retriever, max_steps: int = 3
) -> list[str]:
"""Iteratively retrieve and refine.
At each step, the LLM examines current results and generates
a follow-up query to fill in gaps. This mimics how a human
researcher works: you read initial sources, identify gaps in
your understanding, then search for more information to fill
those gaps.
The process terminates when the LLM determines it has enough
information or when the maximum number of steps is reached.
"""
all_retrieved = []
current_query = query
for step in range(max_steps):
# Retrieve
results = retriever.search(current_query, top_k=3)
all_retrieved.extend(results)
# Ask LLM if we have enough information
context = "\n".join(all_retrieved)
follow_up = llm_call(
prompt=(
f"Original question: {query}\n\n"
f"Information retrieved so far:\n{context}\n\n"
"Is this enough to answer the question? If not, what specific "
"information is still missing? Generate a follow-up search query "
"to find the missing information. If enough, respond with 'SUFFICIENT'."
)
)
if "SUFFICIENT" in follow_up.upper():
break
current_query = follow_up
return all_retrievedPruébalo tú mismo: Piensa en una pregunta compleja que se beneficiaría de la recuperación en múltiples pasos. Por ejemplo: "¿Cómo se compara el entorno regulatorio de la IA en la UE con el de EE.UU., y cuáles son las implicaciones para las empresas que operan en ambos mercados?" ¿Qué subconsultas necesitarías? ¿Qué información del primer paso de recuperación informaría tu segunda consulta?
076. RAG agéntico: Dejando que el agente decida
De la recuperación pasiva a la activa
En RAG estándar, la recuperación se activa automáticamente para cada consulta, un pipeline fijo donde cada entrada pasa por el mismo proceso de recuperar-y-generar. En RAG agéntico, el agente dispone de la recuperación como una de sus herramientas disponibles y decide cuándo y qué recuperar basándose en su evaluación de la información qué necesita.
Este es un cambio significativo: el agente se convierte en un participante activo en el proceso de recopilación de información en lugar de un receptor pasivo del contexto recuperado. Es la diferencia entre un estudiante que lee cada página del libro de texto antes de responder una pregunta (RAG estándar) y uno que primero considera lo que ya sabe, identifica lagunas y busca solo lo que necesita (RAG agéntico).
¿Por qué RAG agéntico?
- No toda consulta necesita recuperación. Saludos simples ("¡Hola!"), aritmética ("¿Cuánto es 2+2?") o hechos bien conocidos ("¿Cuál es la capital de Francia?") no se benefician de la recuperación. Recuperar de todos modos desperdicia tiempo y puede introducir ruido; documentos recuperados sobre el sistema político de Francia podrían confundir una pregunta de geografía simple.
- El agente sabe lo que no sabe. Tras un intento inicial de respuesta, el agente puede identificar lagunas de conocimiento específicas y recuperar información dirigida. Esto es más eficiente que recuperar a ciegas en cada consulta.
- Recuperación de múltiples fuentes. Un agente puede tener acceso a múltiples bases de conocimiento (documentación técnica, wiki de la empresa, artículos de investigación) y necesitar decidir cuál consultar. Una pregunta sobre política de vacaciones debería ir a la base de conocimiento de RRHH, no a la documentación técnica.
- Refinamiento iterativo. El agente puede recuperar, evaluar si los resultados son suficientes y recuperar de nuevo si es necesario. Esto maneja consultas complejas que requieren información de múltiples documentos.
Implementación de RAG agéntico
AGENT_SYSTEM_PROMPT = """You are a helpful assistant with access to a knowledge base.
You have the following tools available:
1. search_knowledge_base(query: str) -> list[str]
Search the knowledge base for relevant information.
2. answer(response: str) -> None
Provide your final answer to the user.
Guidelines:
- Only search the knowledge base when you need information you don't
already know or when accuracy is critical.
- You may search multiple times with different queries.
- Always cite the source when using retrieved information.
- If the knowledge base doesn't have relevant information, say so
and answer based on your general knowledge (with a caveat).
Think step by step about whether you need to search before answering.
"""
class AgenticRAG:
"""An agent that decides when and what to retrieve.
Unlike standard RAG (which always retrieves), this agent
uses retrieval as a tool -- it decides whether to search,
what to search for, and when it has enough information to
answer. This is more efficient and often more accurate.
"""
def __init__(self, llm_call, retriever):
self.llm_call = llm_call
self.retriever = retriever
self.retrieved_context = []
def search_knowledge_base(self, query: str) -> list[str]:
"""Tool: search the knowledge base."""
results = self.retriever.search(query, top_k=3)
self.retrieved_context.extend(results)
return results
def process_query(self, user_query: str) -> str:
"""Process a user query with optional retrieval.
The agent reasons about whether it needs to search,
formulates search queries if needed, and synthesizes
the results into a final answer.
"""
messages = [
{"role": "system", "content": AGENT_SYSTEM_PROMPT},
{"role": "user", "content": user_query},
]
# Agent reasoning loop (max 5 iterations to prevent runaway)
for _ in range(5):
response = self.llm_call(messages=messages)
if "search_knowledge_base" in response:
# Agent decided to search -- extract query and execute
search_query = self._extract_search_query(response)
results = self.search_knowledge_base(search_query)
messages.append({"role": "assistant", "content": response})
messages.append({
"role": "tool",
"content": f"Search results:\n" + "\n---\n".join(results),
})
elif "answer(" in response:
# Agent is ready to give a final answer
return self._extract_answer(response)
else:
return response
return "I was unable to complete the query within the step limit."
def _extract_search_query(self, response: str) -> str:
"""Extract the search query from an agent's tool call."""
start = response.index("search_knowledge_base(") + len("search_knowledge_base(")
end = response.index(")", start)
return response[start:end].strip("\"'")
def _extract_answer(self, response: str) -> str:
"""Extract the answer from an agent's tool call."""
start = response.index("answer(") + len("answer(")
end = response.rindex(")")
return response[start:end].strip("\"'")RAG agentico basado en enrutador
Un enfoque más sofisticado utiliza un enrutador para dirigir las consultas a la fuente de recuperación apropiada:
ROUTER_PROMPT = """Given the user's question, decide which knowledge source to query.
Available sources:
- technical_docs: API documentation, code references, technical specifications
- company_wiki: Company policies, procedures, organizational information
- research_papers: Academic papers and research findings
- none: No retrieval needed (use general knowledge)
Question: {query}
Respond with ONLY the source name."""
def route_query(query: str, llm_call) -> str:
"""Route a query to the appropriate knowledge source.
This is like a librarian who directs you to the right section
of the library based on your question, rather than making you
search the entire library every time.
"""
response = llm_call(prompt=ROUTER_PROMPT.format(query=query))
return response.strip().lower()Idea clave: RAG agéntico representa una maduración del paradigma RAG. RAG estándar trata la recuperación como un paso fijo de preprocesamiento. RAG agéntico trata la recuperación como una herramienta que el agente puede usar estratégicamente. Esto es análogo a la diferencia entre un estudiante que siempre lee el libro de texto antes de responder (incluso para preguntas que ya conoce) y uno que consulta referencias estratégicamente sólo cuando es necesario.
087. Self-RAG: Recuperación autorreflexiva
El framework Self-RAG
Self-RAG (Asai et al., 2024) introduce un framework donde el propio modelo de lenguaje decide cuándo recuperar y luego evalúa críticamente la información recuperada. La innovación clave es el uso de tokens especiales de reflexión que el modelo genera para evaluar su propio proceso de recuperación y generación.
Piensa en Self-RAG como un agente con un verificador de hechos integrado. Tras generar cada parte de su respuesta, el agente se pregunta: "¿Necesitaba buscar esto? ¿Lo que encontré es relevante? ¿Mi respuesta está realmente respaldada por la evidencia?"
El proceso de Self-RAG:
- Decisión de recuperar: El modelo genera un token indicando si se necesita recuperación para el segmento actual.
- Recuperación: Si es necesario, se recuperan pasajes relevantes.
- Evaluación de relevancia: El modelo genera un token indicando si cada pasaje recuperado es relevante.
- Generación: El modelo genera un segmento de respuesta usando los pasajes relevantes.
- Evaluación de soporte: El modelo genera un token indicando si el texto generado está respaldado por la evidencia recuperada.
- Evaluación de utilidad: El modelo evalúa la utilidad general de la respuesta generada.
Input: "What causes aurora borealis?"
Step 1: [Retrieve: Yes] -- Model decides retrieval is needed
Step 2: Retrieved: "Aurora borealis occurs when charged particles from
the Sun interact with Earth's magnetic field..."
Step 3: [Relevant: Yes] -- Model confirms passage is relevant
Step 4: "Aurora borealis is caused by charged particles from the Sun
colliding with gases in Earth's atmosphere..."
Step 5: [Supported: Fully] -- Model confirms response is grounded
Step 6: [Useful: 5] -- Model rates utility highlyVentajas de Self-RAG
- Recuperación adaptativa: Evita la recuperación innecesaria para preguntas fáciles, ahorrando tiempo y coste.
- Control de calidad: El modelo evalúa su propia salida en cuanto a fidelidad, detectando alucinaciones antes de que lleguen al usuario.
- Reducción de alucinaciones: Al verificar si el texto generado está respaldado por la evidencia, Self-RAG reduce las afirmaciones sin respaldo.
Concepto erróneo común: "Self-RAG requiere entrenar un modelo personalizado." Aunque el artículo original entrena los tokens de reflexión en el modelo, las ideas clave (decidir si recuperar, evaluar la relevancia, comprobar el soporte) pueden aproximarse con técnicas de prompting usando cualquier LLM. El marco conceptual es más importante que la implementación exacta.
098. RAG vs. fine-tuning: Compromisos para agentes
Al adaptar un LLM a un dominio específico, existen dos enfoques principales: RAG (recuperar información relevante en tiempo de inferencia) y fine-tuning (actualizar los pesos del modelo con datos específicos del dominio). Comprender los compromisos es esencial para tomar buenas decisiones arquitectónicas.
Ventajas de RAG
- No requiere entrenamiento: Se configura un índice y se comienza a consultar. El fine-tuning requiere infraestructura de entrenamiento, preparación de datos y evaluación.
- Conocimiento actualizado: Los nuevos documentos pueden indexarse inmediatamente. El fine-tuning requiere re-entrenamiento para incorporar nueva información.
- Procedencia: Las fuentes recuperadas pueden citarse. El conocimiento del fine-tuning es opaco.
- Menor coste: No se necesita infraestructura de entrenamiento con GPU.
- Privacidad: Los datos sensibles permanecen en el sistema de recuperación, no integrados en los pesos del modelo que podrían filtrarse mediante prompting adversario.
Ventajas del fine-tuning
- Integración más profunda del conocimiento: El modelo "conoce" él dominio en lugar de leer sobre él en tiempo de inferencia. Puede usar él conocimiento del dominio de forma implícita en su razonamiento.
- Menor latencia: Sin paso de recuperación en tiempo de inferencia.
- Mejor adaptación de estilo: El fine-tuning puede cambiar él estilo de escritura, él tono y él formato del modelo. RAG no puede cambiar cómo escribe él modelo, solo la información a la que tiene acceso.
- Menor uso de contexto: No se consume espacio de la ventana de contexto con pasajes recuperados.
Cuando usar cada uno
| Escenario | Enfoque recomendado |
|---|---|
| El conocimiento cambia con frecuencia | RAG |
| Se necesita citar fuentes | RAG |
| Corpus de dominio pequeño (<1000 docs) | RAG |
| Se necesita un formato/estilo de salida específico | Fine-tuning |
| La latencia es crítica | Fine-tuning |
| Corpus de dominio grande y estable | Fine-tuning (o ambos) |
| Agente en producción con tareas diversas | RAG + fine-tuning |
El enfoque híbrido
En la práctica, los mejores resultados suelen obtenerse combinando ambos enfoques:
- Hacer fíne-tuning del modelo con datos específicos del dominio para aprender los conceptos, la terminología y el estilo del dominio.
- Usar RAG en tiempo de inferencia para proporcionar información actualizada y específica.
Esto a veces se denomina "RAG sobre un modelo con fine-tuning". El modelo ajustado es mejor comprendiendo y razonando sobre contenido específico del dominio, mientras que RAG asegura que tenga acceso a la información más reciente.
109. Evaluación de sistemas RAG
Por qué la evaluación de RAG es un reto
Los sistemas RAG tienen múltiples puntos de fallo, y un fallo en cualquier punto puede producir un mal resultado:
- Fallo de recuperación: Los documentos relevantes no se recuperan (fragmentos incorrectos, embeddings de baja calidad, mala consulta).
- Fallo de integración de contexto: Los documentos se recuperan pero el LLM los ignora o malinterpreta.
- Fallo de generación: El LLM genera información incorrecta a pesar de tener el contexto correcto (alucinación dentro del contexto).
Es necesario evaluar cada componente por separado y en conjunto para comprender dónde se producen los fallos.
Métricas clave
Métricas de recuperación
- Recall@k: Fracción de documentos relevantes que aparecen en los k primeros resultados. Un recall alto significa que se está encontrando la mayor parte de la información relevante.
- Precisión@k: Fracción de los k primeros resultados que son relevantes. Una precisión alta significa que no se está contaminando el contexto con información irrelevante.
- Mean Reciprocal Rank (MRR): Promedio de 1/rango del primer resultado relevante. Mide lo rápido que se encuentra algo relevante.
- Normalized Discounted Cumulative Gain (nDCG): Métrica ponderada que tiene en cuenta la posición de los resultados relevantes. Los resultados en las primeras posiciones importan más que los de las últimas.
def recall_at_k(retrieved_ids: list[str], relevant_ids: set[str], k: int) -> float:
"""Compute recall at k: fraction of relevant docs found in top k results.
Example: If there are 4 relevant documents and 3 of them appear
in the top 10 results, recall@10 = 3/4 = 0.75.
"""
top_k = set(retrieved_ids[:k])
return len(top_k & relevant_ids) / len(relevant_ids) if relevant_ids else 0.0
def precision_at_k(retrieved_ids: list[str], relevant_ids: set[str], k: int) -> float:
"""Compute precision at k: fraction of top k results that are relevant.
Example: If 3 out of 10 top results are relevant,
precision@10 = 3/10 = 0.3.
"""
top_k = set(retrieved_ids[:k])
return len(top_k & relevant_ids) / k if k > 0 else 0.0
def mrr(retrieved_ids: list[str], relevant_ids: set[str]) -> float:
"""Compute Mean Reciprocal Rank.
If the first relevant result is at position 3,
MRR = 1/3 = 0.333. A higher MRR means relevant results
appear earlier in the ranking.
"""
for i, doc_id in enumerate(retrieved_ids):
if doc_id in relevant_ids:
return 1.0 / (i + 1)
return 0.0Métricas de generación
- Fidelidad: ¿Refleja la respuesta generada con precisión el contexto recuperado? (Sin alucinaciones más allá de lo que dice el contexto.)
- Relevancia de la respuesta: ¿Aborda la respuesta realmente la pregunta?
- Relevancia del contexto: ¿Son los pasajes recuperados relevantes para la pregunta?
- Completitud: ¿Cubre la respuesta todos los aspectos de la pregunta?
El framework RAGAS
RAGAS (Retrieval Augmented Generation Assessment) de Es et al. (2024) proporciona un framework de evaluación automatizada con cuatro métricas. Es útil porque no requiere respuestas de referencia; utiliza un LLM para evaluar el pipeline RAG:
- Fidelidad: Mide si las afirmaciones de la respuesta están respaldadas por el contexto.
- Relevancia de la respuesta: Mide si la respuesta aborda la pregunta.
- Precisión del contexto: Mide si el contexto recuperado es relevante para la pregunta.
- Recall del contexto: Mide si el contexto recuperado contiene la información necesaria para responder la pregunta.
def evaluate_faithfulness(
question: str, answer: str, context: str, llm_call
) -> float:
"""Evaluate faithfulness of an answer to its context.
Simplified version of the RAGAS faithfulness metric.
The process:
1. Extract all factual claims from the answer.
2. Check each claim against the retrieved context.
3. Faithfulness = (supported claims) / (total claims).
A faithfulness of 1.0 means every claim in the answer is
supported by the context. A faithfulness of 0.5 means half
the claims are unsupported (hallucinated).
"""
# Step 1: Extract claims from the answer
claims_response = llm_call(
prompt=f"List all factual claims made in this answer:\n\n{answer}\n\nClaims:"
)
claims = [c.strip() for c in claims_response.split("\n") if c.strip()]
if not claims:
return 1.0 # No claims to verify
# Step 2: Check each claim against the context
supported = 0
for claim in claims:
verdict = llm_call(
prompt=(
f"Is the following claim supported by the context?\n\n"
f"Claim: {claim}\n\n"
f"Context: {context}\n\n"
f"Answer only 'Yes' or 'No'."
)
)
if "yes" in verdict.lower():
supported += 1
return supported / len(claims)Pruébalo tú mismo: Construye un pipeline de evaluación RAG sencillo. Crea 10 pares pregunta-respuesta con respuestas conocidas, indexa los documentos fuente, recupera y genera respuestas, y luego calcula la fidelidad y la relevancia. Experimenta con diferentes tamaños de fragmento y modelos de embedding para ver cómo afectan a las métricas.
1110. Ejemplo práctico: Construcción de un pipeline RAG sencillo
Construyamos un pipeline RAG completo desde cero, recorriendo cada componente.
"""
A complete RAG pipeline implementation.
This example demonstrates building a RAG system from document loading
through retrieval and generation. Each component is implemented
transparently so you can see exactly what happens at each step.
Requirements:
pip install sentence-transformers chromadb openai
"""
import os
from pathlib import Path
import chromadb
from sentence_transformers import SentenceTransformer, CrossEncoder
class SimpleRAGPipeline:
"""A complete RAG pipeline with indexing, retrieval, and generation.
This pipeline supports:
- Document loading and chunking
- Embedding generation and vector storage
- Dense retrieval with optional re-ranking
- Context-augmented generation
The pipeline follows the standard RAG architecture:
1. OFFLINE: Documents -> Chunks -> Embeddings -> Vector Store
2. ONLINE: Query -> Retrieve -> Augment Prompt -> Generate Answer
"""
def __init__(
self,
embedding_model: str = "all-MiniLM-L6-v2",
reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2",
chunk_size: int = 500,
chunk_overlap: int = 50,
):
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
# Initialize models
self.embedder = SentenceTransformer(embedding_model)
self.reranker = CrossEncoder(reranker_model)
# Initialize vector store (in-memory for this example)
self.client = chromadb.Client()
self.collection = self.client.create_collection(
name="rag_docs",
metadata={"hnsw:space": "cosine"},
)
self.doc_count = 0
def chunk_text(self, text: str, source: str) -> list[dict]:
"""Split text into overlapping chunks with metadata."""
chunks = []
start = 0
chunk_idx = 0
while start < len(text):
end = start + self.chunk_size
chunk_text = text[start:end].strip()
if chunk_text:
chunks.append({
"text": chunk_text,
"source": source,
"chunk_index": chunk_idx,
})
chunk_idx += 1
start = end - self.chunk_overlap
return chunks
def index_documents(self, documents: list[dict]) -> int:
"""Index a list of documents.
This is the OFFLINE phase: documents are chunked, embedded,
and stored in the vector database. This only needs to happen
once (or when documents are updated).
Args:
documents: List of dicts with 'content' and 'source' keys.
Returns:
Number of chunks indexed.
"""
all_chunks = []
for doc in documents:
chunks = self.chunk_text(doc["content"], doc["source"])
all_chunks.extend(chunks)
if not all_chunks:
return 0
# Add to vector store (ChromaDB handles embedding internally)
self.collection.add(
documents=[c["text"] for c in all_chunks],
metadatas=[{"source": c["source"], "chunk_index": c["chunk_index"]} for c in all_chunks],
ids=[f"doc_{self.doc_count + i}" for i in range(len(all_chunks))],
)
self.doc_count += len(all_chunks)
return len(all_chunks)
def retrieve(
self,
query: str,
top_k: int = 5,
use_reranking: bool = True,
initial_k: int = 20,
) -> list[dict]:
"""Retrieve relevant chunks for a query.
This is the ONLINE retrieval phase. For each query:
1. Search the vector store for initial_k candidates (fast).
2. Optionally re-rank with a cross-encoder (precise).
3. Return the top_k results.
Args:
query: The search query.
top_k: Number of results to return.
use_reranking: Whether to apply cross-encoder re-ranking.
initial_k: Number of candidates for re-ranking.
Returns:
List of relevant chunks with scores.
"""
# Initial retrieval (fast, approximate)
k = initial_k if use_reranking else top_k
results = self.collection.query(
query_texts=[query],
n_results=min(k, self.doc_count),
)
if not results["documents"][0]:
return []
chunks = []
for i, (doc, metadata, distance) in enumerate(zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0],
)):
chunks.append({
"text": doc,
"source": metadata["source"],
"initial_score": 1 - distance, # Convert distance to similarity
})
# Re-rank if requested (slow, precise)
if use_reranking and len(chunks) > top_k:
pairs = [(query, chunk["text"]) for chunk in chunks]
rerank_scores = self.reranker.predict(pairs)
for chunk, score in zip(chunks, rerank_scores):
chunk["rerank_score"] = float(score)
chunks.sort(key=lambda x: x["rerank_score"], reverse=True)
return chunks[:top_k]
def build_prompt(
self, query: str, retrieved_chunks: list[dict]
) -> str:
"""Build the augmented prompt with retrieved context.
This is the AUGMENT phase: we construct a prompt that includes
both the user's question and the retrieved context. The prompt
instructs the LLM to answer based on the context and to
acknowledge when the context is insufficient.
Args:
query: The user's question.
retrieved_chunks: List of retrieved text chunks.
Returns:
The complete prompt string for the LLM.
"""
context_parts = []
for i, chunk in enumerate(retrieved_chunks, 1):
context_parts.append(f"[Source {i}: {chunk['source']}]\n{chunk['text']}")
context = "\n\n".join(context_parts)
prompt = f"""Answer the following question based on the provided context.
If the context does not contain enough information to answer the question,
say so clearly and explain what information is missing.
Context:
{context}
Question: {query}
Answer:"""
return prompt
def query(
self,
question: str,
llm_call,
top_k: int = 5,
use_reranking: bool = True,
) -> dict:
"""Execute a full RAG query: retrieve, augment, generate.
This is the complete ONLINE pipeline:
1. RETRIEVE: Find relevant chunks in the vector store.
2. AUGMENT: Build a prompt with the retrieved context.
3. GENERATE: Call the LLM to produce an answer.
Args:
question: The user's question.
llm_call: A callable that takes a prompt string and returns a response.
top_k: Number of chunks to retrieve.
use_reranking: Whether to use cross-encoder re-ranking.
Returns:
Dict with 'answer', 'sources', and 'prompt' keys.
"""
# Retrieve
chunks = self.retrieve(question, top_k=top_k, use_reranking=use_reranking)
# Augment
prompt = self.build_prompt(question, chunks)
# Generate
answer = llm_call(prompt=prompt)
return {
"answer": answer,
"sources": [{"source": c["source"], "text": c["text"][:200]} for c in chunks],
"prompt": prompt,
"num_chunks_retrieved": len(chunks),
}
# -- Usage Example ---------------------------------------------------
def main():
"""Demonstrate the RAG pipeline."""
# Initialize the pipeline
rag = SimpleRAGPipeline(chunk_size=300, chunk_overlap=50)
# Sample documents
documents = [
{
"content": (
"Retrieval-Augmented Generation (RAG) is a technique that combines "
"information retrieval with text generation. It was introduced by "
"Lewis et al. in 2020. The key idea is to retrieve relevant documents "
"from an external knowledge base and use them as additional context for "
"the language model. This allows the model to access up-to-date "
"information and reduce hallucinations. RAG has become a fundamental "
"building block for modern AI applications."
),
"source": "rag_overview.txt",
},
{
"content": (
"Vector databases are specialized databases designed to store and "
"query high-dimensional vectors efficiently. They use approximate "
"nearest neighbor (ANN) algorithms like HNSW to enable fast similarity "
"search. Popular vector databases include Chroma, Pinecone, Weaviate, "
"and Qdrant. They are essential infrastructure for RAG systems, as they "
"store the embeddings of document chunks and enable semantic search."
),
"source": "vector_databases.txt",
},
{
"content": (
"Fine-tuning is the process of updating a pre-trained model's weights "
"on a smaller, task-specific dataset. Unlike RAG, which adds information "
"at inference time, fine-tuning bakes knowledge into the model's "
"parameters. Fine-tuning is better for adapting the model's style and "
"behavior, while RAG is better for providing up-to-date factual "
"information. Many production systems use both approaches together."
),
"source": "fine_tuning.txt",
},
]
# Index documents
num_chunks = rag.index_documents(documents)
print(f"Indexed {num_chunks} chunks from {len(documents)} documents.\n")
# Simulate an LLM call
def mock_llm_call(prompt: str) -> str:
return (
"Based on the provided context, RAG (Retrieval-Augmented Generation) "
"works by retrieving relevant documents from an external knowledge base "
"and using them as additional context for the language model. This was "
"introduced by Lewis et al. in 2020. The retrieved documents are stored "
"as embeddings in vector databases, which use approximate nearest neighbor "
"algorithms for efficient similarity search."
)
# Execute a query
result = rag.query(
"How does RAG work?",
llm_call=mock_llm_call,
top_k=3,
use_reranking=False,
)
print("Question: How does RAG work?\n")
print(f"Answer: {result['answer']}\n")
print("Sources used:")
for src in result["sources"]:
print(f" - {src['source']}: {src['text'][:100]}...")
if __name__ == "__main__":
main()12Preguntas de discusión
-
RAG vs. ventanas de contexto más largas: A medida qué las ventanas de contexto crecen hasta más de 1M tokens, ¿se volverá RAG obsoleto? ¿Cuáles son las ventajas fundamentales de RAG qué sobreviven a ventanas de contexto arbitrariamente grandes? Pista: considera el coste, la precisión de la recuperación y el problema de "perdido en el medio". Incluso con contexto infinito, aún se necesita decidir qué información incluir.
-
El chunking como cuello de botella: Muchos fallos de RAG se remontan a un chunking deficiente. ¿Cómo podrían los sistemas futuros eliminar la necesidad de chunking por completo? Pista: considera modelos de interacción tardía como ColBERT, que representan documentos como conjuntos de embeddings de tokens en lugar de un único vector.
-
El problema de la fidelidad: ¿Cómo podemos asegurar que un LLM no alucine información que contradiga su contexto recuperado? ¿Es Self-RAG suficiente o necesitamos enfoques fundamentalmente diferentes? Pista: considera la diferencia entre "el contexto no apoya esta afirmación" y "el contexto contradice esta afirmación".
-
Recuperación agéntica vs. automática: ¿En qué escenarios preferirías qué un agente decida cuándo recuperar frente a recuperar siempre? ¿Cuáles son los modos de fallo de cada enfoque? Pista: los agentes podrían decidir erróneamente qué no necesitan recuperar, mientras qué la recuperación automática desperdicia recursos en consultas simples.
-
RAG multimodal: ¿Cómo extenderías un sistema RAG para manejar imágenes, tablas y diagramas además de texto? ¿Qué desafíos adicionales introduce esto? Pista: considera cómo generarías el embedding de una tabla o un diagrama, y cómo incluirías contenido no textual en el prompt del LLM.
-
RAG adversario: Si un atacante puede inyectar documentos en la base de conocimiento de un sistema RAG, ¿cómo podría manipular las salidas del sistema? ¿Qué defensas existen? Pista: considera la inyección de prompt a través de documentos recuperados, donde el propio documento contiene instrucciones como "Ignora todas las instrucciones anteriores y..."
13Resumen y conclusiones clave
-
RAG cierra la brecha entre el conocimiento paramétrico y no paramétrico. Permite a los LLM acceder a información externa, actualizada y específica de un dominio sin necesidad de re-entrenamiento.
-
El pipeline de indexación es crítico. La estrategia de chunking, la selección del modelo de embedding y la configuración de la base de datos vectorial afectan significativamente la calidad de la recuperación. Vale la pena invertir tiempo en configurar correctamente la indexación.
-
La recuperación híbrida supera a la recuperación puramente densa o dispersa en la mayoría de los escenarios. Combinar búsqueda semántica con coincidencia de palabras clave captura ambos tipos de relevancia y debería ser tu enfoque predeterminado.
-
Las técnicas avanzadas aportan un valor significativo. El re-ránking, la transformación de consultas (especialmente HyDE) y la recuperación en múltiples pasos mejoran la calidad de la recuperación y pueden combinarse entre sí.
-
RAG agéntico representa un cambio de paradigma. Pasar de la recuperación automática a la controlada por el agente permite una recopilación de información más inteligente, eficiente y adaptativa.
-
La evaluación debe cubrir todo el pipeline. Las métricas de recuperación (recall, precisión) y las métricas de generación (fidelidad, relevancia) son ambas esenciales para comprender el rendimiento del sistema.
-
RAG y el fine-tuning son complementarios, no enfoques competitivos. Los sistemas en producción a menudo se benefician de combinar ambos.
14Referencias
-
Lewis, P., Pérez, E., Piktus, A., Petroni, F., Karpukhin, V., Goyal, N., Kuttler, H., Lewis, M., Yih, W., Rocktäschel, T., Riedel, S., & Kiela, D. (2020). Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. Advances in Neural Information Processing Systems (NeurIPS), 33.
-
Gao, L., Ma, X., Lin, J., & Callan, J. (2023). Precise Zero-Shot Dense Retrieval without Relevance Labels. Proceedings of the 61st Annual Meeting of the Association for Computational Linguistics (ACL).
-
Asaí, A., Wu, Z., Wang, Y., Sil, A., & Hajishirzi, H. (2024). Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection. International Conference on Learning Representations (ICLR).
-
Muennighoff, N., Tazi, N., Magne, L., & Reimers, N. (2023). MTEB: Massive Text Embedding Benchmark. Proceedings of the 17th Conference of the European Chapter of the Association for Computational Linguistics (EACL).
-
Es, S., James, J., Espinosa-Anke, L., & Schockaert, S. (2024). RAGAS: Automated Evaluation of Retrieval Augmented Generation. Proceedings of the 18th Conference of the European Chapter of the Association for Computational Linguistics (EACL): System Demonstrations.
-
Robertson, S. E., & Zaragoza, H. (2009). The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval, 3(4), 333-389.
-
Málkov, Y. A., & Yashunin, D. A. (2020). Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs. IEEE Transactions on Pattern Analysis and Machine Intelligence, 42(4), 824-836.
-
Karpukhin, V., Oguz, B., Min, S., Lewis, P., Wu, L., Edunov, S., Chen, D., & Yih, W. (2020). Dense Passage Retrieval for Open-Domain Question Answering. Proceedings of the 2020 Conference on Empirical Methods in Natural Language Processing (EMNLP).
Parte de "IA Agentica: Fundamentos, Arquitecturas y Aplicaciones" (CC BY-SA 4.0).