Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/docs/index/hnsw.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions include/rabitqlib/index/hnsw/hnsw.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/index/hnsw.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ HierarchicalNSW::HierarchicalNSW(
dim_ = dim;
rotator_.reset(
choose_rotator<float>(
dim, RotatorType::FhtKacRotator, round_up_to_multiple(dim_, 32)
dim,
RotatorType::FhtKacRotator,
round_up_to_multiple(dim_, 32),
static_cast<uint32_t>(random_seed)
)
);
padded_dim_ = rotator_->size();
Expand Down
23 changes: 23 additions & 0 deletions tests/python/test_hnsw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/rabitqlib/index/hnsw_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,39 @@ std::vector<char> saved_bytes(const HierarchicalNSW& index, const std::string& p
return {std::istreambuf_iterator<char>(file), std::istreambuf_iterator<char>()};
}

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<char> 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;
Expand Down
Loading