RAG es búsqueda por vecino más cercano sobre texto que convertiste en embeddings, más un modelo que lee los resultados. Esta guía monta todo sobre Postgres pelado (schema, chunking, un índice HNSW, una función de búsqueda detrás de RLS y la llamada de retrieval a Claude) y te dice sin rodeos dónde pgvector se queda corto.

En resumen
- No necesitas una base de datos vectorial dedicada para empezar; Postgres más la extensión pgvector te aguanta hasta cientos de miles de filas.
- La dimensión del embedding en tu tabla tiene que coincidir exacto con la de tu modelo, o cada insert va a fallar. Fíjala una vez y no la dejes a la suerte.
- El chunking es la palanca que decide la calidad del retrieval. Unos cientos de tokens con overlap le ganan a cualquier índice ingenioso sobre documentos crudos.
- Agrega un índice HNSW antes de que la tabla crezca, y siempre pre-filtra con metadata para que la búsqueda vectorial trabaje sobre el conjunto más pequeño posible.
- El retrieval le da contexto a Claude, no respuestas. Guarda los chunks, los IDs de origen y una instrucción para que diga 'no sé' cuando nada haga match.
De la generación aumentada por retrieval (RAG) se habla como si hiciera falta una base de datos especial y un montón de infraestructura. No es así. RAG son dos partes aburridas: una búsqueda por vecino más cercano sobre texto que convertiste en vectores, y un modelo de lenguaje que lee los mejores resultados y escribe una respuesta apoyada en ellos. Esta guía monta el pipeline completo sobre Postgres pelado con la extensión pgvector, la misma configuración que uso en un solo proyecto de Supabase, y recorre el schema, el chunking, el índice, la función de búsqueda detrás de row-level security y la llamada que le entrega los chunks a Claude. Es un tutorial con SQL real que puedes copiar y pegar, y te explica qué decisión de verdad mueve la calidad del retrieval y dónde este enfoque se queda corto.
El encuadre honesto desde el arranque: el modelo casi nunca es tu problema. Un RAG malo casi siempre es retrieval malo, y el retrieval malo casi siempre es chunking malo o un filtro que falta. Ahí es donde se va la mayor parte de esta guía.
01 · Requisitos y el modelo mental
Antes de tocar SQL, deja claro el modelo, porque explica cada decisión que viene después. Un embedding es una lista de números de largo fijo que ubica un pedazo de texto en algún punto de un espacio de muchas dimensiones, de manera que los textos sobre cosas parecidas caen cerca. "Buscar" entonces es solo esto: conviertes la pregunta en embedding, encuentras los vectores guardados más cercanos a ella y devuelves el texto del que salieron esos vectores. pgvector agrega el tipo de columna vectorial y los operadores de distancia; todo lo demás es Postgres normal y corriente.
Necesitas tener listas unas cuantas cosas primero:
- Un proyecto de Supabase (o cualquier Postgres 14+ con la extensión vector disponible). El tier gratis sobra para aprender.
- Un modelo de embeddings y su dimensión de salida exacta. Defínela ya, por ejemplo un modelo de 1536 dimensiones, o uno de 1024. El número va en tu schema y no puede cambiar nunca.
- Una API key para ese modelo de embeddings y otra para Claude, guardadas en variables de entorno, nunca en la base de datos ni en el bundle del cliente.
- Un corpus de origen: docs, tickets de soporte, una base de conocimiento, lo que sea que quieras que el modelo responda con fundamento.
Importante
Decide tu modelo de embeddings y su dimensión antes de crear la tabla. La dimensión queda grabada en el tipo de la columna como vector(N). Si después cambias a un modelo con una N distinta, no estás editando una columna, estás volviendo a generar los embeddings de todo el corpus y migrando la tabla. Elige una vez y déjalo anotado.
Una segunda decisión que ahora no cuesta nada y después cuesta mucho: guarda la identidad del origen junto a cada chunk. Una fila debería saber de qué documento vino, en qué parte de ese documento y cualquier metadata por la que vayas a filtrar (tenant, idioma, visibilidad). La vas a usar todo el tiempo.
02 · Activa pgvector y diseña el schema
Activa la extensión y crea la tabla. La dimensión de abajo asume un modelo de 1536 dimensiones, cambia cada 1536 para que coincida con la del tuyo.
create extension if not exists vector;
create table documents (
id bigint generated always as identity primary key,
source_id text not null, -- de qué documento vino
chunk_index int not null, -- posición dentro de ese documento
content text not null, -- el texto plano del chunk
metadata jsonb not null default '{}'::jsonb,
embedding vector(1536) not null,
created_at timestamptz not null default now()
);
create index on documents (source_id);
Varias cosas se ganan su lugar aquí. El source_id más el chunk_index te dejan reconstruir o borrar los chunks de un documento como una unidad, algo que importa cuando el contenido cambia y necesitas volver a generar los embeddings de ese documento nada más. La metadata en jsonb es donde viven los IDs de tenant, el idioma y los flags de acceso, para que puedas filtrar antes de que corra la búsqueda vectorial, el pre-filtrado es la palanca de rendimiento más grande en los sistemas reales. Deja el content crudo en la fila; se lo vas a pasar directo al modelo, e ir a buscarlo a un object storage en cada match es latencia regalada.
Nota
Una sola tabla está bien para empezar, pero piensa en términos de un índice lógico por cada caso de uso. Si tienes contenido muy distinto (texto legal vs. logs de chat), tablas separadas o un campo de metadata que los distinga mantienen limpio el espacio de vecinos, mezclar contenido sin relación hace que "más cercano" signifique menos.
03 · Chunking, embeddings e inserts
Aquí es donde se gana o se pierde la calidad del retrieval. Convertir un PDF entero de 40 páginas en un solo vector te da un promedio borroso que hace match flojo con todo y bueno con nada. Parte los orígenes en chunks de unos cientos de tokens, con un overlap pequeño para que una oración cortada por un borde aparezca igual completa en al menos un chunk.
Valores por defecto prácticos que funcionan bien:
- Tamaño del chunk: más o menos 300–500 tokens. Lo bastante pequeño para ser específico, lo bastante grande para cargar contexto.
- Overlap: cerca del 10–15 por ciento del chunk. Suficiente para recomponer los cortes en los bordes, no tanto como para inflar el storage.
- Parte primero por estructura: títulos, párrafos, ítems de lista, y solo después recurres a un conteo de tokens. Cortar a mitad de una tabla o de un bloque de código es la receta para terminar con chunks basura.
Genera el embedding de cada chunk con el modelo que elegiste e inserta content, metadata y vector juntos. Un loop mínimo en TypeScript, con la llamada de embeddings abstraída como embed():
import { createClient } from "@supabase/supabase-js"
const db = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
async function indexDocument(sourceId: string, chunks: string[], meta: object) {
const rows = await Promise.all(
chunks.map(async (content, i) => ({
source_id: sourceId,
chunk_index: i,
content,
metadata: meta,
embedding: await embed(content), // el mismo modelo, siempre
})),
)
const { error } = await db.from("documents").insert(rows)
if (error) throw error
}
Atención
Genera el embedding de las preguntas con exactamente el mismo modelo que usaste para los chunks. Mezclar modelos, o incluso la misma familia en otra dimensión, pone las preguntas y los documentos en sistemas de coordenadas distintos, y "vecino más cercano" deja de significar nada. Si vuelves a generar los embeddings del corpus con un modelo nuevo, tienes que rehacer también el camino de la query.
04 · Indexa para ganar velocidad y escribe la función de búsqueda
Sin índice, pgvector hace un scan exacto: compara la query contra cada fila. Para unos miles de filas eso está perfectamente bien y te da recall perfecto. Pasando las decenas de miles se pone lento, y ahí cambias a un índice aproximado. HNSW es el default correcto, cambia una pizca de recall por una gran mejora de velocidad y maneja las actualizaciones sin problema.
Elige la operator class del índice para que coincida con la distancia con la que consultas. Para distancia coseno (lo habitual en embeddings de texto) usa vector_cosine_ops y el operador <=> en todos lados. El índice y la query tienen que coincidir.
create index on documents
using hnsw (embedding vector_cosine_ops);
create or replace function match_documents(
query_embedding vector(1536),
match_count int,
filter jsonb default '{}'
)
returns table (id bigint, source_id text, content text, similarity float)
language sql stable as $fn$
select
d.id,
d.source_id,
d.content,
1 - (d.embedding <=> query_embedding) as similarity
from documents d
where d.metadata @> filter -- pre-filtra por metadata primero
order by d.embedding <=> query_embedding
limit match_count
$fn$;
Dos puntos de diseño. El argumento filter aplica un predicado sobre la metadata (@> es contención de jsonb) antes de ordenar por distancia, así un filtro por tenant o idioma reduce el conjunto de candidatos que la búsqueda vectorial tiene que considerar. Y la función devuelve un score de similarity, la distancia convertida en un número de 0 a 1, para que quien la llama pueda descartar los matches flojos en vez de tomar siempre el top k sin importar la calidad.
Si esta tabla queda expuesta a través de la API de Supabase, ponla detrás de row-level security y deja que la función corra con los permisos de quien la llama, o pásale el filtro de tenant de forma explícita. Nunca pongas en producción una función de búsqueda que ignore quién está preguntando.
05 · Ejemplo completo: recupera y luego entrégale el contexto a Claude
Ahora conéctalo de punta a punta. En el momento de la query generas el embedding de la pregunta del usuario con el mismo modelo, llamas a match_documents, te quedas con los matches por encima de un piso de similitud y se los pasas a Claude como contexto, con la instrucción de responder únicamente a partir de ese contexto.
async function answer(question: string, tenantId: string) {
const queryEmbedding = await embed(question) // el mismo modelo
const { data: matches } = await db.rpc("match_documents", {
query_embedding: queryEmbedding,
match_count: 6,
filter: { tenant: tenantId },
})
const useful = (matches ?? []).filter((m) => m.similarity > 0.4)
if (useful.length === 0) return "No tengo nada sobre eso."
const context = useful
.map((m) => "[" + m.source_id + "] " + m.content)
.join("\n\n")
return await askClaude({
system:
"Responde usando SOLO el contexto de abajo. Si el contexto no tiene " +
"la respuesta, di que no sabes. Cita el [source_id] que usaste.",
user: "Contexto:\n" + context + "\n\nPregunta: " + question,
})
}
El retrieval le da material a Claude, no un veredicto. El piso de similitud (aquí 0.4, ajústalo contra tus propios datos) es lo que deja que el sistema diga "no sé" en lugar de resumir con toda confianza seis chunks que no vienen al caso. Etiquetar cada chunk con su source_id dentro del contexto le permite al modelo citar, lo que vuelve auditables las respuestas y rastreables las equivocadas: ves exactamente cuál chunk lo despistó.
Errores comunes
- La dimensión no cuadra en el insert. La columna es vector(1536) y tu embedding mide 1024; el insert da error. Fija la dimensión y verifícala en el código antes de insertar.
- Sin filtro de metadata. Hacer búsqueda vectorial sobre toda la tabla cuando solo te importa un tenant es lento y deja escapar vecinos entre tenants. Pasa siempre el filtro.
- Los operadores del índice y de la query no concuerdan. Un índice HNSW construido con vector_cosine_ops pero consultado con el operador L2 cae sin avisar a un full scan. Mantén ambos en <=>.
- Chunks demasiado grandes. Un vector por documento promedia todo hasta dejarlo hecho papilla. Si el recall está malo, reduce el tamaño de los chunks antes de tocar el índice.
- Tomar el top k a ciegas. Sin un piso de similitud, el modelo siempre recibe seis chunks aunque ninguno venga al caso, y va a alucinar obedientemente alrededor de ellos.
06 · Cuándo pgvector se queda corto
Sé honesto sobre el techo para no sobre-construir antes de tiempo ni quedarte demasiado. pgvector en un solo proyecto de Supabase maneja con comodidad cientos de miles de chunks con un índice HNSW, sobre todo con pre-filtrado por metadata. Empiezas a sentir los límites cuando: un solo índice ya no cabe en memoria y las queries se van a disco; necesitas búsqueda híbrida pesada (vectores densos más full-text BM25) con reranking; o estás haciendo sharding multi-tenant agresivo a una escala en la que el sharding y la cuantización de un motor vectorial dedicado empiezan a valer la pena.
Aun así, la migración es mecánica porque conservaste source_id, chunk_index y metadata en cada fila: re-indexar en otro almacén es cuestión de leer y escribir, no de rediseñar. Empieza en Postgres. Vas a saber cuándo se te quedó pequeño, y la mayoría de los proyectos nunca llega ahí.
RAG no es una feature mágica; es plomería de búsqueda con un modelo al final, y la calidad vive en la plomería. Deja la dimensión fijada, los chunks bien dimensionados, el índice y el filtro en su lugar y el camino del "no sé" funcionando, y tendrás algo que da respuestas con fundamento y citables sobre infraestructura que ya tienes corriendo. Ve por una base de datos vectorial dedicada cuando Postgres te diga que llegó la hora, no antes.
Puntos clave
- RAG es búsqueda por vecino más cercano más un modelo que lee los resultados; la calidad vive en el retrieval, no en el modelo.
- Fija tu modelo de embeddings y su dimensión antes de crear la tabla. Cambiarlo después significa volver a generar los embeddings de todo el corpus.
- El chunking y el pre-filtrado por metadata mueven la calidad y la velocidad del retrieval más que cualquier truco de indexado. Dimensiona los chunks en 300–500 tokens con overlap.
- Agrega un índice HNSW con vector_cosine_ops, consulta con <=> y mantén un piso de similitud para que el sistema pueda decir honestamente 'no sé'.
- Postgres te aguanta hasta cientos de miles de chunks; como guardaste source_id y metadata, migrar después a un almacén dedicado es mecánico, no un rediseño.
Preguntas frecuentes
¿De verdad necesito pgvector, o debería empezar con una base de datos vectorial dedicada?
Empieza con pgvector. Si tus datos ya viven en Postgres, una base vectorial aparte agrega un segundo sistema que sincronizar, asegurar y pagar antes de haber validado el caso de uso. pgvector con un índice HNSW maneja con comodidad cientos de miles de chunks. Múdate solo cuando Postgres te diga que llegó la hora: queries que se van a disco, búsqueda híbrida pesada o sharding a gran escala.
¿De qué tamaño deberían ser mis chunks?
Más o menos 300–500 tokens con un overlap del 10–15 por ciento es un default sólido. Los chunks más pequeños son más específicos pero pierden contexto; los más grandes promedian demasiado y hacen match flojo con todo. Parte primero por estructura (títulos, párrafos) y solo después recurres a un conteo de tokens. Si la calidad del retrieval está mala, reduce el tamaño de los chunks antes de tocar cualquier otra cosa.
¿Qué pasa si cambio de modelo de embeddings más adelante?
Tienes que volver a generar los embeddings de todo. Los vectores de un modelo nuevo viven en otro sistema de coordenadas, así que los viejos y los nuevos no son comparables, y si cambia la dimensión también alteras el tipo de la columna. Regenera los embeddings de todo el corpus y del camino de la query a la vez. Por eso importa tanto fijar el modelo y la dimensión desde el principio.
HNSW o IVFFlat, ¿qué índice debería usar?
HNSW para la mayoría de los casos. Da mejor relación recall-velocidad y maneja inserts y updates sin problema, sin necesidad de reconstruirse cuando los datos cambian. IVFFlat usa menos memoria y se construye más rápido, lo que puede importar en datasets enormes y estáticos, pero requiere tuning y volver a entrenar sus listas. Por defecto, HNSW; recurre a IVFFlat solo si la memoria se vuelve la restricción que más aprieta.
¿Por qué el modelo sigue alucinando incluso con RAG?
Casi siempre porque el retrieval le entregó chunks que no venían al caso y tomaste el top k sin importar la calidad. Agrega un piso de similitud para descartar los matches flojos, y cuando nada supere el piso, devuelve 'no sé' en lugar de llamar al modelo. Además, en el system prompt indícale al modelo que responda únicamente a partir del contexto entregado y que cite la fuente, eso vuelve visibles y rastreables las respuestas equivocadas.
¿Dónde entran row-level security y el multi-tenant?
Pon el ID de tenant en la metadata de cada chunk y filtra por él dentro de la función de búsqueda, para que la búsqueda vectorial solo corra sobre las filas que quien la llama tiene permitido ver. Si la tabla queda expuesta a través de la API de Supabase, refuérzalo también con row-level security, no solo con el argumento de la función. Defensa en profundidad. Una función de búsqueda que ignora quién pregunta es una fuga de datos a punto de ocurrir.
¿Prefieres que lo hagamos por ti?
Esto mismo lo construimos para negocios como el tuyo. La primera conversación es gratis y sin compromiso.
Escríbenos por WhatsAppPrimera conversación gratis. Te responde el fundador.
Recursos relacionados

