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, single-tenant 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 post, and 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 single-tenant tiers. They share the same software, region, and storage, and differ only in cores and RAM, which is exactly the axis you're choosing along:
| Parameter | 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) | ~350K | ~600K | ~1M |
Starter, the $49 tier that adds an LB/HA in front of a 2 vCPU / 4 GB node, 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 Starter, 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:
Starter via :6432 (PgBouncer) p50 = 0.38 ms
Growth/Scale via :5432 (LB) p50 = 0.91 msSo the network + pooling/LB + TLS overhead a same-region app adds is about 0.4 ms on Starter 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.
Starter (2 vCPU / 4 GB): 250,000 × 1536 vectors#
A 250k × 1536 index fits hot in cache on the Starter node, so this is the headline Starter 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 | 16 clients (QPS) |
|---|---|---|
| 80 | 0.93 | ~1,600 |
Reading the table: at recall 0.93 (ef_search=80), the node does about 1,600 QPS at 16 clients. Higher ef_search buys recall at a throughput cost, and lower ef_search trades recall for QPS. That is the same recall-vs-throughput tax you'll see on every tier below.
Latency by concurrency
Latency is the other half of the story, and it's why you can't read throughput in isolation. At the recommended ef_search=80 (recall 0.93) operating point, throughput climbs with concurrency while p50 rises alongside it: on two cores you buy QPS by paying it back in latency. The single-client p50 sits in the low-single-digit-millisecond range, and by 16 clients, where the node reaches about 1,600 QPS, p50 has climbed into the ~10 ms range. That is the shape to internalize: on two cores you do not get peak throughput and the lowest p50 at the same time.
The edge: recall 0.99 collapses
We're including the failure mode because hiding it would defeat the purpose. Chase recall 0.99 with ef_search=200 and throughput collapses on this node: the per-query working set spills past cache and queries go disk-bound, so latency jumps by roughly an order of magnitude and throughput drops several-fold. 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 the cliff is there matters more than pretending it doesn't exist.
Growth (4 vCPU / 8 GB): more cores, bigger buildable index#
Growth doubles Starter's cores and RAM. The extra RAM is what lets it build and hold a 500k index hot (the smaller tiers can't, more on that below), and the extra cores raise throughput without the 2-core latency cliff. Here's Growth at a high-recall operating point on 250k.
Growth, 250,000 × 1536
ef_search |
recall@10 | 16 clients (QPS) |
|---|---|---|
| 120 | 0.94 | ~2,950 |
At recall 0.94 (ef_search=120), Growth does about 2,950 QPS at 16 clients on the 250k index, well over the Starter tier at a comparable recall, which is the four-core difference. Growth's honest fast-search capacity is ~600K; 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
ef_search |
recall@10 | 16 clients (QPS) |
|---|---|---|
| 120 | 0.95 | ~4,465 |
At recall 0.95 (ef_search=120), Scale does about 4,465 QPS at 16 clients on the 250k index. That's the 8-core difference: Scale sustains high throughput at high recall where the two-core Starter tier has to trade one for the other.
Scale, 1,000,000 × 1536 (the headline upgrade, fits hot)
This is the result Starter and Growth can't produce at all: a 1M × 1536 index (~6 GB), built in about 9 minutes and fitting hot in Scale's cache.
ef_search |
recall@10 | 16 clients (QPS) |
|---|---|---|
| 80 | 0.74 | ~3,600 (p50 4.2 ms) |
At recall 0.74 (ef_search=80), Scale serves the million-vector index at about 3,600 QPS at p50 4.2 ms (16 clients).
The honest caveat at 1M: with m=16, recall stays low on this dataset even as you raise ef_search. Turning the knob 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 (Starter) you do not get the lowest p50 and peak throughput at the same time. You pick one end of the curve: low concurrency gives you single-digit-millisecond p50 at modest QPS, while pushing to 16 clients drives QPS up to about 1,600 (recall 0.93) but climbs p50 into the ~10 ms range.
Both ends 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 Starter's 2 cores the concurrency knob is strictly throughput-or-latency. On Scale's 8 cores it largely isn't: it sustains roughly 4,465 QPS at recall 0.95 on 250k at 16 clients without the two-core latency cliff. 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 the Starter node is recall@10 = 0.93 (ef_search=80): about 1,600 QPS at 16 clients. If your product tolerates lower recall, you get more throughput; if you need higher recall, 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 |
|---|---|---|---|
| Starter (4 GB) | 250k (~5 min) | 500k (hours, memory-starved) | 1M (~6 GB, out of memory) |
| Growth (8 GB) | 250k (~2 min), 500k (~40 min) | — | 1M (OOM on 8 GB) |
| Scale (16 GB) | 250k (~76s), 1M (~9 min, fits hot in cache) | — | 2M (out of memory) |
Read the Starter row carefully: a 500k index technically builds on 4 GB, but it takes 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 Starter at all: it fails with an out-of-memory error. It's not slow; it doesn't complete. Growth builds 500k comfortably but still OOMs at 1M on 8 GB. Only Scale, with 16 GB, builds 1M (~9 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. Starter's 2 cores force you to choose: high QPS or low p50, never both (it ceilings around ~1,600 QPS at recall 0.93 only by letting p50 climb into the ~10 ms range). Scale's 8 cores sustain ~4,465 QPS at recall 0.95 without that cliff. 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 (~9 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) |
|---|---|---|
| Shared | shared CPU / small RAM | ~100K |
| Solo | 2 GB | ~250K |
| Starter | 4 GB | ~350K |
| 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 Starter, 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 Starter 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 Starter :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:
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 Starter, up to 500k on Growth, up to 1M on Scale (the capacity ladder above).
- Index: HNSW,
m=16,ef_construction=64 ef_searchsweep: 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 Starter,:5432(the LB) on Growth/Scale. Confirm the network floor first with a bareSELECT 1(expect ~0.38 ms on Starter's pooler, ~0.91 ms on the Growth/Scale LB). ef_searchapplication: scope it withSET LOCALin 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:
- Starter (2 vCPU / 4 GB, $49 with HA): serves 250k × 1536 hot at recall 0.93, ~1,600 QPS at 16 clients. Builds 250k in ~5 min; can't build 1M. Honest capacity ~350K. Two cores mean throughput or low latency, not both.
- Growth (4 vCPU / 8 GB): serves 250k at recall 0.94, ~2,950 QPS at 16 clients. Builds 500k in ~40 min; can't build 1M. Honest capacity ~600K.
- Scale (8 vCPU / 16 GB): serves 250k at recall 0.95, ~4,465 QPS at 16 clients (high QPS and low p50 at once) and is the only tier that builds (~9 min) and serves 1M × 1536 hot, at recall 0.74, ~3,600 QPS at p50 4.2 ms (16 clients). Honest caveat: at 1M with
m=16, recall stays low even at highef_search; for more, raisemand 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