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 c6f85eaac11 branch-4.2: [fix](be) Support BM25 scoring on keyword
SEARCH indexes #69051 (#69318)
c6f85eaac11 is described below
commit c6f85eaac118faf00ef81f09fe243ad69e9e6185
Author: Jack <[email protected]>
AuthorDate: Wed Oct 21 09:50:57 2026 +0800
branch-4.2: [fix](be) Support BM25 scoring on keyword SEARCH indexes #69051
(#69318)
### What problem does this PR solve?
Issue Number: None
Related PR: https://github.com/apache/doris/pull/69051
Problem Summary: Backport the merged keyword SEARCH BM25 fix to
branch-4.2. Projecting `score()` on keyword indexes can drop matching
rows or reject missing scoring metadata. Keyword MATCH filters can also
request statistics that were not collected, and scored SNII conjunctions
can incorrectly require positions from docs-only terms.
Use unit frequency and encoded norm 1 for keyword postings, preserve
whole keyword values, and score docs-only terms through the existing
individual term scorer. Legacy indexes without norms use average length
1; SNII retains its physical collection statistics. The BM25 formula and
the restrictions for analyzed indexes remain unchanged.
Source commit: `4016f207bfe7eff77f56f4d98d23629888539462`.
#### Target branch adaptations
- branch-4.2 already contains the unified MATCH/SEARCH engine. It does
not contain master's normalizer-only scoring exception, so this pick
retains the target analyzer-and-phrase eligibility policy and
analyzed-index metadata requirements.
- The older CLucene interface lacks norm-presence propagation. Pass
`reader->hasNorms()` through posting partitions and restore it on
partition switches, including WAND bounds.
- Retain the target compile-check wrappers and adapt the statistics
helper signatures without weakening assertions. Both source regression
suites and their expected outputs are preserved verbatim.
- GitHub's historical PR-head diff also shows three Variant
parser-option compatibility files. They are absent from the final merged
squash, and branch-4.2 still has that parser option. They are excluded.
Merging the PR head `9b356cb594f60b802de117ff9b5915b60500d424` into the
landing parent reproduces the exact landed tree
`57f5e5d63fafe808a83bc393e36ea59a6e367ca0`.
### Release note
SEARCH with `score()` supports keyword inverted indexes, including
conjunctions, TopN ordering, WAND execution, and keyword MATCH filters.
### Check List (For Author)
- Test
- [x] Regression test: both source suites passed; all 114 expected tags
ran exactly once (54 raw-index and 60 keyword-filter checks).
- [x] Unit Test: 369 affected ASAN tests passed; 3 pre-existing
benchmarks are disabled. All 7 source-added or renamed cases were
discovered and executed.
- [ ] Manual test
- [ ] No need to test or manual test.
- Behavior changed:
- [ ] No.
- [x] Yes, as described above.
- Does this need documentation?
- [x] No. No syntax or configuration changes.
- [ ] Yes.
### Backport validation
- PASS: `source ./custom_env.sh && bash build.sh --be --fe -j 24` (ASAN
BE, FE and packaged output; 45m 19s).
- PASS: full ASAN UT binary build with `bash run-be-ut.sh -j 48`,
default `GLIBC_COMPATIBILITY=ON`; no local CMake test-source selection.
- PASS: `bash run-be-ut.sh --run
--filter="BooleanPostingsBench.*:CluceneIndexSourceTest.*:CollectionStatisticsDetailedTest.*:CollectionStatisticsSniiScoringTest.*:CollectionStatisticsTest.*:CursorChainedPostings.*:DocSetCollectorTest.*:ExpansionLeafTest.*:FunctionSearchNullTest.*:FunctionSearchTest.*:IndexReaderHelperTest.*:ListedTermsTest.*:ListedTermsWalkTest.*:MultiSegmentCollectorTest.*:PhraseQueryV2Test.*:PhraseScorerStreamTest.*:PrefixQueryV2Test.*:SegmentPostingsTest.*"
-j 48`; 369 passed, 3 existing disabled benchmarks, zero failures or
sanitizer reports. Coverage includes postings/norms, collection
statistics, MATCH eligibility, conjunctions, prefix/phrase queries,
cursor chaining, and SEARCH execution/NULL handling.
- PASS: `bash run-regression-test.sh --run -d search -s
test_search_score_raw_index,test_search_score_keyword_filters -parallel
1` on an isolated ASAN FE/BE instance. Both suites passed with zero
failed/fatal/skipped scripts. All 114 tags ran exactly once; source
expected files are byte-identical. Coverage includes V2/V3/SNII, WAND
on/off, whole keyword values, TopN/ascending order, internal AND,
external equality/MATCH filters, and implicit/explicit `parser=none`.
- PASS: complete merged-candidate format preflight for head
`809eb7498a0fdfce905bdde7a350f3ba207947bd` against latest branch-4.2
`bcaaa5f293674f1adba0dfa52c8f8f57fb5e9549`; complete committed PR
English check (17 source files, zero exceptions).
- Builds and execution used target base
`62cb1df3a7f8124ad338e8788e4faaf71f030b07`. The subsequent
commit-message amendment preserved the exact tested code tree. The newer
target commit changes JSON_SEARCH path escaping and has no changed-file
overlap with this pick.
- Modified-line clang-tidy 20.1.8 processed five production and six test
translation units with the branch configuration and current compilation
database. It emitted no changed-line diagnostics. The command exits with
the pre-existing unmatched `NOLINTEND` in `be/src/core/types.h:578`. The
same diagnostic was reproduced with all modified existing production
files restored to target-base contents through a VFS overlay; the
offending header is byte-identical to base
`62cb1df3a7f8124ad338e8788e4faaf71f030b07`. No unrelated baseline fix is
included.
<details>
<summary>Source hunk audit</summary>
| File | Source hunk | Disposition |
| --- | --- | --- |
| `be/src/storage/index/index_reader_helper.cpp` | `@@ -0,0 +1,54 @@` |
Adapted: use the target canonical analyzer-and-phrase policy; no
master-only normalizer exception. |
| `be/src/storage/index/index_reader_helper.h` | `@@ -103,36 +103,12 @@
public:` | Adapted: retain target compile-check wrappers and phrase-only
MATCH eligibility. |
| `be/src/storage/index/inverted/clucene/spi/clucene_postings_cursor.h`
| `@@ -155,7 +155,7 @@ public:` | Adapted: preserve source unit norm and
WAND bound behavior, adding missing norm-presence partition fields from
the older target interface. |
| `be/src/storage/index/inverted/clucene/spi/clucene_postings_cursor.h`
| `@@ -305,6 +305,8 @@ public:` | Adapted: preserve source unit norm and
WAND bound behavior, adding missing norm-presence partition fields from
the older target interface. |
| `be/src/storage/index/inverted/clucene/spi/clucene_postings_cursor.h`
| `@@ -314,8 +316,8 @@ private:` | Adapted: preserve source unit norm
and WAND bound behavior, adding missing norm-presence partition fields
from the older target interface. |
|
`be/src/storage/index/inverted/query/engine/boolean_query/operator_boolean_weight.h`
| `complete postimage` | Ported (verbatim) |
| `be/src/storage/index/inverted/query/scoring/bm25_similarity.h` | `@@
-24,14 +24,8 @@` | Ported: source change retained with target-only
surrounding context. |
| `be/src/storage/index/inverted/reader/collection_statistics.cpp` | `@@
-44,9 +44,11 @@ namespace collection_statistics_detail {` | Adapted: add
keyword/analyzed distinction; keep target analyzed-index metadata
restrictions and omit unavailable normalizer-only exception. |
| `be/src/storage/index/inverted/reader/collection_statistics.cpp` | `@@
-264,10 +266,11 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset,` | Adapted: add keyword/analyzed distinction;
keep target analyzed-index metadata restrictions and omit unavailable
normalizer-only exception. |
| `be/src/storage/index/inverted/reader/collection_statistics.cpp` | `@@
-337,12 +340,11 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset,` | Adapted: add keyword/analyzed distinction;
keep target analyzed-index metadata restrictions and omit unavailable
normalizer-only exception. |
| `be/src/storage/index/inverted/reader/collection_statistics.cpp` | `@@
-351,10 +353,8 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset,` | Adapted: add keyword/analyzed distinction;
keep target analyzed-index metadata restrictions and omit unavailable
normalizer-only exception. |
| `be/src/storage/index/inverted/reader/collection_statistics.cpp` | `@@
-375,11 +375,12 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset,` | Adapted: add keyword/analyzed distinction;
keep target analyzed-index metadata restrictions and omit unavailable
normalizer-only exception. |
| `be/src/storage/index/inverted/reader/collection_statistics.h` | `@@
-86,8 +86,9 @@ private:` | Adapted: target helper gains only
is_analyzed; retain compile-check wrappers. |
| `be/src/storage/index/inverted/reader/collection_statistics.h` | `@@
-121,11 +122,12 @@ struct SniiScoringSegmentStats {` | Adapted: target
helper gains only is_analyzed; retain compile-check wrappers. |
|
`be/test/storage/index/inverted/query/engine/boolean_postings_bench_test.cpp`
| `complete postimage` | Ported (verbatim) |
|
`be/test/storage/index/inverted/query/engine/boolean_query/listed_terms_test.cpp`
| `complete postimage` | Ported (verbatim) |
|
`be/test/storage/index/inverted/query/engine/multi_segment_collector_test.cpp`
| `complete postimage` | Ported (verbatim) |
|
`be/test/storage/index/inverted/query/engine/segment_postings_test.cpp`
| `complete postimage` | Ported (verbatim) |
| `be/test/storage/index/inverted/query/spi/fake_index_source.h` |
`complete postimage` | Ported (verbatim) |
| `be/test/storage/index/inverted/reader/collection_statistics_test.cpp`
| `@@ -667,6 +667,7 @@ protected:` | Adapted: keep keyword tests and
target fixtures; omit signature update for the normalizer-only test that
does not exist on 4.2. |
| `be/test/storage/index/inverted/reader/collection_statistics_test.cpp`
| `@@ -676,8 +677,8 @@ protected:` | Adapted: keep keyword tests and
target fixtures; omit signature update for the normalizer-only test that
does not exist on 4.2. |
| `be/test/storage/index/inverted/reader/collection_statistics_test.cpp`
| `@@ -975,26 +976,45 @@ TEST_F(CollectionStatisticsTest,
LegacyV3RejectsSegmentWrittenWithoutNorms) {` | Adapted: keep keyword
tests and target fixtures; omit signature update for the normalizer-only
test that does not exist on 4.2. |
| `be/test/storage/index/inverted/reader/collection_statistics_test.cpp`
| `@@ -1230,9 +1250,10 @@ TEST(CollectionStatisticsSniiScoringTest,
ResolveRejectsSegmentWithoutPositions)` | N-A: this hunk only updates
the pre-existing normalizer-only exception test; that path/test does not
exist on branch-4.2. Keyword coverage is retained. |
| `be/test/storage/index/inverted/reader/collection_statistics_test.cpp`
| `@@ -1939,7 +1960,9 @@ TEST_F(CollectionStatisticsTest,
CollectSkipsIndexWithoutAnalyzer) {` | Adapted: keep keyword tests and
target fixtures; omit signature update for the normalizer-only test that
does not exist on 4.2. |
| `be/test/storage/index/inverted/reader/collection_statistics_test.cpp`
| `@@ -2377,7 +2400,7 @@ TEST_F(CollectionStatisticsTest,
SearchArrayStringSelectsFulltextLeafIndex) {` | Adapted: keep keyword
tests and target fixtures; omit signature update for the normalizer-only
test that does not exist on 4.2. |
| `be/test/storage/index/inverted/reader/collection_statistics_test.cpp`
| `@@ -2397,8 +2420,8 @@ TEST_F(CollectionStatisticsTest,
SearchExactIgnoresAnalyzedBindingHint) {` | Adapted: keep keyword tests
and target fixtures; omit signature update for the normalizer-only test
that does not exist on 4.2. |
| `be/test/storage/segment/index_reader_helper_test.cpp` | `@@ -22,6
+22,7 @@` | Ported: source change retained with target-only surrounding
context. |
| `be/test/storage/segment/index_reader_helper_test.cpp` | `@@ -380,6
+381,7 @@ TEST_F(IndexReaderHelperTest,
IsNeedSimilarityScoreWithInvertedIndexQueryTypeTes` | Ported: source
change retained with target-only surrounding context. |
| `be/test/storage/segment/index_reader_helper_test.cpp` | `@@ -448,6
+450,7 @@ TEST_F(IndexReaderHelperTest,
IsNeedSimilarityScoreWithTExprOpcodeTest) {` | Ported: source change
retained with target-only surrounding context. |
| `be/test/storage/segment/index_reader_helper_test.cpp` | `@@ -460,3
+463,51 @@ TEST_F(IndexReaderHelperTest,
IsNeedSimilarityScoreWithTExprOpcodeTest) {` | Ported: source change
retained with target-only surrounding context. |
| `regression-test/data/search/test_search_score_keyword_filters.out` |
`complete postimage` | Ported (verbatim) |
| `regression-test/data/search/test_search_score_raw_index.out` |
`complete postimage` | Ported (verbatim) |
|
`regression-test/suites/search/test_search_score_keyword_filters.groovy`
| `complete postimage` | Ported (verbatim) |
| `regression-test/suites/search/test_search_score_raw_index.groovy` |
`complete postimage` | Ported (verbatim) |
| `be/src/storage/index/inverted/clucene/spi/clucene_index_source.cpp` |
`additional target adaptation` | Adapted: pass reader.hasNorms into the
older postings partition so keyword unit norms work. Source parent
already contains this plumbing. |
| `be/test/core/data_type_serde/data_type_serde_arrow_test.cpp` | `@@
-957,9 +957,8 @@ TEST(DataTypeSerDeArrowTest,
PlainArrowConverterDoesNotInferIcebergUuid) {` | N-A: historical PR-head
compatibility edit, absent from the final landed squash. The target API
still has check_duplicate_json_path. |
| `be/test/core/data_type_serde/data_type_serde_arrow_test.cpp` | `@@
-1054,9 +1053,8 @@ TEST(DataTypeSerDeArrowTest,
IcebergVariantExtensionAndParquetSchema) {` | N-A: historical PR-head
compatibility edit, absent from the final landed squash. The target API
still has check_duplicate_json_path. |
|
`be/test/core/data_type_serde/data_type_variant_v2_serde_jsonb_test.cpp`
| `@@ -112,9 +112,8 @@ std::string json_at(const DataTypeVariantV2SerDe&
serde, const IColumn& column,` | N-A: historical PR-head compatibility
edit, absent from the final landed squash. The target API still has
check_duplicate_json_path. |
| `be/test/exprs/variant_v2_execution_test.cpp` | `@@ -49,9 +49,8 @@
TEST(VariantV2ExecutionTest, TypePropertiesDoNotAffectEquality) {` |
N-A: historical PR-head compatibility edit, absent from the final landed
squash. The target API still has check_duplicate_json_path. |
</details>
#### Range-diff review
Compared the landed source commit against the target pick with `git
range-diff 4016f207bfe^..4016f207bfe HEAD^..HEAD`.
Representative differences:
```diff
## be/src/storage/index/index_reader_helper.cpp (new) ##
@@
@@ be/src/storage/index/index_reader_helper.cpp (new)
+ // 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));
++
get_parser_phrase_support_string_from_properties(properties) ==
++ INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES;
+}
+
+} // namespace
@@ be/src/storage/index/index_reader_helper.h: public:
return iter->get_reader(InvertedIndexReaderType::STRING_TYPE)
!= nullptr;
}
-+ // MATCH filters score analyzed phrase indexes and single-term
normalizers.
++ // MATCH filters score analyzed indexes with phrase support.
static bool is_need_similarity_score(InvertedIndexQueryType
query_type,
- const TabletIndex*
index_meta) {
- if (query_type == InvertedIndexQueryType::MATCH_ANY_QUERY ||
+ ## be/src/storage/index/inverted/clucene/spi/clucene_index_source.cpp
##
+@@ be/src/storage/index/inverted/clucene/spi/clucene_index_source.cpp:
ClucenePostingsCursor::Partition open_postings(lucene::index::IndexReader* reade
+ lucene::index::Term*
term, bool positions,
+ bool scoring, const
io::IOContext* io_ctx) {
+ ClucenePostingsCursor::Partition result;
++ result.has_norms = reader->hasNorms(term->field());
+ if (positions) {
+ auto iter = make_term_positions_ptr(reader, scoring, io_ctx);
+ result.positions = iter.get();
## be/src/storage/index/inverted/reader/collection_statistics.cpp ##
@@ be/src/storage/index/inverted/reader/collection_statistics.cpp:
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)) {
+- bool
has_positions, bool has_norms) {
+- if (!has_positions || !has_norms) {
++ bool
has_positions, bool has_norms,
++ bool
is_analyzed) {
++ // Keyword terms use unit frequency and length without positions
or norms.
++ // Analyzed sources still require both sections.
++ if (is_analyzed && (!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\" = "
```
The first and third groups omit the master-only normalizer exception and
preserve branch-4.2's existing analyzed-index restrictions. The second
group supplies the older target's missing norm-presence plumbing; the
landed source parent already has it. Other differences are the
corresponding helper signatures and tests, retained compile-check
context, and backport commit metadata. The source hunk that updates the
normalizer-only test is inapplicable because that feature and test do
not exist on branch-4.2. The complete audit above accounts for all
source hunks and the extra target plumbing file.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
be/src/storage/index/index_reader_helper.cpp | 53 +++++
be/src/storage/index/index_reader_helper.h | 28 +--
.../inverted/clucene/spi/clucene_index_source.cpp | 1 +
.../inverted/clucene/spi/clucene_postings_cursor.h | 12 +-
.../engine/boolean_query/operator_boolean_weight.h | 9 +
.../index/inverted/query/scoring/bm25_similarity.h | 10 +-
.../inverted/reader/collection_statistics.cpp | 23 +-
.../index/inverted/reader/collection_statistics.h | 11 +-
.../query/engine/boolean_postings_bench_test.cpp | 6 +-
.../engine/boolean_query/listed_terms_test.cpp | 26 +++
.../query/engine/multi_segment_collector_test.cpp | 7 +-
.../query/engine/segment_postings_test.cpp | 42 ++++
.../index/inverted/query/spi/fake_index_source.h | 34 ++-
.../inverted/reader/collection_statistics_test.cpp | 50 +++--
.../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 ++++++++++
19 files changed, 931 insertions(+), 74 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..51ed6baa91d
--- /dev/null
+++ b/be/src/storage/index/index_reader_helper.cpp
@@ -0,0 +1,53 @@
+// 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;
+}
+
+} // 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 ad4c856fd26..39e8eabd844 100644
--- a/be/src/storage/index/index_reader_helper.h
+++ b/be/src/storage/index/index_reader_helper.h
@@ -89,34 +89,12 @@ public:
return iter->get_reader(InvertedIndexReaderType::STRING_TYPE) !=
nullptr;
}
+ // MATCH filters score analyzed indexes with phrase support.
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) {
- 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) {
- return true;
- }
- }
- return false;
- }
+ const TabletIndex* index_meta);
};
#include "common/compile_check_end.h"
diff --git a/be/src/storage/index/inverted/clucene/spi/clucene_index_source.cpp
b/be/src/storage/index/inverted/clucene/spi/clucene_index_source.cpp
index b7be72f4770..c509c07a2a6 100644
--- a/be/src/storage/index/inverted/clucene/spi/clucene_index_source.cpp
+++ b/be/src/storage/index/inverted/clucene/spi/clucene_index_source.cpp
@@ -68,6 +68,7 @@ ClucenePostingsCursor::Partition
open_postings(lucene::index::IndexReader* reade
lucene::index::Term* term, bool
positions,
bool scoring, const
io::IOContext* io_ctx) {
ClucenePostingsCursor::Partition result;
+ result.has_norms = reader->hasNorms(term->field());
if (positions) {
auto iter = make_term_positions_ptr(reader, scoring, io_ctx);
result.positions = iter.get();
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 2786eb7a811..f6f517d46f1 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
@@ -39,12 +39,14 @@ public:
lucene::index::TermPositions* positions = nullptr;
uint32_t begin = 0;
uint32_t end = 0;
+ bool has_norms = true;
};
explicit ClucenePostingsCursor(Partition part)
: _single_iter(std::move(part.iter)),
_iter(_single_iter.get()),
- _raw_positions(part.positions) {
+ _raw_positions(part.positions),
+ _has_norms(part.has_norms) {
_check_iterator();
_doc_freq = _iter->docFreq();
}
@@ -92,7 +94,7 @@ public:
? std::span<const
uint32_t>(_block.freq_many->data(),
_block.freq_many_size_)
: std::span<const uint32_t>(),
- .norms = _block.norm_many
+ .norms = _has_norms && _block.norm_many
? std::span<const
uint32_t>(_block.norm_many->data(),
_block.norm_many_size_)
: std::span<const uint32_t>()};
@@ -152,7 +154,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;
@@ -302,6 +304,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 = {};
@@ -311,6 +315,7 @@ private:
const auto& part = _partitions[index];
_iter = part.iter.get();
_raw_positions = part.positions;
+ _has_norms = part.has_norms;
_doc_base = part.begin;
}
}
@@ -376,6 +381,7 @@ private:
const uint32_t* _freqs = nullptr;
uint32_t _prox_cursor = 0;
lucene::index::TermPositions* _raw_positions = nullptr;
+ bool _has_norms = true;
uint32_t _position_remaining = 0;
uint32_t _position = 0;
bool _position_open = false;
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 aa977ed4b68..3adc0c3d850 100644
--- a/be/src/storage/index/inverted/query/scoring/bm25_similarity.h
+++ b/be/src/storage/index/inverted/query/scoring/bm25_similarity.h
@@ -25,14 +25,8 @@
namespace doris::segment_v2 {
#include "common/compile_check_begin.h"
-/**
- * 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 e2d31df8b6a..16185e58570 100644
--- a/be/src/storage/index/inverted/reader/collection_statistics.cpp
+++ b/be/src/storage/index/inverted/reader/collection_statistics.cpp
@@ -43,8 +43,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) {
- if (!has_positions || !has_norms) {
+ bool
has_positions, bool has_norms,
+ bool is_analyzed)
{
+ // Keyword terms use unit frequency and length without positions or norms.
+ // Analyzed sources still require both sections.
+ if (is_analyzed && (!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,7 +267,9 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset, int6
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_accumulator));
+ scoring_stats.has_positions, scoring_stats.has_norms,
&segment_accumulator,
+
segment_v2::inverted_index::InvertedIndexAnalyzer::should_analyzer(
+ collect_info.index_meta->properties())));
for (const auto& logical_term_bytes : collect_info.unique_terms) {
const auto logical_term =
@@ -334,8 +339,8 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset, int6
// 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. An index
- // that is not analyzed never writes norms and is left as it is.
+ // segments that have norms, so refuse to score the collection, as
SNII does.
+ // Keyword indexes instead use unit document length.
if (!scoring_stats.has_norms &&
segment_v2::inverted_index::InvertedIndexAnalyzer::should_analyzer(
collect_info.index_meta->properties())) {
@@ -345,7 +350,8 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset, int6
"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));
}
- _total_num_tokens[ws_field_name] += 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 =
@@ -366,10 +372,11 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset, int6
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, SniiScoringSegmentAccumulator*
segment_accumulator) {
+ bool has_positions, bool has_norms, SniiScoringSegmentAccumulator*
segment_accumulator,
+ bool is_analyzed) {
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);
+ index_doc_count, sum_total_term_freq, has_positions, has_norms,
is_analyzed);
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 8f246b8970e..b05b9470426 100644
--- a/be/src/storage/index/inverted/reader/collection_statistics.h
+++ b/be/src/storage/index/inverted/reader/collection_statistics.h
@@ -87,7 +87,8 @@ private:
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,
- SniiScoringSegmentAccumulator*
segment_accumulator);
+ SniiScoringSegmentAccumulator*
segment_accumulator,
+ bool is_analyzed = true);
void commit_snii_scoring_segment(SniiScoringSegmentAccumulator&&
segment_accumulator);
void clear();
@@ -121,12 +122,12 @@ struct SniiScoringSegmentStats {
uint64_t token_count = 0;
};
-// SNII scoring requires positions (which provide term frequencies) and norms.
The current writer
-// emits norms for every analyzed index with positions. Older segments without
norms return
-// NOT_SUPPORTED until an index rebuild or compaction supplies them.
+// Analyzed SNII indexes need positions and norms. Keyword indexes 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
has_positions, bool has_norms,
+ bool is_analyzed
= true);
void add_term_doc_frequency(
std::unordered_map<std::wstring, std::unordered_map<std::wstring,
uint64_t>>*
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 c19e4f9f4ab..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,7 +202,8 @@ 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);
- const float term_score = similarity.score(1.0F, 0);
+ // 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) {
bool required = true;
@@ -255,7 +256,8 @@ PhysicalTruthRows expected_physical_truth(const
PostingSpec& spec, uint32_t prof
PhysicalTruthRows result;
result.scores.resize(kPostingRows);
BM25Similarity similarity(2.0F, 8.0F);
- const float term_score = similarity.score(1.0F, 0);
+ // 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;
bool required_possible = 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 fcf80f0c197..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,7 +249,9 @@ 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);
- EXPECT_EQ(block.norm_at(0), 0);
+ // These segments omit norms; crossing a partition must retain unit length.
+ EXPECT_TRUE(block.norms.empty());
+ EXPECT_EQ(block.norm_at(0), 1);
}
static void check_partition_position(index_query::PostingsCursor& cursor,
uint32_t position) {
@@ -407,7 +409,8 @@ TEST_F(MultiSegmentCollectorTest,
PhraseCandidatesRemainValidAfterWeightDestruct
auto scorer = weight->scorer(segment, "");
weight.reset();
ASSERT_EQ(scorer->doc(), 3);
- const float expected_score = scoring ? similarity->score(1.0F, 0)
: 1.0F;
+ // 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);
EXPECT_EQ(scorer->advance(), TERMINATED);
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 30c43e4bafe..2dfceefadbc 100644
--- a/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
+++ b/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
@@ -656,6 +656,7 @@ protected:
uint64_t sum_total_term_freq = 0;
bool has_positions = true;
bool has_norms = true;
+ bool is_analyzed = true;
};
Status stage_snii_fields_for_test(
@@ -664,7 +665,7 @@ 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,
segment_accumulator));
+ field.has_positions, field.has_norms, segment_accumulator,
field.is_analyzed));
}
return Status::OK();
}
@@ -962,26 +963,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) {
@@ -1903,7 +1923,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));
@@ -2256,7 +2278,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";
@@ -2276,8 +2298,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 d2038e8bd8f..509a5eb4067 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;
@@ -405,6 +407,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;
@@ -417,3 +420,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]