Arquitectura de memoria para agentes que recuerdan
"Agregar memoria" son en realidad tres problemas distintos bajo un mismo nombre. Esta guía separa la memoria de trabajo, la episódica y la semántica, guarda cada una en el store más barato que le sirva, y te da un schema concreto en Supabase más una política de lectura y escritura para que recuperar salga barato, exacto y relevante, para que tu agente nunca le mande un paquete a la dirección del año pasado.

pgvector: vectores dentro de Postgres
La mayoría monta una base vectorial dedicada antes de tener el problema que esta resuelve. pgvector le suma un tipo vector e índices ANN al Postgres que ya tienes corriendo, así la búsqueda semántica vive junto a tus datos relacionales: una sola base, un solo backup, un solo conjunto de transacciones. Es el default pragmático para RAG, y aquí te explico cuándo es la decisión correcta, cómo montarlo en Supabase y los detalles de índice que, sin avisar, convierten consultas rápidas en escaneos completos.

RAG o meterlo directo en el prompt
RAG no viene por defecto. Es una concesión a la que recurres cuando el conocimiento no cabe, cambia seguido o tiene que citarse. Esta guía te lleva primero por la decisión y después por el build: un stack real de chunk-embed-retrieve en Supabase, un ejemplo paso a paso y los detalles que, sin que te des cuenta, te dañan la recuperación.