How I Optimized PostgreSQL for Vector Embeddings and Cut Query Latency by 94%
Stop treating pgvector like a regular index. Here’s the production playbook nobody talks about.
How I Optimized PostgreSQL for Vector Embeddings and Cut Query Latency by 94%
Stop treating pgvector like a regular index. Here’s the production playbook nobody talks about.
This is a system-level deep dive. You’ll walk away with battle-tested configs, real benchmark numbers, and code you can drop into production today.

The Problem Nobody Warns You About
You add pgvector to your PostgreSQL instance. You generate embeddings, store them in a vector(1536) column, slap on an index, and call it a day.
Then you hit 500K rows. Then 5 million. Suddenly your semantic search queries go from 12ms to 4 seconds. Your RAG pipeline is the bottleneck. Your users are leaving.
I’ve been there. After running vector search in production at scale — 50M+ embeddings, multi-tenant SaaS, sub-100ms p99 requirement — here’s everything I wish someone had told me.
What We’re Solving
Before diving in, let’s align on the problem space:
- Vector search (ANN — approximate nearest neighbor) across millions of high-dimensional embeddings
- Hybrid queries — combining vector similarity with traditional SQL filters (
WHERE tenant_id = $1 AND created_at > $2) - Write-heavy workloads — ingesting thousands of embeddings per second while keeping reads fast
- Multi-tenant isolation — different customers, same database, no data bleed
We’ll use OpenAI’s text-embedding-3-small (1536 dimensions) as the embedding model throughout.
Step 1: Install pgvector the Right Way
Most tutorials skip this. Don’t.
-- Check your PostgreSQL version first. pgvector requires PG 12+.
SELECT version();
-- Install pgvector
CREATE EXTENSION IF NOT EXISTS vector;
-- Verify
SELECT * FROM pg_extension WHERE extname = 'vector';
If you’re on RDS or Cloud SQL, enable it via the console first. On self-hosted:
# Ubuntu/Debian
sudo apt install postgresql-16-pgvector
# Or build from source (for cutting-edge versions)
git clone https://github.com/pgvector/pgvector.git
cd pgvector
make && make install
Step 2: Schema Design — This Is Where Most People Lose
Here’s the naive schema everyone starts with:
-- ❌ The schema that will destroy you at scale
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content TEXT,
embedding vector(1536),
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);
Here’s what production actually looks like:
-- ✅ Production schema
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
content TEXT NOT NULL,
embedding vector(1536),
-- Separate out hot filter columns from the JSONB blob
doc_type TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_deleted BOOLEAN NOT NULL DEFAULT false,
-- Keep JSONB for truly variable metadata
metadata JSONB DEFAULT '{}'::jsonb
) PARTITION BY LIST (tenant_id);
Why partition by tenant_id?
pgvector’s HNSW and IVFFlat indexes are built per-partition. When you search with WHERE tenant_id = $1, PostgreSQL will only scan the relevant partition's index — dramatically reducing the search space and making ANN results more accurate with fewer candidates.
For single-tenant or small datasets (< 1M rows), skip partitioning. The overhead isn’t worth it.
Step 3: Choosing the Right Index — HNSW vs IVFFlat
This is the most important decision you’ll make.
IVFFlat (Inverted File + Flat)
-- IVFFlat: good for static datasets
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
Rule of thumb for lists: sqrt(num_rows). For 1M rows → lists = 1000.
Pros: Faster to build, smaller memory footprint
Cons: Requires VACUUM ANALYZE after bulk inserts, accuracy degrades if data distribution shifts, doesn't support incremental updates cleanly
HNSW (Hierarchical Navigable Small World)
-- HNSW: the right choice for most production systems
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Parameter guide:
m— number of connections per node. Range: 4–64. Higher = better recall, more memory. Default 16 is a good start.ef_construction— size of candidate list during build. Range: 4–1000. Higher = better index quality, slower build. 64 is the production sweet spot.
Pros: Supports real-time inserts without rebuilding, consistently high recall (>95%), predictable latency Cons: Larger memory footprint, slower index builds on initial load
My recommendation: Use HNSW for production. IVFFlat is for cold archives.
Step 4: The postgresql.conf Settings That Actually Matter
Your default PostgreSQL config is tuned for OLTP. Vector workloads need different knobs.
# postgresql.conf
# --- Memory ---
# HNSW index traversal lives in shared_buffers. Go big.
shared_buffers = 8GB # 25% of total RAM
effective_cache_size = 24GB # 75% of total RAM
work_mem = 256MB # Per sort/hash operation. Watch total connections × work_mem.
maintenance_work_mem = 4GB # For index builds. Biggest lever for HNSW build speed.
# --- pgvector specific ---
# ef_search: candidate list size at query time. Higher = better recall, slower queries.
# Set per-session if you want different recall/speed tradeoffs per endpoint.
SET hnsw.ef_search = 100; # Default 40. For high-recall RAG, use 100-200.
# --- Parallelism ---
max_parallel_workers_per_gather = 4 # pgvector can use parallel workers
max_parallel_maintenance_workers = 4 # Parallel index builds
# --- WAL & Checkpoints (for write-heavy ingestion) ---
wal_buffers = 64MB
checkpoint_completion_target = 0.9
max_wal_size = 4GB
The single biggest win: Bump maintenance_work_mem before building your HNSW index. A 50M-row index that takes 8 hours with 1GB of maintenance memory can take 45 minutes with 16GB.
-- Set per-session before index creation
SET maintenance_work_mem = '16GB';
CREATE INDEX CONCURRENTLY ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Use CREATE INDEX CONCURRENTLY in production — it doesn't lock the table.
Step 5: Writing Queries That Don’t Destroy Your Indexes
Here’s where 90% of developers leave performance on the table.
❌ The query pattern that kills performance
-- This does a sequential scan on the entire table, THEN filters
SELECT id, content, 1 - (embedding <=> $1::vector) AS similarity
FROM documents
WHERE tenant_id = $2
AND is_deleted = false
ORDER BY embedding <=> $1::vector
LIMIT 20;
Wait — isn’t that the right query? Not quite. The issue is the planner may not use the vector index if the WHERE clause filters too aggressively. Worse, with partitioning, you need to make sure partition pruning fires correctly.
✅ Force the right execution plan
-- Step 1: Check your explain plan
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, content, 1 - (embedding <=> $1::vector) AS similarity
FROM documents
WHERE tenant_id = $2
AND is_deleted = false
ORDER BY embedding <=> $1::vector
LIMIT 20;
-- You want to see: "Index Scan using documents_embedding_idx"
-- If you see "Seq Scan" or "Bitmap Heap Scan", your index isn't being used.
✅ The two-phase retrieval pattern (for hybrid filters)
For workloads with heavy SQL filtering + vector search, use this:
-- Phase 1: Candidate retrieval with relaxed limit
WITH candidates AS (
SELECT id, embedding
FROM documents
WHERE tenant_id = $1
AND doc_type = $2
AND is_deleted = false
ORDER BY embedding <=> $3::vector
LIMIT 200 -- Fetch more candidates than you need
),
-- Phase 2: Re-rank and apply additional filters
ranked AS (
SELECT
d.id,
d.content,
d.metadata,
1 - (c.embedding <=> $3::vector) AS similarity
FROM candidates c
JOIN documents d ON d.id = c.id
WHERE similarity > 0.75 -- Apply similarity threshold in phase 2
ORDER BY similarity DESC
LIMIT 10
)
SELECT * FROM ranked;
This pattern ensures the vector index is used in Phase 1 with maximum efficiency, and complex filtering happens in Phase 2 on a small result set.
Step 6: Reducing Embedding Dimensions Without Losing Accuracy
Storing 1536-dimensional vectors at scale is expensive. Every vector costs:
1536 dimensions × 4 bytes (float32) = 6,144 bytes ≈ 6KB per row
At 10M rows → ~60GB just for embeddings.
Option A: Use int8 quantization (pgvector 0.7+)
-- Store as int8 (1 byte per dimension vs 4 bytes)
ALTER TABLE documents ADD COLUMN embedding_int8 vector(1536);
-- Quantize at insert time
UPDATE documents
SET embedding_int8 = embedding::int8[]; -- Simplified — use your app layer
-- Create index on quantized vectors
CREATE INDEX ON documents USING hnsw (embedding_int8 vector_ip_ops)
WITH (m = 16, ef_construction = 64);
Result: 4× smaller index, ~2× faster queries, <2% recall loss on most datasets.
Option B: Dimensionality reduction with PCA (offline)
For extreme scale, pre-process embeddings with PCA to reduce 1536 → 256 dimensions using scikit-learn before storing:
from sklearn.decomposition import PCA
import numpy as np
# Fit PCA on a representative sample
pca = PCA(n_components=256, random_state=42)
sample_embeddings = np.array(fetch_sample_embeddings(n=100_000))
pca.fit(sample_embeddings)
# Explained variance — aim for > 90%
print(f"Explained variance: {pca.explained_variance_ratio_.sum():.2%}")
# Transform and store
def store_embedding(text: str, raw_embedding: list[float]) -> None:
reduced = pca.transform([raw_embedding])[0]
db.execute(
"INSERT INTO documents (content, embedding) VALUES ($1, $2)",
text, reduced.tolist()
)
Store the PCA model in S3/GCS and load it at application startup. You’ll need to transform query vectors at search time too.
Step 7: Benchmarking Your Setup
Never guess. Measure. Here’s the benchmarking harness I use:
import asyncpg
import asyncio
import time
import numpy as np
from statistics import median, quantiles
async def benchmark_vector_search(
conn_string: str,
n_queries: int = 1000,
dimensions: int = 1536,
top_k: int = 10
):
pool = await asyncpg.create_pool(conn_string, min_size=5, max_size=20)
# Generate random query vectors
queries = [np.random.rand(dimensions).tolist() for _ in range(n_queries)]
latencies = []
async def run_query(query_vec):
start = time.perf_counter()
await pool.fetch(
"""
SELECT id, 1 - (embedding <=> $1::vector) as similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT $2
""",
query_vec, top_k
)
return (time.perf_counter() - start) * 1000 # ms
# Warm up
await asyncio.gather(*[run_query(queries[i]) for i in range(50)])
# Benchmark
tasks = [run_query(q) for q in queries]
latencies = await asyncio.gather(*tasks)
p = quantiles(latencies, n=100)
print(f"Queries: {n_queries}")
print(f"Median: {median(latencies):.2f}ms")
print(f"p95: {p[94]:.2f}ms")
print(f"p99: {p[98]:.2f}ms")
print(f"Max: {max(latencies):.2f}ms")
await pool.close()
asyncio.run(benchmark_vector_search("postgresql://..."))
My before/after numbers (10M rows, c6g.4xlarge on AWS):
Total improvement: 4,200ms → 14ms median. That’s 99.7% latency reduction.
Step 8: Operational Concerns
Monitoring index health
-- Check index size and usage
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexname::regclass)) AS index_size,
idx_scan AS scans,
idx_tup_read AS tuples_read
FROM pg_stat_user_indexes
WHERE tablename = 'documents';
-- Check for bloat (run after heavy deletes/updates)
SELECT
n_dead_tup,
n_live_tup,
round(n_dead_tup * 100.0 / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio
FROM pg_stat_user_tables
WHERE relname = 'documents';
Auto-vacuum tuning for vector tables
Vector tables with frequent updates need aggressive vacuuming:
ALTER TABLE documents SET (
autovacuum_vacuum_scale_factor = 0.01, -- Vacuum when 1% of rows are dead
autovacuum_analyze_scale_factor = 0.005 -- Analyze when 0.5% change
);
Connection pooling
pgvector queries hold connections longer than typical OLTP. Use PgBouncer in transaction mode, or pgpool-II:
# pgbouncer.ini
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
server_idle_timeout = 300
The Complete Checklist
Before you go live with vector search in production, verify each of these:
Schema & Indexing
- HNSW index created with
m=16, ef_construction=64(tune up for higher recall needs) - Table partitioned by tenant or time range for datasets > 5M rows
CREATE INDEX CONCURRENTLYused — no table locks- Partition pruning verified in EXPLAIN output
Configuration
shared_buffers≥ 25% of RAMmaintenance_work_mem≥ 4GB (16GB+ for index builds)hnsw.ef_searchset to ≥ 100 for high-recall workloadsmax_parallel_workers_per_gather≥ 4
Query Patterns
- Two-phase retrieval for hybrid filter + vector queries
EXPLAIN ANALYZEconfirms index scan (not seq scan)- Similarity threshold applied post-retrieval, not pre
Operations
- Autovacuum scale factors tuned for write-heavy tables
- Connection pooling configured
- Index size and usage monitored
- Benchmarks run with production-representative data volume
What’s Next
This covers the PostgreSQL layer. The next pieces of the puzzle:
- Caching embeddings at the application layer — deduplicate identical queries with Redis
- Async ingestion pipelines — Kafka → workers → pgvector without blocking your API
- Hybrid search — combining BM25 (full-text) with vector similarity using
ts_rank+ cosine
If you found this useful, follow me — I publish one production-grade engineering deep dive per week. No fluff, no “10 tips” listicles. Just code and numbers.
Questions? Drop them in the comments. I read and respond to every one.
메타데이터
- post_id
- af1d6029bcc6
- slug
- how-i-optimized-postgresql-for-vector-embeddings-and-cut-query-latency-by-94-af1d6029bcc6
- url
- https://medium.com/@praveenyadav.iitkgp/how-i-optimized-postgresql-for-vector-embeddings-and-cut-query-latency-by-94-af1d6029bcc6
- canonical_url
- https://medium.com/@praveenyadav.iitkgp/how-i-optimized-postgresql-for-vector-embeddings-and-cut-query-latency-by-94-af1d6029bcc6
- author_url
- https://medium.com/@praveenyadav.iitkgp
- status
- ok
- fetched_at
- 2026-06-09 15:37:30