Tu RAG falla en la búsqueda, no en el modelo: chunking, búsqueda híbrida y reranking
Cómo medir la recuperación de un RAG y corregirla con fragmentos con contexto, búsqueda híbrida BM25 + embeddings, fusión RRF y reranking.

Si el fragmento que contiene la respuesta no llegó al contexto, ningún modelo va a responder bien. Puede inventar algo plausible, puede decir que no sabe, pero no puede citar un párrafo que nunca vio. Por eso cambiar de modelo o reescribir el prompt rara vez arregla un RAG que responde mal: el fallo suele estar un paso antes, en la recuperación.
Este artículo recorre el orden en que conviene depurarlo: medir primero, después corregir cómo se cortan los documentos, añadir búsqueda léxica a la semántica, fusionar resultados y, solo si hace falta, reordenar con un reranker.
Paso cero: mide la recuperación por separado
Arma un conjunto de evaluación pequeño: entre 30 y 100 preguntas reales de usuarios (o de tu equipo de soporte), y para cada una anota a mano qué fragmento contiene la respuesta. Es trabajo tedioso de una tarde, y es lo que separa ajustar a ciegas de ajustar con datos.
Con eso calculas recall@k: en qué porcentaje de preguntas el fragmento correcto aparece entre los k primeros resultados.
def recall_at_k(evaluaciones, buscar, k=5):
"""evaluaciones: lista de (pregunta, id_fragmento_correcto)."""
aciertos = 0
for pregunta, id_correcto in evaluaciones:
ids = [fragmento.id for fragmento in buscar(pregunta, k=k)]
if id_correcto in ids:
aciertos += 1
return aciertos / len(evaluaciones)
print(recall_at_k(evaluaciones, buscar_vectorial, k=5))
print(recall_at_k(evaluaciones, buscar_vectorial, k=20))
La diferencia entre recall@5 y recall@20 ya te dice mucho. Si recall@20 es alto y recall@5 es bajo, el fragmento correcto se encuentra pero queda mal ordenado: eso lo resuelve un reranker. Si recall@20 también es bajo, el problema está en cómo cortas o indexas, y ningún reranker lo va a salvar porque no puede reordenar lo que no recibió.
No midas solo la respuesta final con otro LLM como juez. Si la respuesta es mala, necesitas saber si falló la recuperación o la generación, y una sola nota no lo distingue.
Chunking: el fragmento tiene que entenderse solo
El error más común es cortar por número fijo de caracteres sin mirar la estructura. Un fragmento que empieza a mitad de una tabla, o que dice «este límite se aplica a las cuentas empresariales» sin que aparezca qué límite, genera un embedding que no representa nada útil. La búsqueda lo ignora o, peor, lo devuelve para preguntas que no tienen que ver.
Tres cambios que suelen mover más el recall que cualquier ajuste de tamaño:
- Cortar por estructura del documento: títulos, secciones, artículos de un reglamento, funciones en código. El tamaño en tokens pasa a ser un límite máximo, no el criterio de corte.
- Anteponer al texto de cada fragmento el título del documento y la ruta de secciones antes de calcular el embedding. Por ejemplo:
Manual de facturación > Límites > Cuentas empresariales. Cuesta unos pocos tokens por fragmento y le devuelve al texto el contexto que perdió al cortarse. - Convertir tablas a filas autocontenidas cuando sean de consulta (cada fila con los nombres de sus columnas), en lugar de dejar que el corte las parta por la mitad.
| Estrategia | Funciona bien con | Se rompe con |
|---|---|---|
| Tamaño fijo con solapamiento (p. ej. 500 tokens, 50 de solape) | Texto corrido sin estructura: transcripciones, correos | Tablas, listas numeradas, documentos con referencias internas |
| Por estructura (títulos, secciones) | Manuales, documentación técnica, normativa | Secciones muy largas: hay que subdividirlas igual, y ahí reaparece el problema |
| Por estructura + título y ruta antepuestos | Casi todo lo anterior; es el punto de partida razonable | PDFs mal extraídos, donde los títulos no se detectan y la ruta sale vacía o equivocada |
Antes de culpar al chunking, abre cinco fragmentos al azar y léelos. Si un humano no entiende de qué hablan sin abrir el documento original, el embedding tampoco.
Por qué los embeddings no encuentran «E-4012»
Un embedding resume de qué trata un texto. Es bueno para encontrar «cómo cancelo mi suscripción» cuando el documento dice «baja del servicio». Es malo para identificadores exactos: códigos de error, referencias de producto, números de artículo de una ley, nombres de funciones. Para el modelo de embeddings, «error E-4012» y «error E-4013» son casi el mismo vector, y para tu usuario son dos problemas distintos.
La búsqueda léxica (BM25, o el full-text search de tu base de datos) hace lo contrario: es un Ctrl+F con ponderación. Premia los términos que aparecen en la consulta y son raros en la colección. No entiende sinónimos, pero no confunde un código con otro.
Cada método falla justo donde el otro acierta, y por eso conviene combinarlos. Si tu corpus tiene códigos, siglas, nombres propios o terminología interna, usar solo embeddings deja fuera justo las preguntas más concretas.
No necesitas otra infraestructura. En PostgreSQL con la extensión pgvector puedes hacer las dos búsquedas sobre la misma tabla:
-- Búsqueda semántica (distancia coseno con pgvector)
SELECT id
FROM fragmentos
ORDER BY embedding <=> $1
LIMIT 50;
-- Búsqueda léxica con el analizador de español
SELECT id
FROM fragmentos
WHERE to_tsvector('spanish', contenido) @@ websearch_to_tsquery('spanish', $2)
ORDER BY ts_rank(to_tsvector('spanish', contenido),
websearch_to_tsquery('spanish', $2)) DESC
LIMIT 50;
En producción conviene guardar el tsvector en una columna generada con un índice GIN, en lugar de calcularlo en cada consulta.
Fusionar dos listas sin comparar puntuaciones
Las puntuaciones de BM25 y las distancias coseno viven en escalas distintas y no se pueden sumar directamente. Normalizarlas funciona mal porque la distribución cambia de una consulta a otra. La forma más simple y robusta es ignorar las puntuaciones y usar solo la posición en cada lista: Reciprocal Rank Fusion (RRF).
Cada documento recibe, por cada lista en la que aparece, 1 / (k + posición), con k = 60 como valor habitual. Se suman y se ordena.
def rrf(listas, k=60):
puntajes = {}
for lista in listas:
for posicion, doc_id in enumerate(lista, start=1):
puntajes[doc_id] = puntajes.get(doc_id, 0) + 1 / (k + posicion)
return sorted(puntajes, key=puntajes.get, reverse=True)
candidatos = rrf([ids_lexicos, ids_semanticos])
Un ejemplo con números: un fragmento que sale 1.º en la búsqueda léxica y 3.º en la semántica suma 1/61 + 1/63 ≈ 0,0323. Otro que sale 2.º solo en la semántica suma 1/62 ≈ 0,0161. Aparecer en las dos listas pesa más que una buena posición en una sola, que es exactamente el comportamiento que buscas.
La constante k suaviza la ventaja de los primeros puestos. Con k bajo, el primer resultado de cualquier lista domina; con k alto, la fusión se acerca a contar en cuántas listas aparece cada documento. No la ajustes hasta haber resuelto el chunking: el efecto es pequeño comparado con el de cortar bien los documentos.
Reranking: pagar latencia para ordenar mejor
Los embeddings comparan la pregunta y el fragmento por separado: cada uno se convierte en un vector y luego se mide la distancia. Un reranker de tipo cross-encoder lee la pregunta y el fragmento juntos y devuelve una puntuación de relevancia. Es más preciso porque puede ver relaciones entre las palabras de ambos textos, y más caro porque tiene que procesar cada par por separado en el momento de la consulta.
El flujo típico: recuperar 50 candidatos con la búsqueda híbrida, reordenarlos con el reranker y pasar al LLM solo los 5 mejores.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3") # multilingüe
def reordenar(pregunta, fragmentos, top=5):
pares = [(pregunta, f.texto) for f in fragmentos]
puntuaciones = reranker.predict(pares)
ordenados = sorted(zip(fragmentos, puntuaciones),
key=lambda par: par[1], reverse=True)
return [f for f, _ in ordenados[:top]]
La latencia crece de forma lineal con el número de candidatos: reordenar 50 cuesta aproximadamente diez veces lo que reordenar 5. En CPU eso puede significar cientos de milisegundos o más por consulta; en GPU, o con una API de reranking, baja bastante. Mide con tus fragmentos reales, porque la longitud del texto pesa tanto como la cantidad.
Errores frecuentes y limitaciones
Subir k para «asegurarse» de que la respuesta esté en el contexto. Pasar 20 fragmentos de 500 tokens son 10.000 tokens de entrada por consulta, frente a 2.500 con 5: cuatro veces el costo de entrada y más latencia. Además, los modelos aprovechan peor la información situada en medio de contextos largos (el efecto descrito en el artículo «Lost in the Middle», de Liu y otros, 2023). Más fragmentos no equivale a mejor respuesta.
Usar un modelo de embeddings entrenado sobre todo en inglés para documentos en español. Funciona, pero peor de lo que debería, sobre todo con terminología técnica local. Elige un modelo multilingüe y compáralo con tu conjunto de evaluación, no con los rankings públicos, que miden otros corpus.
Olvidar que cambiar de modelo de embeddings obliga a recalcular todo el índice. Con millones de fragmentos eso es tiempo y dinero. Guarda junto a cada vector el nombre y la versión del modelo que lo generó.
Buscar en toda la colección cuando la pregunta ya trae un filtro. Si el usuario pregunta por el producto X o por documentos de 2025, filtra por metadatos antes de buscar. Reduce candidatos irrelevantes de forma más barata y fiable que cualquier reranker.
Y el más difícil de ver: PDFs mal extraídos. Columnas mezcladas, encabezados repetidos en cada página, tablas convertidas en una sola línea de números. Si el texto que indexas está roto, todo lo demás de este artículo opera sobre basura.
Preguntas frecuentes
¿Necesito una base de datos vectorial dedicada?
Para la mayoría de proyectos de hasta unos pocos millones de fragmentos, PostgreSQL con pgvector y su búsqueda de texto completo cubre la búsqueda híbrida en un solo sistema, con transacciones y filtros por metadatos que ya conoces. Una base dedicada empieza a compensar cuando el volumen o los requisitos de latencia superan lo que tu Postgres maneja con índices HNSW, y eso conviene medirlo con tus datos.
¿Cuántos fragmentos paso finalmente al modelo?
Empieza con 5 después del reranking (o de la fusión RRF si no usas reranker) y mide. Si recall@5 es bajo pero recall@10 es alto, prueba con 8 o 10 antes de tocar otra cosa, y vigila el costo por consulta.
¿Y si el usuario hace preguntas que necesitan información de varios documentos?
La recuperación por similitud está pensada para encontrar un fragmento relevante, no para reunir piezas dispersas. Para preguntas comparativas o agregadas («qué planes incluyen X y cuánto cuestan») suele funcionar mejor descomponer la pregunta en subpreguntas, recuperar para cada una y combinar, o directamente consultar datos estructurados con SQL en lugar de texto.
Orden de trabajo recomendado
- Anota 50 preguntas con su fragmento correcto y calcula recall@5 y recall@20 con tu búsqueda actual.
- Si recall@20 es bajo, corrige el chunking y antepón título y ruta de secciones a cada fragmento.
- Añade búsqueda léxica y fusiona con RRF; vuelve a medir.
- Si recall@20 ya es alto y recall@5 no, añade el reranker.
Hoy mismo puedes hacer el primer paso: exporta las últimas 50 preguntas de tus usuarios, busca a mano dónde está cada respuesta y guarda los pares en un CSV. Todo lo demás se decide con ese archivo.


