Skip to content

Push standalone filter clauses down to the Lance scanner - #450

Merged
aaltshuler merged 2 commits into
ModernRelay:mainfrom
azimafroozeh:filter-clause-pushdown
Aug 6, 2026
Merged

Push standalone filter clauses down to the Lance scanner#450
aaltshuler merged 2 commits into
ModernRelay:mainfrom
azimafroozeh:filter-clause-pushdown

Conversation

@azimafroozeh

@azimafroozeh azimafroozeh commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

The two spellings of the same match predicate executed differently: inline props ($d: Doc { status: "open" }) pushed down to the Lance scanner, while a standalone filter clause ($d.status = "open", the only form that can express ranges) ran in memory after the scan. So a filtered nearest() searched top-k of the whole table and the late filter starved the result set (fewer than k rows, possibly zero, exit 0), and without a search the clause form materialized every row of the table, all columns included, before dropping the non-matching ones.

The fix generalizes the existing FTS-filter hoist in the executor: a pre-pass moves each filter that references exactly one binding and lowers to a structured scanner expression onto the op introducing that binding, the node scan (arming prefilter(true)) or the expand's destination filters. Multi-binding filters, non-pushable shapes, and filters on outer bindings inside not { } keep their in-memory position, so the fallback is exactly the prior behavior.

Benchmark

Release builds, before = main. Docs with a ~216-char text field and a Vector(256) column, 1% matching status = "open", query returns 10 slugs via the clause form. Peak RSS via /usr/bin/time over 5 runs; inline = the same predicate written inline, the target the clause form should match.

rows clause, before clause, after reduction inline (reference)
200k 659.2 MiB 77.6 MiB 88.2% 77.5 MiB
1M 2680.4 MiB 155.5 MiB 94.2% 155.6 MiB

2.6 GB held to return 10 rows becomes 156 MiB, identical to inline: the scan now materializes only the 1% that matches, where the old penalty grew linearly with table size.

Local verification

  • New filtered_nearest_clause_spelling_prefilters_like_inline in the search suite, red on unfixed code (0 rows instead of 3), covers equality and a range predicate; blob coverage extended to the clause form
  • cargo test -p omnigraph-engine: search, traversal, traversal_indexed, literal_filters, ordering, aggregation, proptest_equivalence, end_to_end all green
  • cargo test --workspace --locked (failpoints features): no failures attributable to this change; the 7 failing targets reproduce identically on unmodified main

Greptile Summary

The PR pushes eligible single-binding standalone filters into the scan or destination-expansion operation so filtering occurs before search top-k selection and avoids unnecessary materialization.

  • Adds scalar-filter classification and hoisting during pipeline execution.
  • Extends vector-search and blob-query coverage for standalone equality and range predicates.
  • Documents standalone pre-search filtering in the developer and user search guides.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
crates/omnigraph/src/exec/query.rs Adds a guarded pre-pass that hoists lowerable single-binding filters to their introducing scan or expansion while preserving existing fallback placement for unsupported shapes.
crates/omnigraph/tests/search.rs Adds equality and range coverage proving standalone filters prefilter nearest-neighbor search like inline predicates.
crates/omnigraph/tests/end_to_end.rs Extends blob-bearing scan coverage to the newly hoisted standalone-clause path.
docs/dev/execution.md Updates execution references and describes the filter-hoisting behavior and fallback boundaries.
docs/user/search/index.md Completes the prior documentation request by explaining pre-search behavior for inline and standalone equality/range filters.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    F[Standalone filter] --> V{References exactly one binding?}
    V -- No --> M[Keep in-memory position]
    V -- Yes --> L{Lowerable to scanner expression?}
    L -- No --> M
    L -- Yes --> I{Binding introduced by}
    I -- NodeScan --> S[Attach scanner filter and enable prefilter]
    I -- Expand destination --> E[Attach destination filter]
    S --> Q[Search top-k over matching rows]
    E --> H[Filter during destination hydration]
Loading

Reviews (3): Last reviewed commit: "review" | Re-trigger Greptile

Context used:

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment on lines 724 to 789
search_mode: &'a SearchMode,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
// Pre-pass: collect search filters that need to be hoisted to NodeScan
// Pre-pass: hoist filters onto the op that introduces their binding.
// Search filters always move to the binding's NodeScan (applied via
// `scanner.full_text_search`). Scalar filters referencing exactly one
// binding move to that binding's NodeScan (`filter_expr`, which also
// arms `prefilter(true)` so a `nearest`/`bm25` on the same scanner is
// filtered BEFORE top-k instead of starved after it) or into the
// introducing Expand's `dst_filters` (applied during hydration).
// Multi-binding filters (e.g. the cycle-closing `temp.id = dst.id`)
// and filters on a variable not introduced here (an outer binding
// inside an anti-join pipeline) keep their end-of-pipeline placement.
let mut scan_vars: HashSet<&str> = HashSet::new();
let mut expand_dst_vars: HashSet<&str> = HashSet::new();
for op in pipeline {
match op {
IROp::NodeScan { variable, .. } => {
scan_vars.insert(variable.as_str());
}
IROp::Expand { dst_var, .. } => {
expand_dst_vars.insert(dst_var.as_str());
}
IROp::Filter(_) | IROp::AntiJoin { .. } => {}
}
}

let mut hoisted_search_filters: HashMap<String, Vec<IRFilter>> = HashMap::new();
let mut hoisted_scan_filters: HashMap<String, Vec<IRFilter>> = HashMap::new();
let mut hoisted_dst_filters: HashMap<String, Vec<IRFilter>> = HashMap::new();
let mut hoisted_indices: HashSet<usize> = HashSet::new();
for (i, op) in pipeline.iter().enumerate() {
if let IROp::Filter(filter) = op {
if is_search_filter(filter) {
if let Some(var) = search_filter_variable(filter) {
hoisted_search_filters
.entry(var.to_string())
.or_default()
.push(filter.clone());
hoisted_indices.insert(i);
}
let IROp::Filter(filter) = op else { continue };
if is_search_filter(filter) {
if let Some(var) = search_filter_variable(filter) {
hoisted_search_filters
.entry(var.to_string())
.or_default()
.push(filter.clone());
hoisted_indices.insert(i);
}
continue;
}
let mut vars = filter_variables(filter).into_iter();
let (Some(var), None) = (vars.next(), vars.next()) else {
continue;
};
// Only pushable filters may leave their in-memory position:
// `execute_node_scan` silently ignores filters `ir_filter_to_expr`
// cannot lower (no post-scan fallback there). The schema arg only
// affects a literal's type, never Some-vs-None, so `None` here
// gives the same verdict as the scan site.
if ir_filter_to_expr(filter, params, None).is_none() {
continue;
}
let target = if scan_vars.contains(var.as_str()) {
&mut hoisted_scan_filters
} else if expand_dst_vars.contains(var.as_str()) {
&mut hoisted_dst_filters
} else {
continue;
};
target.entry(var).or_default().push(filter.clone());
hoisted_indices.insert(i);
}

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 Document standalone filter pushdown

This changes the user-visible execution semantics of standalone scalar filters, including pre-top-k filtering for search, but only updates the developer execution guide. Update the relevant docs/user query documentation so users have an accurate account of standalone equality and range-filter behavior.

Context Used: CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

@aaltshuler
aaltshuler force-pushed the filter-clause-pushdown branch from d6e1b1f to a8279ad Compare August 6, 2026 20:50
@aaltshuler
aaltshuler merged commit 72188ec into ModernRelay:main Aug 6, 2026
1 check passed
azimafroozeh pushed a commit to azimafroozeh/omnigraph that referenced this pull request Aug 8, 2026
Adds the missing user-visible changes to the v0.9.0 release notes: edge
property bindings (ModernRelay#447), standalone filter pushdown (ModernRelay#450), the keyed
per-transaction write bound with the Overwrite exemption (ModernRelay#454), strict
cluster policy-bundle validation (ModernRelay#444), and developer-facing lines for
RFC-030/031/032 and the rustfmt baseline.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants