Hybrid Retrieval: Combinar BM25 y Vector Search para RAG en Producción
La búsqueda semántica con embeddings ha revolucionado la forma en que recuperamos información en los sistemas RAG, pero esconde un límite fundamental que emerge puntualmente en producción: si el usuario busca "GPT-4 hallucination rate benchmark Q3 2024", un modelo de embedding encontrará documentos semánticamente cercanos al concepto de "alucinación de modelos lingüísticos", pero podría no recuperar el documento exacto que contiene esa cadena específica de texto. La búsqueda por keyword, en cambio, encuentra exactamente esa frase, pero no sabe que "LLM factuality issue" es conceptualmente idéntica.
El Hybrid Retrieval nace precisamente para resolver esta tensión. Combinando la búsqueda sparse (BM25 y variantes) con la búsqueda dense (vector search), se obtiene un sistema que es tanto preciso en las coincidencias exactas como robusto en la comprensión semántica. Investigaciones recientes muestran que los sistemas híbridos mejoran la calidad del retrieval un 48% respecto a los métodos individuales en los benchmarks BEIR y MTEB, con ganancias particularmente marcadas en consultas técnicas, nombres propios y terminología especializada.
Qué Aprenderás
- Cómo funciona BM25 internamente: TF-IDF, saturación de frecuencia y normalización de longitud
- Diferencias entre búsqueda sparse y dense: cuándo funciona mejor cada una
- Métodos de fusión: Reciprocal Rank Fusion (RRF) y fusión ponderada
- Re-ranking con cross-encoder para máxima precisión
- Implementación práctica con Qdrant y Elasticsearch
- Evaluación con métricas NDCG y MRR
1. BM25: La Búsqueda por Keyword que Aún Funciona
BM25 (Best Matching 25) es el algoritmo de ranking para búsqueda full-text más utilizado en el mundo. Elasticsearch, Lucene, Solr y muchos otros motores lo usan como ranking por defecto. A pesar de tener más de 30 años, sigue siendo competitivo gracias a su eficiencia y a su capacidad de capturar coincidencias exactas.
import math
from collections import Counter
from typing import List, Dict, Tuple
class BM25:
"""Implementación de BM25 desde cero"""
def __init__(self, k1: float = 1.5, b: float = 0.75):
"""
k1: controla la saturación de frecuencia de términos (1.2-2.0)
b: controla la normalización por longitud del documento (0.0-1.0)
"""
self.k1 = k1
self.b = b
self.corpus_size = 0
self.avg_doc_len = 0
self.doc_freqs: Dict[str, int] = {} # Frecuencia de documento por término
self.doc_lens: List[int] = []
self.term_freqs: List[Dict[str, int]] = []
def fit(self, corpus: List[str]):
"""Indexa el corpus"""
self.corpus_size = len(corpus)
self.doc_lens = []
self.term_freqs = []
self.doc_freqs = {}
for doc in corpus:
terms = doc.lower().split()
self.doc_lens.append(len(terms))
tf = Counter(terms)
self.term_freqs.append(dict(tf))
# Actualiza frecuencias de documento
for term in set(terms):
self.doc_freqs[term] = self.doc_freqs.get(term, 0) + 1
self.avg_doc_len = sum(self.doc_lens) / self.corpus_size
def _idf(self, term: str) -> float:
"""Calcula el IDF (Inverse Document Frequency) de un término"""
df = self.doc_freqs.get(term, 0)
return math.log((self.corpus_size - df + 0.5) / (df + 0.5) + 1)
def score(self, query: str, doc_idx: int) -> float:
"""Calcula el score BM25 de un documento para una consulta"""
query_terms = query.lower().split()
score = 0.0
doc_len = self.doc_lens[doc_idx]
tf_dict = self.term_freqs[doc_idx]
for term in query_terms:
if term not in tf_dict:
continue
tf = tf_dict[term]
idf = self._idf(term)
# Fórmula BM25
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avg_doc_len)
score += idf * (numerator / denominator)
return score
def search(self, query: str, top_k: int = 10) -> List[Tuple[int, float]]:
"""Busca los top-K documentos para una consulta"""
scores = [
(i, self.score(query, i))
for i in range(self.corpus_size)
]
# Ordena por score descendente
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]
2. Reciprocal Rank Fusion: Fusionar Rankings
Reciprocal Rank Fusion (RRF) es el método estándar para combinar rankings de fuentes diferentes. Su elegancia reside en la simplicidad: en lugar de comparar scores (que tienen escalas diferentes entre BM25 y vector search), utiliza únicamente la posición de cada documento en cada ranking.
from typing import List, Dict, Tuple, Set
def reciprocal_rank_fusion(
rankings: List[List[Tuple[str, float]]],
k: int = 60,
weights: List[float] = None
) -> List[Tuple[str, float]]:
"""
Reciprocal Rank Fusion (RRF) para combinar múltiples rankings.
rankings: lista de rankings, cada uno como [(doc_id, score), ...]
k: constante RRF (por defecto 60, recomendado por el paper original)
weights: pesos para cada ranking (por defecto pesos iguales)
"""
if weights is None:
weights = [1.0] * len(rankings)
# Acumula scores RRF por documento
rrf_scores: Dict[str, float] = {}
for ranking, weight in zip(rankings, weights):
for rank, (doc_id, _original_score) in enumerate(ranking, start=1):
# Fórmula RRF: weight / (k + rank)
if doc_id not in rrf_scores:
rrf_scores[doc_id] = 0.0
rrf_scores[doc_id] += weight / (k + rank)
# Ordena por score RRF descendente
sorted_results = sorted(
rrf_scores.items(),
key=lambda x: x[1],
reverse=True
)
return sorted_results
3. Re-Ranking con Cross-Encoder
El re-ranking es el paso final de la pipeline hybrid: después de recuperar candidatos con BM25 + vector search + RRF, un cross-encoder evalúa cada par (consulta, documento) de forma conjunta, produciendo un score de relevancia más preciso.
from sentence_transformers import CrossEncoder
from typing import List, Tuple
class ReRanker:
"""Re-ranking con cross-encoder para máxima precisión"""
def __init__(self, model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"):
self.model = CrossEncoder(model_name)
def rerank(
self,
query: str,
documents: List[str],
top_k: int = 5
) -> List[Tuple[str, float]]:
"""Re-ordena documentos por relevancia con cross-encoder"""
# Crea pares (consulta, documento)
pairs = [(query, doc) for doc in documents]
# Score de cada par
scores = self.model.predict(pairs)
# Combina y ordena
doc_scores = list(zip(documents, scores))
doc_scores.sort(key=lambda x: x[1], reverse=True)
return doc_scores[:top_k]
# Ejemplo de uso
reranker = ReRanker()
query = "¿Cómo funciona el hybrid retrieval?"
candidates = [
"El hybrid retrieval combina BM25 y vector search",
"La búsqueda semántica usa embeddings",
"BM25 es un algoritmo de ranking",
"Python es un lenguaje de programación"
]
results = reranker.rerank(query, candidates, top_k=3)
for doc, score in results:
print(f" [{score:.4f}] {doc}")
4. Best Practices y Anti-Patterns
Best Practices Hybrid Retrieval
- RRF con k=60: el valor por defecto funciona bien en la mayoría de los casos. Reduce k (20-40) si quieres favorecer más los documentos en las primeras posiciones.
- Pesos asimétricos: 60% dense + 40% sparse suele ser óptimo. Para consultas técnicas con terminología específica, aumenta el peso de BM25.
- Re-ranking siempre: un cross-encoder en la fase final mejora significativamente la precisión. El costo adicional es mínimo si se aplica solo a los top-20.
- Evalúa con NDCG y MRR: no midas solo el recall; la posición del documento correcto importa tanto como su presencia.
Anti-Patterns a Evitar
- Combinar scores directamente: los scores de BM25 y vector search tienen escalas completamente diferentes. Usa RRF o normaliza antes de combinar.
- Ignorar BM25: para nombres propios, códigos de producto, identificadores técnicos, BM25 supera a la búsqueda semántica.
- Re-ranking de demasiados candidatos: los cross-encoder son lentos. Re-ordena solo los top-20 o top-50, no toda la colección.
Conclusiones
El hybrid retrieval es la estrategia más efectiva para sistemas RAG en producción. Hemos explorado BM25, la fusión con Reciprocal Rank Fusion y el re-ranking con cross-encoder, proporcionando implementaciones prácticas y mejores prácticas.
Los puntos clave:
- BM25 excede en coincidencias exactas, vector search en comprensión semántica
- Reciprocal Rank Fusion combina rankings de diferentes fuentes de forma robusta
- El re-ranking con cross-encoder produce los mejores resultados como fase final
- Los sistemas híbridos mejoran el retrieval quality hasta un 48% respecto a métodos individuales
Continúa la Serie
- Artículo 3: Vector Database
- Artículo 4: Hybrid Retrieval (actual)
- Artículo 5: RAG en Producción
- Artículo 6: LangChain para RAG







