
Arregla un RAG que recupera los chunks equivocados: búsqueda híbrida + reranking (con métricas antes/después, 2026)
Ejecuta búsqueda densa y BM25 en paralelo, fusiónalas con Reciprocal Rank Fusion (RRF, k=60) en un conjunto amplio de ~top-100 candidatos, y reordénalo con un cross-encoder (Voyage rerank-2.5) hasta el top-10 que le pasas a Claude. Mide Recall@k, MRR y NDCG antes y después, y conserva solo lo que las métricas justifiquen.
La respuesta corta: híbrido + RRF + rerank, y mide antes y después
Si tu RAG devuelve pasajes plausibles pero equivocados, el arreglo es un pipeline de recuperación en dos etapas. Corre la búsqueda densa (embeddings) y un BM25 de palabras clave en paralelo, fusiona las dos listas con Reciprocal Rank Fusion (RRF, k=60) en un conjunto amplio de candidatos (~top-100) y reordénalo con un cross-encoder como Voyage rerank-2.5 hasta el top-10 que le pasas a Claude. Después mide Recall@k, MRR y NDCG antes y después, y conserva solo los cambios que tus números justifiquen. Este artículo asume que ya tienes el sistema RAG base con Claude; aquí hacemos que su recuperación sea precisa y probamos la mejora.
La trampa es confiar en una demo. El top-k solo denso se siente correcto en las consultas que probaste a mano y luego falla en silencio en la cola larga en producción. No puedes ver esa falla sin un set de evaluación etiquetado, y por eso la medición es la columna vertebral de todo el artículo, no algo que se agrega al final. Si aún no decides si RAG es siquiera la herramienta correcta, lee primero RAG vs fine-tuning.
Por qué falla el top-k solo denso: términos exactos y parecido-pero-no-relevante
La búsqueda por embeddings falla de dos maneras distintas, y se acumulan.
Primero, subestima los términos de coincidencia exacta. Los embeddings mapean el texto a un espacio semántico, y justo por eso un código de producto como SKU-4471-B, una sigla rara o un apellido quedan diluidos en un vecindario difuso en lugar de coincidir literalmente. El usuario escribió el único token que identifica la respuesta y la búsqueda vectorial se encogió de hombros.
Segundo, devuelve chunks parecidos pero no relevantes. La similitud coseno premia la cercanía temática, así que un párrafo sobre “cómo restablecer una contraseña” puntúa alto para la consulta “restablecer mi PIN de cobro”: misma forma, respuesta equivocada. El chunk correcto está en tu corpus; solo que no quedó en el top-k que devolvió el bi-encoder.
Pierde términos exactos
- Códigos de producto, SKUs, números de parte
- Siglas raras y nombres propios
- El token literal que fija la respuesta
- BM25 (búsqueda por keyword) lo arregla
Devuelve parecido-pero-no-relevante
- Cercano en tema, equivocado en el hecho
- Coseno alto, relevancia real baja
- El chunk correcto existe pero queda bajo el corte
- Un reranker cross-encoder lo arregla
Dos arreglos se apilan limpiamente sobre estas dos fallas: la búsqueda híbrida sube el recall (logra que el chunk correcto entre siquiera al conjunto de candidatos) y el reranking sube la precision (lo empuja arriba para que sobreviva el corte a top-10).
Búsqueda híbrida: denso + BM25, y cuándo gana cada uno
La búsqueda híbrida significa correr dos recuperadores y fusionar sus resultados. El recuperador denso es la búsqueda por embeddings de Voyage que ya construiste. El recuperador disperso (sparse) es BM25, la clásica función de ranking por palabra clave que puntúa documentos por frecuencia y rareza del término: la misma familia que mueve Elasticsearch y la búsqueda de texto completo de Postgres.
Ganan en consultas opuestas. BM25 clava las consultas de término exacto que los embeddings tropiezan: dale SKU-4471-B y encuentra el único chunk que contiene esa cadena literal. La búsqueda densa clava las paráfrasis que BM25 tropieza: pregunta “cómo evito que la app se caiga al abrir” y recupera el chunk sobre “resolver excepciones de arranque” aunque no compartan ni una palabra clave. Corre ambas y cada una cubre el punto ciego de la otra.
El objetivo del híbrido es el recall, no el ranking final. Todavía no intentas ordenar perfectamente los resultados: intentas asegurar que el chunk correcto caiga en algún lugar del conjunto fusionado para que el reranker pueda promoverlo. Un arreglo típico saca BM25 top-50 y denso top-50, y luego fusiona.
Reciprocal Rank Fusion (RRF): la fórmula score(d) = suma 1/(k+rank), k=60
El problema de fusionar dos listas ordenadas es que sus scores son incompatibles. Los scores de BM25 son sumas de frecuencia de término sin cota; las similitudes coseno viven en un rango acotado. Promediarlos no significa nada. Reciprocal Rank Fusion esquiva esto ignorando por completo los scores crudos y fusionando solo por el rank (la posición).
Para cada documento d, RRF suma una pequeña contribución de cada lista donde aparece, y esa contribución encoge con la posición:
def rrf(ranked_lists, k=60):
# ranked_lists: list of lists, each an ordered list of doc IDs (best first).
scores = {}
for lst in ranked_lists:
for rank, doc_id in enumerate(lst, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
# Sort by fused score, highest first.
return sorted(scores, key=scores.get, reverse=True)
# Fuse BM25 and dense results into one ranked candidate list.
fused = rrf([bm25_ids, dense_ids], k=60)
candidates = fused[:100] # keep a wide top-100 for reranking
La fórmula es score(d) = suma sobre las listas de 1 / (k + rank(d)). La constante k=60 es el valor por defecto común: amortigua la influencia de las primeras posiciones para que una sola lista no domine, y hace la fusión robusta ante posiciones ruidosas al principio. Un documento que aparece cerca del tope de ambas listas acumula más, que es justo la señal de consenso que quieres. RRF es barato, no tiene scores que normalizar, y le gana a la mezcla ingenua por scores ponderados precisamente porque nunca toca las escalas incompatibles.
Reranking: cross-encoder vs bi-encoder, recupera-amplio-reordena-estrecho (top-100 → top-10)
La búsqueda híbrida logra que el chunk correcto entre al conjunto de candidatos. Un reranker es lo que lo empuja al tope. La distinción que importa es bi-encoder contra cross-encoder.
Tu recuperación de primer nivel usa un bi-encoder: embebe la consulta y cada documento por separado y luego compara vectores. Eso es rápido y precalculable —puedes indexar millones de vectores de documento por adelantado— pero la consulta y el documento nunca se “ven” de verdad, así que la señal de relevancia es tosca.
Un reranker cross-encoder lee cada par (query, passage) en conjunto en una sola pasada y puntúa la relevancia real. Es mucho más preciso porque modela directamente la interacción entre los dos textos. El costo es la velocidad: no puedes correr un cross-encoder sobre todo el corpus, solo sobre una lista corta. De ahí el patrón recupera amplio, reordena estrecho: fusiona a un top-100, reordena esos 100 y conserva el top-10 para el LLM. En los benchmarks MTEB y BEIR, agregar un reranker cross-encoder suele dar un lift de +5 a +15 NDCG@10 sobre la recuperación de primer nivel sola.
- BM25 top-50recall disperso por keyword (términos exactos)
- Denso top-50recall semántico bi-encoder (paráfrasis)
- RRF fusiona -> top-100fusión por rank, k=60, conjunto amplio
- Cross-encoder rerank -> top-10puntúa (query, passage) en conjunto, para precision
- Claude generafundamentado en los 10 mejores chunks
Qué reranker: Voyage rerank-2.5 (32K, sigue instrucciones, +7.94% sobre Cohere), Cohere, BGE
Anthropic recomienda Voyage AI tanto para embeddings como para reranking, y encaja con el mismo cliente que ya usas para embeddings. Los modelos actuales son rerank-2.5 y el más veloz rerank-2.5-lite. Según los propios números de Voyage, mejoran la precisión de recuperación +7.94% y +7.16% respectivamente sobre Cohere Rerank v3.5, admiten 32K tokens de contexto (así reordenas chunks largos sin truncarlos) y siguen instrucciones (instruction-following): puedes pasar una instrucción junto a la consulta para orientar qué significa “relevante” en tu dominio.
Llamarlo es una línea. Le pasas la consulta y los textos candidatos, y devuelve reordenados y con nuevo score:
import voyageai
vo = voyageai.Client() # reads VOYAGE_API_KEY from the environment
reranked = vo.rerank(
query="how do I reset my billing PIN?",
documents=candidate_texts, # the ~100 texts from the RRF merge
model="rerank-2.5",
top_k=10, # keep only the 10 best for Claude
)
best_chunks = [r.document for r in reranked.results]
# reranked.results are ordered best-first, each with .relevance_score
Las alternativas son reales y vale la pena conocerlas: Cohere Rerank (3.5 / 4 Pro) es un competidor alojado en la nube, y los cross-encoders open source BGE y Jina (más FlashRank como opción local ligera) te dejan auto-hospedar sin costo por llamada. El mecanismo es idéntico en todos —un cross-encoder puntuando pares (query, passage)— así que puedes cambiar el reranker sin tocar el resto del pipeline. Verifica los números y parámetros contra la documentación del reranker de Voyage, nunca contra la memoria.
El pipeline completo: BM25 top-50 + denso top-50 → RRF top-100 → rerank top-10 → Claude
Aquí está la recuperación de dos etapas armada completa. El primer nivel corre BM25 y denso en paralelo; RRF los fusiona a un conjunto amplio; el cross-encoder reordena a los diez finales; Claude genera una respuesta fundamentada sobre esos diez.
import voyageai
import anthropic
vo = voyageai.Client()
claude = anthropic.Anthropic()
def retrieve(question, corpus, bm25_index, doc_embeddings):
# 1. Sparse: BM25 keyword search -> top-50 doc IDs.
bm25_ids = bm25_index.top_n(question, n=50)
# 2. Dense: bi-encoder vector search -> top-50 doc IDs.
q_vec = vo.embed([question], model="voyage-4",
input_type="query").embeddings[0]
dense_ids = nearest_neighbors(q_vec, doc_embeddings, n=50)
# 3. Fuse both lists with RRF (k=60) and keep a wide top-100.
candidate_ids = rrf([bm25_ids, dense_ids], k=60)[:100]
candidate_texts = [corpus[i] for i in candidate_ids]
# 4. Cross-encoder rerank -> top-10 for the LLM context.
reranked = vo.rerank(query=question, documents=candidate_texts,
model="rerank-2.5", top_k=10)
return [r.document for r in reranked.results]
def answer(question, chunks):
# 5. Generate a grounded, cited answer with Claude.
context = "\n\n".join(f"[{i+1}] {c}" for i, c in enumerate(chunks))
resp = claude.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system=("Answer using ONLY the numbered context. Cite as [n]. "
"If it's not there, say you don't know.\n\n" + context),
messages=[{"role": "user", "content": question}],
)
return resp.content[0].text
Las llamadas nearest_neighbors y bm25_index representan el almacén que uses: mete ahí la consulta de distancia coseno de pgvector y un índice de texto completo de Postgres o Elasticsearch de la comparativa de bases vectoriales. La forma del pipeline no cambia. El paso 5 es la API de Claude respondiendo con fundamento.
Medir la mejora: Recall@k / Hit-Rate, MRR, NDCG, y una tabla antes/después (RAGAS)
Este es el punto entero: prueba la mejora, no la asumas. Cada cambio de arriba cuesta latencia y dinero, así que solo conservas los que tus métricas justifiquen. Las métricas de recuperación que te importan son:
Recall@k/Hit-Rate@k: ¿el chunk relevante conocido está en algún lugar del top-k? Esta es la pregunta de recall que ataca la búsqueda híbrida.Precision@k: ¿qué fracción del top-k es de verdad relevante?MRR(mean reciprocal rank): ¿qué tan arriba cae el primer acierto relevante, promediado sobre las consultas? Esto es lo que mueve el reranking.NDCG@k: calidad del orden, premiando que los chunks relevantes queden cerca del tope.
Aquí un snippet autocontenido para las dos que más importan, corrido sobre un set de evaluación etiquetado:
def recall_at_k(retrieved_ids, relevant_id, k):
return 1.0 if relevant_id in retrieved_ids[:k] else 0.0
def reciprocal_rank(retrieved_ids, relevant_id):
for rank, doc_id in enumerate(retrieved_ids, start=1):
if doc_id == relevant_id:
return 1.0 / rank
return 0.0
def evaluate(eval_set, retrieve_fn, k=10):
recalls, rrs = [], []
for query, relevant_id in eval_set: # eval_set: (query, known_relevant_chunk_id)
ids = retrieve_fn(query)
recalls.append(recall_at_k(ids, relevant_id, k))
rrs.append(reciprocal_rank(ids, relevant_id))
return {"recall@k": sum(recalls) / len(recalls),
"mrr": sum(rrs) / len(rrs)}
before = evaluate(eval_set, dense_only_retrieve) # baseline
after = evaluate(eval_set, hybrid_rerank_retrieve) # new pipeline
Córrelo sobre tu baseline y sobre tu pipeline nuevo, y pon las dos filas una junto a la otra. El framework RAGAS es el estándar open source para el lado de generación —definió el patrón de cuatro métricas: context precision, context recall, faithfulness y answer relevance— así que una vez que la recuperación está sólida, mide también si las respuestas siguen fundamentadas. Para el harness más amplio, ve la evaluación mínima de un agente de IA.
Construir un pequeño set de evaluación etiquetado (consulta → chunk relevante conocido)
No necesitas miles de etiquetas para empezar. Un set de evaluación útil son 30 a 50 consultas, cada una emparejada con el ID del chunk que de verdad la responde. Saca las consultas de logs reales, de tickets de soporte reales o de las preguntas que tu equipo ya sabe que hacen los usuarios: los casos de término exacto y de paráfrasis de antes van aquí a propósito, porque ahí es donde el solo denso se rompe.
Etiquétalas una vez, guárdalas como una lista simple de tuplas (query, relevant_chunk_id) y vuelve a correr evaluate() en cada cambio del pipeline. Ese es tu harness de regresión: cuando alguien proponga subir el conjunto de RRF a top-200 o cambiar el reranker, el set de evaluación responde “¿de verdad ayudó?” en segundos. Sáltate este paso y estarás afinando a ciegas: cada “mejora” es una corazonada, no una medición. Si además cambias cómo cortas los documentos, mídelo con las estrategias de chunking + Contextual Retrieval, porque el chunking mueve estas mismas métricas.
El trade-off de costo y latencia: cuántos milisegundos suma cada etapa
Cada etapa compra precisión con latencia, así que pesa cada una contra tu presupuesto:
BM25 es casi gratis: una consulta a un índice de palabras clave, unos pocos milisegundos. La recuperación densa es una llamada de embedding para la consulta más una búsqueda ANN, decenas de milisegundos dominadas por el viaje de red del embedding. RRF es aritmética pura sobre unos cientos de IDs: despreciable. El reranking es la etapa cara: una pasada del cross-encoder sobre ~100 pares (query, passage), más el viaje de red si está alojado. Es el mayor añadido de latencia, y por eso reordenas 100 candidatos y no 10,000.
Cuando la latencia aprieta, rerank-2.5-lite cambia un poco de precisión por menor latencia y costo, y puedes encoger el conjunto de candidatos. También puedes bajar el costo de generación de forma independiente con prompt caching de Claude sobre las partes estables de tu contexto. La disciplina es la misma en todo: gira cada perilla, vuelve a correr el set de evaluación y conserva el ajuste solo si los números lo ganaron.
FAQ: híbrido vs rerank, k de RRF, cuántas consultas de eval, cuándo saltarse el reranking
¿Búsqueda híbrida o reranking, cuál primero? Ambos, y arreglan cosas distintas. El híbrido sube el recall (mete el chunk correcto al conjunto de candidatos); el reranking sube la precision (lo empuja al top-10). El reranking no puede promover un chunk que el híbrido nunca recuperó, así que construye el híbrido primero y luego reordena encima.
¿Por qué k=60 en RRF? Es el valor por defecto común. La constante k amortigua cuánto aporta una sola posición del tope, para que ninguna lista domine y las posiciones ruidosas del tope no sesguen la fusión. Trátalo como un punto de partida sensato y afínalo contra tu set de evaluación.
¿Cuántas consultas de evaluación necesito? Empieza con 30 a 50 pares etiquetados (query, chunk relevante) sacados de uso real. Es suficiente para detectar regresiones y ordenar variantes del pipeline; crécelo conforme encuentres casos de falla.
¿Cuándo puedo saltarme el reranking? Cuando tus números de evaluación ya pasan tu umbral solo con el híbrido, o cuando la latencia está tan ajustada que la llamada extra al cross-encoder no es costeable. La respuesta está en tu tabla de Recall@k y MRR, no en una regla general, que es justo el punto de medir antes y después.
Para el contexto completo de cómo fundamentar la respuesta generada, ve chatbot de IA sin alucinaciones, y para cómo encaja la recuperación en el resto del stack, el hub de agentes de IA reúne las guías compañeras, incluida la de estrategias de chunking y Contextual Retrieval que va de la mano con esta.