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

   ### Description
   
   When `IncrementalHnswGraphMerger` reuses an existing segment's HNSW graph as 
the base for a merged segment (#15003), a surviving node's neighbor list is 
copied minus its deleted neighbors. The node is then repaired only if it lost 
more than 15% of its neighbors *in that single merge* 
(`DISCONNECTED_NODE_FACTOR = 0.85`). That threshold resets every merge, so a 
node that sheds only a small fraction each time never trips it. Its out-degree 
decays across successive merges and approximate-search recall drops with it. 
The effect is worst under continuous low-rate deletes, where an index 
accumulates a few percent of deletions between merges.
   
   This adds a second, cumulative check in 
`InitializedHnswGraphBuilder.copyGraphStructure`: a node that lost at least one 
neighbor this merge and whose out-degree has fallen below 
`CUMULATIVE_DEGREE_FLOOR_FACTOR` (0.5) of the level's connection budget (`2*M` 
at level 0, `M` above) is flagged for repair. Anchoring on the fixed per-level 
budget lets the check see decay that accumulates across merges, which the 
existing prior-degree-relative check cannot. Gating on an actual neighbor loss 
means a node that is simply sparse and lost nothing is never touched, so 
healthy graphs are unaffected and unnecessary work is not done. The repair path 
itself is unchanged.
   
   ### Benchmarks
   
   **Setup:** luceneutil, GloVe-100, `M=32`, `beamWidth=250`, 2% deletes per 
generation, 100 generations, deterministic base. 
   
   "current" is `main`; "rebuild" is the same build with reuse disabled 
(`DELETE_PCT_THRESHOLD=-1`), i.e. a full rebuild every merge, which is the 
ground truth the reused graph is measured against. 
   
   Recall is reported as Δ vs rebuild on the identical per-generation live set; 
out-degree is the level-0 mean.
   
   **Delete-only** (each generation deletes 2% of the live docs, 100k starting 
docs, ~13k ending docs):
   
   | `CUMULATIVE_DEGREE_FLOOR_FACTOR` | Δ recall@100 (gen 50 / gen 100) | L0 
out-degree (gen 100) | force-merge time / gen |
   |---|---|---|---|
   | current (`main`) | −7.0 / −5.6 pp | 15.5 | 2.0 s |
   | 0.35 | −1.6 / −0.3 | 26.2 | 8.1 s |
   | 0.40 | −1.1 / +0.2 | 28.0 | 9.3 s |
   | 0.45 | −0.8 / +0.5 | 29.5 | 11.2 s |
   | **0.50** | **−0.4 / +0.7** | **30.7** | **12.4 s** |
   | 0.55 | +0.1 / +0.9 | 32.0 | 14.7 s |
   | 0.60 | +0.4 / +0.9 | 32.8 | 16.0 s |
   | 0.85 | +0.7 / +1.3 | 34.7 | 21.2 s |
   | rebuild (control) | 0 | 34.7 | 41.8 s |
   
   **Churn** (each generation deletes 2% and re-adds the same count randomly, 
holding the corpus at 100k, so each gen is full-scale):
   
   | `CUMULATIVE_DEGREE_FLOOR_FACTOR` | Δ recall@100 (gen 50 / gen 100) | L0 
out-degree (gen 100) |
   |---|---|---|
   | current (`main`) | −2.7 / −2.9 pp | 14.3 |
   | 0.40 | −0.1 / −0.2 | 21.8 |
   | **0.50** | **+0.3 / +0.2** | **23.2** |
   | rebuild (control) | 0 | 26.5 |
   
   Reuse-on out-degree decays geometrically (28 → 15 delete-only), and recall 
diverges from the full-rebuild graph as merges accumulate. Recall recovers as 
the floor rises and saturates around 0.5; higher floors add merge cost for no 
further recall. At 0.5 the merged graph matches full-rebuild recall on both 
workloads while merging at roughly a third of a full rebuild's cost. The small 
positive Δ values are within run-to-run noise, since the rebuild graph is 
itself approximate.
   
   ### Visual
   
   <img width="2009" height="1430" alt="16552-benchmarks" 
src="https://github.com/user-attachments/assets/c9d53086-258e-46f9-9c8d-751e40b86dd3";
 />
   
   ### Tuning
   
   `CUMULATIVE_DEGREE_FLOOR_FACTOR = 0.5` was chosen in part due to 
benchmarking results, but also due to intuition about the distribution of the 
nodes by neighbor connections. 
   
   The results show that `0.5` is the point which best balances the tradeoff 
between recall and merge cost, the below table shows the 'Kneedle score', which 
finds the perpendicular distance of each point above the endpoint chord of the 
(merge cost, recall) curve on min-max-normalized axes. The point with the 
highest score is the knee, which balances the tradeoff with the least 
diminishing returns. 
   
   | FLOOR | 0.35 | 0.4 | 0.45 | **0.5** | 0.55 | 0.6 | 0.65 | 0.85 |
   |---|---|---|---|---|---|---|---|---|
   | Δ recall vs rebuild (pp) | −1.38 | −0.64 | −0.57 | **−0.04** | +0.15 | 
+0.19 | +0.60 | +0.70 |
   | merge cost (s, full corpus) | 8.1 | 9.3 | 11.2 | **12.4** | 14.7 | 16.0 | 
18.1 | 21.2 |
   | Kneedle score | 0.000 | 0.188 | 0.109 | **0.225** | 0.166 | 0.109 | 0.136 
| 0.000 |
   
   These results make sense intuitively when we consider the insertion 
heuristic, which targets up to M connections per node on the upper levels and 
2M at level 0: measured against that level-0 budget of 2M, a factor of 0.5 
places the repair trigger at exactly M, the graph's per-node connection target. 
   
   A node whose degree has decayed below M across successive merges has fallen 
beneath the connectivity the builder aims for, so repairing there restores it 
to a natural degree. Going lower waits until nodes are badly starved before 
acting (recall keeps sliding), while going higher spends merge cost 
re-connecting nodes that are still within the range the build naturally 
produces.
   
   ### Testing
   
   `./gradlew check` passes. Adds 
`TestHnswFloatVectorGraph#testReconnectsNodeDecayedBelowFloorDuringMerge`, a 
deterministic (fixed-seed) test that drives a single reuse-merge on a node 
engineered to sit just below the cumulative floor. It fails on the pre-fix 
builder (the node stays thinned) and passes with the fix (the node is 
reconnected).
   
   Closes #16552


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