Repository navigation
feat(index): add index format 2 for every index - #222
Merged
Merged
Conversation
Add a draft specification of the shared index container planned for 0.9 (#212): header, section table, feature flags, record layouts, companion files, reading order and compatibility rules. Nothing in it is implemented yet.
Add the container layer of index file format 2 (#212): the header and section table encoding, a zlib-compatible CRC-32, a writer that emits the file front to back without seeking, and a reader that validates the header, feature flags, expected index kind and section table in the order the specification gives. No index uses it yet. Update the draft specification with the rules the implementation settled: empty sections, the check order, padded_dim at most 65,536, reserved section kind 0, and what a resave drops.
IVF-RaBitQ save writes index format 2 (#212): one file whose sections hold the rotator blob, the routing parameters and data, the codes, the rerank data and the IDs. HNSW centroid routing becomes a section, so save no longer writes a <file>.hnsw sidecar. Load dispatches on the magic and keeps reading every earlier layout, including their sidecars. - ivf::Initializer replaces save/load on file streams with write(std::ostream&) and read(std::istream&, bytes). - The vendored hnswlib gains stream overloads of saveIndex and loadIndex; the path overloads delegate to them. - The rotator blob codec and a zlib-compatible CRC-32 move to private core sources for C++ readers and writers. - The format-2 reader reads each bulk section front to back, and the HNSW graph check tracks a byte budget instead of querying positions. - Freeze IVF files written by v0.8.3 (RABQIDX1 v2, Flat RaBitQ and HNSW routing with sidecars) as release-produced fixtures. - Document the IVF sections, the HNSW graph layout and the IVF reader rules in the specification, and note the changes for users.
The HNSW load benchmark still called the removed Initializer save and load; write and read the graph file through the stream interface, with the file open inside the timing as before. The legacy-sidecar test named its directory with a narrow literal, which MSVC encodes in the code page; use a UTF-8 literal.
The first-add test compares saved files, which now include the HNSW routing graph. Training the explicit index with several threads built that graph in a nondeterministic order, so the files differed on CI; train it with one thread, like the implicit first add. Document that the routing graph is reproducible only when built with one thread.
Before HNSW-RaBitQ and SymphonyQG move to index format 2, freeze files their v0.8.3 writers produce: HNSW at 1, 5 and 9 bits with removals and upper layers, and SymphonyQG raw, 4- and 8-bit with FHT/Kac and matrix rotation. A separate generator writes them, so the committed IVF fixtures keep their bytes.
HNSW-RaBitQ (sections 512-516) and SymphonyQG (sections 768-770) now save index format 2, like IVF-RaBitQ. Loading dispatches on the magic and keeps reading their earlier layouts; HNSW-RaBitQ shares its graph checks between both readers. - Every index records its construction seed, and SymphonyQG also its ef_construction and initialization, so Python keeps them after load. New C++ accessors report them: IVF::seed(), HierarchicalNSW::seed(), QuantizedGraph<float>::seed(), ef_construction() and initialization(). QGInitialization moves to qg.hpp. - Unknown read-only-compatible features make every index read-only: HNSW-RaBitQ refuses add, resize, remove and save; SymphonyQG refuses save until a builder rebuilds it. - A failed HNSW-RaBitQ write reports "HNSW index file cannot be written". - Tests that patched freshly saved files at old offsets now damage format-2 fields, and the earlier layouts' checks run on copies of the v0.8.3 fixtures. - Document the HNSW-RaBitQ and SymphonyQG reader rules and the seed of each index in the specification, and format the fixture generator.
MSVC requires a lambda to capture a local constexpr variable it reads, which GCC and Clang do not.
Every index load throws rabitqlib::FormatError, a std::runtime_error in the new public header rabitqlib/index/format_error.hpp, for a truncated, corrupt, mismatched or unsupported file in format 2 and every earlier layout. Failing to open a file stays a plain std::runtime_error. Python raises it as rabitqlib.FormatError, a subclass of both ValueError and RuntimeError, so existing except RuntimeError handlers keep working. rabitqlib.quant.FormatError becomes the same class; it was a ValueError only. An earlier-layout IVF file with an invalid metric byte and an earlier-layout SymphonyQG file with invalid settings threw std::invalid_argument; both now throw FormatError. This resolves the spec's open question on the Python error type.
Closes the Python half of #211. search returns int64 IDs, and slots without a neighbor hold -1 instead of 2**32 - 1, with distance inf as before. add returns int64 IDs; remove already took any integer IDs and still rejects -1 and other out-of-range values. C++ point IDs stay 32-bit PID values with kPidMax for a missing result. The bindings search into a PID buffer and widen it once per batch to int64, mapping kPidMax to -1. That adds one pass and a temporary buffer of num_queries * k IDs per search call; this change makes no performance claim. txtai 9.14.0 and earlier filter with uid != 0xFFFFFFFF; the release notes point to #213 for the first compatible txtai release.
An independent review found loads that still escaped the FormatError contract: - A format-2 SymphonyQG file whose point count and degree bound fit their limits but whose records exceed size_t threw std::length_error. Earlier SymphonyQG layouts with huge stored dimensions did the same. - Earlier IVF-RaBitQ and SymphonyQG layouts with a dimension the stored FHT/Kac rotation does not support threw std::invalid_argument from the rotator. - The earlier IVF-RaBitQ reader relied on the stream exception mask and caught std::ios_base::failure. libstdc++ before GCC 13 throws the old ABI type from its iostreams, which that catch does not match. The reader now checks each group of reads, which also undoes the reindentation of the previous commit. Each case has a C++ test that fails without this change and a Python boundary test. The docs say that a failed read after opening raises FormatError, the release notes list every converted exception, the IVF parameter table names -1, and the txtai compatibility test notes that its filter no longer matches a missing result.
IVF-RaBitQ, HNSW-RaBitQ and SymphonyQG accept METRIC_COSINE (Python metric="cosine"). An index stores METRIC_IP internally and normalizes vectors where they enter: train, construct, add, search and the SymphonyQG builder. The normalization sums squares in double and scales each coordinate once, and a zero vector stays zero. The inner-product distance 1 - dot of unit vectors is 1 - cos, so a cosine index stores exactly what an inner-product index of the normalized vectors stores, apart from the metric field of format 2, which readers now accept. MetricType gains METRIC_COSINE. The stable core API v1, factor computation, query classes, FastScan, clustering and the IVF initializers keep rejecting it with their existing messages, so v1 stays 1.0. rabitqlib.quant still rejects "cosine". Coordinates are checked before normalizing. Training data that fails the check is reported by the index instead of k-means. Centroids given to set_centroids or construct are used as given. Tests compare each index with an inner-product index of the normalized vectors: section bytes, IDs and distances, before and after reload. They also cover zero vectors, failed and replacing loads, training errors, earlier layouts rejecting metric byte 2, and v1 rejecting the new value.
This was referenced Nov 12, 2026
dev/0.9 is main at ecc594c plus the workflow change that runs the Python checks for pull requests into dev/0.9. Conflicts: - docs/docs/index/usage.md: both sides reworded the note on nondeterministic HNSW builds; keep the wording from main. - tests/python/test_ivf.py: keep this branch's save-failure and embedded-routing tests, building their indexes with the _built helper that main introduced instead of the 0.7 constructor and build.
gouyt13
marked this pull request as ready for review
November 13, 2026 14:53
This was referenced Nov 13, 2026
Open
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
This PR adds index file format 2 and moves all three indexes onto it: IVF-RaBitQ, HNSW-RaBitQ and SymphonyQG. Each index also records its construction seed, so Python keeps
seedafterload. It also adds the cosine metric,rabitqlib.FormatErrorfor rejected files, and Python point IDs asint64with-1for missing results (#211).Merge plan: this PR targets
dev/0.9, the branch that collects the breaking changes of the 0.9 release, and is merged there now.maindeploys its docs on every push and must stay releasable for 0.8.x, so this work reachesmainonly whendev/0.9is merged right before the version bump. Format 2 can still change until then.Commits
docs(formats): the specification. It is indocs/formats.md, next to therabitqlib.quantbyte formats. It covers:incompat/ro_compat/compatfeature masks and required/optional sections;feat(index): the container layer (src/index/format2.*, not installed). It writes files front to back without seeking. The reader validates the expected index kind before any section, and reads through the stream buffer.feat(ivf): IVF-RaBitQ saves and loads format 2.savewrites no<file>.hnswsidecar.tests/fixtures/persistence/v0.8.3/. They were generated from an isolated v0.8.3 checkout and cover theRABQIDX1v2 layout, which had no frozen fixture before.rabitqlib.quantis unchanged; the C++ codec reads and rewrites its golden files byte for byte.fix(ivf): two misses that CI found.Initializer::save/load.test(ivf): reproducible HNSW routing in a byte comparison. A test trained its routing graph with several threads, whose insertion order is nondeterministic. It now trains with one thread, and the docs say the graph is reproducible only when built with one thread.test(persistence): frozen v0.8.3 HNSW-RaBitQ and SymphonyQG fixtures.feat(index): HNSW-RaBitQ and SymphonyQG save and load format 2.validate_graph.ef_constructionandinit, whichQGBuilderrecords on the graph.add,reserve,removeandsave.add,resize,removeandsave.saveuntil a builder rebuilds it.feat(index):rabitqlib.FormatError.rabitqlib::FormatError(new public headerrabitqlib/index/format_error.hpp, astd::runtime_error) for a truncated, corrupt, mismatched or unsupported file, in format 2 and every earlier layout. A file that cannot be opened stays a plainstd::runtime_error.rabitqlib.FormatError, a subclass of bothValueErrorandRuntimeError, soexcept RuntimeErrorkeeps working.rabitqlib.quant.FormatErroris the same class.feat(python):int64point IDs (64-bit IDs, with -1 for missing results #211).searchreturnsint64IDs with-1for a missing result, instead of2**32 - 1; distances stayinf.addreturnsint64;removealready took any integer IDs and rejects-1.PIDvalues withkPidMax. The bindings widen each search batch once, mappingkPidMaxto-1.fix(index): the remaining rejected headers. A fourth independent review found loads that escaped the FormatError contract:size_tthrewstd::length_error, in format 2 and the earlier layouts.std::invalid_argument.std::ios_base::failure, which libstdc++ before GCC 13 throws with an ABI the catch does not match. It now checks each group of reads.mainafter 0.8.4. It brings perf(utils): check coordinates in a single pass #223 and test: run the Python quick start in CI #224 and has no conflicts.feat(index): the cosine metric (Index file format v2 with a written specification #212).METRIC_COSINE(Pythonmetric="cosine").METRIC_IPinternally and normalizes vectors where they enter:train, construction,add,searchand the SymphonyQG builder. The helpers are insrc/index/cosine.hpp.1 - dotof unit vectors is1 - cos. A cosine file therefore equals the inner-product file of the normalized vectors, apart from the header'smetricfield, which readers now accept.set_centroidsorconstructare used as given.dev/0.9(8894723).dev/0.9ismainatecc594c, which brings test(python): migrate index count tests to the 0.8 API #226, test(python): migrate ten test files off the 0.7 call forms #227, test: make graph tests independent of graph connectivity #228, test(python): migrate the last seven test files off the 0.7 call forms #230 and feat(python): let IvfIndex.set_centroids take num_threads #231, plus the workflow change that runs the Python checks for pull requests into it. Two conflicts:usage.md, where both sides reworded the note on nondeterministic HNSW builds (the wording ofmainis kept), andtests/python/test_ivf.py, where this branch's save-failure and embedded-routing tests now build their indexes with the_builthelper frommaininstead of the 0.7 constructor andbuild.Breaking changes (0.9, release-noted in
docs/docs/releases/unreleased.md)Invalid IVF index metadatarather than misread; this was inferred from the v0.8.3 reader, not run.ivf::Initializer(installed header):save(std::ofstream&, const char*)andload(std::ifstream&, const char*)becomewrite(std::ostream&)andread(std::istream&, uint64_t).IVF::seed();HierarchicalNSW::seed();QuantizedGraph<float>::seed(),ef_construction()andinitialization().symqg::QGInitializationmoves fromqg_builder.hpptoqg.hpp, whichqg_builder.hppincludes.// RaBitQ:, addssaveIndex(std::ostream&)andloadIndex(std::istream&, size_t, ...). The path overloads delegate to them.MetricTypegainsMETRIC_COSINE.core_api.mdanddevelopment.mdrecord this.switchoverMetricTypewithout adefaultmay warn about the new value. zvec has no such switch.rabitqlib.quantstill rejects"cosine".metric must be 'l2', 'ip' or 'cosine'.rabitqlib.FormatError(C++rabitqlib::FormatError) instead ofRuntimeError/std::runtime_error, which still catch it. Earlier-layout files that threwstd::invalid_argumentorstd::length_errorfor invalid contents also throw it.quant.FormatErrorwas aValueErroronly.searchandaddreturnint64; a missing result is-1, not2**32 - 1.np.isfinite(distances)works before and after. txtai 9.14.0 and earlier filter withuid != 0xFFFFFFFF, so 0.9 waits for a compatible txtai release (Remove the deprecated 0.7 Python call forms #213).load,seedand SymphonyQG'sef_constructionandinitare kept. They areNoneonly for files from releases up to 0.8.cp.seedfollows a recorded seed: the seed itself for IVF-RaBitQ, its low 32 bits for HNSW-RaBitQ.num_threads=1.graphinstead ofsidecar.HNSW index file cannot be written..hnswbeside a resaved IVF file is not removed.Review questions
Known limitations
0.8.4until the 0.9.0 bump.maincannot carry a development version, because automatic releases accept onlyX.Y.Z.train,addand construction normalize a copy of their whole input, so they briefly need memory for one more float32 copy of it. IVFtrainnormalizes every row, although k-means samples at most 256 per centroid. The usage docs suggest training on a sample and adding in several calls. Blockwise normalization is left for later; it is unmeasured.loadIndexintegrity loop. It callstellg()twice per centroid, as upstream does; this also applies to legacy sidecars.saverejects more than 65,536 dimensions, the rotator blob's limit.Testing
All runs: Linux x86-64, GCC 14.3, Release,
RABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF.61550c2.core/metric.hpp.-DRABITQ_BUILD_BENCHMARKS=ON): they build, and their 15 smoke tests pass (run ond2b25a1; later commits do not touch benchmark code).61550c2. The suite, plustests/test_mypy.pyandtests/test_pyright.py: 1186 passed, 2 skipped.check-format.sh,check-python.shandcheck-isa-symbols.shpass. The fullcheck-tidy.shwith the CI configuration reports no findings;.clang-tidyexemptsMETRIC_COSINElike its siblingsMETRIC_L2andMETRIC_IP.mkdocs build --strictpasses.main(75c81d4): C++ 546 passed and 1 skipped, Python 1170 passed, including test: run the Python quick start in CI #224's quick start test.dev/0.9(8894723): against a wheel built from the merged tree, the Python suite has 1169 passed and 18 skipped (the C++ example targets were not built), andtests/test_mypy.pyandtests/test_pyright.pypass;check-python.shand the strict docs build pass. The C++ suites ran in CI on this commit, where every check passes.int64dtypes,-1padding for every index, and search output passed back toremove.train/set_centroidspaths, earlier layouts rejecting metric byte 2, and v1 rejecting the new value.swap(cosine_)makes a test fail.readandlseekcounts.int64IDs, and the cosine metric. Their findings are addressed, including:d2b25a1. This push re-runs them on61550c2.