Módulo 5: Hybrid Search — combinando keyword + semantic para queries que necesitan ambas

Cápsula 05: Weighted hybrid blending — cuando una señal es claramente mejor que la otra

Descripción de la cápsula

RRF (cápsula 04) asume que tus dos rankings (BM25 y semantic) son igualmente confiables. Esa asunción funciona bien en la mayoría de casos. Pero hay dominios donde una señal es claramente superior: en un buscador puramente narrativo, semantic gana siempre; en un catálogo de productos con SKUs, BM25 gana casi siempre. RRF "promedia" ambas, lo que diluye la señal fuerte en lugar de aprovecharla.

Weighted hybrid blending resuelve eso permitiéndote ponderar las señales con un parámetro α (alpha): score = α × semantic + (1-α) × bm25. Si en tu dominio semantic es 70% más confiable, le das α=0.7. Más control, más complejidad de tuning, pero a veces mejora notable.

Esta cápsula te enseña la fórmula correcta (con normalización de scores), cómo tunear α empíricamente, y cuándo weighted vale el esfuerzo extra vs cuándo RRF basta.

Al finalizar esta cápsula serás capaz de:

  • ✅ Implementar weighted blending con normalización de scores correcta
  • ✅ Tunear α para tu corpus con grid search sobre eval set
  • ✅ Diseñar routing dinámico de α según tipo de query
  • ✅ Decidir cuándo weighted gana sobre RRF y cuándo no
  • ✅ Anticipar el error principal: comparar scores no normalizados (rangos incompatibles)
  • ✅ Implementar el patrón híbrido "RRF default + weighted para casos críticos"

Tiempo estimado: 25-30 minutos


Por qué necesitas normalización antes de blendear

La fórmula naive es trampa:

# ❌ NO funciona
hybrid_score = α × bm25_score + (1-α) × semantic_score

El problema: los rangos son incompatibles.

BM25 score:           rango típico 0 - 50+
Cosine similarity:    rango típico 0.7 - 1.0 (en text embeddings normales)
Cosine distance:      rango típico 0 - 0.3 (lo que ChromaDB devuelve)

Si sumas 0.5 × 30 + 0.5 × 0.85, el primer término domina por magnitud (15.0 vs 0.425), no por relevancia. Cualquier α razonable va a dejar a BM25 dominando todo el ranking.

La solución: normalizar ambos scores a [0, 1] antes de combinar.

import numpy as np


def normalize_scores(scores: list[float], method: str = "minmax") -> list[float]:
    """Normaliza scores a rango [0, 1]."""
    if not scores:
        return scores

    arr = np.array(scores, dtype=float)

    if method == "minmax":
        # Min-max scaling: (x - min) / (max - min)
        min_v = arr.min()
        max_v = arr.max()
        if max_v - min_v < 1e-10:
            return [0.5] * len(scores)
        return ((arr - min_v) / (max_v - min_v)).tolist()

    elif method == "zscore":
        # Z-score normalization: (x - mean) / std
        mean = arr.mean()
        std = arr.std()
        if std < 1e-10:
            return [0.5] * len(scores)
        zscores = (arr - mean) / std
        # Mapear a [0, 1] con sigmoid
        return (1 / (1 + np.exp(-zscores))).tolist()

    elif method == "rank":
        # Rank-based: convertir scores a posiciones normalizadas
        ranks = np.argsort(np.argsort(-arr))  # rank 0 = mejor
        return (1 - ranks / max(len(arr) - 1, 1)).tolist()

    raise ValueError(f"Unknown method: {method}")

Recomendación: minmax es el default razonable. rank es más robusto a outliers (un BM25 score de 50 vs scores típicos de 5-10 no distorsiona el resto).


Implementación correcta de weighted blending

# weighted_blending.py
from dataclasses import dataclass


@dataclass
class WeightedHybridResult:
    doc_id: str
    final_score: float
    semantic_score_normalized: float
    bm25_score_normalized: float


