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_bm25a 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_matchconfields=["title^2", "content"]da peso doble al título. Útil cuando el título tiene los keywords más representativos.filterclauses son rápidos (no contribuyen al score, solo filtran). Apropiados para metadata comocategory,language, fechas._scorede 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
| Criterio | Dual (ES + vector DB) | Único (ES con dense vectors) |
|---|---|---|
| Performance vector search | Mejor (vector DB especializada) | OK (ES no es óptimo para vectors) |
| Simplicidad operacional | Peor (2 sistemas) | Mejor (1 sistema) |
| Costo infra | Mayor | Menor |
| Equipo necesita expertise en | ES + vector DB | Solo ES |
| Flexibilidad para cambiar componentes | Alta | Baja (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_bm25in-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:
- Decide entre arquitectura dual (ES + Chroma) o única (ES con dense vectors).
- Diseña el plan de migración.
- 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_docscon 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 (
countmatching). - Garantizar que
_iden ES =iden 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_bm25calls 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_bm25del 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
^2boost 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
ThreadPoolExecutorpara 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_bm25y 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
- Elasticsearch — Official Docs — Documentación completa
- Elasticsearch Python Client — SDK oficial
- Hybrid Search with Elasticsearch (8.x) — Guía oficial
- Elasticsearch RRF — Implementación nativa
- Qdrant — Hybrid Search Alternative — Comparación con otra opción
- BEIR Benchmark — Comparación empírica BM25 vs hybrid
Tiempo estimado: 30-35 minutos Siguiente: 07-strategy-comparison-2.md