From c77bd15d2759e50fb2e398fa8c393b1b5d4fdd22 Mon Sep 17 00:00:00 2001 From: generall Date: Tue, 1 Sep 2026 20:10:34 +0200 Subject: [PATCH 01/14] new: add QdrantServerless client prototype Serverless exposes the same point-level API as a regular cluster (minus read consistency, shard selection, write ordering and filtered updates), but a simplified tenant-facing collection management API. - qdrant_client/serverless: dedicated module, nothing added to the top-level package - gRPC stubs generated from qdrant-cloud-public-api's serverless/collections.proto (renamed to serverless_collections.proto to avoid a descriptor-pool filename clash with the regular client's collections.proto), kept internal - hand-written pydantic models for the serverless collection config, reusing the existing Distance/TokenizerType enums - point operations delegate to an internal QdrantRemote(prefer_grpc=True) with trimmed signatures; collection operations talk to the serverless CollectionsService on the same channel Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw --- examples/serverless_client.py | 49 ++ qdrant_client/serverless/__init__.py | 58 ++ qdrant_client/serverless/client.py | 358 ++++++++++ qdrant_client/serverless/conversions.py | 168 +++++ qdrant_client/serverless/grpc/__init__.py | 2 + .../grpc/serverless_collections_pb2.py | 86 +++ .../grpc/serverless_collections_pb2.pyi | 648 ++++++++++++++++++ .../grpc/serverless_collections_pb2_grpc.py | 181 +++++ qdrant_client/serverless/models.py | 159 +++++ .../proto/serverless_collections.proto | 233 +++++++ tests/test_serverless.py | 57 ++ tools/generate_serverless_grpc_client.sh | 59 ++ 12 files changed, 2058 insertions(+) create mode 100644 examples/serverless_client.py create mode 100644 qdrant_client/serverless/__init__.py create mode 100644 qdrant_client/serverless/client.py create mode 100644 qdrant_client/serverless/conversions.py create mode 100644 qdrant_client/serverless/grpc/__init__.py create mode 100644 qdrant_client/serverless/grpc/serverless_collections_pb2.py create mode 100644 qdrant_client/serverless/grpc/serverless_collections_pb2.pyi create mode 100644 qdrant_client/serverless/grpc/serverless_collections_pb2_grpc.py create mode 100644 qdrant_client/serverless/models.py create mode 100644 qdrant_client/serverless/proto/serverless_collections.proto create mode 100644 tests/test_serverless.py create mode 100755 tools/generate_serverless_grpc_client.sh diff --git a/examples/serverless_client.py b/examples/serverless_client.py new file mode 100644 index 000000000..c57073f0a --- /dev/null +++ b/examples/serverless_client.py @@ -0,0 +1,49 @@ +""" +Example of using the Qdrant Serverless client. + +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 import ( + DenseVectorConfig, + Distance, + KeywordIndex, + QdrantServerless, +) + + +def main() -> None: + client = QdrantServerless( + url="https://serverless.plush-volt.aws.development-cloud.qdrant.io", + api_key="", + ) + + # 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/qdrant_client/serverless/__init__.py b/qdrant_client/serverless/__init__.py new file mode 100644 index 000000000..7023baa47 --- /dev/null +++ b/qdrant_client/serverless/__init__.py @@ -0,0 +1,58 @@ +"""Client for Qdrant Serverless. + +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, 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.client import QdrantServerless +from qdrant_client.serverless.models import ( + BoolIndex, + CollectionConfig, + CollectionInfo, + CollectionSummary, + DatetimeIndex, + DenseVectorConfig, + Distance, + FloatIndex, + GeoIndex, + IntegerIndex, + KeywordIndex, + PayloadIndex, + PrecisionTier, + SparseVectorConfig, + TextIndex, + TokenizerType, + UuidIndex, +) + +__all__ = [ + "QdrantServerless", + "BoolIndex", + "CollectionConfig", + "CollectionInfo", + "CollectionSummary", + "DatetimeIndex", + "DenseVectorConfig", + "Distance", + "FloatIndex", + "GeoIndex", + "IntegerIndex", + "KeywordIndex", + "PayloadIndex", + "PrecisionTier", + "SparseVectorConfig", + "TextIndex", + "TokenizerType", + "UuidIndex", +] diff --git a/qdrant_client/serverless/client.py b/qdrant_client/serverless/client.py new file mode 100644 index 000000000..7e8d01df0 --- /dev/null +++ b/qdrant_client/serverless/client.py @@ -0,0 +1,358 @@ +"""Client for Qdrant Serverless. + +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 typing import Any, Optional, Sequence + +from qdrant_client.conversions import common_types as types +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. + + 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]) + 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.""" + self._grpc_collections = None + self._remote.close(grpc_grace=grpc_grace, **kwargs) + + def __enter__(self) -> "QdrantServerless": + return self + + def __exit__(self, *args: Any) -> None: + self.close() + + # 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. + + At least one dense or sparse vector is required. A bare (non-dict) + vector config is registered as the unnamed default vector, like in + regular qdrant. + + Returns: + Outcome, e.g. "created" or "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. + + Returns: + True if the collection existed and was deleted + """ + 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. + + Does not raise if the collection is missing: check `.exists`. + """ + 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.""" + return self.get_collection(collection_name, timeout=timeout).exists + + def get_collections( + self, timeout: Optional[int] = None + ) -> list[serverless_models.CollectionSummary]: + """Lists the collections of the space, ordered by name.""" + response = self._collections.ListCollections( + pb2.ListCollectionsRequest(), + timeout=self._collections_timeout(timeout), + ) + return [ + 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 + ] + + # 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, + lookup_from: Optional[types.LookupLocation] = 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.""" + 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, + lookup_from=lookup_from, + 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.""" + 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]]: + """Iterates over all points, optionally filtered. + + Returns a page of points and the offset of the next page (None if done). + """ + 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, optionally filtered.""" + 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 = True, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Inserts or updates points.""" + return self._remote.upsert( + collection_name=collection_name, + points=points, + wait=wait, + timeout=timeout, + ) + + def delete( + self, + collection_name: str, + ids: Sequence[types.PointId], + wait: bool = True, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Deletes points by ids. Serverless does not support deletion by filter.""" + return self._remote.delete( + collection_name=collection_name, + points_selector=list(ids), + wait=wait, + timeout=timeout, + ) + + def set_payload( + self, + collection_name: str, + payload: types.Payload, + ids: Sequence[types.PointId], + key: Optional[str] = None, + wait: bool = True, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Merges the given payload into the payload of the given points.""" + return self._remote.set_payload( + collection_name=collection_name, + payload=payload, + points=list(ids), + key=key, + wait=wait, + timeout=timeout, + ) + + def delete_payload( + self, + collection_name: str, + keys: Sequence[str], + ids: Sequence[types.PointId], + wait: bool = True, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Removes the given payload keys from the given points.""" + return self._remote.delete_payload( + collection_name=collection_name, + keys=keys, + points=list(ids), + 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..6c5bdb6d7 --- /dev/null +++ b/qdrant_client/serverless/conversions.py @@ -0,0 +1,168 @@ +"""Conversions between serverless pydantic models and the internal gRPC types. + +The generated gRPC types are an implementation detail and must not leak into +the public interface. +""" + +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: + result = pb2.DenseVectorConfig( + size=model.size, + distance=_DISTANCE_TO_GRPC[model.distance], + multivector=model.multivector, + ) + if model.precision_tier is not None: + result.precision_tier = _PRECISION_TO_GRPC[model.precision_tier] + return result + + +def dense_vector_from_grpc(grpc_model: pb2.DenseVectorConfig) -> models.DenseVectorConfig: + return models.DenseVectorConfig( + size=grpc_model.size, + distance=_DISTANCE_FROM_GRPC[grpc_model.distance], + multivector=grpc_model.multivector, + precision_tier=_PRECISION_FROM_GRPC[grpc_model.precision_tier] + if grpc_model.HasField("precision_tier") + else None, + ) + + +def sparse_vector_to_grpc(model: models.SparseVectorConfig) -> pb2.SparseVectorConfig: + result = pb2.SparseVectorConfig(use_idf=model.use_idf) + if model.precision_tier is not None: + result.precision_tier = _PRECISION_TO_GRPC[model.precision_tier] + return result + + +def sparse_vector_from_grpc(grpc_model: pb2.SparseVectorConfig) -> models.SparseVectorConfig: + return 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, + ) + + +def payload_index_to_grpc(model: models.PayloadIndex) -> pb2.PayloadIndexConfig: + result = pb2.PayloadIndexConfig() + if isinstance(model, models.KeywordIndex): + result.keyword.SetInParent() + elif isinstance(model, models.IntegerIndex): + result.integer.SetInParent() + if model.lookup is not None: + result.integer.lookup = model.lookup + if model.range is not None: + result.integer.range = model.range + elif isinstance(model, models.FloatIndex): + result.float.SetInParent() + elif isinstance(model, models.UuidIndex): + result.uuid.SetInParent() + elif isinstance(model, models.DatetimeIndex): + result.datetime.SetInParent() + elif isinstance(model, models.TextIndex): + result.text.SetInParent() + if model.tokenizer is not None: + result.text.tokenizer = _TOKENIZER_TO_GRPC[model.tokenizer] + if model.lowercase is not None: + result.text.lowercase = model.lowercase + if model.phrase_matching is not None: + result.text.phrase_matching = model.phrase_matching + if model.min_token_len is not None: + result.text.min_token_len = model.min_token_len + if model.max_token_len is not None: + result.text.max_token_len = model.max_token_len + elif isinstance(model, models.GeoIndex): + result.geo.SetInParent() + elif isinstance(model, models.BoolIndex): + result.bool.SetInParent() + else: + 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": + return models.KeywordIndex() + if kind == "integer": + integer = grpc_model.integer + return models.IntegerIndex( + lookup=integer.lookup if integer.HasField("lookup") else None, + range=integer.range if integer.HasField("range") else None, + ) + if kind == "float": + return models.FloatIndex() + if kind == "uuid": + return models.UuidIndex() + if kind == "datetime": + return models.DatetimeIndex() + if kind == "text": + text = grpc_model.text + return models.TextIndex( + 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, + ) + if kind == "geo": + return models.GeoIndex() + if kind == "bool": + return models.BoolIndex() + raise ValueError(f"Unknown payload index type: {kind}") + + +def collection_config_to_grpc(model: models.CollectionConfig) -> pb2.CollectionConfig: + result = pb2.CollectionConfig() + for name, dense in model.dense_vectors.items(): + result.dense_vectors[name].CopyFrom(dense_vector_to_grpc(dense)) + for name, sparse in model.sparse_vectors.items(): + result.sparse_vectors[name].CopyFrom(sparse_vector_to_grpc(sparse)) + for field, index in model.payload_indexes.items(): + result.payload_indexes[field].CopyFrom(payload_index_to_grpc(index)) + return result + + +def collection_config_from_grpc(grpc_model: pb2.CollectionConfig) -> models.CollectionConfig: + return 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() + }, + ) 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..16753034c --- /dev/null +++ b/qdrant_client/serverless/grpc/serverless_collections_pb2.py @@ -0,0 +1,86 @@ +# -*- 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\"\x0e\n\x0cKeywordIndex\"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\"\x83\x02\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\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_len\"\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\"\x18\n\x16ListCollectionsRequest\"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\"T\n\x17ListCollectionsResponse\x12\x39\n\x0b\x63ollections\x18\x01 \x03(\x0b\x32$.qdrant.serverless.CollectionSummary*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=2440 + _globals['_DISTANCE']._serialized_end=2524 + _globals['_PRECISIONTIER']._serialized_start=2526 + _globals['_PRECISIONTIER']._serialized_end=2604 + _globals['_TOKENIZER']._serialized_start=2606 + _globals['_TOKENIZER']._serialized_end=2700 + _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=372 + _globals['_INTEGERINDEX']._serialized_start=374 + _globals['_INTEGERINDEX']._serialized_end=450 + _globals['_FLOATINDEX']._serialized_start=452 + _globals['_FLOATINDEX']._serialized_end=464 + _globals['_UUIDINDEX']._serialized_start=466 + _globals['_UUIDINDEX']._serialized_end=477 + _globals['_DATETIMEINDEX']._serialized_start=479 + _globals['_DATETIMEINDEX']._serialized_end=494 + _globals['_TEXTINDEX']._serialized_start=497 + _globals['_TEXTINDEX']._serialized_end=756 + _globals['_GEOINDEX']._serialized_start=758 + _globals['_GEOINDEX']._serialized_end=768 + _globals['_BOOLINDEX']._serialized_start=770 + _globals['_BOOLINDEX']._serialized_end=781 + _globals['_PAYLOADINDEXCONFIG']._serialized_start=784 + _globals['_PAYLOADINDEXCONFIG']._serialized_end=1201 + _globals['_COLLECTIONCONFIG']._serialized_start=1204 + _globals['_COLLECTIONCONFIG']._serialized_end=1740 + _globals['_COLLECTIONCONFIG_DENSEVECTORSENTRY']._serialized_start=1464 + _globals['_COLLECTIONCONFIG_DENSEVECTORSENTRY']._serialized_end=1553 + _globals['_COLLECTIONCONFIG_SPARSEVECTORSENTRY']._serialized_start=1555 + _globals['_COLLECTIONCONFIG_SPARSEVECTORSENTRY']._serialized_end=1646 + _globals['_COLLECTIONCONFIG_PAYLOADINDEXESENTRY']._serialized_start=1648 + _globals['_COLLECTIONCONFIG_PAYLOADINDEXESENTRY']._serialized_end=1740 + _globals['_CREATECOLLECTIONREQUEST']._serialized_start=1742 + _globals['_CREATECOLLECTIONREQUEST']._serialized_end=1845 + _globals['_CREATECOLLECTIONRESPONSE']._serialized_start=1847 + _globals['_CREATECOLLECTIONRESPONSE']._serialized_end=1914 + _globals['_DELETECOLLECTIONREQUEST']._serialized_start=1916 + _globals['_DELETECOLLECTIONREQUEST']._serialized_end=1966 + _globals['_DELETECOLLECTIONRESPONSE']._serialized_start=1968 + _globals['_DELETECOLLECTIONRESPONSE']._serialized_end=2036 + _globals['_GETCOLLECTIONREQUEST']._serialized_start=2038 + _globals['_GETCOLLECTIONREQUEST']._serialized_end=2085 + _globals['_GETCOLLECTIONRESPONSE']._serialized_start=2088 + _globals['_GETCOLLECTIONRESPONSE']._serialized_end=2238 + _globals['_LISTCOLLECTIONSREQUEST']._serialized_start=2240 + _globals['_LISTCOLLECTIONSREQUEST']._serialized_end=2264 + _globals['_COLLECTIONSUMMARY']._serialized_start=2266 + _globals['_COLLECTIONSUMMARY']._serialized_end=2352 + _globals['_LISTCOLLECTIONSRESPONSE']._serialized_start=2354 + _globals['_LISTCOLLECTIONSRESPONSE']._serialized_end=2438 + _globals['_COLLECTIONSSERVICE']._serialized_start=2703 + _globals['_COLLECTIONSSERVICE']._serialized_end=3147 +# @@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..90f3c10a3 --- /dev/null +++ b/qdrant_client/serverless/grpc/serverless_collections_pb2.pyi @@ -0,0 +1,648 @@ +""" +@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. +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 + + def __init__( + self, + ) -> None: ... + +global___KeywordIndex = KeywordIndex + +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 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 + 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.""" + 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 = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["_lowercase", b"_lowercase", "_max_token_len", b"_max_token_len", "_min_token_len", b"_min_token_len", "_phrase_matching", b"_phrase_matching", "_tokenizer", b"_tokenizer", "lowercase", b"lowercase", "max_token_len", b"max_token_len", "min_token_len", b"min_token_len", "phrase_matching", b"phrase_matching", "tokenizer", b"tokenizer"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["_lowercase", b"_lowercase", "_max_token_len", b"_max_token_len", "_min_token_len", b"_min_token_len", "_phrase_matching", b"_phrase_matching", "_tokenizer", b"_tokenizer", "lowercase", b"lowercase", "max_token_len", b"max_token_len", "min_token_len", b"min_token_len", "phrase_matching", b"phrase_matching", "tokenizer", b"tokenizer"]) -> 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["_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, so there is + nothing to name here. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> 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 + @property + def collections(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___CollectionSummary]: + """Ordered by name. A collection whose creation never published a manifest is + not listed: it is not servable. + """ + def __init__( + self, + *, + collections: collections.abc.Iterable[global___CollectionSummary] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["collections", b"collections"]) -> 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..9c425fa67 --- /dev/null +++ b/qdrant_client/serverless/models.py @@ -0,0 +1,159 @@ +"""Pydantic models for the Qdrant Serverless collection management API. + +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 + +__all__ = [ + "Distance", + "TokenizerType", + "PrecisionTier", + "DenseVectorConfig", + "SparseVectorConfig", + "KeywordIndex", + "IntegerIndex", + "FloatIndex", + "UuidIndex", + "DatetimeIndex", + "TextIndex", + "GeoIndex", + "BoolIndex", + "PayloadIndex", + "CollectionConfig", + "CollectionInfo", + "CollectionSummary", +] + + +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 KeywordIndex(BaseModel): + """Exact match on string values, e.g. `color: "red"`.""" + + type: Literal["keyword"] = "keyword" + + +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 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 + + +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 diff --git a/qdrant_client/serverless/proto/serverless_collections.proto b/qdrant_client/serverless/proto/serverless_collections.proto new file mode 100644 index 000000000..75e979bbd --- /dev/null +++ b/qdrant_client/serverless/proto/serverless_collections.proto @@ -0,0 +1,233 @@ +// 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. +// 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 {} + +// 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 {} + +// 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; +} + +// 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, so there is +// nothing to name here. +message ListCollectionsRequest {} + +// 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 { + // Ordered by name. A collection whose creation never published a manifest is + // not listed: it is not servable. + repeated CollectionSummary collections = 1; +} diff --git a/tests/test_serverless.py b/tests/test_serverless.py new file mode 100644 index 000000000..11ebddb82 --- /dev/null +++ b/tests/test_serverless.py @@ -0,0 +1,57 @@ +from qdrant_client.serverless import ( + CollectionConfig, + DenseVectorConfig, + Distance, + IntegerIndex, + KeywordIndex, + PrecisionTier, + QdrantServerless, + SparseVectorConfig, + TextIndex, + TokenizerType, +) +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(), + "age": IntegerIndex(lookup=True, range=False), + "description": TextIndex(tokenizer=TokenizerType.WORD, lowercase=False), + }, + ) + 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(), "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["text"].text.HasField("tokenizer") + 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() diff --git a/tools/generate_serverless_grpc_client.sh b/tools/generate_serverless_grpc_client.sh new file mode 100755 index 000000000..373fc826a --- /dev/null +++ b/tools/generate_serverless_grpc_client.sh @@ -0,0 +1,59 @@ +#!/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. +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. +// Regenerate with tools/generate_serverless_grpc_client.sh" +echo "$HEADER" > "$PROTO_DIR/serverless_collections.proto" +curl -fsSL https://raw.githubusercontent.com/qdrant/qdrant-cloud-public-api/main/proto/qdrant/serverless/collections.proto \ + >> "$PROTO_DIR/serverless_collections.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 From 6ae38dbe9b78af044e6d72718447981d9602b030 Mon Sep 17 00:00:00 2001 From: generall Date: Tue, 1 Sep 2026 20:18:26 +0200 Subject: [PATCH 02/14] fix: exclude generated serverless grpc code from mypy Same treatment as qdrant_client/grpc: generated stubs have untyped defs, and the ListCollectionsResponse.collections field shadows the collections module in the .pyi. Also import PointStruct from qdrant_client.http.models in the example, matching the rest of the codebase. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw --- examples/serverless_client.py | 2 +- mypy.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/serverless_client.py b/examples/serverless_client.py index c57073f0a..c6e9f1d1d 100644 --- a/examples/serverless_client.py +++ b/examples/serverless_client.py @@ -5,7 +5,7 @@ (query, upsert, ...) work exactly like in the regular client. """ -from qdrant_client.models import PointStruct +from qdrant_client.http.models import PointStruct from qdrant_client.serverless import ( DenseVectorConfig, Distance, diff --git a/mypy.ini b/mypy.ini index 8d3d00881..ff8576ac0 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 From fa1f3c3d2e79f37cf9cda7c4307a847768c97084 Mon Sep 17 00:00:00 2001 From: generall Date: Tue, 1 Sep 2026 20:23:58 +0200 Subject: [PATCH 03/14] fix: remove lookup_from from serverless query_points, not supported Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw --- qdrant_client/serverless/client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/qdrant_client/serverless/client.py b/qdrant_client/serverless/client.py index 7e8d01df0..f169a91f4 100644 --- a/qdrant_client/serverless/client.py +++ b/qdrant_client/serverless/client.py @@ -210,7 +210,6 @@ def query_points( with_payload: bool | Sequence[str] | types.PayloadSelector = True, with_vectors: bool | Sequence[str] = False, score_threshold: Optional[float] = None, - lookup_from: Optional[types.LookupLocation] = None, timeout: Optional[int] = None, ) -> types.QueryResponse: """Universal endpoint to run any available operation, such as search, @@ -227,7 +226,6 @@ def query_points( with_payload=with_payload, with_vectors=with_vectors, score_threshold=score_threshold, - lookup_from=lookup_from, timeout=timeout, ) From afad6b341a07be800dfe7fd7a9820be8f2bdac51 Mon Sep 17 00:00:00 2001 From: generall Date: Tue, 1 Sep 2026 20:30:37 +0200 Subject: [PATCH 04/14] docs: full docstrings for QdrantServerless public methods Match the Args/Returns docstring style of the regular client; each method notes where the serverless API diverges from it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw --- qdrant_client/serverless/client.py | 292 ++++++++++++++++++++++++++--- 1 file changed, 270 insertions(+), 22 deletions(-) diff --git a/qdrant_client/serverless/client.py b/qdrant_client/serverless/client.py index f169a91f4..23d1b1603 100644 --- a/qdrant_client/serverless/client.py +++ b/qdrant_client/serverless/client.py @@ -26,6 +26,13 @@ class QdrantServerless: """Entry point to a Qdrant Serverless space. + 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( @@ -38,8 +45,10 @@ class QdrantServerless: ... ) 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 + 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 @@ -78,7 +87,14 @@ 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.""" + """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) @@ -102,14 +118,35 @@ def create_collection( payload_indexes: dict[str, serverless_models.PayloadIndex] | None = None, timeout: Optional[int] = None, ) -> str: - """Creates a collection. - - At least one dense or sparse vector is required. A bare (non-dict) - vector config is registered as the unnamed default vector, like in - regular qdrant. + """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, e.g. "created" or "already exists" + Outcome of the operation, e.g. `"created"` or `"already exists"` """ if isinstance(dense_vectors, serverless_models.DenseVectorConfig): dense_vectors = {"": dense_vectors} @@ -132,8 +169,13 @@ def create_collection( 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 + `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), @@ -146,7 +188,20 @@ def get_collection( ) -> serverless_models.CollectionInfo: """Returns a collection's configuration and stats. - Does not raise if the collection is missing: check `.exists`. + 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), @@ -161,13 +216,29 @@ def get_collection( ) def collection_exists(self, collection_name: str, timeout: Optional[int] = None) -> bool: - """Checks whether a collection exists.""" + """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, timeout: Optional[int] = None ) -> list[serverless_models.CollectionSummary]: - """Lists the collections of the space, ordered by name.""" + """Lists the collections of the space. + + Args: + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Collection summaries (name and eventually consistent point count), + ordered by name + """ response = self._collections.ListCollections( pb2.ListCollectionsRequest(), timeout=self._collections_timeout(timeout), @@ -213,7 +284,58 @@ def query_points( 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.""" + 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` - infer vector from the document text and use it for nearest search. + - 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 + """ return self._remote.query_points( collection_name=collection_name, query=query, @@ -237,7 +359,28 @@ def retrieve( with_vectors: bool | Sequence[str] = False, timeout: Optional[int] = None, ) -> list[types.Record]: - """Retrieves points by ids.""" + """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, @@ -257,9 +400,35 @@ def scroll( with_vectors: bool | Sequence[str] = False, timeout: Optional[int] = None, ) -> tuple[list[types.Record], Optional[types.PointId]]: - """Iterates over all points, optionally filtered. + """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 page of points and the offset of the next page (None if done). + 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, @@ -279,7 +448,23 @@ def count( exact: bool = True, timeout: Optional[int] = None, ) -> types.CountResult: - """Counts points, optionally filtered.""" + """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, @@ -294,7 +479,22 @@ def upsert( wait: bool = True, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Inserts or updates points.""" + """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 results to be applied on the server side. + If `true`, result will be returned only when all changes are applied + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Operation Result(UpdateResult) + """ return self._remote.upsert( collection_name=collection_name, points=points, @@ -309,7 +509,21 @@ def delete( wait: bool = True, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Deletes points by ids. Serverless does not support deletion by filter.""" + """Deletes points by ids. + + Unlike the regular client, only deletion by explicit ids is available: + serverless does not support deletion by filter. + + Args: + collection_name: Deletes points from this collection + ids: List of ids of the points to delete + wait: Await for the results to be applied on the server side. + If `true`, result will be returned only when all changes are applied + 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(ids), @@ -326,7 +540,26 @@ def set_payload( wait: bool = True, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Merges the given payload into the payload of the given points.""" + """Modifies payload of the given points. + + Only the given payload values are merged into the stored payload; + other existing keys stay untouched. Unlike the regular client, only + selection by explicit ids is available: serverless does not support + payload updates by filter. + + Args: + collection_name: Name of the collection to set payload in + payload: Key-value pairs of payload to assign + ids: 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 results to be applied on the server side. + If `true`, result will be returned only when all changes are applied + 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, @@ -344,7 +577,22 @@ def delete_payload( wait: bool = True, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Removes the given payload keys from the given points.""" + """Removes the given payload keys from the given points. + + Unlike the regular client, only selection by explicit ids is + available: serverless does not support payload updates by filter. + + Args: + collection_name: Name of the collection to delete payload from + keys: List of payload keys to remove + ids: List of ids of the points to modify + wait: Await for the results to be applied on the server side. + If `true`, result will be returned only when all changes are applied + 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, From 2c10031955f38aea07efd6e31381d0d179038de5 Mon Sep 17 00:00:00 2001 From: generall Date: Tue, 1 Sep 2026 21:48:54 +0200 Subject: [PATCH 05/14] new: generate AsyncQdrantServerless from the sync serverless client Same approach as the regular client: the sync client is the source of truth and tools/async_client_generator produces the async version. The serverless generator delegates to AsyncQdrantRemote, awaits the CollectionsService stub RPCs (awaitable on the aio channel the async remote already builds), and drops the sync context manager. The file is covered by the async-client-consistency-check like the other generated clients. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw --- qdrant_client/serverless/__init__.py | 2 + qdrant_client/serverless/async_client.py | 586 ++++++++++++++++++ tests/async-client-consistency-check.sh | 1 + tests/test_serverless.py | 25 + .../serverless_generator.py | 121 ++++ tools/generate_async_client.sh | 3 + 6 files changed, 738 insertions(+) create mode 100644 qdrant_client/serverless/async_client.py create mode 100644 tools/async_client_generator/serverless_generator.py diff --git a/qdrant_client/serverless/__init__.py b/qdrant_client/serverless/__init__.py index 7023baa47..c020ffb5e 100644 --- a/qdrant_client/serverless/__init__.py +++ b/qdrant_client/serverless/__init__.py @@ -15,6 +15,7 @@ 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 from qdrant_client.serverless.models import ( BoolIndex, @@ -37,6 +38,7 @@ ) __all__ = [ + "AsyncQdrantServerless", "QdrantServerless", "BoolIndex", "CollectionConfig", diff --git a/qdrant_client/serverless/async_client.py b/qdrant_client/serverless/async_client.py new file mode 100644 index 000000000..4182cfde0 --- /dev/null +++ b/qdrant_client/serverless/async_client.py @@ -0,0 +1,586 @@ +# ****** 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. + +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 typing import Any, Optional, Sequence +from qdrant_client.conversions import common_types as types +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. + + 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]) + 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"` or `"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, timeout: Optional[int] = None + ) -> list[serverless_models.CollectionSummary]: + """Lists the collections of the space. + + Args: + timeout: Overrides global timeout for this request. Unit is seconds. + + Returns: + Collection summaries (name and eventually consistent point count), + ordered by name + """ + response = await self._collections.ListCollections( + pb2.ListCollectionsRequest(), timeout=self._collections_timeout(timeout) + ) + return [ + 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 + ] + + 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` - infer vector from the document text and use it for nearest search. + - 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 + """ + 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 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 = True, + 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 results to be applied on the server side. + If `true`, result will be returned only when all changes are applied + 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 delete( + self, + collection_name: str, + ids: Sequence[types.PointId], + wait: bool = True, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Deletes points by ids. + + Unlike the regular client, only deletion by explicit ids is available: + serverless does not support deletion by filter. + + Args: + collection_name: Deletes points from this collection + ids: List of ids of the points to delete + wait: Await for the results to be applied on the server side. + If `true`, result will be returned only when all changes are applied + 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(ids), wait=wait, timeout=timeout + ) + + async def set_payload( + self, + collection_name: str, + payload: types.Payload, + ids: Sequence[types.PointId], + key: Optional[str] = None, + wait: bool = True, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Modifies payload of the given points. + + Only the given payload values are merged into the stored payload; + other existing keys stay untouched. Unlike the regular client, only + selection by explicit ids is available: serverless does not support + payload updates by filter. + + Args: + collection_name: Name of the collection to set payload in + payload: Key-value pairs of payload to assign + ids: 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 results to be applied on the server side. + If `true`, result will be returned only when all changes are applied + 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(ids), + key=key, + wait=wait, + timeout=timeout, + ) + + async def delete_payload( + self, + collection_name: str, + keys: Sequence[str], + ids: Sequence[types.PointId], + wait: bool = True, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Removes the given payload keys from the given points. + + Unlike the regular client, only selection by explicit ids is + available: serverless does not support payload updates by filter. + + Args: + collection_name: Name of the collection to delete payload from + keys: List of payload keys to remove + ids: List of ids of the points to modify + wait: Await for the results to be applied on the server side. + If `true`, result will be returned only when all changes are applied + 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(ids), + wait=wait, + timeout=timeout, + ) 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/test_serverless.py b/tests/test_serverless.py index 11ebddb82..6abc2208c 100644 --- a/tests/test_serverless.py +++ b/tests/test_serverless.py @@ -1,4 +1,7 @@ +import inspect + from qdrant_client.serverless import ( + AsyncQdrantServerless, CollectionConfig, DenseVectorConfig, Distance, @@ -55,3 +58,25 @@ def test_client_construction_is_offline() -> None: 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 From 81bc992729c42ae6efa6cb3274ca81d841d2c1c7 Mon Sep 17 00:00:00 2001 From: generall Date: Tue, 1 Sep 2026 22:17:48 +0200 Subject: [PATCH 06/14] fix: resolve raw-vector queries in serverless query_points query_points delegated straight to QdrantRemote, which expects a resolved Query model, so a plain list like [0.1, 0.2] failed with "invalid Query model". Apply the same QdrantFastembedMixin._resolve_query type normalization the regular client applies - type resolution only, no client-side embedding inference: Document/Image inputs go to the server as-is, serverless inference is server-side only. Verified live against a serverless dev space: create/list/get/upsert/ query/delete all pass, sync and async. Along the way: create_collection docstring now documents that an existing collection raises gRPC ALREADY_EXISTS (the service errors instead of returning the "already exists" result string the proto comment mentions), and the example deletes a leftover collection first so it can be rerun. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw --- examples/serverless_client.py | 6 +++++- qdrant_client/serverless/async_client.py | 10 ++++++++-- qdrant_client/serverless/client.py | 13 +++++++++++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/examples/serverless_client.py b/examples/serverless_client.py index c6e9f1d1d..6881c3c72 100644 --- a/examples/serverless_client.py +++ b/examples/serverless_client.py @@ -17,9 +17,13 @@ def main() -> None: client = QdrantServerless( url="https://serverless.plush-volt.aws.development-cloud.qdrant.io", - api_key="", + api_key="IZAeyepNJz2ieJxZ7NQE-Ri3H96cvKcmGNVm7QtmvHM", ) + # 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( diff --git a/qdrant_client/serverless/async_client.py b/qdrant_client/serverless/async_client.py index 4182cfde0..90bdea75a 100644 --- a/qdrant_client/serverless/async_client.py +++ b/qdrant_client/serverless/async_client.py @@ -20,6 +20,7 @@ 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 ( @@ -146,7 +147,10 @@ async def create_collection( timeout: Overrides global timeout for this request. Unit is seconds. Returns: - Outcome of the operation, e.g. `"created"` or `"already exists"` + 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} @@ -289,7 +293,8 @@ async def query_points( - 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` - infer vector from the document text and use it 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. @@ -326,6 +331,7 @@ async def query_points( 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, diff --git a/qdrant_client/serverless/client.py b/qdrant_client/serverless/client.py index 23d1b1603..de0684b63 100644 --- a/qdrant_client/serverless/client.py +++ b/qdrant_client/serverless/client.py @@ -10,6 +10,7 @@ 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 ( @@ -146,7 +147,10 @@ def create_collection( timeout: Overrides global timeout for this request. Unit is seconds. Returns: - Outcome of the operation, e.g. `"created"` or `"already exists"` + 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} @@ -299,7 +303,8 @@ def query_points( - 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` - infer vector from the document text and use it 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. @@ -336,6 +341,10 @@ def query_points( 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, From 10ad1593b5ee4cb04b93c7d92cde04d049e5a2a5 Mon Sep 17 00:00:00 2001 From: generall Date: Tue, 1 Sep 2026 22:33:14 +0200 Subject: [PATCH 07/14] new: expose serverless models under qdrant_client.models.serverless Thin alias module re-exporting qdrant_client.serverless.models, matching the qdrant_client.models convention of the regular client; the old import path keeps working. Examples and tests use the new path. Also replace an API key that slipped into the committed example with the placeholder, and exclude examples/ from mypy so examples can use the dynamic qdrant_client.models namespace. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw --- examples/serverless_client.py | 12 +++----- mypy.ini | 2 +- qdrant_client/models/serverless.py | 46 ++++++++++++++++++++++++++++ qdrant_client/serverless/__init__.py | 3 +- tests/test_serverless.py | 5 ++- 5 files changed, 55 insertions(+), 13 deletions(-) create mode 100644 qdrant_client/models/serverless.py diff --git a/examples/serverless_client.py b/examples/serverless_client.py index 6881c3c72..9b309faa9 100644 --- a/examples/serverless_client.py +++ b/examples/serverless_client.py @@ -5,19 +5,15 @@ (query, upsert, ...) work exactly like in the regular client. """ -from qdrant_client.http.models import PointStruct -from qdrant_client.serverless import ( - DenseVectorConfig, - Distance, - KeywordIndex, - QdrantServerless, -) +from qdrant_client.models import PointStruct +from qdrant_client.models.serverless 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="IZAeyepNJz2ieJxZ7NQE-Ri3H96cvKcmGNVm7QtmvHM", + api_key="", ) # make the example rerunnable: creating an existing collection raises ALREADY_EXISTS diff --git a/mypy.ini b/mypy.ini index ff8576ac0..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/serverless/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/models/serverless.py b/qdrant_client/models/serverless.py new file mode 100644 index 000000000..e030702e5 --- /dev/null +++ b/qdrant_client/models/serverless.py @@ -0,0 +1,46 @@ +"""Public import path for the Qdrant Serverless models. + +Usage: + + from qdrant_client.models.serverless import DenseVectorConfig, KeywordIndex +""" + +from qdrant_client.serverless.models import ( + BoolIndex, + CollectionConfig, + CollectionInfo, + CollectionSummary, + DatetimeIndex, + DenseVectorConfig, + Distance, + FloatIndex, + GeoIndex, + IntegerIndex, + KeywordIndex, + PayloadIndex, + PrecisionTier, + SparseVectorConfig, + TextIndex, + TokenizerType, + UuidIndex, +) + +__all__ = [ + "BoolIndex", + "CollectionConfig", + "CollectionInfo", + "CollectionSummary", + "DatetimeIndex", + "DenseVectorConfig", + "Distance", + "FloatIndex", + "GeoIndex", + "IntegerIndex", + "KeywordIndex", + "PayloadIndex", + "PrecisionTier", + "SparseVectorConfig", + "TextIndex", + "TokenizerType", + "UuidIndex", +] diff --git a/qdrant_client/serverless/__init__.py b/qdrant_client/serverless/__init__.py index c020ffb5e..3639e0c90 100644 --- a/qdrant_client/serverless/__init__.py +++ b/qdrant_client/serverless/__init__.py @@ -5,7 +5,8 @@ Usage: - from qdrant_client.serverless import QdrantServerless, DenseVectorConfig, Distance + from qdrant_client.models.serverless import DenseVectorConfig, Distance + from qdrant_client.serverless import QdrantServerless client = QdrantServerless(url="https://...", api_key="...") client.create_collection( diff --git a/tests/test_serverless.py b/tests/test_serverless.py index 6abc2208c..f20a0775b 100644 --- a/tests/test_serverless.py +++ b/tests/test_serverless.py @@ -1,18 +1,17 @@ import inspect -from qdrant_client.serverless import ( - AsyncQdrantServerless, +from qdrant_client.models.serverless import ( CollectionConfig, DenseVectorConfig, Distance, IntegerIndex, KeywordIndex, PrecisionTier, - QdrantServerless, SparseVectorConfig, TextIndex, TokenizerType, ) +from qdrant_client.serverless import AsyncQdrantServerless, QdrantServerless from qdrant_client.serverless.conversions import ( collection_config_from_grpc, collection_config_to_grpc, From c26bdf4c1e249dc0f9fef9946acbb993dd492031 Mon Sep 17 00:00:00 2001 From: generall Date: Tue, 1 Sep 2026 22:37:54 +0200 Subject: [PATCH 08/14] refactor: import serverless models from qdrant_client.serverless.models Drop the qdrant_client.models.serverless alias module and the model re-exports in qdrant_client.serverless: both were manually maintained re-export lists. qdrant_client.serverless.models is the single public import path; the serverless package itself exports only the clients. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw --- examples/serverless_client.py | 2 +- qdrant_client/models/serverless.py | 46 ---------------------------- qdrant_client/serverless/__init__.py | 38 +---------------------- tests/test_serverless.py | 2 +- 4 files changed, 3 insertions(+), 85 deletions(-) delete mode 100644 qdrant_client/models/serverless.py diff --git a/examples/serverless_client.py b/examples/serverless_client.py index 9b309faa9..fc05eba24 100644 --- a/examples/serverless_client.py +++ b/examples/serverless_client.py @@ -6,7 +6,7 @@ """ from qdrant_client.models import PointStruct -from qdrant_client.models.serverless import DenseVectorConfig, Distance, KeywordIndex +from qdrant_client.serverless.models import DenseVectorConfig, Distance, KeywordIndex from qdrant_client.serverless import QdrantServerless diff --git a/qdrant_client/models/serverless.py b/qdrant_client/models/serverless.py deleted file mode 100644 index e030702e5..000000000 --- a/qdrant_client/models/serverless.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Public import path for the Qdrant Serverless models. - -Usage: - - from qdrant_client.models.serverless import DenseVectorConfig, KeywordIndex -""" - -from qdrant_client.serverless.models import ( - BoolIndex, - CollectionConfig, - CollectionInfo, - CollectionSummary, - DatetimeIndex, - DenseVectorConfig, - Distance, - FloatIndex, - GeoIndex, - IntegerIndex, - KeywordIndex, - PayloadIndex, - PrecisionTier, - SparseVectorConfig, - TextIndex, - TokenizerType, - UuidIndex, -) - -__all__ = [ - "BoolIndex", - "CollectionConfig", - "CollectionInfo", - "CollectionSummary", - "DatetimeIndex", - "DenseVectorConfig", - "Distance", - "FloatIndex", - "GeoIndex", - "IntegerIndex", - "KeywordIndex", - "PayloadIndex", - "PrecisionTier", - "SparseVectorConfig", - "TextIndex", - "TokenizerType", - "UuidIndex", -] diff --git a/qdrant_client/serverless/__init__.py b/qdrant_client/serverless/__init__.py index 3639e0c90..9e164f0f1 100644 --- a/qdrant_client/serverless/__init__.py +++ b/qdrant_client/serverless/__init__.py @@ -5,8 +5,8 @@ Usage: - from qdrant_client.models.serverless import DenseVectorConfig, Distance from qdrant_client.serverless import QdrantServerless + from qdrant_client.serverless.models import DenseVectorConfig, Distance client = QdrantServerless(url="https://...", api_key="...") client.create_collection( @@ -18,44 +18,8 @@ from qdrant_client.serverless.async_client import AsyncQdrantServerless from qdrant_client.serverless.client import QdrantServerless -from qdrant_client.serverless.models import ( - BoolIndex, - CollectionConfig, - CollectionInfo, - CollectionSummary, - DatetimeIndex, - DenseVectorConfig, - Distance, - FloatIndex, - GeoIndex, - IntegerIndex, - KeywordIndex, - PayloadIndex, - PrecisionTier, - SparseVectorConfig, - TextIndex, - TokenizerType, - UuidIndex, -) __all__ = [ "AsyncQdrantServerless", "QdrantServerless", - "BoolIndex", - "CollectionConfig", - "CollectionInfo", - "CollectionSummary", - "DatetimeIndex", - "DenseVectorConfig", - "Distance", - "FloatIndex", - "GeoIndex", - "IntegerIndex", - "KeywordIndex", - "PayloadIndex", - "PrecisionTier", - "SparseVectorConfig", - "TextIndex", - "TokenizerType", - "UuidIndex", ] diff --git a/tests/test_serverless.py b/tests/test_serverless.py index f20a0775b..cd240efc4 100644 --- a/tests/test_serverless.py +++ b/tests/test_serverless.py @@ -1,6 +1,6 @@ import inspect -from qdrant_client.models.serverless import ( +from qdrant_client.serverless.models import ( CollectionConfig, DenseVectorConfig, Distance, From e84c33f1b0fdc45a6f47c4eaa9b1de7e18ec18e7 Mon Sep 17 00:00:00 2001 From: generall Date: Tue, 1 Sep 2026 23:18:07 +0200 Subject: [PATCH 09/14] new: batch/group queries and remaining update ops in serverless client query_batch_points and query_points_groups (both implemented by the service, verified live) with the usual serverless trims: no consistency, no shard selection, no cross-collection lookups. Add the update operations the service implements that were still missing: update_vectors, delete_vectors, overwrite_payload, clear_payload, batch_update_points (ids-only selectors; the service rejects filters). Update methods default to wait=False: serverless reads are eventually consistent with writes, so waiting does not provide read-your-write. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw --- qdrant_client/serverless/async_client.py | 289 +++++++++++++++++++++- qdrant_client/serverless/client.py | 298 ++++++++++++++++++++++- 2 files changed, 563 insertions(+), 24 deletions(-) diff --git a/qdrant_client/serverless/async_client.py b/qdrant_client/serverless/async_client.py index 90bdea75a..63faab567 100644 --- a/qdrant_client/serverless/async_client.py +++ b/qdrant_client/serverless/async_client.py @@ -18,6 +18,7 @@ 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 @@ -347,6 +348,114 @@ async def query_points( 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, @@ -472,7 +581,7 @@ async def upsert( self, collection_name: str, points: types.Points, - wait: bool = True, + wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: """Updates or inserts points into the collection. @@ -484,8 +593,9 @@ async def upsert( Args: collection_name: To which collection to insert points: Batch or list of points to insert - wait: Await for the results to be applied on the server side. - If `true`, result will be returned only when all changes are applied + 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: @@ -495,11 +605,163 @@ async def 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], + ids: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Removes the given named vectors from the given points, keeping the + points themselves. + + Unlike the regular client, only selection by explicit ids is available: + serverless does not support vector deletion by filter. + + Args: + collection_name: Name of the collection to delete vectors from + vectors: List of vector names to delete; use `""` for the unnamed + default vector + ids: 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(ids), + wait=wait, + timeout=timeout, + ) + + async def overwrite_payload( + self, + collection_name: str, + payload: types.Payload, + ids: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Replaces the entire payload of the given points with the given payload. + + Unlike `set_payload`, existing keys not present in the new payload are + removed. Unlike the regular client, only selection by explicit ids is + available: serverless does not support payload updates by filter. + + Args: + collection_name: Name of the collection to overwrite payload in + payload: Key-value pairs of payload to assign + ids: 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(ids), + wait=wait, + timeout=timeout, + ) + + async def clear_payload( + self, + collection_name: str, + ids: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Removes the entire payload of the given points. + + Unlike the regular client, only selection by explicit ids is available: + serverless does not support payload updates by filter. + + Args: + collection_name: Name of the collection to clear payload in + ids: 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(ids), 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, ids: Sequence[types.PointId], - wait: bool = True, + wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: """Deletes points by ids. @@ -510,8 +772,9 @@ async def delete( Args: collection_name: Deletes points from this collection ids: List of ids of the points to delete - wait: Await for the results to be applied on the server side. - If `true`, result will be returned only when all changes are applied + 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: @@ -527,7 +790,7 @@ async def set_payload( payload: types.Payload, ids: Sequence[types.PointId], key: Optional[str] = None, - wait: bool = True, + wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: """Modifies payload of the given points. @@ -543,8 +806,9 @@ async def set_payload( ids: 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 results to be applied on the server side. - If `true`, result will be returned only when all changes are applied + 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: @@ -564,7 +828,7 @@ async def delete_payload( collection_name: str, keys: Sequence[str], ids: Sequence[types.PointId], - wait: bool = True, + wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: """Removes the given payload keys from the given points. @@ -576,8 +840,9 @@ async def delete_payload( collection_name: Name of the collection to delete payload from keys: List of payload keys to remove ids: List of ids of the points to modify - wait: Await for the results to be applied on the server side. - If `true`, result will be returned only when all changes are applied + 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: diff --git a/qdrant_client/serverless/client.py b/qdrant_client/serverless/client.py index de0684b63..8e47822ca 100644 --- a/qdrant_client/serverless/client.py +++ b/qdrant_client/serverless/client.py @@ -7,6 +7,7 @@ serverless CollectionsService. """ +from copy import deepcopy from typing import Any, Optional, Sequence from qdrant_client.conversions import common_types as types @@ -360,6 +361,117 @@ def query_points( 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, @@ -485,7 +597,7 @@ def upsert( self, collection_name: str, points: types.Points, - wait: bool = True, + wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: """Updates or inserts points into the collection. @@ -497,8 +609,9 @@ def upsert( Args: collection_name: To which collection to insert points: Batch or list of points to insert - wait: Await for the results to be applied on the server side. - If `true`, result will be returned only when all changes are applied + 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: @@ -511,11 +624,169 @@ def upsert( 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], + ids: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Removes the given named vectors from the given points, keeping the + points themselves. + + Unlike the regular client, only selection by explicit ids is available: + serverless does not support vector deletion by filter. + + Args: + collection_name: Name of the collection to delete vectors from + vectors: List of vector names to delete; use `""` for the unnamed + default vector + ids: 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(ids), + wait=wait, + timeout=timeout, + ) + + def overwrite_payload( + self, + collection_name: str, + payload: types.Payload, + ids: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Replaces the entire payload of the given points with the given payload. + + Unlike `set_payload`, existing keys not present in the new payload are + removed. Unlike the regular client, only selection by explicit ids is + available: serverless does not support payload updates by filter. + + Args: + collection_name: Name of the collection to overwrite payload in + payload: Key-value pairs of payload to assign + ids: 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(ids), + wait=wait, + timeout=timeout, + ) + + def clear_payload( + self, + collection_name: str, + ids: Sequence[types.PointId], + wait: bool = False, + timeout: Optional[int] = None, + ) -> types.UpdateResult: + """Removes the entire payload of the given points. + + Unlike the regular client, only selection by explicit ids is available: + serverless does not support payload updates by filter. + + Args: + collection_name: Name of the collection to clear payload in + ids: 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(ids), + 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, ids: Sequence[types.PointId], - wait: bool = True, + wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: """Deletes points by ids. @@ -526,8 +797,9 @@ def delete( Args: collection_name: Deletes points from this collection ids: List of ids of the points to delete - wait: Await for the results to be applied on the server side. - If `true`, result will be returned only when all changes are applied + 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: @@ -546,7 +818,7 @@ def set_payload( payload: types.Payload, ids: Sequence[types.PointId], key: Optional[str] = None, - wait: bool = True, + wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: """Modifies payload of the given points. @@ -562,8 +834,9 @@ def set_payload( ids: 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 results to be applied on the server side. - If `true`, result will be returned only when all changes are applied + 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: @@ -583,7 +856,7 @@ def delete_payload( collection_name: str, keys: Sequence[str], ids: Sequence[types.PointId], - wait: bool = True, + wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: """Removes the given payload keys from the given points. @@ -595,8 +868,9 @@ def delete_payload( collection_name: Name of the collection to delete payload from keys: List of payload keys to remove ids: List of ids of the points to modify - wait: Await for the results to be applied on the server side. - If `true`, result will be returned only when all changes are applied + 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: From 1cc50de2dc298f5e6c493454a5710f1e720c3d8b Mon Sep 17 00:00:00 2001 From: generall Date: Tue, 1 Sep 2026 23:36:42 +0200 Subject: [PATCH 10/14] refactor: future-proof selector arguments in serverless update methods All id-selecting update methods take a uniform `points` parameter, typed narrowly as Sequence[PointId] to match what the service accepts today. When serverless adds filtered updates, the type widens to PointsSelector without breaking callers: same name, same position, strictly wider input. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw --- qdrant_client/serverless/async_client.py | 88 ++++++++++++++---------- qdrant_client/serverless/client.py | 82 ++++++++++++---------- 2 files changed, 94 insertions(+), 76 deletions(-) diff --git a/qdrant_client/serverless/async_client.py b/qdrant_client/serverless/async_client.py index 63faab567..1f3a462f2 100644 --- a/qdrant_client/serverless/async_client.py +++ b/qdrant_client/serverless/async_client.py @@ -634,21 +634,22 @@ async def delete_vectors( self, collection_name: str, vectors: Sequence[str], - ids: Sequence[types.PointId], + points: Sequence[types.PointId], wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Removes the given named vectors from the given points, keeping the - points themselves. + """Removes the given named vectors from the selected points, keeping + the points themselves. - Unlike the regular client, only selection by explicit ids is available: - serverless does not support vector deletion by filter. + 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 - ids: List of ids of the points to modify + 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. @@ -660,7 +661,7 @@ async def delete_vectors( return await self._remote.delete_vectors( collection_name=collection_name, vectors=vectors, - points=list(ids), + points=list(points), wait=wait, timeout=timeout, ) @@ -669,20 +670,21 @@ async def overwrite_payload( self, collection_name: str, payload: types.Payload, - ids: Sequence[types.PointId], + points: Sequence[types.PointId], wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Replaces the entire payload of the given points with the given payload. + """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. Unlike the regular client, only selection by explicit ids is - available: serverless does not support payload updates by filter. + 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 - ids: List of ids of the points to modify + 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. @@ -694,7 +696,7 @@ async def overwrite_payload( return await self._remote.overwrite_payload( collection_name=collection_name, payload=payload, - points=list(ids), + points=list(points), wait=wait, timeout=timeout, ) @@ -702,18 +704,19 @@ async def overwrite_payload( async def clear_payload( self, collection_name: str, - ids: Sequence[types.PointId], + points: Sequence[types.PointId], wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Removes the entire payload of the given points. + """Removes the entire payload of the selected points. - Unlike the regular client, only selection by explicit ids is available: - serverless does not support payload updates by filter. + 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 - ids: List of ids of the points to modify + 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. @@ -723,7 +726,10 @@ async def clear_payload( Operation Result(UpdateResult) """ return await self._remote.clear_payload( - collection_name=collection_name, points_selector=list(ids), wait=wait, timeout=timeout + collection_name=collection_name, + points_selector=list(points), + wait=wait, + timeout=timeout, ) async def batch_update_points( @@ -760,18 +766,19 @@ async def batch_update_points( async def delete( self, collection_name: str, - ids: Sequence[types.PointId], + points: Sequence[types.PointId], wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Deletes points by ids. + """Deletes selected points. - Unlike the regular client, only deletion by explicit ids is available: - serverless does not support deletion by filter. + 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 - ids: List of ids of the points to delete + 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. @@ -781,29 +788,33 @@ async def delete( Operation Result(UpdateResult) """ return await self._remote.delete( - collection_name=collection_name, points_selector=list(ids), wait=wait, timeout=timeout + collection_name=collection_name, + points_selector=list(points), + wait=wait, + timeout=timeout, ) async def set_payload( self, collection_name: str, payload: types.Payload, - ids: Sequence[types.PointId], + points: Sequence[types.PointId], key: Optional[str] = None, wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Modifies payload of the given points. + """Modifies payload of the selected points. Only the given payload values are merged into the stored payload; - other existing keys stay untouched. Unlike the regular client, only - selection by explicit ids is available: serverless does not support - payload updates by filter. + 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 - ids: List of ids of the points to modify + 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. @@ -817,7 +828,7 @@ async def set_payload( return await self._remote.set_payload( collection_name=collection_name, payload=payload, - points=list(ids), + points=list(points), key=key, wait=wait, timeout=timeout, @@ -827,19 +838,20 @@ async def delete_payload( self, collection_name: str, keys: Sequence[str], - ids: Sequence[types.PointId], + points: Sequence[types.PointId], wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Removes the given payload keys from the given points. + """Removes the given payload keys from the selected points. - Unlike the regular client, only selection by explicit ids is - available: serverless does not support payload updates by filter. + 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 - ids: List of ids of the points to modify + 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. @@ -851,7 +863,7 @@ async def delete_payload( return await self._remote.delete_payload( collection_name=collection_name, keys=keys, - points=list(ids), + points=list(points), wait=wait, timeout=timeout, ) diff --git a/qdrant_client/serverless/client.py b/qdrant_client/serverless/client.py index 8e47822ca..6a4047354 100644 --- a/qdrant_client/serverless/client.py +++ b/qdrant_client/serverless/client.py @@ -656,21 +656,22 @@ def delete_vectors( self, collection_name: str, vectors: Sequence[str], - ids: Sequence[types.PointId], + points: Sequence[types.PointId], wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Removes the given named vectors from the given points, keeping the - points themselves. + """Removes the given named vectors from the selected points, keeping + the points themselves. - Unlike the regular client, only selection by explicit ids is available: - serverless does not support vector deletion by filter. + 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 - ids: List of ids of the points to modify + 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. @@ -682,7 +683,7 @@ def delete_vectors( return self._remote.delete_vectors( collection_name=collection_name, vectors=vectors, - points=list(ids), + points=list(points), wait=wait, timeout=timeout, ) @@ -691,20 +692,21 @@ def overwrite_payload( self, collection_name: str, payload: types.Payload, - ids: Sequence[types.PointId], + points: Sequence[types.PointId], wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Replaces the entire payload of the given points with the given payload. + """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. Unlike the regular client, only selection by explicit ids is - available: serverless does not support payload updates by filter. + 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 - ids: List of ids of the points to modify + 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. @@ -716,7 +718,7 @@ def overwrite_payload( return self._remote.overwrite_payload( collection_name=collection_name, payload=payload, - points=list(ids), + points=list(points), wait=wait, timeout=timeout, ) @@ -724,18 +726,19 @@ def overwrite_payload( def clear_payload( self, collection_name: str, - ids: Sequence[types.PointId], + points: Sequence[types.PointId], wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Removes the entire payload of the given points. + """Removes the entire payload of the selected points. - Unlike the regular client, only selection by explicit ids is available: - serverless does not support payload updates by filter. + 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 - ids: List of ids of the points to modify + 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. @@ -746,7 +749,7 @@ def clear_payload( """ return self._remote.clear_payload( collection_name=collection_name, - points_selector=list(ids), + points_selector=list(points), wait=wait, timeout=timeout, ) @@ -785,18 +788,19 @@ def batch_update_points( def delete( self, collection_name: str, - ids: Sequence[types.PointId], + points: Sequence[types.PointId], wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Deletes points by ids. + """Deletes selected points. - Unlike the regular client, only deletion by explicit ids is available: - serverless does not support deletion by filter. + 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 - ids: List of ids of the points to delete + 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. @@ -807,7 +811,7 @@ def delete( """ return self._remote.delete( collection_name=collection_name, - points_selector=list(ids), + points_selector=list(points), wait=wait, timeout=timeout, ) @@ -816,22 +820,23 @@ def set_payload( self, collection_name: str, payload: types.Payload, - ids: Sequence[types.PointId], + points: Sequence[types.PointId], key: Optional[str] = None, wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Modifies payload of the given points. + """Modifies payload of the selected points. Only the given payload values are merged into the stored payload; - other existing keys stay untouched. Unlike the regular client, only - selection by explicit ids is available: serverless does not support - payload updates by filter. + 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 - ids: List of ids of the points to modify + 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. @@ -845,7 +850,7 @@ def set_payload( return self._remote.set_payload( collection_name=collection_name, payload=payload, - points=list(ids), + points=list(points), key=key, wait=wait, timeout=timeout, @@ -855,19 +860,20 @@ def delete_payload( self, collection_name: str, keys: Sequence[str], - ids: Sequence[types.PointId], + points: Sequence[types.PointId], wait: bool = False, timeout: Optional[int] = None, ) -> types.UpdateResult: - """Removes the given payload keys from the given points. + """Removes the given payload keys from the selected points. - Unlike the regular client, only selection by explicit ids is - available: serverless does not support payload updates by filter. + 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 - ids: List of ids of the points to modify + 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. @@ -879,7 +885,7 @@ def delete_payload( return self._remote.delete_payload( collection_name=collection_name, keys=keys, - points=list(ids), + points=list(points), wait=wait, timeout=timeout, ) From 99206052227edfed18a90bae8db471402b08c315 Mon Sep 17 00:00:00 2001 From: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:59:51 +0000 Subject: [PATCH 11/14] docs: mark QdrantServerless as in development Warn that the serverless client is experimental and should not be used yet. --- examples/serverless_client.py | 3 +++ qdrant_client/serverless/__init__.py | 3 +++ qdrant_client/serverless/async_client.py | 6 ++++++ qdrant_client/serverless/client.py | 6 ++++++ qdrant_client/serverless/conversions.py | 2 ++ qdrant_client/serverless/models.py | 3 +++ 6 files changed, 23 insertions(+) diff --git a/examples/serverless_client.py b/examples/serverless_client.py index fc05eba24..bb7366420 100644 --- a/examples/serverless_client.py +++ b/examples/serverless_client.py @@ -1,6 +1,9 @@ """ 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. """ diff --git a/qdrant_client/serverless/__init__.py b/qdrant_client/serverless/__init__.py index 9e164f0f1..2e8b104be 100644 --- a/qdrant_client/serverless/__init__.py +++ b/qdrant_client/serverless/__init__.py @@ -1,5 +1,8 @@ """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. diff --git a/qdrant_client/serverless/async_client.py b/qdrant_client/serverless/async_client.py index 1f3a462f2..ac1b6588a 100644 --- a/qdrant_client/serverless/async_client.py +++ b/qdrant_client/serverless/async_client.py @@ -11,6 +11,9 @@ """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 @@ -37,6 +40,9 @@ 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 diff --git a/qdrant_client/serverless/client.py b/qdrant_client/serverless/client.py index 6a4047354..e2d8990f1 100644 --- a/qdrant_client/serverless/client.py +++ b/qdrant_client/serverless/client.py @@ -1,5 +1,8 @@ """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 @@ -28,6 +31,9 @@ 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 diff --git a/qdrant_client/serverless/conversions.py b/qdrant_client/serverless/conversions.py index 6c5bdb6d7..2691e3e58 100644 --- a/qdrant_client/serverless/conversions.py +++ b/qdrant_client/serverless/conversions.py @@ -1,5 +1,7 @@ """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. """ diff --git a/qdrant_client/serverless/models.py b/qdrant_client/serverless/models.py index 9c425fa67..3cc161365 100644 --- a/qdrant_client/serverless/models.py +++ b/qdrant_client/serverless/models.py @@ -1,5 +1,8 @@ """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 ae78e98c4269380dcfc8e320e1f9049324f21d29 Mon Sep 17 00:00:00 2001 From: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:21:09 +0000 Subject: [PATCH 12/14] refactor: explicitly deconstruct serverless conversion fields Bind every model field via structural pattern matching so new fields force an update instead of being silently ignored. --- qdrant_client/serverless/conversions.py | 225 +++++++++++++++--------- 1 file changed, 140 insertions(+), 85 deletions(-) diff --git a/qdrant_client/serverless/conversions.py b/qdrant_client/serverless/conversions.py index 2691e3e58..6f779b106 100644 --- a/qdrant_client/serverless/conversions.py +++ b/qdrant_client/serverless/conversions.py @@ -4,6 +4,9 @@ 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 @@ -34,137 +37,189 @@ def dense_vector_to_grpc(model: models.DenseVectorConfig) -> pb2.DenseVectorConfig: - result = pb2.DenseVectorConfig( - size=model.size, - distance=_DISTANCE_TO_GRPC[model.distance], - multivector=model.multivector, - ) - if model.precision_tier is not None: - result.precision_tier = _PRECISION_TO_GRPC[model.precision_tier] - return result + 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 _: + raise ValueError(f"Unexpected DenseVectorConfig shape: {model!r}") def dense_vector_from_grpc(grpc_model: pb2.DenseVectorConfig) -> models.DenseVectorConfig: - return models.DenseVectorConfig( - size=grpc_model.size, - distance=_DISTANCE_FROM_GRPC[grpc_model.distance], - multivector=grpc_model.multivector, - precision_tier=_PRECISION_FROM_GRPC[grpc_model.precision_tier] + 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, + 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: - result = pb2.SparseVectorConfig(use_idf=model.use_idf) - if model.precision_tier is not None: - result.precision_tier = _PRECISION_TO_GRPC[model.precision_tier] - return result + 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 _: + raise ValueError(f"Unexpected SparseVectorConfig shape: {model!r}") def sparse_vector_from_grpc(grpc_model: pb2.SparseVectorConfig) -> models.SparseVectorConfig: - return models.SparseVectorConfig( - use_idf=grpc_model.use_idf, - precision_tier=_PRECISION_FROM_GRPC[grpc_model.precision_tier] + use_idf = grpc_model.use_idf + precision_tier = ( + _PRECISION_FROM_GRPC[grpc_model.precision_tier] if grpc_model.HasField("precision_tier") - else None, + else None ) + return models.SparseVectorConfig(use_idf=use_idf, precision_tier=precision_tier) def payload_index_to_grpc(model: models.PayloadIndex) -> pb2.PayloadIndexConfig: result = pb2.PayloadIndexConfig() - if isinstance(model, models.KeywordIndex): - result.keyword.SetInParent() - elif isinstance(model, models.IntegerIndex): - result.integer.SetInParent() - if model.lookup is not None: - result.integer.lookup = model.lookup - if model.range is not None: - result.integer.range = model.range - elif isinstance(model, models.FloatIndex): - result.float.SetInParent() - elif isinstance(model, models.UuidIndex): - result.uuid.SetInParent() - elif isinstance(model, models.DatetimeIndex): - result.datetime.SetInParent() - elif isinstance(model, models.TextIndex): - result.text.SetInParent() - if model.tokenizer is not None: - result.text.tokenizer = _TOKENIZER_TO_GRPC[model.tokenizer] - if model.lowercase is not None: - result.text.lowercase = model.lowercase - if model.phrase_matching is not None: - result.text.phrase_matching = model.phrase_matching - if model.min_token_len is not None: - result.text.min_token_len = model.min_token_len - if model.max_token_len is not None: - result.text.max_token_len = model.max_token_len - elif isinstance(model, models.GeoIndex): - result.geo.SetInParent() - elif isinstance(model, models.BoolIndex): - result.bool.SetInParent() - else: - raise ValueError(f"Unknown payload index type: {model}") + match model: + case models.KeywordIndex(type=_type): + result.keyword.SetInParent() + 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, + ): + 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 + case models.GeoIndex(type=_type): + result.geo.SetInParent() + case models.BoolIndex(type=_type): + result.bool.SetInParent() + case _: + 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 return models.KeywordIndex() if kind == "integer": integer = grpc_model.integer - return models.IntegerIndex( - lookup=integer.lookup if integer.HasField("lookup") else None, - range=integer.range if integer.HasField("range") else None, - ) + 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 return models.TextIndex( - 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, + tokenizer=tokenizer, + lowercase=lowercase, + phrase_matching=phrase_matching, + min_token_len=min_token_len, + max_token_len=max_token_len, ) 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}") def collection_config_to_grpc(model: models.CollectionConfig) -> pb2.CollectionConfig: - result = pb2.CollectionConfig() - for name, dense in model.dense_vectors.items(): - result.dense_vectors[name].CopyFrom(dense_vector_to_grpc(dense)) - for name, sparse in model.sparse_vectors.items(): - result.sparse_vectors[name].CopyFrom(sparse_vector_to_grpc(sparse)) - for field, index in model.payload_indexes.items(): - result.payload_indexes[field].CopyFrom(payload_index_to_grpc(index)) - return result + 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 _: + 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={ - 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() - }, + dense_vectors=dense_vectors, + sparse_vectors=sparse_vectors, + payload_indexes=payload_indexes, ) From 73d0c984d49c9fed0c08f1e3e610e884c5840d2a Mon Sep 17 00:00:00 2001 From: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:52:48 +0000 Subject: [PATCH 13/14] feat(serverless): sync collections API pagination and text options Align with the latest public-api collections.proto: paginated ListCollections (limit/offset_token/next_offset_token) plus keyword prefix and text analysis fields (stopwords, stemmer, ascii_folding). --- qdrant_client/serverless/async_client.py | 45 +++-- qdrant_client/serverless/client.py | 47 +++-- qdrant_client/serverless/conversions.py | 87 ++++++++-- .../grpc/serverless_collections_pb2.py | 114 ++++++------ .../grpc/serverless_collections_pb2.pyi | 164 +++++++++++++++++- qdrant_client/serverless/models.py | 43 +++++ .../proto/serverless_collections.proto | 66 ++++++- tests/test_serverless.py | 24 ++- tools/generate_serverless_grpc_client.sh | 21 ++- 9 files changed, 498 insertions(+), 113 deletions(-) diff --git a/qdrant_client/serverless/async_client.py b/qdrant_client/serverless/async_client.py index ac1b6588a..44168364a 100644 --- a/qdrant_client/serverless/async_client.py +++ b/qdrant_client/serverless/async_client.py @@ -238,27 +238,46 @@ async def collection_exists(self, collection_name: str, timeout: Optional[int] = return (await self.get_collection(collection_name, timeout=timeout)).exists async def get_collections( - self, timeout: Optional[int] = None - ) -> list[serverless_models.CollectionSummary]: - """Lists the collections of the space. + 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: - Collection summaries (name and eventually consistent point count), - ordered by name + 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( - pb2.ListCollectionsRequest(), timeout=self._collections_timeout(timeout) + 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, ) - return [ - 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 - ] async def query_points( self, diff --git a/qdrant_client/serverless/client.py b/qdrant_client/serverless/client.py index e2d8990f1..a489a343e 100644 --- a/qdrant_client/serverless/client.py +++ b/qdrant_client/serverless/client.py @@ -239,30 +239,47 @@ def collection_exists(self, collection_name: str, timeout: Optional[int] = None) return self.get_collection(collection_name, timeout=timeout).exists def get_collections( - self, timeout: Optional[int] = None - ) -> list[serverless_models.CollectionSummary]: - """Lists the collections of the space. + 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: - Collection summaries (name and eventually consistent point count), - ordered by name + 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( - pb2.ListCollectionsRequest(), + request, timeout=self._collections_timeout(timeout), ) - return [ - 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 - ] + 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 diff --git a/qdrant_client/serverless/conversions.py b/qdrant_client/serverless/conversions.py index 6f779b106..dbb214523 100644 --- a/qdrant_client/serverless/conversions.py +++ b/qdrant_client/serverless/conversions.py @@ -95,11 +95,62 @@ def sparse_vector_from_grpc(grpc_model: pb2.SparseVectorConfig) -> models.Sparse 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 _: + 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 _: + 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 _: + 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}") + + def payload_index_to_grpc(model: models.PayloadIndex) -> pb2.PayloadIndexConfig: result = pb2.PayloadIndexConfig() match model: - case models.KeywordIndex(type=_type): + 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 _: + 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: @@ -119,6 +170,9 @@ def payload_index_to_grpc(model: models.PayloadIndex) -> pb2.PayloadIndexConfig: 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: @@ -131,6 +185,12 @@ def payload_index_to_grpc(model: models.PayloadIndex) -> pb2.PayloadIndexConfig: 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): @@ -143,8 +203,9 @@ def payload_index_to_grpc(model: models.PayloadIndex) -> pb2.PayloadIndexConfig: def payload_index_from_grpc(grpc_model: pb2.PayloadIndexConfig) -> models.PayloadIndex: kind = grpc_model.WhichOneof("index") if kind == "keyword": - _keyword = grpc_model.keyword - return models.KeywordIndex() + 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 @@ -161,21 +222,23 @@ def payload_index_from_grpc(grpc_model: pb2.PayloadIndexConfig) -> models.Payloa return models.DatetimeIndex() if kind == "text": text = grpc_model.text - tokenizer = ( - _TOKENIZER_FROM_GRPC[text.tokenizer] if text.HasField("tokenizer") else None - ) + 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 - ) + 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 @@ -207,12 +270,10 @@ def collection_config_to_grpc(model: models.CollectionConfig) -> pb2.CollectionC 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() + 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() + name: sparse_vector_from_grpc(sparse) for name, sparse in grpc_model.sparse_vectors.items() } payload_indexes = { field: payload_index_from_grpc(index) diff --git a/qdrant_client/serverless/grpc/serverless_collections_pb2.py b/qdrant_client/serverless/grpc/serverless_collections_pb2.py index 16753034c..f3b54586d 100644 --- a/qdrant_client/serverless/grpc/serverless_collections_pb2.py +++ b/qdrant_client/serverless/grpc/serverless_collections_pb2.py @@ -14,7 +14,7 @@ -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\"\x0e\n\x0cKeywordIndex\"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\"\x83\x02\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\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_len\"\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\"\x18\n\x16ListCollectionsRequest\"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\"T\n\x17ListCollectionsResponse\x12\x39\n\x0b\x63ollections\x18\x01 \x03(\x0b\x32$.qdrant.serverless.CollectionSummary*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') +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) @@ -27,60 +27,70 @@ _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=2440 - _globals['_DISTANCE']._serialized_end=2524 - _globals['_PRECISIONTIER']._serialized_start=2526 - _globals['_PRECISIONTIER']._serialized_end=2604 - _globals['_TOKENIZER']._serialized_start=2606 - _globals['_TOKENIZER']._serialized_end=2700 + _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=372 - _globals['_INTEGERINDEX']._serialized_start=374 - _globals['_INTEGERINDEX']._serialized_end=450 - _globals['_FLOATINDEX']._serialized_start=452 - _globals['_FLOATINDEX']._serialized_end=464 - _globals['_UUIDINDEX']._serialized_start=466 - _globals['_UUIDINDEX']._serialized_end=477 - _globals['_DATETIMEINDEX']._serialized_start=479 - _globals['_DATETIMEINDEX']._serialized_end=494 - _globals['_TEXTINDEX']._serialized_start=497 - _globals['_TEXTINDEX']._serialized_end=756 - _globals['_GEOINDEX']._serialized_start=758 - _globals['_GEOINDEX']._serialized_end=768 - _globals['_BOOLINDEX']._serialized_start=770 - _globals['_BOOLINDEX']._serialized_end=781 - _globals['_PAYLOADINDEXCONFIG']._serialized_start=784 - _globals['_PAYLOADINDEXCONFIG']._serialized_end=1201 - _globals['_COLLECTIONCONFIG']._serialized_start=1204 - _globals['_COLLECTIONCONFIG']._serialized_end=1740 - _globals['_COLLECTIONCONFIG_DENSEVECTORSENTRY']._serialized_start=1464 - _globals['_COLLECTIONCONFIG_DENSEVECTORSENTRY']._serialized_end=1553 - _globals['_COLLECTIONCONFIG_SPARSEVECTORSENTRY']._serialized_start=1555 - _globals['_COLLECTIONCONFIG_SPARSEVECTORSENTRY']._serialized_end=1646 - _globals['_COLLECTIONCONFIG_PAYLOADINDEXESENTRY']._serialized_start=1648 - _globals['_COLLECTIONCONFIG_PAYLOADINDEXESENTRY']._serialized_end=1740 - _globals['_CREATECOLLECTIONREQUEST']._serialized_start=1742 - _globals['_CREATECOLLECTIONREQUEST']._serialized_end=1845 - _globals['_CREATECOLLECTIONRESPONSE']._serialized_start=1847 - _globals['_CREATECOLLECTIONRESPONSE']._serialized_end=1914 - _globals['_DELETECOLLECTIONREQUEST']._serialized_start=1916 - _globals['_DELETECOLLECTIONREQUEST']._serialized_end=1966 - _globals['_DELETECOLLECTIONRESPONSE']._serialized_start=1968 - _globals['_DELETECOLLECTIONRESPONSE']._serialized_end=2036 - _globals['_GETCOLLECTIONREQUEST']._serialized_start=2038 - _globals['_GETCOLLECTIONREQUEST']._serialized_end=2085 - _globals['_GETCOLLECTIONRESPONSE']._serialized_start=2088 - _globals['_GETCOLLECTIONRESPONSE']._serialized_end=2238 - _globals['_LISTCOLLECTIONSREQUEST']._serialized_start=2240 - _globals['_LISTCOLLECTIONSREQUEST']._serialized_end=2264 - _globals['_COLLECTIONSUMMARY']._serialized_start=2266 - _globals['_COLLECTIONSUMMARY']._serialized_end=2352 - _globals['_LISTCOLLECTIONSRESPONSE']._serialized_start=2354 - _globals['_LISTCOLLECTIONSRESPONSE']._serialized_end=2438 - _globals['_COLLECTIONSSERVICE']._serialized_start=2703 - _globals['_COLLECTIONSSERVICE']._serialized_end=3147 + _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 index 90f3c10a3..5c8f5c56e 100644 --- a/qdrant_client/serverless/grpc/serverless_collections_pb2.pyi +++ b/qdrant_client/serverless/grpc/serverless_collections_pb2.pyi @@ -4,6 +4,7 @@ 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 @@ -180,12 +181,36 @@ class KeywordIndex(google.protobuf.message.Message): 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. @@ -247,6 +272,84 @@ class DatetimeIndex(google.protobuf.message.Message): 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.""" @@ -257,6 +360,9 @@ class TextIndex(google.protobuf.message.Message): 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 @@ -267,6 +373,14 @@ class TextIndex(google.protobuf.message.Message): """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, *, @@ -275,9 +389,14 @@ class TextIndex(google.protobuf.message.Message): 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["_lowercase", b"_lowercase", "_max_token_len", b"_max_token_len", "_min_token_len", b"_min_token_len", "_phrase_matching", b"_phrase_matching", "_tokenizer", b"_tokenizer", "lowercase", b"lowercase", "max_token_len", b"max_token_len", "min_token_len", b"min_token_len", "phrase_matching", b"phrase_matching", "tokenizer", b"tokenizer"]) -> builtins.bool: ... - def ClearField(self, field_name: typing_extensions.Literal["_lowercase", b"_lowercase", "_max_token_len", b"_max_token_len", "_min_token_len", b"_min_token_len", "_phrase_matching", b"_phrase_matching", "_tokenizer", b"_tokenizer", "lowercase", b"lowercase", "max_token_len", b"max_token_len", "min_token_len", b"min_token_len", "phrase_matching", b"phrase_matching", "tokenizer", b"tokenizer"]) -> 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 @@ -287,6 +406,10 @@ class TextIndex(google.protobuf.message.Message): @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 @@ -590,15 +713,32 @@ class GetCollectionResponse(google.protobuf.message.Message): global___GetCollectionResponse = GetCollectionResponse class ListCollectionsRequest(google.protobuf.message.Message): - """Lists the caller's collections. The tenant travels in metadata, so there is - nothing to name here. - """ + """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 @@ -633,16 +773,22 @@ class ListCollectionsResponse(google.protobuf.message.Message): 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]: - """Ordered by name. A collection whose creation never published a manifest is - not listed: it is not servable. - """ + """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 ClearField(self, field_name: typing_extensions.Literal["collections", b"collections"]) -> 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/models.py b/qdrant_client/serverless/models.py index 3cc161365..d9fc0cd3f 100644 --- a/qdrant_client/serverless/models.py +++ b/qdrant_client/serverless/models.py @@ -21,11 +21,15 @@ "PrecisionTier", "DenseVectorConfig", "SparseVectorConfig", + "KeywordPrefixParams", "KeywordIndex", "IntegerIndex", "FloatIndex", "UuidIndex", "DatetimeIndex", + "StopwordsSet", + "SnowballParams", + "StemmingAlgorithm", "TextIndex", "GeoIndex", "BoolIndex", @@ -33,6 +37,7 @@ "CollectionConfig", "CollectionInfo", "CollectionSummary", + "CollectionsList", ] @@ -63,10 +68,15 @@ class SparseVectorConfig(BaseModel): 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): @@ -95,6 +105,29 @@ class DatetimeIndex(BaseModel): 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.""" @@ -104,6 +137,9 @@ class TextIndex(BaseModel): 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): @@ -160,3 +196,10 @@ class CollectionSummary(BaseModel): 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 index 75e979bbd..1a5dd9a1b 100644 --- a/qdrant_client/serverless/proto/serverless_collections.proto +++ b/qdrant_client/serverless/proto/serverless_collections.proto @@ -1,11 +1,14 @@ // 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 @@ -86,7 +89,15 @@ enum Tokenizer { } // Exact match on string values, e.g. `color: "red"`. -message KeywordIndex {} +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. @@ -106,6 +117,35 @@ 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. @@ -118,6 +158,12 @@ message TextIndex { 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. @@ -212,9 +258,15 @@ message GetCollectionResponse { optional uint64 point_count = 3; } -// Lists the caller's collections. The tenant travels in metadata, so there is -// nothing to name here. -message ListCollectionsRequest {} +// 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 { @@ -227,7 +279,9 @@ message CollectionSummary { // The caller's collections. message ListCollectionsResponse { - // Ordered by name. A collection whose creation never published a manifest is - // not listed: it is not servable. + // 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/test_serverless.py b/tests/test_serverless.py index cd240efc4..59fa02d20 100644 --- a/tests/test_serverless.py +++ b/tests/test_serverless.py @@ -6,8 +6,12 @@ Distance, IntegerIndex, KeywordIndex, + KeywordPrefixParams, PrecisionTier, + SnowballParams, SparseVectorConfig, + StemmingAlgorithm, + StopwordsSet, TextIndex, TokenizerType, ) @@ -31,9 +35,15 @@ def test_collection_config_grpc_roundtrip() -> None: }, sparse_vectors={"bm25": SparseVectorConfig(use_idf=True)}, payload_indexes={ - "user_id": KeywordIndex(), + "user_id": KeywordIndex(prefix=KeywordPrefixParams()), "age": IntegerIndex(lookup=True, range=False), - "description": TextIndex(tokenizer=TokenizerType.WORD, lowercase=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 @@ -42,12 +52,20 @@ def test_collection_config_grpc_roundtrip() -> None: def test_optional_fields_stay_unset() -> None: config = CollectionConfig( dense_vectors={"": DenseVectorConfig(size=4, distance=Distance.EUCLID)}, - payload_indexes={"age": IntegerIndex(), "text": TextIndex()}, + 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 diff --git a/tools/generate_serverless_grpc_client.sh b/tools/generate_serverless_grpc_client.sh index 373fc826a..1dc3da816 100755 --- a/tools/generate_serverless_grpc_client.sh +++ b/tools/generate_serverless_grpc_client.sh @@ -38,13 +38,30 @@ 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" -echo "$HEADER" > "$PROTO_DIR/serverless_collections.proto" +TMP_PROTO="$(mktemp)" curl -fsSL https://raw.githubusercontent.com/qdrant/qdrant-cloud-public-api/main/proto/qdrant/serverless/collections.proto \ - >> "$PROTO_DIR/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" \ From 4254cc6b68a1988f55ab16a89ec27d11715ecc5e Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Wed, 9 Sep 2026 17:04:21 +0700 Subject: [PATCH 14/14] fix: remove enter/exit, add conversion tests --- qdrant_client/serverless/async_client.py | 1 + qdrant_client/serverless/client.py | 7 +- qdrant_client/serverless/conversions.py | 20 +-- qdrant_client/serverless/models.py | 25 ---- tests/conversions/serverless_fixtures.py | 128 ++++++++++++++++++ .../test_validate_serverless_conversions.py | 124 +++++++++++++++++ tests/coverage-test.sh | 3 + 7 files changed, 267 insertions(+), 41 deletions(-) create mode 100644 tests/conversions/serverless_fixtures.py create mode 100644 tests/conversions/test_validate_serverless_conversions.py diff --git a/qdrant_client/serverless/async_client.py b/qdrant_client/serverless/async_client.py index 44168364a..08e4fa243 100644 --- a/qdrant_client/serverless/async_client.py +++ b/qdrant_client/serverless/async_client.py @@ -97,6 +97,7 @@ 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: diff --git a/qdrant_client/serverless/client.py b/qdrant_client/serverless/client.py index a489a343e..5f1bb0f0a 100644 --- a/qdrant_client/serverless/client.py +++ b/qdrant_client/serverless/client.py @@ -89,6 +89,7 @@ def _collections(self) -> CollectionsServiceStub: # 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: @@ -106,12 +107,6 @@ def close(self, grpc_grace: Optional[float] = None, **kwargs: Any) -> None: self._grpc_collections = None self._remote.close(grpc_grace=grpc_grace, **kwargs) - def __enter__(self) -> "QdrantServerless": - return self - - def __exit__(self, *args: Any) -> None: - self.close() - # region collections def create_collection( diff --git a/qdrant_client/serverless/conversions.py b/qdrant_client/serverless/conversions.py index dbb214523..0592c844e 100644 --- a/qdrant_client/serverless/conversions.py +++ b/qdrant_client/serverless/conversions.py @@ -52,7 +52,7 @@ def dense_vector_to_grpc(model: models.DenseVectorConfig) -> pb2.DenseVectorConf if precision_tier is not None: result.precision_tier = _PRECISION_TO_GRPC[precision_tier] return result - case _: + case _: # pragma: no cover raise ValueError(f"Unexpected DenseVectorConfig shape: {model!r}") @@ -81,7 +81,7 @@ def sparse_vector_to_grpc(model: models.SparseVectorConfig) -> pb2.SparseVectorC if precision_tier is not None: result.precision_tier = _PRECISION_TO_GRPC[precision_tier] return result - case _: + case _: # pragma: no cover raise ValueError(f"Unexpected SparseVectorConfig shape: {model!r}") @@ -99,7 +99,7 @@ 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 _: + case _: # pragma: no cover raise ValueError(f"Unexpected StopwordsSet shape: {model!r}") @@ -118,14 +118,14 @@ def _stemmer_to_grpc(model: models.StemmingAlgorithm) -> pb2.StemmingAlgorithm: match snowball: case models.SnowballParams(language=language): result.snowball.language = language - case _: + 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 _: + case _: # pragma: no cover raise ValueError(f"Unexpected StemmingAlgorithm shape: {model!r}") @@ -137,7 +137,7 @@ def _stemmer_from_grpc(grpc_model: pb2.StemmingAlgorithm) -> models.StemmingAlgo ) if kind == "disabled": return models.StemmingAlgorithm(disabled=True) - raise ValueError(f"Unknown stemming_params variant: {kind}") + raise ValueError(f"Unknown stemming_params variant: {kind}") # pragma: no cover def payload_index_to_grpc(model: models.PayloadIndex) -> pb2.PayloadIndexConfig: @@ -149,7 +149,7 @@ def payload_index_to_grpc(model: models.PayloadIndex) -> pb2.PayloadIndexConfig: match prefix: case models.KeywordPrefixParams(): result.keyword.prefix.SetInParent() - case _: + case _: # pragma: no cover raise ValueError(f"Unexpected KeywordPrefixParams shape: {prefix!r}") case models.IntegerIndex(type=_type, lookup=lookup, range=range_): result.integer.SetInParent() @@ -195,7 +195,7 @@ def payload_index_to_grpc(model: models.PayloadIndex) -> pb2.PayloadIndexConfig: result.geo.SetInParent() case models.BoolIndex(type=_type): result.bool.SetInParent() - case _: + case _: # pragma: no cover raise ValueError(f"Unknown payload index type: {model}") return result @@ -246,7 +246,7 @@ def payload_index_from_grpc(grpc_model: pb2.PayloadIndexConfig) -> models.Payloa if kind == "bool": _bool = grpc_model.bool return models.BoolIndex() - raise ValueError(f"Unknown payload index type: {kind}") + raise ValueError(f"Unknown payload index type: {kind}") # pragma: no cover def collection_config_to_grpc(model: models.CollectionConfig) -> pb2.CollectionConfig: @@ -264,7 +264,7 @@ def collection_config_to_grpc(model: models.CollectionConfig) -> pb2.CollectionC for field, index in payload_indexes.items(): result.payload_indexes[field].CopyFrom(payload_index_to_grpc(index)) return result - case _: + case _: # pragma: no cover raise ValueError(f"Unexpected CollectionConfig shape: {model!r}") diff --git a/qdrant_client/serverless/models.py b/qdrant_client/serverless/models.py index d9fc0cd3f..d37abab81 100644 --- a/qdrant_client/serverless/models.py +++ b/qdrant_client/serverless/models.py @@ -15,31 +15,6 @@ from qdrant_client.http.models import Distance, TokenizerType -__all__ = [ - "Distance", - "TokenizerType", - "PrecisionTier", - "DenseVectorConfig", - "SparseVectorConfig", - "KeywordPrefixParams", - "KeywordIndex", - "IntegerIndex", - "FloatIndex", - "UuidIndex", - "DatetimeIndex", - "StopwordsSet", - "SnowballParams", - "StemmingAlgorithm", - "TextIndex", - "GeoIndex", - "BoolIndex", - "PayloadIndex", - "CollectionConfig", - "CollectionInfo", - "CollectionSummary", - "CollectionsList", -] - class PrecisionTier(str, Enum): """How much vector precision may be traded for cost. 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