Skip to content

Commit bf616c7

Browse files
committed
fix(query_namespaces): preserve input order for deterministic tie-break
Sync Index.query_namespaces() fed per-namespace responses into QueryResultsAggregator in completion order (as_completed), so tied scores broke in a non-deterministic order across runs. The aggregator contract is insertion-order tie-break, so callers must add results in input namespace order. Submit all futures up front (preserving parallelism) and consume them in input namespace order. Adds a unit test that uses graduated sleeps to invert completion order vs input order.
1 parent 4f34f98 commit bf616c7

2 files changed

Lines changed: 44 additions & 8 deletions

File tree

‎pinecone/index/__init__.py‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import logging
66
import os
77
from collections.abc import Iterator, Mapping, Sequence
8-
from concurrent.futures import ThreadPoolExecutor, as_completed
8+
from concurrent.futures import ThreadPoolExecutor
99
from typing import TYPE_CHECKING, Any
1010

1111
if TYPE_CHECKING:
@@ -762,14 +762,13 @@ def query_namespaces(
762762
if vector is not None:
763763
query_kwargs["vector"] = vector
764764

765+
# Submit every query before iterating results so queries run concurrently;
766+
# collect results in input namespace order so the aggregator's
767+
# insertion-order tie-break stays deterministic across runs.
765768
with ThreadPoolExecutor(max_workers=min(len(namespaces), 32)) as pool:
766-
future_to_ns = {
767-
pool.submit(self.query, namespace=ns, **query_kwargs): ns for ns in namespaces
768-
}
769-
for future in as_completed(future_to_ns):
770-
ns = future_to_ns[future]
771-
response = future.result()
772-
aggregator.add_results(ns, response)
769+
futures = [pool.submit(self.query, namespace=ns, **query_kwargs) for ns in namespaces]
770+
for ns, future in zip(namespaces, futures, strict=True):
771+
aggregator.add_results(ns, future.result())
773772

774773
return aggregator.get_results()
775774

‎tests/unit/test_query_namespaces.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import time
56
from unittest.mock import patch
67

78
import pytest
@@ -56,6 +57,42 @@ def test_query_namespaces_fans_out(self) -> None:
5657
assert called_namespaces == ["ns1", "ns2", "ns3"]
5758

5859

60+
class TestQueryNamespacesTieBreaking:
61+
def test_tie_breaking_follows_input_namespace_order(self) -> None:
62+
"""With tied scores, result order must match input namespace order
63+
regardless of completion order. Earlier-listed namespaces are made
64+
slowest so completion order is the reverse of input order; the
65+
aggregator's insertion-order tie-break is only deterministic when
66+
the caller adds results in input order."""
67+
idx = _make_index()
68+
69+
delays = {"ns1": 0.03, "ns2": 0.02, "ns3": 0.01, "ns4": 0.0}
70+
71+
def query_side_effect(**kwargs: object) -> QueryResponse:
72+
ns = str(kwargs["namespace"])
73+
time.sleep(delays[ns])
74+
return _make_query_response([_scored(f"{ns}-v0", 1.0), _scored(f"{ns}-v1", 1.0)])
75+
76+
with patch.object(idx, "query", side_effect=query_side_effect):
77+
result = idx.query_namespaces(
78+
vector=[0.1, 0.2],
79+
namespaces=["ns1", "ns2", "ns3", "ns4"],
80+
metric="cosine",
81+
top_k=8,
82+
)
83+
84+
assert [m.id for m in result.matches] == [
85+
"ns1-v0",
86+
"ns1-v1",
87+
"ns2-v0",
88+
"ns2-v1",
89+
"ns3-v0",
90+
"ns3-v1",
91+
"ns4-v0",
92+
"ns4-v1",
93+
]
94+
95+
5996
class TestQueryNamespacesMerge:
6097
def test_query_namespaces_merges_results(self) -> None:
6198
"""Mock Index.query to return different scores per namespace, verify

0 commit comments

Comments
 (0)