Repository navigation
fix(index): reject unattainable partition counts in weighted k-means - #9346
Conversation
The streaming coreset trainer padded a short cluster list by duplicating the heaviest cluster until it reached the requested partition count. argmin ties always resolve to the first copy, so every duplicate stayed empty for the life of the index — dead partitions inflating num_partitions and probe lists. Return the same descriptive error the flat hierarchical trainer already uses, telling the caller to reduce num_partitions. Assisted-by: GLM-5.3
|
Reworked the loop rather than the wording — you were pointing at the same hazard my own review round flagged and I had left as a note. The loop abandoned itself when the cluster at the top of the heap was a singleton. Priority there is loss, not size ( One thing I could not do, so it is not claimed anywhere: there is no test pinning that guard. Heap priority comes from the initial clustering's |
|
Pushed the regression guard, and one honest correction about the case you named. I added the 257-of-258 shape and then mutation-tested it: reverting the singleton branch from The test is in anyway, because it does guard something real: that the shortfall check cannot over-reject an attainable count. Changing On CI: |
|
Added your fixture, and I was wrong about why my own attempts failed — worth correcting explicitly since I put it in the PR body. I claimed the supplied
I also dropped the earlier over-rejection guard I had added in its place; this fixture covers the repair directly, and that one only pinned that the shortfall check does not fire early. |
|
We've also encountered this issue with streaming IVF training, resulting in many duplicate centroids and empty partitions. Thanks for the fix—looking forward to seeing it merged! |
…-kmeans-pad # Conflicts: # rust/lance/src/index/vector/ivf.rs
…-kmeans-pad # Conflicts: # rust/lance/src/index/vector/ivf.rs
|
@BubbleCal could you take a look, since the streaming k-means trainer is from your #6913? When the weighted hierarchical trainer forms fewer clusters than |
## Summary Fix two independent recall failures seen on main: - In streaming coreset training, a singleton can retain a large compression loss and reach the top of the split heap while other clusters are still splittable. Finalize that singleton and continue; stop only when the heap's remaining clusters are finalized. This prevents premature termination followed by duplicate-centroid padding. Extend the existing regression test with lossy summaries and exhausted splits. - Give `test_legacy_ivf_pq_cosine_multivec_smoke` eight PQ sub-vectors instead of four, reducing quantization error with its small training sample. Keep its data, query, training sample rate, iteration count, and `recall >= 0.5` assertion unchanged. Failing CI: [Float64 streaming coreset recall](https://github.com/lance-format/lance/actions/runs/39917533988/job/120294401363), [legacy multivector PQ recall](https://github.com/lance-format/lance/actions/runs/39919434480/job/120301400723). Related: #9346 proposes fallback bisection and rejecting unattainable partition counts. This PR fixes singleton termination in the existing split loop and preserves the current exhausted-split behavior. ## Validation - The added lossy/exhausted regression cases fail before the fix: 16 distinct centroids instead of 32. All three cases pass afterward. - 15 weighted tests, 28 streaming tests, and the final legacy multivector smoke test pass. - `cargo fmt --all --check` - `cargo clippy --locked --all --tests --benches -- -D warnings` - `git diff --check`; commit hooks pass. Local recall checks were measured on fix commit `271c19a82f6b8a7c2cd9574aca3a9bb8198f664c` against `77b7fd4250e248cce207c54389b7f7065f5252d9`, before syncing main, on Apple M3 Max (arm64), Rust 1.98.1, default features and test profile. Each version uses the same seeded fixture; training runs use normal OS randomness and are independent. | Scenario / metric | Baseline | Fix commit `271c19a82` | | --- | ---: | ---: | | Streaming L2: recall@5 below 0.5 | 2 / 500 runs | 0 / 500 runs | | Streaming cosine: recall@5 below 0.5 | 2 / 500 runs | 0 / 500 runs | | Streaming L2: distinct centroids | 107–176 | 257 | | Streaming cosine: distinct centroids | 80–140 | 257 | | Legacy multivector PQ: mean recall@100 | 0.57940 | 0.76845 | | Legacy multivector PQ: minimum recall@100 | 0.50 | 0.67 | Streaming: 600 Float64 vectors, four dimensions, two fragments, 257 partitions, sample rates 2/1, one training iteration, minimum 16 probes, query row 321. All-partition recall is 1.0 in every run before and after. Multivector PQ: 200 runs per version, 512 rows with three 32-dimensional vectors each, one fully probed partition, query row 0, 8-bit codebooks, sample rate 1, two iterations. The Linux CI failure was 0.49; the local baseline did not fall below 0.5. These are fixture-specific correctness observations. CI on `271c19a82` passed Rust tests on all platforms, Clippy, Python and JNI checks. Its only failed check was the main-branch dependency advisory RUSTSEC-2026-0385. Syncing main incorporates the already-merged handling in #10269. The merged tree was rechecked with formatting, full workspace Clippy, and all 44 focused tests above; new CI results are pending.
There was a problem hiding this comment.
The exhausted-split expectation is corrected without changing production code. The native f32/f64 fallback still reaches attainable partition counts without duplicate padding and rejects unattainable counts.
One existing Dot limitation remains: positive collinear data can collapse back into duplicate centroids during subsequent refinement, leaving most partitions empty. This also occurs with the former padding path, so no additional change is requested here. For such workloads, verify final partition occupancy; bisection is an initialization fallback, not a guarantee of non-empty final partitions.
|
Thank you @Xuanwo |
Closes #9345
train_weighted_hierarchical_kmeans, the trainer behind the streaming coreset path for both f32 and f64 centroids, padded the cluster list with clones of the heaviest cluster whenever it formed fewer thantarget_k. A clone is bit-identical to the cluster it came from, and assignment resolves a tie to the incumbent, so a clone starts with no rows and gains some in the refinement passes that follow only if the original drifts away from it. The build succeeded with partitions that were empty or nearly so.It now returns
invalid_inputnaming the coreset row count and, when the coreset is not smaller thannum_partitions, how many clusters were formed. The flat hierarchical trainer already rejects the same request (lance-index/src/vector/kmeans.rs:1561). The two are not the same error kind, though: the flat path raisesArrowError::InvalidArgumentError, which surfaces in Python asRuntimeError, while this one isError::InvalidInputand surfaces asValueError. Aligning them is a separate change. A coreset that has fewer rows thannum_partitionsgets its own message suggesting a largersample_rate, since more diverse data would not help there.A k-means split can also leave every row on one side when the rows differ. Under Dot this happens often enough that main's
test_build_ivf_model_streaming_dot_coreset_training, 257 partitions over 1,024 distinct rows, came up 9 to 24 clusters short in 4 of 25 local runs with this rejection and no bisection; main padded those shortfalls with clones. Before rejecting, the trainer now bisects the remaining multi-row clusters by L2 around the row farthest from its centroid and the row farthest from that one, which always yields two non-empty halves when the rows differ. The trainer's output therefore falls short only when the coreset holds fewer distinct rows thannum_partitions, and the bisection runs only where main would have padded.That guarantee covers the trainer's output. The refinement passes after it run under the build's metric, and under Dot they can still pull distinct centroids for collinear positive data back onto the same point, as they could with the clones. That is left for a follow-up.
clusters.truncate(target_k)after the check would be a no-op once the shortfall is an error, because the loop stops attarget_kand every step it takes is bounded by what is left. It is adebug_assert_eq!on that invariant instead.How was this patch tested?
test_weighted_hierarchical_rejects_padding_with_duplicatesis an rstest pair over the two shapes that reach the shortfall:fewer_rows_than_partitions, 6 rows againsttarget_k16, expects the coreset-size message, andenough_rows_all_identical, 32 identical rows against 16 partitions, expects the one about rows that cannot be split. Both assert the coreset row count appears in the message. Restoring the padding fails both cases.test_weighted_hierarchical_bisects_clusters_kmeans_cannot_splittrains 4 partitions from 8 distinct positive 1-D rows under Dot, where every k-means split sends all rows to the largest centroid, and asserts the trainer returns 4 distinct centroids, with one case each for f32 and f64. It fails without the bisection.