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

Cápsula 03: BM25 — el algoritmo clásico que sigue ganando para keywords exactos

Descripción de la cápsula

BM25 (Best Match 25, también llamado Okapi BM25) es la evolución moderna de TF-IDF que dominó la información retrieval por décadas antes del boom de los embeddings. La pregunta en 2026 no es si BM25 funciona — es cuándo agregarlo a tu pipeline RAG. Después de la cápsula 02 ya sabes que cosine similarity falla con identificadores exactos; BM25 es exactamente lo opuesto: prioriza matching léxico literal por encima de semántica abstracta.

Esta cápsula te enseña qué hace BM25 por dentro (sin matemáticas avanzadas), cómo implementarlo con rank_bm25 para datasets pequeños o medianos, y cómo evitar los modos de falla típicos: tokenización mal calibrada al dominio, pre-procesamiento agresivo que borra los identificadores que quieres preservar, y el error de comparar BM25 vs embeddings como si fueran competidores en lugar de complementarios.

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

  • ✅ Explicar qué es BM25 y por qué es la elección correcta para keyword search en 2026
  • ✅ Implementar BM25 sobre un corpus con rank_bm25 (in-memory) en menos de 30 líneas
  • ✅ Tokenizar correctamente para preservar identificadores técnicos (camelCase, snake_case)
  • ✅ Diagnosticar problemas de pre-procesamiento que destruyen exactitud
  • ✅ Decidir cuándo usar BM25 standalone vs siempre combinado con semantic
  • ✅ Anticipar el límite de escala: cuándo migrar a Elasticsearch (cápsula 06)

Tiempo estimado: 30-35 minutos


El insight: BM25 mide cuán "improbablemente común" es la coincidencia

El núcleo conceptual de BM25 es una idea simple: un documento que contiene los keywords de tu query es más relevante que uno que no los contiene. Pero la sutileza está en cómo BM25 modela "contiene":

Query:       "OAuth2PasswordBearer FastAPI"

Doc A:       "OAuth2PasswordBearer is the FastAPI security class for password flow.
              It handles token extraction from Authorization headers."

Doc B:       "FastAPI is a modern web framework. It supports OAuth2 authentication
              through various dependency classes including OAuth2PasswordBearer."

Doc C:       "Authentication frameworks like OAuth2 are common in modern APIs."

BM25 score:
  Doc A: 8.42  ← match exacto, alta densidad de términos de la query
  Doc B: 6.18  ← match exacto, pero términos diluidos
  Doc C: 1.94  ← solo "OAuth2" coincide, "PasswordBearer" y "FastAPI" ausentes

BM25 considera tres factores para cada término de la query:

  1. TF (Term Frequency): cuántas veces aparece el término en el documento. Docs que mencionan OAuth2PasswordBearer 3 veces rankean más alto que docs que lo mencionan 1 vez.

  2. IDF (Inverse Document Frequency): qué tan raro es el término en el corpus completo. OAuth2PasswordBearer aparece en pocos docs → gran peso. is aparece en todos → peso despreciable.

  3. Document length normalization: docs cortos con todos los términos rankean más alto que docs largos donde los términos están "diluidos" entre miles de palabras.

La fórmula es matemáticamente precisa pero no necesitas derivarla para usar BM25. Lo que importa es entender el modelo mental: rankea documentos donde los términos de la query son simultáneamente frecuentes (en ese doc) y raros (en el corpus).


Por qué BM25 ya gana donde semantic falla

Volvemos al ejemplo de la cápsula 02 (identificador OAuth2PasswordBearer). Mientras semantic search lo subordinó a una paráfrasis cercana, BM25 funciona al revés:

Query: "OAuth2PasswordBearer scopes"

Semantic (cosine distance):
  Doc B (paráfrasis):   0.421  ← cercano por significado
  Doc A (identificador): 0.482  ← más lejos aunque contenga el match exacto

BM25:
  Doc A (identificador): 8.42   ← gana porque contiene los términos exactos
  Doc B (paráfrasis):    1.20   ← bajo porque NO contiene "OAuth2PasswordBearer"

