Skip to content

[Bug]: FLAT COSINE results are not globally sorted across small sealed segments #52279

Description

@yanliang567

Environment

  • Milvus Version: harbor.milvus.io/milvusdb/milvus:3.0-20260806-955185ef (commit 955185ef9c23162c7784f5fed5e588be37c03bfb)
  • Deployment Mode: distributed cluster on Kubernetes, 2 QueryNodes
  • MQ / Streaming: external Woodpecker service
  • SDK: pymilvus 3.0.1rc74
  • Architecture: amd64
  • Index / Metric: FLAT / COSINE

The original failure was TestSearchV2Shared::test_search_with_expression in branch 3.0 build 237.

Reproduction

The following self-contained script reduces the original wide test schema to four fields: INT64 PK, nullable FLOAT, VARCHAR(512), and FLOAT_VECTOR.

#!/usr/bin/env python3
"""Reproduce COSINE results that are not globally score-sorted."""

from __future__ import annotations

import argparse
import time

import numpy as np
from pymilvus import DataType, MilvusClient


ROWS = 10_000
DIM = 128
NQ = 2
PAYLOAD_BYTES = 512
BATCH_SIZE = 500


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--uri", default="http://127.0.0.1:19530")
    args = parser.parse_args()

    client = MilvusClient(uri=args.uri)
    collection_name = f"repro_cosine_order_{time.time_ns()}"
    rng = np.random.default_rng(20260806)
    vectors = rng.standard_normal((ROWS, DIM), dtype=np.float32)
    vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)

    schema = client.create_schema(enable_dynamic_field=False)
    schema.add_field("id", DataType.INT64, is_primary=True)
    schema.add_field("value", DataType.FLOAT, nullable=True)
    schema.add_field("payload", DataType.VARCHAR, max_length=PAYLOAD_BYTES)
    schema.add_field("vector", DataType.FLOAT_VECTOR, dim=DIM)

    try:
        client.create_collection(collection_name, schema=schema)
        for start in range(0, ROWS, BATCH_SIZE):
            client.insert(
                collection_name,
                [
                    {
                        "id": row,
                        "value": None if row < ROWS // 5 else float(row),
                        "payload": f"{row:08d}" + "x" * (PAYLOAD_BYTES - 8),
                        "vector": vectors[row].tolist(),
                    }
                    for row in range(start, start + BATCH_SIZE)
                ],
            )
        client.flush(collection_name)

        index_params = client.prepare_index_params()
        index_params.add_index("vector", index_type="FLAT", metric_type="COSINE")
        client.create_index(collection_name, index_params=index_params)
        client.load_collection(collection_name)

        query_vectors = rng.standard_normal((NQ, DIM), dtype=np.float32)
        query_vectors /= np.linalg.norm(query_vectors, axis=1, keepdims=True)
        result_sets = client.search(
            collection_name,
            data=query_vectors.tolist(),
            anns_field="vector",
            search_params={"metric_type": "COSINE", "params": {}},
            limit=ROWS,
            filter="",
        )

        for query_index, results in enumerate(result_sets):
            for index in range(len(results) - 1):
                before = results[index]
                after = results[index + 1]
                if before["distance"] < after["distance"]:
                    print(
                        "FAIL: COSINE results are not in descending order\n"
                        f"query={query_index} indexes={index}/{index + 1}\n"
                        f"before: id={before['id']} distance={before['distance']:.9f}\n"
                        f"after:  id={after['id']} distance={after['distance']:.9f}"
                    )
                    return 1

        print(f"PASS: {NQ}x{ROWS} COSINE results are in descending order")
        return 0
    finally:
        if client.has_collection(collection_name):
            client.drop_collection(collection_name)


if __name__ == "__main__":
    raise SystemExit(main())

Run it against the small-segment boundary configuration:

python repro_search_cosine_order.py --uri http://127.0.0.1:19530

Trigger Conditions

  • Frequency: deterministic in repeated local runs and also observed in Jenkins
  • Required in the minimized case:
    • 10,000 rows
    • 2 query vectors
    • multiple small sealed segments
  • Does NOT happen when:
    • the same image and script use the default segment configuration;
    • the row count is reduced to 5,000;
    • the VARCHAR payload is reduced enough that fewer sealed segments are produced
  • The filter is empty, so expression evaluation is not required to trigger the ordering problem.

Expected Behavior

For COSINE, every hit list returned by search should be monotonically sorted in descending score order:

distance[i] >= distance[i + 1]

FLAT is used, so ANN recall is not involved.

Actual Behavior

On the boundary configuration, the deterministic script returns an adjacent inversion:

FAIL: COSINE results are not in descending order
query=1 indexes=3153/3154
before: id=5989 distance=0.042794593
after:  id=7175 distance=0.042794596

The result at index 3154 has a larger COSINE score than the result at index 3153.

The same image, pymilvus version, test code, seed, and data pass on the default configuration:

PASS: 2x10000 COSINE results are in descending order

The original pytest failure is consistent with the minimized script:

tests/python_client/milvus_client/test_milvus_client_search_v2.py:2188:
AssertionError: distances not in descending order for COSINE metric

Error Logs

No server error is returned. The RPC succeeds, but the result order is incorrect.

Non-default Configuration

Failing boundary configuration:

common:
  storage:
    enablev2: true
    useLoonFFI: true
dataCoord:
  segment:
    maxSize: 64
    sealProportion: 0.05
    sealProportionJitter: 0
queryNode:
  segcore:
    storageV2:
      cellTargetSizeBytes: 1048576
  mmap:
    vectorField: true
    vectorIndex: true
    scalarField: true
    scalarIndex: true
    growingMmapEnabled: true

Passing control:

dataCoord:
  segment:
    maxSize: 1024
    sealProportion: 0.12
    sealProportionJitter: 0.1

Analysis Hints

  • Boundary and default use the same Milvus image; the relevant difference is segment shape/count.
  • The failure remains after reducing the schema to one vector field and a fixed VARCHAR payload.
  • FLAT/COSINE and an empty filter rule out ANN recall and expression correctness as causes.
  • The inversion is very small but violates the search API ordering invariant and fails deterministically at the same IDs/scores for this seed.
  • This may be in cross-segment result reduction/merge or score conversion rather than per-segment FLAT search.
  • Searches for existing issues did not find an exact match.

Metadata

Metadata

Assignees

Labels

kind/bugIssues or changes related a bugtriage/acceptedIndicates an issue or PR is ready to be actively worked on.

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions