Current Behavior
In local mode, a cosine vector whose norm falls below EPSILON (1.1920929e-7) gets three different treatments:
- dense vectors keep it
- multivectors normalize it on upsert
- the first search rewrites the dense one
A real Qdrant server keeps it in all three cases.
import numpy as np
from qdrant_client import QdrantClient, models
TINY = [5e-11] * 4 # norm 1e-10, below EPSILON
c = QdrantClient(":memory:")
# (1) dense: stored as given, then rewritten by the first search
c.create_collection(
"d",
vectors_config=models.VectorParams(size=4, distance=models.Distance.COSINE),
)
c.upsert("d", points=[models.PointStruct(id=1, vector=TINY)])
print(c.retrieve("d", [1], with_vectors=True)[0].vector[0])
# 5.00000006675716e-11
c.query_points("d", query=[1.0] * 4, limit=1)
print(c.retrieve("d", [1], with_vectors=True)[0].vector[0])
# 0.5
# (2) multivector: normalized on upsert, unlike dense and unlike the server
c.create_collection(
"m",
vectors_config=models.VectorParams(
size=4,
distance=models.Distance.COSINE,
multivector_config=models.MultiVectorConfig(
comparator=models.MultiVectorComparator.MAX_SIM
),
),
)
c.upsert("m", points=[models.PointStruct(id=1, vector=[TINY])])
print(c.retrieve("m", [1], with_vectors=True)[0].vector[0][0])
# 0.5
There are two causes behind this.
Write path. _add_point guards dense normalization with norm > EPSILON and leaves the vector alone. For multivectors it uses np.where(vector_norm != 0.0, vector_norm, EPSILON), which divides by any nonzero norm, however small. _update_point and _update_vectors carry the same asymmetry.
Search path. cosine_similarity then normalizes its candidate set in place with that same != 0.0 form, and the candidate set is the stored array. #1357 added a comment calling this a no-op in practice, on the grounds that vectors on cosine collections are always already unit-normalized. That holds for vectors the write path normalized, but not for the ones the norm > EPSILON guard deliberately skipped. So searching such a collection rewrites what is stored.
Checked against Qdrant 1.18.1 running locally:
|
before search |
after search |
| server, dense |
5.0e-11 |
5.0e-11 |
| server, multivector token |
5.0e-11 |
5.0e-11 |
| local, dense |
5.0e-11 |
0.5 |
| local, multivector token |
0.5 |
0.5 |
Expected Behavior
A near-zero cosine vector comes back the way it went in, identically for dense vectors and multivectors, and a search leaves the stored value alone.
Severity is low. A float32 vector needs a norm under 1.19e-7 to reach this path, and real embeddings do not produce that. I'm raising it because the two paths disagree with each other and with the server, and because the invariant documented in #1357 does not actually hold.
Fixing it means changing the write-time normalization and cosine_similarity together. A write-path-only change would make the multivector case worse, since the vector would then be rewritten by the first search, exactly as the dense one already is. Happy to open a PR.
Came up in #1381.
Current Behavior
In local mode, a cosine vector whose norm falls below
EPSILON(1.1920929e-7) gets three different treatments:A real Qdrant server keeps it in all three cases.
There are two causes behind this.
Write path.
_add_pointguards dense normalization withnorm > EPSILONand leaves the vector alone. For multivectors it usesnp.where(vector_norm != 0.0, vector_norm, EPSILON), which divides by any nonzero norm, however small._update_pointand_update_vectorscarry the same asymmetry.Search path.
cosine_similaritythen normalizes its candidate set in place with that same!= 0.0form, and the candidate set is the stored array. #1357 added a comment calling this a no-op in practice, on the grounds that vectors on cosine collections are always already unit-normalized. That holds for vectors the write path normalized, but not for the ones thenorm > EPSILONguard deliberately skipped. So searching such a collection rewrites what is stored.Checked against Qdrant 1.18.1 running locally:
5.0e-115.0e-115.0e-115.0e-115.0e-110.50.50.5Expected Behavior
A near-zero cosine vector comes back the way it went in, identically for dense vectors and multivectors, and a search leaves the stored value alone.
Severity is low. A
float32vector needs a norm under1.19e-7to reach this path, and real embeddings do not produce that. I'm raising it because the two paths disagree with each other and with the server, and because the invariant documented in #1357 does not actually hold.Fixing it means changing the write-time normalization and
cosine_similaritytogether. A write-path-only change would make the multivector case worse, since the vector would then be rewritten by the first search, exactly as the dense one already is. Happy to open a PR.Came up in #1381.