Skip to content

feat(pushdown): push array_has_any/array_has_all - #237

Open
RagingKore wants to merge 9 commits into
lance-format:mainfrom
RagingKore:feat/containment-pushdown
Open

feat(pushdown): push array_has_any/array_has_all#237
RagingKore wants to merge 9 commits into
lance-format:mainfrom
RagingKore:feat/containment-pushdown

Conversation

@RagingKore

Copy link
Copy Markdown

feat(pushdown): push array_has_any/array_has_all into Lance

Investigated, implemented and tested with the help of Claude Code.

Summary

No containment predicate on a LIST column could prefilter a search. LanceExprIRSupportsLogicalType
gated on the column's LogicalTypeId, and rejected LIST before it looked at the function, so
array_has_any and array_has_all never left DuckDB while id > 5, IN and LIKE on scalar columns
pushed normally. DuckDB applied the containment predicate after the search had already run, so callers had
to oversample k until the candidate pool covered the whole table, or the search silently missed matching
rows outside the top-k. prefilter := true neither helped nor errored, it quietly did nothing. Lance can
already answer these predicates from a LABEL_LIST index
(LabelListQueryParser::visit_scalar_function in lance-index); the extension never handed them over.
This PR does that, and fixes three further things that sat between sending the predicate and Lance using
the index.

Before and After

ns is any catalog from ATTACH '<dir>' AS ns (TYPE LANCE). means the predicate reaches Lance and
Lance filters, means it never left DuckDB. A scan has no prefilter argument and no top-k, so a missed
pushdown costs time. On a search it changes the answer: the search picks its top-k before applying the
predicate, so you get back fewer rows than you asked for.

Table addressed as scan lance_hybrid_search lance_vector_search lance_fts
'data/items.lance' ❌ → ✅ ❌ → ✅ ❌ → ✅ ❌ → ✅
ns.main.items ❌ → ✅ ❌ → ✅ ❌ → ✅ ❌ → ✅

Containment worked nowhere, under either way of addressing the table, and the index was unreachable by
construction. With a LABEL_LIST index on the list column the scan is an index lookup rather than a
full read; without one the predicate still pushes and Lance answers it by scanning. LABEL_LIST is the
only scalar index that answers containment.

The same defects blocked other predicates, so those are fixed by construction rather than as extra scope.
Cells with a single already worked and are unchanged.

Predicate Table addressed as scan lance_hybrid_search lance_vector_search lance_fts
id > 5, =, numeric IN 'data/items.lance'
ns.main.items ❌ → ✅ ❌ → ✅
starts_with / LIKE / regexp 'data/items.lance' ❌ → ✅ ❌ → ✅
ns.main.items ❌ → ✅ ❌ → ✅ ❌ → ✅
IS NULL on a list column 'data/items.lance' ❌ → ✅ ❌ → ✅ ❌ → ✅ ❌ → ✅
ns.main.items ❌ → ✅ ❌ → ✅ ❌ → ✅ ❌ → ✅

The first two predicate groups resolve to a BTREE index when one exists on the compared column. No scalar
index answers IS NULL on a list column, so that row always scans.

One case this PR does not fix: IN over a VARCHAR column with two or more values throws
requires filter pushdown for prefilterable columns from all three search functions, before and after.
The same predicate pushes on a scan, and pushes from a search under prefilter := false, so the encoder
handles it; the prefilterable check rejects it. CONJUNCTION_OR over VARCHAR fails the same way.
Out of scope here, but worth an issue.

Design

LanceExprIRSupportsLogicalType rejected LogicalTypeId::LIST, so any filter touching a list column
bailed out before anything examined the function name, and the IR had no list literal. This admits LIST
to the type gate, adds a LIST literal tag (a length-prefixed sequence of the existing scalar payloads,
recursive), and gives containment branches to both the TableFilterSet and Expression builders. Rust
decodes the tag into a ScalarValue::List and resolves the two UDFs from datafusion-functions-nested,
promoted from a transitive dependency to an explicit one. The IR always carries the DataFusion spelling, so
DuckDB's list_has_any / list_has_all aliases stay decoupled from it.

Search Functions

lance_fts and lance_hybrid_search registered filter_pushdown and pushdown_expression but never
pushdown_complex_filter, so simple comparisons pushed while the optimizer dropped anything DuckDB models
as an expression. LancePushdownComplexFilter is typed against LanceKnnBindData, so this extracts its
body into CollectLancePushedFilterIRParts and gives both functions a hook over the LanceSearchBindData
they already share. This is wider than containment: starts_with, LIKE and regexp_matches have never
prefiltered from those two functions either, and now do.

Namespace-Backed Tables

lance_vector_search and lance_fts rejected prefilter := true on an attached namespace table without
an explicit filter := '<string>'. That guard was correct when #217 added it: query_table takes a filter
string, DuckDB supplies an IR, and no converter existed. #232 then added filter_expr_to_sql for namespace
scans, but nothing pointed the search path at it. The two search entry points now take the same
filter_ir arguments as lance_create_namespace_scan_stream_ir and share its machinery via
apply_filter_expr; a pushed predicate and an explicit filter combine with AND. The init-time check the
path-backed code already used replaces the bind-time guard. The effect is wider than the guard suggests: no pushed
predicate of any kind previously reached a namespace-backed search, and prefilter := true without an
explicit filter threw outright rather than degrading.

List Field Naming

DuckDB's Arrow exporter hardcodes the list child field name to l, while Arrow, pyarrow, Lance and
DataFusion use item, and Lance persists whatever it receives. DataFusion's array-signature coercion
normalizes every list argument to a child named item, so an l column gets wrapped in a cast and Lance's
maybe_indexed_column no longer matches it. The predicate pushed and prefiltered, but Lance answered it
with a scan:

full_filter=array_has_any(CAST(tags AS List(Utf8)), List([red])), refine_filter=array_has_any(…)

LanceNormalizeArrowListFieldNames rewrites the name to item at every write boundary: COPY, INSERT,
CREATE TABLE, CTAS, namespace create, and both ALTER TABLE paths. The plan then reads
refine_filter=-- with a ScalarIndexQuery … @tags_idx(LabelList).

Compatibility

Renaming on write broke appends to older datasets, since Lance rejects a schema that disagrees with the
destination. The writer now reads the destination schema in Append mode and matches whatever that dataset
already uses, realigning the imported data_type alongside it. New datasets get item, existing ones keep
l.

Two behaviour changes. lance_fts and lance_hybrid_search now genuinely prefilter, which changes results
for anyone who set prefilter := true and was silently getting post-filtering. And datasets written before
this change keep l and will not use the index; they read, append and prefilter correctly, Lance just
answers with a scan. One metadata-only rename fixes a dataset, with no data rewrite:
ALTER TABLE ns.main.t RENAME COLUMN "tags.l" TO item.

Deliberate Exclusions

Empty needles and NULL needle elements fall back to DuckDB, because DuckDB short-circuits an empty
list_has_all to true and ignores NULL elements where DataFusion compares them by value. A non-constant
second argument falls back too. Also excluded: the singular array_has / array_contains, and the && /
@> / <@ aliases, since DuckDB registers <@ without swapping its arguments, unlike Postgres. Only the
containment branches emit the LIST tag, keeping tags = ['a','b'] off a DataFusion list equality whose
nested comparison kernels are patchy. Admitting LIST to the type gate does push down IS NULL and
IS NOT NULL on list columns, which matches Arrow semantics.

Two scope notes on the index, found while testing and left alone. The LABEL_LIST index is reached for
VARCHAR[] and BIGINT[] element types. Narrower integer widths encode through the same I64 literal
tag, so DataFusion coerces the column instead of the literal and the planner falls back to a scan; the
predicate still pushes and still prefilters, it is only the index lookup that is lost. An element-type hint
on the LIST tag would close it. Separately, align_list_field_names descends into struct children but
not into nested lists, so a legacy List<List<T>> column would still fail to append.

Test Plan

  • pushdown_filter_ir_containment.test: both functions and both DuckDB spellings across plain scans,
    lance_vector_search, lance_fts and lance_hybrid_search; VARCHAR[] and BIGINT[] elements; bound
    parameters; a k-semantics probe; index usage asserted through explain_verbose; every negative case
    checked for correct results and for not being pushed.
  • pushdown_filter_ir_containment_namespace.test: the same ground against an attached catalog. The fixture
    makes the tagged rows the worst matches, so at k := 2 a search that filters after top-k returns
    nothing, and every assertion proves prefiltering from its row set alone.
  • Two tests in search_functions.test asserted the removed bind-time error as the contract, and now assert
    that a WHERE clause prefilters without an explicit filter.
  • Four Rust unit tests cover the append schema alignment directly.
  • Some assertions check the filter IR byte count rather than a result set, because the failure mode is
    silent: a plan that stops pushing still returns the right rows, from a full scan.
  • Full regression suite: 4250 assertions across 53 test cases. cargo clippy --all-targets clean.

Docs

docs/sql.md search filter semantics updated to match: pushed WHERE predicates and an explicit
filter combine with AND, and a new "Filter pushdown notes" section covers containment predicates,
the LABEL_LIST index, and the legacy l field-name remedy.

Note on Cargo.lock

The lock diff sits in its own commit and is unrelated. Cargo.toml on main already requires
datafusion = "54.0.0" while the committed lock pins 53.1.0, so --locked fails on main today and any
build regenerates it. This feature's own dependency change is one line of Cargo.toml.

Cargo.toml requires datafusion 54 but the committed lock still pins
53.1.0, so `cargo --locked` fails on a clean checkout and every build
regenerates the lock as an unrelated diff.
DuckDB's Arrow exporter hardcodes the list child field name to `l`
(`duckdb/src/common/arrow/arrow_converter.cpp`). Arrow, pyarrow, Lance and
DataFusion all use `item`, and Lance persists whatever name it receives.

DataFusion's coercion for array-signature functions normalizes every list
argument to `DataType::new_list(elem, nullable)`, whose child is always
`item`. It therefore wraps a column whose child is called `l` in
`CAST(col AS List(..))`, and Lance's scalar-index planner only matches a
bare column reference, so a LABEL_LIST index can never answer a query over
a list column we wrote. The coercion rewrites both arguments to the same
canonical shape, so no change on the literal side avoids it.

Normalize the name at every DuckDB -> Lance write boundary: COPY, INSERT,
CREATE TABLE, CTAS, namespace create, and both ALTER TABLE paths.

Lance rejects an append whose schema disagrees with the destination, so the
writer now reads the destination schema in append mode and renames its list
children to match whatever that dataset already uses. Datasets written
before this change keep `l` and stay appendable; new ones get `item`. The
writer also realigns the imported `data_type`, otherwise RecordBatch
validation fails on the incoming arrays instead.
Admit LIST columns through the filter IR type gate and add a LIST literal
tag (a length-prefixed sequence of the existing scalar literal payloads,
recursive), so both the TableFilterSet and the general Expression builders
can encode containment predicates. Rust decodes the tag into a
ScalarValue::List and resolves the two UDFs from datafusion-functions-nested,
promoted here from a transitive dependency to an explicit one. Lance's
scalar-index planner recognizes those two function names against a
LABEL_LIST-indexed column.

DuckDB's `list_has_*` spelling maps onto the same DataFusion names, so the
IR always carries the DataFusion spelling and the two vocabularies stay
decoupled. The `&&` / `@>` / `<@` operator aliases stay out: DuckDB aliases
`<@` to `list_has_all` without swapping its arguments, which contradicts
Postgres, and they bind under their own names, so they never reach this
path.

Empty needles and needles containing NULL fall back to DuckDB rather than
changing results. DuckDB ignores NULL elements and short-circuits an empty
`list_has_all` to true, where DataFusion compares NULLs by value and
combines the row null mask.

Only the containment branches emit the LIST tag; the general literal
encoder ignores it. Wiring it in would make `tags = ['a','b']` pushable as
a DataFusion list equality, where Arrow's nested comparison kernels are
patchy and the NULL semantics may not match. It falls back today, which is
correct. Admitting LIST to the type gate also pushes down IS NULL and
IS NOT NULL on list columns, which matches Arrow semantics.

Covers plain scans and lance_vector_search, which already had the
pushdown_complex_filter hook.
…arch