def weighted_hybrid(
    semantic_results: dict,   # {"ids": [...], "distances": [...]}
    bm25_results: dict,       # {"ids": [...], "scores": [...]}
    alpha: float = 0.5,       # peso de semantic (0 = solo BM25, 1 = solo semantic)
    top_k: int = 5,
    normalize: str = "minmax",
) -> list[WeightedHybridResult]:
    """
    Combina rankings de semantic y BM25 con pesos α y (1-α).

    Importante: convierte cosine distance a similarity (1 - distance) para que
    "mayor = mejor" en ambos rankings antes de normalizar.
    """
    # Convertir cosine distance → cosine similarity
    semantic_similarities = [1.0 - d for d in semantic_results["distances"]]
    semantic_normalized = normalize_scores(semantic_similarities, method=normalize)

    # BM25 ya es "mayor = mejor"
    bm25_normalized = normalize_scores(bm25_results["scores"], method=normalize)

    # Combinar
    combined = {}

    for doc_id, sem_score in zip(semantic_results["ids"], semantic_normalized):
        combined[doc_id] = {
            "semantic": sem_score,
            "bm25": 0.0,  # default si no aparece en BM25
        }

    for doc_id, bm25_score in zip(bm25_results["ids"], bm25_normalized):
        if doc_id in combined:
            combined[doc_id]["bm25"] = bm25_score
        else:
            combined[doc_id] = {
                "semantic": 0.0,
                "bm25": bm25_score,
            }

    # Calcular final score
    results = []
    for doc_id, scores in combined.items():
        final = alpha * scores["semantic"] + (1 - alpha) * scores["bm25"]
        results.append(WeightedHybridResult(
            doc_id=doc_id,
            final_score=final,
            semantic_score_normalized=scores["semantic"],
            bm25_score_normalized=scores["bm25"],
        ))

    # Ordenar por score descendente
    results.sort(key=lambda x: -x.final_score)
    return results[:top_k]

Uso end-to-end

import chromadb
from chromadb.utils import embedding_functions
from rank_bm25 import BM25Okapi
import os


# Setup ChromaDB y BM25 (asumir ya configurado)
collection = ...
bm25_index = ...
all_doc_ids = ...


def hybrid_search_weighted(query: str, alpha: float = 0.5, top_k: int = 5):
    # Semantic search
    sem_results = collection.query(query_texts=[query], n_results=30)
    semantic_data = {
        "ids": sem_results["ids"][0],
        "distances": sem_results["distances"][0],
    }

    # BM25
    query_tokens = query.lower().split()
    bm25_scores_all = bm25_index.get_scores(query_tokens)
    top_bm25_indices = sorted(range(len(bm25_scores_all)), key=lambda i: -bm25_scores_all[i])[:30]
    bm25_data = {
        "ids": [all_doc_ids[i] for i in top_bm25_indices],
        "scores": [float(bm25_scores_all[i]) for i in top_bm25_indices],
    }

    # Weighted fusion
    final = weighted_hybrid(semantic_data, bm25_data, alpha=alpha, top_k=top_k)
    return final

Tuning de α con grid search

α óptimo depende de tu corpus y queries. La forma correcta de encontrarlo es empírica.

def grid_search_alpha(eval_set, alpha_values=[0.0, 0.2, 0.4, 0.5, 0.6, 0.8, 1.0]):
    """
    Encuentra el α que maximiza recall sobre eval set.

    α=0 → solo BM25 (no usa semantic)
    α=1 → solo semantic (no usa BM25)
    """
    results = {}

    for alpha in alpha_values:
        recalls = []
        for item in eval_set:
            top_5 = hybrid_search_weighted(item.query, alpha=alpha, top_k=5)
            top_5_ids = [r.doc_id for r in top_5]

            relevant = set(item.expected_doc_ids)
            hits = sum(1 for doc_id in top_5_ids if doc_id in relevant)
            recall = hits / len(relevant) if relevant else 0
            recalls.append(recall)

        avg_recall = sum(recalls) / len(recalls)
        results[alpha] = avg_recall
        print(f"α={alpha}: recall@5={avg_recall:.2%}")

    best_alpha = max(results, key=results.get)
    print(f"\nMejor α: {best_alpha} (recall={results[best_alpha]:.2%})")
    return best_alpha

