feat(index): accept a vector index a table is too small to train - #9134
feat(index): accept a vector index a table is too small to train#9134xuanyu-z wants to merge 4 commits into
Conversation
a1aebb0 to
a6f385c
Compare
f14f1c0 to
8606a22
Compare
Creating a vector index on a table below the quantizer's row floor failed, and an already-trained index whose table later shrank below it blocked `optimize_indices` outright, leaving drop-and-recreate as the only way out. Implements the behaviour specified in lance-format#4034 for vector indices, and carries over the empty-index contract lance-format#3940 set for scalar and FTS: - `build_empty_vector_index` writes a definition with no files instead of returning `not_supported_source`. Only PQ has a row floor: RQ trains from a pair of values and flat storage needs none, so those cover a small table rather than degrade. - `should_train_index` carries the floor, measured in vectors rather than rows. A column of nulls degrades instead of reaching the quantizer, and a multivector row is worth its list length, estimated by sampling, so a hundred rows of ten vectors train. A fragment subset is exempt: it is a caller-driven segment build that supplies its own fragments and often its own IVF model. - `supported_num_partitions` caps a requested partition count at a codebook's worth of vectors each, counted the same way. A partition's centroid is what search ranks to choose partitions, so one built from a handful of vectors prunes nothing while still paying the quantizer's precision loss. The reduction is logged. - `optimize_indices` trains a segment that carries only its definition from the parameters that definition holds, deciding that before the logical index is opened, since opening it needs a file such a segment does not have. One such segment makes the whole column retrain: the logical index is opened by name, so it would be reached whichever segments the caller asks for. - The merge accepts a rebuild that covers nothing only where the caller established beforehand that the column can no longer train and the segment wrote no files to match, so coverage lost for any other reason still fails. Statistics and vector-index detection tolerate a segment with no file. A nearest-neighbour query answers from the table, and `fast_search` finds nothing rather than falling back to a scan. Existing tests that assert the partition count they requested are given the vectors that count needs, so they keep asserting it exactly; tests whose subject is recall keep their fixtures and assert the count their data supports. A deferred index takes its partition count from the data it is materialized against, through the `target_partition_size` that `VectorIndexDetails` already carries. An absolute `num_partitions` is not preserved across the deferral.
|
Important Format specification voteThis PR modifies the Lance format specification, so it requires 3 binding +1 votes from PMC members (excluding the proposer) and a minimum 72-hour voting period, weekends excluded, before it can merge. Vote by approving this PR (+1) or requesting changes (−1, a veto). See the voting process. Status: ❌ Blocked — 0 of 3 required approvals
Updated automatically by the format-spec vote gate, which re-checks every 15 minutes — just voted? Re-check now (press Run workflow; leave the input blank to re-check every open format PR). A PMC member may apply the |
Reducing a partition count the data cannot train left several tests asserting numbers their fixtures no longer produce. Each is given the vectors its request needs, so the assertions stand as written rather than being lowered. - `test_ann_with_deletion` asserts a cached-entry count that is per partition, so four partitions need four codebooks' worth of vectors. Its two fragments, the delete that clears the first, and the ids expected afterwards all derive from one row count now, since they have to agree. - `test_ivf_centroids_exposed` asserts one centroid per requested partition. - `test_index_cache_size` and `test_index_cache_size_bytes` size their cache behaviour from 128 partitions. - `test_create_ivf_rq_index` expected creating an index on an emptied table to raise `NotImplementedError`. That is what this branch makes work, so it now asserts the index is created and covers nothing. The multivector test builder takes a per-row list length, which lets uneven and empty lists be built as easily as uniform ones: a row of ten vectors among ninety-nine empty ones counts as ten, and a column of empty lists counts as none. Both defer rather than reaching the quantizer.
8606a22 to
38f8674
Compare
The partition cap asked for a codebook's worth of vectors per partition, which is 256 only at the default 8 bits. IVF training fits each centroid on `sample_rate` vectors, and KMeans warns below `k * 256`, so the number belongs to the IVF stage: with a 4-bit codebook the cap asked for 16 vectors per partition and kept all 8 requested partitions on a column holding 300 vectors. An index that stores vectors uncompressed has no precision to lose against a thin partition, so KMeans needing a vector per centroid stays its only limit. A definition also could not come back at the count it asked for. Its parameters are reconstructed from the stored details, which record `target_partition_size` but not `num_partitions`, so an index created as IVF-8 on a table too small to train stayed IVF-1 however far the table grew. `lance.ivf.num_partitions` joins the IVF runtime hints that the same path already restores; an auto-sized build records none and keeps auto-sizing.
Compaction rewrites fragments and remaps every index whose coverage they intersect. An index that covers nothing has no file to open, so the test pins that compaction leaves it alone rather than reaching for one. The second test pins that a caller optimizing on a schedule keeps getting a no-op while the table stays too small to train, rather than an error on the second pass.
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The new revision adds focused coverage for repeated append-mode optimization and compaction while an index remains definition-only. Production behavior is unchanged, and both lifecycle scenarios preserve the previously verified empty-coverage contract.
|
this one has no format change, wrong label |
The problem
Creating a vector index on a table with too few rows fails with an error, and so
does maintaining one on a table that later shrinks.
create_index(train=False)reaches a
todo!()for vector indices, andbuild_empty_vector_indexreturnsnot_supported_source, so deferred training never worked for them even though itworks for scalar and FTS.
The shrink half is the worse one. An IVF-PQ index whose table falls below the
PQ minimum makes
optimize_indices()fail outright, so index maintenance isstuck until someone drops and recreates the index.
What this does
Implements #4034 for vector indices, and keeps the empty-index contract #3940
set for scalar and FTS:
data file, covering no rows. The next
optimize_indices()with enough datatrains it for real. This applies on creation and when a table shrinks, so
maintenance is never blocked.
2^num_bitsingeneral). RQ trains from two values and flat storage needs none, so those
build normally on a small table.
multivector row holds a list, so it counts for its whole list — 100 rows of 10
vectors give 1,000. The count stops as soon as it reaches the number needed.
min(requested, vectors / sample_rate), and log it. A partition's centroid iswhat a search compares against to choose partitions, so one built from a
handful of vectors prunes nothing and still costs compression accuracy. The
per-partition number is the IVF sample rate — the vectors IVF training fits one
centroid on, 256 by default, and where KMeans starts warning. It is not the
codebook size, which happens to be 256 as well at 8 bits.
A query skips an index that covers nothing and reads the table instead;
fast_searchreturns nothing, since it is limited to what the index covers.Statistics report zero rows indexed and everything still to do.
Main pieces
should_train_index(create.rs) — the single decision: train now, or recordthe settings and train later. A build handed an explicit list of fragments is
exempt, because the pieces of one index must agree on their partition count.
count_trainable_vectors(vector.rs) — one definition of "how many vectorsare there", used by that decision and by the partition cap.
supported_num_partitions(vector.rs) — the cap above.optimize_indices(append.rs) — trains an index that is still only settings,reading them from the index metadata. It decides that before opening the index
file, since there is no file yet.
established beforehand that the column cannot train and the rebuild wrote no
file, so rows lost for any other reason still fail loudly.
Tests
Lifecycle, so the index survives what happens to the data:
reopening
Top-level indices part 2: empty vector indices #4034 (comment)
Thresholds:
Partition counts:
follow the codebook size
rate
asked for
Queries and statistics: a normal search,
fast_search, andindex_statisticsagainst an index that covers nothing.
Maintenance over an index that covers nothing:
intersects an empty coverage bitmap
small, so a caller optimizing on a schedule keeps getting the commit it
expects
The partition count and the runtime hints round-trip through
VectorIndexDetailsfor all eight IVF combinations, and an auto-sized build ischecked to record no count.
Each new test was checked by breaking the code under it and confirming it fails.
cargo nextest run -p lance --lib— 3450 pass, 3 skipped, none failing.cargo fmt --all --check,cargo clippy -p lance --lib --tests -D warningsandci/check_proto_comments.pyare clean.For review
num_partitionsbecomes a maximum, not an exact request. On a small tableyou get fewer partitions than you asked for, logged rather than returned as an
error, and this applies to
create_indexas well asoptimize_indices().Top-level indices part 2: empty vector indices #4034 asks for it; worth confirming you want it on creation too. The code is
one match arm in
supported_num_partitions, so narrowing it to "only reducewhen the build would otherwise fail" is a small change.
Fifteen existing tests asserted the count they requested on fixtures too small
to support it. Rather than lower those assertions, each test about index
metadata was given the vectors its request needs, so it still asserts the exact
count. Tests that measure search quality kept their original data and assert
the count it supports — changing data under a recall threshold is how those
become flaky.
The two 256s are kept apart. The training floor is the codebook size,
2^num_bits, matching the error PQ raises. The per-partition number for thecap is the IVF sample rate, which is configurable and independent of the
quantizer: a build declaring 64 samples per centroid supports four partitions
on 300 vectors where the default supports one.
The cap applies only where a codebook makes thin partitions a bad trade. An
index that stores vectors uncompressed has no precision to lose, so two
partitions over 400 vectors still give some pruning for free, and KMeans
needing a vector per centroid stays the only limit there.
The requested
num_partitionssurvives a deferral. Without it an indexcreated as IVF-8 on a small table stays IVF-1 however far the table grows,
which contradicts the part of Top-level indices part 2: empty vector indices #4034 that returns the requested count once the
data supports it. An auto-sized build records nothing and keeps auto-sizing;
the count recorded is the one requested, with the cap re-derived from the data
present at rebuild time.
This currently rides in
runtime_hintsunderlance.ivf.num_partitions,which is the wrong home: that field is specified as build preferences that do
not affect index structure, and a partition count is exactly index structure.
feat(format): record the requested IVF partition count on a vector index #9151 proposes a typed
target_num_partitionsonVectorIndexDetailsinstead, and this PR moves onto it once that vote passes.
Finishes the direction of #7211 (@burlacio), whose creation, statistics and
detection changes this supersedes; happy to rebase onto it instead if preferred.
Fixes: #4034