This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.2 by this push:
new 5f512d84c64 branch-4.2: [fix](be) Prevent score leakage across Boolean
search branches #69053 (#69265)
5f512d84c64 is described below
commit 5f512d84c649202868b39a86aabf8a80428e3323
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Oct 22 09:45:59 2026 +0800
branch-4.2: [fix](be) Prevent score leakage across Boolean search branches
#69053 (#69265)
Cherry-picked from #69053
---------
Co-authored-by: Jack <[email protected]>
---
be/src/exprs/vcompound_pred.h | 33 ++-
.../query/scoring/collection_similarity.cpp | 9 +
.../inverted/query/scoring/collection_similarity.h | 11 +
be/test/exprs/vcompound_pred_score_test.cpp | 232 +++++++++++++++++++++
.../search/test_search_score_boolean_branches.out | 60 ++++++
.../test_search_score_boolean_branches.groovy | 105 ++++++++++
6 files changed, 448 insertions(+), 2 deletions(-)
diff --git a/be/src/exprs/vcompound_pred.h b/be/src/exprs/vcompound_pred.h
index 9fe4386fd30..42e5f1f772c 100644
--- a/be/src/exprs/vcompound_pred.h
+++ b/be/src/exprs/vcompound_pred.h
@@ -20,6 +20,7 @@
#include <algorithm>
#include <cstdint>
+#include <memory>
#include "common/logging.h"
#include "common/status.h"
@@ -29,7 +30,9 @@
#include "exprs/vectorized_fn_call.h"
#include "exprs/vexpr_context.h"
#include "exprs/vexpr_fwd.h"
+#include "storage/index/inverted/query/scoring/collection_similarity.h"
#include "storage/index/zone_map/zonemap_eval_context.h"
+#include "util/defer_op.h"
#include "util/simd/bits.h"
namespace doris {
@@ -271,9 +274,24 @@ public:
}
}
+ //
NOLINTNEXTLINE(readability-function-size,readability-function-cognitive-complexity):
Keep each operator's bitmap and score updates together.
Status evaluate_inverted_index(VExprContext* context, uint32_t
segment_num_rows) override {
segment_v2::InvertedIndexResultBitmap res;
bool all_pass = true;
+ auto index_context = context->get_index_context();
+ auto query_context = index_context->get_index_query_context();
+ auto parent_similarity =
+ query_context != nullptr ?
query_context->collection_similarity : nullptr;
+ CollectionSimilarityPtr branch_similarity;
+ if (parent_similarity != nullptr) {
+ branch_similarity = std::make_shared<CollectionSimilarity>();
+ query_context->collection_similarity = branch_similarity;
+ }
+ Defer restore_similarity {[&]() {
+ if (branch_similarity != nullptr) {
+ query_context->collection_similarity = parent_similarity;
+ }
+ }};
switch (_op) {
case TExprOpcode::COMPOUND_OR: {
@@ -297,7 +315,7 @@ public:
} else {
res |= *index_result;
}
- if (inverted_index_context->get_score_runtime() ==
nullptr) {
+ if (branch_similarity == nullptr) {
if (res.get_data_bitmap()->cardinality() ==
segment_num_rows) {
break; // Early exit if result is full
}
@@ -363,8 +381,19 @@ public:
"Compound operator must be AND, OR, or NOT to execute with
inverted index.");
}
+ if (branch_similarity != nullptr && _op != TExprOpcode::COMPOUND_NOT) {
+ auto scores = branch_similarity->release_scores();
+ if (!scores.empty() && (!all_pass || res.is_empty())) {
+ return Status::Error<ErrorCode::INVERTED_INDEX_NOT_SUPPORTED>(
+ "Cannot score a compound predicate with incomplete
index evaluation");
+ }
+ if (all_pass && !res.is_empty()) {
+ DORIS_CHECK(res.get_data_bitmap() != nullptr);
+ parent_similarity->merge_scores(scores,
*res.get_data_bitmap());
+ }
+ }
if (all_pass && !res.is_empty()) {
- context->get_index_context()->set_index_result_for_expr(this, res);
+ index_context->set_index_result_for_expr(this, res);
}
return Status::OK();
}
diff --git
a/be/src/storage/index/inverted/query/scoring/collection_similarity.cpp
b/be/src/storage/index/inverted/query/scoring/collection_similarity.cpp
index 5486bee823d..b1a664c6826 100644
--- a/be/src/storage/index/inverted/query/scoring/collection_similarity.cpp
+++ b/be/src/storage/index/inverted/query/scoring/collection_similarity.cpp
@@ -27,6 +27,15 @@ void CollectionSimilarity::collect(segment_v2::rowid_t
row_id, float score) {
_bm25_scores[row_id] += score;
}
+void CollectionSimilarity::merge_scores(const ScoreMap& scores,
+ const roaring::Roaring& matched_rows) {
+ for (const auto& [row_id, score] : scores) {
+ if (matched_rows.contains(row_id)) {
+ collect(row_id, score);
+ }
+ }
+}
+
void CollectionSimilarity::get_bm25_scores(roaring::Roaring* row_bitmap,
IColumn::MutablePtr& scores,
std::unique_ptr<std::vector<uint64_t>>& row_ids,
diff --git
a/be/src/storage/index/inverted/query/scoring/collection_similarity.h
b/be/src/storage/index/inverted/query/scoring/collection_similarity.h
index 97256be13c8..5ec1687f765 100644
--- a/be/src/storage/index/inverted/query/scoring/collection_similarity.h
+++ b/be/src/storage/index/inverted/query/scoring/collection_similarity.h
@@ -50,6 +50,17 @@ public:
void collect(segment_v2::rowid_t row_id, float score);
+ void merge_scores(const ScoreMap& scores, const roaring::Roaring&
matched_rows);
+
+ // Moves a Boolean branch's scores to its parent without copying the
per-row map.
+ ScoreMap release_scores() {
+ ScoreMap released = std::move(_bm25_scores);
+ // A moved-from flat_hash_map is valid but unspecified, not guaranteed
empty, so make the
+ // "leaves this instance empty" half of the contract true rather than
merely likely.
+ _bm25_scores.clear();
+ return released;
+ }
+
void get_bm25_scores(roaring::Roaring* row_bitmap, IColumn::MutablePtr&
scores,
std::unique_ptr<std::vector<uint64_t>>& row_ids,
const ScoreRangeFilterPtr& filter = nullptr) const;
diff --git a/be/test/exprs/vcompound_pred_score_test.cpp
b/be/test/exprs/vcompound_pred_score_test.cpp
new file mode 100644
index 00000000000..60c44b0dd16
--- /dev/null
+++ b/be/test/exprs/vcompound_pred_score_test.cpp
@@ -0,0 +1,232 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+#include <gtest/gtest.h>
+
+#include <cstdint>
+#include <initializer_list>
+#include <memory>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "core/data_type/primitive_type.h"
+#include "exprs/score_runtime.h"
+#include "exprs/vcompound_pred.h"
+#include "exprs/vexpr_context.h"
+#include "storage/index/index_iterator.h"
+#include "storage/index/index_query_context.h"
+#include "storage/index/inverted/query/scoring/collection_similarity.h"
+#include "storage/schema.h"
+
+namespace doris {
+namespace {
+
+class ScoredBitmapExpr final : public VExpr {
+public:
+ ScoredBitmapExpr(std::initializer_list<uint32_t> rows, float score) :
_score(score) {
+ set_node_type(TExprNodeType::MATCH_PRED);
+ for (uint32_t row : rows) {
+ _rows.add(row);
+ }
+ }
+
+ const std::string& expr_name() const override {
+ static const std::string name = "ScoredBitmapExpr";
+ return name;
+ }
+
+ Status execute(VExprContext*, Block*, int*) const override { return
Status::OK(); }
+
+ Status execute_column_impl(VExprContext*, const Block*, const Selector*,
size_t,
+ ColumnPtr&) const override {
+ return Status::OK();
+ }
+
+ Status evaluate_inverted_index(VExprContext* context, uint32_t) override {
+ auto index_context = context->get_index_context();
+ auto query_context = index_context->get_index_query_context();
+ for (uint32_t row : _rows) {
+ query_context->collection_similarity->collect(row, _score);
+ }
+ index_context->set_index_result_for_expr(
+ this,
+
segment_v2::InvertedIndexResultBitmap(std::make_shared<roaring::Roaring>(_rows),
+
std::make_shared<roaring::Roaring>()));
+ return Status::OK();
+ }
+
+private:
+ roaring::Roaring _rows;
+ float _score;
+};
+
+class UnindexedExpr final : public VExpr {
+public:
+ const std::string& expr_name() const override {
+ static const std::string name = "UnindexedExpr";
+ return name;
+ }
+
+ Status execute(VExprContext*, Block*, int*) const override { return
Status::OK(); }
+
+ Status execute_column_impl(VExprContext*, const Block*, const Selector*,
size_t,
+ ColumnPtr&) const override {
+ return Status::OK();
+ }
+
+ Status evaluate_inverted_index(VExprContext*, uint32_t) override { return
Status::OK(); }
+};
+
+std::shared_ptr<VCompoundPred> make_compound(TExprOpcode::type op, VExprSPtr
left,
+ VExprSPtr right) {
+ TExprNode node;
+ node.__set_type(create_type_desc(PrimitiveType::TYPE_BOOLEAN));
+ node.__set_node_type(TExprNodeType::COMPOUND_PRED);
+ node.__set_opcode(op);
+ node.__set_num_children(2);
+ node.__set_is_nullable(false);
+ auto expr = VCompoundPred::create_shared(node);
+ expr->add_child(left);
+ expr->add_child(right);
+ return expr;
+}
+
+std::shared_ptr<VCompoundPred> make_not(VExprSPtr child) {
+ TExprNode node;
+ node.__set_type(create_type_desc(PrimitiveType::TYPE_BOOLEAN));
+ node.__set_node_type(TExprNodeType::COMPOUND_PRED);
+ node.__set_opcode(TExprOpcode::COMPOUND_NOT);
+ node.__set_num_children(1);
+ node.__set_is_nullable(false);
+ auto expr = VCompoundPred::create_shared(node);
+ expr->add_child(child);
+ return expr;
+}
+
+TEST(VCompoundPredScoreTest, FailedAndBranchDoesNotContributeToOrScore) {
+ auto first = make_compound(
+ TExprOpcode::COMPOUND_AND,
+ std::make_shared<ScoredBitmapExpr>(std::initializer_list<uint32_t>
{1, 2, 4}, 1.0F),
+ std::make_shared<ScoredBitmapExpr>(std::initializer_list<uint32_t>
{1, 2, 5}, 2.0F));
+ auto second = make_compound(
+ TExprOpcode::COMPOUND_AND,
+ std::make_shared<ScoredBitmapExpr>(std::initializer_list<uint32_t>
{1, 2, 3, 4, 5},
+ 3.0F),
+ std::make_shared<ScoredBitmapExpr>(std::initializer_list<uint32_t>
{1, 3, 4, 5}, 4.0F));
+ auto root = make_compound(TExprOpcode::COMPOUND_OR, first, second);
+
+ const std::vector<ColumnId> column_ids;
+ std::vector<std::unique_ptr<segment_v2::IndexIterator>> iterators;
+ std::vector<IndexFieldNameAndTypePair> storage_types;
+ std::unordered_map<ColumnId, std::unordered_map<const VExpr*, bool>>
status_map;
+ segment_v2::ColumnIteratorOptions iterator_options;
+ auto score_runtime = std::make_shared<ScoreRuntime>(nullptr, false, 0);
+ auto index_context = std::make_shared<IndexExecContext>(
+ column_ids, iterators, storage_types, status_map, score_runtime,
nullptr,
+ iterator_options,
+ std::make_shared<Schema>(std::vector<TabletColumnPtr> {},
column_ids));
+ auto query_context = std::make_shared<segment_v2::IndexQueryContext>();
+ query_context->collection_similarity =
std::make_shared<CollectionSimilarity>();
+ auto root_similarity = query_context->collection_similarity;
+ index_context->set_index_query_context(query_context);
+ auto expr_context = VExprContext::create_shared(root);
+ expr_context->set_index_context(index_context);
+
+ ASSERT_TRUE(root->evaluate_inverted_index(expr_context.get(), 6).ok());
+ const auto* result = index_context->get_index_result_for_expr(root.get());
+ ASSERT_NE(result, nullptr);
+ EXPECT_EQ(result->get_data_bitmap()->cardinality(), 5);
+ EXPECT_EQ(query_context->collection_similarity, root_similarity);
+
+ auto scores = root_similarity->release_scores();
+ ASSERT_EQ(scores.size(), 5);
+ EXPECT_FLOAT_EQ(scores.at(1), 10.0F);
+ EXPECT_FLOAT_EQ(scores.at(2), 3.0F);
+ EXPECT_FLOAT_EQ(scores.at(3), 7.0F);
+ EXPECT_FLOAT_EQ(scores.at(4), 7.0F);
+ EXPECT_FLOAT_EQ(scores.at(5), 7.0F);
+}
+
+TEST(VCompoundPredScoreTest, NotBranchDoesNotContributeToOrScore) {
+ auto negative = make_not(
+ std::make_shared<ScoredBitmapExpr>(std::initializer_list<uint32_t>
{1, 2}, 1.0F));
+ auto positive =
+ std::make_shared<ScoredBitmapExpr>(std::initializer_list<uint32_t>
{1, 3}, 5.0F);
+ auto root = make_compound(TExprOpcode::COMPOUND_OR, negative, positive);
+
+ const std::vector<ColumnId> column_ids;
+ std::vector<std::unique_ptr<segment_v2::IndexIterator>> iterators;
+ std::vector<IndexFieldNameAndTypePair> storage_types;
+ std::unordered_map<ColumnId, std::unordered_map<const VExpr*, bool>>
status_map;
+ segment_v2::ColumnIteratorOptions iterator_options;
+ auto score_runtime = std::make_shared<ScoreRuntime>(nullptr, false, 0);
+ auto index_context = std::make_shared<IndexExecContext>(
+ column_ids, iterators, storage_types, status_map, score_runtime,
nullptr,
+ iterator_options,
+ std::make_shared<Schema>(std::vector<TabletColumnPtr> {},
column_ids));
+ auto query_context = std::make_shared<segment_v2::IndexQueryContext>();
+ query_context->collection_similarity =
std::make_shared<CollectionSimilarity>();
+ index_context->set_index_query_context(query_context);
+ auto expr_context = VExprContext::create_shared(root);
+ expr_context->set_index_context(index_context);
+
+ ASSERT_TRUE(root->evaluate_inverted_index(expr_context.get(), 4).ok());
+ const auto* result = index_context->get_index_result_for_expr(root.get());
+ ASSERT_NE(result, nullptr);
+ EXPECT_EQ(result->get_data_bitmap()->cardinality(), 3);
+ EXPECT_TRUE(result->get_data_bitmap()->contains(0));
+ EXPECT_TRUE(result->get_data_bitmap()->contains(1));
+ EXPECT_TRUE(result->get_data_bitmap()->contains(3));
+
+ auto scores = query_context->collection_similarity->release_scores();
+ ASSERT_EQ(scores.size(), 2);
+ EXPECT_FLOAT_EQ(scores.at(1), 5.0F);
+ EXPECT_FLOAT_EQ(scores.at(3), 5.0F);
+}
+
+TEST(VCompoundPredScoreTest,
IncompleteScoredBranchReturnsErrorWithoutPublishingScores) {
+ auto root = make_compound(
+ TExprOpcode::COMPOUND_AND,
+ std::make_shared<ScoredBitmapExpr>(std::initializer_list<uint32_t>
{1}, 1.0F),
+ std::make_shared<UnindexedExpr>());
+
+ const std::vector<ColumnId> column_ids;
+ std::vector<std::unique_ptr<segment_v2::IndexIterator>> iterators;
+ std::vector<IndexFieldNameAndTypePair> storage_types;
+ std::unordered_map<ColumnId, std::unordered_map<const VExpr*, bool>>
status_map;
+ segment_v2::ColumnIteratorOptions iterator_options;
+ auto score_runtime = std::make_shared<ScoreRuntime>(nullptr, false, 0);
+ auto index_context = std::make_shared<IndexExecContext>(
+ column_ids, iterators, storage_types, status_map, score_runtime,
nullptr,
+ iterator_options,
+ std::make_shared<Schema>(std::vector<TabletColumnPtr> {},
column_ids));
+ auto query_context = std::make_shared<segment_v2::IndexQueryContext>();
+ query_context->collection_similarity =
std::make_shared<CollectionSimilarity>();
+ auto parent_similarity = query_context->collection_similarity;
+ index_context->set_index_query_context(query_context);
+ auto expr_context = VExprContext::create_shared(root);
+ expr_context->set_index_context(index_context);
+
+ auto status = root->evaluate_inverted_index(expr_context.get(), 2);
+ EXPECT_EQ(status.code(), ErrorCode::INVERTED_INDEX_NOT_SUPPORTED);
+ EXPECT_EQ(query_context->collection_similarity, parent_similarity);
+ EXPECT_TRUE(parent_similarity->release_scores().empty());
+}
+
+} // namespace
+} // namespace doris
diff --git a/regression-test/data/search/test_search_score_boolean_branches.out
b/regression-test/data/search/test_search_score_boolean_branches.out
new file mode 100644
index 00000000000..ba8d7a888cb
--- /dev/null
+++ b/regression-test/data/search/test_search_score_boolean_branches.out
@@ -0,0 +1,60 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !search --
+1 1.3714
+10 1.2833
+2 0.8781
+4 0.4932
+5 0.4932
+6 1.5112
+7 0.4932
+9 1.5553
+
+-- !match --
+1 1.3714
+10 1.2833
+2 0.8781
+4 0.4932
+5 0.4932
+6 1.5112
+7 0.4932
+9 1.5553
+
+-- !match_warm --
+1 1.3714
+10 1.2833
+2 0.8781
+4 0.4932
+5 0.4932
+6 1.5112
+7 0.4932
+9 1.5553
+
+-- !failed_and --
+1 0.4932
+10 0.4052
+4 0.4932
+5 0.4932
+6 0.4932
+7 0.4932
+9 0.4932
+
+-- !failed_and_reversed --
+1 0.4932
+10 0.4052
+4 0.4932
+5 0.4932
+6 0.4932
+7 0.4932
+9 0.4932
+
+-- !not_branch --
+1 0.4932
+10 0.4052
+3 0
+4 0.4932
+5 0.4932
+6 0.4932
+7 0.4932
+8 0
+9 0.4932
+
diff --git
a/regression-test/suites/search/test_search_score_boolean_branches.groovy
b/regression-test/suites/search/test_search_score_boolean_branches.groovy
new file mode 100644
index 00000000000..87bfb8f4e2c
--- /dev/null
+++ b/regression-test/suites/search/test_search_score_boolean_branches.groovy
@@ -0,0 +1,105 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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.
+
+suite("test_search_score_boolean_branches", "p0") {
+ sql "DROP TABLE IF EXISTS test_search_score_multi_column_mixed"
+ sql """
+ CREATE TABLE test_search_score_multi_column_mixed (
+ id INT NOT NULL,
+ title STRING NULL,
+ content STRING NULL,
+ tags STRING NULL,
+ INDEX idx_title(title) USING INVERTED PROPERTIES("parser" =
"english"),
+ INDEX idx_content(content) USING INVERTED PROPERTIES("parser" =
"english"),
+ INDEX idx_tags(tags) USING INVERTED PROPERTIES("parser" =
"english")
+ ) ENGINE=OLAP
+ DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ("replication_allocation" = "tag.location.default:1")
+ """
+ sql """
+ INSERT INTO test_search_score_multi_column_mixed VALUES
+ (1, 'apple banana', 'search engine', 'database analytics'),
+ (2, 'apple guide', 'search basics', 'database'),
+ (3, 'banana manual', 'engine tuning', 'analytics'),
+ (4, 'apple banana', 'storage engine', 'database analytics'),
+ (5, 'pear guide', 'search engine', 'database analytics'),
+ (6, 'apple', 'engine search', 'analytics database'),
+ (7, 'banana', 'search', 'database analytics'),
+ (8, 'grape', 'storage', 'archive'),
+ (9, 'apple banana apple', 'search engine search', 'database
analytics'),
+ (10, 'apple docs', 'search engine', 'database analytics archive')
+ """
+
+ order_qt_search """
+ SELECT id, ROUND(score(), 4) AS bm25_score
+ FROM test_search_score_multi_column_mixed
+ WHERE search('(title:apple AND content:search) OR (tags:database AND
tags:analytics)',
+
'{"default_operator":"AND","mode":"lucene","minimum_should_match":0}')
+ ORDER BY score() DESC LIMIT 100
+ """
+
+ order_qt_match """
+ SELECT id, ROUND(score(), 4) AS bm25_score
+ FROM test_search_score_multi_column_mixed
+ WHERE (title MATCH_ANY 'apple' AND content MATCH_ANY 'search')
+ OR tags MATCH_ALL 'database analytics'
+ ORDER BY score() DESC LIMIT 100
+ """
+
+ order_qt_match_warm """
+ SELECT id, ROUND(score(), 4) AS bm25_score
+ FROM test_search_score_multi_column_mixed
+ WHERE (title MATCH_ANY 'apple' AND content MATCH_ANY 'search')
+ OR tags MATCH_ALL 'database analytics'
+ ORDER BY score() DESC LIMIT 100
+ """
+
+ order_qt_failed_and """
+ SELECT id, ROUND(score(), 4) AS bm25_score
+ FROM test_search_score_multi_column_mixed
+ WHERE (title MATCH_ANY 'apple' AND content MATCH_ANY 'missing_token')
+ OR tags MATCH_ALL 'database analytics'
+ ORDER BY score() DESC LIMIT 100
+ """
+
+ order_qt_failed_and_reversed """
+ SELECT id, ROUND(score(), 4) AS bm25_score
+ FROM test_search_score_multi_column_mixed
+ WHERE (content MATCH_ANY 'missing_token' AND title MATCH_ANY 'apple')
+ OR tags MATCH_ALL 'database analytics'
+ ORDER BY score() DESC LIMIT 100
+ """
+
+ order_qt_not_branch """
+ SELECT id, ROUND(score(), 4) AS bm25_score
+ FROM test_search_score_multi_column_mixed
+ WHERE NOT (title MATCH_ANY 'apple') OR tags MATCH_ALL 'database
analytics'
+ ORDER BY score() DESC LIMIT 100
+ """
+
+ test {
+ sql """
+ SELECT id, score() AS bm25_score
+ FROM test_search_score_multi_column_mixed
+ WHERE (title MATCH_ANY 'apple' AND id > 0)
+ OR tags MATCH_ALL 'database analytics'
+ ORDER BY bm25_score DESC LIMIT 100
+ """
+ exception "Cannot score a compound predicate with incomplete index
evaluation"
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]