From 1fcf83c7f2df1ddaf096b525ca482c42524dc3c8 Mon Sep 17 00:00:00 2001 From: Charles Njoroge Date: Thu, 23 Jul 2026 17:11:40 -0500 Subject: [PATCH] add enable brute force search --- src/index/hnsw/base_hnsw_config.h | 8 ++ .../hnsw/impl/IndexConditionalWrapper.cc | 11 +++ tests/ut/test_faiss_hnsw.cc | 80 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/src/index/hnsw/base_hnsw_config.h b/src/index/hnsw/base_hnsw_config.h index 47bd8605a..d18c67a0e 100644 --- a/src/index/hnsw/base_hnsw_config.h +++ b/src/index/hnsw/base_hnsw_config.h @@ -33,6 +33,9 @@ class BaseHnswConfig : public BaseConfig { CFG_INT overview_levels; CFG_BOOL disable_fallback_brute_force; // default is false, means we will use fallback brute force when hnsw search // does not get enough topk results + CFG_BOOL force_brute_force; // default is false, when true the search bypasses graph traversal and + // scans the index storage exhaustively (e.g. quantized codes for + // HNSW_SQ/PQ), optionally refined when the index was built with refine. KNOWHERE_DECLARE_CONFIG(BaseHnswConfig) { KNOWHERE_CONFIG_DECLARE_FIELD(M).description("hnsw M").set_default(30).set_range(2, 2048).for_train(); KNOWHERE_CONFIG_DECLARE_FIELD(efConstruction) @@ -56,6 +59,11 @@ class BaseHnswConfig : public BaseConfig { .description("disable fallback brute force") .set_default(false) .for_search(); + KNOWHERE_CONFIG_DECLARE_FIELD(force_brute_force) + .description("force exhaustive brute force search instead of graph traversal") + .set_default(false) + .for_search() + .for_range_search(); } Status diff --git a/src/index/hnsw/impl/IndexConditionalWrapper.cc b/src/index/hnsw/impl/IndexConditionalWrapper.cc index e543d5dd9..35e5311ed 100644 --- a/src/index/hnsw/impl/IndexConditionalWrapper.cc +++ b/src/index/hnsw/impl/IndexConditionalWrapper.cc @@ -39,6 +39,12 @@ WhetherPerformBruteForceSearch(const faiss::Index* index, const BaseConfig& cfg, return std::nullopt; } + // an explicit user request to force brute force overrides the heuristics below + const auto* hnsw_cfg = dynamic_cast(&cfg); + if (hnsw_cfg != nullptr && hnsw_cfg->force_brute_force.has_value() && hnsw_cfg->force_brute_force.value()) { + return true; + } + // decide const auto k = cfg.k.value(); @@ -72,6 +78,11 @@ WhetherPerformBruteForceRangeSearch(const faiss::Index* index, const FaissHnswCo return std::nullopt; } + // an explicit user request to force brute force overrides the heuristics below + if (cfg.force_brute_force.has_value() && cfg.force_brute_force.value()) { + return true; + } + // decide const auto ef = cfg.ef.value(); diff --git a/tests/ut/test_faiss_hnsw.cc b/tests/ut/test_faiss_hnsw.cc index bed2a043a..07e469904 100644 --- a/tests/ut/test_faiss_hnsw.cc +++ b/tests/ut/test_faiss_hnsw.cc @@ -2550,3 +2550,83 @@ TEST_CASE("HNSW RangeSearch BF path with empty bitset", "[faiss_hnsw][range_sear REQUIRE(hnsw_total > 0); } } + +// Verifies the `force_brute_force` search param bypasses HNSW graph traversal +// and performs an exhaustive scan of the index storage. For a flat HNSW the +// scan is exact, so results must match a FLAT (IDMAP) oracle exactly. For a +// quantized HNSW (HNSW_SQ) the scan runs over the quantized codes, so recall +// stays high relative to the fp32 oracle. The flag overrides the topk/filter +// heuristics in WhetherPerformBruteForceSearch, so it triggers even for a +// small topk that would otherwise take the regular graph path. +TEST_CASE("HNSW force_brute_force forces exhaustive search", "[faiss_hnsw][brute_force]") { + const int32_t nb = 2000; + const int32_t dim = 32; + const int32_t nq = 10; + const int32_t topk = 20; + const std::string metric = knowhere::metric::L2; + + const auto version = knowhere::Version::GetCurrentVersion().VersionNumber(); + + auto train_ds = GenDataSet(nb, dim, /*seed=*/42); + auto query_ds = GenDataSet(nq, dim, /*seed=*/43); + + knowhere::Json base_conf; + base_conf[knowhere::meta::METRIC_TYPE] = metric; + base_conf[knowhere::meta::DIM] = dim; + base_conf[knowhere::meta::ROWS] = nb; + base_conf[knowhere::meta::TOPK] = topk; + + // FLAT oracle: exact fp32 ground truth. + auto flat_index = knowhere::IndexFactory::Instance() + .Create(knowhere::IndexEnum::INDEX_FAISS_IDMAP, version) + .value(); + REQUIRE(flat_index.Build(train_ds, base_conf) == knowhere::Status::success); + auto golden = flat_index.Search(query_ds, base_conf, nullptr); + REQUIRE(golden.has_value()); + + // Build an HNSW index of the given type and return recall vs. the FLAT oracle. + // `ef == topk` (the minimum allowed) keeps graph-search recall imperfect so + // that forcing brute force is observably different. + auto build_and_recall = [&](const std::string& index_type, const knowhere::Json& extra_conf, bool force_bf) { + knowhere::Json conf = base_conf; + conf[knowhere::meta::INDEX_TYPE] = index_type; + conf[knowhere::indexparam::HNSW_M] = 8; + conf[knowhere::indexparam::EFCONSTRUCTION] = 100; + conf[knowhere::indexparam::EF] = topk; + for (auto it = extra_conf.begin(); it != extra_conf.end(); ++it) { + conf[it.key()] = it.value(); + } + if (force_bf) { + conf["force_brute_force"] = true; + } + auto index = knowhere::IndexFactory::Instance().Create(index_type, version).value(); + REQUIRE(index.Build(train_ds, conf) == knowhere::Status::success); + auto res = index.Search(query_ds, conf, nullptr); + REQUIRE(res.has_value()); + return GetKNNRecall(*golden.value(), *res.value()); + }; + + SECTION("flat HNSW: forced brute force is exact") { + const float recall_graph = + build_and_recall(knowhere::IndexEnum::INDEX_HNSW, knowhere::Json::object(), /*force_bf=*/false); + const float recall_forced = + build_and_recall(knowhere::IndexEnum::INDEX_HNSW, knowhere::Json::object(), /*force_bf=*/true); + INFO("graph recall=" << recall_graph << " forced recall=" << recall_forced); + + // exhaustive exact scan over the flat storage matches the FLAT oracle + REQUIRE(recall_forced == 1.0f); + // forcing brute force can only help relative to graph traversal + REQUIRE(recall_forced >= recall_graph); + } + + SECTION("quantized HNSW_SQ: forced brute force scans all codes") { + knowhere::Json sq_conf; + sq_conf[knowhere::indexparam::SQ_TYPE] = "SQ8"; + const float recall_forced = build_and_recall(knowhere::IndexEnum::INDEX_HNSW_SQ, sq_conf, /*force_bf=*/true); + INFO("forced recall=" << recall_forced); + + // exhaustive scan over the quantized codes stays high-recall vs the + // fp32 oracle (SQ8 quantization error is tiny for this data). + REQUIRE(recall_forced >= 0.9f); + } +}