This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new a8f9f5d419d branch-4.1: [fix](be) Score MATCH on keyword inverted
indexes like SEARCH (#69791)
a8f9f5d419d is described below
commit a8f9f5d419deaa4c1e04db575554d6cfdd46f48a
Author: Jack <[email protected]>
AuthorDate: Thu Nov 5 09:31:24 2026 +0800
branch-4.1: [fix](be) Score MATCH on keyword inverted indexes like SEARCH
(#69791)
### What problem does this PR solve?
Issue Number: None
Related PR: #69790
Problem Summary: score() of a MATCH on an untokenized (keyword) inverted
index is always 0, while SEARCH on the same field scores BM25:
INDEX idx_redirect (redirect) USING INVERTED
-- one row ('BP')
SELECT score() FROM t WHERE redirect MATCH 'BP'; -- 0
SELECT score() FROM t WHERE SEARCH('redirect:BP'); -- 0.2876821
Elasticsearch scores a match, term or match_phrase query on a keyword
field with BM25 in query context: no norms and no frequencies, so
freq = dl = avgdl = 1 and the score is the idf of the whole value
(0.2876821 above, 0.6931471 for N=4, n=2). Only a bool filter scores 0.
SEARCH already gives exactly these values; MATCH on keyword behaved like
a filter, so `body MATCH 'apple' AND tag MATCH 'x'` ranked as if the tag
clause were not there.
Root cause on branch-4.1, three places keep keyword MATCH out of
scoring:
StringTypeInvertedIndexReader::query never sets is_similarity_score (and
answers score() queries from the query cache), MatchPredicateCollector
skips indexes without an analyzer, and is_need_similarity_score only
accepts phrase-capable or lowercase-normalizer indexes.
Fix: a keyword (non-analyzed) index is eligible for MATCH scoring
regardless of support_phrase; the keyword reader requests scoring for
the MATCH query types and bypasses the result cache like the full-text
reader does; MatchPredicateCollector collects the whole value as one
term (case and spaces kept), the term the reader looks up. The legacy
scorer already uses norm 1 when a field has none. Equality runs as
MATCH_ANY inside the reader but keeps EQUAL_QUERY for the decision, so
it stays a pure filter. MATCH_REGEXP and MATCH_PHRASE_PREFIX keep their
current scores.
### Release note
score() of MATCH / MATCH_ALL / MATCH_PHRASE on a keyword (untokenized)
inverted index now returns the BM25 score of the value, the same as
SEARCH and Elasticsearch, instead of 0.
### Check List (For Author)
- Test: Regression test / Unit Test / Manual test
- Regression test: new test_match_keyword_score (V2/V3, implicit and
explicit parser=none); its output equals the master PR's V2/V3
blocks
- Unit Test: IndexReaderHelperTest, CollectionStatisticsTest
- Manual test: the reported table on a branch-4.1 ASAN build; MATCH
equals SEARCH and Elasticsearch 9.5.5 to 7 digits, also for
ARRAY<STRING>, a column with both a keyword and an analyzed index,
and OR/AND mixes
- Behavior changed: Yes. Keyword MATCH contributes its BM25 score to
score() instead of 0.
- Does this need documentation: No
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../storage/compaction/collection_statistics.cpp | 17 ++-
be/src/storage/index/index_reader_helper.h | 46 ++++---
.../index/inverted/inverted_index_reader.cpp | 12 +-
be/src/storage/predicate_collector.cpp | 11 +-
.../compaction/collection_statistics_test.cpp | 16 ++-
.../storage/segment/index_reader_helper_test.cpp | 27 ++++
.../inverted_index_p0/test_match_keyword_score.out | 149 +++++++++++++++++++++
.../search/test_search_score_pattern_constant.out | 8 ++
.../test_match_keyword_score.groovy | 143 ++++++++++++++++++++
.../test_search_score_pattern_constant.groovy | 1 +
10 files changed, 393 insertions(+), 37 deletions(-)
diff --git a/be/src/storage/compaction/collection_statistics.cpp
b/be/src/storage/compaction/collection_statistics.cpp
index 94ab5cefec6..6e89aa157b2 100644
--- a/be/src/storage/compaction/collection_statistics.cpp
+++ b/be/src/storage/compaction/collection_statistics.cpp
@@ -88,6 +88,14 @@ Result<std::shared_ptr<lucene::search::IndexSearcher>>
open_optional_clucene_sta
}
return ResultError(status);
}
+ // Only text indexes have a segments file. A MATCH can reach a BKD index:
CAST(n AS STRING)
+ // MATCH '1' on an INT column, or on a VARIANT path v.n, which is typed
per segment and indexed
+ // as BKD where it holds numbers. Such an index has no terms and adds no
statistics, as a
+ // segment without the path does; its rows match without the index and
score nothing.
+ if (compound.value()->fileExists(
+
InvertedIndexDescriptor::get_temporary_bkd_index_data_file_name())) {
+ return std::shared_ptr<lucene::search::IndexSearcher> {};
+ }
lucene::index::IndexReader* reader = nullptr;
try {
reader = lucene::index::IndexReader::open(compound.value().get());
@@ -650,8 +658,13 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset, int3
}
auto searcher_variant =
inverted_index_cache_handle.get_index_searcher();
- auto index_searcher_ptr =
std::get<FulltextIndexSearcherPtr>(searcher_variant);
- index_searcher = index_searcher_ptr.get();
+ // CAST(v['n'] AS BIGINT) = 1 caches the BKD searcher of v.n under
this key, so a BKD index
+ // can also arrive from the cache: no terms, as in
open_optional_clucene_stats_searcher.
+ const auto* fulltext_searcher =
std::get_if<FulltextIndexSearcherPtr>(&searcher_variant);
+ if (fulltext_searcher == nullptr) {
+ continue;
+ }
+ index_searcher = fulltext_searcher->get();
index_reader = index_searcher->getReader();
#endif
// BM25 on an analyzed index needs the record length of every row, and
CLucene keeps
diff --git a/be/src/storage/index/index_reader_helper.h
b/be/src/storage/index/index_reader_helper.h
index 4b436feed5f..d251741ee4e 100644
--- a/be/src/storage/index/index_reader_helper.h
+++ b/be/src/storage/index/index_reader_helper.h
@@ -22,6 +22,7 @@
#include <string>
#include "storage/index/index_iterator.h"
+#include "storage/index/inverted/analyzer/analyzer.h"
#include "storage/index/inverted/inverted_index_reader.h"
namespace doris::segment_v2 {
@@ -112,18 +113,11 @@ public:
if (query_type == InvertedIndexQueryType::MATCH_REGEXP_QUERY) {
return true;
}
- 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;
+ 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);
}
static bool is_need_similarity_score(TExprOpcode::type query_type,
@@ -132,17 +126,25 @@ public:
if (query_type == TExprOpcode::MATCH_REGEXP) {
return true;
}
- 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 (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);
+ }
+
+private:
+ static bool can_score_match(const TabletIndex* index_meta) {
+ const auto& properties = index_meta->properties();
+ // A keyword index keeps each value as one term, without positions or
norms, and its
+ // postings score with unit frequency and length whatever
support_phrase says, as an
+ // Elasticsearch keyword field does: over tags (x, x, y, z), tag MATCH
'x' scores
+ // idf(x) = ln(2) for both matching rows, the same as SEARCH('tag:x').
+ if
(!inverted_index::InvertedIndexAnalyzer::should_analyzer(properties)) {
+ return true;
}
- return false;
+ return get_parser_phrase_support_string_from_properties(properties) ==
+ INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES ||
+ is_normalizer_only(properties);
}
};
diff --git a/be/src/storage/index/inverted/inverted_index_reader.cpp
b/be/src/storage/index/inverted/inverted_index_reader.cpp
index 12c382fd062..1fd9dd7f5c7 100644
--- a/be/src/storage/index/inverted/inverted_index_reader.cpp
+++ b/be/src/storage/index/inverted/inverted_index_reader.cpp
@@ -537,8 +537,15 @@ Status StringTypeInvertedIndexReader::query(const
IndexQueryContextPtr& context,
search_str};
auto* cache = InvertedIndexQueryCache::instance();
InvertedIndexQueryCacheHandle cache_handler;
- if (handle_query_cache(context, cache, cache_key, &cache_handler,
bit_map)) {
- return Status::OK();
+ // A MATCH on a keyword index scores its whole value like SEARCH does,
so with score() it
+ // must run and publish its scores instead of answering from the
cached bitmap. Equality
+ // runs as MATCH_ANY below but keeps EQUAL_QUERY here, so it stays an
unscored filter.
+ const bool is_similarity_score =
+ IndexReaderHelper::is_need_similarity_score(query_type,
&_index_meta);
+ if (!(context->collection_similarity && is_similarity_score)) {
+ if (handle_query_cache(context, cache, cache_key, &cache_handler,
bit_map)) {
+ return Status::OK();
+ }
}
std::wstring column_name_ws =
StringUtil::string_to_wstring(column_name);
@@ -546,6 +553,7 @@ Status StringTypeInvertedIndexReader::query(const
IndexQueryContextPtr& context,
InvertedIndexQueryInfo query_info;
query_info.field_name = column_name_ws;
query_info.term_infos.emplace_back(search_str, 0);
+ query_info.is_similarity_score = is_similarity_score;
auto result = std::make_shared<roaring::Roaring>();
FulltextIndexSearcherPtr* searcher_ptr = nullptr;
diff --git a/be/src/storage/predicate_collector.cpp
b/be/src/storage/predicate_collector.cpp
index cfebb513f50..166386260eb 100644
--- a/be/src/storage/predicate_collector.cpp
+++ b/be/src/storage/predicate_collector.cpp
@@ -288,10 +288,6 @@ Status MatchPredicateCollector::collect(RuntimeState*
state, const TabletSchemaS
index_metas = {selected};
}
for (const auto* index_meta : index_metas) {
- if (!InvertedIndexAnalyzer::should_analyzer(index_meta->properties()))
{
- continue;
- }
-
if (!IndexReaderHelper::is_need_similarity_score(expr->op(),
index_meta)) {
continue;
}
@@ -305,7 +301,12 @@ Status MatchPredicateCollector::collect(RuntimeState*
state, const TabletSchemaS
options.timezone = &state->timezone_obj();
const std::string value = right_literal->value(options);
std::vector<TermInfo> term_infos;
- if (match_analyzer_ctx != nullptr) {
+ if (!InvertedIndexAnalyzer::should_analyzer(index_meta->properties()))
{
+ // A keyword index stores the whole value as one term, case and
spaces included, and
+ // its reader looks it up unanalyzed: tag MATCH 'Hello World'
scores the single term
+ // "Hello World", as SEARCH('tag:"Hello World"') does.
+ term_infos.emplace_back(value);
+ } else if (match_analyzer_ctx != nullptr) {
term_infos = DORIS_TRY(analyze_plain_query(value,
*match_analyzer_ctx));
} else {
term_infos = DORIS_TRY(analyze_plain_query(value,
index_meta->properties()));
diff --git a/be/test/storage/compaction/collection_statistics_test.cpp
b/be/test/storage/compaction/collection_statistics_test.cpp
index 705e2bb3b21..517068c614b 100644
--- a/be/test/storage/compaction/collection_statistics_test.cpp
+++ b/be/test/storage/compaction/collection_statistics_test.cpp
@@ -1766,10 +1766,9 @@ TEST_F(CollectionStatisticsTest,
CollectNotExtractedColumnSkipsFallback) {
EXPECT_TRUE(collect_infos.empty());
}
-// L1: Index whose properties do not request an analyzer
-// (should_analyzer returns false). The matching index_meta is iterated
-// but skipped before insertion.
-TEST_F(CollectionStatisticsTest, CollectSkipsIndexWithoutAnalyzer) {
+// L1: Index whose properties do not request an analyzer (should_analyzer
returns false). A keyword
+// MATCH scores like SEARCH on the same field, so its whole value is collected
as one term.
+TEST_F(CollectionStatisticsTest, CollectsKeywordIndexValueAsOneTerm) {
auto tablet_schema = std::make_shared<TabletSchema>();
constexpr int32_t kColUid = 1300;
@@ -1794,7 +1793,7 @@ TEST_F(CollectionStatisticsTest,
CollectSkipsIndexWithoutAnalyzer) {
auto match_expr =
std::make_shared<collection_statistics::MockVExpr>(TExprNodeType::MATCH_PRED);
auto slot_ref =
std::make_shared<collection_statistics::MockVSlotRef>("kw", SlotId(kSlotId));
- auto literal = std::make_shared<collection_statistics::MockVLiteral>("v");
+ auto literal =
std::make_shared<collection_statistics::MockVLiteral>("Hello World");
match_expr->_children.push_back(slot_ref);
match_expr->_children.push_back(literal);
@@ -1803,7 +1802,12 @@ TEST_F(CollectionStatisticsTest,
CollectSkipsIndexWithoutAnalyzer) {
auto status =
collector.collect(runtime_state_.get(), tablet_schema, match_expr,
&collect_infos);
ASSERT_TRUE(status.ok()) << status.msg();
- EXPECT_TRUE(collect_infos.empty());
+ ASSERT_EQ(collect_infos.size(), 1U);
+ const auto& collect_info = collect_infos.begin()->second;
+ ASSERT_NE(collect_info.index_meta, nullptr);
+ EXPECT_EQ(collect_info.index_meta->index_id(), 2300);
+ ASSERT_EQ(collect_info.term_infos.size(), 1U);
+ EXPECT_EQ(collect_info.term_infos.begin()->get_single_term(), "Hello
World");
}
// L2: Index whose analyzer is set (should_analyzer returns true) but does
diff --git a/be/test/storage/segment/index_reader_helper_test.cpp
b/be/test/storage/segment/index_reader_helper_test.cpp
index 352d6853249..187162c3344 100644
--- a/be/test/storage/segment/index_reader_helper_test.cpp
+++ b/be/test/storage/segment/index_reader_helper_test.cpp
@@ -439,6 +439,33 @@ TEST_F(IndexReaderHelperTest,
NormalizerOnlyPhraseAndScoringNeedNoPositions) {
InvertedIndexQueryType::MATCH_ANY_QUERY, &custom_meta));
}
+TEST_F(IndexReaderHelperTest, MatchScoringAcceptsKeywordIndexes) {
+ // Keyword indexes score with unit frequency and length, so phrase support
does not matter.
+ for (const auto& properties : std::vector<std::map<std::string,
std::string>> {
+ {}, {{"parser", "none"}}, {{"parser", "none"},
{"support_phrase", "true"}}}) {
+ SCOPED_TRACE(::testing::PrintToString(properties));
+ TabletIndexPB pb;
+ pb.set_index_id(1);
+ pb.set_index_name("keyword_index");
+ pb.set_index_type(IndexType::INVERTED);
+ pb.add_col_unique_id(1);
+ for (const auto& [key, value] : 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_TRUE(IndexReaderHelper::is_need_similarity_score(op,
&index));
+ }
+ EXPECT_TRUE(IndexReaderHelper::is_need_similarity_score(
+ InvertedIndexQueryType::MATCH_ANY_QUERY, &index));
+
EXPECT_FALSE(IndexReaderHelper::is_need_similarity_score(TExprOpcode::EQ,
&index));
+ EXPECT_FALSE(IndexReaderHelper::is_need_similarity_score(
+ InvertedIndexQueryType::EQUAL_QUERY, &index));
+ }
+}
+
TEST_F(IndexReaderHelperTest, RegexpScoresConstantOnEveryIndex) {
// A regexp scores a constant 1 and reads no statistics, so neither the
analyzer nor phrase
// support decides whether it scores.
diff --git
a/regression-test/data/inverted_index_p0/test_match_keyword_score.out
b/regression-test/data/inverted_index_p0/test_match_keyword_score.out
new file mode 100644
index 00000000000..ec56ffcd1a1
--- /dev/null
+++ b/regression-test/data/inverted_index_p0/test_match_keyword_score.out
@@ -0,0 +1,149 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !V2_single_row_match --
+1 0.287682
+
+-- !V2_match --
+1 0.693147
+2 0.693147
+5 0.693147
+
+-- !V2_match_all --
+1 0.693147
+2 0.693147
+5 0.693147
+
+-- !V2_match_phrase --
+1 0.693147
+2 0.693147
+5 0.693147
+
+-- !V2_search --
+1 0.693147
+2 0.693147
+5 0.693147
+
+-- !V2_none_match --
+1 0.693147
+3 0.693147
+6 0.693147
+
+-- !V2_none_match_phrase --
+1 0.693147
+3 0.693147
+6 0.693147
+
+-- !V2_none_search --
+1 0.693147
+3 0.693147
+6 0.693147
+
+-- !V2_analyzed_and_keyword --
+1 0.983863
+2 0.893357
+5 0.961783
+
+-- !V2_search_and --
+1 0.983863
+2 0.893357
+5 0.961783
+
+-- !V2_analyzed_and_equality --
+1 0.290716
+2 0.20021
+5 0.268636
+
+-- !V2_analyzed_and_numeric_cast --
+1 0.290716
+4 0.268636
+
+-- !V2_variant_numeric_path --
+1 0
+3 0
+4 0.287682
+
+-- !V2_variant_text_path --
+1 0.693147
+4 0.693147
+
+-- !V2_variant_sparse_path --
+1 0.980829
+
+-- !V2_variant_numeric_path_cached_bkd --
+1 0
+3 0
+4 0.287682
+
+-- !V3_single_row_match --
+1 0.287682
+
+-- !V3_match --
+1 0.693147
+2 0.693147
+5 0.693147
+
+-- !V3_match_all --
+1 0.693147
+2 0.693147
+5 0.693147
+
+-- !V3_match_phrase --
+1 0.693147
+2 0.693147
+5 0.693147
+
+-- !V3_search --
+1 0.693147
+2 0.693147
+5 0.693147
+
+-- !V3_none_match --
+1 0.693147
+3 0.693147
+6 0.693147
+
+-- !V3_none_match_phrase --
+1 0.693147
+3 0.693147
+6 0.693147
+
+-- !V3_none_search --
+1 0.693147
+3 0.693147
+6 0.693147
+
+-- !V3_analyzed_and_keyword --
+1 0.983863
+2 0.893357
+5 0.961783
+
+-- !V3_search_and --
+1 0.983863
+2 0.893357
+5 0.961783
+
+-- !V3_analyzed_and_equality --
+1 0.290716
+2 0.20021
+5 0.268636
+
+-- !V3_analyzed_and_numeric_cast --
+1 0.290716
+4 0.268636
+
+-- !V3_variant_numeric_path --
+1 0
+3 0
+4 0.287682
+
+-- !V3_variant_text_path --
+1 0.693147
+4 0.693147
+
+-- !V3_variant_sparse_path --
+1 0.980829
+
+-- !V3_variant_numeric_path_cached_bkd --
+1 0
+3 0
+4 0.287682
+
diff --git a/regression-test/data/search/test_search_score_pattern_constant.out
b/regression-test/data/search/test_search_score_pattern_constant.out
index f2a3c30f31d..6af1ee8e6c2 100644
--- a/regression-test/data/search/test_search_score_pattern_constant.out
+++ b/regression-test/data/search/test_search_score_pattern_constant.out
@@ -23,6 +23,10 @@
3 1
6 1
+-- !V2_match_regexp_keyword --
+6 1
+7 1
+
-- !V2_match_and_match_regexp --
2 2.023573
@@ -60,6 +64,10 @@
3 1
6 1
+-- !V3_match_regexp_keyword --
+6 1
+7 1
+
-- !V3_match_and_match_regexp --
2 2.023573
diff --git
a/regression-test/suites/inverted_index_p0/test_match_keyword_score.groovy
b/regression-test/suites/inverted_index_p0/test_match_keyword_score.groovy
new file mode 100644
index 00000000000..56daca02b31
--- /dev/null
+++ b/regression-test/suites/inverted_index_p0/test_match_keyword_score.groovy
@@ -0,0 +1,143 @@
+// 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.
+
+// score() over MATCH on a keyword (untokenized) inverted index. Like an
Elasticsearch match or term
+// query on a keyword field, it scores BM25 with unit frequency and length, so
every matching row
+// scores the idf of the whole value, the same as SEARCH on that field; it
used to score 0. An
+// equality predicate stays a pure filter.
+suite("test_match_keyword_score", "p0") {
+ // MATCH and SEARCH need index execution even when CI fuzzes these session
defaults.
+ sql "set enable_common_expr_pushdown = true"
+ sql "set enable_inverted_index_query = true"
+ sql "set enable_match_without_inverted_index = true"
+
+ for (def storageFormat : ["V2", "V3"]) {
+ sql "DROP TABLE IF EXISTS test_match_keyword_score"
+ sql """
+ CREATE TABLE test_match_keyword_score (
+ id INT NOT NULL,
+ body STRING NULL,
+ tag STRING NULL,
+ tag_none STRING NULL,
+ n INT NULL,
+ INDEX idx_body(body) USING INVERTED
+ PROPERTIES("parser" = "english", "support_phrase" =
"true"),
+ INDEX idx_tag(tag) USING INVERTED PROPERTIES("ignore_above" =
"1024"),
+ INDEX idx_tag_none(tag_none) USING INVERTED
+ PROPERTIES("parser" = "none", "support_phrase" = "true"),
+ INDEX idx_n(n) 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}"
+ )
+ """
+ // The first row alone is the reported case: one document scores idf =
ln(4/3) = 0.287682.
+ sql """INSERT INTO test_match_keyword_score VALUES (1, 'apple apple',
'BP', 'Hello World', 1)"""
+ sql "sync"
+ quickTest("${storageFormat}_single_row_match", """
+ SELECT id, ROUND(s, 6) FROM (
+ SELECT id, score() AS s FROM test_match_keyword_score
+ WHERE tag MATCH 'BP' ORDER BY s DESC LIMIT 10
+ ) hits ORDER BY id
+ """, true)
+
+ sql """INSERT INTO test_match_keyword_score VALUES
+ (2, 'apple pear', 'BP', 'hello world', 2), (3, 'pear', 'other',
'Hello World', 1),
+ (4, 'apple', 'x', 'Hello', 1), (5, 'apple', 'BP', NULL, NULL),
+ (6, 'apple', NULL, 'Hello World', 2)
+ """
+ sql "sync"
+
+ // The whole value is one term: MATCH, MATCH_ALL and MATCH_PHRASE
score as SEARCH does.
+ for (def query : [
+ ["match", "tag MATCH 'BP'"],
+ ["match_all", "tag MATCH_ALL 'BP'"],
+ ["match_phrase", "tag MATCH_PHRASE 'BP'"],
+ ["search", "SEARCH('tag:BP')"],
+ // Case and spaces are kept, and phrase support inherited by
parser=none is ignored.
+ ["none_match", "tag_none MATCH 'Hello World'"],
+ ["none_match_phrase", "tag_none MATCH_PHRASE 'Hello World'"],
+ ["none_search", "SEARCH('tag_none:\"Hello World\"')"],
+ // Both leaves score: idf(apple) from body plus idf(BP) from
tag.
+ ["analyzed_and_keyword", "body MATCH 'apple' AND tag MATCH
'BP'"],
+ ["search_and", "SEARCH('body:apple AND tag:BP')"],
+ // An equality is a filter: only the body leaf scores.
+ ["analyzed_and_equality", "body MATCH 'apple' AND tag = 'BP'"],
+ // A MATCH on a number cast to text is a filter over the BKD
index of n: only the
+ // body leaf scores. Opening that BKD index for statistics
used to abort the BE.
+ ["analyzed_and_numeric_cast", "body MATCH 'apple' AND CAST(n
AS STRING) MATCH '1'"]]) {
+ quickTest("${storageFormat}_${query[0]}", """
+ SELECT id, ROUND(s, 6) FROM (
+ SELECT id, score() AS s FROM test_match_keyword_score
+ WHERE ${query[1]} ORDER BY s DESC LIMIT 10
+ ) hits ORDER BY id
+ """, true)
+ }
+
+ // A VARIANT path is typed per segment: n holds numbers, indexed as
BKD, in the first
+ // insert and text in the second. The plain VARIANT index makes every
path a keyword
+ // index, so CAST(v['n'] AS STRING) MATCH collects statistics for n;
the BKD segment adds
+ // none instead of aborting the BE, and its rows match without the
index with score 0. p
+ // exists only in the first insert: the other segment has no index for
it and adds nothing.
+ sql "set default_variant_enable_typed_paths_to_sparse = false"
+ sql "set default_variant_enable_doc_mode = false"
+ sql "DROP TABLE IF EXISTS test_match_keyword_score_variant"
+ sql """
+ CREATE TABLE test_match_keyword_score_variant (
+ id INT NOT NULL,
+ v VARIANT NULL,
+ INDEX idx_v(v) 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}",
+ "disable_auto_compaction" = "true"
+ )
+ """
+ sql """INSERT INTO test_match_keyword_score_variant VALUES
+ (1, '{"n": 1, "s": "abc", "p": "abc"}'), (2, '{"n": 2, "s": "abd",
"p": "abd"}'),
+ (3, '{"n": 1, "s": "xyz", "p": "xyz"}')
+ """
+ sql """INSERT INTO test_match_keyword_score_variant VALUES (4, '{"n":
"1", "s": "abc"}')"""
+ sql "sync"
+ for (def query : [
+ ["variant_numeric_path", "CAST(v['n'] AS STRING) MATCH '1'"],
+ ["variant_text_path", "CAST(v['s'] AS STRING) MATCH 'abc'"],
+ ["variant_sparse_path", "CAST(v['p'] AS STRING) MATCH
'abc'"]]) {
+ quickTest("${storageFormat}_${query[0]}", """
+ SELECT id, ROUND(s, 6) FROM (
+ SELECT id, score() AS s FROM
test_match_keyword_score_variant
+ WHERE ${query[1]} ORDER BY s DESC LIMIT 10
+ ) hits ORDER BY id
+ """, true)
+ }
+ // A numeric predicate leaves the BKD searcher of n in the searcher
cache, where collecting
+ // statistics finds it next: it used to take it for a text searcher
and abort the BE.
+ sql "SELECT COUNT(*) FROM test_match_keyword_score_variant WHERE
CAST(v['n'] AS BIGINT) = 1"
+ quickTest("${storageFormat}_variant_numeric_path_cached_bkd", """
+ SELECT id, ROUND(s, 6) FROM (
+ SELECT id, score() AS s FROM test_match_keyword_score_variant
+ WHERE CAST(v['n'] AS STRING) MATCH '1' ORDER BY s DESC LIMIT 10
+ ) hits ORDER BY id
+ """, true)
+ }
+}
diff --git
a/regression-test/suites/search/test_search_score_pattern_constant.groovy
b/regression-test/suites/search/test_search_score_pattern_constant.groovy
index 430f313b10f..ad8117c9256 100644
--- a/regression-test/suites/search/test_search_score_pattern_constant.groovy
+++ b/regression-test/suites/search/test_search_score_pattern_constant.groovy
@@ -62,6 +62,7 @@ suite("test_search_score_pattern_constant", "p0") {
["search_prefix", "SEARCH('content:te*')"],
["search_keyword_regexp", "SEARCH('tag:/text.*/')"],
["match_regexp", "content MATCH_REGEXP 'ext'"],
+ ["match_regexp_keyword", "tag MATCH_REGEXP 'text'"],
// A pattern next to a scored clause adds exactly 1, as an
Elasticsearch bool must
// with a regexp clause does.
["match_and_match_regexp", "content MATCH 'search' AND content
MATCH_REGEXP 'ext'"],
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]