Skip to content

[Enhancement] Support filtered vector search with pre/post-filter strategies#74715

Open
sevev wants to merge 16 commits into
StarRocks:mainfrom
sevev:feat/vector-filter-strategy-v3
Open

[Enhancement] Support filtered vector search with pre/post-filter strategies#74715
sevev wants to merge 16 commits into
StarRocks:mainfrom
sevev:feat/vector-filter-strategy-v3

Conversation

@sevev

@sevev sevev commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Why I'm doing:

Today, when a vector ANN query carries a scalar predicate that the index rewrite cannot fully resolve (a "residual" predicate, e.g. WHERE category = 3 ORDER BY approx_l2_distance(...) LIMIT 10), the FE gives up the ANN rewrite and the query degrades to a brute-force scan over all matching rows. This makes filtered vector search — the most common production shape — pay full-scan cost even when an HNSW/IVFPQ index exists, and pushing only part of the predicate down can silently return fewer than k correct results.

The decision of how to combine a filter with ANN (pre-filter the candidate set, post-filter with oversampling, or fall back to brute force) depends on runtime information the FE plan cannot see: whether the index supports efficient filtered search, whether a residual SELECT operator sits above the scan in the execution tree, and how selective the filter actually is. So the FE should keep the ANN rewrite and let the BE decide.

What I'm doing:

The FE now unconditionally rewrites eligible ANN queries while keeping the residual predicate in the plan. The BE adds a filter-strategy resolver (be/src/storage/index/vector/vector_filter_strategy.{h,cpp}) that picks one of three execution paths per segment at runtime: PRE (evaluate the whole predicate tree into a row bitmap and run filtered ANN over it, with a count-based selectivity gate that self-corrects to exact search over the candidates when the filter is too sparse for HNSW), POST (oversample the ANN by vector_index_residual_post_filter_oversample, default 3x, then let the normal read-time predicate path filter), and BRUTE (in-iterator exact search) as the safe fallback. FragmentExecutor walks the execution tree and forces BRUTE when a SELECT operator with complex residuals sits above the ANN scan, since a partial pushdown there would under-return. A new session variable ann_filter_strategy (auto/pre/post/brute_force, default auto) allows overriding the resolver, and enable_vector_index_residual_prefilter (BE config, default true) is the kill switch back to the old behavior. A latent bug where ProjectionIterator dropped the runtime-appended distance column is also fixed.

Verified with FE UT (VectorIndexTest, SessionVariableTest), BE UT (vector_search_test, projection_iterator_test, ASAN-clean), new SQL test test_vector_index/test_vector_filter_strategy, and a cluster matrix of 41 query shapes (duplicate/PK tables, single/multi-column predicates, MATCH-in-OR, all four strategies) where auto/pre/post results match brute-force ground truth with no crashes. User documentation for ann_filter_strategy will follow in a separate doc PR.

What type of PR is this:

  • BugFix
  • Feature
  • Enhancement
  • Refactor
  • UT
  • Doc
  • Tool

Does this PR entail a change in behavior?

  • Yes, this PR will result in a change in behavior.
  • No, this PR will not result in a change in behavior.

If yes, please specify the type of change:

  • Interface/UI changes: syntax, type conversion, expression evaluation, display information
  • Parameter changes: default values, similar parameters but with different default values
  • Policy changes: use new policy to replace old one, functionality automatically enabled
  • Feature removed
  • Miscellaneous: upgrade & downgrade compatibility, etc.

Checklist:

  • I have added test cases for my bug fix or my new feature
  • This pr needs user documentation (for new or modified features or behaviors)
    • I have added documentation for my new feature or new function
    • This pr needs auto generate documentation
  • This is a backport pr

Bugfix cherry-pick branch check:

  • I have checked the version labels which the pr will be auto-backported to the target branch
    • 4.1
    • 4.0
    • 3.5

