Contents
- pg_turbovec Benchmarks
- turbovec 1.1.1 staged search on Graviton4 (v2.11.0, 2026-10-05)
- Methodology
- Correctness gate (the headline result)
- Storage and build
- Recall-vs-latency frontier — pgvector HNSW
- Recall-vs-latency frontier — pg_turbovec
- AVX2 latency frontier (arnold, i9-12900H)
- IVF recall-vs-probes (host-independent)
- IVF warm-p50 (AVX2, floki — the latency win, confirmed)
- IVF latency frontier at scale, head-to-head vs HNSW + ivfflat (AVX2)
- Caveats
- Reproduction
pg_turbovec Benchmarks
Canonical, reproducible head-to-head benchmark page for pg_turbovec vs
pgvector. The goal is a standardized, VectorDBBench-style result on a public
corpus so the numbers are comparable to published ANN benchmarks — not a
bespoke single-host claim.
Status (2026-06-15): First standardized 1M run on a public corpus (Cohere wiki, 1024-d, real embeddings). Correctness, storage, build, and recall are measured and valid. The latency frontier for pg_turbovec is now measured on AVX2 hardware (
arnold, i9-12900H) – see AVX2 latency frontier. The original bench host (meh) is a pre-AVX2 Xeon, so turbovec there runs its scalar fallback (~1000x slower than its AVX2/AVX-512 SIMD kernels); that section is kept as the correctness/storage/recall evidence. See Caveats. The headline result this run establishes: recall@10 = 1.000 on the fixed v1.8.0 build at 1M × 1024-d real embeddings — the pre-AVX2 correctness fix works.
turbovec 1.1.1 staged search on Graviton4 (v2.11.0, 2026-10-05)
Flat index, 1M × 1024-d real Cohere embeddings, c8gd.8xlarge (Graviton4,
Neoverse-V2), PG 16.15 non-assert, warm whole-query EXPLAIN ANALYZE p50,
3 alternated rounds on one index (bytes identical across arms). Full method,
raw data, cold-backend and candidate-agreement numbers:
benches/results/tv111_arm_20261005/FINDINGS.md.
| bit_width | search_k | v2.10.3 (turbovec 1.0.0) | v2.11.0 (turbovec 1.1.1) | speedup | R@10 |
|---|---|---|---|---|---|
| 4 | 32 | 7.88 ms | 6.72 ms | 1.17× | 1.000 |
| 4 | 100 | 10.88 | 9.52 | 1.14× | 1.000 |
| 4 | 256 | 18.32 | 15.96 | 1.15× | 1.000 |
| 4 | 1024 | 62.17 | 54.57 | 1.14× | 1.000 |
| 2 | 100 | 10.45 | 9.78 | 1.07× | 1.000 |
| 2 | 1024 | 61.67 | 55.11 | 1.12× | 1.000 |
turbovec kernel alone (no PostgreSQL), same corpus and host, single query, 32
threads: 4-bit k=10 1.84 → 0.62 ms (3.0×), k=1024 8.29 → 1.28 ms (6.5×).
The end-to-end saving equals the kernel saving in milliseconds; what is left
(~4.5 ms + ~48 µs per candidate) is the heap fetch + exact recheck, which this
release does not change. Applies to aarch64 (dotprod) and x86 AVX-512
VBMI+VNNI only; AVX2-only x86 (including arnold) is unchanged.
Methodology
| Item | Value |
|---|---|
| Corpus | Cohere/wikipedia-22-12-en-embeddings, 1,000,000 rows, 1024-d, cosine, L2-normalized |
| Standard size | VectorDBBench “Medium” (1M). Real embeddings, not synthetic. |
| Held-out queries | 1,000 vectors (ids 1000000–1000999), held out of the index |
| Ground truth | Brute-force exact top-10 by cosine over all 1M rows. BLAS matmul, cross-checked against in-DB seqscan (enable_indexscan=off) — 10/10 overlap on sampled queries; reconstructed corpus byte-identical to the DB (max|diff| = 0.0) |
| Recall metric | recall@10 vs the exact GT, averaged over the held-out queries |
| Latency (pgvector) | server-side Execution Time from EXPLAIN (ANALYZE) |
| Latency (turbovec) | client wall over a unix socket; the in-engine scan dominates (>40s here), so the sub-ms cast/RTT term is negligible |
| Warm protocol | ≥1 warmup query (untimed), then N timed queries with fresh held-out vectors (never corpus members) |
| Host | meh: Intel Xeon E5-2697 v2 (Ivy Bridge), 24 cores, 125 GiB RAM, NixOS |
| SIMD | avx, sse4_1, sse4_2 — no avx2, no avx512 |
| PostgreSQL | 17.9 |
| pgvector | 0.8.0 |
| pg_turbovec | binary v1.8.0 (git 7d01a51, turbovec fork d3d468e) |
shared_buffers |
640 MB · maintenance_work_mem 8 GB · 8 maint. workers |
Indexes built: pgvector HNSW (m=16, ef_construction=64); pg_turbovec 4-bit;
pg_turbovec 2-bit. All on the same 1M-row heap (docs.emb vector(1024)); the
turbovec indexes use the expression cast (emb::real[]::turbovec.vector).
Correctness gate (the headline result)
Before any benchmarking, a correctness gate ran on a 10k × 128-d table of distinct random unit vectors: build a turbovec 4-bit index, compare the index top-10 against the brute-force top-10 for 20 fresh probes.
mean recall@10 = 1.0000 all top-10 sets distinct (10 ids each): True → PASS
And on the full 1M × 1024-d corpus, every pg_turbovec config returned recall@10 = 1.000 vs exact GT.
This matters because the previous run on this exact host (the old turbovec v0.7.0 / pg_turbovec v1.7.1 build) scored recall@10 = 0.0 here — the pre-AVX2 wrong-results bug. v1.8.0 fixes it. Confirmed on real 1024-d embeddings at 1M scale.
Storage and build
| Index | Build time | Size | vs HNSW |
|---|---|---|---|
| pgvector HNSW (m16, efc64) | 15:29 | 7,806 MB | — |
| pg_turbovec 4-bit | 08:20 | 1,026 MB | 7.6× smaller, 1.9× faster build |
| pg_turbovec 2-bit | 07:27 | 512 MB | 15.2× smaller, 2.1× faster build |
Heap: 5,332 MB (incl. TOAST) for 1M × 1024-d. pg_turbovec’s compact quantized codes are its clearest structural advantage and are CPU-independent.
Recall-vs-latency frontier — pgvector HNSW
ef_search sweep, 200 timed queries each. This frontier is valid and
AVX2-independent (it’s pgvector’s own SIMD, unaffected by turbovec’s kernel
path).
| Config | recall@10 | p50 (ms) | p95 (ms) | p99 (ms) | QPS (1 conn) |
|---|---|---|---|---|---|
| HNSW ef=40 | 0.849 | 9.4 | 20.3 | 25.0 | 96.7 |
| HNSW ef=100 | 0.926 | 13.1 | 22.4 | 25.1 | 74.2 |
| HNSW ef=200 | 0.957 | 17.3 | 32.4 | 41.3 | 53.9 |
| HNSW ef=400 | 0.979 | 20.1 | 38.2 | 48.2 | 46.6 |
Recall-vs-latency frontier — pg_turbovec
Recall is exact (1.000) at every config. pg_turbovec is a quantized full-scan (flat) index, so it does not trade recall for speed the way a graph index does — every query scores the whole corpus.
Latency on meh is the scalar-fallback FLOOR, not a representative
competitive number. Reported for completeness only:
| Config | recall@10 | p50 (ms) | basis |
|---|---|---|---|
| tv 2-bit, search_k=100 | 1.000 | 41,618 | scalar fallback (pre-AVX2) |
| tv 2-bit, search_k=500 | 1.000 | 42,014 | scalar fallback (pre-AVX2) |
| tv 4-bit, search_k=100 | 1.000 | 69,043 | scalar fallback (pre-AVX2) |
| tv 4-bit, search_k=1000 | 1.000 | 55,701 | scalar fallback (pre-AVX2) |
Note the fingerprint: latency is independent of search_k and identical
warm vs cold — the cost is the fixed O(n_vectors · dim) full-corpus
blocked-code scan, not I/O or candidate-set size.
Why turbovec is slow on this host (diagnosis)
meh has avx but no avx2. turbovec v0.9.0 correctly dispatches to its
scalar score_query_into_heap path (the same path whose correctness bug
v1.8.0 fixed). That path is right but ~1000× slower than the AVX2/AVX-512
kernels: the on-disk codes use a FAISS-style perm0-interleaved layout built for
the AVX2 kernel, so the scalar path must deinterleave_x86_code_byte per
byte, per vector — ≈256M de-interleave evaluations per query for 2-bit (more
for 4-bit) over 1M × 1024-d. EXPLAIN (ANALYZE) confirms it is an
Index Scan using docs_tv_* (not a seq scan): the time is genuinely inside the
turbovec scan kernel. On an AVX2/AVX-512 host the SIMD kernel runs and these
latencies are expected to fall to the tens-of-ms range.
Tunable recall frontier (oversampling)
The meh build (7d01a51) predates the turbovec.oversample feature, so this
run swept search_k only. Current main adds turbovec.oversample, which
fetches ceil(search_k · oversample) quantized candidates and re-ranks them by
exact distance — turning a fixed-quantization accuracy point into a tunable
recall frontier comparable to HNSW’s ef_search (and to Qdrant oversampling /
VectorChord rerank). A monotone recall-vs-oversample curve is verified in
benches/results/oversample_recall_curve_2026_06_15.json. Re-running this
1M frontier on an AVX2 host with the oversample sweep is the natural next step.
AVX2 latency frontier (arnold, i9-12900H)
Status (2026-06-15): The latency numbers
meh(pre-AVX2) could not produce, measured onarnold– a 12th Gen Intel i9-12900H withavx2+fma(noavx512; Alder Lake fuses it off). Same v1.9.0 binary (e2d49cf, turbovec forkd3d468e), same Cohere-wiki 1M x 1024-d corpus, same 1000 held-out queries, byte-identical ground truth (parquet shards md5-verified againstmeh; in-DB brute-force seqscan top-10 ==gt_top10.npywith overlap@10 = 1.00 on probe queries). pgvector 0.8.0, PG 17.9.
Correctness gate (AVX2 path): the 10k x 128-d distinct-vector sanity
check passed with mean recall@10 = 1.0000 over 20 probes, all top-10 sets
10 distinct ids. This confirms the AVX2 SIMD kernel (not just meh’s scalar
fallback) is correct on v1.9.0.
Isolation method (this is a busy shared box)
arnold runs the user’s interactive desktop + other agent sessions +
Discord/Firefox. The bench was insulated, not given priority:
- The dedicated bench postmaster (port 28815, socket
/scratch/pg_turbovec-bench, separate from the user’s clusters) was started undertaskset -c 2-5– four dedicated P-cores, away from cores 0-1 (kernel/IRQ-favored) and the E-cores 14-19. All backends inherit the CPU mask. The Python sweep driver pinned itself to the same cores. Defaultnice(negative nice needs privilege; CPU pinning is the lever – the goal is to insulate, not preempt the user). - Latency = server-side
Execution TimefromEXPLAIN (ANALYZE)for BOTH engines (the fair engine-to-engine number; excludes client RTT). - Warm protocol: 20 (pgvector) / 5 (turbovec) untimed warmup queries to warm the per-backend Arc cache + OS page cache, then timed.
- Contention measured per batch:
/proc/loadavg,/proc/statCPU busy/iowait/steal delta, and free RAM sampled before+after each timed batch. Per-query>3x-median outliers flagged; both raw and outlier-filtered p50/p95/p99 plus a 5% trimmed mean recorded. A batch is flagged contended if the observed 1-min load exceeded 1.5. - Query counts: 400 timed queries for the fast pgvector configs; 40 for the turbovec full-scan configs (~2.5-2.9s each, so 40 keeps wall-clock sane while the near-zero variance keeps the median stable).
Observed load during the timed windows stayed at ~0.3-1.05 (well under the
1.5 gate); contended_flag was False on all 14 configs, CPU steal ~0
(bare metal), turbovec batches had 0 outliers (p95 within ~3% of p50). No
batches were discarded or re-run. Full per-batch metadata is in
benches/results/latency_frontier_arnold_cohere_1m_v1_9_0_2026_06_15.json.
pgvector HNSW (AVX2, 400 timed queries)
| Config | recall@10 | p50 (ms) | p95 (ms) | p99 (ms) | QPS (1 conn) |
|---|---|---|---|---|---|
| HNSW ef=40 | 0.866 | 2.76 | 5.54 | 7.57 | 341 |
| HNSW ef=100 | 0.938 | 3.32 | 6.82 | 8.44 | 276 |
| HNSW ef=200 | 0.964 | 5.25 | 10.0 | 11.7 | 180 |
| HNSW ef=400 | 0.981 | 8.63 | 16.1 | 21.5 | 109 |
Recall matches the meh HNSW run closely (ef400 0.981 vs 0.979); the much
lower latency is just the faster CPU.
pg_turbovec (AVX2, 40 timed queries)
The AVX2 SIMD kernel runs here – ~15-25x faster than meh’s scalar
fallback (2-bit/k100: 2.55s here vs 41.6s on meh). But pg_turbovec is a
flat quantized full-scan, so even with AVX2 a query over 1M x 1024-d is
seconds, not tens of ms – and recall is exact (1.000) at every
config, including 2-bit.
| Config | recall@10 | p50 (ms) | p95 (ms) | p99 (ms) |
|---|---|---|---|---|
| tv 2-bit, search_k=100 | 1.000 | 2552 | 2604 | 2620 |
| tv 2-bit, search_k=200 | 1.000 | 2523 | 2575 | 2585 |
| tv 2-bit, search_k=500 | 1.000 | 2735 | 2759 | 2802 |
| tv 4-bit, search_k=100 | 1.000 | 2775 | 2852 | 2887 |
| tv 4-bit, search_k=200 | 1.000 | 2711 | 2734 | 2768 |
| tv 4-bit, search_k=500 | 1.000 | 2906 | 2934 | 2973 |
| tv 4-bit, search_k=1000 | 1.000 | 2854 | 2884 | 2918 |
Latency is flat across search_k (the O(n_vectors · dim) scan dominates;
search_k only sizes the result heap) – the same fingerprint meh showed,
now at the AVX2 floor.
Oversample frontier (4-bit, search_k=200)
| oversample | recall@10 | p50 (ms) |
|---|---|---|
| 1 | 1.000 | 2710 |
| 2 | 1.000 | 2661 |
| 4 | 1.000 | 2644 |
On this corpus the oversample lever has no recall headroom to recover:
both 2-bit and 4-bit already reach recall@10 = 1.000 at the smallest
search_k (100). 4-bit at oversample=1 already exceeds HNSW-ef400’s recall
(1.000 vs 0.981), so it never needs oversampling here. The oversample
mechanism is verified correct on a harder synthetic corpus (where base
recall < 1) by the in-tree #[pg_test]
oversample_recall_monotone_non_decreasing.
Headline: recall-vs-p50 at matched recall@10 >= 0.95 (AVX2)
| Engine | Config | recall@10 | p50 (ms) |
|---|---|---|---|
| pgvector HNSW | ef200 | 0.964 | 5.2 |
| pgvector HNSW | ef400 | 0.981 | 8.6 |
| pg_turbovec 2-bit | search_k=100 | 1.000 | 2552 |
| pg_turbovec 4-bit | search_k=100 | 1.000 | 2775 |
At the 1M x 1024-d scale, HNSW is ~490x faster at the warm p50 (5.2ms vs
2552ms) while turbovec is exact and 7.6-15.2x smaller on disk. They sit at
different points on the recall/latency/storage frontier: turbovec is a flat
index (exact recall, tiny storage, O(n) latency that grows with the corpus),
HNSW is a graph (approximate recall, large storage, sublinear latency). The
AVX2 result confirms turbovec’s SIMD path is correct and ~15-25x faster than
the scalar fallback, but does not make a 1M-row flat scan latency-
competitive with a graph index – it was never meant to be. turbovec’s pitch
is exact recall + compact codes, and at smaller corpora (or with a coarse
pre-filter) its per-query O(n·d) cost shrinks proportionally.
IVF recall-vs-probes (host-independent)
This is the recall/scan-work trade-off, measured without needing a quiet AVX2 host. Recall@10 is a function of which cells are probed vs where the true neighbours live — it is independent of SIMD speed — so this curve is reproducible on any host that builds the extension. It is the host-independent evidence that the
turbovec.probesdial trades recall for scan-work exactly as IVF is designed to. Absolute warm-p50 latency on AVX2 is a separate measurement (see AVX2 latency frontier for the flat-scan frontier). The IVF warm-p50 latency win is now confirmed on AVX2 (see IVF warm-p50 — ~5× vs full scan atprobes = 16), and the isolated head-to-head vs HNSW and ivfflat is now measured at 500k × 1024-d on a quietarnoldwindow (see IVF latency frontier at scale — at recall@10 ≥ 0.95, IVF p50 = 18.5 ms vs HNSW 7.9 ms; IVF wins the ≥ 0.99 tail). 1M+ IVF builds are blocked on out-of-core build (Phase B-4) — the build OOMs at 1M on a 31 GiB host. Theblocks_skipped_by_maskfraction below is the CPU-independent proxy for that latency win: a query that skips F% of the corpus’s 32-vector blocks does proportionally less scan work.
The frontier is produced by the ivf_recall_vs_probes_frontier #[pg_test]
(it both asserts the contract and writes the artefact). Corpus: 16,334
distinct deterministic pseudo-random unit vectors, 64-d, 4-bit, lists = 128
(≈√n), 50 held-out queries, brute-force exact top-10 ground truth
(enable_indexscan = off). Random unit vectors have no cluster structure,
so the curve is deliberately the hard case (true neighbours scatter across
cells); a clustered or real-embedding corpus rises faster for the same probes.
The curve shape (monotone, hits 1.0 at probes = lists, skips a large
block fraction at the low end) is scale-invariant; a larger corpus is the same
curve.
| probes | recall@10 | blocks scanned | blocks skipped |
|---|---|---|---|
| 1 | 0.078 | ~1.0% | 99.0% |
| 2 | 0.124 | ~2.0% | 98.0% |
| 4 | 0.200 | ~3.9% | 96.1% |
| 8 | 0.340 | ~7.7% | 92.3% |
| 16 | 0.528 | ~15.2% | 84.8% |
| 32 | 0.722 | ~29.5% | 70.5% |
| 128 (= lists) | 1.000 | 100% | 0.0% |
Artefact: benches/results/ivf_recall_vs_probes_2026-06-16.json.
The headline this delivers: “at probes = P, recall@10 = R while scanning
F% of the corpus” — e.g. at probes = 32 the scan touches ~30% of the blocks
for recall@10 = 0.72, and at probes = 1 it touches ~1% of the blocks. The
dial works. The contract test asserts (1) recall@10 is monotone
non-decreasing in probes, (2) recall(probes = lists) = recall(flat) ≈ 1.0
(probing every cell is the full scan), and (3) the low-probes end skips a
large fraction of blocks.
Soft multi-assignment (WITH (assign_dups = M), IVF-4) raises recall@10 at any
fixed probes by storing boundary vectors in their top-M nearest cells, at a
bounded storage cost — see Migrating from pgvector
and .
assign_dups > 1makes the index READ-ONLY. Storing one row in several cells means its external id appears in several slots, so the id table is not a bijection — and the insert path loads the index into a structure that requires one.INSERT/UPDATEon the table is REJECTED (clearly, namingassign_dups, since v2.8.4);SELECTis unaffected. Use it only for corpora you rebuild rather than append to.assign_dups = 1(the default) has no such restriction.
IVF warm-p50 (AVX2, floki — the latency win, confirmed)
A small in-process AVX2 warm-p50 test confirming the IVF cell-skipping
latency win that meh (pre-AVX2, scalar fallback) physically could not
measure. Host floki (Intel Core Ultra 7 258V, AVX2), v1.10.0 release
build, 200k × 256-d, lists = 448, 4-bit, warm cache (3 throwaway queries
before timing), 50 timed queries per probes via clock_timestamp() around
ORDER BY emb <=> q LIMIT 10.
| probes | warm p50 | p95 | vs full scan |
|---|---|---|---|
| 1 | 0.91 ms | 1.21 ms | |
| 4 | 0.74 ms | 1.09 ms | 5.4× faster |
| 16 | 0.78 ms | 0.97 ms | 5.1× faster |
| 64 | 1.16 ms | 1.36 ms | 3.4× faster |
448 (= lists, the exact full scan) |
3.97 ms | 4.21 ms | baseline |
At probes = 16, warm p50 is 0.78 ms vs the 3.97 ms full exact scan —
~5× faster, on AVX2, release. probes = lists (= 448) is the flat exact
scan baseline; IVF cuts it to sub-millisecond by skipping cells. This is the
latency win IVF was designed for, now demonstrated on AVX2 hardware.
Honest caveat: recall@10 = 1.000 at every probes (even probes = 1)
in this run is an artifact of the synthetic corpus’s strong cluster
structure (200 latent clusters; each query’s true neighbours all live in its
own cell). It is not a general recall guarantee — see the host-independent
recall-vs-probes frontier above (on a hard random corpus, recall climbs with
probes as designed). This bench measures latency honestly; the recall/probes
trade-off is the separate frontier. 200k × 256-d is small — absolute p50s grow
at 1M+ × 1024-d, but the probes-vs-full-scan ratio (the IVF win) is the
point. Lightly-loaded dev box, in-process (not the isolated taskset protocol
of the arnold run); indicative, not a published frontier.
Artefact: benches/results/ivf_warmp50_floki_avx2_2026-06-16.json.
IVF latency frontier at scale, head-to-head vs HNSW + ivfflat (AVX2)
Phase A-2 — the measurement that answers “does IVF beat/equal HNSW at scale?” IVF had only ever been measured at 200k in-process; this is the first isolated, contention-gated head-to-head against pgvector HNSW and ivfflat on the same corpus, same held-out queries, same brute-force GT.
Host arnold (i9-12900H, AVX2, 20 logical CPUs, 31 GiB RAM), v1.11.0
release build, isolated per the v1.9.1 protocol: postmaster + driver pinned
taskset -c 2-5 (off kernel/IRQ cores 0-1 and the user’s load), per-batch
contention sampling (loadavg / cpu busy-iowait-steal / free RAM), >3×-median
outlier filtering, warm cache (20 throwaway queries), 300 timed queries per
config, server-side Execution Time from EXPLAIN ANALYZE as the latency
basis. Corpus: Cohere-wiki 500k × 1024-d (cosine), 1000 fresh held-out
queries (ids ≥ 1,000,000, NOT corpus members), exact-cosine GT recomputed
against the 500k subset via BLAS (verified 1.000 overlap vs pgvector seqscan).
Observed 1-min load stayed 1.0–1.6 throughout; no batch was flagged
contended (gate 2.0), zero steal.
Why 500k and not 1M: the IVF lists>0 build is not yet out-of-core
(Phase B-4). At 1M × 1024-d it accumulates the full flat corpus (~4 GiB) plus
a permuted copy (~4 GiB) plus the k-means GEMM working set — a ~14 GiB peak
backend RSS that OOM-killed the postmaster on this 31 GiB host (twice,
at both 4 GiB and 1 GiB maintenance_work_mem — the peak is structural, not
bounded by maintenance_work_mem). 500k builds comfortably (~2.8 GiB peak).
This is itself a finding: the IVF query path works at 1M; the IVF build
caps the buildable corpus on a 31 GiB host at ~500k–600k until Phase B-4
(streaming build) lands. 5M is blocked on B-4.
Recall-vs-p50 frontier (500k × 1024-d, AVX2, warm, isolated)
| Engine | Config | recall@10 | warm p50 | p95 | p99 | QPS (1 conn) |
|---|---|---|---|---|---|---|
| pgvector HNSW | ef=40 | 0.839 | 28.2 ms† | 85.5 | 111.2 | 30 |
| pgvector HNSW | ef=100 | 0.930 | 8.9 ms | 20.1 | 23.2 | 104 |
| pgvector HNSW | ef=200 | 0.966 | 7.9 ms | 15.8 | 19.7 | 120 |
| pgvector HNSW | ef=400 | 0.983 | 9.6 ms | 17.2 | 20.6 | 99 |
| pg_turbovec IVF | probes=32 | 0.918 | 17.3 ms | 22.0 | 28.9 | 55 |
| pg_turbovec IVF | probes=64 | 0.960 | 18.5 ms | 28.8 | 29.6 | 51 |
| pg_turbovec IVF | probes=128 | 0.986 | 20.9 ms | 32.9 | 35.2 | 45 |
| pg_turbovec IVF | probes=256 | 0.990 | 25.3 ms | 36.9 | 41.7 | 37 |
| pg_turbovec IVF | probes=707 (=lists) | 1.000 | 41.4 ms | 54.7 | 57.8 | 23 |
| pg_turbovec flat | all cells | 1.000 | 41.4 ms | 43.5 | 44.8 | 24 |
| pgvector ivfflat | probes=10 | 0.796 | 13.8 ms | 20.4 | 31.4 | 70 |
| pgvector ivfflat | probes=50 | 0.942 | 60.1 ms | 73.2 | 80.0 | 17 |
| pgvector ivfflat | probes=100 | 0.978 | 117.4 ms | 133.2 | 137.4 | 9 |
| pgvector ivfflat | probes=200 | 0.994 | 227.2 ms | 248.3 | 256.3 | 4 |
† ef=40 p50 is inflated by cold-graph warmup outliers (17 dropped; filtered p50 = 25.7 ms); recall is the honest read at this ef.
Headline — at recall@10 ≥ 0.95 (min-p50 config per engine)
| Engine | Config | recall@10 | warm p50 |
|---|---|---|---|
| pgvector HNSW | ef=200 | 0.966 | 7.9 ms |
| pg_turbovec IVF | probes=64 | 0.960 | 18.5 ms |
| pgvector ivfflat | probes=100 | 0.978 | 117.4 ms |
| pg_turbovec flat (exact) | all cells | 1.000 | 41.4 ms |
Verdict (brutally honest): at matched recall@10 ≈ 0.96, HNSW wins — its
warm p50 is 7.9 ms vs IVF’s 18.5 ms, a ~2.3× advantage. IVF does NOT beat
HNSW on warm p50 at the 0.95 operating point. But the result is far better
than the worst case: IVF lands squarely in HNSW’s order of magnitude (tens
of ms, not hundreds), the turbovec.probes dial behaves exactly as designed
(monotone recall, smooth p50 ramp 17→41 ms), and IVF crushes ivfflat at every
matched recall (18.5 ms vs ~80–117 ms at 0.96–0.98) and beats its own flat
exact scan (18.5 ms vs 41.4 ms). The speculative “~40 ms projection” was
pessimistic: real IVF p50 at 0.95 recall is 18.5 ms.
Where IVF wins: the high-recall tail. HNSW (m=16, efc=64) never reaches recall@10 = 0.99 on this corpus (ef=400 tops out at 0.983), whereas IVF reaches 0.99 at probes=256 in 25.3 ms and 1.000 at probes=707 in 41.4 ms. For workloads that need ≥ 0.99 recall, IVF is both faster and higher- recall than this HNSW configuration, and dramatically faster than ivfflat (227 ms at 0.99). Plus IVF stays ~7.5× smaller on disk (518 MB vs 3902 MB HNSW / 3912 MB ivfflat).
Probes calibration on this corpus: IVF crosses recall@10 = 0.95 at probes ≈ 56–64 (p50 ≈ 18 ms) and recall@10 = 0.99 at probes ≈ 256 (p50 ≈ 25 ms).
Build time + storage at 500k (single-thread, taskset -c 2-5, pg17)
| Index | Build wall-clock | maintenance_work_mem |
Size | vs HNSW |
|---|---|---|---|---|
| pg_turbovec IVF (lists=707, 4-bit) | 6:21 (381 s) | 1 GiB | 518 MB | 7.5× smaller |
| pgvector HNSW (m=16, efc=64) | 4:13 (254 s) | 4 GiB | 3902 MB | — |
| pgvector ivfflat (lists=707) | 1:07 (67 s) | 2 GiB | 3912 MB | 7.6× larger than IVF |
The fast k-means (v1.11.0, ~7.8× faster than the prior path) made the IVF build feasible at all — the v1.9.0 build of the 1M index was killed after 54 minutes. At 500k the k-means + assignment completes in 6:21 single- threaded on 4 pinned cores. (Table: 7106 MB incl. TOAST.)
Artefact: benches/results/ivf_frontier_arnold_cohere-wiki_2026-06-16.json
— full 19-config sweep matrix + per-config contention metadata + build/storage
block + the 1M-build OOM finding.
The IVF build ceiling (motivates Phase B-4)
IVF build OOMs at 1M on a 31 GiB host because the build is not out-of-core.
The ivf_build_and_write path in src/index/build.rs holds the entire flat
corpus and a permuted copy in RAM simultaneously before quantization; peak
RSS ≈ 2 × (n × dim × 4 B) + GEMM scratch. On arnold the largest IVF index
that built successfully was 500k (~2.8 GiB peak); 1M (~14 GiB peak) was
OOM-killed. maintenance_work_mem does not help — the accumulation is
structural. This directly motivates Phase B-4 (streaming / out-of-core
build), without which 1M+ IVF builds require a larger-RAM host. The query
path is unaffected and works at 1M.
Caveats
- Single host, pre-AVX2 CPU.
mehis an Ivy Bridge Xeon (avx, noavx2). turbovec’s SIMD kernels (AVX2/AVX-512) do not run here; it takes the scalar fallback. The pg_turbovec latency numbers are a worst-case floor, not a ceiling, and are NOT representative. Storage, build, and recall are CPU-independent and valid. - Latency frontier for pg_turbovec is now measured on AVX2 (
arnold, i9-12900H) – see AVX2 latency frontier. Themehnumbers above are the pre-AVX2 scalar-fallback floor and remain here only as the correctness/storage/recall evidence. arnoldis a busy, RAM-constrained shared host. The AVX2 run was CPU-pinned (taskset -c 2-5) with per-batch contention measurement; observed 1-min load stayed <= ~1.05 and no batch was flagged contended. Build times onarnoldare NOT comparable tomeh(2GB vs 8GBmaintenance_work_mem, so HNSW spilled to disk); storage sizes ARE comparable and matchmeh.- pg_turbovec is a flat (quantized full-scan) index, not a graph index. It
delivers near-exact recall and tiny storage; its latency is
O(n)per query (SIMD-accelerated), versus HNSW’s sublinear-but-approximate traversal. The two occupy different points on the recall/latency/storage trade-off. - Extension catalog vs binary:
bench_wikireports the extension catalog at 1.7.1 (firstCREATE EXTENSION), but the loaded.sois v1.8.0 — the scan path lives in the binary, so all results were produced by v1.8.0. - No competitor beyond pgvector. VectorChord / pgvectorscale comparison is future work.
- 10M not run this round (budget); 1M is the priority standardized size. 5M / 1M IVF blocked on Phase B-4 (out-of-core build); the IVF frontier is published at 500k × 1024-d (the largest IVF index that builds on a 31 GiB host) — see IVF latency frontier at scale.
- Concurrent (pgbench) QPS not run; single-connection QPS captured for HNSW.
Reproduction
On a host with the v1.8.0+ binary installed (ideally AVX2+ for representative turbovec latency):
# 1. Build/install pg_turbovec from main (pgrx must match Cargo.toml's pgrx pin)
cargo pgrx install --release --pg-config <pgrx pg_config>
# 2. Schema + load 1M Cohere-wiki rows (1024-d), hold out 1000 queries
psql -d bench_wiki -f setup_schema.sql
python3 load_wiki_1m.py --corpus 1000000 --held 1000 # binary COPY pipe
python3 load_queryset.py # held-out -> query_set
# 3. Exact brute-force ground truth (BLAS; cross-check vs in-DB seqscan)
python3 compute_gt_blas.py
# 4. Build indexes (HNSW m16/efc64, turbovec 4-bit, turbovec 2-bit)
psql -d bench_wiki -f build_indexes.sql
# 5. Sweep frontiers
python3 sweep_1m.py --which pgv --n-timed 200 --out res_pgv.json
python3 sweep_tv_lean.py --bits 2 --ks 100,500,1000 --out res_tv2.json # drop the other tv index first
python3 sweep_tv_lean.py --bits 4 --ks 100,1000 --out res_tv4.json
Artifacts (this run): benches/results/vectordbbench_cohere_wiki_1m_v1_8_0_20260615.json.
The bench scripts used on meh live in /scratch/pg_turbovec-bench/
(load_wiki_1m.py, load_queryset.py, compute_gt_blas.py,
setup_schema.sql, build_indexes.sql, sweep_1m.py, sweep_tv_lean.py,
sanity_check.py).