Skip to content

🎯 Meta: el apéndice O te enseñó a llamar a un LLM. Pero un LLM no sabe nada de tu catálogo, tus pedidos, tu documentación interna — solo sabe lo que había en su entrenamiento (y eso caduca, y no es tuyo). RAG (Retrieval-Augmented Generation) resuelve esto: antes de preguntarle al LLM, buscas en tus propios datos los fragmentos relevantes y se los das como contexto. El LLM responde con información real, actual y verificable — no con lo que "recuerda" de su entrenamiento.

Piezas: Claude (generación, ap. O) + Voyage AI (embeddings — el socio de embeddings recomendado por Anthropic; Claude no genera embeddings directamente) + pgvector 0.8 (búsqueda vectorial, como extensión de tu PostgreSQL de siempre).

📘 Requisitos: apéndice O (llamadas a LLM, streaming, salida estructurada), cap. 01-02 (SQL).


V.1 · El problema que resuelve (y por qué "solo pégalo en el prompt" no escala)

Sin RAG: el LLM contesta con lo que memorizó en el entrenamiento
  Usuario: "¿Cuál es la política de reembolsos de MI tienda?"
  LLM: [inventa algo plausible pero falso — no tiene ni idea de TU política] ❌

Con RAG: buscas primero, el LLM razona sobre datos reales
  Usuario: "¿Cuál es la política de reembolsos de mi tienda?"

       ▼ 1. busca en TUS documentos los fragmentos relevantes
  "Los reembolsos se procesan en 5-7 días hábiles si el pedido..."

       ▼ 2. se los pasa al LLM como contexto
  LLM: "Según tu política, los reembolsos tardan 5-7 días hábiles..." ✅

¿Por qué no meter TODOS tus documentos en el prompt? Porque no caben (aunque el contexto sea de 1M de tokens, sigue costando dinero y degradando la precisión con ruido irrelevante — el LLM "se pierde" entre 10.000 documentos igual que un humano). RAG selecciona solo lo relevante para esta pregunta concreta, antes de gastar un solo token de generación.

🧠 RAG no es "IA que aprende tus datos". El modelo no se reentrena ni recuerda nada entre peticiones (la API es stateless, ap. O.2) — cada vez, tú buscas y le pasas el contexto de nuevo. Es más parecido a darle a un becario brillante los documentos correctos justo antes de preguntarle, que a "enseñarle" algo permanente.


V.2 · Embeddings: convertir texto en significado numérico

Un embedding es un vector (una lista de números) que representa el significado de un texto. Textos con significado parecido producen vectores cercanos entre sí — así es como "buscas por significado" en vez de por palabras exactas.

"teclado mecánico"     → [0.02, -0.14, 0.31, ..., 0.08]   (1024 números)
"periférico de escritura" → [0.03, -0.12, 0.29, ..., 0.09]   (vector CERCANO — significado similar)
"receta de tarta"       → [-0.41, 0.55, -0.02, ..., 0.33]   (vector LEJANO — significado distinto)
distancia(vector A, vector B) pequeña  →  textos con significado parecido
distancia(vector A, vector B) grande   →  textos sin relación

💡 Claude no genera embeddings — es un modelo de generación de texto, no de embeddings. El socio recomendado por Anthropic para esto es Voyage AI (mismo proveedor, integración pensada para trabajar junto a Claude). Es una pieza más de tu arquitectura, igual que Redis es tu caché y PostgreSQL tu base de datos — cada herramienta hace una cosa.

typescript
// Generar un embedding (Voyage AI — vía su API REST)
async function embeber(textos: string[], tipo: 'document' | 'query'): Promise<number[][]> {
  const res = await fetch('https://api.voyageai.com/v1/embeddings', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.VOYAGE_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ input: textos, model: 'voyage-3.5', input_type: tipo }),
  });
  const data = await res.json();
  return data.data.map((d: { embedding: number[] }) => d.embedding);   // 1024 dimensiones
}
python
# Python — SDK oficial
import voyageai
vo = voyageai.Client()  # lee VOYAGE_API_KEY del entorno

resultado = vo.embed(["teclado mecánico RGB"], model="voyage-3.5", input_type="document")
vector = resultado.embeddings[0]   # 1024 floats

🧠 input_type importa: "document" al indexar tus datos, "query" al buscar la pregunta del usuario. Voyage optimiza el vector de forma distinta según el rol — usar el tipo incorrecto degrada la calidad de la búsqueda de forma silenciosa.


V.3 · Almacenar y buscar vectores: pgvector (tu PostgreSQL de siempre)

Misma filosofía que el apéndice Q (NoSQL) y H.3 (JSONB): antes de añadir una base de datos vectorial dedicada, prueba la extensión de PostgreSQL que ya tienes. pgvector cubre la inmensa mayoría de casos de RAG sin sumar un sistema más a tu stack.

sql
-- Una vez, en tu base de datos existente (cap. 01)
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE fragmentos (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  documento_id UUID REFERENCES documentos(id) ON DELETE CASCADE,
  contenido TEXT NOT NULL,
  embedding VECTOR(1024),              -- dimensión de voyage-3.5 (V.2)
  creado_en TIMESTAMPTZ DEFAULT now()
);

-- HNSW: el índice para búsqueda por similitud a velocidad razonable (equivalente al
-- índice GIN de full-text, ap. H.2 — sin índice, cada búsqueda escanea TODA la tabla)
CREATE INDEX ON fragmentos USING hnsw (embedding vector_cosine_ops);
typescript
// Guardar un fragmento con su embedding
async function guardarFragmento(documentoId: string, texto: string) {
  const [vector] = await embeber([texto], 'document');
  await db.$executeRaw`
    INSERT INTO fragmentos (documento_id, contenido, embedding)
    VALUES (${documentoId}, ${texto}, ${JSON.stringify(vector)}::vector)
  `;
}

// Buscar los fragmentos más relevantes para una pregunta
async function buscarRelevantes(pregunta: string, limite = 5) {
  const [vectorPregunta] = await embeber([pregunta], 'query');
  return db.$queryRaw<{ contenido: string; distancia: number }[]>`
    SELECT contenido, embedding <=> ${JSON.stringify(vectorPregunta)}::vector AS distancia
    FROM fragmentos
    ORDER BY distancia ASC                -- menor distancia = más relevante
    LIMIT ${limite}
  `;
}

🧠 <=> es distancia coseno (la más usada para embeddings de texto); <-> es distancia euclídea. El operador debe coincidir con el tipo de índice (vector_cosine_ops arriba). El mismo EXPLAIN ANALYZE del cap. 01.9 te confirma si la búsqueda usa el índice HNSW o escanea la tabla entera.


V.4 · Búsqueda híbrida y reranking: cuando la búsqueda vectorial sola no basta

La búsqueda por embeddings (V.2-V.3) es excelente para "significado parecido", pero tiene un punto ciego: términos exactos que importan mucho y aparecen poco (un código de producto, un nombre propio, un identificador) a veces quedan "diluidos" en el espacio vectorial. La solución, igual que en Meilisearch (ap. T.2), es combinar búsqueda léxica y semántica:

sql
-- Búsqueda híbrida en PostgreSQL: combina tsvector (ap. H.2, léxico) + pgvector (semántico)
-- con Reciprocal Rank Fusion (RRF) — fusiona dos rankings SIN necesitar normalizar sus escalas
WITH busqueda_lexica AS (
  SELECT id, RANK() OVER (ORDER BY ts_rank(busqueda, query) DESC) AS rango
  FROM fragmentos, plainto_tsquery('spanish', $1) query
  WHERE busqueda @@ query
  LIMIT 20
),
busqueda_vectorial AS (
  SELECT id, RANK() OVER (ORDER BY embedding <=> $2::vector) AS rango
  FROM fragmentos
  ORDER BY embedding <=> $2::vector
  LIMIT 20
)
SELECT
  COALESCE(l.id, v.id) AS id,
  -- RRF: suma de 1/(k + rango) de cada lista — k=60 es el valor estándar de la literatura
  COALESCE(1.0 / (60 + l.rango), 0) + COALESCE(1.0 / (60 + v.rango), 0) AS puntuacion
FROM busqueda_lexica l
FULL OUTER JOIN busqueda_vectorial v USING (id)
ORDER BY puntuacion DESC
LIMIT 5;

🧠 RRF resuelve un problema real: las dos búsquedas devuelven puntuaciones en escalas distintas (un ts_rank no es comparable a una distancia coseno). En vez de normalizar cada escala a mano, RRF combina solo la posición (rango) de cada resultado en su lista — simple, robusto, y el método estándar para fusionar rankings heterogéneos.

Reranking: un segundo paso, más costoso pero más preciso, que reordena el top-N recuperado antes de pasarlo al LLM:

typescript
// Voyage AI también ofrece un modelo de RERANKING (distinto del de embeddings, V.2)
async function buscarConRerank(pregunta: string, candidatos = 20, final = 5) {
  const primeros = await buscarRelevantes(pregunta, candidatos);   // recall amplio, barato (V.3)

  const res = await fetch('https://api.voyageai.com/v1/rerank', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.VOYAGE_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      query: pregunta,
      documents: primeros.map((f) => f.contenido),
      model: 'rerank-2.5',
      top_k: final,
    }),
  });
  const { data } = await res.json();
  return data.map((r: { index: number; relevance_score: number }) => primeros[r.index]);
}

💡 El patrón "recall amplio, luego precisión": recuperas 20-50 candidatos baratos (rápido, con vectores o híbrido) y le pides a un reranker (más lento, mira query+documento juntos, no por separado) que se quede con los 3-5 realmente mejores. Es más caro que un solo paso, pero notablemente más preciso — resérvalo para donde la calidad de las citas importa de verdad (soporte legal, médico, financiero); para un FAQ sencillo, V.3 sin rerank suele bastar.

⚠️ No optimices esto antes de medir (V.8). Si tu eval de retrieval (V.8) ya encuentra el fragmento correcto en el top-3 con búsqueda vectorial simple, añadir híbrido + reranking es complejidad sin beneficio medible. Añádelo cuando el eval te diga que el retrieval simple se queda corto, no por adelantado.


V.5 · Chunking: cómo trocear tus documentos (la decisión que más importa)

No indexas documentos enteros — los divides en fragmentos (chunks) manejables. El tamaño del chunk es la decisión de diseño que más afecta a la calidad de un sistema RAG:

Chunk DEMASIADO GRANDE (todo el documento)
  → el embedding "diluye" el significado de cada tema en el promedio del documento entero
  → la búsqueda encuentra el documento correcto pero le falta precisión

Chunk DEMASIADO PEQUEÑO (una frase suelta)
  → pierde contexto ("el reembolso" ¿de qué? — la frase anterior lo explicaba)
  → puede aparecer relevante para preguntas de forma incorrecta (coincidencia superficial)
typescript
// Chunking con solapamiento — el patrón estándar que evita cortar ideas a la mitad
function trocear(texto: string, tamanoChunk = 500, solape = 50): string[] {
  const palabras = texto.split(/\s+/);
  const chunks: string[] = [];
  for (let i = 0; i < palabras.length; i += tamanoChunk - solape) {
    chunks.push(palabras.slice(i, i + tamanoChunk).join(' '));
  }
  return chunks;
}

💡 Mejor que trocear por número de palabras: trocear por estructura. Divide por secciones/párrafos naturales del documento (encabezados Markdown, párrafos), y solo si un párrafo es demasiado grande, trocéalo dentro de sus límites. Preserva metadatos (documentoId, título de la sección) en cada chunk — te sirven para citar la fuente en la respuesta (V.7).


V.6 · El pipeline completo: indexar una vez, buscar y generar en cada pregunta

typescript
// FASE 1 — Indexación (se ejecuta al añadir/actualizar documentos, no en cada pregunta)
async function indexarDocumento(documentoId: string, contenidoCompleto: string) {
  const chunks = trocear(contenidoCompleto);
  for (const chunk of chunks) {
    await guardarFragmento(documentoId, chunk);
  }
}

// FASE 2 — Consulta (se ejecuta en cada pregunta del usuario)
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic();

async function preguntarConRAG(pregunta: string): Promise<string> {
  // 1. Recupera el contexto relevante (Retrieval)
  const fragmentos = await buscarRelevantes(pregunta, 5);

  const contexto = fragmentos
    .map((f, i) => `[Fuente ${i + 1}]\n${f.contenido}`)
    .join('\n\n');

  // 2. Genera la respuesta ANCLADA en ese contexto (Augmented Generation)
  const respuesta = await anthropic.messages.create({
    model: 'claude-opus-4-8',
    max_tokens: 1024,
    system: `Responde SOLO con información del contexto proporcionado. Si el contexto
no contiene la respuesta, di explícitamente que no lo sabes — no inventes.
Cita la fuente entre corchetes, ej. [Fuente 1].

Contexto:
${contexto}`,
    messages: [{ role: 'user', content: pregunta }],
  });

  const bloque = respuesta.content.find((b) => b.type === 'text');
  return bloque?.text ?? '';
}

⚠️ La instrucción "si no lo sabes, dilo" es la defensa nº1 contra alucinaciones en RAG. Sin ella, un LLM que no encuentra la respuesta en el contexto suele inventar algo plausible en vez de admitir que no lo sabe — el mismo problema del apéndice O, pero aquí lo mitigas anclando explícitamente el comportamiento al contexto recuperado.


V.7 · Citando fuentes (imprescindible para confianza real)

typescript
// Guarda metadatos junto al chunk para poder citar de vuelta al documento original
interface Fragmento {
  contenido: string;
  documentoTitulo: string;
  documentoUrl: string;
}

// En la respuesta, pide al LLM que cite Y devuelve las fuentes reales al frontend
async function preguntarConCitas(pregunta: string) {
  const fragmentos = await buscarRelevantes(pregunta, 5);
  const respuesta = await generarConContexto(pregunta, fragmentos);

  return {
    respuesta,
    fuentes: fragmentos.map((f) => ({ titulo: f.documentoTitulo, url: f.documentoUrl })),
  };
}
tsx
// Frontend: la respuesta SIEMPRE con sus fuentes visibles — nunca "confía en la IA a ciegas"
<div>
  <p>{respuesta}</p>
  <details>
    <summary>Fuentes consultadas</summary>
    <ul>{fuentes.map((f) => <li key={f.url}><a href={f.url}>{f.titulo}</a></li>)}</ul>
  </details>
</div>

V.8 · Evaluar un sistema RAG (dos capas de evals, no una)

Repasa el apéndice O.8 — aquí hay dos cosas que evaluar por separado:

typescript
// Capa 1: ¿RECUPERA los fragmentos correctos? (evalúa el retrieval, sin el LLM)
const CASOS_RETRIEVAL = [
  { pregunta: '¿Cuánto tardan los reembolsos?', chunkEsperadoId: 'politica-reembolsos-2' },
  // ...
];

it('recupera el fragmento correcto en el top-3', async () => {
  const resultados = await buscarRelevantes(caso.pregunta, 3);
  expect(resultados.map((r) => r.id)).toContain(caso.chunkEsperadoId);
});

// Capa 2: dado el contexto correcto, ¿GENERA una respuesta fiel? (evalúa la generación)
it('no inventa cuando el contexto no tiene la respuesta', async () => {
  const respuesta = await generarConContexto('¿Cuál es la capital de Marte?', []);
  expect(respuesta.toLowerCase()).toMatch(/no (lo sé|tengo información)/);
});

🧠 Si la respuesta final está mal, el bug puede estar en cualquiera de las dos fases: el retrieval no encontró el fragmento correcto, o lo encontró pero el LLM lo ignoró/alucinó igual. Evaluarlas por separado te dice dónde arreglar, no solo que algo falla.


V.9 · Errores comunes (y cómo se ven en producción)

SíntomaCausa probable
El buscador nunca encuentra nada relevanteinput_type mal (query vs document, V.2), o chunks demasiado grandes
Respuestas genéricas que "no usan" tus datosEl contexto no llegó al prompt, o el system prompt no fuerza a usarlo
El LLM inventa cuando no sabeFalta la instrucción explícita "di que no lo sabes" (V.6)
Funciona en pruebas, falla con documentos reales largosChunking sin solape corta ideas a la mitad (V.5)
Respuestas desactualizadasLos embeddings no se reindexaron tras editar el documento — mismo problema de sincronización del ap. T.4
Coste disparadoRecuperas demasiados fragmentos (limite alto) o no cacheas el system estable con prompt caching (ap. O.6)

