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.
2 Arquitectura: dos flujos separados
RAG tiene dos flujos distintos, y conviene entenderlos por separado:
Indexado (offline, una sola vez)
- Leemos los documentos
- Los partimos en chunks
- Cada chunk → embedding
- Guardamos texto + embedding en pgvector
Consulta (online, cada pregunta)
- La pregunta → embedding
- Similarity search en pgvector
- Pasamos top-K chunks al LLM como contexto
- El LLM responde usando sólo ese contexto
Indexar documentación (una sola vez)
Este proceso se corre UNA SOLA VEZ (o cuando cambias la documentación). No en cada pregunta.
Una pregunta entra, un RAG se dispara
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
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."