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 126771d7bc0 [fix](inverted index) Align phrase position defaults on 
4.1 (#69190)
126771d7bc0 is described below

commit 126771d7bc018c3147c01cf6466cc3a8c137d372
Author: Jack <[email protected]>
AuthorDate: Sun Oct 18 11:35:19 2026 +0800

    [fix](inverted index) Align phrase position defaults on 4.1 (#69190)
    
    ### What problem does this PR solve?
    
    Issue Number: CIR-22263
    
    Related PR: #69199
    
    Problem Summary: FE persists `support_phrase=true` for new tokenized
    indexes and leaves exact indexes docs-only. FE and BE retain the
    docs-only interpretation of an omitted property on older persisted
    analyzer indexes, preserving their postings and `TOKENIZE` output.
    Built-in `lowercase` normalization emits one term, so phrase predicates
    can use docs-only postings and BM25 can use unit frequency and document
    length. Custom normalizers can emit multiple terms, so new indexes
    retain positions by default, including when a legacy custom policy
    shadows the built-in name.
    
    This is the `branch-4.1` adaptation of #69199. It uses the branch's
    CLucene scoring path and the keyword-index scoring support already
    merged into `branch-4.1`. This branch has no SNII streamed compaction
    path from #69192, so this PR does not eliminate docs-only rebuilds or
    the tokenized T2 merge hotspot in CIR-22263.
    
    ### Release note
    
    New exact and built-in lowercase-normalized indexes default to docs-only
    postings. Custom normalizers retain phrase positions by default.
    Lowercase-normalized indexes retain phrase matching and relevance
    scoring without stored positions.
    
    ### Check List (For Author)
    
    - Test
    - [x] Unit Test: two targeted FE default and policy-binding tests and
    eight BE collection-statistics, query-scoring, postings, and reader
    tests on the current target branch.
    - [x] Complete PR diff passed the English description check,
    clang-format 16, and Checkstyle on the merged target tree.
    - Behavior changed:
    - [x] Yes. Exact indexes omit positions by default; custom normalizers
    retain positions; built-in lowercase normalization keeps phrase and
    scoring behavior with docs-only postings.
    - Does this need documentation?
        - [x] No.
    
    Remote checks on the updated head are pending.
---
 .../storage/compaction/collection_statistics.cpp   | 10 ++-
 be/src/storage/index/index_reader_helper.h         | 25 +++++-
 .../storage/index/inverted/query/query_helper.cpp  | 18 +++--
 .../compaction/collection_statistics_test.cpp      | 92 ++++++++++++++++++++++
 .../index/inverted/query/query_helper_test.cpp     | 65 ++++++++++++++-
 .../storage/index/inverted_index_parser_test.cpp   | 24 +++++-
 .../storage/segment/index_reader_helper_test.cpp   | 43 ++++++++++
 .../apache/doris/analysis/InvertedIndexUtil.java   | 15 +++-
 .../main/java/org/apache/doris/catalog/Index.java  | 11 ++-
 .../analysis/InvertedIndexPropertiesTest.java      |  5 +-
 .../java/org/apache/doris/catalog/IndexTest.java   | 51 ++++++++++++
 .../suites/index_p0/test_index_meta.groovy         |  4 +-
 .../search/test_search_score_raw_index.groovy      |  3 +
 13 files changed, 338 insertions(+), 28 deletions(-)

diff --git a/be/src/storage/compaction/collection_statistics.cpp 
b/be/src/storage/compaction/collection_statistics.cpp
index 3aeb129d93f..6b59c1cc1ec 100644
--- a/be/src/storage/compaction/collection_statistics.cpp
+++ b/be/src/storage/compaction/collection_statistics.cpp
@@ -208,11 +208,12 @@ Status CollectionStatistics::process_segment(const 
RowsetSharedPtr& rowset, int3
         // 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. An 
index that is not
-        // analyzed never writes norms; use an average length of one when its 
legacy
-        // metadata does not include the total term frequency.
+        // segments that have norms, so refuse to score the collection. 
Keyword indexes and
+        // built-in lowercase normalizers can use unit frequency and length 
instead.
         const auto token_count = 
index_reader->sumTotalTermFreq(ws_field_name.c_str());
-        if (!token_count.has_value() &&
+        const bool single_term_normalizer = 
segment_v2::IndexReaderHelper::is_normalizer_only(
+                collect_info.index_meta->properties());
+        if (!token_count.has_value() && !single_term_normalizer &&
             segment_v2::inverted_index::InvertedIndexAnalyzer::should_analyzer(
                     collect_info.index_meta->properties())) {
             return Status::Error<ErrorCode::INVERTED_INDEX_NOT_SUPPORTED>(
@@ -221,6 +222,7 @@ Status CollectionStatistics::process_segment(const 
RowsetSharedPtr& rowset, int3
                     "for a variant path, when 
inverted_index_skip_norms_for_variant is on",
                     seg_path, StringHelper::to_string(ws_field_name));
         }
+        // Missing term totals imply one token per row for keyword and 
lowercase indexes.
         _total_num_tokens[ws_field_name] += 
token_count.value_or(index_reader->maxDoc());
 
         for (const auto& term_info : collect_info.term_infos) {
diff --git a/be/src/storage/index/index_reader_helper.h 
b/be/src/storage/index/index_reader_helper.h
index ad4c856fd26..988db5c73ec 100644
--- a/be/src/storage/index/index_reader_helper.h
+++ b/be/src/storage/index/index_reader_helper.h
@@ -17,6 +17,10 @@
 
 #pragma once
 
+#include <boost/algorithm/string/predicate.hpp>
+#include <map>
+#include <string>
+
 #include "storage/index/index_iterator.h"
 #include "storage/index/inverted/inverted_index_reader.h"
 
@@ -25,6 +29,16 @@ namespace doris::segment_v2 {
 
 class IndexReaderHelper {
 public:
+    static bool is_normalizer_only(const std::map<std::string, std::string>& 
properties) {
+        const auto normalizer = 
properties.find(INVERTED_INDEX_NORMALIZER_NAME_KEY);
+        const auto analyzer = 
properties.find(INVERTED_INDEX_ANALYZER_NAME_KEY);
+        // A custom normalizer can split a value into multiple terms that need 
positions.
+        return !properties.contains(INVERTED_INDEX_PARSER_PHRASE_SUPPORT_KEY) 
&&
+               normalizer != properties.end() && 
boost::iequals(normalizer->second, "lowercase") &&
+               (analyzer == properties.end() || analyzer->second.empty()) &&
+               get_parser_string_from_properties(properties) == 
INVERTED_INDEX_PARSER_NONE;
+    }
+
     static bool is_fulltext_index(const IndexReaderPtr& reader) {
         if (reader == nullptr || reader->index_type() != IndexType::INVERTED) {
             return false;
@@ -60,7 +74,8 @@ public:
         auto inverted_index_reader = 
std::static_pointer_cast<InvertedIndexReader>(reader);
         const auto& properties = inverted_index_reader->get_index_properties();
         return get_parser_phrase_support_string_from_properties(properties) ==
-               INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES;
+                       INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES ||
+               is_normalizer_only(properties);
     }
 
     // only string type or bkd index reader can be used for equal
@@ -97,7 +112,8 @@ public:
             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) {
+                        INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES ||
+                is_normalizer_only(properties)) {
                 return true;
             }
         }
@@ -111,7 +127,8 @@ public:
             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) {
+                        INVERTED_INDEX_PARSER_PHRASE_SUPPORT_YES ||
+                is_normalizer_only(properties)) {
                 return true;
             }
         }
@@ -120,4 +137,4 @@ public:
 };
 
 #include "common/compile_check_end.h"
-} // namespace doris::segment_v2
\ No newline at end of file
+} // namespace doris::segment_v2
diff --git a/be/src/storage/index/inverted/query/query_helper.cpp 
b/be/src/storage/index/inverted/query/query_helper.cpp
index 1b44de0b1ce..1dd039783ce 100644
--- a/be/src/storage/index/inverted/query/query_helper.cpp
+++ b/be/src/storage/index/inverted/query/query_helper.cpp
@@ -17,6 +17,8 @@
 
 #include "storage/index/inverted/query/query_helper.h"
 
+#include <algorithm>
+
 namespace doris::segment_v2 {
 #include "common/compile_check_begin.h"
 
@@ -24,8 +26,8 @@ void QueryHelper::collect(const IndexQueryContextPtr& context,
                           const std::vector<SimilarityPtr>& similarities,
                           const std::vector<TermIterPtr>& iterators, int32_t 
doc) {
     for (size_t i = 0; i < iterators.size(); i++) {
-        auto freq = iterators[i]->freq();
-        auto doc_length = iterators[i]->norm();
+        auto freq = std::max(iterators[i]->freq(), 1);
+        auto doc_length = std::max(iterators[i]->norm(), 1);
         auto score = similarities[i]->score(static_cast<float>(freq), 
doc_length);
         context->collection_similarity->collect(doc, score);
     }
@@ -38,8 +40,8 @@ void QueryHelper::collect(const IndexQueryContextPtr& context,
         const auto& iter = iterators[i];
         if (std::holds_alternative<TermPositionsIterPtr>(iter)) {
             const auto& term_iter = std::get<TermPositionsIterPtr>(iter);
-            auto freq = term_iter->freq();
-            auto norm = term_iter->norm();
+            auto freq = std::max(term_iter->freq(), 1);
+            auto norm = std::max(term_iter->norm(), 1);
             auto score = similarities[i]->score(static_cast<float>(freq), 
norm);
             context->collection_similarity->collect(doc, score);
         }
@@ -50,8 +52,8 @@ void QueryHelper::collect_many(const IndexQueryContextPtr& 
context, const Simila
                                const DocRange& doc_range) {
     for (size_t j = 0; j < doc_range.doc_many_size_; j++) {
         rowid_t row_id = (*doc_range.doc_many)[j];
-        auto freq = (*doc_range.freq_many)[j];
-        auto norm = (*doc_range.norm_many)[j];
+        auto freq = doc_range.freq_many ? (*doc_range.freq_many)[j] : 1;
+        auto norm = doc_range.norm_many ? std::max((*doc_range.norm_many)[j], 
1U) : 1U;
         auto score = similarity->score(static_cast<float>(freq), norm);
         context->collection_similarity->collect(row_id, score);
     }
@@ -62,8 +64,8 @@ void QueryHelper::collect_range(const IndexQueryContextPtr& 
context,
     const uint32_t docs_size = doc_range.doc_range.second - 
doc_range.doc_range.first;
     for (uint32_t j = 0; j < docs_size; j++) {
         segment_v2::rowid_t row_id = doc_range.doc_range.first + j;
-        auto freq = (*doc_range.freq_many)[j];
-        auto norm = (*doc_range.norm_many)[j];
+        auto freq = doc_range.freq_many ? (*doc_range.freq_many)[j] : 1;
+        auto norm = doc_range.norm_many ? std::max((*doc_range.norm_many)[j], 
1U) : 1U;
         auto score = similarity->score(static_cast<float>(freq), norm);
         context->collection_similarity->collect(row_id, score);
     }
diff --git a/be/test/storage/compaction/collection_statistics_test.cpp 
b/be/test/storage/compaction/collection_statistics_test.cpp
index 617f8dfe439..ae605c15f2e 100644
--- a/be/test/storage/compaction/collection_statistics_test.cpp
+++ b/be/test/storage/compaction/collection_statistics_test.cpp
@@ -38,12 +38,18 @@
 #include "runtime/exec_env.h"
 #include "runtime/index_policy/index_policy_mgr.h"
 #include "storage/compaction/collection_statistics.cpp"
+#include "storage/index/index_file_reader.h"
+#include "storage/index/index_file_writer.h"
+#include "storage/index/index_writer.h"
+#include "storage/index/inverted/inverted_index_desc.h"
+#include "storage/index/inverted/util/term_iterator.h"
 #include "storage/rowset/rowset.h"
 #include "storage/rowset/rowset_meta.h"
 #include "storage/rowset/rowset_reader.h"
 #include "storage/tablet/tablet_schema.h"
 #include "testutil/mock/mock_runtime_state.h"
 #include "util/defer_op.h"
+#include "util/slice.h"
 
 namespace doris {
 
@@ -350,6 +356,92 @@ TEST_F(CollectionStatisticsTest, 
CollectWithEmptyRowsetSplits) {
     EXPECT_TRUE(status.ok()) << status.msg();
 }
 
+TEST_F(CollectionStatisticsTest, DocsOnlyNormalizerCollectsScoringStatistics) {
+    IndexPolicyMgr policy_mgr;
+    auto* exec_env = ExecEnv::GetInstance();
+    auto* original_policy_mgr = exec_env->index_policy_mgr();
+    exec_env->_index_policy_mgr = &policy_mgr;
+    Defer restore_policy_mgr([&] { exec_env->_index_policy_mgr = 
original_policy_mgr; });
+
+    TabletSchemaPB schema_pb;
+    schema_pb.set_keys_type(DUP_KEYS);
+    
schema_pb.set_inverted_index_storage_format(InvertedIndexStorageFormatPB::V3);
+    auto tablet_schema = std::make_shared<TabletSchema>();
+    tablet_schema->init_from_pb(schema_pb);
+
+    TabletColumn column;
+    column.set_unique_id(1);
+    column.set_name("content");
+    column.set_type(FieldType::OLAP_FIELD_TYPE_STRING);
+    tablet_schema->append_column(column);
+
+    TabletIndex index;
+    index._index_id = 1;
+    index._index_type = IndexType::INVERTED;
+    index._col_unique_ids.push_back(1);
+    index._properties["normalizer"] = "lowercase";
+    tablet_schema->append_index(std::move(index));
+
+    std::vector<StorePath> paths;
+    paths.emplace_back(test_dir_, 1024);
+    auto tmp_file_dirs = std::make_unique<segment_v2::TmpFileDirs>(paths);
+    ASSERT_TRUE(tmp_file_dirs->init().ok());
+    ExecEnv::GetInstance()->set_tmp_file_dir(std::move(tmp_file_dirs));
+
+    const std::string segment_path = test_dir_ + "/normalized_0.dat";
+    const std::string prefix {
+            
segment_v2::InvertedIndexDescriptor::get_index_file_path_prefix(segment_path)};
+    io::FileWriterPtr compound_file;
+    io::FileWriterOptions options;
+    auto fs = io::global_local_filesystem();
+    
ASSERT_TRUE(fs->create_file(segment_v2::InvertedIndexDescriptor::get_index_file_path_v2(prefix),
+                                &compound_file, &options)
+                        .ok());
+    segment_v2::IndexFileWriter file_writer(fs, prefix, "normalized", 0,
+                                            InvertedIndexStorageFormatPB::V3,
+                                            std::move(compound_file));
+    const auto indexes = tablet_schema->inverted_indexs(1);
+    ASSERT_EQ(indexes.size(), 1);
+    std::unique_ptr<segment_v2::IndexColumnWriter> column_writer;
+    auto create_status = segment_v2::IndexColumnWriter::create(
+            &tablet_schema->column(0), &column_writer, &file_writer, 
indexes[0]);
+    ASSERT_TRUE(create_status.ok()) << create_status;
+    std::vector<Slice> values {Slice("ALPHA")};
+    ASSERT_TRUE(column_writer->add_values("content", values.data(), 
values.size()).ok());
+    ASSERT_TRUE(column_writer->finish().ok());
+    ASSERT_TRUE(file_writer.begin_close().ok());
+    ASSERT_TRUE(file_writer.finish_close().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_segment_path(0, segment_path);
+    auto reader = 
std::make_shared<collection_statistics::MockRowsetReader>(rowset);
+    std::vector<RowSetSplits> splits {RowSetSplits(reader)};
+
+    auto status = stats_->collect(runtime_state_.get(), splits, tablet_schema,
+                                  create_match_expr_contexts("alpha"), 
nullptr);
+    ASSERT_TRUE(status.ok()) << status;
+    EXPECT_EQ(stats_->get_doc_num(), 1U);
+    EXPECT_EQ(stats_->get_total_term_cnt_by_col(L"1"), 1U);
+    EXPECT_EQ(stats_->get_term_doc_freq_by_col(L"1", L"alpha"), 1U);
+
+    segment_v2::IndexFileReader file_reader(fs, prefix, 
InvertedIndexStorageFormatPB::V3);
+    ASSERT_TRUE(file_reader.init().ok());
+    auto compound = file_reader.open(indexes[0]);
+    ASSERT_TRUE(compound.has_value()) << compound.error();
+    auto* raw_reader = lucene::index::IndexReader::open(compound->get());
+    auto searcher = 
std::make_unique<lucene::search::IndexSearcher>(raw_reader, true);
+    auto term_iter =
+            segment_v2::TermIterator::create(nullptr, true, 
searcher->getReader(), L"1", "alpha");
+    ::DocRange range;
+    ASSERT_TRUE(term_iter->read_range(&range));
+    ASSERT_EQ(range.doc_many_size_, 1);
+    ASSERT_EQ(range.freq_many, nullptr);
+    ASSERT_NE(range.norm_many, nullptr);
+    EXPECT_EQ((*range.norm_many)[0], 1U);
+}
+
 TEST_F(CollectionStatisticsTest, CollectWithEmptyExpressions) {
     auto tablet_schema = create_tablet_schema_with_inverted_index();
     VExprContextSPtrs empty_contexts;
diff --git a/be/test/storage/index/inverted/query/query_helper_test.cpp 
b/be/test/storage/index/inverted/query/query_helper_test.cpp
index 95c1a3a6fad..d6278ce58b5 100644
--- a/be/test/storage/index/inverted/query/query_helper_test.cpp
+++ b/be/test/storage/index/inverted/query/query_helper_test.cpp
@@ -52,6 +52,23 @@ private:
     float _score_value;
 };
 
+class RecordingSimilarity : public doris::segment_v2::Similarity {
+public:
+    MOCK_FUNCTION void for_one_term(const IndexQueryContextPtr&, const 
std::wstring&,
+                                    const std::wstring&) override {}
+    MOCK_FUNCTION void for_terms(const IndexQueryContextPtr&, const 
std::wstring&,
+                                 const std::vector<std::wstring>&) override {}
+    MOCK_FUNCTION float score(float freq, int64_t encoded_norm) override {
+        last_freq = freq;
+        last_norm = encoded_norm;
+        return freq;
+    }
+    MOCK_FUNCTION float max_score() override { return 1.0F; }
+
+    float last_freq = 0;
+    int64_t last_norm = 0;
+};
+
 class MockTermIterator : public TermIterator {
 public:
     MockTermIterator(int32_t freq_val, int32_t norm_val) : _freq(freq_val), 
_norm(norm_val) {}
@@ -180,6 +197,52 @@ TEST_F(QueryHelperTest, CollectMany) {
     ASSERT_TRUE(_context->collection_similarity->_bm25_scores.count(30) > 0);
 }
 
+TEST_F(QueryHelperTest, CollectManyWithoutPostingsStatistics) {
+    SimilarityPtr similarity = std::make_unique<RecordingSimilarity>();
+    auto* recording = static_cast<RecordingSimilarity*>(similarity.get());
+    DocRange doc_range;
+    doc_range.type_ = DocRangeType::kMany;
+    doc_range.doc_many_size_ = 1;
+    std::vector<uint32_t> docs {10};
+    std::vector<uint32_t> norms {0};
+    doc_range.doc_many = &docs;
+    doc_range.norm_many = &norms;
+
+    QueryHelper::collect_many(_context, similarity, doc_range);
+
+    EXPECT_FLOAT_EQ(recording->last_freq, 1.0F);
+    EXPECT_EQ(recording->last_norm, 1);
+    EXPECT_FLOAT_EQ(_context->collection_similarity->_bm25_scores.at(10), 
1.0F);
+}
+
+TEST_F(QueryHelperTest, CollectRangeWithoutPostingsStatistics) {
+    SimilarityPtr similarity = std::make_unique<RecordingSimilarity>();
+    auto* recording = static_cast<RecordingSimilarity*>(similarity.get());
+    DocRange doc_range;
+    doc_range.type_ = DocRangeType::kRange;
+    doc_range.doc_range = std::make_pair(10, 11);
+
+    QueryHelper::collect_range(_context, similarity, doc_range);
+
+    EXPECT_FLOAT_EQ(recording->last_freq, 1.0F);
+    EXPECT_EQ(recording->last_norm, 1);
+    EXPECT_FLOAT_EQ(_context->collection_similarity->_bm25_scores.at(10), 
1.0F);
+}
+
+TEST_F(QueryHelperTest, CollectSingleTermWithoutPostingsStatistics) {
+    std::vector<SimilarityPtr> similarities;
+    similarities.push_back(std::make_unique<RecordingSimilarity>());
+    auto* recording = 
static_cast<RecordingSimilarity*>(similarities.front().get());
+    std::vector<TermIterPtr> iterators;
+    iterators.push_back(std::make_shared<MockTermIterator>(0, 0));
+
+    QueryHelper::collect(_context, similarities, iterators, 10);
+
+    EXPECT_FLOAT_EQ(recording->last_freq, 1.0F);
+    EXPECT_EQ(recording->last_norm, 1);
+    EXPECT_FLOAT_EQ(_context->collection_similarity->_bm25_scores.at(10), 
1.0F);
+}
+
 TEST_F(QueryHelperTest, CollectManyWithZeroSize) {
     SimilarityPtr similarity = std::make_unique<MockSimilarity>(4.0f);
 
@@ -276,4 +339,4 @@ TEST_F(QueryHelperTest, CollectMixedScenario) {
     ASSERT_TRUE(_context->collection_similarity->_bm25_scores.count(1002) > 0);
     ASSERT_TRUE(_context->collection_similarity->_bm25_scores.count(2000) > 0);
     ASSERT_TRUE(_context->collection_similarity->_bm25_scores.count(2001) > 0);
-}
\ No newline at end of file
+}
diff --git a/be/test/storage/index/inverted_index_parser_test.cpp 
b/be/test/storage/index/inverted_index_parser_test.cpp
index 174ef1226a1..d35aed9fd0d 100644
--- a/be/test/storage/index/inverted_index_parser_test.cpp
+++ b/be/test/storage/index/inverted_index_parser_test.cpp
@@ -126,7 +126,29 @@ TEST_F(InvertedIndexParserTest, 
TestGetParserModeStringFromProperties) {
 TEST_F(InvertedIndexParserTest, 
TestGetParserPhraseSupportStringFromProperties) {
     std::map<std::string, std::string> properties;
 
-    // Test with empty properties
+    EXPECT_EQ(get_parser_phrase_support_string_from_properties(properties),
+              INVERTED_INDEX_PARSER_PHRASE_SUPPORT_NO);
+
+    properties[INVERTED_INDEX_PARSER_KEY] = INVERTED_INDEX_PARSER_NONE;
+    EXPECT_EQ(get_parser_phrase_support_string_from_properties(properties),
+              INVERTED_INDEX_PARSER_PHRASE_SUPPORT_NO);
+
+    properties[INVERTED_INDEX_PARSER_KEY] = INVERTED_INDEX_PARSER_ENGLISH;
+    EXPECT_EQ(get_parser_phrase_support_string_from_properties(properties),
+              INVERTED_INDEX_PARSER_PHRASE_SUPPORT_NO);
+
+    properties.clear();
+    properties[INVERTED_INDEX_ANALYZER_NAME_KEY] = "english";
+    EXPECT_EQ(get_parser_phrase_support_string_from_properties(properties),
+              INVERTED_INDEX_PARSER_PHRASE_SUPPORT_NO);
+
+    properties.clear();
+    properties[INVERTED_INDEX_PARSER_KEY_ALIAS] = INVERTED_INDEX_PARSER_NONE;
+    EXPECT_EQ(get_parser_phrase_support_string_from_properties(properties),
+              INVERTED_INDEX_PARSER_PHRASE_SUPPORT_NO);
+
+    properties.clear();
+    properties[INVERTED_INDEX_NORMALIZER_NAME_KEY] = "lowercase";
     EXPECT_EQ(get_parser_phrase_support_string_from_properties(properties),
               INVERTED_INDEX_PARSER_PHRASE_SUPPORT_NO);
 
diff --git a/be/test/storage/segment/index_reader_helper_test.cpp 
b/be/test/storage/segment/index_reader_helper_test.cpp
index d52e036f229..ca7a1ea5c80 100644
--- a/be/test/storage/segment/index_reader_helper_test.cpp
+++ b/be/test/storage/segment/index_reader_helper_test.cpp
@@ -396,6 +396,49 @@ TEST_F(IndexReaderHelperTest, 
IsNeedSimilarityScoreWithInvertedIndexQueryTypeTes
             InvertedIndexQueryType::LESS_THAN_QUERY, &index_meta));
 }
 
+TEST_F(IndexReaderHelperTest, NormalizerOnlyPhraseAndScoringNeedNoPositions) {
+    TabletIndexPB index_pb;
+    index_pb.set_index_type(IndexType::INVERTED);
+    index_pb.set_index_id(2);
+    index_pb.set_index_name("normalized");
+    index_pb.add_col_unique_id(1);
+    (*index_pb.mutable_properties())[INVERTED_INDEX_NORMALIZER_NAME_KEY] = 
"lowercase";
+    TabletIndex index_meta;
+    index_meta.init_from_pb(index_pb);
+
+    auto reader = 
std::make_shared<MockInvertedIndexReader>(InvertedIndexReaderType::FULLTEXT,
+                                                            
index_meta.properties());
+    
EXPECT_EQ(get_parser_phrase_support_string_from_properties(index_meta.properties()),
+              INVERTED_INDEX_PARSER_PHRASE_SUPPORT_NO);
+    EXPECT_TRUE(IndexReaderHelper::is_support_phrase(reader));
+    
EXPECT_TRUE(IndexReaderHelper::is_need_similarity_score(InvertedIndexQueryType::MATCH_ANY_QUERY,
+                                                            &index_meta));
+    
EXPECT_TRUE(IndexReaderHelper::is_need_similarity_score(TExprOpcode::MATCH_ANY, 
&index_meta));
+
+    auto explicit_disabled = index_meta.properties();
+    explicit_disabled[INVERTED_INDEX_PARSER_PHRASE_SUPPORT_KEY] =
+            INVERTED_INDEX_PARSER_PHRASE_SUPPORT_NO;
+    auto disabled_reader = std::make_shared<MockInvertedIndexReader>(
+            InvertedIndexReaderType::FULLTEXT, explicit_disabled);
+    EXPECT_FALSE(IndexReaderHelper::is_support_phrase(disabled_reader));
+
+    auto builtin_alias = index_meta.properties();
+    builtin_alias[INVERTED_INDEX_NORMALIZER_NAME_KEY] = "LowerCase";
+    auto alias_reader = 
std::make_shared<MockInvertedIndexReader>(InvertedIndexReaderType::FULLTEXT,
+                                                                  
builtin_alias);
+    EXPECT_TRUE(IndexReaderHelper::is_support_phrase(alias_reader));
+
+    TabletIndexPB custom_pb = index_pb;
+    (*custom_pb.mutable_properties())[INVERTED_INDEX_NORMALIZER_NAME_KEY] = 
"split_norm";
+    TabletIndex custom_meta;
+    custom_meta.init_from_pb(custom_pb);
+    auto custom_reader = std::make_shared<MockInvertedIndexReader>(
+            InvertedIndexReaderType::FULLTEXT, custom_meta.properties());
+    EXPECT_FALSE(IndexReaderHelper::is_support_phrase(custom_reader));
+    EXPECT_FALSE(IndexReaderHelper::is_need_similarity_score(
+            InvertedIndexQueryType::MATCH_ANY_QUERY, &custom_meta));
+}
+
 TEST_F(IndexReaderHelperTest, IsNeedSimilarityScoreWithTExprOpcodeTest) {
     TabletIndex index_meta;
     auto index_meta_pb = std::make_unique<TabletIndexPB>();
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/analysis/InvertedIndexUtil.java 
b/fe/fe-core/src/main/java/org/apache/doris/analysis/InvertedIndexUtil.java
index fb9a5ddb41c..867c4b86832 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/analysis/InvertedIndexUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/analysis/InvertedIndexUtil.java
@@ -131,7 +131,7 @@ public class InvertedIndexUtil {
 
     public static boolean getInvertedIndexSupportPhrase(Map<String, String> 
properties) {
         String supportPhrase = properties == null ? null : 
properties.get(INVERTED_INDEX_SUPPORT_PHRASE_KEY);
-        return supportPhrase != null ? Boolean.parseBoolean(supportPhrase) : 
true;
+        return supportPhrase != null && Boolean.parseBoolean(supportPhrase);
     }
 
     public static String getPreferredAnalyzer(Map<String, String> properties) {
@@ -419,11 +419,20 @@ public class InvertedIndexUtil {
     }
 
     /**
-     * Canonicalize analyzer and normalizer names in index properties. Legacy 
metadata may contain
-     * case-distinct policy names, so a resolved custom policy must keep its 
exact stored name.
+     * Resolve policy names and retain positions for custom normalizers that 
may split terms.
+     * Preserve exact custom policy spellings when legacy names differ only by 
case.
      */
     public static void normalizeInvertedIndexProperties(Map<String, String> 
properties) {
         resolvePolicyNames(properties);
+        String normalizer = properties.get(INVERTED_INDEX_NORMALIZER_NAME_KEY);
+        if (normalizer != null && !normalizer.isEmpty()
+                && !properties.containsKey(INVERTED_INDEX_SUPPORT_PHRASE_KEY)) 
{
+            boolean builtin = Env.getCurrentEnv().getIndexPolicyMgr()
+                    .getTopLevelBuiltin(normalizer, 
IndexPolicy.BUILTIN_NORMALIZERS) != null;
+            if (!builtin) {
+                properties.put(INVERTED_INDEX_SUPPORT_PHRASE_KEY, "true");
+            }
+        }
         AnalyzerKeyNormalizer.normalizeInvertedIndexProperties(
                 properties,
                 INVERTED_INDEX_PARSER_KEY,
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/Index.java 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/Index.java
index 352f89c7959..556218fbac2 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/Index.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/Index.java
@@ -77,10 +77,13 @@ public class Index implements Writable {
         this.comment = comment;
         if (indexType == IndexDef.IndexType.INVERTED) {
             if (this.properties != null && !this.properties.isEmpty()) {
-                if 
(this.properties.containsKey(InvertedIndexUtil.INVERTED_INDEX_PARSER_KEY)
-                        || 
this.properties.containsKey(InvertedIndexUtil.INVERTED_INDEX_PARSER_KEY_ALIAS)
-                        || 
this.properties.containsKey(InvertedIndexUtil.INVERTED_INDEX_ANALYZER_NAME_KEY)
-                        || 
this.properties.containsKey(InvertedIndexUtil.INVERTED_INDEX_NORMALIZER_NAME_KEY))
 {
+                String normalizerName = this.properties.get(
+                        InvertedIndexUtil.INVERTED_INDEX_NORMALIZER_NAME_KEY);
+                if 
(!InvertedIndexUtil.INVERTED_INDEX_PARSER_NONE.equalsIgnoreCase(getInvertedIndexParser())
+                        || StringUtils.isNotEmpty(this.properties.get(
+                                
InvertedIndexUtil.INVERTED_INDEX_ANALYZER_NAME_KEY))
+                        || (StringUtils.isNotEmpty(normalizerName)
+                                && 
!"lowercase".equalsIgnoreCase(normalizerName))) {
                     String supportPhraseKey = InvertedIndexUtil
                             .INVERTED_INDEX_SUPPORT_PHRASE_KEY;
                     if (!this.properties.containsKey(supportPhraseKey)) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/analysis/InvertedIndexPropertiesTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/analysis/InvertedIndexPropertiesTest.java
index 7ad2b49f704..81b91a0850d 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/analysis/InvertedIndexPropertiesTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/analysis/InvertedIndexPropertiesTest.java
@@ -848,10 +848,11 @@ public class InvertedIndexPropertiesTest {
         policyMgr.replayCreateIndexPolicy(new IndexPolicy(
                 92, "norm_lower", IndexPolicyTypeEnum.NORMALIZER, 
ImmutableMap.of("token_filter", "lowercase")));
         Map<String, String> mixedNormalizer = new 
HashMap<>(ImmutableMap.of("normalizer", "LowerCase"));
+        Map<String, String> customNormalizer = new 
HashMap<>(ImmutableMap.of("normalizer", "lowercase"));
         Map<String, String> mixedAnalyzer = new 
HashMap<>(ImmutableMap.of("analyzer", "Standard"));
 
         withIndexPolicyManager(policyMgr, () -> {
-            for (Map<String, String> properties : 
ImmutableList.of(mixedNormalizer, mixedAnalyzer)) {
+            for (Map<String, String> properties : 
ImmutableList.of(mixedNormalizer, customNormalizer, mixedAnalyzer)) {
                 Assertions.assertDoesNotThrow(() -> 
InvertedIndexUtil.checkInvertedIndexParser("c",
                         PrimitiveType.VARCHAR, properties, 
TInvertedIndexFileStorageFormat.V3));
             }
@@ -859,6 +860,8 @@ public class InvertedIndexPropertiesTest {
                     // The exact policy shadows the canonical name, so only 
the mixed spelling still
                     // reaches the built-in normalizer on BE.
                     () -> Assertions.assertEquals("LowerCase", 
mixedNormalizer.get("normalizer")),
+                    () -> 
Assertions.assertFalse(mixedNormalizer.containsKey("support_phrase")),
+                    () -> Assertions.assertEquals("true", 
customNormalizer.get("support_phrase")),
                     () -> Assertions.assertEquals("LowerCase",
                             
InvertedIndexUtil.resolveAnalyzerName("LowerCase")),
                     () -> Assertions.assertEquals("lowercase",
diff --git a/fe/fe-core/src/test/java/org/apache/doris/catalog/IndexTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/catalog/IndexTest.java
index 776c97ca76b..f753150858b 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/catalog/IndexTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/catalog/IndexTest.java
@@ -18,15 +18,66 @@
 package org.apache.doris.catalog;
 
 import org.apache.doris.analysis.IndexDef;
+import org.apache.doris.analysis.InvertedIndexUtil;
 
 import org.junit.Assert;
 import org.junit.Test;
 
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 public class IndexTest {
 
+    @Test
+    public void testInvertedIndexPhraseDefaultsFollowTokenization() {
+        Index exact = new Index(1, "exact", Collections.singletonList("col"),
+                IndexDef.IndexType.INVERTED, Collections.emptyMap(), "");
+        
Assert.assertFalse(exact.getProperties().containsKey("support_phrase"));
+        
Assert.assertFalse(InvertedIndexUtil.getInvertedIndexSupportPhrase(Collections.emptyMap()));
+
+        Index keyword = new Index(2, "keyword", 
Collections.singletonList("col"),
+                IndexDef.IndexType.INVERTED, 
Collections.singletonMap("parser", "none"), "");
+        
Assert.assertFalse(keyword.getProperties().containsKey("support_phrase"));
+
+        Index normalized = new Index(3, "normalized", 
Collections.singletonList("col"),
+                IndexDef.IndexType.INVERTED, 
Collections.singletonMap("normalizer", "lowercase"), "");
+        
Assert.assertFalse(normalized.getProperties().containsKey("support_phrase"));
+        Assert.assertFalse(InvertedIndexUtil.getInvertedIndexSupportPhrase(
+                Collections.singletonMap("normalizer", "lowercase")));
+
+        Index customNormalizer = new Index(7, "custom_normalizer", 
Collections.singletonList("col"),
+                IndexDef.IndexType.INVERTED, 
Collections.singletonMap("normalizer", "split_norm"), "");
+        Assert.assertEquals("true", 
customNormalizer.getProperties().get("support_phrase"));
+
+        Map<String, String> disabledCustomProperties = new HashMap<>();
+        disabledCustomProperties.put("normalizer", "split_norm");
+        disabledCustomProperties.put("support_phrase", "false");
+        Index disabledCustomNormalizer = new Index(8, 
"disabled_custom_normalizer",
+                Collections.singletonList("col"), IndexDef.IndexType.INVERTED,
+                disabledCustomProperties, "");
+        Assert.assertEquals("false", 
disabledCustomNormalizer.getProperties().get("support_phrase"));
+
+        Index parsed = new Index(4, "parsed", Collections.singletonList("col"),
+                IndexDef.IndexType.INVERTED, 
Collections.singletonMap("parser", "english"), "");
+        Assert.assertEquals("true", 
parsed.getProperties().get("support_phrase"));
+        Assert.assertFalse(InvertedIndexUtil.getInvertedIndexSupportPhrase(
+                Collections.singletonMap("parser", "english")));
+
+        Index analyzed = new Index(5, "analyzed", 
Collections.singletonList("col"),
+                IndexDef.IndexType.INVERTED, 
Collections.singletonMap("analyzer", "english"), "");
+        Assert.assertEquals("true", 
analyzed.getProperties().get("support_phrase"));
+
+        Map<String, String> disabledProperties = new HashMap<>();
+        disabledProperties.put("parser", "english");
+        disabledProperties.put("support_phrase", "false");
+        Index disabled = new Index(6, "disabled", 
Collections.singletonList("col"),
+                IndexDef.IndexType.INVERTED, disabledProperties, "");
+        Assert.assertEquals("false", 
disabled.getProperties().get("support_phrase"));
+    }
+
     @Test
     public void testGetColumnUniqueIds() {
         // Create test columns with unique IDs
diff --git a/regression-test/suites/index_p0/test_index_meta.groovy 
b/regression-test/suites/index_p0/test_index_meta.groovy
index d7647f2a9a3..bcaaf2922b1 100644
--- a/regression-test/suites/index_p0/test_index_meta.groovy
+++ b/regression-test/suites/index_p0/test_index_meta.groovy
@@ -71,7 +71,7 @@ suite("index_meta", "p0") {
     assertEquals(show_result[1][4], "name")
     assertEquals(show_result[1][10], "INVERTED")
     assertEquals(show_result[1][11], "index for name")
-    assertEquals(show_result[1][12], "(\"lower_case\" = \"true\", \"parser\" = 
\"none\", \"support_phrase\" = \"true\")")
+    assertEquals(show_result[1][12], "(\"lower_case\" = \"true\", \"parser\" = 
\"none\")")
 
     // add index on column description
     sql "create index idx_desc on ${tableName}(description) USING INVERTED 
PROPERTIES(\"parser\"=\"standard\") COMMENT 'index for description';"
@@ -90,7 +90,7 @@ suite("index_meta", "p0") {
     assertEquals(show_result[1][4], "name")
     assertEquals(show_result[1][10], "INVERTED")
     assertEquals(show_result[1][11], "index for name")
-    assertEquals(show_result[1][12], "(\"lower_case\" = \"true\", \"parser\" = 
\"none\", \"support_phrase\" = \"true\")")
+    assertEquals(show_result[1][12], "(\"lower_case\" = \"true\", \"parser\" = 
\"none\")")
     assertEquals(show_result[2][2], "idx_desc")
     assertEquals(show_result[2][4], "description")
     assertEquals(show_result[2][10], "INVERTED")
diff --git a/regression-test/suites/search/test_search_score_raw_index.groovy 
b/regression-test/suites/search/test_search_score_raw_index.groovy
index fb80fcf2637..ec10b1fabbc 100644
--- a/regression-test/suites/search/test_search_score_raw_index.groovy
+++ b/regression-test/suites/search/test_search_score_raw_index.groovy
@@ -16,6 +16,9 @@
 // under the License.
 
 suite("test_search_score_raw_index", "p0") {
+    // SEARCH requires inverted-index execution even under fuzzy session 
settings.
+    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 ->
         """


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to