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

Cápsula 06: Elasticsearch — hybrid search a escala de producción

Descripción de la cápsula

rank_bm25 (cápsula 03) es perfecto para aprender y para datasets de hasta ~1M docs. Pero cuando tu corpus crece a 10M, 100M o más, BM25 in-memory deja de ser viable: la RAM no alcanza, las queries se vuelven lentas, y no hay distribución horizontal. La solución de la industria desde hace ~15 años es Elasticsearch (o su fork Apache Solr): motor distribuido que implementa BM25 nativamente y escala a billones de documentos.

Esta cápsula te enseña cómo integrar Elasticsearch como capa de keyword search en un pipeline RAG, las dos arquitecturas posibles (dual: ES + vector DB; o única: ES con vector search nativo), y los detalles operativos que importan en producción: consistencia de IDs entre motores, paralelización de queries, latencia de red.

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

  • ✅ Decidir cuándo migrar de rank_bm25 a Elasticsearch
  • ✅ Setup básico de Elasticsearch local con Docker
  • ✅ Indexar documentos en ES con metadata estructurada
  • ✅ Ejecutar queries BM25 y combinarlas con resultados de vector DB
  • ✅ Comparar arquitectura dual (ES + Pinecone/Chroma) vs motor único (ES con dense vectors)
  • ✅ Anticipar trampas operativas: IDs inconsistentes, latencia de red, ES configurado mal

Tiempo estimado: 30-35 minutos


Cuándo migrar de rank_bm25 a Elasticsearch

Síntoma¿Migrar?
Corpus < 500K docs, RAM holgada❌ Quedarse con rank_bm25
Corpus 1-5M docs, latencia BM25 > 200ms p95⚠️ Evaluar
Corpus > 10M docs✅ Sí, ES o equivalente
Necesitas cluster con HA y replicación✅ Sí
Múltiples instancias de la app necesitan compartir índice✅ Sí (o usar BM25 con Redis)
El equipo ya tiene ES desplegado para logs/metrics⚠️ Probablemente sí (aprovechar infra existente)
Quieres query DSL avanzado (filters, aggregations, fuzzy)✅ Sí

Costo de migración a ES:

  • Setup operativo: ~3-5 días (Docker local OK, cluster productivo más complejo).
  • Re-indexación: depende del tamaño. ES indexea ~10K docs/segundo en hardware razonable.
  • Costo recurrente: $50-500/mes para hosted (Elastic Cloud), o costo de infra propia.

Costo de quedarse con rank_bm25:

  • Si tu corpus crece sin migrar: latencias inaceptables, OOM, app inestable.
  • Si nunca crece más allá de 1M: el costo de migrar a ES no se justifica.

Setup básico con Docker

Para desarrollo local, Docker Compose es lo más rápido:

# docker-compose.yml
services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.13.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false  # solo dev, en prod usar TLS + auth
      - ES_JAVA_OPTS=-Xms1g -Xmx1g
    ports:
      - "9200:9200"
    volumes:
      - es_data:/usr/share/elasticsearch/data

volumes:
  es_data:
docker-compose up -d
# Verificar
curl http://localhost:9200

Output esperado:

{
  "name": "...",
  "cluster_name": "docker-cluster",
  "version": {"number": "8.13.0", ...},
  "tagline": "You Know, for Search"
}

Indexar documentos con metadata estructurada

# es_setup.py
from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk
import os


es = Elasticsearch(
    "http://localhost:9200",
    # Para producción agrega:
    # api_key=os.getenv("ES_API_KEY"),
    # verify_certs=True,
    # ca_certs="/path/to/ca.crt",
)


# Definir mapping (schema del índice)
INDEX_NAME = "rag_docs"

mapping = {
    "mappings": {
        "properties": {
            "doc_id": {"type": "keyword"},     # exact match
            "content": {
                "type": "text",
                "analyzer": "standard",         # tokeniza, lowercase, etc.
            },
            "title": {"type": "text"},
            "category": {"type": "keyword"},
            "language": {"type": "keyword"},
            "created_at": {"type": "date"},
            "metadata": {"type": "object", "enabled": False},
        }
    }
}


def create_index():
    """Crea el índice con mapping. Idempotente: borra y recrea si existe."""
    if es.indices.exists(index=INDEX_NAME):
        es.indices.delete(index=INDEX_NAME)
    es.indices.create(index=INDEX_NAME, body=mapping)
    print(f"Index '{INDEX_NAME}' created")


def index_documents(docs: list[dict]):
    """Indexa documentos en bulk."""
    actions = [
        {
            "_index": INDEX_NAME,
            "_id": doc["doc_id"],
            "_source": doc,
        }
        for doc in docs
    ]
    success, errors = bulk(es, actions, chunk_size=500)
    print(f"Indexed: {success}, errors: {errors}")


# Ejemplo de uso
docs = [
    {
        "doc_id": "doc_001",
        "title": "FastAPI OAuth2 Implementation",
        "content": "OAuth2PasswordBearer is the FastAPI security class for password flow...",
        "category": "auth",
        "language": "en",
        "created_at": "2026-01-15T00:00:00",
    },
    {
        "doc_id": "doc_002",
        "title": "Autenticación OAuth2 en FastAPI",
        "content": "Para implementar autenticación OAuth2 en FastAPI usar la dependency...",
        "category": "auth",
        "language": "es",
        "created_at": "2026-02-20T00:00:00",
    },
    # ...
]

create_index()
index_documents(docs)

Punto clave: el _id de Elasticsearch debería ser el mismo doc_id que usas en tu vector DB. Esto permite fusionar rankings sin lookups extra.


Queries BM25 con filters

def bm25_search_es(query: str, top_k: int = 30, filters: dict = None) -> list[dict]:
    """
    Ejecuta búsqueda BM25 en Elasticsearch con filters opcionales.

    Args:
        query: query text
        top_k: máximo de resultados
        filters: ej {"category": "auth", "language": "en"}
    """
    must_clauses = [
        {
            "multi_match": {
                "query": query,
                "fields": ["title^2", "content"],   # title tiene 2x peso
                "type": "best_fields",
            }
        }
    ]

    filter_clauses = []
    if filters:
        for field, value in filters.items():
            filter_clauses.append({"term": {field: value}})

    query_body = {
        "query": {
            "bool": {
                "must": must_clauses,
                "filter": filter_clauses,
            }
        },
        "size": top_k,
    }

    response = es.search(index=INDEX_NAME, body=query_body)

    results = []
    for hit in response["hits"]["hits"]:
        results.append({
            "doc_id": hit["_id"],
            "score": hit["_score"],         # ES devuelve score BM25
            "content": hit["_source"]["content"],
            "metadata": hit["_source"],
        })
    return results


# Probar
results = bm25_search_es(
    query="OAuth2PasswordBearer scopes",
    top_k=10,
    filters={"category": "auth", "language": "en"},
)
for r in results[:3]:
    print(f"Score: {r['score']:.2f}")
    print(f"  {r['content'][:120]}")

Nota:

  • multi_match con fields=["title^2", "content"] da peso doble al título. Útil cuando el título tiene los keywords más representativos.
  • filter clauses son rápidos (no contribuyen al score, solo filtran). Apropiados para metadata como category, language, fechas.
  • _score de ES es el BM25 raw score, listo para fusionar.

Arquitectura dual: Elasticsearch + ChromaDB/Pinecone

El patrón más común en producción:

                          Query
                            │
              ┌─────────────┴─────────────┐
              │                           │
              ▼                           ▼
      ┌──────────────┐           ┌──────────────────┐
      │ Elasticsearch│           │  Vector DB       │
      │ (BM25)       │           │ (ChromaDB,       │
      │              │           │  Pinecone, etc.) │
      │ Top-30       │           │  Top-30          │
      └──────┬───────┘           └────────┬─────────┘
             │                            │
             └────────────┬───────────────┘
                          │
                          ▼
                   ┌──────────────┐
                   │ RRF / Weighted│
                   │  fusion       │
                   └──────┬───────┘
                          │
                          ▼
                     Top-K final

Implementación:

import chromadb
from chromadb.utils import embedding_functions
from concurrent.futures import ThreadPoolExecutor


# Setup ChromaDB (semantic)
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.getenv("OPENAI_API_KEY"),
    model_name="text-embedding-3-small",
)
chroma_client = chromadb.PersistentClient(path="./chroma_db")
collection = chroma_client.get_collection("rag_docs", embedding_function=openai_ef)


