Skip to content

Use a recall-based check in TestCuVSRandomizedHNSWVectorSearch - #2824

Merged
rapids-bot[bot] merged 1 commit into
NVIDIA:mainfrom
bdice:fix-lucene-randomized-hnsw-recall
Oct 14, 2026
Merged

rapids-bot[bot] merged 1 commit into
NVIDIA:mainfrom
bdice:fix-lucene-randomized-hnsw-recall

Conversation

@bdice

@bdice bdice commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

TestCuVSRandomizedHNSWVectorSearch.testVectorSearch required every hit of one query to be among the closest 3 * topK vectors. The search is approximate: on this test's data (uniform random vectors with up to 2048 dimensions), a topK = 1 to 3 search sometimes returns a valid neighbor just outside that window, typically about 1% farther away than the nearest neighbor.

The test now checks all the queries and allows 2 such misses in total. It keeps hard checks that every result is a distinct document with a vector, scored by its own vector, in score order, with min(topK, vectors) results. This reuses the assertSearchFindsNearestNeighbors helper from #2775, extended for indexes where some documents have no vector. TOP_K_LIMIT is raised to 128, since the search does not fail beyond 64.

Measurements and the reproducer are in #2819. Part of #2783.

The test required every hit of a single query to be among the closest
3 * topK vectors. The search is approximate, and on this test's data
(uniform random vectors with up to 2048 dimensions) a top-1 to top-3
search occasionally returns a valid neighbor just outside that window.
Check all queries instead, allow 2 such misses in total, and keep hard
checks that every result is a distinct document with a vector, scored by
its own vector, in score order, with min(topK, vectors) results.

Also raise TOP_K_LIMIT to 128: the search does not fail beyond 64.
@copy-pr-bot

copy-pr-bot Bot commented Oct 11, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@bdice

bdice commented Oct 11, 2026

Copy link
Copy Markdown
Contributor Author

/ok to test 37f4477

@bdice bdice left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a test-only change for TestCuVSRandomizedHNSWVectorSearch.testVectorSearch, which failed with Result returned was not in topK*3: 229 (CAGRA-built HNSW graph, searched on the CPU). It is workstream O of #2783. The combined stress run found it once. In the reproducer #2819, the unmodified test looped with random seeds failed 3 times in 1586 runs, and seed 2A18DA9484B33896 failed in 3 of 12 replays across amd64 and arm64 (run).

  • Root cause: a legitimate approximate-search miss, not a format bug (analysis). The failing hit was a valid document at rank 8 for topK = 2, only 1.2% farther away than the true nearest neighbor. The graph was healthy (every node reachable, no invalid ids). Lucene searches each segment with a beam as wide as topK, and on uniform random vectors with up to 2048 dimensions that beam is nearly greedy for topK <= 3. The old assertion required every hit of a single query to be in the closest 3 * topK.
  • Fix: check all the queries with the shared assertSearchFindsNearestNeighbors helper from #2775, and allow 2 hits outside 3 * topK in total. It also adds hard checks for the invariants that would catch a real format bug.
  • Evidence (after run, summary): looped with random seeds, the test failed 0 times in 2166 runs, and both failing seeds passed 20/20 replays. On 16,553 random indexes, the new check failed 0 times where the old one would have failed 7 times, and the full cuvs-lucene suite passed on all 4 jobs.

Comment on lines +128 to +140
// The search is approximate. Lucene searches each segment's HNSW graph with a beam as wide as
// topK, and in this data (uniform random vectors with up to 2048 dimensions, so all distances
// are within about 10% of each other) a narrow beam sometimes stops just short of the true
// neighbors. Over 27,003 random indexes from this test's distribution, with 10 queries each, a
// result fell outside the closest 3 * topK in 1.7% of queries at topK = 1, 0.21% at topK = 2,
// 0.03% at topK = 3, 3 times in 270,000 queries at topK = 5, and never at topK >= 8. Those
// results were at most rank 17, and typically only 1.3% farther away than the nearest
// neighbor. On the same segments, Lucene's own HNSW format missed in 22% of queries at
// topK = 1. Allowing 2 misses across all the queries keeps the false failure rate below 1e-5
// per run.
int allowedMisses = 2;
assertSearchFindsNearestNeighbors(
searcher, "vector", "id", dataset, hasVector, queries, topK, allowedMisses);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This replaces the per-hit check on query 0 only (main) with a budget over all the queries. The numbers in the comment come from the #2819 sweeps (before, after). The new check didn't fail on any of the 16,553 sweep indexes, but it still failed on Lucene's own Lucene99HnswVectorsFormat built on the same segments (32 times with default parameters, 29 with maxConn = beamWidth = 32). So it can still tell a weaker graph from this one. #2775 uses the same helper with allowedMisses = 4 and 10 in the two TestCagraToHnswSerializationAndSearch* tests (example).

Question for reviewers: is this recall threshold appropriate? It allows 2 hits outside the closest 3 * topK, counted across all 1-10 queries of a run, no matter what topK is. I calibrated the budget for the small-topK cases where misses happen. A fixed count is lenient at large topK, where there are up to 128 results per query and the sweep never saw a miss. The alternatives I see are a mean-recall floor, or a budget that scales with numQueries * topK. The "below 1e-5 per run" figure is extrapolated from the per-query miss rates above. The direct measurement is 0 failures in 16,553 sweep indexes and 2166 test runs.

