RKSPD opened a new pull request, #16709:
URL: https://github.com/apache/lucene/pull/16709

   # SegmentIVF: a fast(er) Lucene-native inverted-file vector codec
   
   SegmentIVF is a Lucene KNN codec designed to make vector search behave like 
Lucene full-text search architecture. It is a further development of IVFaster 
#16567 with some cool new features. Before going into the implementation, it is 
important to describe the architectural philosophy behind the codec and the 
restrictions that make traditional vector indexes difficult to use inside 
Lucene.
   
   Lucene's segment architecture gives us a simple concurrency model, immutable 
files after flush, natural near-real-time publication, and straightforward 
distribution across nodes. Vector search methods are often at odds with this 
model. Graph structures such as HNSW must be rebuilt during segment merges, 
while traditional k-means IVF implementations can impose a large independent 
training cost on every new segment. Quantizers trained from segment-local data 
also require full-precision vectors to survive so that every merge can train a 
new quantizer and encode the vectors again.
   
   SegmentIVF starts from the opposite constraint: a vector's code must remain 
valid regardless of which segment holds it. Flushes and merges should reuse 
earlier clustering work, merging should copy existing encoded rows instead of 
returning to float32, and search work should scale with the documents searched 
rather than multiplying expensive reranking by the number of segments.
   
   ## Benchmark
   
   This benchmark uses 1M Cohere Embed multilingual-v3 Wikipedia-en vectors, 
dimension 1024, unit-normalized with dot-product similarity, and 1,000 held-out 
queries. Recall is recall@100 against exact nearest neighbors. The index was 
force-merged to one segment. The machine is a 64-core Arm server 
`m7g.16xlarge`with 256-bit SVE running Corretto 25.0.4. Search latency is warm, 
single-threaded, and the minimum of three independent JVM runs through 
luceneutil.
   
   SegmentIVF uses its defaults: `nlist=1000` (`sqrt(1M)`), `nprobe=32`, 
`spillBits=1`, `spillMargin=1.05`, a `0.75` adaptive probe margin, Nitrox2 
coarse codes, INT8 fine codes, and a global rerank of `max(100, 7 * k)` 
candidates. Recall is dialed with `nprobe` alone.
   
   The Lucene HNSW measurements below are the numbers from the previous 
benchmark on this dataset and machine. They use Lucene's 7-bit scalar-quantized 
HNSW path with a Lucene99 graph, `M=16`, and `beamWidth = 100`.
   
   | Recall band | SegmentIVF | Lucene HNSW SQ 7-bit | Speedup |
   | --- | ---: | ---: | ---: |
   | ~0.89 | 0.886 @ 0.465 ms (`nprobe=10`) | 0.888 @ 0.918 ms (`fanout=0`) | 
2.0x |
   | ~0.91 | 0.911 @ 0.527 ms (`nprobe=14`) | 0.908 @ 1.109 ms (`fanout=25`) | 
2.1x |
   | ~0.92 | 0.925 @ 0.576 ms (`nprobe=18`) | 0.923 @ 1.254 ms (`fanout=50`) | 
2.2x |
   | ~0.94 | 0.943 @ 0.676 ms (`nprobe=28`) | 0.939 @ 1.584 ms (`fanout=100`) | 
2.3x |
   | ~0.95 | 0.955 @ 0.816 ms (`nprobe=48`) | 0.956 @ 2.141 ms (`fanout=200`) | 
2.6x |
   | ~0.96 | 0.960 @ 1.033 ms (`nprobe=128`) | 0.963 @ 2.779 ms (`fanout=300`) 
| 2.7x |
   
   ### Indexing
   
   | Codec | Index build | Force merge to one segment | Index size |
   | --- | ---: | ---: | ---: |
   | SegmentIVF | 17.488 s | 13.794 s | 1906.70 MB |
   | Lucene HNSW SQ 7-bit | 150.3 s | 113.8 s | 4965 MB |
   
   SegmentIVF builds the initial index 8.6x faster, force-merges 8.3x faster, 
and produces an index 61.6% smaller. The median end-to-end time to the final 
one-segment index was 31.575 seconds, compared with 264.1 seconds for the 
previous HNSW measurement, an 8.4x improvement.
   
   The size comparison needs one qualification because the indexes retain 
different data. Lucene's scalar-quantized HNSW format wraps a raw float32 
delegate so that vectors can be requantized during merge. Most of its 4965 MB 
is therefore full-precision vector data. SegmentIVF keeps no float32 copy of 
each document vector. Its fine representation is one signed byte per dimension 
plus a per-vector scale, and its coarse representation is a 2-bit Nitrox2 code. 
Spill copies those compressed records only for vectors close enough to a cell 
boundary.
   
   The indexing speed comes from the same architectural choice. SegmentIVF has 
