Postgres as Institutional Memory: Schemas, Audit Lineage, and Grounded Answers
Stop treating your database as a dumb cache. Make it the source of truth for AI agents.
Every time an AI agent hallucinates or forgets past decisions, your organization loses credibility. The typical response is to throw more prompts at the problem or duct-tape a vector database onto a chatbot. That’s cargo-cult engineering. The real solution is already sitting in your infrastructure: Postgres.
Postgres is not just a relational store. With schemas, triggers, foreign data wrappers, and extensions like pgvector, it becomes a single source of truth for agent memory, audit lineage, and grounded retrieval. No cloud vector DB, no SaaS knowledge base, no fragile RAG pipeline that breaks when the embedding model changes.
Why Postgres, Not a Vector Store
Vector databases are fast at similarity search, but they are terrible at enforcing relationships, maintaining schema integrity, or tracking provenance. When an agent retrieves a chunk from a vector store, it gets a vector and a payload. Where did that chunk come from? Who wrote it? When was it last updated? The vector store doesn’t care. You have to build that lineage yourself, and you usually do it badly.
Postgres gives you both: structured metadata and vector search. With pgvector, you can store embeddings alongside the original text, timestamps, user IDs, and source documents in the same row. Queries become joins, not API calls. You can filter by date, author, or schema before you even hit the index.
Schema Design for Institutional Memory
Your institutional memory lives in a set of tables that mirror your organization’s ontology. Start with a schema like this:
CREATE SCHEMA IF NOT EXISTS memory;
CREATE TABLE memory.documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(768), -- for BGE-M3 or similar
source_schema TEXT NOT NULL, -- e.g., 'hr', 'engineering', 'legal'
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE memory.chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID REFERENCES memory.documents(id) ON DELETE CASCADE,
chunk_index INT NOT NULL,
chunk_text TEXT NOT NULL,
embedding vector(768),
UNIQUE(document_id, chunk_index)
);Why two tables? Because you want to retrieve entire documents or specific chunks depending on the agent’s need. The source_schema column lets you restrict queries to a department’s corpus. No more agents answering HR questions with engineering specs.
Audit Lineage with Triggers
Every row change must be logged. Not for debugging — for compliance. The EU AI Act requires that any decision made by an AI system can be traced back to its training data and prompts. If your agent gives a wrong answer, you need to know exactly which document it used.
Postgres triggers are your friend:
CREATE TABLE memory.audit_log (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
record_id UUID NOT NULL,
action TEXT NOT NULL, -- INSERT, UPDATE, DELETE
old_data JSONB,
new_data JSONB,
changed_by TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE OR REPLACE FUNCTION memory.audit_trigger()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO memory.audit_log (table_name, record_id, action, old_data, new_data, changed_by)
VALUES (
TG_TABLE_NAME,
COALESCE(NEW.id, OLD.id),
TG_OP,
CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN row_to_json(OLD)::jsonb ELSE NULL END,
CASE WHEN TG_OP IN ('INSERT','UPDATE') THEN row_to_json(NEW)::jsonb ELSE NULL END,
current_user
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER documents_audit
AFTER INSERT OR UPDATE OR DELETE ON memory.documents
FOR EACH ROW EXECUTE FUNCTION memory.audit_trigger();
CREATE TRIGGER chunks_audit
AFTER INSERT OR UPDATE OR DELETE ON memory.chunks
FOR EACH ROW EXECUTE FUNCTION memory.audit_trigger();Now every insertion, update, or deletion is recorded with a full snapshot. When an auditor asks “Why did the agent recommend this supplier?”, you can query the audit log for the exact chunk that was retrieved, who put it there, and when.
Grounded Answers with pgvector and Hybrid Search
Agents need to answer questions based on facts, not generation. That means retrieval-augmented generation (RAG) with a tight loop: query → retrieve → prompt → verify.
With pgvector, you can do hybrid search — combine vector similarity with keyword matching:
-- Create an index for approximate nearest neighbor search
CREATE INDEX ON memory.chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
-- Query: find top 5 chunks similar to the query embedding, filtered by source
SELECT c.chunk_text, d.title, d.source_schema
FROM memory.chunks c
JOIN memory.documents d ON d.id = c.document_id
WHERE d.source_schema = 'legal'
ORDER BY c.embedding <=> $query_embedding
LIMIT 5;But vector similarity alone misses exact matches. Combine with full-text search using Postgres built-in tsvector:
ALTER TABLE memory.chunks ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', chunk_text)) STORED;
CREATE INDEX ON memory.chunks USING GIN (tsv);
-- Hybrid query: combine vector and text scores
WITH vector_results AS (
SELECT id, 1 - (embedding <=> $query_embedding) AS score
FROM memory.chunks
WHERE source_schema = 'legal'
ORDER BY score DESC
LIMIT 100
),
text_results AS (
SELECT id, ts_rank(tsv, plainto_tsquery('english', $query_text)) AS score
FROM memory.chunks
WHERE source_schema = 'legal'
AND tsv @@ plainto_tsquery('english', $query_text)
ORDER BY score DESC
LIMIT 100
)
SELECT c.id, c.chunk_text,
COALESCE(v.score, 0) * 0.5 + COALESCE(t.score, 0) * 0.5 AS combined_score
FROM memory.chunks c
LEFT JOIN vector_results v ON v.id = c.id
LEFT JOIN text_results t ON t.id = c.id
WHERE v.id IS NOT NULL OR t.id IS NOT NULL
ORDER BY combined_score DESC
LIMIT 5;This gives you grounded answers that are both semantically similar and keyword-relevant. The agent’s prompt then includes the retrieved chunks as context, with citations pointing back to the document IDs. You can even store the prompt and the retrieved IDs in another audit table for full traceability.
Agent Orchestration: Postgres as the Control Plane
Your agents should not call external APIs for memory. They should query Postgres directly. Use a simple Python agent loop with psycopg2 or asyncpg:
import asyncpg
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('BAAI/bge-m3')
async def retrieve_context(question: str, schema_filter: str = None):
conn = await asyncpg.connect(dsn="postgres://...")
query_emb = model.encode(question).tolist()
if schema_filter:
rows = await conn.fetch("""
SELECT c.chunk_text, d.title
FROM memory.chunks c
JOIN memory.documents d ON d.id = c.document_id
WHERE d.source_schema = $1
ORDER BY c.embedding <=> $2::vector
LIMIT 5
""", schema_filter, query_emb)
else:
rows = await conn.fetch("""
SELECT c.chunk_text, d.title
FROM memory.chunks c
JOIN memory.documents d ON d.id = c.document_id
ORDER BY c.embedding <=> $1::vector
LIMIT 5
""", query_emb)
await conn.close()
return [(row['chunk_text'], row['title']) for row in rows]No external vector DB, no Redis cache, no SaaS. Everything lives in one place. You can back up, replicate, and failover Postgres with standard tools. No vendor lock-in.
Scaling Considerations
- Indexing: For large vector collections, use
ivfflatwith a reasonablelistsvalue. For very large collections, considerpgvectorscalefrom Timescale or switch topg_embeddingwith HNSW. But for most organizations, a single Postgres instance with sufficient RAM handles millions of vectors easily. - Connection pooling: Use
pgbouncerto handle hundreds of agent connections. Setpool_mode = transactionfor best performance. - Read replicas: Offload vector search to a replica. Use
pg_stat_replicationto monitor lag. - Embedding computation: Offload to a separate service and write embeddings asynchronously. Use
LISTEN/NOTIFYto trigger re-embedding on updates.
Compliance and the EU AI Act
Under the EU AI Act, high-risk AI systems must maintain logs of data used for training and inference. Postgres audit triggers cover this. Each agent interaction can be logged in a memory.agent_log table:
CREATE TABLE memory.agent_log (
id BIGSERIAL PRIMARY KEY,
agent_id TEXT NOT NULL,
prompt TEXT NOT NULL,
retrieved_chunk_ids UUID[] NOT NULL,
response TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Now you have a complete chain: prompt → retrieved documents → response. If a user complains, you can replay the exact context.
When Not to Use This
- If you need extremely low-latency vector search at very large scale, consider a specialized store with on-prem deployment.
- If your agents need real-time updates across geo-distributed regions, look at distributed Postgres solutions.
- If you hate SQL, this isn’t for you. But if you’re reading The Sovereign Stack, you probably love it.
Conclusion
Postgres is not a legacy database. It’s a platform for building institutional memory that is auditable, grounded, and sovereign. By combining schemas, triggers, full-text search, and vector embeddings, you eliminate the need for external memory stores and SaaS knowledge bases. Your agents become accountable. Your compliance team stops panicking. And you sleep better knowing your data never leaves your infrastructure.