def hybrid_search_es_chroma(query: str, top_k: int = 5, filters: dict = None):
    """Hybrid search con queries en paralelo a ES y ChromaDB."""

    # Ejecutar las dos queries en paralelo (I/O bound)
    with ThreadPoolExecutor(max_workers=2) as executor:
        future_bm25 = executor.submit(bm25_search_es, query, 30, filters)

        # ChromaDB: convertir filters al formato de ChromaDB
        chroma_filters = {k: v for k, v in (filters or {}).items()}

        future_semantic = executor.submit(
            lambda: collection.query(
                query_texts=[query],
                n_results=30,
                where=chroma_filters or None,
            )
        )

        bm25_results = future_bm25.result()
        sem_results = future_semantic.result()

    # Extraer rankings (lista de doc_ids ordenados)
    bm25_ranking = [r["doc_id"] for r in bm25_results]
    semantic_ranking = sem_results["ids"][0]

    # Fusionar con RRF
    fused = reciprocal_rank_fusion([bm25_ranking, semantic_ranking], k=60)
    top_ids = [doc_id for doc_id, score in fused[:top_k]]

    # Recuperar contenido completo (de ChromaDB que tiene los embeddings y metadata)
    return collection.get(ids=top_ids)

Ventajas de esta arquitectura:

  • Cada motor optimizado para su tarea: ES para keyword, vector DB para semantic.
  • Queries en paralelo → latencia total = max(ES, vector DB), no la suma.
  • Cambiar uno no afecta al otro.

Desventajas:

  • Dos sistemas que mantener (operacional overhead).
  • Sincronización de datos: cada documento debe estar en ambos.
  • Latencia de red: dos conexiones por query.

Arquitectura única: Elasticsearch con dense vectors

Desde Elasticsearch 8.x, ES tiene soporte nativo para dense vectors y permite hybrid search en una sola query. Si ya estás en ES o quieres simplificar, esta es opción válida.

# Mapping con vector field
mapping_with_vectors = {
    "mappings": {
        "properties": {
            "doc_id": {"type": "keyword"},
            "content": {"type": "text"},
            "embedding": {
                "type": "dense_vector",
                "dims": 1536,                   # OpenAI text-embedding-3-small
                "index": True,
                "similarity": "cosine",
            },
            "category": {"type": "keyword"},
        }
    }
}


def index_with_embedding(doc_id: str, content: str, metadata: dict):
    """Index doc + embedding en ES."""
    # Generar embedding
    embedding = openai_ef([content])[0]  # asume embedding_function configurada

    es.index(
        index=INDEX_NAME,
        id=doc_id,
        document={
            "doc_id": doc_id,
            "content": content,
            "embedding": embedding,
            **metadata,
        }
    )


def hybrid_search_native_es(query: str, top_k: int = 5):
    """Hybrid search nativo en ES (BM25 + kNN)."""
    query_embedding = openai_ef([query])[0]

    # ES 8+ soporta hybrid con RRF nativo
    body = {
        "size": top_k,
        "query": {
            "bool": {
                "should": [
                    {"match": {"content": query}},  # BM25
                ]
            }
        },
        "knn": {
            "field": "embedding",
            "query_vector": query_embedding,
            "k": 30,
            "num_candidates": 100,
        },
        "rank": {
            "rrf": {
                "rank_window_size": 50,
                "rank_constant": 60,             # k de RRF
            }
        },
    }

    response = es.search(index=INDEX_NAME, body=body)
    return [
        {
            "doc_id": hit["_id"],
            "score": hit["_score"],
            "content": hit["_source"]["content"],
        }
        for hit in response["hits"]["hits"]
    ]

Ventajas:

  • Un solo motor. Operación más simple.
  • RRF nativo (no necesitas implementar fusion).
  • Filters consistentes entre BM25 y vector search.

Desventajas:

  • ES no es el motor más rápido para vector search comparado con Pinecone/Qdrant.
  • Recursos: ES con dense vectors necesita mucha RAM.
  • Migrar después es difícil si quieres cambiar de stack.

Decisión: dual vs único

CriterioDual (ES + vector DB)Único (ES con dense vectors)
Performance vector searchMejor (vector DB especializada)OK (ES no es óptimo para vectors)
Simplicidad operacionalPeor (2 sistemas)Mejor (1 sistema)
Costo infraMayorMenor
Equipo necesita expertise enES + vector DBSolo ES
Flexibilidad para cambiar componentesAltaBaja (lock-in)
Apropiado para>10M docs, equipos grandes<10M docs, equipos chicos