Both functions registered `filter_pushdown` and `pushdown_expression` but
never `pushdown_complex_filter`. Simple comparisons arrived as TableFilters
and pushed fine; the optimizer dropped anything DuckDB models as an
expression. `prefilter := true` did not error in that case, it just quietly
did nothing, so DuckDB always post-checked a containment predicate after
the search had already run over the whole dataset.

`LancePushdownComplexFilter` is typed against `LanceKnnBindData`, so this
extracts its body into `CollectLancePushedFilterIRParts`, gives the two
search functions a hook over the `LanceSearchBindData` they already share,
and adds the matching `lance_pushed_filter_ir_parts` assembly in
`LanceSearchInitGlobal`.

This is wider than containment: the same missing hook dropped
`starts_with`, `LIKE` and `regexp_matches`, which now prefilter too,
matching what `lance_vector_search` and plain scans have always done. It is
a behaviour change for anyone who was relying on the old post-filtering by
accident.
`lance_vector_search` and `lance_fts` rejected `prefilter := true` on a
namespace-backed table unless the caller also passed an explicit
`filter := '<string>'` argument. The check ran during bind, before the
optimizer had offered `pushdown_complex_filter` anything, so a `WHERE`
clause could not satisfy it by construction, and the only way to prefilter
was to interpolate values into a filter string.

Three things stood between a pushed predicate and the namespace request.
Both complex-filter hooks returned early for `namespace_backed`, so they
collected nothing. Both `InitGlobal` paths returned before assembling the
filter IR. And the two FFI entry points had no way to carry it.

The namespace scan path already solved this:
`lance_create_namespace_scan_stream_ir` takes the IR, decodes it, and
unparses the expression into `QueryTableRequest.filter`. The search entry
points now take the same two arguments and share that machinery via
`apply_filter_expr`, which also replaces the equivalent inline block in
`build_namespace_scan_request`. When a query supplies both a pushed
predicate and an explicit `filter` argument, the two combine with AND
rather than one replacing the other.

The init-time check the non-namespace path already uses replaces the
bind-time guard: if `prefilter` is on and a prefilterable column could not
be pushed, it throws there instead. `lance_hybrid_search` never carried the
guard and keeps working; it now takes the same route as the other two.

Two tests in `search_functions.test` asserted the old error as the
contract. They now assert that the `WHERE` clause prefilters with no
explicit filter.
WHERE predicates now push down from all three search functions for both
dataset paths and attached namespace tables, so the namespace bullets no
longer match the code: pushed predicates translate to a query_table
filter and combine with an explicit `filter` using AND, and
prefilter=true no longer requires the explicit parameter. Adds a filter
pushdown notes section covering containment predicates, the LABEL_LIST
index, legacy `l`-named list children, and the VARCHAR IN limitation.
@RagingKore
RagingKore marked this pull request as ready for review August 5, 2026 12:29
@RagingKore RagingKore changed the title feat(pushdown): push array_has_any/array_has_all into Lance feat(pushdown): push array_has_any/array_has_all Aug 5, 2026
@RagingKore

Copy link
Copy Markdown
Author

cc @Xuanwo — this removes the bind-time guard from #217 by pointing the search path at the filter_expr_to_sql converter you added in #232; a pushed predicate and an explicit filter now combine with AND.

@Xuanwo
Xuanwo requested review from lance-community and removed request for lance-community August 8, 2026 11:59
@lance-gatekeeper
lance-gatekeeper Bot removed the request for review from lance-community August 8, 2026 13:01

@lance-gatekeeper lance-gatekeeper 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.

Gate recommendation: request changes.

Search filtering must preserve one contract across path- and namespace-backed execution: required prefilters must be complete before top-k, while optional pushdown must fall back to DuckDB without failing the query. This revision violates both sides of that contract.

A viable revision would make complex-filter collection report completeness and use one stream-creation policy across dataset and namespace searches: reject unencodable predicates when prefilter=true, and retry without generated IR when prefilter=false while preserving explicit user filters.

Comment thread src/lance_search.cpp
string filter_ir;
if (!TryBuildLanceExprFilterIR(get, scan_bind.names, scan_bind.types, true,
*expr, filter_ir)) {
if (!TryBuildLanceExprFilterIR(get, names, types, true, *expr, filter_ir)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Failure to encode this predicate is silently ignored. With prefilter=true, top-k is consequently computed before the missing predicate and DuckDB only filters the truncated result, producing incorrect output. Return completeness from this collector and reject required prefiltering whenever any predicate cannot be encoded; silent skipping is valid only for prefilter=false.

Executed reproducer
COPY (
  SELECT * FROM (VALUES
    (1::BIGINT, [0.0,0.0]::FLOAT[2], NULL::VARCHAR[]),
    (2::BIGINT, [1.0,0.0]::FLOAT[2], ['x']::VARCHAR[])
  ) t(id,vec,tags)
) TO 'test/.tmp/pr237_empty_needle_prefilter.lance'
  (FORMAT lance, MODE 'overwrite');

SELECT id
FROM lance_vector_search(
  'test/.tmp/pr237_empty_needle_prefilter.lance',
  'vec', [0.0,0.0]::FLOAT[2],
  k := 1, prefilter := true, use_index := false
)
WHERE array_has_all(tags, []::VARCHAR[]);

Expected 2; observed zero rows.

Comment thread src/lance_search.cpp Outdated
@Xuanwo Xuanwo added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 10, 2026
A predicate that failed to encode into filter IR was silently dropped
from the pushed prefilter. With prefilter=true, Lance then ran top-k
before the missing predicate and DuckDB post-filtered the truncated
result, losing rows that a complete prefilter would have returned.

The collector now reports completeness. When prefilter=true and any
predicate cannot be encoded, the query fails with a clear error naming
the predicate. Bound parameters stay exempt: PREPARE plans with the
parameter unbound and every EXECUTE re-plans with it folded to a
pushable constant. Volatile predicates are rejected; they never fold.

Silent skipping remains the documented semantics for prefilter=false.
…d IR

Namespace-backed stream creation threw as soon as the backend rejected
the generated filter IR, while the dataset-backed path retried without
it. A predicate that encodes on the DuckDB side but that Lance cannot
parse (for example a regexp option flag) failed the whole query on
namespace tables and succeeded on path-backed ones.

One stream-creation policy now serves all four sites through a shared
helper: with prefilter=false a failed open retries without the pushed
IR, clears the pushed-down state, counts the fallback, and DuckDB
re-applies the predicate after top-k. The explicit namespace filter
survives the retry because it travels in the query config, not in the
IR. With prefilter=true the IR is required, so the failure surfaces.
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 11, 2026

@lance-gatekeeper lance-gatekeeper 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.

Gate recommendation: request changes.

The two previously reported filtering defects are resolved: required filters are now checked for completeness, and optional namespace pushdown retries correctly.

The new rejection path still needs a safe lifecycle boundary. Path-backed searches open a Lance session before the complex-filter callback throws; at process teardown that session closes after Tokio's context is gone, causing a panic and leaving DuckDB hung. Reject before acquiring that session, or make session cleanup safe after runtime teardown.

Comment thread src/lance_search.cpp
@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 11, 2026
A query rejected during optimization leaves its bind-time session to be
closed by a C++ static destructor at process exit, after the thread's
TLS destructors have run. RUNTIME is a static and never drops, so the
initialized_runtime() guard passed and block_on entered tokio's context
thread-local, which panics after destruction and wedges shutdown.

Probe Handle::try_current() first and skip the cache clear when the
error reports the thread-local as destroyed. The caches die with the
process, so skipping is safe. Verified with the gate reproducer on a
debug build: the rejection error now exits promptly instead of hanging.
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 11, 2026

@lance-gatekeeper lance-gatekeeper 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.

Gate recommendation: approve.

The revision now preserves the search-filter contract across path- and namespace-backed execution: required predicates are complete or rejected, optional pushdown falls back without losing explicit filters, and rejected path-backed searches tear down cleanly.

The teardown guard is narrowly limited to the Tokio destroyed-thread-local state; normal session close still clears caches through the initialized runtime.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. and removed K-approved Latest Gatekeeper recommendation permits acceptance. labels Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

K-approved Latest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants