Por qué una demo que funciona no es un buscador de producción
La retrieval-augmented generation, o RAG, es un producto de datos, no un truco de prompt. Una petición se convierte en una o varias búsquedas, los resultados se filtran según la identidad y el tenant, el modelo recibe un contexto limitado y la respuesta vuelve con evidencias. Cada etapa puede funcionar por separado y aun así producir una respuesta incorrecta. El buscador puede encontrar un párrafo pertinente de un tenant que la persona no tiene derecho a ver. Un chunk puede tener la frase correcta y perder el encabezado que le da sentido. Un modelo de lenguaje puede inventar una URL plausible que nunca estuvo entre las fuentes recuperadas.
El contrato de producción debe ser explícito. Guarda en cada chunk la identidad estable del documento, la revisión, la ubicación de la fuente, el orden del chunk, las etiquetas de acceso, el modelo de embedding y la representación léxica. Separa la revisión actual de las históricas. Haz que PostgreSQL imponga el límite del tenant y repite el filtro de autorización en las consultas de recuperación. Una cita debe ser un identificador seleccionado de las filas recuperadas, no texto que el modelo pueda inventar.
Este diseño cabe en una base PostgreSQL y permite mover más adelante la búsqueda léxica o el reranker a un servicio separado. El manual de evaluación de agentes IA desarrolla el arnés de pruebas, y el manual de observabilidad de agentes IA muestra cómo trazar las etapas. El manual de inyección de prompt y seguridad MCP explica por qué el texto recuperado debe tratarse como datos, nunca como instrucciones.
Define la unidad de recuperación antes de elegir un índice
El troceado es la primera decisión de calidad. Empieza por la estructura de la fuente, no por un número de caracteres. Mantén un encabezado junto con sus párrafos, no separes una fila de tabla de su etiqueta y conserva los límites de las listas. Un chunk debe poder responder una pregunta pequeña por sí solo y retener suficiente contexto para un reranker. Cuenta tokens con el modelo de embedding elegido, porque un límite de caracteres no equivale igual en todos los idiomas ni en el código.
El solapamiento puede conservar una frase que cruza un límite, pero también duplica términos, aumenta el trabajo de embedding y puede hacer que el generador repita contenido. Usa el menor solapamiento que corrija los fallos observados en los límites. Guarda source_start, source_end o un ancla de fuente junto con un hash normalizado del texto. Así la cita es precisa y el proceso de ingestión puede saltar chunks sin cambios. Mantén intacto un bloque de código cuando importe su sintaxis. En un documento largo, añade al texto del embedder el título y la ruta de encabezados, pero conserva el cuerpo limpio para mostrarlo.
Un embedding es una representación versionada del texto. Registra el nombre del modelo, la dimensión, la política de normalización y la hora de creación. Cambiar cualquiera de ellos puede cambiar el orden de los vecinos. Declarar una columna vector(1536) rechaza un vector con otra dimensión y evita mezclar modelos por accidente. Si deben convivir varios modelos, usa columnas o tablas e índices separados en lugar de rellenar vectores en silencio.
Contrato de datos de PostgreSQL con límite de tenant
El esquema siguiente guarda un puntero a la revisión actual y todos los metadatos necesarios para búsqueda y citas. english es solo un ejemplo. Para un corpus multilingüe, usa una configuración de búsqueda por idioma o un servicio léxico que conozca los idiomas del corpus.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE rag_documents (
tenant_id uuid NOT NULL,
document_id uuid NOT NULL,
current_revision bigint NOT NULL,
source_uri text NOT NULL,
deleted_at timestamptz,
PRIMARY KEY (tenant_id, document_id)
);
CREATE TABLE rag_chunks (
chunk_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id uuid NOT NULL,
document_id uuid NOT NULL,
revision bigint NOT NULL,
chunk_no integer NOT NULL,
title text NOT NULL,
content text NOT NULL,
source_start integer NOT NULL,
source_end integer NOT NULL,
acl text[] NOT NULL DEFAULT ARRAY['public']::text[],
embedding vector(1536) NOT NULL,
embedding_model text NOT NULL,
search_tsv tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', content), 'B')
) STORED,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, document_id, revision, chunk_no)
);
CREATE INDEX rag_chunks_search_tsv_idx
ON rag_chunks USING gin (search_tsv);
CREATE INDEX rag_chunks_acl_idx
ON rag_chunks USING gin (acl);
CREATE INDEX rag_chunks_tenant_idx
ON rag_chunks (tenant_id);
CREATE INDEX rag_chunks_embedding_hnsw_idx
ON rag_chunks USING hnsw (embedding vector_cosine_ops);
Si eliges IVFFlat como índice aproximado, usa sus parámetros de listas y probes en lugar de HNSW:
CREATE INDEX rag_chunks_embedding_ivfflat_idx
ON rag_chunks USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
Mantén un solo índice aproximado para la distancia y el camino de acceso de una ruta, salvo que una migración o comparación intencional necesite ambos.
La búsqueda de texto completo de PostgreSQL representa lexemas normalizados como tsvector y la consulta como tsquery. La columna generada saca la preparación del camino de consulta, y un índice GIN suele ser adecuado para búsquedas repetidas. Si necesitas semántica BM25 exacta, usa un motor léxico o una extensión que ofrezca BM25 y pásale sus candidatos ordenados. ts_rank_cd es un rango léxico útil de PostgreSQL, pero no es BM25. No uses ambos nombres como si fueran intercambiables.
Para el rol de la aplicación, activa row-level security en ambas tablas y usa un ajuste de tenant local a la transacción, derivado de claims autenticados. El rol que atiende solicitudes no debería ser propietario de las tablas. Como alternativa, fuerza las políticas también para el propietario.
ALTER TABLE rag_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_documents FORCE ROW LEVEL SECURITY;
ALTER TABLE rag_chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE rag_chunks FORCE ROW LEVEL SECURITY;
CREATE POLICY rag_documents_tenant ON rag_documents
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
CREATE POLICY rag_chunks_tenant ON rag_chunks
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid);
Al principio de cada transacción, define el valor mediante una llamada parametrizada, ejecuta la búsqueda y confirma o revierte. Un ajuste ausente no coincide con ningún tenant. Un valor mal formado de los claims debe rechazarse antes de abrir la transacción. La aplicación aun así añade predicados de tenant_id, revisión actual, deleted_at y ACL. RLS es la última frontera, no una razón para confiar en un tenant ID o una lista de roles enviada por el cliente.
HNSW e IVFFlat son decisiones operativas distintas
Sin índice aproximado, pgvector ejecuta una búsqueda exacta de vecinos. Es una referencia de recall y puede ser práctica después de un filtro selectivo de tenant o estado. HNSW construye un grafo de varias capas. Suele ofrecer un mejor equilibrio entre velocidad y recall, pero consume más memoria y tarda más en construirse. No necesita datos de entrenamiento, por lo que puede crearse antes de llenar la tabla. Sus opciones m y ef_construction influyen en el tamaño del grafo, el trabajo de construcción y el recall.
IVFFlat divide los vectores en listas y sondea un subconjunto de las listas cercanas. Usa menos memoria y se construye más rápido, pero su recall depende del número de listas, la distribución de datos y el número de probes. Créalo después de cargar datos representativos. El proyecto pgvector ofrece heurísticas iniciales para elegir listas y ajustar ivfflat.probes. No son benchmarks de tu carga. Mide con tus idiomas, filtros y frecuencia de actualización.
Usa el operador de distancia que corresponda al contrato del embedding. La distancia coseno usa <=>, el producto interno usa <#> y la distancia L2 usa <->. Un índice HNSW de coseno debe usar vector_cosine_ops, como en el esquema. Añade una clave secundaria determinista al orden. Durante una migración, crea el nuevo índice con CREATE INDEX CONCURRENTLY para no bloquear escrituras, recordando que no puede ejecutarse dentro de una transacción. Inspecciona planes con EXPLAIN (ANALYZE, BUFFERS) sobre datos representativos y compara resultados aproximados con exactos.
La búsqueda aproximada con filtros necesita cuidado. pgvector aplica el filtro normal después de escanear el índice aproximado, así que una lista pequeña puede dejar muy pocas filas de un tenant o ACL. Aumenta el presupuesto de candidatos, activa iterative scans cuando la versión instalada lo soporte o añade un índice relacional selectivo. Para pocos valores de filtro puede ayudar un índice vectorial parcial. Para muchos tenants, el particionamiento por lista o hash puede aislar poblaciones, pero miles de particiones aumentan la planificación y la memoria. La documentación de pgvector advierte que un índice aproximado compartido permite que los vectores de un tenant afecten al recall de otro.
Combina intención léxica y similitud vectorial
La búsqueda vectorial entiende paráfrasis y conceptos cercanos. La búsqueda léxica protege identificadores exactos, códigos de error, nombres de productos, frases entre comillas y términos nuevos que un embedding puede representar mal. Un pipeline robusto ejecuta ambas búsquedas sobre el mismo conjunto autorizado de revisiones actuales, toma más candidatos de los que mostrará y fusiona rangos, no scores sin normalizar.
La consulta siguiente usa el rango de texto completo de PostgreSQL como rama léxica. En un sistema con servicio BM25, sustituye text_hits por sus candidatos y conserva el contrato de chunk_id, text_rank, tenant, revisión y ACL. Reciprocal Rank Fusion evita suponer que una distancia coseno y un score BM25 comparten escala.
WITH q AS (
SELECT $1::vector AS embedding,
websearch_to_tsquery('english', $2::text) AS tsq,
$3::uuid AS tenant_id,
$4::text[] AS roles
),
vector_hits AS (
SELECT c.chunk_id,
row_number() OVER (
ORDER BY c.embedding <=> q.embedding, c.chunk_id
) AS vector_rank
FROM rag_chunks c
JOIN rag_documents d
ON d.tenant_id = c.tenant_id
AND d.document_id = c.document_id
AND d.current_revision = c.revision
CROSS JOIN q
WHERE c.tenant_id = q.tenant_id
AND d.deleted_at IS NULL
AND c.acl && q.roles
ORDER BY c.embedding <=> q.embedding, c.chunk_id
LIMIT 50
),
text_hits AS (
SELECT c.chunk_id,
row_number() OVER (
ORDER BY ts_rank_cd(c.search_tsv, q.tsq) DESC, c.chunk_id
) AS text_rank
FROM rag_chunks c
JOIN rag_documents d
ON d.tenant_id = c.tenant_id
AND d.document_id = c.document_id
AND d.current_revision = c.revision
CROSS JOIN q
WHERE c.tenant_id = q.tenant_id
AND d.deleted_at IS NULL
AND c.acl && q.roles
AND c.search_tsv @@ q.tsq
ORDER BY ts_rank_cd(c.search_tsv, q.tsq) DESC, c.chunk_id
LIMIT 50
),
ranked AS (
SELECT chunk_id, vector_rank, NULL::bigint AS text_rank
FROM vector_hits
UNION ALL
SELECT chunk_id, NULL::bigint AS vector_rank, text_rank
FROM text_hits
),
candidate_scores AS (
SELECT chunk_id,
min(vector_rank) AS vector_rank,
min(text_rank) AS text_rank
FROM ranked
GROUP BY chunk_id
)
SELECT c.chunk_id,
c.document_id,
c.chunk_no,
c.title,
c.content,
d.source_uri,
s.vector_rank,
s.text_rank,
coalesce(1.0 / (60.0 + s.vector_rank), 0.0) +
coalesce(1.0 / (60.0 + s.text_rank), 0.0) AS rrf_score
FROM candidate_scores s
JOIN rag_chunks c ON c.chunk_id = s.chunk_id
JOIN rag_documents d
ON d.tenant_id = c.tenant_id
AND d.document_id = c.document_id
AND d.current_revision = c.revision
WHERE d.deleted_at IS NULL
ORDER BY rrf_score DESC, c.chunk_id
LIMIT 8;
La lista de roles se construye en la capa de autorización. acl && roles significa que coincide al menos una etiqueta. Si la política exige todas las etiquetas, propietario o una ventana temporal, usa otro operador o una columna is_public. Parametriza el texto y el embedding. websearch_to_tsquery tolera la sintaxis del usuario, mientras que to_tsquery espera operadores válidos y no debe recibir texto sin validar.
Solo reordena candidatos autorizados
Un cross-encoder u otro reranker lee la consulta junto a cada candidato. Puede distinguir mejor dos coincidencias semánticas cercanas que un solo embedding, pero añade cómputo y una etapa secuencial. Recupera un conjunto acotado, aplica filtros de tenant, revisión, borrado y ACL antes del reranker y envía solo los campos necesarios. Nunca envíes filas no autorizadas a un modelo externo aunque la respuesta final vaya a omitirlas.
Conserva los rangos vectoriales y léxicos originales para diagnosticar. Registra los IDs de candidatos, modo de índice, parámetros de probes o búsqueda, versión del reranker e IDs finales, ocultando o protegiendo el texto sensible. El manual de inyección de prompt y seguridad MCP explica que el contenido recuperado son datos, no instrucciones. Un documento puede contener una inyección, por lo que el prompt de generación debe indicar que los pasajes son evidencias no confiables y no pueden cambiar herramientas, permisos ni reglas del sistema.
Las citas deben ser datos
Cada cita mostrada debe resolver a un document_id, revision, chunk_no, source_uri y desplazamiento o encabezado de la fuente recuperada. Pide al generador que devuelva IDs de cita junto a las afirmaciones y valida esos IDs contra las filas exactas enviadas al contexto. Renderiza título y URL desde la base. Elimina un ID desconocido o marca la afirmación como no respaldada. El modelo no debe fabricar una URL a partir del título.
Pasa contexto vecino suficiente para explicar un resultado, pero conserva el ID de cada chunk. Si unes chunks adyacentes para el modelo, conserva su procedencia individual. Cuando cambia una fuente, una cita de la revisión antigua no debe resolverse en la respuesta actual. En dominios de riesgo, muestra fecha de revisión, alcance de acceso y un estado explícito de «sin respuesta» cuando faltan evidencias.
Mide por separado recuperación y respuesta
Construye un conjunto de evaluación versionado con preguntas reales, documentos esperados, casos aceptables de «sin respuesta», identidades de tenant, etiquetas ACL e idiomas. Incluye búsquedas exactas, paráfrasis, preguntas de varios pasos, documentos obsoletos y solicitudes adversariales. Guarda un conjunto privado para no sobreajustar los ejemplos usados durante el desarrollo.
Para recuperación mide recall@k en varios cutoffs, reciprocal rank o nDCG para el orden y la fracción de resultados que cumple el contrato de autorización. Para generación mide precisión de afirmaciones fundamentadas, precisión y cobertura de citas, completitud, calidad del rechazo y calibración de sin respuesta. La revisión humana sigue siendo útil para preguntas ambiguas. Compara búsqueda exacta con HNSW o IVFFlat sobre el mismo snapshot para medir la pérdida de recall, no para adivinarla por la latencia.
Ejecuta pruebas de seguridad negativas. Un usuario no debe recibir el secreto conocido de otro tenant, un cambio de ACL debe aplicarse a la consulta siguiente y un documento borrado no debe reaparecer inmediatamente. Un buen score de respuesta no compensa una fuga. Registra en cada evaluación el modelo de embedding, versión del troceado, configuración del índice, versión del reranker, versión del prompt y revisión del corpus. Así una regresión tiene una causa reproducible.
Actualizaciones y borrados seguros y observables
Usa hashes de contenido para que la ingestión sea idempotente. Analiza y trocea el documento, crea embeddings por lotes y escribe la nueva revisión antes de mover el puntero. El cambio de puntero puede ser una transacción corta, de modo que los lectores ven la revisión completa antigua o la nueva. Guarda el modelo de embedding en cada fila y programa un backfill cuando cambie. No mezcles vectores de dimensiones distintas en una columna.
BEGIN;
SELECT set_config('app.tenant_id', $1::text, true);
INSERT INTO rag_chunks (
tenant_id, document_id, revision, chunk_no, title, content,
source_start, source_end, acl, embedding, embedding_model
)
VALUES ($1::uuid, $2::uuid, $3::bigint, $4::integer, $5::text, $6::text,
$7::integer, $8::integer, $9::text[], $10::vector, $11::text)
ON CONFLICT (tenant_id, document_id, revision, chunk_no) DO UPDATE
SET title = EXCLUDED.title,
content = EXCLUDED.content,
source_start = EXCLUDED.source_start,
source_end = EXCLUDED.source_end,
acl = EXCLUDED.acl,
embedding = EXCLUDED.embedding,
embedding_model = EXCLUDED.embedding_model,
updated_at = now();
INSERT INTO rag_documents (
tenant_id, document_id, current_revision, source_uri, deleted_at
)
VALUES ($1::uuid, $2::uuid, $3::bigint, $12::text, NULL)
ON CONFLICT (tenant_id, document_id) DO UPDATE
SET current_revision = EXCLUDED.current_revision,
source_uri = EXCLUDED.source_uri,
deleted_at = NULL;
COMMIT;
Este ejemplo inserta un chunk. Un loader real inserta todos los chunks de la revisión antes de actualizar el puntero y comprueba el número esperado de filas. Al borrar, primero haz que el documento no aparezca en recuperación mediante deleted_at dentro del mismo límite de autorización. Borra físicamente los chunks después de la retención requerida por el producto y cumplimiento. Limpia revisiones antiguas por lotes, vigila dead tuples y ejecuta VACUUM (ANALYZE) cuando corresponda. Tras mucha rotación, vacuum puede ser costoso para HNSW. Un reindexado concurrente antes de vacuum puede ayudar, pero mídelo en la tabla real.
Presupuesta latencia y coste por etapa
Registra p50, p95 y p99 por separado para normalización, embedding, búsqueda léxica, búsqueda vectorial, fusión, reranking, generación y validación de citas. Registra también cantidad de candidatos, filas descartadas, tokens, reintentos y aciertos de caché. Una consulta vectorial rápida puede quedar oculta por un timeout del proveedor de embedding. Un retriever barato se vuelve caro si envía demasiados pasajes al reranker o al generador.
Genera embeddings de documentos por lotes y reintenta con backoff limitado. Guarda en caché solo artefactos estables y no sensibles, incluyendo en la clave versión de modelo, normalización y alcance del tenant. La caché de embeddings de consultas requiere revisión de privacidad porque una consulta repetida puede revelar intereses de usuarios. Elige HNSW o IVFFlat según recall medido, memoria, tiempo de construcción, escrituras y latencia de cola, no según un benchmark genérico. Haz configurables los límites de candidatos y el presupuesto del reranker por ruta o tenant.
El panel de producción debe mostrar resultados vacíos, respuestas sin citas, fallos de validación de citas, denegaciones ACL, hits de revisiones antiguas, estado de construcción, salud de vacuum y muestras de recall aproximado frente a exacto. Así la observabilidad de agentes IA se conecta con la suite de evaluación de agentes IA. Alerta por cambios en estas tasas, no solo por CPU de la base.
Secuencia práctica de puesta en marcha
Empieza con búsqueda vectorial exacta y texto completo de PostgreSQL en un corpus pequeño y representativo. Añade el puntero de revisión y RLS antes de incorporar tenants reales. Congela un conjunto de evaluación anotado y compara HNSW e IVFFlat con resultados exactos. Añade RRF, reranking y citas una etapa cada vez para que cada cambio tenga un efecto medible. Antes de ampliar el despliegue, prueba actualizaciones, borrados, cambios ACL, reutilización del pool de conexiones y fallos de construcción de índices.
La documentación oficial de pgvector describe tipos vectoriales, operadores de distancia, HNSW, IVFFlat, búsqueda filtrada, iterative scans, particionamiento y mantenimiento. La documentación de PostgreSQL cubre búsqueda de texto completo, índices GIN y GiST para texto, políticas de seguridad de filas, creación de índices, particionamiento y MVCC. El SQL y los compromisos siguen esas interfaces. No se afirma una latencia, recall o coste universal.