Recomendación práctica:

  • Si ya tienes ES desplegado para logs/metrics: usar único (aprovechar infra).
  • Si no tienes ES y empiezas de cero: dual con vector DB especializada (Pinecone, Qdrant).
  • Si tu corpus es <1M: ni siquiera necesitas ES — rank_bm25 + ChromaDB alcanza.

Trampas y errores comunes

Trampa 1: IDs distintos entre ES y vector DB

El error: ES indexa con _id="abc123". ChromaDB indexa con _id="doc_abc123".

Síntoma: la fusión RRF no encuentra docs comunes. Cada motor "vota" por docs distintos, el ranking final es ruido.

Cómo prevenir: siempre el mismo doc_id en ambos motores. Si tienes que reformatear, hazlo antes del indexing, no después.

Trampa 2: queries secuenciales en arquitectura dual

El error:

bm25 = bm25_search_es(query)        # 50ms
semantic = collection.query(query)   # 100ms
# Total: 150ms

Síntoma: latencia total = suma de las dos queries.

Cómo prevenir: ejecutar en paralelo con ThreadPoolExecutor. I/O bound, paralelismo es trivial. Total: 100ms (max de las dos).

Trampa 3: Elasticsearch sin TLS/auth en producción

El error: copias el setup de desarrollo (xpack.security.enabled=false) a producción.

Síntoma: ES expuesto sin auth. Cualquier escaneo de red puede leer y modificar tu índice.

Cómo prevenir: producción siempre con TLS + API keys + reglas de firewall.

Trampa 4: re-indexar todo cuando cambia el mapping

El error: ES no permite cambiar tipo de campos in-place. Si cambias category: keyword a category: text, hay que re-indexar.

Síntoma: después de cambiar el mapping, queries devuelven 0 resultados o errores.

Cómo prevenir: planificar el mapping al inicio. Para cambios futuros, usar reindex API o crear nuevo índice y migrar.

Trampa 5: olvidar score normalization si NO usas RRF

El error: dual architecture, quieres combinar scores de ES (BM25) y ChromaDB (cosine distance) sin RRF.

Síntoma: rangos incompatibles. Resultado equivalente a la cápsula 05.

Cómo prevenir: o usar RRF (cápsula 04, robusto a rangos), o normalizar scores con min-max (cápsula 05).

Trampa 6: num_candidates muy bajo en kNN nativo de ES

El error:

"knn": {"k": 30, "num_candidates": 30}  # default si no especificas

Síntoma: recall bajo. ES descarta candidatos válidos al limitar num_candidates.

Cómo prevenir: num_candidates >= 5x k. Para k=30, usar num_candidates=150-200.


Ejercicio aplicado

Escenario: eres AI Engineer en una empresa de soporte técnico. Stack actual:

  • 5M chunks de documentación + tickets resueltos
  • ChromaDB con OpenAI embeddings (semantic)
  • rank_bm25 in-memory (BM25)
  • Hybrid con RRF

Síntomas:

  • Latencia BM25 sube de 30ms a 250ms en hora pico (in-memory no escala más).
  • Memoria de la app crece a 12GB → instances quedan al borde de OOM.
  • Necesitas 3-4 réplicas de la app, pero cada una tiene su BM25 propio (caro y desincronizado).

Tu trabajo:

  1. Decide entre arquitectura dual (ES + Chroma) o única (ES con dense vectors).
  2. Diseña el plan de migración.
  3. Estima impacto operacional.
Solución

1. Decisión: arquitectura dual (ES + ChromaDB)

Razones:

  • Vector search es crítico y ChromaDB ya está optimizado. Migrar a ES con dense vectors degradaría performance vector.
  • Equipo ya tiene expertise en ChromaDB. Cambiar el motor vector tiene costo de aprendizaje innecesario.
  • 5M docs justifica ES para BM25 (vs rank_bm25), pero NO requiere unificar el stack.
  • Flexibilidad futura: dual permite migrar uno sin afectar el otro.

2. Plan de migración (3-4 semanas)

Semana 1: Setup de Elasticsearch

  • Deploy ES en cluster (3 nodes para HA, o managed Elastic Cloud).
  • Configurar TLS, API keys, monitoring.
  • Crear índice rag_docs con mapping definido.

