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 k para 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:

  1. 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.
  2. 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.
  3. 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:

Casok recomendadoPor qué
Default general60Validado empíricamente en muchos benchmarks
Quieres que rankings altos dominen10-30Diferencia entre rank 1 y rank 50 se amplifica
Quieres combinar más equitativamente80-150Diferencia entre rankings se aplana, "confianza ancha" gana
Tienes rankings cortos (<20 docs)10-30Con k=60 sobre rankings cortos, todos los scores son muy parecidos
Tienes rankings largos (>100 docs)60-100k 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"

Sistema actual: solo semantic search. Recall@5 = 58%.

Tu trabajo:

  1. Implementa hybrid search con BM25 + RRF.
  2. Decide el k apropiado y justifica.
  3. 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=30 amplifica la diferencia entre rank 1 y rank 30. Con k=60, un doc que está #1 en BM25 y #5 en semantic recibe ~0.0325 RRF. Con k=30, recibe ~0.0608. Si el mismo doc está en posición #20 en semantic, contribuye solo 0.0011 con k=60 pero 0.0204 con k=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:

  1. Construir eval set con 80 queries reales distribuidas según las % del log.
  2. Medir recall@5 baseline (semantic only).
  3. Implementar hybrid con k=30 y n_per_method=30.
  4. Re-medir.
  5. 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 k aú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=60 es el default razonable. Tunear a 20-100 según dominio.
  • k chico 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 k para 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

  1. RRF Paper (Cormack, Clarke, Buettcher 2009) — Paper original con análisis empírico
  2. Pinecone — Hybrid Search with RRF — Tutorial visual
  3. Elasticsearch — RRF Implementation — Versión nativa en ES
  4. LangChain — EnsembleRetriever — Implementación con RRF
  5. LlamaIndex — QueryFusionRetriever — Patrón en LlamaIndex
  6. BEIR Benchmark — Comparación empírica RRF vs alternativas

Tiempo estimado: 25-30 minutos Siguiente: 05-weighted-hybrid-blending.md