diff --git a/examples/serverless_client.py b/examples/serverless_client.py new file mode 100644 index 000000000..bb7366420 --- /dev/null +++ b/examples/serverless_client.py @@ -0,0 +1,52 @@ +""" +Example of using the Qdrant Serverless client. + +**In development — do not use yet.** This client is experimental and unstable; +it may change without notice and is not ready for production or general use. + +Collection management uses the simplified serverless API; point operations +(query, upsert, ...) work exactly like in the regular client. +""" + +from qdrant_client.models import PointStruct +from qdrant_client.serverless.models import DenseVectorConfig, Distance, KeywordIndex +from qdrant_client.serverless import QdrantServerless + + +def main() -> None: + client = QdrantServerless( + url="https://serverless.plush-volt.aws.development-cloud.qdrant.io", + api_key="", + ) + + # make the example rerunnable: creating an existing collection raises ALREADY_EXISTS + if client.collection_exists("my-collection"): + client.delete_collection("my-collection") + + # serverless-specific collection management: no quantization, wal, + # segment number etc. - the serverless manager decides those + print(client.create_collection( + "my-collection", + dense_vectors=DenseVectorConfig(size=4, distance=Distance.COSINE), + payload_indexes={"color": KeywordIndex()}, + )) + + print(client.get_collections()) + print(client.get_collection("my-collection")) + + # point operations, same as in the regular client + client.upsert( + "my-collection", + points=[ + PointStruct(id=1, vector=[0.1, 0.2, 0.3, 0.4], payload={"color": "red"}), + PointStruct(id=2, vector=[0.4, 0.3, 0.2, 0.1], payload={"color": "blue"}), + ], + ) + print(client.query_points("my-collection", query=[0.1, 0.2, 0.3, 0.4])) + + client.delete_collection("my-collection") + client.close() + + +if __name__ == "__main__": + main() diff --git a/mypy.ini b/mypy.ini index 8d3d00881..58bbc2ef0 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,4 +1,4 @@ [mypy] ignore_missing_imports = True follow_imports = skip -exclude = qdrant_client/grpc|qdrant_client/http|tests|venv +exclude = qdrant_client/grpc|qdrant_client/serverless/grpc|qdrant_client/http|tests|venv|examples diff --git a/qdrant_client/serverless/__init__.py b/qdrant_client/serverless/__init__.py new file mode 100644 index 000000000..2e8b104be --- /dev/null +++ b/qdrant_client/serverless/__init__.py @@ -0,0 +1,28 @@ +"""Client for Qdrant Serverless. + +**In development — do not use yet.** This API is experimental and unstable; +it may change without notice and is not ready for production or general use. + +Point-level operations (query, upsert, ...) behave like the regular client; +collection management uses the simplified, tenant-facing serverless API. + +Usage: + + from qdrant_client.serverless import QdrantServerless + from qdrant_client.serverless.models import DenseVectorConfig, Distance + + client = QdrantServerless(url="https://...", api_key="...") + client.create_collection( + "my-collection", + dense_vectors=DenseVectorConfig(size=1536, distance=Distance.COSINE), + ) + client.query_points("my-collection", query=[0.1, 0.2, ...]) +""" + +from qdrant_client.serverless.async_client import AsyncQdrantServerless +from qdrant_client.serverless.client import QdrantServerless + +__all__ = [ + "AsyncQdrantServerless", + "QdrantServerless", +] diff --git a/qdrant_client/serverless/async_client.py b/qdrant_client/serverless/async_client.py new file mode 100644 index 000000000..08e4fa243 --- /dev/null +++ b/qdrant_client/serverless/async_client.py @@ -0,0 +1,895 @@ +# ****** WARNING: THIS FILE IS AUTOGENERATED ****** +# +# This file is autogenerated. Do not edit it manually. +# To regenerate this file, use +# +# ``` +# bash -x tools/generate_async_client.sh +# ``` +# +# ****** WARNING: THIS FILE IS AUTOGENERATED ****** + +"""Client for Qdrant Serverless. + +**In development — do not use yet.** This API is experimental and unstable; +it may change without notice and is not ready for production or general use. + +Serverless exposes the same point-level API as a regular Qdrant cluster (minus +read consistency, shard selection, write ordering and filtered updates), but a +much simpler, tenant-facing collection management API. Point operations are +delegated to the regular gRPC client; collection operations talk to the +serverless CollectionsService. +""" + +from copy import deepcopy +from typing import Any, Optional, Sequence +from qdrant_client.conversions import common_types as types +from qdrant_client.qdrant_fastembed import QdrantFastembedMixin +from qdrant_client.async_qdrant_remote import AsyncQdrantRemote +from qdrant_client.serverless import models as serverless_models +from qdrant_client.serverless.conversions import ( + collection_config_from_grpc, + collection_config_to_grpc, +) +from qdrant_client.serverless.grpc import serverless_collections_pb2 as pb2 +from qdrant_client.serverless.grpc.serverless_collections_pb2_grpc import CollectionsServiceStub + +DEFAULT_SERVERLESS_GRPC_PORT = 443 + + +class AsyncQdrantServerless: + """Entry point to a Qdrant Serverless space. + + **In development — do not use yet.** This client is experimental and unstable; + the API may change without notice and is not ready for production or general use. + + Point operations behave like in the regular `QdrantClient`, except that + parameters serverless does not support (read consistency, shard selection, + write ordering, filtered updates) are not available. Collection management + uses the simplified serverless API: only the tenant-facing configuration is + exposed, storage internals (quantization, WAL, segments, ...) are decided + by the serverless manager. + + Examples: + + >>> client = AsyncQdrantServerless( + ... url="https://serverless.example.cloud.qdrant.io", + ... api_key="", + ... ) + >>> await client.create_collection( + ... "my-collection", + ... dense_vectors=DenseVectorConfig(size=1536, distance=Distance.COSINE), + ... ) + + Args: + url: Base url of the serverless space, + e.g. `https://serverless.example.cloud.qdrant.io` + api_key: API key of the serverless space, + sent as `api-key` metadata with every request + grpc_port: Port of the gRPC interface. Default: 443 + timeout: Timeout for gRPC requests in seconds. Default: 5 seconds + grpc_options: Additional low-level gRPC channel options + """ + + def __init__( + self, + url: str, + api_key: Optional[str] = None, + grpc_port: int = DEFAULT_SERVERLESS_GRPC_PORT, + timeout: Optional[int] = None, + grpc_options: Optional[dict[str, Any]] = None, + **kwargs: Any, + ): + self._remote = AsyncQdrantRemote( + url=url, + api_key=api_key, + grpc_port=grpc_port, + prefer_grpc=True, + timeout=timeout, + grpc_options=grpc_options, + check_compatibility=False, + **kwargs, + ) + self._grpc_collections: Optional[CollectionsServiceStub] = None + + @property + def _collections(self) -> CollectionsServiceStub: + if self._grpc_collections is None: + self._remote._init_grpc_channel() + self._grpc_collections = CollectionsServiceStub(self._remote._grpc_channel_pool[0]) + assert self._grpc_collections is not None + return self._grpc_collections + + def _collections_timeout(self, timeout: Optional[int]) -> int: + return timeout if timeout is not None else self._remote._timeout + + async def close(self, grpc_grace: Optional[float] = None, **kwargs: Any) -> None: + """Closes the underlying gRPC connections. + + The client is unusable afterwards; create a new instance to reconnect. + + Args: + grpc_grace: Grace period for gRPC connection teardown in seconds. + If `None` - close immediately, cancelling active calls. + """ + self._grpc_collections = None + await self._remote.close(grpc_grace=grpc_grace, **kwargs) + + async def create_collection( + self, + collection_name: str, + dense_vectors: serverless_models.DenseVectorConfig + | dict[str, serverless_models.DenseVectorConfig] + | None = None, + sparse_vectors: serverless_models.SparseVectorConfig + | dict[str, serverless_models.SparseVectorConfig] + | None = None, + payload_indexes: dict[str, serverless_models.PayloadIndex] | None = None, + timeout: Optional[int] = None, + ) -> str: + """Creates a collection with the given tenant-facing configuration. + + At least one dense or sparse vector is required. Unlike the regular + client, no storage internals (quantization, WAL, segment number, ...) + can be configured: the serverless manager decides those. + + Args: + collection_name: Name of the collection to create + dense_vectors: + Dense (embedding) vectors of the collection. + - If `DenseVectorConfig` - register as the single unnamed + default vector, like in regular qdrant. + - If `dict` - one config per vector name. + sparse_vectors: + Sparse vectors of the collection. + - If `SparseVectorConfig` - register as the single unnamed + default vector. + - If `dict` - one config per vector name. + payload_indexes: + Payload indexes to create, keyed by payload field name + (JSON path, e.g. `user_id` or `meta.tags`). Only the kind of + filter the field supports is chosen (e.g. `KeywordIndex()`, + `TextIndex(tokenizer=...)`); index placement is decided by the + serverless manager. Serverless does not support changing + payload indexes after creation. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Outcome of the operation, e.g. `"created"` + + Raises: + grpc.RpcError: with `StatusCode.ALREADY_EXISTS` if the collection already exists + """ + if isinstance(dense_vectors, serverless_models.DenseVectorConfig): + dense_vectors = {"": dense_vectors} + if isinstance(sparse_vectors, serverless_models.SparseVectorConfig): + sparse_vectors = {"": sparse_vectors} + config = serverless_models.CollectionConfig( + dense_vectors=dense_vectors or {}, + sparse_vectors=sparse_vectors or {}, + payload_indexes=payload_indexes or {}, + ) + response = await self._collections.CreateCollection( + pb2.CreateCollectionRequest( + collection_name=collection_name, config=collection_config_to_grpc(config) + ), + timeout=self._collections_timeout(timeout), + ) + return response.result + + async def delete_collection(self, collection_name: str, timeout: Optional[int] = None) -> bool: + """Deletes a collection and all of its data. + + Args: + collection_name: Name of the collection to delete + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + `True` if the collection existed and was deleted, `False` if there + was no such collection + """ + response = await self._collections.DeleteCollection( + pb2.DeleteCollectionRequest(collection_name=collection_name), + timeout=self._collections_timeout(timeout), + ) + return response.deleted + + async def get_collection( + self, collection_name: str, timeout: Optional[int] = None + ) -> serverless_models.CollectionInfo: + """Returns a collection's configuration and stats. + + Unlike the regular client, does not raise if the collection is + missing: check the `exists` field of the result. The returned config + is the tenant-facing configuration the collection was created with; + collection internals (segment number, optimizer status, ...) are not + exposed by serverless. + + Args: + collection_name: Name of the collection to fetch + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + `CollectionInfo` with `exists`, the creation-time `config` and an + eventually consistent `point_count` (absent until stats have been + written for the collection) + """ + response = await self._collections.GetCollection( + pb2.GetCollectionRequest(collection_name=collection_name), + timeout=self._collections_timeout(timeout), + ) + return serverless_models.CollectionInfo( + exists=response.exists, + config=collection_config_from_grpc(response.config) + if response.HasField("config") + else None, + point_count=response.point_count if response.HasField("point_count") else None, + ) + + async def collection_exists(self, collection_name: str, timeout: Optional[int] = None) -> bool: + """Checks whether a collection exists. + + Args: + collection_name: Name of the collection to check + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + `True` if the collection exists, `False` otherwise + """ + return (await self.get_collection(collection_name, timeout=timeout)).exists + + async def get_collections( + self, + limit: Optional[int] = None, + offset_token: Optional[str] = None, + timeout: Optional[int] = None, + ) -> serverless_models.CollectionsList: + """Lists a page of collections in the space. + + Args: + limit: Maximum number of collections to return. Defaults to 20 + (server-side) and must not exceed 100. + offset_token: Opaque token from a previous response's + `next_offset_token` to fetch the next page. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + A page of collection summaries (name and eventually consistent + point count) plus an optional `next_offset_token`. + """ + request = pb2.ListCollectionsRequest() + if limit is not None: + request.limit = limit + if offset_token is not None: + request.offset_token = offset_token + response = await self._collections.ListCollections( + request, timeout=self._collections_timeout(timeout) + ) + return serverless_models.CollectionsList( + collections=[ + serverless_models.CollectionSummary( + collection_name=collection.collection_name, + point_count=collection.point_count + if collection.HasField("point_count") + else None, + ) + for collection in response.collections + ], + next_offset_token=response.next_offset_token + if response.HasField("next_offset_token") + else None, + ) + + async def query_points( + self, + collection_name: str, + query: types.PointId + | list[float] + | list[list[float]] + | types.SparseVector + | types.Query + | types.NumpyArray + | types.Document + | types.Image + | types.InferenceObject + | None = None, + using: Optional[str] = None, + prefetch: types.Prefetch | list[types.Prefetch] | None = None, + query_filter: Optional[types.Filter] = None, + search_params: Optional[types.SearchParams] = None, + limit: int = 10, + offset: Optional[int] = None, + with_payload: bool | Sequence[str] | types.PayloadSelector = True, + with_vectors: bool | Sequence[str] = False, + score_threshold: Optional[float] = None, + timeout: Optional[int] = None, + ) -> types.QueryResponse: + """Universal endpoint to run any available operation, such as search, + recommendation, discovery, context search. Same as in the regular + client, minus `consistency`, `shard_key_selector` and `lookup_from`, + which serverless does not support. + + Args: + collection_name: Collection to search in + query: + Query for the chosen search type operation. + - If `str` - use string as UUID of the existing point as a search query. + - If `int` - use integer as ID of the existing point as a search query. + - If `list[float]` - use as a dense vector for nearest search. + - If `list[list[float]]` - use as a multi-vector for nearest search. + - If `SparseVector` - use as a sparse vector for nearest search. + - If `Query` - use as a query for specific search type. + - If `NumpyArray` - use as a dense vector for nearest search. + - If `Document` - the server infers the vector from the document text + (serverless performs no client-side embedding inference). + - If `None` - return first `limit` points from the collection. + using: + Name of the vectors to use for query. + If `None` - use default vectors or provided in named vector structures. + prefetch: Prefetch queries to make a selection of the data to be used with the main query + query_filter: + - Exclude vectors which doesn't fit given conditions. + - If `None` - search among all vectors + search_params: Additional search params + limit: How many results return + offset: + Offset of the first result to return. + May be used to paginate results. + Note: large offset values may cause performance issues. + with_payload: + - Specify which stored payload should be attached to the result. + - If `True` - attach all payload + - If `False` - do not attach any payload + - If List of string - include only specified fields + - If `PayloadSelector` - use explicit rules + with_vectors: + - If `True` - Attach stored vector to the search result. + - If `False` - Do not attach vector. + - If List of string - include only specified fields + - Default: `False` + score_threshold: + Define a minimal score threshold for the result. + If defined, less similar results will not be returned. + Score of the returned result might be higher or smaller than the threshold depending + on the Distance function used. + E.g. for cosine similarity only higher scores will be returned. + timeout: Overrides global timeout for this search. Unit is seconds. + + Returns: + QueryResponse structure containing list of found close points with similarity scores + """ + query = QdrantFastembedMixin._resolve_query(query) + return await self._remote.query_points( + collection_name=collection_name, + query=query, + using=using, + prefetch=prefetch, + query_filter=query_filter, + search_params=search_params, + limit=limit, + offset=offset, + with_payload=with_payload, + with_vectors=with_vectors, + score_threshold=score_threshold, + timeout=timeout, + ) + + async def query_batch_points( + self, + collection_name: str, + requests: Sequence[types.QueryRequest], + timeout: Optional[int] = None, + ) -> list[types.QueryResponse]: + """Performs several queries in one request, same as in the regular + client, minus `consistency`, which serverless does not support. + + Args: + collection_name: Name of the collection + requests: List of query requests + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + List of query responses, in the same order as the requests + """ + resolved_requests = [] + for request in requests: + request = deepcopy(request) + request.query = QdrantFastembedMixin._resolve_query(request.query) + resolved_requests.append(request) + return await self._remote.query_batch_points( + collection_name=collection_name, requests=resolved_requests, timeout=timeout + ) + + async def query_points_groups( + self, + collection_name: str, + group_by: str, + query: types.PointId + | list[float] + | list[list[float]] + | types.SparseVector + | types.Query + | types.NumpyArray + | types.Document + | types.Image + | types.InferenceObject + | None = None, + using: Optional[str] = None, + prefetch: types.Prefetch | list[types.Prefetch] | None = None, + query_filter: Optional[types.Filter] = None, + search_params: Optional[types.SearchParams] = None, + limit: int = 10, + group_size: int = 3, + with_payload: bool | Sequence[str] | types.PayloadSelector = True, + with_vectors: bool | Sequence[str] = False, + score_threshold: Optional[float] = None, + timeout: Optional[int] = None, + ) -> types.GroupsResult: + """Universal endpoint to run any available operation and group results + by a payload field. Same as in the regular client, minus `consistency`, + `shard_key_selector` and the cross-collection lookups (`lookup_from`, + `with_lookup`), which serverless does not support. + + Args: + collection_name: Collection to search in + group_by: Payload field to group by; supports dot notation for + nested fields + query: Query for the chosen search type operation, same forms as in + `query_points` + using: + Name of the vectors to use for query. + If `None` - use default vectors or provided in named vector structures. + prefetch: Prefetch queries to make a selection of the data to be used with the main query + query_filter: + - Exclude vectors which doesn't fit given conditions. + - If `None` - search among all vectors + search_params: Additional search params + limit: How many groups return + group_size: How many results return for a single group + with_payload: + - Specify which stored payload should be attached to the result. + - If `True` - attach all payload + - If `False` - do not attach any payload + - If List of string - include only specified fields + - If `PayloadSelector` - use explicit rules + with_vectors: + - If `True` - Attach stored vector to the search result. + - If `False` - Do not attach vector. + - If List of string - include only specified fields + - Default: `False` + score_threshold: + Define a minimal score threshold for the result. + If defined, less similar results will not be returned. + timeout: Overrides global timeout for this search. Unit is seconds. + + Returns: + List of groups with not more than `group_size` hits in each group + """ + query = QdrantFastembedMixin._resolve_query(query) + return await self._remote.query_points_groups( + collection_name=collection_name, + group_by=group_by, + query=query, + using=using, + prefetch=prefetch, + query_filter=query_filter, + search_params=search_params, + limit=limit, + group_size=group_size, + with_payload=with_payload, + with_vectors=with_vectors, + score_threshold=score_threshold, + timeout=timeout, + ) + + async def retrieve( + self, + collection_name: str, + ids: Sequence[types.PointId], + with_payload: bool | Sequence[str] | types.PayloadSelector = True, + with_vectors: bool | Sequence[str] = False, + timeout: Optional[int] = None, + ) -> list[types.Record]: + """Retrieves points by ids. + + Args: + collection_name: Name of the collection to retrieve from + ids: List of ids to retrieve + with_payload: + - Specify which stored payload should be attached to the result. + - If `True` - attach all payload + - If `False` - do not attach any payload + - If List of string - include only specified fields + - If `PayloadSelector` - use explicit rules + with_vectors: + - If `True` - Attach stored vector to the search result. + - If `False` - Do not attach vector. + - If List of string - include only specified fields + - Default: `False` + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + List of points. Order of the points is not guaranteed; + ids that do not exist are silently skipped. + """ + return await self._remote.retrieve( + collection_name=collection_name, + ids=ids, + with_payload=with_payload, + with_vectors=with_vectors, + timeout=timeout, + ) + + async def scroll( + self, + collection_name: str, + scroll_filter: Optional[types.Filter] = None, + limit: int = 10, + order_by: Optional[types.OrderBy] = None, + offset: Optional[types.PointId] = None, + with_payload: bool | Sequence[str] | types.PayloadSelector = True, + with_vectors: bool | Sequence[str] = False, + timeout: Optional[int] = None, + ) -> tuple[list[types.Record], Optional[types.PointId]]: + """Scrolls over all points, optionally filtered. + + This method provides a way to iterate over all stored points with some + optional filtering condition. Scroll does not apply any similarity + estimations, it will return points sorted by id in ascending order. + + Args: + collection_name: Name of the collection to scroll + scroll_filter: If provided - only returns points matching the filtering conditions + limit: How many points to return + order_by: Order the records by a payload key. If `None` - order by id. + Requires a range-capable payload index on the key. + offset: If provided - skip points with ids less than given `offset` + with_payload: + - Specify which stored payload should be attached to the result. + - If `True` - attach all payload + - If `False` - do not attach any payload + - If List of string - include only specified fields + - If `PayloadSelector` - use explicit rules + with_vectors: + - If `True` - Attach stored vector to the search result. + - If `False` - Do not attach vector. + - If List of string - include only specified fields + - Default: `False` + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + A pair of (List of points) and (optional offset of the next scroll request). + If the next offset is `None` - there are no more points to scroll. + """ + return await self._remote.scroll( + collection_name=collection_name, + scroll_filter=scroll_filter, + limit=limit, + order_by=order_by, + offset=offset, + with_payload=with_payload, + with_vectors=with_vectors, + timeout=timeout, + ) + + async def count( + self, + collection_name: str, + count_filter: Optional[types.Filter] = None, + exact: bool = True, + timeout: Optional[int] = None, + ) -> types.CountResult: + """Counts points in the collection. + + Counts points matching the filtering conditions, or all points if no + filter is given. + + Args: + collection_name: Name of the collection to count points in + count_filter: Filtering conditions + exact: + - If `True` - provide the exact count of points matching the filter. + - If `False` - provide the approximate count of points matching the filter. + Works faster. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Amount of points in the collection matching the filter + """ + return await self._remote.count( + collection_name=collection_name, + count_filter=count_filter, + exact=exact, + timeout=timeout, + ) + + async def upsert( + self, + collection_name: str, + points: types.Points, + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Updates or inserts points into the collection. + + If a point with a given ID already exists - it will be overwritten. + Same as in the regular client, minus `ordering`, `shard_key_selector`, + `update_filter` and `update_mode`, which serverless does not support. + + Args: + collection_name: To which collection to insert + points: Batch or list of points to insert + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return await self._remote.upsert( + collection_name=collection_name, points=points, wait=wait, timeout=timeout + ) + + async def update_vectors( + self, + collection_name: str, + points: Sequence[types.PointVectors], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Updates specified vectors of the given points, keeping payload and + the remaining vectors untouched. + + Args: + collection_name: Name of the collection to update vectors in + points: List of (id, vector) pairs to update + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return await self._remote.update_vectors( + collection_name=collection_name, points=points, wait=wait, timeout=timeout + ) + + async def delete_vectors( + self, + collection_name: str, + vectors: Sequence[str], + points: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Removes the given named vectors from the selected points, keeping + the points themselves. + + Selection is currently limited to explicit ids. Once serverless + supports filtered updates, this parameter will also accept + filter-based selectors (a non-breaking type widening). + + Args: + collection_name: Name of the collection to delete vectors from + vectors: List of vector names to delete; use `""` for the unnamed + default vector + points: List of ids of the points to modify + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return await self._remote.delete_vectors( + collection_name=collection_name, + vectors=vectors, + points=list(points), + wait=wait, + timeout=timeout, + ) + + async def overwrite_payload( + self, + collection_name: str, + payload: types.Payload, + points: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Replaces the entire payload of the selected points with the given payload. + + Unlike `set_payload`, existing keys not present in the new payload are + removed. Selection is currently limited to explicit ids. Once + serverless supports filtered updates, this parameter will also accept + filter-based selectors (a non-breaking type widening). + + Args: + collection_name: Name of the collection to overwrite payload in + payload: Key-value pairs of payload to assign + points: List of ids of the points to modify + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return await self._remote.overwrite_payload( + collection_name=collection_name, + payload=payload, + points=list(points), + wait=wait, + timeout=timeout, + ) + + async def clear_payload( + self, + collection_name: str, + points: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Removes the entire payload of the selected points. + + Selection is currently limited to explicit ids. Once serverless + supports filtered updates, this parameter will also accept + filter-based selectors (a non-breaking type widening). + + Args: + collection_name: Name of the collection to clear payload in + points: List of ids of the points to modify + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return await self._remote.clear_payload( + collection_name=collection_name, + points_selector=list(points), + wait=wait, + timeout=timeout, + ) + + async def batch_update_points( + self, + collection_name: str, + update_operations: Sequence[types.UpdateOperation], + wait: bool = False, + timeout: Optional[int] = None, + ) -> list[types.UpdateResult]: + """Performs a batch of point update operations in one request. + + Operations with filter-based selectors are rejected by the serverless + service; select points by explicit ids inside each operation. + + Args: + collection_name: Name of the collection to update + update_operations: List of update operations (upsert, delete, + set/overwrite/delete/clear payload, update/delete vectors) + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + List of operation results, one per operation + """ + return await self._remote.batch_update_points( + collection_name=collection_name, + update_operations=update_operations, + wait=wait, + timeout=timeout, + ) + + async def delete( + self, + collection_name: str, + points: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Deletes selected points. + + Selection is currently limited to explicit ids. Once serverless + supports filtered updates, this parameter will also accept + filter-based selectors (a non-breaking type widening). + + Args: + collection_name: Deletes points from this collection + points: List of ids of the points to delete + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return await self._remote.delete( + collection_name=collection_name, + points_selector=list(points), + wait=wait, + timeout=timeout, + ) + + async def set_payload( + self, + collection_name: str, + payload: types.Payload, + points: Sequence[types.PointId], + key: Optional[str] = None, + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Modifies payload of the selected points. + + Only the given payload values are merged into the stored payload; + other existing keys stay untouched. Selection is currently limited to + explicit ids. Once serverless supports filtered updates, this + parameter will also accept filter-based selectors (a non-breaking + type widening). + + Args: + collection_name: Name of the collection to set payload in + payload: Key-value pairs of payload to assign + points: List of ids of the points to modify + key: Path to the nested field in the payload to modify. + If `None` - modify the root of the payload. + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return await self._remote.set_payload( + collection_name=collection_name, + payload=payload, + points=list(points), + key=key, + wait=wait, + timeout=timeout, + ) + + async def delete_payload( + self, + collection_name: str, + keys: Sequence[str], + points: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Removes the given payload keys from the selected points. + + Selection is currently limited to explicit ids. Once serverless + supports filtered updates, this parameter will also accept + filter-based selectors (a non-breaking type widening). + + Args: + collection_name: Name of the collection to delete payload from + keys: List of payload keys to remove + points: List of ids of the points to modify + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return await self._remote.delete_payload( + collection_name=collection_name, + keys=keys, + points=list(points), + wait=wait, + timeout=timeout, + ) diff --git a/qdrant_client/serverless/client.py b/qdrant_client/serverless/client.py new file mode 100644 index 000000000..5f1bb0f0a --- /dev/null +++ b/qdrant_client/serverless/client.py @@ -0,0 +1,911 @@ +"""Client for Qdrant Serverless. + +**In development — do not use yet.** This API is experimental and unstable; +it may change without notice and is not ready for production or general use. + +Serverless exposes the same point-level API as a regular Qdrant cluster (minus +read consistency, shard selection, write ordering and filtered updates), but a +much simpler, tenant-facing collection management API. Point operations are +delegated to the regular gRPC client; collection operations talk to the +serverless CollectionsService. +""" + +from copy import deepcopy +from typing import Any, Optional, Sequence + +from qdrant_client.conversions import common_types as types +from qdrant_client.qdrant_fastembed import QdrantFastembedMixin +from qdrant_client.qdrant_remote import QdrantRemote +from qdrant_client.serverless import models as serverless_models +from qdrant_client.serverless.conversions import ( + collection_config_from_grpc, + collection_config_to_grpc, +) +from qdrant_client.serverless.grpc import serverless_collections_pb2 as pb2 +from qdrant_client.serverless.grpc.serverless_collections_pb2_grpc import CollectionsServiceStub + +# Serverless is exposed on the standard TLS port, not on qdrant's 6334. +DEFAULT_SERVERLESS_GRPC_PORT = 443 + + +class QdrantServerless: + """Entry point to a Qdrant Serverless space. + + **In development — do not use yet.** This client is experimental and unstable; + the API may change without notice and is not ready for production or general use. + + Point operations behave like in the regular `QdrantClient`, except that + parameters serverless does not support (read consistency, shard selection, + write ordering, filtered updates) are not available. Collection management + uses the simplified serverless API: only the tenant-facing configuration is + exposed, storage internals (quantization, WAL, segments, ...) are decided + by the serverless manager. + + Examples: + + >>> client = QdrantServerless( + ... url="https://serverless.example.cloud.qdrant.io", + ... api_key="", + ... ) + >>> client.create_collection( + ... "my-collection", + ... dense_vectors=DenseVectorConfig(size=1536, distance=Distance.COSINE), + ... ) + + Args: + url: Base url of the serverless space, + e.g. `https://serverless.example.cloud.qdrant.io` + api_key: API key of the serverless space, + sent as `api-key` metadata with every request + grpc_port: Port of the gRPC interface. Default: 443 + timeout: Timeout for gRPC requests in seconds. Default: 5 seconds + grpc_options: Additional low-level gRPC channel options + """ + + def __init__( + self, + url: str, + api_key: Optional[str] = None, + grpc_port: int = DEFAULT_SERVERLESS_GRPC_PORT, + timeout: Optional[int] = None, + grpc_options: Optional[dict[str, Any]] = None, + **kwargs: Any, + ): + self._remote = QdrantRemote( + url=url, + api_key=api_key, + grpc_port=grpc_port, + prefer_grpc=True, + timeout=timeout, + grpc_options=grpc_options, + check_compatibility=False, + **kwargs, + ) + self._grpc_collections: Optional[CollectionsServiceStub] = None + + @property + def _collections(self) -> CollectionsServiceStub: + if self._grpc_collections is None: + # reuse the delegate's channel: same host, tls, api-key metadata and options + self._remote._init_grpc_channel() + self._grpc_collections = CollectionsServiceStub(self._remote._grpc_channel_pool[0]) + assert self._grpc_collections is not None + return self._grpc_collections + + def _collections_timeout(self, timeout: Optional[int]) -> int: + return timeout if timeout is not None else self._remote._timeout + + def close(self, grpc_grace: Optional[float] = None, **kwargs: Any) -> None: + """Closes the underlying gRPC connections. + + The client is unusable afterwards; create a new instance to reconnect. + + Args: + grpc_grace: Grace period for gRPC connection teardown in seconds. + If `None` - close immediately, cancelling active calls. + """ + self._grpc_collections = None + self._remote.close(grpc_grace=grpc_grace, **kwargs) + + # region collections + + def create_collection( + self, + collection_name: str, + dense_vectors: serverless_models.DenseVectorConfig + | dict[str, serverless_models.DenseVectorConfig] + | None = None, + sparse_vectors: serverless_models.SparseVectorConfig + | dict[str, serverless_models.SparseVectorConfig] + | None = None, + payload_indexes: dict[str, serverless_models.PayloadIndex] | None = None, + timeout: Optional[int] = None, + ) -> str: + """Creates a collection with the given tenant-facing configuration. + + At least one dense or sparse vector is required. Unlike the regular + client, no storage internals (quantization, WAL, segment number, ...) + can be configured: the serverless manager decides those. + + Args: + collection_name: Name of the collection to create + dense_vectors: + Dense (embedding) vectors of the collection. + - If `DenseVectorConfig` - register as the single unnamed + default vector, like in regular qdrant. + - If `dict` - one config per vector name. + sparse_vectors: + Sparse vectors of the collection. + - If `SparseVectorConfig` - register as the single unnamed + default vector. + - If `dict` - one config per vector name. + payload_indexes: + Payload indexes to create, keyed by payload field name + (JSON path, e.g. `user_id` or `meta.tags`). Only the kind of + filter the field supports is chosen (e.g. `KeywordIndex()`, + `TextIndex(tokenizer=...)`); index placement is decided by the + serverless manager. Serverless does not support changing + payload indexes after creation. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Outcome of the operation, e.g. `"created"` + + Raises: + grpc.RpcError: with `StatusCode.ALREADY_EXISTS` if the collection already exists + """ + if isinstance(dense_vectors, serverless_models.DenseVectorConfig): + dense_vectors = {"": dense_vectors} + if isinstance(sparse_vectors, serverless_models.SparseVectorConfig): + sparse_vectors = {"": sparse_vectors} + config = serverless_models.CollectionConfig( + dense_vectors=dense_vectors or {}, + sparse_vectors=sparse_vectors or {}, + payload_indexes=payload_indexes or {}, + ) + response = self._collections.CreateCollection( + pb2.CreateCollectionRequest( + collection_name=collection_name, + config=collection_config_to_grpc(config), + ), + timeout=self._collections_timeout(timeout), + ) + return response.result + + def delete_collection(self, collection_name: str, timeout: Optional[int] = None) -> bool: + """Deletes a collection and all of its data. + + Args: + collection_name: Name of the collection to delete + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + `True` if the collection existed and was deleted, `False` if there + was no such collection + """ + response = self._collections.DeleteCollection( + pb2.DeleteCollectionRequest(collection_name=collection_name), + timeout=self._collections_timeout(timeout), + ) + return response.deleted + + def get_collection( + self, collection_name: str, timeout: Optional[int] = None + ) -> serverless_models.CollectionInfo: + """Returns a collection's configuration and stats. + + Unlike the regular client, does not raise if the collection is + missing: check the `exists` field of the result. The returned config + is the tenant-facing configuration the collection was created with; + collection internals (segment number, optimizer status, ...) are not + exposed by serverless. + + Args: + collection_name: Name of the collection to fetch + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + `CollectionInfo` with `exists`, the creation-time `config` and an + eventually consistent `point_count` (absent until stats have been + written for the collection) + """ + response = self._collections.GetCollection( + pb2.GetCollectionRequest(collection_name=collection_name), + timeout=self._collections_timeout(timeout), + ) + return serverless_models.CollectionInfo( + exists=response.exists, + config=collection_config_from_grpc(response.config) + if response.HasField("config") + else None, + point_count=response.point_count if response.HasField("point_count") else None, + ) + + def collection_exists(self, collection_name: str, timeout: Optional[int] = None) -> bool: + """Checks whether a collection exists. + + Args: + collection_name: Name of the collection to check + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + `True` if the collection exists, `False` otherwise + """ + return self.get_collection(collection_name, timeout=timeout).exists + + def get_collections( + self, + limit: Optional[int] = None, + offset_token: Optional[str] = None, + timeout: Optional[int] = None, + ) -> serverless_models.CollectionsList: + """Lists a page of collections in the space. + + Args: + limit: Maximum number of collections to return. Defaults to 20 + (server-side) and must not exceed 100. + offset_token: Opaque token from a previous response's + `next_offset_token` to fetch the next page. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + A page of collection summaries (name and eventually consistent + point count) plus an optional `next_offset_token`. + """ + request = pb2.ListCollectionsRequest() + if limit is not None: + request.limit = limit + if offset_token is not None: + request.offset_token = offset_token + response = self._collections.ListCollections( + request, + timeout=self._collections_timeout(timeout), + ) + return serverless_models.CollectionsList( + collections=[ + serverless_models.CollectionSummary( + collection_name=collection.collection_name, + point_count=collection.point_count + if collection.HasField("point_count") + else None, + ) + for collection in response.collections + ], + next_offset_token=response.next_offset_token + if response.HasField("next_offset_token") + else None, + ) + + # endregion + + # region points + # Same semantics as the regular client, minus parameters serverless does not + # support: read consistency, shard selection, write ordering, filtered updates. + + def query_points( + self, + collection_name: str, + query: types.PointId + | list[float] + | list[list[float]] + | types.SparseVector + | types.Query + | types.NumpyArray + | types.Document + | types.Image + | types.InferenceObject + | None = None, + using: Optional[str] = None, + prefetch: types.Prefetch | list[types.Prefetch] | None = None, + query_filter: Optional[types.Filter] = None, + search_params: Optional[types.SearchParams] = None, + limit: int = 10, + offset: Optional[int] = None, + with_payload: bool | Sequence[str] | types.PayloadSelector = True, + with_vectors: bool | Sequence[str] = False, + score_threshold: Optional[float] = None, + timeout: Optional[int] = None, + ) -> types.QueryResponse: + """Universal endpoint to run any available operation, such as search, + recommendation, discovery, context search. Same as in the regular + client, minus `consistency`, `shard_key_selector` and `lookup_from`, + which serverless does not support. + + Args: + collection_name: Collection to search in + query: + Query for the chosen search type operation. + - If `str` - use string as UUID of the existing point as a search query. + - If `int` - use integer as ID of the existing point as a search query. + - If `list[float]` - use as a dense vector for nearest search. + - If `list[list[float]]` - use as a multi-vector for nearest search. + - If `SparseVector` - use as a sparse vector for nearest search. + - If `Query` - use as a query for specific search type. + - If `NumpyArray` - use as a dense vector for nearest search. + - If `Document` - the server infers the vector from the document text + (serverless performs no client-side embedding inference). + - If `None` - return first `limit` points from the collection. + using: + Name of the vectors to use for query. + If `None` - use default vectors or provided in named vector structures. + prefetch: Prefetch queries to make a selection of the data to be used with the main query + query_filter: + - Exclude vectors which doesn't fit given conditions. + - If `None` - search among all vectors + search_params: Additional search params + limit: How many results return + offset: + Offset of the first result to return. + May be used to paginate results. + Note: large offset values may cause performance issues. + with_payload: + - Specify which stored payload should be attached to the result. + - If `True` - attach all payload + - If `False` - do not attach any payload + - If List of string - include only specified fields + - If `PayloadSelector` - use explicit rules + with_vectors: + - If `True` - Attach stored vector to the search result. + - If `False` - Do not attach vector. + - If List of string - include only specified fields + - Default: `False` + score_threshold: + Define a minimal score threshold for the result. + If defined, less similar results will not be returned. + Score of the returned result might be higher or smaller than the threshold depending + on the Distance function used. + E.g. for cosine similarity only higher scores will be returned. + timeout: Overrides global timeout for this search. Unit is seconds. + + Returns: + QueryResponse structure containing list of found close points with similarity scores + """ + # Type resolution only (e.g. a raw list becomes NearestQuery) - no client-side + # embedding inference: Document/Image inputs go to the server as-is, serverless + # inference is server-side only. + query = QdrantFastembedMixin._resolve_query(query) + return self._remote.query_points( + collection_name=collection_name, + query=query, + using=using, + prefetch=prefetch, + query_filter=query_filter, + search_params=search_params, + limit=limit, + offset=offset, + with_payload=with_payload, + with_vectors=with_vectors, + score_threshold=score_threshold, + timeout=timeout, + ) + + def query_batch_points( + self, + collection_name: str, + requests: Sequence[types.QueryRequest], + timeout: Optional[int] = None, + ) -> list[types.QueryResponse]: + """Performs several queries in one request, same as in the regular + client, minus `consistency`, which serverless does not support. + + Args: + collection_name: Name of the collection + requests: List of query requests + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + List of query responses, in the same order as the requests + """ + resolved_requests = [] + for request in requests: + # Type resolution only, as in query_points - no client-side inference. + request = deepcopy(request) + request.query = QdrantFastembedMixin._resolve_query(request.query) + resolved_requests.append(request) + return self._remote.query_batch_points( + collection_name=collection_name, + requests=resolved_requests, + timeout=timeout, + ) + + def query_points_groups( + self, + collection_name: str, + group_by: str, + query: types.PointId + | list[float] + | list[list[float]] + | types.SparseVector + | types.Query + | types.NumpyArray + | types.Document + | types.Image + | types.InferenceObject + | None = None, + using: Optional[str] = None, + prefetch: types.Prefetch | list[types.Prefetch] | None = None, + query_filter: Optional[types.Filter] = None, + search_params: Optional[types.SearchParams] = None, + limit: int = 10, + group_size: int = 3, + with_payload: bool | Sequence[str] | types.PayloadSelector = True, + with_vectors: bool | Sequence[str] = False, + score_threshold: Optional[float] = None, + timeout: Optional[int] = None, + ) -> types.GroupsResult: + """Universal endpoint to run any available operation and group results + by a payload field. Same as in the regular client, minus `consistency`, + `shard_key_selector` and the cross-collection lookups (`lookup_from`, + `with_lookup`), which serverless does not support. + + Args: + collection_name: Collection to search in + group_by: Payload field to group by; supports dot notation for + nested fields + query: Query for the chosen search type operation, same forms as in + `query_points` + using: + Name of the vectors to use for query. + If `None` - use default vectors or provided in named vector structures. + prefetch: Prefetch queries to make a selection of the data to be used with the main query + query_filter: + - Exclude vectors which doesn't fit given conditions. + - If `None` - search among all vectors + search_params: Additional search params + limit: How many groups return + group_size: How many results return for a single group + with_payload: + - Specify which stored payload should be attached to the result. + - If `True` - attach all payload + - If `False` - do not attach any payload + - If List of string - include only specified fields + - If `PayloadSelector` - use explicit rules + with_vectors: + - If `True` - Attach stored vector to the search result. + - If `False` - Do not attach vector. + - If List of string - include only specified fields + - Default: `False` + score_threshold: + Define a minimal score threshold for the result. + If defined, less similar results will not be returned. + timeout: Overrides global timeout for this search. Unit is seconds. + + Returns: + List of groups with not more than `group_size` hits in each group + """ + query = QdrantFastembedMixin._resolve_query(query) + return self._remote.query_points_groups( + collection_name=collection_name, + group_by=group_by, + query=query, + using=using, + prefetch=prefetch, + query_filter=query_filter, + search_params=search_params, + limit=limit, + group_size=group_size, + with_payload=with_payload, + with_vectors=with_vectors, + score_threshold=score_threshold, + timeout=timeout, + ) + + def retrieve( + self, + collection_name: str, + ids: Sequence[types.PointId], + with_payload: bool | Sequence[str] | types.PayloadSelector = True, + with_vectors: bool | Sequence[str] = False, + timeout: Optional[int] = None, + ) -> list[types.Record]: + """Retrieves points by ids. + + Args: + collection_name: Name of the collection to retrieve from + ids: List of ids to retrieve + with_payload: + - Specify which stored payload should be attached to the result. + - If `True` - attach all payload + - If `False` - do not attach any payload + - If List of string - include only specified fields + - If `PayloadSelector` - use explicit rules + with_vectors: + - If `True` - Attach stored vector to the search result. + - If `False` - Do not attach vector. + - If List of string - include only specified fields + - Default: `False` + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + List of points. Order of the points is not guaranteed; + ids that do not exist are silently skipped. + """ + return self._remote.retrieve( + collection_name=collection_name, + ids=ids, + with_payload=with_payload, + with_vectors=with_vectors, + timeout=timeout, + ) + + def scroll( + self, + collection_name: str, + scroll_filter: Optional[types.Filter] = None, + limit: int = 10, + order_by: Optional[types.OrderBy] = None, + offset: Optional[types.PointId] = None, + with_payload: bool | Sequence[str] | types.PayloadSelector = True, + with_vectors: bool | Sequence[str] = False, + timeout: Optional[int] = None, + ) -> tuple[list[types.Record], Optional[types.PointId]]: + """Scrolls over all points, optionally filtered. + + This method provides a way to iterate over all stored points with some + optional filtering condition. Scroll does not apply any similarity + estimations, it will return points sorted by id in ascending order. + + Args: + collection_name: Name of the collection to scroll + scroll_filter: If provided - only returns points matching the filtering conditions + limit: How many points to return + order_by: Order the records by a payload key. If `None` - order by id. + Requires a range-capable payload index on the key. + offset: If provided - skip points with ids less than given `offset` + with_payload: + - Specify which stored payload should be attached to the result. + - If `True` - attach all payload + - If `False` - do not attach any payload + - If List of string - include only specified fields + - If `PayloadSelector` - use explicit rules + with_vectors: + - If `True` - Attach stored vector to the search result. + - If `False` - Do not attach vector. + - If List of string - include only specified fields + - Default: `False` + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + A pair of (List of points) and (optional offset of the next scroll request). + If the next offset is `None` - there are no more points to scroll. + """ + return self._remote.scroll( + collection_name=collection_name, + scroll_filter=scroll_filter, + limit=limit, + order_by=order_by, + offset=offset, + with_payload=with_payload, + with_vectors=with_vectors, + timeout=timeout, + ) + + def count( + self, + collection_name: str, + count_filter: Optional[types.Filter] = None, + exact: bool = True, + timeout: Optional[int] = None, + ) -> types.CountResult: + """Counts points in the collection. + + Counts points matching the filtering conditions, or all points if no + filter is given. + + Args: + collection_name: Name of the collection to count points in + count_filter: Filtering conditions + exact: + - If `True` - provide the exact count of points matching the filter. + - If `False` - provide the approximate count of points matching the filter. + Works faster. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Amount of points in the collection matching the filter + """ + return self._remote.count( + collection_name=collection_name, + count_filter=count_filter, + exact=exact, + timeout=timeout, + ) + + def upsert( + self, + collection_name: str, + points: types.Points, + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Updates or inserts points into the collection. + + If a point with a given ID already exists - it will be overwritten. + Same as in the regular client, minus `ordering`, `shard_key_selector`, + `update_filter` and `update_mode`, which serverless does not support. + + Args: + collection_name: To which collection to insert + points: Batch or list of points to insert + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return self._remote.upsert( + collection_name=collection_name, + points=points, + wait=wait, + timeout=timeout, + ) + + def update_vectors( + self, + collection_name: str, + points: Sequence[types.PointVectors], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Updates specified vectors of the given points, keeping payload and + the remaining vectors untouched. + + Args: + collection_name: Name of the collection to update vectors in + points: List of (id, vector) pairs to update + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return self._remote.update_vectors( + collection_name=collection_name, + points=points, + wait=wait, + timeout=timeout, + ) + + def delete_vectors( + self, + collection_name: str, + vectors: Sequence[str], + points: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Removes the given named vectors from the selected points, keeping + the points themselves. + + Selection is currently limited to explicit ids. Once serverless + supports filtered updates, this parameter will also accept + filter-based selectors (a non-breaking type widening). + + Args: + collection_name: Name of the collection to delete vectors from + vectors: List of vector names to delete; use `""` for the unnamed + default vector + points: List of ids of the points to modify + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return self._remote.delete_vectors( + collection_name=collection_name, + vectors=vectors, + points=list(points), + wait=wait, + timeout=timeout, + ) + + def overwrite_payload( + self, + collection_name: str, + payload: types.Payload, + points: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Replaces the entire payload of the selected points with the given payload. + + Unlike `set_payload`, existing keys not present in the new payload are + removed. Selection is currently limited to explicit ids. Once + serverless supports filtered updates, this parameter will also accept + filter-based selectors (a non-breaking type widening). + + Args: + collection_name: Name of the collection to overwrite payload in + payload: Key-value pairs of payload to assign + points: List of ids of the points to modify + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return self._remote.overwrite_payload( + collection_name=collection_name, + payload=payload, + points=list(points), + wait=wait, + timeout=timeout, + ) + + def clear_payload( + self, + collection_name: str, + points: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Removes the entire payload of the selected points. + + Selection is currently limited to explicit ids. Once serverless + supports filtered updates, this parameter will also accept + filter-based selectors (a non-breaking type widening). + + Args: + collection_name: Name of the collection to clear payload in + points: List of ids of the points to modify + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return self._remote.clear_payload( + collection_name=collection_name, + points_selector=list(points), + wait=wait, + timeout=timeout, + ) + + def batch_update_points( + self, + collection_name: str, + update_operations: Sequence[types.UpdateOperation], + wait: bool = False, + timeout: Optional[int] = None, + ) -> list[types.UpdateResult]: + """Performs a batch of point update operations in one request. + + Operations with filter-based selectors are rejected by the serverless + service; select points by explicit ids inside each operation. + + Args: + collection_name: Name of the collection to update + update_operations: List of update operations (upsert, delete, + set/overwrite/delete/clear payload, update/delete vectors) + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + List of operation results, one per operation + """ + return self._remote.batch_update_points( + collection_name=collection_name, + update_operations=update_operations, + wait=wait, + timeout=timeout, + ) + + def delete( + self, + collection_name: str, + points: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Deletes selected points. + + Selection is currently limited to explicit ids. Once serverless + supports filtered updates, this parameter will also accept + filter-based selectors (a non-breaking type widening). + + Args: + collection_name: Deletes points from this collection + points: List of ids of the points to delete + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return self._remote.delete( + collection_name=collection_name, + points_selector=list(points), + wait=wait, + timeout=timeout, + ) + + def set_payload( + self, + collection_name: str, + payload: types.Payload, + points: Sequence[types.PointId], + key: Optional[str] = None, + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Modifies payload of the selected points. + + Only the given payload values are merged into the stored payload; + other existing keys stay untouched. Selection is currently limited to + explicit ids. Once serverless supports filtered updates, this + parameter will also accept filter-based selectors (a non-breaking + type widening). + + Args: + collection_name: Name of the collection to set payload in + payload: Key-value pairs of payload to assign + points: List of ids of the points to modify + key: Path to the nested field in the payload to modify. + If `None` - modify the root of the payload. + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return self._remote.set_payload( + collection_name=collection_name, + payload=payload, + points=list(points), + key=key, + wait=wait, + timeout=timeout, + ) + + def delete_payload( + self, + collection_name: str, + keys: Sequence[str], + points: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Removes the given payload keys from the selected points. + + Selection is currently limited to explicit ids. Once serverless + supports filtered updates, this parameter will also accept + filter-based selectors (a non-breaking type widening). + + Args: + collection_name: Name of the collection to delete payload from + keys: List of payload keys to remove + points: List of ids of the points to modify + wait: Await for the write to be accepted on the server side. + Default `False`: serverless reads are eventually consistent with + writes, so waiting does not guarantee read-your-write anyway. + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ + return self._remote.delete_payload( + collection_name=collection_name, + keys=keys, + points=list(points), + wait=wait, + timeout=timeout, + ) + + # endregion diff --git a/qdrant_client/serverless/conversions.py b/qdrant_client/serverless/conversions.py new file mode 100644 index 000000000..0592c844e --- /dev/null +++ b/qdrant_client/serverless/conversions.py @@ -0,0 +1,286 @@ +"""Conversions between serverless pydantic models and the internal gRPC types. + +**In development — do not use yet.** Part of the experimental serverless client. + +The generated gRPC types are an implementation detail and must not leak into +the public interface. + +All model/proto fields are bound by structural pattern matching (never via +`model.field` or ignored with ``*_``) so adding a field forces an update here. +""" + +from qdrant_client.serverless import models +from qdrant_client.serverless.grpc import serverless_collections_pb2 as pb2 + +_DISTANCE_TO_GRPC = { + models.Distance.COSINE: pb2.COSINE, + models.Distance.EUCLID: pb2.EUCLID, + models.Distance.DOT: pb2.DOT, + models.Distance.MANHATTAN: pb2.MANHATTAN, +} +_DISTANCE_FROM_GRPC = {v: k for k, v in _DISTANCE_TO_GRPC.items()} + +_PRECISION_TO_GRPC = { + models.PrecisionTier.LOW: pb2.LOW, + models.PrecisionTier.MEDIUM: pb2.MEDIUM, + models.PrecisionTier.HIGH: pb2.HIGH, +} +_PRECISION_FROM_GRPC = {v: k for k, v in _PRECISION_TO_GRPC.items()} + +_TOKENIZER_TO_GRPC = { + models.TokenizerType.PREFIX: pb2.PREFIX, + models.TokenizerType.WHITESPACE: pb2.WHITESPACE, + models.TokenizerType.WORD: pb2.WORD, + models.TokenizerType.MULTILINGUAL: pb2.MULTILINGUAL, +} +_TOKENIZER_FROM_GRPC = {v: k for k, v in _TOKENIZER_TO_GRPC.items()} + + +def dense_vector_to_grpc(model: models.DenseVectorConfig) -> pb2.DenseVectorConfig: + match model: + case models.DenseVectorConfig( + size=size, + distance=distance, + multivector=multivector, + precision_tier=precision_tier, + ): + result = pb2.DenseVectorConfig( + size=size, + distance=_DISTANCE_TO_GRPC[distance], + multivector=multivector, + ) + if precision_tier is not None: + result.precision_tier = _PRECISION_TO_GRPC[precision_tier] + return result + case _: # pragma: no cover + raise ValueError(f"Unexpected DenseVectorConfig shape: {model!r}") + + +def dense_vector_from_grpc(grpc_model: pb2.DenseVectorConfig) -> models.DenseVectorConfig: + size = grpc_model.size + distance = grpc_model.distance + multivector = grpc_model.multivector + precision_tier = ( + _PRECISION_FROM_GRPC[grpc_model.precision_tier] + if grpc_model.HasField("precision_tier") + else None + ) + # Re-bind through the public model constructor so every field is named. + return models.DenseVectorConfig( + size=size, + distance=_DISTANCE_FROM_GRPC[distance], + multivector=multivector, + precision_tier=precision_tier, + ) + + +def sparse_vector_to_grpc(model: models.SparseVectorConfig) -> pb2.SparseVectorConfig: + match model: + case models.SparseVectorConfig(use_idf=use_idf, precision_tier=precision_tier): + result = pb2.SparseVectorConfig(use_idf=use_idf) + if precision_tier is not None: + result.precision_tier = _PRECISION_TO_GRPC[precision_tier] + return result + case _: # pragma: no cover + raise ValueError(f"Unexpected SparseVectorConfig shape: {model!r}") + + +def sparse_vector_from_grpc(grpc_model: pb2.SparseVectorConfig) -> models.SparseVectorConfig: + use_idf = grpc_model.use_idf + precision_tier = ( + _PRECISION_FROM_GRPC[grpc_model.precision_tier] + if grpc_model.HasField("precision_tier") + else None + ) + return models.SparseVectorConfig(use_idf=use_idf, precision_tier=precision_tier) + + +def _stopwords_to_grpc(model: models.StopwordsSet) -> pb2.StopwordsSet: + match model: + case models.StopwordsSet(languages=languages, custom=custom): + return pb2.StopwordsSet(languages=list(languages), custom=list(custom)) + case _: # pragma: no cover + raise ValueError(f"Unexpected StopwordsSet shape: {model!r}") + + +def _stopwords_from_grpc(grpc_model: pb2.StopwordsSet) -> models.StopwordsSet: + return models.StopwordsSet( + languages=list(grpc_model.languages), + custom=list(grpc_model.custom), + ) + + +def _stemmer_to_grpc(model: models.StemmingAlgorithm) -> pb2.StemmingAlgorithm: + match model: + case models.StemmingAlgorithm(snowball=snowball, disabled=disabled): + result = pb2.StemmingAlgorithm() + if snowball is not None: + match snowball: + case models.SnowballParams(language=language): + result.snowball.language = language + case _: # pragma: no cover + raise ValueError(f"Unexpected SnowballParams shape: {snowball!r}") + elif disabled: + result.disabled.SetInParent() + else: + raise ValueError("StemmingAlgorithm requires either snowball or disabled=True") + return result + case _: # pragma: no cover + raise ValueError(f"Unexpected StemmingAlgorithm shape: {model!r}") + + +def _stemmer_from_grpc(grpc_model: pb2.StemmingAlgorithm) -> models.StemmingAlgorithm: + kind = grpc_model.WhichOneof("stemming_params") + if kind == "snowball": + return models.StemmingAlgorithm( + snowball=models.SnowballParams(language=grpc_model.snowball.language) + ) + if kind == "disabled": + return models.StemmingAlgorithm(disabled=True) + raise ValueError(f"Unknown stemming_params variant: {kind}") # pragma: no cover + + +def payload_index_to_grpc(model: models.PayloadIndex) -> pb2.PayloadIndexConfig: + result = pb2.PayloadIndexConfig() + match model: + case models.KeywordIndex(type=_type, prefix=prefix): + result.keyword.SetInParent() + if prefix is not None: + match prefix: + case models.KeywordPrefixParams(): + result.keyword.prefix.SetInParent() + case _: # pragma: no cover + raise ValueError(f"Unexpected KeywordPrefixParams shape: {prefix!r}") + case models.IntegerIndex(type=_type, lookup=lookup, range=range_): + result.integer.SetInParent() + if lookup is not None: + result.integer.lookup = lookup + if range_ is not None: + result.integer.range = range_ + case models.FloatIndex(type=_type): + result.float.SetInParent() + case models.UuidIndex(type=_type): + result.uuid.SetInParent() + case models.DatetimeIndex(type=_type): + result.datetime.SetInParent() + case models.TextIndex( + type=_type, + tokenizer=tokenizer, + lowercase=lowercase, + phrase_matching=phrase_matching, + min_token_len=min_token_len, + max_token_len=max_token_len, + ascii_folding=ascii_folding, + stopwords=stopwords, + stemmer=stemmer, + ): + result.text.SetInParent() + if tokenizer is not None: + result.text.tokenizer = _TOKENIZER_TO_GRPC[tokenizer] + if lowercase is not None: + result.text.lowercase = lowercase + if phrase_matching is not None: + result.text.phrase_matching = phrase_matching + if min_token_len is not None: + result.text.min_token_len = min_token_len + if max_token_len is not None: + result.text.max_token_len = max_token_len + if ascii_folding is not None: + result.text.ascii_folding = ascii_folding + if stopwords is not None: + result.text.stopwords.CopyFrom(_stopwords_to_grpc(stopwords)) + if stemmer is not None: + result.text.stemmer.CopyFrom(_stemmer_to_grpc(stemmer)) + case models.GeoIndex(type=_type): + result.geo.SetInParent() + case models.BoolIndex(type=_type): + result.bool.SetInParent() + case _: # pragma: no cover + raise ValueError(f"Unknown payload index type: {model}") + return result + + +def payload_index_from_grpc(grpc_model: pb2.PayloadIndexConfig) -> models.PayloadIndex: + kind = grpc_model.WhichOneof("index") + if kind == "keyword": + keyword = grpc_model.keyword + prefix = models.KeywordPrefixParams() if keyword.HasField("prefix") else None + return models.KeywordIndex(prefix=prefix) + if kind == "integer": + integer = grpc_model.integer + lookup = integer.lookup if integer.HasField("lookup") else None + range_ = integer.range if integer.HasField("range") else None + return models.IntegerIndex(lookup=lookup, range=range_) + if kind == "float": + _float = grpc_model.float + return models.FloatIndex() + if kind == "uuid": + _uuid = grpc_model.uuid + return models.UuidIndex() + if kind == "datetime": + _datetime = grpc_model.datetime + return models.DatetimeIndex() + if kind == "text": + text = grpc_model.text + tokenizer = _TOKENIZER_FROM_GRPC[text.tokenizer] if text.HasField("tokenizer") else None + lowercase = text.lowercase if text.HasField("lowercase") else None + phrase_matching = text.phrase_matching if text.HasField("phrase_matching") else None + min_token_len = text.min_token_len if text.HasField("min_token_len") else None + max_token_len = text.max_token_len if text.HasField("max_token_len") else None + ascii_folding = text.ascii_folding if text.HasField("ascii_folding") else None + stopwords = _stopwords_from_grpc(text.stopwords) if text.HasField("stopwords") else None + stemmer = _stemmer_from_grpc(text.stemmer) if text.HasField("stemmer") else None + return models.TextIndex( + tokenizer=tokenizer, + lowercase=lowercase, + phrase_matching=phrase_matching, + min_token_len=min_token_len, + max_token_len=max_token_len, + ascii_folding=ascii_folding, + stopwords=stopwords, + stemmer=stemmer, + ) + if kind == "geo": + _geo = grpc_model.geo + return models.GeoIndex() + if kind == "bool": + _bool = grpc_model.bool + return models.BoolIndex() + raise ValueError(f"Unknown payload index type: {kind}") # pragma: no cover + + +def collection_config_to_grpc(model: models.CollectionConfig) -> pb2.CollectionConfig: + match model: + case models.CollectionConfig( + dense_vectors=dense_vectors, + sparse_vectors=sparse_vectors, + payload_indexes=payload_indexes, + ): + result = pb2.CollectionConfig() + for name, dense in dense_vectors.items(): + result.dense_vectors[name].CopyFrom(dense_vector_to_grpc(dense)) + for name, sparse in sparse_vectors.items(): + result.sparse_vectors[name].CopyFrom(sparse_vector_to_grpc(sparse)) + for field, index in payload_indexes.items(): + result.payload_indexes[field].CopyFrom(payload_index_to_grpc(index)) + return result + case _: # pragma: no cover + raise ValueError(f"Unexpected CollectionConfig shape: {model!r}") + + +def collection_config_from_grpc(grpc_model: pb2.CollectionConfig) -> models.CollectionConfig: + dense_vectors = { + name: dense_vector_from_grpc(dense) for name, dense in grpc_model.dense_vectors.items() + } + sparse_vectors = { + name: sparse_vector_from_grpc(sparse) for name, sparse in grpc_model.sparse_vectors.items() + } + payload_indexes = { + field: payload_index_from_grpc(index) + for field, index in grpc_model.payload_indexes.items() + } + return models.CollectionConfig( + dense_vectors=dense_vectors, + sparse_vectors=sparse_vectors, + payload_indexes=payload_indexes, + ) diff --git a/qdrant_client/serverless/grpc/__init__.py b/qdrant_client/serverless/grpc/__init__.py new file mode 100644 index 000000000..c78159f89 --- /dev/null +++ b/qdrant_client/serverless/grpc/__init__.py @@ -0,0 +1,2 @@ +# Generated gRPC code for the serverless collections service. +# Internal: do not use these types outside qdrant_client.serverless. diff --git a/qdrant_client/serverless/grpc/serverless_collections_pb2.py b/qdrant_client/serverless/grpc/serverless_collections_pb2.py new file mode 100644 index 000000000..f3b54586d --- /dev/null +++ b/qdrant_client/serverless/grpc/serverless_collections_pb2.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: serverless_collections.proto +# Protobuf Python Version: 4.25.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1cserverless_collections.proto\x12\x11qdrant.serverless\"\xb7\x01\n\x11\x44\x65nseVectorConfig\x12\x0c\n\x04size\x18\x01 \x01(\x04\x12-\n\x08\x64istance\x18\x02 \x01(\x0e\x32\x1b.qdrant.serverless.Distance\x12\x13\n\x0bmultivector\x18\x03 \x01(\x08\x12=\n\x0eprecision_tier\x18\x04 \x01(\x0e\x32 .qdrant.serverless.PrecisionTierH\x00\x88\x01\x01\x42\x11\n\x0f_precision_tier\"w\n\x12SparseVectorConfig\x12\x0f\n\x07use_idf\x18\x01 \x01(\x08\x12=\n\x0eprecision_tier\x18\x02 \x01(\x0e\x32 .qdrant.serverless.PrecisionTierH\x00\x88\x01\x01\x42\x11\n\x0f_precision_tier\"V\n\x0cKeywordIndex\x12;\n\x06prefix\x18\x01 \x01(\x0b\x32&.qdrant.serverless.KeywordPrefixParamsH\x00\x88\x01\x01\x42\t\n\x07_prefix\"\x15\n\x13KeywordPrefixParams\"L\n\x0cIntegerIndex\x12\x13\n\x06lookup\x18\x01 \x01(\x08H\x00\x88\x01\x01\x12\x12\n\x05range\x18\x02 \x01(\x08H\x01\x88\x01\x01\x42\t\n\x07_lookupB\x08\n\x06_range\"\x0c\n\nFloatIndex\"\x0b\n\tUuidIndex\"\x0f\n\rDatetimeIndex\"1\n\x0cStopwordsSet\x12\x11\n\tlanguages\x18\x01 \x03(\t\x12\x0e\n\x06\x63ustom\x18\x02 \x03(\t\"\"\n\x0eSnowballParams\x12\x10\n\x08language\x18\x01 \x01(\t\"\x11\n\x0f\x44isabledStemmer\"\x95\x01\n\x11StemmingAlgorithm\x12\x35\n\x08snowball\x18\x01 \x01(\x0b\x32!.qdrant.serverless.SnowballParamsH\x00\x12\x36\n\x08\x64isabled\x18\x02 \x01(\x0b\x32\".qdrant.serverless.DisabledStemmerH\x00\x42\x11\n\x0fstemming_params\"\xc0\x03\n\tTextIndex\x12\x34\n\ttokenizer\x18\x01 \x01(\x0e\x32\x1c.qdrant.serverless.TokenizerH\x00\x88\x01\x01\x12\x16\n\tlowercase\x18\x02 \x01(\x08H\x01\x88\x01\x01\x12\x1c\n\x0fphrase_matching\x18\x03 \x01(\x08H\x02\x88\x01\x01\x12\x1a\n\rmin_token_len\x18\x04 \x01(\x04H\x03\x88\x01\x01\x12\x1a\n\rmax_token_len\x18\x05 \x01(\x04H\x04\x88\x01\x01\x12\x1a\n\rascii_folding\x18\x06 \x01(\x08H\x05\x88\x01\x01\x12\x37\n\tstopwords\x18\x07 \x01(\x0b\x32\x1f.qdrant.serverless.StopwordsSetH\x06\x88\x01\x01\x12:\n\x07stemmer\x18\x08 \x01(\x0b\x32$.qdrant.serverless.StemmingAlgorithmH\x07\x88\x01\x01\x42\x0c\n\n_tokenizerB\x0c\n\n_lowercaseB\x12\n\x10_phrase_matchingB\x10\n\x0e_min_token_lenB\x10\n\x0e_max_token_lenB\x10\n\x0e_ascii_foldingB\x0c\n\n_stopwordsB\n\n\x08_stemmer\"\n\n\x08GeoIndex\"\x0b\n\tBoolIndex\"\xa1\x03\n\x12PayloadIndexConfig\x12\x32\n\x07keyword\x18\x01 \x01(\x0b\x32\x1f.qdrant.serverless.KeywordIndexH\x00\x12\x32\n\x07integer\x18\x02 \x01(\x0b\x32\x1f.qdrant.serverless.IntegerIndexH\x00\x12.\n\x05\x66loat\x18\x03 \x01(\x0b\x32\x1d.qdrant.serverless.FloatIndexH\x00\x12,\n\x04uuid\x18\x04 \x01(\x0b\x32\x1c.qdrant.serverless.UuidIndexH\x00\x12\x34\n\x08\x64\x61tetime\x18\x05 \x01(\x0b\x32 .qdrant.serverless.DatetimeIndexH\x00\x12,\n\x04text\x18\x06 \x01(\x0b\x32\x1c.qdrant.serverless.TextIndexH\x00\x12*\n\x03geo\x18\x07 \x01(\x0b\x32\x1b.qdrant.serverless.GeoIndexH\x00\x12,\n\x04\x62ool\x18\x08 \x01(\x0b\x32\x1c.qdrant.serverless.BoolIndexH\x00\x42\x07\n\x05index\"\x98\x04\n\x10\x43ollectionConfig\x12L\n\rdense_vectors\x18\x01 \x03(\x0b\x32\x35.qdrant.serverless.CollectionConfig.DenseVectorsEntry\x12N\n\x0esparse_vectors\x18\x02 \x03(\x0b\x32\x36.qdrant.serverless.CollectionConfig.SparseVectorsEntry\x12P\n\x0fpayload_indexes\x18\x03 \x03(\x0b\x32\x37.qdrant.serverless.CollectionConfig.PayloadIndexesEntry\x1aY\n\x11\x44\x65nseVectorsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x33\n\x05value\x18\x02 \x01(\x0b\x32$.qdrant.serverless.DenseVectorConfig:\x02\x38\x01\x1a[\n\x12SparseVectorsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x34\n\x05value\x18\x02 \x01(\x0b\x32%.qdrant.serverless.SparseVectorConfig:\x02\x38\x01\x1a\\\n\x13PayloadIndexesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x34\n\x05value\x18\x02 \x01(\x0b\x32%.qdrant.serverless.PayloadIndexConfig:\x02\x38\x01\"g\n\x17\x43reateCollectionRequest\x12\x17\n\x0f\x63ollection_name\x18\x01 \x01(\t\x12\x33\n\x06\x63onfig\x18\x02 \x01(\x0b\x32#.qdrant.serverless.CollectionConfig\"C\n\x18\x43reateCollectionResponse\x12\x17\n\x0f\x63ollection_name\x18\x01 \x01(\t\x12\x0e\n\x06result\x18\x02 \x01(\t\"2\n\x17\x44\x65leteCollectionRequest\x12\x17\n\x0f\x63ollection_name\x18\x01 \x01(\t\"D\n\x18\x44\x65leteCollectionResponse\x12\x0f\n\x07\x64\x65leted\x18\x01 \x01(\x08\x12\x17\n\x0fobjects_deleted\x18\x02 \x01(\r\"/\n\x14GetCollectionRequest\x12\x17\n\x0f\x63ollection_name\x18\x01 \x01(\t\"\x96\x01\n\x15GetCollectionResponse\x12\x0e\n\x06\x65xists\x18\x01 \x01(\x08\x12\x38\n\x06\x63onfig\x18\x02 \x01(\x0b\x32#.qdrant.serverless.CollectionConfigH\x00\x88\x01\x01\x12\x18\n\x0bpoint_count\x18\x03 \x01(\x04H\x01\x88\x01\x01\x42\t\n\x07_configB\x0e\n\x0c_point_count\"b\n\x16ListCollectionsRequest\x12\x12\n\x05limit\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x19\n\x0coffset_token\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x08\n\x06_limitB\x0f\n\r_offset_token\"V\n\x11\x43ollectionSummary\x12\x17\n\x0f\x63ollection_name\x18\x01 \x01(\t\x12\x18\n\x0bpoint_count\x18\x02 \x01(\x04H\x00\x88\x01\x01\x42\x0e\n\x0c_point_count\"\x8a\x01\n\x17ListCollectionsResponse\x12\x39\n\x0b\x63ollections\x18\x01 \x03(\x0b\x32$.qdrant.serverless.CollectionSummary\x12\x1e\n\x11next_offset_token\x18\x02 \x01(\tH\x00\x88\x01\x01\x42\x14\n\x12_next_offset_token*T\n\x08\x44istance\x12\x18\n\x14\x44ISTANCE_UNSPECIFIED\x10\x00\x12\n\n\x06\x43OSINE\x10\x01\x12\n\n\x06\x45UCLID\x10\x02\x12\x07\n\x03\x44OT\x10\x03\x12\r\n\tMANHATTAN\x10\x04*N\n\rPrecisionTier\x12\x1e\n\x1aPRECISION_TIER_UNSPECIFIED\x10\x00\x12\x07\n\x03LOW\x10\x01\x12\n\n\x06MEDIUM\x10\x02\x12\x08\n\x04HIGH\x10\x03*^\n\tTokenizer\x12\x19\n\x15TOKENIZER_UNSPECIFIED\x10\x00\x12\n\n\x06PREFIX\x10\x01\x12\x0e\n\nWHITESPACE\x10\x02\x12\x08\n\x04WORD\x10\x03\x12\x10\n\x0cMULTILINGUAL\x10\x04\x32\xbc\x03\n\x12\x43ollectionsService\x12k\n\x10\x43reateCollection\x12*.qdrant.serverless.CreateCollectionRequest\x1a+.qdrant.serverless.CreateCollectionResponse\x12k\n\x10\x44\x65leteCollection\x12*.qdrant.serverless.DeleteCollectionRequest\x1a+.qdrant.serverless.DeleteCollectionResponse\x12\x62\n\rGetCollection\x12\'.qdrant.serverless.GetCollectionRequest\x1a(.qdrant.serverless.GetCollectionResponse\x12h\n\x0fListCollections\x12).qdrant.serverless.ListCollectionsRequest\x1a*.qdrant.serverless.ListCollectionsResponseb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'serverless_collections_pb2', _globals) +if _descriptor._USE_C_DESCRIPTORS == False: + DESCRIPTOR._options = None + _globals['_COLLECTIONCONFIG_DENSEVECTORSENTRY']._options = None + _globals['_COLLECTIONCONFIG_DENSEVECTORSENTRY']._serialized_options = b'8\001' + _globals['_COLLECTIONCONFIG_SPARSEVECTORSENTRY']._options = None + _globals['_COLLECTIONCONFIG_SPARSEVECTORSENTRY']._serialized_options = b'8\001' + _globals['_COLLECTIONCONFIG_PAYLOADINDEXESENTRY']._options = None + _globals['_COLLECTIONCONFIG_PAYLOADINDEXESENTRY']._serialized_options = b'8\001' + _globals['_DISTANCE']._serialized_start=3111 + _globals['_DISTANCE']._serialized_end=3195 + _globals['_PRECISIONTIER']._serialized_start=3197 + _globals['_PRECISIONTIER']._serialized_end=3275 + _globals['_TOKENIZER']._serialized_start=3277 + _globals['_TOKENIZER']._serialized_end=3371 + _globals['_DENSEVECTORCONFIG']._serialized_start=52 + _globals['_DENSEVECTORCONFIG']._serialized_end=235 + _globals['_SPARSEVECTORCONFIG']._serialized_start=237 + _globals['_SPARSEVECTORCONFIG']._serialized_end=356 + _globals['_KEYWORDINDEX']._serialized_start=358 + _globals['_KEYWORDINDEX']._serialized_end=444 + _globals['_KEYWORDPREFIXPARAMS']._serialized_start=446 + _globals['_KEYWORDPREFIXPARAMS']._serialized_end=467 + _globals['_INTEGERINDEX']._serialized_start=469 + _globals['_INTEGERINDEX']._serialized_end=545 + _globals['_FLOATINDEX']._serialized_start=547 + _globals['_FLOATINDEX']._serialized_end=559 + _globals['_UUIDINDEX']._serialized_start=561 + _globals['_UUIDINDEX']._serialized_end=572 + _globals['_DATETIMEINDEX']._serialized_start=574 + _globals['_DATETIMEINDEX']._serialized_end=589 + _globals['_STOPWORDSSET']._serialized_start=591 + _globals['_STOPWORDSSET']._serialized_end=640 + _globals['_SNOWBALLPARAMS']._serialized_start=642 + _globals['_SNOWBALLPARAMS']._serialized_end=676 + _globals['_DISABLEDSTEMMER']._serialized_start=678 + _globals['_DISABLEDSTEMMER']._serialized_end=695 + _globals['_STEMMINGALGORITHM']._serialized_start=698 + _globals['_STEMMINGALGORITHM']._serialized_end=847 + _globals['_TEXTINDEX']._serialized_start=850 + _globals['_TEXTINDEX']._serialized_end=1298 + _globals['_GEOINDEX']._serialized_start=1300 + _globals['_GEOINDEX']._serialized_end=1310 + _globals['_BOOLINDEX']._serialized_start=1312 + _globals['_BOOLINDEX']._serialized_end=1323 + _globals['_PAYLOADINDEXCONFIG']._serialized_start=1326 + _globals['_PAYLOADINDEXCONFIG']._serialized_end=1743 + _globals['_COLLECTIONCONFIG']._serialized_start=1746 + _globals['_COLLECTIONCONFIG']._serialized_end=2282 + _globals['_COLLECTIONCONFIG_DENSEVECTORSENTRY']._serialized_start=2006 + _globals['_COLLECTIONCONFIG_DENSEVECTORSENTRY']._serialized_end=2095 + _globals['_COLLECTIONCONFIG_SPARSEVECTORSENTRY']._serialized_start=2097 + _globals['_COLLECTIONCONFIG_SPARSEVECTORSENTRY']._serialized_end=2188 + _globals['_COLLECTIONCONFIG_PAYLOADINDEXESENTRY']._serialized_start=2190 + _globals['_COLLECTIONCONFIG_PAYLOADINDEXESENTRY']._serialized_end=2282 + _globals['_CREATECOLLECTIONREQUEST']._serialized_start=2284 + _globals['_CREATECOLLECTIONREQUEST']._serialized_end=2387 + _globals['_CREATECOLLECTIONRESPONSE']._serialized_start=2389 + _globals['_CREATECOLLECTIONRESPONSE']._serialized_end=2456 + _globals['_DELETECOLLECTIONREQUEST']._serialized_start=2458 + _globals['_DELETECOLLECTIONREQUEST']._serialized_end=2508 + _globals['_DELETECOLLECTIONRESPONSE']._serialized_start=2510 + _globals['_DELETECOLLECTIONRESPONSE']._serialized_end=2578 + _globals['_GETCOLLECTIONREQUEST']._serialized_start=2580 + _globals['_GETCOLLECTIONREQUEST']._serialized_end=2627 + _globals['_GETCOLLECTIONRESPONSE']._serialized_start=2630 + _globals['_GETCOLLECTIONRESPONSE']._serialized_end=2780 + _globals['_LISTCOLLECTIONSREQUEST']._serialized_start=2782 + _globals['_LISTCOLLECTIONSREQUEST']._serialized_end=2880 + _globals['_COLLECTIONSUMMARY']._serialized_start=2882 + _globals['_COLLECTIONSUMMARY']._serialized_end=2968 + _globals['_LISTCOLLECTIONSRESPONSE']._serialized_start=2971 + _globals['_LISTCOLLECTIONSRESPONSE']._serialized_end=3109 + _globals['_COLLECTIONSSERVICE']._serialized_start=3374 + _globals['_COLLECTIONSSERVICE']._serialized_end=3818 +# @@protoc_insertion_point(module_scope) diff --git a/qdrant_client/serverless/grpc/serverless_collections_pb2.pyi b/qdrant_client/serverless/grpc/serverless_collections_pb2.pyi new file mode 100644 index 000000000..5c8f5c56e --- /dev/null +++ b/qdrant_client/serverless/grpc/serverless_collections_pb2.pyi @@ -0,0 +1,794 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +Source: https://github.com/qdrant/qdrant-cloud-public-api/blob/main/proto/qdrant/serverless/collections.proto +Renamed to serverless_collections.proto: the protobuf descriptor pool registers files by +name, and "collections.proto" is already taken by the regular qdrant client proto. +Client copy: buf.validate options are stripped (server-side only; wire format unchanged). +Regenerate with tools/generate_serverless_grpc_client.sh +""" +import builtins +import collections.abc +import google.protobuf.descriptor +import google.protobuf.internal.containers +import google.protobuf.internal.enum_type_wrapper +import google.protobuf.message +import sys +import typing + +if sys.version_info >= (3, 10): + import typing as typing_extensions +else: + import typing_extensions + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +class _Distance: + ValueType = typing.NewType("ValueType", builtins.int) + V: typing_extensions.TypeAlias = ValueType + +class _DistanceEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[_Distance.ValueType], builtins.type): # noqa: F821 + DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor + DISTANCE_UNSPECIFIED: _Distance.ValueType # 0 + """Unset; a concrete metric is required.""" + COSINE: _Distance.ValueType # 1 + """Cosine similarity.""" + EUCLID: _Distance.ValueType # 2 + """Euclidean (L2) distance.""" + DOT: _Distance.ValueType # 3 + """Dot product.""" + MANHATTAN: _Distance.ValueType # 4 + """Manhattan (L1) distance.""" + +class Distance(_Distance, metaclass=_DistanceEnumTypeWrapper): + """Distance metric used to compare dense vectors.""" + +DISTANCE_UNSPECIFIED: Distance.ValueType # 0 +"""Unset; a concrete metric is required.""" +COSINE: Distance.ValueType # 1 +"""Cosine similarity.""" +EUCLID: Distance.ValueType # 2 +"""Euclidean (L2) distance.""" +DOT: Distance.ValueType # 3 +"""Dot product.""" +MANHATTAN: Distance.ValueType # 4 +"""Manhattan (L1) distance.""" +global___Distance = Distance + +class _PrecisionTier: + ValueType = typing.NewType("ValueType", builtins.int) + V: typing_extensions.TypeAlias = ValueType + +class _PrecisionTierEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[_PrecisionTier.ValueType], builtins.type): # noqa: F821 + DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor + PRECISION_TIER_UNSPECIFIED: _PrecisionTier.ValueType # 0 + """Unset: HIGH.""" + LOW: _PrecisionTier.ValueType # 1 + """Aggressive compression, lowest cost, approximate results.""" + MEDIUM: _PrecisionTier.ValueType # 2 + """Moderate compression with a small accuracy trade-off.""" + HIGH: _PrecisionTier.ValueType # 3 + """No lossy compression: exact stored vectors.""" + +class PrecisionTier(_PrecisionTier, metaclass=_PrecisionTierEnumTypeWrapper): + """How much of the original vector precision may be traded for cost. The manager + turns this into a concrete quantization / datatype choice; the tenant never + picks scalar/product/binary quantization or its parameters directly. + """ + +PRECISION_TIER_UNSPECIFIED: PrecisionTier.ValueType # 0 +"""Unset: HIGH.""" +LOW: PrecisionTier.ValueType # 1 +"""Aggressive compression, lowest cost, approximate results.""" +MEDIUM: PrecisionTier.ValueType # 2 +"""Moderate compression with a small accuracy trade-off.""" +HIGH: PrecisionTier.ValueType # 3 +"""No lossy compression: exact stored vectors.""" +global___PrecisionTier = PrecisionTier + +class _Tokenizer: + ValueType = typing.NewType("ValueType", builtins.int) + V: typing_extensions.TypeAlias = ValueType + +class _TokenizerEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[_Tokenizer.ValueType], builtins.type): # noqa: F821 + DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor + TOKENIZER_UNSPECIFIED: _Tokenizer.ValueType # 0 + """Unset: WHITESPACE.""" + PREFIX: _Tokenizer.ValueType # 1 + """Index every prefix of each token.""" + WHITESPACE: _Tokenizer.ValueType # 2 + """Split on whitespace.""" + WORD: _Tokenizer.ValueType # 3 + """Split on word boundaries.""" + MULTILINGUAL: _Tokenizer.ValueType # 4 + """Language-aware tokenization.""" + +class Tokenizer(_Tokenizer, metaclass=_TokenizerEnumTypeWrapper): + """Full-text tokenizer, mirrors qdrant's `TokenizerType`.""" + +TOKENIZER_UNSPECIFIED: Tokenizer.ValueType # 0 +"""Unset: WHITESPACE.""" +PREFIX: Tokenizer.ValueType # 1 +"""Index every prefix of each token.""" +WHITESPACE: Tokenizer.ValueType # 2 +"""Split on whitespace.""" +WORD: Tokenizer.ValueType # 3 +"""Split on word boundaries.""" +MULTILINGUAL: Tokenizer.ValueType # 4 +"""Language-aware tokenization.""" +global___Tokenizer = Tokenizer + +class DenseVectorConfig(google.protobuf.message.Message): + """Configuration of a single dense (embedding) vector.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + SIZE_FIELD_NUMBER: builtins.int + DISTANCE_FIELD_NUMBER: builtins.int + MULTIVECTOR_FIELD_NUMBER: builtins.int + PRECISION_TIER_FIELD_NUMBER: builtins.int + size: builtins.int + """Dimensionality of the embedding, e.g. 512 (CLIP), 1536, 3072.""" + distance: global___Distance.ValueType + """Distance metric used to compare vectors.""" + multivector: builtins.bool + """Store several sub-vectors per point and compare with max-sim, for + late-interaction models (ColBERT, ColPali, ...). + """ + precision_tier: global___PrecisionTier.ValueType + """Precision/cost trade-off for this vector. Unset: HIGH.""" + def __init__( + self, + *, + size: builtins.int = ..., + distance: global___Distance.ValueType = ..., + multivector: builtins.bool = ..., + precision_tier: global___PrecisionTier.ValueType | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["_precision_tier", b"_precision_tier", "precision_tier", b"precision_tier"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["_precision_tier", b"_precision_tier", "distance", b"distance", "multivector", b"multivector", "precision_tier", b"precision_tier", "size", b"size"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["_precision_tier", b"_precision_tier"]) -> typing_extensions.Literal["precision_tier"] | None: ... + +global___DenseVectorConfig = DenseVectorConfig + +class SparseVectorConfig(google.protobuf.message.Message): + """Configuration of a single sparse vector.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + USE_IDF_FIELD_NUMBER: builtins.int + PRECISION_TIER_FIELD_NUMBER: builtins.int + use_idf: builtins.bool + """Apply the IDF modifier at query time. Enable for BM25-style models that + expect inverse-document-frequency weighting. + """ + precision_tier: global___PrecisionTier.ValueType + """Precision/cost trade-off for this vector. Unset: HIGH.""" + def __init__( + self, + *, + use_idf: builtins.bool = ..., + precision_tier: global___PrecisionTier.ValueType | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["_precision_tier", b"_precision_tier", "precision_tier", b"precision_tier"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["_precision_tier", b"_precision_tier", "precision_tier", b"precision_tier", "use_idf", b"use_idf"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["_precision_tier", b"_precision_tier"]) -> typing_extensions.Literal["precision_tier"] | None: ... + +global___SparseVectorConfig = SparseVectorConfig + +class KeywordIndex(google.protobuf.message.Message): + """Exact match on string values, e.g. `color: "red"`.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + PREFIX_FIELD_NUMBER: builtins.int + @property + def prefix(self) -> global___KeywordPrefixParams: + """If set, enable prefix matching (`match: { "prefix": ... }`) on this field. + Presence of this message enables prefix matching; it has no options yet. + """ + def __init__( + self, + *, + prefix: global___KeywordPrefixParams | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["_prefix", b"_prefix", "prefix", b"prefix"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["_prefix", b"_prefix", "prefix", b"prefix"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["_prefix", b"_prefix"]) -> typing_extensions.Literal["prefix"] | None: ... + +global___KeywordIndex = KeywordIndex + +class KeywordPrefixParams(google.protobuf.message.Message): + """Prefix matching options for the keyword index. Has no options yet: + presence of this message enables prefix matching. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___KeywordPrefixParams = KeywordPrefixParams + +class IntegerIndex(google.protobuf.message.Message): + """Exact match and/or range filters on integers, e.g. `age: 25`. Both are on + by default; turning one off shrinks the index. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + LOOKUP_FIELD_NUMBER: builtins.int + RANGE_FIELD_NUMBER: builtins.int + lookup: builtins.bool + """Support exact-match filters.""" + range: builtins.bool + """Support range filters.""" + def __init__( + self, + *, + lookup: builtins.bool | None = ..., + range: builtins.bool | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["_lookup", b"_lookup", "_range", b"_range", "lookup", b"lookup", "range", b"range"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["_lookup", b"_lookup", "_range", b"_range", "lookup", b"lookup", "range", b"range"]) -> None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_lookup", b"_lookup"]) -> typing_extensions.Literal["lookup"] | None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_range", b"_range"]) -> typing_extensions.Literal["range"] | None: ... + +global___IntegerIndex = IntegerIndex + +class FloatIndex(google.protobuf.message.Message): + """Range filters on floating point (and integer) numbers, e.g. `price: 99.5`.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___FloatIndex = FloatIndex + +class UuidIndex(google.protobuf.message.Message): + """Exact match on UUID strings; like keyword but stored compactly.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___UuidIndex = UuidIndex + +class DatetimeIndex(google.protobuf.message.Message): + """Range filters on RFC 3339 datetimes, e.g. `created_at: "2023-02-08T10:49:00Z"`.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___DatetimeIndex = DatetimeIndex + +class StopwordsSet(google.protobuf.message.Message): + """Tokens ignored by a full-text index. Language names match qdrant (e.g. + "english"); predefined lists and custom tokens are merged. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + LANGUAGES_FIELD_NUMBER: builtins.int + CUSTOM_FIELD_NUMBER: builtins.int + @property + def languages(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: + """Languages whose predefined stopword lists to apply.""" + @property + def custom(self) -> google.protobuf.internal.containers.RepeatedScalarFieldContainer[builtins.str]: + """Extra stopwords to ignore, merged with the language lists.""" + def __init__( + self, + *, + languages: collections.abc.Iterable[builtins.str] | None = ..., + custom: collections.abc.Iterable[builtins.str] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["custom", b"custom", "languages", b"languages"]) -> None: ... + +global___StopwordsSet = StopwordsSet + +class SnowballParams(google.protobuf.message.Message): + """Snowball stemming for a full-text index.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + LANGUAGE_FIELD_NUMBER: builtins.int + language: builtins.str + """Language for the snowball algorithm, e.g. "english".""" + def __init__( + self, + *, + language: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["language", b"language"]) -> None: ... + +global___SnowballParams = SnowballParams + +class DisabledStemmer(google.protobuf.message.Message): + """Explicitly disable stemming (overrides any language default).""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___DisabledStemmer = DisabledStemmer + +class StemmingAlgorithm(google.protobuf.message.Message): + """Stemming algorithm for a full-text index. Unset: no stemming.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + SNOWBALL_FIELD_NUMBER: builtins.int + DISABLED_FIELD_NUMBER: builtins.int + @property + def snowball(self) -> global___SnowballParams: + """Snowball stemmer for the given language.""" + @property + def disabled(self) -> global___DisabledStemmer: + """Explicitly disable stemming.""" + def __init__( + self, + *, + snowball: global___SnowballParams | None = ..., + disabled: global___DisabledStemmer | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["disabled", b"disabled", "snowball", b"snowball", "stemming_params", b"stemming_params"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["disabled", b"disabled", "snowball", b"snowball", "stemming_params", b"stemming_params"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["stemming_params", b"stemming_params"]) -> typing_extensions.Literal["snowball", "disabled"] | None: ... + +global___StemmingAlgorithm = StemmingAlgorithm + +class TextIndex(google.protobuf.message.Message): + """Full-text filtering on string values.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + TOKENIZER_FIELD_NUMBER: builtins.int + LOWERCASE_FIELD_NUMBER: builtins.int + PHRASE_MATCHING_FIELD_NUMBER: builtins.int + MIN_TOKEN_LEN_FIELD_NUMBER: builtins.int + MAX_TOKEN_LEN_FIELD_NUMBER: builtins.int + ASCII_FOLDING_FIELD_NUMBER: builtins.int + STOPWORDS_FIELD_NUMBER: builtins.int + STEMMER_FIELD_NUMBER: builtins.int + tokenizer: global___Tokenizer.ValueType + """Tokenizer to split text with. Unset: WHITESPACE.""" + lowercase: builtins.bool + """Lowercase text before indexing. Default true.""" + phrase_matching: builtins.bool + """Support phrase queries; extra index structure. Default true.""" + min_token_len: builtins.int + """Minimum token length to index.""" + max_token_len: builtins.int + """Maximum token length to index.""" + ascii_folding: builtins.bool + """Fold accented characters to ASCII. Default false.""" + @property + def stopwords(self) -> global___StopwordsSet: + """Tokens to ignore at index and query time.""" + @property + def stemmer(self) -> global___StemmingAlgorithm: + """Stemming algorithm. Unset: engine default (no stemming).""" + def __init__( + self, + *, + tokenizer: global___Tokenizer.ValueType | None = ..., + lowercase: builtins.bool | None = ..., + phrase_matching: builtins.bool | None = ..., + min_token_len: builtins.int | None = ..., + max_token_len: builtins.int | None = ..., + ascii_folding: builtins.bool | None = ..., + stopwords: global___StopwordsSet | None = ..., + stemmer: global___StemmingAlgorithm | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["_ascii_folding", b"_ascii_folding", "_lowercase", b"_lowercase", "_max_token_len", b"_max_token_len", "_min_token_len", b"_min_token_len", "_phrase_matching", b"_phrase_matching", "_stemmer", b"_stemmer", "_stopwords", b"_stopwords", "_tokenizer", b"_tokenizer", "ascii_folding", b"ascii_folding", "lowercase", b"lowercase", "max_token_len", b"max_token_len", "min_token_len", b"min_token_len", "phrase_matching", b"phrase_matching", "stemmer", b"stemmer", "stopwords", b"stopwords", "tokenizer", b"tokenizer"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["_ascii_folding", b"_ascii_folding", "_lowercase", b"_lowercase", "_max_token_len", b"_max_token_len", "_min_token_len", b"_min_token_len", "_phrase_matching", b"_phrase_matching", "_stemmer", b"_stemmer", "_stopwords", b"_stopwords", "_tokenizer", b"_tokenizer", "ascii_folding", b"ascii_folding", "lowercase", b"lowercase", "max_token_len", b"max_token_len", "min_token_len", b"min_token_len", "phrase_matching", b"phrase_matching", "stemmer", b"stemmer", "stopwords", b"stopwords", "tokenizer", b"tokenizer"]) -> None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_ascii_folding", b"_ascii_folding"]) -> typing_extensions.Literal["ascii_folding"] | None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_lowercase", b"_lowercase"]) -> typing_extensions.Literal["lowercase"] | None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_max_token_len", b"_max_token_len"]) -> typing_extensions.Literal["max_token_len"] | None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_min_token_len", b"_min_token_len"]) -> typing_extensions.Literal["min_token_len"] | None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_phrase_matching", b"_phrase_matching"]) -> typing_extensions.Literal["phrase_matching"] | None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_stemmer", b"_stemmer"]) -> typing_extensions.Literal["stemmer"] | None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_stopwords", b"_stopwords"]) -> typing_extensions.Literal["stopwords"] | None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_tokenizer", b"_tokenizer"]) -> typing_extensions.Literal["tokenizer"] | None: ... + +global___TextIndex = TextIndex + +class GeoIndex(google.protobuf.message.Message): + """Geo radius / bounding box / polygon filters on `{lon, lat}` values.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___GeoIndex = GeoIndex + +class BoolIndex(google.protobuf.message.Message): + """Exact match on booleans, e.g. `is_active: true`.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___BoolIndex = BoolIndex + +class PayloadIndexConfig(google.protobuf.message.Message): + """One payload index. Only the *kind* of filter the field supports is chosen + here; storage placement of the index is the manager's decision. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEYWORD_FIELD_NUMBER: builtins.int + INTEGER_FIELD_NUMBER: builtins.int + FLOAT_FIELD_NUMBER: builtins.int + UUID_FIELD_NUMBER: builtins.int + DATETIME_FIELD_NUMBER: builtins.int + TEXT_FIELD_NUMBER: builtins.int + GEO_FIELD_NUMBER: builtins.int + BOOL_FIELD_NUMBER: builtins.int + @property + def keyword(self) -> global___KeywordIndex: + """Exact match on strings.""" + @property + def integer(self) -> global___IntegerIndex: + """Exact match and/or range filters on integers.""" + @property + def float(self) -> global___FloatIndex: + """Range filters on numbers.""" + @property + def uuid(self) -> global___UuidIndex: + """Exact match on UUIDs.""" + @property + def datetime(self) -> global___DatetimeIndex: + """Range filters on datetimes.""" + @property + def text(self) -> global___TextIndex: + """Full-text filtering.""" + @property + def geo(self) -> global___GeoIndex: + """Geo filters.""" + @property + def bool(self) -> global___BoolIndex: + """Exact match on booleans.""" + def __init__( + self, + *, + keyword: global___KeywordIndex | None = ..., + integer: global___IntegerIndex | None = ..., + float: global___FloatIndex | None = ..., + uuid: global___UuidIndex | None = ..., + datetime: global___DatetimeIndex | None = ..., + text: global___TextIndex | None = ..., + geo: global___GeoIndex | None = ..., + bool: global___BoolIndex | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["bool", b"bool", "datetime", b"datetime", "float", b"float", "geo", b"geo", "index", b"index", "integer", b"integer", "keyword", b"keyword", "text", b"text", "uuid", b"uuid"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["bool", b"bool", "datetime", b"datetime", "float", b"float", "geo", b"geo", "index", b"index", "integer", b"integer", "keyword", b"keyword", "text", b"text", "uuid", b"uuid"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["index", b"index"]) -> typing_extensions.Literal["keyword", "integer", "float", "uuid", "datetime", "text", "geo", "bool"] | None: ... + +global___PayloadIndexConfig = PayloadIndexConfig + +class CollectionConfig(google.protobuf.message.Message): + """The tenant's collection config. Persisted verbatim. At least one dense or + sparse vector is required. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + class DenseVectorsEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + @property + def value(self) -> global___DenseVectorConfig: ... + def __init__( + self, + *, + key: builtins.str = ..., + value: global___DenseVectorConfig | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["value", b"value"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["key", b"key", "value", b"value"]) -> None: ... + + class SparseVectorsEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + @property + def value(self) -> global___SparseVectorConfig: ... + def __init__( + self, + *, + key: builtins.str = ..., + value: global___SparseVectorConfig | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["value", b"value"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["key", b"key", "value", b"value"]) -> None: ... + + class PayloadIndexesEntry(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + KEY_FIELD_NUMBER: builtins.int + VALUE_FIELD_NUMBER: builtins.int + key: builtins.str + @property + def value(self) -> global___PayloadIndexConfig: ... + def __init__( + self, + *, + key: builtins.str = ..., + value: global___PayloadIndexConfig | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["value", b"value"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["key", b"key", "value", b"value"]) -> None: ... + + DENSE_VECTORS_FIELD_NUMBER: builtins.int + SPARSE_VECTORS_FIELD_NUMBER: builtins.int + PAYLOAD_INDEXES_FIELD_NUMBER: builtins.int + @property + def dense_vectors(self) -> google.protobuf.internal.containers.MessageMap[builtins.str, global___DenseVectorConfig]: + """Keyed by vector name. The empty name "" is the unnamed default vector, as + in qdrant; a collection either has the single unnamed vector or named ones. + """ + @property + def sparse_vectors(self) -> google.protobuf.internal.containers.MessageMap[builtins.str, global___SparseVectorConfig]: + """Keyed by vector name.""" + @property + def payload_indexes(self) -> google.protobuf.internal.containers.MessageMap[builtins.str, global___PayloadIndexConfig]: + """Keyed by payload field name (JSON path, e.g. `user_id` or `meta.tags`).""" + def __init__( + self, + *, + dense_vectors: collections.abc.Mapping[builtins.str, global___DenseVectorConfig] | None = ..., + sparse_vectors: collections.abc.Mapping[builtins.str, global___SparseVectorConfig] | None = ..., + payload_indexes: collections.abc.Mapping[builtins.str, global___PayloadIndexConfig] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["dense_vectors", b"dense_vectors", "payload_indexes", b"payload_indexes", "sparse_vectors", b"sparse_vectors"]) -> None: ... + +global___CollectionConfig = CollectionConfig + +class CreateCollectionRequest(google.protobuf.message.Message): + """Every request names the collection by its tenant-facing name only. The + tenant (`x-account-id`, `x-space-id`) travels in gRPC metadata, injected by + auth. The storage id is the manager's: minted on create, resolved + internally on get/delete. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + COLLECTION_NAME_FIELD_NUMBER: builtins.int + CONFIG_FIELD_NUMBER: builtins.int + collection_name: builtins.str + """Tenant-facing name of the collection to create.""" + @property + def config(self) -> global___CollectionConfig: + """The collection's configuration.""" + def __init__( + self, + *, + collection_name: builtins.str = ..., + config: global___CollectionConfig | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["config", b"config"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["collection_name", b"collection_name", "config", b"config"]) -> None: ... + +global___CreateCollectionRequest = CreateCollectionRequest + +class CreateCollectionResponse(google.protobuf.message.Message): + """Result of a create.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + COLLECTION_NAME_FIELD_NUMBER: builtins.int + RESULT_FIELD_NUMBER: builtins.int + collection_name: builtins.str + """Tenant-facing name of the collection.""" + result: builtins.str + """Outcome, e.g. "created", "already exists".""" + def __init__( + self, + *, + collection_name: builtins.str = ..., + result: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["collection_name", b"collection_name", "result", b"result"]) -> None: ... + +global___CreateCollectionResponse = CreateCollectionResponse + +class DeleteCollectionRequest(google.protobuf.message.Message): + """Names the collection to delete.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + COLLECTION_NAME_FIELD_NUMBER: builtins.int + collection_name: builtins.str + """Tenant-facing name of the collection to delete.""" + def __init__( + self, + *, + collection_name: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["collection_name", b"collection_name"]) -> None: ... + +global___DeleteCollectionRequest = DeleteCollectionRequest + +class DeleteCollectionResponse(google.protobuf.message.Message): + """Result of a delete.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + DELETED_FIELD_NUMBER: builtins.int + OBJECTS_DELETED_FIELD_NUMBER: builtins.int + deleted: builtins.bool + """Whether the collection existed and was deleted.""" + objects_deleted: builtins.int + """Number of storage objects removed.""" + def __init__( + self, + *, + deleted: builtins.bool = ..., + objects_deleted: builtins.int = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["deleted", b"deleted", "objects_deleted", b"objects_deleted"]) -> None: ... + +global___DeleteCollectionResponse = DeleteCollectionResponse + +class GetCollectionRequest(google.protobuf.message.Message): + """Names the collection to fetch.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + COLLECTION_NAME_FIELD_NUMBER: builtins.int + collection_name: builtins.str + """Tenant-facing name of the collection.""" + def __init__( + self, + *, + collection_name: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["collection_name", b"collection_name"]) -> None: ... + +global___GetCollectionRequest = GetCollectionRequest + +class GetCollectionResponse(google.protobuf.message.Message): + """The collection's configuration and stats, if it exists.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + EXISTS_FIELD_NUMBER: builtins.int + CONFIG_FIELD_NUMBER: builtins.int + POINT_COUNT_FIELD_NUMBER: builtins.int + exists: builtins.bool + """Whether the collection exists.""" + @property + def config(self) -> global___CollectionConfig: + """The configuration the collection was created with.""" + point_count: builtins.int + """Available points as of the last applied write (eventually consistent); + absent until the updater has written stats for the collection. + """ + def __init__( + self, + *, + exists: builtins.bool = ..., + config: global___CollectionConfig | None = ..., + point_count: builtins.int | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["_config", b"_config", "_point_count", b"_point_count", "config", b"config", "point_count", b"point_count"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["_config", b"_config", "_point_count", b"_point_count", "config", b"config", "exists", b"exists", "point_count", b"point_count"]) -> None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_config", b"_config"]) -> typing_extensions.Literal["config"] | None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_point_count", b"_point_count"]) -> typing_extensions.Literal["point_count"] | None: ... + +global___GetCollectionResponse = GetCollectionResponse + +class ListCollectionsRequest(google.protobuf.message.Message): + """Lists the caller's collections. The tenant travels in metadata.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + LIMIT_FIELD_NUMBER: builtins.int + OFFSET_TOKEN_FIELD_NUMBER: builtins.int + limit: builtins.int + """Maximum number of collections to return. Defaults to 20 and must not + exceed 100. + """ + offset_token: builtins.str + """Opaque token returned as `next_offset_token` by the previous page. Clients + must not interpret this value. + """ + def __init__( + self, + *, + limit: builtins.int | None = ..., + offset_token: builtins.str | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["_limit", b"_limit", "_offset_token", b"_offset_token", "limit", b"limit", "offset_token", b"offset_token"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["_limit", b"_limit", "_offset_token", b"_offset_token", "limit", b"limit", "offset_token", b"offset_token"]) -> None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_limit", b"_limit"]) -> typing_extensions.Literal["limit"] | None: ... + @typing.overload + def WhichOneof(self, oneof_group: typing_extensions.Literal["_offset_token", b"_offset_token"]) -> typing_extensions.Literal["offset_token"] | None: ... + +global___ListCollectionsRequest = ListCollectionsRequest + +class CollectionSummary(google.protobuf.message.Message): + """One collection in a listing.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + COLLECTION_NAME_FIELD_NUMBER: builtins.int + POINT_COUNT_FIELD_NUMBER: builtins.int + collection_name: builtins.str + """Tenant-facing name of the collection.""" + point_count: builtins.int + """Available points as of the last applied write (eventually consistent); + absent until the updater has written stats for the collection. + """ + def __init__( + self, + *, + collection_name: builtins.str = ..., + point_count: builtins.int | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["_point_count", b"_point_count", "point_count", b"point_count"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["_point_count", b"_point_count", "collection_name", b"collection_name", "point_count", b"point_count"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["_point_count", b"_point_count"]) -> typing_extensions.Literal["point_count"] | None: ... + +global___CollectionSummary = CollectionSummary + +class ListCollectionsResponse(google.protobuf.message.Message): + """The caller's collections.""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + COLLECTIONS_FIELD_NUMBER: builtins.int + NEXT_OFFSET_TOKEN_FIELD_NUMBER: builtins.int + @property + def collections(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___CollectionSummary]: + """Collections in this page.""" + next_offset_token: builtins.str + """Opaque token to pass as `offset_token` to retrieve the next page. Absent + when there are no more results. + """ + def __init__( + self, + *, + collections: collections.abc.Iterable[global___CollectionSummary] | None = ..., + next_offset_token: builtins.str | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["_next_offset_token", b"_next_offset_token", "next_offset_token", b"next_offset_token"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["_next_offset_token", b"_next_offset_token", "collections", b"collections", "next_offset_token", b"next_offset_token"]) -> None: ... + def WhichOneof(self, oneof_group: typing_extensions.Literal["_next_offset_token", b"_next_offset_token"]) -> typing_extensions.Literal["next_offset_token"] | None: ... + +global___ListCollectionsResponse = ListCollectionsResponse diff --git a/qdrant_client/serverless/grpc/serverless_collections_pb2_grpc.py b/qdrant_client/serverless/grpc/serverless_collections_pb2_grpc.py new file mode 100644 index 000000000..1e1d85f6f --- /dev/null +++ b/qdrant_client/serverless/grpc/serverless_collections_pb2_grpc.py @@ -0,0 +1,181 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from . import serverless_collections_pb2 as serverless__collections__pb2 + + +class CollectionsServiceStub(object): + """CollectionsService manages the collections of a qdrant serverless space. + Unlike the qdrant server API, it exposes only a simplified configuration: + the manager turns it into concrete qdrant settings, which are never + exposed to the tenant. + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.CreateCollection = channel.unary_unary( + '/qdrant.serverless.CollectionsService/CreateCollection', + request_serializer=serverless__collections__pb2.CreateCollectionRequest.SerializeToString, + response_deserializer=serverless__collections__pb2.CreateCollectionResponse.FromString, + ) + self.DeleteCollection = channel.unary_unary( + '/qdrant.serverless.CollectionsService/DeleteCollection', + request_serializer=serverless__collections__pb2.DeleteCollectionRequest.SerializeToString, + response_deserializer=serverless__collections__pb2.DeleteCollectionResponse.FromString, + ) + self.GetCollection = channel.unary_unary( + '/qdrant.serverless.CollectionsService/GetCollection', + request_serializer=serverless__collections__pb2.GetCollectionRequest.SerializeToString, + response_deserializer=serverless__collections__pb2.GetCollectionResponse.FromString, + ) + self.ListCollections = channel.unary_unary( + '/qdrant.serverless.CollectionsService/ListCollections', + request_serializer=serverless__collections__pb2.ListCollectionsRequest.SerializeToString, + response_deserializer=serverless__collections__pb2.ListCollectionsResponse.FromString, + ) + + +class CollectionsServiceServicer(object): + """CollectionsService manages the collections of a qdrant serverless space. + Unlike the qdrant server API, it exposes only a simplified configuration: + the manager turns it into concrete qdrant settings, which are never + exposed to the tenant. + """ + + def CreateCollection(self, request, context): + """Creates a collection with the given tenant-facing configuration. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def DeleteCollection(self, request, context): + """Deletes a collection and all of its data. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def GetCollection(self, request, context): + """Returns a single collection's configuration and stats. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ListCollections(self, request, context): + """Lists the caller's collections. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_CollectionsServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'CreateCollection': grpc.unary_unary_rpc_method_handler( + servicer.CreateCollection, + request_deserializer=serverless__collections__pb2.CreateCollectionRequest.FromString, + response_serializer=serverless__collections__pb2.CreateCollectionResponse.SerializeToString, + ), + 'DeleteCollection': grpc.unary_unary_rpc_method_handler( + servicer.DeleteCollection, + request_deserializer=serverless__collections__pb2.DeleteCollectionRequest.FromString, + response_serializer=serverless__collections__pb2.DeleteCollectionResponse.SerializeToString, + ), + 'GetCollection': grpc.unary_unary_rpc_method_handler( + servicer.GetCollection, + request_deserializer=serverless__collections__pb2.GetCollectionRequest.FromString, + response_serializer=serverless__collections__pb2.GetCollectionResponse.SerializeToString, + ), + 'ListCollections': grpc.unary_unary_rpc_method_handler( + servicer.ListCollections, + request_deserializer=serverless__collections__pb2.ListCollectionsRequest.FromString, + response_serializer=serverless__collections__pb2.ListCollectionsResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'qdrant.serverless.CollectionsService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class CollectionsService(object): + """CollectionsService manages the collections of a qdrant serverless space. + Unlike the qdrant server API, it exposes only a simplified configuration: + the manager turns it into concrete qdrant settings, which are never + exposed to the tenant. + """ + + @staticmethod + def CreateCollection(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/qdrant.serverless.CollectionsService/CreateCollection', + serverless__collections__pb2.CreateCollectionRequest.SerializeToString, + serverless__collections__pb2.CreateCollectionResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def DeleteCollection(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/qdrant.serverless.CollectionsService/DeleteCollection', + serverless__collections__pb2.DeleteCollectionRequest.SerializeToString, + serverless__collections__pb2.DeleteCollectionResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def GetCollection(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/qdrant.serverless.CollectionsService/GetCollection', + serverless__collections__pb2.GetCollectionRequest.SerializeToString, + serverless__collections__pb2.GetCollectionResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def ListCollections(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/qdrant.serverless.CollectionsService/ListCollections', + serverless__collections__pb2.ListCollectionsRequest.SerializeToString, + serverless__collections__pb2.ListCollectionsResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/qdrant_client/serverless/models.py b/qdrant_client/serverless/models.py new file mode 100644 index 000000000..d37abab81 --- /dev/null +++ b/qdrant_client/serverless/models.py @@ -0,0 +1,180 @@ +"""Pydantic models for the Qdrant Serverless collection management API. + +**In development — do not use yet.** Part of the experimental serverless client; +the API may change without notice. + +These mirror the tenant-facing serverless config: unlike the regular client's +collection models, they deliberately expose no storage internals (quantization, +WAL, segments, on_disk placement, ...) - the serverless manager decides those. +""" + +from enum import Enum +from typing import Literal, Optional, Union + +from pydantic import BaseModel, Field + +from qdrant_client.http.models import Distance, TokenizerType + + +class PrecisionTier(str, Enum): + """How much vector precision may be traded for cost. + + The manager turns this into a concrete quantization / datatype choice. + """ + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class DenseVectorConfig(BaseModel): + """Configuration of a single dense (embedding) vector.""" + + size: int + distance: Distance + multivector: bool = False + precision_tier: Optional[PrecisionTier] = None + + +class SparseVectorConfig(BaseModel): + """Configuration of a single sparse vector.""" + + use_idf: bool = False + precision_tier: Optional[PrecisionTier] = None + + +class KeywordPrefixParams(BaseModel): + """Prefix matching options for a keyword index. Presence enables prefix matching.""" + + +class KeywordIndex(BaseModel): + """Exact match on string values, e.g. `color: "red"`.""" + + type: Literal["keyword"] = "keyword" + prefix: Optional[KeywordPrefixParams] = None + + +class IntegerIndex(BaseModel): + """Exact match and/or range filters on integers. Both default to enabled.""" + + type: Literal["integer"] = "integer" + lookup: Optional[bool] = None + range: Optional[bool] = None + + +class FloatIndex(BaseModel): + """Range filters on floating point (and integer) numbers.""" + + type: Literal["float"] = "float" + + +class UuidIndex(BaseModel): + """Exact match on UUID strings; like keyword but stored compactly.""" + + type: Literal["uuid"] = "uuid" + + +class DatetimeIndex(BaseModel): + """Range filters on RFC 3339 datetimes.""" + + type: Literal["datetime"] = "datetime" + + +class StopwordsSet(BaseModel): + """Tokens ignored by a full-text index.""" + + languages: list[str] = Field(default_factory=list) + custom: list[str] = Field(default_factory=list) + + +class SnowballParams(BaseModel): + """Snowball stemming for a full-text index.""" + + language: str + + +class StemmingAlgorithm(BaseModel): + """Stemming algorithm for a full-text index. Unset: no stemming. + + Exactly one of `snowball` or `disabled` should be set. + """ + + snowball: Optional[SnowballParams] = None + disabled: Optional[bool] = None + + +class TextIndex(BaseModel): + """Full-text filtering on string values.""" + + type: Literal["text"] = "text" + tokenizer: Optional[TokenizerType] = None + lowercase: Optional[bool] = None + phrase_matching: Optional[bool] = None + min_token_len: Optional[int] = None + max_token_len: Optional[int] = None + ascii_folding: Optional[bool] = None + stopwords: Optional[StopwordsSet] = None + stemmer: Optional[StemmingAlgorithm] = None + + +class GeoIndex(BaseModel): + """Geo radius / bounding box / polygon filters on `{lon, lat}` values.""" + + type: Literal["geo"] = "geo" + + +class BoolIndex(BaseModel): + """Exact match on booleans.""" + + type: Literal["bool"] = "bool" + + +PayloadIndex = Union[ + KeywordIndex, + IntegerIndex, + FloatIndex, + UuidIndex, + DatetimeIndex, + TextIndex, + GeoIndex, + BoolIndex, +] + + +class CollectionConfig(BaseModel): + """The tenant-facing collection config. + + Vector maps are keyed by vector name; the empty name "" is the unnamed + default vector. Payload indexes are keyed by payload field name + (JSON path, e.g. `user_id` or `meta.tags`). + """ + + dense_vectors: dict[str, DenseVectorConfig] = Field(default_factory=dict) + sparse_vectors: dict[str, SparseVectorConfig] = Field(default_factory=dict) + payload_indexes: dict[str, PayloadIndex] = Field(default_factory=dict) + + +class CollectionInfo(BaseModel): + """A collection's configuration and stats, as returned by `get_collection`. + + `point_count` is eventually consistent and absent until stats have been + written for the collection. + """ + + exists: bool + config: Optional[CollectionConfig] = None + point_count: Optional[int] = None + + +class CollectionSummary(BaseModel): + """One collection in a `get_collections` listing.""" + + collection_name: str + point_count: Optional[int] = None + + +class CollectionsList(BaseModel): + """A page of collections returned by `get_collections`.""" + + collections: list[CollectionSummary] + next_offset_token: Optional[str] = None diff --git a/qdrant_client/serverless/proto/serverless_collections.proto b/qdrant_client/serverless/proto/serverless_collections.proto new file mode 100644 index 000000000..1a5dd9a1b --- /dev/null +++ b/qdrant_client/serverless/proto/serverless_collections.proto @@ -0,0 +1,287 @@ +// Source: https://github.com/qdrant/qdrant-cloud-public-api/blob/main/proto/qdrant/serverless/collections.proto +// Renamed to serverless_collections.proto: the protobuf descriptor pool registers files by +// name, and "collections.proto" is already taken by the regular qdrant client proto. +// Client copy: buf.validate options are stripped (server-side only; wire format unchanged). +// Regenerate with tools/generate_serverless_grpc_client.sh + +syntax = "proto3"; + +package qdrant.serverless; + + +// CollectionsService manages the collections of a qdrant serverless space. +// Unlike the qdrant server API, it exposes only a simplified configuration: +// the manager turns it into concrete qdrant settings, which are never +// exposed to the tenant. +service CollectionsService { + // Creates a collection with the given tenant-facing configuration. + rpc CreateCollection(CreateCollectionRequest) returns (CreateCollectionResponse); + // Deletes a collection and all of its data. + rpc DeleteCollection(DeleteCollectionRequest) returns (DeleteCollectionResponse); + // Returns a single collection's configuration and stats. + rpc GetCollection(GetCollectionRequest) returns (GetCollectionResponse); + // Lists the caller's collections. + rpc ListCollections(ListCollectionsRequest) returns (ListCollectionsResponse); +} + +// Distance metric used to compare dense vectors. +enum Distance { + // Unset; a concrete metric is required. + DISTANCE_UNSPECIFIED = 0; + // Cosine similarity. + COSINE = 1; + // Euclidean (L2) distance. + EUCLID = 2; + // Dot product. + DOT = 3; + // Manhattan (L1) distance. + MANHATTAN = 4; +} + +// How much of the original vector precision may be traded for cost. The manager +// turns this into a concrete quantization / datatype choice; the tenant never +// picks scalar/product/binary quantization or its parameters directly. +enum PrecisionTier { + // Unset: HIGH. + PRECISION_TIER_UNSPECIFIED = 0; + // Aggressive compression, lowest cost, approximate results. + LOW = 1; + // Moderate compression with a small accuracy trade-off. + MEDIUM = 2; + // No lossy compression: exact stored vectors. + HIGH = 3; +} + +// Configuration of a single dense (embedding) vector. +message DenseVectorConfig { + // Dimensionality of the embedding, e.g. 512 (CLIP), 1536, 3072. + uint64 size = 1; + // Distance metric used to compare vectors. + Distance distance = 2; + // Store several sub-vectors per point and compare with max-sim, for + // late-interaction models (ColBERT, ColPali, ...). + bool multivector = 3; + // Precision/cost trade-off for this vector. Unset: HIGH. + optional PrecisionTier precision_tier = 4; +} + +// Configuration of a single sparse vector. +message SparseVectorConfig { + // Apply the IDF modifier at query time. Enable for BM25-style models that + // expect inverse-document-frequency weighting. + bool use_idf = 1; + // Precision/cost trade-off for this vector. Unset: HIGH. + optional PrecisionTier precision_tier = 2; +} + +// Full-text tokenizer, mirrors qdrant's `TokenizerType`. +enum Tokenizer { + // Unset: WHITESPACE. + TOKENIZER_UNSPECIFIED = 0; + // Index every prefix of each token. + PREFIX = 1; + // Split on whitespace. + WHITESPACE = 2; + // Split on word boundaries. + WORD = 3; + // Language-aware tokenization. + MULTILINGUAL = 4; +} + +// Exact match on string values, e.g. `color: "red"`. +message KeywordIndex { + // If set, enable prefix matching (`match: { "prefix": ... }`) on this field. + // Presence of this message enables prefix matching; it has no options yet. + optional KeywordPrefixParams prefix = 1; +} + +// Prefix matching options for the keyword index. Has no options yet: +// presence of this message enables prefix matching. +message KeywordPrefixParams {} + +// Exact match and/or range filters on integers, e.g. `age: 25`. Both are on +// by default; turning one off shrinks the index. +message IntegerIndex { + // Support exact-match filters. + optional bool lookup = 1; + // Support range filters. + optional bool range = 2; +} + +// Range filters on floating point (and integer) numbers, e.g. `price: 99.5`. +message FloatIndex {} + +// Exact match on UUID strings; like keyword but stored compactly. +message UuidIndex {} + +// Range filters on RFC 3339 datetimes, e.g. `created_at: "2023-02-08T10:49:00Z"`. +message DatetimeIndex {} + +// Tokens ignored by a full-text index. Language names match qdrant (e.g. +// "english"); predefined lists and custom tokens are merged. +message StopwordsSet { + // Languages whose predefined stopword lists to apply. + repeated string languages = 1; + // Extra stopwords to ignore, merged with the language lists. + repeated string custom = 2; +} + +// Snowball stemming for a full-text index. +message SnowballParams { + // Language for the snowball algorithm, e.g. "english". + string language = 1; +} + +// Explicitly disable stemming (overrides any language default). +message DisabledStemmer {} + +// Stemming algorithm for a full-text index. Unset: no stemming. +message StemmingAlgorithm { + // Which stemming algorithm to use. + oneof stemming_params { + // Snowball stemmer for the given language. + SnowballParams snowball = 1; + // Explicitly disable stemming. + DisabledStemmer disabled = 2; + } +} + +// Full-text filtering on string values. +message TextIndex { + // Tokenizer to split text with. Unset: WHITESPACE. + optional Tokenizer tokenizer = 1; + // Lowercase text before indexing. Default true. + optional bool lowercase = 2; + // Support phrase queries; extra index structure. Default true. + optional bool phrase_matching = 3; + // Minimum token length to index. + optional uint64 min_token_len = 4; + // Maximum token length to index. + optional uint64 max_token_len = 5; + // Fold accented characters to ASCII. Default false. + optional bool ascii_folding = 6; + // Tokens to ignore at index and query time. + optional StopwordsSet stopwords = 7; + // Stemming algorithm. Unset: engine default (no stemming). + optional StemmingAlgorithm stemmer = 8; +} + +// Geo radius / bounding box / polygon filters on `{lon, lat}` values. +message GeoIndex {} + +// Exact match on booleans, e.g. `is_active: true`. +message BoolIndex {} + +// One payload index. Only the *kind* of filter the field supports is chosen +// here; storage placement of the index is the manager's decision. +message PayloadIndexConfig { + // The kind of filter the field supports. + oneof index { + // Exact match on strings. + KeywordIndex keyword = 1; + // Exact match and/or range filters on integers. + IntegerIndex integer = 2; + // Range filters on numbers. + FloatIndex float = 3; + // Exact match on UUIDs. + UuidIndex uuid = 4; + // Range filters on datetimes. + DatetimeIndex datetime = 5; + // Full-text filtering. + TextIndex text = 6; + // Geo filters. + GeoIndex geo = 7; + // Exact match on booleans. + BoolIndex bool = 8; + } +} + +// The tenant's collection config. Persisted verbatim. At least one dense or +// sparse vector is required. +message CollectionConfig { + // Keyed by vector name. The empty name "" is the unnamed default vector, as + // in qdrant; a collection either has the single unnamed vector or named ones. + map dense_vectors = 1; + // Keyed by vector name. + map sparse_vectors = 2; + // Keyed by payload field name (JSON path, e.g. `user_id` or `meta.tags`). + map payload_indexes = 3; +} + +// Every request names the collection by its tenant-facing name only. The +// tenant (`x-account-id`, `x-space-id`) travels in gRPC metadata, injected by +// auth. The storage id is the manager's: minted on create, resolved +// internally on get/delete. +message CreateCollectionRequest { + // Tenant-facing name of the collection to create. + string collection_name = 1; + // The collection's configuration. + CollectionConfig config = 2; +} + +// Result of a create. +message CreateCollectionResponse { + // Tenant-facing name of the collection. + string collection_name = 1; + // Outcome, e.g. "created", "already exists". + string result = 2; +} + +// Names the collection to delete. +message DeleteCollectionRequest { + // Tenant-facing name of the collection to delete. + string collection_name = 1; +} + +// Result of a delete. +message DeleteCollectionResponse { + // Whether the collection existed and was deleted. + bool deleted = 1; + // Number of storage objects removed. + uint32 objects_deleted = 2; +} + +// Names the collection to fetch. +message GetCollectionRequest { + // Tenant-facing name of the collection. + string collection_name = 1; +} + +// The collection's configuration and stats, if it exists. +message GetCollectionResponse { + // Whether the collection exists. + bool exists = 1; + // The configuration the collection was created with. + optional CollectionConfig config = 2; + // Available points as of the last applied write (eventually consistent); + // absent until the updater has written stats for the collection. + optional uint64 point_count = 3; +} + +// Lists the caller's collections. The tenant travels in metadata. +message ListCollectionsRequest { + // Maximum number of collections to return. Defaults to 20 and must not + // exceed 100. + optional uint32 limit = 1; + // Opaque token returned as `next_offset_token` by the previous page. Clients + // must not interpret this value. + optional string offset_token = 2; +} + +// One collection in a listing. +message CollectionSummary { + // Tenant-facing name of the collection. + string collection_name = 1; + // Available points as of the last applied write (eventually consistent); + // absent until the updater has written stats for the collection. + optional uint64 point_count = 2; +} + +// The caller's collections. +message ListCollectionsResponse { + // Collections in this page. + repeated CollectionSummary collections = 1; + // Opaque token to pass as `offset_token` to retrieve the next page. Absent + // when there are no more results. + optional string next_offset_token = 2; +} diff --git a/tests/async-client-consistency-check.sh b/tests/async-client-consistency-check.sh index f3ebf2bc3..fe0e7814b 100755 --- a/tests/async-client-consistency-check.sh +++ b/tests/async-client-consistency-check.sh @@ -10,6 +10,7 @@ cd $CLIENT_DIR async_files=$(ls -1 async*) async_files+=" local/async_qdrant_local.py" +async_files+=" serverless/async_client.py" for file in $async_files ; do cp $file{,.diff} diff --git a/tests/conversions/serverless_fixtures.py b/tests/conversions/serverless_fixtures.py new file mode 100644 index 000000000..110347c6f --- /dev/null +++ b/tests/conversions/serverless_fixtures.py @@ -0,0 +1,128 @@ +from google.protobuf.message import Message + +from qdrant_client.serverless.grpc import serverless_collections_pb2 as pb2 + +# Dense vectors: every Distance and every PrecisionTier member is covered, so a +# mis-mapped enum constant cannot hide behind a self-consistent round-trip +# (_*_FROM_GRPC is derived by inverting _*_TO_GRPC). +dense_vector = pb2.DenseVectorConfig(size=1536, distance=pb2.COSINE) +dense_vector_multivector = pb2.DenseVectorConfig( + size=128, distance=pb2.DOT, multivector=True, precision_tier=pb2.LOW +) +dense_vector_manhattan = pb2.DenseVectorConfig( + size=4, distance=pb2.MANHATTAN, precision_tier=pb2.MEDIUM +) +dense_vector_euclid = pb2.DenseVectorConfig(size=4, distance=pb2.EUCLID, precision_tier=pb2.HIGH) + +sparse_vector = pb2.SparseVectorConfig(use_idf=True) +sparse_vector_tier = pb2.SparseVectorConfig(use_idf=False, precision_tier=pb2.HIGH) + +payload_index_keyword = pb2.PayloadIndexConfig(keyword=pb2.KeywordIndex()) +payload_index_integer = pb2.PayloadIndexConfig(integer=pb2.IntegerIndex()) +# `range=False` is set, not absent: catches `if range_:` in place of `is not None` +payload_index_integer_falsy = pb2.PayloadIndexConfig( + integer=pb2.IntegerIndex(lookup=True, range=False) +) +payload_index_float = pb2.PayloadIndexConfig(float=pb2.FloatIndex()) +payload_index_uuid = pb2.PayloadIndexConfig(uuid=pb2.UuidIndex()) +payload_index_datetime = pb2.PayloadIndexConfig(datetime=pb2.DatetimeIndex()) +payload_index_text = pb2.PayloadIndexConfig(text=pb2.TextIndex()) +# every optional TextIndex field set, with falsy values where they are legal +payload_index_text_full = pb2.PayloadIndexConfig( + text=pb2.TextIndex( + tokenizer=pb2.MULTILINGUAL, + lowercase=False, + phrase_matching=True, + min_token_len=0, + max_token_len=20, + ) +) +payload_index_text_prefix = pb2.PayloadIndexConfig(text=pb2.TextIndex(tokenizer=pb2.PREFIX)) +payload_index_text_whitespace = pb2.PayloadIndexConfig( + text=pb2.TextIndex(tokenizer=pb2.WHITESPACE) +) +payload_index_text_word = pb2.PayloadIndexConfig(text=pb2.TextIndex(tokenizer=pb2.WORD)) +payload_index_geo = pb2.PayloadIndexConfig(geo=pb2.GeoIndex()) +payload_index_bool = pb2.PayloadIndexConfig(bool=pb2.BoolIndex()) + +stopwords_empty = pb2.StopwordsSet() +stopwords_languages = pb2.StopwordsSet(languages=["english", "german"]) +stopwords_custom = pb2.StopwordsSet(languages=["english"], custom=["foo", "bar"]) + +stemmer_snowball = pb2.StemmingAlgorithm(snowball=pb2.SnowballParams(language="english")) +stemmer_disabled = pb2.StemmingAlgorithm(disabled=pb2.DisabledStemmer()) + +# presence on an empty submessage: `prefix` set but carrying no fields +payload_index_keyword_prefix = pb2.PayloadIndexConfig( + keyword=pb2.KeywordIndex(prefix=pb2.KeywordPrefixParams()) +) +payload_index_text_analysis = pb2.PayloadIndexConfig( + text=pb2.TextIndex( + ascii_folding=True, + stopwords=stopwords_custom, + stemmer=stemmer_snowball, + ) +) +payload_index_text_stemmer_disabled = pb2.PayloadIndexConfig( + text=pb2.TextIndex( + ascii_folding=False, + stopwords=stopwords_empty, + stemmer=stemmer_disabled, + ) +) + +collection_config = pb2.CollectionConfig( + dense_vectors={"": dense_vector}, + payload_indexes={"user_id": payload_index_keyword}, +) +collection_config_named_vectors = pb2.CollectionConfig( + dense_vectors={"dense": dense_vector, "colbert": dense_vector_multivector}, + sparse_vectors={"bm25": sparse_vector}, + payload_indexes={"age": payload_index_integer_falsy, "text": payload_index_text_full}, +) +collection_config_empty = pb2.CollectionConfig() + +fixtures: dict[str, list[Message]] = { + "dense_vector": [ + dense_vector, + dense_vector_multivector, + dense_vector_manhattan, + dense_vector_euclid, + ], + "sparse_vector": [ + sparse_vector, + sparse_vector_tier, + ], + "stopwords": [ + stopwords_empty, + stopwords_languages, + stopwords_custom, + ], + "stemmer": [ + stemmer_snowball, + stemmer_disabled, + ], + "payload_index": [ + payload_index_keyword, + payload_index_keyword_prefix, + payload_index_integer, + payload_index_integer_falsy, + payload_index_float, + payload_index_uuid, + payload_index_datetime, + payload_index_text, + payload_index_text_full, + payload_index_text_prefix, + payload_index_text_whitespace, + payload_index_text_word, + payload_index_text_analysis, + payload_index_text_stemmer_disabled, + payload_index_geo, + payload_index_bool, + ], + "collection_config": [ + collection_config, + collection_config_named_vectors, + collection_config_empty, + ], +} diff --git a/tests/conversions/test_validate_serverless_conversions.py b/tests/conversions/test_validate_serverless_conversions.py new file mode 100644 index 000000000..4db991343 --- /dev/null +++ b/tests/conversions/test_validate_serverless_conversions.py @@ -0,0 +1,124 @@ +from inspect import getmembers, isfunction +from typing import Any, Callable, get_args + +import pytest +from google.protobuf.json_format import MessageToDict + +from qdrant_client._pydantic_compat import model_fields +from qdrant_client.serverless import conversions, models +from qdrant_client.serverless.conversions import ( + _DISTANCE_TO_GRPC, + _PRECISION_TO_GRPC, + _TOKENIZER_TO_GRPC, +) +from qdrant_client.serverless.grpc import serverless_collections_pb2 as pb2 +from tests.conversions.serverless_fixtures import ( + fixtures as class_fixtures, +) + + +def _converters(suffix: str) -> dict[str, Callable[[Any], Any]]: + """Map converters by the model they convert, keyed independently of privacy. + + Sub-message converters are module-private (`_stemmer_to_grpc`), so the + leading underscore is stripped: a fixture key stays valid if a converter + later becomes public, or the reverse. + """ + matched = [ + (name, func) for name, func in getmembers(conversions, isfunction) if name.endswith(suffix) + ] + converters = {name[: -len(suffix)].lstrip("_"): func for name, func in matched} + assert len(converters) == len(matched), f"stem collision among {suffix} converters" + return converters + + +def test_conversion_completeness() -> None: + """Round-trip every fixture grpc -> model -> grpc, as tests/conversions does. + + Starting from the grpc side is what exercises server-authored messages; a + model -> grpc -> model round-trip cannot detect a mis-mapped enum, because + the decode maps are derived by inverting the encode maps. + """ + to_grpc, from_grpc = _converters("_to_grpc"), _converters("_from_grpc") + + assert set(to_grpc) == set(from_grpc), "every converter needs both directions" + assert set(to_grpc) == set(class_fixtures), "every converter needs a fixture" + + for model_name, fixtures in class_fixtures.items(): + for fixture in fixtures: + model_fixture = from_grpc[model_name](fixture) + + back_convert_function_name = f"{model_name}_to_grpc" + + print( + f"back_convert_function_name: {back_convert_function_name} for {type(model_fixture)}" + ) + + grpc_fixture = to_grpc[model_name](model_fixture) + assert MessageToDict(grpc_fixture) == MessageToDict( + fixture + ), f"{model_name} conversion is broken for {fixture}" + + +def test_every_payload_index_kind_has_a_fixture() -> None: + """Fails when the proto gains an index kind that nothing covers.""" + oneof = pb2.PayloadIndexConfig.DESCRIPTOR.oneofs_by_name["index"] + covered = {fixture.WhichOneof("index") for fixture in class_fixtures["payload_index"]} + assert covered == {field.name for field in oneof.fields} + + +@pytest.mark.parametrize( + "mapping,descriptor", + [ + (_DISTANCE_TO_GRPC, pb2.Distance.DESCRIPTOR), + (_PRECISION_TO_GRPC, pb2.PrecisionTier.DESCRIPTOR), + (_TOKENIZER_TO_GRPC, pb2.Tokenizer.DESCRIPTOR), + ], +) +def test_enum_maps_match_proto(mapping: dict, descriptor: Any) -> None: + """Pin each enum mapping by name, and require every proto member to be mapped. + + The round-trip alone cannot catch a swapped pair of constants; comparing the + proto member name against the model member name can. + """ + proto_names = { + value.name for value in descriptor.values if not value.name.endswith("_UNSPECIFIED") + } + assert proto_names == {descriptor.values_by_number[value].name for value in mapping.values()} + + for model_value, grpc_value in mapping.items(): + assert descriptor.values_by_number[grpc_value].name == model_value.name + + +@pytest.mark.parametrize( + "model,message", + [ + (models.DenseVectorConfig, pb2.DenseVectorConfig), + (models.SparseVectorConfig, pb2.SparseVectorConfig), + (models.CollectionConfig, pb2.CollectionConfig), + (models.KeywordIndex, pb2.KeywordIndex), + (models.KeywordPrefixParams, pb2.KeywordPrefixParams), + (models.IntegerIndex, pb2.IntegerIndex), + (models.TextIndex, pb2.TextIndex), + (models.StopwordsSet, pb2.StopwordsSet), + (models.SnowballParams, pb2.SnowballParams), + (models.StemmingAlgorithm, pb2.StemmingAlgorithm), + # response shapes: a dropped field here silently loses server data + (models.CollectionInfo, pb2.GetCollectionResponse), + (models.CollectionSummary, pb2.CollectionSummary), + (models.CollectionsList, pb2.ListCollectionsResponse), + ], +) +def test_models_match_proto_messages(model: Any, message: Any) -> None: + """A field added on either side has to be added on the other.""" + # `type` is the client-side union tag, it has no proto counterpart + assert set(model_fields(model)) - {"type"} == { + field.name for field in message.DESCRIPTOR.fields + } + + +def test_payload_index_union_covers_every_model() -> None: + kinds = [index_type().type for index_type in get_args(models.PayloadIndex)] + assert sorted(kinds) == sorted( + field.name for field in pb2.PayloadIndexConfig.DESCRIPTOR.oneofs_by_name["index"].fields + ) diff --git a/tests/coverage-test.sh b/tests/coverage-test.sh index 2b9694083..845ff4537 100755 --- a/tests/coverage-test.sh +++ b/tests/coverage-test.sh @@ -5,4 +5,7 @@ set -ex coverage run --include='qdrant_client/conversions/conversion.py' -m pytest tests/conversions/test_validate_conversions.py -vv -s coverage report --fail-under=98 +coverage run --include='qdrant_client/serverless/conversions.py' -m pytest tests/conversions/test_validate_serverless_conversions.py -vv -s +coverage report --fail-under=98 + #coverage html diff --git a/tests/test_serverless.py b/tests/test_serverless.py new file mode 100644 index 000000000..59fa02d20 --- /dev/null +++ b/tests/test_serverless.py @@ -0,0 +1,99 @@ +import inspect + +from qdrant_client.serverless.models import ( + CollectionConfig, + DenseVectorConfig, + Distance, + IntegerIndex, + KeywordIndex, + KeywordPrefixParams, + PrecisionTier, + SnowballParams, + SparseVectorConfig, + StemmingAlgorithm, + StopwordsSet, + TextIndex, + TokenizerType, +) +from qdrant_client.serverless import AsyncQdrantServerless, QdrantServerless +from qdrant_client.serverless.conversions import ( + collection_config_from_grpc, + collection_config_to_grpc, +) + + +def test_collection_config_grpc_roundtrip() -> None: + config = CollectionConfig( + dense_vectors={ + "": DenseVectorConfig(size=1536, distance=Distance.COSINE), + "colbert": DenseVectorConfig( + size=128, + distance=Distance.DOT, + multivector=True, + precision_tier=PrecisionTier.LOW, + ), + }, + sparse_vectors={"bm25": SparseVectorConfig(use_idf=True)}, + payload_indexes={ + "user_id": KeywordIndex(prefix=KeywordPrefixParams()), + "age": IntegerIndex(lookup=True, range=False), + "description": TextIndex( + tokenizer=TokenizerType.WORD, + lowercase=False, + ascii_folding=True, + stopwords=StopwordsSet(languages=["english"]), + stemmer=StemmingAlgorithm(snowball=SnowballParams(language="english")), + ), + }, + ) + assert collection_config_from_grpc(collection_config_to_grpc(config)) == config + + +def test_optional_fields_stay_unset() -> None: + config = CollectionConfig( + dense_vectors={"": DenseVectorConfig(size=4, distance=Distance.EUCLID)}, + payload_indexes={ + "age": IntegerIndex(), + "user_id": KeywordIndex(), + "text": TextIndex(), + }, + ) + grpc_config = collection_config_to_grpc(config) + assert not grpc_config.dense_vectors[""].HasField("precision_tier") + assert not grpc_config.payload_indexes["age"].integer.HasField("lookup") + assert not grpc_config.payload_indexes["user_id"].keyword.HasField("prefix") + assert not grpc_config.payload_indexes["text"].text.HasField("tokenizer") + assert not grpc_config.payload_indexes["text"].text.HasField("ascii_folding") + assert not grpc_config.payload_indexes["text"].text.HasField("stopwords") + assert not grpc_config.payload_indexes["text"].text.HasField("stemmer") + assert collection_config_from_grpc(grpc_config) == config + + +def test_client_construction_is_offline() -> None: + client = QdrantServerless(url="https://serverless.example.qdrant.io", api_key="secret") + assert ("api-key", "secret") in client._remote._grpc_headers + assert client._remote._grpc_port == 443 + assert client._remote._https + client.close() + + +def test_async_client_mirrors_sync_client() -> None: + sync_methods = { + name + for name, _ in inspect.getmembers(QdrantServerless, predicate=inspect.isfunction) + if not name.startswith("__") + } + async_methods = { + name + for name, _ in inspect.getmembers(AsyncQdrantServerless, predicate=inspect.isfunction) + if not name.startswith("__") + } + assert sync_methods == async_methods + for name in async_methods: + if name.startswith("_"): + continue + assert inspect.iscoroutinefunction(getattr(AsyncQdrantServerless, name)), name + + client = AsyncQdrantServerless(url="https://serverless.example.qdrant.io", api_key="secret") + assert ("api-key", "secret") in client._remote._grpc_headers + assert client._remote._grpc_port == 443 diff --git a/tools/async_client_generator/serverless_generator.py b/tools/async_client_generator/serverless_generator.py new file mode 100644 index 000000000..c2a4da858 --- /dev/null +++ b/tools/async_client_generator/serverless_generator.py @@ -0,0 +1,121 @@ +import ast +import inspect + +from qdrant_client.async_qdrant_remote import AsyncQdrantRemote +from qdrant_client.serverless.grpc.serverless_collections_pb2_grpc import CollectionsServiceStub +from tools.async_client_generator.base_generator import BaseGenerator +from tools.async_client_generator.transformers import ( + CallTransformer, + ClassDefTransformer, + ConstantTransformer, + FunctionDefTransformer, + ImportTransformer, + NameTransformer, +) +from tools.async_client_generator.transformers.remote import RemoteImportFromTransformer + + +class ServerlessFunctionDefTransformer(FunctionDefTransformer): + """FunctionDefTransformer with method removal. + + RemoteFunctionDefTransformer is not reusable here: it overrides `close` with an + AsyncQdrantRemote-specific body, while the serverless close just delegates. + """ + + def __init__( + self, + keep_sync: list[str] | None = None, + exclude_methods: list[str] | None = None, + ): + super().__init__(keep_sync=keep_sync) + self.exclude_methods = exclude_methods if exclude_methods is not None else [] + + def visit_FunctionDef(self, sync_node: ast.FunctionDef) -> ast.AST | None: + if sync_node.name in self.exclude_methods: + return None + return super().visit_FunctionDef(sync_node) + + +# Same helpers as RemoteGenerator; that module cannot be imported standalone since it +# depends on the generated async_client_base, which only exists mid-run of the script. +def get_async_methods(class_obj: type) -> list[str]: + return [ + name + for name, method in inspect.getmembers(class_obj) + if inspect.iscoroutinefunction(method) + ] + + +def get_grpc_methods(grpc_stub_class: type) -> list[str]: + parsed = ast.parse(inspect.getsource(grpc_stub_class)) + return [ + target.attr + for node in ast.walk(parsed) + if isinstance(node, ast.Assign) + for target in node.targets + if isinstance(target, ast.Attribute) + and isinstance(target.value, ast.Name) + and target.value.id == "self" + ] + + +class ServerlessGenerator(BaseGenerator): + def __init__(self) -> None: + super().__init__() + + class_replace_map = { + "QdrantServerless": "AsyncQdrantServerless", + "QdrantRemote": "AsyncQdrantRemote", + } + import_replace_map = { + "qdrant_client.qdrant_remote": "qdrant_client.async_qdrant_remote", + "QdrantRemote": "AsyncQdrantRemote", + } + # delegated point/collection methods are coroutines on AsyncQdrantRemote; + # stub RPCs are awaitable on an aio channel + async_methods = get_async_methods(AsyncQdrantRemote) + get_grpc_methods( + CollectionsServiceStub + ) + + self.transformers.append( + RemoteImportFromTransformer(import_replace_map=import_replace_map) + ) + self.transformers.append(ClassDefTransformer(class_replace_map=class_replace_map)) + self.transformers.append( + CallTransformer(class_replace_map=class_replace_map, async_methods=async_methods) + ) + self.transformers.append(ImportTransformer(import_replace_map=import_replace_map)) + self.transformers.append( + ServerlessFunctionDefTransformer( + keep_sync=["__init__", "_collections", "_collections_timeout"], + # a sync context manager makes no sense on the async client; + # the regular async client has none either + exclude_methods=["__enter__", "__exit__"], + ) + ) + self.transformers.append( + NameTransformer( + class_replace_map=class_replace_map, import_replace_map=import_replace_map + ) + ) + self.transformers.append( + ConstantTransformer( + constant_replace_map={ + "QdrantServerless": "AsyncQdrantServerless", + ">>> client.create_collection(": ">>> await client.create_collection(", + } + ) + ) + + +if __name__ == "__main__": + from tools.async_client_generator.config import CLIENT_DIR, CODE_DIR + + with open(CLIENT_DIR / "serverless" / "client.py", "r") as source_file: + code = source_file.read() + + generator = ServerlessGenerator() + modified_code = generator.generate(code) + + with open(CODE_DIR / "async_client.py", "w") as target_file: + target_file.write(modified_code) diff --git a/tools/generate_async_client.sh b/tools/generate_async_client.sh index d19d4e614..c0dadf5cf 100755 --- a/tools/generate_async_client.sh +++ b/tools/generate_async_client.sh @@ -11,6 +11,7 @@ python3 -m tools.async_client_generator.fastembed_generator python3 -m tools.async_client_generator.client_generator python3 -m tools.async_client_generator.remote_generator python3 -m tools.async_client_generator.local_generator +python3 -m tools.async_client_generator.serverless_generator cd $ABSOLUTE_PROJECT_ROOT/tools/async_client_generator @@ -19,6 +20,7 @@ mv async_qdrant_client.py $ABSOLUTE_PROJECT_ROOT/qdrant_client/async_qdrant_clie mv async_qdrant_fastembed.py $ABSOLUTE_PROJECT_ROOT/qdrant_client/async_qdrant_fastembed.py mv async_qdrant_remote.py $ABSOLUTE_PROJECT_ROOT/qdrant_client/async_qdrant_remote.py mv async_qdrant_local.py $ABSOLUTE_PROJECT_ROOT/qdrant_client/async_qdrant_local.py +mv async_client.py $ABSOLUTE_PROJECT_ROOT/qdrant_client/async_serverless_client.py cd $ABSOLUTE_PROJECT_ROOT/qdrant_client @@ -26,3 +28,4 @@ ls -1 async*.py | autoflake --recursive --imports qdrant_client --remove-unused- ls -1 async*.py | xargs -I {} ruff format --line-length 99 {} mv async_qdrant_local.py local/async_qdrant_local.py +mv async_serverless_client.py serverless/async_client.py diff --git a/tools/generate_serverless_grpc_client.sh b/tools/generate_serverless_grpc_client.sh new file mode 100755 index 000000000..1dc3da816 --- /dev/null +++ b/tools/generate_serverless_grpc_client.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Regenerates qdrant_client/serverless/grpc from the public serverless collections proto. +# Mirrors tools/generate_grpc_client.sh (same pinned tool versions). + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEMP_ENV=$(mktemp -d) +VENV_DIR="$TEMP_ENV/grpc_generator_venv" + +trap "rm -rf \"$TEMP_ENV\"" EXIT + +PYTHON_BIN="" +if [[ "$(python --version 2>&1 | awk '{print $2}')" == "3.10.10" ]]; then + PYTHON_BIN="python" +elif [[ "$(python3 --version 2>&1 | awk '{print $2}')" == "3.10.10" ]]; then + PYTHON_BIN="python3" +elif [[ "$(python3.10 --version 2>&1 | awk '{print $2}')" == "3.10.10" ]]; then + PYTHON_BIN="python3.10" +fi + +if [[ -z "$PYTHON_BIN" ]]; then + echo "Error: No suitable Python 3.10.10 installation found among {python, python3, python3.10}" >&2 + exit 1 +fi + +"$PYTHON_BIN" -m venv "$VENV_DIR" +source "$VENV_DIR/bin/activate" + +pip install --upgrade pip +pip install "grpcio==1.62.0" +pip install "grpcio-tools==1.62.0" +pip install "mypy-protobuf==3.3.0" + +cd "$PROJECT_ROOT" +PROTO_DIR="qdrant_client/serverless/proto" +OUT_DIR="qdrant_client/serverless/grpc" + +# Renamed from collections.proto: the protobuf descriptor pool registers files by name, +# and "collections.proto" is already taken by the regular qdrant client proto. +# Client copy: buf.validate options are stripped (server-side only; wire format unchanged). +HEADER="// Source: https://github.com/qdrant/qdrant-cloud-public-api/blob/main/proto/qdrant/serverless/collections.proto +// Renamed to serverless_collections.proto: the protobuf descriptor pool registers files by +// name, and \"collections.proto\" is already taken by the regular qdrant client proto. +// Client copy: buf.validate options are stripped (server-side only; wire format unchanged). +// Regenerate with tools/generate_serverless_grpc_client.sh" +TMP_PROTO="$(mktemp)" +curl -fsSL https://raw.githubusercontent.com/qdrant/qdrant-cloud-public-api/main/proto/qdrant/serverless/collections.proto \ + > "$TMP_PROTO" +python3 - "$TMP_PROTO" "$PROTO_DIR/serverless_collections.proto" "$HEADER" <<'PY' +import re, sys +src_path, out_path, header = sys.argv[1], sys.argv[2], sys.argv[3] +src = open(src_path).read() +src = re.sub(r'\nimport "buf/validate/validate\.proto";\n', '\n', src) +src = re.sub( + r' \[\(buf\.validate\.field\)\.uint32 = \{\s*gt: 0\s*lte: 100\s*\}\]', + '', + src, +) +if 'buf.validate' in src: + raise SystemExit('failed to strip buf.validate annotations from collections.proto') +open(out_path, 'w').write(header + '\n' + src) +PY +rm -f "$TMP_PROTO" + +"$VENV_DIR/bin/python" -m grpc_tools.protoc \ + --proto_path="$PROTO_DIR" \ + "$PROTO_DIR"/serverless_collections.proto \ + --python_out="$OUT_DIR" \ + --grpc_python_out="$OUT_DIR" \ + --mypy_out="$OUT_DIR" + +# https://github.com/protocolbuffers/protobuf/issues/1491 +sed -i -re 's/^import (\w*)_pb2/from . import \1_pb2/g' "$OUT_DIR"/*.py + +deactivate