Contents
- Scaling pg_turbovec to billions of vectors with partitioning
- 1. Partition sizing: 10M–50M vectors per partition
- 2. Concurrent per-partition build (the key build-time lever)
- 3. Insert routing (native, nothing to do)
- 4. Per-partition VACUUM and REINDEX (native, sharded, online)
- 5. Query patterns
- 6. Partition pruning at large N (Phase S-1 — planned, next release)
- Cross-node distribution (Phase S-3, beyond this doc)
Scaling pg_turbovec to billions of vectors with partitioning
Status: Phase S-0 cookbook — usable today with zero AM changes. Partition pruning for very large partition counts is Phase S-1 (see the last section).
pg_turbovec scales to 1–10 billion vectors on a big box or a small
cluster, today, with no new extension code, by combining three things that
already work:
- Declarative PostgreSQL hash partitioning — one parent table, N child partitions, native tuple routing on insert.
- One
turbovecindex per partition — each is an independent relfile with its own IVF codebook; per-partition build, VACUUM, and REINDEX. - PostgreSQL’s native
Merge Append— the parent-tableORDER BY emb <=> q LIMIT kquery fans out to the per-partition index scans and merges them into the correct global top-k, lazily (it pulls only as many rows from each partition as the global top-k needs).
The scatter→gather→global-merge that sounds like it needs the most new code
needs none: it is PG’s native partition-wise plan over the existing
amcanorderbyop order-by-operator scan. This was verified against a
single-table exact top-k — overlap 10/10, and the merge pulled 13 rows total
across 4 partitions for a global top-10 (not 10 per partition). The worked
example is benches/poc/scatter_gather_partitioned_topk.sql.
The index is not the biggest cost at extreme scale — the heap is. At 1024d/2-bit the index is ~277 B/vector (~277 TB at 1T), but the raw f32 heap is ~4 KB/row (~4 PB at 1T). For billions of vectors, store
halfvec(2 KB/row) or keep only quantized codes as the source of truth and put raw vectors in cold object storage for the rare exact re-rank. That is an architecture decision above the index AM; this doc covers the index side.
1. Partition sizing: 10M–50M vectors per partition
Pick the partition count so that one partition is a bounded, testable build
unit. A 10M-vector / 1024d / lists=4096 IVF partition builds in a measured
~26 min and occupies ~5.3 GB. That is the recommended unit:
| Corpus | Partitions @ 10M each | Partitions @ 50M each |
|---|---|---|
| 100M | 10 | 2 |
| 1B | 100 | 20 |
| 10B | 1 000 | 200 |
| 1T | 100 000 | 20 000 |
- Smaller partitions (10M) = more build parallelism headroom, smaller blast radius for a rebuild, but a larger per-query fan-out (→ §6, Phase S-1).
- Larger partitions (50M) = fewer index opens per query, but each build is a longer, less-parallel unit.
- Do not exceed ~50M/partition until the 100M single-partition build is validated (the linear 10M→100M extrapolation is currently unverified). One partition should always be a build unit you can rebuild in bounded time.
- Set
lists ≈ √(rows_in_partition)per partition (the usual IVF rule):lists = 4096for 10M,≈ 7000for 50M.
Choose the modulus for hash partitioning = your partition count:
CREATE TABLE docs (id bigint, emb turbovec.vector) PARTITION BY HASH (id);
-- 100 partitions for a 1B corpus @ 10M each:
CREATE TABLE docs_000 PARTITION OF docs FOR VALUES WITH (MODULUS 100, REMAINDER 0);
CREATE TABLE docs_001 PARTITION OF docs FOR VALUES WITH (MODULUS 100, REMAINDER 1);
-- ... through REMAINDER 99
Generate the DDL rather than hand-typing it:
SELECT format(
'CREATE TABLE docs_%1$s PARTITION OF docs FOR VALUES WITH (MODULUS 100, REMAINDER %2$s);',
lpad(r::text, 3, '0'), r)
FROM generate_series(0, 99) r \gexec
2. Concurrent per-partition build (the key build-time lever)
Each partition’s CREATE INDEX is fully independent — separate relation,
separate relfile, separate k-means, no shared state. So a partitioned build is
embarrassingly parallel: wall = per_partition_build × ceil(P / concurrency).
This is the single biggest build-time win and it needs no extension code, only
orchestration.
On one N-core box, run min(N_cores / build_threads, P) builds concurrently.
CREATE INDEX already uses rayon threads inside one backend, so give each
concurrent build a slice of the cores (turbovec respects the rayon pool; run
a few concurrent builds rather than one per core).
# Build every partition's index, C at a time. Each psql runs one CREATE INDEX.
C=8 # concurrent builds; tune to cores / build_threads
DB=mydb
psql -d "$DB" -Atc \
"SELECT c.relname
FROM pg_inherits i JOIN pg_class c ON c.oid = i.inhrelid
WHERE i.inhparent = 'docs'::regclass ORDER BY c.relname" \
| xargs -P "$C" -I{} psql -d "$DB" -c \
"CREATE INDEX IF NOT EXISTS {}_emb_idx ON {} USING turbovec (emb vec_cosine_ops) WITH (lists = 4096);"
Across a cluster, run this per node over that node’s partitions — there is no
stitch step (unlike the graph kind’s partitioned build): Merge Append is
the “stitch”, done per query, for free.
Projected 1T build wall (100 000 × 10M partitions, ~26 min each):
| Concurrency | Build wall |
|---|---|
| 1 at a time | ~1 800 days ❌ |
| 32-core node, 8 concurrent | ~224 days ❌ |
| 100-node cluster × 8 concurrent = 800× | ~2.2 days ✅ |
| 1000-node × 8 = 8000× | ~5.4 h ✅ |
1T is inherently a cluster-scale build. Partitioning is what makes the
cluster fan-out embarrassingly parallel. amcanbuildparallel (intra-partition
PG parallel workers) is the wrong lever at scale — cross-partition
parallelism already saturates all cores; it only helps single-partition
rebuild latency.
3. Insert routing (native, nothing to do)
INSERT INTO docs routes each row to the correct child by hash(id) — this is
core PostgreSQL tuple routing, not pg_turbovec’s concern. The per-child
aminsert is the existing deferred-commit path.
Caveat (unchanged from the single-index case): turbovec’s aminsert is an
O(n)-per-row whole-relfile rewrite at commit (build-then-serve model). At scale
this is actually cheaper than a monolith — an insert rewrites only its target
partition’s relfile (10M rows), not the whole corpus. Still, for heavy
ingestion: bulk-load, then CREATE INDEX, or accept the per-partition
rewrite cost, or REINDEX the churned partition periodically.
4. Per-partition VACUUM and REINDEX (native, sharded, online)
VACUUM docscascades to each child (ambulkdelete+amvacuumcleanupper partition, the generic tombstone-bitmap path — IVF and flat both supported). Autovacuum treats each partition as its own relation with its own thresholds, so vacuum work is naturally sharded and parallel.REINDEX INDEX docs_042_emb_idxrebuilds exactly one partition’s index while every other partition keeps serving. This is the online-migration story: a wire-format bump rolls out partition-by-partition, and theREINDEX-required ERROR (fromambeginscan) fires per partition. A 1T monolith would need one catastrophic 277 TB REINDEX; the partitioned design reindexes 10M at a time, online, rolling.
VACUUM (VERBOSE) docs_042; -- one partition
REINDEX INDEX CONCURRENTLY docs_042_emb_idx; -- rebuild one, others keep serving
5. Query patterns
(a) Native parent-table query — use this (small-to-moderate N)
SET turbovec.probes = 16; -- per-partition IVF recall knob
SELECT id FROM docs
ORDER BY emb <=> $q -- cosine; <-> L2, <#> inner product
LIMIT 10;
Plans as:
Limit
-> Merge Append
Sort Key: (docs.emb <=> $q)
-> Index Scan using docs_000_emb_idx on docs_000
-> Index Scan using docs_001_emb_idx on docs_001
... (one per partition)
Each per-partition scan is the ordinary turbovec order-by-op scan and
streams candidates in distance order; Merge Append k-way-merges them and the
outer Limit stops early. Recall holds exactly — the merge is over exact
distances (xs_recheckorderby makes each stream exact), so the global top-k is
the true global top-k. This needs no pg_turbovec code beyond what already
ships.
(b) Explicit UNION-ALL fallback — only when the planner won’t do (a)
Some queries (older PG, or a filtered predicate that defeats the partition-wise
Merge Append) fall back to a per-partition LIMIT k then an outer merge:
WITH per_partition AS (
(SELECT id, emb <=> $q AS d FROM docs_000 ORDER BY emb <=> $q LIMIT 10)
UNION ALL
(SELECT id, emb <=> $q AS d FROM docs_001 ORDER BY emb <=> $q LIMIT 10)
-- ... one per partition
)
SELECT id FROM per_partition ORDER BY d LIMIT 10;
This is byte-identical in result to (a) but strictly worse: it forces each
partition to fully produce its own top-k (k rows each, k·N total) before the
outer merge — it cannot stop a partition early. Use it only where (a) does not
plan. benches/poc/scatter_gather_partitioned_topk.sql runs both and proves
they match a single-table exact top-k.
6. Partition pruning at large N (Phase S-1 — planned, next release)
The native all-partitions Merge Append opens one Index Scan node per
partition. That is fine up to ~a few thousand partitions; at N ≈ 10 000+ the
O(N) open/plan cost alone is seconds, and 1T (100 000 partitions) is
unusable. The planned fix is a two-level IVF: a per-partition summary so a
query probes only the Kp nearest partitions instead of all N.
Status: partition pruning is designed but not yet shipped — it lands in a later release (Phase S-1), validated on real PostgreSQL. Until then, the free scatter-gather in §1–§5 (native
Merge Append, no new code) scales to a few thousand partitions (comfortably 1–10B vectors). What follows is the design, not a currently-callable API.
The design: a per-partition summary = the partition’s mean vector in original
(un-rotated) space. (Partitions train independent rotation matrices, so their
persisted per-partition centroids are not cross-comparable across partitions;
the mean is, and is scored with the same <=>/<->/<#> operator the user
queries with.) A refresh_partition_summary(parent, vec_col) computes + stores
them in a derived catalog table (additive; no wire-format change), and a
nearest_partitions(parent, query, Kp, metric) selector returns the Kp
nearest-mean partitions; the client (or, later, native runtime pruning) then
fans out only to those. This turns the O(N) executor fan-out into O(Kp):
query cost ≈ (flat scan over the summaries, ms) + Kp ×
per-partition IVF scan + merge. Recall depends on Kp exactly as IVF recall
depends on probes — more probed partitions, higher recall, monotone.
Recall / pruning tradeoff — read this before pruning
Pruning to Kp < N can miss a true neighbour if it lives in a partition whose
mean is not among the Kp nearest to the query. This matters for how you
partition:
- Hash-partitioned by
id(the default): every partition is a uniform random sample of the corpus, so every partition’s mean ≈ the global mean and the true neighbours are spread evenly across all partitions. Summary-based pruning then does not reliably preserve recall unlessKpis large — hash partitioning is for scatter-gather throughput and build parallelism, not for pruning. - Content-clustered partitions (range/list-partitioned by a semantic key,
or hash-partitioned then re-clustered so each partition holds a coherent
region of vector space): a partition’s mean is representative of its
contents, so the
Kpnearest-mean partitions contain the true neighbours and pruning is (near-)lossless.
Guidance: use hash partitioning for the free scatter-gather (§1–§5) up to a
few thousand partitions; when you need pruning at large N, cluster your
partitions by content and set Kp from a recall sweep, exactly as you’d set
IVF probes.
What remains (Phase S-1 follow-up)
- Native runtime partition pruning wiring. Today the selector returns
partition
regclasses and the client fans out (via UNION-ALL of the chosen children, or aWHERE-based restriction). Driving PostgreSQL’s native runtime partition pruning directly from the selector result (so the parentMerge Appendopens only theKpchildren) is a planned follow-up. - Sublinear summary search. The selector currently flat-scans the summaries
(
O(N·dim), single-digit ms at N=100k). A centroid-graph over the summaries (reusing the Phase G-1ivf::CentroidGraph) makes selection sublinear if the flat scan itself becomes the bottleneck. - Persisted / auto-refreshed summaries. The summary is refreshed manually today. Triggering refresh on bulk-load completion is a follow-up.
Cross-node distribution (Phase S-3, beyond this doc)
True 1T needs the partitions to live on separate hosts: partitions become
foreign tables via postgres_fdw (or Citus shards), and the parent’s
Merge Append fans out across nodes. The single-node partitioned design in
this doc is the substrate for that — same DDL, same query patterns, partitions
just move off-box.