From 3c814fac81cc1c09328c3bef8eed7848fd6d2e35 Mon Sep 17 00:00:00 2001 From: "Dian-Lun (Aaron) Lin" Date: Thu, 2 Jul 2026 22:14:58 +0000 Subject: [PATCH 1/5] Compaction: fix cold compaction time by eliminating disk scan in computeLayerInfoFromSources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit computeLayerInfoFromSources called getNodes(0) on each source graph to count live nodes at level 0. getNodes(0) sequentially seeks through every node record on disk to filter out deleted entries. On a cold page cache this touches large amounts of source data before compaction even begins, significantly delaying the start of actual graph merging. Since every live node is present at level 0 by the HNSW invariant, the count is simply liveNodes.get(s).cardinality() — an in-memory popcount requiring no I/O. Also switch PQ retraining from ProductQuantization.compute() (full k-means++ init) to basePQ.refine() (Lloyd's iterations only, warm-started from the existing codebook). The source codebooks are already trained on the same distribution, so warm-starting converges in far fewer passes with no recall loss. --- .../graph/disk/OnDiskGraphIndexCompactor.java | 17 +++++++--- .../jvector/graph/disk/PQRetrainer.java | 33 +++++++++++-------- .../quantization/ProductQuantization.java | 2 +- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java index deffb719f..ddf5d3cc5 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java @@ -1354,11 +1354,18 @@ private List computeLayerInfoFromSources() { int count = 0; for (int s = 0; s < sources.size(); s++) { if (level > sources.get(s).getMaxLevel()) continue; - NodesIterator it = sources.get(s).getNodes(level); - FixedBitSet alive = liveNodes.get(s); - while (it.hasNext()) { - int node = it.next(); - if (alive.get(node)) count++; + if (level == 0) { + // Every live node is present at level 0 (HNSW base layer invariant), + // so count directly from the in-memory bitset instead of scanning node + // records on disk (which touches gigabytes of source data on a cold cache). + count += liveNodes.get(s).cardinality(); + } else { + NodesIterator it = sources.get(s).getNodes(level); + FixedBitSet alive = liveNodes.get(s); + while (it.hasNext()) { + int node = it.next(); + if (alive.get(node)) count++; + } } } layerInfo.add(new CommonHeader.LayerInfo(count, maxDegrees.get(level))); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java index 280d75c9c..c45120f70 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java @@ -22,6 +22,7 @@ import io.github.jbellis.jvector.quantization.ProductQuantization; import io.github.jbellis.jvector.util.DocIdSetIterator; import io.github.jbellis.jvector.util.FixedBitSet; +import io.github.jbellis.jvector.util.PhysicalCoreExecutor; import io.github.jbellis.jvector.vector.VectorizationProvider; import io.github.jbellis.jvector.vector.VectorSimilarityFunction; import io.github.jbellis.jvector.vector.types.VectorFloat; @@ -32,6 +33,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ThreadLocalRandom; /** @@ -96,22 +98,27 @@ public ProductQuantization retrain(VectorSimilarityFunction similarityFunction, log.info("Collected {} training samples", samples.size()); - // Extract vectors sequentially in sorted (source, node) order so disk reads are - // purely sequential and the OS read-ahead can cover them efficiently. We do this - // here rather than letting ProductQuantization.compute() drive the reads via its - // parallel stream, which would scatter page faults across a potentially very large - // file and cause I/O that scales with dataset size rather than sample count. List> trainingVectors = extractVectorsSequential(samples); - var ravv = new ListRandomAccessVectorValues(trainingVectors, dimension); - boolean center = similarityFunction == VectorSimilarityFunction.EUCLIDEAN; + long t0 = System.nanoTime(); + log.info("Extracted {} vectors in {}ms; starting PQ refinement", + trainingVectors.size(), (System.nanoTime() - t0) / 1_000_000L); + + var ravv = new ListRandomAccessVectorValues(trainingVectors, dimension); - return ProductQuantization.compute( - ravv, - basePQ.getSubspaceCount(), - basePQ.getClusterCount(), - center - ); + // Warm-start from the existing codebook via Lloyd's-only refinement rather than + // re-running k-means++ from scratch. k-means++ initialization visits every point + // once per centroid (256 passes for k=256), which dominates training time. + // Since the source codebooks are already trained on data from the same underlying + // distribution, this warm-start converges in far fewer passes with no recall loss. + long t1 = System.nanoTime(); + ProductQuantization result = basePQ.refine(ravv, + ProductQuantization.K_MEANS_ITERATIONS, + -1.0f, // UNWEIGHTED / isotropic + PhysicalCoreExecutor.pool(), + ForkJoinPool.commonPool()); + log.info("PQ refinement complete in {}ms", (System.nanoTime() - t1) / 1_000_000L); + return result; } /** diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ProductQuantization.java b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ProductQuantization.java index c84b7b955..6d9d23879 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ProductQuantization.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/quantization/ProductQuantization.java @@ -60,7 +60,7 @@ public class ProductQuantization implements VectorCompressor>, A private static final VectorTypeSupport vectorTypeSupport = VectorizationProvider.getInstance().getVectorTypeSupport(); static final int DEFAULT_CLUSTERS = 256; // number of clusters per subspace = one byte's worth - static final int K_MEANS_ITERATIONS = 6; + public static final int K_MEANS_ITERATIONS = 6; public static final int MAX_PQ_TRAINING_SET_SIZE = 128000; final VectorFloat[] codebooks; // array of codebooks, where each codebook is a VectorFloat consisting of k contiguous subvectors each of length M From a5a0c6ac6f71229ac1db39a7212d44acaf1ce810 Mon Sep 17 00:00:00 2001 From: "Dian-Lun (Aaron) Lin" Date: Thu, 16 Jul 2026 23:49:16 +0000 Subject: [PATCH 2/5] Compaction: start extraction timer before extractVectorsSequential The timer was initialized after the extraction call, so the logged duration was always ~0. Addresses PR review feedback. --- .../java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java index c45120f70..c03c934f2 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java @@ -98,9 +98,8 @@ public ProductQuantization retrain(VectorSimilarityFunction similarityFunction, log.info("Collected {} training samples", samples.size()); - List> trainingVectors = extractVectorsSequential(samples); - long t0 = System.nanoTime(); + List> trainingVectors = extractVectorsSequential(samples); log.info("Extracted {} vectors in {}ms; starting PQ refinement", trainingVectors.size(), (System.nanoTime() - t0) / 1_000_000L); From 9cca786a9e9ff1201d016e74c810c29a8d7ad6b7 Mon Sep 17 00:00:00 2001 From: "Dian-Lun (Aaron) Lin" Date: Sat, 18 Jul 2026 00:08:56 +0000 Subject: [PATCH 3/5] Compaction: windowed streaming prefetch for bulk-phase reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source graphs are mapped with MADV_RANDOM (correct for search-time access), which disables kernel readahead and makes compaction's bulk phases fault one page at a time on a cold cache (measured: retrain ~37s, pre-encode ~42s on a disk-cold 10M-node compaction that takes ~3s/~8s warm). Adds ReaderSupplier.prefetch(offset, length) — streams a byte range into the page cache through a separate readahead-enabled descriptor — and OnDiskGraphIndex.prefetchL0Records(minNode, maxNode) on top of it. Each bulk phase warms exactly the records it is about to read, from the worker that will read them: - PQ retrain prefetches its (source, node)-sorted sample ranges before extraction (bounded by training-set size, not file size), - code pre-encode prefetches each chunk's records at task start, - L0 batch processing prefetches each batch's own records at task start (cross-source search reads are data-dependent and stay demand-faulted). The per-thread prefetch buffer is 64KB: streamed bytes are discarded, so the buffer is sized to stay L2-resident. Measured on 56 threads: disk-cold throughput is device-bound (~3.3 GB/s) at every size from 32KB to 4MB, and in the warm case (range already cached, pass is pure overhead) 64KB runs within noise of larger buffers while 4KB is 2-3x slower in both regimes. Transient cache demand is proportional to the in-flight windows, so there is no up-front whole-file streaming pass and no memory-availability gate to mistune: on a box that cannot hold the sources, pages are simply evicted and reads degrade per-page to the old fault-on-demand behavior. --- .../jbellis/jvector/disk/ReaderSupplier.java | 11 ++++++ .../graph/disk/FusedCompactionStrategy.java | 3 ++ .../jvector/graph/disk/OnDiskGraphIndex.java | 16 ++++++++ .../graph/disk/OnDiskGraphIndexCompactor.java | 7 +++- .../jvector/graph/disk/PQRetrainer.java | 29 +++++++++++++++ .../jvector/disk/MemorySegmentReader.java | 37 ++++++++++++++++++- 6 files changed, 101 insertions(+), 2 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ReaderSupplier.java b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ReaderSupplier.java index 30431d6ca..7ebb3f9b0 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ReaderSupplier.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/disk/ReaderSupplier.java @@ -30,6 +30,17 @@ public interface ReaderSupplier extends AutoCloseable { */ RandomAccessReader get() throws IOException; + /** + * Streams the byte range {@code [offset, offset + length)} of the underlying storage into + * the page cache, if the implementation supports it. Synchronous and best-effort; a no-op + * by default. Sized for repeated windowed calls — a caller that knows which records a bulk + * phase is about to read (e.g. graph compaction, whose readers otherwise advise + * {@code MADV_RANDOM} and forgo kernel readahead) can warm exactly those, keeping transient + * cache demand proportional to the window rather than the file. + */ + default void prefetch(long offset, long length) { + } + default void close() throws IOException { } } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/FusedCompactionStrategy.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/FusedCompactionStrategy.java index f759bc702..b6a44ece3 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/FusedCompactionStrategy.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/FusedCompactionStrategy.java @@ -230,6 +230,9 @@ private void precomputeCodes(CompactWriter writer) throws IOException { final int cStart = chunkStart; final int cEnd = Math.min(chunkStart + chunkSize, upper); tasks.add(() -> { + // Stream this chunk's records into the page cache before the encode loop; + // the mapping's MADV_RANDOM otherwise faults them one page at a time. + source.prefetchL0Records(cStart, cEnd - 1); ByteSequence code = vectorTypeSupport.createByteSequence(cs); VectorFloat vec = vectorTypeSupport.createFloatVector(ctx.dimension); long count = 0; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java index 3fb69d967..61d8157fb 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java @@ -443,6 +443,22 @@ public int maxDegree() { return layerInfo.stream().mapToInt(li -> li.degree).max().orElseThrow(); } + /** + * Streams the L0 records of ordinals {@code [minNode, maxNode]} (inclusive) into the page + * cache; see {@link ReaderSupplier#prefetch(long, long)}. An L0 record holds the node's id, + * inline features (vector, fused codes), and adjacency, so warming it covers every read a + * bulk scan makes for that node. Best-effort no-op when unsupported. + */ + public void prefetchL0Records(int minNode, int maxNode) { + if (maxNode < minNode) { + return; + } + long blockBytes = Integer.BYTES + inlineBlockSize + + (long) Integer.BYTES * (layerInfo.get(0).degree + 1); + long start = neighborsOffset + blockBytes * minNode; + readerSupplier.prefetch(start, blockBytes * (maxNode - minNode + 1)); + } + // re-declared to specify type @Override public View getView() { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java index ddf5d3cc5..bf254340d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java @@ -84,7 +84,6 @@ public final class OnDiskGraphIndexCompactor implements Accountable { private final ForkJoinPool executor; private final int taskWindowSize; private final VectorSimilarityFunction similarityFunction; - /** * Constructs a new OnDiskGraphIndexCompactor for graphs without a non-fused compressed sidecar. * Equivalent to calling the 6-arg constructor with {@code sourceCompressed = null}. @@ -997,6 +996,12 @@ private List computeBaseBatch(CompactWriter writer, CompactionParams params) throws IOException { List out = new ArrayList<>(bs.end - bs.start); + if (bs.end > bs.start) { + // Stream this batch's own records into the page cache before processing. Search + // reads into other sources are data-dependent and stay demand-faulted, but each + // node's own record read (adjacency + vector) is fully predictable. + sources.get(bs.sourceIdx).prefetchL0Records(bs.nodes[bs.start], bs.nodes[bs.end - 1]); + } for (int i = bs.start; i < bs.end; i++) { int node = bs.nodes[i]; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java index c03c934f2..19bfde85b 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/PQRetrainer.java @@ -217,6 +217,8 @@ private List sampleBalanced(int totalSamples) { * cover them efficiently. Each source's view is opened once and reused for all its samples. */ private List> extractVectorsSequential(List samples) { + prefetchSampleRanges(samples); + OnDiskGraphIndex.View[] views = new OnDiskGraphIndex.View[sources.size()]; for (int s = 0; s < sources.size(); s++) { views[s] = (OnDiskGraphIndex.View) sources.get(s).getView(); @@ -231,6 +233,33 @@ private List> extractVectorsSequential(List samples) { return vectors; } + // Merging samples closer than this many ordinals into one range keeps the prefetch mostly + // sequential without dragging in long runs of unsampled records. + private static final int PREFETCH_MERGE_GAP = 256; + + /** + * Streams the records of the (source, node)-sorted sample list into the page cache before + * extraction. The mappings advise {@code MADV_RANDOM}, so without this the extraction loop + * faults one page at a time on a cold cache; total demand is bounded by the training-set + * size, not the file size. + */ + private void prefetchSampleRanges(List samples) { + int i = 0; + while (i < samples.size()) { + SampleRef first = samples.get(i); + int last = first.node; + int j = i + 1; + while (j < samples.size() + && samples.get(j).source == first.source + && samples.get(j).node - last <= PREFETCH_MERGE_GAP) { + last = samples.get(j).node; + j++; + } + sources.get(first.source).prefetchL0Records(first.node, last); + i = j; + } + } + /** * Reference to a sampled vector from a specific source index. */ diff --git a/jvector-native/src/main/java/io/github/jbellis/jvector/disk/MemorySegmentReader.java b/jvector-native/src/main/java/io/github/jbellis/jvector/disk/MemorySegmentReader.java index c9ac39c21..0a5fde49e 100644 --- a/jvector-native/src/main/java/io/github/jbellis/jvector/disk/MemorySegmentReader.java +++ b/jvector-native/src/main/java/io/github/jbellis/jvector/disk/MemorySegmentReader.java @@ -148,12 +148,14 @@ public void close() { public static class Supplier implements ReaderSupplier { private final Arena arena; private final MemorySegment memory; + private final Path path; public Supplier(Path path) throws IOException { + this.path = path; this.arena = Arena.ofShared(); try (var ch = FileChannel.open(path, StandardOpenOption.READ)) { this.memory = ch.map(MapMode.READ_ONLY, 0L, ch.size(), arena); - + // Apply MADV_RANDOM advice var linker = Linker.nativeLinker(); var maybeMadvise = linker.defaultLookup().find("posix_madvise"); @@ -176,6 +178,39 @@ public Supplier(Path path) throws IOException { } } + // Windowed prefetch streams ranges through a separate read-ahead-enabled file descriptor: + // the mapping itself advises MADV_RANDOM (disables kernel readahead), and MADV_WILLNEED + // proved to be a no-op hint for large ranges. The page cache is shared per file, so + // subsequent random access through the mapping hits the populated pages. The bytes read + // here are discarded, so the buffer is sized to stay L2-resident rather than for syscall + // amortization (cold throughput is device-bound at any size >= 32KB, and the warm case + // is within noise of larger buffers at 64KB). + private static final ThreadLocal PREFETCH_BUF = + ThreadLocal.withInitial(() -> java.nio.ByteBuffer.wrap(new byte[64 << 10])); + + @Override + public void prefetch(long offset, long length) { + if (length <= 0) { + return; + } + var buf = PREFETCH_BUF.get(); + long end = Math.min(offset + length, memory.byteSize()); + try (var ch = FileChannel.open(path, StandardOpenOption.READ)) { + long pos = Math.max(0, offset); + while (pos < end) { + buf.clear().limit((int) Math.min(buf.capacity(), end - pos)); + int n = ch.read(buf, pos); + if (n < 0) { + break; + } + pos += n; + } + } catch (IOException e) { + logger.warn("ranged prefetch of {} [{}, {}) failed; continuing without warm cache", + path, offset, end, e); + } + } + @Override public MemorySegmentReader get() { return new MemorySegmentReader(memory); From 60ca9968eb1f057466aad75c73d00760e36b5114 Mon Sep 17 00:00:00 2001 From: "Dian-Lun (Aaron) Lin" Date: Sat, 18 Jul 2026 00:21:06 +0000 Subject: [PATCH 4/5] Compaction: use searchTopK as rerankK for L0 cross-source searches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The L0 cross-source candidate search passed beamWidth (= 2x searchTopK) as rerankK, doubling the approximate-phase beam over what the candidate budget needs. A seeding-vs-beam decomposition study (7 paired disk-cold arms, cohere-10M, median-of-3) showed the narrower beam is where the time goes: S=2: L0 165.2s -> 133.4s (-19%), recall 0.5718 -> 0.5660 S=4: L0 260.3s -> 224.6s (-14%), recall 0.5733 -> 0.5659 Query latency on the merged index is unaffected (0.55ms avg both ways). The wider beam's extra candidates were largely pruned by diversity selection, which keeps at most degree edges per node. The same study found warm-start seeding of these searches (from finished neighbors' merged adjacency, reverse candidates, or upper-layer descent) is net-negative: at matched beam width, seeded searches run 8-17% slower with equal recall — the per-search seeding overhead exceeds the few cheap descent hops it saves. Beam width is the whole lever. --- .../jvector/graph/disk/OnDiskGraphIndexCompactor.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java index bf254340d..bb1a8362d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java @@ -1222,8 +1222,11 @@ private int gatherFromOtherSource(int node, int level, int sourceIdx, ); if (level == 0) { + // rerankK = searchTopK, not beamWidth: the wider beam's extra candidates are largely + // pruned by diversity selection, so the doubled approximate-phase cost buys almost + // no recall. SearchResult results = scratch.gs[sourceIdx].search( - ssp, params.searchTopK, params.beamWidth, 0f, 0f, indexAlive + ssp, params.searchTopK, params.searchTopK, 0f, 0f, indexAlive ); for (var r : results.getNodes()) { From 8324dd6d8ec3a1b4cc8fbc39ccee2df5f9d1b524 Mon Sep 17 00:00:00 2001 From: "Dian-Lun (Aaron) Lin" Date: Sat, 18 Jul 2026 00:22:08 +0000 Subject: [PATCH 5/5] Compaction: make post-compaction refinement optional (default off) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refinement ablations (three datasets, disk-cold, paired same-window runs) show its recall contribution on the merged index is ~0: same-beam arms with and without refinement land within 0.001. Skipping it saves 45-58s, ~20-25% of total compaction time at 10M nodes. What it buys is navigability — query latency on the merged index rises from ~0.55ms to ~0.95ms avg (p99 2.0ms to 3.9ms, cohere-10M) without it. That is a workload tradeoff, not a correctness call: default to compaction throughput, and let latency-sensitive pipelines opt back in with setRefineAfterCompaction(true). --- .../graph/disk/OnDiskGraphIndexCompactor.java | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java index bb1a8362d..be8ae8c7e 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndexCompactor.java @@ -84,6 +84,19 @@ public final class OnDiskGraphIndexCompactor implements Accountable { private final ForkJoinPool executor; private final int taskWindowSize; private final VectorSimilarityFunction similarityFunction; + private boolean refineAfterCompaction = false; + + /** + * Whether to run the second-pass neighbor refinement after the merged graph is written + * (default false). Refinement is a navigability pass: it has no measurable effect on + * recall, but it improves query latency on the merged index at the cost of a significant + * fraction of total compaction time. Enable it when search latency matters more than + * compaction throughput. + */ + @Experimental + public void setRefineAfterCompaction(boolean refineAfterCompaction) { + this.refineAfterCompaction = refineAfterCompaction; + } /** * Constructs a new OnDiskGraphIndexCompactor for graphs without a non-fused compressed sidecar. * Equivalent to calling the 6-arg constructor with {@code sourceCompressed = null}. @@ -297,7 +310,9 @@ public void compact(Path outputPath) throws FileNotFoundException { try { compactGraphImpl(outputPath, strategy); releaseSourcesBeforeRefine(strategy); - refineCompactedGraph(outputPath, strategy); + if (refineAfterCompaction) { + refineCompactedGraph(outputPath, strategy); + } } finally { // Delayed until after refinement so refineCompactedGraph can read from the pre-encoded // code cache appended past the projected EOF; onAfterClose unmaps it and truncates. @@ -329,7 +344,9 @@ public void compact(Path graphPath, Path compressedPath) throws FileNotFoundExce try { sidecarStrategy.retrain(similarityFunction); compactGraphImpl(graphPath, inlineStrategy); - refineCompactedGraph(graphPath, inlineStrategy); + if (refineAfterCompaction) { + refineCompactedGraph(graphPath, inlineStrategy); + } sidecarStrategy.writeSidecar(compressedPath); } catch (IOException e) { throw new RuntimeException("Sidecar compaction failed", e);