Skip to content

Add ivfaster: a segment-lifecycle-aware IVF vector codec for the sandbox - #16567

Open
RKSPD wants to merge 2 commits into
apache:mainfrom
RKSPD:ivfaster
Open

Add ivfaster: a segment-lifecycle-aware IVF vector codec for the sandbox#16567
RKSPD wants to merge 2 commits into
apache:mainfrom
RKSPD:ivfaster

Conversation

@RKSPD

@RKSPD RKSPD commented Aug 26, 2026

Copy link
Copy Markdown

IVFaster: a segment-lifecycle-aware IVF vector codec (sandbox)

It's no secret that vector search sits awkwardly on Lucene's segment model. Segments are immutable and merge constantly, and both properties are cheap for an inverted index and expensive for an ANN structure. There have been attempts to improve this behavior, namely IncrementalHnswGraphMerger, which adopts the largest incoming graph as a base and inserts the remaining documents instead of rebuilding a new graph from the document contents of both segments. However, HNSW construction itself is a very expensive operation: every inserted document is a beam search with a random access per hop, and insertion mutates the base graph's neighbor lists. Furthermore, with incremental merges, a base graph with more than 40% deletions is declined, because its connectivity has degraded, and the merge falls back to a full rebuild. All of these problems are made worse with quantization. Since standard quantization schemes create segment-dependent compressed vectors, the codec needs to keep full-precision vectors on disk just to requantize on merge. Related discussion: #15612.

IVFaster takes the position that a vector index should look like a posting list, and is built around the Lucene segment architecture rather than adapted to it afterwards.

Read literally, that is what the IVF family already is: a cell id is a key, and the vectors routed to it are the values under that key. In IVFaster, a single document also can live in multiple cells. Document vectors near a cell boundary are listed under several keys so that a query probing any one of them finds the document, with spillBits setting how many extra cells a document may occupy.

The structure a merge settles is nlist-sized, not N-sized. The largest incoming segment donates its centroids, the other segments' documents are routed into them by a tiled scan over centroid codes, and a few Lloyd iterations settle the means. The only thing rebuilt from scratch is the centroid graph, over nlist nodes rather than N: in the benchmarks below, 8000 centroids against 1M documents.

Quantization is global. The grid is derived in closed form from dim after a shared Hadamard rotation, with no trained or per-segment statistic, so a document's code is the same in every segment. Merges copy codes, and the codec never has to retain float32 vectors just to requantize later.

Two tiers: a 2-bit coarse code quantizer (Nitrox2) narrows each probed cell to a shortlist with one XOR+popcount pass over a contiguous byte string, and an int8 fine tier ranks the survivors. Cells are chosen by greedy descent over a navigable small-world graph on the centroids. Each node's payload in the graph is the centroid's 2-bit code interleaved with its neighbor list, so a hop stays L3-resident and descent cost is set by ef rather than by nlist.

The codec is capable of disk-based search. Slots are grouped by cell, so the byte range of every probed cell is known before the scan begins, and the reader hints all nprobe runs through IndexInput#prefetch up front, so cold faults overlap in place of one synchronous fault per cell. A graph descent has no equivalent, because the next hop's address is unknown until the current node is scored, which makes its misses serial by construction. The hint is adaptive and collapses to a counter increment once pages are resident, so it is on by default.

Note: an async or batched I/O path is deliberately absent, because it only benefits when nearly everything is cold and carries heavy overhead when warm. Every number below is page-cache warm, and disk-resident performance is coming soon.

Results

1M Cohere Embed multilingual-v3 Wikipedia-en, dim 1024, unit norm, dot product, 1000 held-out queries, recall@100 against exact NN, force-merged to one segment. Graviton3, Corretto 25, index on standard gp2 EBS (250MiB/s). Latency is warm and single-threaded through luceneutil. IVFaster dialed with nprobe and nlist=8000, HNSW with search fanout. Every number below was measured in one session on one machine.

