
RAG paso a paso para tu SaaS: del PDF al chatbot en 2 horas
Respuesta corta (60 segundos): RAG (Retrieval-Augmented Generation) es darle al LLM solo los pedazos relevantes de tus documentos antes de que responda. Tres pasos: (1) indexar tus documentos como vectores (embeddings), (2) buscar los chunks más parecidos a la pregunta del usuario, y (3) inyectar esos chunks en el prompt del LLM. En este post implementás los tres con Next.js, Supabase pgvector y OpenAI — código real, en 2 horas, sin abstracciones.
La pregunta no es "¿qué es RAG?" — eso lo explica cualquier hilo de Twitter. La pregunta es "¿cómo se implementa bien en mi SaaS?" sin sobre-ingeniería ni dependencias innecesarias.
Este post va paso a paso, con código real que podés copiar a tu proyecto. El stack es Next.js 15 + Supabase + OpenAI porque es el que veo más seguido en startups LATAM en 2026, pero la lógica se traduce a cualquier otro stack.
Lo que vas a tener al final
Un endpoint /api/ask que:
- Recibe una pregunta del usuario.
- Busca los 5 chunks más relevantes en tu Supabase.
- Pasa esos chunks como contexto a OpenAI.
- Devuelve la respuesta del LLM, fundamentada en tus documentos.
Tiempo total: ~2 horas si ya tenés Next.js + Supabase corriendo. Si arrancás desde cero, ~3 horas.
Prerrequisitos
- Proyecto Next.js 15 con App Router.
- Cuenta Supabase con un proyecto creado.
- API key de OpenAI (~USD 5 te alcanza para experimentar).
- Documentos para indexar (PDFs, markdown, texto plano — lo que sea).
Paso 1 · Habilitar pgvector y crear la tabla de embeddings
En el SQL Editor de Supabase, corré:
~-- Habilitar la extensión pgvector create extension if not exists vector; -- Tabla para chunks indexados create table documents ( id bigserial primary key, content text not null, metadata jsonb, embedding vector(1536), -- text-embedding-3-small produce 1536 dims tenant_id uuid not null, -- multi-tenancy desde el día 1 created_at timestamptz default now() ); -- Índice para búsqueda rápida (HNSW para hasta ~1M vectores) create index documents_embedding_idx on documents using hnsw (embedding vector_cosine_ops); -- Índice por tenant para que el filtro sea barato create index documents_tenant_idx on documents(tenant_id); -- RLS para que cada cliente solo vea sus chunks alter table documents enable row level security; create policy "tenants can only access their own documents" on documents using (tenant_id = auth.uid());
Por qué vector(1536): es la dimensión que produce text-embedding-3-small de OpenAI. Si usás otro modelo (Voyage AI, Cohere), ajustá.
Por qué HNSW y no IVFFlat: HNSW es más rápido en queries y no requiere reentrenamiento cuando insertás nuevos vectores. La diferencia se nota arriba de 100K chunks.
Paso 2 · Chunking y embeddings
Instalá las deps:
~pnpm add openai @supabase/supabase-js pnpm add -D tsx
Script de indexación. Esto es one-off, no va en la API:
~// scripts/index-documents.ts import { createClient } from "@supabase/supabase-js"; import OpenAI from "openai"; import fs from "node:fs"; const supabase = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! // service_role para bypass RLS al indexar ); const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const TENANT_ID = process.env.TENANT_ID!; const CHUNK_SIZE = 600; // tokens aprox. 1 token ~= 4 chars en español const CHUNK_OVERLAP = 80; // overlap para que el contexto no se corte function chunkText(text: string): string[] { const chunks: string[] = []; const approxCharsPerChunk = CHUNK_SIZE * 4; const overlapChars = CHUNK_OVERLAP * 4; let i = 0; while (i < text.length) { chunks.push(text.slice(i, i + approxCharsPerChunk)); i += approxCharsPerChunk - overlapChars; } return chunks; } async function embed(text: string): Promise<number[]> { const res = await openai.embeddings.create({ model: "text-embedding-3-small", input: text, }); return res.data[0].embedding; } async function indexFile(path: string, source: string) { const text = fs.readFileSync(path, "utf-8"); const chunks = chunkText(text); console.log(` > ${chunks.length} chunks`); // Embeddings en batch — OpenAI permite hasta 2048 inputs por request const embedRes = await openai.embeddings.create({ model: "text-embedding-3-small", input: chunks, }); const rows = chunks.map((content, idx) => ({ content, embedding: embedRes.data[idx].embedding, tenant_id: TENANT_ID, metadata: { source, chunk_index: idx }, })); const { error } = await supabase.from("documents").insert(rows); if (error) throw error; } async function main() { const files = process.argv.slice(2); for (const f of files) { console.log(`Indexing ${f}...`); await indexFile(f, f); } console.log("Done."); } main();
Correlo:
~TENANT_ID=<uuid> pnpm tsx scripts/index-documents.ts docs/*.txt
Costo de indexación: con text-embedding-3-small a USD 0.02 por 1M tokens, indexar 5,000 chunks de 600 tokens te cuesta ~USD 0.06. Sí, seis centavos.
Paso 3 · Búsqueda semántica con función RPC
Esto es lo que permite hacer la query desde Next.js sin SQL ad-hoc:
~create or replace function match_documents( query_embedding vector(1536), match_threshold float, match_count int, filter_tenant_id uuid ) returns table ( id bigint, content text, metadata jsonb, similarity float ) language sql stable as $$ select documents.id, documents.content, documents.metadata, 1 - (documents.embedding <=> query_embedding) as similarity from documents where documents.tenant_id = filter_tenant_id and 1 - (documents.embedding <=> query_embedding) > match_threshold order by documents.embedding <=> query_embedding limit match_count; $$;
El operador <=> calcula la distancia coseno (0 = idéntico, 2 = opuesto). El 1 - distancia te da la similitud (1 = idéntico).
Threshold típico: 0.7-0.8 para texto técnico, 0.6 para texto más conversacional. Más bajo = más recall pero más ruido. Empezá en 0.75 y ajustá si las respuestas no son buenas.
Paso 4 · El endpoint de Next.js
~// app/api/ask/route.ts import { createClient } from "@supabase/supabase-js"; import OpenAI from "openai"; import { NextResponse } from "next/server"; const supabase = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY! // anon key + RLS para que respete el tenant_id del usuario ); const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); export async function POST(req: Request) { const { question, tenantId } = await req.json(); // 1. Embed la pregunta const embedRes = await openai.embeddings.create({ model: "text-embedding-3-small", input: question, }); const queryEmbedding = embedRes.data[0].embedding; // 2. Buscar top-5 chunks const { data: chunks, error } = await supabase.rpc("match_documents", { query_embedding: queryEmbedding, match_threshold: 0.75, match_count: 5, filter_tenant_id: tenantId, }); if (error || !chunks?.length) { return NextResponse.json({ answer: "No encontré información relevante. ¿Podés reformular la pregunta?", sources: [], }); } // 3. Armar el prompt aumentado const context = chunks .map((c: any, idx: number) => `[Fuente ${idx + 1}]: ${c.content}`) .join("\n\n"); const systemPrompt = `Sos un asistente que responde preguntas basándose ÚNICAMENTE en el contexto provisto. Si la respuesta no está en el contexto, decí que no lo sabés. Cita las fuentes usando [Fuente N] al final de cada afirmación.`; // 4. Llamar al LLM const completion = await openai.chat.completions.create({ model: "gpt-4o-mini", messages: [ { role: "system", content: systemPrompt }, { role: "user", content: `Contexto:\n${context}\n\nPregunta: ${question}` }, ], temperature: 0.2, // bajo para respuestas factuales }); return NextResponse.json({ answer: completion.choices[0].message.content, sources: chunks.map((c: any) => c.metadata), tokens: completion.usage, }); }
Por qué temperature: 0.2: RAG es para respuestas factuales. Temperature alto introduce creatividad que en este caso es alucinaciones disfrazadas.
Por qué gpt-4o-mini y no gpt-4o: para Q&A sobre contexto ya recuperado, el modelo no necesita razonar mucho — necesita parafrasear bien. gpt-4o-mini te ahorra 10x en costo de tokens.
Paso 5 · Monitoreo
Tres métricas mínimas para producción:
- Tokens por request — vienen en
completion.usage. Loggealos a tu observability stack o a una tablaquery_logs. - Similarity score promedio — si baja semana a semana, tu corpus está quedando obsoleto o tus usuarios están preguntando cosas nuevas.
- Rate de "no encontré información" — si sube, ajustá threshold, agregá más documentos, o revisá el chunking.
Setup mínimo con Helicone (proxy de OpenAI):
~const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, baseURL: "https://oai.helicone.ai/v1", defaultHeaders: { "Helicone-Auth": `Bearer ${process.env.HELICONE_API_KEY}`, "Helicone-User-Id": tenantId, }, });
Helicone te da dashboard de costos por usuario, latencia y errores. Free tier hasta 100K requests/mes.
Pitfalls que vas a encontrar
- Chunks demasiado pequeños cortan ideas por la mitad. Si las respuestas son malas, primero revisá los chunks recuperados antes de cambiar el modelo. Probablemente el problema sea ese.
- Filtros multi-tenant olvidados. Si tu app sirve a varios clientes, siempre pasá
tenant_iden el filtro. Olvidar este filtro es cómo se filtra data entre clientes. - Re-indexar todo cuando agregás un documento. No hace falta. Solo embebés los chunks nuevos e insertás. pgvector mantiene el índice actualizado.
- No loggear el prompt enviado al LLM. Cuando una respuesta es mala, querés ver qué contexto se inyectó. Sin ese log estás depurando a ciegas.
Hablemos de tu caso
Si estás pensando en implementar RAG en tu SaaS y querés validar si tu use case lo justifica (a veces no — ver la FAQ), reservá una llamada de 30 minutos sin costo. En 20 minutos suelo poder decirte si RAG es la herramienta correcta o si tu caso se resuelve más simple con prompt engineering + un buen system prompt.
Leer también:
- Cuánto cuesta implementar IA en una startup SaaS en 2026 — qué presupuesto razonable para un feature RAG.
- Más artículos sobre IA — guías técnicas y comparativas.
- Volver al blog — todos los artículos.
Preguntas frecuentes
¿Por qué pgvector y no Pinecone u otra vector DB dedicada?
Para la mayoría de los SaaS hasta 1M de chunks, pgvector en Supabase es suficiente: cero infra extra, scoping por tenant trivial vía RLS, y costos planos. Pinecone tiene sentido cuando llegas a 10M+ vectores o necesitas filtros muy complejos. Empezar con pgvector y migrar después si es necesario.
¿Qué tamaño de chunk uso?
400-800 tokens con 50-100 de overlap funciona bien para documentación técnica y artículos. Para legal/contratos, sube a 1000-1500 tokens — la unidad semántica es más larga. Para chat logs, baja a 200-300. Si el chunk corta una idea por la mitad, las respuestas van a ser malas.
¿Qué modelo de embeddings conviene en 2026?
text-embedding-3-small de OpenAI es el caballo de batalla: USD 0.02 por 1M tokens, 1536 dimensiones, calidad sólida para casi todo SaaS. text-embedding-3-large da 5-10% mejor recall pero 6x más caro. Voyage AI y Cohere embed-3 son alternativas válidas, sobre todo si querés evitar dependencia de OpenAI.
¿Cuánto cuesta correr esto en producción?
Para 5K documentos indexados (10-50K chunks) y 1K queries/mes: indexación inicial USD 5-15 una vez, queries en operación USD 30-80/mes (embeddings de queries + completion del LLM). Supabase suele entrar en el free tier hasta cierto volumen.
¿Cómo manejo permisos multi-tenant?
Agregá una columna `tenant_id` a la tabla de embeddings y filtralo en la query SQL antes de hacer el similarity search. Si usás Supabase, configurá Row Level Security policies por tenant_id. Nunca filtres permisos solo en código de aplicación — un bug y un cliente ve la data de otro.
¿Cuándo NO uso RAG?
Cuando la respuesta no requiere conocimiento externo (tareas como clasificación, generación creativa, traducción). Cuando los documentos relevantes caben en el context window del modelo (Claude Sonnet acepta 200K tokens — para corpus pequeños es más simple meter todo). Cuando necesitás respuestas que combinen información de muchos documentos a la vez — RAG ingenuo falla con preguntas tipo 'compara todos los contratos del 2025'.