La diferencia conceptual:

  • Semantic pregunta: "¿qué tan parecido es el significado?"
  • BM25 pregunta: "¿qué tan presente están los tokens exactos?"

Para queries con identificadores, BM25 es estructuralmente superior. No es "mejor en general" — es mejor para esa clase específica. Por eso hybrid search los combina en lugar de elegir uno.


Implementación básica con rank_bm25

rank_bm25 es la librería Python más usada para BM25 in-memory. Funciona bien hasta ~1M de documentos en una máquina razonable. Para más, considera Elasticsearch (cápsula 06).

Setup

pip install rank-bm25

Código mínimo

# bm25_basic.py
from rank_bm25 import BM25Okapi
from dataclasses import dataclass


@dataclass
class BM25Result:
    document: str
    score: float
    original_index: int


class BM25Index:
    """Wrapper sobre rank_bm25 con tokenización configurable."""

    def __init__(self, documents: list[str], tokenizer=None):
        self.documents = documents
        self.tokenizer = tokenizer or self._default_tokenizer

        # Tokenizar todos los documentos
        tokenized_corpus = [self.tokenizer(doc) for doc in documents]
        self.bm25 = BM25Okapi(tokenized_corpus)

    @staticmethod
    def _default_tokenizer(text: str) -> list[str]:
        """Tokenizer simple que preserva camelCase e identificadores."""
        return text.lower().split()

    def search(self, query: str, top_k: int = 5) -> list[BM25Result]:
        """Buscar top-K documentos por score BM25."""
        query_tokens = self.tokenizer(query)
        scores = self.bm25.get_scores(query_tokens)

        # Top-K por score descendente
        top_indices = sorted(
            range(len(scores)),
            key=lambda i: -scores[i],
        )[:top_k]

        return [
            BM25Result(
                document=self.documents[i],
                score=float(scores[i]),
                original_index=i,
            )
            for i in top_indices
        ]


# Probar
docs = [
    "OAuth2PasswordBearer is the FastAPI security class for OAuth2 password flow.",
    "Para autenticación OAuth2 con username/password en FastAPI usar dependency adecuada.",
    "OAuth2 is an authorization framework that enables third-party application access.",
    "FastAPI provides multiple authentication methods including OAuth2 and JWT tokens.",
    "Password flows in OAuth2 allow users to authenticate with their credentials.",
]

index = BM25Index(docs)
results = index.search("OAuth2PasswordBearer scopes", top_k=3)

for i, r in enumerate(results, 1):
    print(f"#{i} (BM25 score={r.score:.2f})")
    print(f"   {r.document[:100]}...")

Output:

#1 (BM25 score=1.11)
   OAuth2PasswordBearer is the FastAPI security class for OAuth2 password flow....

#2 (BM25 score=0.00)
   Para autenticación OAuth2 con username/password en FastAPI usar dependency adecuada....

#3 (BM25 score=0.00)
   OAuth2 is an authorization framework that enables third-party application access....

Nota: el doc con OAuth2PasswordBearer literal queda primero y es el único con score distinto de cero. Todos los demás dan exactamente 0.00: ninguno contiene el token oauth2passwordbearer, y scopes no aparece en ningún doc del corpus. BM25 es así de literal: sin token exacto, no hay score. Esa brutalidad es justamente lo que lo vuelve el complemento correcto de cosine.


Tokenización: el detalle que hace o rompe BM25

El default de la librería (text.split()) es muy simple y suele funcionar OK. Pero hay casos donde necesita ajuste fino — específicamente, cuando tu corpus tiene identificadores en CamelCase, snake_case, o con caracteres especiales.

El problema: CamelCase

# Default tokenizer
"OAuth2PasswordBearer".lower().split()
# → ["oauth2passwordbearer"]

El tokenizer trata OAuth2PasswordBearer como un solo token. Si el usuario tipea OAuth2 password bearer (separado), no matchea.

Solución 1: tokenizar por casing transitions

import re


def code_aware_tokenizer(text: str) -> list[str]:
    """
    Tokenizer que separa CamelCase y snake_case en tokens individuales,
    pero también preserva la versión completa.
    """
    text = text.lower() if not _has_camelcase(text) else text

    # Split por casing (camelCase → camel, Case)
    tokens = re.findall(r'[A-Z][a-z]+|[a-z]+|\d+', text)

    # Lowercase
    tokens = [t.lower() for t in tokens]

    # También preservar el token original completo
    if '_' in text or any(c.isupper() for c in text):
        tokens.extend(text.lower().split())

    return tokens


def _has_camelcase(text: str) -> bool:
    return bool(re.search(r'[a-z][A-Z]', text))


# Probar
tokens = code_aware_tokenizer("OAuth2PasswordBearer")
print(tokens)
# → ['o', 'auth', '2', 'password', 'bearer', 'oauth2passwordbearer']

Ahora OAuth2PasswordBearer matchea con queries oauth2, password, bearer, Y oauth2passwordbearer. Mejor cobertura.

Solución 2: incluir n-gramas de tokens

Para queries con error codes que tienen estructura específica:

def ngram_tokenizer(text: str, max_n: int = 3) -> list[str]:
    """Tokens individuales + n-gramas de hasta max_n."""
    words = text.lower().split()

    tokens = list(words)
    for n in range(2, max_n + 1):
        for i in range(len(words) - n + 1):
            ngram = "_".join(words[i:i+n])
            tokens.append(ngram)

    return tokens


# Probar
tokens = ngram_tokenizer("ERR NETWORK TIMEOUT 504", max_n=3)
print(tokens)
# → ['err', 'network', 'timeout', '504',
#    'err_network', 'network_timeout', 'timeout_504',
#    'err_network_timeout', 'network_timeout_504']

Útil para que la query "ERR NETWORK TIMEOUT 504" encuentre el documento que tiene la frase exacta como una unidad.

Tokenizer recomendado para corpus técnico

def technical_tokenizer(text: str) -> list[str]:
    """
    Tokenizer apropiado para corpus técnico (código + texto).
    Preserva identificadores y separa casing.
    """
    # Lowercase para matching consistente
    text_lower = text.lower()

    # Tokens estándar (split por whitespace y puntuación común)
    tokens = re.findall(r'\b\w+\b', text_lower)

    # Plus: identificadores con underscore o números preservados
    underscore_tokens = re.findall(r'\b\w*_\w+\b', text_lower)
    tokens.extend(underscore_tokens)

    # Plus: error codes (mayúsculas con underscore o números)
    code_tokens = re.findall(r'[A-Z][A-Z_0-9]+', text)  # NO lowercase aquí
    tokens.extend([t.lower() for t in code_tokens])

    return tokens

Pre-procesamiento: lo que NO hacer

Una trampa común con BM25 es aplicar pre-procesamiento "de NLP clásica" que rompe lo que quieres preservar:

Trampa 1: stemming agresivo

# ❌ Esto borra el match exacto
from nltk.stem import PorterStemmer
stemmer = PorterStemmer()
tokens = [stemmer.stem(t) for t in tokens]
# "OAuth2PasswordBearer" → "oauth2passwordbear"
# El usuario tipea "OAuth2PasswordBearer", no matchea

Cómo prevenir: evitar stemming para corpus técnico. Es útil para texto narrativo en inglés (ej: matching running con run), pero destruye identificadores.

Trampa 2: stopword removal global

# ❌ Remover stopwords del documento Y de la query
STOPWORDS = {"a", "an", "the", "of", "in", "to", "is"}
tokens = [t for t in tokens if t not in STOPWORDS]

Síntoma: queries como "how to use" quedan sin tokens. BM25 no puede rankear nada.

Cómo prevenir: stopword removal NO es necesario para BM25 — la fórmula IDF ya da peso despreciable a tokens muy comunes. El stopword removal es residuo de épocas con menos memoria, no aporta calidad.

Trampa 3: lowercase agresivo

# ❌ Lowercase de todo, perdiendo case-sensitivity de error codes
text = text.lower()
# "ERR_NETWORK_TIMEOUT" → "err_network_timeout"
# Usuario tipea "ERR_NETWORK_TIMEOUT", matchea OK por suerte

Síntoma: tokens en mayúsculas (códigos de error, constantes) tratados igual que texto normal. Mayoría de las veces funciona, pero pierdes señal cuando la query sí preserva mayúsculas.

Cómo prevenir: lowercase para tokens normales, preservar mayúsculas para tokens que son claramente identificadores (regex match [A-Z]{2,}).

Trampa 4: limpiar puntuación dentro de identificadores

# ❌ Reemplazar todo lo que no sea alfanumérico
text = re.sub(r'[^\w\s]', '', text)
# "v2.3.1" → "v231"
# Usuario tipea "v2.3.1", no matchea

Cómo prevenir: preservar . dentro de versiones, - dentro de identificadores, : dentro de timestamps. Limpiar solo puntuación al final de oraciones.


Cuándo usar BM25 standalone (sin semantic)

BM25 puede ser suficiente por sí solo en estos casos:

Caso¿BM25 standalone?Por qué
Buscador de error codes / messages✅ SíMatch exacto es lo único que importa
Code search (GitHub-style)✅ SíIdentificadores y sintaxis dominan
Catálogo de productos con SKUs✅ SíCódigos exactos son lo único relevante
Logs de aplicación con timestamps específicos✅ SíMatch exacto crítico
FAQ corporativo con vocabulario consistente⚠️ A vecesSi las preguntas son siempre directas
Chatbot conversacional general❌ NoNecesita semantic para paráfrasis
Buscador de papers académicos❌ NoConceptos abstractos requieren semantic
Búsqueda interna de documentación⚠️ HybridQueries mixtas requieren ambos

Regla práctica: si el 90%+ de tus queries son exact-match, BM25 standalone alcanza. Si hay mezcla con queries conceptuales, hybrid es mejor.


Limitaciones de rank_bm25 (in-memory)

rank_bm25 carga todo el corpus en memoria. Funciona bien hasta cierto punto:

Tamaño del corpusRAM aproxLatencia query típica
10K docs~100 MB~5ms
100K docs~1 GB~30ms
1M docs~10 GB~200ms
10M docs~100 GBinutilizable

Para >1M docs, BM25 in-memory deja de ser práctico. La cápsula 06 cubre Elasticsearch como solución a escala — mismo algoritmo, distribuido.


Trampas y errores comunes

Trampa 1: aplicar pre-procesamiento agresivo "porque es estándar NLP"

Cubierta arriba. Stemming, stopword removal y lowercase agresivo destruyen lo que BM25 necesita.

Trampa 2: usar tokenizer default sin verificar

El error: corpus técnico con CamelCase, default tokenizer trata OAuth2PasswordBearer como un solo token.

Síntoma: queries con palabras separadas (oauth2 password bearer) no matchean con docs que tienen el identificador junto.

Cómo prevenir: validar manualmente la tokenización con identificadores representativos antes de poblar el índice.

Trampa 3: olvidar re-indexar cuando cambia tokenización

El error: ajustas el tokenizer. No re-indexas. Las queries usan el tokenizer nuevo, los docs en el índice usan el viejo.

Síntoma: queries que deberían matchear no encuentran nada.

Cómo prevenir: cualquier cambio al tokenizer requiere reconstruir el índice BM25 completo. Versionar el tokenizer + caché key.

Trampa 4: comparar scores de BM25 con cosine similarity

El error: sumas scores BM25 con cosine para "fusionar".

Síntoma: los scores tienen rangos completamente distintos. BM25 puede ir de 0 a 50+, cosine de 0 a 2. La suma da rankings sin sentido.

Cómo prevenir: usar Reciprocal Rank Fusion (cápsula 04) que combina rankings ignorando scores absolutos.

Trampa 5: no eliminar el bias por documentos largos