recall IVFaster spillBits=3 IVFaster spillBits=2 Lucene HNSW (SQ 7-bit) speedup
~0.91 0.911 @ 0.593 ms (np 16) 0.913 @ 0.607 ms (np 24) 0.910 @ 1.122 ms (fo 25) 1.9x
~0.94 0.938 @ 0.632 ms (np 24) 0.943 @ 0.712 ms (np 40) 0.941 @ 1.600 ms (fo 100) 2.5x
~0.95 0.951 @ 0.713 ms (np 32) 0.950 @ 0.778 ms (np 48) 0.956 @ 2.189 ms (fo 200) 3.1x
~0.96 0.960 @ 0.790 ms (np 40) 0.960 @ 0.902 ms (np 64) 0.963 @ 2.817 ms (fo 300) 3.6x
~0.97 0.972 @ 1.008 ms (np 64) 0.970 @ 1.155 ms (np 96) 0.972 @ 5.103 ms (fo 800) 5.1x

The gap widens with recall, because HNSW buys recall by visiting more nodes at a memory latency each while IVFaster scans more cells sequentially.

config index(s) force_merge(s) total(s) size (MB)
IVFaster spillBits=3 48.3 50.0 98.3 5168
IVFaster spillBits=2 39.8 42.7 82.6 3887
Lucene HNSW SQ 7-bit 149.4 114.0 263.4 4965

Build is storage-bound on EBS rather than CPU-bound: about half the IVFaster build wall clock is fsync on the data file with the cores idle, so these figures track bytes written more than clustering work.

Index size is the column IVFaster loses at spillBits=3. A slot costs 1344 B and both the fine records and the coarse planes replicate per slot, so at spillMargin=1.40 the index holds almost exactly four slots per document. spillBits=2 trades that back: 25% smaller, 16% faster to build, and roughly 1.5x the nprobe to match a given recall, which costs 9 to 15% more latency in the 0.94-and-above bands.

IVFaster config: nlist=8000, spillMargin=1.40, soarLambda=1.0, lloydIters=10, bruteN=700, nprobeMargin=0.75, verifyMultiplier=2, Nitrox2 coarse + int8 fine. All codec defaults except nlist, whose default is 1000; nlist is corpus-dependent and not auto-scaled, and we see best results near 100-150 docs per cell. HNSW baseline: Lucene104HnswScalarQuantizedVectorsFormat, 7-bit scalar quantization, M=16, beamWidth=100, dialed with search fanout.

Caveats: each point is a single run and latency noise is about 5%.

Scope

Hi all. This is a sandbox codec, marked @lucene.experimental with no back-compat guarantee. It is a pretty big PR with 37 files under lucene/sandbox. It is self-contained with no other modifications to Lucene.

I would really appreciate any feedback from the community. Thank you.

Rikhil Konduru added 2 commits August 26, 2026 21:38
An IVF vector format built around two quantization tiers and an exhaustive
router. Documents are clustered into nlist cells by Lloyd iteration, and a
query selects a few cells and scores only their documents.

  * Coarse tier (nitrox2): a symmetric 2-bit thermometer code whose summed
    per-dimension level distance equals popcount(q ^ d) over the whole code,
    so scoring a cell is one XOR and popcount over a contiguous byte string.
  * Fine tier (int8): one byte per dimension, ranking the coarse survivors.
  * The Reaper: after a centroid update, only documents that could have
    changed cell are re-routed, on a per-pair movement bound that provably
    contains every document whose assignment changes.
  * Clustering runs to convergence, stopping once a pass changes at most
    0.5% of assignments, with lloydIters as a backstop for a field that does
    not converge.
  * Spill: near-boundary documents are written into additional cells chosen
    by the SOAR objective, so a query reaches them at a lower nprobe.
  * A centroid graph serves search-time cell selection, built from the final
    centroids. Index-time routing is always an exhaustive scan and never
    consults the graph.

Centroids are unit norm for every similarity, so argmin -dot and
argmin ||v - c||^2 coincide, both Lloyd steps minimize the same objective,
and every distance in the codec reduces to one dot product.

