Skip to content

[WIP] IVF index interface - #1187

Draft
Aditya Krishnan (arkrishn94) wants to merge 10 commits into
mainfrom
u/adkrishnan/ivf-index
Draft

Aditya Krishnan (arkrishn94) wants to merge 10 commits into
mainfrom
u/adkrishnan/ivf-index

Conversation

@arkrishn94

Copy link
Copy Markdown
Contributor

This is a major work in progress!

To see what I'm going for, see diskann/src/ivf/glue.rs and index.rs.

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-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.97727% with 53 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.51%. Comparing base (d9ce362) to head (b70b235).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
diskann/src/ivf/test/provider.rs 88.95% 39 Missing ⚠️
diskann/src/ivf/test/cases/insert.rs 96.69% 4 Missing ⚠️
diskann/src/ivf/test/cases/correctness.rs 94.91% 3 Missing ⚠️
diskann/src/ivf/index.rs 97.75% 2 Missing ⚠️
diskann/src/ivf/test/cases/errors.rs 96.66% 2 Missing ⚠️
diskann/src/ivf/test/harness.rs 97.95% 2 Missing ⚠️
diskann/src/ivf/test/cases/concurrency.rs 98.73% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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     
Flag Coverage Δ
miri 89.51% <93.97%> (+0.03%) ⬆️
unittests 89.16% <93.97%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
diskann/src/ivf/test/cases/mod.rs 100.00% <100.00%> (ø)
diskann/src/ivf/test/cases/concurrency.rs 98.73% <98.73%> (ø)
diskann/src/ivf/index.rs 97.75% <97.75%> (ø)
diskann/src/ivf/test/cases/errors.rs 96.66% <96.66%> (ø)
diskann/src/ivf/test/harness.rs 97.95% <97.95%> (ø)
diskann/src/ivf/test/cases/correctness.rs 94.91% <94.91%> (ø)
diskann/src/ivf/test/cases/insert.rs 96.69% <96.69%> (ø)
diskann/src/ivf/test/provider.rs 88.95% <88.95%> (ø)

... and 18 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread diskann/src/ivf/index.rs Outdated
}

/// Insert a vector under external id `id`.
pub fn insert<'a, S, T>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we also want delete in this interface?

Comment thread diskann/src/ivf/index.rs Outdated
context: &'a P::Context,
id: &P::ExternalId,
vector: T,
) -> impl SendFuture<ANNResult<()>>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread diskann/src/ivf/index.rs Outdated
pub fn knn_search<'a, S, T, OB>(
&'a self,
k: NonZeroUsize,
nprobe: usize,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread diskann/src/ivf/index.rs Outdated

let k = k.get();
let mut queue = NeighborPriorityQueue::new(k);
let mut cmps: u32 = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also count distance comparisons done to select centroids, right?

Comment thread diskann/src/ivf/glue.rs Outdated
}

/// Per-call factory for IVF insert.
pub trait InsertStrategy<'a, Provider, T>: Send + Sync

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread diskann/src/ivf/dynamic.rs Outdated
Comment on lines +181 to +189
#[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>,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might make debugging/reading code easier if we made this an enum over delete, insert, and reassign, but just a thought

Comment thread rfcs/01187-incremental-ivf.md Outdated
### 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add some integration tests with exact baselines using the same framework as is used for DiskANN.

Comment thread diskann/src/lib.rs
// Index Implementations
pub mod flat;
pub mod graph;
pub mod ivf;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Meta-question, do we want a separate diskann-ivf crate or do we want inclusion within diskann?

Comment thread diskann/src/ivf/dynamic.rs Outdated
/// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the difference between pub(in crate::ivf) and pub(crate) here?

utils::VectorId,
};

/// Staged points, their canonical vectors, and their routes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread diskann/src/ivf/update.rs Outdated
/// The source and destination of reassigned points. The two never match.
///
/// Ordered by destination, then source.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to be able to hash this?

Comment thread diskann/src/ivf/index.rs
/// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can make all of these usize NonZeroUsize instead

Comment thread diskann/src/ivf/index.rs Outdated
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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just call this num_parents instead?

P: Provider<InternalId = Id, ListId = L>,
A: MaintenanceAccessor<P>,
{
let count = 2 * parents.len();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement the accessor and the insert path

3 participants