Módulo 5: Hybrid Search — combinando keyword + semantic para queries que necesitan ambas
Cápsula 04: Reciprocal Rank Fusion — la fórmula simple que combina rankings de cualquier origen
Descripción de la cápsula
Tienes dos rankings: uno de BM25 (cápsula 03) y otro de semantic search. ¿Cómo los combinas para obtener un ranking final unificado? La respuesta intuitiva es "promediar scores", pero esa intuición rompe en la práctica — los scores tienen rangos completamente distintos (BM25 va de 0 a 50+, cosine similarity de 0 a 2). Sumar peras con manzanas da resultados sin sentido.
Reciprocal Rank Fusion (RRF) resuelve el problema cambiando el frame: en vez de combinar scores, combina posiciones en el ranking. Un documento en posición #1 contribuye 1/(60+1) = 0.0164 a su score final, sin importar qué algoritmo produjo ese ranking. Un documento en posición #5 contribuye 1/(60+5) = 0.0154. La fusión es robusta a cualquier diferencia entre los rankings de origen.
Esta cápsula te enseña la fórmula, su implementación trivial (~10 líneas), por qué el parámetro k=60 es el default razonable, cuándo conviene tunearlo, y los modos de falla típicos.
Al finalizar esta cápsula serás capaz de:
- ✅ Implementar RRF en menos de 15 líneas con tipado correcto
- ✅ Explicar por qué RRF funciona donde "sumar scores" rompe
- ✅ Tunear el parámetro
kpara tu caso (default: 60, rango útil: 20-100) - ✅ Decidir cuándo RRF es suficiente vs cuándo conviene weighted fusion (cápsula 05)
- ✅ Anticipar los modos de falla: rankings demasiado cortos, signo de score, deduplicación
- ✅ Combinar más de dos rankings (3+ señales) sin reescribir la lógica
Tiempo estimado: 25-30 minutos
El insight: combinar posiciones, no scores
Para entender por qué RRF gana, mira lo que pasa con la opción intuitiva "sumar scores":
# Ranking 1: BM25
bm25 = [
("doc_A", 12.5), # rank 1
("doc_B", 8.2), # rank 2
("doc_C", 5.1), # rank 3
]
# Ranking 2: Semantic (cosine distance, menor = mejor)
semantic = [
("doc_C", 0.21), # rank 1
("doc_A", 0.34), # rank 2
("doc_D", 0.42), # rank 3
]
# Sumar scores ingenuamente:
# doc_A: 12.5 + 0.34 = 12.84 ← BM25 domina por magnitud
# doc_B: 8.2 + ? ← no aparece en semantic
# doc_C: 5.1 + 0.21 = 5.31 ← cosine domina si invertimos
# doc_D: ? + 0.42 ← no aparece en BM25
Problemas:
- Magnitudes incomparables: BM25 va de 0 a 50+, cosine de 0 a 2. La suma siempre está dominada por BM25 sin importar la calidad real.
- Manejo de "no aparece": ¿qué score le pones a un doc que está en uno pero no en el otro? Si pones 0, lo penalizas de más. Si lo ignoras, pierdes información.
- Cosine es "menor = mejor", BM25 es "mayor = mejor": signos opuestos. Hay que invertir uno.
RRF cambia el frame: en vez de scores, usa posiciones. Cada documento contribuye 1 / (k + rank) por cada ranking donde aparece. Posiciones están en la misma escala (1, 2, 3, ...) sin importar el algoritmo de origen.
# Con RRF (k=60):
# doc_A: 1/(60+1) + 1/(60+2) = 0.0164 + 0.0161 = 0.0325 ← rank alto en ambos
# doc_C: 1/(60+3) + 1/(60+1) = 0.0159 + 0.0164 = 0.0323 ← rank alto en ambos
# doc_B: 1/(60+2) = 0.0161 ← solo en BM25
# doc_D: 1/(60+3) = 0.0159 ← solo en semantic
# Ranking final: doc_A, doc_C, doc_B, doc_D
Lectura: docs que aparecen alto en ambos rankings ganan (doc_A, doc_C). Docs que aparecen solo en uno con rank decente quedan después. La fusión refleja la "confianza combinada" de las dos señales.
La fórmula completa
RRF_score(doc) = Σ_i 1 / (k + rank_i(doc))
donde:
- i itera sobre todos los rankings que estás fusionando
- rank_i(doc) = posición del doc en el ranking i (1-based: 1, 2, 3, ...)
- k = constante (default 60)
- Si el doc NO aparece en el ranking i, ese término es 0
Por qué k=60 es el default: vino del paper original (Cormack et al., 2009). Empíricamente funciona bien en la mayoría de casos. Detalle importante: k controla qué tan "decisivos" son los rankings altos vs bajos.
# Con k=60 (default)
1/(60+1) = 0.0164 # rank 1
1/(60+5) = 0.0154 # rank 5
1/(60+10) = 0.0143 # rank 10
1/(60+50) = 0.0091 # rank 50
# Diferencia rank 1 vs rank 50: 0.0164 / 0.0091 = 1.8x
# Con k=10 (más decisivo a rank alto)
1/(10+1) = 0.0909 # rank 1
1/(10+5) = 0.0667 # rank 5
1/(10+50) = 0.0167 # rank 50
# Diferencia rank 1 vs rank 50: 0.0909 / 0.0167 = 5.4x
k chico hace que los rankings altos dominen. k grande aplana las diferencias.
Implementación
# rrf.py
from typing import List
from collections import defaultdict
def reciprocal_rank_fusion(
rankings: List[List[str]],
k: int = 60,
) -> List[tuple[str, float]]:
"""
Fusiona N rankings usando Reciprocal Rank Fusion.
Args:
rankings: lista de rankings. Cada ranking es lista de doc_ids ordenados
por relevancia descendente (rank 1 = mejor).
k: constante RRF. Default 60 (del paper original).
Returns:
Lista de (doc_id, rrf_score) ordenada por score descendente.
"""
rrf_scores: dict[str, float] = defaultdict(float)
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
rrf_scores[doc_id] += 1.0 / (k + rank)
sorted_results = sorted(rrf_scores.items(), key=lambda x: -x[1])
return sorted_results
# Probar
bm25_ranking = ["doc_A", "doc_B", "doc_C", "doc_D", "doc_E"]
semantic_ranking = ["doc_C", "doc_A", "doc_F", "doc_B", "doc_G"]
fused = reciprocal_rank_fusion([bm25_ranking, semantic_ranking])
print("Ranking fusionado:")
for doc_id, score in fused:
print(f" {doc_id}: {score:.4f}")
Output:
Ranking fusionado:
doc_A: 0.0325 ← top en BM25 (#1), alto en semantic (#2)
doc_C: 0.0323 ← medio en BM25 (#3), top en semantic (#1)
doc_B: 0.0318 ← #2 en BM25, #4 en semantic
doc_F: 0.0159 ← solo en semantic (#3)
doc_D: 0.0156 ← solo en BM25 (#4)
doc_E: 0.0154 ← solo en BM25 (#5)
doc_G: 0.0154 ← solo en semantic (#5)
Nota:
- Docs que aparecen en ambos rankings (A, B, C) dominan el top.
- Docs que aparecen solo en uno (D, E, F, G) quedan después.
- Entre los que están solo en uno, ranking alto importa: D (#4 en BM25) gana sobre G (#5 en semantic).
Pipeline hybrid completo con RRF
# hybrid_search.py
import chromadb
from chromadb.utils import embedding_functions
from rank_bm25 import BM25Okapi
import os
# Setup
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
api_key=os.getenv("OPENAI_API_KEY"),
model_name="text-embedding-3-small",
)
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection("docs", embedding_function=openai_ef)
# Cargar todos los docs y construir índice BM25 (una vez al startup)
all_docs = collection.get()
all_doc_ids = all_docs['ids']
all_doc_texts = all_docs['documents']
tokenized_corpus = [doc.lower().split() for doc in all_doc_texts]
bm25_index = BM25Okapi(tokenized_corpus)
def hybrid_search(query: str, top_k: int = 5, n_per_method: int = 30):
"""
Pipeline hybrid: BM25 + semantic + RRF.
Args:
query: query del usuario
top_k: número de resultados finales
n_per_method: cuántos candidatos retrieve cada método
Returns:
Lista de doc_ids ordenada por relevancia hybrid.
"""
# 1. Semantic search
semantic_results = collection.query(query_texts=[query], n_results=n_per_method)
semantic_ranking = semantic_results['ids'][0]
# 2. BM25 search
query_tokens = query.lower().split()
bm25_scores = bm25_index.get_scores(query_tokens)
bm25_top_indices = sorted(range(len(bm25_scores)), key=lambda i: -bm25_scores[i])[:n_per_method]
bm25_ranking = [all_doc_ids[i] for i in bm25_top_indices]
# 3. RRF
fused = reciprocal_rank_fusion([semantic_ranking, bm25_ranking])
final_ids = [doc_id for doc_id, score in fused[:top_k]]
# 4. Recuperar contenido
return collection.get(ids=final_ids)
# Probar
results = hybrid_search("OAuth2PasswordBearer scopes", top_k=5)
print(f"Hybrid top 5:")
for i, (doc_id, doc) in enumerate(zip(results['ids'], results['documents']), 1):
print(f"\n#{i} [{doc_id}]")
print(f" {doc[:120]}...")
Tuning del parámetro k
k=60 es el default razonable, pero algunos casos justifican ajustarlo:
| Caso | k recomendado | Por qué |
|---|---|---|
| Default general | 60 | Validado empíricamente en muchos benchmarks |
| Quieres que rankings altos dominen | 10-30 | Diferencia entre rank 1 y rank 50 se amplifica |
| Quieres combinar más equitativamente | 80-150 | Diferencia entre rankings se aplana, "confianza ancha" gana |
| Tienes rankings cortos (<20 docs) | 10-30 | Con k=60 sobre rankings cortos, todos los scores son muy parecidos |
| Tienes rankings largos (>100 docs) | 60-100 | k mayor previene que docs en posición 80-100 inflen el resultado |
Cómo encontrar el k óptimo empíricamente:
def find_optimal_k(eval_set, k_values=[10, 30, 60, 100, 150]):
"""Encuentra el k que maximiza recall sobre eval set."""
best_k = 60
best_recall = 0
for k in k_values:
recalls = []
for item in eval_set:
# Ejecutar hybrid con este k
semantic_ids = get_semantic_ranking(item.query, top_k=30)
bm25_ids = get_bm25_ranking(item.query, top_k=30)
fused = reciprocal_rank_fusion([semantic_ids, bm25_ids], k=k)
top_5_ids = set(doc_id for doc_id, _ in fused[:5])
relevant_in_top_5 = top_5_ids & set(item.expected_doc_ids)
recalls.append(len(relevant_in_top_5) / len(item.expected_doc_ids))
avg_recall = sum(recalls) / len(recalls)
print(f"k={k}: recall={avg_recall:.2%}")
if avg_recall > best_recall:
best_recall = avg_recall
best_k = k
return best_k
optimal_k = find_optimal_k(eval_set)
print(f"\nOptimal k: {optimal_k}")
En la mayoría de casos, el k óptimo es 30-80. Si tu eval set sugiere k <20 o k >150, probablemente hay otro problema (rankings de origen pobres).
Combinar más de dos rankings
RRF escala trivialmente a N rankings. Solo agregas más entradas al loop:
# Hybrid con 3 señales: semantic + BM25 + reranking de cross-encoder
semantic_ranking = collection.query(...)['ids'][0]
bm25_ranking = bm25_search(...)
cross_encoder_ranking = cross_encoder_rerank(...) # solo top-N
fused = reciprocal_rank_fusion(
[semantic_ranking, bm25_ranking, cross_encoder_ranking],
k=60,
)
Patrón típico de "estado del arte" en RAG production:
Query
↓
├─→ Semantic search (top-30) ──┐
├─→ BM25 search (top-30) ──┼─→ RRF ─→ top-10 ─→ Cross-encoder rerank ─→ top-5
└─→ HyDE search (top-30, opcional) ──┘
Trampas y errores comunes
Trampa 1: rankings de tamaño desbalanceado
El error: semantic devuelve top-50, BM25 devuelve top-5.
Síntoma: docs que aparecen en BM25 contribuyen mucho menos al RRF score que docs solo en semantic, simplemente porque BM25 tiene menos posiciones.
Cómo prevenir: rankings de igual tamaño. n_per_method consistente entre fuentes.
Trampa 2: usar BM25 ranking SOLO sobre los docs ya retrieved por semantic
El error: primero semantic search top-30, después BM25 reranking sobre esos 30.
Síntoma: BM25 ya está limitado a lo que semantic encontró. Pierde la ventaja de descubrir docs relevantes que semantic no encontraba.
Cómo prevenir: BM25 debe correr sobre el corpus completo, no sobre el subset de semantic. Cada método busca independientemente, después fusión.
Trampa 3: signo del score (cosine es "menor = mejor")
El error: ChromaDB devuelve cosine distance (menor = más similar). Pasas eso directamente como ranking — pero invertido.
Síntoma: RRF da rankings extraños porque el orden está invertido en una de las fuentes.
Cómo prevenir: verificar siempre que tus rankings están ordenados con rank 1 = más relevante. Para ChromaDB:
# Resultados ya vienen ordenados por relevancia (menor distance primero)
# El primer ID es rank 1 (más relevante)
semantic_ranking = results['ids'][0] # OK
Trampa 4: olvidar deduplicar antes de RRF
El error: dos chunks distintos del mismo documento aparecen en el ranking. RRF los ranquea como dos docs distintos.
Síntoma: top-5 contiene 2-3 chunks del mismo doc, pierdes diversidad.
Cómo prevenir: deduplicar por doc_id antes de fusión, o agregar lógica que filtra después:
def dedupe_by_parent_doc(fused_ids: list[str]) -> list[str]:
"""Mantiene solo el primer chunk de cada doc padre."""
seen_parents = set()
deduped = []
for chunk_id in fused_ids:
parent = chunk_id.split("_")[0] # asume formato parent_chunk_N
if parent not in seen_parents:
seen_parents.add(parent)
deduped.append(chunk_id)
return deduped
Trampa 5: RRF con k=0
El error: algún tutorial sugiere k=0 y lo aceptas.
Síntoma: división por cero o por número muy chico. Scores explotan.
Cómo prevenir: k siempre >= 1. Default 60 es seguro y razonable.
Trampa 6: comparar RRF contra weighted fusion sin medir
El error: asumes que RRF siempre gana sobre weighted fusion (próxima cápsula).
Realidad: weighted fusion gana cuando una de las señales es claramente mejor que la otra para tu dominio (ej: BM25 mucho mejor que semantic, o viceversa). RRF asume "ambos son igualmente confiables".
Cómo prevenir: medir empíricamente sobre eval set. Si una señal es 20%+ mejor que la otra, weighted fusion con peso adaptativo puede mejorar el ranking final.
Ejercicio aplicado
Escenario: eres AI Engineer en una empresa de e-commerce. Tu sistema RAG sirve preguntas sobre productos.
Datos:
- 200K productos con descripciones, specs, reviews
- Queries del log:
- 40% son SKUs y códigos de producto exactos:
"NIKE-AM2024-RED-43" - 25% son nombres parciales con identificadores:
"Air Max 2024 size 43" - 25% son conceptuales:
"running shoes for marathons" - 10% son comparativas:
"Air Max vs Pegasus"
- 40% son SKUs y códigos de producto exactos:
Sistema actual: solo semantic search. Recall@5 = 58%.
Tu trabajo:
- Implementa hybrid search con BM25 + RRF.
- Decide el
kapropiado y justifica. - Estima el impacto esperado en recall.
Solución
1. Implementación de hybrid con RRF
# ecommerce_hybrid.py
from rank_bm25 import BM25Okapi
import re
def ecommerce_tokenizer(text: str) -> list[str]:
"""Tokenizer que preserva SKUs y códigos de producto."""
text_lower = text.lower()
# Tokens normales
tokens = re.findall(r'\b\w+\b', text_lower)
# SKUs y códigos (NIKE-AM2024-RED-43)
sku_tokens = re.findall(r'[A-Z]+-[A-Z0-9-]+', text) # preservar mayúsculas
tokens.extend([t.lower() for t in sku_tokens])
# Versiones de producto (AM2024)
version_tokens = re.findall(r'[A-Z]{2,}\d+', text)
tokens.extend([t.lower() for t in version_tokens])
# Tallas (size 43, 43 EU)
size_tokens = re.findall(r'\b\d{2,3}\b', text)
tokens.extend(size_tokens)
return tokens
def setup_hybrid(products: list[dict]):
"""Construye índice BM25 + asume que semantic ya está indexado en ChromaDB."""
bm25_corpus = []
product_ids = []
for p in products:
# Concatenar SKU + nombre + descripción
text = f"{p['sku']} {p['name']} {p['description']}"
tokenized = ecommerce_tokenizer(text)
bm25_corpus.append(tokenized)
product_ids.append(p['sku'])
bm25_index = BM25Okapi(bm25_corpus)
return bm25_index, product_ids
def hybrid_search(query: str, bm25_index, product_ids, semantic_collection, top_k=5):
# Semantic
sem_results = semantic_collection.query(query_texts=[query], n_results=30)
semantic_ranking = sem_results['ids'][0]
# BM25
query_tokens = ecommerce_tokenizer(query)
bm25_scores = bm25_index.get_scores(query_tokens)
bm25_top = sorted(range(len(bm25_scores)), key=lambda i: -bm25_scores[i])[:30]
bm25_ranking = [product_ids[i] for i in bm25_top]
# RRF
fused = reciprocal_rank_fusion([semantic_ranking, bm25_ranking], k=30)
return [doc_id for doc_id, _ in fused[:top_k]]
2. k=30 justificación
Para este dominio, k=30 es mejor que el default de 60.
Razones:
- 40% del tráfico son SKUs exactos. En esos casos, BM25 va a tener match casi perfecto en posición #1, mientras semantic puede subir el SKU a posición #5-10. Queremos que BM25 rank #1 domine.
k=30amplifica la diferencia entre rank 1 y rank 30. Conk=60, un doc que está #1 en BM25 y #5 en semantic recibe ~0.0325 RRF. Conk=30, recibe ~0.0608. Si el mismo doc está en posición #20 en semantic, contribuye solo 0.0011 conk=60pero 0.0204 conk=30. La diferencia entre buenas y malas posiciones se amplifica.
Verificación con eval set:
for k in [10, 30, 60, 100]:
recall = evaluate_hybrid_with_k(eval_set, k=k)
print(f"k={k}: recall@5={recall:.2%}")
# Output esperado:
# k=10: recall@5=82%
# k=30: recall@5=85% ← mejor
# k=60: recall@5=82%
# k=100: recall@5=78%
3. Impacto esperado en recall
Por categoría:
Categoría % Recall actual Recall hybrid
────────────────────────────────────────────────────────────────────
SKUs exactos 40% 35% 92% (BM25 perfecto)
Nombres parciales 25% 62% 82%
Conceptuales 25% 78% 80% (semantic ya gana)
Comparativas 10% 55% 78%
Recall global ponderado:
Actual: 0.40(0.35) + 0.25(0.62) + 0.25(0.78) + 0.10(0.55)
= 0.140 + 0.155 + 0.195 + 0.055 = 0.545 ≈ 54%
Hybrid: 0.40(0.92) + 0.25(0.82) + 0.25(0.80) + 0.10(0.78)
= 0.368 + 0.205 + 0.200 + 0.078 = 0.851 ≈ 85%
Mejora: +31 puntos de recall global
Plan de validación:
- Construir eval set con 80 queries reales distribuidas según las % del log.
- Medir recall@5 baseline (semantic only).
- Implementar hybrid con
k=30yn_per_method=30. - Re-medir.
- Si recall sube >25 puntos sin caída de precision, deployar.
Plan B si BM25 no aporta como esperado:
- Revisar tokenización: ¿maneja correctamente los SKUs específicos del catálogo?
- Verificar que el corpus BM25 incluye toda la metadata relevante (no solo descripciones).
- Considerar
kaún más chico (k=10 o 20) si los SKUs deben dominar absolutamente.
Resumen y siguiente paso
Lo que aprendiste:
- RRF combina rankings de múltiples fuentes ignorando scores absolutos. Solo importa la posición.
- Fórmula:
RRF_score(doc) = Σ 1/(k + rank_i(doc))para cada ranking i. k=60es el default razonable. Tunear a 20-100 según dominio.kchico amplifica diferencias entre rankings altos vs bajos. Útil cuando una señal debe dominar.- Implementación trivial: ~10 líneas. Robusta a cualquier diferencia de scores entre fuentes.
- Escala a N rankings: agregas más entradas al loop, no requiere reescritura.
- Pipeline típico: semantic + BM25 → RRF → cross-encoder rerank → top-K.
- Trampas: rankings desbalanceados, signo del score, deduplicación olvidada.
Checkpoint: antes de avanzar, deberías poder:
- Implementar RRF en menos de 15 líneas con tipado correcto.
- Justificar la elección de
kpara tu caso con datos del eval set. - Diseñar pipeline hybrid con RRF + dedupe + rerank en cascada.
Siguiente cápsula: 05 — Weighted hybrid blending.
RRF es robusto pero asume que todas las fuentes son igual de confiables. La cápsula 05 cubre weighted fusion: cuando sabes que BM25 es mejor para queries específicas (con identificadores) y semantic es mejor para conceptuales, puedes ponderar cada señal según el tipo de query. Más tuning, más complejidad, pero a veces vale la mejora marginal.
Recursos
- RRF Paper (Cormack, Clarke, Buettcher 2009) — Paper original con análisis empírico
- Pinecone — Hybrid Search with RRF — Tutorial visual
- Elasticsearch — RRF Implementation — Versión nativa en ES
- LangChain — EnsembleRetriever — Implementación con RRF
- LlamaIndex — QueryFusionRetriever — Patrón en LlamaIndex
- BEIR Benchmark — Comparación empírica RRF vs alternativas
Tiempo estimado: 25-30 minutos Siguiente: 05-weighted-hybrid-blending.md