#10609·qdrant

Query returns zero rank at the documented equality boundary

Author: leemeiiCreated Sep 11, 2026Updated Sep 11, 2026
Labelsbug

Summary

Qdrant 1.19.0 returns an incorrect score for a Discover query when a candidate's positive and negative context similarities are exactly equal.

The documented Discover formula assigns a context rank of +1 when:

positive_similarity >= negative_similarity

However, when the two similarities are exactly equal, Qdrant contributes 0 instead of +1.

Environment

  • Qdrant: qdrant/qdrant:v1.19.0
  • Distance: Dot
  • Vector dimension: 2
  • API: REST POST /collections/{collection_name}/points/query
  • Query type: public discover
  • Search mode: exact=true
  • Filter: none
  • Quantization: disabled

Steps to Reproduce

Start Qdrant v1.19.0 on port 16355:

bash
docker run --rm --name qdrant-discover-tie-repro \
  -p 127.0.0.1:16355:6333 \
  qdrant/qdrant:v1.19.0

Install the dependency:

bash
pip install requests==2.34.2

Run this standalone Python script:

python
import json
import math
import uuid
import requests

BASE = "http://127.0.0.1:16355"
COLLECTION = "discover_tie_" + uuid.uuid4().hex[:8]

TARGET = [1.0, 0.0]

POINTS = {
    100: [1.0, 0.0],   # positive context
    101: [0.0, 1.0],   # negative context
    0: [0.95, 0.10],
    1: [0.70, 0.40],
    2: [0.40, 0.70],
    3: [0.10, 0.95],
    4: [0.50, 0.40],
}

MUTANT = [0.50, 0.50]


def call(method, path, **kwargs):
    response = requests.request(
        method,
        BASE + path,
        timeout=30,
        **kwargs,
    )
    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 sigmoid(value):
    return 0.5 * (1.0 + value / (1.0 + abs(value)))


def expected(values):
    rows = []

    for point_id, vector in values.items():
        if point_id in (100, 101):
            continue

        positive = dot(values[100], vector)
        negative = dot(values[101], vector)

        # Documented rule: equality belongs to the positive zone.
        rank = 1 if positive >= negative else -1
        score = sigmoid(dot(TARGET, vector)) + rank
        rows.append((point_id, score))

    return sorted(rows, key=lambda row: (-row[1], row[0]))


def query():
    response = call(
        "POST",
        f"/collections/{COLLECTION}/points/query",
        json={
            "query": {
                "discover": {
                    "target": TARGET,
                    "context": [
                        {
                            "positive": 100,
                            "negative": 101,
                        }
                    ],
                }
            },
            "limit": 5,
            "params": {
                "exact": True,
            },
        },
    )

    return [
        (int(point["id"]), float(point["score"]))
        for point in response["result"]["points"]
    ]


def matches(actual, expected_rows):
    if [point_id for point_id, _ in actual] != [
        point_id for point_id, _ in expected_rows
    ]:
        return False

    return all(
        math.isclose(actual_score, expected_score, abs_tol=3e-5)
        for (_, actual_score), (_, expected_score)
        in zip(actual, expected_rows)
    )


try:
    call(
        "PUT",
        f"/collections/{COLLECTION}",
        json={
            "vectors": {
                "size": 2,
                "distance": "Dot",
            }
        },
    )

    call(
        "PUT",
        f"/collections/{COLLECTION}/points?wait=true",
        json={
            "points": [
                {"id": point_id, "vector": vector}
                for point_id, vector in POINTS.items()
            ]
        },
    )

    seed_actual = query()
    seed_expected = expected(POINTS)

    values = dict(POINTS)
    values[4] = MUTANT

    call(
        "PUT",
        f"/collections/{COLLECTION}/points/vectors?wait=true",
        json={
            "points": [
                {
                    "id": 4,
                    "vector": MUTANT,
                }
            ]
        },
    )

    mutant_actual = query()
    mutant_expected = expected(values)

    print(json.dumps({
        "seed_actual": seed_actual,
        "seed_expected": seed_expected,
        "mutant_actual": mutant_actual,
        "mutant_expected": mutant_expected,
    }, indent=2))

    assert matches(seed_actual, seed_expected), (
        "The no-tie seed did not match the documented formula"
    )

    assert not matches(mutant_actual, mutant_expected), (
        "The equality-boundary anomaly was not reproduced"
    )

    print("BUG REPRODUCED")

finally:
    requests.delete(
        f"{BASE}/collections/{COLLECTION}",
        timeout=30,
    )

Actual Behavior

Before the update, the no-tie seed follows the documented formula.

After changing point 4 from:

[0.50, 0.40]

to:

[0.50, 0.50]

the positive and negative similarities are both 0.50.

The expected score is:

sigmoid(0.5) + 1 = 1.6666667

Qdrant instead returns:

sigmoid(0.5) + 0 = 0.6666667

The equality case contributes 0 instead of +1.

Expected Behavior

When:

positive_similarity == negative_similarity

the candidate should be treated as belonging to the positive zone because the documented rule uses >=.

The expected score for the mutated point is approximately:

1.6666667

Possible Solution

Change the Discover context comparison so that equality contributes the positive rank:

rust
if positive_score >= negative_score {
    1
} else {
    -1
}

A regression test should cover:

  • positive similarity greater than negative similarity;
  • positive similarity less than negative similarity;
  • positive similarity exactly equal to negative similarity.

Context (Environment)

This affects applications using Discover queries for context-based ranking or recommendation.

The equality case can change both the returned score and the ordering of results. The issue reproduces with exact search, no filters, no payload indexes, no quantization, and no ANN approximation.

A single stored vector coordinate update is sufficient to create the equality boundary.

Detailed Description

The seed data contains positive and negative context points:

positive context: [1.0, 0.0]
negative context: [0.0, 1.0]

Point 4 initially has vector:

[0.50, 0.40]

Its positive similarity is greater than its negative similarity, so the documented rank is +1.

After replacing it with:

[0.50, 0.50]

both similarities become exactly equal. According to the documented >= rule, the rank should remain +1.

Instead, Qdrant returns a score corresponding to a rank contribution of 0.

The behavior reproduced deterministically across fresh collections.

Possible Implementation

Add an integration test using the Python reproducer above and verify that the equality case returns the same positive-zone rank as the positive_similarity > negative_similarity case.

The test should assert the exact score and result order before and after the vector mutation.