[WIP] IVF index interface - #1187
Aditya Krishnan (arkrishn94) wants to merge 10 commits into
Conversation
Introduces diskann::ivf with the IvfIndex wrapper, five core glue traits (ListAccessor, SearchAccessor, SearchStrategy, InsertAccessor, IvfInsertStrategy), an example list-owned IVF-Flat provider, an exact oracle harness, and a test suite covering correctness, concurrency, inserts (including concurrent inserts), and error escalation.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1187 +/- ##
==========================================
+ Coverage 89.47% 89.51% +0.03%
==========================================
Files 486 495 +9
Lines 92161 93050 +889
==========================================
+ Hits 82458 83290 +832
- Misses 9703 9760 +57
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
| } | ||
|
|
||
| /// Insert a vector under external id `id`. | ||
| pub fn insert<'a, S, T>( |
There was a problem hiding this comment.
Do we also want delete in this interface?
| context: &'a P::Context, | ||
| id: &P::ExternalId, | ||
| vector: T, | ||
| ) -> impl SendFuture<ANNResult<()>> |
There was a problem hiding this comment.
Should we have the concept of an index that is read-only? I am thinking of designs where the index is loaded into memory for maintenance and then queries are served from a slower provider. In that case, we don't necessarily want to support insert/delete in the slower provider.
| pub fn knn_search<'a, S, T, OB>( | ||
| &'a self, | ||
| k: NonZeroUsize, | ||
| nprobe: usize, |
There was a problem hiding this comment.
Should nprobe be a NonZeroUsize that we enforce is greater than or equal to k?
Also, how will this contract deal with parameters used for searching the centroid index (i.e. L_search)? Maybe it should take in a type CentroidSearchParams or something similar instead
There was a problem hiding this comment.
Well, >= k maybe doesn't make sense, but maybe n_probe * min_size >= k? That would, however, mean we needed to enforce the minimum size strictly.
|
|
||
| let k = k.get(); | ||
| let mut queue = NeighborPriorityQueue::new(k); | ||
| let mut cmps: u32 = 0; |
There was a problem hiding this comment.
We should also count distance comparisons done to select centroids, right?
| } | ||
|
|
||
| /// Per-call factory for IVF insert. | ||
| pub trait InsertStrategy<'a, Provider, T>: Send + Sync |
There was a problem hiding this comment.
Trying to understand the vision for the dynamic case here--is the idea that we will later extend this to deletes, at which point we call it something like DynamicStrategy, or that there will be a separate DeleteStrategy object?
|
|
||
| After a maintenance accessor returns `Ok(())` from `apply`: | ||
|
|
||
| 1. Every visible point is assigned to exactly one live list. |
There was a problem hiding this comment.
This seems like something we do not want to deeply commit to in our architecture. Replication of points is a pretty standard technique in IVF.
| 2. `assignment[point] == list` if and only if `point` is a member of `list`. | ||
| 3. Every live list has exactly one live centroid with the same logical id. | ||
| 4. Every list member has an available canonical vector and scan payload. | ||
| 5. Retired list ids are never reused for a different centroid. |
There was a problem hiding this comment.
Why can't we reuse list ids? This seems like something we might want to do.
| 5. Retired list ids are never reused for a different centroid. | ||
| 6. Point identity mappings agree with point visibility in the partition. | ||
| 7. Inserts may trigger splits but not dissolves. | ||
| 8. Deletes may trigger dissolves but not splits. |
There was a problem hiding this comment.
Do we want to commit that deletes can't trigger splits? I think this is a bit contrary to some of our discussions on a batching API, and iirc we did at least one experiment that shows that this policy would degrade recall.
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub struct PointMove<Id, ListId> { | ||
| /// Internal point id being inserted, deleted, or reassigned. | ||
| pub id: Id, | ||
| /// Previous list, or `None` for a newly inserted point. | ||
| pub from: Option<ListId>, | ||
| /// New list, or `None` for a deleted point. | ||
| pub to: Option<ListId>, | ||
| } |
There was a problem hiding this comment.
It might make debugging/reading code easier if we made this an enum over delete, insert, and reassign, but just a thought
| ### Split Semantics | ||
|
|
||
| A split retires one parent and installs two children, for a net increase of one | ||
| live cluster. Parent ids are never reused. Candidate centroids for a region are |
There was a problem hiding this comment.
Why can't parent ids be reused?
| current assignment. | ||
| 2. Stage deletion of each point id. | ||
| 3. Group deletes by list and compute projected post-delete sizes. | ||
| 4. Admit underfull victims without crossing `min_clusters`. |
There was a problem hiding this comment.
Is a victim a centroid that is being deleted, or the points in the deleted centroid's posting list? What does it mean to "admit" them?
| centroid cannot make a point already assigned to another surviving centroid | ||
| prefer the removed centroid. Deletes never trigger a split in the same | ||
| operation. A survivor above the split threshold remains eligible for the next | ||
| insert-driven split. |
There was a problem hiding this comment.
We should add a section for a combined insert/delete batch API since that will likely be necessary. Also, I don't think we should allow deletes to over-fill a posting list. In fact, we should maintain an invariant that posting list updates passed via the PartitionUpdate can never exceed the maximum number of points. We can be flexible on the minimum number of points, but by being inflexible on the max we maintain openness to providers that do a fixed allocation for each posting list.
| - Design blob coordination separately rather than forcing disk and blob through | ||
| one generic publication protocol. | ||
|
|
||
| ## Testing Strategy |
There was a problem hiding this comment.
We should add some integration tests with exact baselines using the same framework as is used for DiskANN.
| // Index Implementations | ||
| pub mod flat; | ||
| pub mod graph; | ||
| pub mod ivf; |
There was a problem hiding this comment.
Meta-question, do we want a separate diskann-ivf crate or do we want inclusion within diskann?
| /// Errors from list selection or scanning. | ||
| type Error: ToRanked + Debug + Send + Sync + 'static; | ||
|
|
||
| /// Select exactly the available requested number of lists for the bound query. |
There was a problem hiding this comment.
This should probably also return some kind of stats object
| /// | ||
| /// The centroid catalog is authoritative. Approximate implementations, such as | ||
| /// a graph navigator, must retain enough catalog information to reject retired | ||
| /// ids and recover with exact selection when navigation cannot produce a usable |
There was a problem hiding this comment.
We expect a graph index to handle fallback to flat scan? Why is that? That's not typical for DiskANN in general, is there a reason it would be needed for an IVF centroid index?
Co-authored-by: Magdalen Manohar <magdalen@magdalen.localdomain>
| /// Staged points, their canonical vectors, and their routes. | ||
| /// | ||
| /// Batch position `i` refers to `ids[i]`, row `i` of `vectors`, and `routes[i]`. | ||
| pub(in crate::ivf) struct StagedBatch<Id, L> { |
There was a problem hiding this comment.
What is the difference between pub(in crate::ivf) and pub(crate) here?
| utils::VectorId, | ||
| }; | ||
|
|
||
| /// Staged points, their canonical vectors, and their routes. |
There was a problem hiding this comment.
I think we can just say "vector" instead of "canonical vector" in the docstrings here. Canonical doesn't have a defined meaning in the repo
| /// The source and destination of reassigned points. The two never match. | ||
| /// | ||
| /// Ordered by destination, then source. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
There was a problem hiding this comment.
We want to be able to hash this?
| /// Parameters for the online split policy. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| pub struct DynamicIvfConfig { | ||
| /// Split a list once an insert batch would grow it beyond this many points. |
There was a problem hiding this comment.
We can make all of these usize NonZeroUsize instead
| pub struct DynamicIvfConfig { | ||
| /// Split a list once an insert batch would grow it beyond this many points. | ||
| pub split_threshold: usize, | ||
| /// Hard cap on live lists; `None` allows unbounded growth. |
There was a problem hiding this comment.
Is there an advantage to allowing unbounded growth? It seems like it might introduce too much complication without us getting much back in return
| pub(super) ids: Vec<Id>, | ||
| pub(super) vectors: Matrix<f32>, | ||
| /// The nearest live list of each point. | ||
| pub(super) routes: Vec<L>, |
There was a problem hiding this comment.
Can we call this something like nearest_centroids instead?
| /// The nearest live list of each point. | ||
| pub(super) routes: Vec<L>, | ||
| /// Batch positions grouped by route. | ||
| pub(super) by_route: Grouped<L, usize>, |
There was a problem hiding this comment.
Do we really need the Grouped struct for this? Could we just have a Vec<(L, Vec<usize>)> instead? Seems like it could save a lot of code and I find the Csr thing a little hard to understand
| /// | ||
| /// Reserves two child ids per parent, selects each parent's nearest surviving | ||
| /// lists as its neighbors, and reads the members and canonical vectors of every | ||
| /// parent and neighbor. `parents` must be ascending. |
There was a problem hiding this comment.
"Ascending" = in sorted order by ID, right? Can we just say that explicitly?
| pub(in crate::ivf) struct SplitPlan<Id, L> { | ||
| batch: StagedBatch<Id, L>, | ||
| lists: Vec<L>, | ||
| parents: usize, |
There was a problem hiding this comment.
Can we just call this num_parents instead?
| P: Provider<InternalId = Id, ListId = L>, | ||
| A: MaintenanceAccessor<P>, | ||
| { | ||
| let count = 2 * parents.len(); |
There was a problem hiding this comment.
num_children instead of count maybe?
| .centroid(parent) | ||
| .ok_or_else(|| index_error(format!("split parent {parent} is not live")))?; | ||
| let selected = centroids | ||
| .select(anchor, config.reassign_neighbors.saturating_add(1)) |
There was a problem hiding this comment.
Do we just add +1 here to reassign_neighbors to get some headroom in case one of the parent centroid's nearest neighbors is also deleted?
This is a major work in progress!
To see what I'm going for, see
diskann/src/ivf/glue.rsandindex.rs.