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

Cápsula 02: Por qué semantic search solo no alcanza — los modos de falla concretos

Descripción de la cápsula

Semantic search con embeddings cambió el juego del retrieval. Capturar significado en vez de palabras exactas hizo posible que un usuario tipear "cómo manejo autenticación" encuentre documentos sobre "implementing OAuth2 in FastAPI". Esa abstracción es poderosa.

Pero esa misma abstracción rompe en una clase específica de queries: las que dependen de tokens exactos. Cuando un desarrollador busca "OAuth2PasswordBearer" (el nombre de una clase específica), o un técnico busca "ERR_CONNECTION_RESET" (un código de error literal), o un usuario busca "v2.3.1" (una versión específica), semantic search normaliza la paráfrasis y pierde el match exacto. El sistema devuelve docs sobre el tema general pero raramente los específicos que el usuario sabe que están.

Esta cápsula te muestra los modos de falla concretos de semantic-only, cómo diagnosticarlos en tu corpus, y por qué la solución natural es agregar BM25 (keyword search) en paralelo — no reemplazar semantic, sino complementarlo.

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

  • ✅ Identificar las cinco clases de queries donde semantic search consistentemente falla
  • ✅ Diagnosticar qué porcentaje de tu tráfico es problemático con un script de análisis
  • ✅ Explicar geométricamente por qué los embeddings normalizan paráfrasis
  • ✅ Cuantificar el impacto en recall típico (10-30% según el dominio)
  • ✅ Anticipar el error de "embebir mejor" como falsa solución
  • ✅ Justificar la decisión de agregar BM25 al pipeline con datos

Tiempo estimado: 25-30 minutos


El insight: los embeddings codifican significado, no exactitud

Cuando un modelo de embeddings procesa un texto, produce un vector que codifica el significado semántico de las palabras. Eso significa que OAuth2PasswordBearer y "dependency de OAuth2 con username/password" terminan en regiones cercanas del espacio vectorial — el modelo "entiende" que son lo mismo conceptualmente.

Para queries conceptuales como "¿cómo manejo autenticación?", esa normalización es exactamente lo que quieres. El usuario no sabe el nombre de la clase, sabe lo que quiere lograr; los embeddings lo conectan con el documento correcto.

Pero hay queries donde el usuario sí sabe el token exacto y quiere encontrarlo literalmente. Tres ejemplos:

Query del usuario:    "OAuth2PasswordBearer scopes"
                       (sabe el nombre exacto de la clase)

Doc en el corpus:     Doc A: "OAuth2PasswordBearer is the FastAPI security class..."
                              cosine: 0.78  ← más bajo, paradójicamente

                      Doc B: "Para autenticación OAuth2 con scopes en FastAPI usar
                              la dependency adecuada del módulo de seguridad..."
                              cosine: 0.86  ← más alto, pero NO menciona la clase

Top-1 del retrieval:  Doc B (paráfrasis cercana)
Lo que el usuario quería: Doc A (mención literal)

El sistema "funciona" — devuelve docs sobre el tema. Pero no devuelve el doc específico que el usuario sabía que estaba. Para soporte técnico, documentación de APIs, debugging — eso es el caso de uso central, y semantic search lo pierde sistemáticamente.


Las cinco clases de queries que rompen semantic-only

Clase 1: identificadores de código

"OAuth2PasswordBearer"
"RunnablePassthrough"
"AsyncIO event loop"
"useState hook"
"pd.merge"

Nombres de clases, funciones, métodos. El usuario sabe exactamente qué buscar y quiere documentación de ese símbolo específico.

Por qué fallan: los embeddings codifican estos identificadores junto con el contexto técnico circundante. Un doc que menciona el identificador 3 veces puede ranking más bajo que un doc que parafrasea el concepto en un texto más largo.

Clase 2: códigos de error

"ERR_CONNECTION_RESET"
"E4502"
"HTTP 503"
"ENOENT"
"OSError [Errno 2]"

Errores que el sistema produce. El usuario los copia literalmente del log de error.

Por qué fallan: los embeddings tratan estos códigos como tokens raros. Un doc de troubleshooting con el código exacto puede tener un cosine similarity menor que un doc genérico sobre "errores de red".

Clase 3: versiones y números específicos

"FastAPI 0.110.0 changes"
"Python 3.12 typing"
"Django 4.2 migration"
"Node 20 LTS"

Información que cambia con la versión.

Por qué fallan: los embeddings tienen poca capacidad para diferenciar números. "Python 3.10" y "Python 3.12" tienen embeddings casi idénticos aunque las APIs sean distintas.

