Skip to content

Commit 2f8f63b

Browse files
committed
Lucene: Fix GPU search failing on segments with no live vectors
Searching a segment where every document with a vector was deleted threw an exception instead of returning no hits. The reader now skips such a segment, and no longer assumes a search always comes back with results. The same assumption is fixed in cuvs-java. Fixes #2599
1 parent 6063a37 commit 2f8f63b

5 files changed

Lines changed: 177 additions & 38 deletions

File tree

fern/pages/lucene_api/lucene-api-com-nvidia-cuvs-lucene-cuvs2510gpuvectorsreader.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ Return the k nearest neighbor documents as determined by comparison of their vec
229229

230230
This is not supported.
231231

232-
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:598`_
232+
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:609`_
233233

234234
### readEntry
235235

@@ -257,7 +257,7 @@ an instance of FieldEntry
257257
| --- | --- |
258258
| `IOException` | I/O Exceptions |
259259

260-
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:626`_
260+
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:637`_
261261

262262
### getCagraIndexForField
263263

@@ -278,7 +278,7 @@ Returns the `CagraIndex` for the given field, or `null` if unavailable
278278

279279
the CAGRA index, or `null`
280280

281-
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:678`_
281+
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:689`_
282282

283283
### getFilterBitsetCache
284284

@@ -288,7 +288,7 @@ FilterBitsetCache getFilterBitsetCache()
288288

289289
Returns the filter cache owned by the vectors format that created this reader.
290290

291-
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:688`_
291+
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:699`_
292292

293293
### getFieldInfos
294294

@@ -302,7 +302,7 @@ Gets the instance of FieldInfos.
302302

303303
the instance of FieldInfos
304304

305-
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:697`_
305+
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:708`_
306306

307307
### getCuvsIndexes
308308

@@ -316,7 +316,7 @@ Gets the map of `GPUIndex` objects.
316316

317317
the map of GPU index objects
318318

319-
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:706`_
319+
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:717`_
320320

321321
### getFieldEntries
322322

@@ -330,6 +330,6 @@ Gets the map of FieldEntry objects that hold the meta information for the field.
330330

331331
the map of FieldEntry objects
332332

333-
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:715`_
333+
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:726`_
334334

335335
_Source: `java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java:59`_

java/cuvs-java/src/main/java22/com/nvidia/cuvs/internal/CagraSearchResults.java

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,29 +37,28 @@ static SearchResults create(
3737
long numberOfQueries) {
3838

3939
List<Map<Integer, Float>> results = new LinkedList<>();
40-
Map<Integer, Float> intermediateResultMap = new LinkedHashMap<>();
4140
var neighboursVarHandle =
4241
neighboursSequenceLayout.varHandle(MemoryLayout.PathElement.sequenceElement());
4342
var distancesVarHandle =
4443
distancesSequenceLayout.varHandle(MemoryLayout.PathElement.sequenceElement());
4544

46-
int count = 0;
47-
for (long i = 0; i < topK * numberOfQueries; i++) {
48-
long id = (long) neighboursVarHandle.get(neighboursMemorySegment, 0, i);
49-
float dst = (float) distancesVarHandle.get(distancesMemorySegment, 0L, i);
50-
// Empty top-k slots (fewer than k passing candidates) carry a sentinel distance of FLT_MAX.
51-
// Prefer this over the neighbor-index sentinel: the index sentinel is not uniform across
52-
// CAGRA search algorithms (single-CTA emits 0x7FFFFFFF, multi-CTA 0xFFFFFFFF), so the
53-
// distance is the reliable, algorithm-independent signal for an empty slot.
54-
if (dst != Float.MAX_VALUE) {
55-
intermediateResultMap.put(mapping.applyAsInt(id), dst);
56-
}
57-
count += 1;
58-
if (count == topK) {
59-
results.add(intermediateResultMap);
60-
intermediateResultMap = new LinkedHashMap<>();
61-
count = 0;
45+
// One map per query, so callers can rely on the result list holding exactly numberOfQueries
46+
// entries even when topK is 0 and no map has any content.
47+
for (long query = 0; query < numberOfQueries; query++) {
48+
Map<Integer, Float> resultMap = new LinkedHashMap<>();
49+
for (int j = 0; j < topK; j++) {
50+
long i = query * topK + j;
51+
long id = (long) neighboursVarHandle.get(neighboursMemorySegment, 0, i);
52+
float dst = (float) distancesVarHandle.get(distancesMemorySegment, 0L, i);
53+
// Empty top-k slots (fewer than k passing candidates) carry a sentinel distance of
54+
// FLT_MAX. Prefer this over the neighbor-index sentinel: the index sentinel is not uniform
55+
// across CAGRA search algorithms (single-CTA emits 0x7FFFFFFF, multi-CTA 0xFFFFFFFF), so
56+
// the distance is the reliable, algorithm-independent signal for an empty slot.
57+
if (dst != Float.MAX_VALUE) {
58+
resultMap.put(mapping.applyAsInt(id), dst);
59+
}
6260
}
61+
results.add(resultMap);
6362
}
6463
return new SearchResultsImpl(results);
6564
}

java/cuvs-java/src/main/java22/com/nvidia/cuvs/internal/SearchResultsImpl.java

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* SPDX-FileCopyrightText: Copyright (c) 2025, NVIDIA CORPORATION.
2+
* SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55
package com.nvidia.cuvs.internal;
@@ -35,23 +35,22 @@ static SearchResults create(
3535
LongToIntFunction mapping,
3636
long numberOfQueries) {
3737
List<Map<Integer, Float>> results = new LinkedList<>();
38-
Map<Integer, Float> intermediateResultMap = new LinkedHashMap<>();
3938
var neighboursVarHandle =
4039
neighboursSequenceLayout.varHandle(MemoryLayout.PathElement.sequenceElement());
4140
var distancesVarHandle =
4241
distancesSequenceLayout.varHandle(MemoryLayout.PathElement.sequenceElement());
4342

44-
int count = 0;
45-
for (long i = 0; i < topK * numberOfQueries; i++) {
46-
long id = (long) neighboursVarHandle.get(neighboursMemorySegment, 0L, i);
47-
float dst = (float) distancesVarHandle.get(distancesMemorySegment, 0L, i);
48-
intermediateResultMap.put(mapping != null ? mapping.applyAsInt((int) id) : (int) id, dst);
49-
count += 1;
50-
if (count == topK) {
51-
results.add(intermediateResultMap);
52-
intermediateResultMap = new LinkedHashMap<>();
53-
count = 0;
43+
// One map per query, so callers can rely on the result list holding exactly numberOfQueries
44+
// entries even when topK is 0 and no map has any content.
45+
for (long query = 0; query < numberOfQueries; query++) {
46+
Map<Integer, Float> resultMap = new LinkedHashMap<>();
47+
for (int j = 0; j < topK; j++) {
48+
long i = query * topK + j;
49+
long id = (long) neighboursVarHandle.get(neighboursMemorySegment, 0L, i);
50+
float dst = (float) distancesVarHandle.get(distancesMemorySegment, 0L, i);
51+
resultMap.put(mapping != null ? mapping.applyAsInt((int) id) : (int) id, dst);
5452
}
53+
results.add(resultMap);
5554
}
5655

5756
return new SearchResultsImpl(results);

java/cuvs-lucene/src/main/java/com/nvidia/cuvs/lucene/CuVS2510GPUVectorsReader.java

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,14 @@ public void search(String field, float[] target, KnnCollector knnCollector, Bits
487487
mask[0].set(i);
488488
}
489489
}
490-
topK = Math.min(knnCollector.k() + 10, mask[0].cardinality());
490+
int cardinality = mask[0].cardinality();
491+
if (cardinality == 0) {
492+
// Every vector in this segment is deleted or filtered out. cuVS cannot be asked for zero
493+
// neighbours, and Lucene's contract for such a leaf is to collect nothing, so return
494+
// before touching the GPU.
495+
return;
496+
}
497+
topK = Math.min(knnCollector.k() + 10, cardinality);
491498
// numDocs must be the total vector count so cuVS sizes the prefilter to cover every ordinal.
492499
// BitSet.length() is (highest set bit + 1), which under a selective filter is smaller than
493500
// the
@@ -562,7 +569,11 @@ public void search(String field, float[] target, KnnCollector knnCollector, Bits
562569
searchResult = bruteforceIndex.search(query).getResults();
563570
}
564571

565-
// List expected to have only one entry because of single query "target".
572+
// List expected to have only one entry because of single query "target". A zero-row
573+
// response can only mean no neighbours were produced, so there is nothing to collect.
574+
if (searchResult.isEmpty()) {
575+
return;
576+
}
566577
assert searchResult.size() == 1;
567578
final IntToIntFunction ordToDocFunction = (IntToIntFunction) rawValues::ordToDoc;
568579
final FloatToFloatFunction scoreCorrectionFunction =

java/cuvs-lucene/src/test/java/com/nvidia/cuvs/lucene/TestCuVSDeletedDocuments.java

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,25 @@
2424
import org.apache.lucene.index.DirectoryReader;
2525
import org.apache.lucene.index.IndexWriter;
2626
import org.apache.lucene.index.IndexWriterConfig;
27+
import org.apache.lucene.index.LeafReaderContext;
28+
import org.apache.lucene.index.NoMergePolicy;
2729
import org.apache.lucene.index.Term;
2830
import org.apache.lucene.index.VectorSimilarityFunction;
2931
import org.apache.lucene.search.IndexSearcher;
32+
import org.apache.lucene.search.KnnFloatVectorQuery;
3033
import org.apache.lucene.search.Query;
3134
import org.apache.lucene.search.ScoreDoc;
3235
import org.apache.lucene.search.TermQuery;
3336
import org.apache.lucene.search.TopDocs;
37+
import org.apache.lucene.search.TopKnnCollector;
3438
import org.apache.lucene.store.Directory;
3539
import org.apache.lucene.tests.analysis.MockAnalyzer;
3640
import org.apache.lucene.tests.analysis.MockTokenizer;
3741
import org.apache.lucene.tests.index.RandomIndexWriter;
3842
import org.apache.lucene.tests.util.LuceneTestCase;
3943
import org.apache.lucene.tests.util.LuceneTestCase.SuppressSysoutChecks;
4044
import org.apache.lucene.tests.util.TestUtil;
45+
import org.apache.lucene.util.Bits;
4146
import org.junit.BeforeClass;
4247
import org.junit.Test;
4348

@@ -320,6 +325,131 @@ public void testVectorSearchWithPartialDeletionAndReindexing() throws IOExceptio
320325
}
321326
}
322327

328+
/**
329+
* A segment whose vector-bearing documents are all deleted must contribute zero hits rather than
330+
* failing. Such a segment still reaches the reader as long as it keeps at least one live document
331+
* without a vector, so Lucene does not drop it. See
332+
* <a href="https://github.com/NVIDIA/cuvs/issues/2599">issue 2599</a>: the accepted-ordinal set is
333+
* empty, which used to clamp the cuVS top-k to zero and yield a result list with no rows at all.
334+
*/
335+
@Test
336+
public void testSearchSegmentWithAllVectorsDeleted() throws IOException {
337+
338+
final int dimensions = 64;
339+
final int liveDocs = 16;
340+
final int topK = 5;
341+
342+
try (Directory directory = newDirectory()) {
343+
float[][] dataset = generateDataset(random, liveDocs + 1, dimensions);
344+
345+
// NoMergePolicy keeps the two commits as two separate segments, so the first one survives as
346+
// a leaf with a single, deleted vector.
347+
try (IndexWriter writer =
348+
new IndexWriter(directory, createWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE))) {
349+
350+
// Segment 1: one document with a vector (deleted below) and one live document without a
351+
// vector, which is what keeps the segment from being dropped once the first is deleted.
352+
Document withVector = new Document();
353+
withVector.add(new StringField("id", "deleted-vector", Field.Store.YES));
354+
withVector.add(
355+
new KnnFloatVectorField("vector", dataset[0], VectorSimilarityFunction.EUCLIDEAN));
356+
writer.addDocument(withVector);
357+
358+
Document withoutVector = new Document();
359+
withoutVector.add(new StringField("id", "no-vector", Field.Store.YES));
360+
writer.addDocument(withoutVector);
361+
writer.commit();
362+
363+
// Segment 2: live vectors, so the query still has something to return.
364+
for (int i = 0; i < liveDocs; i++) {
365+
Document doc = new Document();
366+
doc.add(new StringField("id", "live-" + i, Field.Store.YES));
367+
doc.add(
368+
new KnnFloatVectorField(
369+
"vector", dataset[i + 1], VectorSimilarityFunction.EUCLIDEAN));
370+
writer.addDocument(doc);
371+
}
372+
writer.commit();
373+
374+
writer.deleteDocuments(new Term("id", "deleted-vector"));
375+
writer.commit();
376+
}
377+
378+
try (DirectoryReader reader = DirectoryReader.open(directory)) {
379+
assertTrue("Expected more than one segment", reader.leaves().size() > 1);
380+
IndexSearcher searcher = new IndexSearcher(reader);
381+
float[] queryVector = generateRandomVector(dimensions, random);
382+
383+
// KnnFloatVectorQuery always goes through the per-segment reader path, which is where the
384+
// empty accepted-ordinal set is handled.
385+
assertOnlyLiveHits(
386+
reader, searcher.search(new KnnFloatVectorQuery("vector", queryVector, topK), topK));
387+
388+
// GPUKnnFloatVectorQuery may take either the multi-partition or the per-segment path
389+
// depending on whether every segment has a usable CAGRA index; both must behave the same.
390+
assertOnlyLiveHits(
391+
reader,
392+
searcher.search(
393+
new GPUKnnFloatVectorQuery("vector", queryVector, topK, null, topK, 1), topK));
394+
}
395+
}
396+
}
397+
398+
/**
399+
* The reader must treat any empty accepted-ordinal set as "no hits", whatever produced it.
400+
*
401+
* <p>Going through {@link KnnFloatVectorQuery} with an explicit filter cannot reach this state:
402+
* Lucene ANDs every user filter with a {@code FieldExistsQuery} on the vector field, so the
403+
* accepted set always holds at least one vector-bearing document. This test therefore drives
404+
* {@link org.apache.lucene.index.LeafReader#searchNearestVectors} directly with an all-false
405+
* {@link Bits}, pinning the reader's own contract independently of the query layer.
406+
*/
407+
@Test
408+
public void testSearchWithAcceptDocsMatchingNoVectors() throws IOException {
409+
410+
final int dimensions = 64;
411+
final int vectorDocs = 16;
412+
final int topK = 5;
413+
414+
try (Directory directory = newDirectory()) {
415+
float[][] dataset = generateDataset(random, vectorDocs, dimensions);
416+
417+
try (IndexWriter writer = new IndexWriter(directory, createWriterConfig())) {
418+
for (int i = 0; i < vectorDocs; i++) {
419+
Document doc = new Document();
420+
doc.add(new StringField("id", String.valueOf(i), Field.Store.YES));
421+
doc.add(
422+
new KnnFloatVectorField("vector", dataset[i], VectorSimilarityFunction.EUCLIDEAN));
423+
writer.addDocument(doc);
424+
}
425+
writer.commit();
426+
}
427+
428+
try (DirectoryReader reader = DirectoryReader.open(directory)) {
429+
float[] queryVector = generateRandomVector(dimensions, random);
430+
for (LeafReaderContext ctx : reader.leaves()) {
431+
TopKnnCollector collector = new TopKnnCollector(topK, Integer.MAX_VALUE);
432+
ctx.reader()
433+
.searchNearestVectors(
434+
"vector", queryVector, collector, new Bits.MatchNoBits(ctx.reader().maxDoc()));
435+
assertEquals(
436+
"An empty accepted-ordinal set should collect nothing",
437+
0,
438+
collector.topDocs().scoreDocs.length);
439+
}
440+
}
441+
}
442+
}
443+
444+
/** Asserts every hit comes from a live, vector-bearing document. */
445+
private void assertOnlyLiveHits(DirectoryReader reader, TopDocs topDocs) throws IOException {
446+
assertTrue("Expected hits from the live segment", topDocs.scoreDocs.length > 0);
447+
for (ScoreDoc hit : topDocs.scoreDocs) {
448+
String id = reader.storedFields().document(hit.doc).get("id");
449+
assertTrue("Unexpected hit: " + id, id.startsWith("live-"));
450+
}
451+
}
452+
323453
private RandomIndexWriter createWriter(Directory directory) throws IOException {
324454
return new RandomIndexWriter(
325455
random(),

0 commit comments

Comments
 (0)