This is an automated email from the ASF dual-hosted git repository.
airborne12 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 4016f207bfe [fix](be) Support BM25 scoring on keyword SEARCH indexes
(#69051)
4016f207bfe is described below
commit 4016f207bfe7eff77f56f4d98d23629888539462
Author: Jack <[email protected]>
AuthorDate: Tue Oct 20 10:29:45 2026 +0800
[fix](be) Support BM25 scoring on keyword SEARCH indexes (#69051)
### What problem does this PR solve?
Issue Number: None
Related PR: #68758
Problem Summary: SEARCH can match a keyword inverted index, but
projecting score() can drop matched rows or report an unsupported index.
Keyword indexes omit norms and may omit frequencies; they still need
usable BM25 defaults.
Preserve whole keyword values, including case and whitespace. Use
frequency and encoded norm 1 for docs-only/no-norm postings. Legacy
indexes without total-term-frequency metadata use average length 1; SNII
keeps its physical statistics. Analyzed segments missing required
scoring metadata and numeric BKD scoring retain their existing
restrictions.
This branch incorporates master's unified MATCH/SEARCH engine from
#68758. Keyword norm handling lives in the CLucene postings cursor,
including partition switches and WAND bounds; SNII cursors provide
docs-only frequencies and norms. The obsolete query_v2/SNII scoring
implementations are removed with master.
Keyword matching and missing-metadata behavior follow Elasticsearch
while retaining Doris's existing BM25 formula and collection-statistics
conventions.
#### P1 follow-up: keyword filters and internal conjunctions
1. A keyword index explicitly declared with `parser=none` can inherit
`support_phrase=true` from FE defaults. Ordinary MATCH filters then
entered scoring although the statistics collector skipped that field.
Both IndexReaderHelper query-type overloads now share the canonical
analyzer-and-phrase eligibility check used by statistics collection. The
implementation is out of line to keep analyzer dependencies out of the
header. Named-analyzer and normalizer behavior is preserved. Equality
and keyword MATCH filters keep the SEARCH-only baseline score.
2. Scored SNII AND grouped a docs-only keyword term into a path that
counts positions. Such sources now use the existing individual term
scorer with implicit unit frequency; position-bearing sources retain
batched scoring. Internal `SEARCH('body:apple AND tag:x')` continues to
score both fields.
---
be/src/storage/index/index_reader_helper.cpp | 54 +++++
be/src/storage/index/index_reader_helper.h | 30 +--
.../inverted/clucene/spi/clucene_postings_cursor.h | 6 +-
.../engine/boolean_query/operator_boolean_weight.h | 9 +
.../index/inverted/query/scoring/bm25_similarity.h | 10 +-
.../inverted/reader/collection_statistics.cpp | 37 ++--
.../index/inverted/reader/collection_statistics.h | 10 +-
.../query/engine/boolean_postings_bench_test.cpp | 2 +
.../engine/boolean_query/listed_terms_test.cpp | 26 +++
.../query/engine/multi_segment_collector_test.cpp | 2 +
.../query/engine/segment_postings_test.cpp | 42 ++++
.../index/inverted/query/spi/fake_index_source.h | 34 ++-
.../inverted/reader/collection_statistics_test.cpp | 59 +++--
.../storage/segment/index_reader_helper_test.cpp | 51 +++++
.../search/test_search_score_keyword_filters.out | 240 +++++++++++++++++++++
.../data/search/test_search_score_raw_index.out | 205 ++++++++++++++++++
.../test_search_score_keyword_filters.groovy | 85 ++++++++
.../search/test_search_score_raw_index.groovy | 112 ++++++++++
18 files changed, 930 insertions(+), 84 deletions(-)
diff --git a/be/src/storage/index/index_reader_helper.cpp
b/be/src/storage/index/index_reader_helper.cpp
new file mode 100644
index 00000000000..5607169cd08
--- /dev/null
+++ b/be/src/storage/index/index_reader_helper.cpp
@@ -0,0 +1,54 @@
+// 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 "storage/index/index_reader_helper.h"
+
+#include "storage/index/inverted/analysis/analyzer/analyzer.h"
+
+namespace doris::segment_v2 {
+namespace {
+
+bool can_score_match(const TabletIndex* index_meta) {
+ const auto& properties = index_meta->properties();
+ // parser=none can inherit support_phrase=true from FE defaults. MATCH
statistics skip
+ // these keyword indexes, so their filters must not request a scoring
context either.
+ return inverted_index::InvertedIndexAnalyzer::should_analyzer(properties)
&&
+ (get_parser_phrase_support_string_from_properties(properties) ==
+ INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES ||
+ IndexReaderHelper::is_normalizer_only(properties));
+}
+
+} // namespace
+
+bool IndexReaderHelper::is_need_similarity_score(InvertedIndexQueryType
query_type,
+ const TabletIndex*
index_meta) {
+ return (query_type == InvertedIndexQueryType::MATCH_ANY_QUERY ||
+ query_type == InvertedIndexQueryType::MATCH_ALL_QUERY ||
+ query_type == InvertedIndexQueryType::MATCH_PHRASE_QUERY ||
+ query_type == InvertedIndexQueryType::MATCH_PHRASE_PREFIX_QUERY) &&
+ can_score_match(index_meta);
+}
+
+bool IndexReaderHelper::is_need_similarity_score(TExprOpcode::type query_type,
+ const TabletIndex*
index_meta) {
+ return (query_type == TExprOpcode::MATCH_ANY || query_type ==
TExprOpcode::MATCH_ALL ||
+ query_type == TExprOpcode::MATCH_PHRASE ||
+ query_type == TExprOpcode::MATCH_PHRASE_PREFIX) &&
+ can_score_match(index_meta);
+}
+
+} // namespace doris::segment_v2
diff --git a/be/src/storage/index/index_reader_helper.h
b/be/src/storage/index/index_reader_helper.h
index c1b0b65008d..ffdb0a30075 100644
--- a/be/src/storage/index/index_reader_helper.h
+++ b/be/src/storage/index/index_reader_helper.h
@@ -103,36 +103,12 @@ public:
return iter->get_reader(InvertedIndexReaderType::STRING_TYPE) !=
nullptr;
}
+ // MATCH filters score analyzed phrase indexes and single-term normalizers.
static bool is_need_similarity_score(InvertedIndexQueryType query_type,
- const TabletIndex* index_meta) {
- if (query_type == InvertedIndexQueryType::MATCH_ANY_QUERY ||
- query_type == InvertedIndexQueryType::MATCH_ALL_QUERY ||
- query_type == InvertedIndexQueryType::MATCH_PHRASE_QUERY ||
- query_type == InvertedIndexQueryType::MATCH_PHRASE_PREFIX_QUERY) {
- const auto& properties = index_meta->properties();
- if (get_parser_phrase_support_string_from_properties(properties) ==
- INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES ||
- is_normalizer_only(properties)) {
- return true;
- }
- }
- return false;
- }
+ const TabletIndex* index_meta);
static bool is_need_similarity_score(TExprOpcode::type query_type,
- const TabletIndex* index_meta) {
- if (query_type == TExprOpcode::MATCH_ANY || query_type ==
TExprOpcode::MATCH_ALL ||
- query_type == TExprOpcode::MATCH_PHRASE ||
- query_type == TExprOpcode::MATCH_PHRASE_PREFIX) {
- const auto& properties = index_meta->properties();
- if (get_parser_phrase_support_string_from_properties(properties) ==
- INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES ||
- is_normalizer_only(properties)) {
- return true;
- }
- }
- return false;
- }
+ const TabletIndex* index_meta);
};
} // namespace doris::segment_v2
diff --git
a/be/src/storage/index/inverted/clucene/spi/clucene_postings_cursor.h
b/be/src/storage/index/inverted/clucene/spi/clucene_postings_cursor.h
index 2bb3406eaad..9bc5b9328c1 100644
--- a/be/src/storage/index/inverted/clucene/spi/clucene_postings_cursor.h
+++ b/be/src/storage/index/inverted/clucene/spi/clucene_postings_cursor.h
@@ -155,7 +155,7 @@ public:
const int32_t last = _iter->getLastDocInBlock();
_bound = {.last_doc = static_cast<uint32_t>(last),
.max_freq = _iter->getMaxBlockFreq(),
- .max_norm = _iter->getMaxBlockNorm(),
+ .max_norm = _has_norms ? _iter->getMaxBlockNorm() : 1,
.last_doc_known = last >= 0 && last != INT_MAX};
if (_bound->last_doc_known) {
_bound->last_doc += _doc_base;
@@ -305,6 +305,8 @@ public:
}
private:
+ // Keyword indexes omit norms even when CLucene supplies a zero-filled
norm buffer.
+ // Exposing no norms makes PostingsBlock use length one, including after
partition switches.
void _select_partition(size_t index) {
_partition = index;
_block = {};
@@ -314,8 +316,8 @@ private:
const auto& part = _partitions[index];
_iter = part.iter.get();
_raw_positions = part.positions;
- _doc_base = part.begin;
_has_norms = part.has_norms;
+ _doc_base = part.begin;
}
}
diff --git
a/be/src/storage/index/inverted/query/engine/boolean_query/operator_boolean_weight.h
b/be/src/storage/index/inverted/query/engine/boolean_query/operator_boolean_weight.h
index cd092de9405..fd4e4c5905b 100644
---
a/be/src/storage/index/inverted/query/engine/boolean_query/operator_boolean_weight.h
+++
b/be/src/storage/index/inverted/query/engine/boolean_query/operator_boolean_weight.h
@@ -209,6 +209,15 @@ private:
if (source == nullptr || !(any_source || source->batches_reads()))
{
continue;
}
+ if (scoring && _type == OperatorType::OP_AND) {
+ index_query::IndexScoringStats stats;
+ THROW_IF_ERROR(source->scoring_stats(&stats));
+ // Batched AND scoring counts positions. Docs-only keywords
have none, so
+ // keep their individual scorers, which supply implicit unit
frequencies.
+ if (!stats.has_positions) {
+ continue;
+ }
+ }
auto group = std::ranges::find_if(groups, [&source](const
ListedTerms& candidate) {
return candidate.source() == source;
});
diff --git a/be/src/storage/index/inverted/query/scoring/bm25_similarity.h
b/be/src/storage/index/inverted/query/scoring/bm25_similarity.h
index 535a2271efc..73e74422edb 100644
--- a/be/src/storage/index/inverted/query/scoring/bm25_similarity.h
+++ b/be/src/storage/index/inverted/query/scoring/bm25_similarity.h
@@ -24,14 +24,8 @@
namespace doris::segment_v2 {
-/**
- * BM25 similarity scoring implementation for inverted index queries.
- *
- * Note: Currently, BM25 scoring is only applied to tokenized indexes (e.g.,
standard, chinese analyzers)
- * and not to non-tokenized indexes (e.g., keyword analyzer). This differs
from Elasticsearch,
- * which applies scoring to keyword indexes as well. In Doris,
keyword/non-tokenized indexes
- * are primarily used for exact matching without relevance scoring.
- */
+// BM25 scoring for analyzed and keyword indexes. Keyword indexes without norms
+// use the Lucene default encoded norm of one.
class BM25Similarity : public Similarity {
public:
BM25Similarity();
diff --git a/be/src/storage/index/inverted/reader/collection_statistics.cpp
b/be/src/storage/index/inverted/reader/collection_statistics.cpp
index 276b1d2c2dd..07e3fa866d6 100644
--- a/be/src/storage/index/inverted/reader/collection_statistics.cpp
+++ b/be/src/storage/index/inverted/reader/collection_statistics.cpp
@@ -44,9 +44,11 @@ namespace collection_statistics_detail {
Result<SniiScoringSegmentStats> resolve_snii_scoring_segment(uint64_t
index_doc_count,
uint64_t
sum_total_term_freq,
bool
has_positions, bool has_norms,
+ bool is_analyzed,
bool
single_term_normalizer) {
- // Lowercase-normalized docs-only postings use unit frequency and length
for scoring.
- if ((!has_positions || !has_norms) && !single_term_normalizer) {
+ // Keywords and lowercase-only normalizers can omit both sections: a
matched value
+ // contributes one term with unit frequency and length. Other analyzed
sources need both.
+ if (is_analyzed && !single_term_normalizer && (!has_positions ||
!has_norms)) {
return
ResultError(Status::Error<ErrorCode::INVERTED_INDEX_NOT_SUPPORTED, false>(
"SNII scoring requires positions and norms; this segment was
written without "
"positions or without norms. Norms are left out when the index
sets \"norms\" = "
@@ -264,10 +266,11 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset,
const uint64_t segment_doc_count = scoring_stats.doc_count;
RETURN_IF_ERROR(admit_snii_scoring_segment(
ws_field_name, segment_doc_count,
scoring_stats.token_count,
- scoring_stats.has_positions, scoring_stats.has_norms,
- segment_v2::IndexReaderHelper::is_normalizer_only(
+ scoring_stats.has_positions, scoring_stats.has_norms,
&segment_accumulator,
+
segment_v2::inverted_index::InvertedIndexAnalyzer::should_analyzer(
collect_info.index_meta->properties()),
- &segment_accumulator));
+ segment_v2::IndexReaderHelper::is_normalizer_only(
+ collect_info.index_meta->properties())));
for (const auto& logical_term_bytes : collect_info.unique_terms) {
const auto logical_term =
@@ -337,12 +340,11 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset,
// BM25 on an analyzed index needs the record length of every row, and
CLucene keeps
// them, together with the field's token count, in the norms. A
segment written without
// norms would feed a zero avgdl, or rank its rows as zero-length
documents next to the
- // segments that have norms, so refuse to score the collection, as
SNII does. A
- // single-term normalizer can instead use unit frequency and length.
- const bool single_term_normalizer =
segment_v2::IndexReaderHelper::is_normalizer_only(
- collect_info.index_meta->properties());
- // Docs-only lowercase indexes have no norms, but each matching row
has one term.
- if (!scoring_stats.has_norms && !single_term_normalizer &&
+ // segments that have norms, so refuse to score the collection, as
SNII does.
+ // Keywords and lowercase-only normalizers instead use unit document
length.
+ if (!scoring_stats.has_norms &&
+ !segment_v2::IndexReaderHelper::is_normalizer_only(
+ collect_info.index_meta->properties()) &&
segment_v2::inverted_index::InvertedIndexAnalyzer::should_analyzer(
collect_info.index_meta->properties())) {
return Status::Error<ErrorCode::INVERTED_INDEX_NOT_SUPPORTED>(
@@ -351,10 +353,8 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset,
"for a variant path, when
inverted_index_skip_norms_for_variant is on",
seg_path,
segment_v2::inverted_index::StringHelper::to_string(ws_field_name));
}
- // CLucene omits term totals without norms; one token per row gives a
unit-length estimate.
- _total_num_tokens[ws_field_name] += !scoring_stats.has_norms &&
single_term_normalizer
- ? scoring_stats.doc_count
- :
scoring_stats.token_count;
+ _total_num_tokens[ws_field_name] +=
+ scoring_stats.has_norms ? scoring_stats.token_count :
scoring_stats.doc_count;
for (const auto& logical_term_bytes : collect_info.unique_terms) {
const auto logical_term =
@@ -375,11 +375,12 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset,
Status CollectionStatistics::admit_snii_scoring_segment(
const std::wstring& field_name, uint64_t index_doc_count, uint64_t
sum_total_term_freq,
- bool has_positions, bool has_norms, bool single_term_normalizer,
- SniiScoringSegmentAccumulator* segment_accumulator) {
+ bool has_positions, bool has_norms, SniiScoringSegmentAccumulator*
segment_accumulator,
+ bool is_analyzed, bool single_term_normalizer) {
DORIS_CHECK(segment_accumulator != nullptr);
auto segment_stats =
collection_statistics_detail::resolve_snii_scoring_segment(
- index_doc_count, sum_total_term_freq, has_positions, has_norms,
single_term_normalizer);
+ index_doc_count, sum_total_term_freq, has_positions, has_norms,
is_analyzed,
+ single_term_normalizer);
if (!segment_stats.has_value()) {
clear();
return segment_stats.error();
diff --git a/be/src/storage/index/inverted/reader/collection_statistics.h
b/be/src/storage/index/inverted/reader/collection_statistics.h
index e62258c2b81..27b61b6ad75 100644
--- a/be/src/storage/index/inverted/reader/collection_statistics.h
+++ b/be/src/storage/index/inverted/reader/collection_statistics.h
@@ -86,8 +86,9 @@ private:
io::IOContext* io_ctx);
Status admit_snii_scoring_segment(const std::wstring& field_name, uint64_t
index_doc_count,
uint64_t sum_total_term_freq, bool
has_positions,
- bool has_norms, bool
single_term_normalizer,
- SniiScoringSegmentAccumulator*
segment_accumulator);
+ bool has_norms,
+ SniiScoringSegmentAccumulator*
segment_accumulator,
+ bool is_analyzed = true, bool
single_term_normalizer = false);
void commit_snii_scoring_segment(SniiScoringSegmentAccumulator&&
segment_accumulator);
void clear();
@@ -121,11 +122,12 @@ struct SniiScoringSegmentStats {
uint64_t token_count = 0;
};
-// Single-term normalizers can score docs-only postings with frequency and
length one.
-// Other analyzed indexes need positions and norms for accurate BM25 scores.
+// Analyzed SNII indexes need positions and norms. Keywords and lowercase-only
+// normalizers can use unit frequency and norm when those sections are absent.
Result<SniiScoringSegmentStats> resolve_snii_scoring_segment(uint64_t
index_doc_count,
uint64_t
sum_total_term_freq,
bool
has_positions, bool has_norms,
+ bool is_analyzed
= true,
bool
single_term_normalizer = false);
void add_term_doc_frequency(
diff --git
a/be/test/storage/index/inverted/query/engine/boolean_postings_bench_test.cpp
b/be/test/storage/index/inverted/query/engine/boolean_postings_bench_test.cpp
index 6e1e2dfc717..b99f525aca4 100644
---
a/be/test/storage/index/inverted/query/engine/boolean_postings_bench_test.cpp
+++
b/be/test/storage/index/inverted/query/engine/boolean_postings_bench_test.cpp
@@ -202,6 +202,7 @@ uint64_t posting_row_checksum(uint32_t doc, float score,
bool scoring) {
uint64_t expected_posting_checksum(const PostingSpec& spec, uint32_t profile,
bool sparse,
bool scoring) {
BM25Similarity similarity(2.0F, 8.0F);
+ // The physical fixtures omit norms, so every document has unit length.
const float term_score = similarity.score(1.0F, 1);
uint64_t result = 0;
for (uint32_t doc = 0; doc < kPostingRows; ++doc) {
@@ -255,6 +256,7 @@ PhysicalTruthRows expected_physical_truth(const
PostingSpec& spec, uint32_t prof
PhysicalTruthRows result;
result.scores.resize(kPostingRows);
BM25Similarity similarity(2.0F, 8.0F);
+ // The physical fixtures omit norms, so every document has unit length.
const float term_score = similarity.score(1.0F, 1);
for (uint32_t doc = 0; doc < kPostingRows; ++doc) {
bool required_true = true;
diff --git
a/be/test/storage/index/inverted/query/engine/boolean_query/listed_terms_test.cpp
b/be/test/storage/index/inverted/query/engine/boolean_query/listed_terms_test.cpp
index fe3ec36a32f..c3ab4f94e41 100644
---
a/be/test/storage/index/inverted/query/engine/boolean_query/listed_terms_test.cpp
+++
b/be/test/storage/index/inverted/query/engine/boolean_query/listed_terms_test.cpp
@@ -687,6 +687,32 @@ TEST_F(ListedTermsTest,
AScoredConjunctionScoresTheRowsItLists) {
EXPECT_TRUE(listed->opened.empty());
}
+// A batching keyword source has no positions: its term scorers use frequency
and norm 1.
+TEST_F(ListedTermsTest,
AScoredDocsOnlyConjunctionUsesUnitFrequencyWithoutPositions) {
+ for (bool batches : {false, true}) {
+ auto keyword = std::make_shared<FakeIndexSource>();
+ keyword->batches = batches;
+ keyword->has_positions = false;
+ keyword->set_doc_count(10);
+ keyword->add("a", std::vector<uint32_t> {1, 2, 3});
+ keyword->add("b", std::vector<uint32_t> {2, 3, 4});
+ auto expected = summed({"a", "b"}, keyword);
+ expected.erase(1);
+ expected.erase(4);
+ keyword->opened.clear();
+ const auto actual = scored(
+ [&] {
+ return boolean(OperatorType::OP_AND, {term("a"),
term("b")});
+ },
+ keyword);
+ expect_scores_eq(actual, expected);
+ EXPECT_EQ(keys(actual), (std::vector<uint32_t> {2, 3}));
+ EXPECT_GT(actual.at(2), 0.0F);
+ EXPECT_EQ(keyword->opened, (std::vector<std::string> {"a", "b"}));
+ EXPECT_TRUE(keyword->opened_together.empty());
+ }
+}
+
// The chain lists the rarer term whole and the other on its rows; then both
read the positions
// of the rows holding every term, in one round.
TEST_F(ListedTermsTest,
AScoredConjunctionReadsTheListedRowsPositionsInOneRound) {
diff --git
a/be/test/storage/index/inverted/query/engine/multi_segment_collector_test.cpp
b/be/test/storage/index/inverted/query/engine/multi_segment_collector_test.cpp
index d2e096fffc0..74affbaa8be 100644
---
a/be/test/storage/index/inverted/query/engine/multi_segment_collector_test.cpp
+++
b/be/test/storage/index/inverted/query/engine/multi_segment_collector_test.cpp
@@ -249,6 +249,7 @@ static void check_native_posting_block(const
index_query::PostingsBlock& block,
EXPECT_EQ(block.doc_at(0), doc);
EXPECT_EQ(block.docs.front(), doc);
EXPECT_EQ(block.freq_at(0), 1);
+ // These segments omit norms; crossing a partition must retain unit length.
EXPECT_TRUE(block.norms.empty());
EXPECT_EQ(block.norm_at(0), 1);
}
@@ -408,6 +409,7 @@ TEST_F(MultiSegmentCollectorTest,
PhraseCandidatesRemainValidAfterWeightDestruct
auto scorer = weight->scorer(segment, "");
weight.reset();
ASSERT_EQ(scorer->doc(), 3);
+ // Materialized phrase postings retain the no-norm source's unit
length.
const float expected_score = scoring ? similarity->score(1.0F, 1)
: 1.0F;
EXPECT_FLOAT_EQ(scorer->score(), expected_score);
EXPECT_EQ(scorer->seek(3), 3);
diff --git
a/be/test/storage/index/inverted/query/engine/segment_postings_test.cpp
b/be/test/storage/index/inverted/query/engine/segment_postings_test.cpp
index 402965dec88..06951645cef 100644
--- a/be/test/storage/index/inverted/query/engine/segment_postings_test.cpp
+++ b/be/test/storage/index/inverted/query/engine/segment_postings_test.cpp
@@ -1282,6 +1282,48 @@ TEST_F(SegmentPostingsTest,
test_segment_postings_base_constructor_next_true) {
EXPECT_EQ(base.norm(), 1);
}
+// A leaf cursor exposes block bounds; the general mock deliberately inherits
the
+// unsupported multi-reader bound methods and cannot exercise keyword WAND
defaults.
+class KeywordTermDocs final : public MockTermDocs {
+public:
+ using MockTermDocs::MockTermDocs;
+ int32_t getLastDocInBlock() override { return
static_cast<int32_t>(_docs.back()); }
+ int32_t getMaxBlockFreq() override { return -1; }
+ int32_t getMaxBlockNorm() override { return -1; }
+};
+
+TEST_F(SegmentPostingsTest, KeywordPostingsUseUnitNorm) {
+ ClucenePostingsCursor::Partition part {
+ .iter = TermDocsPtr(new KeywordTermDocs({1, 3}, {1, 1}, {0, 0},
2)),
+ .has_norms = false};
+ auto cursor = std::make_unique<ClucenePostingsCursor>(std::move(part));
+ auto similarity = std::make_shared<BM25Similarity>(2.0F, 1.0F);
+ SegmentPostings postings(std::move(cursor), true, similarity);
+ EXPECT_EQ(postings.doc(), 1);
+ EXPECT_EQ(postings.norm(), 1);
+ EXPECT_EQ(postings.max_block_norm(), 1);
+ EXPECT_GE(postings.block_max_score(), similarity->score(1.0F, 1));
+ EXPECT_EQ(postings.advance(), 3);
+ EXPECT_EQ(postings.norm(), 1);
+}
+
+TEST_F(SegmentPostingsTest, KeywordNormSurvivesPartitionSwitch) {
+ std::vector<ClucenePostingsCursor::Partition> parts;
+ for (uint32_t base : {0, 10}) {
+ parts.push_back({.iter = TermDocsPtr(new KeywordTermDocs({1}, {1},
{0}, 1)),
+ .begin = base,
+ .end = base + 10,
+ .has_norms = false});
+ }
+ SegmentPostings
postings(std::make_unique<ClucenePostingsCursor>(std::move(parts)), true,
+ nullptr);
+ EXPECT_EQ(postings.doc(), 1);
+ EXPECT_EQ(postings.norm(), 1);
+ EXPECT_EQ(postings.advance(), 11);
+ EXPECT_EQ(postings.norm(), 1);
+ EXPECT_EQ(postings.max_block_norm(), 1);
+}
+
TEST_F(SegmentPostingsTest, test_segment_postings_base_constructor_next_false)
{
TermDocsPtr ptr(new MockTermDocs({}, {}, {}, 0));
SegmentPostings
base(std::make_unique<ClucenePostingsCursor>(std::move(ptr)), true, nullptr);
diff --git a/be/test/storage/index/inverted/query/spi/fake_index_source.h
b/be/test/storage/index/inverted/query/spi/fake_index_source.h
index db6fcbdac4b..7a53415f6c2 100644
--- a/be/test/storage/index/inverted/query/spi/fake_index_source.h
+++ b/be/test/storage/index/inverted/query/spi/fake_index_source.h
@@ -217,18 +217,29 @@ public:
void set_scoring_stats(IndexScoringStats stats) { _scoring_stats = stats; }
+ uint32_t doc_count() const override { return _doc_count; }
+ bool batches_reads() const override { return batches; }
+
Status scoring_stats(IndexScoringStats* out) const override {
- // Most fixtures do not score expansions and deliberately provide no
source statistics.
- if (!_scoring_stats.has_value()) {
- return IndexSource::scoring_stats(out);
+ // Expansion fixtures can override statistics; conjunction fixtures
derive the source's
+ // posting shape below so docs-only terms do not enter positional
batch scoring.
+ if (_scoring_stats.has_value()) {
+ *out = *_scoring_stats;
+ return Status::OK();
+ }
+ uint64_t tokens = 0;
+ for (const auto& [term, postings] : _terms) {
+ for (const auto& posting : postings) {
+ tokens += std::max<size_t>(1, posting.positions.size());
+ }
}
- *out = *_scoring_stats;
+ *out = {.doc_count = _doc_count,
+ .token_count = tokens,
+ .has_positions = has_positions,
+ .has_norms = !norms.empty()};
return Status::OK();
}
- uint32_t doc_count() const override { return _doc_count; }
- bool batches_reads() const override { return batches; }
-
Status prepare_terms(std::span<const std::string> terms) override {
prepared.emplace_back(terms.begin(), terms.end());
return Status::OK();
@@ -236,6 +247,10 @@ public:
Status open_term(std::string_view term, bool positions, bool scoring,
std::unique_ptr<PostingsCursor>* out) override {
+ // Model a docs-only keyword source so positional scoring requests
fail in tests.
+ if (positions && !has_positions) {
+ return Status::NotSupported("fake: the index holds no positions");
+ }
opened.emplace_back(term);
*out = _cursor(term, positions, scoring);
return Status::OK();
@@ -243,6 +258,10 @@ public:
Status open_terms(std::span<const std::string> terms, bool positions, bool
scoring,
std::vector<std::unique_ptr<PostingsCursor>>* out)
override {
+ // Model a docs-only keyword source so positional scoring requests
fail in tests.
+ if (positions && !has_positions) {
+ return Status::NotSupported("fake: the index holds no positions");
+ }
opened_together.emplace_back(terms.begin(), terms.end());
out->clear();
for (const std::string& term : terms) {
@@ -304,6 +323,7 @@ public:
bool is_live(uint32_t doc) const override { return
!_deleted.contains(doc); }
bool batches = false;
+ bool has_positions = true;
// The decode work per document every cursor reports for its positions.
uint64_t position_work = 0;
// What every expansion returns before it enumerates.
diff --git
a/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
b/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
index 6d00d6da478..c5c454bd0b3 100644
--- a/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
+++ b/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
@@ -667,6 +667,7 @@ protected:
uint64_t sum_total_term_freq = 0;
bool has_positions = true;
bool has_norms = true;
+ bool is_analyzed = true;
bool single_term_normalizer = false;
};
@@ -676,8 +677,8 @@ protected:
for (const auto& field : fields) {
RETURN_IF_ERROR(statistics->admit_snii_scoring_segment(
field.field_name, field.index_doc_count,
field.sum_total_term_freq,
- field.has_positions, field.has_norms,
field.single_term_normalizer,
- segment_accumulator));
+ field.has_positions, field.has_norms, segment_accumulator,
field.is_analyzed,
+ field.single_term_normalizer));
}
return Status::OK();
}
@@ -975,26 +976,45 @@ TEST_F(CollectionStatisticsTest,
LegacyV3RejectsSegmentWrittenWithoutNorms) {
expect_no_collected_tokens(L"1");
}
-// An index that is not analyzed never writes norms, and its scoring
statistics are collected as
-// before.
-TEST_F(CollectionStatisticsTest,
LegacyV3KeywordIndexWithoutNormsIsStillCollected) {
+TEST_F(CollectionStatisticsTest,
LegacyV3KeywordIndexWithoutNormsCollectsScoring) {
auto tablet_schema = create_legacy_v3_schema({});
const std::string segment_path = test_dir_ + "/legacy_v3_keyword_0.dat";
ASSERT_TRUE(write_legacy_v3_segment(tablet_schema, segment_path).ok());
+ const std::string second_path = test_dir_ + "/legacy_v3_keyword_1.dat";
+ ASSERT_TRUE(write_legacy_v3_segment(tablet_schema, second_path).ok());
+
auto rowset_meta =
std::make_shared<collection_statistics::MockRowsetMeta>();
auto rowset =
std::make_shared<collection_statistics::MockRowset>(tablet_schema, rowset_meta);
- rowset->set_num_segments(1);
+ rowset->set_num_segments(2);
rowset->set_segment_path(0, segment_path);
+ rowset->set_segment_path(1, second_path);
auto reader =
std::make_shared<collection_statistics::MockRowsetReader>(rowset);
std::vector<RowSetSplits> splits {RowSetSplits(reader)};
- const Status status = stats_->collect(runtime_state_.get(), splits,
tablet_schema,
- create_search_contexts("TERM",
"alpha beta"), nullptr);
+ for (const auto* clause_type : {"TERM", "PHRASE", "EXACT"}) {
+ const Status status =
+ stats_->collect(runtime_state_.get(), splits, tablet_schema,
+ create_search_contexts(clause_type, "alpha
beta"), nullptr);
- ASSERT_TRUE(status.ok()) << status;
- expect_collected_stats(L"1", 1, 0);
- expect_collected_term(L"1", L"alpha beta", 1);
+ ASSERT_TRUE(status.ok()) << status;
+ expect_collected_stats(L"1", 2, 2);
+ expect_collected_term(L"1", L"alpha beta", 2);
+ EXPECT_FLOAT_EQ(stats_->get_or_calculate_avg_dl(L"1"), 1.0F);
+ }
+}
+
+TEST_F(CollectionStatisticsTest,
SniiKeywordStatsAllowMissingPositionsAndNorms) {
+ auto resolved = resolve_snii_scoring_segment(3, 3, false, false, false);
+ ASSERT_TRUE(resolved.has_value()) << resolved.error();
+ EXPECT_EQ(resolved->doc_count, 3);
+ EXPECT_EQ(resolved->token_count, 3);
+ SniiScoringFieldInput input(L"1", 3, 3, false);
+ input.has_positions = false;
+ input.is_analyzed = false;
+ ASSERT_TRUE(admit_snii_fields_for_test(stats_.get(), {input}).ok());
+ expect_collected_stats(L"1", 3, 3);
+ EXPECT_FLOAT_EQ(stats_->get_or_calculate_avg_dl(L"1"), 1.0F);
}
TEST_F(CollectionStatisticsTest, SniiScoringUsesPhysicalStatistics) {
@@ -1230,9 +1250,10 @@ TEST(CollectionStatisticsSniiScoringTest,
ResolveRejectsSegmentWithoutPositions)
}
TEST(CollectionStatisticsSniiScoringTest,
ResolveAcceptsSingleTermNormalizerWithoutPositions) {
- auto result = resolve_snii_scoring_segment(3, 2, /*has_positions=*/false,
- /*has_norms=*/false,
-
/*single_term_normalizer=*/true);
+ auto result =
+ resolve_snii_scoring_segment(3, 2, /*has_positions=*/false,
+ /*has_norms=*/false,
+ /*is_analyzed=*/true,
/*single_term_normalizer=*/true);
ASSERT_TRUE(result.has_value()) << result.error();
EXPECT_EQ(result->doc_count, 3U);
@@ -1939,7 +1960,9 @@ TEST_F(CollectionStatisticsTest,
CollectSkipsIndexWithoutAnalyzer) {
index_pb.set_index_name("kw_idx");
index_pb.set_index_type(IndexType::INVERTED);
index_pb.add_col_unique_id(kColUid);
- // No "parser" property -> should_analyzer returns false
+ // An explicit keyword parser inherits phrase support but is still not
analyzed.
+ (*index_pb.mutable_properties())["parser"] = "none";
+ (*index_pb.mutable_properties())["support_phrase"] = "true";
TabletIndex index;
index.init_from_pb(index_pb);
tablet_schema->append_index(std::move(index));
@@ -2377,7 +2400,7 @@ TEST_F(CollectionStatisticsTest,
SearchArrayStringSelectsFulltextLeafIndex) {
EXPECT_EQ(collect_infos.begin()->second.index_meta->index_id(), 20);
}
-TEST_F(CollectionStatisticsTest, SearchExactIgnoresAnalyzedBindingHint) {
+TEST_F(CollectionStatisticsTest,
SearchExactUsesKeywordIndexWithAnalyzedBindingHint) {
auto tablet_schema =
create_tablet_schema_with_keyword_and_fulltext_indexes();
TSearchClause clause;
clause.clause_type = "EXACT";
@@ -2397,8 +2420,8 @@ TEST_F(CollectionStatisticsTest,
SearchExactIgnoresAnalyzedBindingHint) {
runtime_state_.get(), create_search_contexts(std::move(clause),
{std::move(binding)}),
tablet_schema, &collect_infos);
- ASSERT_TRUE(status.ok()) << status.msg();
- ASSERT_EQ(collect_infos.size(), 1u);
+ ASSERT_TRUE(status.ok()) << status;
+ ASSERT_EQ(collect_infos.size(), 1U);
ASSERT_NE(collect_infos.begin()->second.index_meta, nullptr);
EXPECT_EQ(collect_infos.begin()->second.index_meta->index_id(), 10);
EXPECT_EQ(collect_infos.begin()->second.unique_terms,
diff --git a/be/test/storage/segment/index_reader_helper_test.cpp
b/be/test/storage/segment/index_reader_helper_test.cpp
index b7a6da09eda..64d0c173858 100644
--- a/be/test/storage/segment/index_reader_helper_test.cpp
+++ b/be/test/storage/segment/index_reader_helper_test.cpp
@@ -22,6 +22,7 @@
#include <map>
#include <memory>
#include <string>
+#include <vector>
#include "common/be_mock_util.h"
#include "storage/index/index_reader.h"
@@ -380,6 +381,7 @@ TEST_F(IndexReaderHelperTest,
IsNeedSimilarityScoreWithInvertedIndexQueryTypeTes
index_meta_pb->add_col_unique_id(1);
auto* properties = index_meta_pb->mutable_properties();
+ (*properties)[INVERTED_INDEX_PARSER_KEY] = INVERTED_INDEX_PARSER_ENGLISH;
(*properties)[INVERTED_INDEX_PARSER_PHRASE_SUPPORT_KEY] =
INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES;
@@ -448,6 +450,7 @@ TEST_F(IndexReaderHelperTest,
IsNeedSimilarityScoreWithTExprOpcodeTest) {
index_meta_pb->add_col_unique_id(1);
auto* properties = index_meta_pb->mutable_properties();
+ (*properties)[INVERTED_INDEX_PARSER_KEY] = INVERTED_INDEX_PARSER_ENGLISH;
(*properties)[INVERTED_INDEX_PARSER_PHRASE_SUPPORT_KEY] =
INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES;
@@ -460,3 +463,51 @@ TEST_F(IndexReaderHelperTest,
IsNeedSimilarityScoreWithTExprOpcodeTest) {
EXPECT_FALSE(IndexReaderHelper::is_need_similarity_score(TExprOpcode::EQ,
&index_meta));
EXPECT_FALSE(IndexReaderHelper::is_need_similarity_score(TExprOpcode::LT,
&index_meta));
}
+
+TEST_F(IndexReaderHelperTest, MatchScoringRequiresAnalyzedPhraseIndex) {
+ struct Case {
+ std::map<std::string, std::string> properties;
+ bool expected;
+ };
+ for (const auto& test : std::vector<Case> {
+ {.properties = {{"support_phrase", "true"}}, .expected =
false},
+ {.properties = {{"parser", "none"}, {"support_phrase",
"true"}},
+ .expected = false},
+ {.properties = {{"parser", "unknown"}, {"support_phrase",
"true"}},
+ .expected = false},
+ {.properties = {{"normalizer", "lowercase"},
{"support_phrase", "true"}},
+ .expected = true},
+ {.properties = {{"parser", "english"}, {"support_phrase",
"true"}},
+ .expected = true},
+ {.properties = {{"analyzer", "custom_analyzer"},
{"support_phrase", "true"}},
+ .expected = true},
+ {.properties = {{"parser", "english"}}, .expected = false},
+ {.properties = {{"parser", "english"}, {"support_phrase",
"false"}},
+ .expected = false}}) {
+ // The canonical analyzer predicate also recognizes named normalizers.
+ SCOPED_TRACE(::testing::PrintToString(test.properties));
+ TabletIndexPB pb;
+ pb.set_index_id(1);
+ pb.set_index_name("scoring_index");
+ pb.set_index_type(IndexType::INVERTED);
+ pb.add_col_unique_id(1);
+ for (const auto& [key, value] : test.properties) {
+ (*pb.mutable_properties())[key] = value;
+ }
+ TabletIndex index;
+ index.init_from_pb(pb);
+ for (auto op : {TExprOpcode::MATCH_ANY, TExprOpcode::MATCH_ALL,
TExprOpcode::MATCH_PHRASE,
+ TExprOpcode::MATCH_PHRASE_PREFIX}) {
+ EXPECT_EQ(IndexReaderHelper::is_need_similarity_score(op, &index),
test.expected);
+ }
+ for (auto type :
+ {InvertedIndexQueryType::MATCH_ANY_QUERY,
InvertedIndexQueryType::MATCH_ALL_QUERY,
+ InvertedIndexQueryType::MATCH_PHRASE_QUERY,
+ InvertedIndexQueryType::MATCH_PHRASE_PREFIX_QUERY}) {
+ EXPECT_EQ(IndexReaderHelper::is_need_similarity_score(type,
&index), test.expected);
+ }
+
EXPECT_FALSE(IndexReaderHelper::is_need_similarity_score(TExprOpcode::EQ,
&index));
+ EXPECT_FALSE(IndexReaderHelper::is_need_similarity_score(
+ InvertedIndexQueryType::EQUAL_QUERY, &index));
+ }
+}
diff --git a/regression-test/data/search/test_search_score_keyword_filters.out
b/regression-test/data/search/test_search_score_keyword_filters.out
new file mode 100644
index 00000000000..5b1d6730b55
--- /dev/null
+++ b/regression-test/data/search/test_search_score_keyword_filters.out
@@ -0,0 +1,240 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !V2_false_internal_and --
+1 0.983863 true
+4 0.961783 true
+
+-- !V2_false_baseline --
+1 0.290716 true
+4 0.268636 true
+
+-- !V2_false_equality --
+1 0.290716 true
+4 0.268636 true
+
+-- !V2_false_keyword_match --
+1 0.290716 true
+4 0.268636 true
+
+-- !V2_false_keyword_match_all --
+1 0.290716 true
+4 0.268636 true
+
+-- !V2_true_internal_and --
+1 0.983863 true
+4 0.961783 true
+
+-- !V2_true_baseline --
+1 0.290716 true
+4 0.268636 true
+
+-- !V2_true_equality --
+1 0.290716 true
+4 0.268636 true
+
+-- !V2_true_keyword_match --
+1 0.290716 true
+4 0.268636 true
+
+-- !V2_true_keyword_match_all --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V2_false_internal_and --
+1 0.983863 true
+4 0.961783 true
+
+-- !explicit_none_V2_false_baseline --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V2_false_equality --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V2_false_keyword_match --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V2_false_keyword_match_all --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V2_true_internal_and --
+1 0.983863 true
+4 0.961783 true
+
+-- !explicit_none_V2_true_baseline --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V2_true_equality --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V2_true_keyword_match --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V2_true_keyword_match_all --
+1 0.290716 true
+4 0.268636 true
+
+-- !V3_false_internal_and --
+1 0.983863 true
+4 0.961783 true
+
+-- !V3_false_baseline --
+1 0.290716 true
+4 0.268636 true
+
+-- !V3_false_equality --
+1 0.290716 true
+4 0.268636 true
+
+-- !V3_false_keyword_match --
+1 0.290716 true
+4 0.268636 true
+
+-- !V3_false_keyword_match_all --
+1 0.290716 true
+4 0.268636 true
+
+-- !V3_true_internal_and --
+1 0.983863 true
+4 0.961783 true
+
+-- !V3_true_baseline --
+1 0.290716 true
+4 0.268636 true
+
+-- !V3_true_equality --
+1 0.290716 true
+4 0.268636 true
+
+-- !V3_true_keyword_match --
+1 0.290716 true
+4 0.268636 true
+
+-- !V3_true_keyword_match_all --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V3_false_internal_and --
+1 0.983863 true
+4 0.961783 true
+
+-- !explicit_none_V3_false_baseline --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V3_false_equality --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V3_false_keyword_match --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V3_false_keyword_match_all --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V3_true_internal_and --
+1 0.983863 true
+4 0.961783 true
+
+-- !explicit_none_V3_true_baseline --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V3_true_equality --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V3_true_keyword_match --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_V3_true_keyword_match_all --
+1 0.290716 true
+4 0.268636 true
+
+-- !SNII_false_internal_and --
+1 0.93144 true
+4 0.90936 true
+
+-- !SNII_false_baseline --
+1 0.290716 true
+4 0.268636 true
+
+-- !SNII_false_equality --
+1 0.290716 true
+4 0.268636 true
+
+-- !SNII_false_keyword_match --
+1 0.290716 true
+4 0.268636 true
+
+-- !SNII_false_keyword_match_all --
+1 0.290716 true
+4 0.268636 true
+
+-- !SNII_true_internal_and --
+1 0.93144 true
+4 0.90936 true
+
+-- !SNII_true_baseline --
+1 0.290716 true
+4 0.268636 true
+
+-- !SNII_true_equality --
+1 0.290716 true
+4 0.268636 true
+
+-- !SNII_true_keyword_match --
+1 0.290716 true
+4 0.268636 true
+
+-- !SNII_true_keyword_match_all --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_SNII_false_internal_and --
+1 0.93144 true
+4 0.90936 true
+
+-- !explicit_none_SNII_false_baseline --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_SNII_false_equality --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_SNII_false_keyword_match --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_SNII_false_keyword_match_all --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_SNII_true_internal_and --
+1 0.93144 true
+4 0.90936 true
+
+-- !explicit_none_SNII_true_baseline --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_SNII_true_equality --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_SNII_true_keyword_match --
+1 0.290716 true
+4 0.268636 true
+
+-- !explicit_none_SNII_true_keyword_match_all --
+1 0.290716 true
+4 0.268636 true
diff --git a/regression-test/data/search/test_search_score_raw_index.out
b/regression-test/data/search/test_search_score_raw_index.out
new file mode 100644
index 00000000000..97acb731c06
--- /dev/null
+++ b/regression-test/data/search/test_search_score_raw_index.out
@@ -0,0 +1,205 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !V2_unscored --
+1
+3
+9
+
+-- !V2_true_term --
+1 1.232144 true
+3 1.232144 true
+9 1.232144 true
+
+-- !V2_true_phrase --
+5 1.568616 true
+10 1.568616 true
+
+-- !V2_true_exact --
+5 1.568616 true
+10 1.568616 true
+
+-- !V2_true_or_top_two --
+5 1.568616 true
+10 1.568616 true
+
+-- !V2_true_top_one --
+6 2.079442 true
+
+-- !V2_true_ascending --
+1 1.232144 true
+3 1.232144 true
+9 1.232144 true
+
+-- !V2_true_case --
+6 2.079442 true
+
+-- !V2_true_missing --
+
+-- !V2_false_term --
+1 1.232144 true
+3 1.232144 true
+9 1.232144 true
+
+-- !V2_false_phrase --
+5 1.568616 true
+10 1.568616 true
+
+-- !V2_false_exact --
+5 1.568616 true
+10 1.568616 true
+
+-- !V2_false_or_top_two --
+5 1.568616 true
+10 1.568616 true
+
+-- !V2_false_top_one --
+6 2.079442 true
+
+-- !V2_false_ascending --
+1 1.232144 true
+3 1.232144 true
+9 1.232144 true
+
+-- !V2_false_case --
+6 2.079442 true
+
+-- !V2_false_missing --
+
+-- !V2_dual_exact --
+1 0.980829 true
+
+-- !V3_unscored --
+1
+3
+9
+
+-- !V3_true_term --
+1 1.232144 true
+3 1.232144 true
+9 1.232144 true
+
+-- !V3_true_phrase --
+5 1.568616 true
+10 1.568616 true
+
+-- !V3_true_exact --
+5 1.568616 true
+10 1.568616 true
+
+-- !V3_true_or_top_two --
+5 1.568616 true
+10 1.568616 true
+
+-- !V3_true_top_one --
+6 2.079442 true
+
+-- !V3_true_ascending --
+1 1.232144 true
+3 1.232144 true
+9 1.232144 true
+
+-- !V3_true_case --
+6 2.079442 true
+
+-- !V3_true_missing --
+
+-- !V3_false_term --
+1 1.232144 true
+3 1.232144 true
+9 1.232144 true
+
+-- !V3_false_phrase --
+5 1.568616 true
+10 1.568616 true
+
+-- !V3_false_exact --
+5 1.568616 true
+10 1.568616 true
+
+-- !V3_false_or_top_two --
+5 1.568616 true
+10 1.568616 true
+
+-- !V3_false_top_one --
+6 2.079442 true
+
+-- !V3_false_ascending --
+1 1.232144 true
+3 1.232144 true
+9 1.232144 true
+
+-- !V3_false_case --
+6 2.079442 true
+
+-- !V3_false_missing --
+
+-- !V3_dual_exact --
+1 0.980829 true
+
+-- !SNII_unscored --
+1
+3
+9
+
+-- !SNII_true_term --
+1 1.183719 true
+3 1.183719 true
+9 1.183719 true
+
+-- !SNII_true_phrase --
+5 1.506967 true
+10 1.506967 true
+
+-- !SNII_true_exact --
+5 1.506967 true
+10 1.506967 true
+
+-- !SNII_true_or_top_two --
+5 1.506967 true
+10 1.506967 true
+
+-- !SNII_true_top_one --
+6 1.997717 true
+
+-- !SNII_true_ascending --
+1 1.183719 true
+3 1.183719 true
+9 1.183719 true
+
+-- !SNII_true_case --
+6 1.997717 true
+
+-- !SNII_true_missing --
+
+-- !SNII_false_term --
+1 1.183719 true
+3 1.183719 true
+9 1.183719 true
+
+-- !SNII_false_phrase --
+5 1.506967 true
+10 1.506967 true
+
+-- !SNII_false_exact --
+5 1.506967 true
+10 1.506967 true
+
+-- !SNII_false_or_top_two --
+5 1.506967 true
+10 1.506967 true
+
+-- !SNII_false_top_one --
+6 1.997717 true
+
+-- !SNII_false_ascending --
+1 1.183719 true
+3 1.183719 true
+9 1.183719 true
+
+-- !SNII_false_case --
+6 1.997717 true
+
+-- !SNII_false_missing --
+
+-- !SNII_dual_exact --
+1 0.980829 true
+
diff --git
a/regression-test/suites/search/test_search_score_keyword_filters.groovy
b/regression-test/suites/search/test_search_score_keyword_filters.groovy
new file mode 100644
index 00000000000..a1d41ab4861
--- /dev/null
+++ b/regression-test/suites/search/test_search_score_keyword_filters.groovy
@@ -0,0 +1,85 @@
+// 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_keyword_filters", "p0") {
+ // SEARCH needs index execution even when CI fuzzes these session defaults.
+ sql "set enable_common_expr_pushdown = true"
+ sql "set enable_inverted_index_query = true"
+
+ for (def storageFormat : ["V2", "V3", "SNII"]) {
+ for (def explicitNone : [false, true]) {
+ String variant = explicitNone ? "explicit_none_" : ""
+ String keywordProperties = explicitNone ? 'PROPERTIES("parser" =
"none")' : ""
+ sql "DROP TABLE IF EXISTS test_search_score_keyword_filters"
+ sql """
+ CREATE TABLE test_search_score_keyword_filters (
+ id INT NOT NULL,
+ body STRING NULL,
+ tag STRING NULL,
+ INDEX idx_body(body) USING INVERTED
+ PROPERTIES("parser" = "english", "support_phrase" =
"true"),
+ INDEX idx_tag(tag) USING INVERTED ${keywordProperties}
+ ) ENGINE=OLAP
+ DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_allocation" = "tag.location.default:1",
+ "inverted_index_storage_format" = "${storageFormat}"
+ )
+ """
+ sql """INSERT INTO test_search_score_keyword_filters VALUES
+ (1, 'apple apple', 'x'), (2, 'apple pear', 'y'),
+ (3, 'pear', 'x'), (4, 'apple', 'x'),
+ (5, 'apple', 'z'), (6, 'apple', NULL)
+ """
+ sql "sync"
+
+ for (def wand : [false, true]) {
+ // Inside SEARCH both fields contribute scores; keyword terms
have unit frequency.
+ quickTest("${variant}${storageFormat}_${wand}_internal_and",
"""
+ SELECT id, ROUND(bm25_score, 6), bm25_score > 0 AND
bm25_score < 1000000
+ FROM (
+ SELECT
/*+SET_VAR(enable_inverted_index_wand_query=${wand}) */
+ id, score() AS bm25_score
+ FROM test_search_score_keyword_filters
+ WHERE SEARCH('body:apple AND tag:x',
'{"mode":"standard"}')
+ ORDER BY bm25_score DESC LIMIT 10
+ ) hits
+ ORDER BY id
+ """)
+ for (def filter : [
+ ["baseline", "id IN (1, 4)"],
+ ["equality", "tag = 'x'"],
+ ["keyword_match", "tag MATCH_ANY 'x'"],
+ ["keyword_match_all", "tag MATCH_ALL 'x'"]]) {
+ // The keyword predicate filters rows; only the SEARCH
leaf contributes a score.
+
quickTest("${variant}${storageFormat}_${wand}_${filter[0]}", """
+ SELECT id, ROUND(bm25_score, 6), bm25_score > 0 AND
bm25_score < 1000000
+ FROM (
+ SELECT
/*+SET_VAR(enable_inverted_index_wand_query=${wand}) */
+ id, score() AS bm25_score
+ FROM test_search_score_keyword_filters
+ WHERE SEARCH('body:apple') AND ${filter[1]}
+ ORDER BY bm25_score DESC LIMIT 10
+ ) hits
+ ORDER BY id
+ """)
+ }
+ }
+ }
+ }
+}
diff --git a/regression-test/suites/search/test_search_score_raw_index.groovy
b/regression-test/suites/search/test_search_score_raw_index.groovy
new file mode 100644
index 00000000000..a2f0f7fc653
--- /dev/null
+++ b/regression-test/suites/search/test_search_score_raw_index.groovy
@@ -0,0 +1,112 @@
+// 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_raw_index", "p0") {
+ // SEARCH needs index execution even when CI fuzzes these session defaults.
+ sql "set enable_common_expr_pushdown = true"
+ sql "set enable_inverted_index_query = true"
+
+ // Score TopN pushdown accepts one ordering key. Sort ties outside the
bounded query.
+ def scoredQuery = { hint, table, predicate, direction, limit ->
+ """
+ SELECT id, ROUND(bm25_score, 6), bm25_score > 0 AND bm25_score <
1000000
+ FROM (
+ SELECT ${hint} id, score() AS bm25_score FROM ${table}
+ WHERE ${predicate}
+ ORDER BY bm25_score ${direction} LIMIT ${limit}
+ ) hits
+ ORDER BY bm25_score ${direction}, id
+ """
+ }
+ for (def storageFormat : ["V2", "V3", "SNII"]) {
+ sql "DROP TABLE IF EXISTS test_search_score_raw_index"
+ sql """
+ CREATE TABLE test_search_score_raw_index (
+ id INT NOT NULL,
+ raw_index_content STRING NULL,
+ INDEX idx_raw_content(raw_index_content) USING INVERTED
+ ) ENGINE=OLAP
+ DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_allocation" = "tag.location.default:1",
+ "inverted_index_storage_format" = "${storageFormat}"
+ )
+ """
+ // Separate inserts exercise statistics across multiple segments.
+ sql """INSERT INTO test_search_score_raw_index VALUES
+ (1, 'apple'), (2, 'banana'), (3, 'apple'), (4, 'orange'),
+ (5, 'apple banana'), (6, 'Apple'), (7, NULL), (8, '')
+ """
+ sql "sync"
+ sql """INSERT INTO test_search_score_raw_index VALUES
+ (9, 'apple'), (10, 'apple banana'), (11, 'banana')
+ """
+ sql "sync"
+
+ def term = "search('raw_index_content:apple', '{\"mode\":\"lucene\"}')"
+ def phrase = "search('raw_index_content:\"apple banana\"',
'{\"mode\":\"lucene\"}')"
+ def exact = "search('raw_index_content:EXACT(apple banana)',
'{\"mode\":\"standard\"}')"
+ def rankedOr = "search('raw_index_content:apple OR
raw_index_content:\"apple banana\"', '{\"mode\":\"lucene\"}')"
+ def uniqueTop = "search('raw_index_content:apple OR
raw_index_content:Apple', '{\"mode\":\"lucene\"}')"
+ def caseSensitive = "search('raw_index_content:Apple',
'{\"mode\":\"lucene\"}')"
+ def missing = "search('raw_index_content:pear',
'{\"mode\":\"lucene\"}')"
+ quickTest("${storageFormat}_unscored", """
+ SELECT id FROM test_search_score_raw_index WHERE ${term} ORDER BY
id
+ """)
+ for (def wand : [true, false]) {
+ def hint = "/*+SET_VAR(enable_inverted_index_wand_query=${wand})
*/"
+ for (def entry : [
+ ["term", term, "DESC", 100],
+ ["phrase", phrase, "DESC", 100],
+ ["exact", exact, "DESC", 100],
+ ["or_top_two", rankedOr, "DESC", 2],
+ ["top_one", uniqueTop, "DESC", 1],
+ ["ascending", term, "ASC", 100],
+ ["case", caseSensitive, "DESC", 100],
+ ["missing", missing, "DESC", 100]]) {
+ quickTest("${storageFormat}_${wand}_${entry[0]}",
+ scoredQuery(hint, "test_search_score_raw_index",
entry[1],
+ entry[2], entry[3]))
+ }
+ }
+ sql "DROP TABLE IF EXISTS test_search_score_dual_index"
+ sql """
+ CREATE TABLE test_search_score_dual_index (
+ id INT NOT NULL,
+ content STRING NULL,
+ INDEX idx_raw(content) USING INVERTED,
+ INDEX idx_analyzed(content) USING INVERTED
+ PROPERTIES("parser"="standard", "support_phrase"="true")
+ ) ENGINE=OLAP
+ DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_allocation"="tag.location.default:1",
+ "inverted_index_storage_format"="${storageFormat}"
+ )
+ """
+ sql """INSERT INTO test_search_score_dual_index VALUES
+ (1, 'apple banana'), (2, 'apple'), (3, 'apple banana orange')
+ """
+ sql "sync"
+ quickTest("${storageFormat}_dual_exact",
+ scoredQuery("", "test_search_score_dual_index",
+ "search('content:EXACT(apple banana)',
'{\"mode\":\"standard\"}')",
+ "DESC", 10))
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]