Postgres and pgvector
Keep vectors next to relational data — pgvector types and operators, HNSW/IVFFlat indexes, hybrid SQL filters, ingest and dual-write patterns, recall tuning, ops reality, and when to graduate to a dedicated vector DB.
The pragmatic default
Many AI products already run on Postgres. pgvector adds a vector type, distance operators, and ANN indexes so you can keep documents, ACLs, and embeddings in one transactional store — excellent until scale forces a split.
You still need the concepts from Vector databases: same embedding model for query and docs, stable chunk ids, recall@k evals. pgvector changes the ops contract, not the math. If you cannot explain ANN, filtered search, and system of record vs derived index, you are not ready to put embeddings in production Postgres either.
flowchart TB
App[App / RAG service] --> PG[(Postgres)]
PG --> Rel[users, docs, acls]
PG --> Vec[chunks.embedding vector]
Vec --> IVF[ivfflat / hnsw index]
Rel --> Join[SQL joins + filters]
IVF --> Join
Join --> Pack[Top-k for LLM]
One-sentence definition
pgvector is an extension that stores embeddings as a first-class Postgres type and supports exact and ANN similarity search alongside normal SQL filters, joins, and transactions.
What you get that a “numpy in RAM” demo does not
| Capability | Why it matters in a product |
|---|---|
| Persistence + backups | Survive restarts; PITR includes vectors |
| SQL filters + joins | Tenant/ACL/time in the same query as ANN |
| Transactions | Ingest chunk rows atomically with doc metadata |
| Connection pooling / replicas | Same ops muscle your team already has |
| One backup story | Docs + embeddings + ACL in one restore drill |
Analogy: a dedicated vector DB is a specialty tool for “nearest neighbors at huge QPS.” pgvector is “put the specialty index inside the database you already trust for money and auth.”
Why teams love it
| Benefit | Detail |
|---|---|
| One system of record | Docs + embeddings + ACL in one backup |
| Filters that are real SQL | tenant_id, updated_at, role arrays |
| Transactions | Ingest chunk rows atomically with doc metadata |
| Skills | Most backend teams already speak SQL |
| Joins | Enrich hits with titles/URLs without a second hop |
| Auth story | Same roles/RLS patterns you may already use |
| Cost curve | No second managed cluster until you need it |
When pgvector is the wrong first move
- Greenfield with no Postgres and a pure ANN SaaS already approved
- Billion-scale vectors on day one with multi-region ANN SLAs
- Team has zero Postgres ops capacity but strong managed-vector budget
- Embedding write QPS already saturates your OLTP primary in load tests
Otherwise, try pgvector before inventing a second datastore. See Choosing vector stores.
Interview cue: “We put vectors in Postgres because our ACL and chunk text already live there — not because HNSW in Postgres beats every specialized engine at every scale.”
Mental model: Postgres is still Postgres
pgvector does not turn Postgres into a different product. You still get:
- MVCC, WAL, vacuum, TOAST, planner choices
- Index bloat from updates/deletes
- Connection limits and lock contention
- The same need for
EXPLAIN (ANALYZE, BUFFERS)
What changes is that a large HNSW graph can dominate RAM and I/O the way a giant GIN index can — except the query pattern (filtered ORDER BY embedding <=> $1 LIMIT k) is easier to get subtly wrong.
flowchart LR
subgraph StillPG[Still Postgres]
WAL[WAL / PITR]
Vac[Autovacuum]
Plan[Planner + buffers]
end
subgraph New[pgvector adds]
Type[vector type]
Ops[distance operators]
ANN[HNSW / IVFFlat]
end
StillPG --> Query[Filtered ANN query]
New --> Query
Schema sketch
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE docs (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
title text NOT NULL,
source_uri text,
doc_type text NOT NULL DEFAULT 'generic',
deleted_at timestamptz,
updated_at timestamptz NOT NULL
);
CREATE TABLE chunks (
id uuid PRIMARY KEY,
doc_id uuid NOT NULL REFERENCES docs(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
chunk_index int NOT NULL,
content text NOT NULL,
content_hash text NOT NULL,
embedding vector(1536) NOT NULL,
embedding_model text NOT NULL,
updated_at timestamptz NOT NULL,
acl_roles text[] NOT NULL,
UNIQUE (doc_id, chunk_index, embedding_model)
);
CREATE INDEX chunks_hnsw ON chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
CREATE INDEX chunks_tenant_doc ON chunks (tenant_id, doc_id);
CREATE INDEX chunks_acl ON chunks USING gin (acl_roles);
CREATE INDEX chunks_tenant_updated ON chunks (tenant_id, updated_at DESC);Ship rule: put embedding_model (or collection version) in the row or table name so you never mix dimensions silently.
Why denormalize tenant_id onto chunks
You could join docs for every ANN query. In practice, denormalizing tenant_id (and often ACL) onto chunks keeps the hot path simple, makes partial indexes easier, and reduces “forgot the join filter” bugs. Keep docs as the place for titles and source URIs; keep chunks as the retrieval unit.
Optional: partial indexes for hot subsets
-- Example: only index “live” policy chunks for a product surface
CREATE INDEX chunks_hnsw_policies ON chunks
USING hnsw (embedding vector_cosine_ops)
WHERE doc_type = 'policy' AND deleted_at IS NULL;Partial ANN indexes help when most queries share a predicate — measure before proliferating. Wrong partial predicate → silent misses that look like “the embedder got worse.”
Extension install and version pinning
- Install via your platform’s package (
postgresql-15-pgvector, Docker image with the extension, etc.). - Pin extension version in migrations / runbooks the same way you pin app dependencies.
CREATE EXTENSION vectoris a privileged DDL step — plan who can run it in staging vs prod.- After upgrades, re-check operator classes and GUC names (
hnsw.ef_search,ivfflat.probes) against the release notes.
Distance operators
| Operator | Meaning (typical) | Common opclass |
|---|---|---|
<-> |
L2 distance | vector_l2_ops |
<#> |
inner product (negative for ORDER BY convenience in some setups — check docs) |
vector_ip_ops |
<=> |
cosine distance | vector_cosine_ops |
Match the operator and opclass to how you train/normalize embeddings. For cosine, many teams L2-normalize at write and query time so cosine and inner-product rankings behave predictably.
-- Store normalized vectors if you use cosine / inner product consistently
-- (normalize in app before INSERT / at query bind time)
-- Pseudocode: v = v / ||v||Ship rule: the index opclass, the ORDER BY operator, and the embedding model’s intended metric must agree. Mixing vector_l2_ops with <=> is a classic silent quality bug.
Half-precision and storage levers (version-aware)
Newer pgvector versions add storage options (e.g. halfvec / binary-ish paths depending on release). Treat them as quality vs RAM knobs:
- Measure task recall@k on your eval set before and after
- Document the type in the collection/table version name
- Do not silently ALTER types under a live RAG path
Always verify against the docs for your installed version — do not cargo-cult blog posts from a different major.
Hybrid queries that matter
-- $1 = query embedding, $2 = tenant, $3 = roles text[]
SELECT
c.id,
c.content,
d.title,
d.source_uri,
1 - (c.embedding <=> $1) AS score
FROM chunks c
JOIN docs d ON d.id = c.doc_id
WHERE c.tenant_id = $2
AND c.acl_roles && $3 -- overlap with user roles
AND c.updated_at > now() - interval '180 days'
AND d.deleted_at IS NULL
ORDER BY c.embedding <=> $1
LIMIT 40;Metadata filters + vector order in one place is pgvector’s superpower. Dedicated vector DBs need filter pushdown too — verify before you migrate.
For keyword + vector hybrid inside Postgres, combine tsvector / ts_rank with vector scores (or RRF in the app). See Hybrid search and rerankers.
-- Sketch: keyword candidate set then vector re-order (cascade)
WITH kw AS (
SELECT id FROM chunks
WHERE tenant_id = $2
AND content_tsv @@ plainto_tsquery('english', $4)
LIMIT 200
)
SELECT c.id, c.content, 1 - (c.embedding <=> $1) AS score
FROM chunks c
JOIN kw ON kw.id = c.id
ORDER BY c.embedding <=> $1
LIMIT 40;Filter selectivity — the product-critical footgun
| Situation | What happens | Fix direction |
|---|---|---|
| Highly selective ACL | ANN may walk many candidates to fill LIMIT k |
Raise ef_search; retrieve wider; consider partial indexes |
Missing tenant_id |
Cross-tenant exfiltration via the LLM prompt | Mandatory helper + tests |
| Post-filter in app after global ANN | Empty / biased hits | Keep filters in SQL |
deleted_at not in WHERE |
Zombie chunks in prompts | Soft-delete predicate everywhere |
Load-test with realistic ACL selectivity, not only “tenant with millions of open chunks.”
Index choices
| Index | Build | Query | Notes |
|---|---|---|---|
Exact (ORDER BY <=> no ANN) |
None | Slow at scale | Correctness baseline for recall@k |
| IVFFlat | Fast; needs lists |
Needs SET ivfflat.probes |
OK mid-scale |
| HNSW | Heavier build / RAM | Strong recall/latency | Common default |
-- IVFFlat example
CREATE INDEX chunks_ivf ON chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- Session / role knobs (names vary by pgvector version — check docs)
SET hnsw.ef_search = 40;
SET ivfflat.probes = 10;Tune ef_search / probes / lists for recall vs latency. Always measure recall@k against exact search on a held-out sample:
-- Sketch: compare ANN top-k ids vs exact top-k ids on sample queries
-- recall = |ANN ∩ Exact| / kHNSW knobs in plain English
| Knob | Effect if raised | Cost |
|---|---|---|
m |
Richer graph connectivity | RAM + build time |
ef_construction |
Better graph quality at build | Longer CREATE INDEX |
ef_search |
Better query recall | Higher query latency / CPU |
Start with documented defaults, then ablate on your eval queries under your filters. Vendor/blog defaults are not your product SLO.
IVFFlat knobs in plain English
| Knob | Effect | Watch-out |
|---|---|---|
lists |
More coarse cells | Often scales near (\sqrt{n}) as a starting heuristic |
probes |
Search more lists at query time | Low probes → silent recall cliffs |
IVFFlat is cheaper to build than HNSW at some scales; it is also easier to mis-tune after a bulk load if centroids were trained on a tiny early sample.
Build-time tips
- Create HNSW after a bulk load when practical (faster than updating the graph row-by-row during a huge backfill — check your version’s guidance).
- Prefer
CREATE INDEX CONCURRENTLYin production windows when your ops policy allows — still budget time and disk. listsfor IVFFlat: start with a heuristic, then ablate with recall@k.- Monitor index size vs
shared_buffers/ RAM; HNSW wants to be hot. - Do not create the ANN index on an empty table and forget to rebuild after the first million-row backfill.
flowchart TD
Bulk[Bulk load chunks] --> Exact[Optional: exact recall baseline]
Exact --> Build[CREATE INDEX hnsw / ivfflat]
Build --> Tune[Tune ef_search / probes]
Tune --> Gate[recall@k gate]
Gate -->|pass| Serve[Serve RAG]
Gate -->|fail| Retune[Retune or reindex]
Ingest patterns
# Pseudocode — skip re-embed when content_hash unchanged
row = fetch_chunk(doc_id, idx)
new_hash = sha256(text)
if row and row.content_hash == new_hash and row.embedding_model == MODEL:
return # idempotent no-op
emb = embed(text)
upsert_chunk(..., embedding=emb, content_hash=new_hash, embedding_model=MODEL)- Batch inserts; avoid per-row round-trips.
- Use
COPYor multi-rowINSERTfor backfills. - Rebuild ANN indexes after huge bulk loads if your version recommends it.
VACUUM/ bloat: deleted vectors and updates still need Postgres hygiene.- Soft-delete with
deleted_at+ filter if GDPR needs tombstones before hard delete. - Cap embed-worker concurrency; unbounded gather →
max_connectionsdeath.
sequenceDiagram
participant Worker as Ingest worker
participant Emb as Embedding API
participant PG as Postgres
participant API as RAG API
Worker->>Worker: parse + chunk + hash
alt hash unchanged
Worker-->>Worker: skip
else changed
Worker->>Emb: batch embed
Emb-->>Worker: vectors
Worker->>PG: upsert chunks + embeddings
end
API->>PG: filtered ORDER BY embedding <=> q
PG-->>API: top-k + join docs
API->>API: optional rerank + pack prompt
Idempotent ids
Prefer stable chunk primary keys such as uuid derived from doc_id + chunk_index + embedding_model (or content-addressed ids). Re-ingest must upsert, not duplicate. Unstable ids → duplicate neighbors forever and broken citations.
Row Level Security (optional but powerful)
If you already use RLS for multi-tenant tables, you can align chunk access with the same policies — still put explicit tenant_id predicates in app SQL for defense in depth and clearer plans.
-- Shape only — wire to your auth.uid() / session GUC pattern
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY chunks_tenant_isolation ON chunks
-- USING (tenant_id = current_setting('app.tenant_id')::uuid);Ship rule: RLS is not a substitute for forgetting tenant_id in ANN queries during code review. Reviewers should see the predicate in the helper SQL.
Performance and ops reality
| Topic | Practice |
|---|---|
| RAM | HNSW wants hot index pages in memory — size the box |
| Parallelism | Limit runaway max_parallel_workers fights with OLTP |
| Connection pooling | PgBouncer; embeddings jobs can stampede |
| Primary health | Heavy ANN + OLTP on one primary → isolate read replica or graduate |
| Backups | PITR covers vectors; test restore of a large table |
| Migrations | Changing vector(N) dimension = new column / table + backfill |
| Autovacuum | Tune for large chunk tables or risk bloat + dead tuples |
| Observability | pg_stat_statements, index size, ANN p95 from app traces |
| Disk | Index build needs free space; plan before concurrent reindex |
Read replica pattern
flowchart LR
Ingest[Ingest] --> Primary[(Primary OLTP + writes)]
API[RAG reads] --> Replica[(Replica with pgvector)]
Primary --> Replica
Use a replica for heavy ANN if lag is acceptable for your freshness SLO. Embedding writes still hit primary (or a dedicated ingest path). Document the max acceptable replica lag for “new doc searchable” UX.
Separating workloads without leaving Postgres
Before graduating to Pinecone/Qdrant:
- Cap RAG concurrency (semaphore at the API)
- Add Redis exact/semantic cache in front (Redis for AI caching)
- Move ANN reads to a hot replica
- Partition or partial-index hot doc types
- Only then split derived ANN out
When to leave Postgres (or split)
Leave the primary as the only ANN store when:
- Billions of vectors or multi-region ANN SLAs
- Embedding write QPS saturates OLTP
- You need specialized cold/hot vector tiers
- p95 ANN latency fights checkout/auth queries
- Compliance requires a harder blast radius than one Postgres cluster
Common pattern:
- Postgres = system of record (docs, ACL, canonical chunk text)
- Specialized ANN (Qdrant/Pinecone) as derived index
- Dual-write or CDC with idempotent chunk ids
Until then, pgvector is often the highest-ROI choice.
flowchart TD
Stress{ANN hurting OLTP?}
Stress -->|No| Stay[Stay on pgvector]
Stress -->|Yes| Split[Postgres SoT + dedicated ANN]
Split --> Dual[Dual-write / CDC]
Ship rule: write the graduation trigger in the design doc before the incident — e.g. “split when p95 ANN > 80ms and checkout p95 rises >20% during RAG load tests.”
Architecture that survives production
flowchart TB
subgraph SoT[System of record]
PG[(Postgres: docs, ACL, chunk text, embeddings)]
S3[(Object store: raw files)]
end
subgraph OptionalDerived[Optional later]
VDB[(Dedicated vector DB)]
end
subgraph Online
API[RAG API] --> Cache[Redis exact/semantic]
Cache --> Emb[Embed query]
Emb --> PG
PG --> Pack[Join + pack + citations]
Pack --> LLM[LLM]
end
S3 --> PG
PG -.->|graduate| VDB
| Layer | Owns |
|---|---|
| Object store | Raw PDFs / blobs |
| Postgres | Canonical text, ACL, embeddings (while on pgvector) |
| Redis | Hot exact / semantic response cache |
| App | Filters, packing, citations, evals |
When you graduate ANN, keep Postgres as SoT and treat the external vector DB as rebuildable.
How to evaluate retrieval on pgvector (not vibes)
Hold a frozen set of queries with labeled relevant chunk ids.
| Metric | Asks |
|---|---|
| ANN recall@k | |ANN ∩ Exact| / k at current ef_search / probes |
| Task recall@k | Did any gold chunk appear in top-k under real filters? |
| Empty-rate under ACL | Filter too strict? |
| p95 latency | Under production-like concurrency + filters |
| Cross-tenant leak rate | Must be 0 in automated tests |
Ship rule: never ship a new embedder, chunker, or index knob without a before/after on the same eval set. Generation quality alone hides retrieval rot.
Interface contract (staff bar)
| Inputs | Chunk rows with vector(N), tenant/ACL columns, query embedding bound as parameter |
| Outputs | Joined rows: chunk text + doc title/uri + score |
| Invariants | Dimension matches model; filters always include tenant; deletes cascade or tombstone consistently |
Three measurable metrics
- recall@10 vs exact on sample queries at current
ef_search/probes - p95 of filtered ANN query under load
- Primary CPU / buffer hit ratio during peak RAG traffic
Two degrade modes
- ANN index dropped/invalid → exact search on a capped candidate set or BM25-only with user-visible quality note
- Postgres failover → serve stale Redis FAQ cache; block writes until primary healthy
Threat note
Missing tenant_id in WHERE turns ANN into a cross-tenant exfiltration tool via the LLM prompt. Catch with SQL review checklists, integration tests, and optional RLS. Prompt injection via retrieved docs is a packing/policy problem — cite spans and do not treat retrieved text as instructions.
Failure modes
- Missing
tenant_idinWHERE→ cross-tenant leak - IVFFlat with default
probes→ silent recall drop - Mixing cosine ops with unnormalized embeddings
- ANN index on empty/small table then never rebuilding after bulk load
- Storing only vectors without
content→ cannot cite or rebuild prompts - Giant HNSW on undersized RAM → latency cliffs / IO storm
- Embedding workers opening unlimited connections → slot exhaustion
- Dimension / opclass mismatch after a “quick” migration
- Soft-deleted docs still retrieved → policy ghosts in the prompt
- Evaluating only final answers → blind to retrieval rot
- Holding DB connections open during slow embed HTTP calls
- Planner skipping ANN index — verify with
EXPLAIN
Debugging playbook (first hour)
EXPLAIN (ANALYZE, BUFFERS)the ANN query — is the HNSW/IVF index used?- Raise
ef_search/probesin a session; did recall jump on eval? - Compare exact vs ANN id overlap for the failing query
- Check
embedding_modeland vector length constraints - Look for bloat / dead tuples on
chunks - Run the failing query with filters relaxed only in a secure staging clone — is it filter or geometry?
- Inspect chunk text for parse/chrome garbage from the ingest pipeline
- Confirm app p95 vs DB time — is embed API the real bottleneck?
| Symptom | First checks | Fix direction |
|---|---|---|
| Empty results | Filters, soft-delete, wrong tenant GUC | Predicate / data |
| Good neighbors, bad answers | Packing, rerank, prompt | Downstream of pgvector |
| Sudden latency cliff | Index size vs RAM, vacuum, connection storms | Ops / capacity |
| Recall drop after deploy | Model id, opclass, ef_search, reindex |
Versioning / knobs |
Production readiness checklist
- Extension version pinned in migrations
- HNSW or IVFFlat created with documented knobs
- Tenant + ACL predicates mandatory in query helpers
- recall@k job vs exact baseline (weekly or CI against ephemeral Postgres)
- Backup restore tested including large
chunks - Connection pool sized for ingest + API
- Graduation criteria written (when to split ANN out)
- Soft-delete / GDPR delete verification job
-
EXPLAINsampled in staging under realistic filters - Alerts: ANN p95, empty-retrieval rate, dead tuples, connection saturation
Interview prompts
- IVFFlat vs HNSW in Postgres — when each?
- How do you measure ANN recall without a dedicated vector DB?
- Why store
contentin Postgres even if another ANN serves queries later? - What breaks when ANN and checkout share one primary?
- How do you change embedding dimension without downtime?
- Why is post-filtering ANN results in the app dangerous under strict ACLs?
- Walk through
EXPLAINfindings that show the ANN index is not used.
Capacity planning sketch
Assume 2M chunks, 1536-dim float32:
- Raw vectors ≈ (2e6 \times 1536 \times 4 \approx 12.3) GB
- HNSW overhead often adds a large fraction more (graph links) — treat 2× raw as a planning buffer until measured
- Payload text may dominate if you store full chunks inline — consider TOAST behavior and whether cold text lives only for joins
- Indexes + WAL + bloat need headroom beyond “raw math”
Size RAM so the hot index is not thrashing disks during p95 queries. Also budget embedding API cost for backfill and ongoing CDC — a multi-million-chunk re-embed is a calendar project.
Sizing worksheet (fill before buying hardware)
| Input | Your number |
|---|---|
| Chunk count (n) | |
| Dimension (d) | |
| Bytes/vector (e.g. 4) | |
| Raw vector GB ≈ (n \times d \times 4 / 1e9) | |
| HNSW planning multiplier (start 2×) | |
| Avg chunk text bytes | |
| Concurrent RAG QPS | |
| Target p95 ms | |
| Replica lag SLO for “searchable” |
Migration: change embedding dimension
-- Shape: new table or new column, never ALTER vector(1536) → vector(3072) in place casually
CREATE TABLE chunks_v4 (LIKE chunks INCLUDING ALL);
-- embed into chunks_v4.embedding vector(3072)
-- dual-read in app; drop old after gateKeep embedding_model populated. Application query helpers should take an explicit collection/table version.
Dual-write cutover (safe upgrade)
flowchart LR
V3[chunks serving] --> Shadow[Shadow compare]
V4[chunks_v4 backfill] --> Shadow
Shadow -->|pass gates| Flip[Flip read traffic]
Flip --> Keep[Keep v3 for rollback]
- Create
chunks_v4with newvector(N)+ new HNSW - Backfill async; keep serving
chunks - Shadow-query both; compare recall@k / nDCG on eval set
- Flip traffic; keep v3 read-only until rollback window ends
- Drop old only after the window — and after backup
Combining with Postgres full-text
Maintain content_tsv:
ALTER TABLE chunks ADD COLUMN content_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;
CREATE INDEX chunks_tsv ON chunks USING gin (content_tsv);Use for hybrid cascades or RRF in the app. Analyzers matter — test SKUs and error codes. pgvector does not replace a serious BM25 engine for keyword-heavy corpora; it coexists.
Connection and pool hygiene
| Actor | Pool | Notes |
|---|---|---|
| API | Transaction pooler OK for reads | Avoid holding connections during embed HTTP |
| Ingest | Session pool / direct | Long batches; limited concurrency |
| Migrations | Direct to primary | DDL + index builds |
Never run unbounded asyncio gather of ANN queries without a semaphore — you will exhaust max_connections. Embed outside the DB transaction whenever possible: open connection → write vectors → close; do not sit in BEGIN while waiting on OpenAI.
Exact baseline query (for recall@k)
-- Disable index scan in a session when measuring exact neighbors (shape; verify with EXPLAIN)
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SELECT id FROM chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1
LIMIT 10;
RESET enable_indexscan;
RESET enable_bitmapscan;Compare id sets to ANN with indexes enabled. Automate in a weekly job — do not rely on one-off notebooks. Cap the exact baseline to a sampled tenant or random subset when (n) is huge; document the sampling bias.
Vacuum and delete reality
Updates to embedding rewrite rows. High-churn corpora need:
- Sensible
fillfactorexperiments only with measurement - Autovacuum scale factors tuned for large tables
- Periodic
REINDEX INDEX CONCURRENTLYwhen appropriate for your version/ops policy - Monitoring
n_dead_tupand last autovacuum time
GDPR hard-deletes should be verified with a search for the doc_id after the job — both in docs and chunks, and in any derived external ANN if you split later.
Multi-tenancy patterns on pgvector
| Pattern | Pros | Cons |
|---|---|---|
Shared table + tenant_id filter |
Simple ops | Bug = leak; test hard |
| Schema / DB per tenant | Hard isolation | Sprawl; migrations hurt |
| Partial indexes per hot tenant | Latency win | Operational complexity |
| Partition by tenant | Pruning help | Planner + ANN quirks — measure |
Start shared + mandatory filters for MVPs; graduate when compliance or noisy-neighbor requires harder walls. Decision detail: Choosing vector stores.
Worked walkthrough: support policy RAG on pgvector
- Ingest — parse policy PDFs → chunk → hash → embed → upsert into
chunkswithtenant_id,acl_roles,embedding_model - Index — HNSW on
embedding vector_cosine_opsafter bulk load - Query — embed user question → SQL with tenant + ACL →
ORDER BY <=>LIMIT 40 - Pack — rerank optional → pack 5–10 chunks with citations into the LLM
- Eval — 50 labeled queries: task recall@10 + ANN vs exact overlap + zero cross-tenant hits
- Ops — alert on p95, empty rate, dead tuples; document graduation trigger
If step 5 fails, do not “fix” generation temperature first — fix retrieval.
Anti-patterns (pgvector edition)
| Anti-pattern | Why it hurts | Do this instead |
|---|---|---|
| One embedding column, no model id | Silent mix of dims/models | Version table or embedding_model |
ANN only, no content |
Cannot cite or rebuild prompts | Store chunk text in SoT |
| App-side post-filter only | Empty hits under ACL | Filters in SQL |
| Index before bulk load, never rebuild | Weak graph / bad lists | Build after backfill |
| Shared primary, unlimited RAG fan-out | Melts OLTP | Semaphore + replica |
| “RLS will save us” without tests | False safety | Explicit predicates + isolation tests |
Tuning ef_search without eval set |
Latency theater | recall@k gates |
Interview whiteboard: design a pgvector layer
Be ready to draw and narrate:
- Tables:
docs,chunks(+ optionalcontent_tsv) - Indexes: HNSW + tenant/ACL helpers
- Ingest path with content_hash short-circuit
- Online path: embed → filtered ANN → pack → LLM
- Eval: exact vs ANN + task recall + tenant isolation
- Failure: degrade to BM25/cache; graduation to dedicated ANN
If you skip filters or SoT, the whiteboard fails staff bar even if HNSW knobs are perfect.
Glossary
| Term | Meaning |
|---|---|
| pgvector | Postgres extension for vector type + indexes |
| HNSW | Graph ANN index available in pgvector |
| IVFFlat | Coarse-quantizer ANN index |
<=> |
Cosine distance operator |
| Opclass | Index operator class (vector_cosine_ops, etc.) |
| PITR | Point-in-time recovery |
| recall@k | Overlap of ANN top-k with exact (or gold) top-k |
| SoT | System of record — canonical text/ACL |
Application helper pattern
def search_chunks(conn, qvec, tenant_id: str, roles: list[str], k: int = 40):
# tenant_id from session — never from client body alone
sql = """
SELECT c.id, c.content, d.title, d.source_uri,
1 - (c.embedding <=> %s) AS score
FROM chunks c
JOIN docs d ON d.id = c.doc_id
WHERE c.tenant_id = %s
AND c.acl_roles && %s::text[]
AND d.deleted_at IS NULL
ORDER BY c.embedding <=> %s
LIMIT %s
"""
return conn.execute(sql, (qvec, tenant_id, roles, qvec, k)).fetchall()Centralize this helper so reviewers only audit one place for missing filters. Add integration tests that insert tenant A/B fixtures and assert isolation on every PR that touches retrieval.
Monitoring queries worth saving
-- Index sizes
SELECT relname, pg_size_pretty(pg_relation_size(oid))
FROM pg_class
WHERE relname LIKE 'chunks%';
-- Bloat / dead tuples (approx)
SELECT relname, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'chunks';
-- Hot queries (enable pg_stat_statements)
SELECT substring(query, 1, 80), calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
WHERE query ILIKE '%embedding%'
ORDER BY total_exec_time DESC
LIMIT 20;Pair with app-level p95 ANN latency. DB-only metrics miss embed time. Trace one request: embed ms + SQL ms + rerank ms + LLM ms.
Observability for the pgvector data plane
| Signal | Why |
|---|---|
| ANN p95 / p99 by route | Catch RAM thrash early |
| Empty retrieval rate | Filter or ingest bugs |
| recall@k weekly | Silent knob regressions |
Embed skip ratio (content_hash hits) |
Cost sanity |
| Connection pool wait time | Stampede detector |
| Replica lag | Freshness SLO |
End-to-end smoke test script (shape)
- Insert tenant A and B chunks with known text.
- Query as A — never see B.
- Exact top-10 vs HNSW top-10 overlap on 10 queries.
- Delete doc — confirm chunks gone / filtered.
- Change one chunk hash — confirm re-embed path updates vector.
EXPLAINconfirms index use.- Soft-delete a doc — confirm it disappears from RAG hits.
- Flip
ef_searchup/down — confirm latency/recall move as expected on eval.
Automate in CI against ephemeral Postgres when feasible.
FAQ (pgvector)
Does ORDER BY embedding <=> $1 LIMIT k always use HNSW?
Check EXPLAIN. Missing index, wrong opclass, or planner choices can surprise you.
Can I use RLS instead of tenant_id in SQL?
Use both. RLS helps; explicit predicates keep plans and reviews clearer.
Will pgvector replace Elastic for keyword search?
No — use tsvector or an external search engine for serious BM25. pgvector shines at vectors + SQL filters together.
Is HNSW always better than IVFFlat?
Often for interactive recall/latency, not always for build time or RAM. Measure on your (n), filters, and hardware.
Can I put a separate HNSW per tenant?
Partial indexes or partitions can help hot tenants; sprawl has a cost. Measure before proliferating.
What if replica lag is 30s?
New docs may be invisible to RAG on the replica. Either read-your-writes on primary for that tenant, accept lag in UX copy, or shorten the path to primary for freshness-critical queries.
Do I need a dedicated vector DB eventually?
Maybe. Start with graduation triggers, not vibes. Many products stay on pgvector far longer than Twitter threads suggest.
Deep dive: keeping OLTP healthy
Symptoms ANN is hurting the primary:
- Checkout/auth p95 rises when RAG traffic spikes
chunksindex larger than RAM; disks busy- Autovacuum cannot keep up with embed worker updates
- Connection saturation from parallel RAG fan-out
pg_stat_activityshows piles ofORDER BY embedding <=> …
Responses in order of cost:
- Cap RAG concurrency; add Redis cache
- Raise
ef_searchcarefully — sometimes lower load with slightly lower recall is required under incident - Move ANN reads to a replica
- Split derived ANN to Qdrant/Pinecone; keep Postgres as SoT
Write the graduation trigger in the design doc before the incident.
Deep dive: filtered search under sparse ACLs
When only 0.1% of chunks match a user’s roles, a naive “ANN then filter” mental model fails. In SQL you want the engine to consider predicates with the distance order. Practical tactics:
- Keep
tenant_id+ ACL predicates in the same query asORDER BY embedding <=> … - Retrieve wider (
LIMIT 80) then rerank/pack narrower - Raise
ef_searchunder sparse ACL load tests - Consider partial indexes for common role/doc_type surfaces
- Add an isolation test that uses a rare role — not only the admin role that sees everything
Deep dive: embedding model upgrades without downtime
- New table/version with new dim + model id
- Dual-write new docs; backfill old
- Shadow traffic + eval gates
- Flip reads; keep rollback window
- Drop old after backup + quiet period
Never ALTER the vector dimension in place on a live hot table as your only plan.
Consistency models you will actually hit
| Mode | Meaning for RAG |
|---|---|
| Read-after-write on primary | User’s just-uploaded doc is searchable immediately |
| Replica read | Faster/isolated ANN; possible lag |
| Dual-write to external ANN | Eventual consistency until CDC catches up |
Pick one and document it in the API (“docs searchable within N seconds”).
Common interview traps
- Claiming pgvector “doesn’t do ANN” (it does — HNSW/IVFFlat)
- Ignoring filters when praising cosine similarity
- Treating Redis as a substitute for durable chunk storage
- Sizing RAM from raw float32 only (forgetting HNSW overhead)
- Saying “we’ll just raise
ef_search” without a recall@k graph
Putting what / why / how together
| Lens | Answer for pgvector |
|---|---|
| What | Vector type + ANN indexes inside Postgres |
| Why | One SoT for text, ACL, and embeddings; real SQL filters |
| How | Schema → HNSW/IVF → filtered ORDER BY → eval → ops/graduation |
If you can only recite operators, you know syntax. If you can defend filters, recall@k, and OLTP isolation, you know the product.
Micro-project
CREATE EXTENSION vectorin local Postgres.- Create
chunkswith HNSW + tenant filter. - Insert 100 fake chunks across two tenants; run filtered similarity query.
- Compare exact vs HNSW top-10 overlap (recall@10).
- Deliberately omit
tenant_idonce in a throwaway script — observe the leak — then fix the helper and add a test. EXPLAIN (ANALYZE, BUFFERS)and confirm the ANN index is used.
Related
Vector databases · Choosing stores · Guided RAG. Redis if you need a hot semantic/exact cache in front: Redis for AI caching. Chunking quality still dominates: Chunking and metadata. Hybrid keyword+vector: Hybrid search and rerankers.