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 más práctico 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 información relevante en tu base de datos y luego genera una respuesta fundamentada en datos reales.
Este tutorial cubre la implementación 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 código funcional que puedes copiar a tu proyecto.
cómo funciona RAG
RAG -- Retrieval-Augmented Generation, o Generación Aumentada por Recuperación -- tiene un flujo claro de dos fases: indexación y consulta.
Fase 1: Indexación (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 tamaño 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 más 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 información real, no en su conocimiento general
La clave de RAG es que el LLM no necesita "saber" sobre tus documentos. Solo necesita recibir la información relevante en el momento de generar la respuesta.
Embeddings en 30 segundos
Un embedding es una representación 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 16 (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 generación 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 indexación 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 guía de variables de entorno.
Estructura del proyecto
mi-buscador-rag/
app/
api/
chat/
route.ts (Route Handler -- búsqueda + generación)
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 índice para búsquedas rapidas por similitud
create index on documents using ivfflat (embedding vector_cosine_ops)
with (lists = 100);La columna embedding almacena vectores de 1536 dimensiones, qué es el tamaño de los embeddings generados por text-embedding-3-small de OpenAI. La columna metadata te permite guardar información adicional como el nombre del archivo fuente, la sección, la fecha, etc.
Ahora crea la función SQL que hará la búsqueda por similitud:
-- Función 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 función recibe un embedding de consulta y devuelve los documentos más 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 parámetro 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 según 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 guía de Supabase con Next.js cubre la configuración 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 función 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 más importantes de RAG. Un chunk demasiado grande puede diluir la información relevante. Uno demasiado pequeño 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 oración, parrafo)
let fin = inicio + tamanoChunk
if (fin < texto.length) {
// Intentar cortar en un salto de línea 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.
tamaño del chunk
1000 caracteres con 200 de overlap es un buen punto de partida. En la sección de optimización veremos como ajustar estos valores según tu caso de uso.
API de indexación
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 aquí...",
"metadata": { "fuente": "documentación-interna", "sección": "guía-usuario" }
}'Protege el endpoint de indexación
En producción, este endpoint debe tener autenticación. Cualquier persona que pueda hacer POST puede inyectar documentos en tu base de datos. Agrega un header de autorización o middleware que verifique un token.
API de búsqueda con streaming
Este es el corazon de la aplicación. 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 última 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 búsqueda:', error)
return new Response('Error en la búsqueda', { 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 español
- Si el contexto no contiene información suficiente para responder, dilo claramente
- No inventes información 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 más similares en Supabase usando la función
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 crítico. Le decimos al modelo que solo use la información del contexto. Sin esta instrucción, 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 automáticamente el estado de la conversación, el streaming y el envio de mensajes. Si quieres profundizar en lo que el AI SDK puede hacer, la guía 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 automático 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 documentación. 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 búsqueda */}
<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 conversación
- 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 algún 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 días
después de la compra. El producto debe estar en su empaque original
y sin señales de uso.
Para iniciar una devolución, contacta a soporte@ejemplo.com con tu
número de pedido. El reembolso se procesa en 5-7 días habiles
después 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 devolución. Los productos personalizados tampoco
aceptan devolución.
`
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', sección: 'devoluciones' },
}),
})
const resultado = await respuesta.json()
console.log(resultado)
}
indexar()$ después, abre http://localhost:3000 y pregunta algo como "cuál es la politica de devolución para productos digitales?". El buscador debería encontrar los chunks relevantes y darte una respuesta basada en tu documento.
Optimización
La implementación base funciona, pero hay varios ajustes que hacen la diferencia entre un prototipo y algo que sirve en producción.
tamaño de chunks
El tamaño optimo depende del tipo de contenido:
| Tipo de contenido | tamaño sugerido | Overlap |
|---|---|---|
| Documentación técnica | 800-1200 caracteres | 150-200 |
| FAQs y preguntas | 300-500 caracteres | 50-100 |
| artículos largos | 1000-1500 caracteres | 200-300 |
| código fuente | 500-800 caracteres | 100-150 |
La regla general: si tus respuestas son demasiado vagas, reduce el tamaño del chunk. Si pierden contexto, aumentalo.
Metadata para filtrado
La columna metadata de tipo JSONB permite filtrar resultados antes de la búsqueda vectorial:
-- Función 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, sección, fecha u cualquier campo que hayas guardado en metadata:
// Buscar solo en la sección de devoluciones
const { data } = await supabaseAdmin.rpc('match_documents_filtered', {
query_embedding: embeddingPregunta,
filter: { fuente: 'politicas', sección: 'devoluciones' },
match_threshold: 0.7,
match_count: 5,
})Esto es útil cuando tienes documentos de diferentes areas (soporte, legal, producto) y quieres que el usuario busque en un contexto específico.
búsqueda hibrida: vectores + texto
La búsqueda puramente vectorial a veces falla con términos muy especificos (nombres propios, codigos de producto, números de serie). La solución es combinar búsqueda vectorial con búsqueda de texto tradicional:
-- Agregar índice de texto completo
create index on documents using gin (to_tsvector('spanish', content));
-- Función de búsqueda 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 función combina dos señales: la similitud vectorial (semántica) y la coincidencia de texto (lexica). Los pesos peso_vector y peso_texto controlan cuánto influye cada una. Por defecto, 70% semántico y 30% texto.
Consideraciones para producción
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 definición, así 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 código 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 producción real, usa Redis o un cache distribuido en vez de un Map en memoria. El Map se reinicia con cada cold start de la función serverless.
Control de costos
Los costos de OpenAI para RAG son predecibles:
| Operación | 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 más cuesta es la generación de respuestas.
Algunas estrategias para mantener los costos bajos:
- Limitar el número 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 máximo 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 producción, cada paso de la pipeline puede fallar. Maneja cada caso de forma explicita:
// app/api/chat/route.ts -- versión 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 búsqueda 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 español
- Si el contexto indica que no se encontraron documentos relevantes, responde que no tienes información suficiente para responder esa pregunta
- No inventes información 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-indexación de documentos
Cuando tus documentos cambian, necesitas re-indexar. Una estrategia simple es borrar los chunks de un documento específico 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 documentación 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
¿Qué es RAG y por qué lo necesito?
RAG (Retrieval-Augmented Generation) es un patron que combina búsqueda en tus documentos con generación de texto por IA. En vez de que la IA invente respuestas, primero busca información 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.
¿Cuánto cuesta implementar RAG en producción?
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 español?
Si. Los modelos de OpenAI manejan español perfectamente, tanto para generar embeddings como para generar respuestas. La búsqueda por similitud funciona por semántica, no por idioma, así que un documento en español se indexa y se consulta sin problema alguno.
¿Cuál es la diferencia entre RAG y fine-tuning?
Fine-tuning modifica los pesos del modelo con tus datos. Es costoso, lento y difícil de actualizar. RAG busca tus datos en tiempo real sin modificar el modelo: es barato, fácil de actualizar y tus datos siempre estan frescos. Para la gran mayoria de casos empresariales -- soporte, documentación, bases de conocimiento -- RAG es la mejor opción.
Recursos adicionales
- Supabase pgvector Guide -- documentación oficial de vectores en Supabase
- OpenAI Embeddings -- guía de embeddings de OpenAI
- Vercel AI SDK -- documentación del AI SDK usado en este tutorial
- guía de Supabase con Next.js -- configuración completa de Supabase con Next.js
- Variables de entorno en Next.js -- manejo seguro de API keys y credenciales
Preguntas frecuentes
¿Qué es RAG y por qué lo necesito?
RAG (Retrieval-Augmented Generation) es un patron que combina búsqueda en tus documentos con generación de texto por IA. En vez de que la IA invente respuestas, primero busca información 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.
¿Cuánto cuesta implementar RAG en producción?
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 español?
Si. Los modelos de OpenAI y otros proveedores manejan español perfectamente, tanto para generar embeddings como para generar respuestas. No necesitas traducir nada.
¿Cuál es la diferencia entre RAG y fine-tuning?
Fine-tuning modifica el modelo con tus datos (costoso, lento, difícil de actualizar). RAG busca tus datos en tiempo real sin modificar el modelo (barato, fácil de actualizar, datos siempre frescos). Para la mayoria de casos, RAG es mejor opción.
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. Verificación de firmas, tipado y manejo de errores.