Skip to content

Local mode: near-zero cosine vectors diverge from the server, and a search rewrites them #1382

Description

@Gronoxx

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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions