lance-format / lance-format/lance
Expose precomputed `RowAddrMask` as scanner prefilter input from Python API
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 7.1k
- Forks
- 852
- Avg merge
- 3d 18h
- Merged PRs (30d)
- 272
Description
Summary
Lance internally supports precomputed RowAddrMask as a first-class prefilter input via DatasetPreFilter and the existing SelectionVectorToPrefilter FilterLoader. The Arrow-binary serialization format is already defined for inter-process mask passing. However, there is no Python API to provide such a mask directly to a scanner — all prefilter inputs must go through expression evaluation (SQL string or pyarrow / Substrait Expression).
For workloads where row membership is precomputed externally — tag/labeling systems, business-logic-defined cohorts, materialized bitmaps maintained by separate services — this forces a round trip through expression construction and evaluation that becomes a bottleneck at scale.
This issue requests adding a parameter to LanceDataset.scanner (and to_table, ScannerBuilder) that accepts a serialized RowAddrMask and feeds it directly into the existing prefilter machinery, bypassing expression evaluation.
Use case
We operate a Lance dataset at ~3B rows with an IVF vector index. A separate tag service is the source of truth for "datasets" — named subsets of rows identified by UUID — which mutate frequently and are managed independently of the main table. Encoding dataset membership as a column on the main table is not viable because:
- Frequent membership changes would trigger fragment rewrites and invalidate scalar indexes.
- The tag service is owned by a different system; coupling its updates to main-table writes is undesirable.
- Membership is often computed dynamically (rule-based, user-curated) and not always representable as a stable scalar value.
A typical query is "vector search top-K, restricted to rows in the union of datasets {A, B, C}, optionally with additional scalar predicates like city = 'NYC' AND ips_value < 100." The dataset union ranges from a few hundred thousand rows to ~80M rows out of 3B.
The tag service can efficiently produce a serialized RowAddrMask (or its constituent RowAddrTreeMap bytes) describing allowed row IDs. But pylance has no way to consume one.
Current workarounds and why they fall short
1. SQL _rowid IN (...) — Falls over at ~100k–1M values due to SQL parser cost. (Issue #4115 proposes treating _rowid and _rowaddr as having an implicit scalar index, which would help but doesn't address the broader case of providing arbitrary bitmaps without going through expression evaluation.)
2. Substrait singular_or_list — Pushes the ceiling to a few million values, but at 50M+ values:
- Substrait deserialization: multiple seconds, several GB of memory.
- Building DataFusion's internal hash set from 50M literals: more seconds and memory.
- Scalar index probe of 50M lookups: 10–30s even with an index on UUID.
- Total: 15–40s and 10+ GB of memory before vector search begins.
3. dataset.take(row_ids) + brute-force scoring — Bypasses the IVF index entirely. For 50M candidates, materialization plus scoring takes minutes.
4. Postfilter with overshoot — Workable for moderately selective masks; recall becomes probabilistic and the overshoot factor is hard to tune across varying mask sizes.
5. DuckDB hash semi-join — Excellent for filtering rows but cannot use the IVF vector index; still degenerates to brute force for the vector search step.
6. Custom Rust + PyO3 binding — What we're currently planning. It calls Dataset::open, deserializes a mask into RowAddrMask, and feeds it through DatasetPreFilter to VectorIndex::search. Works, but:
- Depends on Lance internal Rust APIs that aren't covered by any stability contract.
- Maintenance cost across Lance version upgrades (e.g., the recent
RowIdMask→RowAddrMaskrename). - Every team facing this problem ends up writing roughly the same 60–250 lines.
Why this should be easy to expose
The internals already do almost all the work:
RowAddrMask(inrust/lance-core/src/utils/mask.rs) is the canonical filter representation, supporting both AllowList and BlockList semantics.RowAddrTreeMapis roaring-bitmap-backed and already serializes to a defined binary format (seemask.rslines 112–179) — a 2-elementBinaryArraycontaining optional BlockList bytes and optional AllowList bytes.SelectionVectorToPrefilter(inrust/lance/src/io/exec/utils.rs) already implementsFilterLoaderfor deserializing this exact format from a record batch stream. It's used for inter-process mask passing between execution nodes.DatasetPreFilter(inrust/lance/src/index/prefilter.rs) already accepts anyFilterLoaderand composes its result with deletion masks via the existing AND/OR truth table onRowAddrMask.
The ask is to expose this existing serialization format as a Python parameter and route it through the existing SelectionVectorToPrefilter path. No new internal machinery is required.
Proposed API
A new keyword argument on LanceDataset.scanner (and corresponding methods like to_table, plus ScannerBuilder for the builder pattern):
ds.to_table(
nearest={"column": "vector", "q": query_vec, "k": 10},
row_addr_mask=mask_bytes, # serialized RowAddrMask (existing binary format)
filter="city = 'NYC'", # optional, combined with row_addr_mask
prefilter=True,
)
Semantics:
row_addr_maskaccepts the existing serialized format already used internally (BinaryArraywith optional BlockList bytes at index 0 and optional AllowList bytes at index 1).- When provided, it is loaded via
SelectionVectorToPrefilterand combined with other filter sources (deletion vectors, expression-based filters) by the existingDatasetPreFilterlogic. - When
prefilter=True, it gates IVF traversal. Whenprefilter=False, it acts as a postfilter. - Supports both allow-list and block-list semantics, matching the existing internal type.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with rust/lance-core/src/utils/mask.rs to confirm the serialized RowAddrMask format, then trace SelectionVectorToPrefilter in rust/lance/src/io/exec/utils.rs and DatasetPreFilter in rust/lance/src/index/prefilter.rs. Follow the Python scanner, to_table, and ScannerBuilder entry points. Done means serialized masks are accepted, combined with existing filters, and honored for both prefilter and postfilter behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python, rust
- Domain
- api, data
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100