Cosine distance: vectors with norm below ~3.45e-4 are stored un-normalized, producing incorrect ranking
Nobody has claimed this yet.
- 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 x². 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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