github-actions[bot] commented on code in PR #64377:
URL: https://github.com/apache/doris/pull/64377#discussion_r3575655957
##########
be/src/storage/segment/segment_iterator.cpp:
##########
@@ -1061,6 +1074,72 @@ Status SegmentIterator::_apply_ann_topn_predicate() {
return Status::OK();
}
+bool SegmentIterator::_enable_ann_topn_predicate_prefilter() const {
+ return !_opts.runtime_state ||
+
!_opts.runtime_state->query_options().__isset.enable_ann_topn_predicate_prefilter
||
+
_opts.runtime_state->query_options().enable_ann_topn_predicate_prefilter;
+}
+
+Status SegmentIterator::_eager_filter_predicates_into_bitmap() {
+ // The residual column predicates (those not resolvable by
zonemap/inverted/bitmap index, hence
+ // not yet reflected in _row_bitmap) are evaluated here over the candidate
rows, and the
+ // survivors are intersected back into _row_bitmap. The narrowed bitmap is
then fed to the ANN
+ // index as an IDSelector (see
AnnTopNRuntime::evaluate_vector_ann_search), so a predicated
+ // TopN query keeps using the index instead of degrading to an O(N)
brute-force distance scan.
+ //
+ // This runs before the main scan loop sets up _range_iter / _block_rowids
/
+ // _current_return_columns (see _lazy_init), so it allocates the predicate
columns and drives a
+ // local pass with the same _read_columns_by_index + predicate-evaluation
primitives the main
+ // loop uses. All of those members are re-initialized by the main loop
afterwards (_range_iter
+ // is recreated over the narrowed _row_bitmap right after
_apply_ann_topn_predicate returns).
+ if (!_is_need_vec_eval && !_is_need_short_eval) {
+ // has_column_predicate was true but every predicate was already
resolved via index;
Review Comment:
This eager pass needs to keep the same predicate-column semantics as the
normal scan path. Right now it reads `_predicate_column_ids` and then goes
straight to dictionary/hash conversion and `_evaluate_*_predicate()`, but
`_next_batch_internal()` applies `_replace_version_col_if_needed()`,
`_update_lsn_col_if_needed()`, and `_update_tso_col_if_needed()` immediately
after `_read_columns_by_index()` and before predicate evaluation. Those
substitutions are semantic: `__DORIS_VERSION_COL__` is stored as 0 until read
time, and row-binlog LSN/timestamp predicate columns are rebuilt from the
rowset commit TSO. With prefiltering enabled, a predicate on one of those
hidden columns can therefore narrow `_row_bitmap` using placeholder values
before the ANN search, while the brute-force/normal path would evaluate the
substituted values. Please apply the same three fixups in the eager loop before
`_convert_dict_code_for_predicate_if_necessary()` (or share the normal
predicate-read preparation)
so the IDSelector sees the same survivors as the normal scan.
##########
regression-test/suites/ann_index_p0/ann_topn_predicate_prefilter.groovy:
##########
@@ -0,0 +1,273 @@
+// 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.
+
+// Regression for an ANN TopN query that carries a residual column predicate
(one NOT
+// resolvable by zonemap/inverted/bitmap index). Such a predicate must be
pre-filtered into a
+// candidate bitmap fed to the ANN index as an IDSelector so the query keeps
using the index,
+// instead of degrading to an O(N) brute-force distance scan.
+//
+// Observation mechanism (same as ann_index_only_scan_debug_point): with debug
point
+// "segment_iterator._read_columns_by_index" enabled for
column_name="embedding", reading the
+// vector column throws "does not need to read data". On the index path the
vector column is not
+// read, so the query succeeds; on a brute-force fallback it is read, so the
debug point fires.
+//
+// Three cases are covered:
+// 1. a small single-batch segment -- basic index-path + top-K correctness
check.
+// 2. a segment large enough to span several scan batches (batch_size) --
this guards the
+// per-batch column reset in _eager_filter_predicates_into_bitmap.
Without that reset,
+// batches after the first are filtered against stale predicate data, so
the prefiltered
+// candidate set (and the top-K) is wrong. A single-batch segment never
exercises it.
+// 3. a segment spanning two condition-cache granules -- ANN TopN must not
cache its top-K
+// truncation as the result of the column predicate.
+suite("ann_topn_predicate_prefilter", "nonConcurrent") {
+ sql "unset variable all;"
+ sql "set enable_common_expr_pushdown=true;"
+ sql "set experimental_enable_virtual_slot_for_cse=true;"
+ sql "set enable_no_need_read_data_opt=true;"
+ sql "set parallel_pipeline_task_num=1;"
+ sql "set enable_sql_cache=false;"
+ sql "set enable_condition_cache=false;"
+
+ // ============================ Case 1: single small batch
============================
+ sql "drop table if exists ann_topn_pred_prefilter"
+ sql """
+ create table ann_topn_pred_prefilter (
+ id int not null,
+ category int not null,
+ embedding array<float> not null,
+ index ann_embedding(`embedding`) using ann properties(
+ "index_type"="hnsw",
+ "metric_type"="l2_distance",
+ "dim"="8"
+ )
+ ) duplicate key(id)
+ distributed by hash(id) buckets 1
+ properties("replication_num"="1");
+ """
+
+ // 7 of 10 rows have category=1 (70% > 30%), so after pre-filtering the
candidate set stays
+ // above the small-candidate (0.3) fallback threshold and the ANN index
path is exercised.
+ sql """
+ insert into ann_topn_pred_prefilter values
+ (0, 1, [39.906116, 10.495334, 54.08394, 88.67262, 55.243687,
10.162686, 36.335983, 38.684258]),
+ (1, 1, [62.759315, 97.15586, 25.832521, 39.604908, 88.76715, 72.64085,
9.688437, 17.721428]),
+ (2, 1, [15.447449, 59.7771, 65.54516, 12.973712, 99.685135, 72.080734,
85.71118, 99.35976]),
+ (3, 1, [72.26747, 46.42257, 32.368374, 80.50209, 5.777631, 98.803314,
7.0915947, 68.62693]),
+ (4, 1, [22.098177, 74.10027, 63.634556, 4.710955, 12.405106, 79.39356,
63.014366, 68.67834]),
+ (5, 1, [27.53003, 72.1106, 50.891026, 38.459953, 68.30715, 20.610682,
94.806274, 45.181377]),
+ (6, 1, [77.73215, 64.42907, 71.50025, 43.85641, 94.42648, 50.04773,
65.12575, 68.58207]),
+ (7, 2, [2.1537063, 82.667885, 16.171143, 71.126656, 5.335274,
40.286068, 11.943586, 3.69409]),
+ (8, 2, [54.435013, 56.800594, 59.335514, 55.829235, 85.46627,
33.388138, 11.076194, 20.480877]),
+ (9, 2, [76.197945, 60.623528, 84.229805, 31.652937, 71.82595,
48.04684, 71.29212, 30.282396]);
+ """
+
+ def v =
"[26.360261917114258,7.05784273147583,32.361351013183594,86.39714050292969,58.79527282714844,27.189321517944336,99.38946533203125,80.19270324707031]"
+
+ // Primary signal: with a category predicate (no index on category, not
excluded by zonemap)
+ // the index path must NOT read the vector column. If it fell back to
brute force it would read
+ // embedding and the debug point would throw "does not need to read data",
failing the query.
+ try {
+ GetDebugPoint().enableDebugPointForAllBEs(
+ "segment_iterator._read_columns_by_index", [column_name:
"embedding"])
+
+ sql """
+ select id
+ from ann_topn_pred_prefilter
+ where category = 1
+ order by l2_distance_approximate(embedding, ${v})
+ limit 5;
+ """
+ } finally {
+
GetDebugPoint().disableDebugPointForAllBEs("segment_iterator._read_columns_by_index")
+ }
+
+ // Correctness: the index pre-filter path must return the same top-K as
the brute-force path
+ // (prefilter off => fall back to an exact distance scan over the same
filtered rows).
+ def topnSql = """
+ select id
+ from ann_topn_pred_prefilter
+ where category = 1
+ order by l2_distance_approximate(embedding, ${v})
+ limit 5;
+ """
+
+ sql "set enable_ann_topn_predicate_prefilter=true;"
+ def withPrefilter = sql topnSql
+
+ sql "set enable_ann_topn_predicate_prefilter=false;"
+ def bruteForce = sql topnSql
+
+ assertEquals(bruteForce, withPrefilter,
+ "ANN pre-filter top-K must match brute-force top-K for the same
predicated query")
+
+ // ===================== Case 2: multiple scan batches in one segment
=====================
+ // Shrink the scan batch so a few hundred rows in a single segment span
several batches; this
+ // is the path where a missing per-batch reset would filter later batches
with stale data.
+ sql "set batch_size = 64;"
+ // Case 1 left the prefilter disabled (its brute-force leg); re-enable it
before the index-path
+ // check below, otherwise the column predicate would force a brute-force
fallback.
+ sql "set enable_ann_topn_predicate_prefilter=true;"
+
+ sql "drop table if exists ann_topn_pred_prefilter_mb"
+ sql """
+ create table ann_topn_pred_prefilter_mb (
+ id int not null,
+ category int not null,
+ embedding array<float> not null,
+ index ann_embedding(`embedding`) using ann properties(
+ "index_type"="hnsw",
+ "metric_type"="l2_distance",
+ "dim"="8"
+ )
+ ) duplicate key(id)
+ distributed by hash(id) buckets 1
+ properties("replication_num"="1");
+ """
+
+ // Query vector and a deterministic layout: a handful of category=1 rows
are planted very
+ // close to the query (increasing distance), all at high ids so they land
in batch 2+. One
+ // category=2 row is planted equally close to catch a false-positive leak.
Every other row is
+ // far away, so the true top-5 (category=1, nearest) is exactly the 5
planted category=1 ids.
+ def qmb = [10, 10, 10, 10, 10, 10, 10, 10]
+ def plantedCat1 = [100: 1, 140: 2, 180: 3, 220: 4, 260: 6] // id ->
first-coord offset (== L2 distance)
+ def plantedCat2Id = 200
+ def plantedCat2Offset = 5
+ def total = 300
+
+ def fmt = { e -> "[" + e.collect { (it as float) }.join(",") + "]" }
+ def rows = []
+ for (int id = 0; id < total; id++) {
+ def cat
+ def emb
+ if (plantedCat1.containsKey(id)) {
+ cat = 1
+ emb = [10 + plantedCat1[id], 10, 10, 10, 10, 10, 10, 10]
+ } else if (id == plantedCat2Id) {
+ cat = 2
+ emb = [10 + plantedCat2Offset, 10, 10, 10, 10, 10, 10, 10]
+ } else {
+ // ~70% category=1 so survivors stay above the 0.3 fallback
threshold; far from qmb.
+ cat = (id % 10 < 7) ? 1 : 2
+ emb = [200 + (id % 7), 201, 202, 203, 204, 205, 206, 207]
+ }
+ rows.add("(${id}, ${cat}, ${fmt(emb)})")
+ }
+ // One INSERT => one rowset => one segment, so the rows really span scan
batches in a segment.
+ sql "insert into ann_topn_pred_prefilter_mb values ${rows.join(",")};"
+
+ def vmb = fmt(qmb)
+
+ // Primary signal again: predicated TopN over a multi-batch segment must
stay on the index path.
+ try {
+ GetDebugPoint().enableDebugPointForAllBEs(
+ "segment_iterator._read_columns_by_index", [column_name:
"embedding"])
+
+ sql """
+ select id
+ from ann_topn_pred_prefilter_mb
+ where category = 1
+ order by l2_distance_approximate(embedding, ${vmb})
+ limit 5;
+ """
+ } finally {
+
GetDebugPoint().disableDebugPointForAllBEs("segment_iterator._read_columns_by_index")
+ }
+
+ def topnSqlMb = """
+ select id
+ from ann_topn_pred_prefilter_mb
+ where category = 1
+ order by l2_distance_approximate(embedding, ${vmb})
+ limit 5;
+ """
+
+ sql "set enable_ann_topn_predicate_prefilter=true;"
+ def withPrefilterMb = sql topnSqlMb
+
+ sql "set enable_ann_topn_predicate_prefilter=false;"
+ def bruteForceMb = sql topnSqlMb
+
+ // Same top-K as the exact path, and exactly the planted category=1 ids in
distance order.
+ // A stale-batch bug would drop a planted category=1 row or leak the
planted category=2 row,
+ // changing this result. The explicit id list also rejects a vacuous (e.g.
both-empty) pass.
+ def idsOf = { res -> res.collect { (it[0] as int) } }
+ assertEquals(idsOf(bruteForceMb), idsOf(withPrefilterMb),
Review Comment:
This assertion is fragile because Case 2 is still using the default
`hnsw_ef_search` (32); the suite only raises it to 4096 later for the
condition-cache case. Here `withPrefilterMb` comes from the approximate HNSW
path over a few hundred candidates, while `bruteForceMb` is the exact fallback,
so the exact equality and planted-id assertions can fail even when the
prefilter bitmap is correct. Please raise `hnsw_ef_search` before this
multi-batch exactness check, or make the oracle avoid exact ANN-vs-brute-force
equality.
--
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]