El error: algunos docs en tu corpus son muy largos (10K+ chars). BM25 los rankea sistemáticamente más alto en queries cortas.

Síntoma: retrieval devuelve siempre los docs más largos, irrelevantes.

Cómo prevenir: BM25 ya tiene normalización por longitud (b parameter en la fórmula). Verificar que está habilitado (default b=0.75). Si docs muy largos siguen dominando, considerar chunkear más fino antes de indexar.

Trampa 6: indexar texto sin metadata útil

El error: indexas solo el contenido del chunk en BM25. La metadata (título, sección, autor) se pierde.

Síntoma: queries que coinciden con metadata (ej: nombre del autor) no encuentran nada.

Cómo prevenir: concatenar metadata relevante con el contenido antes de tokenizar:

def doc_for_bm25(chunk: dict) -> str:
    parts = [
        chunk.get("title", ""),
        chunk.get("section", ""),
        chunk.get("text", ""),
    ]
    return " ".join(filter(None, parts))


index = BM25Index([doc_for_bm25(c) for c in chunks])

Ejercicio aplicado

Escenario: eres AI Engineer en una empresa SaaS de DevOps. Datos:

  • 80K chunks de documentación técnica (mezcla código + texto narrativo)
  • Queries del log de producción: 65% son del tipo "código identificador + descripción" (kubectl get pods, helm chart values, OAuth2PasswordBearer scopes)
  • Sistema actual: solo semantic search con OpenAI embeddings + cross-encoder rerank

Métricas:

  • Precision@5: 84%
  • Recall@5: 62%

Tu trabajo:

  1. Decide si BM25 ayudaría aquí.
  2. Diseña la implementación incluyendo tokenizer apropiado.
  3. Estima el impacto esperado.
Solución

1. Sí ayudaría — BM25 ataca exactamente el problema

El 65% de tráfico es queries con identificadores exactos. Esos son los casos donde semantic search subordina docs específicos a paráfrasis cercanas. BM25 los recupera exactamente porque rankea match léxico.

Mejora esperada según distribución:

  • 65% queries con identificadores: recall esperado pasa de ~50% (semantic) a ~85% (hybrid).
  • 35% queries semánticas: sin cambio (semantic ya gana).
  • Recall global: 0.65 × 0.85 + 0.35 × 0.85 = ~85% (vs 62% actual = +23 puntos).

2. Implementación con tokenizer técnico

# bm25_setup.py
import re
from rank_bm25 import BM25Okapi


def technical_tokenizer(text: str) -> list[str]:
    """Tokenizer para corpus técnico DevOps."""
    # Lowercase de tokens normales
    text_lower = text.lower()

    # 1. Tokens estándar
    tokens = re.findall(r'\b\w+\b', text_lower)

    # 2. Identificadores con underscore (helm_chart, oauth2_password_bearer)
    underscore_tokens = re.findall(r'\b\w+_\w+\b', text_lower)
    tokens.extend(underscore_tokens)

    # 3. CamelCase split (OAuth2PasswordBearer → oauth, 2, password, bearer + completo)
    camel_matches = re.findall(r'[A-Z][a-z]+|[A-Z]+(?=[A-Z][a-z])|[A-Z]+|\d+', text)
    tokens.extend([m.lower() for m in camel_matches])

    # 4. Error codes (ERR_TIMEOUT, HTTP 503)
    error_codes = re.findall(r'[A-Z][A-Z_0-9]{2,}|HTTP\s+\d{3}', text)
    tokens.extend([c.lower().replace(' ', '_') for c in error_codes])

    # 5. Versions (v1.27.3)
    versions = re.findall(r'\bv?\d+\.\d+(?:\.\d+)?\b', text_lower)
    tokens.extend(versions)

    # 6. Comandos shell (kubectl, helm, etc. + flags)
    commands = re.findall(r'\b(?:kubectl|helm|docker|git)\s+\w+', text_lower)
    tokens.extend(commands)

    return tokens


def build_bm25_index(chunks: list[dict]) -> BM25Okapi:
    """Construir índice BM25 sobre chunks con metadata."""
    docs_for_indexing = []
    for chunk in chunks:
        # Concatenar metadata relevante + contenido
        text = f"{chunk.get('title', '')} {chunk.get('section', '')} {chunk['content']}"
        docs_for_indexing.append(text)

    tokenized = [technical_tokenizer(d) for d in docs_for_indexing]
    return BM25Okapi(tokenized)

3. Estimación de impacto

Asumiendo rank_bm25 con corpus de 80K docs:

  • RAM: ~800 MB. Manejable.
  • Latencia query BM25: ~25ms (in-memory).
  • Latencia total agregada al pipeline: +30-50ms (incluyendo tokenización de query + scoring + fusión).

Recall esperado por categoría:

Categoría                  %       Recall actual    Recall con BM25 hybrid
─────────────────────────────────────────────────────────────────────────
Identificadores            65%     50%              85% (BM25 directo)
Comandos shell             15%     55%              90% (BM25 perfecto)
Versiones                  10%     58%              92%
Semánticas puras           10%     85%              85%

Recall global esperado:
  0.65(0.85) + 0.15(0.90) + 0.10(0.92) + 0.10(0.85)
= 0.5525 + 0.135 + 0.092 + 0.085
= 0.865 ≈ 86%

vs 62% actual = +24 puntos de recall

Plan de validación:

  1. Construir eval set de 80 queries reales (proporcional a la distribución del log).
  2. Medir baseline (semantic only).
  3. Implementar BM25 + fusión con RRF (cápsula 04).
  4. Re-medir sobre eval set.
  5. Si recall sube >15 puntos sin caer precision, deployar.

Plan B si BM25 ranquea ruido:

  • Revisar tokenización: ¿separa correctamente CamelCase?
  • Verificar que docs largos no dominan (parámetro b de BM25).
  • Considerar boosting de queries específicas (ej: queries con error codes → mayor peso BM25).

Resumen y siguiente paso

Lo que aprendiste:

  • BM25 es la evolución moderna de TF-IDF: rankea por presencia de tokens exactos, normalizando por raridad y longitud.
  • Implementación in-memory con rank_bm25 funciona hasta ~1M docs. Para más, Elasticsearch.
  • Tokenización es la decisión más sensible. Para corpus técnico, tokenizer custom que preserva CamelCase, snake_case, error codes y versiones.
  • Pre-procesamiento agresivo (stemming, stopwords, lowercase) destruye exactitud. Evitarlo en BM25.
  • BM25 standalone es útil para corpus muy específicos (error codes, code search, SKUs). En la mayoría de casos, hybrid (BM25 + semantic) gana.
  • Scores de BM25 NO son comparables con cosine similarity. Necesitas Reciprocal Rank Fusion (cápsula 04) para combinarlos.

Checkpoint: antes de avanzar, deberías poder:

  • Implementar BM25 con rank_bm25 y tokenizer apropiado para tu dominio.
  • Diagnosticar problemas de tokenización con identificadores específicos.
  • Decidir cuándo BM25 standalone alcanza vs cuándo necesitas hybrid.

Siguiente cápsula: 04 — Reciprocal Rank Fusion (RRF).

Tienes dos rankings (BM25 y semantic). ¿Cómo los combinas para obtener un ranking final unificado? RRF es el algoritmo estándar — combina rankings ignorando scores absolutos, robusto a la diferencia de rangos entre técnicas. La cápsula 04 lo cubre con implementación, tuning del parámetro k, y comparación con alternativas.


Recursos

  1. BM25 Paper — Robertson & Zaragoza (2009) — Foundational completo
  2. Okapi BM25 — Wikipedia — Fórmula y variantes
  3. rank_bm25 — Python Library — Documentación de la librería
  4. Elasticsearch BM25 Implementation — Para escalar después
  5. Pinecone — Hybrid Search Theory — Contexto de fusión
  6. Anthropic — Contextual Retrieval — Técnica complementaria

Tiempo estimado: 30-35 minutos Siguiente: 04-reciprocal-rank-fusion.md