Clase 4: comandos y sintaxis

"curl -X POST -H 'Content-Type: application/json'"
"git rebase --interactive HEAD~5"
"docker compose up -d"
"kubectl get pods --all-namespaces"

Comandos exactos con sintaxis específica.

Por qué fallan: los embeddings normalizan flags y opciones. "git rebase interactive" y "git rebase --interactive HEAD~5" terminan cerca, perdiendo la especificidad de la query.

Clase 5: queries en idiomas mezclados

"cómo usar OAuth2PasswordBearer"      (español + identificador inglés)
"erro ENOENT no Linux"                 (portugués + código inglés)
"how to fix erreur 404"                (inglés + francés)

Queries multilingües donde los identificadores técnicos quedan en el idioma original.

Por qué fallan: el embedding multilingüe normaliza el idioma pero el doc específico tiene el identificador exacto que se debería preservar.


El experimento que demuestra el problema

# experimento_limitaciones.py
import chromadb
from chromadb.utils import embedding_functions
import os


openai_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.getenv("OPENAI_API_KEY"),
    model_name="text-embedding-3-small",
)
client = chromadb.PersistentClient(path="./chroma_test")
collection = client.get_or_create_collection(
    name="hybrid_test",
    embedding_function=openai_ef,
)

# Dataset: 5 docs sobre OAuth2 + FastAPI
docs = [
    # Doc A: el específico que mencionamos en el insight
    "OAuth2PasswordBearer is the FastAPI security class for OAuth2 password flow. "
    "Import it from fastapi.security and use it as a dependency in your endpoints. "
    "It handles token extraction from the Authorization header automatically.",

    # Doc B: paráfrasis cercana sin el identificador
    "Para implementar autenticación OAuth2 con username y password en FastAPI, "
    "usar la dependency adecuada del módulo de seguridad. Esta dependency extrae "
    "automáticamente el token del header de Authorization.",

    # Doc C: genérico sobre OAuth2
    "OAuth2 is an authorization framework that enables third-party applications "
    "to obtain limited access to a user's account. It's commonly used for SSO.",

    # Doc D: sobre password flows en general
    "Password flows in OAuth2 allow users to authenticate with their credentials "
    "and receive an access token. This flow is appropriate for first-party clients.",

    # Doc E: tutorial general de auth en FastAPI
    "FastAPI provides multiple authentication methods including OAuth2, JWT tokens, "
    "and API keys. Choose the method that best fits your security requirements.",
]
ids = [f"doc_{c}" for c in ['A', 'B', 'C', 'D', 'E']]
collection.add(documents=docs, ids=ids)


# Query con identificador exacto
query = "OAuth2PasswordBearer scopes"
results = collection.query(query_texts=[query], n_results=5)

print(f"Query: {query}\n")
print("Ranking de cosine similarity:")
for i, (doc_id, doc, dist) in enumerate(zip(
    results['ids'][0],
    results['documents'][0],
    results['distances'][0],
), 1):
    print(f"\n#{i} [{doc_id}] cosine_distance={dist:.3f}")
    print(f"   {doc[:100]}...")

Output típico:

Query: OAuth2PasswordBearer scopes

Ranking de cosine similarity:

#1 [doc_B] cosine_distance=0.421
   Para implementar autenticación OAuth2 con username y password en FastAPI...

#2 [doc_A] cosine_distance=0.482
   OAuth2PasswordBearer is the FastAPI security class for OAuth2 password flow...

#3 [doc_E] cosine_distance=0.587
   FastAPI provides multiple authentication methods including OAuth2, JWT tokens...

#4 [doc_C] cosine_distance=0.681
   OAuth2 is an authorization framework that enables third-party applications...

#5 [doc_D] cosine_distance=0.752
   Password flows in OAuth2 allow users to authenticate with their credentials...

Lectura: doc_A (que menciona literalmente OAuth2PasswordBearer) queda segundo, debajo de doc_B (paráfrasis cercana). El usuario que escribió la query sabía el nombre exacto y quería el doc que lo documentaba. Semantic search lo subordinó a una paráfrasis genérica.

Esto no es un bug — es el comportamiento esperado de cosine similarity. Los embeddings normalizaron la paráfrasis. Pero para casos donde exactitud importa, ese comportamiento esperado es el problema.


Cómo diagnosticar el impacto en tu corpus

# diagnose_semantic_only_impact.py
import re
from collections import Counter


def categorize_query(query: str) -> str:
    """
    Heurística para detectar queries que probablemente fallan con semantic-only.
    """
    # Identificadores de código (CamelCase, snake_case)
    has_identifier = bool(re.search(r'[A-Z][a-z]+[A-Z][a-z]+|[a-z]+_[a-z]+', query))

    # Códigos de error (mayúsculas con números o guiones)
    has_error_code = bool(re.search(r'[A-Z]{2,}_?[0-9A-Z_]+|HTTP\s*\d{3}', query))

    # Versiones (X.Y.Z, vN, etc.)
    has_version = bool(re.search(r'\bv?\d+\.\d+(\.\d+)?', query))

    # Comandos shell (flags, paths)
    has_command = bool(re.search(r'-{1,2}\w+|/\w+/', query))

    if has_identifier:
        return "code_identifier"
    if has_error_code:
        return "error_code"
    if has_version:
        return "version_specific"
    if has_command:
        return "shell_command"

    return "semantic_only_friendly"


def analyze_query_log(query_log: list[str]) -> dict:
    """Analiza un sample de queries reales."""
    categories = [categorize_query(q) for q in query_log]
    counts = Counter(categories)

    total = len(query_log)
    return {
        cat: f"{count} ({count/total:.0%})"
        for cat, count in counts.most_common()
    }


# Aplicar sobre 1000 queries reales del log de producción
diagnosis = analyze_query_log(production_queries[:1000])
print("Distribución de tipos de query:")
for cat, count in diagnosis.items():
    print(f"  {cat}: {count}")

Output típico (corpus técnico):

Distribución de tipos de query:
  semantic_only_friendly: 412 (41%)
  code_identifier: 285 (29%)
  error_code: 145 (15%)
  version_specific: 98 (10%)
  shell_command: 60 (6%)

Interpretación: 59% de las queries (todo lo que no es "semantic_only_friendly") tiene componentes que semantic search maneja peor. Esto explica por qué tu sistema RAG técnico tiene recall mediocre — más de la mitad de las queries son del tipo donde cosine pierde.


Cuantificando el impacto: recall por categoría

def evaluate_by_category(eval_set, collection):
    """Calcular recall@5 por categoría de query."""
    by_category = {}

    for item in eval_set:
        category = categorize_query(item.query)
        if category not in by_category:
            by_category[category] = {"hits": 0, "total": 0}

        results = collection.query(query_texts=[item.query], n_results=5)
        retrieved_ids = set(results['ids'][0])
        relevant_ids = set(item.expected_doc_ids)

        if retrieved_ids & relevant_ids:
            by_category[category]["hits"] += 1
        by_category[category]["total"] += 1

    print("Recall@5 por categoría (semantic-only):")
    for cat, stats in by_category.items():
        recall = stats["hits"] / stats["total"]
        print(f"  {cat}: {recall:.0%} ({stats['hits']}/{stats['total']})")

Output típico:

Recall@5 por categoría (semantic-only):
  semantic_only_friendly: 87% (35/40)   ← muy bueno
  code_identifier: 58% (17/29)           ← problema
  error_code: 47% (7/15)                 ← peor
  version_specific: 62% (6/10)           ← problema
  shell_command: 50% (3/6)               ← problema

Lectura: semantic search es excelente en queries que no tienen identificadores exactos (87%). Falla dramáticamente en las que sí (47-62%). El recall global del sistema queda arrastrado por el 60% del tráfico que es problemático.

Mejorar el modelo de embeddings ataca el 40% que ya funciona bien. Para los problemas reales (60% del tráfico), necesitas otra técnica.


La trampa: "vamos a embebir mejor"

Cuando ves baja precision/recall, la primera reacción común es:

"Cambiemos a un modelo de embeddings mejor (text-embedding-3-large, Cohere, Voyage)."

Realidad:

  • Mejorar el modelo de embeddings reduce ~5-10% los falsos positivos en queries semánticas.
  • No resuelve el problema de exactitud. El nuevo modelo sigue normalizando paráfrasis, solo lo hace ligeramente mejor.

Si tu problema dominante es queries con identificadores exactos, mejorar el embedder no soluciona casi nada. Lo que necesitas es agregar otra técnica que SÍ priorice exactitud: BM25 (keyword search clásico).

                Cosine                BM25
                (semantic)           (keyword)
Queries:        ──────────           ──────────
"how to auth"   ✅ excelente         🟡 OK
"OAuth2..."     ❌ falla              ✅ excelente
"ERR_404"       ❌ falla              ✅ excelente

Hybrid search es exactamente esa combinación: ejecutar las dos en paralelo, fusionar resultados. Las cápsulas 03-05 cubren el "cómo".


Trampas y errores comunes

Trampa 1: asumir que el problema es de chunking

El error: ves recall bajo en queries con identificadores → asumes que el chunking está mal.

Síntoma: ajustas chunking, gastas 1 semana, mejora marginal.

Cómo prevenir: diagnosticar primero. Si las queries problemáticas son del tipo "identificador exacto", el problema NO es chunking — es la métrica de búsqueda.

Trampa 2: probar con queries semantic-friendly

El error: validas tu sistema con queries como "how to authenticate". Recall@5 sale 88%. Asumes que todo está bien.

Síntoma: producción reporta recall malo. Investigas y descubres que las queries reales son "OAuth2PasswordBearer scopes", no "how to authenticate".

Cómo prevenir: eval set debe reflejar la distribución real de queries de producción. Si 60% del tráfico real son identificadores, 60% del eval set deben ser identificadores.

Trampa 3: agregar BM25 sin medir mejora

El error: "todo el mundo dice que hybrid search mejora", lo agregas sin validar.

Síntoma: la complejidad sube (mantienes dos índices, gestionas fusión), pero la mejora real sobre tu corpus es 2%.

Cómo prevenir: medir antes y después con tu eval set. Si la mejora es <5%, no vale la complejidad.

Trampa 4: usar BM25 como reemplazo de semantic

El error: lees que BM25 funciona para identificadores, lo usas como única búsqueda. Remueves semantic.

Síntoma: las queries semánticas ("how to deploy", "why is X slow") ahora fallan porque BM25 no entiende paráfrasis.

Cómo prevenir: hybrid es ambas en paralelo, no una en lugar de la otra. Cubierto en cápsulas 03-05.

Trampa 5: ignorar queries multilingües al diagnosticar

El error: tu diagnóstico usa solo queries en inglés. Conclusión: 30% del tráfico necesita BM25.

Síntoma: después de implementar BM25, descubres que el 70% del tráfico real está en español, donde el patrón de identificadores es distinto.

Cómo prevenir: diagnóstico segmentado por idioma. La distribución de queries problemáticas puede variar entre idiomas.

Trampa 6: confiar en heurísticas de regex sin validar

El error: tu categorize_query() con regex categoriza queries. Asumes que es preciso.

Síntoma: queries como "explica useState" (que tiene un identificador) las categorizas como "code_identifier", pero la query real es semántica (el usuario quiere explicación, no documentación literal del símbolo).

Cómo prevenir: validar la categorización manualmente sobre 100 queries reales. Ajustar las heurísticas o usar un LLM clasificador para casos ambiguos.


Ejercicio aplicado

Escenario: eres AI Engineer en una empresa de soporte para herramientas DevOps. Tu sistema RAG sirve ~10K queries/día.

Métricas actuales (semantic search con OpenAI text-embedding-3-small + cross-encoder rerank):

  • Precision@5: 85%
  • Recall@5: 64% ← problema

Sample del log de queries:

"how do I configure helm chart values"        ← semántica
"ERR_NETWORK_TIMEOUT_504"                      ← código de error
"kubectl get pods --all-namespaces"            ← comando shell
"why is my pod stuck in CrashLoopBackOff?"     ← semántica + identificador
"v1.27 vs v1.28 differences"                   ← versiones
"how to fix memory leak"                        ← semántica

Tu trabajo:

  1. Diagnostica qué porcentaje del tráfico es problemático con semantic-only.
  2. Estima el potencial de mejora con hybrid search.
  3. Justifica la decisión a stakeholders en términos de ROI.
Solución

1. Diagnóstico

Aplica el script categorize_query sobre el sample (asumiendo que 1000 queries reales reflejan la distribución):

diagnosis = {
    "semantic_only_friendly": "350 (35%)",   # "how to ...", "why is ..."
    "code_identifier": "240 (24%)",           # CrashLoopBackOff, helm chart names
    "error_code": "180 (18%)",                # ERR_TIMEOUT_504
    "shell_command": "130 (13%)",             # kubectl, helm CLI
    "version_specific": "100 (10%)",          # v1.27, v1.28
}

# 65% del tráfico es "problemático" para semantic-only

2. Estimación de mejora

Recall actual segmentado (estimado en base al promedio de la industria):

Categoría                    %       Recall actual    Recall con hybrid
────────────────────────────────────────────────────────────────────────
semantic_only_friendly      35%       85%              85% (sin cambio)
code_identifier             24%       58%              82% (BM25 ayuda)
error_code                  18%       50%              92% (BM25 perfecto)
shell_command               13%       55%              80%
version_specific            10%       62%              88%

Recall global ponderado:
  Actual: 0.35(0.85) + 0.24(0.58) + 0.18(0.50) + 0.13(0.55) + 0.10(0.62)
        = 0.298 + 0.139 + 0.090 + 0.072 + 0.062
        = 0.661 ≈ 66%

  Con hybrid: 0.35(0.85) + 0.24(0.82) + 0.18(0.92) + 0.13(0.80) + 0.10(0.88)
            = 0.298 + 0.197 + 0.166 + 0.104 + 0.088
            = 0.853 ≈ 85%

Mejora esperada: +19 puntos de recall global

3. ROI para stakeholders

# Propuesta: Implementar Hybrid Search

## Diagnóstico
65% del tráfico (queries con identificadores, errores, comandos, versiones) tiene
recall ~50-60% con semantic search. Eso explica por qué nuestro recall global
está estancado en 64%.

## Solución
Agregar BM25 (keyword search) en paralelo con semantic. Las queries con identificadores
exactos van a encontrar matches literales. Las semánticas siguen funcionando con
embeddings. Fusionamos resultados con Reciprocal Rank Fusion.

## Impacto esperado
- Recall@5: 64% → ~85% (+21 puntos)
- Precision@5: estable o ligera mejora
- Latencia p95: +30-50ms (BM25 es rápido)
- Costo: $0 extra (rank_bm25 corre local)

## Tiempo de implementación
- Setup BM25: 2 días
- Integración con pipeline: 1 día
- Validación con eval set: 1 día
- A/B test producción: 2 semanas
- Total: ~3 semanas calendar (5 días de ingeniería)

## Por qué NO mejorar el modelo de embeddings primero
Cambiar a text-embedding-3-large mejoraría queries semánticas (~5-7%) pero NO afecta
las 65% queries con problema de exactitud. Esos casos requieren keyword search.
Los dos cambios son complementarios — hybrid primero (mejora 21 pts), después
upgrade de embeddings (mejora marginal extra).

## Riesgos
- BM25 sobre tokenizado mal puede ranquear ruido. Mitigación: usar tokenizer
  específico del dominio si es necesario.
- Rate limit en re-ranking puede degradarse con más candidatos. Mitigación:
  ajustar n_results del retrieval.

## Recomendación
Aprobar implementación. Sprint de 1 semana de ingeniería + 2 semanas de A/B test.
Mejora medible esperada en recall, sin costo recurrente.

Resumen y siguiente paso

Lo que aprendiste:

  • Semantic search normaliza paráfrasis — comportamiento bueno para queries conceptuales, problemático para queries con identificadores exactos.
  • Cinco clases de queries que rompen semantic-only: identificadores de código, error codes, versiones, comandos, multilingüe con tokens técnicos.
  • En corpus técnico típico, 50-70% del tráfico es del tipo problemático.
  • Recall global suele estar en 60-70% con semantic-only sobre corpus técnico.
  • Diagnóstico con regex + categorización manual permite cuantificar el problema.
  • Mejorar el modelo de embeddings NO resuelve el problema — mejora otra cosa.
  • La solución correcta es agregar BM25 en paralelo, no reemplazar semantic.

Checkpoint: antes de avanzar, deberías poder:

  • Identificar las cinco clases de queries problemáticas para semantic-only.
  • Diagnosticar tu corpus con un script que categoriza queries del log.
  • Estimar el potencial de mejora con hybrid search basándote en distribución de tipos.

Siguiente cápsula: 03 — BM25 keyword search.

Acabas de entender el "por qué" de hybrid search. La cápsula 03 cubre el "cómo" del primer componente: BM25. Vas a aprender qué es BM25 (la evolución moderna de TF-IDF), por qué funciona donde semantic falla, y cómo implementarlo con rank_bm25 (in-memory) o Elasticsearch (a escala).


Recursos

  1. BM25 — The Probabilistic Relevance Framework — Foundational paper
  2. Pinecone — Hybrid Search — Overview accesible
  3. Anthropic — Contextual Retrieval — Técnica complementaria
  4. Stanford NLP — IR Book — Foundational sobre information retrieval
  5. Elasticsearch — Why Hybrid Search — Caso de producción
  6. BEIR Benchmark — Comparación empírica semantic vs hybrid

Tiempo estimado: 25-30 minutos Siguiente: 03-bm25-keyword-search.md