Comment on lines +56 to +58
// A wider search is more accurate, not less: in the measurements described in testVectorSearch,
// no result was outside the closest 3 * topK for any topK from 8 to 200.
static int TOP_K_LIMIT = 128;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The old TODO said "This fails beyond 64" (main). I couldn't find a failure there: in the #2819 sweep, topK = 89, 128 and 200 had 0 hits outside 3 * topK, mean recall was 1.000, and the worst per-query recall was 0.989 (comment). testVectorSearchWithFilter also uses this limit, but its filter matches a single document, so a larger topK doesn't change what it checks (L163-L177).

Question for reviewers: the TODO doesn't say what failed beyond 64. If anyone remembers the original failure, please check whether this change would bring it back.

Comment on lines +107 to +124
public static void assertSearchFindsNearestNeighbors(
IndexSearcher searcher,
String vectorField,
String idField,
float[][] dataset,
boolean[] indexed,
float[][] queries,
int topK,
int allowedMisses)
throws IOException {
int indexedCount = dataset.length;
if (indexed != null) {
indexedCount = 0;
for (boolean isIndexed : indexed) {
if (isIndexed) indexedCount++;
}
}
int expectedResults = Math.min(topK, indexedCount);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This test leaves the vector out of about 40% of the documents when the dataset has 100 or more rows (L88-L96), but the existing helper assumes every row is indexed. This overload takes the hasVector mask and ranks only the rows that have a vector, as the old generateExpectedResults did (main). The existing 7-argument overload delegates with null, so its callers in the TestCagraToHnswSerializationAndSearch* tests behave as before.

The expected count becomes min(topK, indexed rows) instead of topK, because a small dataset can have fewer indexed rows than topK. The old test didn't check the count at all.

Comment on lines +140 to 151
assertTrue(
"id " + ids[i] + " for query " + q + " has no vector",
indexed == null || indexed[ids[i]]);
assertTrue("id " + ids[i] + " returned twice for query " + q, seen.add(ids[i]));
if (i > 0) {
assertTrue(
"results for query " + q + " are not sorted by score at position " + i,
results.scoreDocs[i].score <= results.scoreDocs[i - 1].score);
}
float expectedScore =
VectorSimilarityFunction.EUCLIDEAN.compare(queries[q], dataset[ids[i]]);
assertEquals(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These are the hard checks, and they don't count against the miss budget: every hit has a vector, there are no duplicates, scores don't increase, and each score equals the exact distance to that document's own vector. An ordinal-to-docID mapping error or a corrupted graph would break one of them, while a legitimate ANN miss wouldn't. All of them held in every #2819 sweep (0 violations, comment).

@bdice
bdice marked this pull request as ready for review October 12, 2026 14:52
@bdice
bdice requested a review from a team as a code owner October 12, 2026 14:52
@coderabbitai

coderabbitai Bot commented Oct 12, 2026

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: NVIDIA/cuvs/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Enterprise
  • Run ID: 22b87545-95cc-47e6-9671-846dcfd53428

📥 Commits

Reviewing files that changed from the base of the PR and between 24b0b17 and 37f4477.


📒 Files selected for processing (2)
  • java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestCuVSRandomizedHNSWVectorSearch.java
  • java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestUtils.java

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 2 remain after this review.



📝 Summary

Summary by CodeRabbit

  • Tests
    • Improved randomized vector-search validation to check nearest neighbors across queries while allowing up to two approximate misses.
    • Expanded test coverage for partially indexed datasets, including result counts, unique indexed results, score ordering, and neighbor ranking.
    • Increased the tested result limit from 64 to 128.

Walkthrough

The search test helper now validates results against indexed rows. The randomized HNSW test uses the helper for generated queries, allows two misses, and raises its top-k limit to 128.

Changes

Indexed-row search checks

Layer / File(s) Summary
Validate results against indexed rows
java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestUtils.java
The search assertion accepts an optional indexed-row mask and checks result count, IDs, distinctness, score order, and approximate ranks. Distance ranking excludes unindexed rows.
Use indexed-row assertion in randomized test
java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestCuVSRandomizedHNSWVectorSearch.java
The randomized test calls the shared assertion for generated queries, allows two misses, and raises TOP_K_LIMIT from 64 to 128.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Other

Suggested reviewers: imotov


Merge Risk: ⚪ Minimal · up to 37f44

This PR only changes test assertions and a test top-k limit, with no production code affected. No concrete merge-blocking risk was found. The remaining open questions concern how strict the new recall threshold should be.

Pre-merge checks | Passed 4 | Failed 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: replacing the randomized HNSW test with a recall-based check.
Description check Passed The description directly explains the recall-based validation, allowed misses, retained checks, helper changes, and increased TOP_K_LIMIT.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR

🧪 Generate unit tests (beta)
  • Create a new PR

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@imotov imotov 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.

LGTM, thanks for running this!

@imotov

imotov commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

/merge

@rapids-bot
rapids-bot Bot merged commit 32776b9 into NVIDIA:main Oct 14, 2026
147 of 151 checks passed
@github-project-automation github-project-automation Bot moved this from Approved to Done in Unstructured Data Processing Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working Lucene non-breaking Introduces a non-breaking change

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants