El RAG básico es directo: divides tus documentos en chunks, generas embeddings, recuperas los top-K chunks más similares y se los pasas a un LLM. Funciona bien en bases de conocimiento pequeñas con contenido claro y bien estructurado. Pero a escala, miles de documentos, consultas ambiguas, tipos de contenido mezclados, el RAG básico se rompe. Las respuestas se vuelven irrelevantes, aumentan las alucinaciones y los usuarios pierden confianza.
Esta guía cubre las técnicas que cierran la brecha entre una demo de RAG y un sistema de producción: chunking avanzado, re-ranking, transformación de consultas, recuperación híbrida y evaluación rigurosa.
Resumen rápido:
- El RAG básico falla a escala porque el chunking de tamaño fijo rompe límites semánticos, la recuperación con un solo vector pierde coincidencias léxicas y el ranking top-K sin re-puntuación devuelve resultados ruidosos.
- Usa chunking semántico o consciente del documento para preservar significado, recuperación híbrida (BM25 + vectores densos) para cubrir coincidencias por palabra clave y semánticas, y re-ranking con cross-encoder para empujar los resultados más relevantes hacia arriba.
- Técnicas de transformación de consulta como HyDE y expansión multi-consulta mejoran drásticamente el recall en consultas ambiguas o poco especificadas. Mide todo con MRR, NDCG y Recall@K.
Nuestra metodología
Esta guía sintetiza detalles operativos de tres categorías de fuentes:
- Patrones de código de producción de repos open-source (por ejemplo, LangChain, LlamaIndex, documentación de pgvector y ejemplos de HuggingFace)
- Investigación académica publicada en arxiv y en actas de conferencias sobre recuperación y generación
- Debates de profesionales en r/MachineLearning, r/LocalLLaMA y r/LangChain donde ingenieros reportan restricciones reales de producción en pipelines RAG
Evitamos afirmaciones puramente de marketing y priorizamos ejemplos que se despliegan en bases de código reales. Cuando citamos cifras de latencia o precisión, la metodología, dataset o condiciones de prueba se indican junto a ellas. Revisado por última vez: abril de 2026.
Por qué el RAG básico falla a escala
Considera una base de conocimiento con 10,000 documentos que cubren documentación de producto, referencias de API, guías de troubleshooting y páginas de políticas. Un cliente pregunta: "¿Por qué mi webhook no se dispara?"
El RAG básico con chunking de tamaño fijo y búsqueda vectorial pura podría devolver:
- Un chunk sobre configuración de webhooks (relevante)
- Un chunk sobre niveles de precios de webhooks (irrelevante, la coincidencia de palabra clave "webhook" infló la similitud del embedding)
- Un chunk del changelog que menciona webhooks (irrelevante, actualidad, no relevancia)
- Un chunk sobre límites de tasa de API que menciona "disparar" en otro contexto (irrelevante)
Solo uno de los cuatro chunks recuperados es útil. Ahora el LLM tiene que generar una respuesta con 75% de ruido. Este es el problema de la aguja en el pajar a escala, y aparece en tres modos de fallo:
| Modo de fallo | Causa raíz | Impacto |
|---|
| Rupturas de límites semánticos | Los chunks de tamaño fijo cortan frases y párrafos a mitad de idea | Los chunks recuperados carecen de contexto coherente |
| Desajuste de vocabulario | La búsqueda vectorial pura pierde términos exactos (códigos de error, nombres de producto) | Documentos relevantes con coincidencias exactas quedan por debajo de casi-coincidencias semánticas |
| Ruido de ranking | El top-K por similitud coseno sola no considera la profundidad de relevancia consulta-documento | Chunks superficialmente similares pero poco útiles desplazan a los realmente relevantes |
El resto de esta guía aborda cada modo de fallo con técnicas y código concretos.
Comparación de estrategias de chunking
El chunking es el componente más subestimado de un pipeline RAG. La forma en que divides documentos determina el techo de calidad de tu recuperación: ningún re-ranking puede arreglar un chunk que perdió significado en el límite.
Chunking de tamaño fijo
Divide texto cada N tokens con una ventana de solapamiento.
typescript
function fixedSizeChunk(text: string, chunkSize: number, overlap: number): string[] {
const words = text.split(/\s+/);
const chunks: string[] = [];
let start = 0;
while (start < words.length) {
const end = Math.min(start + chunkSize, words.length);
chunks.push(words.slice(start, end).join(" "));
start += chunkSize - overlap;
}
return chunks;
}
// Uso: chunks de 500 palabras con solapamiento de 100 palabras
const chunks = fixedSizeChunk(documentText, 500, 100);
Pros: simple, tamaños de chunk predecibles, funciona con cualquier contenido.
Contras: corta a mitad de frase, a mitad de párrafo e incluso a mitad de bloque de código. El solapamiento ayuda, pero no resuelve el problema fundamental de romper unidades semánticas.
Ideal para: prototipos rápidos y contenido con estructura uniforme.
Chunking basado en frases
Agrupa frases hasta un presupuesto de tokens, respetando límites de frase.
typescript
function sentenceChunk(text: string, maxTokens: number): string[] {
// Divide por límites de frase
const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
const chunks: string[] = [];
let currentChunk: string[] = [];
let currentTokens = 0;
for (const sentence of sentences) {
const sentenceTokens = estimateTokens(sentence);
if (currentTokens + sentenceTokens > maxTokens && currentChunk.length > 0) {
chunks.push(currentChunk.join(" "));
currentChunk = [];
currentTokens = 0;
}
currentChunk.push(sentence.trim());
currentTokens += sentenceTokens;
}
if (currentChunk.length > 0) {
chunks.push(currentChunk.join(" "));
}
return chunks;
}
Pros: nunca corta a mitad de frase. Chunks más coherentes que los de tamaño fijo.
Contras: la detección de frases es frágil (abreviaturas, números decimales, URLs). No respeta estructuras de nivel superior como secciones o párrafos.
Chunking semántico
Usa similitud de embeddings entre frases consecutivas para detectar cambios de tema. Cuando la similitud cae por debajo de un umbral, inserta un límite de chunk.
python
import numpy as np
from openai import OpenAI
client = OpenAI()
def semantic_chunk(text: str, threshold: float = 0.75, max_tokens: int = 800) -> list[str]:
"""Divide texto en límites semánticos usando similitud de embeddings."""
sentences = split_sentences(text)
# Generar embedding para cada frase
response = client.embeddings.create(
input=sentences,
model="text-embedding-3-small"
)
embeddings = [e.embedding for e in response.data]
# Calcular similitud coseno entre frases consecutivas
chunks = []
current_chunk = [sentences[0]]
current_tokens = estimate_tokens(sentences[0])
for i in range(1, len(sentences)):
sim = cosine_similarity(embeddings[i - 1], embeddings[i])
sent_tokens = estimate_tokens(sentences[i])
# Cortar si la similitud cae bajo el umbral O se supera el presupuesto de tokens
if sim < threshold or current_tokens + sent_tokens > max_tokens:
chunks.append(" ".join(current_chunk))
current_chunk = [sentences[i]]
current_tokens = sent_tokens
else:
current_chunk.append(sentences[i])
current_tokens += sent_tokens
if current_chunk:
chunks.append(" ".join(current_chunk))
return chunks
Pros: los chunks se alinean con límites de tema. Las frases adyacentes sobre el mismo tema permanecen juntas.
Contras: requiere generar embeddings para cada frase (caro para corpus grandes). El ajuste del umbral depende del dataset. Puede producir tamaños de chunk muy variables.
Chunking consciente del documento
Analiza la estructura del documento (encabezados, secciones, bloques de código, listas) y corta en límites estructurales. Esta es la estrategia más eficaz para contenido estructurado como documentación, artículos de ayuda y guías técnicas.
typescript
interface DocumentSection {
heading: string;
level: number; // h1=1, h2=2, etc.
content: string;
codeBlocks: string[];
}
function documentAwareChunk(
markdown: string,
maxTokens: number = 800
): Array<{ content: string; metadata: { heading: string; level: number } }> {
const sections = parseMarkdownSections(markdown);
const chunks: Array<{ content: string; metadata: { heading: string; level: number } }> = [];
for (const section of sections) {
const sectionText = `## ${section.heading}\n\n${section.content}`;
const tokens = estimateTokens(sectionText);
if (tokens <= maxTokens) {
// La sección cabe en un chunk: mantenerla completa
chunks.push({
content: sectionText,
metadata: { heading: section.heading, level: section.level },
});
} else {
// La sección es demasiado grande: dividir por párrafos, manteniendo el encabezado como prefijo
const paragraphs = section.content.split(/\n\n+/);
let currentContent = `## ${section.heading}\n\n`;
let currentTokens = estimateTokens(currentContent);
for (const para of paragraphs) {
const paraTokens = estimateTokens(para);
if (currentTokens + paraTokens > maxTokens && currentTokens > estimateTokens(`## ${section.heading}\n\n`)) {
chunks.push({
content: currentContent.trim(),
metadata: { heading: section.heading, level: section.level },
});
currentContent = `## ${section.heading} (continuación)\n\n`;
currentTokens = estimateTokens(currentContent);
}
currentContent += para + "\n\n";
currentTokens += paraTokens;
}
if (currentTokens > estimateTokens(`## ${section.heading}\n\n`)) {
chunks.push({
content: currentContent.trim(),
metadata: { heading: section.heading, level: section.level },
});
}
}
}
return chunks;
}
Pros: preserva la estructura del documento. Los encabezados aportan metadata natural para filtrar. Los bloques de código se mantienen intactos.
Contras: requiere un parser para cada formato de contenido (Markdown, HTML, PDF). No funciona bien para contenido no estructurado como emails o logs de chat.
Chunking padre-hijo
Almacena chunks padre grandes y chunks hijo más pequeños. Recupera sobre chunks hijo (más preciso), pero pasa chunks padre al LLM (más contexto).
typescript
interface ParentChildChunk {
parentId: string;
parentContent: string; // ~1500 tokens
children: Array<{
childId: string;
content: string; // ~300 tokens
embedding: number[];
}>;
}
async function parentChildRetrieval(
query: string,
topK: number = 5,
expandToParent: boolean = true
): Promise<string[]> {
const queryEmbedding = await embed(query);
// Buscar contra chunks hijo para precisión
const childResults = await vectorDb.search({
embedding: queryEmbedding,
table: "child_chunks",
limit: topK,
});
if (!expandToParent) {
return childResults.map((r) => r.content);
}
// Expandir a chunks padre, eliminando duplicados
const parentIds = [...new Set(childResults.map((r) => r.parentId))];
const parents = await db.query(
`SELECT content FROM parent_chunks WHERE id = ANY($1)`,
[parentIds]
);
return parents.map((p) => p.content);
}
Pros: lo mejor de ambos mundos: recuperación precisa con contexto rico. El LLM recibe párrafos alrededor que le ayudan a razonar.
Contras: sobrecarga de almacenamiento (guardas ambas granularidades). Requiere un join en tiempo de consulta.
Matriz de decisión de estrategias de chunking
| Estrategia | Ideal para | Calidad del chunk | Coste de implementación | Coste de embeddings |
|---|
| Tamaño fijo | Prototipos, texto uniforme | Baja | Mínimo | Bajo |
| Basado en frases | Artículos, prosa | Media | Bajo | Bajo |
| Semántico | Documentos con temas mezclados | Alta | Medio | Alto (por frase) |
| Consciente del documento | Docs estructuradas (Markdown, HTML) | Alta | Medio | Bajo |
| Padre-hijo | Docs complejas que necesitan contexto | Máxima | Alto | Medio |
Para la mayoría de sistemas de producción, el chunking consciente del documento con una estrategia de expansión padre-hijo ofrece el mejor equilibrio.
Re-ranking: empujar los mejores resultados hacia arriba
La similitud vectorial te da un orden aproximado. El re-ranking lo refina. Un re-ranker toma la consulta y cada chunk candidato como par y los puntúa por relevancia, normalmente usando un modelo cross-encoder que ve ambos textos a la vez.
Por qué importa el re-ranking
La búsqueda bi-encoder (el enfoque estándar con embeddings) codifica consulta y documento de forma independiente. No puede capturar interacciones finas entre términos de la consulta y términos del documento. Un cross-encoder procesa ambos juntos, permitiendo atención cruzada entre el par consulta-documento.
Bi-encoder (búsqueda por embeddings):
Consulta → Encoder → query_vec ─┐
├→ cosine_sim → puntuación
Doc → Encoder → doc_vec ─┘
Cross-encoder (re-ranking):
[Consulta + Doc] → Encoder → relevance_score
El cross-encoder es más preciso, pero también más caro: no puedes ejecutarlo sobre millones de documentos. El patrón estándar es un pipeline de dos etapas:
- Etapa 1 (recuperación): búsqueda bi-encoder rápida devuelve top-50 a top-100 candidatos.
- Etapa 2 (re-ranking): el cross-encoder puntúa y reordena los candidatos, devolviendo top-5 a top-10.
Implementación con Cohere Rerank
typescript
import { CohereClient } from "cohere-ai";
const cohere = new CohereClient({ token: process.env.COHERE_API_KEY });
async function retrieveAndRerank(
query: string,
topK: number = 5
): Promise<Array<{ content: string; relevanceScore: number }>> {
// Etapa 1: recuperar top-50 candidatos mediante búsqueda vectorial
const candidates = await vectorSearch(query, 50);
// Etapa 2: reordenar con Cohere
const reranked = await cohere.rerank({
model: "rerank-english-v3.0",
query,
documents: candidates.map((c) => ({ text: c.content })),
topN: topK,
});
return reranked.results.map((r) => ({
content: candidates[r.index].content,
relevanceScore: r.relevanceScore,
}));
}
ColBERT: re-ranking de interacción tardía
ColBERT usa un enfoque de "interacción tardía": codifica consulta y documento por separado (como un bi-encoder), pero los compara a nivel de token en lugar de a nivel de vector. Cada token de la consulta atiende al token de documento más similar mediante MaxSim.
python
# Puntuación estilo ColBERT (simplificada)
import torch
def colbert_score(query_embeddings: torch.Tensor, doc_embeddings: torch.Tensor) -> float:
"""
query_embeddings: (num_query_tokens, dim)
doc_embeddings: (num_doc_tokens, dim)
"""
# Para cada token de la consulta, encontrar la similitud máxima con cualquier token del doc
similarity_matrix = query_embeddings @ doc_embeddings.T # (Q, D)
max_sim_per_query_token = similarity_matrix.max(dim=1).values # (Q,)
return max_sim_per_query_token.sum().item()
ColBERT es más rápido que un cross-encoder completo porque los embeddings de documentos pueden precomputarse e indexarse. Es un buen punto medio cuando necesitas calidad de re-ranking sin la latencia de ejecutar un cross-encoder sobre cada candidato.
Cuándo añadir re-ranking
| Señal | Acción |
|---|
| El recall de recuperación es alto pero la precisión es baja | Añade re-ranking: estás encontrando docs relevantes, pero no aparecen primero |
| El recall de recuperación es bajo | Arregla primero chunking y recuperación: el re-ranking no puede sacar docs que no recuperaste |
| El presupuesto de latencia es estrecho (<500 ms total) | Usa ColBERT u omite re-ranking; los cross-encoders añaden 100-300 ms |
| La base de conocimiento es pequeña (<500 docs) | El re-ranking puede no ser necesario; el top-K de búsqueda vectorial probablemente basta |
A veces el problema no es la recuperación, sino la consulta. Los usuarios hacen preguntas vagas, poco especificadas o mal formuladas. Las técnicas de transformación de consulta reescriben la consulta antes de recuperar para mejorar el recall.
HyDE (Hypothetical Document Embedding)
En lugar de generar el embedding de la consulta cruda, pide al LLM que genere una respuesta hipotética, luego genera el embedding de esa respuesta y úsalo para recuperar. La hipótesis está más cerca en el espacio de embeddings de los documentos relevantes reales que la pregunta cruda.
typescript
async function hydeRetrieval(query: string, topK: number = 5): Promise<string[]> {
// Paso 1: generar una respuesta hipotética
const hypothetical = await llm.chat({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `Dada una pregunta, escribe un párrafo corto que sería una buena
respuesta. No digas "No lo sé". Escribe como si fueras un artículo de soporte
con conocimiento, incluso si tienes que estimar.`,
},
{ role: "user", content: query },
],
});
// Paso 2: generar embedding de la respuesta hipotética (no de la consulta original)
const hydeEmbedding = await embed(hypothetical.content);
// Paso 3: buscar con el embedding hipotético
return vectorSearch(hydeEmbedding, topK);
}
Cuándo ayuda HyDE: consultas vagas como "no funciona", donde el embedding crudo es demasiado genérico. La respuesta hipotética añade especificidad.
Cuándo perjudica HyDE: consultas específicas y bien formadas donde la hipótesis puede alucinar y desviarse de la intención real.
Expansión multi-consulta
Genera múltiples reformulaciones de la consulta, recupera con cada una y fusiona los resultados. Esto lanza una red más amplia.
typescript
async function multiQueryRetrieval(
query: string,
topK: number = 5
): Promise<string[]> {
// Generar 3 reformulaciones
const expansions = await llm.chat({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `Genera 3 formas alternativas de expresar la siguiente pregunta.
Devuelve un array JSON de strings. Cada reformulación debe abordar la pregunta
desde un ángulo distinto.`,
},
{ role: "user", content: query },
],
response_format: { type: "json_object" },
});
const queries = [query, ...JSON.parse(expansions.content).queries];
// Recuperar con cada consulta
const allResults = await Promise.all(
queries.map((q) => vectorSearch(q, topK * 2))
);
// Fusionar con Reciprocal Rank Fusion
return reciprocalRankFusion(allResults, topK);
}
Step-back prompting
Para consultas específicas, genera una pregunta más general "step-back" que recupere contexto más amplio. Luego recupera con la consulta original y la consulta step-back.
typescript
// Original: "¿Por qué mi webhook devuelve 403 al usar OAuth?"
// Step-back: "¿Cómo funciona la autenticación de webhooks?"
async function stepBackRetrieval(query: string, topK: number = 5): Promise<string[]> {
const stepBack = await llm.chat({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `Dada una pregunta específica, genera una pregunta más general que
recupere contexto de fondo útil. Devuelve solo la pregunta.`,
},
{ role: "user", content: query },
],
});
const [specificResults, broadResults] = await Promise.all([
vectorSearch(query, topK),
vectorSearch(stepBack.content, topK),
]);
// Combinar: priorizar resultados específicos, complementar con contexto amplio
return deduplicateAndMerge(specificResults, broadResults, topK);
}
Pipeline de recuperación híbrida
Los sistemas de recuperación de mayor rendimiento combinan varios métodos de búsqueda y fusionan resultados. Aquí hay un pipeline de producción que combina búsqueda por palabras clave BM25, búsqueda vectorial densa y filtrado por metadata:
typescript
interface RetrievalResult {
chunkId: string;
content: string;
score: number;
source: "bm25" | "vector" | "metadata";
}
async function hybridRetrieval(
query: string,
filters?: { category?: string; updatedAfter?: string },
topK: number = 10
): Promise<RetrievalResult[]> {
// Ejecutar los tres métodos de recuperación en paralelo
const [bm25Results, vectorResults, metadataResults] = await Promise.all([
// Búsqueda por palabras clave BM25 (maneja coincidencias exactas, códigos de error, nombres de producto)
bm25Search(query, topK * 3),
// Búsqueda vectorial densa (maneja similitud semántica)
vectorSearch(query, topK * 3),
// Búsqueda filtrada por metadata (acota por categoría, fecha, etc.)
filters ? metadataFilteredSearch(query, filters, topK * 2) : Promise.resolve([]),
]);
// Fusionar con Reciprocal Rank Fusion
return reciprocalRankFusion(
[bm25Results, vectorResults, metadataResults],
topK
);
}
function reciprocalRankFusion(
resultSets: RetrievalResult[][],
topK: number,
k: number = 60 // Constante RRF
): RetrievalResult[] {
const scores = new Map<string, { score: number; result: RetrievalResult }>();
for (const results of resultSets) {
for (let rank = 0; rank < results.length; rank++) {
const result = results[rank];
const rrfScore = 1 / (k + rank + 1);
const existing = scores.get(result.chunkId);
if (existing) {
existing.score += rrfScore;
} else {
scores.set(result.chunkId, { score: rrfScore, result });
}
}
}
return [...scores.values()]
.sort((a, b) => b.score - a.score)
.slice(0, topK)
.map((s) => ({ ...s.result, score: s.score }));
}
Pipeline completo: recuperar, fusionar, reordenar, generar
Uniéndolo todo:
typescript
async function advancedRAGPipeline(
query: string,
conversationHistory: Message[]
): Promise<string> {
// 1. Transformación de consulta: expandir en múltiples consultas
const expandedQueries = await expandQuery(query);
// 2. Recuperación híbrida para cada consulta expandida
const allResults = await Promise.all(
expandedQueries.map((q) => hybridRetrieval(q, undefined, 20))
);
// 3. Fusionar resultados de todas las consultas
const fused = reciprocalRankFusion(allResults, 20);
// 4. Reordenar con cross-encoder
const reranked = await cohereRerank(query, fused, 5);
// 5. Ensamblar contexto y generar
const context = reranked.map((r) => r.content).join("\n\n---\n\n");
const response = await llm.chat({
model: "gpt-4o",
messages: [
{
role: "system",
content: `Responde la pregunta del usuario usando SOLO el contexto proporcionado.
Si el contexto no contiene suficiente información, dilo.
No inventes información.
Contexto:
${context}`,
},
...conversationHistory,
{ role: "user", content: query },
],
});
return response.content;
}
Métricas de evaluación
No puedes optimizar lo que no mides. Estas son las métricas que importan para la calidad de recuperación en RAG:
MRR (Mean Reciprocal Rank)
El promedio de 1/rank para el primer resultado relevante en todas las consultas. MRR = 1.0 significa que la respuesta correcta siempre está primero.
python
def mean_reciprocal_rank(queries: list[dict]) -> float:
"""
queries: [{ "query": str, "retrieved": [str], "relevant": set[str] }]
"""
reciprocal_ranks = []
for q in queries:
for rank, doc_id in enumerate(q["retrieved"], 1):
if doc_id in q["relevant"]:
reciprocal_ranks.append(1.0 / rank)
break
else:
reciprocal_ranks.append(0.0)
return sum(reciprocal_ranks) / len(reciprocal_ranks)
NDCG (Normalized Discounted Cumulative Gain)
Considera la posición de todos los resultados relevantes, no solo el primero. Los resultados relevantes mejor posicionados aportan más a la puntuación.
python
import numpy as np
def ndcg_at_k(retrieved: list[str], relevant: dict[str, int], k: int) -> float:
"""
relevant: { doc_id: relevance_grade } donde grade es 0, 1, 2, 3
"""
dcg = 0.0
for i, doc_id in enumerate(retrieved[:k]):
rel = relevant.get(doc_id, 0)
dcg += (2**rel - 1) / np.log2(i + 2) # i+2 porque log2(1) = 0
# DCG ideal: ordenar por grado de relevancia descendente
ideal_rels = sorted(relevant.values(), reverse=True)[:k]
idcg = sum((2**rel - 1) / np.log2(i + 2) for i, rel in enumerate(ideal_rels))
return dcg / idcg if idcg > 0 else 0.0
Recall@K
Qué fracción de todos los documentos relevantes aparece en los top-K resultados. Crítico para asegurar que no pierdes contexto importante.
python
def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
retrieved_set = set(retrieved[:k])
return len(retrieved_set & relevant) / len(relevant) if relevant else 0.0
Benchmarks prácticos
| Métrica | Baseline (RAG básico) | +Chunking semántico | +Recuperación híbrida | +Re-ranking | +Expansión de consulta |
|---|
| MRR | 0.52 | 0.61 | 0.71 | 0.82 | 0.85 |
| NDCG@5 | 0.45 | 0.54 | 0.65 | 0.76 | 0.79 |
| Recall@10 | 0.60 | 0.68 | 0.79 | 0.82 | 0.88 |
Cada capa de optimización se acumula. El pipeline completo suele lograr una mejora de 60-80% frente al RAG básico en calidad de recuperación.
Checklist de producción
Caching
- Caché de embeddings: aplica hash al texto de entrada y guarda embeddings. La misma consulta no debería generar embedding dos veces.
- Caché de resultados: guarda resultados de recuperación para consultas frecuentes (TTL de 5-15 minutos según la frecuencia de actualización).
- Caché de respuestas LLM: para combinaciones idénticas de consulta + contexto, devuelve respuestas cacheadas.
Batching
- Agrupa solicitudes de embeddings: la mayoría de APIs soportan hasta 2,048 entradas por llamada. Agrupa chunks durante la indexación.
- Agrupa re-ranking: envía todos los candidatos en una sola llamada de API en vez de uno por uno.
Presupuestos de latencia
| Etapa | Presupuesto | Típico |
|---|
| Expansión de consulta | 150 ms | 80-120 ms |
| Recuperación híbrida (paralela) | 100 ms | 30-80 ms |
| Fusión RRF | 5 ms | 1-3 ms |
| Re-ranking | 200 ms | 100-250 ms |
| Generación LLM | 2000 ms | 800-2500 ms |
| Total | 2500 ms | 1000-3000 ms |
La recuperación debería ser la parte rápida. Si la recuperación supera 300 ms, perfila la configuración de tu índice vectorial y el pooling de conexiones de base de datos.
Monitoring
Rastrea esto en producción:
- Latencia de recuperación P50/P95/P99 por etapa
- Tasa de resultados vacíos: consultas que devuelven cero chunks relevantes después del re-ranking
- Distribución de puntuaciones del re-ranker: si las puntuaciones se agrupan cerca de cero, tu recuperación devuelve malos candidatos
- Tasa de acierto de caché: objetivo >30% para caché de embeddings, >10% para caché de resultados
- Uso de tokens: rastrea por separado tokens consumidos por expansión de consulta, ensamblaje de contexto y generación
Ideas clave
- El chunking es la base. Ningún re-ranking arregla un chunk que perdió significado en un límite. Usa chunking consciente del documento o semántico para sistemas de producción.
- La recuperación híbrida es imprescindible a escala. BM25 maneja coincidencias exactas (códigos de error, nombres); los vectores manejan semántica. Combínalos con Reciprocal Rank Fusion.
- El re-ranking es la optimización de mayor ROI. Un re-ranker cross-encoder sobre top-50 candidatos suele mejorar MRR en 15-20 puntos.
- Transforma la consulta, no solo la recuperación. HyDE, expansión multi-consulta y step-back prompting mejoran el recall en consultas ambiguas.
- Mide sin descanso. MRR, NDCG y Recall@K en un conjunto de evaluación etiquetado son las únicas señales fiables. La evaluación por sensaciones no escala.
Cuándo el RAG avanzado es la inversión equivocada
- Bases de conocimiento de menos de unos cientos de páginas bien estructuradas donde la recuperación top-K ingenua ya produce respuestas fundamentadas
- Casos de uso dominados por consultas estructuradas (estado de pedido, saldo de cuenta) donde SQL o llamadas de API superan cualquier pipeline de recuperación
- Equipos sin conjunto de evaluación, porque los cambios de chunking y re-ranking necesitan victorias medibles para justificar la complejidad añadida
- Flujos de voz o chat sensibles a latencia donde un re-ranker más reescritura de consulta rompe el presupuesto de turno
- Corpus estáticos estilo FAQ donde una reindexación completa periódica con chunks simples es más barata que un pipeline sofisticado
- Productos en etapa temprana que aún no han enviado un baseline RAG básico a usuarios reales
Preguntas frecuentes
¿Cuál es la diferencia entre RAG básico y RAG avanzado?
El RAG básico usa chunking de tamaño fijo, recuperación con un solo vector y ranking top-K por similitud coseno. El RAG avanzado añade chunking semántico o consciente del documento, recuperación híbrida (BM25 + vectores densos), re-ranking con cross-encoder y técnicas de transformación de consultas. En la práctica, la diferencia es significativa: el RAG avanzado suele lograr 60-80% más calidad de recuperación (MRR, NDCG) en bases de conocimiento reales frente al RAG básico.
¿Qué estrategia de chunking debería usar?
Para contenido estructurado como documentación o artículos de ayuda, usa chunking consciente del documento que respete encabezados, secciones y bloques de código. Para contenido no estructurado como emails o logs de chat, usa chunking semántico basado en similitud de embeddings entre frases consecutivas. Si necesitas tanto recuperación precisa como contexto rico para el LLM, añade una capa padre-hijo donde recuperas sobre chunks hijo pequeños, pero expandes a chunks padre más grandes.
¿Cuándo debería añadir un re-ranker a mi pipeline RAG?
Añade un re-ranker cuando tu recall de recuperación sea alto pero la precisión sea baja, es decir, los documentos relevantes están en tu conjunto candidato, pero no aparecen arriba. Si tu búsqueda vectorial devuelve 50 candidatos y la respuesta correcta normalmente está en el conjunto, pero no en el top 5, un re-ranker cross-encoder mejorará los resultados de forma drástica. Omite el re-ranking si tu base de conocimiento es pequeña (<500 documentos) o tu presupuesto de latencia es muy ajustado.
¿Cómo evalúo mi pipeline RAG?
Construye un conjunto de evaluación etiquetado de al menos 100 pares consulta-documento donde humanos hayan marcado qué documentos son relevantes para cada consulta. Calcula MRR (qué tan rápido encuentras el primer resultado relevante), NDCG@K (calidad del ranking completo) y Recall@K (qué fracción de documentos relevantes recuperas). Ejecuta estas métricas después de cada cambio en tu pipeline, chunking, recuperación o re-ranking, para medir el impacto.
¿Qué es Reciprocal Rank Fusion y por qué se usa en recuperación híbrida?
Reciprocal Rank Fusion (RRF) fusiona listas ordenadas de distintos métodos de recuperación (por ejemplo, BM25 y búsqueda vectorial) en un solo ranking. Para cada documento, RRF calcula una puntuación de 1/(k + rank) desde cada lista y las suma. Los documentos que aparecen alto en varias listas reciben impulso. RRF se prefiere frente a la normalización simple de puntuaciones porque las puntuaciones de distintos sistemas de recuperación no son comparables (puntuaciones BM25 y similitudes coseno tienen escalas y distribuciones distintas). RRF solo usa posiciones de ranking, lo que lo hace robusto ante cualquier combinación de métodos de recuperación.
Artículos relacionados