Tu RAG falla en producción: chunking, retrieval y reranking
Tu RAG pasa todos los tests. En demo, el agente responde perfectamente. Lo subes a producción y el 30% de las respuestas son imprecisas, incompletas o directamente incorrectas.
El modelo no ha cambiado. El prompt tampoco. El problema está en el pipeline de retrieval.
Esto no es un caso aislado: es el patrón más repetido en proyectos RAG que escalamos en DAILYMP. Los mismos tres puntos de fallo, una y otra vez.
El primer fallo: chunking sin estrategia
La mayoría de los proyectos RAG empiezan con el mismo enfoque: dividir los documentos en chunks de tamaño fijo (típicamente 512 tokens) y guardarlos en un vector store. Rápido de implementar, fácil de entender, catastrófico en producción.
El problema no es el tamaño del chunk: es que un chunk de tamaño fijo no respeta la estructura semántica del documento.
Imagina un manual de producto de 50 páginas. La sección 3.2 explica el contexto de una funcionalidad. La sección 3.3 describe cómo usarla. Con chunking de 512 tokens, esa división puede caer exactamente en medio de la sección 3.2. El chunk resultante empieza en mitad de una idea, sin el contexto anterior. Cuando el retriever lo recupera, el LLM ve una respuesta incompleta.
// El antipatrón más común: chunking sin overlap ni metadata
const chunks = splitByTokens(document, 512);
// El chunk 4 puede empezar con:
// "...que en estos casos conviene activar la opción B, ya que
// el sistema de validación verifica automáticamente..."
// Sin saber qué opción, qué casos ni qué sistema.
La solución: overlap obligatorio + metadata estructurada.
interface Chunk {
text: string;
metadata: {
source: string; // nombre del documento
section: string; // sección a la que pertenece
pageNumber: number;
chunkIndex: number;
contentType: string; // "procedimiento" | "definición" | "ejemplo"
};
}
function splitWithOverlap(
doc: Document,
chunkSize = 512,
overlap = 64
): Chunk[] {
// 64 tokens de overlap → el inicio de cada chunk comparte
// contexto con el final del anterior.
// La metadata permite filtrar ANTES del retrieval.
}
El overlap de 64 tokens resuelve el problema de las ideas que cruzan fronteras de chunk. La metadata permite filtrar antes de calcular similitudes: si el usuario pregunta por un procedimiento específico, puedes limitar la búsqueda a contentType: "procedimiento" antes de comparar vectores.
El segundo fallo: dense retrieval solo
Cuando tu base de conocimiento incluye identificadores, nombres propios o términos muy específicos, el retrieval semántico falla de forma silenciosa.
"número de factura INV-2024-0045" y "documento INV-2024-0045" tienen alta similitud semántica entre sí, pero una pregunta como "¿qué productos incluye la factura INV-2024-0045?" puede no recuperar el documento correcto si el embedding no captura bien esa especificidad léxica.
El dense retrieval está diseñado para capturar similitud de significado. No para encontrar coincidencias exactas de texto. Para eso existe BM25 (keyword search clásico).
La solución: hybrid search con Reciprocal Rank Fusion.
async function hybridSearch(
query: string,
vectorStore: VectorStore,
bm25Index: BM25Index,
topK = 10
): Promise<RankedResult[]> {
// Búsqueda paralela: semántica + keyword
const [denseResults, bm25Results] = await Promise.all([
vectorStore.similaritySearch(query, topK),
bm25Index.search(query, topK),
]);
return reciprocalRankFusion([denseResults, bm25Results], topK);
}
function reciprocalRankFusion(
resultSets: SearchResult[][],
topK: number,
k = 60 // constante RRF estándar
): RankedResult[] {
const scores = new Map<string, number>();
for (const results of resultSets) {
results.forEach((result, rank) => {
const current = scores.get(result.id) ?? 0;
scores.set(result.id, current + 1 / (k + rank + 1));
});
}
return Array.from(scores.entries())
.sort(([, a], [, b]) => b - a)
.slice(0, topK)
.map(([id, score]) => ({ id, score }));
}
RRF no necesita normalizar los scores de cada sistema (que usan escalas distintas). Combina las posiciones de ranking y devuelve los chunks que aparecen bien posicionados en ambas búsquedas. El documento INV-2024-0045 puede no ganar en similitud semántica, pero sí en BM25 — y eso es suficiente para que suba al top.
El tercer fallo: top-K sin umbral ni reranking
El retrieval devuelve los K chunks más similares. Pero "más similar" no significa "suficientemente similar". Si los diez primeros resultados tienen scores de similitud entre 0.35 y 0.45, los estás incluyendo en el contexto aunque ninguno sea realmente relevante.
El LLM no puede detectar este problema. Ve diez chunks en el contexto y los usa todos como si fueran relevantes. El resultado es context poisoning: respuestas que mezclan información correcta con contenido sin relación.
La solución: score threshold + reranking con cross-encoder.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
async function retrieveWithReranking(
query: string,
candidates: RankedResult[],
scoreThreshold = 0.7,
finalTopK = 4
): Promise<Chunk[]> {
// 1. Filtrar por umbral mínimo antes de reranking
const aboveThreshold = candidates.filter(c => c.score >= scoreThreshold);
if (aboveThreshold.length === 0) {
return []; // Sin resultados relevantes → "no tengo información sobre esto"
}
// 2. Reranking con LLM (cross-encoder eficiente con Haiku)
const rerankPrompt = `Clasifica estos fragmentos por relevancia para la pregunta: "${query}"
${aboveThreshold.map((c, i) => `[${i}] ${c.text}`).join("\n\n")}
Responde SOLO con JSON: {"ranking": [índices en orden de relevancia], "threshold_cut": número_de_fragmentos_relevantes}`;
const response = await client.messages.create({
model: "claude-haiku-4-5-20251001",
max_tokens: 256,
messages: [{ role: "user", content: rerankPrompt }],
});
const result = JSON.parse(
response.content[0].type === "text" ? response.content[0].text : "{}"
);
return result.ranking
.slice(0, Math.min(result.threshold_cut, finalTopK))
.map((idx: number) => aboveThreshold[idx].chunk);
}
El cross-encoder evalúa cada chunk en el contexto de la pregunta específica, no como embedding independiente. Puede detectar que un chunk sobre "política de devoluciones generales" no es relevante para una pregunta sobre "devolución de un producto digital adquirido en promoción", aunque ambos contengan la palabra "devolución".
El pipeline completo
Estos tres fixes encadenados dan un pipeline RAG que funciona en producción:
Query
→ Hybrid Search (dense + BM25)
→ RRF
→ Score Threshold (0.7)
→ Reranking (Haiku cross-encoder)
→ Top-4 Chunks
→ Contexto limpio
→ LLM
La diferencia con el pipeline naive:
- Chunking con overlap y metadata: recuperación más precisa, filtrado antes del retrieval
- Hybrid search + RRF: captura similitud semántica Y coincidencias léxicas exactas
- Score threshold + reranking: solo llega al LLM información que supera el umbral de relevancia
Esto se conecta directamente con el trabajo de context engineering: el retrieval es la capa que alimenta el contexto. Si el retrieval falla, el contexto envenenado llega al LLM aunque el system prompt sea perfecto.
Por qué estos fallos se descubren tarde
Los tests de RAG suelen ejecutarse sobre preguntas conocidas, con documentos conocidos, en condiciones controladas. En producción, los usuarios preguntan de formas inesperadas, sobre términos específicos, y con la base de conocimiento completa.
El chunking naive funciona en demo porque el dataset de prueba es pequeño y las preguntas están calibradas. Escala el dataset a 10.000 documentos y añade usuarios reales, y los tres fallos aparecen a la vez.
En DAILYMP diseñamos los pipelines RAG con hybrid search y reranking desde el primer sprint, no como optimización posterior. El resultado: sistemas que no necesitan un refactor a los dos meses porque las respuestas no son suficientemente precisas.
¿Tu agente RAG responde bien en demo pero falla con usuarios reales? Lo revisamos juntos.