Context Search returns negative loss scores inconsistent with the documented formula
Summary
Qdrant 1.19.0 applies an additional nonlinear compression to negative Context Search losses, causing the returned scores to differ from the formula documented by Qdrant.
The documented formula is:
sum(min(positive_similarity - negative_similarity, 0.0))However, when the raw loss is -1.6, Qdrant returns approximately -0.61538464. When the raw loss is -0.4, it returns approximately -0.28571436.
These values correspond to an additional transformation:
x / (1 + abs(x))rather than the documented raw loss.
Environment
- Qdrant:
qdrant/qdrant:v1.19.0 - Distance:
Cosine - Vector dimension: 2
- API:
POST /collections/{collection_name}/points/query - Query type: Context Search
- Search mode:
exact=true - Filter: none
- Quantization: disabled
- Python dependency:
requests==2.34.2
Steps to Reproduce
Start Qdrant:
docker run --rm --name qdrant-context-score-repro \
-p 127.0.0.1:16354:6333 \
qdrant/qdrant:v1.19.0Install the dependency:
pip install requests==2.34.2Run this standalone Python script:
import json
import math
import uuid
import requests
BASE = "http://127.0.0.1:16354"
COLLECTION = "context_score_" + uuid.uuid4().hex[:8]
POSITIVE = [1.0, 0.0]
NEGATIVE = [-1.0, 0.0]
SEED = {
0: [0.8, 0.6],
1: [0.0, 1.0],
2: [-0.8, 0.6],
3: [-0.2, 0.9797958971],
4: [0.3, -0.9539392014],
}
MUTANT = [-0.5, 0.8660254038]
def call(method, path, body=None):
response = requests.request(
method,
BASE + path,
json=body,
timeout=30,
)
response.raise_for_status()
return response.json() if response.content else {}
def dot(left, right):
return sum(a * b for a, b in zip(left, right))
def documented_score(vector):
positive = dot(POSITIVE, vector)
negative = dot(NEGATIVE, vector)
return min(positive - negative, 0.0)
def query():
result = call(
"POST",
f"/collections/{COLLECTION}/points/query",
{
"query": {
"context": [
{
"positive": 100,
"negative": 101,
}
]
},
"limit": len(SEED),
"params": {
"exact": True,
},
},
)["result"]["points"]
return {
int(point["id"]): float(point["score"])
for point in result
}
try:
call(
"PUT",
f"/collections/{COLLECTION}",
{
"vectors": {
"size": 2,
"distance": "Cosine",
}
},
)
points = [
{"id": 100, "vector": POSITIVE},
{"id": 101, "vector": NEGATIVE},
]
points.extend(
{"id": point_id, "vector": vector}
for point_id, vector in SEED.items()
)
call(
"PUT",
f"/collections/{COLLECTION}/points?wait=true",
{"points": points},
)
seed_actual = query()
seed_expected = {
point_id: documented_score(vector)
for point_id, vector in SEED.items()
}
call(
"PUT",
f"/collections/{COLLECTION}/points/vectors?wait=true",
{
"points": [
{
"id": 1,
"vector": MUTANT,
}
]
},
)
mutant_actual = query()
mutant_expected = dict(seed_expected)
mutant_expected[1] = documented_score(MUTANT)
print(json.dumps({
"seed_actual": seed_actual,
"seed_expected_documented": seed_expected,
"mutant_actual": mutant_actual,
"mutant_expected_documented": mutant_expected,
}, indent=2, sort_keys=True))
assert abs(seed_actual[2] - seed_expected[2]) > 1e-5
assert abs(mutant_actual[1] - mutant_expected[1]) > 1e-5
print("BUG REPRODUCED")
finally:
requests.delete(
f"{BASE}/collections/{COLLECTION}",
timeout=30,
)Actual Behavior
Typical output:
seed_actual:
{
0: 0.0,
1: -0.0000001192,
2: -0.61538464,
3: -0.28571436,
4: 0.0
}
seed_expected_documented:
{
0: 0.0,
1: 0.0,
2: -1.6,
3: -0.4,
4: 0.0
}After updating point 1:
mutant_actual[1] = -0.50000006
mutant_expected_documented[1] = -1.0The returned values correspond to:
-1.6 / (1 + 1.6) = -0.6153846
-0.4 / (1 + 0.4) = -0.2857143
-1.0 / (1 + 1.0) = -0.5Expected Behavior
Returned scores should exactly follow the documented Context Search formula:
score = sum(min(positive_similarity - negative_similarity, 0.0))Expected values:
seed:
point 0: 0.0
point 1: 0.0
point 2: -1.6
point 3: -0.4
point 4: 0.0
after mutation:
point 1: -1.0Possible Solution
Do not apply an additional nonlinear compression to each negative loss, or update the official documentation to clearly state that the API returns compressed scores rather than the raw documented loss.
If the compressed score is intentional, consider exposing the raw Context Search loss separately so clients can interpret the result according to the documented formula.
Context (Environment)
This affects applications that use Context Search scores for:
- score thresholds;
- score calibration;
- cross-query score comparison;
- combining multiple scoring signals;
- recommendation or ranking decisions based on loss magnitude.
Non-negative losses still return 0.0, so this is not an empty-result or result-order issue. It is a score-semantics mismatch for negative losses.
Detailed Description
The positive context vector is:
[1.0, 0.0]The negative context vector is:
[-1.0, 0.0]For candidate [-0.8, 0.6]:
positive_similarity = -0.8
negative_similarity = 0.8
difference = -1.6The documented score is -1.6, but the API returns -0.61538464.
For candidate [-0.2, 0.9797958971]:
difference = -0.4The documented score is -0.4, but the API returns -0.28571436.
The behavior reproduces across fresh collections and remains after updating a stored vector.
Possible Implementation
Add Context Search regression tests covering:
- zero loss;
- negative loss;
- vector updates;
- Cosine, Dot, and Manhattan distance metrics.
The tests should verify that the API output matches the documented formula, or explicitly document any intentional score transformation.
Source: qdrant/qdrant