github-actions[bot] commented on code in PR #68758:
URL: https://github.com/apache/doris/pull/68758#discussion_r4249371193


##########
be/src/storage/index/inverted/query/engine/boolean_query/listed_terms.cpp:
##########
@@ -0,0 +1,304 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "storage/index/inverted/query/engine/boolean_query/listed_terms.h"
+
+#include <parallel_hashmap/phmap.h>
+
+#include <algorithm>
+#include <bit>
+#include <optional>
+#include <span>
+#include <utility>
+
+#include "common/check.h"
+#include "common/exception.h"
+#include "storage/index/inverted/query/engine/postings/listed_walk.h"
+#include "storage/index/inverted/query/engine/scored_rows_scorer.h"
+#include "storage/index/inverted/query/exec/block_doc_set.h"
+#include "storage/index/inverted/query/exec/cursor_chained_postings.h"
+#include "storage/index/inverted/query/exec/roaring_docid_sink.h"
+#include "storage/index/inverted/query/exec/term_waves.h"
+#include "storage/index/inverted/query/phrase/position_span.h"
+
+namespace doris::segment_v2::inverted_index::query_v2 {
+
+ListedTerms::ListedTerms(index_query::IndexSourcePtr source,
+                         std::shared_ptr<roaring::Roaring> nulls)
+        : _source(std::move(source)), _nulls(std::move(nulls)) {}
+
+void ListedTerms::add(size_t clause, std::string term,
+                      index_query::ScoringContextPtr<float> similarity) {
+    DCHECK(_clauses.empty() || _clauses.back() < clause);
+    _clauses.push_back(clause);
+    _terms.push_back(std::move(term));
+    _similarities.push_back(std::move(similarity));
+}
+
+bool ListedTerms::holds(size_t clause) const {
+    return std::ranges::binary_search(_clauses, clause);
+}
+
+void ListedTerms::open(bool conjunctive, bool scoring) {
+    if (!conjunctive) {
+        return;
+    }
+    for (const std::string& term : _terms) {
+        bool held = false;
+        THROW_IF_ERROR(_source->may_hold(term, &held));
+        if (!held) {
+            _cursors.resize(_terms.size());
+            return;
+        }
+    }
+    // A scored conjunction reads the positions of the rows it lists.
+    THROW_IF_ERROR(
+            _source->open_terms(_terms, /*positions=*/scoring, 
/*scoring=*/false, &_cursors));
+}
+
+bool ListedTerms::has_absent_term() const {
+    return std::ranges::any_of(_cursors, [](const auto& cursor) { return 
cursor == nullptr; });
+}
+
+uint64_t ListedTerms::cheapest_doc_freq() const {
+    uint64_t cheapest = UINT64_MAX;
+    for (const auto& cursor : _cursors) {
+        cheapest = std::min<uint64_t>(cheapest, cursor == nullptr ? 0 : 
cursor->doc_freq());
+    }
+    return cheapest;
+}
+
+index_query::TruthSet ListedTerms::conjunction(const std::vector<uint32_t>* 
candidates) {
+    index_query::TruthSet result;
+    if (has_absent_term()) {
+        return result;
+    }
+    std::vector<uint32_t> docs;
+    THROW_IF_ERROR(index_query::chain_cursors(_cursors, candidates, &docs));
+    result.true_rows.addMany(docs.size(), docs.data());
+    if (_nulls != nullptr) {
+        result.null_rows = *_nulls;
+    }
+    return result;
+}
+
+index_query::TruthSet ListedTerms::disjunction() {
+    index_query::TruthSet result;
+    bool any_present = false;
+    index_query::RoaringDocIdSink sink(result.true_rows);
+    THROW_IF_ERROR(_source->collect_terms(_terms, sink, &any_present));
+    if (any_present && _nulls != nullptr) {
+        result.null_rows = *_nulls;
+    }
+    return result;
+}
+
+namespace {
+
+// Merges a term's postings into the rows scored so far, both ascending: a row 
both hold sums
+// the scores, a row either holds alone keeps its own.
+void merge_term(index_query::BlockDocSet& docs, 
index_query::ScoringContext<float>& similarity,
+                std::vector<uint32_t>* rows, std::vector<float>* scores) {
+    // Reading the rows so far through spans lets the loop keep their bounds 
in registers, which
+    // it cannot do through vectors a caller may hold by reference.
+    const std::span<const uint32_t> old_rows(*rows);
+    const std::span<const float> old_scores(*scores);
+    std::vector<uint32_t> merged_rows;
+    std::vector<float> merged_scores;
+    merged_rows.reserve(old_rows.size() + docs.size_hint());
+    merged_scores.reserve(old_rows.size() + docs.size_hint());
+    size_t i = 0;
+    for (; !docs.exhausted(); docs.advance()) {
+        const uint32_t doc = docs.doc();
+        for (; i < old_rows.size() && old_rows[i] < doc; ++i) {
+            merged_rows.push_back(old_rows[i]);
+            merged_scores.push_back(old_scores[i]);
+        }
+        float score = similarity.score(static_cast<float>(docs.freq()), 
docs.norm());
+        if (i < old_rows.size() && old_rows[i] == doc) {
+            score += old_scores[i];
+            ++i;
+        }
+        merged_rows.push_back(doc);
+        merged_scores.push_back(score);
+    }
+    merged_rows.insert(merged_rows.end(), old_rows.begin() + 
static_cast<std::ptrdiff_t>(i),
+                       old_rows.end());
+    merged_scores.insert(merged_scores.end(), old_scores.begin() + 
static_cast<std::ptrdiff_t>(i),
+                         old_scores.end());
+    rows->swap(merged_rows);
+    scores->swap(merged_scores);
+}
+
+// The rows a disjunction holds and their scores, one slot per row of the 
segment: a term adds
+// into the slots in one pass where merging would copy every row scored so far.
+class RowSlots {
+public:
+    RowSlots(uint32_t doc_count, std::span<const uint32_t> rows, 
std::span<const float> scores)
+            : _scores(doc_count, 0.0F), _held((doc_count + 63) / 64, 0) {
+        for (size_t i = 0; i < rows.size(); ++i) {
+            _scores[rows[i]] = scores[i];
+            hold(rows[i]);
+        }
+    }
+
+    void add_term(index_query::BlockDocSet& docs, 
index_query::ScoringContext<float>& similarity) {
+        for (; !docs.exhausted(); docs.advance()) {
+            const uint32_t doc = docs.doc();
+            DCHECK_LT(doc, _scores.size());
+            _scores[doc] += similarity.score(static_cast<float>(docs.freq()), 
docs.norm());
+            hold(doc);
+        }
+    }
+
+    // The rows held, ascending, and their scores.
+    void list(std::vector<uint32_t>* rows, std::vector<float>* scores) const {
+        size_t count = 0;
+        for (const uint64_t word : _held) {
+            count += std::popcount(word);
+        }
+        rows->clear();
+        scores->clear();
+        rows->reserve(count);
+        scores->reserve(count);
+        for (size_t word = 0; word < _held.size(); ++word) {
+            for (uint64_t bits = _held[word]; bits != 0; bits &= bits - 1) {
+                const auto row = static_cast<uint32_t>(word * 64 + 
std::countr_zero(bits));
+                rows->push_back(row);
+                scores->push_back(_scores[row]);
+            }
+        }
+    }
+
+private:
+    void hold(uint32_t row) { _held[row / 64] |= uint64_t {1} << (row % 64); }
+
+    std::vector<float> _scores;
+    std::vector<uint64_t> _held;
+};
+
+} // namespace
+
+// The chain lists the rows, then the terms read their positions a wave at a 
time, one round
+// each, and score each row on the positions it holds there and the source's 
norm.
+ScorerPtr ListedTerms::scored_conjunction() {
+    if (has_absent_term()) {
+        return std::make_shared<EmptyScorer>();
+    }
+    std::vector<uint32_t> rows;
+    THROW_IF_ERROR(index_query::chain_cursors(_cursors, nullptr, &rows));
+    if (rows.empty()) {

Review Comment:
   [P1] Keep UNKNOWN rows when a scored conjunction has no matches. For a 
nullable SNII field containing separate `'foo'`, `'bar'`, and NULL rows, 
`SELECT id, score() AS s FROM t WHERE search('NOT body:foo/bar', 
'{"mode":"standard","default_operator":"and"}') ORDER BY s DESC LIMIT 10` 
incorrectly includes the NULL row. The single TERM analyzes to two present 
terms with an empty intersection; this return drops `_nulls`, so the enclosing 
NOT sees FALSE instead of UNKNOWN. The previous native MATCH_ALL leaf preserved 
its NULL bitmap even for an empty result, and the new unscored conjunction 
still does. Return an empty scored result carrying `_nulls` and add 
scored/unscored parity for this shape.



##########
be/src/storage/index/inverted/analysis/analyzer/analyzer.cpp:
##########
@@ -255,6 +256,56 @@ std::vector<TermInfo> 
InvertedIndexAnalyzer::get_analyse_result(
     return get_analyse_result(reader, analyzer.get());
 }
 
+Status InvertedIndexAnalyzer::analyze(std::string_view value, const 
InvertedIndexAnalyzerCtx* ctx,
+                                      const std::map<std::string, 
std::string>& properties,
+                                      std::vector<TermInfo>* out) {
+    try {
+        if (ctx == nullptr) {
+            *out = get_analyse_result(value, properties);
+        } else if (!ctx->requires_analysis()) {
+            // Every string, the empty one included, is a term of an 
untokenized index.
+            *out = {TermInfo {.term = std::string(value)}};
+        } else if (const auto analyzer = ctx->get_analyzer(); analyzer != 
nullptr) {
+            auto reader = create_reader(ctx->char_filter_map);
+            reader->init(value.data(), static_cast<int32_t>(value.size()), 
true);
+            *out = get_analyse_result(reader, analyzer.get());
+        } else {
+            *out = get_analyse_result(value, properties);
+        }
+    } catch (const CLuceneError& e) {
+        return 
Status::Error<ErrorCode::INVERTED_INDEX_ANALYZER_ERROR>("analyzing '{}' failed: 
{}",
+                                                                       value, 
e.what());
+    } catch (const Exception& e) {
+        return 
Status::Error<ErrorCode::INVERTED_INDEX_ANALYZER_ERROR>("analyzing '{}' failed: 
{}",
+                                                                       value, 
e.what());
+    }
+    return Status::OK();
+}
+
+std::string InvertedIndexAnalyzer::normalize(const std::string& value,
+                                             const std::map<std::string, 
std::string>& properties) {
+    const std::string analyzer_name = 
get_analyzer_name_from_properties(properties);
+    if (analyzer_name.empty() || is_builtin_analyzer(analyzer_name)) {
+        // The index writers lowercase a builtin analyzer's terms unless 
lower_case is false.
+        return get_parser_lowercase_from_properties<true>(properties) == 
INVERTED_INDEX_PARSER_TRUE
+                       ? to_lower(value)

Review Comment:
   [P1] Use Unicode lowercase normalization for builtin analyzers. On an SNII 
index with `parser=icu` and `lower_case=true`, the writer's ICU LowerCaseFilter 
stores `'Éclair'` as `'éclair'`, but `search('body:É*')` now runs this bytewise 
`to_lower` and looks up the uppercase UTF-8 prefix `'É'`, missing the row. The 
former SNII prefix path analyzed the stem with the ICU analyzer and produced 
`'é'`. This occurs without any character filter, so it is separate from the 
existing char_replace omission. Reuse the builtin analyzer's Unicode-aware 
lowercase transformation while preserving `lower_case=false`, and cover a 
non-ASCII prefix in format parity tests.



##########
be/src/storage/index/inverted/query/engine/phrase_query/phrase_weight.cpp:
##########
@@ -0,0 +1,1065 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "storage/index/inverted/query/engine/phrase_query/phrase_weight.h"
+
+#include <algorithm>
+#include <functional>
+#include <iterator>
+#include <limits>
+#include <memory>
+#include <optional>
+#include <span>
+#include <utility>
+
+#include "common/check.h"
+#include "common/exception.h"
+#include "storage/index/inverted/common/string_helper.h"
+#include "storage/index/inverted/query/engine/bit_set_query/bit_set_scorer.h"
+#include 
"storage/index/inverted/query/engine/const_score_query/const_score_scorer.h"
+#include "storage/index/inverted/query/engine/nullable_scorer.h"
+#include "storage/index/inverted/query/engine/phrase_query/phrase_scorer.h"
+#include "storage/index/inverted/query/engine/postings/listed_walk.h"
+#include "storage/index/inverted/query/engine/scored_rows_scorer.h"
+#include "storage/index/inverted/query/engine/segment_postings.h"
+#include "storage/index/inverted/query/exec/cursor_chained_postings.h"
+#include "storage/index/inverted/query/exec/term_pattern.h"
+#include "storage/index/inverted/query/exec/term_waves.h"
+#include "storage/index/inverted/query/phrase/exact_phrase_matcher.h"
+#include "storage/index/inverted/query/phrase/phrase_verifier.h"
+#include "storage/index/inverted/query/phrase/position_span.h"
+#include "storage/index/inverted/query/spi/postings_cursor.h"
+
+namespace doris::segment_v2::inverted_index::query_v2 {
+
+SlotPhraseWeight::SlotPhraseWeight(std::wstring field, 
index_query::PhraseQueryOptions options,
+                                   index_query::ScoringContextPtr<float> 
similarity,
+                                   bool enable_scoring, bool nullable)
+        : _field(std::move(field)),
+          _options(options),
+          _similarity(std::move(similarity)),
+          _enable_scoring(enable_scoring),
+          _nullable(nullable) {}
+
+index_query::IndexSourcePtr SlotPhraseWeight::_source(const 
QueryExecutionContext& ctx,
+                                                      const std::string& 
binding_key) const {
+    auto source = lookup_source(_field, ctx, binding_key);
+    if (!source) {
+        throw Exception(ErrorCode::NOT_FOUND, "Reader not found for field 
'{}'",
+                        StringHelper::to_string(_field));
+    }
+    return source;
+}
+
+ScorerPtr SlotPhraseWeight::scorer(const QueryExecutionContext& ctx,
+                                   const std::string& binding_key) {
+    auto source = _source(ctx, binding_key);
+    ScorerPtr scorer = _lists(*source) ? _listed_scorer(*source, 
_options.candidates)
+                                       : _streamed_scorer(*source, 
ctx.segment_num_rows);
+    if (_nullable) {
+        auto logical_field = logical_field_or_fallback(ctx, binding_key, 
_field);
+        return make_nullable_scorer(scorer, logical_field, ctx.null_resolver);
+    }
+    return scorer;
+}
+
+// A scored phrase needs a scorer to retain its phrase frequencies.
+bool SlotPhraseWeight::lists_rows(const QueryExecutionContext& ctx,
+                                  const std::string& binding_key) const {
+    auto source = lookup_source(_field, ctx, binding_key);
+    return source != nullptr && !_enable_scoring && _lists(*source);
+}
+
+index_query::TruthSet SlotPhraseWeight::listed_rows(const 
QueryExecutionContext& ctx,
+                                                    const std::string& 
binding_key,
+                                                    const roaring::Roaring* 
candidates) {
+    auto source = _source(ctx, binding_key);
+    DORIS_CHECK(_lists(*source));
+    // The scan's candidates narrow the conjunction's for the rows listed; the 
UNKNOWN rows
+    // are the field's among the conjunction's candidates, as the streamed 
phrase reports them.
+    const roaring::Roaring* listed = candidates;
+    std::optional<roaring::Roaring> narrowed;
+    if (_options.candidates != nullptr) {
+        if (candidates == nullptr) {
+            listed = _options.candidates;
+        } else {
+            narrowed = *candidates & *_options.candidates;
+            listed = &*narrowed;
+        }
+    }
+    index_query::TruthSet result;
+    std::vector<uint32_t> matched;
+    _list_matches(*source, listed, &matched, nullptr);
+    result.true_rows.addMany(matched.size(), matched.data());
+    if (_nullable) {
+        auto nulls = FieldNullBitmapFetcher::fetch(
+                ctx.null_resolver, logical_field_or_fallback(ctx, binding_key, 
_field));
+        if (nulls != nullptr) {
+            result.null_rows = candidates == nullptr ? *nulls : *nulls & 
*candidates;
+        }
+    }
+    return result;
+}
+
+PhraseWeight::PhraseWeight(std::wstring field, std::vector<TermInfo> 
term_infos,
+                           index_query::PhraseQueryOptions options,
+                           index_query::ScoringContextPtr<float> similarity, 
bool enable_scoring,
+                           bool nullable)
+        : SlotPhraseWeight(std::move(field), options, std::move(similarity), 
enable_scoring,
+                           nullable),
+          _term_infos(std::move(term_infos)) {}
+
+std::vector<PhraseSlot> PhraseWeight::_slots() const {
+    std::vector<PhraseSlot> slots;
+    slots.reserve(_term_infos.size());
+    for (const auto& term_info : _term_infos) {
+        slots.push_back({.offset = static_cast<uint32_t>(term_info.position),
+                         .terms = {term_info.get_single_term()}});
+    }
+    return slots;
+}
+
+ScorerPtr PhraseWeight::_streamed_scorer(index_query::IndexSource& source, 
uint32_t num_docs) {
+    std::vector<std::pair<size_t, SegmentPostingsPtr>> term_postings_list;
+    const uint32_t first_doc = _options.candidates != nullptr && 
!_options.candidates->isEmpty()
+                                       ? _options.candidates->minimum()
+                                       : 0;
+    for (const auto& term_info : _term_infos) {
+        size_t offset = term_info.position;
+        auto posting = open_postings(source, term_info.get_single_term(),

Review Comment:
   [P1] Preserve term identity when opening repeated phrase clauses. Each 
occurrence of the same CLucene term is opened as a different posting here, but 
`PhraseScorer::create` identifies repeats by posting pointer. On a 
phrase-enabled V2/V3 fulltext index, `body='foo'` therefore satisfies `body 
MATCH_PHRASE 'foo foo ~1'`: the verifier gets IDs `[0,1]`, skips 
repeat-collision checks, and accepts the two views of position 0 within slop 1. 
The removed CLucene matcher grouped by term text and rejected this row; SNII 
still deduplicates its slots. Reuse the posting or pass explicit term 
identities, and cover this through a real CLucene reader rather than only the 
contract test that supplies the same pointer twice.



##########
be/src/storage/index/inverted/query/engine/term_query/term_weight.h:
##########
@@ -62,29 +63,31 @@ class TermWeight : public Weight {
                 std::move(result));
     }
 
+    const std::wstring& field() const { return _field; }
+    const std::string& term() const { return _term; }
+    const index_query::ScoringContextPtr<float>& similarity() const { return 
_similarity; }
+    bool scores() const { return _enable_scoring; }
+
 private:
     TermOrEmptyScorer specialized_scorer(const QueryExecutionContext& ctx,
                                          const std::string& binding_key) {
-        auto reader = lookup_reader(_field, ctx, binding_key);
+        auto source = lookup_source(_field, ctx, binding_key);
         auto logical_field = logical_field_or_fallback(ctx, binding_key, 
_field);
-        if (!reader) {
+        if (!source) {
             return std::make_shared<EmptyScorer>();
         }
 
-        SegmentPostingsPtr segment_postings;
-        segment_postings = create_term_posting(reader.get(), _field, _term, 
_enable_scoring,
-                                               _similarity, _context->io_ctx);
+        SegmentPostingsPtr segment_postings =
+                open_postings(*source, _term, /*positions=*/false, 
_enable_scoring, _similarity);
         if (segment_postings) {
             return std::make_shared<TermScorer>(segment_postings, _similarity, 
logical_field);
         }
         return std::make_shared<EmptyScorer>();

Review Comment:
   [P1] Preserve the field NULL bitmap for an absent SNII term. 
`SniiIndexSource` returns no cursor when the term is absent, so this bare 
`EmptyScorer` loses `logical_field` and its NULL rows. On a nullable SNII field 
containing `'foo'` and NULL, `NOT search('body:missing', 
'{"mode":"standard"}')` now returns the NULL row with both scoring and caching 
disabled. The previous native SEARCH leaf returned an empty `BitSetQuery` with 
the full field NULL bitmap and excluded it under NOT. Keep a nullable empty 
scorer for this branch and preserve UNKNOWN in grouped absent-term paths too; 
cover absent-term NOT through a real SNII reader.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to