Files
589a87a106 new: QdrantServerless client prototype (#1393)
* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* fix: remove lookup_from from serverless query_points, not supported

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R25zh9xS78xMHgPcoFaUdw

* docs: mark QdrantServerless as in development

Warn that the serverless client is experimental and should not be used yet.

* 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.

* 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).

* fix: remove enter/exit, add conversion tests

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: George Panchuk <george.panchuk@qdrant.tech>
2026-09-16 14:53:47 +07:00

287 lines
12 KiB
Python

"""Conversions between serverless pydantic models and the internal gRPC types.
**In development — do not use yet.** Part of the experimental serverless client.
The generated gRPC types are an implementation detail and must not leak into
the public interface.
All model/proto fields are bound by structural pattern matching (never via
`model.field` or ignored with ``*_``) so adding a field forces an update here.
"""
from qdrant_client.serverless import models
from qdrant_client.serverless.grpc import serverless_collections_pb2 as pb2
_DISTANCE_TO_GRPC = {
models.Distance.COSINE: pb2.COSINE,
models.Distance.EUCLID: pb2.EUCLID,
models.Distance.DOT: pb2.DOT,
models.Distance.MANHATTAN: pb2.MANHATTAN,
}
_DISTANCE_FROM_GRPC = {v: k for k, v in _DISTANCE_TO_GRPC.items()}
_PRECISION_TO_GRPC = {
models.PrecisionTier.LOW: pb2.LOW,
models.PrecisionTier.MEDIUM: pb2.MEDIUM,
models.PrecisionTier.HIGH: pb2.HIGH,
}
_PRECISION_FROM_GRPC = {v: k for k, v in _PRECISION_TO_GRPC.items()}
_TOKENIZER_TO_GRPC = {
models.TokenizerType.PREFIX: pb2.PREFIX,
models.TokenizerType.WHITESPACE: pb2.WHITESPACE,
models.TokenizerType.WORD: pb2.WORD,
models.TokenizerType.MULTILINGUAL: pb2.MULTILINGUAL,
}
_TOKENIZER_FROM_GRPC = {v: k for k, v in _TOKENIZER_TO_GRPC.items()}
def dense_vector_to_grpc(model: models.DenseVectorConfig) -> pb2.DenseVectorConfig:
match model:
case models.DenseVectorConfig(
size=size,
distance=distance,
multivector=multivector,
precision_tier=precision_tier,
):
result = pb2.DenseVectorConfig(
size=size,
distance=_DISTANCE_TO_GRPC[distance],
multivector=multivector,
)
if precision_tier is not None:
result.precision_tier = _PRECISION_TO_GRPC[precision_tier]
return result
case _: # pragma: no cover
raise ValueError(f"Unexpected DenseVectorConfig shape: {model!r}")
def dense_vector_from_grpc(grpc_model: pb2.DenseVectorConfig) -> models.DenseVectorConfig:
size = grpc_model.size
distance = grpc_model.distance
multivector = grpc_model.multivector
precision_tier = (
_PRECISION_FROM_GRPC[grpc_model.precision_tier]
if grpc_model.HasField("precision_tier")
else None
)
# Re-bind through the public model constructor so every field is named.
return models.DenseVectorConfig(
size=size,
distance=_DISTANCE_FROM_GRPC[distance],
multivector=multivector,
precision_tier=precision_tier,
)
def sparse_vector_to_grpc(model: models.SparseVectorConfig) -> pb2.SparseVectorConfig:
match model:
case models.SparseVectorConfig(use_idf=use_idf, precision_tier=precision_tier):
result = pb2.SparseVectorConfig(use_idf=use_idf)
if precision_tier is not None:
result.precision_tier = _PRECISION_TO_GRPC[precision_tier]
return result
case _: # pragma: no cover
raise ValueError(f"Unexpected SparseVectorConfig shape: {model!r}")
def sparse_vector_from_grpc(grpc_model: pb2.SparseVectorConfig) -> models.SparseVectorConfig:
use_idf = grpc_model.use_idf
precision_tier = (
_PRECISION_FROM_GRPC[grpc_model.precision_tier]
if grpc_model.HasField("precision_tier")
else None
)
return models.SparseVectorConfig(use_idf=use_idf, precision_tier=precision_tier)
def _stopwords_to_grpc(model: models.StopwordsSet) -> pb2.StopwordsSet:
match model:
case models.StopwordsSet(languages=languages, custom=custom):
return pb2.StopwordsSet(languages=list(languages), custom=list(custom))
case _: # pragma: no cover
raise ValueError(f"Unexpected StopwordsSet shape: {model!r}")
def _stopwords_from_grpc(grpc_model: pb2.StopwordsSet) -> models.StopwordsSet:
return models.StopwordsSet(
languages=list(grpc_model.languages),
custom=list(grpc_model.custom),
)
def _stemmer_to_grpc(model: models.StemmingAlgorithm) -> pb2.StemmingAlgorithm:
match model:
case models.StemmingAlgorithm(snowball=snowball, disabled=disabled):
result = pb2.StemmingAlgorithm()
if snowball is not None:
match snowball:
case models.SnowballParams(language=language):
result.snowball.language = language
case _: # pragma: no cover
raise ValueError(f"Unexpected SnowballParams shape: {snowball!r}")
elif disabled:
result.disabled.SetInParent()
else:
raise ValueError("StemmingAlgorithm requires either snowball or disabled=True")
return result
case _: # pragma: no cover
raise ValueError(f"Unexpected StemmingAlgorithm shape: {model!r}")
def _stemmer_from_grpc(grpc_model: pb2.StemmingAlgorithm) -> models.StemmingAlgorithm:
kind = grpc_model.WhichOneof("stemming_params")
if kind == "snowball":
return models.StemmingAlgorithm(
snowball=models.SnowballParams(language=grpc_model.snowball.language)
)
if kind == "disabled":
return models.StemmingAlgorithm(disabled=True)
raise ValueError(f"Unknown stemming_params variant: {kind}") # pragma: no cover
def payload_index_to_grpc(model: models.PayloadIndex) -> pb2.PayloadIndexConfig:
result = pb2.PayloadIndexConfig()
match model:
case models.KeywordIndex(type=_type, prefix=prefix):
result.keyword.SetInParent()
if prefix is not None:
match prefix:
case models.KeywordPrefixParams():
result.keyword.prefix.SetInParent()
case _: # pragma: no cover
raise ValueError(f"Unexpected KeywordPrefixParams shape: {prefix!r}")
case models.IntegerIndex(type=_type, lookup=lookup, range=range_):
result.integer.SetInParent()
if lookup is not None:
result.integer.lookup = lookup
if range_ is not None:
result.integer.range = range_
case models.FloatIndex(type=_type):
result.float.SetInParent()
case models.UuidIndex(type=_type):
result.uuid.SetInParent()
case models.DatetimeIndex(type=_type):
result.datetime.SetInParent()
case models.TextIndex(
type=_type,
tokenizer=tokenizer,
lowercase=lowercase,
phrase_matching=phrase_matching,
min_token_len=min_token_len,
max_token_len=max_token_len,
ascii_folding=ascii_folding,
stopwords=stopwords,
stemmer=stemmer,
):
result.text.SetInParent()
if tokenizer is not None:
result.text.tokenizer = _TOKENIZER_TO_GRPC[tokenizer]
if lowercase is not None:
result.text.lowercase = lowercase
if phrase_matching is not None:
result.text.phrase_matching = phrase_matching
if min_token_len is not None:
result.text.min_token_len = min_token_len
if max_token_len is not None:
result.text.max_token_len = max_token_len
if ascii_folding is not None:
result.text.ascii_folding = ascii_folding
if stopwords is not None:
result.text.stopwords.CopyFrom(_stopwords_to_grpc(stopwords))
if stemmer is not None:
result.text.stemmer.CopyFrom(_stemmer_to_grpc(stemmer))
case models.GeoIndex(type=_type):
result.geo.SetInParent()
case models.BoolIndex(type=_type):
result.bool.SetInParent()
case _: # pragma: no cover
raise ValueError(f"Unknown payload index type: {model}")
return result
def payload_index_from_grpc(grpc_model: pb2.PayloadIndexConfig) -> models.PayloadIndex:
kind = grpc_model.WhichOneof("index")
if kind == "keyword":
keyword = grpc_model.keyword
prefix = models.KeywordPrefixParams() if keyword.HasField("prefix") else None
return models.KeywordIndex(prefix=prefix)
if kind == "integer":
integer = grpc_model.integer
lookup = integer.lookup if integer.HasField("lookup") else None
range_ = integer.range if integer.HasField("range") else None
return models.IntegerIndex(lookup=lookup, range=range_)
if kind == "float":
_float = grpc_model.float
return models.FloatIndex()
if kind == "uuid":
_uuid = grpc_model.uuid
return models.UuidIndex()
if kind == "datetime":
_datetime = grpc_model.datetime
return models.DatetimeIndex()
if kind == "text":
text = grpc_model.text
tokenizer = _TOKENIZER_FROM_GRPC[text.tokenizer] if text.HasField("tokenizer") else None
lowercase = text.lowercase if text.HasField("lowercase") else None
phrase_matching = text.phrase_matching if text.HasField("phrase_matching") else None
min_token_len = text.min_token_len if text.HasField("min_token_len") else None
max_token_len = text.max_token_len if text.HasField("max_token_len") else None
ascii_folding = text.ascii_folding if text.HasField("ascii_folding") else None
stopwords = _stopwords_from_grpc(text.stopwords) if text.HasField("stopwords") else None
stemmer = _stemmer_from_grpc(text.stemmer) if text.HasField("stemmer") else None
return models.TextIndex(
tokenizer=tokenizer,
lowercase=lowercase,
phrase_matching=phrase_matching,
min_token_len=min_token_len,
max_token_len=max_token_len,
ascii_folding=ascii_folding,
stopwords=stopwords,
stemmer=stemmer,
)
if kind == "geo":
_geo = grpc_model.geo
return models.GeoIndex()
if kind == "bool":
_bool = grpc_model.bool
return models.BoolIndex()
raise ValueError(f"Unknown payload index type: {kind}") # pragma: no cover
def collection_config_to_grpc(model: models.CollectionConfig) -> pb2.CollectionConfig:
match model:
case models.CollectionConfig(
dense_vectors=dense_vectors,
sparse_vectors=sparse_vectors,
payload_indexes=payload_indexes,
):
result = pb2.CollectionConfig()
for name, dense in dense_vectors.items():
result.dense_vectors[name].CopyFrom(dense_vector_to_grpc(dense))
for name, sparse in sparse_vectors.items():
result.sparse_vectors[name].CopyFrom(sparse_vector_to_grpc(sparse))
for field, index in payload_indexes.items():
result.payload_indexes[field].CopyFrom(payload_index_to_grpc(index))
return result
case _: # pragma: no cover
raise ValueError(f"Unexpected CollectionConfig shape: {model!r}")
def collection_config_from_grpc(grpc_model: pb2.CollectionConfig) -> models.CollectionConfig:
dense_vectors = {
name: dense_vector_from_grpc(dense) for name, dense in grpc_model.dense_vectors.items()
}
sparse_vectors = {
name: sparse_vector_from_grpc(sparse) for name, sparse in grpc_model.sparse_vectors.items()
}
payload_indexes = {
field: payload_index_from_grpc(index)
for field, index in grpc_model.payload_indexes.items()
}
return models.CollectionConfig(
dense_vectors=dense_vectors,
sparse_vectors=sparse_vectors,
payload_indexes=payload_indexes,
)