Output típico:

α=0.0: recall@5=72.5%   ← solo BM25
α=0.2: recall@5=78.3%
α=0.4: recall@5=82.1%
α=0.5: recall@5=84.5%   ← peak
α=0.6: recall@5=83.7%
α=0.8: recall@5=78.9%
α=1.0: recall@5=68.2%   ← solo semantic

Mejor α: 0.5

Lectura: la curva tiene forma de U invertida — extremos (solo BM25 o solo semantic) son peores que la mezcla. El óptimo está en el medio. La forma exacta depende del dominio.


Routing dinámico de α por tipo de query

Para corpus con queries muy variadas (algunas técnicas, otras conceptuales), un solo α global no es óptimo. Mejor: detectar el tipo de query y usar α adaptativo.

import re


def detect_query_type(query: str) -> str:
    """Detecta tipo de query con heurística simple."""
    has_identifier = bool(re.search(r'[A-Z][a-z]+[A-Z][a-z]+|[a-z]+_[a-z]+', query))
    has_error_code = bool(re.search(r'[A-Z]{2,}_?[0-9A-Z_]+', query))
    has_version = bool(re.search(r'\bv?\d+\.\d+(\.\d+)?', query))

    word_count = len(query.split())

    if has_identifier or has_error_code or has_version:
        return "exact_match"  # priorizar BM25
    if word_count > 8 and "?" in query:
        return "conceptual"  # priorizar semantic
    return "balanced"


def choose_alpha(query: str) -> float:
    """Elige α según tipo de query."""
    query_type = detect_query_type(query)

    if query_type == "exact_match":
        return 0.3   # priorizar BM25 (70% peso)
    elif query_type == "conceptual":
        return 0.7   # priorizar semantic (70% peso)
    else:
        return 0.5   # balanceado


def smart_hybrid_search(query: str, top_k: int = 5):
    alpha = choose_alpha(query)
    return hybrid_search_weighted(query, alpha=alpha, top_k=top_k)

Beneficio típico: routing dinámico mejora 3-5% sobre α global óptimo, especialmente en corpus mixtos.

Costo: complejidad extra. Mantener heurísticas de detección, validar con eval set segmentado por tipo.


Cuándo weighted gana sobre RRF

CasoMejor opciónPor qué
Una señal es 30%+ mejor que la otra para tu dominioWeighted con α apropiadoRRF promedia, weighted explota la señal fuerte
Corpus mixto con queries muy variadasWeighted con routingRRF global no se adapta; routing sí
Quieres explicar el ranking ("este doc rankeó porque BM25 le dio 0.85")WeightedLos componentes están explícitos
MVP simple, no tienes eval set para tunear αRRFRRF no requiere tuning
Equipo sin ML/data science backgroundRRFConceptualmente más simple
Más de 2 fuentes (semantic + BM25 + HyDE + ...)RRFWeighted con N pesos requiere tuning de cada uno

Patrón pragmático recomendado:

  1. Empezar con RRF (cápsula 04). Funciona en 80% de casos sin tuning.
  2. Medir sobre eval set. Si recall es satisfactorio, parar acá.
  3. Si recall queda bajo: probar weighted con grid search de α.
  4. Si weighted mejora >3%: considerar deployar.
  5. Si quieres exprimir más: routing dinámico de α.

Trampas y errores comunes

Trampa 1: olvidar normalizar antes de blendear

Cubierta arriba. Sin normalización, BM25 domina por magnitud.

Trampa 2: usar cosine distance directamente como "similarity"

El error:

hybrid = α * cosine_distance + (1-α) * bm25_score

Síntoma: menor distance = más similar, pero estás sumando como si fuera "score positivo". El ranking sale invertido.

Cómo prevenir: convertir distance a similarity primero: similarity = 1 - distance.

Trampa 3: tunear α con eval set chico

El error: grid search con 10 queries.

Síntoma: el "α óptimo" varía mucho entre runs. No es estable estadísticamente.

Cómo prevenir: mínimo 50 queries, ideal 100+. Si no puedes conseguir 100, usar α=0.5 default y ahorrar el tuning.

Trampa 4: routing con heurísticas mal calibradas

El error: detect_query_type tiene falsos positivos. Queries semánticas se categorizan como "exact_match", se les pone α=0.3, BM25 domina y la calidad cae.

Síntoma: después de implementar routing, recall baja en algunas categorías.

Cómo prevenir: validar manualmente la categorización sobre 100 queries reales. Ajustar regex hasta que la precisión de la categorización sea >90%.

Trampa 5: comparar α=0 con BM25 standalone y asumir equivalencia

El error: asumes que weighted_hybrid(α=0) da el mismo resultado que BM25 standalone.

Realidad: no necesariamente. weighted_hybrid(α=0) solo considera los docs que están en CUALQUIERA de las dos fuentes (semantic o BM25). BM25 standalone solo considera los que están en BM25.

Cómo prevenir: entender que α=0 es "ignorar el peso de semantic" pero el ranking sigue construido sobre la unión de ambas fuentes.

Trampa 6: weighted como reemplazo total de RRF en producción sin A/B test

El error: después de tunear α, deployas 100% sin validar contra RRF en producción.

Síntoma: la mejora del eval set no se traslada a producción real (queries reales pueden tener distribución distinta).

Cómo prevenir: A/B test producción 1-2 semanas. Si weighted no mejora >3% sobre RRF en métricas reales (CTR, satisfaction), revertir a RRF y simplificar.


Ejercicio aplicado

Escenario: eres AI Engineer en una plataforma de educación corporativa. Datos:

  • 100K cursos chunkeados (mezcla código + texto narrativo + ejemplos)
  • 30K queries/día
  • Sistema actual: hybrid search con RRF (k=60). Recall@5 = 81%

Análisis del eval set:

Tipo de query              %     Recall actual con RRF
─────────────────────────────────────────────────────
Conceptuales              45%    88%
Identificadores de código 30%    72%
Comandos shell            15%    78%
Mixtas (código + texto)   10%    80%

Stakeholders piden: alcanzar recall@5 ≥ 88% global.

Tu trabajo:

  1. Decide si weighted blending con routing puede llegar al target.
  2. Diseña el routing y los α por tipo.
  3. Estima el impacto.
Solución

1. Sí puede alcanzar el target — el problema es el peso fijo de RRF

RRF asume que BM25 y semantic contribuyen igual. Pero los datos muestran:

  • Queries conceptuales: semantic >> BM25. Recall ya alto (88%).
  • Queries con identificadores: BM25 >> semantic. RRF está balanceando, pero BM25 debería pesar más → recall sube.
  • Comandos shell: BM25 >> semantic, similar al anterior.

Routing dinámico de α puede explotar la señal mejor para cada caso.

2. Diseño del routing

def detect_query_type(query: str) -> str:
    """Detecta tipo para routing de α."""
    import re
    has_camel = bool(re.search(r'[A-Z][a-z]+[A-Z][a-z]+', query))
    has_underscore_id = bool(re.search(r'\b\w+_\w+\b', query))
    has_command = bool(re.search(r'\b(kubectl|helm|docker|npm|git|aws)\b', query.lower()))
    is_question = "?" in query or any(query.lower().startswith(w) for w in ["how", "why", "what", "when"])
    word_count = len(query.split())

    if has_command:
        return "shell_command"  # BM25 fuerte
    if has_camel or has_underscore_id:
        return "code_identifier"  # BM25 fuerte
    if is_question and word_count > 6:
        return "conceptual"  # semantic fuerte
    return "mixed"


def choose_alpha(query: str) -> float:
    query_type = detect_query_type(query)
    return {
        "shell_command": 0.25,    # BM25 domina (75%)
        "code_identifier": 0.30,  # BM25 domina (70%)
        "conceptual": 0.75,       # semantic domina (75%)
        "mixed": 0.50,            # balanceado
    }[query_type]

3. Impacto esperado

Tipo de query              %     RRF actual    Weighted+routing     Ponderado
──────────────────────────────────────────────────────────────────────────────
Conceptuales              45%    88%           90% (α=0.75)         +0.9 pts
Identificadores           30%    72%           87% (α=0.30)         +4.5 pts
Comandos shell            15%    78%           90% (α=0.25)         +1.8 pts
Mixtas                    10%    80%           82%                  +0.2 pts

Mejora total: +7.4 puntos
Recall global: 81% → ~88%   ← cumple target

Plan de validación:

  1. Construir eval set de 100 queries reales con ground truth (proporcional a las % del log).
  2. Medir baseline (RRF actual): recall@5 por tipo de query.
  3. Implementar weighted con routing.
  4. Re-medir.
  5. Si recall global ≥88% sin caída en precision >2%, deployar con feature flag.
  6. A/B test en producción 2 semanas.

Riesgos a monitorear:

  • Precision en queries conceptuales: con α=0.75 muy alto, semantic puede ranquear paráfrasis irrelevantes que BM25 hubiera filtrado. Si precision cae >3%, bajar α a 0.65.
  • Falsos positivos del classifier: medir % de queries mal categorizadas. Si >15%, refinar las regex.
  • Latencia: routing agrega ~1ms (regex). Despreciable.

Plan B si no llega a 88%:

  • Considerar agregar HyDE (cápsula M03/06) para queries conceptuales más ambiciosas.
  • Combinar con re-ranking más agresivo (n_results=50 antes del rerank).
  • Si sigue sin alcanzar, el problema puede no ser de weighted blending — investigar chunking o cobertura del corpus.

Resumen y siguiente paso

Lo que aprendiste:

  • Weighted blending pondera scores con un parámetro α. α=0.5 balanceado, α=0.7 semantic dominante, α=0.3 BM25 dominante.
  • Normalización de scores es obligatoria antes de blendear (rangos incompatibles).
  • Min-max normalization es default razonable. Rank-based es más robusto a outliers.
  • Tuning de α con grid search sobre eval set. Mínimo 50 queries para resultado estable.
  • Routing dinámico (α distinto por tipo de query) mejora 3-5% sobre α global.
  • Weighted gana sobre RRF cuando una señal es claramente mejor para tu dominio.
  • Patrón pragmático: empezar con RRF, escalar a weighted solo si la mejora se justifica.

Checkpoint: antes de avanzar, deberías poder:

  • Implementar weighted blending con normalización correcta.
  • Hacer grid search de α y elegir el óptimo basándote en eval set.
  • Diseñar routing dinámico de α por tipo de query.

Siguiente cápsula: 06 — Elasticsearch para hybrid search a escala.

rank_bm25 funciona bien hasta ~1M docs. Para más, necesitas Elasticsearch. La cápsula 06 cubre el setup, indexación, y cómo hacer hybrid search nativo en Elasticsearch (que ya tiene RRF integrado en versiones recientes).


Recursos

  1. Pinecone — Score Normalization — Patrones de normalización
  2. LangChain — EnsembleRetriever — Soporta weighted blending
  3. Feature Scaling — Wikipedia — Min-max vs z-score vs rank
  4. Hybrid Search Tuning Guide (Vespa) — Tuning empírico
  5. BEIR Benchmark — Comparación empírica
  6. Anthropic — Contextual Retrieval — Técnica complementaria

Tiempo estimado: 25-30 minutos Siguiente: 06-elasticsearch-integration.md