Repository navigation
feat(index): train centroids inside IVF and HNSW indexes - #203
Merged
Merged
Conversation
Add a trained-but-empty state to IVF and HierarchicalNSW so callers can train or set centroids on the index and then add points, without running k-means themselves. - IVF: a constructor without a point count (dim, num_clusters, total_bits, metric, seed, ...), is_trained(), train(n, x, cp) and set_centroids(centroids). train uses RaBitQKMeans up to kMaxRaBitQKMeansClusters (2000, provisional and unmeasured) and QGKMeans above it. - HierarchicalNSW: is_trained(), train(num_clusters, n, x, cp) and set_centroids(num_clusters, centroids). The first add into an empty graph links points with their exact vectors, as construct does. - index/training.hpp: default_training_parameters(metric, seed) with 25 iterations, at most 256 sampled points per centroid and spherical k-means for inner product. - LloydKMeans::train_centroids trains the same centroids as train but skips the final assignment of all points. train and set_centroids replace centroids only while the index stores no points and leave the index unchanged on failure. Saving an index without stored points now throws std::logic_error; for HNSW this includes an untrained empty index, which earlier releases saved. Existing constructors, construct and the persisted formats are unchanged.
- IVF: train and set_centroids now throw std::logic_error for an index created with a point count, which construct builds; they no longer reset its max_elements(). Document that construct fails on an index from the new constructor until add has stored points, and that search on a trained index without points rejects every k. - HNSW: construct on a trained index reports "HNSW index is already trained, constructed or loaded" (also asserted in the Python test). - Copy the parameters the Lloyd driver reads through one helper, clustering::detail::copy_shared_parameters, so the QGKMeans path cannot silently drop a shared field. - IVF/HNSW train leave training-data validation to k-means, which applies the same finiteness and range check in parallel, instead of an extra serial pass; development.md records the exception.
This was referenced Nov 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
First of three PRs that move the Python indexes to a Faiss-style
train→add→searchworkflow, so callers no longer run k-means themselves and pass centroids in. This PR adds the C++ side: a "trained but empty" state for IVF-RaBitQ and HNSW-RaBitQ, centroid training on the index, andaddinto an empty trained index. The Python API (PR 2) and SymphonyQG (PR 3) follow separately.IVF
IVF(dim, num_clusters, total_bits, metric, seed, rotator_type, initializer_type). It seeds the rotation explicitly and is not meant forconstruct.train(n, x, cp)trainsnum_clusters()centroids and sets them asset_centroidsdoes. It usesRaBitQKMeansup tokMaxRaBitQKMeansClusters(2000) clusters andQGKMeansabove it. The QGKMeans path copies the fields the two parameter types share and keeps the default graph settings. The 2000 threshold is provisional and has not been measured.set_centroids(centroids, num_threads)builds the centroid routing and empty clusters, soaddworks on the index.is_trained().HNSW
train(num_clusters, n, x, cp)(RaBitQKMeans),set_centroids(num_clusters, centroids)andis_trained().addinto an empty graph links points with their exact vectors, using the same code asconstruct(split out aslink_exact). With one thread it produces the same index asconstructwith the same centroids and cluster IDs. If it fails, the graph is left empty and the centroids are kept. Later adds keep linking through the stored codes, as before.Shared
include/rabitqlib/index/training.hpp:default_training_parameters(metric, seed)gives 25 iterations, at most 256 sampled points per centroid (the k-means classes keep their own default of 0), and spherical k-means for inner product.LloydKMeans::train_centroids(n, x)trains the same centroids astrainbut skips the final assignment of allnpoints, which the indexes do not need;addassigns points anyway. It leavesassignments/distancesempty andfinal_objzero;assign()still works. The cost saving is an argument from complexity (one extra full-data pass), not a measurement.State rules
trainandset_centroidsreplace the centroids only while the index stores no points (removed points count); otherwise they throwstd::logic_error. A failed call leaves the index unchanged.addthrowsstd::logic_erroron both indexes, and IVFsearchthrows as before. HNSWsearchon an empty index, trained or not, still returns no results (existing C++ contract, relied on byHnswConstructionAllocationTestandModulesStaticInitialization). The Python layer in PR 2 will raise there.Compatibility
std::logic_error("... has no points to save; add points first"). For HNSW this includes an untrained, empty index, which earlier releases saved as an empty file. Such files still load. This keeps every newly saved index trained when loaded.adderrors before training (and IVFsearch's) now read "must be trained, constructed or loaded". The Python wrappers check state first, so Python messages are unchanged.constructkeep their signatures and behavior. The old IVF constructor now shares validation with the new one; when several arguments are invalid at once, whichstd::invalid_argumentis reported first can differ.RaBitQKMeansParameters; they include no clustering, encoding or rotator headers.Testing
All checks ran on this commit (based on
6ba97a8), on Linux x86-64, GCC 14.3,RABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF:--no-tests=error): 483/483 passed (468 onmainplus 15 new). One test (ThreadCount.BoundParallelForUsesRuntimePlacementAndPropagatesErrors) skipped becauseOMP_PROC_BINDwas not set, as onmain../scripts/check-tidy.sh(library and Python bindings, clang-tidy 23) passed../scripts/check-format.shandgit diff --checkpassed. No new compiler warnings compared withmain../scripts/check-isa-symbols.shpassed (43 translation units);hnsw.hppis included by the ISA-flagged HNSW search sources.tests/consumerbuild and test, 3/3 passed.mkdocs build --strictpassed.index/ivf.mdandindex/hnsw.mdgain a short C++ "Training centroids" section; the Python docs change in PR 2.IvfTrainTest(7) andHnswTrainTest(5):add/search/savebefore training, and the index is unchanged and usable afterwards;set_centroids + addproduces byte-identical saved indexes toconstructwith the same rotation, for L2/IP and 1/4/9/32 bits, with explicit and routed cluster IDs;set_centroidsplus a single-threaded firstaddproduces byte-identical saved indexes toconstruct, for L2/IP, 1/4/9 bits and both quantization modes;add, and the empty-save error;RaBitQKMeanscentroids exactly, and 2001 reproducesQGKMeans. Changing<=to<in the threshold makes this test fail;KMeansTrainCentroidsTest(2):train_centroidsmatchestraincentroids and objectives for both classes, with and without sampling and in both final modes; it leaves the assignments empty, andassignstill matches.HnswFirstAddAllocationTest(1): every allocation failure in the firstaddkeeps the centroids and an empty graph, and a retry succeeds.niterchoice will be measured separately.