$ cd ../blog

~/rivestack/blog $ cat pgvector-performance-nvme-vs-cloud-ssd-benchmarks.md

pgvector Benchmarks, Per Tier: Honest, Reproducible Numbers from Solo to Scale

/Rivestack Team/ 18 min read
#pgvector#benchmarks#NVMe#performance
pgvector Benchmarks, Per Tier: Honest, Reproducible Numbers from Solo to Scale

Most pgvector "benchmarks" are marketing. They quote a single headline number — "2,000 QPS!" — with no recall, no concurrency, and no way to reproduce it. So you can't tell whether it was 0.50 recall at 64 clients on a 64-core box, or something you could actually run.

This post is the opposite. Here is exactly how our dedicated nodes perform — across three tiers, from the cheapest 2-core box up to an 8-core node — measured honestly, and here is how to reproduce it. Every throughput number below comes with the recall it was measured at and the client concurrency it ran under, because without those two numbers a QPS figure means nothing.

We're a bootstrapped product. Our whole pitch is that you can run a real pgvector workload on a small, cheap, dedicated node — so the credible thing to do is show you the real curve for each tier, including where it breaks. This started life as a single-node (Solo) post; we've expanded it into a per-tier guide so you can size the node by your actual index, not by a hopeful guess.

The nodes we benchmarked#

We benchmarked three dedicated tiers. They share the same software, region, and storage — they differ only in cores and RAM, which is exactly the axis you're choosing along:

Parameter Solo / Starter Growth Scale
vCPU 2 4 8
RAM 4 GB 8 GB 16 GB
Storage Local NVMe Local NVMe Local NVMe
Region Hetzner EU, Falkenstein Hetzner EU, Falkenstein Hetzner EU, Falkenstein
PostgreSQL 17.10 17.10 17.10
pgvector 0.8.2 0.8.2 0.8.2
shared_buffers 1 GB 2 GB 4 GB
Honest fast-search capacity (1536d) ~300K ~600K ~1M

Solo (and Starter, the $35 tier that adds an LB/HA in front of the same per-node spec) is two cores and four gigabytes. Growth doubles both. Scale doubles them again. No oversized benchmark rig, no 64-core machine anywhere in this post. If you've been wondering which of these is enough for your vector search, this is the post that answers it with numbers instead of adjectives — and the short version is that the right tier is set mostly by how big your index is.

Methodology#

Client and network path

The benchmark client ran on a separate VM in the same region (also Falkenstein), connecting to the node over the public endpoint with TLS (sslmode=require). That's deliberate: it's exactly the path a customer's same-region application takes. We did not benchmark over a local Unix socket to make the numbers look better than what you'd actually get.

The exact entry point differs by tier, so the network floor does too. On Solo, the client connects through PgBouncer (port 6432, transaction pooling). On Growth and Scale, it connects through the load balancer (port 5432) that fronts the HA pair. To isolate each path's overhead, we measured a bare SELECT 1 round trip from the same-region client:

SELECT 1, same region, TLS:
  Solo via :6432 (PgBouncer)      p50 = 0.38 ms
  Growth/Scale via :5432 (LB)     p50 = 0.91 ms

So the network + pooling/LB + TLS overhead a same-region app adds is about 0.4 ms on Solo and about 0.9 ms on Growth/Scale (the LB path costs a bit more than the direct pooler hop). That's the floor under every latency number on the respective tier. Co-locate your app in the same region as the database — a cross-continent client would add roughly 90 ms of round-trip time per query, which would dominate everything else here. None of the numbers below are achievable from another continent.

Dataset

The vectors are synthetic but clustered: a 1536-dimensional Gaussian mixture, normalized for cosine distance. We're being upfront about that — it isn't a public embedding set. We use a Gaussian mixture (clustered) rather than uniform random noise specifically so that recall is representative of real embedding structure, where vectors form clusters. Real embeddings may recall a touch higher than what you see here, because true semantic data is often more cleanly clustered than a synthetic mixture. Uniform random vectors, by contrast, make recall look artificially easy and aren't representative of anything.

The index is HNSW with m=16, ef_construction=64 (the pgvector defaults for 1536-dim work). Queries are perturbations of real data points, and recall@10 is measured against exact brute-force KNN ground truth — we compute the true nearest neighbors with a full scan and check how many the approximate index actually returns.

Why recall@k is non-negotiable

Approximate nearest-neighbor search is a tradeoff: you trade recall for speed. The hnsw.ef_search knob controls it. Set it low and queries are fast but miss neighbors; set it high and you get near-exact results but pay in latency and throughput.

