Skip to content

Add a HNSW collector that exits early when nearest neighbor queue saturates #14094

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 51 commits into from
Apr 2, 2025
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
3b30c07
Add a HNSW early termination based on nn queue saturation
tteofili Dec 24, 2024
0b24e79
enable optimized collector with 1k+ docs
tteofili Jan 2, 2025
70b6144
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Jan 2, 2025
93fb470
tidy
tteofili Jan 2, 2025
c5aa473
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Jan 8, 2025
7fc49c5
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Jan 9, 2025
aed6fd5
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Jan 12, 2025
b7eb24f
don't trigger exact search when early terminating
tteofili Jan 15, 2025
d143bbb
improved javadoc
tteofili Jan 15, 2025
51df9ee
improved javadoc
tteofili Jan 15, 2025
e55f967
improved javadoc
tteofili Jan 15, 2025
e3f8db3
minor fixes, more tests
tteofili Jan 15, 2025
ec1e686
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Jan 15, 2025
a71e936
tidy
tteofili Jan 15, 2025
09b0229
dropped useless assertions
tteofili Jan 16, 2025
74132f1
changes added
tteofili Jan 16, 2025
8d00ae8
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Jan 16, 2025
370f513
changes to 10.2
tteofili Jan 16, 2025
fed77c9
more tests
tteofili Jan 16, 2025
88d22df
more tests
tteofili Jan 16, 2025
e86ebdc
minor fixes
tteofili Jan 17, 2025
e69730f
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Jan 22, 2025
5b001ee
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Feb 3, 2025
20a481f
tidy
tteofili Feb 3, 2025
1dbaa1a
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Feb 13, 2025
c6dbf7e
make hnsw collector a decorator
tteofili Feb 13, 2025
55fdea2
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Feb 24, 2025
460efd9
moved the early termination logic into PatienceKnnVectorQuery
tteofili Feb 25, 2025
3d2e46b
minor fix
tteofili Feb 25, 2025
0f3f047
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Feb 25, 2025
eef4f97
updated CHANGES to reflect new query, minor fix
tteofili Feb 25, 2025
acf5866
reverted unneeded change
tteofili Feb 25, 2025
620e985
tidy
tteofili Feb 25, 2025
ca0f05d
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Mar 11, 2025
f116141
minor tweaks
tteofili Mar 12, 2025
45b2031
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Mar 13, 2025
66bd51d
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Mar 19, 2025
0b47585
signal next candidate in filtered search too
tteofili Mar 20, 2025
bb57ca1
tidy
tteofili Mar 20, 2025
8f846b8
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Mar 21, 2025
695a4eb
Update lucene/core/src/java/org/apache/lucene/util/hnsw/OrdinalTransl…
tteofili Mar 21, 2025
c899f29
minor tweaks
tteofili Mar 21, 2025
36b9931
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Mar 26, 2025
a84032e
update test patience query tests
tteofili Mar 26, 2025
5b12866
get rid of HnswKnnCollector, extend KnnCollector.Decortor instead
tteofili Apr 1, 2025
c54a596
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Apr 1, 2025
73d5069
expose nextVectorsBlock in KnnStrategy, no new KnnCollector/Decorator…
tteofili Apr 1, 2025
60f3384
Merge branch 'main' of github.com:apache/lucene into hnsw_qset
tteofili Apr 1, 2025
3a85f8a
javadoc fix
tteofili Apr 1, 2025
5231f86
javadoc fix
tteofili Apr 1, 2025
54e77a3
tidy fix
tteofili Apr 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lucene/CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Optimizations
* GITHUB#14133: Dense blocks of postings are now encoded as bit sets.
(Adrien Grand)

* GITHUB#14094: Early terminate when HNSW nearest neighbor queue saturates (Tommaso Teofili)

Bug Fixes
---------------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import org.apache.lucene.index.VectorEncoding;
import org.apache.lucene.index.VectorSimilarityFunction;
import org.apache.lucene.internal.hppc.IntObjectHashMap;
import org.apache.lucene.search.HnswQueueSaturationCollector;
import org.apache.lucene.search.KnnCollector;
import org.apache.lucene.store.ChecksumIndexInput;
import org.apache.lucene.store.DataInput;
Expand Down Expand Up @@ -314,10 +315,16 @@ private void search(
return;
}
final RandomVectorScorer scorer = scorerSupplier.get();
final KnnCollector collector =
new OrdinalTranslatedKnnCollector(knnCollector, scorer::ordToDoc);
final Bits acceptedOrds = scorer.getAcceptOrds(acceptDocs);
if (knnCollector.k() < scorer.maxOrd()) {
final KnnCollector collector;
OrdinalTranslatedKnnCollector ordinalTranslatedKnnCollector =
new OrdinalTranslatedKnnCollector(knnCollector, scorer::ordToDoc);
if (scorer.maxOrd() > 1000) {
collector = new HnswQueueSaturationCollector(ordinalTranslatedKnnCollector);
} else {
collector = ordinalTranslatedKnnCollector;
}
HnswGraphSearcher.search(scorer, collector, getGraph(fieldEntry), acceptedOrds);
} else {
// if k is larger than the number of vectors, we can just iterate over all vectors
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.search;

/** {@link KnnCollector} that exposes methods to hook into specific parts of the HNSW algorithm. */
public interface HnswKnnCollector extends KnnCollector {

/** Indicates exploration of the next HNSW candidate graph node. */
void nextCandidate();
}
Copy link
Member

Choose a reason for hiding this comment

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

I think this kind of collector is OK. But it makes most sense to me to be a delegate collector. An abstract collector to KnnCollector.Delegate.

Then, I also think that the OrdinalTranslatingKnnCollector should inherit directly from HnswKnnCollector always assuming that the passed in collector is a HnswKnnCollector.

Note, the default behavior for HnswKnnCollector#nextCandidate can simply be nothing, allowing for overriding.

This might require a new HnswGraphSearcher#search interface to keep the old collector actions, but it can be simple to add a new one that accepts a HnswKnnCollector and delegate to it with new HnswKnnCollector(KnnCollector delegate).

Copy link
Member

Choose a reason for hiding this comment

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

I adjusted my refactoring for the seeded queries similarly. It seems nicer IMO: #14170

Copy link
Contributor Author

Choose a reason for hiding this comment

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

thanks Ben. I'll incorporate your suggestions once #14170 is in.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

made HnswKnnCollector a KnnCollector.Decorator in c6dbf7e

Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.apache.lucene.search;

/**
* A {@link HnswKnnCollector} that early exits when nearest neighbor queue keeps saturating beyond a
* 'patience' parameter. This records the rate of collection of new nearest neighbors in the {@code
* delegate} KnnCollector queue, at each HNSW node candidate visit. Once it saturates for a number
* of consecutive node visits (e.g., the patience parameter), this early terminates.
*/
public class HnswQueueSaturationCollector implements HnswKnnCollector {

private static final double DEFAULT_SATURATION_THRESHOLD = 0.995d;

private final KnnCollector delegate;
private final double saturationThreshold;
private final int patience;
private boolean patienceFinished;
private int countSaturated;
private int previousQueueSize;
private int currentQueueSize;

HnswQueueSaturationCollector(
KnnCollector delegate, double saturationThreshold, int patience) {
this.delegate = delegate;
this.previousQueueSize = 0;
this.currentQueueSize = 0;
this.countSaturated = 0;
this.patienceFinished = false;
this.saturationThreshold = saturationThreshold;
this.patience = patience;
}

public HnswQueueSaturationCollector(KnnCollector delegate) {
this.delegate = delegate;
this.previousQueueSize = 0;
this.currentQueueSize = 0;
this.countSaturated = 0;
this.patienceFinished = false;
this.saturationThreshold = DEFAULT_SATURATION_THRESHOLD;
this.patience = defaultPatience();
}

private int defaultPatience() {
return Math.max(7, (int) (k() * 0.3));
}

@Override
public boolean earlyTerminated() {
return delegate.earlyTerminated() || patienceFinished;
}

@Override
public void incVisitedCount(int count) {
delegate.incVisitedCount(count);
}

@Override
public long visitedCount() {
return delegate.visitedCount();
}

@Override
public long visitLimit() {
return delegate.visitLimit();
}

@Override
public int k() {
return delegate.k();
}

@Override
public boolean collect(int docId, float similarity) {
boolean collect = delegate.collect(docId, similarity);
if (collect) {
currentQueueSize++;
}
return collect;
}

@Override
public float minCompetitiveSimilarity() {
return delegate.minCompetitiveSimilarity();
}

@Override
public TopDocs topDocs() {
TopDocs topDocs;
if (patienceFinished && delegate.earlyTerminated() == false) {
TopDocs delegateDocs = delegate.topDocs();
TotalHits totalHits =
new TotalHits(delegateDocs.totalHits.value(), TotalHits.Relation.EQUAL_TO);
topDocs = new TopDocs(totalHits, delegateDocs.scoreDocs);
} else {
topDocs = delegate.topDocs();
}
return topDocs;
}

@Override
public void nextCandidate() {
Copy link
Member

Choose a reason for hiding this comment

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

@tteofili what do you think of making this more general? I think having a "nextCandidate" or "nextBlockOfVectors" is generally useful, and might be applicable to all types of kNN indices.

For example:

  • Flat, you just get called once, indicating you are searching ALL vectors
  • HNSW, you get called for each NSW (or in the case of filtered search, extended NSW)
  • IVF, you get called for each posting list
  • Vamana, you get called for each node before calling the neighbors

Do you think we can make this API general?

Maybe not, I am not sure.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I really like this idea Ben, I'll see if I can make up something reasonable for that ;)

double queueSaturation =
(double) Math.min(currentQueueSize, previousQueueSize) / currentQueueSize;
previousQueueSize = currentQueueSize;
if (queueSaturation >= saturationThreshold) {
countSaturated++;
} else {
countSaturated = 0;
}
if (countSaturated > patience) {
patienceFinished = true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import java.io.IOException;
import org.apache.lucene.search.DocIdSetIterator;
import org.apache.lucene.search.HnswKnnCollector;
import org.apache.lucene.search.KnnCollector;
import org.apache.lucene.search.TopKnnCollector;
import org.apache.lucene.search.knn.EntryPointProvider;
Expand Down Expand Up @@ -272,6 +273,9 @@ void searchLevel(
}
}
}
if (results instanceof HnswKnnCollector hnswKnnCollector) {
hnswKnnCollector.nextCandidate();
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.search;

import java.util.Random;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.junit.Test;

/** Tests for {@link HnswQueueSaturationCollector} */
public class TestHnswQueueSaturationCollector extends LuceneTestCase {

@Test
public void testDelegate() {
Random random = random();
int numDocs = 100;
int k = random.nextInt(1, 10);
KnnCollector delegate = new TopKnnCollector(k, numDocs);
HnswQueueSaturationCollector queueSaturationCollector =
new HnswQueueSaturationCollector(delegate);
for (int i = 0; i < random.nextInt(numDocs); i++) {
queueSaturationCollector.collect(random.nextInt(numDocs), random.nextFloat(1.0f));
}
assertEquals(delegate.k(), queueSaturationCollector.k());
assertEquals(delegate.visitedCount(), queueSaturationCollector.visitedCount());
assertEquals(delegate.visitLimit(), queueSaturationCollector.visitLimit());
assertEquals(
delegate.minCompetitiveSimilarity(),
queueSaturationCollector.minCompetitiveSimilarity(),
1e-3);
}

@Test
public void testEarlyExpectedExit() {
int numDocs = 1000;
int k = 10;
KnnCollector delegate = new TopKnnCollector(k, numDocs);
HnswQueueSaturationCollector queueSaturationCollector =
new HnswQueueSaturationCollector(delegate, 0.9, 10);
for (int i = 0; i < numDocs; i++) {
queueSaturationCollector.collect(i, 1.0f - i * 1e-3f);
if (i % 10 == 0) {
queueSaturationCollector.nextCandidate();
}
if (queueSaturationCollector.earlyTerminated()) {
assertEquals(120, i);
break;
}
}
}

@Test
public void testDelegateVsSaturateEarlyExit() {
Random random = random();
int numDocs = 10000;
int k = random.nextInt(1, 100);
KnnCollector delegate = new TopKnnCollector(k, numDocs);
HnswQueueSaturationCollector queueSaturationCollector =
new HnswQueueSaturationCollector(delegate);
for (int i = 0; i < random.nextInt(numDocs); i++) {
queueSaturationCollector.collect(random.nextInt(numDocs), random.nextFloat(1.0f));
if (i % 10 == 0) {
queueSaturationCollector.nextCandidate();
}
boolean earlyTerminatedSaturation = queueSaturationCollector.earlyTerminated();
boolean earlyTerminatedDelegate = delegate.earlyTerminated();
assertTrue(earlyTerminatedSaturation || !earlyTerminatedDelegate);
}
}

@Test
public void testEarlyExitRelation() {
Random random = random();
int numDocs = 10000;
int k = random.nextInt(100);
KnnCollector delegate = new TopKnnCollector(k, random.nextInt(numDocs));
HnswQueueSaturationCollector queueSaturationCollector =
new HnswQueueSaturationCollector(delegate);
for (int i = 0; i < random.nextInt(numDocs); i++) {
queueSaturationCollector.collect(random.nextInt(numDocs), random.nextFloat(1.0f));
if (i % 10 == 0) {
queueSaturationCollector.nextCandidate();
}
if (delegate.earlyTerminated()) {
TopDocs topDocs = queueSaturationCollector.topDocs();
assertEquals(TotalHits.Relation.GREATER_THAN_OR_EQUAL_TO, topDocs.totalHits.relation());
}
if (queueSaturationCollector.earlyTerminated()) {
TopDocs topDocs = queueSaturationCollector.topDocs();
assertEquals(TotalHits.Relation.EQUAL_TO, topDocs.totalHits.relation());
break;
}
}
}
}
Loading