RAG con Next.js y TypeScript: Crea un Buscador Inteligente
Tutorial paso a paso para implementar RAG (Retrieval-Augmented Generation) con Next.js, Supabase y OpenAI. Crea un buscador que responde preguntas usando tus propios documentos.
RAG con Next.js y TypeScript: Crea un Buscador Inteligente
RAG (Retrieval-Augmented Generation) con Next.js y TypeScript es el patron mas practico para construir aplicaciones de IA que responden preguntas usando tus propios documentos. En vez de depender de lo que el modelo "sabe" (y a veces inventa), RAG primero busca informacion relevante en tu base de datos y luego genera una respuesta fundamentada en datos reales.
Este tutorial cubre la implementacion completa: desde procesar tus documentos y almacenarlos como vectores en Supabase, hasta crear un buscador con streaming de respuestas en Next.js. Todo con TypeScript y codigo funcional que puedes copiar a tu proyecto.
Como funciona RAG
RAG -- Retrieval-Augmented Generation, o Generacion Aumentada por Recuperacion -- tiene un flujo claro de dos fases: indexacion y consulta.
Fase 1: Indexacion (se hace una vez)
Documento -> Dividir en chunks -> Generar embedding por chunk -> Guardar en vector DB- Tomas tus documentos (PDFs, Markdown, texto, lo que sea)
- Los divides en fragmentos (chunks) de tamano manejable
- Generas un embedding (vector numerico) por cada chunk usando un modelo como
text-embedding-3-smallde OpenAI - Guardas cada chunk con su embedding en una base de datos con soporte vectorial
Fase 2: Consulta (cada vez que un usuario pregunta)
Pregunta del usuario -> Generar embedding de la pregunta -> Buscar chunks similares -> Enviar contexto + pregunta al LLM -> Respuesta fundamentada- El usuario escribe una pregunta
- Generas un embedding de esa pregunta
- Buscas en la base de datos los chunks cuyo embedding sea mas similar al de la pregunta (similarity search)
- Tomas esos chunks como contexto y se los envias al LLM junto con la pregunta
- El LLM genera una respuesta basada en esa informacion real, no en su conocimiento general
La clave de RAG es que el LLM no necesita "saber" sobre tus documentos. Solo necesita recibir la informacion relevante en el momento de generar la respuesta.
Embeddings en 30 segundos
Un embedding es una representacion numerica del significado de un texto. Dos textos que hablan de lo mismo tendran embeddings cercanos en el espacio vectorial, aunque usen palabras diferentes. Esto permite buscar por significado, no solo por palabras exactas.
Que vamos a construir
Un buscador inteligente que:
- Recibe documentos en texto (Markdown en este caso)
- Los procesa y almacena en Supabase con pgvector
- Permite hacer preguntas en lenguaje natural
- Responde con streaming usando los documentos como fuente
Stack del proyecto
| Componente | Tecnologia |
|---|---|
| Framework | Next.js 15 (App Router) |
| Base de datos vectorial | Supabase (PostgreSQL + pgvector) |
| Embeddings | OpenAI text-embedding-3-small |
| LLM | OpenAI gpt-4o-mini |
| AI SDK | Vercel AI SDK (ai) |
| Lenguaje | TypeScript |
Setup del proyecto
Crea un proyecto nuevo de Next.js o usa uno existente:
$ Instala las dependencias necesarias:
$ ai: el Vercel AI SDK para streaming y generacion de texto@ai-sdk/openai: el provider de OpenAI para el AI SDK@supabase/supabase-js: el cliente de Supabase para queries y almacenamiento
Variables de entorno
Crea un archivo .env.local con las credenciales necesarias:
# OpenAI
OPENAI_API_KEY=sk-...
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://tu-proyecto.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...Service role key vs anon key
Para la indexacion de documentos usamos la service role key de Supabase, que tiene permisos completos y solo debe existir en el servidor. Nunca la expongas con el prefijo NEXT_PUBLIC_. Si necesitas repasar como funcionan las variables de entorno en Next.js, revisa la guia de variables de entorno.
Estructura del proyecto
mi-buscador-rag/
app/
api/
chat/
route.ts (Route Handler -- busqueda + generacion)
ingest/
route.ts (Route Handler -- indexar documentos)
page.tsx (Interfaz del buscador)
lib/
supabase.ts (Cliente Supabase)
embeddings.ts (Generar embeddings)
chunking.ts (Dividir documentos en chunks)
.env.local (Variables de entorno)
Configurar Supabase con pgvector
Supabase incluye la extension pgvector en todos los planes, incluyendo el gratuito. Necesitas habilitarla y crear la tabla donde guardaras los chunks con sus embeddings.
Ve al SQL Editor de tu proyecto en Supabase y ejecuta:
-- Habilitar la extension de vectores
create extension if not exists vector;
-- Crear la tabla para almacenar chunks de documentos
create table documents (
id bigserial primary key,
content text not null,
metadata jsonb default '{}',
embedding vector(1536)
);
-- Crear un indice para busquedas rapidas por similitud
create index on documents using ivfflat (embedding vector_cosine_ops)
with (lists = 100);La columna embedding almacena vectores de 1536 dimensiones, que es el tamano de los embeddings generados por text-embedding-3-small de OpenAI. La columna metadata te permite guardar informacion adicional como el nombre del archivo fuente, la seccion, la fecha, etc.
Ahora crea la funcion SQL que hara la busqueda por similitud:
-- Funcion para buscar documentos similares
create or replace function match_documents (
query_embedding vector(1536),
match_threshold float default 0.7,
match_count int default 5
)
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 1 - (documents.embedding <=> query_embedding) > match_threshold
order by documents.embedding <=> query_embedding
limit match_count;
$$;Esta funcion recibe un embedding de consulta y devuelve los documentos mas similares. El operador <=> calcula la distancia coseno entre dos vectores. Restamos de 1 para convertirlo en similitud (1 = identico, 0 = completamente diferente).
match_threshold
El parametro match_threshold (0.7 por defecto) filtra resultados con baja similitud. Si obtienes pocos resultados, bajalo a 0.5. Si obtienes resultados irrelevantes, subelo a 0.8. Es algo que vas a ajustar segun tus datos.
Cliente Supabase
Crea el cliente que usaras desde el servidor. Si ya tienes Supabase integrado en tu proyecto, puedes reutilizar tu cliente existente. Si es un proyecto nuevo, la guia de Supabase con Next.js cubre la configuracion completa.
// lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
// Cliente con service role para operaciones del servidor
export const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)Generar embeddings
Esta funcion convierte texto en un vector numerico usando la API de OpenAI:
// lib/embeddings.ts
import { openai } from '@ai-sdk/openai'
import { embed } from 'ai'
// Generar un embedding para un texto dado
export async function generarEmbedding(texto: string): Promise<number[]> {
const { embedding } = await embed({
model: openai.embedding('text-embedding-3-small'),
value: texto,
})
return embedding
}El modelo text-embedding-3-small genera vectores de 1536 dimensiones y cuesta $0.02 por millon de tokens. Para la mayoria de proyectos, es el mejor balance entre calidad y costo.
Procesar documentos: chunking
Dividir documentos en chunks es uno de los pasos mas importantes de RAG. Un chunk demasiado grande puede diluir la informacion relevante. Uno demasiado pequeno puede perder el contexto necesario.
// lib/chunking.ts
interface Chunk {
contenido: string
metadata: Record<string, string>
}
// Dividir un documento en chunks con overlap
export function dividirEnChunks(
texto: string,
opciones: {
tamanoChunk?: number
overlap?: number
metadata?: Record<string, string>
} = {}
): Chunk[] {
const {
tamanoChunk = 1000, // caracteres por chunk
overlap = 200, // caracteres de solapamiento entre chunks
metadata = {},
} = opciones
const chunks: Chunk[] = []
let inicio = 0
while (inicio < texto.length) {
// Buscar un punto natural de corte (fin de oracion, parrafo)
let fin = inicio + tamanoChunk
if (fin < texto.length) {
// Intentar cortar en un salto de linea doble (parrafo)
const corteParrafo = texto.lastIndexOf('\n\n', fin)
if (corteParrafo > inicio + tamanoChunk * 0.5) {
fin = corteParrafo
} else {
// Intentar cortar en un punto seguido de espacio
const cortePunto = texto.lastIndexOf('. ', fin)
if (cortePunto > inicio + tamanoChunk * 0.5) {
fin = cortePunto + 1
}
}
} else {
fin = texto.length
}
const contenido = texto.slice(inicio, fin).trim()
if (contenido.length > 0) {
chunks.push({
contenido,
metadata: {
...metadata,
charInicio: String(inicio),
charFin: String(fin),
},
})
}
// Avanzar con overlap para evitar cortes abruptos
const nuevoInicio = fin - overlap
inicio = nuevoInicio > inicio ? nuevoInicio : fin
}
return chunks
}El overlap (solapamiento) entre chunks es fundamental. Garantiza que si una idea empieza al final de un chunk y termina al inicio del siguiente, al menos uno de los dos la contiene completa.
Tamano del chunk
1000 caracteres con 200 de overlap es un buen punto de partida. En la seccion de optimizacion veremos como ajustar estos valores segun tu caso de uso.
API de indexacion
Este Route Handler recibe documentos, los divide en chunks, genera embeddings y los almacena en Supabase:
// app/api/ingest/route.ts
import { NextResponse } from 'next/server'
import { supabaseAdmin } from '@/lib/supabase'
import { generarEmbedding } from '@/lib/embeddings'
import { dividirEnChunks } from '@/lib/chunking'
export async function POST(request: Request) {
try {
const { documento, metadata } = await request.json()
if (!documento || typeof documento !== 'string') {
return NextResponse.json(
{ error: 'El campo "documento" es requerido y debe ser texto' },
{ status: 400 }
)
}
// Dividir el documento en chunks
const chunks = dividirEnChunks(documento, {
tamanoChunk: 1000,
overlap: 200,
metadata: metadata || {},
})
// Generar embeddings y guardar cada chunk
const resultados = await Promise.all(
chunks.map(async (chunk) => {
const embedding = await generarEmbedding(chunk.contenido)
const { error } = await supabaseAdmin
.from('documents')
.insert({
content: chunk.contenido,
metadata: chunk.metadata,
embedding,
})
if (error) throw error
return { contenido: chunk.contenido.slice(0, 50) + '...', status: 'ok' }
})
)
return NextResponse.json({
mensaje: `${resultados.length} chunks indexados correctamente`,
chunks: resultados,
})
} catch (error) {
console.error('Error al indexar documento:', error)
return NextResponse.json(
{ error: 'Error al procesar el documento' },
{ status: 500 }
)
}
}Para indexar un documento, puedes hacer un POST desde cualquier cliente o script:
curl -X POST http://localhost:3000/api/ingest \
-H "Content-Type: application/json" \
-d '{
"documento": "Tu texto completo aqui...",
"metadata": { "fuente": "documentacion-interna", "seccion": "guia-usuario" }
}'Protege el endpoint de indexacion
En produccion, este endpoint debe tener autenticacion. Cualquier persona que pueda hacer POST puede inyectar documentos en tu base de datos. Agrega un header de autorizacion o middleware que verifique un token.
API de busqueda con streaming
Este es el corazon de la aplicacion. El Route Handler recibe una pregunta, busca documentos relevantes y genera una respuesta con streaming:
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai'
import { streamText } from 'ai'
import { supabaseAdmin } from '@/lib/supabase'
import { generarEmbedding } from '@/lib/embeddings'
export const maxDuration = 30
export async function POST(request: Request) {
const { messages } = await request.json()
// Tomar la ultima pregunta del usuario
const ultimaPregunta = messages
.filter((m: { role: string }) => m.role === 'user')
.pop()?.content
if (!ultimaPregunta) {
return new Response('No se recibio pregunta', { status: 400 })
}
// 1. Generar embedding de la pregunta
const embeddingPregunta = await generarEmbedding(ultimaPregunta)
// 2. Buscar documentos similares en Supabase
const { data: documentos, error } = await supabaseAdmin.rpc(
'match_documents',
{
query_embedding: embeddingPregunta,
match_threshold: 0.7,
match_count: 5,
}
)
if (error) {
console.error('Error en busqueda:', error)
return new Response('Error en la busqueda', { status: 500 })
}
// 3. Construir el contexto con los documentos encontrados
const contexto = documentos
.map(
(doc: { content: string; similarity: number }) =>
`[Similitud: ${(doc.similarity * 100).toFixed(1)}%]\n${doc.content}`
)
.join('\n\n---\n\n')
// 4. Generar respuesta con streaming usando el contexto
const resultado = streamText({
model: openai('gpt-4o-mini'),
system: `Eres un asistente que responde preguntas basandose UNICAMENTE en el contexto proporcionado.
Reglas:
- Responde en espanol
- Si el contexto no contiene informacion suficiente para responder, dilo claramente
- No inventes informacion que no este en el contexto
- Cita las partes relevantes del contexto cuando sea posible
- Se conciso y directo
Contexto de documentos:
${contexto}`,
messages,
})
return resultado.toDataStreamResponse()
}El flujo es directo:
- El usuario hace una pregunta
- Generamos el embedding de esa pregunta
- Buscamos los 5 chunks mas similares en Supabase usando la funcion
match_documents - Inyectamos esos chunks como contexto en el system prompt
- El LLM genera una respuesta basada en ese contexto, con streaming
El system prompt es critico. Le decimos al modelo que solo use la informacion del contexto. Sin esta instruccion, el modelo mezclaria su conocimiento general con tus documentos, lo cual es exactamente lo que RAG busca evitar.
Interfaz del buscador
El componente del frontend usa el hook useChat del Vercel AI SDK, que maneja automaticamente el estado de la conversacion, el streaming y el envio de mensajes. Si quieres profundizar en lo que el AI SDK puede hacer, la guia de Vercel AI SDK con agentes lo cubre en detalle.
// app/page.tsx
'use client'
import { useChat } from 'ai/react'
import { useRef, useEffect } from 'react'
export default function BuscadorPage() {
const { messages, input, handleInputChange, handleSubmit, isLoading } =
useChat({
api: '/api/chat',
})
const scrollRef = useRef<HTMLDivElement>(null)
// Scroll automatico cuando llegan nuevos mensajes
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages])
return (
<main className="mx-auto max-w-2xl px-4 py-12">
<h1 className="mb-2 text-3xl font-bold">Buscador Inteligente</h1>
<p className="mb-8 text-neutral-400">
Haz preguntas sobre la documentacion. Las respuestas se generan
a partir de los documentos indexados.
</p>
{/* Area de mensajes */}
<div
ref={scrollRef}
className="mb-4 h-[500px] overflow-y-auto rounded-lg border border-neutral-800 bg-neutral-950 p-4"
>
{messages.length === 0 && (
<p className="text-neutral-500">
Escribe una pregunta para comenzar...
</p>
)}
{messages.map((mensaje) => (
<div
key={mensaje.id}
className={`mb-4 ${
mensaje.role === 'user' ? 'text-right' : 'text-left'
}`}
>
<div
className={`inline-block max-w-[85%] rounded-lg px-4 py-2 ${
mensaje.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-neutral-800 text-neutral-100'
}`}
>
<p className="text-sm font-medium text-neutral-400">
{mensaje.role === 'user' ? 'Tu' : 'Asistente'}
</p>
<div className="mt-1 whitespace-pre-wrap">
{mensaje.content}
</div>
</div>
</div>
))}
{isLoading && messages[messages.length - 1]?.role === 'user' && (
<div className="mb-4 text-left">
<div className="inline-block rounded-lg bg-neutral-800 px-4 py-2">
<p className="text-sm text-neutral-400">Buscando en documentos...</p>
</div>
</div>
)}
</div>
{/* Formulario de busqueda */}
<form onSubmit={handleSubmit} className="flex gap-2">
<input
value={input}
onChange={handleInputChange}
placeholder="Que quieres saber sobre los documentos?"
className="flex-1 rounded-lg border border-neutral-700 bg-neutral-900 px-4 py-2 text-white placeholder-neutral-500 focus:border-blue-500 focus:outline-none"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="rounded-lg bg-blue-600 px-6 py-2 font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Buscando...' : 'Preguntar'}
</button>
</form>
</main>
)
}Este componente es un Client Component porque necesita hooks (useChat, useRef, useEffect) y event handlers. El useChat se encarga de:
- Enviar los mensajes al endpoint
/api/chat - Procesar el streaming de la respuesta
- Mantener el historial de la conversacion
- Actualizar la UI en tiempo real mientras llega la respuesta
Probarlo todo junto
Con Supabase configurado, los endpoints creados y la interfaz lista, levanta el servidor de desarrollo:
$ Primero, indexa algun documento. Puedes usar curl o crear un script:
// scripts/indexar.ts
// Ejecutar con: npx tsx scripts/indexar.ts
const documentoEjemplo = `
# Politica de Devoluciones
Los productos pueden devolverse dentro de los primeros 30 dias
despues de la compra. El producto debe estar en su empaque original
y sin senales de uso.
Para iniciar una devolucion, contacta a soporte@ejemplo.com con tu
numero de pedido. El reembolso se procesa en 5-7 dias habiles
despues de recibir el producto.
Los productos digitales no son reembolsables una vez descargados.
Las tarjetas de regalo no son reembolsables.
## Excepciones
Los productos en oferta con descuento mayor al 50% son venta final
y no aceptan devolucion. Los productos personalizados tampoco
aceptan devolucion.
`
async function indexar() {
const respuesta = await fetch('http://localhost:3000/api/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documento: documentoEjemplo,
metadata: { fuente: 'politicas', seccion: 'devoluciones' },
}),
})
const resultado = await respuesta.json()
console.log(resultado)
}
indexar()$ Despues, abre http://localhost:3000 y pregunta algo como "Cual es la politica de devolucion para productos digitales?". El buscador deberia encontrar los chunks relevantes y darte una respuesta basada en tu documento.
Optimizacion
La implementacion base funciona, pero hay varios ajustes que hacen la diferencia entre un prototipo y algo que sirve en produccion.
Tamano de chunks
El tamano optimo depende del tipo de contenido:
| Tipo de contenido | Tamano sugerido | Overlap |
|---|---|---|
| Documentacion tecnica | 800-1200 caracteres | 150-200 |
| FAQs y preguntas | 300-500 caracteres | 50-100 |
| Articulos largos | 1000-1500 caracteres | 200-300 |
| Codigo fuente | 500-800 caracteres | 100-150 |
La regla general: si tus respuestas son demasiado vagas, reduce el tamano del chunk. Si pierden contexto, aumentalo.
Metadata para filtrado
La columna metadata de tipo JSONB permite filtrar resultados antes de la busqueda vectorial:
-- Funcion con filtrado por metadata
create or replace function match_documents_filtered (
query_embedding vector(1536),
filter jsonb default '{}',
match_threshold float default 0.7,
match_count int default 5
)
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
1 - (documents.embedding <=> query_embedding) > match_threshold
and documents.metadata @> filter
order by documents.embedding <=> query_embedding
limit match_count;
$$;Ahora puedes filtrar por fuente, seccion, fecha u cualquier campo que hayas guardado en metadata:
// Buscar solo en la seccion de devoluciones
const { data } = await supabaseAdmin.rpc('match_documents_filtered', {
query_embedding: embeddingPregunta,
filter: { fuente: 'politicas', seccion: 'devoluciones' },
match_threshold: 0.7,
match_count: 5,
})Esto es util cuando tienes documentos de diferentes areas (soporte, legal, producto) y quieres que el usuario busque en un contexto especifico.
Busqueda hibrida: vectores + texto
La busqueda puramente vectorial a veces falla con terminos muy especificos (nombres propios, codigos de producto, numeros de serie). La solucion es combinar busqueda vectorial con busqueda de texto tradicional:
-- Agregar indice de texto completo
create index on documents using gin (to_tsvector('spanish', content));
-- Funcion de busqueda hibrida
create or replace function match_documents_hybrid (
query_embedding vector(1536),
query_text text,
match_threshold float default 0.5,
match_count int default 5,
peso_vector float default 0.7,
peso_texto float default 0.3
)
returns table (
id bigint,
content text,
metadata jsonb,
score float
)
language sql stable
as $$
select
d.id,
d.content,
d.metadata,
(
peso_vector * (1 - (d.embedding <=> query_embedding)) +
peso_texto * coalesce(
ts_rank(to_tsvector('spanish', d.content), plainto_tsquery('spanish', query_text)),
0
)
) as score
from documents d
where
(1 - (d.embedding <=> query_embedding)) > match_threshold
or to_tsvector('spanish', d.content) @@ plainto_tsquery('spanish', query_text)
order by score desc
limit match_count;
$$;Esta funcion combina dos senales: la similitud vectorial (semantica) y la coincidencia de texto (lexica). Los pesos peso_vector y peso_texto controlan cuanto influye cada una. Por defecto, 70% semantico y 30% texto.
Consideraciones para produccion
Seguridad de las API keys
Las claves de OpenAI y Supabase solo deben existir en el servidor. Nunca las expongas en el cliente. Los Route Handlers de Next.js (app/api/*/route.ts) corren en el servidor por definicion, asi que las variables process.env.OPENAI_API_KEY y process.env.SUPABASE_SERVICE_ROLE_KEY nunca llegan al navegador.
Si tu proyecto maneja datos sensibles de usuarios, considera agregar una capa de monitoreo de seguridad. Herramientas como datahogo escanean tu repositorio y detectan si alguna API key o credencial quedo expuesta accidentalmente en el codigo o en el historial de commits.
Cache de embeddings
Si los mismos usuarios hacen las mismas preguntas con frecuencia, puedes cachear los embeddings de las consultas:
// lib/cache-embeddings.ts
const cacheEmbeddings = new Map<string, number[]>()
export async function obtenerEmbedding(texto: string): Promise<number[]> {
// Normalizar el texto para mejorar cache hits
const clave = texto.toLowerCase().trim()
if (cacheEmbeddings.has(clave)) {
return cacheEmbeddings.get(clave)!
}
const embedding = await generarEmbedding(texto)
cacheEmbeddings.set(clave, embedding)
// Limpiar cache si crece mucho (cada embedding ocupa ~6KB)
if (cacheEmbeddings.size > 1000) {
const primeraKey = cacheEmbeddings.keys().next().value
if (primeraKey) cacheEmbeddings.delete(primeraKey)
}
return embedding
}Para produccion real, usa Redis o un cache distribuido en vez de un Map en memoria. El Map se reinicia con cada cold start de la funcion serverless.
Control de costos
Los costos de OpenAI para RAG son predecibles:
| Operacion | Modelo | Costo |
|---|---|---|
| Generar embedding | text-embedding-3-small | $0.02 / 1M tokens |
| Generar respuesta | gpt-4o-mini | $0.15 / 1M tokens input, $0.60 / 1M tokens output |
| Almacenamiento | Supabase (pgvector) | Gratis hasta 500MB |
Para una app con 1,000 consultas diarias, el costo mensual estimado es de $5-15 USD. Los embeddings son baratos; lo que mas cuesta es la generacion de respuestas.
Algunas estrategias para mantener los costos bajos:
- Limitar el numero de chunks que envias como contexto (5 es un buen default)
- Truncar chunks largos antes de enviarlos al LLM
- Cachear respuestas para preguntas frecuentes
- Usar gpt-4o-mini en vez de gpt-4o para la mayoria de casos
// Limitar el contexto enviado al LLM
const LIMITE_CONTEXTO = 4000 // caracteres maximo de contexto
function construirContexto(documentos: { content: string; similarity: number }[]): string {
let contexto = ''
for (const doc of documentos) {
if (contexto.length + doc.content.length > LIMITE_CONTEXTO) break
contexto += doc.content + '\n\n---\n\n'
}
return contexto
}Manejo de errores
En produccion, cada paso de la pipeline puede fallar. Maneja cada caso de forma explicita:
// app/api/chat/route.ts -- version robusta
import { openai } from '@ai-sdk/openai'
import { streamText } from 'ai'
import { supabaseAdmin } from '@/lib/supabase'
import { generarEmbedding } from '@/lib/embeddings'
export const maxDuration = 30
export async function POST(request: Request) {
try {
const { messages } = await request.json()
const ultimaPregunta = messages
.filter((m: { role: string }) => m.role === 'user')
.pop()?.content
if (!ultimaPregunta) {
return new Response(
JSON.stringify({ error: 'No se recibio pregunta' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
// Generar embedding con manejo de error
let embeddingPregunta: number[]
try {
embeddingPregunta = await generarEmbedding(ultimaPregunta)
} catch (error) {
console.error('Error al generar embedding:', error)
return new Response(
JSON.stringify({ error: 'Error al procesar la pregunta' }),
{ status: 502, headers: { 'Content-Type': 'application/json' } }
)
}
// Buscar documentos con manejo de error
const { data: documentos, error: errorBusqueda } = await supabaseAdmin.rpc(
'match_documents',
{
query_embedding: embeddingPregunta,
match_threshold: 0.7,
match_count: 5,
}
)
if (errorBusqueda) {
console.error('Error en busqueda vectorial:', errorBusqueda)
return new Response(
JSON.stringify({ error: 'Error al buscar en la base de datos' }),
{ status: 502, headers: { 'Content-Type': 'application/json' } }
)
}
// Si no hay documentos relevantes, avisar al usuario
const contexto = documentos?.length
? documentos
.map(
(doc: { content: string; similarity: number }) =>
`[Similitud: ${(doc.similarity * 100).toFixed(1)}%]\n${doc.content}`
)
.join('\n\n---\n\n')
: 'No se encontraron documentos relevantes para esta pregunta.'
const resultado = streamText({
model: openai('gpt-4o-mini'),
system: `Eres un asistente que responde preguntas basandose UNICAMENTE en el contexto proporcionado.
Reglas:
- Responde en espanol
- Si el contexto indica que no se encontraron documentos relevantes, responde que no tienes informacion suficiente para responder esa pregunta
- No inventes informacion que no este en el contexto
- Se conciso y directo
Contexto de documentos:
${contexto}`,
messages,
})
return resultado.toDataStreamResponse()
} catch (error) {
console.error('Error inesperado en /api/chat:', error)
return new Response(
JSON.stringify({ error: 'Error interno del servidor' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
)
}
}Re-indexacion de documentos
Cuando tus documentos cambian, necesitas re-indexar. Una estrategia simple es borrar los chunks de un documento especifico y volver a insertarlos:
// lib/reindexar.ts
import { supabaseAdmin } from './supabase'
import { generarEmbedding } from './embeddings'
import { dividirEnChunks } from './chunking'
export async function reindexarDocumento(
fuente: string,
contenido: string
) {
// 1. Eliminar chunks anteriores de esta fuente
const { error: errorDelete } = await supabaseAdmin
.from('documents')
.delete()
.eq('metadata->>fuente', fuente)
if (errorDelete) throw errorDelete
// 2. Crear nuevos chunks
const chunks = dividirEnChunks(contenido, {
metadata: { fuente },
})
// 3. Generar embeddings e insertar
for (const chunk of chunks) {
const embedding = await generarEmbedding(chunk.contenido)
const { error } = await supabaseAdmin.from('documents').insert({
content: chunk.contenido,
metadata: chunk.metadata,
embedding,
})
if (error) throw error
}
return { chunksIndexados: chunks.length }
}Si tu documentacion se actualiza con frecuencia, considera automatizar este proceso con un webhook o un cron job que detecte cambios y re-indexe solo los documentos modificados.
Preguntas frecuentes
Que es RAG y por que lo necesito?
RAG (Retrieval-Augmented Generation) es un patron que combina busqueda en tus documentos con generacion de texto por IA. En vez de que la IA invente respuestas, primero busca informacion relevante en tu base de datos y luego genera una respuesta basada en esos datos reales. Es la diferencia entre un chatbot que alucina y uno que cita fuentes.
Necesito una GPU o servidor especial para RAG?
No. Con servicios como Supabase (pgvector) para almacenar embeddings y OpenAI para generar respuestas, todo corre en la nube. Tu app Next.js puede estar en Vercel sin problemas. El procesamiento pesado (generar embeddings, ejecutar el LLM) lo hacen los proveedores externos.
Cuanto cuesta implementar RAG en produccion?
Los costos principales son las llamadas a la API de OpenAI. Los embeddings cuestan $0.02 por millon de tokens y las respuestas con gpt-4o-mini cuestan $0.15/$0.60 por millon de tokens (input/output). Para una app con pocas miles de consultas al mes, estamos hablando de menos de $10 USD. Supabase tiene tier gratuito con pgvector incluido.
Puedo usar RAG con documentos en espanol?
Si. Los modelos de OpenAI manejan espanol perfectamente, tanto para generar embeddings como para generar respuestas. La busqueda por similitud funciona por semantica, no por idioma, asi que un documento en espanol se indexa y se consulta sin problema alguno.
Cual es la diferencia entre RAG y fine-tuning?
Fine-tuning modifica los pesos del modelo con tus datos. Es costoso, lento y dificil de actualizar. RAG busca tus datos en tiempo real sin modificar el modelo: es barato, facil de actualizar y tus datos siempre estan frescos. Para la gran mayoria de casos empresariales -- soporte, documentacion, bases de conocimiento -- RAG es la mejor opcion.
Recursos adicionales
- Supabase pgvector Guide -- documentacion oficial de vectores en Supabase
- OpenAI Embeddings -- guia de embeddings de OpenAI
- Vercel AI SDK -- documentacion del AI SDK usado en este tutorial
- Guia de Supabase con Next.js -- configuracion completa de Supabase con Next.js
- Variables de entorno en Next.js -- manejo seguro de API keys y credenciales
Preguntas frecuentes
Que es RAG y por que lo necesito?
RAG (Retrieval-Augmented Generation) es un patron que combina busqueda en tus documentos con generacion de texto por IA. En vez de que la IA invente respuestas, primero busca informacion relevante en tu base de datos y luego genera una respuesta basada en esos datos reales.
Necesito una GPU o servidor especial para RAG?
No. Con servicios como Supabase (pgvector) para almacenar embeddings y OpenAI para generar respuestas, todo corre en la nube. Tu app Next.js puede estar en Vercel sin problemas.
Cuanto cuesta implementar RAG en produccion?
Los costos principales son las llamadas a la API de OpenAI para generar embeddings y respuestas. Para una app con pocas miles de consultas al mes, estamos hablando de menos de $10 USD. Supabase tiene tier gratuito con pgvector incluido.
Puedo usar RAG con documentos en espanol?
Si. Los modelos de OpenAI y otros proveedores manejan espanol perfectamente, tanto para generar embeddings como para generar respuestas. No necesitas traducir nada.
Cual es la diferencia entre RAG y fine-tuning?
Fine-tuning modifica el modelo con tus datos (costoso, lento, dificil de actualizar). RAG busca tus datos en tiempo real sin modificar el modelo (barato, facil de actualizar, datos siempre frescos). Para la mayoria de casos, RAG es mejor opcion.
Articulos relacionados
Zod Avanzado: Discriminated Unions, Transforms y Pipes
Patrones avanzados de Zod: discriminated unions, transforms, pipes, preprocess, y como validar datos complejos en TypeScript con schemas reutilizables.
tRPC + Next.js: APIs Type-Safe sin REST
Implementa tRPC en Next.js para APIs 100% type-safe. Sin schemas de API, sin fetch manual, sin types duplicados. End-to-end type safety con TypeScript.
Webhooks en Next.js: Recibe y Procesa Eventos
Implementa webhooks en Next.js para recibir eventos de Stripe, GitHub, Clerk y otros servicios. Verificacion de firmas, tipado y manejo de errores.