This is an automated email from the ASF dual-hosted git repository.

airborne12 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 21f37fe1822 [fix](inverted index) Score MATCH_PHRASE_PREFIX on the 
tablet idf of the terms the prefix expands to (#69698)
21f37fe1822 is described below

commit 21f37fe182295601a774467857f315bd427a1ab3
Author: Jack <[email protected]>
AuthorDate: Wed Oct 28 16:01:17 2026 +0800

    [fix](inverted index) Score MATCH_PHRASE_PREFIX on the tablet idf of the 
terms the prefix expands to (#69698)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #68758
    
    Problem Summary:
    score() over MATCH_PHRASE_PREFIX gave scores that ignored the prefix and
    differed between inverted index storage formats.
    
    1. The prefix of a phrase prefix never entered the idf. "apple r" scored
    on
    idf(apple) alone, and a rare expansion such as "rose" ranked like a
    common
       one such as "red". Lucene's multi-phrase query, and so Elasticsearch,
    sum the idf of the phrase terms and of every term the prefix expands to.
    The statistics collector now registers the prefix; every segment expands
       it, counts the document frequency of each expanded term in the tablet
    statistics, and records which terms the prefix expanded to. The scorer
       adds their idf to the phrase's, so all idfs come from the same tablet
       statistics and the score does not depend on how rows are split into
    segments. With the reported table, `apple r` now scores 1.4508328, the
    value Elasticsearch 9.5 gives; on a 10-row table, `apple r`, `apple ro`
    and `apple g` match Elasticsearch to four decimals, over one segment and
       over three.
    2. A prefix of one token scored 0 on V2/V3 (CLucene did not publish
    scores
    for expansion leaves) and 1 on SNII. It now scores 1 on every format, as
       the constant score of Lucene and Elasticsearch prefix queries. At 0,
       `score() > 0` dropped every row the prefix matched.
    3. No test covered score() over MATCH_PHRASE_PREFIX across storage
    formats.
    Add unit tests for the collector, the segment expansion, the tablet idf
       and the constant score, and a regression suite over V2, V3 and SNII.
    
    Two existing tests asserted the old behavior and are updated: the SNII
    public phrase prefix scoring test (the idf now counts the expanded term)
    and the CLucene one-token phrase prefix test (it asserted no score).
    
    A prefix collected by a SEARCH predicate has no recorded expansion and
    keeps
    scoring the phrase terms alone.
---
 .../inverted/clucene/clucene_index_reader.cpp      |   3 +-
 .../phrase_prefix_query/phrase_prefix_query.h      |   5 +-
 .../inverted/query/scoring/bm25_similarity.cpp     |  12 +
 .../index/inverted/query/scoring/bm25_similarity.h |   9 +
 .../inverted/reader/collection_statistics.cpp      | 137 ++++++++++-
 .../index/inverted/reader/collection_statistics.h  |  74 ++++++
 .../index/inverted/reader/predicate_collector.cpp  |  35 ++-
 .../index/inverted/reader/predicate_collector.h    |  14 ++
 .../query/engine/phrase_prefix_query_test.cpp      | 267 +++++++++++++++++++++
 .../inverted/reader/collection_statistics_test.cpp |  80 +++++-
 .../snii/snii_index_reader_count_fallback_test.cpp |  14 +-
 .../storage/segment/inverted_index_reader_test.cpp |  11 +-
 .../test_match_phrase_prefix_score.out             |  74 ++++++
 .../test_match_phrase_prefix_score.groovy          | 129 ++++++++++
 14 files changed, 841 insertions(+), 23 deletions(-)

diff --git a/be/src/storage/index/inverted/clucene/clucene_index_reader.cpp 
b/be/src/storage/index/inverted/clucene/clucene_index_reader.cpp
index 6c569dafdf4..0e0661382b5 100644
--- a/be/src/storage/index/inverted/clucene/clucene_index_reader.cpp
+++ b/be/src/storage/index/inverted/clucene/clucene_index_reader.cpp
@@ -418,10 +418,9 @@ Status CluceneTextIndexReader::_run_leaf(const 
IndexQueryContextPtr& context,
                                          const logical::Node& leaf,
                                          const roaring::Roaring* candidates, 
bool scoring,
                                          std::shared_ptr<roaring::Roaring>* 
out) {
-    const bool publish_scores = scoring && leaf.as<logical::Expand>() == 
nullptr;
     auto result = std::make_shared<roaring::Roaring>();
     RETURN_IF_ERROR(run_clucene_leaf(context, 
StringUtil::string_to_wstring(column_name), leaf,
-                                     candidates, publish_scores,
+                                     candidates, scoring,
                                      
static_cast<CluceneOpenedIndex&>(index).searcher, result));
     result->runOptimize();
     *out = std::move(result);
diff --git 
a/be/src/storage/index/inverted/query/engine/phrase_prefix_query/phrase_prefix_query.h
 
b/be/src/storage/index/inverted/query/engine/phrase_prefix_query/phrase_prefix_query.h
index ec34e7b78b7..eb5f1e044db 100644
--- 
a/be/src/storage/index/inverted/query/engine/phrase_prefix_query/phrase_prefix_query.h
+++ 
b/be/src/storage/index/inverted/query/engine/phrase_prefix_query/phrase_prefix_query.h
@@ -72,14 +72,15 @@ private:
             return nullptr;
         }
 
-        SimilarityPtr bm25_similarity;
+        std::shared_ptr<BM25Similarity> bm25_similarity;
         if (enable_scoring) {
             bm25_similarity = std::make_shared<BM25Similarity>();
             std::vector<std::wstring> all_terms;
             for (const auto& phrase_term : _phrase_terms) {
                 
all_terms.push_back(StringHelper::to_wstring(phrase_term.second));
             }
-            bm25_similarity->for_terms(_context, _field, all_terms);
+            bm25_similarity->for_terms_and_prefix(_context, _field, all_terms,
+                                                  
StringHelper::to_wstring(_prefix.value().second));
         }
 
         return std::make_shared<PhrasePrefixWeight>(
diff --git a/be/src/storage/index/inverted/query/scoring/bm25_similarity.cpp 
b/be/src/storage/index/inverted/query/scoring/bm25_similarity.cpp
index 0d29fce4564..db683061be9 100644
--- a/be/src/storage/index/inverted/query/scoring/bm25_similarity.cpp
+++ b/be/src/storage/index/inverted/query/scoring/bm25_similarity.cpp
@@ -92,6 +92,18 @@ void BM25Similarity::for_terms(const IndexQueryContextPtr& 
context, const std::w
     compute_tf_cache();
 }
 
+void BM25Similarity::for_terms_and_prefix(const IndexQueryContextPtr& context,
+                                          const std::wstring& field_name,
+                                          const std::vector<std::wstring>& 
terms,
+                                          const std::wstring& prefix) {
+    std::vector<std::wstring> all_terms = terms;
+    for (auto& expansion :
+         context->collection_statistics->get_prefix_expansions(field_name, 
prefix)) {
+        all_terms.push_back(std::move(expansion));
+    }
+    for_terms(context, field_name, all_terms);
+}
+
 float BM25Similarity::score(float freq, int64_t encoded_norm) {
     float norm_inverse = _cache[((uint8_t)encoded_norm) & 0xFF];
     return _weight - _weight / (1.0F + freq * norm_inverse);
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 73e74422edb..230d7c961d0 100644
--- a/be/src/storage/index/inverted/query/scoring/bm25_similarity.h
+++ b/be/src/storage/index/inverted/query/scoring/bm25_similarity.h
@@ -37,6 +37,15 @@ public:
     void for_terms(const IndexQueryContextPtr& context, const std::wstring& 
field_name,
                    const std::vector<std::wstring>& terms) override;
 
+    // for_terms of `terms` and of every term the tablet expanded `prefix` to, 
so that a phrase
+    // prefix is scored on the idf of the terms it matches, as Lucene's 
multi-phrase query does.
+    //
+    // Example: "quick bro" over a tablet holding brother and brown is scored 
on
+    // idf(quick) + idf(brother) + idf(brown), so a row with the rare brother 
ranks above one with
+    // the common brown.
+    void for_terms_and_prefix(const IndexQueryContextPtr& context, const 
std::wstring& field_name,
+                              const std::vector<std::wstring>& terms, const 
std::wstring& prefix);
+
     float score(float freq, int64_t encoded_norm) override;
     float max_score() override;
     void bind_norms(std::span<const float> lengths) override;
diff --git a/be/src/storage/index/inverted/reader/collection_statistics.cpp 
b/be/src/storage/index/inverted/reader/collection_statistics.cpp
index 11a1a5d4bb0..c163133fbde 100644
--- a/be/src/storage/index/inverted/reader/collection_statistics.cpp
+++ b/be/src/storage/index/inverted/reader/collection_statistics.cpp
@@ -22,6 +22,7 @@
 #include <iterator>
 #include <set>
 #include <sstream>
+#include <unordered_set>
 
 #include "common/exception.h"
 #include "core/column/column_vector.h"
@@ -40,6 +41,7 @@
 #include "storage/index/inverted/clucene/spi/clucene_index_source.h"
 #include "storage/index/inverted/common/inverted_index_cache.h"
 #include "storage/index/inverted/common/string_helper.h"
+#include "storage/index/inverted/query/exec/term_pattern.h"
 #include "storage/index/inverted/snii/reader/snii_index_source.h"
 #include "storage/index/inverted/snii/snii_doris_adapter.h"
 #include "storage/iterators.h"
@@ -117,6 +119,65 @@ void add_term_doc_frequency(
     (*logical_frequencies)[field][logical_term] += doc_frequency;
 }
 
+Status segment_scoring_terms(index_query::IndexSource& source, const 
CollectInfo& collect_info,
+                             const VisibleDocFreq& visible_doc_freq,
+                             FieldPrefixExpansions* expansions, 
std::vector<std::string>* terms) {
+    DORIS_CHECK(expansions != nullptr);
+    DORIS_CHECK(terms != nullptr);
+    *terms = collect_info.unique_terms;
+    std::unordered_set<std::string> listed(terms->begin(), terms->end());
+    for (const auto& scoring_prefix : collect_info.scoring_prefixes) {
+        index_query::TermPattern pattern;
+        
RETURN_IF_ERROR(index_query::TermPattern::create(index_query::TermPatternKind::kPrefix,
+                                                         
scoring_prefix.prefix, &pattern));
+        const auto wanted = static_cast<size_t>(scoring_prefix.max_expansions);
+        std::vector<std::string> visible;
+        for (int32_t limit = scoring_prefix.max_expansions;; limit *= 2) {
+            std::vector<std::string> expanded;
+            RETURN_IF_ERROR(source.expand_terms(pattern, limit, &expanded));
+            visible.clear();
+            for (auto& term : expanded) {
+                // The statistics are keyed by wide string, and 
StringHelper::to_wstring drops the
+                // bytes that are not valid UTF-8, which SNII keeps in a 
token. Two such terms,
+                // "ra\xFF" and "ra\xFE", would share the key "ra" and add 
their document
+                // frequencies up, to more than the documents of the tablet 
and a negative idf. A
+                // term without a key of its own is not scored, and takes no 
place.
+                if (segment_v2::inverted_index::StringHelper::to_string(
+                            
segment_v2::inverted_index::StringHelper::to_wstring(term)) != term) {
+                    continue;
+                }
+                uint64_t doc_freq = 0;
+                RETURN_IF_ERROR(visible_doc_freq(term, &doc_freq));
+                if (doc_freq > 0) {
+                    visible.push_back(std::move(term));
+                }
+            }
+            // The dictionary ran out of terms, or the first `wanted` visible 
ones are found.
+            if (expanded.size() < static_cast<size_t>(limit) || visible.size() 
>= wanted) {
+                break;
+            }
+        }
+        // A read with doubled limit can find more visible terms than the 
prefix keeps; the
+        // dictionary order puts the ones to keep first.
+        if (visible.size() > wanted) {
+            visible.resize(wanted);
+        }
+        auto& prefix_terms =
+                expansions
+                        
->try_emplace(segment_v2::inverted_index::StringHelper::to_wstring(
+                                              scoring_prefix.prefix),
+                                      scoring_prefix.max_expansions)
+                        .first->second;
+        for (auto& term : visible) {
+            
prefix_terms.insert(segment_v2::inverted_index::StringHelper::to_wstring(term));
+            if (listed.insert(term).second) {
+                terms->push_back(std::move(term));
+            }
+        }
+    }
+    return Status::OK();
+}
+
 Status sum_non_null_norm_lengths(index_query::IndexSource& source,
                                  std::span<const uint32_t> null_docs, 
uint64_t* out) {
     if (!null_docs.empty() && null_docs.back() >= source.doc_count()) {
@@ -193,6 +254,24 @@ Status exclude_hidden_term_docs(index_query::IndexSource& 
source, std::string_vi
     return Status::OK();
 }
 
+VisibleDocFreq cached_visible_doc_freq(index_query::IndexSource& source,
+                                       std::span<const uint32_t> hidden_docs) {
+    auto cache = std::make_shared<std::unordered_map<std::string, uint64_t>>();
+    return [&source, hidden_docs, cache](const std::string& term,
+                                         uint64_t* doc_frequency) -> Status {
+        if (const auto cached = cache->find(term); cached != cache->end()) {
+            *doc_frequency = cached->second;
+            return Status::OK();
+        }
+        RETURN_IF_ERROR(source.scoring_doc_freq(term, doc_frequency));
+        if (!hidden_docs.empty()) {
+            RETURN_IF_ERROR(exclude_hidden_term_docs(source, term, 
hidden_docs, doc_frequency));
+        }
+        cache->emplace(term, *doc_frequency);
+        return Status::OK();
+    };
+}
+
 } // namespace collection_statistics_detail
 
 namespace {
@@ -513,15 +592,18 @@ Status CollectionStatistics::process_segment(const 
RowsetSharedPtr& rowset,
                     segment_v2::IndexReaderHelper::is_normalizer_only(
                             collect_info.index_meta->properties())));
 
-            for (const auto& logical_term_bytes : collect_info.unique_terms) {
+            // The document frequency of a term in this segment, without its 
hidden rows.
+            const auto visible_doc_freq =
+                    
collection_statistics_detail::cached_visible_doc_freq(source, hidden_docs);
+            std::vector<std::string> segment_terms;
+            
RETURN_IF_ERROR(collection_statistics_detail::segment_scoring_terms(
+                    source, collect_info, visible_doc_freq,
+                    &segment_accumulator.prefix_expansions[ws_field_name], 
&segment_terms));
+            for (const auto& logical_term_bytes : segment_terms) {
                 const auto logical_term =
                         
segment_v2::inverted_index::StringHelper::to_wstring(logical_term_bytes);
                 uint64_t doc_frequency = 0;
-                RETURN_IF_ERROR(source.scoring_doc_freq(logical_term_bytes, 
&doc_frequency));
-                if (!hidden_docs.empty()) {
-                    
RETURN_IF_ERROR(collection_statistics_detail::exclude_hidden_term_docs(
-                            source, logical_term_bytes, hidden_docs, 
&doc_frequency));
-                }
+                RETURN_IF_ERROR(visible_doc_freq(logical_term_bytes, 
&doc_frequency));
                 if (doc_frequency > segment_doc_count) {
                     return 
Status::Error<ErrorCode::INVERTED_INDEX_FILE_CORRUPTED>(
                             "SNII term document frequency {} exceeds segment 
document count {}",
@@ -647,15 +729,18 @@ Status CollectionStatistics::process_segment(const 
RowsetSharedPtr& rowset,
         _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) {
+        // The document frequency of a term in this segment, without its 
hidden rows.
+        const auto visible_doc_freq =
+                collection_statistics_detail::cached_visible_doc_freq(*source, 
hidden_docs);
+        std::vector<std::string> segment_terms;
+        RETURN_IF_ERROR(collection_statistics_detail::segment_scoring_terms(
+                *source, collect_info, visible_doc_freq, 
&_prefix_expansions[ws_field_name],
+                &segment_terms));
+        for (const auto& logical_term_bytes : segment_terms) {
             const auto logical_term =
                     
segment_v2::inverted_index::StringHelper::to_wstring(logical_term_bytes);
             uint64_t doc_frequency = 0;
-            RETURN_IF_ERROR(source->scoring_doc_freq(logical_term_bytes, 
&doc_frequency));
-            if (!hidden_docs.empty()) {
-                
RETURN_IF_ERROR(collection_statistics_detail::exclude_hidden_term_docs(
-                        *source, logical_term_bytes, hidden_docs, 
&doc_frequency));
-            }
+            RETURN_IF_ERROR(visible_doc_freq(logical_term_bytes, 
&doc_frequency));
             
collection_statistics_detail::add_term_doc_frequency(&_term_doc_freqs, 
ws_field_name,
                                                                  logical_term, 
doc_frequency);
         }
@@ -703,6 +788,16 @@ void CollectionStatistics::commit_snii_scoring_segment(
             _term_doc_freqs[field_name][term] += doc_freq;
         }
     }
+    for (const auto& [field_name, prefixes] : 
segment_accumulator.prefix_expansions) {
+        for (const auto& [prefix, expansion] : prefixes) {
+            auto& merged = _prefix_expansions[field_name]
+                                   .try_emplace(prefix, expansion.limit())
+                                   .first->second;
+            for (const auto& term : expansion.terms()) {
+                merged.insert(term);
+            }
+        }
+    }
     _total_num_docs += segment_accumulator.doc_count;
     _avg_dl_by_col.clear();
     _idf_by_col_term.clear();
@@ -713,6 +808,7 @@ void CollectionStatistics::clear() {
     _counted_hidden_docs = false;
     _total_num_tokens.clear();
     _term_doc_freqs.clear();
+    _prefix_expansions.clear();
     _avg_dl_by_col.clear();
     _idf_by_col_term.clear();
 }
@@ -722,6 +818,7 @@ void CollectionStatistics::copy_collected_from(const 
CollectionStatistics& other
     _counted_hidden_docs = other._counted_hidden_docs;
     _total_num_tokens = other._total_num_tokens;
     _term_doc_freqs = other._term_doc_freqs;
+    _prefix_expansions = other._prefix_expansions;
     _avg_dl_by_col.clear();
     _idf_by_col_term.clear();
 }
@@ -801,6 +898,22 @@ float CollectionStatistics::get_or_calculate_avg_dl(const 
std::wstring& lucene_c
     return avg_dl;
 }
 
+std::vector<std::wstring> CollectionStatistics::get_prefix_expansions(
+        const std::wstring& lucene_col_name, const std::wstring& prefix) {
+    const auto field = _prefix_expansions.find(lucene_col_name);
+    if (field == _prefix_expansions.end()) {
+        return {};
+    }
+    const auto expansions = field->second.find(prefix);
+    if (expansions == field->second.end()) {
+        return {};
+    }
+    // Only terms that a returned row holds were expanded to 
(segment_scoring_terms), so each has
+    // a document frequency.
+    const auto& terms = expansions->second.terms();
+    return std::vector<std::wstring>(terms.begin(), terms.end());
+}
+
 float CollectionStatistics::get_or_calculate_idf(const std::wstring& 
lucene_col_name,
                                                  const std::wstring& term) {
     auto iter = _idf_by_col_term.find(lucene_col_name);
diff --git a/be/src/storage/index/inverted/reader/collection_statistics.h 
b/be/src/storage/index/inverted/reader/collection_statistics.h
index ebd52ce91e4..3aac78df29a 100644
--- a/be/src/storage/index/inverted/reader/collection_statistics.h
+++ b/be/src/storage/index/inverted/reader/collection_statistics.h
@@ -19,8 +19,10 @@
 #include <condition_variable>
 #include <cstdint>
 #include <functional>
+#include <iterator>
 #include <mutex>
 #include <optional>
+#include <set>
 #include <span>
 #include <string>
 #include <string_view>
@@ -81,6 +83,37 @@ struct MowHiddenRows {
     bool use_page_cache = true;
 };
 
+// The `limit` smallest terms, in dictionary order, that a prefix expanded to 
in the segments seen
+// so far: the terms an index-wide expansion of Lucene would take. A term only 
enters or stays
+// while fewer than `limit` smaller terms are known, and one dropped never 
returns, whatever
+// order the segments come in.
+//
+// That is what keeps the document frequencies whole. A term of the final set 
has fewer than
+// `limit` smaller terms in every segment holding it, so each of those 
segments expanded to it
+// and counted its documents. With a limit of 1, segment A holding {rose} and 
segment B holding
+// {red, rose} leave {red}: B's expansion is red alone, but rose is not 
scored, so its document
+// frequency, which only A counted, is not used.
+class PrefixExpansionSet {
+public:
+    explicit PrefixExpansionSet(int32_t limit) : _limit(limit) { 
DORIS_CHECK_GT(limit, 0); }
+
+    void insert(const std::wstring& term) {
+        _terms.insert(term);
+        if (_terms.size() > static_cast<size_t>(_limit)) {
+            _terms.erase(std::prev(_terms.end()));
+        }
+    }
+
+    int32_t limit() const { return _limit; }
+    const std::set<std::wstring>& terms() const { return _terms; }
+
+private:
+    int32_t _limit;
+    std::set<std::wstring> _terms;
+};
+// The terms each prefix expanded to, by prefix.
+using FieldPrefixExpansions = std::unordered_map<std::wstring, 
PrefixExpansionSet>;
+
 class CollectionStatistics {
 public:
     explicit CollectionStatistics(PredicateCollectorPtr search_collector = 
nullptr)
@@ -106,15 +139,25 @@ public:
                                              const std::wstring& term);
     MOCK_FUNCTION float get_or_calculate_avg_dl(const std::wstring& 
lucene_col_name);
 
+    // The terms the collected `prefix` expanded to in any segment of the 
tablet and that some
+    // returned row holds, in dictionary order; their idfs are in 
get_or_calculate_idf. None for a
+    // prefix that was not collected, as for a SEARCH predicate.
+    MOCK_FUNCTION std::vector<std::wstring> get_prefix_expansions(
+            const std::wstring& lucene_col_name, const std::wstring& prefix);
+
     // Replaces the collected counters with those of `other`. The derived 
idf/avgdl caches are
     // reset rather than copied, so the copy can be used by another thread 
without sharing them.
     void copy_collected_from(const CollectionStatistics& other);
 
 private:
+    // The terms each collected prefix expanded to, by field and prefix.
+    using PrefixExpansions = std::unordered_map<std::wstring, 
FieldPrefixExpansions>;
+
     struct SniiScoringSegmentAccumulator {
         uint64_t doc_count = 0;
         std::unordered_map<std::wstring, uint64_t> token_counts;
         std::unordered_map<std::wstring, std::unordered_map<std::wstring, 
uint64_t>> term_doc_freqs;
+        PrefixExpansions prefix_expansions;
     };
 
     Status extract_collect_info(RuntimeState* state,
@@ -143,6 +186,7 @@ private:
     bool _counted_hidden_docs = false;
     std::unordered_map<std::wstring, uint64_t> _total_num_tokens;
     std::unordered_map<std::wstring, std::unordered_map<std::wstring, 
uint64_t>> _term_doc_freqs;
+    PrefixExpansions _prefix_expansions;
 
     std::unordered_map<std::wstring, float> _avg_dl_by_col;
     std::unordered_map<std::wstring, std::unordered_map<std::wstring, float>> 
_idf_by_col_term;
@@ -230,10 +274,40 @@ Status exclude_hidden_docs(std::span<const uint32_t> 
hidden_docs, const roaring:
                            const SumNormLengthsOf& sum_norm_lengths_of,
                            index_query::IndexScoringStats* stats);
 
+// The terms whose document frequencies a segment adds for `collect_info`: its 
scoring terms, then
+// each term its prefixes expand to in `source`, so that a phrase prefix is 
scored on the same
+// tablet statistics as the other terms. A term met twice is listed once, as 
its documents count
+// once. The terms each prefix expands to in this segment are added to 
`expansions`, which keeps
+// the smallest ones of the segments seen (PrefixExpansionSet).
+//
+// Only terms that a returned row holds are expanded to, as `visible_doc_freq` 
tells: the document
+// frequency of a term in this segment without its hidden rows. A term held by 
hidden rows alone
+// must not take one of the `max_expansions` places, or the scores of the same 
visible rows would
+// change when a compaction drops the hidden rows. With a limit of 1, and `ra` 
held by hidden rows
+// and `rb` by visible ones, the prefix "r" expands to rb before and after the 
compaction.
+// The dictionary is therefore read again, with twice the limit, while it 
still has terms and the
+// terms found hold fewer than `max_expansions` visible ones. A term whose 
bytes are not valid UTF-8
+// has no wide-string key of its own and is skipped like a hidden one.
+//
+// Example: "red r" collects red and, in a segment holding red, rose and run, 
the terms red (already
+// listed), rose and run, and records {red, rose, run} for the prefix "r".
+using VisibleDocFreq = std::function<Status(const std::string& term, uint64_t* 
doc_freq)>;
+Status segment_scoring_terms(index_query::IndexSource& source, const 
CollectInfo& collect_info,
+                             const VisibleDocFreq& visible_doc_freq,
+                             FieldPrefixExpansions* expansions, 
std::vector<std::string>* terms);
+
 // Takes from a term's document frequency the documents of `hidden_docs` that 
hold it.
 Status exclude_hidden_term_docs(index_query::IndexSource& source, 
std::string_view term,
                                 std::span<const uint32_t> hidden_docs, 
uint64_t* doc_frequency);
 
+// The document frequency of a term in `source` without the rows of 
`hidden_docs`, computed once
+// per term for the lifetime of the returned function. With hidden rows every 
term costs an
+// intersection of its postings with them, and the terms a prefix expands to 
are asked for twice:
+// to choose the ones to keep, and to add up the frequencies of the ones kept.
+// `source` and `hidden_docs` must outlive the function.
+VisibleDocFreq cached_visible_doc_freq(index_query::IndexSource& source,
+                                       std::span<const uint32_t> hidden_docs);
+
 } // namespace collection_statistics_detail
 
 } // namespace doris
diff --git a/be/src/storage/index/inverted/reader/predicate_collector.cpp 
b/be/src/storage/index/inverted/reader/predicate_collector.cpp
index 27d65ef2a7b..6f1fab50075 100644
--- a/be/src/storage/index/inverted/reader/predicate_collector.cpp
+++ b/be/src/storage/index/inverted/reader/predicate_collector.cpp
@@ -19,6 +19,8 @@
 
 #include <glog/logging.h>
 
+#include <algorithm>
+#include <optional>
 #include <vector>
 
 #include "exec/common/variant_util.h"
@@ -30,6 +32,7 @@
 #include "storage/index/index_reader_helper.h"
 #include "storage/index/inverted/analysis/analyzer/analyzer.h"
 #include "storage/index/inverted/common/string_helper.h"
+#include "storage/index/inverted/query/exec/term_pattern.h"
 #include "storage/index/inverted/reader/inverted_index_selector.h"
 #include "storage/tablet/tablet_schema.h"
 #include "storage/utils.h"
@@ -116,6 +119,21 @@ Status append_scoring_leaf(CollectInfo* collect_info, 
const std::vector<TermInfo
     return Status::OK();
 }
 
+void append_scoring_prefix(CollectInfo* collect_info, const TermInfo& prefix,
+                           int32_t max_expansions) {
+    DORIS_CHECK(collect_info != nullptr);
+    DORIS_CHECK(prefix.is_single_term());
+    const auto& text = prefix.get_single_term();
+    const bool known = std::ranges::any_of(
+            collect_info->scoring_prefixes, [&](const LogicalScoringPrefix& 
registered) {
+                return registered.prefix == text && registered.max_expansions 
== max_expansions;
+            });
+    if (!known) {
+        collect_info->scoring_prefixes.push_back(
+                LogicalScoringPrefix {.prefix = text, .max_expansions = 
max_expansions});
+    }
+}
+
 InvertedIndexQueryType match_query_type(TExprOpcode::type opcode) {
     switch (opcode) {
     case TExprOpcode::MATCH_ANY:
@@ -356,7 +374,13 @@ Status MatchPredicateCollector::collect(RuntimeState* 
state, const TabletSchemaS
     auto options = DataTypeSerDe::get_default_format_options();
     options.timezone = &state->timezone_obj();
     auto term_infos = 
DORIS_TRY(analyze_plain_query(right_literal->value(options), *analyzer_ctx));
+    // The prefix of a phrase prefix is scored by the terms it expands to, so 
the statistics are
+    // collected for them per segment; a prefix alone scores a constant and 
needs none.
+    std::optional<TermInfo> scoring_prefix;
     if (expr->op() == TExprOpcode::MATCH_PHRASE_PREFIX && !term_infos.empty()) 
{
+        if (term_infos.size() > 1) {
+            scoring_prefix = term_infos.back();
+        }
         term_infos.pop_back();
     }
 
@@ -369,11 +393,20 @@ Status MatchPredicateCollector::collect(RuntimeState* 
state, const TabletSchemaS
         CollectInfo collect_info;
         RETURN_IF_ERROR(append_scoring_leaf(&collect_info, term_infos));
         preserve_selected_index_metadata(candidates, index_meta, 
&collect_info);
-        (*collect_infos)[ws_field_name] = std::move(collect_info);
+        iter = collect_infos->emplace(ws_field_name, 
std::move(collect_info)).first;
     } else {
         RETURN_IF_ERROR(validate_same_physical_index(iter->second, 
*index_meta));
         RETURN_IF_ERROR(append_scoring_leaf(&iter->second, term_infos));
     }
+    if (scoring_prefix.has_value()) {
+        // The terms of a prefix are kept for the whole scan and copied to 
every scanner, so their
+        // number is bounded. Under a session limit of 0, which expands a 
prefix to every term,
+        // "quick b" would keep the whole vocabulary starting with b, and a 
sum of thousands of
+        // idfs ranks nothing; the default limit scores it instead.
+        const int32_t session_limit = index_query::max_expansions(state);
+        append_scoring_prefix(&iter->second, *scoring_prefix,
+                              session_limit > 0 ? session_limit : 
kDefaultScoringExpansions);
+    }
 
     return Status::OK();
 }
diff --git a/be/src/storage/index/inverted/reader/predicate_collector.h 
b/be/src/storage/index/inverted/reader/predicate_collector.h
index ef5107f14a8..211c7ca0f09 100644
--- a/be/src/storage/index/inverted/reader/predicate_collector.h
+++ b/be/src/storage/index/inverted/reader/predicate_collector.h
@@ -56,9 +56,20 @@ struct LogicalScoringLeaf {
     std::vector<LogicalScoringClause> clauses;
 };
 
+// The number of terms a scored phrase prefix expands to when the session sets 
no limit.
+inline constexpr int32_t kDefaultScoringExpansions = 50;
+
+// The last term of a scored phrase prefix, whose idf is the sum of the idfs 
of the smallest
+// `max_expansions` terms it expands to over the tablet, which is positive.
+struct LogicalScoringPrefix {
+    std::string prefix;
+    int32_t max_expansions = kDefaultScoringExpansions;
+};
+
 struct CollectInfo {
     std::vector<std::string> unique_terms;
     std::unordered_map<std::string, uint32_t> unique_term_slots;
+    std::vector<LogicalScoringPrefix> scoring_prefixes;
     std::vector<LogicalScoringLeaf> logical_scoring_leaves;
     std::shared_ptr<const TabletIndex> owned_index_meta;
     const TabletIndex* index_meta = nullptr;
@@ -78,6 +89,9 @@ Result<std::vector<segment_v2::TermInfo>> analyze_plain_query(
         const std::string& value, const InvertedIndexAnalyzerCtx& 
analyzer_ctx);
 Status append_scoring_leaf(CollectInfo* collect_info,
                            const std::vector<segment_v2::TermInfo>& 
term_infos);
+// Registers the prefix of a scored phrase prefix once, however many 
predicates repeat it.
+void append_scoring_prefix(CollectInfo* collect_info, const 
segment_v2::TermInfo& prefix,
+                           int32_t max_expansions);
 Result<const TabletIndex*> select_index_meta(const std::vector<const 
TabletIndex*>& index_metas,
                                              FieldType field_type,
                                              
segment_v2::InvertedIndexQueryType query_type,
diff --git 
a/be/test/storage/index/inverted/query/engine/phrase_prefix_query_test.cpp 
b/be/test/storage/index/inverted/query/engine/phrase_prefix_query_test.cpp
index b2bc9aee74b..c39ecc840ed 100644
--- a/be/test/storage/index/inverted/query/engine/phrase_prefix_query_test.cpp
+++ b/be/test/storage/index/inverted/query/engine/phrase_prefix_query_test.cpp
@@ -21,11 +21,14 @@
 #include <gtest/gtest.h>
 #include <sys/resource.h>
 
+#include <algorithm>
 #include <array>
+#include <cmath>
 #include <memory>
 #include <roaring/roaring.hh>
 #include <set>
 #include <string>
+#include <vector>
 
 #include "io/fs/local_file_system.h"
 #include "runtime/runtime_state.h"
@@ -36,6 +39,7 @@
 #include "storage/index/inverted/common/string_helper.h"
 #include "storage/index/inverted/query/engine/expand_query/expand_weight.h"
 #include "storage/index/inverted/query/scoring/collection_similarity.h"
+#include "storage/index/inverted/query/spi/index_source.h"
 #include "storage/index/inverted/reader/collection_statistics.h"
 
 CL_NS_USE(store)
@@ -384,6 +388,269 @@ TEST_F(PhrasePrefixQueryV2Test, scorer_with_scoring) {
     _CLDECDELETE(dir);
 }
 
+// --- PhrasePrefixWeight scorer: the expanded terms count in the idf ---
+
+// "quick bro" expands to brother and brown, "quick brot" to brother alone: a 
document holding
+// "quick brother" scores on the idf of the phrase terms plus that of every 
term the prefix expands
+// to, all of them from the statistics of the tablet. Those statistics 
describe 1000 documents
+// where the segment holds 20, so an idf read from the segment would differ.
+TEST_F(PhrasePrefixQueryV2Test, prefix_expansions_add_their_idf_to_the_score) {
+    auto ctx = std::make_shared<IndexQueryContext>();
+    ctx->collection_statistics = std::make_shared<CollectionStatistics>();
+    ctx->collection_similarity = std::make_shared<CollectionSimilarity>();
+
+    auto* dir = FSDirectory::getDirectory(kTestDir.c_str());
+    auto reader = make_shared_reader(lucene::index::IndexReader::open(dir, 
true));
+    const std::wstring field = StringHelper::to_wstring("content");
+    auto& stats = *ctx->collection_statistics;
+    stats._total_num_docs = 1000;
+    stats._total_num_tokens[field] = 8000;
+    stats._term_doc_freqs[field][StringHelper::to_wstring("quick")] = 100;
+    stats._term_doc_freqs[field][StringHelper::to_wstring("brother")] = 3;
+    stats._term_doc_freqs[field][StringHelper::to_wstring("brown")] = 400;
+    auto& bro = stats._prefix_expansions[field]
+                        .try_emplace(StringHelper::to_wstring("bro"), 50)
+                        .first->second;
+    bro.insert(StringHelper::to_wstring("brother"));
+    bro.insert(StringHelper::to_wstring("brown"));
+    stats._prefix_expansions[field]
+            .try_emplace(StringHelper::to_wstring("brot"), 50)
+            .first->second.insert(StringHelper::to_wstring("brother"));
+
+    const auto score_of_brother = [&](const std::string& prefix) {
+        PhrasePrefixQuery q(ctx, field, make_term_infos({"quick", prefix}));
+        auto w = q.weight(true);
+        QueryExecutionContext exec_ctx;
+        exec_ctx.segment_num_rows = reader->maxDoc();
+        exec_ctx.field_sources.emplace(field, clucene_index_source(reader, 
field, nullptr));
+        auto scorer = w->scorer(exec_ctx, "");
+        EXPECT_EQ(scorer->seek(10), 10);
+        return scorer->score();
+    };
+    const auto idf = [&](const std::string& term) {
+        return stats.get_or_calculate_idf(field, 
StringHelper::to_wstring(term));
+    };
+    const float avgdl = stats.get_or_calculate_avg_dl(field);
+
+    BM25Similarity brother_only(idf("quick") + idf("brother"), avgdl);
+    BM25Similarity brother_and_brown(idf("quick") + idf("brother") + 
idf("brown"), avgdl);
+    // The document holds the phrase once and the source reports no norms.
+    EXPECT_FLOAT_EQ(score_of_brother("brot"), brother_only.score(1.0F, 1));
+    EXPECT_FLOAT_EQ(score_of_brother("bro"), brother_and_brown.score(1.0F, 1));
+    EXPECT_GT(score_of_brother("bro"), score_of_brother("brot"));
+
+    _CLDECDELETE(dir);
+}
+
+// A statistics object that collected no expansion of the prefix, as for a 
SEARCH predicate, scores
+// the phrase terms alone.
+TEST_F(PhrasePrefixQueryV2Test, 
prefix_without_collected_expansions_scores_the_phrase_terms) {
+    auto ctx = std::make_shared<IndexQueryContext>();
+    ctx->collection_statistics = std::make_shared<CollectionStatistics>();
+    ctx->collection_similarity = std::make_shared<CollectionSimilarity>();
+
+    auto* dir = FSDirectory::getDirectory(kTestDir.c_str());
+    auto reader = make_shared_reader(lucene::index::IndexReader::open(dir, 
true));
+    const std::wstring field = StringHelper::to_wstring("content");
+    auto& stats = *ctx->collection_statistics;
+    stats._total_num_docs = 1000;
+    stats._total_num_tokens[field] = 8000;
+    stats._term_doc_freqs[field][StringHelper::to_wstring("quick")] = 100;
+
+    PhrasePrefixQuery q(ctx, field, make_term_infos({"quick", "bro"}));
+    auto w = q.weight(true);
+    QueryExecutionContext exec_ctx;
+    exec_ctx.segment_num_rows = reader->maxDoc();
+    exec_ctx.field_sources.emplace(field, clucene_index_source(reader, field, 
nullptr));
+    auto scorer = w->scorer(exec_ctx, "");
+    ASSERT_EQ(scorer->seek(10), 10);
+
+    BM25Similarity quick_only(stats.get_or_calculate_idf(field, 
StringHelper::to_wstring("quick")),
+                              stats.get_or_calculate_avg_dl(field));
+    EXPECT_FLOAT_EQ(scorer->score(), quick_only.score(1.0F, 1));
+
+    _CLDECDELETE(dir);
+}
+
+// --- segment statistics: the terms a prefix expands to ---
+
+// "quick bro" collects quick, then the terms of the segment starting with 
bro. A term the scoring
+// terms already hold is listed once, and each prefix keeps the terms it 
expanded to.
+TEST_F(PhrasePrefixQueryV2Test, 
segment_statistics_count_the_terms_a_prefix_expands_to) {
+    auto* dir = FSDirectory::getDirectory(kTestDir.c_str());
+    auto reader = make_shared_reader(lucene::index::IndexReader::open(dir, 
true));
+    const std::wstring field = StringHelper::to_wstring("content");
+    auto source = clucene_index_source(reader, field, nullptr);
+
+    CollectInfo collect_info;
+    TermInfo quick;
+    quick.term = std::string("quick");
+    ASSERT_TRUE(scoring_collection_detail::append_scoring_leaf(&collect_info, 
{quick}).ok());
+    TermInfo bro;
+    bro.term = std::string("bro");
+    scoring_collection_detail::append_scoring_prefix(&collect_info, bro, 50);
+    TermInfo quic;
+    quic.term = std::string("quic");
+    scoring_collection_detail::append_scoring_prefix(&collect_info, quic, 50);
+
+    FieldPrefixExpansions expansions;
+    std::vector<std::string> terms;
+    const collection_statistics_detail::VisibleDocFreq doc_freq = 
[&source](const std::string& term,
+                                                                            
uint64_t* out) {
+        return source->doc_freq(term, out);
+    };
+    ASSERT_TRUE(collection_statistics_detail::segment_scoring_terms(*source, 
collect_info, doc_freq,
+                                                                    
&expansions, &terms)
+                        .ok());
+    // quick is held twice, as a scoring term and as an expansion of quic, and 
listed once.
+    EXPECT_EQ(terms, (std::vector<std::string> {"quick", "brother", "brown", 
"quickly"}));
+    EXPECT_EQ(expansions.at(StringHelper::to_wstring("bro")).terms(),
+              (std::set<std::wstring> {StringHelper::to_wstring("brother"),
+                                       StringHelper::to_wstring("brown")}));
+    EXPECT_EQ(expansions.at(StringHelper::to_wstring("quic")).terms(),
+              (std::set<std::wstring> {StringHelper::to_wstring("quick"),
+                                       StringHelper::to_wstring("quickly")}));
+
+    // At most max_expansions terms of the dictionary, in order.
+    CollectInfo limited;
+    scoring_collection_detail::append_scoring_prefix(&limited, bro, 1);
+    expansions.clear();
+    ASSERT_TRUE(collection_statistics_detail::segment_scoring_terms(*source, 
limited, doc_freq,
+                                                                    
&expansions, &terms)
+                        .ok());
+    EXPECT_EQ(terms, (std::vector<std::string> {"brother"}));
+
+    // A term held by hidden rows alone has no visible document and takes no 
place: with a limit
+    // of 1 the prefix expands to brown, the next term, and the same after the 
hidden rows are
+    // compacted away. All terms hidden leaves nothing, however far the 
dictionary is read.
+    const auto brother_hidden = [&source](const std::string& term, uint64_t* 
out) {
+        RETURN_IF_ERROR(source->doc_freq(term, out));
+        if (term == "brother") {
+            *out = 0;
+        }
+        return Status::OK();
+    };
+    expansions.clear();
+    ASSERT_TRUE(collection_statistics_detail::segment_scoring_terms(
+                        *source, limited, brother_hidden, &expansions, &terms)
+                        .ok());
+    EXPECT_EQ(terms, (std::vector<std::string> {"brown"}));
+    EXPECT_EQ(expansions.at(StringHelper::to_wstring("bro")).terms(),
+              std::set<std::wstring> {StringHelper::to_wstring("brown")});
+
+    const auto all_hidden = [](const std::string&, uint64_t* out) {
+        *out = 0;
+        return Status::OK();
+    };
+    expansions.clear();
+    ASSERT_TRUE(collection_statistics_detail::segment_scoring_terms(*source, 
limited, all_hidden,
+                                                                    
&expansions, &terms)
+                        .ok());
+    EXPECT_TRUE(terms.empty());
+    
EXPECT_TRUE(expansions.at(StringHelper::to_wstring("bro")).terms().empty());
+
+    _CLDECDELETE(dir);
+}
+
+namespace {
+
+// A dictionary alone: it lists the terms that start with a prefix, in byte 
order.
+class DictionaryOnlySource final : public index_query::IndexSource {
+public:
+    explicit DictionaryOnlySource(std::vector<std::string> terms) : 
_terms(std::move(terms)) {
+        std::ranges::sort(_terms);
+    }
+
+    uint32_t doc_count() const override { return 1; }
+
+    Status open_term(std::string_view, bool, bool,
+                     std::unique_ptr<index_query::PostingsCursor>* out) 
override {
+        out->reset();
+        return Status::OK();
+    }
+
+    // Every term is held by 3 documents; the calls are counted.
+    Status doc_freq(std::string_view, uint64_t* out) override {
+        ++doc_freq_calls;
+        *out = 3;
+        return Status::OK();
+    }
+
+    int doc_freq_calls = 0;
+
+    Status expand_terms(index_query::TermPattern&, int32_t max_expansions,
+                        std::vector<std::string>* out) override {
+        out->clear();
+        for (const auto& term : _terms) {
+            if (max_expansions > 0 && out->size() == 
static_cast<size_t>(max_expansions)) {
+                break;
+            }
+            out->push_back(term);
+        }
+        return Status::OK();
+    }
+
+private:
+    std::vector<std::string> _terms;
+};
+
+} // namespace
+
+// SNII keeps the bytes of a token as they are, so its dictionary can hold 
terms that are not valid
+// UTF-8. StringHelper::to_wstring drops those bytes, and "ra\xFF" and 
"ra\xFE" would both get the
+// key "ra" and add their document frequencies up, to more than the documents 
of the tablet. Such
+// terms are not scored and take no place: with a limit of 2 the prefix 
expands to rb and rc.
+TEST_F(PhrasePrefixQueryV2Test, 
segment_statistics_skip_terms_without_a_wide_string_key) {
+    DictionaryOnlySource source({"ra\xFF", "ra\xFE", "rb", "rc", "rd"});
+    CollectInfo collect_info;
+    TermInfo r;
+    r.term = std::string("r");
+    scoring_collection_detail::append_scoring_prefix(&collect_info, r, 2);
+
+    const collection_statistics_detail::VisibleDocFreq one_document = [](const 
std::string&,
+                                                                         
uint64_t* out) {
+        *out = 1;
+        return Status::OK();
+    };
+    FieldPrefixExpansions expansions;
+    std::vector<std::string> terms;
+    ASSERT_TRUE(collection_statistics_detail::segment_scoring_terms(
+                        source, collect_info, one_document, &expansions, 
&terms)
+                        .ok());
+    EXPECT_EQ(terms, (std::vector<std::string> {"rb", "rc"}));
+    EXPECT_EQ(expansions.at(StringHelper::to_wstring("r")).terms(),
+              (std::set<std::wstring> {StringHelper::to_wstring("rb"),
+                                       StringHelper::to_wstring("rc")}));
+}
+
+// With hidden rows every document frequency costs an intersection of the 
term's postings with
+// them. The terms a prefix expands to are asked for twice, to choose the ones 
kept and to add up
+// their frequencies, and the intersection is made once.
+TEST_F(PhrasePrefixQueryV2Test, 
visible_document_frequencies_are_computed_once_per_term) {
+    DictionaryOnlySource source({"rb", "rc"});
+    const auto doc_freq = 
collection_statistics_detail::cached_visible_doc_freq(source, {});
+
+    CollectInfo collect_info;
+    TermInfo r;
+    r.term = std::string("r");
+    scoring_collection_detail::append_scoring_prefix(&collect_info, r, 2);
+    FieldPrefixExpansions expansions;
+    std::vector<std::string> terms;
+    ASSERT_TRUE(collection_statistics_detail::segment_scoring_terms(source, 
collect_info, doc_freq,
+                                                                    
&expansions, &terms)
+                        .ok());
+    ASSERT_EQ(terms, (std::vector<std::string> {"rb", "rc"}));
+    EXPECT_EQ(source.doc_freq_calls, 2);
+
+    // The final count asks for the same terms again.
+    for (const auto& term : terms) {
+        uint64_t frequency = 0;
+        ASSERT_TRUE(doc_freq(term, &frequency).ok());
+        EXPECT_EQ(frequency, 3U);
+    }
+    EXPECT_EQ(source.doc_freq_calls, 2);
+}
+
 // --- PhrasePrefixWeight scorer: nullable branch ---
 
 TEST_F(PhrasePrefixQueryV2Test, scorer_nullable) {
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 a6b52573770..6e00b652fd3 100644
--- a/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
+++ b/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
@@ -2678,7 +2678,7 @@ TEST_F(CollectionStatisticsTest, 
CollectConvertsAnalyzerFailureToStatus) {
     EXPECT_TRUE(collect_infos.empty());
 }
 
-TEST_F(CollectionStatisticsTest, CollectPhrasePrefixExcludesScoringTail) {
+TEST_F(CollectionStatisticsTest, 
CollectPhrasePrefixRegistersTheTailForExpansion) {
     auto tablet_schema = create_tablet_schema_with_inverted_index();
     auto match_expr = 
std::make_shared<collection_statistics::MockVExpr>(TExprNodeType::MATCH_PRED);
     match_expr->set_opcode(TExprOpcode::MATCH_PHRASE_PREFIX);
@@ -2700,6 +2700,60 @@ TEST_F(CollectionStatisticsTest, 
CollectPhrasePrefixExcludesScoringTail) {
     ASSERT_EQ(collect_info.logical_scoring_leaves[0].clauses.size(), 2u);
     EXPECT_EQ(collect_info.logical_scoring_leaves[0].clauses[0].position, 1);
     EXPECT_EQ(collect_info.logical_scoring_leaves[0].clauses[1].position, 2);
+    // The tail is no scoring term: segments expand it, and the terms it 
expands to are scored.
+    ASSERT_EQ(collect_info.scoring_prefixes.size(), 1u);
+    EXPECT_EQ(collect_info.scoring_prefixes[0].prefix, "gamma");
+    EXPECT_EQ(collect_info.scoring_prefixes[0].max_expansions, 50);
+}
+
+TEST_F(CollectionStatisticsTest,
+       CollectPhrasePrefixUnderNoSessionLimitScoresTheDefaultNumberOfTerms) {
+    TQueryOptions query_options;
+    query_options.inverted_index_max_expansions = 0;
+    runtime_state_->set_query_options(query_options);
+    auto tablet_schema = create_tablet_schema_with_inverted_index();
+    auto match_expr = 
std::make_shared<collection_statistics::MockVExpr>(TExprNodeType::MATCH_PRED);
+    match_expr->set_opcode(TExprOpcode::MATCH_PHRASE_PREFIX);
+    match_expr->_children.push_back(
+            std::make_shared<collection_statistics::MockVSlotRef>("content", 
SlotId(1)));
+    match_expr->_children.push_back(
+            std::make_shared<collection_statistics::MockVLiteral>("alpha 
gamma"));
+
+    MatchPredicateCollector collector;
+    CollectInfoMap collect_infos;
+    auto status =
+            collector.collect(runtime_state_.get(), tablet_schema, match_expr, 
&collect_infos);
+
+    ASSERT_TRUE(status.ok()) << status.msg();
+    const auto& collect_info = collect_infos.begin()->second;
+    // A limit of 0 expands the query's prefix to every term, but the 
statistics are kept for the
+    // whole scan and copied to each scanner, so they keep a bounded number.
+    ASSERT_EQ(collect_info.scoring_prefixes.size(), 1u);
+    EXPECT_EQ(collect_info.scoring_prefixes[0].max_expansions, 
kDefaultScoringExpansions);
+}
+
+TEST_F(CollectionStatisticsTest, 
CollectRepeatedPhrasePrefixRegistersTheTailOnce) {
+    auto tablet_schema = create_tablet_schema_with_inverted_index();
+    MatchPredicateCollector collector;
+    CollectInfoMap collect_infos;
+    for (const char* query : {"alpha gamma", "beta gamma", "alpha gamma"}) {
+        auto match_expr =
+                
std::make_shared<collection_statistics::MockVExpr>(TExprNodeType::MATCH_PRED);
+        match_expr->set_opcode(TExprOpcode::MATCH_PHRASE_PREFIX);
+        match_expr->_children.push_back(
+                
std::make_shared<collection_statistics::MockVSlotRef>("content", SlotId(1)));
+        match_expr->_children.push_back(
+                std::make_shared<collection_statistics::MockVLiteral>(query));
+        auto status =
+                collector.collect(runtime_state_.get(), tablet_schema, 
match_expr, &collect_infos);
+        ASSERT_TRUE(status.ok()) << status.msg();
+    }
+
+    ASSERT_EQ(collect_infos.size(), 1u);
+    const auto& collect_info = collect_infos.begin()->second;
+    EXPECT_EQ(collect_info.unique_terms, std::vector<std::string>({"alpha", 
"beta"}));
+    ASSERT_EQ(collect_info.scoring_prefixes.size(), 1u);
+    EXPECT_EQ(collect_info.scoring_prefixes[0].prefix, "gamma");
 }
 
 TEST_F(CollectionStatisticsTest, 
CollectSingleTermPhrasePrefixHasEmptyScoringLeaf) {
@@ -2721,6 +2775,8 @@ TEST_F(CollectionStatisticsTest, 
CollectSingleTermPhrasePrefixHasEmptyScoringLea
     EXPECT_TRUE(collect_info.unique_terms.empty());
     ASSERT_EQ(collect_info.logical_scoring_leaves.size(), 1u);
     EXPECT_TRUE(collect_info.logical_scoring_leaves[0].clauses.empty());
+    // A prefix alone scores a constant and needs no statistics.
+    EXPECT_TRUE(collect_info.scoring_prefixes.empty());
 }
 
 TEST_F(CollectionStatisticsTest, SearchMatchCollectsRawExecutionTerm) {
@@ -3313,4 +3369,26 @@ TEST(SearchPredicateCollectorTest, 
LucenePureExclusionAllowsFieldlessMatchAll) {
     EXPECT_TRUE(infos.empty());
 }
 
+// With a limit of 1, segment A holding {rose} and segment B holding {red, 
rose} expand to rose and
+// to red. The tablet scores red alone, whichever segment is counted first: 
rose's document
+// frequency, which only segment A counted, would otherwise be too small.
+TEST(PrefixExpansionSetTest, KeepsTheSmallestTermsWhateverTheSegmentOrder) {
+    const std::wstring red = L"red";
+    const std::wstring rose = L"rose";
+    for (const bool a_first : {true, false}) {
+        PrefixExpansionSet merged(1);
+        for (const bool is_a : {a_first, !a_first}) {
+            merged.insert(is_a ? rose : red);
+        }
+        EXPECT_EQ(merged.terms(), std::set<std::wstring> {red}) << "a_first=" 
<< a_first;
+    }
+
+    PrefixExpansionSet three(3);
+    for (const std::wstring term : {L"d", L"b", L"e", L"a", L"c", L"b"}) {
+        three.insert(term);
+    }
+    EXPECT_EQ(three.terms(), (std::set<std::wstring> {L"a", L"b", L"c"}));
+    EXPECT_EQ(three.limit(), 3);
+}
+
 } // namespace doris
diff --git 
a/be/test/storage/index/inverted/snii/snii_index_reader_count_fallback_test.cpp 
b/be/test/storage/index/inverted/snii/snii_index_reader_count_fallback_test.cpp
index 9c7d2f9caf8..c057bc18914 100644
--- 
a/be/test/storage/index/inverted/snii/snii_index_reader_count_fallback_test.cpp
+++ 
b/be/test/storage/index/inverted/snii/snii_index_reader_count_fallback_test.cpp
@@ -342,10 +342,18 @@ public:
         return avgdl;
     }
 
+    // The tablet expanded every prefix to beta.
+    std::vector<std::wstring> get_prefix_expansions(const std::wstring& 
/*field_name*/,
+                                                    const std::wstring& 
prefix) override {
+        prefixes.push_back(prefix);
+        return {L"beta"};
+    }
+
     float avgdl = 3.0F;
     std::unordered_map<std::wstring, float> idfs {{L"alpha", 1.0F}, {L"beta", 
2.0F}};
     std::vector<std::wstring> fields;
     std::vector<std::wstring> idf_terms;
+    std::vector<std::wstring> prefixes;
 };
 
 std::vector<uint32_t> positive_score_docids(const CollectionSimilarity& 
similarity,
@@ -1001,9 +1009,11 @@ TEST_F(SniiIndexReaderCountFallback, 
PublicPhraseQueriesScoreOccurrenceFrequency
                                          prefix_bitmap, &analyzer_ctx));
     ASSERT_NE(prefix_bitmap, nullptr);
     EXPECT_EQ(bitmap_docids(*prefix_bitmap), (std::vector<uint32_t> {1}));
-    EXPECT_EQ(prefix_stats->idf_terms, (std::vector<std::wstring> {L"alpha"}));
+    // The phrase's idf counts alpha and the term the tablet expanded "be" to.
+    EXPECT_EQ(prefix_stats->prefixes, (std::vector<std::wstring> {L"be"}));
+    EXPECT_EQ(prefix_stats->idf_terms, (std::vector<std::wstring> {L"alpha", 
L"beta"}));
     EXPECT_EQ(prefix.stats.inverted_index_query_cache_lookup, 0);
-    BM25Similarity prefix_similarity(1.0F, 3.0F);
+    BM25Similarity prefix_similarity(3.0F, 3.0F);
     prefix_similarity.bind_norms(index_query::kByteNormLengths);
     EXPECT_FLOAT_EQ(score_for_doc(*prefix.context->collection_similarity, 1),
                     prefix_similarity.score(2.0F, 
doris::snii::query::encode_norm(4)));
diff --git a/be/test/storage/segment/inverted_index_reader_test.cpp 
b/be/test/storage/segment/inverted_index_reader_test.cpp
index 11b560aabc2..1123bc6fb5a 100644
--- a/be/test/storage/segment/inverted_index_reader_test.cpp
+++ b/be/test/storage/segment/inverted_index_reader_test.cpp
@@ -5229,8 +5229,10 @@ TEST_F(InvertedIndexReaderTest, 
FulltextMatchPhraseAloneConsumesCandidateRows) {
     EXPECT_FALSE(run.context->candidate_rows_consumed);
 }
 
-// A phrase prefix of one token runs as a prefix of it and publishes no score.
-TEST_F(InvertedIndexReaderTest, FulltextOneTokenPhrasePrefixPublishesNoScore) {
+// A phrase prefix of one token runs as a prefix of it and scores every match 
1, as SNII does. A
+// match left unscored would read 0, and `score() > 0` would then drop every 
row the prefix
+// matched.
+TEST_F(InvertedIndexReaderTest, 
FulltextOneTokenPhrasePrefixScoresMatchesAsAConstant) {
     TabletIndex meta;
     auto reader =
             english_fulltext_reader("fulltext_prefix_score",
@@ -5238,7 +5240,10 @@ TEST_F(InvertedIndexReaderTest, 
FulltextOneTokenPhrasePrefixPublishesNoScore) {
     MatchContext run(/*scoring=*/true);
     EXPECT_EQ(match(*reader, run.context, 
InvertedIndexQueryType::MATCH_PHRASE_PREFIX_QUERY, "qui"),
               rows({0, 1}));
-    EXPECT_TRUE(run.context->collection_similarity->_bm25_scores.empty());
+    const auto& scores = run.context->collection_similarity->_bm25_scores;
+    EXPECT_EQ(scores.size(), 2U);
+    EXPECT_FLOAT_EQ(scores.at(0), 1.0F);
+    EXPECT_FLOAT_EQ(scores.at(1), 1.0F);
 }
 
 // A keyword MATCH_PHRASE takes a trailing " ~N" or " ~N+" as its slop, as 
SNII does, and reading
diff --git 
a/regression-test/data/inverted_index_p0/test_match_phrase_prefix_score.out 
b/regression-test/data/inverted_index_p0/test_match_phrase_prefix_score.out
new file mode 100644
index 00000000000..c82a936aa6e
--- /dev/null
+++ b/regression-test/data/inverted_index_p0/test_match_phrase_prefix_score.out
@@ -0,0 +1,74 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !v2_apple_r --
+1      1.1093
+4      0.9415
+5      0.9415
+
+-- !v3_apple_r --
+1      1.1093
+4      0.9415
+5      0.9415
+
+-- !snii_apple_r --
+1      1.1093
+4      0.9415
+5      0.9415
+
+-- !v2_apple_gr --
+2      3.7009
+7      3.7009
+
+-- !v3_apple_gr --
+2      3.7009
+7      3.7009
+
+-- !snii_apple_gr --
+2      3.7009
+7      3.7009
+
+-- !v2_apple_red_g --
+4      5.4381
+
+-- !v3_apple_red_g --
+4      5.4381
+
+-- !snii_apple_red_g --
+4      5.4381
+
+-- !v2_prefix_only --
+1      1
+2      1
+4      1
+5      1
+7      1
+8      1
+
+-- !v3_prefix_only --
+1      1
+2      1
+4      1
+5      1
+7      1
+8      1
+
+-- !snii_prefix_only --
+1      1
+2      1
+4      1
+5      1
+7      1
+8      1
+
+-- !v3_rows --
+2
+7
+
+-- !capped_apple_r --
+1      1.1144
+2      1.1144
+
+-- !uncapped_apple_r --
+1      1.5844
+2      1.5844
+3      1.5844
+
diff --git 
a/regression-test/suites/inverted_index_p0/test_match_phrase_prefix_score.groovy
 
b/regression-test/suites/inverted_index_p0/test_match_phrase_prefix_score.groovy
new file mode 100644
index 00000000000..9acca2adb61
--- /dev/null
+++ 
b/regression-test/suites/inverted_index_p0/test_match_phrase_prefix_score.groovy
@@ -0,0 +1,129 @@
+// 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_PHRASE_PREFIX: never fails, counts the terms the prefix 
expands to in the
+// idf, and gives the same scores on every inverted index storage format. A 
prefix that expands to
+// fewer documents than the other terms used to make 4.0/4.1 throw 
"UnionTermIterator does not
+// support scoring".
+suite("test_match_phrase_prefix_score", "p0") {
+    sql "DROP TABLE IF EXISTS test_match_phrase_prefix_score_v2"
+    sql "DROP TABLE IF EXISTS test_match_phrase_prefix_score_v3"
+    sql "DROP TABLE IF EXISTS test_match_phrase_prefix_score_snii"
+
+    sql """
+        CREATE TABLE test_match_phrase_prefix_score_v2 (
+            id INT NULL,
+            c1 TEXT NULL,
+            INDEX idx_c1 (c1) USING INVERTED PROPERTIES("lower_case" = "true", 
"parser" = "english", "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" = "V2");
+    """
+    sql """
+        CREATE TABLE test_match_phrase_prefix_score_v3 (
+            id INT NULL,
+            c1 TEXT NULL,
+            INDEX idx_c1 (c1) USING INVERTED PROPERTIES("lower_case" = "true", 
"parser" = "english", "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" = "V3");
+    """
+    sql """
+        CREATE TABLE test_match_phrase_prefix_score_snii (
+            id INT NULL,
+            c1 TEXT NULL,
+            INDEX idx_c1 (c1) USING INVERTED PROPERTIES("lower_case" = "true", 
"parser" = "english", "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" = "SNII");
+    """
+
+    // One insert per table keeps every table to a single segment, so the 
segment statistics that
+    // score the expanded terms are the table's.
+    def rows = """(1, 'apple red'), (2, 'apple green'), (3, 'banana yellow'), 
(4, 'apple red green'),
+                  (5, 'apple apple red'), (6, 'red red red'), (7, 'apple 
gray'), (8, 'apple garden path')"""
+    sql "INSERT INTO test_match_phrase_prefix_score_v2 VALUES ${rows}"
+    sql "INSERT INTO test_match_phrase_prefix_score_v3 VALUES ${rows}"
+    sql "INSERT INTO test_match_phrase_prefix_score_snii VALUES ${rows}"
+    sql "sync"
+
+    // 'apple r': the reported query. 'apple red' is rarer than 'apple', so 
the prefix is the
+    // shorter list of the phrase.
+    order_qt_v2_apple_r """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_v2
+                            WHERE c1 MATCH_PHRASE_PREFIX 'apple r' ORDER BY 
score() DESC LIMIT 10 """
+    order_qt_v3_apple_r """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_v3
+                            WHERE c1 MATCH_PHRASE_PREFIX 'apple r' ORDER BY 
score() DESC LIMIT 10 """
+    order_qt_snii_apple_r """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_snii
+                              WHERE c1 MATCH_PHRASE_PREFIX 'apple r' ORDER BY 
score() DESC LIMIT 10 """
+
+    // 'apple gr' expands to green and gray, both rarer than apple.
+    order_qt_v2_apple_gr """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_v2
+                             WHERE c1 MATCH_PHRASE_PREFIX 'apple gr' ORDER BY 
score() DESC LIMIT 10 """
+    order_qt_v3_apple_gr """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_v3
+                             WHERE c1 MATCH_PHRASE_PREFIX 'apple gr' ORDER BY 
score() DESC LIMIT 10 """
+    order_qt_snii_apple_gr """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_snii
+                               WHERE c1 MATCH_PHRASE_PREFIX 'apple gr' ORDER 
BY score() DESC LIMIT 10 """
+
+    // Two phrase terms before the prefix.
+    order_qt_v2_apple_red_g """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_v2
+                                WHERE c1 MATCH_PHRASE_PREFIX 'apple red g' 
ORDER BY score() DESC LIMIT 10 """
+    order_qt_v3_apple_red_g """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_v3
+                                WHERE c1 MATCH_PHRASE_PREFIX 'apple red g' 
ORDER BY score() DESC LIMIT 10 """
+    order_qt_snii_apple_red_g """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_snii
+                                  WHERE c1 MATCH_PHRASE_PREFIX 'apple red g' 
ORDER BY score() DESC LIMIT 10 """
+
+    // A prefix alone is a constant score on every format.
+    order_qt_v2_prefix_only """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_v2
+                                WHERE c1 MATCH_PHRASE_PREFIX 'app' ORDER BY 
score() DESC LIMIT 10 """
+    order_qt_v3_prefix_only """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_v3
+                                WHERE c1 MATCH_PHRASE_PREFIX 'app' ORDER BY 
score() DESC LIMIT 10 """
+    order_qt_snii_prefix_only """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_snii
+                                  WHERE c1 MATCH_PHRASE_PREFIX 'app' ORDER BY 
score() DESC LIMIT 10 """
+
+    // The same rows without score().
+    order_qt_v3_rows """ SELECT id FROM test_match_phrase_prefix_score_v3
+                         WHERE c1 MATCH_PHRASE_PREFIX 'apple gr' """
+
+    // A limit of one expansion over two segments. Segment A holds "apple 
rose", segment B holds
+    // "apple red" and "apple rose"; each segment expands "r" to its smallest 
term, rose in A and
+    // red in B. The tablet scores the prefix on red alone, the smallest term 
of the tablet, and
+    // counts red in every segment that holds it. Counting rose from segment A 
only would give it
+    // a document frequency of 1 instead of 2.
+    sql "DROP TABLE IF EXISTS test_match_phrase_prefix_score_capped"
+    sql """
+        CREATE TABLE test_match_phrase_prefix_score_capped (
+            id INT NULL,
+            c1 TEXT NULL,
+            INDEX idx_c1 (c1) USING INVERTED PROPERTIES("lower_case" = "true", 
"parser" = "english", "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" = "V3");
+    """
+    sql "INSERT INTO test_match_phrase_prefix_score_capped VALUES (1, 'apple 
rose')"
+    sql "INSERT INTO test_match_phrase_prefix_score_capped VALUES (2, 'apple 
red'), (3, 'apple rose')"
+    sql "sync"
+    sql "set inverted_index_max_expansions = 1"
+    order_qt_capped_apple_r """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_capped
+                                WHERE c1 MATCH_PHRASE_PREFIX 'apple r' ORDER 
BY score() DESC LIMIT 10 """
+    sql "set inverted_index_max_expansions = 50"
+    order_qt_uncapped_apple_r """ SELECT id, round(score(), 4) s FROM 
test_match_phrase_prefix_score_capped
+                                  WHERE c1 MATCH_PHRASE_PREFIX 'apple r' ORDER 
BY score() DESC LIMIT 10 """
+}


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

Reply via email to