Semana 2: Indexar corpus existente en ES

  • Script de migración: leer chunks de ChromaDB (collection.get(...)) y bulk index a ES.
  • 5M docs × 100ms por bulk de 500 = ~17 minutos puros. Realísticamente 30-60 minutos.
  • Verificar que ES tiene los mismos docs que ChromaDB (count matching).
  • Garantizar que _id en ES = id en ChromaDB (para fusión RRF posterior).

Semana 3: Implementar paralelización en pipeline

  • Refactorear pipeline RAG: queries paralelas a ES y ChromaDB con ThreadPoolExecutor.
  • Reemplazar rank_bm25 calls con calls a ES.
  • A/B test sobre eval set: comparar resultados pre/post migración.

Semana 4: Rollout gradual

  • Feature flag: 10% del tráfico al nuevo pipeline. Monitorear latencia + recall.
  • Si métricas se mantienen, escalar a 50% → 100%.
  • Removar rank_bm25 del código una vez deployado a 100%.

3. Impacto operacional

Mejoras esperadas:

  • Latencia BM25: 250ms p95 → 30-50ms p95 (ES es rápido para BM25).
  • Memoria de la app: 12GB → 4GB (sin BM25 in-memory).
  • Réplicas de la app pueden compartir el mismo ES, sin desincronización.
  • Disponibilidad: ES con HA tiene mejor uptime que app con BM25 in-memory.

Costo extra:

  • Infra ES: $200-500/mes (managed) o costo de operar cluster propio.
  • Latencia agregada por la red ES: ~5-10ms (despreciable).
  • Operacional: monitoreo + backups de ES + actualizaciones de versión.

ROI:

  • Si latencia bajada de 250ms a 50ms mejora retention/satisfaction del usuario, los $300/mes se justifican.
  • Si la app pasa de 4 réplicas (12GB cada una) a 4 réplicas (4GB cada una), ahorras 32GB de RAM en infra → potencialmente $100-200/mes.

Plan B si la migración tiene problemas:

  • Rollback rápido posible con feature flag (1 hora).
  • Si ES tiene problemas de calidad: investigar mapping (¿está bien el analyzer? ¿se aplica el ^2 boost al título?).
  • Si latencia es peor de lo esperado: revisar configuración de ES (refresh_interval, replicas, shards).

Métricas a monitorear post-migración:

  • Latencia ES vs antes (debería bajar 5x).
  • Recall@5 (debería mantener o mejorar).
  • Errores 5xx en ES (deberían ser 0).
  • Memoria de la app (debería bajar 50%+).

Resumen y siguiente paso

Lo que aprendiste:

  • Elasticsearch es la solución estándar de la industria para BM25 a escala (>1M docs).
  • Setup local con Docker es rápido (5 minutos). Producción requiere TLS, auth, cluster.
  • Indexación con bulk API: ~10K docs/segundo en hardware razonable.
  • Multi-match con boost (^2) para títulos. Filters para metadata (rápidos, no contribuyen al score).
  • Dos arquitecturas: dual (ES + vector DB especializada) o única (ES con dense vectors nativo).
  • Para queries dual: paralelizar con ThreadPoolExecutor para que latencia = max, no suma.
  • IDs consistentes entre motores es crítico para fusión RRF posterior.
  • ES 8+ tiene RRF nativo via rank.rrf — útil si vas con arquitectura única.

Checkpoint: antes de avanzar, deberías poder:

  • Decidir entre rank_bm25 y Elasticsearch según escala.
  • Setup local de ES con Docker en 10 minutos.
  • Implementar pipeline hybrid dual con queries paralelas.
  • Diferenciar arquitectura dual vs única y elegir según contexto.

Siguiente cápsula: 07 — Decision framework de hybrid search.

Cubrimos los componentes (BM25, semantic, RRF, weighted, ES). La cápsula 07 consolida todo: cuándo cada técnica gana, qué patrón elegir según tu escala/dominio/equipo, y un decision framework reproducible.


Recursos

  1. Elasticsearch — Official Docs — Documentación completa
  2. Elasticsearch Python Client — SDK oficial
  3. Hybrid Search with Elasticsearch (8.x) — Guía oficial
  4. Elasticsearch RRF — Implementación nativa
  5. Qdrant — Hybrid Search Alternative — Comparación con otra opción
  6. BEIR Benchmark — Comparación empírica BM25 vs hybrid

Tiempo estimado: 30-35 minutos Siguiente: 07-strategy-comparison-2.md