Tests cover the coarse and fine codes, the routing cascade, clustering and
the Reaper's sufficiency claim, spill completeness, merge, and end-to-end
recall through the real codec.
@msokolov

Copy link
Copy Markdown
Contributor

exciting! the numbers you shared look compelling. Can you address the failing checks? It looks like a reference to the new module crept in to another module somehow? And there are some warnings that need to be cleaned up. But beyond that, this is a lot of new code to review. I wonder if you can help reviewers by (1) providing some overview of the algorithm and the new classes. If you included that in the PR javadocs or module.java, that's ideal, but please reference from the PR description if so. Also (2) is there any code here that's optional for a first implementation? For example I see you included multiple quantization options. Can we split this up and look at a stripped down version without quantization support? I'm afraid this may languish if we can't simplify it.

@RKSPD

RKSPD commented Aug 27, 2026

Copy link
Copy Markdown
Author

exciting! the numbers you shared look compelling. Can you address the failing checks? It looks like a reference to the new module crept in to another module somehow? And there are some warnings that need to be cleaned up. But beyond that, this is a lot of new code to review. I wonder if you can help reviewers by (1) providing some overview of the algorithm and the new classes. If you included that in the PR javadocs or module.java, that's ideal, but please reference from the PR description if so. Also (2) is there any code here that's optional for a first implementation? For example I see you included multiple quantization options. Can we split this up and look at a stripped down version without quantization support? I'm afraid this may languish if we can't simplify it.

Hi Mike! Thank you for commenting on my PR. I agree this is really huge and hard to review. I'm working on breaking the codec down into reviewable pieces, but just wanted to get the results out so the community can get a feel for the results and the implementation scope.

Unlike other codecs, IVFaster necessarily uses a cascading coarse/fine quantizer in both the index and search phases, which makes it difficult to think about the IVFaster algorithm without these pieces. The performance characteristics are largely based on the new quantizer design. I'll get more comprehensive documentation and architecture diagrams soon. Once again thank you.

@gsmiller

Copy link
Copy Markdown
Contributor

Disclaimer: Rikhil and I work together and started a conversation about this earlier today, but I wanted to bring it here so others have an opportunity to join the discussion.

A property that really excites me about IVF is that docIDs can be traversed in monotonically increasing order within each cluster since it's fundamentally just a postings list of docs (and it also follows that a disjunction of clusters can produce a union of ordered docIDs, exactly the same as something like TermInSetQuery). I find this compelling since it means docs could potentially be evaluated doc-at-a-time along with all other clauses of the query (i.e., filters) instead of first collecting semantic results then post-filtering (or alternatively, creating a semantically-unaware filter bitset that gets pushed down into semantic search). But... I don't think this current codec-approach allows this idea to be leveraged.

I'm wondering if we could restructure the approach here to allow for this doc-at-a-time scoring. I'm going to look at the code in more detail soon (so I may have an overly-naive mental model of what's going on), but it seems to me like we could create a new Query/Collector implementations that do the following:

  • Store cluster IDs as simple terms in a standard inverted field with associated docs in the postings (along with embeddings, or possibly put embeddings in a separate docValues field?). Store a graph of cluster nodes using our existing HNSW codec (or maybe something new if it's warranted)?
  • On query rewrite (or maybe on scorer pull?), do a centroid search to determine the clusters worth unpacking. This could be our existingl HNSW search or something new if we need it. Then, simply rewrite as a TermInSetQuery for those clusters.
  • In a collector, do full precision scoring for docs that make it that far before putting them in a topK heap.

What I'd love to achieve here is a way to run filters doc-at-a-time before doing expensive full-precision scoring. We fundamentally can't do this with HNSW since it can't visit docs in docID order (same problem we have with the points index, just much more expensive because of vector scoring).

I'm positive I'm missing some important bits of IVF and your proposed implementation, but I wanted to float this structure to see what you think.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants