TL;DR
RAG "naive" funciona en el demo y se rompe en producción. El patrón que se ha estabilizado en 2026 combina siete técnicas: Chonkie para chunking semántico (no por caracteres), Qdrant con búsqueda híbrida dense + BM25, BGE-reranker v2-m3 self-hosted para reordenar top-k, Contextual Retrieval (técnica Anthropic) que agrega contexto a cada chunk vía Haiku en batch, RAPTOR para resúmenes jerárquicos, RedisVL como cache semántico, y evaluación continua con Ragas + DeepEval trazada en Langfuse. Este artículo muestra el pipeline end-to-end con código, métricas reales (recall@5 sube de 0.62 a 0.91) y costos al escalar. El punto dónde "tu RAG se rompe" es casi siempre chunking + falta de reranker.
Los síntomas
El agente funcionó perfecto con 20 documentos en QA. Entró a producción con 500 y empezó a:
- Fabricar detalles que no están en la fuente.
- Recuperar pedazos correctos pero incompletos.
- Ignorar documentos recientes porque los viejos tienen mejor embedding.
- Responder "no encontré información" cuando sí está.
- Mezclar información de dos clientes distintos.
Cada síntoma tiene una causa distinta, y cada causa tiene una solución.
El pipeline en 7 pasos
Documento crudo
│
▼
[1] Parsing con Unstructured.io o Firecrawl
│
▼
[2] Chunking semántico (Chonkie)
│
▼
[3] Contextual Retrieval: prepend contexto a cada chunk
│
▼
[4] Embedding dense (OpenAI 3-small o BGE-m3) + BM25
│
▼
[5] Qdrant (collection híbrida)
│
▼
Query time:
[6] Hybrid search (dense + sparse) → top-40
▼
[7] Rerank con BGE-reranker → top-6
▼
LLM recibe contexto limpio
Paso 1 — Parsing serio
Olvida PyPDF2. Unstructured.io extrae con estructura: sabe distinguir encabezados, tablas, código, listas. Firecrawl hace lo equivalente para páginas web, manejando JS y paginación.
from unstructured.partition.auto import partition
elements = partition("manual-fiscal-sat-2026.pdf")
for el in elements:
print(el.category, "|", el.text[:80])
# → Title, NarrativeText, Table, ListItem, FigureCaption, ...
Resultado: cada chunk sabe qué tipo de contenido es. Las tablas se mantienen como tablas (no se parten a la mitad), los encabezados se vinculan a sus párrafos.
Paso 2 — Chunking semántico con Chonkie
RecursiveCharacterTextSplitter de LangChain parte en cada 1000 caracteres. Termina partiendo una tabla importante por la mitad o cortando una frase a la mitad.
Chonkie tiene tres estrategias mejores:
SemanticChunker— parte donde la similitud embedding entre frases consecutivas cae.SDPMChunker— Semantic Double-Pass Merging: chunks semánticos iniciales, luego merge para respetar tamaño target.LateChunker— embed el documento entero primero, luego chunk. Preserva contexto de largo alcance.
from chonkie import SDPMChunker
chunker = SDPMChunker(
embedding_model="BAAI/bge-m3",
chunk_size=512,
threshold=0.75,
skip_window=1,
)
chunks = chunker.chunk(document_text)
Para documentos tipo manual/contrato, SDPMChunker funciona mejor. Para tickets cortos/emails, SemanticChunker.
Paso 3 — Contextual Retrieval
Técnica publicada por Anthropic en 2024, ya estándar. Problema: un chunk que dice "el plazo es de 30 días" es ambiguo sin contexto de qué plazo habla.
Solución: antes de embedar, prepend al chunk un párrafo de contexto que el documento entero le da. Se hace una vez por chunk con un modelo barato (Claude Haiku en batch API, costo ~0.03 USD por 1000 chunks).
CONTEXT_PROMPT = """Documento completo:
<documento>
{document}
</documento>
Aquí el chunk que vamos a colocar en el índice:
<chunk>
{chunk}
</chunk>
Escribe 1-3 frases situando este chunk en el documento. Incluye:
- qué tema trata
- entidad/cliente/producto de referencia
- fecha relevante si aplica
No repitas información del chunk; sólo contexto.
"""
def contextualize(document: str, chunk: str) -> str:
ctx = call_haiku(CONTEXT_PROMPT.format(document=document, chunk=chunk))
return f"{ctx}\n\n---\n\n{chunk}"
Impacto medido en Anthropic: recall@20 sube de 5.7% a 1.9% de fallos (67% de reducción). En nuestros clientes vemos mejora análoga.
Paso 4 — Embedding dense + sparse
Embedding dense captura semántica ("cuota mensual" ≈ "suscripción periódica"). Embedding sparse (BM25) captura coincidencia exacta (ID, nombres propios, códigos).
from fastembed import TextEmbedding, SparseTextEmbedding
dense = TextEmbedding("BAAI/bge-small-en-v1.5") # o 'intfloat/multilingual-e5-large'
sparse = SparseTextEmbedding("Qdrant/bm25")
dense_vec = next(dense.embed([chunk_text]))
sparse_vec = next(sparse.embed([chunk_text]))
Para español, usamos intfloat/multilingual-e5-large (768 dims) o jinaai/jina-embeddings-v3 (1024 dims). BGE-m3 da un modelo que produce dense + sparse en una sola pasada.
Paso 5 — Qdrant con búsqueda híbrida
Colección con dos vectores nombrados:
from qdrant_client import QdrantClient, models
client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
client.create_collection(
collection_name="kb_numoru",
vectors_config={"dense": models.VectorParams(size=768, distance=models.Distance.COSINE)},
sparse_vectors_config={"bm25": models.SparseVectorParams(modifier=models.Modifier.IDF)},
)
client.upsert(
collection_name="kb_numoru",
points=[
models.PointStruct(
id=uuid4().hex,
vector={"dense": dense_vec, "bm25": sparse_vec.as_object()},
payload={
"text": chunk,
"source": doc_id,
"tenant_id": tenant_id,
"created_at": doc.created_at.isoformat(),
"category": doc.category,
},
)
],
)
Query híbrida con fusión RRF:
results = client.query_points(
collection_name="kb_numoru",
prefetch=[
models.Prefetch(query=dense_vec, using="dense", limit=40),
models.Prefetch(query=sparse_vec.as_object(), using="bm25", limit=40),
],
query=models.FusionQuery(fusion=models.Fusion.RRF),
query_filter=models.Filter(must=[
models.FieldCondition(key="tenant_id", match=models.MatchValue(value=tenant_id))
]),
limit=40,
)
Paso 6 — Reranking con BGE-reranker v2-m3
Los top-40 de la búsqueda híbrida tienen precisión decente pero aún incluyen ruido. El reranker re-evalúa pares (query, chunk) con un cross-encoder y ordena.
from FlagEmbedding import FlagLLMReranker
reranker = FlagLLMReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
def rerank(query: str, hits, top_k=6):
pairs = [[query, h.payload["text"]] for h in hits]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(zip(hits, scores), key=lambda x: -x[1])
return [h for h, _ in ranked[:top_k]]
Corriendo el reranker self-hosted en CPU para 40 pares tarda ~250 ms. Sobre GPU pequeña, <50 ms. Mejora típica de nDCG@5: +15-22 puntos.
Alternativas:
- Cohere Rerank v3 (SaaS) — excelente multilingüe, 0.002 USD por rerank.
- Jina Reranker v2 — open source competitivo.
Paso 7 — RAPTOR para documentación jerárquica
Cuando los documentos son largos (manuales, regulaciones, libros), el retrieval flat falla en preguntas tipo "¿cuál es el tema general del capítulo 3?". RAPTOR construye un árbol de resúmenes:
- Chunks base (hojas del árbol).
- Cluster de chunks relacionados (por similitud).
- Genera un resumen por cluster (padre).
- Repetir hasta tener una raíz.
- Embed cada nivel.
En query time, buscas en todos los niveles simultáneamente. Preguntas específicas caen en hojas; preguntas generales caen en resúmenes.
from raptor import RetrievalAugmentation
RA = RetrievalAugmentation(config=raptor_config)
RA.add_documents(document_texts)
answer = RA.answer_question(question=query)
Para la mayoría de casos (docs cortos, FAQs, emails) RAPTOR es overkill. Lo usamos en legal y manuales técnicos.
Caché semántico con RedisVL
Antes de consultar Qdrant, chequea Redis: ¿alguien hizo una query "suficientemente parecida" en las últimas 24h?
from redisvl.extensions.llmcache import SemanticCache
cache = SemanticCache(
name="rag_cache",
redis_url=os.getenv("REDIS_URL"),
distance_threshold=0.12, # ≈ similitud 0.88
ttl=86400,
)
def answer(query: str) -> str:
if hit := cache.check(query):
return hit[0]["response"]
ctx = rag_retrieve(query)
response = llm(ctx, query)
cache.store(prompt=query, response=response)
return response
Hit rate típico en agentes de soporte: 35-55%. Ahorra latencia y tokens.
Evaluación con Ragas + DeepEval
RAG es no determinista; necesita eval sistemático. Tres métricas imprescindibles:
- Faithfulness — ¿la respuesta es derivable de los chunks recuperados?
- Answer Relevancy — ¿la respuesta atiende la query?
- Context Recall — ¿los chunks recuperados cubren la respuesta esperada?
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_recall, context_precision
dataset = load_golden_rag_v2()
result = evaluate(
dataset,
metrics=[faithfulness, answer_relevancy, context_recall, context_precision],
llm=claude_sonnet,
)
Los scores se escriben a Langfuse como evals de la dataset run. En CI integramos con Promptfoo + DeepEval: un PR que baje context_recall más de 5 puntos se bloquea.
Métricas reales de un caso cliente
Cliente legal, 8,000 documentos, 120 queries dorados.
| Configuración | Recall@5 | Faithfulness | Latencia p95 |
|---|---|---|---|
| Naive (recursive 1k, e5 base, flat search) | 0.62 | 0.71 | 1.2 s |
| + SDPMChunker | 0.71 | 0.76 | 1.2 s |
| + Contextual Retrieval | 0.78 | 0.83 | 1.3 s |
| + Hybrid search (dense + BM25) | 0.85 | 0.85 | 1.4 s |
| + BGE-reranker | 0.91 | 0.90 | 1.6 s |
| + RedisVL cache (en queries repetidas) | 0.91 | 0.90 | 0.2 s |
Cada capa aporta. La combinación es lo que separa producción de demo.
Mismo golden set de 120 queries contra 8,000 documentos legales. Cada punto es la config acumulada; el último es con caché RedisVL en queries repetidas.
- Recall@5
- Faithfulness
Telemetría de cliente Numoru, Q1 2026.
Costos al escalar
Caso: 50,000 chunks indexados, 100k queries/mes.
| Partida | Costo/mes (USD) |
|---|---|
| Contextual Retrieval (Haiku batch) — indexación inicial | 15 (una vez) |
| Embedding dense (100k queries × 1 llamada + re-indexación semanal) | 40 |
| Qdrant (droplet $40 compartido) | 10 (pro-rata) |
| BGE-reranker self-host (CPU compartido) | 5 |
| Cohere Rerank alternativa SaaS | 200 |
| LiteLLM LLM calls (Claude Sonnet, avg 2k tokens) | 350 |
| Redis cache | 5 |
| Total mensual (self-host reranker) | 425 |
Sin caché, Sonnet solo, sin reranker: 800-950 USD. El stack reduce 45% de costo mientras sube precisión.
Impacto de negocio y casos
El servicio "RAG rescue"
Decenas de empresas shipearon una feature RAG en 2024-2025 que dejó de funcionar al crecer el corpus. El modo de falla es consistente: usuarios dejan de confiar, el uso cae, el exec team quiere cancelar el proyecto. "RAG rescue" es un engagement de 6-8 semanas que reemplaza el pipeline naive con el patrón productivo y lo prueba con evals.
Quién compra un rescue
Ticket por rescue engagement por comprador (Numoru, USD)
Benchmarks públicos que anclan el pitch
Anthropic — Contextual Retrieval
BGE Reranker v2-m3 — benchmark público
Caso ilustrativo — SaaS de research legal mid-market
SaaS LATAM de research legal con corpus de 8,000 docs, golden set de 120 queries
Calculadora ROI — rescue para SaaS mid-market
SaaS mid-market: rescue vs seguir con calidad pobre (12 meses post-launch)
| Rescue (one-time) | −$42,000 |
| Retainer (12 mo × $1,800) | −$21,600 |
| Delta infra (12 mo × $380) | −$4,560 |
| ARR retenido (9% × $806K) | +$72,540 |
| Expansión por crecimiento de activos | +$571,200 |
| Tickets de soporte evitados | +$18,000 |
| Contribución neta año 1 | +$593,580 |
Tiers de pricing Numoru
- Ragas + DeepEval sobre tu golden set
- Auditoría de chunking + embedding
- Taxonomía de fallos de retrieval
- Roadmap priorizado
- Entregable: reporte + notebook demo
- Chonkie + Contextual Retrieval
- Qdrant híbrido + BGE reranker
- Caché semántica RedisVL
- Evals Ragas + Langfuse
- Training al equipo + runbook
- Guarantee 90 días sobre métricas
- Revisión mensual de evals + alertas
- Gestión de upgrades de modelo
- Fine-tune de prompt + retrieval
- Curación de dashboard Langfuse
- Call estratégica trimestral
- On-call durante lanzamientos
Scope salud / compliance agrega $8,000-20,000 por trabajo extra de audit-trail y redacción.
Anti-patrones frecuentes
- Chunks de 500 tokens porque "alguien dijo que era lo óptimo". Depende del dominio. Prueba 256, 512, 1024 y mide.
- Embed todo con
text-embedding-ada-002(v1). Obsoleto;text-embedding-3-smalles 5× mejor y más barato. - No filtrar por
tenant_id. Crítico en multi-tenant. Un filtro omitido = data leak. - Olvidar actualizar el índice cuando cambia un doc. Sin TTL o upsert por doc_id, el índice se desincroniza.
- Pasarle al LLM los 40 top-k sin rerank. Ruido degrada la respuesta más que la mejora.
- No medir. Sin Ragas o DeepEval en CI, las regresiones pasan sin aviso.
Cuándo NO hacer RAG
- Si los datos relevantes caben en el prompt (<100k tokens) y la latencia del contexto largo es aceptable.
- Si el dominio cambia más rápido que el re-indexing (segundos-minutos).
- Si el costo de un error por respuesta ficticia es catastrófico — mejor búsqueda determinista + LLM sólo como formateador.
FAQ
¿Qué tamaño de chunk usar?Empieza con 512 tokens + overlap 50. Medí. Ajusta. Para código: 256 suele ganar; para regulación: 768-1024.
¿Embed local o SaaS? SaaS mientras volumen <500k embeddings/mes. A partir de ahí, BGE-m3 local en CPU es 10× más barato.
¿Qué modelo de reranker?BGE-reranker v2-m3 es el mejor OSS multilingüe hoy. Cohere Rerank v3 si prefieres SaaS.
¿RAG vs long context?Ambos sirven. RAG controla mejor atribución y costo; long context es simple. Usa long context para conversaciones cortas con documento único; RAG para KB grandes y multi-tenant.
¿Cómo manejo documentos que se actualizan?Upsert por doc_id como clave. Cuando llega versión nueva, re-chunk, re-embed y reemplaza todos los chunks de ese doc. Usa schedule_date en payload para marcar freshness.
Próximos pasos
El pipeline completo está en github.com/numoru-ia/rag-production-stack con Docker Compose que levanta Qdrant + Redis + Langfuse + FastAPI + los scripts de indexación. La siguiente pieza cubre orquestación de tres agentes sobre este RAG para un caso vertical concreto.