Skip to content

fix(fts): support list columns in post-filter path - #8185

Open
Ecthlion wants to merge 3 commits into
lance-format:mainfrom
Ecthlion:codex/fts-list-post-filter
Open

fix(fts): support list columns in post-filter path#8185
Ecthlion wants to merge 3 commits into
lance-format:mainfrom
Ecthlion:codex/fts-list-post-filter

Conversation

@Ecthlion

@Ecthlion Ecthlion commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add List and LargeList string support to FlatMatchFilterExec
  • preserve indexed row-document semantics by joining each list row before tokenization
  • honor both Operator::Or and Operator::And across the whole row
  • reject fuzzy post-filter match queries explicitly instead of silently applying exact-match semantics
  • add indexed/post-filter parity coverage for the default and raw tokenizers

Problem

FTS indexing and direct/flat search accept list<string> and large_list<string> columns, but FlatMatchFilterExec only dispatched Utf8 and LargeUtf8. As a result, the same match query failed at runtime when used as a post-filter, such as after vector search with prefilter(false).

The initial list dispatch also exposed two semantic differences from the indexed path: treating each element as an independent document changes non-default tokenizer behavior, and checking for any query token degrades Operator::And to OR.

Implementation

The post-filter now dispatches List and LargeList arrays to a shared offset-generic helper. Each parent list is materialized with the same space separator used by IndexWorker, then evaluated as one document with the tokenizer loaded from the FTS index. The match predicate groups query tokens by token position so AND queries require every query position while still allowing tokenizer alternatives at the same position.

Null lists, empty lists, and lists containing only null elements remain non-matches. Unsupported column/list item types return contextual errors. Fuzzy match queries are not currently reproducible in this post-filter because they require index-dictionary expansion, so they now return a clear NotSupported error.

The integration test covers both outer list offset types and verifies:

  • indexed FTS and vector post-filter FTS return the same rows
  • OR and cross-element AND semantics agree with the indexed path
  • the raw tokenizer preserves the same row-document boundary
  • an IVF-Flat vector index is used and FlatMatchFilterExec wraps ANNSubIndex
  • null lists, empty lists, and null list elements follow the expected semantics
  • unsupported fuzzy post-filter queries fail clearly

Fixes #7662.

Validation

  • cargo fmt --all -- --check
  • cargo clippy --all --tests --benches -- -D warnings
  • cargo test -p lance --lib test_fts_list_postfilter_vector_search -- --nocapture
  • cargo test -p lance --lib test_flat_match_filter_find_matches_large_utf8 -- --nocapture

Thanks to @BubbleCal for the clear issue report and suggested integration coverage.

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.31579% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance/src/io/exec/fts.rs 76.31% 9 Missing ⚠️

📢 Thoughts on this report? Let us know!

@westonpace
westonpace requested a review from BubbleCal August 3, 2026 12:59
@Ecthlion
Ecthlion marked this pull request as ready for review August 3, 2026 13:17

@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: 75566f0bc8

ℹ️ 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".

Comment thread rust/lance/src/io/exec/fts.rs Outdated
Comment on lines +1251 to +1253
iter_str_array(elements.as_ref())
.flatten()
.any(|element| has_query_token(element, tokenizer, query_tokens))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply the match operator to the whole list document

When a list post-filter receives a multi-term MatchQuery with Operator::And, this predicate accepts the row as soon as any element contains either query token; for example, docs = ["alpha"] matches the query alpha beta. The indexed path applies query.operator and requires both terms, so vector searches using prefilter(false) can now return false positives for list columns. Accumulate matches across the entire list row and evaluate them according to the query's operator instead of using any over individual token hits.

Useful? React with 👍 / 👎.

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

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 list post-filter needs to preserve indexed row-document semantics for the loaded tokenizer and supported MatchQuery options. The current predicate is only correct for exact single-term/default-OR cases, so otherwise equivalent indexed and vector-post-filter queries can return different rows.

A viable revision should evaluate each parent list as the same document used by indexing, honor AND across the whole row, and add parity coverage for a non-default tokenizer. Unsupported query modes should fail clearly instead of silently changing results.

Comment thread rust/lance/src/io/exec/fts.rs Outdated
let is_match = elements.is_some_and(|elements| {
iter_str_array(elements.as_ref())
.flatten()
.any(|element| has_query_token(element, tokenizer, query_tokens))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This evaluates each list element as an independent document and accepts the row on the first matching query token. That violates two supported contracts: Operator::And is degraded to OR, and non-default tokenizers do not see the space-joined row document used by indexing. This causes false positives and can cause false negatives. Build the same row-document tokenization boundary as the index and honor the query operator, or explicitly reject modes the post-filter cannot preserve.

Reproducer

I extended test_fts_list_postfilter_vector_search and ran cargo test -p lance --lib test_fts_list_postfilter_vector_search -- --nocapture against this head.

  • Querying target missing with Operator::And expected 0 rows but returned ids [0, 3] for both List and LargeList.
  • After rebuilding the FTS index with InvertedIndexParams::default().base_tokenizer("raw".to_owned()), indexed target search returned 1 row while the vector post-filter returned ids [0, 3] (2 rows), again for both outer list types.

The unmodified single-term/default-tokenizer test still passes.

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

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 post-filter still needs to construct exactly the same row document as the index writer. Empty-string list elements currently violate that invariant under tokenizers that preserve whitespace, so indexed and vector-post-filter searches can return different rows.

A viable revision should share the canonical list materialization logic (or exactly reproduce its separator rule) and add indexed/post-filter parity coverage with a leading empty element and the raw tokenizer.

let is_match = elements.is_some_and(|elements| {
// IndexWorker uses the same separator to turn one list row into one document.
// Tokenizing elements independently changes semantics for tokenizers such as raw.
let document = iter_str_array(elements.as_ref()).flatten().join(" ");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This materialization differs from the index writer for a leading empty element: ["", "target"] becomes " target" here, while materialize_string_list produces "target". With the raw tokenizer, a target query therefore misses in the post-filter even though indexed search finds the row. Reuse the canonical materializer or exactly mirror its separator rule.

Reproducer

I added a leading empty string before the existing null and target elements in test_fts_list_postfilter_vector_search, then ran:

cargo test -p lance --lib test_fts_list_postfilter_vector_search -- --nocapture

The indexed assertion returned [3], but the post-filter returned [] instead of [3] for both List and LargeList. The test failed in both cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support list<string> FTS match queries in post-filter/refine paths

1 participant