V.10 · Buenas prácticas

  1. RAG no es fine-tuning. Para "que el modelo responda con tus datos", RAG es más barato, más rápido de iterar y más fácil de mantener actualizado que reentrenar un modelo.
  2. pgvector antes que una base vectorial dedicada (Pinecone, Weaviate) — mismo criterio del apéndice Q: prueba la extensión de PostgreSQL primero.
  3. Chunking con solape, respetando la estructura del documento.
  4. Instruye explícitamente "no inventes si no está en el contexto".
  5. Cita fuentes siempre — un sistema RAG sin citas es una caja negra que nadie debería confiar a ciegas.
  6. Evalúa retrieval y generación por separado (V.8) — son dos fallos distintos.
  7. Reindexa cuando cambien tus datos, con el mismo patrón de sincronización del ap. T.4/S.4.
  8. Todo lo del apéndice O aplica encima: la clave de Voyage/Claude vive solo en el backend, valida la salida, y protege el endpoint con auth y rate limit (cap. 20, ap. O.6).

✅ Ejercicio del apéndice

1. Indexa la documentación de la Cantina (cap. 22): carta, políticas de pedidos,
   FAQ. Trocea por sección, genera embeddings con Voyage y guárdalos en pgvector.
2. Endpoint /soporte/preguntar: recupera los 5 fragmentos más relevantes y genera
   una respuesta con Claude, citando las fuentes (V.7).
3. Añade streaming (ap. O.3) para que la respuesta aparezca palabra a palabra
   en el frontend, con las fuentes mostradas al terminar.
4. Test de retrieval: 10 preguntas con su fragmento esperado, verifica que aparece
   en el top-3. Test de generación: una pregunta SIN respuesta en tus datos,
   verifica que el sistema admite que no lo sabe (no inventa).
5. Reindexación: al editar la política de reembolsos, dispara la reindexación de
   ESE documento (no de todos) usando el patrón de eventos del apéndice T/S.
6. Mide el coste: cuántos tokens de contexto usas de media por pregunta, y si el
   prompt caching (ap. O.6) del system prompt estable reduce el gasto.
7. Bonus: implementa la búsqueda híbrida con RRF (V.4) para una pregunta que
   incluya un término exacto (ej. el nombre de un plato) y compara el ranking
   contra la búsqueda vectorial sola — anota si el fragmento correcto sube de
   posición.

🧠 Autoevaluación

Aunque el contexto sea de 1M de tokens, cuesta dinero por cada petición y degrada la precisión: el modelo tiene que "encontrar la aguja" entre miles de documentos irrelevantes para la pregunta concreta. RAG selecciona solo lo relevante ANTES de generar, con una búsqueda barata y rápida.

Claude es un modelo de generación de texto, no un modelo de embeddings. Anthropic recomienda Voyage AI como socio de embeddings — son piezas separadas de la arquitectura, igual que PostgreSQL y Redis cumplen roles distintos aunque trabajen juntos.

En la fase de generación, no en la de retrieval — el problema no es "no encontró la información" sino "la encontró y no la usó bien" (posible alucinación, o el system prompt no fuerza a anclarse en el contexto). Por eso se evalúan por separado (V.8).

Decir explícitamente que responda SOLO con el contexto proporcionado y que admita abiertamente cuando no sepa la respuesta, en vez de inventar algo plausible. Sin esa instrucción, el comportamiento por defecto de un LLM ante un hueco de información es rellenar con una respuesta verosímil pero falsa.

Porque la puntuación de una búsqueda léxica (ts_rank) y la de una búsqueda vectorial (distancia coseno) están en escalas completamente distintas y no son comparables entre sí. RRF evita ese problema fusionando solo la posición de cada resultado en su propia lista — un método simple y robusto que no requiere normalizar nada.

No, salvo que tengas una razón adicional. El reranking añade coste y latencia a cambio de precisión; si el eval ya muestra buen resultado con el método simple, añadir una capa extra es complejidad sin beneficio medible. Se justifica cuando el eval muestra que el retrieval simple se queda corto, no de antemano.


Volver al: README.md · Relacionado: O-apis-ia.md, Q-nosql-mongodb.md, H-patrones-datos.md