|
24 | 24 | import org.apache.lucene.index.DirectoryReader; |
25 | 25 | import org.apache.lucene.index.IndexWriter; |
26 | 26 | import org.apache.lucene.index.IndexWriterConfig; |
| 27 | +import org.apache.lucene.index.LeafReaderContext; |
| 28 | +import org.apache.lucene.index.NoMergePolicy; |
27 | 29 | import org.apache.lucene.index.Term; |
28 | 30 | import org.apache.lucene.index.VectorSimilarityFunction; |
29 | 31 | import org.apache.lucene.search.IndexSearcher; |
| 32 | +import org.apache.lucene.search.KnnFloatVectorQuery; |
30 | 33 | import org.apache.lucene.search.Query; |
31 | 34 | import org.apache.lucene.search.ScoreDoc; |
32 | 35 | import org.apache.lucene.search.TermQuery; |
33 | 36 | import org.apache.lucene.search.TopDocs; |
| 37 | +import org.apache.lucene.search.TopKnnCollector; |
34 | 38 | import org.apache.lucene.store.Directory; |
35 | 39 | import org.apache.lucene.tests.analysis.MockAnalyzer; |
36 | 40 | import org.apache.lucene.tests.analysis.MockTokenizer; |
37 | 41 | import org.apache.lucene.tests.index.RandomIndexWriter; |
38 | 42 | import org.apache.lucene.tests.util.LuceneTestCase; |
39 | 43 | import org.apache.lucene.tests.util.LuceneTestCase.SuppressSysoutChecks; |
40 | 44 | import org.apache.lucene.tests.util.TestUtil; |
| 45 | +import org.apache.lucene.util.Bits; |
41 | 46 | import org.junit.BeforeClass; |
42 | 47 | import org.junit.Test; |
43 | 48 |
|
@@ -320,6 +325,131 @@ public void testVectorSearchWithPartialDeletionAndReindexing() throws IOExceptio |
320 | 325 | } |
321 | 326 | } |
322 | 327 |
|
| 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 | + |
323 | 453 | private RandomIndexWriter createWriter(Directory directory) throws IOException { |
324 | 454 | return new RandomIndexWriter( |
325 | 455 | random(), |
|
0 commit comments