All findings · Lucene finding · LFV-467 · explicit-similarity-threshold-tie-not-competitive

Similarity-threshold vector queries with traversalSimilarity == resultSimilarity skip documents that score exactly the threshold

With traversalSimilarity equal to resultSimilarity (what the short constructors do), FloatVectorSimilarityQuery and ByteVectorSimilarityQuery can skip documents that score exactly the threshold once they have found a document at or above it: in the runs, looking up exact copies of a vector returned 1 of 2, or 1 of 6 on an HNSW graph. Affects 9.10.0-10.4.0, 10.5.2, 10.6.0 (by reading) and main.

Summary

Priority
Low, rank 281Needs an exact score tie at the threshold, and on 10.5.2 and main only the deprecated Explicit similarity-query classes are affected.
Impact
silent wrong results
Since
9.10.0 (#12679, 2023)introduced 2023-12-11 · about 2 years
Affected
9.10.0-10.4.0, 10.5.2, 10.6.0 (by reading) and main; not 10.5.0 or 10.5.1
Status
documented
Found by
code reading, then random metamorphic testing against an exact reference

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

A similarity-threshold query should return every document whose similarity to the target is at least resultSimilarity. The collector counts a score equal to the threshold as a hit, but then reports that same value as the bound at or below which nothing is worth looking at, and the search code skips scores equal to the bound.

Step-by-step diagram of FloatVectorSimilarityQuery.Explicit(field, target, 1.0f) on 65 vectors with exact copies of the target at docs 0 and 64: the exhaustive scan collects the first batch (docs 0-63), the collector's bound rises to 1.0, and the second batch (doc 64, best score exactly 1.0) fails the strict test best > 1.0; the HNSW graph search applies the same rule; the result is [0] instead of [0, 64].
The 65-vector example step by step: the exhaustive scan scores docs 0-63, then doc 64; after doc 0 the collector's bound is 1.0, and the strict "best > bound" test skips doc 64's batch. The same tie rule then drops copies in the HNSW graph search. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 65 vectors with the default codec. Docs 0 and 64 are exact copies of the query vector [1, 2], so their EUCLIDEAN similarity is 1.0, the highest possible; the other 63 vectors are far away.
  2. Ask for every document with similarity >= 1.0: new FloatVectorSimilarityQuery.Explicit("v", target, 1.0f), which sets traversalSimilarity = resultSimilarity = 1.0. You expect docs 0 and 64.
  3. You get only doc 0, and count() is 1, although explain(query, 64) says "Score above threshold". On a 3,000-vector segment with an HNSW graph and 6 exact copies you get 1 of the 6.
  4. Why: after doc 0 the collector reports 1.0 as its minimum competitive score, and the search treats a score equal to that bound as not worth collecting. Doc 64 sits alone in the second batch of 64 scored vectors, whose best score is not > 1.0, so it is skipped; the graph search likewise accepts only scores above 1.0.
  5. The same query with traversalSimilarity one step lower (Math.nextDown(1f)) returns every copy, and on this segment every vector is scored, so this is not a graph-approximation miss. The fix is one line in the collector.

Who is affected

Callers that set traversalSimilarity equal to resultSimilarity: the short constructors in 9.10.0-10.4.0, the deprecated Explicit classes in 10.5.2 and main. It needs an exact score tie, which is routine for exact-duplicate lookups (EUCLIDEAN 1.0), byte vectors, or a threshold copied from a document's score. Default codec, no error; from 10.4.0 a segment needs at least 65 vectors.

Mechanism, terms and numbers846 words

A similarity-threshold vector query (FloatVectorSimilarityQuery, ByteVectorSimilarityQuery) returns every document whose similarity to the target is at least resultSimilarity; traversalSimilarity controls how far the search explores. The constructors without a traversal argument, (field, target, resultSimilarity[, filter]), set the two equal: the plain constructors in 9.10.0-10.4.0, the deprecated Explicit classes in 10.5.2 and main (the full forms behave the same when given equal values). Then, once the collector has seen a hit at exactly the threshold t, its minCompetitiveSimilarity() = min(traversalSimilarity, maxSimilarity) equals t, and every consumer treats a score equal to that bound as not competitive: the exhaustive scan of a segment without a graph collects a 64-vector batch only if its best score is > the bound, and the HNSW graph search accepts only scores >= nextUp(bound). So later documents at exactly t can be silently dropped (without a graph, those in a later batch that holds no better score; on a graph, those met after the first hit), although the query's own collect(), explain() and exact fallback all count them. On 65 vectors with exact copies of the target at docs 0 and 64 (EUCLIDEAN, t = 1.0) the query returns [0]; on a 3,000-vector HNSW segment with 6 copies, 1 of 6; with traversal one ulp lower, all of them. It needs an exact score tie, which is routine for exact-duplicate lookups, byte vectors and thresholds copied from a document's score. The fix is one line: Math.min(Math.nextDown(traversalSimilarity), maxSimilarity).

Terms

  • Similarity-threshold query: FloatVectorSimilarityQuery / ByteVectorSimilarityQuery (@lucene.experimental, "Search for all (approximate) float vectors above a similarity threshold"). resultSimilarity is the score a document must reach to be returned; traversalSimilarity (<= resultSimilarity) controls how far the HNSW graph is explored. The constructors without a traversal argument, (field, target, resultSimilarity[, filter]), pass resultSimilarity for both; the full forms (field, target, traversalSimilarity, resultSimilarity[, filter]) take both values. On 10.5.2 and main these are the deprecated Explicit classes ("Equivalent to the FloatVectorSimilarityQuery constructors in Lucene 10.4 and earlier"), next to the Adaptive classes, which are not affected. In 10.5.0 and 10.5.1 the plain constructors are adaptive (#15784); the nested Adaptive and Explicit classes first appear in 10.5.2 (#16654/#16671).
  • EUCLIDEAN similarity: 1 / (1 + squared distance), so 1.0 means "identical to the target" and nothing scores higher.
  • minCompetitiveSimilarity(): the KnnCollector method that the vector readers and the HNSW searcher ask for the score below which candidates are not worth visiting. Its javadoc says only "When full, the minimum score should be returned"; every consumer treats a score equal to it as not competitive, which is right for top-k collectors.

The bug

  1. The short constructors set traversalSimilarity = resultSimilarity = t (FloatVectorSimilarityQuery.java:182-183, :194-195; ByteVectorSimilarityQuery.java:181-182, :193-194). Only traversal > result is rejected.
  2. ExplicitVectorSimilarityCollector.collect() keeps similarity >= resultSimilarity (:59) and tracks the best score seen (:58); minCompetitiveSimilarity() returns Math.min(traversalSimilarity, maxSimilarity) (:66-67). After the first hit at exactly t, that is t.
  3. Segments without a graph (with the default format, fewer than 648 vectors; from 10.4.0): Lucene99HnswVectorsReader scores vectors in batches of 64 and collects a batch only if bulkScore(...) > minCompetitiveSimilarity() (:396, :408). A later batch whose best score is exactly t is skipped. Ties inside a collected batch are kept, so on this path it takes at least 65 vectors (a segment with a graph can lose a tie with a few vectors: 3 in a run with a forced graph). The scalar-quantized reader has the same gate (Lucene104ScalarQuantizedVectorsReader.java:390, :402).
  4. Segments with a graph (every affected release): HnswGraphSearcher accepts a neighbour only if score >= Math.nextUp(minCompetitiveSimilarity()) (:304, :347, :352) and skips a scored batch unless its best is > the bound (:341-343). Every node at exactly t met after the first hit is neither collected nor explored. A filter does not change this: the query pins KnnSearchStrategy.Hnsw(0) (AbstractVectorSimilarityQuery.java:48), so FilteredHnswGraphSearcher is never chosen (HnswGraphSearcher.java:139-145, KnnSearchStrategy.java:80-82) and the same searcher runs with acceptOrds.
  5. The query's other paths count the dropped document: explain() says "Score above threshold" for score >= resultSimilarity (AbstractVectorSimilarityQuery.java:175-176), and the exact fallback keeps score >= threshold (:363).

The numbers

Main ea57360912ed, default codec, one segment, traversal == result == t.

casehitsexpected
65 vectors, exact copies of the target at docs 0 and 64, EUCLIDEAN, t = 1.0 (no graph)[0], count() 1[0, 64]
the same with byte vectors at squared distance 1, t = 0.5[0][0, 64]
the 65-vector case plus a MatchAllDocsQuery filter (every document has a vector, so the scan reaches the visit limit and falls back to the exact scan)[0, 64][0, 64]
3,000 vectors with an HNSW graph, 6 exact copies[7] (1 of 6)6 copies
4,000 vectors with an HNSW graph, 12 exact copies, with or without a 25% filter1 of 1212
controls: traversal = Math.nextDown(t), or the Adaptive queryevery copyevery copy
Exposure and likelihood487 words
  • Who is exposed. Code that builds a similarity-threshold query with traversalSimilarity equal to resultSimilarity: the plain constructors without a traversal argument in 9.10.0-10.4.0 (the short, non-deprecated forms (field, target, resultSimilarity[, filter])), [Float|Byte]VectorSimilarityQuery.Explicit with those arguments in 10.5.2 and main (deprecated), or a full form (field, target, traversalSimilarity, resultSimilarity[, filter]) with equal values. Not 10.5.0 or 10.5.1 (adaptive collector, no Explicit), not the Adaptive queries, not Float16VectorSimilarityQuery, and not any call with traversal strictly below result.
  • What it takes. A document scoring exactly the threshold after another vector at or above it was collected in the same segment (without a graph, only in a later 64-vector batch that holds no better score). Exact-duplicate lookup at EUCLIDEAN 1.0 always qualifies (nothing scores above 1.0, so every further copy is a tie); byte vectors give many equal scores; so does a threshold copied from a known document's score. For float embeddings with an arbitrary threshold an exact tie is very unlikely.
  • How much is lost. On a segment with a graph, every tie met after the first hit: 1 of 6 and 1 of 12 copies in the runs. On a segment without a graph (fewer than 648 vectors with the default format, from 10.4.0), a tie in a later 64-vector batch that holds no better vector. Rarely an above-threshold document reachable only through tied graph nodes goes too: in a 120-query check, 11 of 6,699 missing documents were above the threshold, and the fix recovered all 11 (attributed to tied nodes, inferred).
  • Silent and inconsistent. No error; count() matches the short hit list, while explain() says the missing documents match. The answer changes with document order, with an all-matching filter (no-graph segments in which every document has a vector, 10.4.0 on) and with a one-ulp change of traversalSimilarity. As a MUST_NOT clause, a tied document is not excluded (64 hits instead of 63).
  • Settings and size. Default codec, directory and IndexSearcher. From 10.4.0 a segment needs at least 65 vectors: below 648 it has no graph and is scanned in 64-vector batches, and ties in the first collected batch are kept. Smaller segments are hit only when they have a graph: with a format whose tiny-segment threshold is 0 (executed with 3 vectors), or on 9.10.0-10.3.2, which had no tiny-segment threshold (read; not run below 65 vectors). The scalar-quantized formats have the same gate; a random hunt hit them, but no dedicated test targets them.
  • Downstream. Solr's vector-similarity parser defaults minTraverse to -Infinity (not affected), but a request may set minTraverse equal to minReturn, and an open Solr issue (SOLR-17713) shows a user doing that. The OpenSearch k-NN plugin uses the Adaptive query or 0.95 x threshold (not affected); no Elasticsearch use was found.
  • How likely. Rare: it needs both the equal-thresholds setting and an exact tie, and no public report was found in the two and a half years since 9.10.0 (released 2024-02-20).

Minimal repro

FloatVectorSimilarityQuery.Explicit with traversalSimilarity == resultSimilarity drops a document whose similarity equals the threshold once an earlier hit at the threshold was collected (65 vectors, exact copies of the target at doc 0 and doc 64, EUCLIDEAN threshold 1.0).

TestExplicitSimilarityThresholdTie.java (JUnit test)58 lines
/*
 * 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.ArrayList;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.VectorSimilarityFunction;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

@SuppressWarnings("deprecation")
public class TestExplicitSimilarityThresholdTie extends LuceneTestCase {

  public void testFloatExactDuplicates() throws Exception {
    float[] target = {1f, 2f};
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w =
          new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
        for (int i = 0; i < 65; i++) {
          Document doc = new Document();
          float[] v = (i == 0 || i == 64) ? target : new float[] {100f, 100f};
          doc.add(new KnnFloatVectorField("v", v, VectorSimilarityFunction.EUCLIDEAN));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query query = new FloatVectorSimilarityQuery.Explicit("v", target, 1.0f);
        List<Integer> docs = new ArrayList<>();
        for (ScoreDoc sd : new IndexSearcher(reader).search(query, 10).scoreDocs) {
          docs.add(sd.doc);
        }
        // docs 0 and 64 are exact copies (similarity 1.0 >= resultSimilarity 1.0); main returns [0]
        assertEquals(List.of(0, 64), docs);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-467/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.search.TestExplicitSimilarityThresholdTie
Expected vs actual
Expected hits [0, 64] (both have similarity 1.0 >= resultSimilarity); main returns [0].

All files of this repro: repro/LFV-467/

More files, environment, full output35 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<[0, 64]> but was:<[0]>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.search.TestExplicitSimilarityThresholdTie.testFloatExactDuplicates(TestExplicitSimilarityThresholdTie.java:54)

Why it looks unintended

  • The query's own code counts a tie as a match: collect() and the exact fallback use >=, and explain() says "Score above threshold".
  • Every vector of the 65-vector segment is scored, and a traversal one step lower returns every copy: not a graph-recall miss.
  • The strict tie rule was made for top-k (#12770) while this query (#12679) was in review.
The full argument376 words
  • Contract text. Collector javadoc (ExplicitVectorSimilarityCollector.java:35-37): "The graph is traversed till better scoring nodes are available, or the best candidate is below {@link #traversalSimilarity}. All traversed nodes above {@link #resultSimilarity} are collected." A tie is not "below". collect() (:59) uses >=; explain() (AbstractVectorSimilarityQuery.java:175-176) returns "Score above threshold" for score >= resultSimilarity; the exact fallback (:363) uses score >= threshold.
  • Release notes. The 9.10.0 CHANGES entry for #12679: "find all vectors scoring above a resultSimilarity while traversing the HNSW graph till better-scoring nodes are available, or the best candidate is below a score of traversalSimilarity in the lowest level."
  • Upstream tests. TestVectorSimilarityCollector.testExplicitResultCollection (:53-85) expects node 4 (score 0.5) at resultSimilarity 0.5 under "All nodes above resultSimilarity appear in order of collection" (:76-78). BaseExplicitVectorSimilarityQueryTestCase.testVectorsAboveSimilarity builds the expected set with score >= resultSimilarity (:404) and asserts all are returned (:427-428), but with traversal = -Infinity (:414), which hides the tie. Three upstream tests do run traversal == result (testFallbackToExact and testApproximate at the exact score of an indexed document, testExtremes at -Infinity and +Infinity), but none checks that tied documents are returned: testFallbackToExact asserts only the fallback to exact search (:452), testApproximate only an upper bound (:489).
  • The PR's own definition. In #12679 the author describes the baseline as "any vector with a score >= resultSimilarity".
  • History. #12770 (merged 2023-11-07, 9.9.0) made the graph searcher's neighbour test strict because the search over-explored the graph on equal scores (its commit message names lower dimensionality and quantization); for top-k an equal score does not raise the cutoff. #12679 was in review then; the traversal = result default was proposed on 2023-11-30, and the PR merged on 2023-12-11 without adapting the collector. #14215 (10.2.0) kept the strict form; in its review jimczi noted that this contract "does place the responsibility on the various implementers". #15021 (10.4.0) added the exhaustive batch gate, and #16654 (10.5.2) restored the 10.4 collector's body, renamed and deprecated, behind Explicit.
  • Counterfactuals. On 9.10.0, the pre-#12770 >= in the graph searcher makes the repro pass; on 10.4.0, an inclusive batch gate makes it pass; on main, the one-line collector fix makes it pass.
The devil's-advocate check358 words

The strongest "works as designed" case (built on purpose by a devil's-advocate verifier and by a cross-model refute review, which rated it strong): (1) the queries are documented as "(approximate)" and @lucene.experimental, and no upstream traversal == result test checks that tied documents are returned (testApproximate asserts an upper bound); (2) the strict tie rule in the searchers is deliberate (#12770, #14215), and TestFilteredHnswGraphSearcher.testAllTiedScoresBoundedVisitation bounds equal-score exploration on purpose; (3) completeness is promised only for traversal = -Infinity: #15836 (10.5.0) made that case exact "to guarantee completeness"; (4) "above" can be read as strictly above, and testExplicitResultCollection pins the bound to min(traversal, best); (5) Explicit is a deprecated shim that #16654 proposed to remove.

Why it fails: (1, 3) approximation and #15836 cover documents the search never reaches (#15836 fixed a disconnected graph, where the missed document scored 0.1033 against 0.0979, not a tie); here the tie is reached: on a segment without a graph it is scored and then thrown away at a batch boundary, and on a graph a traversal one ulp lower finds every copy on the same graph; (2) the strict rule and the bounded tie test serve top-k collectors, which still pass with the fix, while this collector defines its results with >=; (4) collect(), explain(), the exact fallback and the upstream unit test all mean >=, the pinned bound uses delta 1e-3 with traversal 0.3 below result 0.5, and under either reading one document at exactly 1.0 is returned and another is not, depending only on document order; (5) deprecation is a won't-fix risk, not intent: 9.10.0-10.4.0 hit it through the primary constructors, and Explicit still ships on main and branch_10_6. Verdict: a defect. The devil's advocate rated the intended argument weak, cross-model reviews moderate, the refute review strong (it could not run code); the reproducer and the adjudication say real. The cross-model fact check of the brief rated the case moderate for the no-graph path and strong for the graph path, where the query is approximate and equal-score exploration was bounded on purpose, so expect maintainers to weigh the graph case that way.

Suggested fix

Recommended A: report a bound one ulp below traversalSimilarity

One line in ExplicitVectorSimilarityCollector.minCompetitiveSimilarity(): return Math.min(Math.nextDown(traversalSimilarity), maxSimilarity), so a score equal to the threshold stays competitive. The strict search gates and top-k collectors are untouched. With it the repro passes, and 90 similarity-query tests and 891 vector, kNN and HNSW tests pass.

@Override
public float minCompetitiveSimilarity() {
  // Searchers treat a score equal to minCompetitiveSimilarity() as not competitive, but a score
  // equal to traversalSimilarity must still be traversed (and, when traversalSimilarity ==
  // resultSimilarity, collected).
  return Math.min(Math.nextDown(traversalSimilarity), maxSimilarity);
}
All options and trade-offs5 options ยท 698 words

The cause is one inclusive threshold handed to exclusive gates, and the gates are right for top-k collectors, so the fix belongs in the collector. Option A was built and validated in a scratch worktree at ea57360912ed (removed afterwards); the others were not built. Option A is this doc's recommendation: it also covers the full constructors, but whether equality should include ties is the maintainers' call, and its cost on tie-heavy data was not benchmarked. Users can work around it today with a full constructor (field, target, traversalSimilarity, resultSimilarity[, filter]) and traversalSimilarity = Math.nextDown(resultSimilarity), or with the Adaptive query on 10.5.2 and later (in 10.5.0 and 10.5.1 the plain constructors are already adaptive).

A: report a bound one ulp below traversalSimilarity (validated by the checks) (recommended)

In ExplicitVectorSimilarityCollector (and VectorSimilarityCollector in 9.10.0-10.4.0: 10.4.0 :65, 9.10.0 :64), return Math.min(Math.nextDown(traversalSimilarity), maxSimilarity). The bound is then below resultSimilarity, so any batch holding a score >= resultSimilarity passes the exhaustive gate, and the graph searcher's acceptance bound nextUp(bound) is at most traversalSimilarity (for finite thresholds; with traversalSimilarity -Infinity nothing changes, and the fix does not change how a NaN traversalSimilarity, which the constructor check lets through, is handled).

@Override
public float minCompetitiveSimilarity() {
  // Searchers treat a score equal to minCompetitiveSimilarity() as not competitive, but a score
  // equal to traversalSimilarity must still be traversed (and, when traversalSimilarity ==
  // resultSimilarity, collected).
  return Math.min(Math.nextDown(traversalSimilarity), maxSimilarity);
}

Trade-offs

  • Extra work. With many exact ties at the threshold the graph search now explores all of them; they are all results, so the work is proportional to the output. With traversal < result, nodes scoring exactly traversalSimilarity are now explored, as the javadoc ("below traversalSimilarity") describes. The worst case is a large plateau of equal scores at a traversalSimilarity below resultSimilarity: the graph search now explores it in full and returns none of it. No benchmark was run, and the upstream bounded-tie test uses a top-k collector, so it does not cover this collector.
  • Scope. The strict gates in the readers and searchers, and every top-k collector, are unchanged. The upstream testExplicitResultCollection pins minCompetitiveSimilarity with delta 1e-3 and still passes.
  • Backports. Not run on releases before main; the same line exists in 9.10.0-10.4.0 under the old class name.

What the checks validated

  • Stock: the 3 tests fail 3 of 3 through gradle, assertions on and off, seeds DEADBEEF and 5EED1234.
  • Patched, assertions on and off: the 7 closest classes (the repro, TestExplicitFloatVectorSimilarityQuery, TestExplicitByteVectorSimilarityQuery, TestVectorSimilarityCollector, TestFloatVectorSimilarityQuery, TestByteVectorSimilarityQuery, TestFloat16VectorSimilarityQuery): 90 tests, 0 failures, 3 skipped.
  • Patched: org.apache.lucene.search.*Vector*, *Knn*, org.apache.lucene.util.hnsw.*, codecs.lucene99.*, codecs.lucene104.*: 891 tests, 0 failures, 23 skipped.
  • ecjLint, forbiddenApis (main and test) and checkGoogleJavaFormat on core pass.
  • Earlier runs: the finder's, the independent reproducer's and the root-cause verifier's tests pass with the fix; 140 and 201 upstream similarity-query and collector tests pass; the random hunt's 473 failures drop to 0.

B: have the short constructors pass Math.nextDown(resultSimilarity) as traversal

Same effect for the constructors without a traversal argument; this is exactly the one-ulp control that was run.

Trade-offs

Leaves the full 4- and 5-argument constructors (field, target, traversalSimilarity, resultSimilarity[, filter]) broken when they are given equal values, and changes toString() and equals() of the short forms. Not built.

C: make the reader and searcher gates inclusive (>=)

Run only as a counterfactual: on 9.10.0 (graph searcher) and 10.4.0 (exhaustive gate) it makes the repro pass.

Trade-offs

Not recommended: it undoes the deliberate top-k optimisation of #12770 and #14215 for every collector, and would be needed in each reader and searcher, including the scalar-quantized reader.

D (with A): document the equality convention of KnnCollector#minCompetitiveSimilarity

State that a score equal to the returned value is treated as not competitive, as raised in the #14215 review.

Trade-offs

Documentation only; it prevents the next collector from repeating the mistake but fixes nothing by itself.

E: remove the deprecated Explicit classes

As #16654 proposes.

Trade-offs

Fixes main eventually, not 10.5.2, branch_10_6 or the 9.10.0-10.4.0 releases. Open PR #16811 rewrites the exhaustive path with a per-vector scores[j] > minScore check that, by code reading only, would widen the drop; A should land first or with it.

How it was found

While building a metamorphic test harness for the kNN and similarity queries, the finder read the exhaustive-scan batch gate and wrote a probe: the same query returned one copy without a filter and both with an all-matching filter. Random small indexes checked against an exact brute-force reference then hit it 473 times, always with traversal equal to result.

The method in full172 words

Method: code reading, then random differential (metamorphic) testing (the registry's non-formal tier; the finder tagged it semi-formal). A hunt unit built a harness of random small indexes (0-300 documents, 1-4 segments, deletions, small-integer vectors so every similarity is computed exactly, several vector formats) and checked every kNN and similarity query against an exact brute-force reference.

  • Reading the exhaustive-scan gate bulkScore(...) > minCompetitiveSimilarity() next to the collector's >=, the finder wrote a public-API probe: Explicit(field, target, 1.0f) returned [0], the same query with a MatchAllDocsQuery filter [0, 64].
  • The random hunt's relation "similarity query == documents with exact score >= t" then failed 473 times in 55,970 traversal == result checks over 1,496 indexes (default codec and both scalar-quantized formats), with 0 failures for traversal -Infinity, traversal t - 0.25 and the Adaptive query, 0 when no segment exceeded 60 vectors, and 0 with the fix.
  • The finder minimised it to the 65-vector JUnit; an independent reproducer, a root-cause verifier, a devil's advocate and an adjudication followed (see the checks).

Prior reports, history and checks

Prior reports

No prior report found: 261 queries over Lucene, Solr, OpenSearch and Elasticsearch issues, PRs and commits, the JIRA archive, the Lucene mailing lists and the web, 32 hits read (2026-10-26). Closest is #14215, which set the strict tie rule for top-k only.

Searches4 searches

None of the items read reports this bug.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene main, branch_10x, branch_10_6; apache/solr; the OpenSearch k-NN plugin and OpenSearch; Elasticsearch)2026-10-26a scripted prior-report search in four runs (class, method, symptom, mechanism and downstream phrasings) (210 queries) plus 51 manual searches; 261 recorded queriesno report of this bug; no open PR changes the collector's bound or the gates' equality
Related items (read)2026-10-2632 hits readmechanism only: #14215 (strict nextUp rule; review remark on the contract), #12770, #14249, #14366, #16409, #15021 (top-k gates); #12679, #15784, #16654/#16671 (introduction, adaptive replacement, Explicit restoration); #12955, #15816/#15836 (disconnected graph, not a tie)
Open PRs2026-10-26PRs touching the vector readers and the similarity collectors#16811 rewrites the exhaustive path and, by code reading only, would widen the drop; #15802 and #16882 do not change the bound
Apache JIRA (LUCENE, SOLR) and the dev@, java-user@, issues@, commits@ lists2026-10-26tool queries plus manual JIRA searchesSOLR-17713 (open): a user sets minTraverse equal to minReturn, the trigger configuration, without reporting the drop

History

Introduced in 9.10.0 by #12679 (merged 2023-12-11), which added the query; affected through 10.4.0. Gone in 10.5.0 and 10.5.1 (adaptive collector), back in 10.5.2 when #16654 restored the 10.4 behaviour as the deprecated Explicit classes. Still on main and branch_10_6.

Versions tested8 rows

Introduced by #12679 "Add support for similarity-based vector searches" (kaivalnp), cd195980ecbc on main, committed 2023-12-11, backport 8089b8c9d337, first released in 9.10.0. The graph searcher was already strict (#12770, merged 2023-11-07), so the code was defective the day it merged. From 10.4.0, #15021 (exhaustive batch gate) and #14963 (no graph for small segments) moved the repro's 65-vector case to the exhaustive scan. #15784 (be2af1e7b43, 10.5.0) switched the plain constructors to the adaptive collector; #16654 (bc9adc96cc58, 2026-09-13; backport #16671) restored the 10.4 collector as Explicit in 10.5.2.

Versions and branches executed, with results
Version or commitVerdictResult
9.9.0, 9.9.2 no APIdoes not compile: the query does not exist yet (9.9.1 was not run; its release tag has no such class)
9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3 affectedfail 2/2 (9.12.3 also with assertions off); plain constructors, graph search
10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2 affectedfail 2/2; plain constructors, graph search
10.4.0 affectedfail 2/2, assertions on and off; plain constructors, exhaustive scan
10.5.0, 10.5.1 not affectedpass 2/2: the plain constructors use the adaptive collector
10.5.2 affectedfail 2/2, assertions on and off; deprecated Explicit
main ea57360912ed affectedfail 2/2, assertions on and off; this doc's 3 tests fail 3/3
9.10.0 with the pre-#12770 >=; 10.4.0 with an inclusive batch gate counterfactualpass 2/2: pins the cause per era

Independent checks

An independent reproducer, a devil's advocate and an adjudication say real; cross-model reviews (OpenAI GPT-5.6, reading only) agreed on the root cause, two rating it real. Every executed check fails on stock and passes with the one-line fix, and this doc's own gradle runs reproduced it and validated the fix.

Checks and verdicts689 words

Every check ran on main ea57360912ed with assertions on and off unless noted. The cross-model reviews could only read code; their execution evidence is separate re-runs of the finder's test (fail 2 of 2 each time).

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateProbe, then 473 random-hunt failures, 0 with the fix; the 2-test JUnit fails 2 of 2 on main, 10.5.2, 10.4.0, 10.3.2, 10.0.0, 9.12.3 and 9.10.0, and passes with the fix.
Refute review (OpenAI GPT-5.6, reading only) not real (medium), proceduralIt had no executed repro of its own, so it returned not real; it agreed with the mechanism and that the inputs are within the contract, and rated the intended argument strong (completeness is promised only for traversal -Infinity). A separate run re-ran the repro: 2 of 2 fail.
Intended-behaviour review (OpenAI GPT-5.6, reading only) real, mediumIntended argument moderate: the short constructors create the equality, and collect(), explain(), the exact fallback and the tests all use >=; root cause agreed.
Independent reproducer realOwn tests with two different vectors, both at distance 1 from the target, so both score exactly 0.5: [10] instead of [10, 150], 5 of 5 fail on main and 10.5.2; 5 controls pass; 10 of 10 pass with the fix; the plain-constructor variant fails on 10.4.0, 9.12.3 and 9.10.0, passes on 10.5.1. The all-matching-filter relation does not reproduce on 9.x.
Root-cause review (OpenAI GPT-5.6, reading only) and separate checks real, mediumThe review agreed: one cause with two consumers (exhaustive scan and graph search), intended argument moderate. A separate test: 1 of 12 copies on a 4,000-vector graph, with and without a filter; all 12 with the fix; 201 upstream tests pass stock and with the fix.
Devil's advocate real, intended argument weakBuilt and rejected four works-as-designed readings; probe on main, 10.4.0 and 9.10.0: 1 of 6 copies on a 3,000-vector graph, 6 of 6 with a one-ulp lower traversal; found the #12770 / #12679 timeline.
Adjudication confirmed, highRe-ran the independent reproduction's suite on a fresh seed (5 of 5 fail, assertions on and off; controls pass) and the finder's repro (fails; passes with the fix). Class: silent wrong results.
Follow-up, registry re-runs and audit confirmedHistory sweep of 38 runs, security assessment (none), prior-report search (new); fresh-seed re-runs and an independent credibility audit kept the row.
This doc's gradle runs reproduced; fix validated3 of 3 fail stock (two seeds, assertions on and off); with the fix 90 closest and 891 broader tests pass, and the static checks pass.
Fact checks of this doc core claims hold; wording correctedAn independent fact check re-ran the repro and the fix through gradle at ea57360912ed (3 of 3 fail stock with assertions on and off; 38 tests pass with the fix) and read every cited line; a cross-model fact check (OpenAI GPT-5.6, reading only) read the same sources. Neither found an error in the root cause, the repro, the affected versions or the fix. Their wording, scope and version corrections are applied on this page and in the brief; one point was rejected (resolution in the hunt's record). An independent diagram check passed; its wording points were applied and the visuals re-rendered.
Correction to the registry row wordings out of dateThe registry row (fact strip and registry card) still has wordings the checks corrected here: title, introduced_commit, affected_versions, notes and likelihood_note say "3/4-argument constructors" (there are two 4-argument overloads, and only (field, target, resultSimilarity, filter) sets the thresholds equal); impact_note says "no index size is needed" (from 10.4.0 a segment needs at least 65 vectors); introduced_commit says the collector was "restored verbatim" (same body, renamed and deprecated); notes lists FilteredHnswGraphSearcher as a consumer (this query never reaches it), calls segments of at most 64 vectors "safe" (only from the batch gate) and says a document's vector can be moved "below any threshold by one ulp" (not for byte vectors); upstream says "about 45 manual searches" (51); likelihood_note says "nothing was reported" (no public report was found).

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,838 words

Finding record

LFV-467, snapshot of 2026-11-06

Registry title
FloatVectorSimilarityQuery and ByteVectorSimilarityQuery with traversalSimilarity == resultSimilarity (the 3- and 4-argument constructors: the plain ones in 9.10.0-10.4.0, the deprecated Explicit classes in 10.5.2 and main) silently drop documents whose similarity is exactly resultSimilarity, because the collector's minCompetitiveSimilarity() rises to the inclusive threshold and every searcher treats an equal score as not competitive
Area
search / vector similarity-threshold queries (ExplicitVectorSimilarityCollector with the exhaustive scan and the HNSW graph search)
Priority
Low, rank 281: Needs an exact score tie at the threshold, and on 10.5.2 and main only the deprecated Explicit similarity-query classes are affected.
Impact class
silent wrong results
Impact note
Applications that run a similarity-threshold vector query with traversalSimilarity equal to resultSimilarity on the default codec, directory and IndexSearcher (in 9.10.0-10.4.0 the plain, non-deprecated FloatVectorSimilarityQuery/ByteVectorSimilarityQuery(field, target, resultSimilarity[, filter]) constructors; in 10.5.2 and main only the deprecated Explicit classes; 10.5.0 and 10.5.1 are not affected) silently miss documents whose score is exactly the threshold once another vector at or above it was seen earlier in the same segment: deterministically on the graph path for every tie met after the first hit (1 of 6 and 1 of 12 exact copies returned on 3,000- and 4,000-vector graphs), and on segments below about 650 vectors, which have no graph, whenever the tie sits in a later 64-vector scoring batch with no better vector; rarely an above-threshold document reachable only through tied graph nodes is lost too; no index size is needed, only an exact score tie, which is natural for exact-duplicate lookups (EUCLIDEAN threshold 1.0), byte vectors (integer arithmetic) and thresholds copied from a known document's score.
Summary
You index vectors with the default codec and run FloatVectorSimilarityQuery.Explicit(field, target, 1.0f) on main or 10.5.2 (the plain FloatVectorSimilarityQuery(field, target, 1.0f) on 9.10.0-10.4.0), which sets traversalSimilarity = resultSimilarity, to find every exact copy of target under EUCLIDEAN, and you expect every document with similarity >= 1.0, as collect() ('similarity >= resultSimilarity'), explain() ('Score above threshold') and the query's own exact fallback all count them. Instead only the first copy comes back: a 65-vector segment with copies at doc 0 and doc 64 returns [0] and count() 1, the same query with a MatchAllDocsQuery filter returns [0, 64], a 4,000-vector HNSW segment returns 1 of its 12 copies, and a traversalSimilarity one ulp lower returns all of them (byte vectors at an exact 0.5 behave the same). Once a hit at the threshold is collected, ExplicitVectorSimilarityCollector.minCompetitiveSimilarity() = min(traversalSimilarity, maxSimilarity) equals the inclusive result threshold, and both the exhaustive scan (a 64-vector batch is collected only if its best score > minCompetitiveSimilarity()) and the graph search (score >= nextUp(minCompetitiveSimilarity())) treat an equal score as not competitive; the one-line fix returns min(nextDown(traversalSimilarity), maxSimilarity).
Introduced
cd195980ecbcb45e19ce9465854fed0b6be79ea7 on main (GITHUB#12679 'Add support for similarity-based vector searches' by kaivalnp, merged 2023-12-11) and its branch_9x backport 8089b8c9d3374dc4d451a4280daae85e0b139b84, first released in 9.10.0 [read: git blame, git log -L and git show of the merge parent in the full-history clone; exec: 9.9.0 and 9.9.2 lack the API, 9.10.0 fails]: the new collector kept similarity >= resultSimilarity but reported min(traversalSimilarity, maxSimilarity) as minCompetitiveSimilarity, and the 3/4-argument constructors passed resultSimilarity as traversalSimilarity (a default the author proposed in the PR on 2023-11-30), while #12770 (e1ce1d692ca4, merged 2023-11-07 during that review, released in 9.9.0) had already made HnswGraphSearcher's level-0 neighbour test strict ('>'), so the code was defective the day it merged. Not adapted when #15021 (0421a8de771e, 10.4.0) added the exhaustive-scan batch gate 'max > minCompetitiveSimilarity()', and #14963 (4a7c9c2780b, 10.4.0) stopped building graphs for small segments, so that gate became the normal path for them. Gone in 10.5.0 (#15784, be2af1e7b43: the plain constructors use the adaptive collector) and restored verbatim in 10.5.2 by #16654 (bc9adc96cc58 on main, 2026-09-13; backport #16671 5caef2a1fda9) as the deprecated ExplicitVectorSimilarityCollector behind [Byte|Float]VectorSimilarityQuery.Explicit [read + exec] (2023-12-11 (main cd195980ecbc, #12679); reintroduced 2026-09-13 (main bc9adc96cc58, #16654; backport #16671, released in 10.5.2))
First affected version
9.10.0 [exec] (the release that added the query; 9.9.0 and 9.9.2 have no FloatVectorSimilarityQuery [exec: compile error]); 10.5.0 and 10.5.1 are not affected [exec] and 10.5.2 is affected again [exec]
Affected versions
9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2 and 10.4.0 through the plain 3/4-argument constructors, and 10.5.2 and main ea57360912ed through the deprecated Explicit 3/4-argument constructors (and the 5-argument form with equal values) [exec: the finder's JUnit fails 2 of 2 on every one of these releases in the follow-up history sweep, also with -da on 9.12.3, 10.4.0, 10.5.2 and main; the independent reproduction on main, 10.5.2, 10.4.0, 9.12.3 and 9.10.0; a separate run on main, 10.5.2 and 9.10.0; the intended-behaviour check on main, 10.4.0 and 9.10.0]; not affected: 10.5.0 and 10.5.1 (adaptive collector, no Explicit class) [exec], before 9.10.0 the API does not exist [exec: 9.9.0, 9.9.2]. Path by era [exec: probe and counterfactual shadow runs]: 9.10.0-10.3.2 drop the tie in the HNSW graph search (the 65-vector repro segment has a graph there); from 10.4.0 a segment below the graph threshold has no graph and the exhaustive scan's batch gate drops it, while larger segments still drop ties in the graph search [exec on main, 10.4.0 and 9.10.0]. Float16VectorSimilarityQuery has no Explicit variant and the Adaptive queries are not affected [read; Adaptive exec]. Upstream main 49483e82b357 (unreleased) and branch_10_6 still have the code, so the released 10.6.0 (2026-10-29) has it [read, 2026-10-26].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading; also reached by randomized/MBT
Upstream and prior reports
none found (2026-10-26, read-only; the prior-report record): the finder's run of the scripted prior-report search and three follow-up runs (GitHub issues, PRs and commits on apache/lucene, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web) plus about 45 manual searches (apache/lucene main, branch_10x and branch_10_6 commits by path, apache/solr, the OpenSearch k-NN plugin and OpenSearch, Elasticsearch, Apache JIRA LUCENE and SOLR): 261 recorded queries, 32 hits read, none the same bug; the independent reproduction's and the intended-behaviour check's own read-only searches found nothing either. Not fixed upstream: ExplicitVectorSimilarityCollector.java was last changed by #16654/#16671 on main (49483e82b357), branch_10x and branch_10_6, and no open PR changes its minCompetitiveSimilarity or the strict gates. Closest, mechanism only: #14215 (merged 2025-02; made HnswGraphSearcher accept only score >= nextUp(minCompetitiveSimilarity()); in its review GitHub jimczi remarked that the current KnnCollector#minCompetitiveSimilarity form 'does place the responsibility on the various implementers'; threshold collectors are not discussed), #12770 (strict level-0 test, merged during #12679's review), #14249/#14366/#16409 (top-k tie and boundary fixes), #15021 (exhaustive batch gate, no discussion of ties). Context: #12679 (the author defines the baseline as 'any vector with a score >= resultSimilarity'), #15784 (adaptive collector, not affected), #16654/#16671 (restored the 10.4 behaviour as the deprecated Explicit classes and proposed removing them), #12955 and #15816/#15836 (missed documents with traversal = -Infinity from a disconnected graph, not a tie), #13611 (kNN versus similarity-query recall); SOLR-17713 (open, 2025-03: a Solr user sets minTraverse equal to minReturn, the trigger configuration, without reporting this bug). Open PR #16811 (2026-10-11) rewrites the exhaustive path and adds a per-vector 'scores[j] > minScore' check, which by code reading only (not executed) would widen the drop to tied vectors inside a batch that holds a better vector. Gaps: GitHub search is capped per query and two manual REST calls hit the rate limit (re-run through GraphQL); keyword searches cannot prove absence; private security reports are invisible.
Lucene commit tested
main ea57360912ed (2026-10-26): this doc's three tests through gradle in a scratch worktree (assertions on and off, seeds DEADBEEF and 5EED1234), the one-line fix with the 7 closest similarity-query and collector classes (90 tests, assertions on and off), the vector, kNN, HNSW and codec test classes (891 tests) and the core static checks; the finder's two tests on the Maven Central jars of 21 of the 22 releases from 9.9.0 to 10.5.2 (all but 9.9.1, which has no such query; follow-up history sweep, SHA-1 checked); the hunt's main refresh a9779ef3c19 still reproduces. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/ExplicitVectorSimilarityCollector.java:28-29 (at ea57360912ed) (@Deprecated class), :35-37 (javadoc: 'The graph is traversed till better scoring nodes are available, or the best candidate is below traversalSimilarity. All traversed nodes above resultSimilarity are collected'), :57-63 (collect keeps similarity >= resultSimilarity at :59), :66-67 (minCompetitiveSimilarity returns Math.min(traversalSimilarity, maxSimilarity))
  • lucene/core/src/java/org/apache/lucene/search/FloatVectorSimilarityQuery.java:31-34 (at ea57360912ed) (class javadoc 'Search for all (approximate) float vectors above a similarity threshold', @lucene.experimental), :130-131 (@Deprecated Explicit), :152 (only traversalSimilarity > resultSimilarity is rejected), :182-183 and :194-195 (4- and 3-argument constructors pass resultSimilarity as traversalSimilarity); ByteVectorSimilarityQuery.java:129-130, :151, :181-182, :193-194 (same shape)
  • lucene/core/src/java/org/apache/lucene/search/AbstractVectorSimilarityQuery.java:175-176 (at ea57360912ed) (explain: score >= resultSimilarity -> 'Score above threshold'), :363 (exact fallback: score >= threshold)
  • lucene/core/src/java/org/apache/lucene/search/KnnCollector.java:74-80 (at ea57360912ed) (minCompetitiveSimilarity javadoc: 'When full, the minimum score should be returned'; silent on equal scores)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsReader.java:81 (at ea57360912ed) (EXHAUSTIVE_BULK_SCORE_ORDS = 64), :396 and :408 (a batch is collected only if bulkScore(...) > minCompetitiveSimilarity()); codecs/lucene104/Lucene104ScalarQuantizedVectorsReader.java:87, :390 and :402 (same gate)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphSearcher.java:304 (at ea57360912ed) and :352 (minAcceptedSimilarity = Math.nextUp(minCompetitiveSimilarity())), :343 (bulk batch skipped unless > minCompetitiveSimilarity()); util/hnsw/FilteredHnswGraphSearcher.java:133, :206, :215 (same convention)
  • lucene/core/src/test/org/apache/lucene/search/TestVectorSimilarityCollector.java:53-78 (at ea57360912ed) (testExplicitResultCollection collects the 0.5 score at resultSimilarity 0.5 under 'All nodes above resultSimilarity'); BaseExplicitVectorSimilarityQueryTestCase.java:389 (testVectorsAboveSimilarity: expected set built with score >= resultSimilarity, traversal -Infinity) and :456 (testApproximate: traversal == result, upper bound only); lucene/MIGRATE.md:815-835 (Explicit exposes the Lucene 10.4 behaviour)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FloatVectorSimilarityQuery.java:31-34 (class javadoc), :127-131 (Explicit deprecated), :152-154, :182-183, :194-195, :201-202; search/ByteVectorSimilarityQuery.java:31, :151, :181-182, :193-194
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/ExplicitVectorSimilarityCollector.java:35-37, 46, 57-63, 66-68
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/KnnCollector.java:70-80
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsReader.java:81, 373-378, 394-413; codecs/lucene104/Lucene104ScalarQuantizedVectorsReader.java:87, 390, 402
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphSearcher.java:56-58, 139-145, 304, 341-352; search/knn/KnnSearchStrategy.java:80-82; search/AbstractVectorSimilarityQuery.java:48 (DEFAULT_STRATEGY = Hnsw(0)); search/ExplicitVectorSimilarityCollector.java:46
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:715-721 and codecs/lucene99/Lucene99HnswVectorsFormat.java:137 (graph only when numVectors > (int)(ln(numVectors) * 100): first at 648, computed)
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/AbstractVectorSimilarityQuery.java:175-176, 222-247 (with a filter: visit limit = the filter's cost, exact fallback unless the result is complete), 363; search/AbstractKnnCollector.java:44-45
Who is affected
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/FloatVectorSimilarityQuery.java:127-131, 182-195; lucene/MIGRATE.md:834-835
  • Lucene main ea57360912ed dev-tools/doap/lucene.rdf (9.10.0 released 2024-02-20)
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/ExplicitVectorSimilarityCollector.java:35-37, 59; search/AbstractVectorSimilarityQuery.java:175-176, 363
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/search/TestVectorSimilarityCollector.java:53-85; search/BaseExplicitVectorSimilarityQueryTestCase.java:139-169 (testExtremes), 389-430, 432-454 (testFallbackToExact), 456-492 (testApproximate), 550-563 (getSimilarity)
  • Lucene main ea57360912ed: lucene/CHANGES.txt:2940-2943 (#12679 entry), :1167-1168 (#15836); lucene/core/src/test/org/apache/lucene/util/hnsw/TestFilteredHnswGraphSearcher.java:69
Suggested fix
  • a Lucene clone releases/lucene/10.4.0 and releases/lucene/9.10.0: lucene/core/src/java/org/apache/lucene/search/VectorSimilarityCollector.java (:65 and :64)
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/search/TestVectorSimilarityCollector.java:81-84 (delta 1e-3)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

The diagram follows the repro's 65-vector example. Every value on screen comes from the code at ea57360912ed or an executed run, per the diagram script; it is made in parallel with this doc and was re-rendered after the diagram check (wording and contrast fixes).

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.