All findings · Lucene finding · LFV-1079 · vector-similarity-valuesource-equals-ignores-function

Vector similarity value sources: equals() ignores the similarity function that hashCode() includes, so EUCLIDEAN and COSINE sources compare equal

Two FloatVectorSimilarityFunction value sources over the same vectors, one EUCLIDEAN and one COSINE, score the document vector [2, 1] 0.333 and 0.9, yet equals() returns true while their hash codes differ. The shared base class compares only the two vector sources; one added condition fixes it.

Summary

Priority
Low, rank 859Since 9.7.0, vector similarity value sources with different similarity functions compare equal but have different hash codes. Lucene's own consumers are hash-based or never cache these queries, so in practice no search result changes and only application code that relies on equals alone is affected, which keeps it Low.
Impact
minor
Since
9.7.0 (#12253, 2023)introduced 2023-06-14 · about 3 years
Affected
9.7.0 to 10.6.0, main and branch_11x; not fixed upstream
Status
documented
Found by
semi-formal: a bytecode scan for fields that hashCode reads but equals does not, then a one-test JUnit

Upstream status: Fix in progress, by sjs004 (PR #16974) on 2026-11-03. PR #16974 references this bug. Source

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. A fix for it is open upstream (see Upstream status). Snapshot of 2026-11-06.

The finding in 30 seconds

These value sources compute similarityFunction.compare(v1, v2) for each document. Their base class stores three fields, but equals() compares only the two vector sources, while hashCode() and description() include the function. So two sources that differ only in the function are equal, yet their hash codes differ (with equal operands they could only match if the two enum constants' identity hash codes collided).

Background: vector similarity function queries

  1. A ValueSource (module lucene/queries) computes one value per document. FunctionQuery scores each document by it, and FunctionRangeQuery matches the documents whose value lies in a range.
  2. FloatVectorSimilarityFunction(function, v1, v2) (also ByteVectorSimilarityFunction and, on main and branch_11x, Float16VectorSimilarityFunction) computes function.compare(v1, v2), for example a document's vector field against a constant query vector, with any of EUCLIDEAN, DOT_PRODUCT, COSINE or MAXIMUM_INNER_PRODUCT.
  3. ValueSource declares equals and hashCode abstract, and the query classes delegate their equality to it. Java requires that objects that are equals return the same hash code.
Summary card. Panel 1, what the user does: a = new FloatVectorSimilarityFunction(EUCLIDEAN, field, target), b = the same with COSINE, over the field v and the constant query vector [1, 2]; for one document with v = [2, 1], a scores 1 / (1 + 2) = 0.333 and b scores (1 + 0.8) / 2 = 0.9; expected a.equals(b) false, actual true on main with assertions on and off. Panel 2, why: the similarity function decides the score, the hash code and the description but is missing from equals, so a.equals(b) is true while the hash codes differ; ArrayList.contains(b) is true, HashSet.contains(b) is false, and FunctionQuery passes the equality on. Fix strip: one more condition in equals, similarityFunction == ((VectorSimilarityFunction) o).similarityFunction; the repro passes and a source with the same function and operands stays equal.
The repro in one card: the EUCLIDEAN and the COSINE source over the same field and query vector score a document 0.333 and 0.9, yet a.equals(b) is true while their hash codes differ; a List treats them as equal, a HashSet does not. One more condition in equals fixes it. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build two value sources over the vector field v and the query vector [1, 2]: new FloatVectorSimilarityFunction(EUCLIDEAN, new FloatKnnVectorFieldSource("v"), new ConstKnnFloatValueSource(new float[] {1, 2})), and the same with COSINE.
  2. They compute different things: for a document with v = [2, 1] the EUCLIDEAN source gives 0.333 and the COSINE source 0.9. You expect equals() to return false; hashCode() and description() already tell them apart.
  3. Instead equals() returns true, with assertions on and off, in every release since 9.7.0, while the hash codes differ (1652613436 vs 613814086 in one run). Java requires equal objects to have equal hash codes.
  4. The queries built on them inherit it: two such FunctionQuery objects are equal, so List.contains says yes while a HashSet says no, and a FunctionRangeQuery over [0.5, 1] that matches 0 documents equals one that matches 1.
  5. Why: VectorSimilarityFunction.equals compares only vector1 and vector2, while hashCode is Objects.hash(similarityFunction, vector1, vector2). A review commit in the PR that added the class (#12253) dropped the function from equals.

Who is affected

Only callers that compare two of these sources (or the queries around them) with different functions over the same operands by equals alone: List.contains, equality-based caches or de-duplication, tests. Inside Lucene no search result changes in practice: these value-source weights are never cached, and query de-duplication is hash-based (barring an identity-hash collision of the two enum constants).

Mechanism, terms and numbers484 words

FloatVectorSimilarityFunction, ByteVectorSimilarityFunction and (on main and branch_11x) Float16VectorSimilarityFunction are lucene/queries value sources that score each document by similarityFunction.compare(v1, v2), for example a document's vector field against a constant query vector. Their shared base class valuesource.VectorSimilarityFunction holds three fields, but its equals() compares only the two vector sources, while hashCode() (Objects.hash(similarityFunction, vector1, vector2)) and description() include the function. So a EUCLIDEAN and a COSINE source over the same field and query vector, which score a document [2, 1] 0.333 and 0.9, compare equal with different hash codes, breaking the java.lang.Object contract; FunctionQuery, FunctionRangeQuery and FunctionScoreQuery pass the false equality on. Inside Lucene no search result changes in practice, because these value-source weights are never cached and query de-duplication is hash-based (only a collision of the two enum constants' identity hash codes could defeat that); only callers that compare with equals alone are misled. It has been in every release since the classes were added in 9.7.0 (#12253): an earlier commit of that PR compared the function, and a review commit dropped it. The fix is one added condition.

The mechanism (main e1faeba08bde, valuesource/VectorSimilarityFunction.java)

  1. Three fields: similarityFunction, vector1, vector2 (:29-31), set by the public constructor (:33-41).
  2. The function decides the values. Each subclass's func ends in return similarityFunction.compare(v1, v2); (FloatVectorSimilarityFunction.java:46, ByteVectorSimilarityFunction.java:47, Float16VectorSimilarityFunction.java:46). No subclass overrides equals or hashCode.
  3. equals leaves it out (:79-85): after the this == o, null and getClass() checks it returns Objects.equals(vector1, ...vector1) && Objects.equals(vector2, ...vector2).
  4. hashCode and description() include it: Objects.hash(similarityFunction, vector1, vector2) (:89) and similarityFunction.name() + "(" + ... (:94).
  5. The operands compare by value (ConstKnnFloatValueSource with Arrays.equals, FloatKnnVectorFieldSource by field name), so separately built EUCLIDEAN and COSINE sources over "field v, vector [1, 2]" are equal.
  6. The wrappers pass it on. FunctionQuery.equals is sameClassAs(other) && func.equals(...) (FunctionQuery.java:180-182); FunctionRangeQuery compares its bounds and Objects.equals(valueSource, other.valueSource) (FunctionRangeQuery.java:115-125); FunctionScoreQuery compares its source (FunctionScoreQuery.java:144-149), and ValueSource.asDoubleValuesSource() wraps with Objects.equals(in, that.in) (ValueSource.java:249-253). FunctionScoreQuery use became possible in 10.4.0 (#15328).

The numbers (illustration probe, main, not the repro)

One document with v = [2, 1] and the query vector [1, 2]: EUCLIDEAN gives 1 / (1 + 2) = 0.333, COSINE (1 + 0.8) / 2 = 0.9. equals is true and the hash codes are 1652613436 and 613814086 (the function's part of the hash is the enum constant's identity hash, so the numbers can change between JVM runs: both runs of this probe printed this pair, the probe behind the diagram printed -1860021253 and -1987185915; they differed in every recorded run). List.of(euclideanQuery).contains(cosineQuery) is true, a HashSet of the same query says false. FunctionRangeQuery(source, 0.5f, 1f, true, true) matches 0 documents with EUCLIDEAN and 1 with COSINE, and the two range queries are equals.

Exposure and likelihood360 words
  • Who is exposed. Callers that compare these value sources or the queries around them with equals alone, for example List.contains, indexOf or remove, an equality-based cache or request de-duplicator, or a test that asserts two queries differ, and that see two functions over the same operands.
  • What does not break inside Lucene. The weights are never cached: isCacheable returns false in FunctionQuery (FunctionQuery.java:82-83), FunctionRangeQuery (FunctionRangeQuery.java:177-178) and the asLongValuesSource and asDoubleValuesSource wrappers (ValueSource.java:150-151, :230-231), so LRUQueryCache never keys on them. FunctionScoreQuery's scoring weight is cacheable only when both its inner weight and its source are (FunctionScoreQuery.java:280-282), so never with these sources; when scores are not needed it returns the inner query's weight itself (:117-120), which may be cached but whose matches do not depend on the function. BooleanQuery keeps its clauses in a Multiset / HashSet (BooleanQuery.java:132-140) and DisjunctionMaxQuery its disjuncts in a Multiset (DisjunctionMaxQuery.java:47); these compare hash codes first, which differ. Executed: a BooleanQuery with SHOULD EUCLIDEAN + SHOULD COSINE keeps both clauses (score 0.333 + 0.9 = 1.2333). No full sweep of in-tree consumers was run. One theoretical exception: with equal operands the two sources' hash codes are equal exactly when the two enum constants' identity hash codes are (Objects.hash multiplies the function's hash by 961, an odd number), and identity hash codes are not unique; in a JVM run where they collided, these sets and BooleanQuery.deduplicateClauses (BooleanQuery.java:714-716) would reach the wrong equals and could merge the two clauses. Never observed; not claimed.
  • Downstream. Solr's vectorSimilarity() builds these classes, and its 4-argument form takes the function from the request. Whether a Solr cache could mix them up was not tested (the devil's-advocate verifier only re-did Solr's key hash arithmetic by hand), so it is not claimed.
  • Default vs opt-in. Opt-in lucene/queries classes, not on the default search path. No index, codec or setting matters; assertions on or off behave the same.
  • How likely. Rare: it needs an equals-only caller that meets two functions over the same operands, and no report of it was found since 9.7.0 (June 2023).

Minimal repro

One JUnit test for lucene/queries, public API only, no index: build the EUCLIDEAN and the COSINE source over the same two operands and assert they are not equal. Deterministic and single-threaded; it fails the same way with assertions on and off.

TestVectorSimilarityFunctionEquals.java (lucene/queries, public API)35 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.queries.function;

import org.apache.lucene.index.VectorSimilarityFunction;
import org.apache.lucene.queries.function.valuesource.ConstKnnFloatValueSource;
import org.apache.lucene.queries.function.valuesource.FloatKnnVectorFieldSource;
import org.apache.lucene.queries.function.valuesource.FloatVectorSimilarityFunction;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestVectorSimilarityFunctionEquals extends LuceneTestCase {

  public void testSimilarityFunctionIsPartOfEquals() {
    ValueSource field = new FloatKnnVectorFieldSource("v");
    ValueSource target = new ConstKnnFloatValueSource(new float[] {1f, 2f});
    // Expected: EUCLIDEAN and COSINE sources are not equal; actual: equals() returns true.
    assertNotEquals(
        new FloatVectorSimilarityFunction(VectorSimilarityFunction.EUCLIDEAN, field, target),
        new FloatVectorSimilarityFunction(VectorSimilarityFunction.COSINE, field, target));
  }
}
Command
cp $REPRO/TestVectorSimilarityFunctionEquals.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/function/
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestVectorSimilarityFunctionEquals -Ptests.seed=5EED1079 -Ptests.asserts=true
# also with -Ptests.asserts=false; nothing depends on the seed
Expected
The EUCLIDEAN and the COSINE source are not equal, so assertNotEquals passes.
Observed
equals() returns true: AssertionError: Values should be different. Actual: COSINE(FloatKnnVectorFieldSource(v), ConstKnnFloatValueSource([1.0, 2.0])), with assertions on and off.

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

More files, environment, full output236 words

The test is byte-identical to the hunt's minimal repro and passes :lucene:queries:check -x test. It runs unchanged on the 10.6.0 and 9.7.0 release jars; 9.6.0 has none of these classes.

Executed: gradle on main e1faeba08bde with assertions on (seed 5EED1079) and off (5EED107A), both fail; the standalone runner on the 10.6.0 release jars with assertions on and off and on 9.7.0, all fail; the hunt's runs on main fail the same way. With the one-condition fix the test passes. JUnit's message prints only the second argument.

Use the same test as the regression test. It checks only the equals expectation: FunctionRangeQuery is exercised only by the illustration probe, and FunctionScoreQuery, the Float16 subclass, the query cache and downstream callers are not exercised.

Environment

lucenemain e1faeba08bde (12.0.0-SNAPSHOT); also the 10.6.0 and 9.7.0 release jars, test unchanged
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (plain Java; only this platform was run)
Command (environment)
cp $REPRO/TestVectorSimilarityFunctionEquals.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/function/
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestVectorSimilarityFunctionEquals -Ptests.seed=5EED1079 -Ptests.asserts=true
# also with -Ptests.asserts=false; nothing depends on the seed

Observed output

TestVectorSimilarityFunctionEquals > testSimilarityFunctionIsPartOfEquals FAILED
    java.lang.AssertionError: Values should be different. Actual: COSINE(FloatKnnVectorFieldSource(v), ConstKnnFloatValueSource([1.0, 2.0]))
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.failEquals(Assert.java:187)
        at junit@4.13.2/org.junit.Assert.assertNotEquals(Assert.java:163)
        at junit@4.13.2/org.junit.Assert.assertNotEquals(Assert.java:177)
        at org.apache.lucene.queries.function.TestVectorSimilarityFunctionEquals.testSimilarityFunctionIsPartOfEquals(TestVectorSimilarityFunctionEquals.java:31)
1 test completed, 1 failed

# the same with -Ptests.asserts=false, and on the 10.6.0 (assertions on and off) and 9.7.0 release jars;
# with the fix: 1 test completed, 0 failed

Why it looks unintended

  • The function decides the values, and hashCode() includes it: equal objects with different hash codes are wrong under any equality definition.
  • The PR that added the class compared the function in equals; a review change adding getClass() and null checks dropped it, unremarked.
  • LUCENE-7736: equals must "never return true for values sources that could return different values".
The full argument188 words
  • Contract. ValueSource declares equals and hashCode abstract (ValueSource.java:51-55); core Query.java:95-100 asks subclasses to implement equivalence "properly ... so that QueryCache works properly". java.lang.Object#hashCode requires objects that are equal by equals to return the same hash code; here they are equal and their hash codes differ.
  • Maintainer rule. On LUCENE-7736 (#8787) jpountz wrote: "I think it's important for equals to behave correctly and never return true for values sources that could return different values." romseygeek agreed in the next comment ("I'll change things back again").
  • Siblings. Other value sources put every value-deciding parameter in equals, for example LinearFloatFunction compares slope, intercept and source (LinearFloatFunction.java:85-91).
  • Tests. TestKnnVectorSimilarityFunctions has no equals, hashCode or QueryUtils assertions; it passes unchanged with the fix.
  • History. In PR #12253, commit 901febe400 (2023-06-13) compared similarityFunction in equals. uschindler's review asked for a getClass() check and a this == other / null check; commit 7219987d52 ("Addressing review") added them, and the function term disappeared, while hashCode kept the field. No review or conversation comment on the PR mentions leaving it out. The PR was squash-merged as 14c18d86245 (2023-06-14, 9.7.0).
The devil's-advocate check134 words

The strongest "intended" case (built by the devil's-advocate verifier; the cross-model lenses rated it weak): equality identifies only the subclass and its two operands, the function is "implied" by the vector field, and Query.equals' javadoc speaks of "document-filtering properties", which a FunctionQuery (it matches every document) does not change.

Why it fails: it cannot explain why hashCode() and description() include the function, and equal objects with different hash codes are wrong under any equality definition. The function is an independent constructor argument (func never reads the field's own similarity, and Solr's 4-argument form lets a request choose it). For FunctionRangeQuery the function does decide which documents match (0 vs 1 hits in the probe, still equals). And the PR history is consistent with an accidental omission. Verdict: a defect, Low.

Suggested fix

Recommended Compare the similarity function in equals (fix A)

Compare the function in equals: add similarityFunction == ((VectorSimilarityFunction) o).similarityFunction && before the two operand checks, one line. The repro then passes, and so do TestKnnVectorSimilarityFunctions, the whole lucene/queries suite (assertions on) and :lucene:queries:check -x test.

--- a/lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/VectorSimilarityFunction.java
+++ b/lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/VectorSimilarityFunction.java
@@ -80,7 +80,8 @@ public abstract class VectorSimilarityFunction extends ValueSource {
   public boolean equals(Object o) {
     if (this == o) return true;
     if (o == null || getClass() != o.getClass()) return false;
-    return Objects.equals(vector1, ((VectorSimilarityFunction) o).vector1)
+    return similarityFunction == ((VectorSimilarityFunction) o).similarityFunction
+        && Objects.equals(vector1, ((VectorSimilarityFunction) o).vector1)
         && Objects.equals(vector2, ((VectorSimilarityFunction) o).vector2);
   }
All options and trade-offs2 options ยท 369 words

One fix was built and validated in a scratch worktree of e1faeba08bde; the alternative is listed with its trade-off.

Compare the similarity function in equals (fix A) (validated by the checks) (recommended)

In VectorSimilarityFunction.equals, compare similarityFunction too, so equals uses the same three fields as hashCode. The field is an enum, so == is exact (and null-safe: the constructor does not null-check the function, VectorSimilarityFunction.java:33-41, though a null one already fails in func and description()). Sources with the same function and operands stay equal, and the three subclasses and every wrapper are fixed at once. A reviewer may prefer casting once (VectorSimilarityFunction other = (VectorSimilarityFunction) o;); same behaviour.

--- a/lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/VectorSimilarityFunction.java
+++ b/lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/VectorSimilarityFunction.java
@@ -80,7 +80,8 @@ public abstract class VectorSimilarityFunction extends ValueSource {
   public boolean equals(Object o) {
     if (this == o) return true;
     if (o == null || getClass() != o.getClass()) return false;
-    return Objects.equals(vector1, ((VectorSimilarityFunction) o).vector1)
+    return similarityFunction == ((VectorSimilarityFunction) o).similarityFunction
+        && Objects.equals(vector1, ((VectorSimilarityFunction) o).vector1)
         && Objects.equals(vector2, ((VectorSimilarityFunction) o).vector2);
   }

Trade-offs

A deliberate behaviour change for callers that compare two such sources (or the queries around them) with different functions over the same operands: equals now tells them apart, which is the intended correction. Pairs with the same function and operands stay equal, with the same hash code.

What the checks validated

  • Gradle, JDK 25, scratch worktree of e1faeba08bde: the test fails 1 of 1 on stock with assertions on and off; with the fix, the test plus TestKnnVectorSimilarityFunctions pass (2 suites, 12 tests, 0 failures) with assertions on and off (one upstream test skips itself without assertions); the whole :lucene:queries:test with assertions on passes (52 suites, 513 tests, 5 skipped, 0 failures); :lucene:queries:check -x test passes (checkGoogleJavaFormat, ecjLint, forbiddenApis).
  • The hunt: the repro passes with the fix shadowed, and so do the independent reproducer's own test (Byte subclass inside FunctionQuery, with its identical fix) and the intended-lens reviewer's contract test.

Drop the function from hashCode instead (not recommended)

Make hashCode match the current equals.

Trade-offs

Restores equals/hashCode consistency but makes the false equality permanent: sources that compute different values stay equal, against the LUCENE-7736 rule. Not validated.

Downstream note

Solr's vectorSimilarity() function builds these classes and its 4-argument form takes the similarity function from the request; whether any downstream cache confuses the two was not tested.

How it was found

An AI finder scanning query and value-source classes for fields that hashCode reads but equals does not flagged similarityFunction in all three subclasses; its equality harness then saw equal FunctionQuery objects with different hashes and scores. A later hunt unit reduced it to one public-API test.

The method in full122 words

Method: semi-formal (an automated bytecode field-set scan, an equality-congruence harness, then executed JUnit tests).

  • A hunt unit (query-object contracts), 2026-10-26: a bytecode scan of the stock jars reported VectorSimilarityFunction.similarityFunction as read by hashCode, description() and each subclass's func but not by equals, for the Float, Byte and Float16 subclasses. The unit's equality harness witnessed it: a FunctionQuery over EUCLIDEAN equals the one over DOT_PRODUCT while their hash codes and scores differ.
  • A later hunt unit (2026-11-02) re-ran the lead on main e1faeba08bde (unchanged) and reduced it to one public-API JUnit test, which fails with assertions on and off and passes with the one-condition fix.
  • Independent verifiers, cross-model reviews, an adjudication and an audit then re-ran it (see the checks below).

Prior reports, history and checks

Prior reports

No public report found in the sources searched (2026-11-02): GitHub issues, PRs and commits (incl. the migrated JIRA archive), the mailing lists, Solr, Elasticsearch, OpenSearch, the web and the open PRs. The nearest item is the introducing PR #12253, whose review touched equals without noticing the missing function.

Searches4 searches

None of the hits is the same bug. The class postdates the JIRA migration, so no JIRA report can exist. Search limits: keyword search only; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; apache/solr, Elasticsearch, OpenSearch)2026-11-02four scripted prior-report searches (each about 16 GitHub issue/PR, 3 commit and 2 downstream queries) and manual searches: VectorSimilarityFunction equals, VectorSimilarityFunction hashCode, similarityFunction equals, FunctionQuery equals vector, knn function query equals, the three class names and othersno report; the only targeted hit is #12253, the introducing PR
Open PRs and recent activity2026-11-02the changed files of the open apache/lucene PRs matching VectorSimilarityFunction, valuesource or FunctionQuery; every issue and PR updated since 2026-10-31; the commit history of the four class files on main, branch_10x and branch_10_6none touches queries/function/valuesource/; only #12253, #15329 (doubleVal) and #16383 (Float16 subclass) changed the files, none of them equals
Mailing lists, JIRA and the web2026-11-02about 20 queries per run over dev@, java-user@, issues@ and commits@; JIRA-archive and Solr JIRA queries; web queriesno report
Nearest items (read)2026-11-02#12253 review and comments, #15328 / #15329, #16383, Solr QueryEqualityTest, LUCENE-2092, LUCENE-10026, LUCENE-10552, #8861 (LUCENE-7810)the introducing PR (its review asked for getClass() and null checks), a different bug in the same class (doubleVal), the Float16 subclass, a Solr test that checks only equal inputs, and the same defect class fixed in other classes

History

Present since the classes were added in 14c18d86245 (#12253, 2023-06-14), so in every release from 9.7.0 to 10.6.0, in main and in branch_11x; not a regression. 9.6.0 has no such classes. Main, 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.7.0 were executed and all fail. Not fixed upstream.

Versions tested8 rows

Introduced by 14c18d86245 (main, author date 2023-06-14, #12253 "GITHUB-12252: Add function queries for computing similarity scores between knn vectors", eliaporciani; co-authored and merged by alessandrobenedetti) and its same-day branch_9x backport 433aa49b2ec; CHANGES lists it under 9.7.0. The equals block has the same md5 at every release tag from 9.7.0 to 10.6.0 and on main, and hashCode is Objects.hash(similarityFunction, vector1, vector2) at all of them. The only later commit to the file, 05af6514226 (#15329, 2025-10-12, doubleVal), does not touch equals or hashCode. The Float16 subclass (#16383, CHANGES 11.0.0) inherits the same equals on main and on the 11.0.0 release branch branch_11x (both unreleased; branch_11x has the same VectorSimilarityFunction.java as main); it is not on branch_10_6 or in any release. So 11.0.0 would ship the defect unless the fix also lands on branch_11x, a backport target. Upstream main head was e1faeba08bd at the 2026-11-02 search.

Versions and branches executed, with results
Version or commitVerdictResult
main e1faeba08bde affectedthis doc's test fails 1 of 1 through gradle with assertions on and off
branch_11x (11.0.0, unreleased) readthe same VectorSimilarityFunction.java blob as main (860f0c05617), with the Float16 subclass; not executed
10.6.0 affectedthis doc's test fails 1 of 1 with assertions on and off (release jars)
10.5.2, 10.0.0, 9.12.3 same pairthe hunt's earlier test of the same EUCLIDEAN/COSINE pair fails 1 of 1
9.7.0 first releasethis doc's test and the hunt's earlier test fail 1 of 1 (release jars)
9.7.0 to 10.6.0 (25 releases) readthe equals block is byte-identical and hashCode includes the function at every release tag
9.6.0 no APIthe classes do not exist; the test does not compile
main with the fix fixthe test passes, as do TestKnnVectorSimilarityFunctions and the whole lucene/queries suite (assertions on)

Independent checks

An independent reproducer, cross-model reviews (OpenAI GPT-5.6, executed), a devil's-advocate verifier, an adjudication and an audit agree on the root cause, and each re-ran a failing test. This doc's gradle runs reproduced it on main e1faeba08bde and validated the fix.

Checks and verdicts383 words

Every executed check saw equals return true for two sources that differ only in the function, from the same lines, VectorSimilarityFunction.java:79-85, and every one that ran the fix saw it pass. The cross-model reviews ran on an earlier main checkout, ea57360912ed, whose VectorSimilarityFunction.java is identical to e1faeba08bde.

Each independent check and its verdict
CheckVerdictWhat it found
first sighting flaggedThe bytecode scan flagged the field; the equality harness saw equal FunctionQuery objects with different hashes and scores; its 3-test JUnit failed on main, 10.5.2 and 9.12.3. Its cross-model reviews (OpenAI GPT-5.6, a reviewer that could not run code) returned a procedural "not real" at low confidence while agreeing with the root cause; intended argument weak.
finder reproducedRe-ran the lead on main e1faeba08bde and reduced it to the one test: fails with assertions on and off, passes with the fix.
independent reproducer realOwn test written from the claim (Byte subclass, DOT_PRODUCT vs EUCLIDEAN, inside FunctionQuery): fails on main (assertions on and off), 10.6.0, 10.5.2, 9.12.3 and 9.7.0; does not compile on 9.6.0; passes with the fix.
cross-model review, refute lens real (high)OpenAI GPT-5.6, executed: its own test fails with assertions on and off while same-function and changed-operand controls pass. Intended argument weak.
cross-model review, intended lens real (high)OpenAI GPT-5.6, executed: its contract test fails unpatched and passes with the fix. "Equality covers only the subclass and its operands" cannot explain equal objects with unequal hash codes; intended argument weak.
devil's advocate real, LowReplay fails on main (assertions on and off). Found the PR history (the function was compared, then dropped in a review commit) and the LUCENE-7736 rule.
adjudication confirmed (high), LowRe-ran with a new seed: fails with assertions on and off, passes with the fix. Rated it a contract break, not a wrong-results bug.
credibility audit confirmed (Low)The audit re-ran the test on main (assertions on and off) and rated the impact minor; a separate re-run also checked the one-test repro (no index, deterministic, assertions on and off) and the fix.
this doc reproducedGradle on main (assertions on and off) and the standalone runner on 10.6.0 (both) and 9.7.0 fail; fix A validated with the module's tests and static checks.

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,311 words

Finding record

LFV-1079, snapshot of 2026-11-06

Registry title
Function-query vector similarity value sources (Float/Byte/Float16VectorSimilarityFunction) leave the similarity function out of equals but hash it, so EUCLIDEAN and COSINE sources over the same vectors compare equal with different hash codes
Area
queries / function queries: valuesource.VectorSimilarityFunction equality (base of Float/Byte/Float16VectorSimilarityFunction, and so of the FunctionQuery, FunctionRangeQuery and FunctionScoreQuery that wrap them)
Priority
Low, rank 859: Since 9.7.0, vector similarity value sources with different similarity functions compare equal but have different hash codes. Lucene's own consumers are hash-based or never cache these queries, so in practice no search result changes and only application code that relies on equals alone is affected, which keeps it Low.
Impact class
minor
Impact note
Only code that builds two of these lucene-queries value sources (or the FunctionQuery, FunctionRangeQuery or FunctionScoreQuery that wrap them) over the same two vector sources with different similarity functions, and then compares them with equals alone (List.contains or indexOf, equality-based de-duplication or caching, tests that assert inequality), treats a COSINE source as the same as a EUCLIDEAN one; inside Lucene no search result changes in practice, because these weights are not cacheable (FunctionQuery, FunctionRangeQuery and ValueSource.asDoubleValuesSource return isCacheable false) and in-tree de-duplication (the BooleanQuery and DisjunctionMaxQuery Multiset/HashSet) is hash-based while the hash codes differ; no index or scale is needed, -ea and -da behave the same, and it has been in every release since 9.7.0.
Summary
You build two function-query value sources over the same vector field and the same query vector, one with EUCLIDEAN and one with COSINE (new FloatVectorSimilarityFunction(EUCLIDEAN, field, target) and the same with COSINE), and expect them to be unequal because they compute different scores; the class itself treats the function as part of its identity, since hashCode() and description() include it. Instead equals() compares only the two vector sources and returns true, while hashCode() returns different values, which breaks the java.lang.Object rule that equal objects have equal hash codes. The Byte and Float16 subclasses share this equals, and FunctionQuery and the other wrappers pass the false equality on.
Introduced
14c18d86245 (apache/lucene#12253 'GITHUB-12252: Add function queries for computing similarity scores between knn vectors', author eliaporciani, co-author and merger alessandrobenedetti; author date 2023-06-14T14:49Z, the same instant as the PR merge; branch_9x backport 433aa49b2ec the same day): it added the classes with this equals [read: git blame of equals and hashCode on main and at 9.7.0; exec: 9.6.0 lacks the classes, 9.7.0 fails]. Inside the squash-merged PR, commit 7219987d52 ('Addressing review', 2023-06-13) rewrote equals with the getClass() and null checks that uschindler had asked for and dropped the similarityFunction term that the previous PR commit 901febe400 still compared; hashCode kept the field (bc504291cc made it Objects.hash(similarityFunction, vector1, vector2)) [read: PR commits via the GitHub API]. The only later commit to the file, 05af6514226 (#15329, author date 2025-10-12, doubleVal), does not touch equals or hashCode. (2023-06-14)
First affected version
9.7.0 [exec: the repro fails on the 9.7.0 release jars; 9.6.0 has none of the classes (compile error)]; there is no release in which this equals was correct
Affected versions
every release from 9.7.0 through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main e1faeba08bde (12.0.0-SNAPSHOT); never fixed (upstream main head was e1faeba08bd at the 2026-11-02 search, and the last commit to the file, 05af6514226 of 2025-10-12, does not touch equals or hashCode; re-checked 2026-11-02). [exec] the one-test repro fails 1 of 1 on main with -ea and -da (seeds DEADBEEF, 5EED0497, 5EED1101 and 5EED1102), on the 10.6.0 release jars with -ea and -da, and on 10.5.2, 10.0.0, 9.12.3 and 9.7.0; the independent reproduction's own Byte-subclass test fails on main (-ea and -da), 10.6.0, 10.5.2, 9.12.3 and 9.7.0; 9.6.0 does not compile it (classes absent). [read] the equals block is byte-identical, and hashCode is Objects.hash(similarityFunction, vector1, vector2), at every releases/lucene tag from 9.7.0 to 10.6.0. The Float16VectorSimilarityFunction subclass (#16383, CHANGES 11.0.0) inherits the same equals on main and branch_11x (both unreleased). Not new in 10.6.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; a fix PR is open upstream.
Found by
semi-formal
Upstream and prior reports
none found (2026-11-02, re-checked the same day): four scripted prior-report search passes (the finder's two and two follow-up passes, each with about 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@) and 4 JIRA-archive queries, plus web queries); manual GitHub searches in apache/lucene (issues and PRs incl. the migrated JIRA archive), apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch; the changed files of the open apache/lucene PRs matching VectorSimilarityFunction, valuesource or FunctionQuery (none touches queries/function/valuesource); every apache/lucene issue and PR updated since 2026-10-31 (none related); author dates of every commit to the four valuesource files on main, branch_10x and branch_10_6; the comments of #12253, #12252, #16383, #15328 and #15329; Solr JIRA searches. Related, not reports: #12253 (the introducing PR; its review asked for getClass() and null checks in equals and did not mention the function), #15328/#15329 (same class, doubleVal), #16383 (added the Float16 subclass, review silent on equals), and the same defect class fixed in other classes (LUCENE-2092, LUCENE-10026, LUCENE-10552, #8861 = LUCENE-7810).
Lucene commit tested
main e1faeba08bde (2026-11-02) through gradle in a scratch worktree with assertions on and off; the 10.6.0 release jars (assertions on and off) and the 9.7.0 release jars through the standalone JUnit runner. The hunt and the follow-up also ran the same EUCLIDEAN/COSINE pair on 10.5.2, 10.0.0, 9.12.3 and 9.7.0 (all fail) and confirmed that 9.6.0 has no such classes. The fix was validated in the scratch worktree: the test, TestKnnVectorSimilarityFunctions (assertions on and off), the whole lucene/queries suite (assertions on) and :lucene:queries:check -x test.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/VectorSimilarityFunction.java:29-31 (fields), 79-85 (equals compares vector1 and vector2 only), 87-90 (hashCode = Objects.hash(similarityFunction, vector1, vector2)), 92-100 (description() starts with similarityFunction.name()); FloatVectorSimilarityFunction.java:46 (func calls similarityFunction.compare)
  • lucene/queries/src/java/org/apache/lucene/queries/function/ValueSource.java:51-55 (abstract equals and hashCode), 150 and 230 (isCacheable false); FunctionQuery.java:82 (isCacheable false), 180-187 (equals and hashCode delegate to the value source); FunctionRangeQuery.java:177 (isCacheable false)
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:132-140 (clause sets are Multiset/HashSet); DisjunctionMaxQuery.java:47 (Multiset of disjuncts)
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/VectorSimilarityFunction.java:29-31 (at e1faeba08bde) (fields), :33-41 (constructor), :79-85 (equals), :87-90 (hashCode), :92-100 (description)
  • lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/FloatVectorSimilarityFunction.java:46, (at e1faeba08bde) ByteVectorSimilarityFunction.java:47, Float16VectorSimilarityFunction.java:46 (func returns similarityFunction.compare)
  • lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/ConstKnnFloatValueSource.java:58-63, (at e1faeba08bde) FloatKnnVectorFieldSource.java:85-90 (operand equals)
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionQuery.java:180-187, (at e1faeba08bde) FunctionRangeQuery.java:115-125, FunctionScoreQuery.java:144-149, ValueSource.java:51-55, :249-253
  • lucene/CHANGES.txt:1381-1382 (at e1faeba08bde) (GITHUB#15328 under 10.4.0: doubleVal, FunctionScoreQuery use)
Who is affected
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionQuery.java:82-83, (at e1faeba08bde) FunctionRangeQuery.java:177-178, ValueSource.java:150-151, :230-231 (isCacheable false)
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:117-120 (at e1faeba08bde) (returns the inner weight when scores are not needed), :280-282 (FunctionScoreWeight.isCacheable = inner && valueSource)
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:132-140, (at e1faeba08bde) :714-716 (deduplicateClauses: HashMap, HashSet), DisjunctionMaxQuery.java:47 (hash-based clause sets)
  • apache/solr main f489a30a31b solr/core/src/java/org/apache/solr/search/VectorSimilaritySourceParser.java:84-88, :134-143 (read through the GitHub API, 2026-11-02)
Why it looks unintended
  • lucene/queries/src/java/org/apache/lucene/queries/function/ValueSource.java:51-55; (at e1faeba08bde) lucene/core/src/java/org/apache/lucene/search/Query.java:95-107
  • lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/LinearFloatFunction.java:85-91 (at e1faeba08bde)
  • apache/lucene#8787 (LUCENE-7736) comments by jpountz (JIRA comment 16211070) and romseygeek (16211308), read with gh issue view, 2026-11-02
  • apache/lucene#12253: PR commits 901febe400 and 7219987d52 (contents of VectorSimilarityFunction.java at each, GitHub API), review comment 1228085889 (uschindler, 2023-06-13T12:54:19Z)
Suggested fix
  • apache/lucene#8787 (LUCENE-7736, jpountz's comment)
History
  • a Lucene clone releases/lucene/9.7.0, 9.12.3, 10.6.0 and e1faeba08bde: md5 of the equals block (identical), Objects.hash(similarityFunction, vector1, vector2) present
  • lucene/CHANGES.txt:3555 (at e1faeba08bde) (GITHUB#12252 under 9.7.0), :210 (GITHUB#16383 under 11.0.0)
  • a Lucene clone origin/branch_11x da3ea39fa59 (2026-11-01): git ls-tree lists Float16VectorSimilarityFunction.java; VectorSimilarityFunction.java blob 860f0c05617 (same as e1faeba08bde); git merge-base --is-ancestor bfe5c847d3c origin/branch_11x exits 0; build-options.properties version.base=11.0.0; origin/branch_10_6 has no Float16 subclass

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

Repro files

About the diagrams

A light summary card (the PNG), made from a probe program run on main e1faeba08bde with assertions on and off and with the fix as a control.

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.