feat: qdrant generic client (#3377)
Some checks failed
Notify Downstream / notify-downstream (push) Has been cancelled

### 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
This commit is contained in:
Greyson LaLonde
2025-08-25 16:02:25 -04:00
committed by GitHub
parent c02997d956
commit 2e4bd3f49d
9 changed files with 1826 additions and 2 deletions

View File

@@ -68,6 +68,9 @@ docling = [
aisuite = [
"aisuite>=0.1.10",
]
qdrant = [
"qdrant-client[fastembed]>=1.14.3",
]
[tool.uv]
dev-dependencies = [

View File

@@ -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)

View File

@@ -0,0 +1 @@
"""Qdrant vector database client implementation."""

View File

@@ -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)

View File

@@ -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)

View File

@@ -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]

View File

@@ -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},
)

View File

@@ -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()

109
uv.lock generated
View File

@@ -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"