Skip to content
Draft
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
8 changes: 8 additions & 0 deletions src/index/hnsw/base_hnsw_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/index/hnsw/impl/IndexConditionalWrapper.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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<const BaseHnswConfig*>(&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();

Expand Down Expand Up @@ -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();

Expand Down
80 changes: 80 additions & 0 deletions tests/ut/test_faiss_hnsw.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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::fp32>(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<knowhere::fp32>(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);
}
}
Loading