Repository navigation
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.
This branch has not been deployed
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 draft 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 addsrabitqlib.FormatErrorfor rejected files and moves Python point IDs toint64with-1for missing results (#211). The cosine metric (#212) is not in this PR yet.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.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.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.3until the version is bumped.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.5bf7b1f.d2b25a1, before the last three commits, which change only index and binding code).-DRABITQ_BUILD_BENCHMARKS=ON): they build, and their 15 smoke tests pass (run ond2b25a1).5bf7b1f. The suite, plustests/test_mypy.pyandtests/test_pyright.py: 1169 passed, 2 skipped.check-format.shandcheck-python.shpass. The fullcheck-tidy.shwith the CI configuration reports no findings.mkdocs build --strictpasses.int64dtypes,-1padding for every index, and search output passed back toremove.readandlseekcounts.int64IDs. Their findings are addressed, including:d2b25a1. This push re-runs them.