sevev added 13 commits June 10, 2026 21:49
Evaluate a residual scalar predicate (one not resolved by an exact index) against the vector ANN search in SegmentIterator: pre-filter by early-evaluating it into the candidate id set when the index supports efficient filtered search, otherwise post-filter by oversampling and letting the read-time predicate path filter the result. Add VectorIndexReader::supports_efficient_filtered_search() and the enable_vector_index_residual_prefilter / vector_index_residual_post_filter_oversample configs.

Signed-off-by: sevev <qiangzh95@gmail.com>
RewriteToVectorPlanRule used to disable the vector index (falling back to a brute-force scan) whenever a scan predicate could not be fully parsed into a vector distance range. Split the predicate instead: distance-range conjuncts fold into the ANN range, and the residual scalar conjuncts stay on the scan where the BE pre/post-filters them against the ANN. A predicate involving the vector distance but not a valid range bound still disables the index.

Signed-off-by: sevev <qiangzh95@gmail.com>
Design for WHERE <scalar predicate> ORDER BY approx_*_distance(vec, q) LIMIT k on internal tables, including predicates on un-indexed columns. FE unconditionally rewrites to an ANN plan; the BE runtime resolver picks PRE/POST/BRUTE per segment so a residual filter is never silently under-returned.

Signed-off-by: sevev <qiangzh95@gmail.com>
Route a vector top-k query to exact brute-force whenever the residual filter cannot be safely served by a segment-level ANN k-limit, so results are never silently under-returned.

- vector_filter_strategy.{h,cpp}: pure resolve_ann_filter_strategy() returning PRE/POST/BRUTE from bitmap-free gates (user choice, config, residual, above-iterator predicate, exactness, reader capability).

- has_predicate_above_iterator: computed in OlapChunkSource and LakeDataSource, plumbed via TabletReaderParams -> RowsetReadOptions -> SegmentReadOptions.

- _init_ann_reader: run the resolver before column iterators; route to brute (may add the embedding column to _schema) on BRUTE. Disable late materialization when brute is active so the brute-only embedding column is not materialized into the output chunk.

- Tests: resolver truth table; residual queries route to brute on an above-iterator predicate and with the kill-switch off.

Signed-off-by: sevev <qiangzh95@gmail.com>
The residual pre-filter only walked the immediate per-column predicate map and skipped expr predicates, so OR / compound / single-column-expr residuals were silently dropped: the ANN ran effectively unfiltered and the read-time predicate then cut the result below k (under-return). Evaluate the entire pred_tree into the candidate bitmap instead.

- _evaluate_residual_to_bitmap: read all pred_tree columns into a per-batch chunk keyed by column id and call PredicateTree::evaluate (AND/OR/compound/expr). A predicate column that is not readable is now a hard error, not a silent skip.

- has_residual uses !pred_tree.empty() (whole tree) in the resolver and the PRE path; drop the dead post-filter oversample (AUTO routes to brute, not POST; explicit POST returns later).

- Test: residual_or_predicate_prefilters_ann (filter_col=4 OR filter_col=6).

Signed-off-by: sevev <qiangzh95@gmail.com>
Wire VectorSearchOption.filter_strategy (the ann_filter_strategy override) into the resolver and record the resolved strategy on the vector index context so the search dispatches on it.

- _init_ann_reader passes filter_strategy as the resolver's user_choice (was hardcoded AUTO) and stores the result; BRUTE routes to the read-loop fallback.

- _get_row_ranges_by_vector_index: PRE narrows the scan range to the exact residual bitmap before the search (unchanged); POST (explicit opt-in) skips the bitmap and over-fetches, leaving the read-time predicate to drop non-matching rows (approximate, may return < k).

- Tests: explicit_brute_force_returns_all_matching ({4,5,6,7}); explicit_post_filter_may_underreturn ({} -- the 3 nearest the query all fail the filter).

Signed-off-by: sevev <qiangzh95@gmail.com>
Expose the per-query override that drives the BE filter-strategy resolver.

