From 2e4bd3f49dbdfbc33ac7acc49e5ddd26d795547d Mon Sep 17 00:00:00 2001 From: Greyson LaLonde Date: Mon, 25 Aug 2025 16:02:25 -0400 Subject: [PATCH] feat: qdrant generic client (#3377) ### Qdrant Client * Add core client with collection, search, and document APIs (sync + async) * Refactor utilities, types, and vector params (default 384-dim) * Improve error handling with `ClientMethodMismatchError` * Add score normalization, async embeddings, and optional `qdrant-client` dep * Expand tests and type safety throughout --- pyproject.toml | 3 + src/crewai/rag/core/exceptions.py | 26 + src/crewai/rag/qdrant/__init__.py | 1 + src/crewai/rag/qdrant/client.py | 527 +++++++++++++++++++ src/crewai/rag/qdrant/constants.py | 7 + src/crewai/rag/qdrant/types.py | 134 +++++ src/crewai/rag/qdrant/utils.py | 228 +++++++++ tests/rag/qdrant/test_client.py | 793 +++++++++++++++++++++++++++++ uv.lock | 109 +++- 9 files changed, 1826 insertions(+), 2 deletions(-) create mode 100644 src/crewai/rag/core/exceptions.py create mode 100644 src/crewai/rag/qdrant/__init__.py create mode 100644 src/crewai/rag/qdrant/client.py create mode 100644 src/crewai/rag/qdrant/constants.py create mode 100644 src/crewai/rag/qdrant/types.py create mode 100644 src/crewai/rag/qdrant/utils.py create mode 100644 tests/rag/qdrant/test_client.py diff --git a/pyproject.toml b/pyproject.toml index 90fd87b08..cad405d1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,9 @@ docling = [ aisuite = [ "aisuite>=0.1.10", ] +qdrant = [ + "qdrant-client[fastembed]>=1.14.3", +] [tool.uv] dev-dependencies = [ diff --git a/src/crewai/rag/core/exceptions.py b/src/crewai/rag/core/exceptions.py new file mode 100644 index 000000000..c4c098138 --- /dev/null +++ b/src/crewai/rag/core/exceptions.py @@ -0,0 +1,26 @@ +"""Core exceptions for RAG module.""" + + +class ClientMethodMismatchError(TypeError): + """Raised when a method is called with the wrong client type. + + Typically used when a sync method is called with an async client, + or vice versa. + """ + + def __init__( + self, method_name: str, expected_client: str, alt_method: str, alt_client: str + ) -> None: + """Create a ClientMethodMismatchError. + + Args: + method_name: Method that was called incorrectly. + expected_client: Required client type. + alt_method: Suggested alternative method. + alt_client: Client type for the alternative method. + """ + message = ( + f"Method {method_name}() requires a {expected_client}. " + f"Use {alt_method}() for {alt_client}." + ) + super().__init__(message) diff --git a/src/crewai/rag/qdrant/__init__.py b/src/crewai/rag/qdrant/__init__.py new file mode 100644 index 000000000..d0c225f2d --- /dev/null +++ b/src/crewai/rag/qdrant/__init__.py @@ -0,0 +1 @@ +"""Qdrant vector database client implementation.""" \ No newline at end of file diff --git a/src/crewai/rag/qdrant/client.py b/src/crewai/rag/qdrant/client.py new file mode 100644 index 000000000..9c9bd5f00 --- /dev/null +++ b/src/crewai/rag/qdrant/client.py @@ -0,0 +1,527 @@ +"""Qdrant client implementation.""" + +from typing import Any, cast + +from fastembed import TextEmbedding +from qdrant_client import QdrantClient as SyncQdrantClientBase +from typing_extensions import Unpack + +from crewai.rag.core.base_client import ( + BaseClient, + BaseCollectionParams, + BaseCollectionAddParams, + BaseCollectionSearchParams, +) +from crewai.rag.core.exceptions import ClientMethodMismatchError +from crewai.rag.qdrant.types import ( + AsyncEmbeddingFunction, + EmbeddingFunction, + QdrantClientParams, + QdrantClientType, + QdrantCollectionCreateParams, +) +from crewai.rag.qdrant.utils import ( + _is_async_client, + _is_async_embedding_function, + _is_sync_client, + _create_point_from_document, + _get_collection_params, + _prepare_search_params, + _process_search_results, +) +from crewai.rag.types import SearchResult + + +class QdrantClient(BaseClient): + """Qdrant implementation of the BaseClient protocol. + + Provides vector database operations for Qdrant, supporting both + synchronous and asynchronous clients. + + Attributes: + client: Qdrant client instance (QdrantClient or AsyncQdrantClient). + embedding_function: Function to generate embeddings for documents. + """ + + client: QdrantClientType + embedding_function: EmbeddingFunction | AsyncEmbeddingFunction + + def __init__( + self, + client: QdrantClientType | None = None, + embedding_function: EmbeddingFunction | AsyncEmbeddingFunction | None = None, + **kwargs: Unpack[QdrantClientParams], + ) -> None: + """Initialize QdrantClient with optional client and embedding function. + + Args: + client: Optional pre-configured Qdrant client instance. + embedding_function: Optional embedding function. If not provided, + uses FastEmbed's BAAI/bge-small-en-v1.5 model. + **kwargs: Additional arguments for QdrantClient creation. + """ + if client is not None: + self.client = client + else: + location = kwargs.get("location", ":memory:") + client_kwargs = {k: v for k, v in kwargs.items() if k != "location"} + self.client = SyncQdrantClientBase(location, **cast(Any, client_kwargs)) + + if embedding_function is not None: + self.embedding_function = embedding_function + else: + _embedder = TextEmbedding("BAAI/bge-small-en-v1.5") + + def _embed_fn(text: str) -> list[float]: + embeddings = list(_embedder.embed([text])) + return [float(x) for x in embeddings[0]] if embeddings else [] + + self.embedding_function = _embed_fn + + def create_collection(self, **kwargs: Unpack[QdrantCollectionCreateParams]) -> None: + """Create a new collection in Qdrant. + + Keyword Args: + collection_name: Name of the collection to create. Must be unique. + vectors_config: Optional vector configuration. Defaults to 1536 dimensions with cosine distance. + sparse_vectors_config: Optional sparse vector configuration. + shard_number: Optional number of shards. + replication_factor: Optional replication factor. + write_consistency_factor: Optional write consistency factor. + on_disk_payload: Optional flag to store payload on disk. + hnsw_config: Optional HNSW index configuration. + optimizers_config: Optional optimizer configuration. + wal_config: Optional write-ahead log configuration. + quantization_config: Optional quantization configuration. + init_from: Optional collection to initialize from. + timeout: Optional timeout for the operation. + + Raises: + ValueError: If collection with the same name already exists. + ConnectionError: If unable to connect to Qdrant server. + """ + if not _is_sync_client(self.client): + raise ClientMethodMismatchError( + method_name="create_collection", + expected_client="QdrantClient", + alt_method="acreate_collection", + alt_client="AsyncQdrantClient", + ) + + collection_name = kwargs["collection_name"] + + if self.client.collection_exists(collection_name): + raise ValueError(f"Collection '{collection_name}' already exists") + + params = _get_collection_params(kwargs) + self.client.create_collection(**params) + + async def acreate_collection( + self, **kwargs: Unpack[QdrantCollectionCreateParams] + ) -> None: + """Create a new collection in Qdrant asynchronously. + + Keyword Args: + collection_name: Name of the collection to create. Must be unique. + vectors_config: Optional vector configuration. Defaults to 1536 dimensions with cosine distance. + sparse_vectors_config: Optional sparse vector configuration. + shard_number: Optional number of shards. + replication_factor: Optional replication factor. + write_consistency_factor: Optional write consistency factor. + on_disk_payload: Optional flag to store payload on disk. + hnsw_config: Optional HNSW index configuration. + optimizers_config: Optional optimizer configuration. + wal_config: Optional write-ahead log configuration. + quantization_config: Optional quantization configuration. + init_from: Optional collection to initialize from. + timeout: Optional timeout for the operation. + + Raises: + ValueError: If collection with the same name already exists. + ConnectionError: If unable to connect to Qdrant server. + """ + if not _is_async_client(self.client): + raise ClientMethodMismatchError( + method_name="acreate_collection", + expected_client="AsyncQdrantClient", + alt_method="create_collection", + alt_client="QdrantClient", + ) + + collection_name = kwargs["collection_name"] + + if await self.client.collection_exists(collection_name): + raise ValueError(f"Collection '{collection_name}' already exists") + + params = _get_collection_params(kwargs) + await self.client.create_collection(**params) + + def get_or_create_collection( + self, **kwargs: Unpack[QdrantCollectionCreateParams] + ) -> Any: + """Get an existing collection or create it if it doesn't exist. + + Keyword Args: + collection_name: Name of the collection to get or create. + vectors_config: Optional vector configuration. Defaults to 1536 dimensions with cosine distance. + sparse_vectors_config: Optional sparse vector configuration. + shard_number: Optional number of shards. + replication_factor: Optional replication factor. + write_consistency_factor: Optional write consistency factor. + on_disk_payload: Optional flag to store payload on disk. + hnsw_config: Optional HNSW index configuration. + optimizers_config: Optional optimizer configuration. + wal_config: Optional write-ahead log configuration. + quantization_config: Optional quantization configuration. + init_from: Optional collection to initialize from. + timeout: Optional timeout for the operation. + + Returns: + Collection info dict with name and other metadata. + + Raises: + ConnectionError: If unable to connect to Qdrant server. + """ + if not _is_sync_client(self.client): + raise ClientMethodMismatchError( + method_name="get_or_create_collection", + expected_client="QdrantClient", + alt_method="aget_or_create_collection", + alt_client="AsyncQdrantClient", + ) + + collection_name = kwargs["collection_name"] + + if self.client.collection_exists(collection_name): + return self.client.get_collection(collection_name) + + params = _get_collection_params(kwargs) + self.client.create_collection(**params) + + return self.client.get_collection(collection_name) + + async def aget_or_create_collection( + self, **kwargs: Unpack[QdrantCollectionCreateParams] + ) -> Any: + """Get an existing collection or create it if it doesn't exist asynchronously. + + Keyword Args: + collection_name: Name of the collection to get or create. + vectors_config: Optional vector configuration. Defaults to 1536 dimensions with cosine distance. + sparse_vectors_config: Optional sparse vector configuration. + shard_number: Optional number of shards. + replication_factor: Optional replication factor. + write_consistency_factor: Optional write consistency factor. + on_disk_payload: Optional flag to store payload on disk. + hnsw_config: Optional HNSW index configuration. + optimizers_config: Optional optimizer configuration. + wal_config: Optional write-ahead log configuration. + quantization_config: Optional quantization configuration. + init_from: Optional collection to initialize from. + timeout: Optional timeout for the operation. + + Returns: + Collection info dict with name and other metadata. + + Raises: + ConnectionError: If unable to connect to Qdrant server. + """ + if not _is_async_client(self.client): + raise ClientMethodMismatchError( + method_name="aget_or_create_collection", + expected_client="AsyncQdrantClient", + alt_method="get_or_create_collection", + alt_client="QdrantClient", + ) + + collection_name = kwargs["collection_name"] + + if await self.client.collection_exists(collection_name): + return await self.client.get_collection(collection_name) + + params = _get_collection_params(kwargs) + await self.client.create_collection(**params) + + return await self.client.get_collection(collection_name) + + def add_documents(self, **kwargs: Unpack[BaseCollectionAddParams]) -> None: + """Add documents with their embeddings to a collection. + + Keyword Args: + collection_name: The name of the collection to add documents to. + documents: List of BaseRecord dicts containing document data. + + Raises: + ValueError: If collection doesn't exist or documents list is empty. + ConnectionError: If unable to connect to Qdrant server. + """ + if not _is_sync_client(self.client): + raise ClientMethodMismatchError( + method_name="add_documents", + expected_client="QdrantClient", + alt_method="aadd_documents", + alt_client="AsyncQdrantClient", + ) + + collection_name = kwargs["collection_name"] + documents = kwargs["documents"] + + if not documents: + raise ValueError("Documents list cannot be empty") + + if not self.client.collection_exists(collection_name): + raise ValueError(f"Collection '{collection_name}' does not exist") + + points = [] + for doc in documents: + if _is_async_embedding_function(self.embedding_function): + raise TypeError( + "Async embedding function cannot be used with sync add_documents. " + "Use aadd_documents instead." + ) + sync_fn = cast(EmbeddingFunction, self.embedding_function) + embedding = sync_fn(doc["content"]) + point = _create_point_from_document(doc, embedding) + points.append(point) + + self.client.upsert(collection_name=collection_name, points=points, wait=True) + + async def aadd_documents(self, **kwargs: Unpack[BaseCollectionAddParams]) -> None: + """Add documents with their embeddings to a collection asynchronously. + + Keyword Args: + collection_name: The name of the collection to add documents to. + documents: List of BaseRecord dicts containing document data. + + Raises: + ValueError: If collection doesn't exist or documents list is empty. + ConnectionError: If unable to connect to Qdrant server. + """ + if not _is_async_client(self.client): + raise ClientMethodMismatchError( + method_name="aadd_documents", + expected_client="AsyncQdrantClient", + alt_method="add_documents", + alt_client="QdrantClient", + ) + + collection_name = kwargs["collection_name"] + documents = kwargs["documents"] + + if not documents: + raise ValueError("Documents list cannot be empty") + + if not await self.client.collection_exists(collection_name): + raise ValueError(f"Collection '{collection_name}' does not exist") + + points = [] + for doc in documents: + if _is_async_embedding_function(self.embedding_function): + async_fn = cast(AsyncEmbeddingFunction, self.embedding_function) + embedding = await async_fn(doc["content"]) + else: + sync_fn = cast(EmbeddingFunction, self.embedding_function) + embedding = sync_fn(doc["content"]) + point = _create_point_from_document(doc, embedding) + points.append(point) + + await self.client.upsert( + collection_name=collection_name, points=points, wait=True + ) + + def search( + self, **kwargs: Unpack[BaseCollectionSearchParams] + ) -> list[SearchResult]: + """Search for similar documents using a query. + + Keyword Args: + collection_name: Name of the collection to search in. + query: The text query to search for. + limit: Maximum number of results to return (default: 10). + metadata_filter: Optional filter for metadata fields. + score_threshold: Optional minimum similarity score (0-1) for results. + + Returns: + List of SearchResult dicts containing id, content, metadata, and score. + + Raises: + ValueError: If collection doesn't exist. + ConnectionError: If unable to connect to Qdrant server. + """ + if not _is_sync_client(self.client): + raise ClientMethodMismatchError( + method_name="search", + expected_client="QdrantClient", + alt_method="asearch", + alt_client="AsyncQdrantClient", + ) + + collection_name = kwargs["collection_name"] + query = kwargs["query"] + limit = kwargs.get("limit", 10) + metadata_filter = kwargs.get("metadata_filter") + score_threshold = kwargs.get("score_threshold") + + if not self.client.collection_exists(collection_name): + raise ValueError(f"Collection '{collection_name}' does not exist") + + if _is_async_embedding_function(self.embedding_function): + raise TypeError( + "Async embedding function cannot be used with sync search. " + "Use asearch instead." + ) + sync_fn = cast(EmbeddingFunction, self.embedding_function) + query_embedding = sync_fn(query) + + search_kwargs = _prepare_search_params( + collection_name=collection_name, + query_embedding=query_embedding, + limit=limit, + score_threshold=score_threshold, + metadata_filter=metadata_filter, + ) + + response = self.client.query_points(**search_kwargs) + return _process_search_results(response) + + async def asearch( + self, **kwargs: Unpack[BaseCollectionSearchParams] + ) -> list[SearchResult]: + """Search for similar documents using a query asynchronously. + + Keyword Args: + collection_name: Name of the collection to search in. + query: The text query to search for. + limit: Maximum number of results to return (default: 10). + metadata_filter: Optional filter for metadata fields. + score_threshold: Optional minimum similarity score (0-1) for results. + + Returns: + List of SearchResult dicts containing id, content, metadata, and score. + + Raises: + ValueError: If collection doesn't exist. + ConnectionError: If unable to connect to Qdrant server. + """ + if not _is_async_client(self.client): + raise ClientMethodMismatchError( + method_name="asearch", + expected_client="AsyncQdrantClient", + alt_method="search", + alt_client="QdrantClient", + ) + + collection_name = kwargs["collection_name"] + query = kwargs["query"] + limit = kwargs.get("limit", 10) + metadata_filter = kwargs.get("metadata_filter") + score_threshold = kwargs.get("score_threshold") + + if not await self.client.collection_exists(collection_name): + raise ValueError(f"Collection '{collection_name}' does not exist") + + if _is_async_embedding_function(self.embedding_function): + async_fn = cast(AsyncEmbeddingFunction, self.embedding_function) + query_embedding = await async_fn(query) + else: + sync_fn = cast(EmbeddingFunction, self.embedding_function) + query_embedding = sync_fn(query) + + search_kwargs = _prepare_search_params( + collection_name=collection_name, + query_embedding=query_embedding, + limit=limit, + score_threshold=score_threshold, + metadata_filter=metadata_filter, + ) + + response = await self.client.query_points(**search_kwargs) + return _process_search_results(response) + + def delete_collection(self, **kwargs: Unpack[BaseCollectionParams]) -> None: + """Delete a collection and all its data. + + Keyword Args: + collection_name: Name of the collection to delete. + + Raises: + ValueError: If collection doesn't exist. + ConnectionError: If unable to connect to Qdrant server. + """ + if not _is_sync_client(self.client): + raise ClientMethodMismatchError( + method_name="delete_collection", + expected_client="QdrantClient", + alt_method="adelete_collection", + alt_client="AsyncQdrantClient", + ) + + collection_name = kwargs["collection_name"] + + if not self.client.collection_exists(collection_name): + raise ValueError(f"Collection '{collection_name}' does not exist") + + self.client.delete_collection(collection_name=collection_name) + + async def adelete_collection(self, **kwargs: Unpack[BaseCollectionParams]) -> None: + """Delete a collection and all its data asynchronously. + + Keyword Args: + collection_name: Name of the collection to delete. + + Raises: + ValueError: If collection doesn't exist. + ConnectionError: If unable to connect to Qdrant server. + """ + if not _is_async_client(self.client): + raise ClientMethodMismatchError( + method_name="adelete_collection", + expected_client="AsyncQdrantClient", + alt_method="delete_collection", + alt_client="QdrantClient", + ) + + collection_name = kwargs["collection_name"] + + if not await self.client.collection_exists(collection_name): + raise ValueError(f"Collection '{collection_name}' does not exist") + + await self.client.delete_collection(collection_name=collection_name) + + def reset(self) -> None: + """Reset the vector database by deleting all collections and data. + + Raises: + ConnectionError: If unable to connect to Qdrant server. + """ + if not _is_sync_client(self.client): + raise ClientMethodMismatchError( + method_name="reset", + expected_client="QdrantClient", + alt_method="areset", + alt_client="AsyncQdrantClient", + ) + + collections_response = self.client.get_collections() + + for collection in collections_response.collections: + self.client.delete_collection(collection_name=collection.name) + + async def areset(self) -> None: + """Reset the vector database by deleting all collections and data asynchronously. + + Raises: + ConnectionError: If unable to connect to Qdrant server. + """ + if not _is_async_client(self.client): + raise ClientMethodMismatchError( + method_name="areset", + expected_client="AsyncQdrantClient", + alt_method="reset", + alt_client="QdrantClient", + ) + + collections_response = await self.client.get_collections() + + for collection in collections_response.collections: + await self.client.delete_collection(collection_name=collection.name) diff --git a/src/crewai/rag/qdrant/constants.py b/src/crewai/rag/qdrant/constants.py new file mode 100644 index 000000000..027e5f1f2 --- /dev/null +++ b/src/crewai/rag/qdrant/constants.py @@ -0,0 +1,7 @@ +"""Constants for Qdrant implementation.""" + +from typing import Final + +from qdrant_client.models import Distance, VectorParams + +DEFAULT_VECTOR_PARAMS: Final = VectorParams(size=384, distance=Distance.COSINE) diff --git a/src/crewai/rag/qdrant/types.py b/src/crewai/rag/qdrant/types.py new file mode 100644 index 000000000..306f66c91 --- /dev/null +++ b/src/crewai/rag/qdrant/types.py @@ -0,0 +1,134 @@ +"""Type definitions specific to Qdrant implementation.""" + +from collections.abc import Awaitable, Callable +from typing import Annotated, Any, Protocol, TypeAlias, TypedDict +from typing_extensions import NotRequired + +import numpy as np +from qdrant_client import AsyncQdrantClient, QdrantClient as SyncQdrantClient +from qdrant_client.models import ( + FieldCondition, + Filter, + HasIdCondition, + HasVectorCondition, + HnswConfigDiff, + InitFrom, + IsEmptyCondition, + IsNullCondition, + NestedCondition, + OptimizersConfigDiff, + QuantizationConfig, + ShardingMethod, + SparseVectorsConfig, + VectorsConfig, + WalConfigDiff, +) + +from crewai.rag.core.base_client import BaseCollectionParams + +QdrantClientType = SyncQdrantClient | AsyncQdrantClient + +QueryEmbedding: TypeAlias = list[float] | np.ndarray[Any, np.dtype[np.floating[Any]]] + +BasicConditions = FieldCondition | IsEmptyCondition | IsNullCondition +StructuralConditions = HasIdCondition | HasVectorCondition | NestedCondition +FilterCondition = BasicConditions | StructuralConditions | Filter + +MetadataFilterValue = bool | int | str +MetadataFilter = dict[str, MetadataFilterValue] + + +class EmbeddingFunction(Protocol): + """Protocol for embedding functions that convert text to vectors.""" + + def __call__(self, text: str) -> QueryEmbedding: + """Convert text to embedding vector. + + Args: + text: Input text to embed. + + Returns: + Embedding vector as list of floats or numpy array. + """ + ... + + +class AsyncEmbeddingFunction(Protocol): + """Protocol for async embedding functions that convert text to vectors.""" + + async def __call__(self, text: str) -> QueryEmbedding: + """Convert text to embedding vector asynchronously. + + Args: + text: Input text to embed. + + Returns: + Embedding vector as list of floats or numpy array. + """ + ... + + +class QdrantClientParams(TypedDict, total=False): + """Parameters for QdrantClient initialization.""" + + location: str | None + url: str | None + port: int + grpc_port: int + prefer_grpc: bool + https: bool | None + api_key: str | None + prefix: str | None + timeout: int | None + host: str | None + path: str | None + force_disable_check_same_thread: bool + grpc_options: dict[str, Any] | None + auth_token_provider: Callable[[], str] | Callable[[], Awaitable[str]] | None + cloud_inference: bool + local_inference_batch_size: int | None + check_compatibility: bool + + +class CommonCreateFields(TypedDict, total=False): + """Fields shared between high-level and direct create_collection params.""" + + vectors_config: VectorsConfig + sparse_vectors_config: SparseVectorsConfig + shard_number: Annotated[int, "Number of shards (default: 1)"] + sharding_method: ShardingMethod + replication_factor: Annotated[int, "Number of replicas per shard (default: 1)"] + write_consistency_factor: Annotated[int, "Await N replicas on write (default: 1)"] + on_disk_payload: Annotated[bool, "Store payload on disk instead of RAM"] + hnsw_config: HnswConfigDiff + optimizers_config: OptimizersConfigDiff + wal_config: WalConfigDiff + quantization_config: QuantizationConfig + init_from: InitFrom | str + timeout: Annotated[int, "Operation timeout in seconds"] + + +class QdrantCollectionCreateParams( + BaseCollectionParams, CommonCreateFields, total=False +): + """High-level parameters for creating a Qdrant collection.""" + + pass + + +class CreateCollectionParams(CommonCreateFields, total=False): + """Parameters for qdrant_client.create_collection.""" + + collection_name: str + + +class PreparedSearchParams(TypedDict): + """Type definition for prepared Qdrant search parameters.""" + + collection_name: str + query: list[float] + limit: Annotated[int, "Max results to return"] + with_payload: Annotated[bool, "Include payload in results"] + with_vectors: Annotated[bool, "Include vectors in results"] + score_threshold: NotRequired[Annotated[float, "Min similarity score (0-1)"]] + query_filter: NotRequired[Filter] diff --git a/src/crewai/rag/qdrant/utils.py b/src/crewai/rag/qdrant/utils.py new file mode 100644 index 000000000..8ac011a5a --- /dev/null +++ b/src/crewai/rag/qdrant/utils.py @@ -0,0 +1,228 @@ +"""Utility functions for Qdrant operations.""" + +import asyncio +from typing import TypeGuard +from uuid import uuid4 + +from qdrant_client import AsyncQdrantClient, QdrantClient as SyncQdrantClient +from qdrant_client.models import ( + FieldCondition, + Filter, + MatchValue, + PointStruct, + QueryResponse, +) + +from crewai.rag.qdrant.constants import DEFAULT_VECTOR_PARAMS +from crewai.rag.qdrant.types import ( + AsyncEmbeddingFunction, + CreateCollectionParams, + EmbeddingFunction, + FilterCondition, + MetadataFilter, + PreparedSearchParams, + QdrantClientType, + QdrantCollectionCreateParams, + QueryEmbedding, +) +from crewai.rag.types import SearchResult, BaseRecord + + +def _ensure_list_embedding(embedding: QueryEmbedding) -> list[float]: + """Convert embedding to list[float] format if needed. + + Args: + embedding: Embedding vector as list or numpy array. + + Returns: + Embedding as list[float]. + """ + if not isinstance(embedding, list): + return embedding.tolist() + return embedding + + +def _is_sync_client(client: QdrantClientType) -> TypeGuard[SyncQdrantClient]: + """Type guard to check if the client is a synchronous QdrantClient. + + Args: + client: The client to check. + + Returns: + True if the client is a QdrantClient, False otherwise. + """ + return isinstance(client, SyncQdrantClient) + + +def _is_async_client(client: QdrantClientType) -> TypeGuard[AsyncQdrantClient]: + """Type guard to check if the client is an asynchronous AsyncQdrantClient. + + Args: + client: The client to check. + + Returns: + True if the client is an AsyncQdrantClient, False otherwise. + """ + return isinstance(client, AsyncQdrantClient) + + +def _is_async_embedding_function( + func: EmbeddingFunction | AsyncEmbeddingFunction, +) -> TypeGuard[AsyncEmbeddingFunction]: + """Type guard to check if the embedding function is async. + + Args: + func: The embedding function to check. + + Returns: + True if the function is async, False otherwise. + """ + return asyncio.iscoroutinefunction(func) + + +def _get_collection_params( + kwargs: QdrantCollectionCreateParams, +) -> CreateCollectionParams: + """Extract collection creation parameters from kwargs.""" + params: CreateCollectionParams = { + "collection_name": kwargs["collection_name"], + "vectors_config": kwargs.get("vectors_config", DEFAULT_VECTOR_PARAMS), + } + + if "sparse_vectors_config" in kwargs: + params["sparse_vectors_config"] = kwargs["sparse_vectors_config"] + if "shard_number" in kwargs: + params["shard_number"] = kwargs["shard_number"] + if "sharding_method" in kwargs: + params["sharding_method"] = kwargs["sharding_method"] + if "replication_factor" in kwargs: + params["replication_factor"] = kwargs["replication_factor"] + if "write_consistency_factor" in kwargs: + params["write_consistency_factor"] = kwargs["write_consistency_factor"] + if "on_disk_payload" in kwargs: + params["on_disk_payload"] = kwargs["on_disk_payload"] + if "hnsw_config" in kwargs: + params["hnsw_config"] = kwargs["hnsw_config"] + if "optimizers_config" in kwargs: + params["optimizers_config"] = kwargs["optimizers_config"] + if "wal_config" in kwargs: + params["wal_config"] = kwargs["wal_config"] + if "quantization_config" in kwargs: + params["quantization_config"] = kwargs["quantization_config"] + if "init_from" in kwargs: + params["init_from"] = kwargs["init_from"] + if "timeout" in kwargs: + params["timeout"] = kwargs["timeout"] + + return params + + +def _prepare_search_params( + collection_name: str, + query_embedding: QueryEmbedding, + limit: int, + score_threshold: float | None, + metadata_filter: MetadataFilter | None, +) -> PreparedSearchParams: + """Prepare search parameters for Qdrant query_points. + + Args: + collection_name: Name of the collection to search. + query_embedding: Embedding vector for the query. + limit: Maximum number of results. + score_threshold: Optional minimum similarity score. + metadata_filter: Optional metadata filters. + + Returns: + Dictionary of parameters for query_points method. + """ + query_vector = _ensure_list_embedding(query_embedding) + + search_kwargs: PreparedSearchParams = { + "collection_name": collection_name, + "query": query_vector, + "limit": limit, + "with_payload": True, + "with_vectors": False, + } + + if score_threshold is not None: + search_kwargs["score_threshold"] = score_threshold + + if metadata_filter: + filter_conditions: list[FilterCondition] = [] + for key, value in metadata_filter.items(): + filter_conditions.append( + FieldCondition(key=key, match=MatchValue(value=value)) + ) + + search_kwargs["query_filter"] = Filter(must=filter_conditions) + + return search_kwargs + + +def _normalize_qdrant_score(score: float) -> float: + """Normalize Qdrant cosine similarity score to [0, 1] range. + + Converts from Qdrant's [-1, 1] cosine similarity range to [0, 1] range for standardization across clients. + + Args: + score: Raw cosine similarity score from Qdrant [-1, 1]. + + Returns: + Normalized score in [0, 1] range where 1 is most similar. + """ + normalized = (score + 1.0) / 2.0 + return max(0.0, min(1.0, normalized)) + + +def _process_search_results(response: QueryResponse) -> list[SearchResult]: + """Process Qdrant search response into SearchResult format. + + Args: + response: Response from Qdrant query_points method. + + Returns: + List of SearchResult dictionaries. + """ + results: list[SearchResult] = [] + for point in response.points: + payload = point.payload or {} + score = _normalize_qdrant_score(score=point.score) + result: SearchResult = { + "id": str(point.id), + "content": payload.get("content", ""), + "metadata": {k: v for k, v in payload.items() if k != "content"}, + "score": score, + } + results.append(result) + + return results + + +def _create_point_from_document( + doc: BaseRecord, embedding: QueryEmbedding +) -> PointStruct: + """Create a PointStruct from a document and its embedding. + + Args: + doc: Document dictionary containing content, metadata, and optional doc_id. + embedding: The embedding vector for the document content. + + Returns: + PointStruct ready to be upserted to Qdrant. + """ + doc_id = doc.get("doc_id", str(uuid4())) + vector = _ensure_list_embedding(embedding) + + metadata = doc.get("metadata", {}) + if isinstance(metadata, list): + metadata = metadata[0] if metadata else {} + elif not isinstance(metadata, dict): + metadata = dict(metadata) if metadata else {} + + return PointStruct( + id=doc_id, + vector=vector, + payload={"content": doc["content"], **metadata}, + ) diff --git a/tests/rag/qdrant/test_client.py b/tests/rag/qdrant/test_client.py new file mode 100644 index 000000000..3eaed7921 --- /dev/null +++ b/tests/rag/qdrant/test_client.py @@ -0,0 +1,793 @@ +"""Tests for QdrantClient implementation.""" + +from unittest.mock import AsyncMock, Mock + +import pytest +from qdrant_client import AsyncQdrantClient, QdrantClient as SyncQdrantClient + +from crewai.rag.core.exceptions import ClientMethodMismatchError +from crewai.rag.qdrant.client import QdrantClient +from crewai.rag.types import BaseRecord + + +@pytest.fixture +def mock_qdrant_client(): + """Create a mock Qdrant client.""" + return Mock(spec=SyncQdrantClient) + + +@pytest.fixture +def mock_async_qdrant_client(): + """Create a mock async Qdrant client.""" + return Mock(spec=AsyncQdrantClient) + + +@pytest.fixture +def client(mock_qdrant_client) -> QdrantClient: + """Create a QdrantClient instance for testing.""" + mock_embedding = Mock() + mock_embedding.return_value = [0.1, 0.2, 0.3] + client = QdrantClient(client=mock_qdrant_client, embedding_function=mock_embedding) + return client + + +@pytest.fixture +def async_client(mock_async_qdrant_client) -> QdrantClient: + """Create a QdrantClient instance with async client for testing.""" + mock_embedding = Mock() + mock_embedding.return_value = [0.1, 0.2, 0.3] + client = QdrantClient( + client=mock_async_qdrant_client, embedding_function=mock_embedding + ) + return client + + +class TestQdrantClient: + """Test suite for QdrantClient.""" + + def test_create_collection(self, client, mock_qdrant_client): + """Test that create_collection creates a new collection.""" + mock_qdrant_client.collection_exists.return_value = False + + client.create_collection(collection_name="test_collection") + + mock_qdrant_client.collection_exists.assert_called_once_with("test_collection") + mock_qdrant_client.create_collection.assert_called_once() + call_args = mock_qdrant_client.create_collection.call_args + assert call_args.kwargs["collection_name"] == "test_collection" + assert call_args.kwargs["vectors_config"] is not None + + def test_create_collection_already_exists(self, client, mock_qdrant_client): + """Test that create_collection raises error if collection exists.""" + mock_qdrant_client.collection_exists.return_value = True + + with pytest.raises( + ValueError, match="Collection 'test_collection' already exists" + ): + client.create_collection(collection_name="test_collection") + + def test_create_collection_wrong_client_type(self, mock_async_qdrant_client): + """Test that create_collection raises TypeError for async client.""" + client = QdrantClient( + client=mock_async_qdrant_client, embedding_function=Mock() + ) + + with pytest.raises( + ClientMethodMismatchError, match=r"Method create_collection\(\) requires" + ): + client.create_collection(collection_name="test_collection") + + @pytest.mark.asyncio + async def test_acreate_collection(self, async_client, mock_async_qdrant_client): + """Test that acreate_collection creates a new collection asynchronously.""" + mock_async_qdrant_client.collection_exists = AsyncMock(return_value=False) + mock_async_qdrant_client.create_collection = AsyncMock() + + await async_client.acreate_collection(collection_name="test_collection") + + mock_async_qdrant_client.collection_exists.assert_called_once_with( + "test_collection" + ) + mock_async_qdrant_client.create_collection.assert_called_once() + call_args = mock_async_qdrant_client.create_collection.call_args + assert call_args.kwargs["collection_name"] == "test_collection" + assert call_args.kwargs["vectors_config"] is not None + + @pytest.mark.asyncio + async def test_acreate_collection_already_exists( + self, async_client, mock_async_qdrant_client + ): + """Test that acreate_collection raises error if collection exists.""" + mock_async_qdrant_client.collection_exists = AsyncMock(return_value=True) + + with pytest.raises( + ValueError, match="Collection 'test_collection' already exists" + ): + await async_client.acreate_collection(collection_name="test_collection") + + @pytest.mark.asyncio + async def test_acreate_collection_wrong_client_type(self, mock_qdrant_client): + """Test that acreate_collection raises TypeError for sync client.""" + client = QdrantClient(client=mock_qdrant_client, embedding_function=Mock()) + + with pytest.raises( + ClientMethodMismatchError, match=r"Method acreate_collection\(\) requires" + ): + await client.acreate_collection(collection_name="test_collection") + + def test_get_or_create_collection_existing(self, client, mock_qdrant_client): + """Test get_or_create_collection returns existing collection.""" + mock_qdrant_client.collection_exists.return_value = True + mock_collection_info = Mock() + mock_qdrant_client.get_collection.return_value = mock_collection_info + + result = client.get_or_create_collection(collection_name="test_collection") + + mock_qdrant_client.collection_exists.assert_called_once_with("test_collection") + mock_qdrant_client.get_collection.assert_called_once_with("test_collection") + mock_qdrant_client.create_collection.assert_not_called() + assert result == mock_collection_info + + def test_get_or_create_collection_new(self, client, mock_qdrant_client): + """Test get_or_create_collection creates new collection.""" + mock_qdrant_client.collection_exists.return_value = False + mock_collection_info = Mock() + mock_qdrant_client.get_collection.return_value = mock_collection_info + + result = client.get_or_create_collection(collection_name="test_collection") + + mock_qdrant_client.collection_exists.assert_called_once_with("test_collection") + mock_qdrant_client.create_collection.assert_called_once() + mock_qdrant_client.get_collection.assert_called_once_with("test_collection") + assert result == mock_collection_info + + def test_get_or_create_collection_wrong_client_type(self, mock_async_qdrant_client): + """Test get_or_create_collection raises TypeError for async client.""" + client = QdrantClient( + client=mock_async_qdrant_client, embedding_function=Mock() + ) + + with pytest.raises( + ClientMethodMismatchError, + match=r"Method get_or_create_collection\(\) requires", + ): + client.get_or_create_collection(collection_name="test_collection") + + @pytest.mark.asyncio + async def test_aget_or_create_collection_existing( + self, async_client, mock_async_qdrant_client + ): + """Test aget_or_create_collection returns existing collection.""" + mock_async_qdrant_client.collection_exists = AsyncMock(return_value=True) + mock_collection_info = Mock() + mock_async_qdrant_client.get_collection = AsyncMock( + return_value=mock_collection_info + ) + + result = await async_client.aget_or_create_collection( + collection_name="test_collection" + ) + + mock_async_qdrant_client.collection_exists.assert_called_once_with( + "test_collection" + ) + mock_async_qdrant_client.get_collection.assert_called_once_with( + "test_collection" + ) + mock_async_qdrant_client.create_collection.assert_not_called() + assert result == mock_collection_info + + @pytest.mark.asyncio + async def test_aget_or_create_collection_new( + self, async_client, mock_async_qdrant_client + ): + """Test aget_or_create_collection creates new collection.""" + mock_async_qdrant_client.collection_exists = AsyncMock(return_value=False) + mock_async_qdrant_client.create_collection = AsyncMock() + mock_collection_info = Mock() + mock_async_qdrant_client.get_collection = AsyncMock( + return_value=mock_collection_info + ) + + result = await async_client.aget_or_create_collection( + collection_name="test_collection" + ) + + mock_async_qdrant_client.collection_exists.assert_called_once_with( + "test_collection" + ) + mock_async_qdrant_client.create_collection.assert_called_once() + mock_async_qdrant_client.get_collection.assert_called_once_with( + "test_collection" + ) + assert result == mock_collection_info + + @pytest.mark.asyncio + async def test_aget_or_create_collection_wrong_client_type( + self, mock_qdrant_client + ): + """Test aget_or_create_collection raises TypeError for sync client.""" + client = QdrantClient(client=mock_qdrant_client, embedding_function=Mock()) + + with pytest.raises( + ClientMethodMismatchError, + match=r"Method aget_or_create_collection\(\) requires", + ): + await client.aget_or_create_collection(collection_name="test_collection") + + def test_add_documents(self, client, mock_qdrant_client): + """Test that add_documents adds documents to collection.""" + mock_qdrant_client.collection_exists.return_value = True + client.embedding_function.return_value = [0.1, 0.2, 0.3] + + documents: list[BaseRecord] = [ + { + "content": "Test document", + "metadata": {"source": "test"}, + } + ] + + client.add_documents(collection_name="test_collection", documents=documents) + + mock_qdrant_client.collection_exists.assert_called_once_with("test_collection") + client.embedding_function.assert_called_once_with("Test document") + mock_qdrant_client.upsert.assert_called_once() + + # Check upsert was called with correct parameters + call_args = mock_qdrant_client.upsert.call_args + assert call_args.kwargs["collection_name"] == "test_collection" + assert call_args.kwargs["wait"] is True + assert len(call_args.kwargs["points"]) == 1 + point = call_args.kwargs["points"][0] + assert point.vector == [0.1, 0.2, 0.3] + assert point.payload["content"] == "Test document" + assert point.payload["source"] == "test" + + def test_add_documents_with_doc_id(self, client, mock_qdrant_client): + """Test that add_documents uses provided doc_id.""" + mock_qdrant_client.collection_exists.return_value = True + client.embedding_function.return_value = [0.1, 0.2, 0.3] + + documents: list[BaseRecord] = [ + { + "doc_id": "custom-id-123", + "content": "Test document", + "metadata": {"source": "test"}, + } + ] + + client.add_documents(collection_name="test_collection", documents=documents) + + call_args = mock_qdrant_client.upsert.call_args + point = call_args.kwargs["points"][0] + assert point.id == "custom-id-123" + + def test_add_documents_empty_list(self, client, mock_qdrant_client): + """Test that add_documents raises error for empty documents list.""" + documents: list[BaseRecord] = [] + + with pytest.raises(ValueError, match="Documents list cannot be empty"): + client.add_documents(collection_name="test_collection", documents=documents) + + def test_add_documents_collection_not_exists(self, client, mock_qdrant_client): + """Test that add_documents raises error if collection doesn't exist.""" + mock_qdrant_client.collection_exists.return_value = False + + documents: list[BaseRecord] = [ + { + "content": "Test document", + "metadata": {"source": "test"}, + } + ] + + with pytest.raises( + ValueError, match="Collection 'test_collection' does not exist" + ): + client.add_documents(collection_name="test_collection", documents=documents) + + def test_add_documents_wrong_client_type(self, mock_async_qdrant_client): + """Test that add_documents raises TypeError for async client.""" + client = QdrantClient( + client=mock_async_qdrant_client, embedding_function=Mock() + ) + + documents: list[BaseRecord] = [ + { + "content": "Test document", + "metadata": {"source": "test"}, + } + ] + + with pytest.raises( + ClientMethodMismatchError, match=r"Method add_documents\(\) requires" + ): + client.add_documents(collection_name="test_collection", documents=documents) + + @pytest.mark.asyncio + async def test_aadd_documents(self, async_client, mock_async_qdrant_client): + """Test that aadd_documents adds documents to collection asynchronously.""" + mock_async_qdrant_client.collection_exists = AsyncMock(return_value=True) + mock_async_qdrant_client.upsert = AsyncMock() + async_client.embedding_function.return_value = [0.1, 0.2, 0.3] + + documents: list[BaseRecord] = [ + { + "content": "Test document", + "metadata": {"source": "test"}, + } + ] + + await async_client.aadd_documents( + collection_name="test_collection", documents=documents + ) + + mock_async_qdrant_client.collection_exists.assert_called_once_with( + "test_collection" + ) + async_client.embedding_function.assert_called_once_with("Test document") + mock_async_qdrant_client.upsert.assert_called_once() + + # Check upsert was called with correct parameters + call_args = mock_async_qdrant_client.upsert.call_args + assert call_args.kwargs["collection_name"] == "test_collection" + assert call_args.kwargs["wait"] is True + assert len(call_args.kwargs["points"]) == 1 + point = call_args.kwargs["points"][0] + assert point.vector == [0.1, 0.2, 0.3] + assert point.payload["content"] == "Test document" + assert point.payload["source"] == "test" + + @pytest.mark.asyncio + async def test_aadd_documents_with_doc_id( + self, async_client, mock_async_qdrant_client + ): + """Test that aadd_documents uses provided doc_id.""" + mock_async_qdrant_client.collection_exists = AsyncMock(return_value=True) + mock_async_qdrant_client.upsert = AsyncMock() + async_client.embedding_function.return_value = [0.1, 0.2, 0.3] + + documents: list[BaseRecord] = [ + { + "doc_id": "custom-id-123", + "content": "Test document", + "metadata": {"source": "test"}, + } + ] + + await async_client.aadd_documents( + collection_name="test_collection", documents=documents + ) + + call_args = mock_async_qdrant_client.upsert.call_args + point = call_args.kwargs["points"][0] + assert point.id == "custom-id-123" + + @pytest.mark.asyncio + async def test_aadd_documents_empty_list( + self, async_client, mock_async_qdrant_client + ): + """Test that aadd_documents raises error for empty documents list.""" + documents: list[BaseRecord] = [] + + with pytest.raises(ValueError, match="Documents list cannot be empty"): + await async_client.aadd_documents( + collection_name="test_collection", documents=documents + ) + + @pytest.mark.asyncio + async def test_aadd_documents_collection_not_exists( + self, async_client, mock_async_qdrant_client + ): + """Test that aadd_documents raises error if collection doesn't exist.""" + mock_async_qdrant_client.collection_exists = AsyncMock(return_value=False) + + documents: list[BaseRecord] = [ + { + "content": "Test document", + "metadata": {"source": "test"}, + } + ] + + with pytest.raises( + ValueError, match="Collection 'test_collection' does not exist" + ): + await async_client.aadd_documents( + collection_name="test_collection", documents=documents + ) + + @pytest.mark.asyncio + async def test_aadd_documents_wrong_client_type(self, mock_qdrant_client): + """Test that aadd_documents raises TypeError for sync client.""" + client = QdrantClient(client=mock_qdrant_client, embedding_function=Mock()) + + documents: list[BaseRecord] = [ + { + "content": "Test document", + "metadata": {"source": "test"}, + } + ] + + with pytest.raises( + ClientMethodMismatchError, match=r"Method aadd_documents\(\) requires" + ): + await client.aadd_documents( + collection_name="test_collection", documents=documents + ) + + def test_search(self, client, mock_qdrant_client): + """Test that search returns matching documents.""" + mock_qdrant_client.collection_exists.return_value = True + client.embedding_function.return_value = [0.1, 0.2, 0.3] + + mock_point = Mock() + mock_point.id = "doc-123" + mock_point.payload = {"content": "Test content", "source": "test"} + mock_point.score = 0.95 + + mock_response = Mock() + mock_response.points = [mock_point] + mock_qdrant_client.query_points.return_value = mock_response + + results = client.search(collection_name="test_collection", query="test query") + + mock_qdrant_client.collection_exists.assert_called_once_with("test_collection") + client.embedding_function.assert_called_once_with("test query") + mock_qdrant_client.query_points.assert_called_once() + + call_args = mock_qdrant_client.query_points.call_args + assert call_args.kwargs["collection_name"] == "test_collection" + assert call_args.kwargs["query"] == [0.1, 0.2, 0.3] + assert call_args.kwargs["limit"] == 10 + assert call_args.kwargs["with_payload"] is True + assert call_args.kwargs["with_vectors"] is False + + assert len(results) == 1 + assert results[0]["id"] == "doc-123" + assert results[0]["content"] == "Test content" + assert results[0]["metadata"] == {"source": "test"} + assert results[0]["score"] == 0.975 + + def test_search_with_filters(self, client, mock_qdrant_client): + """Test that search applies metadata filters correctly.""" + mock_qdrant_client.collection_exists.return_value = True + client.embedding_function.return_value = [0.1, 0.2, 0.3] + + mock_response = Mock() + mock_response.points = [] + mock_qdrant_client.query_points.return_value = mock_response + + client.search( + collection_name="test_collection", + query="test query", + metadata_filter={"category": "tech", "status": "published"}, + ) + + call_args = mock_qdrant_client.query_points.call_args + query_filter = call_args.kwargs["query_filter"] + assert len(query_filter.must) == 2 + assert any( + cond.key == "category" and cond.match.value == "tech" + for cond in query_filter.must + ) + assert any( + cond.key == "status" and cond.match.value == "published" + for cond in query_filter.must + ) + + def test_search_with_options(self, client, mock_qdrant_client): + """Test that search applies limit and score_threshold correctly.""" + mock_qdrant_client.collection_exists.return_value = True + client.embedding_function.return_value = [0.1, 0.2, 0.3] + + mock_response = Mock() + mock_response.points = [] + mock_qdrant_client.query_points.return_value = mock_response + + client.search( + collection_name="test_collection", + query="test query", + limit=5, + score_threshold=0.8, + ) + + call_args = mock_qdrant_client.query_points.call_args + assert call_args.kwargs["limit"] == 5 + assert call_args.kwargs["score_threshold"] == 0.8 + + def test_search_collection_not_exists(self, client, mock_qdrant_client): + """Test that search raises error if collection doesn't exist.""" + mock_qdrant_client.collection_exists.return_value = False + + with pytest.raises( + ValueError, match="Collection 'test_collection' does not exist" + ): + client.search(collection_name="test_collection", query="test query") + + def test_search_wrong_client_type(self, mock_async_qdrant_client): + """Test that search raises TypeError for async client.""" + client = QdrantClient( + client=mock_async_qdrant_client, embedding_function=Mock() + ) + + with pytest.raises( + ClientMethodMismatchError, match=r"Method search\(\) requires" + ): + client.search(collection_name="test_collection", query="test query") + + @pytest.mark.asyncio + async def test_asearch(self, async_client, mock_async_qdrant_client): + """Test that asearch returns matching documents asynchronously.""" + mock_async_qdrant_client.collection_exists = AsyncMock(return_value=True) + async_client.embedding_function.return_value = [0.1, 0.2, 0.3] + + mock_point = Mock() + mock_point.id = "doc-123" + mock_point.payload = {"content": "Test content", "source": "test"} + mock_point.score = 0.95 + + mock_response = Mock() + mock_response.points = [mock_point] + mock_async_qdrant_client.query_points = AsyncMock(return_value=mock_response) + + results = await async_client.asearch( + collection_name="test_collection", query="test query" + ) + + mock_async_qdrant_client.collection_exists.assert_called_once_with( + "test_collection" + ) + async_client.embedding_function.assert_called_once_with("test query") + mock_async_qdrant_client.query_points.assert_called_once() + + call_args = mock_async_qdrant_client.query_points.call_args + assert call_args.kwargs["collection_name"] == "test_collection" + assert call_args.kwargs["query"] == [0.1, 0.2, 0.3] + assert call_args.kwargs["limit"] == 10 + assert call_args.kwargs["with_payload"] is True + assert call_args.kwargs["with_vectors"] is False + + assert len(results) == 1 + assert results[0]["id"] == "doc-123" + assert results[0]["content"] == "Test content" + assert results[0]["metadata"] == {"source": "test"} + assert results[0]["score"] == 0.975 + + @pytest.mark.asyncio + async def test_asearch_with_filters(self, async_client, mock_async_qdrant_client): + """Test that asearch applies metadata filters correctly.""" + mock_async_qdrant_client.collection_exists = AsyncMock(return_value=True) + async_client.embedding_function.return_value = [0.1, 0.2, 0.3] + + mock_response = Mock() + mock_response.points = [] + mock_async_qdrant_client.query_points = AsyncMock(return_value=mock_response) + + await async_client.asearch( + collection_name="test_collection", + query="test query", + metadata_filter={"category": "tech", "status": "published"}, + ) + + call_args = mock_async_qdrant_client.query_points.call_args + query_filter = call_args.kwargs["query_filter"] + assert len(query_filter.must) == 2 + assert any( + cond.key == "category" and cond.match.value == "tech" + for cond in query_filter.must + ) + assert any( + cond.key == "status" and cond.match.value == "published" + for cond in query_filter.must + ) + + @pytest.mark.asyncio + async def test_asearch_collection_not_exists( + self, async_client, mock_async_qdrant_client + ): + """Test that asearch raises error if collection doesn't exist.""" + mock_async_qdrant_client.collection_exists = AsyncMock(return_value=False) + + with pytest.raises( + ValueError, match="Collection 'test_collection' does not exist" + ): + await async_client.asearch( + collection_name="test_collection", query="test query" + ) + + @pytest.mark.asyncio + async def test_asearch_wrong_client_type(self, mock_qdrant_client): + """Test that asearch raises TypeError for sync client.""" + client = QdrantClient(client=mock_qdrant_client, embedding_function=Mock()) + + with pytest.raises( + ClientMethodMismatchError, match=r"Method asearch\(\) requires" + ): + await client.asearch(collection_name="test_collection", query="test query") + + def test_delete_collection(self, client, mock_qdrant_client): + """Test that delete_collection deletes the collection.""" + mock_qdrant_client.collection_exists.return_value = True + + client.delete_collection(collection_name="test_collection") + + mock_qdrant_client.collection_exists.assert_called_once_with("test_collection") + mock_qdrant_client.delete_collection.assert_called_once_with( + collection_name="test_collection" + ) + + def test_delete_collection_not_exists(self, client, mock_qdrant_client): + """Test that delete_collection raises error if collection doesn't exist.""" + mock_qdrant_client.collection_exists.return_value = False + + with pytest.raises( + ValueError, match="Collection 'test_collection' does not exist" + ): + client.delete_collection(collection_name="test_collection") + + mock_qdrant_client.collection_exists.assert_called_once_with("test_collection") + mock_qdrant_client.delete_collection.assert_not_called() + + def test_delete_collection_wrong_client_type(self, mock_async_qdrant_client): + """Test that delete_collection raises TypeError for async client.""" + client = QdrantClient( + client=mock_async_qdrant_client, embedding_function=Mock() + ) + + with pytest.raises( + ClientMethodMismatchError, match=r"Method delete_collection\(\) requires" + ): + client.delete_collection(collection_name="test_collection") + + @pytest.mark.asyncio + async def test_adelete_collection(self, async_client, mock_async_qdrant_client): + """Test that adelete_collection deletes the collection asynchronously.""" + mock_async_qdrant_client.collection_exists = AsyncMock(return_value=True) + mock_async_qdrant_client.delete_collection = AsyncMock() + + await async_client.adelete_collection(collection_name="test_collection") + + mock_async_qdrant_client.collection_exists.assert_called_once_with( + "test_collection" + ) + mock_async_qdrant_client.delete_collection.assert_called_once_with( + collection_name="test_collection" + ) + + @pytest.mark.asyncio + async def test_adelete_collection_not_exists( + self, async_client, mock_async_qdrant_client + ): + """Test that adelete_collection raises error if collection doesn't exist.""" + mock_async_qdrant_client.collection_exists = AsyncMock(return_value=False) + + with pytest.raises( + ValueError, match="Collection 'test_collection' does not exist" + ): + await async_client.adelete_collection(collection_name="test_collection") + + mock_async_qdrant_client.collection_exists.assert_called_once_with( + "test_collection" + ) + mock_async_qdrant_client.delete_collection.assert_not_called() + + @pytest.mark.asyncio + async def test_adelete_collection_wrong_client_type(self, mock_qdrant_client): + """Test that adelete_collection raises TypeError for sync client.""" + client = QdrantClient(client=mock_qdrant_client, embedding_function=Mock()) + + with pytest.raises( + ClientMethodMismatchError, match=r"Method adelete_collection\(\) requires" + ): + await client.adelete_collection(collection_name="test_collection") + + def test_reset(self, client, mock_qdrant_client): + """Test that reset deletes all collections.""" + mock_collection1 = Mock() + mock_collection1.name = "collection1" + mock_collection2 = Mock() + mock_collection2.name = "collection2" + mock_collection3 = Mock() + mock_collection3.name = "collection3" + + mock_collections_response = Mock() + mock_collections_response.collections = [ + mock_collection1, + mock_collection2, + mock_collection3, + ] + mock_qdrant_client.get_collections.return_value = mock_collections_response + + client.reset() + + mock_qdrant_client.get_collections.assert_called_once() + assert mock_qdrant_client.delete_collection.call_count == 3 + mock_qdrant_client.delete_collection.assert_any_call( + collection_name="collection1" + ) + mock_qdrant_client.delete_collection.assert_any_call( + collection_name="collection2" + ) + mock_qdrant_client.delete_collection.assert_any_call( + collection_name="collection3" + ) + + def test_reset_no_collections(self, client, mock_qdrant_client): + """Test that reset handles no collections gracefully.""" + mock_collections_response = Mock() + mock_collections_response.collections = [] + mock_qdrant_client.get_collections.return_value = mock_collections_response + + client.reset() + + mock_qdrant_client.get_collections.assert_called_once() + mock_qdrant_client.delete_collection.assert_not_called() + + def test_reset_wrong_client_type(self, mock_async_qdrant_client): + """Test that reset raises TypeError for async client.""" + client = QdrantClient( + client=mock_async_qdrant_client, embedding_function=Mock() + ) + + with pytest.raises( + ClientMethodMismatchError, match=r"Method reset\(\) requires" + ): + client.reset() + + @pytest.mark.asyncio + async def test_areset(self, async_client, mock_async_qdrant_client): + """Test that areset deletes all collections asynchronously.""" + mock_collection1 = Mock() + mock_collection1.name = "collection1" + mock_collection2 = Mock() + mock_collection2.name = "collection2" + mock_collection3 = Mock() + mock_collection3.name = "collection3" + + mock_collections_response = Mock() + mock_collections_response.collections = [ + mock_collection1, + mock_collection2, + mock_collection3, + ] + mock_async_qdrant_client.get_collections = AsyncMock( + return_value=mock_collections_response + ) + mock_async_qdrant_client.delete_collection = AsyncMock() + + await async_client.areset() + + mock_async_qdrant_client.get_collections.assert_called_once() + assert mock_async_qdrant_client.delete_collection.call_count == 3 + mock_async_qdrant_client.delete_collection.assert_any_call( + collection_name="collection1" + ) + mock_async_qdrant_client.delete_collection.assert_any_call( + collection_name="collection2" + ) + mock_async_qdrant_client.delete_collection.assert_any_call( + collection_name="collection3" + ) + + @pytest.mark.asyncio + async def test_areset_no_collections(self, async_client, mock_async_qdrant_client): + """Test that areset handles no collections gracefully.""" + mock_collections_response = Mock() + mock_collections_response.collections = [] + mock_async_qdrant_client.get_collections = AsyncMock( + return_value=mock_collections_response + ) + + await async_client.areset() + + mock_async_qdrant_client.get_collections.assert_called_once() + mock_async_qdrant_client.delete_collection.assert_not_called() + + @pytest.mark.asyncio + async def test_areset_wrong_client_type(self, mock_qdrant_client): + """Test that areset raises TypeError for sync client.""" + client = QdrantClient(client=mock_qdrant_client, embedding_function=Mock()) + + with pytest.raises( + ClientMethodMismatchError, match=r"Method areset\(\) requires" + ): + await client.areset() diff --git a/uv.lock b/uv.lock index a353396a2..65f29e5fa 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10, <3.14" resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'", @@ -749,6 +749,9 @@ pandas = [ pdfplumber = [ { name = "pdfplumber" }, ] +qdrant = [ + { name = "qdrant-client", extra = ["fastembed"] }, +] tools = [ { name = "crewai-tools" }, ] @@ -801,6 +804,7 @@ requires-dist = [ { name = "pyjwt", specifier = ">=2.9.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyvis", specifier = ">=0.3.2" }, + { name = "qdrant-client", extras = ["fastembed"], marker = "extra == 'qdrant'", specifier = ">=1.14.3" }, { name = "regex", specifier = ">=2024.9.11" }, { name = "tiktoken", marker = "extra == 'embeddings'", specifier = "~=0.8.0" }, { name = "tokenizers", specifier = ">=0.20.3" }, @@ -808,7 +812,7 @@ requires-dist = [ { name = "tomli-w", specifier = ">=1.1.0" }, { name = "uv", specifier = ">=0.4.25" }, ] -provides-extras = ["aisuite", "docling", "embeddings", "mem0", "openpyxl", "pandas", "pdfplumber", "tools"] +provides-extras = ["aisuite", "docling", "embeddings", "mem0", "openpyxl", "pandas", "pdfplumber", "qdrant", "tools"] [package.metadata.requires-dev] dev = [ @@ -1284,6 +1288,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/84/9c2917a70ed570ddbfd1d32ac23200c1d011e36c332e59950d2f6d204941/fastavro-1.11.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1bc2824e9969c04ab6263d269a1e0e5d40b9bd16ade6b70c29d6ffbc4f3cc102", size = 3387171, upload-time = "2025-05-18T04:55:32.531Z" }, ] +[[package]] +name = "fastembed" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "loguru" }, + { name = "mmh3" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "onnxruntime" }, + { name = "pillow" }, + { name = "py-rust-stemmers" }, + { name = "requests" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/e0/75b294baf2497f085d225b83f7124c627807806c29cb052136d09d4a8599/fastembed-0.7.1.tar.gz", hash = "sha256:cb45be91779ba1dcbe4dbdbdcfb3e2cffb8ec546f8f4317e33fe3014113ee64c", size = 62197, upload-time = "2025-06-16T09:01:42.766Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/ad/5a7a19f7ca6a0b440056e0499bf15e3244217eedec06896ae95a80a73340/fastembed-0.7.1-py3-none-any.whl", hash = "sha256:b4f6a8f620c32f2e3de8231034ca2ca76dadc7d5463f2e9ab4930b51adc03b12", size = 100860, upload-time = "2025-06-16T09:01:41.373Z" }, +] + [[package]] name = "filelock" version = "3.18.0" @@ -2305,6 +2331,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/e4/f1546746049c99c6b8b247e2f34485b9eae36faa9322b84e2a17262e6712/litellm-1.74.9-py3-none-any.whl", hash = "sha256:ab8f8a6e4d8689d3c7c4f9c3bbc7e46212cc3ebc74ddd0f3c0c921bb459c9874", size = 8740449, upload-time = "2025-07-28T16:42:36.8Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "lxml" version = "5.4.0" @@ -4011,6 +4050,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "py-rust-stemmers" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/63/4fbc14810c32d2a884e2e94e406a7d5bf8eee53e1103f558433817230342/py_rust_stemmers-0.1.5.tar.gz", hash = "sha256:e9c310cfb5c2470d7c7c8a0484725965e7cab8b1237e106a0863d5741da3e1f7", size = 9388, upload-time = "2025-02-19T13:56:28.708Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/28/2247e06de9896ac5d0fe9c6c16e611fd39549cb3197e25f12ca4437f12e7/py_rust_stemmers-0.1.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:bfbd9034ae00419ff2154e33b8f5b4c4d99d1f9271f31ed059e5c7e9fa005844", size = 286084, upload-time = "2025-02-19T13:54:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/95/d9/5d1743a160eb9e0bc4c162360278166474e5d168e318c0d5e1bc32b18c96/py_rust_stemmers-0.1.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7162ae66df2bb0fc39b350c24a049f5f5151c03c046092ba095c2141ec223a2", size = 272020, upload-time = "2025-02-19T13:54:53.957Z" }, + { url = "https://files.pythonhosted.org/packages/98/21/a94c32ffa38417bad41d6e72cb89a32eac45cc8c6bed1a7b2b0f88bf3626/py_rust_stemmers-0.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da6de2b694af6227ba8c5a0447d4e0ef69991e63ee558b969f90c415f33e54d0", size = 310546, upload-time = "2025-02-19T13:54:55.462Z" }, + { url = "https://files.pythonhosted.org/packages/2c/43/95449704e43be071555448507ab9242f5edebe75fe5ff5fb9674bef0fd9f/py_rust_stemmers-0.1.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3abbd6d26722951a04550fff55460c0f26819169c23286e11ea25c645be6140", size = 315236, upload-time = "2025-02-19T13:54:56.577Z" }, + { url = "https://files.pythonhosted.org/packages/a7/77/fbd2bd6d3bb5a3395e09b990fa7598be4093d7b8958e2cadfae3d14dcc5b/py_rust_stemmers-0.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:019221c57a7bcc51097fa3f124b62d0577b5b6167184ee51abd3aea822d78f69", size = 324419, upload-time = "2025-02-19T13:54:58.373Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8d/3566e9b067d3551d72320193aa9377a1ddabaf7d4624dd0a10f4c496d6f5/py_rust_stemmers-0.1.5-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8dd5824194c279ee07f2675a55b3d728dfeec69a4b3c27329fab9b2ff5063c91", size = 324792, upload-time = "2025-02-19T13:54:59.547Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ce/9b4bdb548974c7e79f188057efb2a3426b2df8c9a3d8ac0d5a81b5f1a297/py_rust_stemmers-0.1.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7cf4d69bf20cec373ba0e89df3d98549b1a0cfb130dbd859a50ed772dd044546", size = 488012, upload-time = "2025-02-19T13:55:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3e/ea9d8328af1c0661adb47daeb460185285e0e5e26aeca84df5cbde2e4e58/py_rust_stemmers-0.1.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b42eb52609ac958e7fcc441395457dc5183397e8014e954f4aed78de210837b9", size = 575579, upload-time = "2025-02-19T13:55:02.915Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ba/49ea71077a5a52017a0a30c47e944c0a4ee33a88c5eaf2d96a06e74771d6/py_rust_stemmers-0.1.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c836aeb53409a44f38b153106374fe780099a7c976c582c5ae952061ff5d2fed", size = 493265, upload-time = "2025-02-19T13:55:04.966Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a7/26404770230634cec952b9f80444eba76bf8b514b1f3b550494566001893/py_rust_stemmers-0.1.5-cp310-none-win_amd64.whl", hash = "sha256:39550089f7a021a3a97fec2ff0d4ad77e471f0a65c0f100919555e60a4daabf0", size = 209394, upload-time = "2025-02-19T13:55:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/36/9b/6b11f843c01d110db58a68ec4176cb77b37f03268831742a7241f4810fe4/py_rust_stemmers-0.1.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:e644987edaf66919f5a9e4693336930f98d67b790857890623a431bb77774c84", size = 286085, upload-time = "2025-02-19T13:55:08.484Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/e16b587dc0ebc42916b1caad994bc37fbb19ad2c7e3f5f3a586ba2630c16/py_rust_stemmers-0.1.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:910d87d39ba75da1fe3d65df88b926b4b454ada8d73893cbd36e258a8a648158", size = 272019, upload-time = "2025-02-19T13:55:10.268Z" }, + { url = "https://files.pythonhosted.org/packages/41/66/8777f125720acb896b336e6f8153e3ec39754563bc9b89523cfe06ba63da/py_rust_stemmers-0.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31ff4fb9417cec35907c18a6463e3d5a4941a5aa8401f77fbb4156b3ada69e3f", size = 310547, upload-time = "2025-02-19T13:55:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f5/b79249c787c59b9ce2c5d007c0a0dc0fc1ecccfcf98a546c131cca55899e/py_rust_stemmers-0.1.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07b3b8582313ef8a7f544acf2c887f27c3dd48c5ddca028fa0f498de7380e24f", size = 315238, upload-time = "2025-02-19T13:55:13.39Z" }, + { url = "https://files.pythonhosted.org/packages/62/4c/c05c266ed74c063ae31dc5633ed63c48eb3b78034afcc80fe755d0cb09e7/py_rust_stemmers-0.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:804944eeb5c5559443d81f30c34d6e83c6292d72423f299e42f9d71b9d240941", size = 324420, upload-time = "2025-02-19T13:55:15.292Z" }, + { url = "https://files.pythonhosted.org/packages/7f/65/feb83af28095397466e6e031989ff760cc89b01e7da169e76d4cf16a2252/py_rust_stemmers-0.1.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c52c5c326de78c70cfc71813fa56818d1bd4894264820d037d2be0e805b477bd", size = 324791, upload-time = "2025-02-19T13:55:16.45Z" }, + { url = "https://files.pythonhosted.org/packages/20/3e/162be2f9c1c383e66e510218d9d4946c8a84ee92c64f6d836746540e915f/py_rust_stemmers-0.1.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8f374c0f26ef35fb87212686add8dff394bcd9a1364f14ce40fe11504e25e30", size = 488014, upload-time = "2025-02-19T13:55:18.486Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ee/ed09ce6fde1eefe50aa13a8a8533aa7ebe3cc096d1a43155cc71ba28d298/py_rust_stemmers-0.1.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0ae0540453843bc36937abb54fdbc0d5d60b51ef47aa9667afd05af9248e09eb", size = 575581, upload-time = "2025-02-19T13:55:19.669Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/2a48960a072e54d7cc244204d98854d201078e1bb5c68a7843a3f6d21ced/py_rust_stemmers-0.1.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85944262c248ea30444155638c9e148a3adc61fe51cf9a3705b4055b564ec95d", size = 493269, upload-time = "2025-02-19T13:55:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/91/33/872269c10ca35b00c5376159a2a0611a0f96372be16b616b46b3d59d09fe/py_rust_stemmers-0.1.5-cp311-none-win_amd64.whl", hash = "sha256:147234020b3eefe6e1a962173e41d8cf1dbf5d0689f3cd60e3022d1ac5c2e203", size = 209399, upload-time = "2025-02-19T13:55:22.639Z" }, + { url = "https://files.pythonhosted.org/packages/43/e1/ea8ac92454a634b1bb1ee0a89c2f75a4e6afec15a8412527e9bbde8c6b7b/py_rust_stemmers-0.1.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:29772837126a28263bf54ecd1bc709dd569d15a94d5e861937813ce51e8a6df4", size = 286085, upload-time = "2025-02-19T13:55:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/fe1cc3d36a19c1ce39792b1ed151ddff5ee1d74c8801f0e93ff36e65f885/py_rust_stemmers-0.1.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d62410ada44a01e02974b85d45d82f4b4c511aae9121e5f3c1ba1d0bea9126b", size = 272021, upload-time = "2025-02-19T13:55:25.685Z" }, + { url = "https://files.pythonhosted.org/packages/0a/38/b8f94e5e886e7ab181361a0911a14fb923b0d05b414de85f427e773bf445/py_rust_stemmers-0.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b28ef729a4c83c7d9418be3c23c0372493fcccc67e86783ff04596ef8a208cdf", size = 310547, upload-time = "2025-02-19T13:55:26.891Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/62e97652d359b75335486f4da134a6f1c281f38bd3169ed6ecfb276448c3/py_rust_stemmers-0.1.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a979c3f4ff7ad94a0d4cf566ca7bfecebb59e66488cc158e64485cf0c9a7879f", size = 315237, upload-time = "2025-02-19T13:55:28.116Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b9/fc0278432f288d2be4ee4d5cc80fd8013d604506b9b0503e8b8cae4ba1c3/py_rust_stemmers-0.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c3593d895453fa06bf70a7b76d6f00d06def0f91fc253fe4260920650c5e078", size = 324419, upload-time = "2025-02-19T13:55:29.211Z" }, + { url = "https://files.pythonhosted.org/packages/6b/5b/74e96eaf622fe07e83c5c389d101540e305e25f76a6d0d6fb3d9e0506db8/py_rust_stemmers-0.1.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:96ccc7fd042ffc3f7f082f2223bb7082ed1423aa6b43d5d89ab23e321936c045", size = 324792, upload-time = "2025-02-19T13:55:30.948Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f7/b76816d7d67166e9313915ad486c21d9e7da0ac02703e14375bb1cb64b5a/py_rust_stemmers-0.1.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef18cfced2c9c676e0d7d172ba61c3fab2aa6969db64cc8f5ca33a7759efbefe", size = 488014, upload-time = "2025-02-19T13:55:32.066Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ed/7d9bed02f78d85527501f86a867cd5002d97deb791b9a6b1b45b00100010/py_rust_stemmers-0.1.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:541d4b5aa911381e3d37ec483abb6a2cf2351b4f16d5e8d77f9aa2722956662a", size = 575582, upload-time = "2025-02-19T13:55:34.005Z" }, + { url = "https://files.pythonhosted.org/packages/93/40/eafd1b33688e8e8ae946d1ef25c4dc93f5b685bd104b9c5573405d7e1d30/py_rust_stemmers-0.1.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ffd946a36e9ac17ca96821963663012e04bc0ee94d21e8b5ae034721070b436c", size = 493267, upload-time = "2025-02-19T13:55:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6a/15135b69e4fd28369433eb03264d201b1b0040ba534b05eddeb02a276684/py_rust_stemmers-0.1.5-cp312-none-win_amd64.whl", hash = "sha256:6ed61e1207f3b7428e99b5d00c055645c6415bb75033bff2d06394cbe035fd8e", size = 209395, upload-time = "2025-02-19T13:55:36.519Z" }, + { url = "https://files.pythonhosted.org/packages/80/b8/030036311ec25952bf3083b6c105be5dee052a71aa22d5fbeb857ebf8c1c/py_rust_stemmers-0.1.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:398b3a843a9cd4c5d09e726246bc36f66b3d05b0a937996814e91f47708f5db5", size = 286086, upload-time = "2025-02-19T13:55:37.581Z" }, + { url = "https://files.pythonhosted.org/packages/ed/be/0465dcb3a709ee243d464e89231e3da580017f34279d6304de291d65ccb0/py_rust_stemmers-0.1.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4e308fc7687901f0c73603203869908f3156fa9c17c4ba010a7fcc98a7a1c5f2", size = 272019, upload-time = "2025-02-19T13:55:39.183Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b6/76ca5b1f30cba36835938b5d9abee0c130c81833d51b9006264afdf8df3c/py_rust_stemmers-0.1.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9efc4da5e734bdd00612e7506de3d0c9b7abc4b89d192742a0569d0d1fe749", size = 310545, upload-time = "2025-02-19T13:55:40.339Z" }, + { url = "https://files.pythonhosted.org/packages/56/8f/5be87618cea2fe2e70e74115a20724802bfd06f11c7c43514b8288eb6514/py_rust_stemmers-0.1.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc2cc8d2b36bc05b8b06506199ac63d437360ae38caefd98cd19e479d35afd42", size = 315236, upload-time = "2025-02-19T13:55:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/00/02/ea86a316aee0f0a9d1449ad4dbffff38f4cf0a9a31045168ae8b95d8bdf8/py_rust_stemmers-0.1.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a231dc6f0b2a5f12a080dfc7abd9e6a4ea0909290b10fd0a4620e5a0f52c3d17", size = 324419, upload-time = "2025-02-19T13:55:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/1612c22545dcc0abe2f30fc08f30a2332f2224dd536fa1508444a9ca0e39/py_rust_stemmers-0.1.5-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5845709d48afc8b29e248f42f92431155a3d8df9ba30418301c49c6072b181b0", size = 324794, upload-time = "2025-02-19T13:55:43.896Z" }, + { url = "https://files.pythonhosted.org/packages/66/18/8a547584d7edac9e7ac9c7bdc53228d6f751c0f70a317093a77c386c8ddc/py_rust_stemmers-0.1.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e48bfd5e3ce9d223bfb9e634dc1425cf93ee57eef6f56aa9a7120ada3990d4be", size = 488014, upload-time = "2025-02-19T13:55:45.088Z" }, + { url = "https://files.pythonhosted.org/packages/3b/87/4619c395b325e26048a6e28a365afed754614788ba1f49b2eefb07621a03/py_rust_stemmers-0.1.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:35d32f6e7bdf6fd90e981765e32293a8be74def807147dea9fdc1f65d6ce382f", size = 575582, upload-time = "2025-02-19T13:55:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/98/6e/214f1a889142b7df6d716e7f3fea6c41e87bd6c29046aa57e175d452b104/py_rust_stemmers-0.1.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:191ea8bf922c984631ffa20bf02ef0ad7eec0465baeaed3852779e8f97c7e7a3", size = 493269, upload-time = "2025-02-19T13:55:49.057Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b9/c5185df277576f995ae34418eb2b2ac12f30835412270f9e05c52face521/py_rust_stemmers-0.1.5-cp313-none-win_amd64.whl", hash = "sha256:e564c9efdbe7621704e222b53bac265b0e4fbea788f07c814094f0ec6b80adcf", size = 209397, upload-time = "2025-02-19T13:55:50.853Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fa/796ba1ae243bac9bdcf89c7605d642d21e07ae4f6b77a3c968d546371353/py_rust_stemmers-0.1.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f8c6596f04e7a6df2a5cc18854d31b133d2a69a8c494fa49853fe174d8739d14", size = 286746, upload-time = "2025-02-19T13:56:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/4a/66/3c547373839d615217cd94c47ae1965366fa37642ef1bc4f8d32a5884a84/py_rust_stemmers-0.1.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:154c27f5d576fabf2bacf53620f014562af4c6cf9eb09ba7477830f2be868902", size = 272130, upload-time = "2025-02-19T13:56:25.114Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8f/381502753e8917e874daefad0000f61d6069dffaba91acbdb864a74cae10/py_rust_stemmers-0.1.5-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec42b66927b62fd57328980b6c7004fe85e8fad89c952e8718da68b805a119e3", size = 310955, upload-time = "2025-02-19T13:56:26.368Z" }, + { url = "https://files.pythonhosted.org/packages/3a/15/b1894b9741f7a48f0b4cbea458f7d4141a6df6a1b26bec05fcde96703ce1/py_rust_stemmers-0.1.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57b061c3b4af9e409d009d729b21bc53dabe47116c955ccf0b642a5a2d438f93", size = 324879, upload-time = "2025-02-19T13:56:27.462Z" }, +] + [[package]] name = "pyarrow" version = "20.0.0" @@ -4727,6 +4818,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/5e/8174c845707e60b60b65c58f01e40bbc1d8181b5ff6463f25df470509917/qdrant_client-1.14.3-py3-none-any.whl", hash = "sha256:66faaeae00f9b5326946851fe4ca4ddb1ad226490712e2f05142266f68dfc04d", size = 328969, upload-time = "2025-06-16T11:13:46.636Z" }, ] +[package.optional-dependencies] +fastembed = [ + { name = "fastembed" }, +] + [[package]] name = "referencing" version = "0.36.2" @@ -6366,6 +6462,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +] + [[package]] name = "wrapt" version = "1.17.2"