Skip to content

Commit 0a3696e

Browse files
committed
fix: use multi_search for long vector queries
1 parent 6058cce commit 0a3696e

5 files changed

Lines changed: 178 additions & 83 deletions

File tree

langchain_typesense/vectorstores.py

Lines changed: 55 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
DeleteQueryParameters,
2727
DocumentSchema,
2828
DocumentWriteParameters,
29-
SearchParameters,
29+
MultiSearchParameters,
3030
)
31+
from typesense.types.multi_search import MultiSearchRequestSchema
3132

3233
from langchain_typesense._codec import (
3334
build_vector_query,
@@ -239,7 +240,7 @@ def _build_search_parameters(
239240
ef: int | None,
240241
flat_search_cutoff: int | None,
241242
include_vectors: bool,
242-
) -> SearchParameters:
243+
) -> MultiSearchParameters:
243244
"""Build a vector search request while protecting managed parameters."""
244245
self._validate_k(k)
245246
vector_query = self._build_vector_query(
@@ -281,7 +282,8 @@ def _build_search_parameters(
281282
filter_by = self._to_filter_by(filter)
282283
if filter_by:
283284
parameters["filter_by"] = filter_by
284-
return cast(SearchParameters, parameters)
285+
parameters["collection"] = self._collection_name
286+
return cast(MultiSearchParameters, parameters)
285287

286288
def _build_vector_query(
287289
self,
@@ -352,7 +354,7 @@ def _build_hybrid_search_parameters(
352354
distance_threshold: float | None,
353355
ef: int | None,
354356
flat_search_cutoff: int | None,
355-
) -> SearchParameters:
357+
) -> MultiSearchParameters:
356358
"""Build a Typesense request that fuses keyword and vector ranking."""
357359
parameters: dict[str, Any] = {
358360
"q": query,
@@ -376,7 +378,8 @@ def _build_hybrid_search_parameters(
376378
filter_by = self._to_filter_by(filter)
377379
if filter_by:
378380
parameters["filter_by"] = filter_by
379-
return cast(SearchParameters, parameters)
381+
parameters["collection"] = self._collection_name
382+
return cast(MultiSearchParameters, parameters)
380383

381384
def _parse_search_response(
382385
self,
@@ -405,6 +408,49 @@ def _parse_hybrid_search_response(
405408
metadata_key=self._metadata_key,
406409
)
407410

411+
def _build_multi_search_request(
412+
self, parameters: MultiSearchParameters
413+
) -> MultiSearchRequestSchema:
414+
"""Wrap one collection search for the Typesense multi-search endpoint."""
415+
return {"searches": [parameters]}
416+
417+
@staticmethod
418+
def _extract_multi_search_result(response: object) -> Mapping[str, Any]:
419+
"""Extract the single result returned for a one-query multi-search."""
420+
if not isinstance(response, Mapping):
421+
raise TypesenseVectorStoreError(
422+
"Typesense multi-search response must be an object with one result."
423+
)
424+
results = response.get("results")
425+
if (
426+
not isinstance(results, list)
427+
or len(results) != 1
428+
or not isinstance(results[0], Mapping)
429+
):
430+
raise TypesenseVectorStoreError(
431+
"Typesense multi-search response must contain exactly one result."
432+
)
433+
result = results[0]
434+
if result.get("code") == 404:
435+
raise ObjectNotFound(str(result.get("error", "Collection not found.")))
436+
return result
437+
438+
def _perform_search(self, parameters: MultiSearchParameters) -> Mapping[str, Any]:
439+
"""Run one search through Typesense's POST-based multi-search endpoint."""
440+
response = self._require_sync_client().multi_search.perform(
441+
self._build_multi_search_request(parameters)
442+
)
443+
return self._extract_multi_search_result(response)
444+
445+
async def _aperform_search(self, parameters: MultiSearchParameters) -> Mapping[str, Any]:
446+
"""Run one search through the native async multi-search endpoint."""
447+
if self._async_client is None: # pragma: no cover - guarded by callers
448+
raise RuntimeError("An asynchronous Typesense client is required.")
449+
response = await self._async_client.multi_search.perform(
450+
self._build_multi_search_request(parameters)
451+
)
452+
return self._extract_multi_search_result(response)
453+
408454
# ------------------------------------------------------------------
409455
# Collection management
410456
# ------------------------------------------------------------------
@@ -817,11 +863,7 @@ def _search_by_vector(
817863
flat_search_cutoff=flat_search_cutoff,
818864
include_vectors=include_vectors,
819865
)
820-
response = (
821-
self._require_sync_client()
822-
.collections[self._collection_name]
823-
.documents.search(parameters)
824-
)
866+
response = self._perform_search(parameters)
825867
return self._parse_search_response(response, include_vectors=include_vectors)
826868

827869
async def _asearch_by_vector(
@@ -863,9 +905,7 @@ async def _asearch_by_vector(
863905
flat_search_cutoff=flat_search_cutoff,
864906
include_vectors=include_vectors,
865907
)
866-
response = await self._async_client.collections[self._collection_name].documents.search(
867-
parameters
868-
)
908+
response = await self._aperform_search(parameters)
869909
return self._parse_search_response(response, include_vectors=include_vectors)
870910

871911
def similarity_search(
@@ -1257,11 +1297,7 @@ def hybrid_search_with_score(
12571297
ef=ef,
12581298
flat_search_cutoff=flat_search_cutoff,
12591299
)
1260-
response = (
1261-
self._require_sync_client()
1262-
.collections[self._collection_name]
1263-
.documents.search(parameters)
1264-
)
1300+
response = self._perform_search(parameters)
12651301
return self._parse_hybrid_search_response(response)
12661302

12671303
async def ahybrid_search(
@@ -1352,9 +1388,7 @@ async def ahybrid_search_with_score(
13521388
ef=ef,
13531389
flat_search_cutoff=flat_search_cutoff,
13541390
)
1355-
response = await self._async_client.collections[self._collection_name].documents.search(
1356-
parameters
1357-
)
1391+
response = await self._aperform_search(parameters)
13581392
return self._parse_hybrid_search_response(response)
13591393

13601394
# ------------------------------------------------------------------

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "langchain-typesense"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
description = "LangChain vector store integration for Typesense"
55
readme = "README.md"
66
requires-python = ">=3.10"

tests/integration_tests/test_vectorstores.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ def embed_query(self, text: str) -> list[float]:
6060
return list(self._vectors[text])
6161

6262

63+
class LongVectorEmbeddings(Embeddings):
64+
"""Embedding fixture whose serialized vector exceeds the GET query limit."""
65+
66+
dimension = 2000
67+
68+
def embed_documents(self, texts: list[str]) -> list[list[float]]:
69+
return [self.embed_query(text) for text in texts]
70+
71+
def embed_query(self, text: str) -> list[float]:
72+
return [1.0] + [0.0] * (self.dimension - 1)
73+
74+
6375
def client_config() -> ConfigDict:
6476
"""Return the local integration-test Typesense configuration."""
6577
return {
@@ -182,6 +194,23 @@ def test_sync_add_search_score_vector_mmr_and_delete(self, store: TypesenseVecto
182194
assert store.delete(delete_all_documents=True) is True
183195
assert store.similarity_search("alpha") == []
184196

197+
def test_large_vector_query_uses_multi_search(self) -> None:
198+
config = client_config()
199+
collection_name = f"langchain-typesense-long-query-{uuid4()}"
200+
store = TypesenseVectorStore(
201+
client=typesense.Client(config),
202+
embedding=LongVectorEmbeddings(),
203+
collection_name=collection_name,
204+
)
205+
try:
206+
store.add_texts(["large vector"], ids=["large"])
207+
assert [document.id for document in store.similarity_search("large vector", k=1)] == [
208+
"large"
209+
]
210+
finally:
211+
store.delete_collection()
212+
store.close()
213+
185214
async def test_native_async_add_search_mmr_get_and_delete(
186215
self, store: TypesenseVectorStore
187216
) -> None:

0 commit comments

Comments
 (0)