RAG Storage Schema
A RAG store in Postgres splits documents into chunks, stores embeddings per chunk, and keeps metadata for filtering and citations. Design for tenant isolation, idempotent re-ingestion, and hybrid FTS from day one.
Search across all documentation pages
A RAG store in Postgres splits documents into chunks, stores embeddings per chunk, and keeps metadata for filtering and citations. Design for tenant isolation, idempotent re-ingestion, and hybrid FTS from day one.
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 of normalized source
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)
);When to reach for this: Building retrieval for LLM apps where source citations, per-tenant corpora, and SQL joins to user permissions matter.
-- Tenant-scoped indexes
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);
-- Idempotent ingest: skip if source unchanged
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;
-- Retrieval with tenant filter + hybrid-ready columns
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;What this demonstrates:
content_hash drives idempotent pipeline skipsmetadata JSONB holds page numbers, section, ACL hintstsvector supports hybrid search without duplicate parsing logic{
"page": 12,
"section": "Replication",
"heading": "Streaming standby",
"embed_model": "text-embedding-3-small",
"embed_version": "2026-03-01"
}Store model name and version in metadata or dedicated columns. Re-embed campaigns filter WHERE metadata->>'embed_model' <> 'new-model'.
| Approach | Mechanism | Notes |
|---|---|---|
| Column + RLS | tenant_id + policy | Default for SaaS |
| Schema per tenant | tenant_abc.rag_chunks | Heavy ops at scale |
| Database per tenant | Separate DB | Enterprise tier |
ALTER TABLE rag_chunks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON rag_chunks
USING (tenant_id = current_setting('app.tenant_id')::uuid);Set app.tenant_id per connection from pooler session variable.
Return to LLM:
chunk.content (trimmed)document.source_urimetadata->>'page'chunk_index for ordering within documentAvoid storing full PDF blobs in chunk table; keep object storage URI in rag_documents.
ALTER TABLE rag_chunks
ADD COLUMN embed_status text NOT NULL DEFAULT 'pending'
CHECK (embed_status IN ('pending', 'ready', 'failed'));Workers claim pending rows with FOR UPDATE SKIP LOCKED.
ON DELETE CASCADE from chunks to documents.WHERE embed_status = 'ready'.language, product_id).UNIQUE (document_id, chunk_index) plus content hash at document level.| Alternative | Use When | Don't Use When |
|---|---|---|
| Single table (no document) | Tiny static FAQ | Multi-source citations required |
| pgvector + object storage | Large PDF originals | Need transactional chunk + blob together |
| External vector DB | Dedicated search team and scale | Permission joins live in Postgres |
| Materialized view of chunks | Read-heavy analytics on corpus | Write-heavy ingest pipeline |
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PostGIS 3.5+, pgbouncer 1.x, and Patroni 3.x.
Reviewed by Chris St. John·Last updated Jul 18, 2026