Skip to content

Fix CosineSimilarity precision so identical inputs give 1.0 and distance 0.0 - #789

Open
atharv1205 wants to merge 2 commits into
apache:masterfrom
atharv1205:fix-cosine-similarity-precision
Open

atharv1205 wants to merge 2 commits into
apache:masterfrom
atharv1205:fix-cosine-similarity-precision

Conversation

@atharv1205

@atharv1205 atharv1205 commented Nov 4, 2026 •

Copy link
Copy Markdown

What

CosineSimilarity.cosineSimilarity can return values slightly above or below 1.0 for identical inputs, so CosineDistance returns tiny non-zero, even negative, distances:

Input (identical on both sides) Before After
similarity, 2 tokens 0.9999999999999998 1.0
similarity, 3 / 6 / 12 tokens 1.0000000000000002 1.0
CosineDistance.apply("a b", "a b") 2.220446049250313E-16 0.0
CosineDistance.apply("a b c", "a b c") -2.220446049250313E-16 0.0
CosineDistance.apply("a b c d e f", "a b c d e f") -2.220446049250313E-16 0.0

Over 10,000 random identical token maps, 2093 returned a similarity above 1.0 before this change.

Why

The denominator was Math.sqrt(d1) * Math.sqrt(d2). Each square root is rounded separately and their product can be off by one ulp in either direction, so dot / norm misses 1.0 for identical vectors.

How

Compute dotProduct / Math.sqrt(d1 * d2). For identical vectors d1 * d2 is an exact square, and Math.sqrt is correctly rounded, so the result is exactly 1.0. The result is also clamped with Math.min(1.0, ...) so rounding on other inputs cannot exceed 1.0. Only the upper bound is clamped: the method accepts signed vector components, so its range is [-1.0, 1.0], and negative similarities such as {x=1} versus {x=-1} (exactly -1.0) are kept. The d1 <= 0 || d2 <= 0 branch is unchanged.

Tests

  • CosineSimilarityTest: identical vectors of several sizes give exactly 1.0; non-negative vectors (the token counts CosineDistance builds) stay within [0.0, 1.0]; signed vectors keep negative similarities: a vector versus its negation gives exactly -1.0, and opposite-signed vectors stay within [-1.0, 0.0].
  • CosineDistanceTest: identical inputs ("a", "a b", "a b c", "a b c d e f", ...) give exactly 0.0; distance stays within [0.0, 1.0] over generated inputs.
  • The new tests fail without the runtime change (10 of 18 fail) and pass with it. Existing tests are unchanged.

  • Read the contribution guidelines for this project.
  • Read the ASF Generative Tooling Guidance if you use Artificial Intelligence (AI).
  • Run a successful build using the default Maven goal with mvn; that's mvn on the command line by itself.
  • Write unit tests that match behavioral changes, where the tests fail if the changes to the runtime are not applied.
  • Write a pull request description that is detailed enough to understand what the pull request does, how, and why.
  • Each commit in the pull request should have a meaningful subject line and body.

CosineSimilarity divided the dot product by Math.sqrt(d1) * Math.sqrt(d2).
Rounding each square root separately can leave the product one ulp off, so
identical vectors gave 1.0000000000000002 or 0.9999999999999998 and
CosineDistance returned small non-zero, even negative, values such as
-2.220446049250313E-16 for "a b c".

Compute dotProduct / Math.sqrt(d1 * d2) instead. For identical vectors
d1 * d2 is an exact square, so the result is exactly 1.0. Clamp with
Math.min(1.0, ...) so rounding on other inputs cannot exceed 1.0.

Add tests that identical inputs give similarity 1.0 and distance 0.0, and
that both stay within [0.0, 1.0] over generated inputs.

@agammann agammann left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The new range test and PR description treat cosine similarity as always lying in [0, 1], but this public method accepts signed Integer vector components without a nonnegative precondition. For example, {x=1} versus {x=-1} correctly has cosine similarity -1; only the token-frequency vectors used by CosineDistance are necessarily nonnegative. Could the test name/description make that scope explicit and add a signed-vector case, so a future lower clamp does not discard valid negative similarities?

The range test only covers non-negative vectors, such as the token
counts built by CosineDistance. The method accepts signed components,
so add a test that negative similarities are kept: a vector versus its
negation gives exactly -1.0, and opposite-signed vectors stay within
[-1.0, 0.0].
@atharv1205

Copy link
Copy Markdown
Author

Thanks @agammann, good point. Only the upper bound is clamped, so negative similarities are kept. I renamed the range test to testCosineSimilarityOfNonNegativeVectorsIsInRange and added testCosineSimilarityOfSignedVectors, which checks that {x=1} vs {x=-1} is exactly -1.0, that a vector vs its negation is -1.0, and that opposite-signed vectors stay within [-1.0, 0.0]. I also updated the description to say [0, 1] holds only for non-negative vectors.

@agammann

Copy link
Copy Markdown

Thanks, I rechecked e1b7ea0: the non-negative range test is scoped to non-negative vectors, and the signed-vector tests cover negative similarity, including an opposite vector. That addresses my review point. I reviewed the diff but did not run the tests locally. AI-assisted review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants