ChanlChanl
Learning AI

RAG desde Cero: Construye un Pipeline de Generación Aumentada por Recuperación

Construye un pipeline RAG funcional desde cero en TypeScript y Python. Cubre embeddings, chunking, búsqueda vectorial y generación con código real y ejecutable.

DGDean GroverCo-founderFollow
March 6, 2026
18 min read
Ilustración de una persona organizando conocimiento en un tablero de corcho con notas conectadas

Pregúntale a un LLM sobre la política de devoluciones de tu empresa y te inventará una con toda confianza. El modelo no sabe que tus documentos existen — está generando desde datos de entrenamiento, no desde tus datos.

La Generación Aumentada por Recuperación (RAG) soluciona esto. En lugar de esperar que el modelo haya memorizado la información correcta durante el entrenamiento, primero buscas los documentos relevantes y se los entregas al modelo como contexto. El modelo genera una respuesta fundamentada en tus datos reales. Sin fine-tuning, sin re-entrenamiento, sin esperar semanas por una actualización del modelo.

Aquí construirás un pipeline RAG completo desde cero en TypeScript: chunking, embeddings, búsqueda vectorial y generación. Sin abstracciones de frameworks ocultando las partes móviles — solo los componentes en bruto conectados para que entiendas cada pieza.

Etapa del pipelineQué haceDecisión clave
ChunkingDivide documentos en piezas buscablesDivisión recursiva a 300-500 tokens (mejor opción por defecto)
EmbeddingConvierte chunks de texto en representaciones vectorialestext-embedding-3-small por costo; 3-large por calidad
Almacén de vectoresAlmacena y busca embeddings por similitudEn memoria para prototipos; Pinecone/pgvector para producción
RecuperaciónEncuentra los top-K chunks más cercanos a la consulta del usuarioTop 3-5 chunks equilibra precisión y cobertura de contexto
GeneraciónEl LLM responde usando solo el contexto recuperadoRestringir el prompt para prevenir alucinaciones más allá del contexto
EvaluaciónEvalúa relevancia, fidelidad y calidad de la respuestaLLM-as-judge con rúbrica estructurada

Qué hace RAG realmente

La Generación Aumentada por Recuperación funciona en tres etapas: indexa tus documentos como embeddings vectoriales, recupera los chunks más relevantes para una consulta dada usando búsqueda por similitud, y luego genera una respuesta alimentando esos chunks como contexto a un LLM. Todo lo demás es optimización sobre estos tres pasos.

1. Indexación — Toma tus documentos, divídelos en chunks, convierte cada chunk en un embedding vectorial y almacena esos vectores en algún lugar buscable.

2. Recuperación — Cuando un usuario hace una pregunta, convierte esa pregunta en un embedding vectorial también, luego encuentra los chunks de documentos cuyos vectores están más cerca del vector de la pregunta.

3. Generación — Toma los chunks recuperados, insértalos en un prompt junto con la pregunta del usuario, y envía todo al LLM. El modelo genera una respuesta fundamentada en esos documentos específicos.

Ese es todo el patrón. La razón por la que RAG funciona tan bien es que separa saber dónde buscar (recuperación) de saber cómo responder (generación). El sistema de recuperación maneja la relevancia. El LLM maneja la síntesis y el lenguaje. Cada uno hace lo que mejor sabe hacer.

¿Por qué no simplemente hacer fine-tuning?

El fine-tuning incorpora el conocimiento directamente en los pesos del modelo. Eso suena atractivo hasta que consideras las desventajas:

  • Obsolescencia. El conocimiento del fine-tuning queda congelado al momento del entrenamiento. Cuando tus documentos cambian, re-entrenas. Con RAG, simplemente re-indexas los documentos que cambiaron.
  • Costo. Hacer fine-tuning de GPT-4o cuesta ~$25 por millón de tokens de entrenamiento, toma horas, y pagas de nuevo cada vez que tus datos cambian. Los embeddings de RAG cuestan centavos por millón de tokens y toman segundos.
  • Trazabilidad. Con RAG, puedes mostrar exactamente qué documentos produjeron una respuesta. Con fine-tuning, el razonamiento del modelo es opaco — no puedes señalar una fuente.
  • Control de alucinaciones. Los modelos con fine-tuning siguen alucinando. RAG te da un mecanismo concreto para restringir las respuestas al contexto recuperado.

El fine-tuning es útil para enseñarle a un modelo un nuevo estilo o comportamiento (por ejemplo, siempre responder en un formato específico). RAG es para darle a un modelo acceso a conocimiento específico. La mayoría de los equipos necesitan RAG, no fine-tuning. Algunos necesitan ambos.

¿Y las ventanas de contexto largas?

GPT-4o soporta 128K tokens de contexto. Claude soporta 200K. ¿No puedes simplemente meter todos tus documentos en el prompt y saltarte la recuperación por completo?

Puedes, y para conjuntos de documentos pequeños funciona. Pero hay tres problemas:

  1. El costo escala linealmente. Cada consulta paga por la ventana de contexto completa. Enviar 100K tokens por consulta a $2.50/1M tokens de entrada significa $0.25 por pregunta. RAG envía solo los 1-2K tokens relevantes, reduciendo el costo 50-100x.
  2. La latencia aumenta. Más tokens de entrada = respuestas más lentas. Un prompt de 100K tokens toma notablemente más tiempo que un prompt de 2K tokens con tres chunks específicos.
  3. La precisión se degrada. La investigación muestra consistentemente que los LLMs tienen peor desempeño encontrando información relevante en el medio de contextos muy largos. RAG pre-filtra a solo los chunks relevantes, para que el modelo no tenga que buscar.

La regla práctica: si toda tu base de conocimiento cabe en 10-20K tokens y no cambia seguido, simplemente métela en el prompt. Más allá de eso, RAG es más rentable, más rápido y más preciso.

También existe un enfoque híbrido: usa RAG para recuperar chunks relevantes, pero incluye un "resumen de contexto" más amplio en cada prompt (una descripción general de 500 tokens de tu producto o dominio). Esto le da al modelo conciencia general mientras RAG proporciona detalles específicos. Piénsalo como que el modelo conoce la tabla de contenidos de tu base de conocimiento, mientras que RAG recupera las páginas específicas.

RAG para agentes de IA en producción

Para agentes de IA en producción, RAG es lo que convierte un chatbot genérico en algo que realmente conoce tu negocio. Un agente con RAG puede referenciar tu base de conocimiento, buscar documentos de políticas específicas y dar respuestas fundamentadas en información real — que es exactamente el tipo de memoria persistente que hace útiles a los agentes en el mundo real.

Sin RAG, un agente respondiendo "¿Cuál es nuestra política de reembolso para clientes empresariales?" tiene que adivinar basándose en sus datos de entrenamiento. Con RAG, recupera tu documento real de política de reembolso y cita la sección relevante. La diferencia entre esas dos experiencias es la diferencia entre una demo de juguete y una herramienta de producción.

La arquitectura RAG

Un pipeline RAG fluye en dos direcciones: los documentos pasan por chunking, embedding y almacenamiento en tiempo de indexación, mientras que las consultas de usuario pasan por embedding, búsqueda por similitud contra los vectores almacenados, y generación del LLM en tiempo de consulta. Cada componente es intercambiable de forma independiente.

Esto es lo que construiremos:

Tiempo de indexación Tiempo de consulta Documentos Chunking Embedding Almacén de vectores Consulta del usuario Embedding Búsqueda por similitud Top-K Chunks + Consulta LLM Respuesta
El pipeline RAG de dos fases: los documentos se indexan offline, las consultas se responden en tiempo real

Cada pieza es intercambiable. Puedes cambiar la estrategia de chunking, el modelo de embeddings, el almacén de vectores o el LLM de forma independiente. Esa modularidad es todo el punto — y es por eso que construir desde cero primero es tan valioso. Cuando usas un framework como LangChain o LlamaIndex, estas piezas están ocultas detrás de abstracciones. Construirlas tú mismo significa que entiendes exactamente dónde mirar cuando algo se rompe.

Prerequisitos

Necesitarás una API key de OpenAI. El modelo de embeddings que usaremos (text-embedding-3-small) cuesta $0.02 por millón de tokens — ejecutar este tutorial cuesta una fracción de centavo. Para generación, usaremos gpt-4o-mini.

Construyendo el pipeline

Construiremos cuatro módulos — chunker, embedder, almacén de vectores y generador — y luego los conectaremos en un archivo principal. Cada módulo es independiente y tiene una sola responsabilidad, lo que facilita intercambiar componentes después.

La estructura de archivos se ve así:

text
rag-from-scratch/
  src/
    chunker.ts       # Split documents into chunks
    embeddings.ts    # Convert text to vectors via OpenAI
    vector-store.ts  # In-memory store with cosine similarity
    generator.ts     # Prompt construction and LLM generation
    rag.ts           # Main pipeline: index + query
  package.json

Crea un nuevo proyecto:

bash
mkdir rag-from-scratch && cd rag-from-scratch
npm init -y
npm install openai

Aquí está el package.json que necesitarás:

json
{
  "name": "rag-from-scratch",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "npx tsx src/rag.ts"
  },
  "dependencies": {
    "openai": "^4.73.0"
  },
  "devDependencies": {
    "tsx": "^4.19.0"
  }
}

Paso 1: Chunking

Primero, necesitamos dividir los documentos en chunks. ¿Por qué? Porque los modelos de embeddings tienen límites de tokens, y los chunks más pequeños producen una recuperación más precisa. Si generas el embedding de un documento completo de 50 páginas como un solo vector, el embedding es un promedio borroso de todo en ese documento. Si generas embeddings de párrafos individuales, cada vector captura una idea específica — y la recuperación puede señalar exactamente el párrafo correcto.

Hay tres estrategias comunes de chunking:

Chunking de tamaño fijo — Divide cada N caracteres con solapamiento. Simple y predecible, pero corta a mitad de oración. Funciona bien para datos estructurados como logs o CSVs donde los límites de oración no importan mucho.

Chunking basado en oraciones — Divide en los límites de las oraciones. Preserva el significado pero produce chunks de tamaños desiguales — algunos chunks terminan con una sola oración corta, otros con un párrafo largo.

Chunking recursivo — Intenta dividir primero por párrafos, luego por oraciones, luego por palabras. Mantiene la coherencia semántica respetando los límites de tamaño. Esto es lo que LangChain usa internamente, y es lo que construiremos.

El enfoque recursivo funciona intentando el separador más grande primero (doble salto de línea para párrafos). Si un segmento resultante sigue siendo demasiado grande, recurre al siguiente separador (salto de línea simple, luego límites de oraciones, luego espacios). Esto preserva la estructura natural de tus documentos tanto como sea posible.

typescript
// Recursive character text splitter
// Tries separators in order: paragraphs → sentences → words → characters
 
export interface Chunk {
  text: string;
  index: number;
  metadata?: Record<string, unknown>;
}
 
export function chunkText(
  text: string,
  options: {
    maxChunkSize?: number;
    overlap?: number;
    separators?: string[];
  } = {}
): Chunk[] {
  const {
    maxChunkSize = 500,
    overlap = 50,
    separators = ["\n\n", "\n", ". ", " "],
  } = options;
 
  const chunks: Chunk[] = [];
 
  function splitRecursive(text: string, separatorIndex: number): string[] {
    if (text.length <= maxChunkSize) return [text];
    if (separatorIndex >= separators.length) {
      // Last resort: hard split
      const parts: string[] = [];
      for (let i = 0; i < text.length; i += maxChunkSize - overlap) {
        parts.push(text.slice(i, i + maxChunkSize));
      }
      return parts;
    }
 
    const separator = separators[separatorIndex];
    const parts = text.split(separator);
 
    const merged: string[] = [];
    let current = "";
 
    for (const part of parts) {
      const candidate = current ? current + separator + part : part;
      if (candidate.length > maxChunkSize && current) {
        merged.push(current);
        current = part;
      } else {
        current = candidate;
      }
    }
    if (current) merged.push(current);
 
    // If any chunk is still too large, split it with the next separator
    const result: string[] = [];
    for (const chunk of merged) {
      if (chunk.length > maxChunkSize) {
        result.push(...splitRecursive(chunk, separatorIndex + 1));
      } else {
        result.push(chunk);
      }
    }
    return result;
  }
 
  const rawChunks = splitRecursive(text, 0);
 
  for (let i = 0; i < rawChunks.length; i++) {
    const trimmed = rawChunks[i].trim();
    if (trimmed.length > 0) {
      chunks.push({ text: trimmed, index: chunks.length });
    }
  }
 
  return chunks;
}

El campo metadata en cada chunk está vacío aquí, pero se vuelve importante en producción. Le adjuntarías el nombre del documento fuente, el encabezado de sección, número de página, fecha de creación — cualquier cosa que te ayude a filtrar o clasificar resultados después. Cuando un usuario pregunta sobre precios, los filtros de metadata pueden restringir la búsqueda a documentos etiquetados como "pricing" antes de que la comparación vectorial siquiera se ejecute. Así se ven los metadata de producción típicamente:

typescript
chunks.push({
  text: trimmed,
  index: chunks.length,
  metadata: {
    source: "pricing-faq.md",
    section: "Enterprise Plan",
    lastUpdated: "2026-02-15",
    category: "pricing",
    accessLevel: "public",
  },
});

Esto te permite hacer cosas como "solo buscar documentos actualizados en los últimos 6 meses" o "solo buscar documentos a los que el usuario actual tiene acceso" — crítico para sistemas de producción.

El parámetro overlap también merece una nota. Cuando divides texto en chunks, pierdes contexto en los límites. Un hecho que abarca dos párrafos podría cortarse por la mitad. El solapamiento mitiga esto repitiendo los últimos N caracteres de cada chunk al inicio del siguiente. Cincuenta caracteres de solapamiento es un valor predeterminado razonable — suficiente para preservar el contexto en los límites sin inflar demasiado la cantidad de chunks.

Paso 2: Embeddings

Ahora convertimos los chunks en vectores. Un embedding es una lista de números (un vector) que representa el significado de un fragmento de texto. Los textos con significados similares tienen vectores que apuntan en direcciones similares — "política de devoluciones" y "guías de reembolso" producirían vectores cercanos entre sí, aunque no compartan palabras exactas.

Esto es lo que hace a RAG fundamentalmente diferente de la búsqueda por palabras clave. La búsqueda tradicional requiere coincidencias exactas de palabras. La búsqueda basada en embeddings entiende el significado. Un usuario que pregunte "¿Puedo recuperar mi dinero?" coincidirá con un documento sobre "políticas de reembolso" porque los embeddings capturan la similitud semántica, no la superposición léxica.

Usaremos text-embedding-3-small de OpenAI, que produce vectores de 1536 dimensiones. Cada dimensión captura algún aspecto del significado del texto — el modelo aprendió estas dimensiones durante el entrenamiento con miles de millones de pares de texto. Puedes pensar en cada dimensión como un control deslizante en una consola de mezcla, y el vector completo de 1536 dimensiones como una "huella digital" única del significado del texto.

typescript
import OpenAI from "openai";
 
const openai = new OpenAI(); // Uses OPENAI_API_KEY env var
 
export async function embedTexts(texts: string[]): Promise<number[][]> {
  const response = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: texts,
  });
 
  // Sort by index to maintain order
  return response.data
    .sort((a, b) => a.index - b.index)
    .map((item) => item.embedding);
}
 
export async function embedQuery(query: string): Promise<number[]> {
  const [embedding] = await embedTexts([query]);
  return embedding;
}

Separamos embedTexts (lote) de embedQuery (individual) para mayor claridad. La función de lote acepta un array de textos y devuelve un array de vectores en el mismo orden — esto es importante porque OpenAI los procesa más eficientemente en una sola llamada API que en múltiples llamadas individuales.

En producción, querrás manejar conjuntos de documentos más grandes por lotes. La API de OpenAI acepta hasta 2048 textos por llamada, así que para un corpus de 10,000 chunks los dividirías en 5 lotes:

typescript
async function embedBatch(texts: string[], batchSize = 2048): Promise<number[][]> {
  const allEmbeddings: number[][] = [];
  for (let i = 0; i < texts.length; i += batchSize) {
    const batch = texts.slice(i, i + batchSize);
    const embeddings = await embedTexts(batch);
    allEmbeddings.push(...embeddings);
  }
  return allEmbeddings;
}

También querrás lógica de reintento para los límites de velocidad — OpenAI devuelve errores 429 cuando excedes tu cuota de tokens por minuto. Un backoff exponencial simple maneja esto de forma elegante.

El paso de ordenar por índice en embedTexts importa porque la API de OpenAI no garantiza que el orden de la respuesta coincida con el orden de entrada. Sin él, tus embeddings podrían quedar desordenados respecto a tus chunks — y silenciosamente almacenarías el vector incorrecto para cada chunk. Este es el tipo de bug que es extremadamente difícil de depurar porque todo parece funcionar, solo con una calidad de recuperación ligeramente peor.

Paso 3: Almacén de vectores (en memoria)

Un almacén de vectores es simplemente una colección de vectores con una forma de encontrar los vecinos más cercanos a un vector de consulta. Empezaremos con la implementación más simple posible: un almacén en memoria usando similitud coseno.

La similitud coseno mide el ángulo entre dos vectores. Un valor de 1.0 significa dirección idéntica (significado idéntico), 0.0 significa completamente no relacionados. Ignora la magnitud del vector, así que funciona independientemente de si tus embeddings están normalizados. Esto es importante porque diferentes modelos de embeddings producen vectores con diferentes magnitudes — la similitud coseno te da una métrica de comparación consistente.

¿Por qué similitud coseno sobre otras métricas de distancia? Hay tres opciones comunes:

  • Similitud coseno — Mide el ángulo entre vectores. Rango: -1 a 1, donde 1 = dirección idéntica. Ignora la magnitud.
  • Distancia euclidiana (L2) — Mide la distancia en línea recta entre dos puntos. Menor = más similar. Sensible a la magnitud.
  • Producto punto — Mide tanto dirección como magnitud. Más rápido de calcular pero los resultados dependen de las normas de los vectores.

Para embeddings normalizados (que son los de OpenAI), los tres dan el mismo ranking. Pero la similitud coseno es el estándar para embeddings de texto porque es invariante a la longitud del vector, haciéndola más robusta entre diferentes modelos de embeddings y longitudes de documentos.

typescript
import { Chunk } from "./chunker.js";
 
export interface StoredDocument {
  chunk: Chunk;
  embedding: number[];
  source: string;
}
 
export interface SearchResult {
  chunk: Chunk;
  score: number;
  source: string;
}
 
function cosineSimilarity(a: number[], b: number[]): number {
  let dotProduct = 0;
  let normA = 0;
  let normB = 0;
  for (let i = 0; i < a.length; i++) {
    dotProduct += a[i] * b[i];
    normA += a[i] * a[i];
    normB += b[i] * b[i];
  }
  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
 
export class VectorStore {
  private documents: StoredDocument[] = [];
 
  add(chunks: Chunk[], embeddings: number[][], source: string): void {
    for (let i = 0; i < chunks.length; i++) {
      this.documents.push({
        chunk: chunks[i],
        embedding: embeddings[i],
        source,
      });
    }
  }
 
  search(queryEmbedding: number[], topK: number = 3): SearchResult[] {
    const scored = this.documents.map((doc) => ({
      chunk: doc.chunk,
      source: doc.source,
      score: cosineSimilarity(queryEmbedding, doc.embedding),
    }));
 
    scored.sort((a, b) => b.score - a.score);
    return scored.slice(0, topK);
  }
 
  get size(): number {
    return this.documents.length;
  }
}

Este enfoque de fuerza bruta revisa cada documento en cada consulta. Es O(n) por búsqueda, lo cual está bien para cientos o incluso miles de documentos. Una vez que llegas a decenas de miles, querrás un índice de vecino más cercano aproximado (ANN) — que es exactamente lo que proporcionan las bases de datos vectoriales de producción.

Los algoritmos ANN como HNSW (Hierarchical Navigable Small World) construyen una estructura de grafo sobre tus vectores durante la indexación. En tiempo de consulta, navegan este grafo para encontrar vecinos más cercanos aproximados en O(log n) en lugar de comparar contra cada vector. La compensación es una pequeña pérdida de precisión (típicamente 95-99% de recall) por una búsqueda dramáticamente más rápida — milisegundos en lugar de segundos a escala.

Para los tres documentos de nuestro tutorial con seis chunks, la fuerza bruta es instantánea. Pero si estás indexando 100,000 artículos de soporte, cada búsqueda compararía contra cada uno de esos vectores. A esa escala, una base de datos vectorial dedicada con indexación HNSW devuelve resultados en menos de 50ms donde la fuerza bruta tardaría segundos.

Paso 4: Generación

Ahora la parte divertida. Tomamos los chunks recuperados, construimos un prompt y le pedimos al LLM que responda usando solo el contexto proporcionado. Aquí es donde ocurre lo "aumentado" en Generación Aumentada por Recuperación — la generación del modelo se aumenta con información recuperada.

typescript
import OpenAI from "openai";
import { SearchResult } from "./vector-store.js";
 
const openai = new OpenAI();
 
export interface GenerationResult {
  answer: string;
  sources: SearchResult[];
  prompt: string;
}
 
export async function generate(
  query: string,
  results: SearchResult[],
  options: { model?: string; temperature?: number } = {}
): Promise<GenerationResult> {
  const { model = "gpt-4o-mini", temperature = 0.2 } = options;
 
  const contextBlock = results
    .map(
      (r, i) =>
        `[Source ${i + 1}] (score: ${r.score.toFixed(3)}, from: ${r.source})\n${r.chunk.text}`
    )
    .join("\n\n");
 
  const systemPrompt = `You are a helpful assistant that answers questions based on the provided context documents.
 
Rules:
- Answer ONLY based on the provided context
- If the context doesn't contain enough information, say so
- Cite which source(s) you used with [Source N] notation
- Be concise and direct`;
 
  const userPrompt = `Context documents:
${contextBlock}
 
Question: ${query}
 
Answer based on the context above:`;
 
  const response = await openai.chat.completions.create({
    model,
    temperature,
    messages: [
      { role: "system", content: systemPrompt },
      { role: "user", content: userPrompt },
    ],
  });
 
  return {
    answer: response.choices[0].message.content ?? "",
    sources: results,
    prompt: userPrompt,
  };
}

Algunas decisiones de diseño que vale la pena notar aquí:

El system prompt le dice explícitamente al modelo que solo use el contexto proporcionado. Esto es crucial — sin él, el modelo llenará alegremente los vacíos con sus datos de entrenamiento, lo cual derrota el propósito de RAG. Este es un principio fundamental de ingeniería de prompts: las restricciones explícitas producen un comportamiento más confiable.

Incluimos la puntuación de similitud en cada bloque de fuente. Esto es útil para depuración — si tu chunk principal tiene una puntuación de 0.65, esa es una señal de que tu recuperación podría no estar encontrando buenas coincidencias, incluso antes de mirar la respuesta generada.

La temperatura está establecida en 0.2, lo que mantiene la salida del modelo enfocada y determinística. Temperaturas más altas (0.7+) producen respuestas más creativas, pero para respuestas RAG factuales quieres consistencia. Si haces la misma pregunta dos veces, deberías obtener sustancialmente la misma respuesta.

Devolvemos el prompt completo junto con la respuesta. Esto facilita mucho la depuración — puedes ver exactamente qué recibió el modelo y por qué respondió de la manera que lo hizo. En producción, registrar el prompt, los chunks recuperados, las puntuaciones de similitud y la respuesta generada te da una pista de auditoría completa para cada respuesta.

La estructura del prompt en sí importa más de lo que podrías pensar. Ponemos el contexto antes de la pregunta, lo cual funciona bien para la mayoría de los modelos. Algunos equipos encuentran que poner la pregunta primero ("Dada esta pregunta: X, usa el siguiente contexto para responder:") funciona mejor para su caso de uso. El formato de citas [Source N] facilita que los usuarios verifiquen las respuestas contra los documentos originales — transparencia que genera confianza en los sistemas impulsados por RAG.

El parámetro top-K (cuántos chunks recuperar) crea una compensación directa entre cobertura de contexto y costo del prompt. Más chunks significa que el modelo tiene más información con la cual trabajar, pero también más tokens que pagar y más potencial para que el modelo se confunda con contexto irrelevante. Un buen punto de partida:

  • K=3 para preguntas enfocadas de un solo tema ("¿Cuál es la política de reembolso?")
  • K=5 para preguntas más amplias que podrían abarcar múltiples documentos ("Dame una visión general de los niveles de precios y qué incluye cada uno")
  • K=1 para preguntas de búsqueda simple donde solo necesitas la coincidencia más cercana ("¿Cuál es el número de teléfono de soporte?")

Paso 5: Uniendo todo

Ahora conectamos los cuatro componentes — chunker, embedder, almacén de vectores y generador — en un pipeline completo. La fase de indexación se ejecuta una vez (o cuando los documentos cambian), mientras que la fase de consulta se ejecuta para cada pregunta del usuario.

Los documentos de ejemplo a continuación simulan una base de conocimiento real con tres tipos de contenido: descripción del producto (características), FAQ de precios (números y planes) y documentación técnica (el sistema de memoria). Esta variedad nos permite probar si la recuperación enruta correctamente diferentes tipos de preguntas a los documentos fuente correctos.

typescript
import { chunkText } from "./chunker.js";
import { embedTexts, embedQuery } from "./embeddings.js";
import { VectorStore } from "./vector-store.js";
import { generate } from "./generator.js";
 
// Sample documents — imagine these come from your knowledge base
const documents = [
  {
    source: "product-overview.md",
    content: `Chanl is an AI agent platform for building, connecting, and monitoring
customer experience agents. It supports voice and text channels. Agents can be
configured with custom prompts, knowledge bases, and tool integrations.
 
The platform provides real-time analytics for monitoring agent performance,
including call duration, resolution rates, and customer satisfaction scores.
Analytics dashboards show trends over time and highlight areas for improvement.
 
Agents connect to external systems through MCP (Model Context Protocol)
integrations. MCP allows agents to call APIs, query databases, and trigger
workflows in third-party tools without custom code.`,
  },
  {
    source: "pricing-faq.md",
    content: `Chanl offers three pricing tiers: Lite, Startup, and Business.
 
The Lite plan includes up to 5 agents and 1,000 interactions per month.
It costs $49/month and is designed for small teams getting started.
 
The Startup plan includes up to 25 agents and 10,000 interactions per month.
It costs $199/month and includes advanced analytics and priority support.
 
The Business plan includes unlimited agents and interactions.
Pricing is custom and includes dedicated support, SLAs, and SSO.`,
  },
  {
    source: "memory-system.md",
    content: `The memory system allows agents to remember information across conversations.
Short-term memory persists within a single conversation session.
Long-term memory stores facts about customers across multiple conversations.
 
Memory entries are automatically extracted from conversations and stored
as key-value pairs. For example, if a customer mentions they prefer email
communication, the agent stores this preference and uses it in future
interactions.
 
Memory can be managed through the API or the admin dashboard. Entries can
be viewed, edited, or deleted. Memory is scoped per customer per agent.`,
  },
];
 
async function main() {
  console.log("=== RAG Pipeline Demo ===\n");
 
  // Step 1: Index documents
  console.log("Indexing documents...");
  const store = new VectorStore();
 
  for (const doc of documents) {
    const chunks = chunkText(doc.content, { maxChunkSize: 300, overlap: 30 });
    const embeddings = await embedTexts(chunks.map((c) => c.text));
    store.add(chunks, embeddings, doc.source);
    console.log(`  ${doc.source}: ${chunks.length} chunks`);
  }
 
  console.log(`\nTotal chunks in store: ${store.size}\n`);
 
  // Step 2: Query
  const queries = [
    "What analytics features does Chanl provide?",
    "How much does the Startup plan cost?",
    "How does the memory system work?",
    "Does Chanl support Salesforce integration?",
  ];
 
  for (const query of queries) {
    console.log(`Q: ${query}`);
 
    // Retrieve
    const queryEmbedding = await embedQuery(query);
    const results = store.search(queryEmbedding, 3);
 
    console.log(`  Retrieved ${results.length} chunks:`);
    for (const r of results) {
      console.log(
        `    - [${r.source}] score: ${r.score.toFixed(3)} | "${r.chunk.text.slice(0, 60)}..."`
      );
    }
 
    // Generate
    const { answer } = await generate(query, results);
    console.log(`\nA: ${answer}\n`);
    console.log("---\n");
  }
}
 
main().catch(console.error);

Ejecútalo:

bash
export OPENAI_API_KEY="sk-your-key-here"
npx tsx src/rag.ts

Deberías ver el pipeline indexar tus documentos, recuperar chunks relevantes para cada consulta y generar respuestas fundamentadas. Así se ve la salida:

text
=== RAG Pipeline Demo ===
 
Indexing documents...
  product-overview.md: 2 chunks
  pricing-faq.md: 2 chunks
  memory-system.md: 2 chunks
 
Total chunks in store: 6
 
Q: What analytics features does Chanl provide?
  Retrieved 3 chunks:
    - [product-overview.md] score: 0.847 | "The platform provides real-time analytics for monitoring..."
    - [product-overview.md] score: 0.762 | "Chanl is an AI agent platform for building, connecting..."
    - [pricing-faq.md] score: 0.643 | "The Startup plan includes up to 25 agents and 10,000..."
 
A: Chanl provides real-time analytics for monitoring agent performance,
including call duration, resolution rates, and customer satisfaction scores.
Analytics dashboards show trends over time and highlight areas for improvement
[Source 1].

Presta atención a las puntuaciones de similitud — te dicen qué tan confiada es la recuperación para cada chunk. En este ejemplo, el chunk principal tiene 0.847 (coincidencia fuerte), el segundo es 0.762 (buen contexto de apoyo), y el tercero con 0.643 es una coincidencia más débil que fue incluida porque menciona analytics tangencialmente.

Observa que las cuatro consultas prueban diferentes aspectos del pipeline. Las primeras tres tienen respuestas claras en los documentos. La última consulta — sobre Salesforce — es intencionalmente imposible de responder desde el contexto proporcionado. Un pipeline RAG bien configurado debería decir que no tiene suficiente información en lugar de alucinar. Si tu pipeline inventa una respuesta sobre Salesforce, tu system prompt necesita ajustes.

Esta es una buena prueba de cordura para cualquier sistema RAG: siempre incluye al menos una pregunta que no pueda ser respondida desde el contexto. Si el modelo la responde de todos modos, tienes un problema de fidelidad.

Eligiendo una estrategia de chunking

La estrategia de chunking que elijas tiene un impacto mayor en la calidad de la recuperación de lo que la mayoría de la gente espera. El chunking recursivo es tu mejor opción por defecto — intenta párrafos primero, recurre a oraciones, luego a palabras, preservando la coherencia semántica mientras respeta los límites de tamaño.

EstrategiaProsContrasMejor para
Tamaño fijo (cada N caracteres)Simple, predecibleCorta a mitad de oración, rompe significadoDatos estructurados, logs
Basada en oraciones (dividir por .)Preserva el significado de la oraciónTamaños desiguales, algunos chunks muy pequeñosProsa limpia, FAQs
Recursiva (párrafo, oración, palabra)Mejor coherencia semánticaMás compleja de implementarUso general (recomendada)
Semántica (dividir cuando el significado cambia)Límites más precisosRequiere generar embedding de cada oración primeroBases de conocimiento de alta calidad

El divisor recursivo que construimos es la opción correcta por defecto para la mayoría de los casos de uso. Intenta mantener los párrafos juntos, recurre a oraciones, luego a palabras. El parámetro de solapamiento asegura que el contexto en los límites de los chunks no se pierda completamente.

El tamaño del chunk afecta directamente la precisión de la recuperación. Los chunks más pequeños (200-300 tokens) son más precisos pero pierden contexto circundante. Los chunks más grandes (500-1000 tokens) capturan más contexto pero diluyen la señal — el embedding se convierte en un promedio de demasiadas ideas. 300-500 tokens es el punto ideal para la mayoría de los pipelines.

Hay una cuarta estrategia que vale la pena mencionar: chunking semántico. En lugar de dividir por límites de caracteres, generas el embedding de cada oración, luego divides donde la similitud coseno entre oraciones consecutivas cae por debajo de un umbral. Esto produce chunks que siguen los límites naturales de los temas del texto. La desventaja es que tienes que generar embeddings de cada oración durante la indexación (más llamadas API), así que es más costoso. Pero para bases de conocimiento de alto valor donde la calidad de recuperación es crítica, puede mejorar significativamente los resultados.

Para ilustrar por qué la estrategia importa, considera este documento:

Nuestro plan empresarial incluye soporte dedicado con un SLA de 4 horas. Todos los clientes empresariales obtienen integración SSO. El precio se basa en el volumen de uso y comienza en $500/mes.

Con chunking de tamaño fijo a 80 caracteres, esto podría dividirse en:

  • Chunk 1: "Our enterprise plan includes dedicated support with a 4-hour SLA. All ente"
  • Chunk 2: "rprise customers get SSO integration. Pricing is based on usage volume and"
  • Chunk 3: " starts at $500/month."

Una consulta sobre "enterprise pricing" coincidiría con el Chunk 3 (que menciona $500 pero carece de contexto) y posiblemente el Chunk 2 (que menciona pricing pero se corta). El divisor recursivo mantiene esto como un solo chunk porque está por debajo del límite de tamaño, así que los tres hechos permanecen juntos.

Una forma práctica de validar tu chunking: ejecuta 20 consultas representativas y verifica manualmente si el chunk correcto aparece en los 3 primeros resultados. Si la información relevante sigue dividiéndose entre chunks o quedando enterrada en chunks demasiado grandes, ajusta tus parámetros de tamaño y solapamiento.

Eligiendo un modelo de embeddings

Usamos text-embedding-3-small de OpenAI porque es el más fácil para empezar. Así se compara con las alternativas:

ModeloDimensionesCostoCalidadVelocidad
text-embedding-3-small (OpenAI)1536$0.02/1M tokensBuenaRápida
text-embedding-3-large (OpenAI)3072$0.13/1M tokensMejorRápida
Voyage AI voyage-31024$0.06/1M tokensExcelente para códigoRápida
Nomic Embed (local)768Gratis (auto-hospedado)BuenaDepende del hardware
BGE-M3 (local)1024Gratis (auto-hospedado)Buena multilingüeDepende del hardware

Para la mayoría de los equipos, text-embedding-3-small es la opción correcta por defecto — rápido, barato y suficientemente bueno. Si ya estás usando OpenAI, mantiene las cosas simples. Si necesitas la mejor calidad de recuperación posible y no te importa el costo, text-embedding-3-large es un paso significativo hacia arriba — las 1536 dimensiones extra capturan distinciones semánticas más finas. Si no puedes enviar datos a APIs externas, Nomic o BGE-M3 se ejecutan localmente vía Ollama.

Voyage AI merece mención especial si tus documentos contienen código. Su modelo voyage-3 fue entrenado específicamente con código y texto técnico, así que supera a los modelos de OpenAI para búsqueda de código y recuperación de documentación técnica.

Una regla crítica: debes usar el mismo modelo de embeddings para indexación y consulta. Los vectores de diferentes modelos viven en diferentes espacios vectoriales y no pueden compararse de forma significativa. Si cambias de modelo de embeddings, necesitas regenerar los embeddings de todo tu conjunto de documentos. Esta es también la razón por la que vale la pena elegir cuidadosamente desde el inicio — regenerar embeddings de un millón de documentos no es gratis.

Estimación de costos

Aquí hay una forma rápida de estimar tus costos de embeddings. Una palabra típica en inglés es aproximadamente 1.3 tokens. Un chunk de 500 palabras son ~650 tokens.

Tamaño del corpusChunks (a 500 tokens cada uno)Costo de embedding (3-small)Costo de embedding (3-large)
100 páginas~200 chunks$0.006$0.04
1,000 páginas~2,000 chunks$0.06$0.40
10,000 páginas~20,000 chunks$0.60$4.00
100,000 páginas~200,000 chunks$6.00$40.00

Solo pagas por el embedding una vez por documento. La regeneración de embeddings ocurre solo cuando los documentos cambian. Los costos de embedding de consultas son insignificantes — una consulta es una sola llamada API de ~20 tokens.

Reducción de dimensiones

Los modelos text-embedding-3-* de OpenAI soportan un parámetro dimensions que te permite reducir el tamaño de la salida. Puedes solicitar 256 o 512 dimensiones en lugar de las 1536 completas. Vectores más pequeños significan búsquedas más rápidas y menos almacenamiento, a costa de algo de calidad de recuperación. Para prototipos o corpus muy grandes donde el costo de almacenamiento importa, esta es una palanca útil.

typescript
const response = await openai.embeddings.create({
  model: "text-embedding-3-small",
  input: texts,
  dimensions: 512, // Reduced from 1536
});

Con 512 dimensiones, tus vectores usan 3x menos memoria y las búsquedas son más rápidas, mientras que la calidad de recuperación baja solo 1-3% para la mayoría de los casos de uso. Esta es una buena opción cuando estás indexando millones de documentos y el costo de almacenamiento es una preocupación real. Puedes probar ejecutando tu suite de evaluación a 1536 vs 512 dimensiones y midiendo la diferencia de calidad real para tus datos específicos.

Eligiendo una base de datos vectorial

El almacén de vectores en memoria que construimos funciona para demos y conjuntos de datos pequeños. Para producción, querrás una base de datos vectorial dedicada que maneje persistencia, escalabilidad, filtrado por metadata y búsqueda eficiente de vecinos más cercanos aproximados. Aquí está el panorama:

Pinecone — Completamente administrado, serverless, latencia inferior a 50ms incluso a escala de miles de millones. Mejor para equipos que no quieren administrar infraestructura. Nivel gratuito disponible.

Chroma — Código abierto, nativo de Python, configuración mínima. Ideal para prototipos y conjuntos de datos pequeños a medianos. Puede ejecutarse embebido en tu proceso o como un servidor separado.

pgvector — Extensión de PostgreSQL. Si ya ejecutas Postgres, esta es la opción con menos fricción. Rendimiento competitivo hasta ~100M vectores con la extensión pgvectorscale. La mayor ventaja: tus vectores viven junto a tus datos relacionales, así que puedes hacer JOIN contra metadata sin una segunda base de datos. Tus chunks, embeddings y metadata de documentos todos viven en la misma instancia de Postgres, consultables con SQL estándar.

Ejemplo de consulta pgvector para contexto:

sql
SELECT chunk_text, source, 1 - (embedding <=> $1) AS similarity
FROM document_chunks
WHERE category = 'pricing'
ORDER BY embedding <=> $1
LIMIT 3;

El operador <=> calcula la distancia coseno. El filtrado por metadata (WHERE category = 'pricing') ocurre antes de la búsqueda vectorial, que es exactamente el tipo de acotación que mejora la precisión de la recuperación.

Weaviate — Código abierto con búsqueda híbrida sólida (combinando vectorial + palabras clave). Disponible como nube administrada o auto-hospedado.

Qdrant — Código abierto, basado en Rust, excelente rendimiento. Mejor nivel gratuito entre las bases de datos vectoriales dedicadas.

Usa pgvector si ya ejecutas PostgreSQL, Pinecone si quieres completamente administrado con cero infraestructura, o Qdrant para la mejor opción de código abierto con un generoso nivel gratuito.

Aquí hay una matriz de decisión rápida:

Si tú...Usa
Ya ejecutas PostgreSQLpgvector — sin infraestructura nueva
Quieres cero operacionesPinecone — completamente administrado, serverless
Necesitas código abierto + auto-hospedadoQdrant — mejor rendimiento, basado en Rust
Estás prototipando en PythonChroma — lo más rápido para empezar
Necesitas búsqueda híbrida integradaWeaviate — vectorial + palabras clave listo para usar

Cambiar de nuestro almacén en memoria a cualquiera de estos es sencillo — estás reemplazando los métodos add() y search(). Las capas de chunking, embedding y generación permanecen exactamente iguales. Esa modularidad es por lo que construir desde cero primero es valioso: entiendes qué pieza hace qué, así que actualizar un componente no requiere replantear todo el sistema.

Cuando tus agentes empiecen a conectarse a sistemas externos vía integraciones MCP y llamadas a tools, el pipeline RAG se convierte en solo una de varias fuentes de información. El almacén de vectores podría manejar documentos de producto mientras una tool MCP consulta datos de inventario en tiempo real. Entender cada pieza independientemente hace esa composición sencilla.

Evaluando tu pipeline

Un pipeline RAG que devuelve respuestas incorrectas con confianza es peor que no tener RAG. Necesitas medir tres cosas.

Calidad de la recuperación

¿El recuperador encontró los chunks correctos? La verificación más simple: mira las puntuaciones de similitud y el texto recuperado. Si el chunk principal no es relevante para la pregunta, tu recuperación está rota — ninguna cantidad de calidad de generación arregla eso.

Una puntuación por encima de 0.8 generalmente significa fuerte relevancia. Entre 0.6-0.8 es aceptable pero vale la pena monitorear. Por debajo de 0.6, el recuperador probablemente está trayendo ruido. Registra estas puntuaciones para cada consulta en producción para que puedas detectar degradación con el tiempo.

También deberías verificar los falsos negativos — consultas donde el chunk correcto existe en tu almacén pero no aparece en los resultados top-K. Esto generalmente significa que el embedding del chunk no captura la semántica correcta, o tu chunk es demasiado grande y el pasaje relevante está enterrado en texto no relacionado.

Una métrica simple de evaluación de recuperación es Recall@K: para un conjunto de consultas de prueba donde sabes qué chunk debería recuperarse, ¿qué porcentaje de las veces aparece el chunk correcto en los K primeros resultados? Apunta a un Recall@3 por encima del 85%. Si estás por debajo de eso, enfócate en la calidad del chunking y los embeddings antes de tocar cualquier otra cosa.

Fidelidad

¿La respuesta generada realmente usa el contexto recuperado, o el modelo lo ignora y alucina? Esta es la dimensión de evaluación más crítica. Un modelo que inventa información que suena plausible es activamente dañino — los usuarios confían en él porque suena seguro.

Prueba esto explícitamente: recupera chunks sobre el Tema A, pero pregunta sobre el Tema B. Si el modelo responde sobre el Tema B (usando datos de entrenamiento en lugar de admitir que el contexto no lo cubre), tu restricción de fidelidad es demasiado débil.

Calidad de la respuesta

¿La respuesta es correcta, completa y útil? Esta es la métrica de extremo a extremo. Incluso con buena recuperación y generación fiel, la respuesta podría estar mal estructurada, perder matices importantes o ser innecesariamente verbosa.

Aquí hay una función de evaluación usando LLM-as-judge:

typescript
import OpenAI from "openai";
import { SearchResult } from "./vector-store.js";
 
const openai = new OpenAI();
 
interface EvalResult {
  relevanceScore: number;
  faithfulnessScore: number;
  qualityScore: number;
  reasoning: string;
}
 
export async function evaluateResponse(
  query: string,
  answer: string,
  retrievedChunks: SearchResult[],
  referenceAnswer?: string
): Promise<EvalResult> {
  const context = retrievedChunks.map((r) => r.chunk.text).join("\n\n");
 
  const evalPrompt = `You are an evaluation judge for a RAG system. Score the following on a scale of 1-5.
 
Query: ${query}
 
Retrieved Context:
${context}
 
Generated Answer:
${answer}
${referenceAnswer ? `\nReference Answer: ${referenceAnswer}` : ""}
 
Score these three dimensions (1-5 each):
1. RELEVANCE: Are the retrieved chunks relevant to the query?
2. FAITHFULNESS: Does the answer only use information from the retrieved context? (5 = fully grounded, 1 = hallucinated)
3. QUALITY: Is the answer correct, complete, and helpful?
 
Respond in JSON format:
{"relevance": N, "faithfulness": N, "quality": N, "reasoning": "brief explanation"}`;
 
  const response = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    temperature: 0,
    messages: [{ role: "user", content: evalPrompt }],
    response_format: { type: "json_object" },
  });
 
  const parsed = JSON.parse(response.choices[0].message.content ?? "{}");
 
  return {
    relevanceScore: parsed.relevance ?? 0,
    faithfulnessScore: parsed.faithfulness ?? 0,
    qualityScore: parsed.quality ?? 0,
    reasoning: parsed.reasoning ?? "",
  };
}
 
// Usage: add this to your main() function
// const evalResult = await evaluateResponse(query, answer, results);
// console.log(`  Eval: R=${evalResult.relevanceScore} F=${evalResult.faithfulnessScore} Q=${evalResult.qualityScore}`);
// console.log(`  Reasoning: ${evalResult.reasoning}`);

Esto usa evaluación LLM-as-judge — el mismo enfoque utilizado por frameworks de evaluación RAG como RAGAS y DeepEval. El juez puede equivocarse (también es un LLM), pero es la forma más rápida de obtener señales automatizadas de calidad a escala.

Algunas notas sobre cómo hacer la evaluación útil en la práctica:

  • Construye un conjunto de prueba. Cura 50-100 pares de pregunta-respuesta que cubran tus casos de uso principales. Ejecútalos a través del pipeline después de cada cambio para detectar regresiones.
  • Rastrea las puntuaciones con el tiempo. Una caída repentina en la puntuación promedio de fidelidad después de cambiar tu estrategia de chunking es una señal clara de que algo salió mal.
  • Usa respuestas de referencia. Cuando tienes respuestas de referencia, pásalas como referenceAnswer para darle al juez algo contra lo cual comparar.
  • No confíes ciegamente en el juez. Verifica sus evaluaciones manualmente de forma selectiva. Si el juez consistentemente califica la fidelidad 5/5 cuando puedes ver que el modelo está alucinando, tu prompt de evaluación necesita trabajo.

Para un harness de evaluación de producción con testing de regresión e integración con CI, consulta nuestra guía sobre construir un framework de evaluación desde cero. En producción, combina la puntuación automatizada de LLM-as-judge con evaluación humana sobre una muestra de consultas y dashboards de analytics que rastreen métricas de calidad a lo largo del tiempo. Las puntuaciones automatizadas detectan regresiones rápido; las revisiones humanas detectan las fallas sutiles que la puntuación automatizada pasa por alto.

Modos de falla comunes

Una vez que tienes un pipeline funcionando, esto es lo que típicamente se rompe — y cómo arreglarlo.

El recuperador encuentra chunks irrelevantes. Tus chunks son demasiado grandes, o tus embeddings no capturan la semántica correcta. Solución: chunks más pequeños, prueba un mejor modelo de embeddings, o agrega filtrado por metadata para acotar las búsquedas por categoría de documento. Si un usuario pregunta sobre facturación pero tu recuperador trae documentos de onboarding, los filtros de metadata que restrinjan la búsqueda a documentos de "facturación" resolverían esto inmediatamente.

El modelo ignora el contexto. Esto generalmente significa que tu prompt no está restringiendo suficiente al modelo, o el contexto es tan largo que el modelo "pierde" la información relevante en el medio. La investigación sobre "lost in the middle" ha mostrado que los LLMs prestan más atención al inicio y al final de su ventana de contexto. Solución: ajusta tu system prompt, reduce el número de chunks recuperados, o pon el chunk más relevante al final.

Las respuestas son correctas pero pierden detalles importantes. Tu top-K es demasiado bajo, o la información relevante está distribuida entre chunks que no se recuperan juntos. Solución: aumenta top-K de 3 a 5, agrega un reranker para promover mejores chunks de un conjunto de recuperación inicial más grande (recupera 20, reordena, quédate con 3), o prueba chunks más grandes con más solapamiento para que la información relacionada permanezca junta.

El rendimiento es lento. Generar el embedding de la consulta + buscar + generar toma demasiado tiempo para uso interactivo. Solución: almacena en caché los embeddings de preguntas frecuentes para saltar el paso de embedding para consultas repetidas, usa una base de datos vectorial con indexación ANN en lugar de fuerza bruta, y considera un modelo de generación más pequeño o rápido para consultas de menor importancia.

Las respuestas alucinan más allá del contexto. El modelo llena vacíos con datos de entrenamiento incluso cuando se le dice que no lo haga. Solución: baja la temperatura a 0.1, haz el system prompt más explícito sobre rechazar responder cuando el contexto es insuficiente, y agrega un paso de evaluación de fidelidad que marque o bloquee respuestas con baja puntuación antes de que lleguen a los usuarios.

Los documentos obsoletos producen respuestas incorrectas. Tu base de conocimiento se ha actualizado pero los embeddings no se han regenerado. Solución: construye un pipeline de re-indexación que vigile los cambios en los documentos y regenere los embeddings de los chunks afectados. Rastrea la marca de tiempo de la última indexación por documento para que puedas verificar la frescura. Esto es especialmente peligroso para contenido sensible al tiempo como precios, políticas o documentos de cumplimiento donde una respuesta desactualizada podría tener consecuencias reales.

Los chunks duplicados o casi duplicados dominan los resultados. Si la misma información aparece en múltiples documentos (por ejemplo, la política de reembolso se menciona tanto en las FAQ como en los términos de servicio), tus resultados top-K podrían contener todos la misma información, desplazando otro contexto relevante. Solución: deduplica en tiempo de indexación verificando la similitud coseno entre chunks nuevos y existentes, o agrega diversidad a tu recuperación penalizando chunks que son demasiado similares a los resultados ya seleccionados (maximal marginal relevance).

