diff --git a/docs/docs/index/hnsw.md b/docs/docs/index/hnsw.md index cbf7e36e..42cababb 100644 --- a/docs/docs/index/hnsw.md +++ b/docs/docs/index/hnsw.md @@ -63,6 +63,11 @@ see [C++ integration](../cpp_integration.md). | `metric`, `random_seed` | Python defaults are `"l2"` and 100; `"ip"` is also supported. | | `k`, `ef` | Positive result count and search window. Python requires `k <= max_elements`; omitted/zero `ef` uses `max(k, 10)`, and positive `ef` is raised to at least `k`. | +`random_seed` controls graph levels and the FHT/Kac rotation (using its low 32 bits). +With identical inputs and parameters, single-threaded construction is reproducible +within the same build environment. Parallel insertion order and different toolchains +or SIMD backends can still change results. Loading restores the saved rotation. + Results use squared L2 or `1 - dot` estimates, ordered from smallest to largest. Python fills missing results with ID `2**32 - 1` and distance `+inf`; C++ returns only the available pairs. See [result handling](../quick_start.md#read-the-results) diff --git a/include/rabitqlib/index/hnsw/hnsw.hpp b/include/rabitqlib/index/hnsw/hnsw.hpp index 55f7f8b8..22e97c27 100644 --- a/include/rabitqlib/index/hnsw/hnsw.hpp +++ b/include/rabitqlib/index/hnsw/hnsw.hpp @@ -46,6 +46,8 @@ class HierarchicalNSW { public: explicit HierarchicalNSW(); // dim is the original input dimension; total_bits includes the sign bit. + // random_seed controls graph levels and (through its low 32 bits) rotation. + // Parallel insertion order can still vary with thread scheduling. explicit HierarchicalNSW( size_t max_elements, size_t dim, diff --git a/src/index/hnsw.cpp b/src/index/hnsw.cpp index da678eba..7c66e881 100644 --- a/src/index/hnsw.cpp +++ b/src/index/hnsw.cpp @@ -82,7 +82,10 @@ HierarchicalNSW::HierarchicalNSW( dim_ = dim; rotator_.reset( choose_rotator( - dim, RotatorType::FhtKacRotator, round_up_to_multiple(dim_, 32) + dim, + RotatorType::FhtKacRotator, + round_up_to_multiple(dim_, 32), + static_cast(random_seed) ) ); padded_dim_ = rotator_->size(); diff --git a/tests/python/test_hnsw.py b/tests/python/test_hnsw.py index 27be3e40..a0c509f8 100644 --- a/tests/python/test_hnsw.py +++ b/tests/python/test_hnsw.py @@ -68,6 +68,29 @@ def test_fast_quantization_builds(base_data, clusters): assert idx.is_built +def test_seed_reproduces_serial_build_and_search(tmp_path): + rng = np.random.default_rng(47) + data = rng.standard_normal((65, 65), dtype=np.float32) + queries = rng.standard_normal((5, 65), dtype=np.float32) + centroids = data[:2].copy() + clusters = (np.arange(len(data)) % 2).astype(np.uint32) + snapshots = [] + results = [] + for run, seed in enumerate((42, 42, 43)): + index = HnswIndex( + 65, len(data), M=8, ef_construction=32, nbits=4, random_seed=seed + ) + index.build(data, centroids, clusters, num_threads=1) + path = tmp_path / f"seed-{run}.index" + index.save(str(path)) + snapshots.append(path.read_bytes()) + results.append(index.search(queries, k=5, ef=65, num_threads=1)) + assert snapshots[0] == snapshots[1] + assert snapshots[0] != snapshots[2] + for actual, expected in zip(results[1], results[0], strict=True): + np.testing.assert_array_equal(actual, expected) + + def test_parallel_build_preserves_self_retrieval(base_data, clusters): # Keep every neighbor so insertion-order-dependent pruning cannot isolate a # point. This checks parallel construction, not sparse-graph recall. diff --git a/tests/unit/rabitqlib/index/hnsw_test.cpp b/tests/unit/rabitqlib/index/hnsw_test.cpp index 04b97f38..4e59a3f4 100644 --- a/tests/unit/rabitqlib/index/hnsw_test.cpp +++ b/tests/unit/rabitqlib/index/hnsw_test.cpp @@ -987,6 +987,39 @@ std::vector saved_bytes(const HierarchicalNSW& index, const std::string& p return {std::istreambuf_iterator(file), std::istreambuf_iterator()}; } +TEST(HnswConstructionTest, SeedReproducesRotationCodesAndSerialSearch) { + constexpr size_t kCount = 65; + AddFixture fixture(kCount, 47); + const std::string path = ::testing::TempDir() + "rabitq_hnsw_seed.index"; + for (auto metric : {METRIC_L2, METRIC_IP}) { + std::vector expected_bytes; + Results expected_results; + for (size_t seed : {42U, 42U, 43U}) { + HierarchicalNSW index(kCount, AddFixture::kDim, 4, 8, 32, seed, metric); + index.construct( + 1, + fixture.centroid.data(), + kCount, + fixture.data.data(), + fixture.cluster_ids.data(), + 1 + ); + const auto bytes = saved_bytes(index, path); + const auto results = index.search(fixture.data.data(), 5, 10, kCount, 1); + if (expected_bytes.empty()) { + expected_bytes = bytes; + expected_results = results; + } else if (seed == 42) { + EXPECT_EQ(bytes, expected_bytes); + EXPECT_EQ(results, expected_results); + } else { + EXPECT_NE(bytes, expected_bytes); + } + } + } + std::remove(path.c_str()); +} + TEST(HnswRemoveTest, ExcludesRemovedPointsAndKeepsSurvivors) { constexpr size_t kCount = 120; constexpr size_t kQueries = 8;