This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.2 by this push:
new da739613d35 branch-4.2: [fix](inverted index) Score
MATCH_PHRASE_PREFIX on the tablet idf of the terms the prefix expands to #69698
(#69801)
da739613d35 is described below
commit da739613d35b2197087c83ea2e0629dc75c0124f
Author: Jack <[email protected]>
AuthorDate: Fri Oct 30 09:38:12 2026 +0800
branch-4.2: [fix](inverted index) Score MATCH_PHRASE_PREFIX on the tablet
idf of the terms the prefix expands to #69698 (#69801)
### What problem does this PR solve?
Issue Number: None
Related PR: #69698, #69752
Problem Summary:
Backport of #69698 to branch-4.2. score() over MATCH_PHRASE_PREFIX
ignored the prefix and
differed between inverted index storage formats:
1. The idf of a phrase prefix now counts every term the prefix expands
to, as Lucene's
multi-phrase query and Elasticsearch do, and all idfs come from the
tablet statistics. The
statistics expand the prefix in every segment and keep the
`inverted_index_max_expansions`
smallest terms of the tablet that a returned row holds (a session limit
of 0 keeps 50), so a
term's document frequency is counted in every segment that holds it and
merge-on-write hidden
rows do not change the scores after a compaction. A term whose bytes are
not valid UTF-8 is not
scored.
2. A phrase prefix of one token scores 1 on every storage format; on
V2/V3 it read 0, so
`score() > 0` dropped every row it matched.
See #69698 for the full description and the review rounds.
This change builds on #69438 (BM25 statistics count only visible rows of
merge-on-write tables),
whose branch-4.2 backport #69752 is merged. The PR is rebased onto the
current branch-4.2 and
contains only the backport commit; its patch is identical to the one
validated below.
### Branch adaptations
- No production code adaptation. The automatic pick conflicted only
because #69438 was not on
branch-4.2 yet; on top of #69752 the change applies with one conflict,
in the context of
`collection_statistics_test.cpp`: branch-4.2 already has
`CollectConvertsAnalyzerFailureToStatus` elsewhere in the file, so the
copy that master shows
next to the renamed test is not added again.
### Release note
score() over MATCH_PHRASE_PREFIX counts the terms the prefix expands to,
and a phrase prefix of
one token scores 1 on every inverted index storage format.
### Check List (For Author)
- Test: Regression test, Unit Test
- Validated locally on branch-4.2 at 711db43b00, the commit before
#69625, with the same two
commits on top: the newest branch-4.2 calls a lance-c API that no
thirdparty build on the
machine used here provides. The PR branch itself is on the current
branch-4.2 and is built
by CI.
- test_match_phrase_prefix_score passes with the `.out` of master
unchanged (V2, V3, SNII and
the capped two-segment case).
- test_bm25_score, test_bm25_score_parallel_scan_statistics,
test_bm25_score_range_filter,
test_bm25_score_replica_statistics, test_bm25_score_variant,
test_score_filter_matrix,
test_score_filter_usage and test_search_score_topn_predicates pass.
- 1644 related BE unit tests pass (PhrasePrefixQueryV2Test,
CollectionStatistics*,
PrefixExpansionSetTest, SNII, InvertedIndexReaderTest,
MultiSegmentCollectorTest,
BM25SimilarityTest).
- Behavior changed: Yes (scores of MATCH_PHRASE_PREFIX change)
- Does this need documentation: No
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../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 478601f6a19..eb2b20b624e 100644
--- a/be/src/storage/index/inverted/clucene/clucene_index_reader.cpp
+++ b/be/src/storage/index/inverted/clucene/clucene_index_reader.cpp
@@ -413,10 +413,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 63aa9b63e1b..0ca1409eba7 100644
--- a/be/src/storage/index/inverted/query/scoring/bm25_similarity.cpp
+++ b/be/src/storage/index/inverted/query/scoring/bm25_similarity.cpp
@@ -93,6 +93,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 3adc0c3d850..a8e210f42a8 100644
--- a/be/src/storage/index/inverted/query/scoring/bm25_similarity.h
+++ b/be/src/storage/index/inverted/query/scoring/bm25_similarity.h
@@ -38,6 +38,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 1f1d02154df..b73d80825af 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"
@@ -116,6 +118,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()) {
@@ -192,6 +253,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 {
@@ -519,15 +598,18 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset, int6
segment_v2::inverted_index::InvertedIndexAnalyzer::should_analyzer(
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 {}",
@@ -651,15 +733,18 @@ Status CollectionStatistics::process_segment(const
RowsetSharedPtr& rowset, int6
_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);
}
@@ -706,6 +791,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();
@@ -716,6 +811,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();
}
@@ -725,6 +821,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();
}
@@ -804,6 +901,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 18b81312ce2..7f842e88be2 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;
@@ -229,10 +273,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
#include "common/compile_check_end.h"
diff --git a/be/src/storage/index/inverted/reader/predicate_collector.cpp
b/be/src/storage/index/inverted/reader/predicate_collector.cpp
index 615c8fc7b33..376e7af5604 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 "common/exception.h"
@@ -31,6 +33,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 5adf3f7432c..77dd6c26163 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 436063235c3..afabc73b9d7 100644
--- a/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
+++ b/be/test/storage/index/inverted/reader/collection_statistics_test.cpp
@@ -2612,7 +2612,7 @@ TEST_F(CollectionStatisticsTest,
CollectUsesMatchRequestAnalyzerProvider) {
EXPECT_EQ(collect_info.logical_scoring_leaves[0].clauses[1].df_slot, 1u);
}
-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);
@@ -2634,6 +2634,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) {
@@ -2655,6 +2709,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) {
@@ -3282,4 +3338,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 fedd6b8395a..27fa4026b81 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 cf6fb3c63bf..5c34d184dc0 100644
--- a/be/test/storage/segment/inverted_index_reader_test.cpp
+++ b/be/test/storage/segment/inverted_index_reader_test.cpp
@@ -5216,8 +5216,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",
@@ -5225,7 +5227,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]