Repository navigation
Add IVF add and remove without the original data - #124
Merged
Merged
Conversation
IVF::add quantizes new points against the rotator and centroids the index already stores and appends them, so callers no longer need to keep raw vectors to grow an index. IVF::remove hides points from search by setting their additive factor to +inf, which every scan path turns into an infinite estimated and lower-bound distance. Neither call changes the index file format: removal is stored in the batch data and files that contain removed points still load and search correctly in v0.5.0. - fastscan::unpack_codes is the exact inverse of pack_codes, used to repack the partial tail batch of a cluster together with new points. - add builds the grown index next to the old one and swaps it in, so the index is unchanged if it throws. - Python: IvfIndex.add(data, cluster_ids=None, num_threads, fast_quantization) returns the new ids, IvfIndex.remove(ids) returns how many were newly removed. Ids and cluster_ids must have an integer dtype and are range checked as int64 so nothing wraps into a valid id. - Tests: pack/unpack round trip, byte-identical indexes for single, chunked, routed and threaded adds, existing distances unchanged after add, removal on every scan path (1-9 and 32 bits, L2 and IP, HACC on and off), persistence, error handling, and the Python boundary.
…ckends Address the review of the add/remove design: document that remove stores f_add = +inf, document the cost of add and recommend batching, verify search and save/load for L2 and inner product, bit widths 1 to 9 and 32, with and without high accuracy, and run the new tests under the emulated AVX2 and AVX-512 CPUs in CI.
…to avoid index changing with allocation err.
This was referenced Sep 30, 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
Adds
addandremovetoIVFandIvfIndex, so a built or loaded index can gain and lose points without the original vectors. This follows the design discussion in #123.The motivation is txtai (neuml/txtai#1251). Its backend has to keep a float32 copy of every vector because a built
IvfIndexcan't be extended or hide a point. For 20k vectors with 384 dimensions that copy is 30.88 MB of a 33.59 MB file, while the native 1-bit index is 2.70 MB.API:
addquantizes the new vectors against the rotator and centroids the index already stores. New points get the PIDsmax_elements() + i.cluster_idsis optional: without it each vector goes to its nearest centroid, chosen the way a query is routed. The partial last batch of each cluster is unpacked with the newfastscan::unpack_codes(the exact inverse ofpack_codes) and repacked with the new points. The grown index is built next to the old one and swapped in, so the index is unchanged ifaddthrows.removesetsf_add = +inffor each point. The estimated and lower-bound distances of that point are then+inf, so it never enters the result buffer and never triggers reranking.Saved-index format and compatibility: the format does not change, so no version discriminator is needed. No file written before this change has an infinite
f_add, so nothing in an old file is reinterpreted. A file with removed points loads in the published 0.5.0 wheel and never returns them (checked withnbits1, 4 and 32, L2). The convention is documented indocs/docs/index/ivf.mdand next to the persistence recipe inDEVELOPMENT.md.Costs and limits, documented in
docs/docs/index/ivf.md:addcopies the whole storage, so it needs memory for both copies and each call has a fixed cost that grows with the index. Adding many vectors per call is recommended. There is no spare capacity.removekeeps the storage of removed points and cannot be undone.Not in this change, as discussed in #123: spare capacity so small adds don't move data, and an explicit deleted bitmap instead of
+inf(which would need a format version).Testing
Commands run on Linux x86-64 (i7-12700KF, GCC 13), following CONTRIBUTING.md and AGENTS.md:
RABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF)python -m pytest tests/python -ra -qafter reinstalling the extension./scripts/check-format.sh(clang-format 15.0.7),./scripts/check-python.sh,git diff --check./scripts/check-tidy.sh build-tidy(clang-tidy 15, full)mkdocs build --strict.github/scripts/test_ci_changes.pyThe new tests cover L2 and inner product, bit widths 1 to 9 and 32, and
high_accuracyon and off:cluster_idsand with several threads gives byte-identical files.kPidMaxwith an infinite distance.saveandload, search matches the original index. A furtheraddon the loaded index keeps the removed points removed and gives the same results as the original index.I also checked that the tests can fail. Dropping the tombstone for 5 to 7 bit indexes only (an earlier version of the tests did not cover those widths) passes that earlier version and fails the extended tests. Each of these mutations makes at least one IVF or FastScan packing test fail: no tombstone, a finite tombstone (
FLT_MAX), the wrong nibble order inunpack_codes, new PIDs without the offset, new points quantized against the wrong centroid, and dropping the old tail vectors when repacking.Where the new C++ tests ran. CI runs are on my fork, with the same workflows: pma1999#1 (runs 36687459981 Test, 36687460043 Python Wheel, 36687460062 Release wheels, 36687459335 Documentation), all jobs green.
-hsw(local and the installed-consumer job)-icl(local and the installed-consumer job)tests/python(597 to 600 passed per platform for CPython 3.11).github/workflows/test.yamlnow addsIvfAddTest.*andIvfRemoveTest.*to the SDE filter, so the tombstone, add and save/load paths run with real AVX2 and AVX-512 dispatch. Locally that adds about 100 s to the-iclrun. The installed-consumer job took 347 s on the fork, against a 15 minute limit.Not run:
Measurements (1,000,000 vectors, 128 dimensions, 1,024 clusters, 1 bit, 8 threads, portable build, i7-12700KF; the benchmark scripts are not part of this PR, and I can share them):
addof 1,000 vectors in one call: about 5 ms. The same vectors one call each: about 3 s (2.8 s and 3.7 s in two runs).addof 100,000 vectors: 0.16 s.removeof 100,000 ids: about 3 ms.addof 1,000 vectors.nbits4,nprobe= clusters / 16): 0.897 recall@10 for an index built on 10% or 50% of the data and grown withadd, against 0.904 for a full rebuild. Starting from 1% gives 0.844. If the later vectors come from topics the first ones did not cover, recall falls to 0.52 to 0.74.Refs #123.