feat(index): covering columns for IVF vector indexes - #8811
vivek-bharathan wants to merge 3 commits into
Conversation
|
Important This PR touches the Lance format specification. Substantive changes to the format specification — the If this is a meaningful format change:
|
bedba12 to
bfac834
Compare
bfac834 to
f64966f
Compare
f64966f to
82c2044
Compare
de5fbec to
67fcc1c
Compare
67fcc1c to
4add6f5
Compare
There was a problem hiding this comment.
The revised test now exercises covered take elision without conflating that contract with HNSW’s default search beam. Production behavior is unchanged, and the covering correctness and lifecycle assessment remains supported.
Covered searches can still be materially slower than a plain index when results stream per partition—HNSW, query_parallelism > 1, or a pending deferred-remap/fragment-reuse state—because payload reads scale with probed or contributing partitions instead of final k. For latency-sensitive deployments, prefer a global-top-k IVF path and avoid or finish deferred remaps, or let planning fall back to the base-table take when survivor positions are unavailable.
4add6f5 to
4e867ff
Compare
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
This revision makes covering_fields a serving guarantee even though the accepted format contract defines it only as a declaration. The safe dependency order is to land #8856 first, then validate each selected segment’s physical field-ID bindings and retain the base-table take for anything not proven present.
| let mut fields = KNN_INDEX_SCHEMA.fields().to_vec(); | ||
| let included_ids = indices | ||
| .first() | ||
| .map(|idx| idx.covering_fields.clone()) |
There was a problem hiding this comment.
covering_fields cannot define the emitted schema until the selected segment’s physical capability is verified. The current format contract explicitly makes declaration-without-payload legal and requires a base-table fallback. Here the declaration widens ANNIvfSubIndexExec, so Scanner::take skips that fallback before storage is checked.
Reproducer
I added this focused regression in an isolated worktree:
#[tokio::test]
async fn test_declared_covering_without_storage_falls_back_to_take() {
let uri = TempStrDir::default();
let mut dataset = covering::write_vector_payload_dataset(&uri).await;
covering::create_ivf_pq_index(&mut dataset, "vec").await;
covering::declare_covering(&mut dataset, "vec", "payload").await;
let query = generate_random_array(covering::DIMENSION as usize);
let mut scan = dataset.scan();
scan.nearest("vec", &query, 10).unwrap();
scan.project(&["payload"]).unwrap();
assert!(scan.try_into_batch().await.is_ok());
}Run with:
CARGO_TARGET_DIR=/home/agent/tmp/pr8811-target-impl cargo test -p lance --lib dataset::tests::dataset_index::test_declared_covering_without_storage_falls_back_to_take --locked -- --exact --nocapture
It fails in 0.37s with PhysicalExpr Column references column 'payload' at index 2 ... input schema only has 2 columns: ["_distance", "_rowid"].
#8856, this PR’s stated prerequisite, defines the missing field-ID binding but is still open and absent from this head. Land/rebase it first, persist and validate those bindings for every selected segment, derive effective coverage only from columns every segment proves, and leave unverifiable fields to the base-table take.
8e5b530 to
b125075
Compare
An IVF_PQ index can store the values of chosen extra columns next to its compressed vectors, and a search returns them directly from the index. A query whose projection those columns satisfy no longer reads the base table at all. Each storage format names its own internal columns, so covering detection is a per-storage filter rather than a per-type special case. Nested fields, blob columns, duplicates, reserved storage names and non-IVF_PQ index types are rejected at creation. `fields` is built as `[keyed_id] ++ covering_fields` wherever an index is created, so every covered index satisfies the suffix rule `IndexMetadata::validate_covering_fields` enforces at commit, and segments of one logical index are rejected if they disagree on `covering_fields` -- the read path derives its output schema from the first segment, so a disagreement yields a plan no segment can satisfy. BREAKING CHANGE: `VectorIndexParams` gains a public `covering_columns: Vec<String>` field. The struct is not `#[non_exhaustive]`, so code constructing it with an exhaustive struct literal needs one added line.
Covering columns worked only on IVF_PQ. Each storage format now declares which of its own columns are internal, so anything else is treated as covered payload, and the creation-time restriction to IVF_PQ is gone. IVF_SQ, RQ, FLAT and the HNSW variants each get end-to-end coverage.
A covered index now survives the operations that change its data: schema evolution, overlays, remap, compaction, and concurrent commits. Guards reject the alterations that would desync covering data -- casting an indexed key column, and any rename, cast or nullability change of a covering column -- because the read path resolves covering columns from the live schema while index storage still emits the old name and type. A partial `merge_insert` that updates a covered column is rewritten as a row-move on the indexed-scan path, so only the rows it touched leave the covering index instead of the whole fragment. The move is an optimisation and never fails the operation: stable row ids, sources carrying inserts, legacy v1 blob columns and partial struct subschemas each fall back to the in-place path, which is correct for all of them. Four public surfaces reported a covered index wrongly -- one answering "no index on this column" for an indexed column, another returning an unrelated column's centroids. All are the same mistake: `fields` answers "what invalidates this index", not "what it can serve". The keyed prefix answers the second.
b125075 to
7d0ac19
Compare
Lets an IVF vector index store the values of chosen extra columns alongside its quantization
codes, so a search returns them directly. A query whose projection those columns satisfy skips
the base-table take.
Covers every IVF index type (PQ, SQ, RQ, FLAT and the HNSW variants) and the index lifecycle:
schema evolution, overlays, remap, compaction,
optimize_indices,merge_insertand concurrentcommits. Builds on #8535 and #8856.