Skip to content

Compare live vector counts when picking the HNSW merge base graph - #16739

Open
LantaoJin wants to merge 4 commits into
apache:mainfrom
LantaoJin:fix-hnsw-merge-base-live-count
Open

LantaoJin wants to merge 4 commits into
apache:mainfrom
LantaoJin:fix-hnsw-merge-base-live-count

Conversation

@LantaoJin

Copy link
Copy Markdown

Description

IncrementalHnswGraphMerger#addReader picks the base graph for a merge with candidateVectorCount > largestGraphReader.graphSize. The left side is the candidate's live vector count. The right side is the current base's total node count, which includes deleted nodes. Before #15003 the two were always equal, because graphs with deletions were never admitted as the base. Since #15003, a graph with up to 40% deletions can become the base, so the comparison mixes units.

As a result, which graph becomes the base depends on the order in which readers are added. Example: segment A has 1000 nodes with 300 deleted (700 live), and segment B has 900 nodes with no deletions. If A is added first, B would need 900 > 1000 to replace it, so A stays the base even though B has more live vectors. If B is added first, B is chosen. The merged graph is still correct either way, so this is a performance issue, not a correctness issue. With a worse base, more nodes must be joined or inserted. This hurts most in ConcurrentHnswMerger, which initializes only from the base graph and inserts every other node one by one. MergingHnswGraphBuilder joins the other deletion-free graphs, so the effect there is smaller.

The fix adds a liveVectorCount component to the GraphReader record and compares live against live. graphSize stays the total node count, because getNewOrdMapping uses it to size the old-to-new ordinal arrays. Ties still keep the earlier reader. ConcurrentHnswMerger inherits addReader, so it is covered too. GraphReader is a protected record, so adding a component changes its canonical constructor. Nothing in Lucene constructs it outside IncrementalHnswGraphMerger.

IncrementalHnswGraphMerger#addReader compared a candidate's live vector
count against the current base's total node count (graphSize), which
includes deleted nodes since GITHUB#15003 admitted graphs with deletes as
the base. A graph with deletions could therefore stay the base over a
larger deletion-free graph, depending on reader order.

Track the live count separately in GraphReader and compare live against
live; graphSize stays the total node count used for array sizing.
/** Represents a vector reader that contains graph info. */
/**
* Represents a vector reader that contains graph info.
*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While you are here, let's add params for the other members as well here for completeness?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, added @param for reader and initDocMap as well.

Comment thread lucene/CHANGES.txt Outdated
Optimizations
---------------------

* GITHUB#16739: IncrementalHnswGraphMerger now picks the base graph for a merge by comparing live

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should go into Bug Fixes (But I am not sure so no strong opinion).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

try (IndexWriter w = new IndexWriter(dir, cfg)) {
addSegment(w, "a", 1000);
addSegment(w, "b", 900);
for (int i = 0; i < 300; i++) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are trying to keep the delete% under 40% which is set in IncrementalHnswGraphMerger.java.DELETE_PCT_THRESHOLD. What is that threshold changes, this can make the test case buggy as it can fail/succeed for wrong reasons.

We should derive the delete% here based on IncrementalHnswGraphMerger.java.DELETE_PCT_THRESHOLD instead of hardcoding.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Fixed.

cfg.setCodec(TestUtil.alwaysKnnVectorsFormat(new Lucene99HnswVectorsFormat(16, 100, 0)));
cfg.setMergePolicy(NoMergePolicy.INSTANCE);
try (IndexWriter w = new IndexWriter(dir, cfg)) {
addSegment(w, "a", 1000);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we make constant variable as we are using same value across multiple places?

private static final int A_DOCS = 1000;
private static final int B_DOCS = 900;
private static final String FIELD_NAME = "id";

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

IncrementalHnswGraphMerger merger =
concurrent
? new ConcurrentHnswMerger(fieldInfo, null, 16, 100, null, 2)
: new IncrementalHnswGraphMerger(fieldInfo, null, 16, 100);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we move the constant from here as well i.e M and beamWidth?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

* Tests which source graph {@link IncrementalHnswGraphMerger} picks as the base graph when some
* candidates carry deletions.
*/
public class TestIncrementalHnswGraphMergerBaseSelection extends LuceneTestCase {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non blocking: Should we make the test more robust which covers more scenario and not just live(A) < live(B) with total(A) > total(B)?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added testBaseGraphHasMostLiveVectorsAmongEligible: 2-6 segments with random sizes and deletion counts (about half of them within the threshold), readers added in 5 shuffled orders, on both IncrementalHnswGraphMerger and ConcurrentHnswMerger.

…zed test

- Make DELETE_PCT_THRESHOLD a package-private static constant so the test
  can derive its deletion counts from it instead of hardcoding them.
- Add a randomized test: random segment sizes and deletion counts, readers
  added in shuffled order, for both mergers.
- Name the test constants, document every GraphReader component.
- Move the CHANGES entry to Bug Fixes.
@Pulkitg64

Copy link
Copy Markdown
Contributor

We should try to send this PR in 10.6 release. I don't have permission to add the milestone.
Tagging @kaivalnp for the help here since he is release manager for 10.6 branch.

@kaivalnp kaivalnp added this to the 10.6.0 milestone Oct 1, 2026
@kaivalnp

kaivalnp commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

LGTM at first glance, assuming we want to start with a graph having the highest live vector count.

However, I'm not super familiar with the original change, and it would be great to get this reviewed by someone who is!

Adding the 10.6 milestone for now, given it is a small fix.

@Pulkitg64 Pulkitg64 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @LantaoJin! The new changes looks good to me.

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