Partitioned HNSW for large vector corpora: design and benchmarks (up to 1M×960d) #9806
Replies: 1 comment
Update: the bulk loader now builds partitioned indexesOne of the "not yet supported" items above is done: How it worksNormally So the build is split into two stages:
The result is a ready-to-serve partitioned index straight out of Three things keep that second pass cheap: sampled k-means (centers trained on a bounded, RAM-capped sample rather than repeated full-corpus scans), a routing memo (each vector's cluster is computed once and reused across the index passes instead of recomputed every pass), and parallel per-cluster commits. ResultsTwo datasets, monolithic vs partitioned, identical index parameters. Recall@10 is measured against exact euclidean brute-force ground truth.
dbpedia-openai = 1M real OpenAI A note on the build-time gap: these numbers are at badger's default settings. Part of the monolithic slowdown is I/O — the logs show the 1M-node graph doesn't fit the default block cache ( But tuning wouldn't erase the gap, because the real reason is structural: a monolithic insert has to search a graph of all N nodes, and that search only gets more expensive as the corpus grows. A partitioned insert only ever touches its own ~N/k-node cluster graph (~1000 nodes at k=1000), which stays small and cheap no matter how big the corpus gets. So partitioning's build advantage is inherent to the data structure, not just a caching effect. Higher recall is the usual IVF effect — probing ~40 small cluster graphs covers more of the true neighbourhood than one greedy walk at Exactly how it was run
These are single runs per config at 1M on a development branch, so treat the exact multipliers as one operating point rather than a tuned frontier — Still not supported for partitioned specsExport, predicate move, and incremental backup — those remain follow-ups. Feedback welcome, especially from anyone bulk-loading large real-world embedding corpora. |
Uh oh!
There was an error while loading. Please reload this page.
We've been working on the partitioned HNSW vector index (PR #9469 lineage) — getting it correct and benchmarking it honestly across three dataset scales. This post shares the design and the numbers (including a 1M-vector run with exact ground truth). Feedback welcome, especially on the scale trade-offs.
What it is (in one paragraph)
Plain HNSW builds one large proximity graph over all vectors. Partitioned HNSW is IVF-over-HNSW: k-means splits the vectors into
numClustersclusters, builds one small HNSW graph per cluster, and persists the cluster centroids. A query compares against the centroids, then searches only the nearestnumProbesclusters and merges the results. It is not a reimplementation — each cluster is a real HNSW graph built by the existing engine; partitioning is a ~1000-line routing/orchestration layer on top.What works today
Four core pipelines are implemented and covered by unit + integration tests:
numClusterschange and re-hydrating routing state after an alpha restart)similar_tocentroid routing, cross-cluster fan-out and merge; per-queryef/distance-threshold options)Everything outside those four is not supported yet (see Known limitations). The intent is to land the correct core first and add the remaining surface incrementally.
Benchmark setup
harshil-goel/split-vector3, single zero + alpha via docker compose, 24 GB machine (Docker VM limit 23.4 GB).go_memstats_heap_inuse_bytes) + RSS sampled every 250 ms; build time = alter →opIndexingcleared.maxLevels:3, efSearch:32, efConstruction:64, metric:euclidean. Partitioned addsnumClusters ≈ √N(400 at 171k, 1000 at 1M);numProbesdefaults tonumClusters/25(16, 40).Result 1 — index-build memory (the headline)
Peak heap above the post-restart baseline:
At 1M the monolithic build peaked at 23.4 GiB — flush against the machine's ceiling — while the partitioned build peaked at 12.5 GiB in a steady per-pass sawtooth (build a slice of clusters → commit → free → next).
Why the saving grows with scale. Build memory ≈ a roughly-flat machinery floor (badger memtables, caches, GC churn — paid by both) + the held graph state (edges + uncommitted deltas) that partitioning splits across passes. At 171k the held state is ~0.5 GiB — splitting it barely moves the total. At 1M it's ~17+ GiB and dominant, and it grows super-linearly. One corpus-size step further, the monolithic build simply won't fit on ordinary hardware while the partitioned peak stays bounded by pass size. The −12% at small scale and −50% at 1M are the same mechanism at different points on that curve. (171k reproduced within ~0.05 GiB across two runs.)
Result 2 — accuracy
At identical ef params, partitioned was more accurate — and dramatically so on GIST (the classic hard case). One greedy walk through a 1M-node graph at
efSearch=32collapses to 0.29 recall; 40 independent searches over ~1k-node cluster graphs explore far more of the true neighborhood and hold 0.75. Routing correctness was verified separately: every indexed vector returns itself as top-1 (self-recall = 1.00), zero errors across ~1.5M vectors indexed.Result 3 — build time
Par at both scales. (Side-finding: the rebuild path is ~11× faster than inserting into a live index — 2.5 min vs 29 min at 171k, either config. "Load data, then add the index" is the cheaper bulk-ingestion strategy.)
Result 4 — query latency (the trade-off)
numProbesis a latency↔recall dial. At 171k with moderate probes, partitioned matched monolithic latency and beat its NDCG. At 1M the 40-probe setting bought the 2.6× recall at a latency cost. Note a same-recall comparison would force the monolithicefSearchmuch higher — slower and hungrier at build time — so at matched params partitioned wins memory and accuracy and pays in latency.Where partitioning does NOT help
Correctness work that made this measurable
The partitioned index required a fix stack before it was functional: wiring the (previously dead) centroid search-routing, persisting + re-hydrating centroids across restarts, routing deletes to per-cluster dead lists, rebuild data hygiene (dropping stale per-cluster keys, detecting
numClusterschanges), and a build-commit durability fix (a retry bug silently dropped cluster graphs in non-alpha processes). Unit + integration coverage now spans all four supported pipelines (build, rebuild, mutation, query) from a starting point of 0%.Known limitations / not yet supported
Try it out
This is early, experimental work and we'd love more eyes on it. Play with it on your own datasets, and let us know how it behaves — feedback, suggestions, and contributions are all very welcome.
All reactions