Skip to content

[Bug]: Hybrid search with iterator returns hits but omits iterator metadata #50407

@SpadeA-Tang

Description

@SpadeA-Tang

Is there an existing issue for this?

  • I have searched the existing issues

Environment

- Milvus version:master
- Deployment mode(standalone or cluster):
- MQ type(rocksmq, pulsar or kafka):    
- SDK version(e.g. pymilvus v2.0.0rc2):
- OS(Ubuntu or CentOS): 
- CPU/Memory: 
- GPU: 
- Others:

When using hybrid_search with ordinary vector fields and search iterator v2 enabled, the first page can return valid hits, but the response does not include iterator metadata required
for fetching the next page.

In contrast, normal search with the same iterator v2 parameters returns search_iterator_v2_results.token, last_bound, and session_ts correctly.

Reproduction

import numpy as np

from pymilvus import AnnSearchRequest, DataType, MilvusClient, RRFRanker


COLLECTION_NAME = "hybrid_iterator_metadata_repro"
DIM = 8
NUM_ROWS = 2000
BATCH_SIZE = 5


def print_hits(title, result):
    print(f"\n=== {title} ===")
    for qi, hits in enumerate(result):
        print(f"query {qi}, hits={len(hits)}")
        for hit in hits[:BATCH_SIZE]:
            print(hit)


def print_iterator_info(title, result):
    info = result.get_search_iterator_v2_results_info()
    session_ts = result.get_session_ts()
    token = getattr(info, "token", "")
    last_bound = getattr(info, "last_bound", None)
    print(f"\n--- {title} iterator metadata ---")
    print(f"token: {token!r}")
    print(f"last_bound: {last_bound!r}")
    print(f"session_ts: {session_ts!r}")
    return token, last_bound, session_ts


def make_ann_requests(query_a, query_b):
    req_a = AnnSearchRequest(
        data=[query_a],
        anns_field="vec_a",
        param={"metric_type": "L2", "params": {"nprobe": 16}},
        limit=BATCH_SIZE,
    )
    req_b = AnnSearchRequest(
        data=[query_b],
        anns_field="vec_b",
        param={"metric_type": "L2", "params": {"nprobe": 16}},
        limit=BATCH_SIZE,
    )
    return [req_a, req_b]


def main():
    client = MilvusClient("http://localhost:19530")

    if client.has_collection(COLLECTION_NAME, timeout=5):
        client.drop_collection(COLLECTION_NAME)

    schema = client.create_schema(auto_id=False)
    schema.add_field("id", DataType.INT64, is_primary=True)
    schema.add_field("value", DataType.DOUBLE)
    schema.add_field("vec_a", DataType.FLOAT_VECTOR, dim=DIM)
    schema.add_field("vec_b", DataType.FLOAT_VECTOR, dim=DIM)

    index_params = client.prepare_index_params()
    index_params.add_index(
        field_name="vec_a",
        index_type="IVF_FLAT",
        metric_type="L2",
        params={"nlist": 128},
    )
    index_params.add_index(
        field_name="vec_b",
        index_type="IVF_FLAT",
        metric_type="L2",
        params={"nlist": 128},
    )

    client.create_collection(
        COLLECTION_NAME,
        schema=schema,
        index_params=index_params,
        consistency_level="Strong",
    )

    rng = np.random.default_rng(19530)
    rows = []
    for i in range(NUM_ROWS):
        rows.append(
            {
                "id": i,
                "value": float(i),
                "vec_a": rng.random(DIM).astype("float32").tolist(),
                "vec_b": rng.random(DIM).astype("float32").tolist(),
            }
        )

    client.insert(COLLECTION_NAME, rows)
    client.flush(COLLECTION_NAME)
    client.load_collection(COLLECTION_NAME)

    query_a = rows[0]["vec_a"]
    query_b = rows[0]["vec_b"]

    iterator_kwargs = {
        "iterator": True,
        "search_iter_v2": True,
        "search_iter_batch_size": BATCH_SIZE,
    }

    # Control case: a normal single-vector search returns iterator metadata.
    normal_page_1 = client.search(
        collection_name=COLLECTION_NAME,
        data=[query_a],
        anns_field="vec_a",
        search_params={"metric_type": "L2", "params": {"nprobe": 16}},
        limit=BATCH_SIZE,
        output_fields=["id", "value"],
        **iterator_kwargs,
    )
    print_hits("normal search iterator page 1", normal_page_1)
    normal_token, normal_last_bound, normal_session_ts = print_iterator_info(
        "normal search page 1", normal_page_1
    )

    if normal_token:
        normal_page_2 = client.search(
            collection_name=COLLECTION_NAME,
            data=[query_a],
            anns_field="vec_a",
            search_params={"metric_type": "L2", "params": {"nprobe": 16}},
            limit=BATCH_SIZE,
            output_fields=["id", "value"],
            search_iter_id=normal_token,
            search_iter_last_bound=normal_last_bound,
            guarantee_timestamp=normal_session_ts,
            **iterator_kwargs,
        )
        print_hits("normal search iterator page 2", normal_page_2)
        print_iterator_info("normal search page 2", normal_page_2)
    else:
        print("normal search did not return a token; server may not support iterator v2")

    # Repro case: ordinary-vector hybrid search can execute the current batch,
    # but current server code does not return hybrid-level iterator metadata.
    hybrid_page_1 = client.hybrid_search(
        collection_name=COLLECTION_NAME,
        reqs=make_ann_requests(query_a, query_b),
        ranker=RRFRanker(),
        limit=BATCH_SIZE,
        output_fields=["id", "value"],
        **iterator_kwargs,
    )
    print_hits("hybrid search iterator page 1", hybrid_page_1)
    hybrid_token, hybrid_last_bound, hybrid_session_ts = print_iterator_info(
        "hybrid search page 1", hybrid_page_1
    )

    if not hybrid_token or not hybrid_session_ts:
        print(
            "\nREPRODUCED: hybrid search returned current-batch hits, but did not "
            "return token/session_ts needed to request page 2."
        )
        return

    hybrid_page_2 = client.hybrid_search(
        collection_name=COLLECTION_NAME,
        reqs=make_ann_requests(query_a, query_b),
        ranker=RRFRanker(),
        limit=BATCH_SIZE,
        output_fields=["id", "value"],
        search_iter_id=hybrid_token,
        search_iter_last_bound=hybrid_last_bound,
        guarantee_timestamp=hybrid_session_ts,
        **iterator_kwargs,
    )
    print_hits("hybrid search iterator page 2", hybrid_page_2)
    print_iterator_info("hybrid search page 2", hybrid_page_2)


if __name__ == "__main__":
    main()

Actual Result

Hybrid search returns hits, but iterator metadata is missing:

--- hybrid search page 1 iterator metadata ---
token: ''
last_bound: 0.0
session_ts: 0

Because token and session_ts are missing, the client cannot construct a valid page-2 request.

Expected Result

Hybrid search with iterator v2 should either:

  1. Return valid iterator metadata, including:

    • search_iterator_v2_results.token
    • search_iterator_v2_results.last_bound
    • session_ts

    so the client can fetch the next page, or

  2. Reject hybrid_search + iterator explicitly if this combination is not supported.

Notes

Normal search + iterator v2 works as expected:

--- normal search page 1 iterator metadata ---
token: '...'
last_bound: 0.17913571000099182
session_ts: 466875486770298891

The issue appears to be in the Proxy hybrid search path: iterator state is parsed per sub-search, but not promoted to task-level iterator state, and final response metadata is only
populated for the single-query-info search path.

Metadata

Metadata

Assignees

Labels

kind/bugIssues or changes related a bugneeds-triageIndicates an issue or PR lacks a `triage/foo` label and requires one.

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions