Repository navigation
[WIP] IVF Index: Insert and Search - #1187
Aditya Krishnan (arkrishn94) wants to merge 47 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?
There was a problem hiding this comment.
Great question. Not quite sure. If diskann is going to be the home for the algorithms, then maybe it should live there?
| /// 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?
| /// | ||
| /// - [`ReassignCount::None`] corresponds to no neighborhood gathering. | ||
| /// - [`ReassignCount::Count`] coresponds to a non-zero neighborhood size to be gathered. | ||
| pub reassign_count: ReassignCount, |
There was a problem hiding this comment.
Why should we allow this to be zero?
There was a problem hiding this comment.
In case we want to do pure splits, without any neighborhood re-assignment.
| /// - [`ReassignCount::Count`] coresponds to a non-zero neighborhood size to be gathered. | ||
| pub reassign_count: ReassignCount, | ||
| /// Positive number of Lloyd steps per parent. | ||
| pub split_iterations: NonZeroUsize, |
There was a problem hiding this comment.
Is the number of Lloyd steps something that we really want to configure? Are there like meaningful performance tradeoffs that the user might want to have a say in here?
There was a problem hiding this comment.
Hmm fair enough. There is a sort of tradeoff here, could probably set it between 4-5 iterations. Below that quality degrades but is pretty fast, more than that gives minimal gains but is much slower.
| pub reassign_count: ReassignCount, | ||
| /// Positive number of Lloyd steps per parent. | ||
| pub split_iterations: NonZeroUsize, | ||
| pub seed: u64, |
There was a problem hiding this comment.
Same question here I guess, is it really what we want to pass in a seed?
There was a problem hiding this comment.
I see what you're saying. We want the Config to be only what the user passes in. I guess the seed should move to some kind of set of default values.
|
|
||
| async move { | ||
| let mut accessor = strategy.build_accessor(context).into_ann_result()?; | ||
|
|
There was a problem hiding this comment.
Would be helpful to have some comments on this part
| async move { | ||
| let mut accessor = strategy.build_accessor(context).into_ann_result()?; | ||
|
|
||
| let mut parents = BTreeSet::new(); |
There was a problem hiding this comment.
Should this be a set?
I am still working on adding documentation, testing and cleaning up some parts of this. If you're interested, please take a look at
traits.rsandindex.rsto see the entry points for the algorithm.