- TQueryOptions.ann_filter_strategy (field 153) + SessionVariable ann_filter_strategy (auto|pre|post|brute_force, default auto), mapped to the AnnFilterStrategy ordinal (0/1/2/3) and passed via toThrift().

- OlapChunkSource / LakeDataSource read query_options().ann_filter_strategy into VectorSearchOption::filter_strategy (the resolver's user_choice).

- Test: SessionVariableTest.testAnnFilterStrategyToThrift (mapping, case-insensitive, invalid->auto, thrift passthrough).

Signed-off-by: sevev <qiangzh95@gmail.com>
- vector-filter-strategy-test-coverage.md: enumerate every covered scenario (resolver truth table, PRE/POST/BRUTE + Gap2 execution, brute fallback, FE session-var mapping), map each to a correctness invariant, and list what is deferred (selectivity gate, execution-layer Strategy classes, SQL e2e).

- vector-filter-strategy.md: add an implementation-status section correcting the in-scope abstraction (resolver decision-layer implemented; polymorphic VectorFilterStrategy/VectorFilterContext execution layer and selectivity gate deferred).

Signed-off-by: sevev <qiangzh95@gmail.com>
Cover {scalar, fulltext (GIN MATCH), scalar AND/OR fulltext, above-iterator} x {high/medium/low/very-low selectivity} x {auto, pre, post, brute_force} on DUPLICATE and PRIMARY KEY tables. Each exact strategy's ANN result (approx_l2_distance, FE-rewritten) is asserted equal to the full-scan ground truth (l2_distance); POST asserts soundness; EXPLAIN asserts VECTORINDEX: ON; the kill-switch toggles enable_vector_index_residual_prefilter. R/ is recorded on first cluster run.

Signed-off-by: sevev <qiangzh95@gmail.com>
…tionIterator

A vector-index scan (ANN or brute-force) with a residual scalar/fulltext
predicate plus global late materialization crashed the BE with a SIGSEGV in
ColumnRef::evaluate_checked under ProjectOperator::push_chunk.

Root cause: with a residual predicate, new_segment_iterator wraps the
SegmentIterator in a ProjectionIterator. SegmentIterator appends the ANN
distance column to the output chunk at runtime (append_vector_column), but
ProjectionIterator builds its column index map from the static schemas, so the
runtime-appended distance column is never copied to the output and is dropped.
OlapChunkSource then cannot find the distance slot, stores SIZE_MAX as its
column index, and the upstream ColumnRef dereferences _columns[SIZE_MAX].

Fix: in ProjectionIterator::do_get_next, after the index-map projection,
re-attach any columns the child appended beyond its static output schema by
their slot id. No-op for non-vector reads (no extra columns); for vector reads
it carries the distance column through by a pointer swap (no row-data copy),
leaving an empty clone in the internal chunk so the next reset() is safe.

Also broaden the predicate-column late-materialization guard in
SegmentIterator::_init_context from brute-force-only to all non-IVFPQ
vector-index reads: ANN appends a synthetic distance column and brute
additionally appends the embedding column to _schema, neither of which the
late-materialization rowid/output assumptions handle.

Tests:
- projection_iterator_test: ProjectionIterator carries a runtime-appended
  slot-tagged column to the output (without the fix the output has 1 column
  instead of 2).
- vector_search_test: residual predicate + predicate-column late
  materialization + multi-output read keeps every output column == num_rows.

Signed-off-by: sevev <qiangzh95@gmail.com>
…urn, POST)

Correctness fixes for vector ANN search with scalar/fulltext residual
predicates, found by an exhaustive cluster matrix (41 predicate shapes over
DUP + PRIMARY KEY tables, scalar + GIN fulltext, AUTO/PRE/POST/BRUTE vs an
exact brute-force ground truth).

1. Multi-column-leaf residual returned wrong (empty) results.
   A residual leaf referencing >1 column (e.g. cat + tag > 50) cannot be pushed
   into the ANN exact pre-filter bitmap; the planner keeps it as a filter above
   the segment k-limit, so a k-limited PRE search under-returns. The FE now flags
   such residuals (RewriteToVectorPlanRule.hasMultiColumnLeaf ->
   TVectorSearchOptions.has_complex_residual) and the BE resolver treats them like
   an above-iterator predicate, routing the scan to the exact BRUTE path.

2. Selectivity gate: HNSW filtered search under-returns on selective+scattered
   residuals.
   Even with an exact pre-filter bitmap, HNSW graph traversal can miss candidates
   scattered far apart in vector space under a very selective filter (e.g.
   cat=1 AND tag=51 -> 7 of 10 rows). After the filtered search, if it found
   fewer survivors than the bitmap could supply, the BE now rescans the (small)
   candidate set with exact distances (_exact_search_over_candidates), restoring
   completeness. The brute distance math is shared via _brute_force_distance_column.

3. POST under-returned by default.
   POST post-filters after the search but did not over-fetch (search_k = k), so a
   selective residual dropped most results. Wire the existing
   vector_index_residual_post_filter_oversample config into search_k for POST.

4. SIGSEGV on inverted-index fallback predicate in the PRE bitmap.
   A residual with a MATCH inside an OR (e.g. doc MATCH 'x' OR cat < 2) is kept as
   an InvertedIndexFallbackPredicate whose evaluate() maps chunk rows via the
   iterator's rowid buffer. _evaluate_residual_to_bitmap did not publish that
   buffer, so the predicate dereferenced an empty buffer and crashed. Publish each
   batch's contiguous rowids before evaluating the tree.

Tests:
- FE: VectorIndexTest.testHasMultiColumnLeaf (direct unit test).
- BE: vector_search_test explicit_post_filter_oversamples (POST over-fetch).
- Cluster: 17 scalar + 11 fulltext shapes on DUP, 13 on PK; AUTO/PRE == exact
  ground truth, POST sound (approximate by design), no crashes.

Signed-off-by: sevev <qiangzh95@gmail.com>
The multi-column-leaf residual fix previously relied on an FE rewrite-time
heuristic (RewriteToVectorPlanRule.hasMultiColumnLeaf -> has_complex_residual
thrift flag). The vector rewrite runs BEFORE the predicate split, so the rule had
to guess which residuals would later land in a SELECT above the ANN scan. That
guess assumed single-column predicates always push into the scan -- true in the
current planner but not an invariant.

Replace the guess with an observation of the real execution tree. After
FragmentExecutor builds the ExecNode tree, a one-time walk flags any ScanNode that
has a row-filtering operator (SELECT) above it but below the TopN limit; the ANN
filter resolver then treats it exactly like an above-iterator predicate and uses
the exact brute-force path. This is robust to how single-column predicates are
placed, and mirrors Doris's BE-side decision -- Doris reads it from the scan's own
conjuncts because Doris pushes every predicate onto the scan, whereas StarRocks
keeps a non-pushable residual in a separate SELECT operator, so it walks the tree.

- ScanNode: _filtered_above_iterator + setter/getter.
- FragmentExecutor::mark_filtered_above_scans: reset at SORT_NODE (TopN is the limit
  boundary; a filter above it is post-limit), set at SELECT_NODE, flag ScanNodes in
  scope. Project and other passthrough nodes are transparent.
- OlapChunkSource / LakeDataSource: OR the flag into has_predicate_above_iterator.
- segment_iterator: derive both resolver inputs from has_predicate_above_iterator.

Removes the FE heuristic and its plumbing: hasMultiColumnLeaf, has_complex_residual
(VectorSearchOptions field, TVectorSearchOptions thrift field 12 [retired, ordinal
not reused], BE VectorSearchOption field + read), and the FE unit test.

Performance: the walk is a one-time O(plan-nodes) pass at fragment setup, off any
per-row path; the set of queries routed to brute is unchanged.

Signed-off-by: sevev <qiangzh95@gmail.com>
- Add R/test_vector_filter_strategy: recorded and validated on a shared-nothing
  cluster. The case asserts the ANN result (approx_*_distance, FE-rewritten)
  equals the exact full-scan ground truth (l2_distance) across scalar / fulltext
  (GIN MATCH) / combined / above-iterator residuals x selectivities x
  auto/pre/post/brute_force, plus POST soundness, the residual-prefilter
  kill-switch, and a PRIMARY KEY table. It is @Sequential (run with -a sequential).
- Remove docs/design/vector-filter-strategy{,-test-coverage}.md: internal design
  notes, not intended for upstream.

Signed-off-by: sevev <qiangzh95@gmail.com>
@github-actions github-actions Bot added behavior_changed documentation Documentation changes labels Jun 12, 2026
@wanpengfei-git wanpengfei-git requested a review from a team June 12, 2026 02:38
@CelerData-Reviewer

Copy link
Copy Markdown

@codex review

@mergify mergify Bot assigned sevev Jun 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

No new undocumented parameters detected by the param-drift check.

@sevev sevev changed the title [Feature] Support filtered vector search with pre/post-filter strategies [Enhancement] Support filtered vector search with pre/post-filter strategies Jun 12, 2026
@github-actions github-actions Bot requested review from luohaha and srlch June 12, 2026 02:40

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 87d7aedd8c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

-- ===========================================================================
-- B1. medium selectivity (cat < 5 ~ 50%): auto == pre == brute_force == exact
set ann_filter_strategy = 'auto';
function: assert_equal_result("select id from vt where cat < 5 order by approx_l2_distance(vec, [0,0,0,0,0]), id limit 10", "select id from vt where cat < 5 order by l2_distance(vec, [0,0,0,0,0]), id limit 10")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove tie-breaker from vector strategy assertions

These correctness checks do not exercise the new vector-filter paths because the left query orders by two expressions (approx_l2_distance(...), id). RewriteToVectorPlanRule.check() returns false unless topNOp.getOrderByElements().size() == 1, so these assert_equal_result calls bypass VECTORINDEX entirely and the preceding set ann_filter_strategy values are not tested. Use a single vector-distance order expression here, or add a separate assertion that the exact query being compared contains VECTORINDEX: ON.

Useful? React with 👍 / 👎.

Comment on lines +1089 to +1090
// ANN scalar-filter strategy override: auto | pre | post | brute_force.
public static final String ANN_FILTER_STRATEGY = "ann_filter_strategy";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new ann_filter_strategy variable

This adds a user-settable session variable, and the new SQL tests use SET ann_filter_strategy = ..., but the docs changed in this commit only cover enable_vector_index_refine; I could not find ann_filter_strategy in docs/en or docs/zh. The repo AGENTS.md requires user-facing config/session changes to update the matching English and Chinese docs, otherwise users have no documented accepted values (auto, pre, post, brute_force) or semantics.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

🌎 Translation Required?

Thanks for your doc contribution! The following languages are missing or outdated for your changes.

🤖 Automated Translations

If you are fluent in any of the missing languages you can add them. If not, a maintainer can generate the translations below.

Maintainer: Check the ones you wish to generate:

docs/en/sql-reference/System_variable.md

  • ja for docs/en/sql-reference/System_variable.md (New)

docs/en/table_design/indexes/vector_index.md

  • ja for docs/en/table_design/indexes/vector_index.md (New)

Maintainers: Check boxes and reply with /translate to start.

🕒 Last updated: Fri, 12 Jun 2026 02:46:47 GMT

@github-actions github-actions Bot added the docs-maintainer Picked up by the weekly docs triage workflow label Jun 12, 2026
sevev and others added 2 commits June 12, 2026 11:02
The quantization-keyed refine option itself merged upstream (StarRocks#74533). This keeps
only the parts that PR does not cover:

- Rename the remaining use_ivfpq gates in the v3 filter-strategy code paths
  (segment_iterator resolver/late-mat gates, vector_filter_strategy, BE UT
  fixture) to refine_distance, matching the upstream rename.
- test_vector_index_hnsw_lazymat: SET enable_vector_index_refine = true so the
  IVFPQ-eager check keeps refine semantics under the new default (refine off).

Signed-off-by: sevev <qiangzh95@gmail.com>
@sevev sevev force-pushed the feat/vector-filter-strategy-v3 branch from d2b6012 to 348ad82 Compare June 12, 2026 03:04
@github-actions github-actions Bot added behavior_changed and removed documentation Documentation changes labels Jun 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

[Java-Extensions Incremental Coverage Report]

pass : 0 / 0 (0%)

@github-actions

Copy link
Copy Markdown
Contributor

[FE Incremental Coverage Report]

pass : 1 / 1 (100.00%)

file detail

path covered_line new_line coverage not_covered_line_detail
🔵 com/starrocks/qe/SessionVariable.java 1 1 100.00% []

@github-actions

Copy link
Copy Markdown
Contributor

[BE Incremental Coverage Report]

fail : 69 / 143 (48.25%)

file detail

path covered_line new_line coverage not_covered_line_detail
🔵 src/connector/lake_connector.cpp 0 5 00.00% [406, 449, 450, 452, 454]
🔵 src/exec/pipeline/fragment_executor.cpp 0 23 00.00% [367, 368, 369, 370, 371, 372, 374, 375, 376, 377, 378, 379, 381, 382, 383, 384, 385, 386, 387, 388, 389, 516, 519]
🔵 src/exec/pipeline/scan/olap_chunk_source.cpp 0 7 00.00% [308, 310, 357, 359, 362, 363, 364]
🔵 src/storage/rowset/segment_iterator.cpp 67 106 63.21% [1171, 1172, 1176, 1177, 1178, 1179, 1182, 1183, 1184, 1192, 1211, 1225, 1226, 1227, 1228, 1230, 1231, 1232, 1233, 1234, 1289, 1294, 1295, 1296, 1297, 1298, 1299, 1301, 1304, 1305, 1306, 1307, 1308, 1310, 1311, 1312, 1317, 1318, 1320]
🔵 src/storage/tablet_reader.cpp 1 1 100.00% []
🔵 src/storage/rowset/rowset.cpp 1 1 100.00% []

…E bitmaps

After the PRE strategy evaluates the residual predicate into an exact
candidate bitmap, skip the filtered HNSW search and score the candidates
exactly (_exact_search_over_candidates, the same kernel the post-search
count gate uses) when the search cannot pay for itself:

- cardinality <= k: a top-k over <= k candidates must return every
  candidate, so the search is a logical no-op. Always on.
- cardinality <= vector_index_brute_selectivity_threshold * segment rows
  (new mutable BE config, default 0.01, 0 disables): a bitmap this sparse
  relative to the graph makes the HNSW traversal slow and likely to
  under-return, paying the exact rescan on top of the wasted search. The
  denominator is the segment's total rows -- the graph the traversal
  walks -- not the pruned scan range.

This is routing only: both sides are exact, so a mis-set threshold costs
speed, never correctness. Completeness remains owned by the post-search
count gate, which is unchanged. The gate applies to top-k PRE only: range
search keeps its own path and refine (which never ran the resolver) is
explicitly excluded, leaving both behaviors untouched.

Tests: 4 new BE UT cases (cardinality < k, == k boundary, ratio leg with
a 0.5 threshold, and an anti-overfire case asserting the search still
runs above both thresholds), keyed on vector_search_timer == 0 -- the
timer accumulates only inside the search block, so it is a deterministic
skipped-search marker, not a timing assertion. All 33 vector suites green
under ASAN.

Signed-off-by: sevev <qiangzh95@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

behavior_changed docs-maintainer Picked up by the weekly docs triage workflow PROTO-REVIEW

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants