msokolov commented on code in PR #16627:
URL: https://github.com/apache/lucene/pull/16627#discussion_r3934745043
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/InitializedHnswGraphBuilder.java:
##########
@@ -352,105 +411,36 @@ private void repairDisconnectedNodes(
}
}
- /**
- * Fixes disconnected nodes at a specific level by performing graph searches
from their existing
- * neighbors to find additional connections.
- *
- * <p>For each disconnected node:
- *
- * <ol>
- * <li>Use existing neighbors as entry points for graph search
- * <li>Search the level to find candidate neighbors
- * <li>Add diverse neighbors using the HNSW heuristic selection algorithm
- * </ol>
- *
- * <p>If a node has no neighbors at all, it cannot be repaired at this level
and will rely on the
- * rebalancing phase.
- *
- * @param disconnectedNodes list of node ordinals that need additional
neighbors
- * @param level the level at which to repair connections
- * @param scorer vector similarity scorer for distance calculations
- * @throws IOException if an I/O error occurs during search operations
- */
- private void fixDisconnectedNodes(
- List<Integer> disconnectedNodes, int level, UpdateableRandomVectorScorer
scorer)
- throws IOException {
- if (disconnectedNodes.isEmpty()) return;
-
- int beamWidth = beamCandidates.k();
- GraphBuilderKnnCollector candidates = new
GraphBuilderKnnCollector(beamWidth);
- NeighborArray scratchArray = new NeighborArray(beamWidth, false);
-
- for (int node : disconnectedNodes) {
- maybeAbort();
- scorer.setScoringOrdinal(node);
- NeighborArray existingNeighbors = hnsw.getNeighbors(level, node);
-
- // Only repair if node has at least one neighbor to use as entry point
- if (existingNeighbors.size() > 0) {
- // Use all existing neighbors as entry points for search
- int[] entryPoints = new int[existingNeighbors.size()];
- System.arraycopy(existingNeighbors.nodes(), 0, entryPoints, 0,
existingNeighbors.size());
-
- // Search from entry points to find candidate neighbors
- graphSearcher.searchLevel(candidates, scorer, level, entryPoints,
hnsw, null);
- popToScratch(candidates, scratchArray);
-
- // Add diverse neighbors using HNSW heuristic (prunes similar
neighbors)
- addDiverseNeighbors(level, node, scratchArray, scorer, true);
- } else {
- // Node has no nighbors, add connections from scratch
- addConnections(node, level, scorer);
- }
-
- // Clear for next iteration
- scratchArray.clear();
- candidates.clear();
- }
- }
-
/**
* Rebalances the graph hierarchy by promoting nodes from lower levels to
higher levels to
- * maintain the expected exponential decay in level sizes according to the
HNSW probabilistic
- * model.
- *
- * <p>The expected number of nodes at each level follows the formula: <br>
- * {@code maxNodesAtLevel = totalNodes * (1/M)^level}
- *
- * <p>For each level that has fewer nodes than expected, this method
randomly promotes nodes from
- * the level below with probability 1/M until the target count is reached.
- *
- * <p>This rebalancing is necessary during merging graph where deletions may
have disrupted the
- * proper hierarchical distribution, which could degrade semantic matches
quality.
+ * maintain the expected exponential decay in level sizes ({@code totalNodes
* (1/M)^level}) after
+ * deletions disrupted the distribution during a merge-reuse. For each
under-populated level,
+ * nodes from the level below are promoted with probability {@code 1/M}.
*
* @throws IOException if an I/O error occurs during node promotion
*/
- private void rebalanceGraph() throws IOException {
- SplittableRandom random = new SplittableRandom();
+ void rebalanceGraph() throws IOException {
+ SplittableRandom random = new SplittableRandom(seed);
int size = hnsw.size();
double invMaxConn = 1.0 / M;
// Process each level starting from level 1 (level 0 always contains all
nodes)
for (int level = 1; ; level++) {
- // Calculate expected number of nodes at this level
Review Comment:
why did we remove all the comments in this method?
##########
lucene/core/src/test/org/apache/lucene/util/hnsw/TestHnswFloatVectorGraph.java:
##########
@@ -188,6 +218,282 @@ public void testAbortCheckInterruptsGraphInitAndRepair()
throws IOException {
}
}
+ /**
Review Comment:
how long do these tests take to run? We try to keep unit tests fast
(definitely less than 1 second each). If they're slower than that we should
reduce iteration counts, nuymber of documents, etc, if possible while still
retaining the value of the test. Otherwise if we need a heavy test we can also
mark it @Nightly to run it less frequently
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:
##########
@@ -117,6 +155,40 @@ public OnHeapHnswGraph build(int maxOrd) throws
IOException {
return getCompletedGraph();
}
+ /**
+ * Repairs the copied graph's disconnected nodes across the worker pool, one
level at a time from
+ * the top down. The per-level {@link TaskExecutor#invokeAll} is a barrier,
so {@link
Review Comment:
a barrier to what? This comment is confusing to me -- can you expand/explain?
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:
##########
@@ -117,6 +155,40 @@ public OnHeapHnswGraph build(int maxOrd) throws
IOException {
return getCompletedGraph();
}
+ /**
+ * Repairs the copied graph's disconnected nodes across the worker pool, one
level at a time from
+ * the top down. The per-level {@link TaskExecutor#invokeAll} is a barrier,
so {@link
+ * HnswGraphBuilder#addConnections} always navigates finished upper levels.
+ */
+ private void repairDisconnectedNodes() throws IOException {
+ for (int level = copied.numLevels() - 1; level >= 0; level--) {
+ List<Integer> disconnectedNodes =
copied.disconnectedNodesByLevel().get(level);
+ if (disconnectedNodes == null || disconnectedNodes.isEmpty()) {
+ continue;
+ }
+ int total = disconnectedNodes.size();
+ int taskCount = Math.min(workers.length, 1 + (total - 1) /
REPAIR_BATCH_SIZE);
Review Comment:
please add a comment explaining the logic here -- I think we are trying to
divide the disconnected nodes evenly amopng worker threads?
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:
##########
@@ -419,8 +419,21 @@ void addDiverseNeighbors(
*/
NeighborArray neighbors = hnsw.getNeighbors(level, node);
int maxConnOnLevel = level == 0 ? M * 2 : M;
- boolean[] mask =
- selectAndLinkDiverse(node, neighbors, candidates, maxConnOnLevel,
scorer, isLinkRepair);
+ boolean[] mask;
+ if (isLinkRepair && hnswLock != null) {
Review Comment:
do we need the `hnswLock` null check? Shouldn't it always be non-null in the
case of link repair?
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:
##########
@@ -117,6 +155,40 @@ public OnHeapHnswGraph build(int maxOrd) throws
IOException {
return getCompletedGraph();
}
+ /**
+ * Repairs the copied graph's disconnected nodes across the worker pool, one
level at a time from
+ * the top down. The per-level {@link TaskExecutor#invokeAll} is a barrier,
so {@link
Review Comment:
I think it means we guarantee that each level is completed before the next
level is started?
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:
##########
@@ -419,8 +419,21 @@ void addDiverseNeighbors(
*/
NeighborArray neighbors = hnsw.getNeighbors(level, node);
int maxConnOnLevel = level == 0 ? M * 2 : M;
- boolean[] mask =
- selectAndLinkDiverse(node, neighbors, candidates, maxConnOnLevel,
scorer, isLinkRepair);
+ boolean[] mask;
+ if (isLinkRepair && hnswLock != null) {
Review Comment:
I think the comment about "release before the per-neighbor loop" is
gratuitous - we always want locks to cover the minimum scope
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/InitializedHnswGraphBuilder.java:
##########
@@ -229,6 +232,62 @@ private InitializedHnswGraphBuilder(
throws IOException {
super(scorerSupplier, beamWidth, seed, initializedGraph);
this.initializedNodes = initializedNodes;
+ this.seed = seed;
+ }
+
+ /**
+ * Copies the initializer graph without repairing or rebalancing it,
returning the copied graph
+ * plus the state a caller needs to run those phases itself.
+ *
+ * @param scorerSupplier provides vector similarity scoring for graph
operations
+ * @param beamWidth the search beam width for graph construction
+ * @param initializerGraph the source graph to copy structure from
+ * @param newOrdMap maps old ordinals to new ordinals; -1 indicates deleted
documents
+ * @param totalNumberOfVectors the total number of vectors in the merged
graph
+ * @param abortCheck optional check invoked during the copy; may be null
+ * @return the copied graph and its deferred repair/rebalance state
+ * @throws IOException if an I/O error occurs during the copy
+ */
+ static CopiedGraph copyGraph(
+ RandomVectorScorerSupplier scorerSupplier,
+ int beamWidth,
+ HnswGraph initializerGraph,
+ int[] newOrdMap,
+ int totalNumberOfVectors,
+ IORunnable abortCheck)
+ throws IOException {
+ InitializedHnswGraphBuilder builder =
+ new InitializedHnswGraphBuilder(
+ scorerSupplier,
+ beamWidth,
+ randSeed,
+ new OnHeapHnswGraph(initializerGraph.maxConn(),
totalNumberOfVectors),
+ null);
+ if (abortCheck != null) {
+ builder.setAbortCheck(abortCheck);
+ }
+ Map<Integer, List<Integer>> disconnectedNodesByLevel =
Review Comment:
Could we also take this opportunity to replace the boxed Integers with
primitives? Lucene has an IntsRefBuilder designed for this purpose -- also, the
disconnectedNodesByLevel can be an array -- we don't need a map for this
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:
##########
@@ -90,6 +111,23 @@ public OnHeapHnswGraph build(int maxOrd) throws IOException
{
worker.setMergeStartTimeNs(mergeStartTimeNs);
worker.setCumulativeWorkTimeNs(cumulativeWorkTimeNs);
}
+ if (copied != null && copied.hasDeletes()) {
Review Comment:
Can we make it so `copied.hasDeletes()` is always true, or equivalently,
that `copied` is always null when there are no deletes?
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:
##########
@@ -42,10 +42,12 @@ public class HnswConcurrentMergeBuilder implements
HnswBuilder {
private static final int DEFAULT_BATCH_SIZE =
2048; // number of vectors the worker handles sequentially at one batch
+ private static final int REPAIR_BATCH_SIZE = 64;
Review Comment:
please add a comment explaining
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/InitializedHnswGraphBuilder.java:
##########
@@ -229,6 +232,62 @@ private InitializedHnswGraphBuilder(
throws IOException {
super(scorerSupplier, beamWidth, seed, initializedGraph);
this.initializedNodes = initializedNodes;
+ this.seed = seed;
+ }
+
+ /**
+ * Copies the initializer graph without repairing or rebalancing it,
returning the copied graph
+ * plus the state a caller needs to run those phases itself.
+ *
+ * @param scorerSupplier provides vector similarity scoring for graph
operations
+ * @param beamWidth the search beam width for graph construction
+ * @param initializerGraph the source graph to copy structure from
+ * @param newOrdMap maps old ordinals to new ordinals; -1 indicates deleted
documents
+ * @param totalNumberOfVectors the total number of vectors in the merged
graph
+ * @param abortCheck optional check invoked during the copy; may be null
+ * @return the copied graph and its deferred repair/rebalance state
+ * @throws IOException if an I/O error occurs during the copy
+ */
+ static CopiedGraph copyGraph(
Review Comment:
Let's rename this method and the associated record. We're not so much
copying the graph (the new one is different) as we are removing deletes.
Maybe `pruneGraph` and `PrunedGraph` would be better?
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:
##########
@@ -117,6 +155,40 @@ public OnHeapHnswGraph build(int maxOrd) throws
IOException {
return getCompletedGraph();
}
+ /**
+ * Repairs the copied graph's disconnected nodes across the worker pool, one
level at a time from
+ * the top down. The per-level {@link TaskExecutor#invokeAll} is a barrier,
so {@link
+ * HnswGraphBuilder#addConnections} always navigates finished upper levels.
+ */
+ private void repairDisconnectedNodes() throws IOException {
+ for (int level = copied.numLevels() - 1; level >= 0; level--) {
+ List<Integer> disconnectedNodes =
copied.disconnectedNodesByLevel().get(level);
+ if (disconnectedNodes == null || disconnectedNodes.isEmpty()) {
Review Comment:
it should be either one or the other -- let's decide how we communicate "no
deleted nodes on level" -- probably null and we shouldn't need this defensive
check for isEmpty
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:
##########
@@ -665,6 +678,94 @@ private void link(int level, int n0, int n1, float score,
FixedBitSet notFullyCo
}
}
+ /**
+ * Fixes disconnected nodes at a specific level by searching from each
node's existing neighbors
Review Comment:
Let's preserve the javadocs from the original implementation please, unless
we (a human, not the AI, please) read them and think they need correcting.
Honestly I'm finding it really tedious reviewing the AI's javadocs. Could you
please take a pass over them all yourself and see if you think they are OK
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:
##########
@@ -117,6 +155,40 @@ public OnHeapHnswGraph build(int maxOrd) throws
IOException {
return getCompletedGraph();
}
+ /**
+ * Repairs the copied graph's disconnected nodes across the worker pool, one
level at a time from
+ * the top down. The per-level {@link TaskExecutor#invokeAll} is a barrier,
so {@link
+ * HnswGraphBuilder#addConnections} always navigates finished upper levels.
+ */
+ private void repairDisconnectedNodes() throws IOException {
+ for (int level = copied.numLevels() - 1; level >= 0; level--) {
+ List<Integer> disconnectedNodes =
copied.disconnectedNodesByLevel().get(level);
+ if (disconnectedNodes == null || disconnectedNodes.isEmpty()) {
+ continue;
+ }
+ int total = disconnectedNodes.size();
+ int taskCount = Math.min(workers.length, 1 + (total - 1) /
REPAIR_BATCH_SIZE);
Review Comment:
the integer math is a little confusing
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:
##########
@@ -665,6 +678,94 @@ private void link(int level, int n0, int n1, float score,
FixedBitSet notFullyCo
}
}
+ /**
+ * Fixes disconnected nodes at a specific level by searching from each
node's existing neighbors
+ * to find additional connections. A node with no neighbors is instead
connected from scratch via
+ * {@link #addConnections}.
+ *
+ * <p>When {@link #hnswLock} is set (concurrent repair) each node's existing
neighbors are
Review Comment:
I think `hnswLock` is always non-null, isn't it? If so we don't need these
two branches below
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:
##########
@@ -665,6 +678,94 @@ private void link(int level, int n0, int n1, float score,
FixedBitSet notFullyCo
}
}
+ /**
+ * Fixes disconnected nodes at a specific level by searching from each
node's existing neighbors
Review Comment:
sorry if you did that, but I'm finding enough things that sounds like AI to
me ...
##########
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:
##########
@@ -665,6 +678,94 @@ private void link(int level, int n0, int n1, float score,
FixedBitSet notFullyCo
}
}
+ /**
+ * Fixes disconnected nodes at a specific level by searching from each
node's existing neighbors
+ * to find additional connections. A node with no neighbors is instead
connected from scratch via
+ * {@link #addConnections}.
+ *
+ * <p>When {@link #hnswLock} is set (concurrent repair) each node's existing
neighbors are
Review Comment:
It's only ever called from `HnswConcurrentMergeBuilder`. This method should
be in that class to make this clearer.
--
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]