qdrant / qdrant/qdrant

Cosine distance: vectors with norm below ~3.45e-4 are stored un-normalized, producing incorrect ranking

Open
#10,465 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

area/segment bug parked
Dominant language
Rust
Stars
34.7k
Forks
2.7k
Avg merge
1d 18h
Merged PRs (30d)
187

Description

Summary

The docs state that normalization for Cosine collections is unconditional:

For search efficiency, Cosine similarity is implemented as dot-product over normalized vectors. Vectors are automatically normalized during upload.

In practice normalization is skipped when the squared norm falls below f32::EPSILON, i.e. when ‖v‖ < sqrt(2^-23) ≈ 3.4526698e-4. Those vectors are stored as-is and scored by raw dot product, so cosine ranking becomes magnitude-dependent and can return the wrong nearest neighbour.

Reproduction

Qdrant 1.19.0, SearchParams(exact=true) (also reproduces without it).

1. Stored vectors are not normalized below the threshold

client.create_collection("c", vectors_config=VectorParams(size=2, distance="Cosine"))
client.upsert("c", wait=True, points=[
    PointStruct(id=0, vector=[1e-3, 0.0]),
    PointStruct(id=1, vector=[1e-4, 0.0]),
])
client.retrieve("c", ids=[0, 1], with_vectors=True)
id=0  stored [1.0,    0.0]   normalized
id=1  stored [0.0001, 0.0]   NOT normalized

2. The nearest neighbour is wrong

client.upsert("c2", wait=True, points=[
    PointStruct(id=0, vector=[1e-4, 0.0]),                      # same direction as query
    PointStruct(id=1, vector=[cos(pi/3), sin(pi/3)]),           # 60 degrees away
])
client.query_points("c2", query=[1.0, 0.0], limit=2)
top-1 score returned for id 0
expected id 0 — it points exactly at the query 1.0
actual id 1 — 60° away, score 0.5 1e-4 — the raw dot product

A vector pointing exactly at the query ranks below one 60° away. Because id 0 was
stored un-normalized, its reported similarity is its raw dot product with the query,
1e-4, rather than 1.0.

Threshold

Located by bisection to 3.4526699e-4, which agrees with sqrt(2^-23) = 3.4526698e-4 to seven significant figures — consistent with a norm_squared < f32::EPSILON guard, the residual being f32 rounding of x and . Below it, the returned score is exactly the raw dot product (verified: reported similarity equals ‖v‖ for ‖v‖ = 1e-5 and 1e-7).

The guard also applies to the query vector, though there the ranking survives when stored vectors are normalized, since scaling the query scales all scores equally.

The cause: a squared quantity tested against a linear epsilon

The guard is being handed the squared norm, but tests it against a threshold meaningful for the norm itself. In lib/segment/src/spaces/simple.rs:

pub fn cosine_preprocess(vector: DenseVector) -> DenseVector {
    let mut length: f32 = vector.iter().map(|x| x * x).sum();   // this is ‖v‖²
    if is_length_zero_or_normalized(length) {                    // but the parameter is `length`
        return vector;
    }
    length = length.sqrt();                                      // only here does it become ‖v‖
    vector.iter().map(|x| x / length).collect()
}

and in tools.rs:

pub fn is_length_zero_or_normalized(length: f32) -> bool {
    length < f32::EPSILON || (length - 1.0).abs() <= 1.0e-6
}

length at the call site is Σxᵢ², so the zero-check is really ‖v‖² < f32::EPSILON, i.e. ‖v‖ < sqrt(f32::EPSILON). That is 3.4526698e-4 rather than the 1.1920929e-7 the constant suggests — about 2,900× larger than intended. It matches the bisected threshold above to seven significant figures.

The same ordering appears in the SIMD paths (simple_avx.rs, simple_sse.rs, simple_neon.rs): the squared accumulator is passed to is_length_zero_or_normalized before sqrt() is applied.

Worth noting why this survived: the second clause is very nearly right. ‖v‖² = 1 exactly when ‖v‖ = 1, so the exact-match point is correct and only the tolerance band differs. Near 1, ‖v‖² - 1 ≈ 2(‖v‖ - 1), so testing the squared norm accepts |‖v‖ - 1| ≤ 5e-7 where testing the norm would accept 1e-6 — a factor of two, invisible in practice. Already-normalized vectors therefore round-trip correctly and the defect only shows up on small-norm input.

For completeness, the arithmetic genuinely does not need a guard this large. Normalization requires computing norm² = Σxᵢ² in f32, which underflows only near ‖v‖ ≈ 1e-22:

‖v‖ norm² in f32 usable
1e-4 1.0e-08 yes
1e-16 1.0e-32 yes
1e-22 9.8e-45 yes (subnormal)
1e-23 0 no — underflow

So the guard fires roughly 18 orders of magnitude earlier than the arithmetic requires. A vector of norm 1e-4 has a perfectly well-defined direction and normalizes exactly.

A true zero vector is different — it has no direction, and guarding that case is reasonable. The issue is that the guard also captures small-but-well-defined vectors.

Suggested fix

I am opening a PR alongside this issue with the option that seemed least invasive: moving
sqrt() above the guard, so the function receives a length rather than a squared length.
That corrects the units for both clauses and makes the helper's existing doc comment true.

There are at least two other reasonable fixes — comparing against f32::EPSILON², or
testing length == 0.0 specifically — and I do not think the choice between them is
obviously mine. The trade-offs are laid out in the PR; I am happy to switch to either.

One thing I cannot judge from outside: existing collections already hold un-normalized
vectors in this band.
Changing preprocessing affects new writes only, so a collection
written before the fix keeps its current ranking until those points are rewritten. Whether
that warrants a migration, a changelog note, or nothing is a call for you rather than me.

If the current threshold turns out to be deliberate, documenting it would still help — the
present wording promises unconditional normalization.

Environment

Qdrant 1.19.0, official qdrant-aarch64-unknown-linux-musl release binary
(commit 74f3e85b9473c62560006c043e13737ce6b48412), stock config/config.yaml,
single node, brute-force search (params: {"exact": true}).
Ubuntu 26.04 (aarch64) under WSL2 on Windows 11. Client: qdrant-client 1.19.0,
Python 3.14.4.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in lib/segment/src/spaces/simple.rs and inspect how cosine_preprocess passes the squared norm to is_length_zero_or_normalized in tools.rs. Compare the corresponding ordering in simple_avx.rs, simple_sse.rs, and simple_neon.rs. Done means small, nonzero vectors are normalized consistently and cosine ranking no longer depends on their magnitude.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
databases, search
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
65/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.