RAG — Módulo 7: búsqueda híbrida, juntar BM25 y vectores sin romper nada
Séptimo módulo del Curso de RAG: por qué los scores de BM25 y de coseno no son comparables, las dos familias de fusión (normalizar con alpha o fusionar por ranking con RRF), un ejemplo numérico a mano y las queries nativas de Elasticsearch, Weaviate y Qdrant.
☰ Índice del contenido
- La tesis del módulo
- El problema central: los scores no son comparables
- Familia 1: fusión por score (normalizar y ponderar)
- Familia 2: fusión por ranking (RRF)
- El ejemplo numérico que lo explica todo
- La implementación completa
- La comparación que ordena la decisión
- Elegir alpha, y cuándo el híbrido no ayuda
- El pipeline completo
- El embudo: cuántos candidatos traer de cada etapa
- Las queries nativas
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En el módulo 5 vimos la búsqueda vectorial: entiende el significado pero se le escapan los códigos de producto, los nombres propios y la jerga interna. En el módulo 6 vimos BM25: encuentra el token exacto pero no tiene idea de que “devolver” y “reembolso” son lo mismo. Cada uno falla justo donde el otro acierta, y esa complementariedad es demasiado obvia como para desaprovecharla. Este módulo es sobre juntarlos. Y el problema no es correr las dos búsquedas — eso es trivial — sino fusionar dos listas de resultados cuyos puntajes no significan lo mismo. Ahí es donde se rompe la mayoría de las implementaciones caseras.
La tesis del módulo
No elijas entre léxico y semántico: corre los dos y fusiona. El híbrido supera a cualquiera de los dos por separado en casi todos los benchmarks, pero solo si la fusión se hace por posición y no por puntaje, porque los puntajes de BM25 y de coseno viven en escalas distintas y sumarlos directo es un error conceptual, no un detalle de implementación.
El problema central: los scores no son comparables
Este es el punto que hay que entender antes de escribir una línea. Las dos búsquedas devuelven un número por documento, pero esos números no son de la misma naturaleza.
El coseno está acotado. La similitud coseno (el coseno del ángulo entre el vector de la pregunta y el del chunk) vive matemáticamente en [-1, 1], y con embeddings modernos en la práctica se mueve entre 0.2 y 0.9. Tiene techo y piso conocidos, y un 0.85 significa más o menos lo mismo en cualquier query.
El score de BM25 no está acotado. BM25 suma, por cada término de la query que aparece en el documento, un aporte que depende de tres cosas: la frecuencia del término en ese documento, el IDF (inverse document frequency, cuán raro es el término en todo el corpus) y la longitud del documento comparada con el promedio. No hay techo. Un documento puede sacar 8.3 en una query y 42.1 en otra. Y peor: el rango depende de la query, porque una query de un término raro produce scores altísimos y una de tres términos comunes produce scores bajos, sobre el mismo corpus.
Las consecuencias son inmediatas:
# El error clásico que se ve en repos de ejemplo. NO hagas esto.
score_final = score_bm25 + score_coseno
# BM25 = 31.4, coseno = 0.87 -> el coseno es ruido de redondeo.
# La búsqueda vectorial deja de existir: BM25 domina siempre.
Sumarlos directo no es “ponderar mal”: es apagar una de las dos ramas. Y como el rango de BM25 varía por query, tampoco puedes elegir un factor de conversión fijo y quedarte tranquilo. De ahí salen las dos familias de solución.
Familia 1: fusión por score (normalizar y ponderar)
La idea es llevar ambos puntajes a una escala común y después promediarlos con un peso.
La normalización más usada es min-max: dentro del conjunto de resultados devuelto, el mejor documento pasa a valer 1, el peor a 0, y el resto queda proporcionalmente en el medio.
def min_max(scores: list[float]) -> list[float]:
"""Lleva una lista de scores al rango [0, 1] usando el mínimo y el
máximo del propio lote. Ojo: la escala depende del lote, no es
absoluta."""
lo, hi = min(scores), max(scores)
if hi == lo: # todos iguales: evita dividir por 0
return [1.0] * len(scores)
return [(s - lo) / (hi - lo) for s in scores]
La alternativa es z-score: restar el promedio y dividir por la desviación estándar, lo que deja los valores centrados en 0. Es menos sensible a un outlier que se lleve el máximo, pero produce valores negativos y sigue dependiendo del lote.
Con los scores normalizados, la fusión es una combinación lineal gobernada por alpha, un número entre 0 y 1 que decide cuánto pesa cada rama:
def fusion_por_score(densos: dict, lexicos: dict, alpha: float = 0.5):
"""densos y lexicos son dict {doc_id: score} ya normalizados a [0,1].
alpha=1.0 es puro vectorial, alpha=0.0 es puro BM25."""
ids = set(densos) | set(lexicos)
return {
# .get(d, 0.0): si un doc salió solo en una rama, la otra aporta 0
d: alpha * densos.get(d, 0.0) + (1 - alpha) * lexicos.get(d, 0.0)
for d in ids
}
Su ventaja es real: puedes ponderar. Si tu corpus es un catálogo de repuestos lleno de SKUs, subes el peso léxico (alpha bajo, tipo 0.3). Si es un manual escrito en prosa, subes el semántico (alpha 0.7).
Su desventaja también es real y es la que la gente descubre tarde: la normalización depende del lote de resultados. El documento que sacó el mejor score de BM25 en esta query vale 1.0, aunque en términos absolutos sea malísimo. Si en otra query el mejor es excelente, también vale 1.0. Estás comparando posiciones relativas disfrazadas de puntajes absolutos, y eso hace que el mismo alpha se comporte distinto de query en query.
Familia 2: fusión por ranking (RRF)
RRF (Reciprocal Rank Fusion, fusión por rango recíproco) resuelve el problema por la vía radical: tira los scores a la basura y usa solo la posición.
La fórmula, del paper de Cormack, Clarke y Buettcher (2009), es:
score(d) = Σ 1 / (k + rank_i(d))
i
Donde rank_i(d) es la posición del documento d en la lista i (1 para el primero, 2 para el segundo), y la suma recorre todas las listas que estás fusionando. Un documento que no aparece en una lista simplemente no suma por esa lista.
La constante k vale 60 por convención — es el valor del paper original y quedó como default de la industria. Su función es amortiguar: sin k, el primer lugar valdría 1/1 = 1.0 y el segundo 1/2 = 0.5, una caída brutal que haría que el primero de cualquier lista dominara todo. Con k=60, el primero vale 1/61 = 0.0164 y el segundo 1/62 = 0.0161: la diferencia entre posiciones vecinas se vuelve pequeña, y lo que termina pesando es aparecer bien rankeado en varias listas a la vez. Bajar k hace la fusión más agresiva con los primeros puestos; subirlo la aplana.
El ejemplo numérico que lo explica todo
Cinco documentos, una query. Estas son sus posiciones en cada rama y el RRF resultante con k=60:
| Doc | rank BM25 | rank denso | 1/(60+r) BM25 | 1/(60+r) denso | RRF total | Puesto final |
|---|---|---|---|---|---|---|
| D3 | 3 | 3 | 0.01587 | 0.01587 | 0.03175 | 1 |
| D5 | 5 | 8 | 0.01538 | 0.01471 | 0.03009 | 2 |
| D1 | 1 | 20 | 0.01639 | 0.01250 | 0.02889 | 3 |
| D2 | 20 | 1 | 0.01250 | 0.01639 | 0.02889 | 4 |
| D4 | 2 | ausente | 0.01613 | 0.00000 | 0.01613 | 5 |
Las filas están ordenadas por el RRF resultante, que es el orden en que estos documentos llegarían al reranker.
Lee la primera fila con calma, porque es la moraleja completa del algoritmo. D3 no fue primero en ninguna de las dos búsquedas — salió 3° en ambas — y aun así gana. Le gana a D1, que fue primero en BM25, y a D2, que fue primero en el denso. ¿Por qué? Porque D1 y D2 fueron primeros en una lista y 20° en la otra: cada uno convenció solo a un juez. D3 convenció a los dos.
Eso es exactamente lo que quieres de una fusión. Un documento que es el mejor por coincidencia de palabras y razonablemente bueno por significado es casi siempre una respuesta más segura que uno que arrasó en una señal y fue ignorado por la otra. La consistencia entre señales independientes es evidencia; el pico en una sola señal muchas veces es una coincidencia (una palabra rara que apareció por casualidad, o un chunk que “suena” al tema sin contenerlo).
D5 refuerza la lección: nunca estuvo cerca del podio — 5° y 8° — y aun así queda segundo, por encima de dos documentos que fueron primeros. Ser consistentemente decente en ambas señales vale más que ser espectacular en una sola.
Fíjate también en D4: fue 2° en BM25, un puesto excelente, pero no apareció en el top del denso. Termina último. RRF castiga duro la ausencia, y eso es deseado: si una de las dos señales ni siquiera lo consideró candidato, hay motivo para desconfiar.
La implementación completa
from collections import defaultdict
def rrf(listas: list[list[str]], k: int = 60, top_n: int = 20):
"""Reciprocal Rank Fusion.
listas: cada elemento es una lista de doc_id YA ORDENADA de mejor a
peor por una rama de búsqueda (BM25, denso, lo que sea).
k: constante de amortiguación. 60 es el default del paper.
Devuelve [(doc_id, score_rrf)] ordenado de mejor a peor.
Nota: no recibe ningún score. Esa es toda la gracia: la función es
inmune a la escala de cada motor.
"""
acumulado = defaultdict(float)
for lista in listas:
for posicion, doc_id in enumerate(lista, start=1):
# +1/(k+rank) por cada lista donde el doc aparece.
# Aparecer en varias listas suma varias veces: ahí está el
# premio a la consistencia entre señales.
acumulado[doc_id] += 1.0 / (k + posicion)
ordenado = sorted(acumulado.items(), key=lambda x: x[1], reverse=True)
return ordenado[:top_n]
# --- Uso con las dos ramas del módulo 6 y del módulo 5 ---
resultados_bm25 = ["D1", "D4", "D3", "D7", "D5"] # ya ordenado
resultados_denso = ["D2", "D9", "D3", "D8", "D6"] # ya ordenado
for doc, score in rrf([resultados_bm25, resultados_denso], top_n=5):
print(f"{doc}: {score:.5f}")
# D3: 0.03175 <- 3° en ambas, gana
# D1: 0.01639
# D2: 0.01639
# D4: 0.01613
# D9: 0.01613
Veinticinco líneas y no necesita saber nada de embeddings ni de IDF. Por eso RRF escala también a tres o cuatro ramas (denso + BM25 + búsqueda por título + filtro por metadata) sin tocar la fórmula: agregas una lista más al argumento.
La comparación que ordena la decisión
| Eje | Fusión por score (alpha) | RRF |
|---|---|---|
| Qué usa | Los puntajes, normalizados | Solo las posiciones |
| Requiere normalizar | Sí, y es el punto frágil | No |
| Robustez entre queries | Baja: la escala cambia por lote | Alta: el rango es siempre el mismo |
| Tunabilidad | Alta: alpha por dominio | Baja: solo k (y casi nunca se toca) |
| Aprovecha el margen | Sí: distingue 0.9 de 0.6 | No: 1° es 1° aunque gane por poco |
| Fusionar 3+ ramas | Se complica (pesos que suman 1) | Trivial: agrega otra lista |
| Soporte nativo en motores | Parcial | Elasticsearch, OpenSearch, Qdrant, Weaviate |
| Cuándo usarlo | Sabes que una señal manda en tu dominio y lo mediste | Default. Empieza aquí siempre |
La recomendación práctica es simple: arranca con RRF. Es el default de la industria por una razón — no tiene perillas que ajustar mal y su comportamiento no se degrada cuando cambia el corpus. Solo pásate a alpha si mediste (módulo 10) que en tu dominio una de las dos señales domina consistentemente y RRF te está costando calidad por no poder expresar esa preferencia.
Elegir alpha, y cuándo el híbrido no ayuda
Si vas por la vía del alpha, no lo elijas a ojo. Arma un set de 50 preguntas con su respuesta correcta conocida, corre el pipeline con alpha en 0.0, 0.2, 0.4, 0.5, 0.6, 0.8, 1.0 y grafica la métrica. Casi siempre sale una curva con máximo entre 0.4 y 0.7. Los extremos (0.0 y 1.0) son los baselines: si ninguna combinación les gana, el híbrido no te está aportando y estás pagando infra de más.
Y sí, hay casos donde el híbrido no aporta. El más claro es un corpus puramente conversacional sin jerga ni códigos: transcripciones de llamadas, foros de opinión, reseñas de clientes. Ahí la gente escribe con vocabulario común y muy variable (“no me llegó”, “sigue sin aparecer”, “lo estoy esperando hace dos semanas” son la misma consulta con cero palabras en común). El denso resuelve eso y BM25 aporta poco más que ruido. La regla inversa también vale: mientras más códigos, siglas, nombres propios, versiones y números de norma tenga tu corpus, más gana el híbrido.
Ojo: el híbrido no es gratis. Son dos índices que mantener sincronizados: cada documento nuevo o editado tiene que entrar a los dos, y si el pipeline de ingesta falla a la mitad quedas con un índice adelantado y otro atrasado, produciendo resultados inconsistentes muy difíciles de depurar. Suma además latencia (dos búsquedas, aunque se hagan en paralelo) e infraestructura. Antes de asumir ese costo, mide si te aporta con el set de evaluación del módulo 10. “Todos usan híbrido” no es una medición.
El pipeline completo
Así queda la etapa de recuperación con las dos ramas y la fusión:
START
│
▼
┌──────────────┐
│ pregunta │
│ usuario │
└──────┬───────┘
│
┌───────────────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ BM25 │ │ vectorial │
│ léxico │ │ denso │
│ top-50 │ │ top-50 │
└──────┬───────┘ └──────┬───────┘
│ │
└─────────┬─────────┘
│
▼
┌──────────────┐
│ RRF k=60 │
│ fusión │
│ top-20 │
└──────┬───────┘
│
▼
┌──────────────┐
│ reranker │
│ módulo 8 │
│ top-5 │
└──────┬───────┘
│
▼
┌──────────────┐
│ prompt LLM │ ──► END
└──────────────┘
Las dos ramas se corren en paralelo, no en secuencia: la latencia total es la de la rama más lenta, no la suma.
El embudo: cuántos candidatos traer de cada etapa
El patrón que muestra el diagrama tiene nombre y es el estándar: top-50 por rama → fusión → top-20 → reranker → top-5 al prompt. Cada etapa achica el conjunto y a la vez sube el costo por documento evaluado. Esa es toda la lógica del embudo.
| Etapa | Entrada | Salida | Costo por doc | Qué optimiza |
|---|---|---|---|---|
| BM25 | Millones | 50 | Ínfimo | Recall |
| Vectorial | Millones | 50 | Muy bajo | Recall |
| Fusión RRF | 100 | 20 | Cero (aritmética) | Consolidar señales |
| Reranker | 20 | 5 | Alto (modelo por par) | Precisión |
| LLM | 5 | 1 respuesta | Altísimo | Redacción |
La lógica es que arriba del embudo se prioriza recall (no perder el documento correcto, aunque entren 49 malos con él) y abajo se prioriza precisión (que los 5 que llegan al prompt sean realmente buenos). Traer 50 de cada rama en vez de 10 casi no cuesta nada — la búsqueda vectorial aproximada y BM25 escalan bien — y sube bastante la probabilidad de que el chunk correcto esté en alguna parte. En cambio, mandar 100 documentos al reranker multiplica el costo por 5 sin ganancia proporcional, y mandar 20 chunks al LLM empeora la respuesta por ruido y por lost in the middle (módulo 9).
Números de partida razonables: 50 por rama si tus chunks son de 300-500 tokens y tu corpus pasa las 10.000 unidades. Con corpus chicos (menos de 1.000 chunks), 20 por rama sobra.
Las queries nativas
No siempre necesitas implementar RRF: los motores serios ya lo traen.
Elasticsearch / OpenSearch — el retriever rrf fusiona dos sub-retrievers del lado del servidor:
resp = es.search(
index="documentos",
body={
"retriever": {
"rrf": {
"retrievers": [
{ # rama lexica: BM25 clasico sobre el campo texto
"standard": {
"query": {
"match": {"texto": "garantia legal 6 meses"}
}
}
},
{ # rama densa: kNN sobre el campo de embeddings
"knn": {
"field": "embedding",
"query_vector": vector_pregunta,
"k": 50,
"num_candidates": 200,
}
},
],
"rank_window_size": 50, # cuantos toma de cada rama
"rank_constant": 60, # la k de la formula
}
},
"size": 20,
},
)
Weaviate — hybrid con alpha explícito y opción de cambiar el algoritmo de fusión:
import weaviate.classes as wvc
resultado = coleccion.query.hybrid(
query="garantia legal 6 meses",
alpha=0.5, # 0.0 = puro BM25, 1.0 = puro vectorial
# RANKED = fusión por posición (tipo RRF). RELATIVE_SCORE, el default
# desde la v1.24, normaliza los scores y los pondera con alpha.
fusion_type=wvc.query.HybridFusion.RANKED,
limit=20,
return_metadata=wvc.query.MetadataQuery(score=True, explain_score=True),
)
for obj in resultado.objects:
print(obj.metadata.score, obj.properties["texto"][:80])
Qdrant — la Query API fusiona prefetches en el servidor:
from qdrant_client import models
resultado = client.query_points(
collection_name="documentos",
prefetch=[
models.Prefetch( # rama densa
query=vector_pregunta,
using="denso",
limit=50,
),
models.Prefetch( # rama lexica: vector disperso (BM25/SPLADE)
query=models.SparseVector(indices=idx, values=vals),
using="sparse",
limit=50,
),
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
limit=20,
)
Usar la versión nativa tiene una ventaja concreta más allá de escribir menos código: la fusión ocurre en el motor, así que no transfieres 100 documentos por la red para descartar 80 en tu aplicación.
Errores comunes
- Sumar los scores crudos de BM25 y coseno. Es el error fundacional del módulo. BM25 no está acotado y su magnitud típica es 10 a 50 veces la del coseno, así que la rama densa queda anulada. Normaliza o usa RRF, pero nunca sumes directo.
- Normalizar con constantes fijas “porque BM25 suele llegar a 30”. El rango de BM25 depende de la query y del corpus. La constante que calibraste con tus 20 queries de prueba se rompe en producción con la primera consulta de un solo término raro.
- Correr las ramas en secuencia. Si esperas el resultado de BM25 para recién lanzar la vectorial, duplicas la latencia sin motivo. Son independientes: lánzalas en paralelo con
asyncioo con la query nativa del motor. - Índices desincronizados. El bug más difícil de este módulo: un documento se borró del índice vectorial pero sigue en el léxico. El resultado es que aparece por una rama y desaparece por la otra, y el debugging es infernal. La ingesta tiene que ser transaccional o al menos reconciliarse periódicamente.
- Fusionar sobre identificadores distintos. Si la rama densa devuelve
chunk_idy la léxica devuelvedocumento_id, RRF nunca va a poder sumar dos apariciones del mismo elemento, y el premio a la consistencia — que es todo el punto — desaparece. Ambas ramas tienen que hablar del mismo nivel de granularidad. - Adoptar híbrido sin medir. Duplicas infra, costo y latencia por una mejora que asumes. En corpus conversacionales sin jerga la ganancia puede ser cero o negativa. La decisión es empírica, no de moda.
La frase para llevarse
“RRF no pregunta cuánto le gustaste a cada buscador, sino en qué lugar te dejaron. Y un documento que quedó tercero para los dos jueces es mejor apuesta que uno que fue primero para uno y último para el otro.”
Qué viene
Módulo 8: rerankers, la etapa que más calidad entrega por dólar invertido en todo el pipeline. Vamos a ver la distinción entre bi-encoder (lo que usamos hasta ahora: codificar pregunta y documento por separado y comparar vectores, rapidísimo pero ciego a la interacción entre ambos) y cross-encoder (meter pregunta y documento juntos en el modelo para que la atención los compare token a token, mucho más preciso pero imposible de precomputar). Con eso queda explicado por qué el embudo de este módulo termina en un reranker, cuánto sube realmente la calidad, cuánto cuesta en latencia y plata, y por qué es lo primero que hay que probar cuando tu RAG “no encuentra”.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.