In this post
What this post covers
How this was tested
GitHub Actions runner, 2 vCPU x86_64, 7 GB RAM, Python 3.12.16, numpy 2.5.4, psycopg 3.3.8, pgvector Python 0.5.1, PostgreSQL 17.11 with pgvector 0.8.7 in Docker (pgvector/pgvector:pg17, default shared_buffers 128MB, shm 1 GB). Synthetic clustered, normalized float32 vectors, 100,000 x 384, 200 queries, single client over localhost.
If you already run Postgres, is pgvector without an index good enough for 100,000 chunks, or do you need HNSW? And how does it compare with a numpy matrix multiply?
Short answer: unindexed pgvector took 37 ms per query, 5.2x slower than numpy (7.2 ms) on the same data. An HNSW index brought it to 1.0 ms at 0.955 recall@10, 7.2x faster than numpy. Exact scans are fine at this size if you can live with tens of milliseconds; the index costs recall.
Setup
- 100,000 vectors, 384 dimensions, normalized, built from 2,000 Gaussian clusters (closer to real embedding geometry than uniform noise; see the HNSW numbers in the earlier benchmark).
- 200 queries: random corpus vectors plus noise. Ground truth is exact numpy top-10.
- pgvector:
vector(384)column, cosine distance (<=>), prepared statement,LIMIT 10. Latency is measured from the Python client, so it includes the round trip over localhost and result parsing. - numpy:
X @ qplusargpartition, in-process. - HNSW:
m=16,ef_construction=64(the pgvector defaults), withhnsw.ef_searchswept.
All of this ran on a 2 vCPU machine, so absolute numbers will differ on yours. Compare the ratios.
Results
| Method | p50 | p95 | recall@10 |
|---|---|---|---|
| numpy exact | 7.15 ms | 7.56 ms | 1.000 |
| pgvector, no index | 37.22 ms | 39.95 ms | 1.000 |
| pgvector, no index, parallel workers allowed | 37.98 ms | 39.66 ms | 1.000 |
| pgvector HNSW, ef_search=10 | 0.67 ms | 1.05 ms | 0.613 |
| pgvector HNSW, ef_search=40 (default) | 0.99 ms | 1.72 ms | 0.955 |
| pgvector HNSW, ef_search=100 | 1.90 ms | 2.47 ms | 0.985 |
| pgvector HNSW, ef_search=200 | 3.50 ms | 4.26 ms | 1.000 |
Costs of the index: building it took 30.7 s (loading the 100k rows took 3.4 s, about 9x less), and it is 195 MB next to a 157 MB table, 1.24x the table size.
What the numbers mean
Why is the exact scan slower than numpy? Both do the same arithmetic over 100k x 384 floats. Postgres adds per-row executor overhead, reads from 8 KB pages instead of one contiguous array, and the query goes through a client protocol. I did not profile which part dominates, so treat that as the likely reason, not a measured one.
Parallel workers did not help. Setting max_parallel_workers_per_gather to 2 gave 37.98 ms vs 37.22 ms with 0. I did not check the query plan for each run, so I can’t say whether Postgres chose a parallel plan. With 2 vCPUs, one of them also running the client, there is little room to gain anyway.
HNSW recall depends on ef_search. At ef_search=10 you lose 38.7% of the true top-10. The default of 40 gives 0.955. Reaching 1.000 at ef_search=200 cost 3.5 ms, still about 2.0x faster than numpy but with more latency than the default.
Caveats
- Synthetic data. Real embeddings from your model may be easier or harder for HNSW; uniform random vectors are far harder.
- Single client, warm cache, no concurrent writes. Under load, or with a table bigger than
shared_buffersand RAM, the exact scan gets worse. - Only one dimension (384) and one size (100k). The earlier benchmark showed numpy scaling roughly linearly with both.
- No filtering. A
WHERE tenant_id = ...clause changes how HNSW behaves (it filters after the index scan, so it can return fewer than k rows). I did not test that here. - Default Postgres settings in Docker. Tuning
shared_bufferswould change the scan numbers.
Reproduce it
Start the container, then run the script from the Experiment files box below:
docker run -d --shm-size=1g --name pg -e POSTGRES_PASSWORD=pw -p 5433:5432 pgvector/pgvector:pg17
pip install "psycopg[binary]" pgvector numpy
python3 bench.py
The key queries:
CREATE INDEX ON items USING hnsw (emb vector_cosine_ops) WITH (m = 16, ef_construction = 64);
SET hnsw.ef_search = 40;
SELECT id FROM items ORDER BY emb <=> $1 LIMIT 10;
--shm-size=1g matters: with Docker’s default 64 MB shared memory my first HNSW build failed with “No space left on device”.
Rule of thumb
- Under about 100k chunks and a latency budget above 50 ms: pgvector with no index is exact and simple. Keep it.
- Need single-digit milliseconds, or more traffic: add HNSW and measure recall@10 against exact search on your own data before choosing
ef_search. - Already have the vectors in process memory and no need for SQL joins or filters: numpy is faster than unindexed pgvector.
Related: Do you need a vector database? covers the same question with numpy, faiss and hnswlib up to 1M vectors. For the basics of embeddings and indexes, see the free guide Embeddings & vector databases.
Experiment files
Get the free RAG production checklist
25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.