no document graph to rebuild. Build rows are staged sequentially on disk, 
encoding and clustering use all available build workers, compatible merges copy 
existing fine and coarse codes, and HotStart carries useful centroid and 
assignment state into the next clustering run.
   
   ### 900M-vector scaling (on a single `m7g.16xlarge` )
   
   This run indexed the first 900M vectors from a 901,180,094-vector FineWeb 
corpus. The vectors are 768-dimensional FP16 input with cosine similarity. The 
codec used `nlist=3873`, which is approximately `sqrt(15M)` for the target 
maximum segment population, `spillBits=1`, `spillMargin=1.05`, and the INT8 
fine tier.
   
   | Documents | Indexing time | Final merge drain | End-to-end | End-to-end 
throughput | Final segments | Index size |
   | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
   | 900,000,000 | 36,970.572 s | 156.123 s | 37,126.695 s | 24,241 vectors/s | 
104 | 1.528 TiB |
   
   SegmentIVF is designed to scale. The complete build took **10 hours 18 
minutes 47 seconds**, including waiting for already-running background merges 
at the end. It was not force-merged: the result is a normal Lucene index with 
104 immutable segments averaging 8.65M vectors each. Background merges ran 
concurrently with ingestion; the separate 156-second figure is only the final 
drain after the last document was committed.
   
   ## Global, segment-independent codes
   
   Quantization is critical ANN infrastructure: it reduces storage and memory 
traffic while retaining production-quality recall. In Lucene, quantizer design 
also determines merge behavior.
   
   SegmentIVF's transform and encodings do not depend on segment statistics. 
Every vector is L2-normalized and rotated by
   
   ```
   R = F · P · S
   ```
   
   where `S` applies deterministic random plus/minus-one signs, `P` is a 
Fisher-Yates permutation, and `F` is a normalized fast Walsh-Hadamard transform 
applied block-diagonally over the set bits of the dimension. The seed is 
derived from the dimension, so every segment for a compatible field uses the 
same rotation.
   
   The rotation spreads variance across dimensions, preserves similarity, and 
puts unit vectors on a shared grid whose coordinate scale is determined by the 
dimension rather than by a training set. The INT8 fine code then uses a 
per-vector scale, and the Nitrox2 coarse code uses fixed thresholds derived 
from `1 / sqrt(dim)`. Moving a vector between segments does not change either 
code.
   
   This gives merges ordinary Lucene semantics: copy the encoded row, update 
its document and cell metadata, and place it in the new posting lists. There is 
no segment-level requantization pass and no need to retain the original float32 
vector.
   
   
   ## Nitrox2: A quantizer built for vector search
   
   The coarse tier has a narrower job than the fine tier. It does not need to 
produce the final ranking; it only needs to keep true neighbors in a bounded 
shortlist. That permits a much cheaper representation.
   
   Nitrox2 stores two threshold planes for every rotated coordinate. At 
dimension `d`, the thresholds are `-0.5 / sqrt(d)` and `+0.5 / sqrt(d)`, 
producing three ordered levels. Because the planes are cumulative, Hamming 
distance between two codes is the sum of the per-coordinate level differences:
   
   ```
   popcount(query_plane_0 XOR doc_plane_0)
     + popcount(query_plane_1 XOR doc_plane_1)
   ```
   
   The query and document use the same code. There is no document correction 
term and no asymmetric expression that a scan path can accidentally omit. At 
1024 dimensions the complete coarse code is 256 bytes.
   
   The scan kernel is a fused XOR and popcount over contiguous rows. The Panama 
Vector API implementation processes native memory directly, scores several rows 
together so query chunks are reused, and falls back to scalar Java when the 
vector module is unavailable. The result is a bandwidth-oriented posting-list 
scan with no native library dependency.
   
   The fine tier defaults to INT8. Each rotated vector uses one signed byte per 
dimension and one scale. The query is encoded once, and reranking uses SIMD 
byte dot products before applying Lucene's similarity-score transformation. 
FP32 is also supported when a caller prefers exact fine records over the 
smaller default.
   
   
   ## Indexing: Segment-aware clustering and HotStart
   
   ### How cold segment build works:
   
   1. Normalize, rotate, and encode every vector into a disk-backed staging 
file.
   2. Seed `nlist` centroids from a deterministic reservoir sample.
   3. Route each vector to its nearest centroid.
   4. Update the spherical centroids from their assigned vectors.
   5. Reroute only vectors whose assignment may have changed.
   6. Repeat until fewer than 0.5% of vectors move or ten Lloyd iterations 
complete.
   7. Assign eligible boundary vectors to one additional SOAR-selected cell.
   8. Write contiguous posting runs and build the centroid graph.
   
   The expensive mistake in a segment architecture is treating every flush and 
merge as an unrelated clustering problem. `Clustering.HotStart` retains recent 
clustering state and reuses it in two places.
   
   For a flush, SegmentIVF finds the largest live compatible segment written by 
this process. Its centroids seed the new segment, and compatible segments from 
the same lineage contribute membership-weighted centroid state. This makes a 
stream of near-real-time flushes progressively warmer instead of repeatedly 
paying the full cold-start cost.
   
   For a merge, SegmentIVF selects the largest compatible source segment as the 
donor. Its centroid layout seeds the merged segment. Sources from the same 
HotStart lineage contribute their live primary-cell populations to a weighted 
seed, and their rows carry primary and runner-up assignments into the first 
iteration. Rows whose lineage or assignment is unknown are safely routed from 
scratch.
   
   ### Exact movement pruning
   
   Rerouting every vector after every centroid update is mostly wasted work 
because only vectors near a Voronoi boundary can change assignment. SegmentIVF 
tracks the best and runner-up centroids for each vector and accumulates the 
movement of those centroids.
   
   A vector can only keep its current assignment when
   
   ```
   d2 - d1 > movement(c1) + movement(c2)
   ```
   
   where `d1` and `d2` are the distances to its current best and runner-up 
cells. When the runner-up is not known, the implementation uses a conservative 
bound derived from the maximum centroid movement. This is a sufficient 
condition, so skipped vectors cannot have changed their nearest cell.
   
   The bound follows the k-means pruning line from Elkan, Hamerly, and Yinyang, 
applied here to donor-warm-started segment clustering. As centroids settle, 
almost all vectors stop participating in later routing passes.
   
   ## Indexing: Spill
   
   Traditional IVF probes the cells whose centroids are closest to the query. 
In high dimensions, pairwise distances concentrate and cell boundaries become 
unreliable: a true neighbor can sit just across a Voronoi boundary in a cell 
that is not among the query's first probes.
   
   Spill attacks that failure directly. A vector close to a boundary is written 
to its primary cell and `spillBits` secondary cells, so a query can find it 
from either side. The default `spillBits=1` permits one secondary copy, and 
`spillMargin=1.05` limits duplication to vectors whose two closest cells are 
competitive within the margin.
   
   The secondary cell is selected with SOAR, Spilling with 
Orthogonality-Amplified Residuals. Choosing the second-nearest centroid often 
duplicates the same failure direction as the primary. SOAR instead penalizes a 
candidate whose residual is parallel to the primary residual:
   
   ```
   loss(c) = ||v - c||² + lambda * (r1 · (v - c))² / ||r1||²
   
   where r1 = v - c_primary
   ```
   
   The default `lambda=1` favors a complementary cell whose residual covers a 
different direction. That makes each spill copy more useful and lets the codec 
keep the spill count and index size low.
   
   Spill means one document may appear in several probed posting lists. A fixed 
slot shortlist can therefore collapse to far fewer distinct documents. 
SegmentIVF admits up to `shortlist * (1 + spillBits)` slots, orders them by 
coarse distance, deduplicates by document, and keeps the requested number of 
distinct candidates. The writer's spill cap consequently gives the reader a 
hard bound on how much extra admission is needed.
   
   
   ## Search: Query-Time Cell selection
   
   Choosing the cells to scan is itself a nearest-neighbor problem. A 
brute-force scan across all `nlist` centroids is reasonable while indexing 
because many documents can share a tiled, cache-resident centroid scan. A 
single search query cannot amortize that work, and `nlist` must grow with the 
corpus to keep posting lists small.
   
   SegmentIVF therefore builds a compact graph over centroid codes. Each node 
stores the centroid's 2-bit Nitrox2 code next to up to 16 neighbor ordinals in 
a cache-aligned record inspired by USearch. At dimension 1024 the code is 256 
bytes instead of the centroid's 4096-byte float32 vector.
   
   The query starts with a maximum `nprobe`, then applies an adaptive margin to 
the exactly ranked centroid candidates. A cell is discarded once its centroid 
distance exceeds the configured fraction of the best cell's distance. Easy 
queries with one dominant cell stop early; ambiguous queries retain more cells. 
The default margin is `0.75`, and a margin of `1.0` disables the cutoff.
   
   ## Search: Index-wide rerank
   
   Lucene searches segments independently, which creates a cost for a two-tier 
vector index. If every segment fine-reranks its own fixed shortlist, fine 
scoring and random fine-record reads grow linearly with the number of segments. 
That is especially undesirable for near-real-time indexes, where segment count 
is expected to fluctuate.
   
   ### How `SegmentIVFKnnQuery` works:
   
   1. Normalize and rotate the query once for each segment search.
   2. Encode the Nitrox2 and fine query representations once for that search.
   3. Select cells and scan contiguous Nitrox2 posting runs in each segment.
   4. Produce a deduplicated coarse shortlist from each segment.
   5. Merge all leaf shortlists by coarse distance.
   6. Keep one index-wide budget of `max(100, 7 * k)` candidates.
   7. Read and fine-rerank only those records, in parallel across the leaves 
that contributed them.
   
   For a top-100 query, the entire index fine-reranks 700 records whether it 
has one segment or many. A plain `KnnFloatVectorQuery` remains supported 
through the codec interface, but it performs the normal per-segment rerank. 
`SegmentIVFKnnQuery` is the path that provides the index-wide bound.
   
   Note: 7 * k was chosen empirically using Cohere Wikipedia v3-1024 and may 
not be optimal for your dataset.
   
   ## Search: Automatic mapped reads and io_uring
   
   The coarse and fine tiers have different memory access patterns, so 
SegmentIVF manages them differently.
   
   Coarse posting runs are large, contiguous, and scanned repeatedly. On reader 
open, SegmentIVF queues a background off-heap copy of the coarse codes and 
slot-to-document mapping when the memory budget allows it. At most three 
quarters of that remaining space is reserved for pinned coarse data. Searches 
never wait for the copy; they use the mapped view until the pinned copy is 
published.
   
   Fine records are accessed sparsely after the global shortlist is known. 
Mapped reads are fastest while the working set fits in memory, but a page miss 
turns each scattered record into a synchronous fault. SegmentIVF tracks the 
total fine-tier bytes across open readers and reevaluates its read path at most 
once per second:
   
   - Use mapped records while the fine tiers fit in the memory left after the 
heap and pinned coarse data.
   - Switch to io_uring when the fine tiers exceed that room or Linux PSI 
reports sustained memory pressure.
   - Use hysteresis, switching on above 1% `some avg10` pressure and back below 
0.1%, to avoid oscillation.
   
   The io_uring path submits the rerank's scattered fine-record reads as one 
batch. Cached records still come from the page cache, while page misses can run 
concurrently instead of serializing as faults on the search thread. Rings and 
buffers are per search thread; each segment reader owns only its file 
descriptor. If io_uring is unavailable or a batch fails, the reader falls back 
to the mapped input.
   
   | Memory limit | Automatic mode | Forced mmap | Forced buffered io_uring | 
Path selected automatically |
   |---:|---:|---:|---:|---|
   | Unlimited, warm | 0.728–0.747 ms | 0.726–0.728 ms | 1.292–1.303 ms | mmap |
   | 3.0 GB | 0.717–0.729 ms | 0.720–0.723 ms | 1.324–1.342 ms | mmap |
   | 1.6 GB | 4.802–4.803 ms | 9.846–10.054 ms | 4.807–4.812 ms | io_uring |
   | 1.1 GB | 9.407–9.418 ms | 32.304–32.314 ms | 9.406–9.418 ms | io_uring |
   
   ## Filtered search
   
   Filters are applied before vector scoring. `SegmentIVFKnnQuery` materializes 
a dense filter as a per-segment bit set and passes it into the codec.
   
   The reader chooses between two paths based on filter cost:
   
   - For a selective filter, visit accepted documents directly and rerank their 
slots.
   - For a denser filter, select cells, scan only matching rows, and widen the 
probe budget as selectivity falls.
   
   Segments whose slots fit within the coarse admission pool scan all slots 
instead of spending probes on mostly empty cells. Sufficiently selective 
filters visit their accepted documents directly. Filtered candidates enter the 
same coarse ordering and global rerank as unfiltered candidates, preserving the 
index-wide fine-read bound.
   
   ## Final Note
   Hi everyone, this PR is a culmination of a lot of trial and error and a 
collaborative effort between many people. Thank you for reading this very long 
PR description. I would appreciate any feedback on the method or general 
thoughts of this IVF implementation in Lucene.


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to