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
| Caso | Mejor opción | Por qué |
|---|---|---|
| Una señal es 30%+ mejor que la otra para tu dominio | Weighted con α apropiado | RRF promedia, weighted explota la señal fuerte |
| Corpus mixto con queries muy variadas | Weighted con routing | RRF global no se adapta; routing sí |
| Quieres explicar el ranking ("este doc rankeó porque BM25 le dio 0.85") | Weighted | Los componentes están explícitos |
| MVP simple, no tienes eval set para tunear α | RRF | RRF no requiere tuning |
| Equipo sin ML/data science background | RRF | Conceptualmente más simple |
| Más de 2 fuentes (semantic + BM25 + HyDE + ...) | RRF | Weighted con N pesos requiere tuning de cada uno |
Patrón pragmático recomendado:
- Empezar con RRF (cápsula 04). Funciona en 80% de casos sin tuning.
- Medir sobre eval set. Si recall es satisfactorio, parar acá.
- Si recall queda bajo: probar weighted con grid search de α.
- Si weighted mejora >3%: considerar deployar.
- 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:
- Decide si weighted blending con routing puede llegar al target.
- Diseña el routing y los α por tipo.
- 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:
- Construir eval set de 100 queries reales con ground truth (proporcional a las % del log).
- Medir baseline (RRF actual): recall@5 por tipo de query.
- Implementar weighted con routing.
- Re-medir.
- Si recall global ≥88% sin caída en precision >2%, deployar con feature flag.
- 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=50antes 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.5balanceado,α=0.7semantic dominante,α=0.3BM25 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
- Pinecone — Score Normalization — Patrones de normalización
- LangChain — EnsembleRetriever — Soporta weighted blending
- Feature Scaling — Wikipedia — Min-max vs z-score vs rank
- Hybrid Search Tuning Guide (Vespa) — Tuning empírico
- BEIR Benchmark — Comparación empírica
- Anthropic — Contextual Retrieval — Técnica complementaria
Tiempo estimado: 25-30 minutos Siguiente: 06-elasticsearch-integration.md