This means a QPS number without a recall number is meaningless. "2,000 QPS" at recall 0.75 and "2,000 QPS" at recall 0.96 describe completely different products. Every result below is reported as a (recall, concurrency, QPS, latency) tuple for exactly this reason.

Solo (2 vCPU / 4 GB): 250,000 × 1536 vectors#

A 250k × 1536 index fits hot in cache on the Solo/Starter node, so this is the headline Solo result — the node serving an index that lives in memory. We swept hnsw.ef_search (which sets recall) and client concurrency (which trades latency for throughput). The per-tier sections that follow keep the same recall-anchored shape, so you can read across tiers at a fixed recall.

Throughput and recall by ef_search

ef_search recall@10 4 clients (QPS) 16 clients (QPS)
10 0.75 1,300 1,980
20 0.80 1,272 1,927
40 0.88 1,126 1,683
80 0.93 999 1,568
120 0.96 904 1,339
200 0.99 299 (see below)

Reading the table: at recall 0.93 (ef_search=80), the node does about 1,000 QPS at 4 clients and about 1,570 QPS at 16 clients. Push recall up to 0.96 (ef_search=120) and throughput drops to 904 QPS at 4 clients / 1,339 QPS at 16 clients. That's the recall-vs-throughput tax, on real hardware.

Latency by concurrency

Latency is the other half of the story, and it's why you can't read throughput in isolation. Here's ef_search=10 (recall 0.75) across the concurrency curve:

Clients QPS p50 p95 p99
1 324 3.1 ms
4 1,300 2.9 ms 4.3 ms 5.2 ms
8 1,713 4.5 ms
16 1,980 8.0 ms 11.2 ms

And ef_search=80 (recall 0.93), the operating point we'd actually recommend:

Clients QPS p50 p95 p99
1 286 3.5 ms
4 999 3.7 ms 5.9 ms 7.1 ms
8 1,345 5.8 ms
16 1,568 10.0 ms

A couple of other measured points worth having on the record: ef_search=40 (recall 0.88) does 324 QPS at p50 3.1 ms single-client, 1,472 QPS at p50 5.2 ms at 8 clients, and 1,683 QPS at p50 9.4 ms (p95 13.5 ms) at 16 clients.

The edge: recall 0.99 collapses

We're including the failure point because hiding it would defeat the purpose. At ef_search=200 (recall 0.99), throughput collapses on this node:

Clients QPS p50
1 25 36 ms
16 299 43 ms

That's roughly a 10x latency increase (p50 36 ms vs ~4 ms at recall 0.96 single-client) and a 4x-plus throughput drop at 16 clients (299 QPS vs 1,339). What happens: at ef_search=200 the per-query working set spills past cache, and queries go disk-bound. On a 2 vCPU / 4 GB node, chasing recall 0.99 with HNSW isn't free — it's a cliff. If you genuinely need 0.99 recall at volume, you want a bigger node (more RAM to keep the larger working set hot) or exact search on a pre-filtered candidate set. Knowing where the cliff is matters more than pretending it doesn't exist.

Smaller-index reference: 100,000 × 1536

For reference, a smaller 100k × 1536 index (also hot) at recall 0.94 (ef_search=40) does about 325 QPS at p50 3.0 ms single-client and about 1,700 QPS at p50 9.2 ms at 16 clients. Smaller index, slightly higher recall at the same ef_search, similar shape.

Growth (4 vCPU / 8 GB): more cores, bigger buildable index#

Growth doubles Solo's cores and RAM. The extra RAM is what lets it build and hold a 500k index hot (Solo can't — more on that below), and the extra cores raise throughput without the 2-core latency cliff. Here's Growth at the same recall-0.93 operating point on 250k, plus the 500k index it can actually serve.

Growth, 250,000 × 1536 (recall 0.93)

ef_search recall@10 8 clients (QPS) 16 clients (QPS)
120 0.93 1,280 (p50 5.6 ms) 1,716 (p50 8.5 ms)

At recall 0.93 (ef_search=120), Growth does about 1,280 QPS at p50 5.6 ms (8 clients), scaling to about 1,716 QPS at p50 8.5 ms (16 clients).

Growth, 500,000 × 1536 (fits hot)

ef_search recall@10 8 clients (QPS) 16 clients (QPS)
120 0.88 1,380 (p50 5.6 ms) 1,797 (p50 8.7 ms)
200 0.90 1,506 (p50 10 ms)

On the bigger 500k index, recall 0.88 (ef_search=120) does about 1,380 QPS at p50 5.6 ms (8 clients) and about 1,797 QPS at p50 8.7 ms (16 clients). Push recall to 0.90 (ef_search=200) and you get about 1,506 QPS at p50 10 ms (16 clients) — the usual recall-for-throughput tax. Growth's honest fast-search capacity is ~600K; like Solo, it will not build a 1M index (it OOMs on the 8 GB node — see the build-ceiling section).

Scale (8 vCPU / 16 GB): the tier that does high QPS and low p50 — and the only one that serves 1M#

Scale doubles Growth again. Two things change qualitatively here: with 8 cores you get high throughput and low p50 at the same time (no concurrency cliff), and with 16 GB you can finally build and serve a 1M × 1536 index hot.

Scale, 250,000 × 1536 (recall 0.93)

ef_search recall@10 8 clients (QPS) 16 clients (QPS)
120 0.93 1,458 (p50 5.4 ms) 2,468 (p50 6.3 ms)

At recall 0.93 (ef_search=120), Scale does about 1,458 QPS at p50 5.4 ms (8 clients) and about 2,468 QPS at p50 6.3 ms (16 clients). Note the shape: going from 8 to 16 clients on Scale raises QPS (1,458 → 2,468) while p50 barely moves (5.4 → 6.3 ms). On Solo, the same concurrency jump bought throughput only by paying it back in latency. That's the 8-core difference.

Scale, 1,000,000 × 1536 (the headline upgrade — fits hot)

This is the result Solo and Growth can't produce at all: a 1M × 1536 index, built in about 30 minutes and fitting hot in Scale's ~12 GB cache.

ef_search recall@10 16 clients (QPS)
80 0.75 2,504 (p50 5.9 ms)
200 0.85 1,953 (p50 8 ms)

At recall 0.75 (ef_search=80), Scale serves the million-vector index at about 2,504 QPS at p50 5.9 ms (16 clients). Raise ef_search to 200 for recall 0.85 and you get about 1,953 QPS at p50 8 ms (16 clients).

The honest caveat at 1M: with m=16, recall caps around 0.85 at ef_search=200 on this dataset — turning ef_search higher buys diminishing recall, not a path to 0.95. If you need higher recall at a million vectors, the lever is the graph itself: raise m (e.g. m=24 or m=32) and rebuild, which costs more build memory and time, or accept the higher ef_search cost. We're stating that plainly rather than implying you can dial any recall you want at this size. Scale's honest fast-search capacity is ~1M; a 2M × 1536 index will not build (out of memory) even on the 16 GB node.

The two tradeoffs, stated plainly#

1. Throughput vs. latency is a concurrency choice — and cores decide how hard it bites

On two cores (Solo) you do not get sub-4 ms p50 and 2,000 QPS at the same time. You pick one end of the curve:

  • Low concurrency (~4 clients): sub-4 ms p50, ~1,000–1,300 QPS depending on recall.
  • High concurrency (16 clients): ~1,600–2,000 QPS, but p50 climbs to ~8–10 ms.

Both are real, measured, on the same node. Neither is "the" number. Anyone who quotes you a single "X QPS at Y ms" as if both happen simultaneously is selling you the peak of two different rows of the table.

More cores soften this tradeoff — and Scale removes it. On Solo's 2 cores the concurrency knob is strictly throughput-or-latency. On Scale's 8 cores it largely isn't: at recall 0.93 on 250k, going from 8 to 16 clients takes QPS from ~1,458 to ~2,468 while p50 moves only 5.4 → 6.3 ms. That's ~2,500 QPS at p50 ~6 ms simultaneously — the thing two cores can't do. Solo tops out around ~2,000 QPS only by accepting p50 8–10 ms; Scale clears 2,500 QPS while staying near 6 ms. If your problem is "high QPS without a latency cliff," that's a core-count problem, and the fix is Scale, not a knob.

2. Throughput vs. recall is the ef_search knob

Recall is set by hnsw.ef_search, and higher recall costs throughput and latency. The honest headline operating point for this node is recall@10 = 0.93 (ef_search=80): about 1,000 QPS at p50 3.7 ms (4 clients), scaling to about 1,570 QPS at 16 clients. If your product tolerates recall 0.88, you get more throughput; if you need 0.96, you get less. Quote the recall with the QPS, always.

The build ceiling: HNSW builds are memory-bound, per tier#

This is the finding that should drive your tier choice more than QPS does. Building an HNSW index is memory-bound, and each tier has a hard size beyond which the build either takes absurdly long or doesn't complete at all. The search numbers don't matter if the index won't build.

Here's the measured build ladder, per tier:

Tier (RAM) Builds fine Builds, but slow Will NOT build
Solo — 4 GB 250k (~11 min) 500k (~4.2 hours, memory-starved) 1M (~6 GB — out of memory)
Growth — 8 GB 250k (~90s), 500k (~40 min) 1M (OOM on 8 GB)
Scale — 16 GB 250k (~60s), 1M (~30 min, fits hot in 12 GB cache) 2M (out of memory)

Read the Solo row carefully: a 500k index technically builds on 4 GB, but it takes about 4.2 hours because the build is memory-starved and thrashing — that's not a usable build, it's a warning. And a 1,000,000 × 1536 index (~6 GB) does not build on Solo at all — it fails with an out-of-memory error. It's not slow; it doesn't complete. Growth builds 500k comfortably (~40 min) but still OOMs at 1M on 8 GB. Only Scale, with 16 GB, builds 1M (~30 min) and holds it hot — and even Scale won't build 2M. We're telling you this directly because it's exactly the kind of thing a benchmark post normally buries.

The rule that falls out of this: size the node by your index's build memory, not by your QPS target. A tier that can serve your query load is useless if it can't build your index first.

If your index is bigger than the tier you want can build, you have three honest options:

  • A bigger tier — Growth gets you a buildable 500k; Scale gets you a buildable, hot 1M. The capacity ladder below tells you which.
  • A disk-based vector index like pgvectorscale's StreamingDiskANN. Unlike HNSW, it's designed to keep most of the index on disk with bounded build memory, so it sidesteps the in-RAM build ceiling entirely — the right tool once your set outgrows what fits in a node's RAM.
  • Sharding or partitioning the dataset so each index stays within a node's memory budget.

What you should not do is expect a 4 GB node to build or serve a million-vector HNSW index. It won't, and now you know before you try it.

Cross-tier takeaway: cores buy latency-free throughput, RAM buys index size#

Two independent levers, and the per-tier numbers above separate them cleanly:

  • More cores remove the QPS/latency tradeoff. Solo's 2 cores force you to choose: high QPS or low p50, never both (it ceilings around ~2,000 QPS only at p50 8–10 ms). Scale's 8 cores do ~2,500 QPS at p50 ~6 ms simultaneously. If you're throughput-bound and latency-sensitive, you're buying cores.
  • More RAM raises the buildable and hot index size. HNSW builds are memory-bound, and an index only serves fast when it fits hot in cache. 1M × 1536 only works on Scale because only 16 GB can both build it (~30 min) and hold it hot. If you're size-bound, you're buying RAM.

The measured capacity ladder (in-RAM fast-search, 1536-dim) that summarizes where each tier tops out:

Tier RAM Honest fast-search capacity (1536d)
Free 2 GB (shared) ~100K
Solo / Starter 4 GB ~300K
Growth 8 GB ~600K
Scale 16 GB ~1M

The takeaway in one line: 1M+ vectors belong on Scale, both because it's the only tier that builds them and because it's the only one that serves them hot. Below that, pick the tier whose capacity covers your index, then confirm it builds before you commit.

Why NVMe matters once the index spills RAM#

Every per-tier result above is hot — we deliberately sized each index to fit in its tier's cache (250k on Solo, up to 500k on Growth, up to 1M on Scale), so storage barely touches the query path. That's the whole point of the capacity ladder. But the moment an index (or a query's working set at high ef_search, as in the Solo recall-0.99 case above) spills out of RAM, every cache miss becomes a random read from disk. That's where the storage layer starts to define your tail latency.

This is a mechanism, not a benchmark we ran head-to-head — we did not measure pgvector on cloud block storage, so we won't quote you competitor QPS. But the mechanism is real and worth understanding:

  • HNSW search is a graph traversal — a series of random pointer chases between nodes. It never reads pages sequentially during queries. It's random access all the way down.
  • General-purpose cloud block storage (e.g. AWS gp3) provides on the order of 3,000 baseline IOPS. Local NVMe provides orders of magnitude more.

So once the index no longer fits in cache, a storage layer capped at a few thousand random IOPS becomes the bottleneck — every graph hop that misses cache waits on a random read, and HNSW does a lot of hops. Local NVMe has an order-of-magnitude headroom there. This is why we run on NVMe, and it's the part of the story that only shows up at scale, after the index outgrows RAM. For small indexes that fit in memory — like the 250k case here — storage is largely irrelevant, and we won't pretend otherwise.

Gotcha: ef_search over PgBouncer transaction pooling#

This one bites people on every tier, and it's worth its own section because the benchmark client hit it too. It applies anywhere a PgBouncer transaction pool sits in front of your queries — that's the Solo :6432 path directly, and any pooled app in front of the Growth/Scale :5432 LB.

Over PgBouncer transaction pooling, a plain session-level SET hnsw.ef_search is silently dropped between statements. In transaction pooling mode, your connection is handed back to the pool at the end of each transaction, so a SET you ran earlier may apply to a different backend — or no longer apply at all — by the time your query runs. No error. Your recall just quietly isn't what you set — which means every QPS-vs-recall number in this post is only meaningful if ef_search actually applied to the query that produced it.

The fix is to scope the setting to the transaction with SET LOCAL, in the same transaction as the query:

BEGIN;
SET LOCAL hnsw.ef_search = 80;
SELECT id
FROM items
ORDER BY embedding <=> $1
LIMIT 10;
COMMIT;

SET LOCAL lives and dies with the transaction, so it's guaranteed to apply to the backend running your query and nothing else. Alternatively, set a database-level default so every connection inherits it:

ALTER DATABASE mydb SET hnsw.ef_search = 80;

If you're benchmarking pgvector through a pooler and your recall numbers look randomly inconsistent, this is almost always why.

Reproduce this yourself#

You shouldn't trust these numbers because we published them. You should trust them because you can reproduce them. We built a CLI for exactly this:

pgvector-bench →

Point it at any PostgreSQL + pgvector endpoint and it will build the index, generate the clustered dataset, compute brute-force ground truth, sweep ef_search, and report QPS with recall@k and p50/p95/p99 per concurrency level. To reproduce the per-tier results above, use:

  • Dataset: clustered Gaussian mixture, normalized (cosine), 1536-dim. Size it to the tier you're testing — 250k on Solo, up to 500k on Growth, up to 1M on Scale (the capacity ladder above).
  • Index: HNSW, m=16, ef_construction=64
  • ef_search sweep: 10, 20, 40, 80, 120, 200
  • Concurrency: 4, 8, and 16 clients
  • Client: a separate VM in the same region as the database, with TLS (sslmode=require), connecting over the tier's real entry point — :6432 (PgBouncer transaction pooling) on Solo, :5432 (the LB) on Growth/Scale. Confirm the network floor first with a bare SELECT 1 (expect ~0.38 ms on Solo's pooler, ~0.91 ms on the Growth/Scale LB).
  • ef_search application: scope it with SET LOCAL in the query's transaction (see the gotcha above), or your recall won't be what you think it is.

Run it against the matching Rivestack tier and you should land within run-to-run noise of the tables above. Run it against your own pgvector setup and you'll get your curve — which is the one that actually matters for your decision. Full methodology and the exact reproduce steps live at /blog/pgvector-performance-nvme-vs-cloud-ssd-benchmarks.

Bottom line#

Don't trust marketing benchmarks. That includes ours. The right way to size a pgvector deployment is to run your embeddings, at your target recall, under your concurrency, against the tier you're actually considering — and read the whole curve, not a single number.

What we can tell you honestly, per tier, all over the real same-region path a customer's app would use:

  • Solo (2 vCPU / 4 GB, $35 with HA): serves 250k × 1536 hot at recall 0.93, ~1,000 QPS at p50 3.7 ms (4 clients), up to ~1,570 QPS at 16 clients. Builds 250k in ~11 min; can't build 1M. Honest capacity ~300K. Two cores mean throughput or low latency, not both.
  • Growth (4 vCPU / 8 GB): serves 250k at recall 0.93, ~1,716 QPS at p50 8.5 ms (16 clients), and 500k at recall 0.88, ~1,797 QPS at p50 8.7 ms (16 clients). Builds 500k in ~40 min; can't build 1M. Honest capacity ~600K.
  • Scale (8 vCPU / 16 GB): serves 250k at recall 0.93, ~2,468 QPS at p50 6.3 ms (16 clients) — high QPS and low p50 at once — and is the only tier that builds (~30 min) and serves 1M × 1536 hot, at recall 0.75, ~2,504 QPS at p50 5.9 ms (16 clients). Honest caveat: at 1M with m=16, recall caps ~0.85 at ef_search=200; for more, raise m and rebuild. Honest capacity ~1M; won't build 2M.

The cross-tier shape: more cores buy throughput without a latency cliff; more RAM buys a bigger buildable, hot index. 1M+ vectors belong on Scale. Size by your index first, your QPS second.

If you want to run the benchmark yourself, grab pgvector-bench. If you want managed PostgreSQL on NVMe with pgvector pre-installed, PgBouncer included, and these specs documented up front, see Rivestack managed pgvector — or start with the free tier and benchmark it against your own data.

# keep reading