Skip to content

Commit c0ac873

Browse files
committed
fix(grpc): remove wrap_dense_vector=False from GrpcIndex.search()
## Purpose `GrpcIndex.search()` was calling `_build_search_records_body(..., wrap_dense_vector=False)`, which caused a bare `list[float]` dense vector to be sent as `{"vector": [1.0, ...]}` to the REST endpoint. The Pinecone API requires the dict form `{"vector": {"values": [1.0, ...]}}`. `Index.search()` and `AsyncIndex.search()` already used the correct default (`True`), so `GrpcIndex.search()` was uniquely broken for dense vector queries on integrated inference indexes. ## Solution Removed the `wrap_dense_vector=False` keyword argument from the `_build_search_records_body` call in `GrpcIndex.search()`. The method now uses the default `wrap_dense_vector=True`, matching the behavior of `Index.search()` and `AsyncIndex.search()`. Added `tests/unit/grpc/test_grpc_search.py` with `test_grpc_search_dense_vector_wrapped` to assert that a bare `list[float]` vector is correctly wrapped as `{"values": [...]}` in the request body. Also updated `tests/unit/test_grpc_index.py` for parity.
1 parent a5fda9d commit c0ac873

3 files changed

Lines changed: 155 additions & 2 deletions

File tree

‎pinecone/grpc/__init__.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1352,7 +1352,6 @@ def search(
13521352
rerank=rerank,
13531353
match_terms=match_terms,
13541354
query=query,
1355-
wrap_dense_vector=False,
13561355
)
13571356

13581357
logger.info(
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Unit tests for GrpcIndex.search() REST-delegation method."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from unittest.mock import MagicMock, patch
7+
8+
import httpx
9+
import pytest
10+
import respx
11+
12+
from pinecone.grpc import GrpcIndex
13+
from pinecone.errors.exceptions import ValidationError
14+
from pinecone.models.vectors.search import SearchRecordsResponse
15+
16+
_MOCK_GRPC_MODULE_PATH = "pinecone._grpc"
17+
_INDEX_HOST = "test-index-abc123.svc.pinecone.io"
18+
_INDEX_HOST_HTTPS = f"https://{_INDEX_HOST}"
19+
_SEARCH_NS = "test-ns"
20+
_SEARCH_URL = f"{_INDEX_HOST_HTTPS}/records/namespaces/{_SEARCH_NS}/search"
21+
22+
_SEARCH_RESPONSE: dict[str, object] = {
23+
"result": {
24+
"hits": [
25+
{"_id": "r1", "_score": 0.95, "fields": {"chunk_text": "hello world"}},
26+
{"_id": "r2", "_score": 0.82, "fields": {"chunk_text": "foo bar"}},
27+
]
28+
},
29+
"usage": {"read_units": 5, "embed_total_tokens": 10},
30+
}
31+
32+
33+
def _make_grpc_index() -> GrpcIndex:
34+
mock_channel = MagicMock()
35+
mock_module = MagicMock()
36+
mock_module.GrpcChannel.return_value = mock_channel
37+
with patch.dict("sys.modules", {_MOCK_GRPC_MODULE_PATH: mock_module}):
38+
return GrpcIndex(
39+
host=_INDEX_HOST,
40+
api_key="test-api-key",
41+
)
42+
43+
44+
@pytest.fixture
45+
def grpc_index() -> GrpcIndex:
46+
return _make_grpc_index()
47+
48+
49+
class TestGrpcSearchDenseVectorWrapped:
50+
"""GrpcIndex.search() must wrap bare list[float] vectors as {"values": [...]}."""
51+
52+
@respx.mock
53+
def test_grpc_search_dense_vector_wrapped(self, grpc_index: GrpcIndex) -> None:
54+
"""A bare list[float] vector must arrive as {"values": [...]} — not a bare list."""
55+
route = respx.post(_SEARCH_URL).mock(
56+
return_value=httpx.Response(200, json=_SEARCH_RESPONSE),
57+
)
58+
grpc_index.search(namespace=_SEARCH_NS, top_k=5, vector=[0.1, 0.2])
59+
60+
body = json.loads(route.calls.last.request.content)
61+
assert body["query"]["vector"] == {"values": [0.1, 0.2]}, (
62+
"GrpcIndex.search() must wrap a bare list[float] as {'values': [...]}, "
63+
"not send a bare list — the REST endpoint requires dict form"
64+
)
65+
66+
@respx.mock
67+
def test_grpc_search_dense_vector_not_bare_list(self, grpc_index: GrpcIndex) -> None:
68+
"""Confirm the body does NOT contain a bare list at query.vector."""
69+
route = respx.post(_SEARCH_URL).mock(
70+
return_value=httpx.Response(200, json=_SEARCH_RESPONSE),
71+
)
72+
grpc_index.search(namespace=_SEARCH_NS, top_k=5, vector=[0.1, 0.2])
73+
74+
body = json.loads(route.calls.last.request.content)
75+
assert not isinstance(body["query"]["vector"], list), (
76+
"query.vector must be a dict, not a bare list"
77+
)
78+
79+
@respx.mock
80+
def test_grpc_search_dict_vector_passthrough(self, grpc_index: GrpcIndex) -> None:
81+
"""A dict vector passed directly must be forwarded unchanged."""
82+
route = respx.post(_SEARCH_URL).mock(
83+
return_value=httpx.Response(200, json=_SEARCH_RESPONSE),
84+
)
85+
grpc_index.search(
86+
namespace=_SEARCH_NS,
87+
top_k=5,
88+
vector={"values": [0.3, 0.4]},
89+
)
90+
91+
body = json.loads(route.calls.last.request.content)
92+
assert body["query"]["vector"] == {"values": [0.3, 0.4]}
93+
94+
@respx.mock
95+
def test_grpc_search_inputs(self, grpc_index: GrpcIndex) -> None:
96+
"""Text inputs are forwarded correctly."""
97+
route = respx.post(_SEARCH_URL).mock(
98+
return_value=httpx.Response(200, json=_SEARCH_RESPONSE),
99+
)
100+
grpc_index.search(
101+
namespace=_SEARCH_NS,
102+
top_k=10,
103+
inputs={"text": "hello world"},
104+
)
105+
106+
body = json.loads(route.calls.last.request.content)
107+
assert body["query"]["inputs"] == {"text": "hello world"}
108+
assert body["query"]["top_k"] == 10
109+
110+
@respx.mock
111+
def test_grpc_search_returns_search_records_response(
112+
self, grpc_index: GrpcIndex
113+
) -> None:
114+
"""search() returns a SearchRecordsResponse."""
115+
respx.post(_SEARCH_URL).mock(
116+
return_value=httpx.Response(200, json=_SEARCH_RESPONSE),
117+
)
118+
result = grpc_index.search(namespace=_SEARCH_NS, top_k=5, inputs={"text": "hi"})
119+
120+
assert isinstance(result, SearchRecordsResponse)
121+
assert len(result.result.hits) == 2
122+
assert result.result.hits[0].id == "r1"
123+
assert result.usage.read_units == 5
124+
125+
126+
class TestGrpcSearchValidation:
127+
"""GrpcIndex.search() input validation."""
128+
129+
def test_grpc_search_namespace_not_string_raises(self, grpc_index: GrpcIndex) -> None:
130+
with pytest.raises(ValidationError, match="namespace must be a string"):
131+
grpc_index.search(namespace=123, top_k=5, inputs={"text": "x"}) # type: ignore[arg-type]
132+
133+
def test_grpc_search_namespace_empty_raises(self, grpc_index: GrpcIndex) -> None:
134+
with pytest.raises(ValidationError, match="namespace must be a non-empty string"):
135+
grpc_index.search(namespace="", top_k=5, inputs={"text": "x"})
136+
137+
def test_grpc_search_namespace_whitespace_raises(self, grpc_index: GrpcIndex) -> None:
138+
with pytest.raises(ValidationError, match="namespace must be a non-empty string"):
139+
grpc_index.search(namespace=" ", top_k=5, inputs={"text": "x"})
140+
141+
142+
class TestGrpcSearchRecordsAlias:
143+
"""GrpcIndex.search_records() delegates to search(), inheriting the dense-vector fix."""
144+
145+
@respx.mock
146+
def test_search_records_dense_vector_wrapped(self, grpc_index: GrpcIndex) -> None:
147+
"""search_records() must also wrap list[float] as {'values': [...]}."""
148+
route = respx.post(_SEARCH_URL).mock(
149+
return_value=httpx.Response(200, json=_SEARCH_RESPONSE),
150+
)
151+
grpc_index.search_records(namespace=_SEARCH_NS, top_k=5, vector=[0.5, 0.6])
152+
153+
body = json.loads(route.calls.last.request.content)
154+
assert body["query"]["vector"] == {"values": [0.5, 0.6]}

‎tests/unit/test_grpc_index.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1096,7 +1096,7 @@ def test_search_with_vector(self, mock_channel: MagicMock) -> None:
10961096
import orjson
10971097

10981098
body = orjson.loads(route.calls.last.request.content)
1099-
assert body["query"]["vector"] == [0.1, 0.2, 0.3]
1099+
assert body["query"]["vector"] == {"values": [0.1, 0.2, 0.3]}
11001100

11011101
@respx.mock
11021102
def test_search_with_rerank(self, mock_channel: MagicMock) -> None:

0 commit comments

Comments
 (0)