Skip to content

Commit bc2d137

Browse files
authored
fix: train IVF indexes on fragment subsets (#7768)
## Summary - Resolve explicit vector fragment filters by fragment id in O(k), so fragment-scoped IVF training no longer scans the whole manifest to find selected fragments. - Treat an explicit vector fragment filter that covers every current dataset fragment as an unfiltered full build in `CreateIndexBuilder`. - Add a filtered vector build path for genuine subset fragment builds without precomputed IVF, covering standalone segmented index creation. - Preserve distributed builds with shared precomputed IVF/PQ/RQ state, and reject unsafe merge/optimize of independently trained vector segments that do not share the same model. - Harden distributed vector auxiliary merge validation for IVF centroids and quantizer metadata, including codebook/rotation payload checks. - Add regression coverage for all-fragment filters, subset training, empty precomputed-IVF segments, unsafe merge/optimize rejection, legacy filtered IVF_PQ rejection, and precomputed centroid partition mismatches. The important semantic distinction is that subset training/build is valid and now uses fragment-scoped work, but independently trained subset IVF segments are not merge-compatible unless they share the same precomputed vector model. Validated locally with `cargo fmt --all`, `cargo clippy --all --tests --benches -- -D warnings`, targeted `lance` vector index tests, and `cargo test -p lance-index index_merger`. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added support for building vector indexes over a selected subset of dataset fragments, including correct behavior for empty subsets and precomputed-IVF centroids. - Improved index-build routing to better respect fragment selection when training. - **Bug Fixes** - Strengthened cross-shard merge/optimize validation for shared vector models (metrics, centroids, quantizers, rotation details) with NaN-aware comparisons. - Rejected unsupported or incompatible metadata during merges (including legacy IVF_PQ filtered builds, transposed/packed PQ/RQ cases). - **Tests** - Expanded and added coverage for fragment-selection behavior and shared-model comparison and rejection scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 2887837 commit bc2d137

7 files changed

Lines changed: 1017 additions & 268 deletions

File tree

rust/lance-index/src/vector/distributed/index_merger.rs

Lines changed: 298 additions & 75 deletions
Large diffs are not rendered by default.

rust/lance/src/dataset.rs

Lines changed: 102 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ use roaring::RoaringBitmap;
6060
use rowids::get_row_id_index;
6161
use serde::{Deserialize, Serialize};
6262
use std::borrow::Cow;
63-
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
63+
use std::collections::{BTreeMap, HashMap, HashSet};
6464
use std::fmt::Debug;
6565
use std::num::NonZero;
6666
use std::ops::Range;
@@ -1716,26 +1716,7 @@ impl Dataset {
17161716
));
17171717
}
17181718

1719-
let selected_fragment_ids = fragment_ids.iter().copied().collect::<BTreeSet<_>>();
1720-
let selected_fragments = self
1721-
.get_fragments()
1722-
.into_iter()
1723-
.filter(|fragment| selected_fragment_ids.contains(&(fragment.id() as u32)))
1724-
.collect::<Vec<_>>();
1725-
1726-
if selected_fragments.len() != selected_fragment_ids.len() {
1727-
let present_fragment_ids = selected_fragments
1728-
.iter()
1729-
.map(|fragment| fragment.id() as u32)
1730-
.collect::<HashSet<_>>();
1731-
let missing_fragment_ids = selected_fragment_ids
1732-
.into_iter()
1733-
.filter(|fragment_id| !present_fragment_ids.contains(fragment_id))
1734-
.collect::<Vec<_>>();
1735-
return Err(Error::invalid_input(format!(
1736-
"Dataset::sample received fragment ids that are not part of the current dataset version: {missing_fragment_ids:?}",
1737-
)));
1738-
}
1719+
let selected_fragments = self.get_fragments_from_ids(fragment_ids)?;
17391720

17401721
let num_rows = stream::iter(selected_fragments.iter().cloned())
17411722
.map(|fragment| async move { fragment.count_rows(None).await })
@@ -2545,37 +2526,108 @@ impl Dataset {
25452526
&self.manifest.fragments
25462527
}
25472528

2548-
// Gets a filtered list of fragments from ids in O(N) time instead of using
2549-
// `get_fragment` which would require O(N^2) time.
2550-
pub fn get_frags_from_ordered_ids(&self, ordered_ids: &[u32]) -> Vec<Option<FileFragment>> {
2551-
let mut fragments = Vec::with_capacity(ordered_ids.len());
2552-
let mut id_iter = ordered_ids.iter();
2553-
let mut id = id_iter.next();
2554-
// This field is just used to assert the ids are in order
2555-
let mut last_id: i64 = -1;
2556-
for frag in self.manifest.fragments.iter() {
2557-
let mut the_id = if let Some(id) = id { *id } else { break };
2558-
// Assert the given ids are, in fact, in order
2559-
assert!(the_id as i64 > last_id);
2560-
// For any IDs we've passed we can assume that no fragment exists any longer
2561-
// with that ID.
2562-
while the_id < frag.id as u32 {
2563-
fragments.push(None);
2564-
last_id = the_id as i64;
2565-
id = id_iter.next();
2566-
the_id = if let Some(id) = id { *id } else { break };
2567-
}
2529+
pub(crate) fn normalize_fragment_ids(fragment_ids: &[u32]) -> Vec<u32> {
2530+
let mut ids = fragment_ids.to_vec();
2531+
ids.sort_unstable();
2532+
ids.dedup();
2533+
ids
2534+
}
25682535

2569-
if the_id == frag.id as u32 {
2570-
fragments.push(Some(FileFragment::new(
2571-
Arc::new(self.clone()),
2572-
frag.clone(),
2573-
)));
2574-
last_id = the_id as i64;
2575-
id = id_iter.next();
2576-
}
2536+
pub(crate) fn get_fragments_from_ids(&self, fragment_ids: &[u32]) -> Result<Vec<FileFragment>> {
2537+
let ordered_ids = Self::normalize_fragment_ids(fragment_ids);
2538+
let fragments = self.get_frags_from_ordered_ids(&ordered_ids);
2539+
if let Some(missing_id) = fragments
2540+
.iter()
2541+
.zip(ordered_ids.iter())
2542+
.find_map(|(fragment, fragment_id)| fragment.is_none().then_some(*fragment_id))
2543+
{
2544+
return Err(Error::invalid_input(format!(
2545+
"Unknown fragment id {missing_id} in fragment filter; not part of the current dataset version"
2546+
)));
25772547
}
2578-
fragments
2548+
2549+
Ok(fragments.into_iter().flatten().collect())
2550+
}
2551+
2552+
pub(crate) fn get_existing_fragments_from_ids(
2553+
&self,
2554+
fragment_ids: &[u32],
2555+
) -> Vec<FileFragment> {
2556+
let ordered_ids = Self::normalize_fragment_ids(fragment_ids);
2557+
self.get_frags_from_ordered_ids(&ordered_ids)
2558+
.into_iter()
2559+
.flatten()
2560+
.collect()
2561+
}
2562+
2563+
pub(crate) fn get_fragment_metadata_from_ids(
2564+
&self,
2565+
fragment_ids: &[u32],
2566+
) -> Result<Vec<Fragment>> {
2567+
Ok(self
2568+
.get_fragments_from_ids(fragment_ids)?
2569+
.into_iter()
2570+
.map(|fragment| fragment.metadata().clone())
2571+
.collect())
2572+
}
2573+
2574+
pub(crate) fn get_existing_fragment_metadata_from_ids(
2575+
&self,
2576+
fragment_ids: &[u32],
2577+
) -> Vec<Fragment> {
2578+
self.get_existing_fragments_from_ids(fragment_ids)
2579+
.into_iter()
2580+
.map(|fragment| fragment.metadata().clone())
2581+
.collect()
2582+
}
2583+
2584+
pub(crate) async fn count_rows_in_fragments(&self, fragment_ids: &[u32]) -> Result<usize> {
2585+
let fragments = self.get_fragments_from_ids(fragment_ids)?;
2586+
self.count_rows_in_resolved_fragments(fragments).await
2587+
}
2588+
2589+
pub(crate) async fn count_rows_in_existing_fragments(
2590+
&self,
2591+
fragment_ids: &[u32],
2592+
) -> Result<usize> {
2593+
let fragments = self.get_existing_fragments_from_ids(fragment_ids);
2594+
self.count_rows_in_resolved_fragments(fragments).await
2595+
}
2596+
2597+
async fn count_rows_in_resolved_fragments(
2598+
&self,
2599+
fragments: Vec<FileFragment>,
2600+
) -> Result<usize> {
2601+
let counts = stream::iter(fragments)
2602+
.map(|fragment| async move { fragment.count_rows(None).await })
2603+
.buffer_unordered(16)
2604+
.try_collect::<Vec<_>>()
2605+
.await?;
2606+
Ok(counts.iter().sum())
2607+
}
2608+
2609+
/// Resolves fragments for the given ids without scanning the manifest.
2610+
///
2611+
/// The ids do not need to be sorted or deduplicated. Each id is resolved
2612+
/// independently via the fragment bitmap.
2613+
pub fn get_frags_from_ordered_ids(&self, ordered_ids: &[u32]) -> Vec<Option<FileFragment>> {
2614+
let dataset = Arc::new(self.clone());
2615+
ordered_ids
2616+
.iter()
2617+
.map(|id| {
2618+
if !self.fragment_bitmap.contains(*id) {
2619+
return None;
2620+
}
2621+
let fragment_index = self.fragment_bitmap.rank(*id) as usize - 1;
2622+
let fragment = self.manifest.fragments.get(fragment_index)?;
2623+
debug_assert_eq!(
2624+
fragment.id, *id as u64,
2625+
"fragment_bitmap rank({id}) resolved to fragment {}, but fragment_bitmap and manifest.fragments are expected to stay in sync",
2626+
fragment.id
2627+
);
2628+
Some(FileFragment::new(dataset.clone(), fragment.clone()))
2629+
})
2630+
.collect()
25792631
}
25802632

25812633
// This method filters deleted items from `addr_or_ids` using `addrs` as a reference

0 commit comments

Comments
 (0)