tutoriales·18 min de lectura

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)

plaintext
Documento -> Dividir en chunks -> Generar embedding por chunk -> Guardar en vector DB
  1. Tomas tus documentos (PDFs, Markdown, texto, lo que sea)
  2. Los divides en fragmentos (chunks) de tamaño manejable
  3. Generas un embedding (vector numerico) por cada chunk usando un modelo como text-embedding-3-small de OpenAI
  4. Guardas cada chunk con su embedding en una base de datos con soporte vectorial

Fase 2: Consulta (cada vez que un usuario pregunta)

plaintext
Pregunta del usuario -> Generar embedding de la pregunta -> Buscar chunks similares -> Enviar contexto + pregunta al LLM -> Respuesta fundamentada
  1. El usuario escribe una pregunta
  2. Generas un embedding de esa pregunta
  3. Buscas en la base de datos los chunks cuyo embedding sea más similar al de la pregunta (similarity search)
  4. Tomas esos chunks como contexto y se los envias al LLM junto con la pregunta
  5. 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

ComponenteTecnologia
FrameworkNext.js 16 (App Router)
Base de datos vectorialSupabase (PostgreSQL + pgvector)
EmbeddingsOpenAI text-embedding-3-small
LLMOpenAI gpt-4o-mini
AI SDKVercel AI SDK (ai)
LenguajeTypeScript

Setup del proyecto

Crea un proyecto nuevo de Next.js o usa uno existente:

Terminal
$

Instala las dependencias necesarias:

Terminal
$
  • 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:

bash
# 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

Estructura de archivos

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:

sql
-- 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:

sql
-- 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.

typescript
// 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:

typescript
// 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.

typescript
// 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:

typescript
// 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:

bash
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:

typescript
// 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:

  1. El usuario hace una pregunta
  2. Generamos el embedding de esa pregunta
  3. Buscamos los 5 chunks más similares en Supabase usando la función match_documents
  4. Inyectamos esos chunks como contexto en el system prompt
  5. 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.

tsx
// 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:

Terminal
$

Primero, indexa algún documento. Puedes usar curl o crear un script:

typescript
// 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()
Terminal
$

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 contenidotamaño sugeridoOverlap
Documentación técnica800-1200 caracteres150-200
FAQs y preguntas300-500 caracteres50-100
artículos largos1000-1500 caracteres200-300
código fuente500-800 caracteres100-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:

sql
-- 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:

typescript
// 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:

sql
-- 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:

typescript
// 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ónModeloCosto
Generar embeddingtext-embedding-3-small$0.02 / 1M tokens
Generar respuestagpt-4o-mini$0.15 / 1M tokens input, $0.60 / 1M tokens output
AlmacenamientoSupabase (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
typescript
// 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:

typescript
// 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:

typescript
// 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

#rag#nextjs#typescript#ia#supabase#openai

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.