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]
