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 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)

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 tamano 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 mas 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 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

ComponenteTecnologia
FrameworkNext.js 15 (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 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:

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

Estructura de archivos

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:

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

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

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 funcion 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 mas importantes de RAG. Un chunk demasiado grande puede diluir la informacion relevante. Uno demasiado pequeno 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 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:

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

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

  1. El usuario hace una pregunta
  2. Generamos el embedding de esa pregunta
  3. Buscamos los 5 chunks mas similares en Supabase usando la funcion 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 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.

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

Terminal
$

Primero, indexa algun 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 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()
Terminal
$

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 contenidoTamano sugeridoOverlap
Documentacion tecnica800-1200 caracteres150-200
FAQs y preguntas300-500 caracteres50-100
Articulos largos1000-1500 caracteres200-300
Codigo fuente500-800 caracteres100-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:

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

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

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

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

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

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

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

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

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.