Consideraciones de producción

Pasar de este tutorial a un sistema RAG de producción implica algunas preocupaciones adicionales que vale la pena pensar temprano, aunque no las implementes de inmediato.

Búsqueda híbrida. La búsqueda vectorial pura pierde coincidencias exactas de palabras clave. La búsqueda pura por palabras clave pierde la similitud semántica. La búsqueda híbrida combina ambas — ejecuta una búsqueda BM25 por palabras clave y una búsqueda vectorial en paralelo, luego fusiona los resultados. La mayoría de los sistemas RAG de producción usan este enfoque. Tanto Weaviate como pgvector lo soportan nativamente.

Reranking. La búsqueda vectorial es rápida pero aproximada. Un reranker cross-encoder toma los top 20 resultados de la búsqueda vectorial y los re-evalúa usando un modelo más preciso (pero más lento). El reranker ve tanto la consulta como el documento juntos (no solo sus embeddings independientes), así que puede detectar matices que la comparación de embeddings pierde — como negación, palabras calificadoras o distinciones sutiles. La API Rerank de Cohere y modelos de código abierto como bge-reranker-v2-m3 son opciones populares. La sobrecarga típica de latencia es 100-300ms, lo cual vale la pena cuando la precisión importa.

Enrutamiento de consultas. No todas las preguntas necesitan RAG. "¿Cuánto es 2+2?" no requiere recuperación de documentos. Un clasificador ligero puede enrutar preguntas simples directamente al LLM e invocar el pipeline RAG solo para preguntas que necesitan conocimiento del dominio. Esto reduce latencia y costo.

Contexto multi-turno. En una conversación, la segunda pregunta del usuario a menudo referencia la primera. "Cuéntame sobre los precios" seguido de "¿Y el nivel empresarial?" — la segunda consulta sola no menciona precios. Si generas su embedding tal cual, el recuperador no sabrá buscar documentos de precios. Solución: usa el LLM para reescribir la consulta en una forma autónoma ("¿Cuál es el precio del nivel empresarial?") antes de generar el embedding. Esto se llama "reescritura de consultas" o "condensación de preguntas" y agrega una llamada al LLM por turno pero mejora dramáticamente la precisión de recuperación multi-turno.

Control de acceso. Si diferentes usuarios deben ver diferentes documentos, necesitas filtrar los resultados por permiso en tiempo de consulta. Etiqueta cada chunk con los grupos de acceso que deberían verlo, luego agrega un filtro a tu búsqueda vectorial. Esto es sencillo con filtrado por metadata en Pinecone o pgvector, pero fácil de olvidar hasta que alguien recupera un documento al que no debería tener acceso.

Versionado de documentos. Los documentos cambian con el tiempo. Tu política de devoluciones de 2024 podría diferir de la de 2026. Si simplemente sobrescribes chunks, las consultas antiguas podrían obtener información obsoleta. Un patrón común: almacena una versión o marca de tiempo con cada chunk, y durante la re-indexación, inserta los chunks nuevos antes de eliminar los antiguos para que no haya vacíos en la cobertura.

Manejo de errores y fallbacks. En producción, la API de embeddings podría ser lenta o no estar disponible. Tu almacén de vectores podría tener un timeout. El modelo de generación podría devolver un error. Construye degradación elegante: si los embeddings fallan, recurre a búsqueda por palabras clave. Si la recuperación devuelve resultados de baja confianza (todas las puntuaciones por debajo de 0.5), salta la generación y devuelve "No pude encontrar información relevante." Si la generación falla, devuelve los chunks en bruto con una nota de que la respuesta no pudo sintetizarse.

Monitoreo y observabilidad. Registra cada consulta junto con: los chunks recuperados, sus puntuaciones de similitud, la respuesta generada y la latencia de cada paso (embedding, recuperación, generación). Esto te da una imagen completa de la salud del pipeline. Alerta cuando: la puntuación promedio de recuperación cae por debajo de un umbral, la latencia de generación se dispara, o la proporción de respuestas "No lo sé" aumenta. Con el tiempo, estos registros también se convierten en tu conjunto de datos de evaluación — consultas reales de usuarios con resultados recuperados reales que puedes usar para mejorar chunking, embeddings y prompts.

Caché. Dos capas de caché pueden reducir dramáticamente el costo y la latencia. Primero, almacena en caché los resultados de embeddings para consultas repetidas — si 100 usuarios preguntan "¿Cuál es tu política de reembolso?", solo necesitas generar el embedding una vez. Segundo, almacena en caché las respuestas RAG completas para consultas idénticas. La clave de caché es el texto de la consulta (o su hash), y la invalidas cuando los documentos subyacentes cambian.

Siguientes pasos

Ahora tienes un pipeline RAG funcional que entiendes de extremo a extremo. Cada pieza es visible, cada decisión es tuya. Desde aquí, las progresiones naturales son:

  1. Cambia el almacén de vectores por Chroma o pgvector y prueba con un corpus de documentos más grande — nuestros documentos de ejemplo son diminutos, y el rendimiento del mundo real depende de la escala
  2. Agrega un reranker — recupera top-20 con búsqueda vectorial, reordena con un modelo cross-encoder, quédate con top-3. Esto mejora dramáticamente la precisión cuando tu recuperación inicial es ruidosa
  3. Implementa búsqueda híbrida — combina similitud vectorial con coincidencia de palabras clave (BM25). La búsqueda vectorial maneja la similitud semántica; BM25 maneja las coincidencias exactas de palabras clave. Juntos detectan casos que cualquiera de los dos pierde solo
  4. Agrega filtrado por metadata — etiqueta los chunks con fuente, fecha, categoría y filtra durante la recuperación. Un usuario preguntando sobre "precios 2026" no debería obtener chunks de tu página de precios de 2024
  5. Prueba la expansión de consultas — reescribe la consulta del usuario en múltiples formas antes de buscar. "¿Cómo obtengo un reembolso?" también podría buscar "política de devoluciones" y "garantía de devolución de dinero"
  6. Construye una suite de evaluación adecuada — crea un conjunto de prueba de pares pregunta-respuesta y ejecútalos contra tu pipeline automáticamente. Rastrea la precisión de recuperación y la calidad de las respuestas a lo largo del tiempo para detectar regresiones antes que los usuarios

Cada una de estas mejoras es independiente — puedes agregar un reranker sin cambiar tu chunking, o cambiar bases de datos vectoriales sin tocar tu código de generación. Esa es la ventaja de entender cada pieza: sabes exactamente qué componente actualizar cuando llegas a una limitación específica.

RAG es la base que hace a los agentes de IA realmente útiles con tus datos. Ya sea que estés construyendo un agente de soporte al cliente, un asistente de conocimiento interno, o cualquier sistema que necesite responder preguntas de un corpus específico, el patrón es el mismo: chunk, embed, retrieve, generate. Todo lo demás es optimización sobre estas cuatro operaciones, y ahora entiendes exactamente qué hace cada una y por qué.

DG

Co-founder

Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.

Aprende IA Agéntica

Una lección por semana: técnicas prácticas para construir, probar y lanzar agentes IA. Desde ingeniería de prompts hasta monitoreo en producción. Aprende haciendo.

500+ ingenieros suscritos

Frequently Asked Questions