From 7bfcec31e956d8120be369f64592a348b256ebf7 Mon Sep 17 00:00:00 2001 From: guptaishaan Date: Tue, 6 Oct 2026 17:24:35 -0700 Subject: [PATCH 1/2] Python: Fix Qdrant vector search on qdrant-client 1.16+ AsyncQdrantClient.search was removed in qdrant-client 1.16. Use query_points for vector search, as hybrid search already does, and raise the extra's lower bound to 1.10 where query_points was added. Also wrap a single comparison filter (a bare FieldCondition) in Filter(must=[...]). Only lists were wrapped before, so single comparisons failed in both vector and hybrid search. --- python/pyproject.toml | 2 +- python/semantic_kernel/connectors/qdrant.py | 15 +++--- .../unit/connectors/memory/test_qdrant.py | 17 ++++--- .../connectors/memory/test_qdrant_local.py | 50 +++++++++++++++++++ python/uv.lock | 2 +- 5 files changed, 71 insertions(+), 15 deletions(-) create mode 100644 python/tests/unit/connectors/memory/test_qdrant_local.py diff --git a/python/pyproject.toml b/python/pyproject.toml index 0862b75bc5d7..73f924b69855 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -136,7 +136,7 @@ postgres = [ "psycopg[binary,pool] ~= 3.2" ] qdrant = [ - "qdrant-client ~= 1.9" + "qdrant-client ~= 1.10" ] redis = [ "redis[hiredis] >= 6,< 8", diff --git a/python/semantic_kernel/connectors/qdrant.py b/python/semantic_kernel/connectors/qdrant.py index 482bfdc59fd0..95f4110e4563 100644 --- a/python/semantic_kernel/connectors/qdrant.py +++ b/python/semantic_kernel/connectors/qdrant.py @@ -274,8 +274,6 @@ async def _inner_search( vector: Sequence[float | int] | None = None, **kwargs: Any, ) -> KernelSearchResults[VectorSearchResult[TModel]]: - query_vector: tuple[str, Sequence[float | int]] | Sequence[float | int] | None = None - if not vector: vector = await self._generate_vector_from_values(values, options) @@ -287,19 +285,24 @@ async def _inner_search( raise VectorStoreOperationException( f"Vector field {options.vector_property_name} not found in data model definition." ) - query_vector = (vector_field.storage_name or vector_field.name, vector) if self.named_vectors else vector filters: Filter | list[Filter] | None = self._build_filter(options.filter) # type: ignore - filter: Filter | None = Filter(must=filters) if filters and isinstance(filters, list) else filters # type: ignore + filter: Filter | None = ( + Filter(must=filters if isinstance(filters, list) else [filters]) # type: ignore + if filters and not isinstance(filters, Filter) + else filters + ) if search_type == SearchType.VECTOR: - results = await self.qdrant_client.search( + points = await self.qdrant_client.query_points( collection_name=self.collection_name, - query_vector=query_vector, # type: ignore + query=vector, # type: ignore + using=(vector_field.storage_name or vector_field.name) if self.named_vectors else None, query_filter=filter, with_vectors=options.include_vectors, limit=options.top, offset=options.skip, **kwargs, ) + results = points.points else: # Hybrid search: vector + keywords (RRF fusion) # 1. Get keywords and text field diff --git a/python/tests/unit/connectors/memory/test_qdrant.py b/python/tests/unit/connectors/memory/test_qdrant.py index 3fc19fa976f3..bc49e722db3c 100644 --- a/python/tests/unit/connectors/memory/test_qdrant.py +++ b/python/tests/unit/connectors/memory/test_qdrant.py @@ -4,7 +4,7 @@ from pytest import fixture, mark, raises from qdrant_client.async_qdrant_client import AsyncQdrantClient -from qdrant_client.models import Datatype, Distance, FieldCondition, MatchValue, VectorParams +from qdrant_client.models import Datatype, Distance, FieldCondition, Filter, MatchValue, VectorParams from semantic_kernel.connectors.qdrant import QdrantCollection, QdrantStore from semantic_kernel.data.vector import DistanceFunction, VectorStoreField @@ -109,12 +109,12 @@ def mock_delete(): @fixture(autouse=True) def mock_search(): - with patch(f"{BASE_PATH}.search") as mock_search: + with patch(f"{BASE_PATH}.query_points") as mock_search: from qdrant_client.models import ScoredPoint response1 = ScoredPoint(id="id1", version=1, score=0.0, payload={"content": "content"}) response2 = ScoredPoint(id="id2", version=1, score=0.0, payload={"content": "content"}) - mock_search.return_value = [response1, response2] + mock_search.return_value = MagicMock(points=[response1, response2]) yield mock_search @@ -285,7 +285,8 @@ async def test_search(collection, mock_search): assert mock_search.call_count == 1 mock_search.assert_called_with( collection_name="test", - query_vector=[1.0, 2.0, 3.0], + query=[1.0, 2.0, 3.0], + using=None, query_filter=None, with_vectors=False, limit=3, @@ -307,7 +308,8 @@ async def test_search_named_vectors(collection, mock_search): assert mock_search.call_count == 1 mock_search.assert_called_with( collection_name="test", - query_vector=("vector", [1.0, 2.0, 3.0]), + query=[1.0, 2.0, 3.0], + using="vector", query_filter=None, with_vectors=False, limit=3, @@ -328,8 +330,9 @@ async def test_search_filter(collection, mock_search): assert mock_search.call_count == 1 mock_search.assert_called_with( collection_name="test", - query_vector=("vector", [1.0, 2.0, 3.0]), - query_filter=FieldCondition(key="id", match=MatchValue(value="id1")), + query=[1.0, 2.0, 3.0], + using="vector", + query_filter=Filter(must=[FieldCondition(key="id", match=MatchValue(value="id1"))]), with_vectors=False, limit=3, offset=0, diff --git a/python/tests/unit/connectors/memory/test_qdrant_local.py b/python/tests/unit/connectors/memory/test_qdrant_local.py new file mode 100644 index 000000000000..89d2b2da0bbc --- /dev/null +++ b/python/tests/unit/connectors/memory/test_qdrant_local.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft. All rights reserved. + +from dataclasses import dataclass +from typing import Annotated + +from pytest import fixture + +from semantic_kernel.connectors.qdrant import QdrantCollection +from semantic_kernel.data.vector import VectorStoreField, vectorstoremodel + + +@vectorstoremodel +@dataclass +class Record: + id: Annotated[int, VectorStoreField("key")] + text: Annotated[str, VectorStoreField("data", is_full_text_indexed=True)] + temperature: Annotated[int, VectorStoreField("data", is_indexed=True)] + vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=2)] = None + + +@fixture +async def local_collection(): + async with QdrantCollection(record_type=Record, collection_name="test", location=":memory:") as collection: + await collection.ensure_collection_exists() + await collection.upsert([ + Record(1, "a", 1, [1.0, 0.2]), + Record(2, "a", 5, [0.0, 1.0]), + Record(3, "a", 9, [1.0, 1.0]), + ]) + yield collection + + +async def test_local_vector_search(local_collection): + results = await local_collection.search(vector=[1.0, 1.0]) + assert [r.record.id async for r in results.results] == [3, 1, 2] + + +async def test_local_vector_search_single_comparison_filter(local_collection): + results = await local_collection.search(vector=[1.0, 1.0], filter="lambda x: x.temperature > 1") + assert [r.record.id async for r in results.results] == [3, 2] + + +async def test_local_hybrid_search_single_comparison_filter(local_collection): + results = await local_collection.hybrid_search( + values=["a"], + vector=[1.0, 1.0], + additional_property_name="text", + filter="lambda x: x.temperature > 1", + ) + assert sorted([r.record.id async for r in results.results]) == [2, 3] diff --git a/python/uv.lock b/python/uv.lock index d8e3a278e357..767678331bd7 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -6868,7 +6868,7 @@ requires-dist = [ { name = "pymilvus", marker = "extra == 'milvus'", specifier = ">=2.3,<2.7" }, { name = "pymongo", marker = "extra == 'mongo'", specifier = ">=4.8.0,<4.17" }, { name = "pyodbc", marker = "extra == 'sql'", specifier = ">=5.2" }, - { name = "qdrant-client", marker = "extra == 'qdrant'", specifier = "~=1.9" }, + { name = "qdrant-client", marker = "extra == 'qdrant'", specifier = "~=1.10" }, { name = "redis", extras = ["hiredis"], marker = "extra == 'redis'", specifier = ">=6,<8" }, { name = "redisvl", marker = "extra == 'redis'", specifier = "~=0.4" }, { name = "scipy", specifier = ">=1.15.1" }, From 05da3cf6c04d2d74bf96ae6e2f34adc58aa80d74 Mon Sep 17 00:00:00 2001 From: guptaishaan Date: Thu, 8 Oct 2026 16:26:48 -0700 Subject: [PATCH 2/2] Python: Cover named_vectors=False in local Qdrant search tests Parametrize the in-memory Qdrant fixture over named_vectors so the using=None path of query_points is tested against a real local client. The hybrid search test keeps a named-vectors-only fixture. --- .../connectors/memory/test_qdrant_local.py | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/python/tests/unit/connectors/memory/test_qdrant_local.py b/python/tests/unit/connectors/memory/test_qdrant_local.py index 89d2b2da0bbc..6e308e379af7 100644 --- a/python/tests/unit/connectors/memory/test_qdrant_local.py +++ b/python/tests/unit/connectors/memory/test_qdrant_local.py @@ -18,9 +18,10 @@ class Record: vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=2)] = None -@fixture -async def local_collection(): - async with QdrantCollection(record_type=Record, collection_name="test", location=":memory:") as collection: +async def _populated_collection(named_vectors: bool): + async with QdrantCollection( + record_type=Record, collection_name="test", location=":memory:", named_vectors=named_vectors + ) as collection: await collection.ensure_collection_exists() await collection.upsert([ Record(1, "a", 1, [1.0, 0.2]), @@ -30,6 +31,18 @@ async def local_collection(): yield collection +@fixture(params=[True, False], ids=["named_vectors", "unnamed_vectors"]) +async def local_collection(request): + async for collection in _populated_collection(request.param): + yield collection + + +@fixture +async def named_local_collection(): + async for collection in _populated_collection(True): + yield collection + + async def test_local_vector_search(local_collection): results = await local_collection.search(vector=[1.0, 1.0]) assert [r.record.id async for r in results.results] == [3, 1, 2] @@ -40,8 +53,8 @@ async def test_local_vector_search_single_comparison_filter(local_collection): assert [r.record.id async for r in results.results] == [3, 2] -async def test_local_hybrid_search_single_comparison_filter(local_collection): - results = await local_collection.hybrid_search( +async def test_local_hybrid_search_single_comparison_filter(named_local_collection): + results = await named_local_collection.hybrid_search( values=["a"], vector=[1.0, 1.0], additional_property_name="text",