Volver a anexos
Ampliación opcional: RAG con Supabase
AMPLIACIÓN OPCIONAL

RAG con Supabase

El asistente base consulta datos estructurados con tool calling. Pero si le preguntas sobre la política de devoluciones, un manual interno o un PDF de onboarding, no tiene ni idea. Aquí veremos cómo enseñarle.

¿Tu proyecto lo necesita? Solo si manejas documentación no estructurada (manuales, PDFs, políticas, FAQ libre). Para un CRUD o un dashboard de datos, con el anexo anterior basta. No hace falta hacer este para aprobar.

Prerrequisito si decides hacerlo: haber seguido el Asistente con LLM.

1 ¿Qué es RAG y para qué sirve?

RAG = Retrieval Augmented Generation. En cristiano: antes de pedirle al LLM que responda, le damos los trozos de documentación relevantes a la pregunta como contexto. El LLM responde basándose en ese contexto, no en lo que aprendió de internet.

Es la forma de que el asistente sepa cosas que no están en su entrenamiento: manuales internos, tus contratos, políticas de tu empresa, tickets históricos, documentación técnica privada.

Sin RAG

El LLM responde con lo que sabe del entrenamiento general. Si no lo sabe, se lo inventa con voz segura.

Con tool calling (asistente base)

Accede a datos estructurados ejecutando funciones concretas. Perfecto para BD relacional, APIs.

Con RAG (este anexo)

Accede a documentación no estructurada (PDFs, markdown, manuales). Responde citando fuentes.

¿Qué cambia al añadir RAG?

La misma pregunta, con y sin documentación propia como contexto.

LLM sin RAG
"¿Cuántos días tengo para devolver un producto en vuestra tienda?"
"Normalmente las tiendas online permiten entre 14 y 30 días, aunque algunas ofrecen hasta 60. Te recomiendo revisar los términos del establecimiento."
❌ Inventa. No sabe NADA de tu tienda concreta.
LLM con RAG
"¿Cuántos días tengo para devolver un producto en vuestra tienda?"
"Tienes 30 días desde la recepción para iniciar la devolución, siempre que el producto esté sin usar."
📄 Fuente: Política de devoluciones, sección 3.
✅ Cita la fuente. Si la info no existe, dice "no lo sé".

2 Arquitectura: dos flujos separados

RAG tiene dos flujos distintos, y conviene entenderlos por separado:

Indexado (offline, una sola vez)

  1. Leemos los documentos
  2. Los partimos en chunks
  3. Cada chunk → embedding
  4. Guardamos texto + embedding en pgvector

Consulta (online, cada pregunta)

  1. La pregunta → embedding
  2. Similarity search en pgvector
  3. Pasamos top-K chunks al LLM como contexto
  4. El LLM responde usando sólo ese contexto

Indexar documentación (una sola vez)

1. Partimos de un documento
PDF, markdown, manual interno… el formato original no importa.
Documento
manual.pdf
Chunks
Introducción…
Políticas de devolución…
Horarios de atención…
Proceso de pedido…
Embeddings
[0.87, -0.47, …]
[-0.10, 0.51, …]
[0.46, -0.39, …]
[-0.99, 0.78, …]
pgvector
documents
vector(1536)

Este proceso se corre UNA SOLA VEZ (o cuando cambias la documentación). No en cada pregunta.

Una pregunta entra, un RAG se dispara

1. El usuario pregunta
"¿Cuántos días tengo para devolver un producto?"
Pregunta
Embedding
pgvector
LLM
Respuesta + citas

3 Embeddings: el concepto más importante

Un embedding es un vector de números (1536 en el caso de OpenAI) que representa el significado de un texto. Textos con significado parecido tienen vectores cercanos en el espacio, aunque usen palabras distintas.

Ejemplo conceptual

"¿cuánto cuesta el envío?" → [0.12, -0.44, 0.78, …]
"precio del transporte" → [0.14, -0.41, 0.80, …] ✅ cerca
"¿qué es un erizo?" → [-0.89, 0.23, -0.05, …] ❌ lejos

Por eso el RAG funciona aunque el usuario use palabras distintas a las del documento: no buscamos palabras, buscamos significados parecidos.

Modelo recomendado para empezar

OpenAI text-embedding-3-small: 1536 dimensiones, barato (unos céntimos por millón de tokens) y muy bueno para la mayoría de casos. Si en un futuro necesitas más precisión, text-embedding-3-large (3072 dim).

4 Activar pgvector en Supabase

pgvector es una extensión de PostgreSQL que añade un tipo de dato vector y operadores de distancia (coseno, L2, producto interno). Supabase la trae lista, sólo hay que activarla.

En el SQL Editor de Supabase:

-- 1. Activar la extensión
create extension if not exists vector;

-- 2. Tabla para guardar los chunks + sus embeddings
create table documents (
  id bigint primary key generated always as identity,
  source text not null,         -- nombre del archivo o url
  section text,                 -- capítulo/sección si aplica
  content text not null,        -- el chunk de texto original
  embedding vector(1536),       -- dimensiones de text-embedding-3-small
  created_at timestamptz default now()
);

-- 3. Índice para que la búsqueda sea rápida
create index on documents
  using ivfflat (embedding vector_cosine_ops)
  with (lists = 100);

-- 4. Función de similarity search (la llamaremos desde el backend)
create or replace function match_documents(
  query_embedding vector(1536),
  match_count int default 3
)
returns table (
  id bigint,
  source text,
  section text,
  content text,
  similarity float
)
language sql stable as $$
  select
    id,
    source,
    section,
    content,
    1 - (embedding <=> query_embedding) as similarity
  from documents
  order by embedding <=> query_embedding
  limit match_count;
$$;

-- 5. RLS: solo lectura desde anon (igual que en el asistente base)
alter table documents enable row level security;
create policy "lectura publica documents" on documents
  for select using (true);

¿Qué es <=>?

Es el operador de distancia coseno que añade pgvector. 1 - distancia nos da la similitud (0 = nada parecido, 1 = idéntico). Ordenamos por distancia ascendente y devolvemos los más cercanos.

5 Indexar la documentación

Este script lo corres una vez (o cuando cambies la documentación). Lee archivos, los parte en chunks, pide el embedding a OpenAI y los guarda en Supabase.

Crea scripts/indexar.js:

import 'dotenv/config';
import fs from 'node:fs/promises';
import path from 'node:path';
import OpenAI from 'openai';
import { createClient } from '@supabase/supabase-js';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const supabase = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_ANON_KEY
);

const CHUNK_SIZE = 500;   // caracteres aprox
const OVERLAP = 100;      // solapamiento entre chunks

function chunk(text) {
  const chunks = [];
  let start = 0;
  while (start < text.length) {
    const end = Math.min(start + CHUNK_SIZE, text.length);
    chunks.push(text.slice(start, end).trim());
    start += CHUNK_SIZE - OVERLAP;
  }
  return chunks.filter(c => c.length > 50);
}

async function embed(text) {
  const { data } = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
  });
  return data[0].embedding;
}

async function indexarArchivo(filePath) {
  const content = await fs.readFile(filePath, 'utf8');
  const source = path.basename(filePath);
  const pieces = chunk(content);

  console.log(`→ $${source}: $${pieces.length} chunks`);

  for (const [i, piece] of pieces.entries()) {
    const embedding = await embed(piece);
    await supabase.from('documents').insert({
      source,
      section: `chunk $${i + 1}`,
      content: piece,
      embedding,
    });
    console.log(`  ✓ chunk $${i + 1}/$${pieces.length}`);
  }
}

const docs = [
  'docs/politica-devoluciones.md',
  'docs/horarios.md',
  'docs/faq.md',
];

for (const doc of docs) {
  await indexarArchivo(doc);
}

console.log('✅ Indexado completo');

Ejecútalo con:

node scripts/indexar.js

Chunking: el arte oculto

El tamaño del chunk cambia todo. Mucho chunk = pierdes precisión, responde con ruido. Poco chunk = pierdes contexto, no entiende la pregunta completa.

  • 500-1000 caracteres funciona bien para docs tipo FAQ o manuales.
  • Solapamiento del 15-20% evita que un concepto quede partido justo en el límite.
  • Para código o texto muy estructurado, usa librerías como langchain/text_splitter.

6 Endpoint /api/rag: búsqueda + respuesta

El endpoint recibe la pregunta, genera su embedding, busca los chunks más parecidos en pgvector y pide al LLM una respuesta basada SOLO en esos chunks.

Crea src/pages/api/rag.js:

import OpenAI from 'openai';
import { createClient } from '@supabase/supabase-js';

export const prerender = false; // habilita SSR para este endpoint

const openai = new OpenAI({ apiKey: import.meta.env.OPENAI_API_KEY });
const supabase = createClient(
  import.meta.env.SUPABASE_URL,
  import.meta.env.SUPABASE_ANON_KEY
);

const SYSTEM_PROMPT = `Eres un asistente que responde ÚNICAMENTE con la
información del CONTEXTO que se te proporciona. Reglas estrictas:

1. Si la información NO está en el contexto, responde exactamente:
   "No tengo esa información en la documentación."
2. NUNCA inventes datos, fechas, nombres o cifras.
3. Al final de cada respuesta, indica las fuentes entre corchetes:
   [fuente: nombre_archivo, sección]
4. Responde en español, claro y conciso.`;

export async function POST({ request }) {
  const { question } = await request.json();

  // 1. Embedding de la pregunta
  const { data: emb } = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: question,
  });
  const queryEmbedding = emb[0].embedding;

  // 2. Similarity search en pgvector
  const { data: matches, error } = await supabase.rpc('match_documents', {
    query_embedding: queryEmbedding,
    match_count: 3,
  });

  if (error) {
    return new Response(JSON.stringify({ error: error.message }), { status: 500 });
  }

  // 3. Filtrar por umbral de similitud (evita ruido)
  const relevantes = matches.filter(m => m.similarity > 0.75);

  if (relevantes.length === 0) {
    return new Response(JSON.stringify({
      reply: 'No tengo esa información en la documentación.',
      sources: [],
    }), { headers: { "Content-Type": "application/json" } });
  }

  // 4. Construir el contexto para el LLM
  const contexto = relevantes.map((m, i) =>
    `[Fuente $${i + 1}: $${m.source}, $${m.section}]\n$${m.content}`
  ).join('\n\n---\n\n');

  // 5. Pedir la respuesta al LLM
  const completion = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    temperature: 0.2,  // bajo: queremos respuestas fieles al contexto
    messages: [
      { role: "system", content: SYSTEM_PROMPT },
      {
        role: 'user',
        content: `CONTEXTO:\n$${contexto}\n\nPREGUNTA: $${question}`,
      },
    ],
  });

  return new Response(JSON.stringify({
    reply: completion.choices[0].message.content,
    sources: relevantes.map(m => ({
      source: m.source,
      section: m.section,
      similarity: m.similarity,
    })),
  }), { headers: { "Content-Type": "application/json" } });
}

7 Delimitar el asistente: evitar la alucinación

Por defecto, el LLM siempre quiere contestar. Es su naturaleza. Si no lo delimitas, cuando no encuentre la respuesta en el contexto se la inventará. Hay tres palancas para controlarlo:

1. System prompt estricto

Repetir la regla varias veces: "ÚNICAMENTE con el contexto", "NUNCA inventes", "si no está, di que no lo sabes". El LLM lee y obedece mejor cuando se lo repites con firmeza.

2. Umbral de similitud

Si el mejor match tiene similitud < 0.75, significa que el RAG no tiene nada relevante. Responde directamente "No lo sé" SIN llamar al LLM. Ahorras tokens y evitas que improvise.

3. Temperatura baja

temperature: 0.1-0.3. Cuanto más baja, más se pega al texto del contexto y menos "creatividad" pone en la respuesta. Para RAG queremos fidelidad, no poesía.

8 Frontend: mostrar respuesta + fuentes

Las fuentes son CRÍTICAS para la confianza del usuario. Que vea de dónde sale la respuesta y pueda verificar. Un chat RAG sin citas es un LLM disfrazado.

Componente src/components/RAGChat.astro:

---
// Sin props: todo el estado vive en el navegador.
---

<div class="rag">
  <div class="rag-input">
    <input type="text" data-input placeholder="Pregunta sobre la documentación..." />
    <button type="button" data-ask>Preguntar</button>
  </div>

  <!-- Resultado (se llena dinámicamente) -->
  <div class="rag-result" data-result hidden>
    <p class="rag-reply" data-reply></p>
    <div class="rag-sources" data-sources-wrap hidden>
      <p class="rag-sources-title">Fuentes</p>
      <ul data-sources></ul>
    </div>
  </div>
</div>

<style>
  .rag { max-width: 640px; }
  .rag-input { display: flex; gap: 8px; margin-bottom: 16px; }
  .rag-input input { flex: 1; background: #1e293b; border: none; color: white;
    padding: 8px 12px; border-radius: 8px; outline: none; }
  .rag-input button { background: #7c3aed; color: white; border: none;
    padding: 8px 16px; border-radius: 8px; cursor: pointer; }
  .rag-input button:disabled { opacity: 0.5; }
  .rag-result { background: #1e293b; border: 1px solid #334155;
    border-radius: 12px; padding: 20px; }
  .rag-result[hidden] { display: none; }
  .rag-reply { color: #f1f5f9; margin: 0 0 16px 0; white-space: pre-wrap; }
  .rag-sources { border-top: 1px solid #334155; padding-top: 16px; }
  .rag-sources[hidden] { display: none; }
  .rag-sources-title { font-size: 11px; color: #64748b; text-transform: uppercase;
    letter-spacing: 0.05em; margin: 0 0 8px 0; }
  .rag-sources ul { list-style: none; padding: 0; margin: 0; }
  .rag-sources li { font-size: 12px; color: #94a3b8; padding: 2px 0; }
  .rag-sources .src-name { color: #c4b5fd; }
  .rag-sources .src-match { color: #475569; }
</style>

<script>
  const $input = document.querySelector('[data-input]');
  const $ask = document.querySelector('[data-ask]');
  const $result = document.querySelector('[data-result]');
  const $reply = document.querySelector('[data-reply]');
  const $sourcesWrap = document.querySelector('[data-sources-wrap]');
  const $sources = document.querySelector('[data-sources]');

  async function ask() {
    const question = $input.value.trim();
    if (!question || $ask.disabled) return;

    $ask.disabled = true;
    $ask.textContent = '...';
    $result.hidden = true;
    $sources.innerHTML = '';

    try {
      const res = await fetch('/api/rag', {
        method: 'POST',
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ question }),
      });
      const data = await res.json();

      $reply.textContent = data.reply;

      if (data.sources && data.sources.length > 0) {
        data.sources.forEach((s) => {
          const li = document.createElement('li');
          const pct = Math.round(s.similarity * 100);
          li.innerHTML = '📄 <span class="src-name">' + s.source + '</span>' +
            (s.section ? ' · ' + s.section : '') +
            ' <span class="src-match">(' + pct + '% match)</span>';
          $sources.appendChild(li);
        });
        $sourcesWrap.hidden = false;
      } else {
        $sourcesWrap.hidden = true;
      }

      $result.hidden = false;
    } catch (e) {
      $reply.textContent = 'Error: ' + e.message;
      $result.hidden = false;
    } finally {
      $ask.disabled = false;
      $ask.textContent = 'Preguntar';
    }
  }

  $ask.addEventListener('click', ask);
  $input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') ask();
  });
</script>

Para incluirlo en una página:

---
import RAGChat from '../components/RAGChat.astro';
---

<main>
  <h1>Preguntas sobre documentación</h1>
  <RAGChat />
</main>

9 Integrar RAG como una tool más

Aquí está la gracia: si ya tienes el asistente base montado, podemos añadir RAG como una tool extra. El LLM decide solo cuándo consultar la BD (tool calling) y cuándo consultar la documentación (RAG).

Añade a src/lib/tools.js:

// En toolSchemas
{
  type: 'function',
  function: {
    name: 'buscarEnDocumentacion',
    description: 'Busca en la documentacion interna (politicas, manuales, FAQ) ' +
                 'para responder preguntas que NO son de datos de BD. ' +
                 'Usar cuando la pregunta sea sobre procesos, politicas o normas.',
    parameters: {
      type: 'object',
      properties: {
        consulta: { type: "string", description: "La pregunta del usuario tal cual" }
      },
      required: ['consulta']
    }
  }
}

// En toolHandlers
async buscarEnDocumentacion({ consulta }) {
  const { data: emb } = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: consulta,
  });
  const { data: matches } = await supabase.rpc('match_documents', {
    query_embedding: emb[0].embedding,
    match_count: 3,
  });
  return matches
    .filter(m => m.similarity > 0.75)
    .map(m => ({ source: m.source, section: m.section, content: m.content }));
}

Resultado: un asistente híbrido

Pregunta: "¿Cuántos productos tenemos y cuántos días hay para devolverlos?" → el LLM llama a listarProductos() Y a buscarEnDocumentacion() en la misma conversación, y combina ambas respuestas.

Eso es un asistente empresarial real. No un chatbot de feria.

10 Afinar el RAG: qué tocar cuando no funciona

📏 Tamaño del chunk

Si el asistente corta ideas a la mitad → chunks más grandes o más overlap. Si trae ruido irrelevante → chunks más pequeños.

🎯 Top-K

match_count: si pones 3 y la respuesta está en el 4º match, la pierdes. Si pones 10, metes ruido y diluyes el contexto. Empieza en 3-5.

🚪 Umbral de similitud

0.75 es un buen punto de partida. Si el asistente dice "no lo sé" demasiado, baja a 0.7. Si mete cosas irrelevantes, sube a 0.8.

🧹 Calidad de los documentos

RAG sufre con PDFs mal escaneados, tablas complejas y texto lleno de menús/pies de página. Limpia los documentos antes de indexar. Basura dentro, basura fuera.

🌐 Multilingüe

OpenAI embeddings manejan bien español + inglés. Pero si tu docs están en un idioma y preguntas en otro, la similitud baja. Traduce o usa un modelo específicamente multilingüe.

11 Gotchas de producción

Actualizar documentación

Si cambias un documento indexado, hay que reindexarlo. Pensar en un job (cron) que detecte cambios y actualice los chunks afectados. Sin esto, el RAG contestará con info obsoleta con todo el aplomo del mundo.

Coste de embeddings

Cada pregunta genera 1 embedding (barato). Cada documento al indexarlo genera N (uno por chunk). Con miles de documentos la factura se nota. Cachea y deduplica.

Fuga de datos por embeddings

Si tu documentación es sensible, estás enviándola a OpenAI para convertirla a embeddings. Revisa política de retención, considera modelos de embeddings locales (bge-m3, nomic) o Azure OpenAI con compromiso de no-training.

Prompt injection

Si indexas contenido de fuentes no confiables (web scraping, comentarios de usuarios), un atacante puede meter instrucciones en el texto que luego el LLM leerá como parte del contexto. Valida y sanea antes de indexar.

12 Cierre

Entre el asistente base y esta ampliación tienes las DOS formas de conectar un LLM a tu empresa:

  • Tool calling para datos estructurados: BD, APIs, sistemas internos.
  • RAG para documentación no estructurada: PDFs, manuales, políticas.

Con esto construyes asistentes que responden a la realidad de tu negocio, sin alucinar y con fuentes verificables. Eso es lo que diferencia un chatbot de feria de una herramienta de empresa.

"El LLM es el cerebro. Tu arquitectura es el sistema nervioso."