fix(fts): preserve zero-match filter semantics (#584)

This commit is contained in:
egolearner 2026-07-10 15:20:51 +08:00 committed by GitHub
parent e906168cc1
commit a90ec5b1d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 21 additions and 10 deletions

View File

@ -206,11 +206,14 @@ Result<std::vector<FtsResult>> FtsColumnIndexer::search(
// Candidate-driven mode: AND a CandidateDocIterator into the root so the
// small candidate set leads (Conjunction sorts by cost asc), turning the
// posting walk into per-candidate advance()+matches()+score().
if (!query_params.candidate_ids.empty()) {
if (query_params.candidate_ids) {
if (query_params.candidate_ids->empty()) {
return std::vector<FtsResult>{};
}
std::vector<DocIteratorPtr> musts;
musts.reserve(2);
musts.push_back(
std::make_unique<CandidateDocIterator>(query_params.candidate_ids));
std::make_unique<CandidateDocIterator>(*query_params.candidate_ids));
musts.push_back(std::move(root_iter));
root_iter = std::make_unique<ConjunctionIterator>(
std::move(musts), std::vector<DocIteratorPtr>{});

View File

@ -15,6 +15,7 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "db/index/common/index_filter.h"
@ -28,11 +29,11 @@ struct FtsQueryParams {
// Wraps zvec::IndexFilter for push-down filtering inside the search loop.
IndexFilter::Ptr filter{nullptr};
// Candidate-driven (brute-force) mode: ascending segment-local doc_ids;
// when non-empty, FtsColumnIndexer restricts evaluation to this set by
// AND-ing it with the root iterator. Filled by the planner via
// nullopt means no candidate restriction, while a present empty vector
// means no document can match. Filled by the planner via
// DocFilter::get_bf_by_keys_and_update when an invert result is highly
// selective.
std::vector<uint64_t> candidate_ids;
std::optional<std::vector<uint64_t>> candidate_ids;
};
/*! Per-segment statistics needed by the FTS reducer for doc_id remapping.

View File

@ -1778,7 +1778,8 @@ TEST_F(FtsColumnIndexerTest, BruteForceCoexistsWithFilterPushdown) {
zvec::fts::FtsQueryParams qp;
qp.topk = 10;
qp.candidate_ids = {0, 1, 2}; // candidates restrict to {0,1,2}
qp.candidate_ids =
std::vector<uint64_t>{0, 1, 2}; // candidates restrict to {0,1,2}
qp.filter = make_blocked_filter({1}); // further drop doc 1
auto ret = indexer->search(*ast, qp);
ASSERT_TRUE(ret.has_value());
@ -1792,9 +1793,8 @@ TEST_F(FtsColumnIndexerTest, BruteForceCoexistsWithFilterPushdown) {
EXPECT_EQ(ids[1], 2ull);
}
// Empty candidate_ids takes the regular posting-driven path (the wrap guard
// requires non-empty), so search still finds all matching docs.
TEST_F(FtsColumnIndexerTest, BruteForceEmptyCandidatesFallsBack) {
// A present empty candidate_ids means the upstream filter matched no docs.
TEST_F(FtsColumnIndexerTest, BruteForceEmptyCandidatesReturnsEmpty) {
auto indexer = make_indexer("content");
EXPECT_TRUE(indexer->insert(0, "alpha beta").has_value());
EXPECT_TRUE(indexer->insert(1, "alpha gamma").has_value());
@ -1802,7 +1802,7 @@ TEST_F(FtsColumnIndexerTest, BruteForceEmptyCandidatesFallsBack) {
std::vector<FtsResult> r;
EXPECT_TRUE(search_ok_with_candidates(*indexer, "alpha", 10, {}, &r));
EXPECT_EQ(r.size(), 2u);
EXPECT_TRUE(r.empty());
}
// Regression guard: a null filter yields the same doc_ids and scores as the

View File

@ -528,6 +528,13 @@ TEST_F(FtsRecallTest, FtsSearchWithFilter_TopkRespected) {
EXPECT_LE(result->size(), 1u);
}
// "apple" matches docs 0,3,5, but no doc has tag=999.
TEST_F(FtsRecallTest, FtsSearchWithFilter_ZeroMatchesReturnsEmpty) {
auto result = fts_search_with_filter("apple", "tag = 999");
ASSERT_TRUE(result.has_value()) << result.error().c_str();
EXPECT_TRUE(result->empty());
}
// An FTS field can only be used as a query target, not as a filter condition.
// Putting the FTS field ("content") in the WHERE filter must be rejected.
TEST_F(FtsRecallTest, FtsFieldNotAllowedInFilter) {