Tutorial: RAG empresarial con pgvector + Postgres + Vercel AI SDK
Tutorial técnico para construir un sistema RAG empresarial con la base de datos que tu empresa ya tiene: Postgres + pgvector + embeddings + búsqueda híbrida + integración con agente Claude.
pgvector convierte tu Postgres operacional existente en una base de datos vectorial capaz de potenciar RAG empresarial sin sumar un servicio nuevo. Este tutorial entrega el código completo para implementar RAG productivo en 90 minutos: setup de pgvector, generación de embeddings con OpenAI/Anthropic, indexación HNSW para latencias en milisegundos, búsqueda híbrida (vector + keyword) y integración con un agente Vercel AI SDK + Claude. Asume conocimiento básico de SQL y TypeScript. Para PyME chilena con menos de 50M de vectores, este stack es el default razonable.
Por qué pgvector y no Pinecone/Qdrant/Weaviate
La conversación sobre vector DBs en 2026 se simplifica: para PyME y empresa mediana chilena con menos de 50 millones de vectores, pgvector sobre el Postgres que ya tienes es suficiente, escalable y mucho más barato. Tres razones operativas:
- Cero servicios nuevos: corre dentro del Postgres existente. Una operación, una base, una factura.
- Datos en tu jurisdicción: relevante para Ley 21.719 vigente el 1-dic-2026.
- HNSW configurado correctamente entrega latencias de 20-80 ms para hasta 10M vectores, suficiente para casi todo caso PyME.
Para detalle de comparativa con Pinecone/Qdrant/Weaviate ver nuestro satélite dedicado. Aquí vamos al código.
Paso 1: Setup pgvector
pgvector se instala como extensión de PostgreSQL. Versiones 0.7+ (recomendamos 0.8+) soportan índices HNSW e IVFFlat con buen rendimiento.
-- En tu Postgres existente (>= v14):
CREATE EXTENSION IF NOT EXISTS vector;
-- Verificar:
SELECT * FROM pg_extension WHERE extname = 'vector';Crear la tabla de documentos con columna vector:
CREATE TABLE documentos (
id BIGSERIAL PRIMARY KEY,
origen TEXT NOT NULL, -- ej. 'manual-soporte' o 'producto-A'
contenido TEXT NOT NULL, -- chunk del documento
metadata JSONB, -- categoría, tags, versión, etc.
embedding vector(1536), -- dimensión del modelo embedding usado
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Índice HNSW para búsqueda por similitud coseno:
CREATE INDEX idx_documentos_embedding
ON documentos USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Índice GIN para búsqueda keyword (full text search) en el contenido:
CREATE INDEX idx_documentos_contenido_fts
ON documentos USING gin (to_tsvector('spanish', contenido));
-- Índice JSONB para filtros por metadata:
CREATE INDEX idx_documentos_metadata
ON documentos USING gin (metadata);Dimensión 1536 es para text-embedding-3-small de OpenAI. Si usas otro modelo, ajustar (text-embedding-3-large = 3072, Cohere Embed v3 = 1024, etc.).
Paso 2: Indexar documentos con embeddings
Necesitamos chunkear el documento original, generar el embedding de cada chunk y persistir. Implementación con Vercel AI SDK:
import { openai } from '@ai-sdk/openai';
import { embedMany } from 'ai';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function indexarDocumento(
origen: string,
textoCompleto: string,
metadata: Record<string, any> = {}
) {
const chunks = chunkearTexto(textoCompleto, 1000, 200); // size=1000, overlap=200
const { embeddings } = await embedMany({
model: openai.embedding('text-embedding-3-small'),
values: chunks,
});
// Inserta en batch
const rows = chunks.map((c, i) => ({
origen,
contenido: c,
metadata,
embedding: `[${embeddings[i].join(',')}]`,
}));
await pool.query(
`INSERT INTO documentos (origen, contenido, metadata, embedding)
SELECT origen, contenido, metadata, embedding::vector
FROM jsonb_to_recordset($1) AS x(origen text, contenido text, metadata jsonb, embedding text)`,
[JSON.stringify(rows)]
);
}
function chunkearTexto(texto: string, tamano = 1000, overlap = 200): string[] {
const chunks: string[] = [];
for (let i = 0; i < texto.length; i += tamano - overlap) {
chunks.push(texto.slice(i, i + tamano));
}
return chunks;
}El parámetro overlap=200 garantiza que información en los bordes de chunks no se pierda. Para casos especiales (código, listas estructuradas, normativa) conviene chunkear semánticamente con librerías como llamaindex o semantic-chunking.
Paso 3: Búsqueda por similitud (RAG básico)
Para una consulta del usuario, generamos su embedding y buscamos los chunks más similares:
import { embed } from 'ai';
async function buscarSimilares(
query: string,
limite = 6,
filtro?: Record<string, any>
): Promise<{ contenido: string; origen: string; score: number }[]> {
const { embedding } = await embed({
model: openai.embedding('text-embedding-3-small'),
value: query,
});
const filtroSQL = filtro ? `AND metadata @> $3::jsonb` : '';
const params = filtro
? [`[${embedding.join(',')}]`, limite, JSON.stringify(filtro)]
: [`[${embedding.join(',')}]`, limite];
const result = await pool.query(
`SELECT contenido, origen, 1 - (embedding <=> $1::vector) AS score
FROM documentos
WHERE 1=1 ${filtroSQL}
ORDER BY embedding <=> $1::vector
LIMIT $2`,
params
);
return result.rows;
}El operador `<=>` es distancia coseno (0 = idéntico, 2 = opuesto). Convertimos a score (1 - distancia) para que sea más interpretable: 1.0 = perfecto match.
Paso 4: Búsqueda híbrida (vector + keyword)
Búsqueda solo vectorial pierde matches donde el usuario busca términos exactos (nombres propios, números, códigos). La búsqueda híbrida combina vector + full-text con scoring ponderado:
async function buscarHibrido(query: string, limite = 6) {
const { embedding } = await embed({
model: openai.embedding('text-embedding-3-small'),
value: query,
});
// Combinamos vector + keyword con pesos 0.7 / 0.3
const result = await pool.query(
`WITH
vector_search AS (
SELECT id, contenido, origen,
1 - (embedding <=> $1::vector) AS score_vector
FROM documentos
ORDER BY embedding <=> $1::vector
LIMIT 20
),
keyword_search AS (
SELECT id, contenido, origen,
ts_rank(to_tsvector('spanish', contenido),
plainto_tsquery('spanish', $2)) AS score_keyword
FROM documentos
WHERE to_tsvector('spanish', contenido) @@ plainto_tsquery('spanish', $2)
ORDER BY score_keyword DESC
LIMIT 20
)
SELECT
COALESCE(v.id, k.id) AS id,
COALESCE(v.contenido, k.contenido) AS contenido,
COALESCE(v.origen, k.origen) AS origen,
COALESCE(v.score_vector, 0) * 0.7
+ COALESCE(k.score_keyword, 0) * 0.3 AS score
FROM vector_search v
FULL OUTER JOIN keyword_search k ON v.id = k.id
ORDER BY score DESC
LIMIT $3`,
[`[${embedding.join(',')}]`, query, limite]
);
return result.rows;
}Los pesos 0.7/0.3 son punto de partida. Ajustar según tu corpus: si tu base es muy técnica con términos exactos importantes, aumentar peso keyword (0.5/0.5 o 0.4/0.6).
Paso 5: Integración con agente Claude
El RAG es un retrieval. La generación la hace el LLM con los chunks recuperados como contexto. Vercel AI SDK con tool calling:
import { anthropic } from '@ai-sdk/anthropic';
import { generateText, tool } from 'ai';
import { z } from 'zod';
async function responderConRAG(pregunta: string) {
const { text } = await generateText({
model: anthropic('claude-opus-4-7'),
system: `Eres un asistente que responde basado en la documentación
proporcionada. Si la respuesta no está en la documentación, dilo
explícitamente. NUNCA inventes. Cita el origen de cada afirmación.`,
prompt: pregunta,
maxSteps: 3,
tools: {
buscarDocumentacion: tool({
description: 'Busca en la base de conocimiento de la empresa.',
parameters: z.object({
query: z.string().describe('Consulta para buscar documentos relevantes'),
limite: z.number().min(1).max(10).default(6),
}),
execute: async ({ query, limite }) => {
const resultados = await buscarHibrido(query, limite);
return {
resultados: resultados.map((r, i) => ({
indice: i + 1,
origen: r.origen,
extracto: r.contenido,
relevancia: r.score,
})),
};
},
}),
},
});
return text;
}El agente decide cuándo y cómo buscar. Para casos donde quieres forzar la búsqueda siempre, llamar buscarHibrido manualmente y pasar los resultados como contexto del system o prompt.
Optimización: índice HNSW y parámetros
Para corpus de 100k+ vectores, los parámetros HNSW afectan latencia y precisión. Recomendaciones:
- `m = 16` (default) para corpus medianos. Aumentar a `m = 32` para mayor precisión a costa de uso de memoria.
- `ef_construction = 64` (default) suficiente para la mayoría. `ef_construction = 100` cuando precisión es crítica.
- En query time: `SET hnsw.ef_search = 100;` antes de la búsqueda para mayor precisión (default es 40).
- Si el corpus crece sobre 5M vectores, considerar quantization (pgvector 0.8+ soporta `halfvec`).
- Postgres tuning: `shared_buffers`, `work_mem`, `maintenance_work_mem` impactan rendimiento. Default de Vercel Postgres suele ser suficiente para PyME.
Mantenimiento y operación
Tres cosas que vemos olvidadas en producción y duelen después:
- Versionado de embeddings: cuando cambies el modelo embedding, todos los vectores existentes quedan inválidos. Diseña con columna `embedding_model` y plan de re-indexación.
- Eval suite con casos golden: 30-50 preguntas reales con respuestas esperadas. Correr antes de cada cambio significativo (cambio de modelo, parámetros HNSW, chunking strategy).
- Observabilidad: loguear queries, scores top-k, tiempo de búsqueda, tokens consumidos en embedding. Sin métricas no hay mejora continua.
RAG con pgvector en 90 minutos
Cinco pasos desde Postgres limpio hasta RAG productivo con agente Claude.
- 15 min
Setup pgvector
Habilitar extensión + crear tablas e índices.
- CREATE EXTENSION vector
- Tabla documentos con vector(1536)
- Índices HNSW + GIN
- 20 min
Indexar documentos
Chunkear, generar embeddings y persistir.
- Función chunkearTexto con overlap
- embedMany con OpenAI
- Insert batch en Postgres
- 15 min
Búsqueda similar
Endpoint o función para retrieval.
- embed query
- Query con operador <=>
- 20 min
Búsqueda híbrida
Combinar vector + keyword con scoring ponderado.
- CTE con vector y keyword search
- Ponderación 0.7/0.3 ajustable
- 20 min
Integración con agente
Tool tipada en Vercel AI SDK para que el agente busque.
- Tool buscarDocumentacion con Zod
- System con regla anti-alucinación + cita
Preguntas frecuentes
Da el siguiente paso
Hablemos
Cuéntanos sobre tu negocio. Respondemos en 24 horas.
Otros canales
Escríbenos por WhatsApp o agenda una llamada de 30 minutos.