← All posts
·5 min read

The Postgres-only AI stack

pgvector for embeddings, LISTEN/NOTIFY for jobs, row-level security for isolation. One database instead of four services, until you need more.

The operational argument for pgvector doesn't stop at vector search. Postgres already has primitives for the job queue, the pub/sub layer, and the tenant isolation most RAG stacks reach for a separate service to get — which means the "one database" argument isn't really about vectors, it's about how much infrastructure a RAG system actually needs before it's proven it needs more.

Who this is for: an engineer whose RAG stack has a vector database, a message queue, and a background job runner, each a separate service with its own connection pool, monitoring, and failure mode — for a system that doesn't yet have the traffic to justify any of that being separate.

Four services collapse into one database. The trade is real, and it's usually the right one until traffic proves otherwise.

Vectors: pgvector, already covered

Already the default worth reaching for first, and it's also what makes hybrid search a two-query fusion instead of a separate infrastructure integration. The rest of this post is what else the same database replaces.

Jobs: LISTEN/NOTIFY plus a table, not a message broker

A background job system needs three things: a place to put work, a way to be told work exists, and a way to make sure two workers don't grab the same job. Postgres has all three without adding a service.

CREATE TABLE jobs (
  id BIGSERIAL PRIMARY KEY,
  payload JSONB NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending',
  claimed_at TIMESTAMPTZ
);
 
-- claim one job, skip ones another worker already has locked
UPDATE jobs SET status = 'processing', claimed_at = now()
WHERE id = (
  SELECT id FROM jobs WHERE status = 'pending'
  ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1
)
RETURNING *;

FOR UPDATE SKIP LOCKED is the part that makes this safe under concurrency — two workers running the same query never claim the same row, no external coordination required. LISTEN/NOTIFY replaces the polling loop: a worker listens on a channel, and an insert trigger notifies it immediately instead of the worker checking the table every few seconds.

This isn't a message broker's feature set — no fan-out to multiple consumer groups, no delivery guarantees across a cluster. For the embedding-and-ingestion pipeline behind most RAG systems, it's also not missing anything that pipeline actually uses.

Isolation: row-level security, enforced where it can't be skipped

Multi-tenant RAG leaks when isolation is filtered after retrieval instead of enforced inside it — relevance ranks documents, it doesn't check permissions. Postgres row-level security is the mechanism that makes "inside the query" the only option rather than a discipline every query has to remember:

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY tenant_isolation ON documents
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

Once that policy exists, every query against documents — including the vector similarity search — is filtered by tenant before a single row comes back, regardless of whether the application code that wrote the query remembered to add a WHERE tenant_id = ... clause. The database enforces it structurally, which is a stronger guarantee than "we always remember to filter," because it doesn't depend on always.

What this doesn't replace

This isn't an argument against ever using a separate service — it's an argument about order. A message broker earns its complexity at a scale where LISTEN/NOTIFY genuinely can't keep up: high fan-out, cross-service delivery guarantees, consumer groups. A dedicated vector database earns its keep at a scale or a filtering complexity pgvector genuinely can't handle. Both of those are real thresholds — they're just further out than most RAG stacks are when the second service gets added.

The pattern worth naming: each of these was added because it's what a "proper" system is supposed to have, not because the system had outgrown what one database could do. Four services means four things that can fail independently, four things to monitor, and four backup strategies instead of one.

When to actually add the second service

Concretely: when a single Postgres instance's connection pool is the bottleneck under your real load, not your projected load. When job volume is high enough that SKIP LOCKED contention shows up in query latency. When tenant count and document volume together make row-level security's per-query filter cost measurable rather than theoretical. Each of those is a number you can watch for, not a guess you make on day one. The same order-of-operations argument applies one layer up, to where the whole application runs — most of what gets asked as a provider comparison is a question about your own system first.

If your stack has four services for a system with one database's worth of traffic, collapsing it back down is usually a day of work, not a redesign.

Shanker Dhand
Shanker Dhand
AI Engineer & Technical Lead

I design and ship production AI systems — RAG pipelines, agents, and evaluation infrastructure — built on 10+ years of full-stack engineering.

Related posts