Esquema de Almacenamiento RAG
Una tienda RAG en Postgres divide los documentos en fragmentos, almacena embeddings por fragmento y mantiene metadatos para filtrado y citas. Diseña para el aislamiento de inquilinos, la reingesta idempotente y la búsqueda FTS híbrida desde el primer día.
Receta
CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA extensions;
CREATE TABLE rag_documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
source_uri text NOT NULL,
title text,
content_hash text NOT NULL, -- sha256 del origen normalizado
created_at timestamptz DEFAULT now(),
UNIQUE (tenant_id, source_uri)
);
CREATE TABLE rag_chunks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
document_id uuid NOT NULL REFERENCES rag_documents(id) ON DELETE CASCADE,
chunk_index int NOT NULL,
content text NOT NULL,
token_count int,
metadata jsonb NOT NULL DEFAULT '{}',
embedding extensions.vector(1536),
content_tsv tsvector GENERATED ALWAYS AS (
to_tsvector('english', content)
) STORED,
UNIQUE (document_id, chunk_index)
);Cuándo usar esto: Al construir la recuperación para aplicaciones LLM donde las citas de origen, los corpus por inquilino y las uniones SQL con los permisos del usuario son importantes.
Ejemplo de Trabajo
-- Índices con ámbito de inquilino
CREATE INDEX rag_chunks_tenant_hnsw ON rag_chunks
USING hnsw (embedding extensions.vector_cosine_ops)
WHERE embedding IS NOT NULL;
CREATE INDEX rag_chunks_tenant_fts ON rag_chunks USING gin (content_tsv);
CREATE INDEX rag_chunks_metadata ON rag_chunks USING gin (metadata jsonb_path_ops);
CREATE INDEX rag_chunks_tenant ON rag_chunks (tenant_id);
-- Ingesta idempotente: omitir si el origen no ha cambiado
INSERT INTO rag_documents (tenant_id, source_uri, title, content_hash)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_id, source_uri) DO UPDATE
SET content_hash = EXCLUDED.content_hash,
title = EXCLUDED.title
WHERE rag_documents.content_hash IS DISTINCT FROM EXCLUDED.content_hash
RETURNING id, (xmax = 0) AS inserted;
-- Recuperación con filtro de inquilino + columnas preparadas para híbrido
SELECT c.id,
c.content,
d.title AS source_title,
c.metadata->>'page' AS page,
c.embedding <=> $2 AS distance
FROM rag_chunks c
JOIN rag_documents d ON d.id = c.document_id
WHERE c.tenant_id = $1
AND c.embedding IS NOT NULL
ORDER BY c.embedding <=> $2
LIMIT 8;Lo que esto demuestra:
- La separación de documentos vs fragmentos permite la re-fragmentación sin perder la identidad de origen.
content_hashimpulsa los saltos de pipeline idempotentes.- El índice HNSW parcial excluye las filas aún no incrustadas.
metadataJSONB contiene números de página, sección, pistas de ACL.tsvectorgenerado admite la búsqueda híbrida sin lógica de análisis duplicada.
Inmersión Profunda
Metadatos de Fragmentación
{
"page": 12,
"section": "Replication",
"heading": "Streaming standby",
"embed_model": "text-embedding-3-small",
"embed_version": "2026-03-01"
}Almacena el nombre y la versión del modelo en metadatos o columnas dedicadas. Las campañas de re-incrustación filtran WHERE metadata->>'embed_model' <> 'new-model'.
Aislamiento de Inquilinos
| Enfoque | Mecanismo | Notas |
|---|---|---|
| Columna + RLS | tenant_id + política | Predeterminado para SaaS |
| Esquema por inquilino | tenant_abc.rag_chunks | Operaciones pesadas a escala |
| Base de datos por inquilino | Base de datos separada | Nivel empresarial |
ALTER TABLE rag_chunks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON rag_chunks
USING (tenant_id = current_setting('app.tenant_id')::uuid);Establece app.tenant_id por conexión desde la variable de sesión del pooler.
Carga Útil de Citas
Devolver a LLM:
chunk.content(recortado)document.source_urimetadata->>'page'chunk_indexpara ordenar dentro del documento
Evita almacenar blobs PDF completos en la tabla de fragmentos; mantén la URI del almacenamiento de objetos en rag_documents.
Estados de Ingesta
ALTER TABLE rag_chunks
ADD COLUMN embed_status text NOT NULL DEFAULT 'pending'
CHECK (embed_status IN ('pending', 'ready', 'failed'));Los trabajadores reclaman filas pending con FOR UPDATE SKIP LOCKED.
Trampas Comunes
- Fragmentos sin padre de documento - La limpieza de huérfanos falla. Solución: FK con
ON DELETE CASCADEde fragmentos a documentos. - Incrustación antes de que el contenido esté finalizado - Carrera en la ingesta en streaming. Solución: Columna de estado; índice
WHERE embed_status = 'ready'. - Fragmentos de tamaño excesivo - El manual completo en una sola fila agota el contexto y la calidad de la incrustación. Solución: Apuntar a 400-800 tokens con superposición (almacenar superposición en metadatos).
- Hinchazón de JSONB en la ruta activa - Metadatos enormes por fragmento. Solución: Promover claves de filtro a columnas reales (
language,product_id). - Predicado de índice sin inquilino - HNSW escanea todos los inquilinos. Solución: Índice parcial por inquilino grande o clave de partición compuesta.
- Único en contenido solamente - Fragmentos duplicados entre documentos. Solución:
UNIQUE (document_id, chunk_index)más hash de contenido a nivel de documento.
Alternativas
| Alternativa | Usar Cuando | No Usar Cuando |
|---|---|---|
| Tabla única (sin documento) | FAQ estática pequeña | Se requieren citas de múltiples fuentes |
| pgvector + almacenamiento de objetos | Originales PDF grandes | Necesidad de fragmento transaccional + blob juntos |
| DB de vectores externa | Equipo de búsqueda dedicado y escala | Uniones de permisos en vivo en Postgres |
| Vista materializada de fragmentos | Análisis intensivo de lectura sobre el corpus | Pipeline de ingesta intensivo en escritura |
Preguntas Frecuentes
Tamaño del fragmento?
Dependiente del modelo; 512 tokens comunes para modelos de incrustación de texto. Medir la MRR de recuperación.Superposición?
Superposición de 50-100 tokens reduce los fallos en los límites; almacenar `start_offset` en metadatos.Eliminar documentos obsoletos?
Eliminar en cascada los fragmentos cuando se elimina el documento o el hash no cambia, omitir la reincrustación.Incrustaciones de versión?
Nueva columna `embedding_v2` durante la migración; escritura dual y luego corte.ACL en metadatos vs RLS?
RLS en `tenant_id` obligatorio; ACL de objeto en metadatos para filtro opcional granular.Texto completo en fragmentos?
Sí; el artículo de búsqueda híbrida se combina con la puntuación vectorial.Incrustar por lotes?
COPY fragmentos sin incrustación; el trabajador UPDATE establece el vector y el estado listo.Algoritmo de suma de verificación?
SHA-256 de texto UTF-8 normalizado; documentar cuándo cambian la normalización.IDs UUID vs bigint?
UUID está bien para ingesta distribuida; bigint índices más pequeños si es escritor único.Diseño de referencia?
Ver el caso de estudio de referencia de la tienda RAG de pgvector para un ejemplo de HNSW ajustado.Relacionado
- Conceptos Básicos de pgvector - tipo vector
- Índices IVFFlat vs HNSW - elección de índice
- Búsqueda Híbrida - FTS + vector
- Seguridad a Nivel de Fila - políticas de inquilino
- Referencia: Tienda RAG de pgvector - ejemplo práctico
Versiones de Stack: Esta página fue escrita para PostgreSQL 18.4 (estable 18, mantenimiento 17), pgvector 0.8+, PostGIS 3.5+, pgbouncer 1.x, y Patroni 3.x.