diff --git a/include/svs/orchestrators/dynamic_vamana.h b/include/svs/orchestrators/dynamic_vamana.h index 261278d7b..9830f986d 100644 --- a/include/svs/orchestrators/dynamic_vamana.h +++ b/include/svs/orchestrators/dynamic_vamana.h @@ -609,6 +609,20 @@ class DynamicVamana : public manager::IndexManager { /// @brief Check if the index has been consolidated - no `Deleted` entries remain. bool is_consolidated() const { return impl_->is_consolidated(); } + + ///// Range search + /// @copydoc svs::index::vamana::MutableVamanaIndex::range_search + template + std::vector>> range_search( + data::ConstSimpleDataView queries, + float radius, + const VamanaSearchParameters& search_parameters, + const lib::DefaultPredicate& cancel = lib::Returns(lib::Const()) + ) { + return impl_->range_search( + AnonymousArray<2>(queries), radius, search_parameters, cancel + ); + } }; /// diff --git a/include/svs/orchestrators/vamana.h b/include/svs/orchestrators/vamana.h index 5787e8a7a..f80ddd5bc 100644 --- a/include/svs/orchestrators/vamana.h +++ b/include/svs/orchestrators/vamana.h @@ -108,8 +108,26 @@ class VamanaInterface { // through the wrong slot with no link-time diagnostic. virtual svs::index::vamana::MemoryBreakdown get_memory_breakdown() const = 0; virtual svs::index::vamana::VamanaIndexParameters parameters() const = 0; + + ///// Range search + virtual std::vector>> range_search( + AnonymousArray<2> queries, + float radius, + const index::vamana::VamanaSearchParameters& search_parameters, + const lib::DefaultPredicate& cancel = lib::Returns(lib::Const()) + ) = 0; }; +/// Whether the index implementation `Impl` provides ``range_search`` for `Queries`. +template +concept HasRangeSearch = requires( + Impl& impl, + const Queries& queries, + float radius, + const index::vamana::VamanaSearchParameters& search_parameters, + const lib::DefaultPredicate& cancel +) { impl.range_search(queries, radius, search_parameters, cancel); }; + template class VamanaImpl : public manager::ManagerImpl { private: @@ -283,6 +301,37 @@ class VamanaImpl : public manager::ManagerImpl { svs::index::vamana::VamanaIndexParameters parameters() const override { return impl().parameters(); } + + ///// Range search + std::vector>> range_search( + AnonymousArray<2> queries, + float radius, + const VamanaSearchParameters& search_parameters, + const lib::DefaultPredicate& cancel = lib::Returns(lib::Const()) + ) override { + return lib::match( + QueryTypes{}, + queries.type(), + [&](lib::Type SVS_UNUSED(type)) { + const auto view = data::ConstSimpleDataView(queries); + if constexpr (HasRangeSearch>) { + return this->impl().range_search( + view, radius, search_parameters, cancel + ); + } else { + throw ANNEXCEPTION("Range search is not supported by this index!"); + return std::vector>>{}; + } + }, + [&](svs::DataType data_type) -> std::vector>> { + throw ANNEXCEPTION( + "Unsupported datatype! Got: {}. Expected one of: {}.", + data_type, + fmt::join(QueryTypes::data_types(), ", ") + ); + } + ); + } }; ///// Forward declarations @@ -776,6 +825,20 @@ class Vamana : public manager::IndexManager { return impl_->parameters(); } + ///// Range search + /// @copydoc svs::index::vamana::VamanaIndex::range_search + template + std::vector>> range_search( + data::ConstSimpleDataView queries, + float radius, + const VamanaSearchParameters& search_parameters, + const lib::DefaultPredicate& cancel = lib::Returns(lib::Const()) + ) { + return impl_->range_search( + AnonymousArray<2>(queries), radius, search_parameters, cancel + ); + } + private: template < manager::QueryTypeDefinition QueryTypes, diff --git a/tests/svs/orchestrators/concurrent_dynamic_vamana.cpp b/tests/svs/orchestrators/concurrent_dynamic_vamana.cpp index 699152e1a..b86533a4f 100644 --- a/tests/svs/orchestrators/concurrent_dynamic_vamana.cpp +++ b/tests/svs/orchestrators/concurrent_dynamic_vamana.cpp @@ -24,6 +24,7 @@ #include "svs/lib/file.h" // tests +#include "tests/utils/range_search.h" #include "tests/utils/test_dataset.h" #include "tests/utils/utils.h" #include "tests/utils/vamana_reference.h" @@ -406,3 +407,18 @@ CATCH_TEST_CASE( CATCH_REQUIRE(index.has_id(i)); } } + +CATCH_TEST_CASE( + "ConcurrentDynamicVamana Range Search", "[managers][concurrent_dynamic_vamana]" +) { + auto data = load_data(); + auto queries = svs::data::SimpleData::load(test_dataset::query_file()); + auto index = svs::ConcurrentDynamicVamana::build( + svs::index::vamana::VamanaBuildParameters{1.2, 32, 64, 750, 28, true}, + load_data(), + iota_ids(data.size()), + svs::DistanceType::L2, + num_threads + ); + svs_test::check_orchestrator_range_search(index, data, queries); +} diff --git a/tests/svs/orchestrators/dynamic_vamana.cpp b/tests/svs/orchestrators/dynamic_vamana.cpp index 77f08a469..670735c03 100644 --- a/tests/svs/orchestrators/dynamic_vamana.cpp +++ b/tests/svs/orchestrators/dynamic_vamana.cpp @@ -25,6 +25,7 @@ #include "svs/core/distance.h" // Test dataset utilities +#include "tests/utils/range_search.h" #include "tests/utils/test_dataset.h" #include "tests/utils/utils.h" #include "tests/utils/vamana_reference.h" @@ -310,3 +311,18 @@ CATCH_TEST_CASE("DynamicVamana Per-Index Logger", "[managers][dynamic_vamana][lo CATCH_REQUIRE(custom.messages->empty()); } } + +CATCH_TEST_CASE("DynamicVamana Range Search", "[managers][dynamic_vamana]") { + auto data = svs::data::SimpleData::load(test_dataset::data_svs_file()); + auto queries = svs::data::SimpleData::load(test_dataset::query_file()); + std::vector ids(data.size()); + std::iota(ids.begin(), ids.end(), 0); + svs::DynamicVamana index = svs::DynamicVamana::build( + svs::index::vamana::VamanaBuildParameters{1.2, 32, 64, 750, 28, true}, + data, + ids, + svs::DistanceL2(), + 2 + ); + svs_test::check_orchestrator_range_search(index, data, queries); +} diff --git a/tests/svs/orchestrators/vamana.cpp b/tests/svs/orchestrators/vamana.cpp index 0ffd8b479..75947418a 100644 --- a/tests/svs/orchestrators/vamana.cpp +++ b/tests/svs/orchestrators/vamana.cpp @@ -25,6 +25,7 @@ #include "svs/core/distance.h" // Test dataset utilities +#include "tests/utils/range_search.h" #include "tests/utils/test_dataset.h" #include "tests/utils/utils.h" #include "tests/utils/vamana_reference.h" @@ -280,3 +281,15 @@ CATCH_TEST_CASE("Vamana Per-Index Logger", "[managers][vamana][logging]") { CATCH_REQUIRE(custom.messages->empty()); } } + +CATCH_TEST_CASE("Vamana Range Search", "[managers][vamana]") { + auto data = svs::data::SimpleData::load(test_dataset::data_svs_file()); + auto queries = svs::data::SimpleData::load(test_dataset::query_file()); + svs::Vamana index = svs::Vamana::build( + svs::index::vamana::VamanaBuildParameters{1.2, 32, 64, 750, 28, true}, + data, + svs::DistanceL2(), + 2 + ); + svs_test::check_orchestrator_range_search(index, data, queries); +} diff --git a/tests/utils/range_search.h b/tests/utils/range_search.h new file mode 100644 index 000000000..b55cf5c3c --- /dev/null +++ b/tests/utils/range_search.h @@ -0,0 +1,125 @@ +/* + * Copyright 2026 Intel Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#pragma once + +// svs +#include "svs/core/data/simple.h" +#include "svs/core/distance.h" +#include "svs/index/vamana/dynamic_search_buffer.h" +#include "svs/lib/exception.h" + +// catch2 +#include "catch2/catch_test_macros.hpp" + +// stl +#include +#include +#include +#include + +namespace svs_test { + +/// +/// @brief Check the ``range_search`` of an orchestrator built with L2 over ``data`` and +/// queried with float queries, with external IDs equal to the row in ``data``. +/// +template +void check_orchestrator_range_search( + Index& index, const Data& data, const svs::data::SimpleData& all_queries +) { + const size_t num_queries = 20; + auto queries = svs::data::SimpleData(num_queries, all_queries.dimensions()); + for (size_t q = 0; q < num_queries; ++q) { + queries.set_datum(q, all_queries.get_datum(q)); + } + auto distance = svs::distance::DistanceL2(); + + // Brute-force distances and a radius that holds about 30 neighbors per query. + auto distances = std::vector>(num_queries); + float radius = 0; + for (size_t q = 0; q < num_queries; ++q) { + for (size_t i = 0; i < data.size(); ++i) { + distances[q].push_back( + svs::distance::compute(distance, queries.get_datum(q), data.get_datum(i)) + ); + } + auto sorted = distances[q]; + std::nth_element(sorted.begin(), sorted.begin() + 30, sorted.end()); + radius += sorted[30]; + } + radius /= static_cast(num_queries); + + auto check = [&](const auto& results, size_t max_results) { + CATCH_REQUIRE(results.size() == num_queries); + size_t num_expected = 0; + size_t num_found = 0; + for (size_t q = 0; q < num_queries; ++q) { + const auto& result = results[q]; + CATCH_REQUIRE(result.size() <= max_results); + auto seen = std::unordered_set{}; + float last = 0; + for (const auto& neighbor : result) { + CATCH_REQUIRE(neighbor.id() < data.size()); + CATCH_REQUIRE(neighbor.distance() < radius); + CATCH_REQUIRE(neighbor.distance() >= last); + CATCH_REQUIRE(seen.insert(neighbor.id()).second); + last = neighbor.distance(); + } + for (size_t i = 0; i < data.size(); ++i) { + if (distances[q][i] < radius) { + ++num_expected; + num_found += seen.contains(i); + } + } + } + return static_cast(num_found) / static_cast(num_expected); + }; + + using buffer_type = svs::index::vamana::MutableBuffer; + auto sp = index.get_search_parameters(); + + // Unbounded. The shared test dataset is hard for graph search (top-30 at window 20); + // the index tests check recall more strictly. + sp.buffer_config({20, buffer_type::unbounded_capacity}); + CATCH_REQUIRE( + check(index.range_search(queries.cview(), radius, sp), data.size()) >= 0.85 + ); + + // Bounded by the buffer capacity. + sp.buffer_config({10, 20}); + auto bounded = index.range_search(queries.cview(), radius, sp); + check(bounded, 20); + CATCH_REQUIRE(std::any_of(bounded.begin(), bounded.end(), [](const auto& r) { + return r.size() == 20; + })); + + // Cancelled before starting. + sp.buffer_config({20, buffer_type::unbounded_capacity}); + auto none = index.range_search(queries.cview(), radius, sp, []() { return true; }); + CATCH_REQUIRE(none.size() == num_queries); + for (const auto& result : none) { + CATCH_REQUIRE(result.empty()); + } + + // Unsupported query type. + auto bytes = svs::data::SimpleData(1, queries.dimensions()); + CATCH_REQUIRE_THROWS_AS( + index.range_search(bytes.cview(), radius, sp), svs::ANNException + ); +} + +} // namespace svs_test