From b6de6a8a64e0e42720365ae201dfa8243a299a3f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 08:42:38 +0000 Subject: [PATCH 1/4] feat: add QdrantServerless client prototype Introduce a parallel client for Qdrant Serverless with tenant-facing collection management and point ops that strip unsupported cluster parameters, matching the Python client approach. --- Cargo.toml | 4 + examples/serverless.rs | 74 ++++ proto/serverless_collections.proto | 232 ++++++++++++ src/lib.rs | 2 + src/qdrant_client/mod.rs | 10 +- src/serverless/client.rs | 533 +++++++++++++++++++++++++++ src/serverless/conversions.rs | 299 +++++++++++++++ src/serverless/grpc.rs | 564 +++++++++++++++++++++++++++++ src/serverless/mod.rs | 41 +++ src/serverless/models.rs | 349 ++++++++++++++++++ tests/serverless_protos.rs | 64 ++++ tools/sync_serverless_proto.sh | 25 ++ 12 files changed, 2196 insertions(+), 1 deletion(-) create mode 100644 examples/serverless.rs create mode 100644 proto/serverless_collections.proto create mode 100644 src/serverless/client.rs create mode 100644 src/serverless/conversions.rs create mode 100644 src/serverless/grpc.rs create mode 100644 src/serverless/mod.rs create mode 100644 src/serverless/models.rs create mode 100644 tests/serverless_protos.rs create mode 100755 tools/sync_serverless_proto.sh diff --git a/Cargo.toml b/Cargo.toml index a8e809a..cffa0b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,10 @@ uuid = ["dep:uuid"] name = "query" required-features = ["serde"] +[[example]] +name = "serverless" +required-features = ["serde"] + [package.metadata.docs.rs] features = ["download_snapshots", "serde"] no-default-features = true diff --git a/examples/serverless.rs b/examples/serverless.rs new file mode 100644 index 0000000..a7ea403 --- /dev/null +++ b/examples/serverless.rs @@ -0,0 +1,74 @@ +//! Example of using the Qdrant Serverless client. +//! +//! Collection management uses the simplified serverless API; point operations +//! (query, upsert, ...) work like in the regular client (unsupported cluster +//! parameters are stripped before each RPC). +//! +//! ```bash +//! cargo run --example serverless --features serde +//! ``` + +use qdrant_client::qdrant::{PointStruct, QueryPointsBuilder, UpsertPointsBuilder}; +use qdrant_client::serverless::{ + CollectionConfig, DenseVectorConfig, Distance, KeywordIndex, QdrantServerless, +}; +use qdrant_client::{Payload, QdrantError}; + +#[tokio::main] +async fn main() -> Result<(), QdrantError> { + let client = QdrantServerless::from_url( + "https://serverless.plush-volt.aws.development-cloud.qdrant.io", + ) + .api_key("") + .build()?; + + let collection_name = "my-collection"; + + // make the example rerunnable: creating an existing collection returns ALREADY_EXISTS + if client.collection_exists(collection_name).await? { + client.delete_collection(collection_name).await?; + } + + // serverless-specific collection management: no quantization, wal, + // segment number etc. - the serverless manager decides those + let result = client + .create_collection( + collection_name, + CollectionConfig::new() + .dense_vector(DenseVectorConfig::new(4, Distance::Cosine)) + .payload_index("color", KeywordIndex), + ) + .await?; + println!("create_collection: {result}"); + + println!("collections: {:?}", client.list_collections().await?); + println!("info: {:?}", client.get_collection(collection_name).await?); + + let points = vec![ + PointStruct::new( + 1, + vec![0.1, 0.2, 0.3, 0.4], + Payload::try_from(serde_json::json!({"color": "red"})).unwrap(), + ), + PointStruct::new( + 2, + vec![0.4, 0.3, 0.2, 0.1], + Payload::try_from(serde_json::json!({"color": "blue"})).unwrap(), + ), + ]; + client + .upsert_points(UpsertPointsBuilder::new(collection_name, points)) + .await?; + + let hits = client + .query( + QueryPointsBuilder::new(collection_name) + .query(vec![0.1, 0.2, 0.3, 0.4]) + .limit(10), + ) + .await?; + println!("query: {hits:?}"); + + client.delete_collection(collection_name).await?; + Ok(()) +} diff --git a/proto/serverless_collections.proto b/proto/serverless_collections.proto new file mode 100644 index 0000000..680e8cf --- /dev/null +++ b/proto/serverless_collections.proto @@ -0,0 +1,232 @@ +// Source: https://github.com/qdrant/qdrant-cloud-public-api/blob/main/proto/qdrant/serverless/collections.proto +// Renamed to serverless_collections.proto to sit alongside the regular collections.proto. +// Regenerate with: cargo test --test serverless_protos -- --ignored +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/src/lib.rs b/src/lib.rs index b0cc2de..b6541b3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -144,6 +144,8 @@ mod grpc_macros; mod manual_builder; mod payload; mod qdrant_client; +/// Client for Qdrant Serverless +pub mod serverless; #[cfg(feature = "serde")] mod serde_impl; diff --git a/src/qdrant_client/mod.rs b/src/qdrant_client/mod.rs index 6376037..e7a32ba 100644 --- a/src/qdrant_client/mod.rs +++ b/src/qdrant_client/mod.rs @@ -190,8 +190,16 @@ impl Qdrant { QdrantBuilder::from_url(url) } + /// Shared connection pool (used by the serverless client for CollectionsService). + pub(crate) fn channel(&self) -> &Arc { + &self.channel + } + /// Wraps a channel with a metadata interceptor (api key + custom headers) - fn with_api_key(&self, channel: Channel) -> InterceptedService { + pub(crate) fn with_api_key( + &self, + channel: Channel, + ) -> InterceptedService { let interceptor = MetadataInterceptor::new( self.config.api_key.clone(), self.config.custom_headers.clone(), diff --git a/src/serverless/client.rs b/src/serverless/client.rs new file mode 100644 index 0000000..6be202a --- /dev/null +++ b/src/serverless/client.rs @@ -0,0 +1,533 @@ +//! 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. + +use std::future::Future; + +use tonic::codegen::InterceptedService; +use tonic::transport::{Channel, Uri}; +use tonic::Status; + +use crate::auth::MetadataInterceptor; +use crate::config::{AsOptionApiKey, AsTimeout, CompressionEncoding, QdrantConfig}; +use crate::qdrant::{ + ClearPayloadPoints, CountPoints, CountResponse, DeletePayloadPoints, DeletePointVectors, + DeletePoints, GetPoints, GetResponse, PointsOperationResponse, QueryBatchPoints, + QueryBatchResponse, QueryGroupsResponse, QueryPointGroups, QueryPoints, QueryResponse, + ScrollPoints, ScrollResponse, SetPayloadPoints, UpdateBatchPoints, UpdateBatchResponse, + UpdatePointVectors, UpsertPoints, +}; +use crate::qdrant_client::QdrantResult; +use crate::serverless::conversions::{collection_config_from_grpc, collection_config_to_grpc}; +use crate::serverless::grpc::collections_service_client::CollectionsServiceClient; +use crate::serverless::grpc::{ + CreateCollectionRequest, DeleteCollectionRequest, GetCollectionRequest, ListCollectionsRequest, +}; +use crate::serverless::models::{CollectionConfig, CollectionInfo, CollectionSummary}; +use crate::Qdrant; + +/// Default gRPC port for serverless when the URL omits an explicit port. +/// +/// Serverless is exposed on the standard TLS port, not on Qdrant's 6334. +pub const DEFAULT_SERVERLESS_GRPC_PORT: u16 = 443; + +/// Entry point to a Qdrant Serverless space. +/// +/// Point operations behave like in the regular [`Qdrant`] client, except that +/// parameters serverless does not support (read consistency, shard selection, +/// write ordering, filtered updates, cross-collection lookups) are cleared +/// before the request is sent. 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. +/// +/// # Example +/// +/// ```no_run +/// use qdrant_client::serverless::{ +/// CollectionConfig, DenseVectorConfig, Distance, KeywordIndex, QdrantServerless, +/// }; +/// use qdrant_client::qdrant::{PointStruct, QueryPointsBuilder, UpsertPointsBuilder}; +/// use qdrant_client::Payload; +/// +/// # async fn run() -> Result<(), qdrant_client::QdrantError> { +/// let client = QdrantServerless::from_url("https://serverless.example.cloud.qdrant.io") +/// .api_key("") +/// .build()?; +/// +/// client +/// .create_collection( +/// "my-collection", +/// CollectionConfig::new() +/// .dense_vector(DenseVectorConfig::new(4, Distance::Cosine)) +/// .payload_index("color", KeywordIndex), +/// ) +/// .await?; +/// +/// client +/// .upsert_points(UpsertPointsBuilder::new( +/// "my-collection", +/// vec![PointStruct::new(1, vec![0.1, 0.2, 0.3, 0.4], Payload::default())], +/// )) +/// .await?; +/// +/// client +/// .query(QueryPointsBuilder::new("my-collection").query(vec![0.1, 0.2, 0.3, 0.4])) +/// .await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct QdrantServerless { + points: Qdrant, +} + +/// Builder for [`QdrantServerless`]. +/// +/// Defaults differ from the regular [`Qdrant`] client: +/// - compatibility checks are skipped (serverless has no matching server version) +/// - URLs without an explicit port default to port [`DEFAULT_SERVERLESS_GRPC_PORT`] (443) +#[derive(Clone)] +pub struct QdrantServerlessBuilder { + config: QdrantConfig, +} + +impl QdrantServerless { + /// Start configuring a serverless client from a base URL. + /// + /// ```no_run + /// use qdrant_client::serverless::QdrantServerless; + /// + /// # fn main() -> Result<(), qdrant_client::QdrantError> { + /// let client = QdrantServerless::from_url("https://serverless.example.cloud.qdrant.io") + /// .api_key(std::env::var("QDRANT_API_KEY")) + /// .build()?; + /// # Ok(()) + /// # } + /// ``` + pub fn from_url(url: &str) -> QdrantServerlessBuilder { + QdrantServerlessBuilder { + config: QdrantConfig { + uri: normalize_serverless_url(url), + check_compatibility: false, + ..QdrantConfig::default() + }, + } + } + + /// Create a serverless client from an existing config. + /// + /// Compatibility checks are forced off. Prefer [`Self::from_url`]. + pub fn new(mut config: QdrantConfig) -> QdrantResult { + config.check_compatibility = false; + config.uri = normalize_serverless_url(&config.uri); + Ok(Self { + points: Qdrant::new(config)?, + }) + } + + async fn with_collections_client>>( + &self, + f: impl Fn(CollectionsServiceClient>) -> O, + ) -> QdrantResult { + let result = self + .points + .channel() + .with_channel( + |channel| { + let service = self.points.with_api_key(channel); + let mut client = CollectionsServiceClient::new(service) + .max_decoding_message_size(usize::MAX); + if let Some(compression) = self.points.config.compression { + client = client + .send_compressed(compression.into()) + .accept_compressed(compression.into()); + } + f(client) + }, + true, + ) + .await?; + Ok(result) + } +} + +/// # Construct and connect +impl QdrantServerlessBuilder { + /// Set an optional API key (sent as `api-key` metadata). + pub fn api_key(mut self, api_key: impl AsOptionApiKey) -> Self { + self.config.api_key = api_key.api_key(); + self + } + + /// Set the timeout for API requests. + pub fn timeout(mut self, timeout: impl AsTimeout) -> Self { + self.config.timeout = timeout.timeout(); + self + } + + /// Set the connect timeout. + pub fn connect_timeout(mut self, timeout: impl AsTimeout) -> Self { + self.config.connect_timeout = timeout.timeout(); + self + } + + /// Set optional request compression. + pub fn compression(mut self, compression: Option) -> Self { + self.config.compression = compression; + self + } + + /// Add a custom header to send with every request. + pub fn header(mut self, key: impl Into, value: impl Into) -> Self { + self.config.custom_headers.push((key.into(), value.into())); + self + } + + /// Keep idle connections alive. + pub fn keep_alive_while_idle(mut self) -> Self { + self.config.keep_alive_while_idle = true; + self + } + + /// Build the configured [`QdrantServerless`] client. + pub fn build(self) -> QdrantResult { + QdrantServerless::new(self.config) + } +} + +/// # Collection operations +/// +/// Simplified, tenant-facing collection management. Storage internals are not exposed. +impl QdrantServerless { + /// 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. + /// + /// Returns the outcome string from the service (e.g. `"created"`). + pub async fn create_collection( + &self, + collection_name: impl Into, + config: CollectionConfig, + ) -> QdrantResult { + let request = CreateCollectionRequest { + collection_name: collection_name.into(), + config: Some(collection_config_to_grpc(&config)), + }; + let request = &request; + self.with_collections_client(|mut api| async move { + let response = api.create_collection(request.clone()).await?; + Ok(response.into_inner().result) + }) + .await + } + + /// Deletes a collection and all of its data. + /// + /// Returns `true` if the collection existed and was deleted, `false` otherwise. + pub async fn delete_collection(&self, collection_name: impl Into) -> QdrantResult { + let request = DeleteCollectionRequest { + collection_name: collection_name.into(), + }; + let request = &request; + self.with_collections_client(|mut api| async move { + let response = api.delete_collection(request.clone()).await?; + Ok(response.into_inner().deleted) + }) + .await + } + + /// Returns a collection's configuration and stats. + /// + /// Unlike the regular client, does not error if the collection is missing: + /// check the [`CollectionInfo::exists`] field of the result. + pub async fn get_collection( + &self, + collection_name: impl Into, + ) -> QdrantResult { + let request = GetCollectionRequest { + collection_name: collection_name.into(), + }; + let request = &request; + let response = self + .with_collections_client(|mut api| async move { + Ok(api.get_collection(request.clone()).await?.into_inner()) + }) + .await?; + Ok(CollectionInfo { + exists: response.exists, + config: response + .config + .as_ref() + .map(collection_config_from_grpc) + .transpose()?, + point_count: response.point_count, + }) + } + + /// Checks whether a collection exists. + pub async fn collection_exists(&self, collection_name: impl Into) -> QdrantResult { + Ok(self.get_collection(collection_name).await?.exists) + } + + /// Lists the collections of the space. + /// + /// Returns summaries (name and eventually consistent point count), ordered by name. + pub async fn list_collections(&self) -> QdrantResult> { + self.with_collections_client(|mut api| async move { + let response = api + .list_collections(ListCollectionsRequest {}) + .await? + .into_inner(); + Ok(response + .collections + .into_iter() + .map(|c| CollectionSummary { + collection_name: c.collection_name, + point_count: c.point_count, + }) + .collect()) + }) + .await + } +} + +/// # Point operations +/// +/// Same semantics as the regular client, minus parameters serverless does not +/// support. Unsupported fields on the request (`read_consistency`, +/// `shard_key_selector`, `ordering`, `lookup_from`, `with_lookup`, +/// `update_filter`, `update_mode`) are cleared before the RPC. +impl QdrantServerless { + /// Query points in a collection. + pub async fn query(&self, request: impl Into) -> QdrantResult { + let mut request = request.into(); + sanitize_query_points(&mut request); + self.points.query(request).await + } + + /// Batch multiple point queries in a collection. + pub async fn query_batch( + &self, + request: impl Into, + ) -> QdrantResult { + let mut request = request.into(); + request.read_consistency = None; + for query in &mut request.query_points { + sanitize_query_points(query); + } + self.points.query_batch(request).await + } + + /// Query points and group results by a payload field. + pub async fn query_groups( + &self, + request: impl Into, + ) -> QdrantResult { + let mut request = request.into(); + request.read_consistency = None; + request.shard_key_selector = None; + request.lookup_from = None; + request.with_lookup = None; + self.points.query_groups(request).await + } + + /// Retrieve points by IDs. + pub async fn get_points(&self, request: impl Into) -> QdrantResult { + let mut request = request.into(); + request.read_consistency = None; + request.shard_key_selector = None; + self.points.get_points(request).await + } + + /// Scroll over points, optionally filtered. + pub async fn scroll(&self, request: impl Into) -> QdrantResult { + let mut request = request.into(); + request.read_consistency = None; + request.shard_key_selector = None; + self.points.scroll(request).await + } + + /// Count points in a collection. + pub async fn count(&self, request: impl Into) -> QdrantResult { + let mut request = request.into(); + request.read_consistency = None; + request.shard_key_selector = None; + self.points.count(request).await + } + + /// Insert or update points. + /// + /// Clears `ordering`, `shard_key_selector`, `update_filter`, and `update_mode`. + pub async fn upsert_points( + &self, + request: impl Into, + ) -> QdrantResult { + let mut request = request.into(); + request.ordering = None; + request.shard_key_selector = None; + request.update_filter = None; + request.update_mode = None; + self.points.upsert_points(request).await + } + + /// Update vectors of existing points. + pub async fn update_vectors( + &self, + request: impl Into, + ) -> QdrantResult { + let mut request = request.into(); + request.ordering = None; + request.shard_key_selector = None; + request.update_filter = None; + self.points.update_vectors(request).await + } + + /// Delete named vectors from points. + pub async fn delete_vectors( + &self, + request: impl Into, + ) -> QdrantResult { + let mut request = request.into(); + request.ordering = None; + request.shard_key_selector = None; + self.points.delete_vectors(request).await + } + + /// Delete points by selector. + /// + /// Prefer selecting by explicit IDs: serverless rejects filtered updates. + pub async fn delete_points( + &self, + request: impl Into, + ) -> QdrantResult { + let mut request = request.into(); + request.ordering = None; + request.shard_key_selector = None; + self.points.delete_points(request).await + } + + /// Set (merge) payload on points. + pub async fn set_payload( + &self, + request: impl Into, + ) -> QdrantResult { + let mut request = request.into(); + request.ordering = None; + request.shard_key_selector = None; + self.points.set_payload(request).await + } + + /// Overwrite the entire payload of points. + pub async fn overwrite_payload( + &self, + request: impl Into, + ) -> QdrantResult { + let mut request = request.into(); + request.ordering = None; + request.shard_key_selector = None; + self.points.overwrite_payload(request).await + } + + /// Delete payload keys from points. + pub async fn delete_payload( + &self, + request: impl Into, + ) -> QdrantResult { + let mut request = request.into(); + request.ordering = None; + request.shard_key_selector = None; + self.points.delete_payload(request).await + } + + /// Clear the entire payload of points. + pub async fn clear_payload( + &self, + request: impl Into, + ) -> QdrantResult { + let mut request = request.into(); + request.ordering = None; + request.shard_key_selector = None; + self.points.clear_payload(request).await + } + + /// Batch point update operations. + /// + /// Operations with filter-based selectors are rejected by the serverless service. + pub async fn batch_update_points( + &self, + request: impl Into, + ) -> QdrantResult { + let mut request = request.into(); + request.ordering = None; + self.points.update_points_batch(request).await + } +} + +fn sanitize_query_points(request: &mut QueryPoints) { + request.read_consistency = None; + request.shard_key_selector = None; + request.lookup_from = None; +} + +/// Ensure a serverless URL uses port 443 when none is specified. +fn normalize_serverless_url(url: &str) -> String { + match url.parse::() { + Ok(uri) => { + if uri.port().is_some() { + return url.to_string(); + } + let scheme = uri.scheme_str().unwrap_or("https"); + let Some(authority) = uri.authority() else { + return url.to_string(); + }; + let host = authority.host(); + let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or(""); + // Avoid duplicating "/" path when the URI has only the default path. + let path = if path_and_query == "/" { + "" + } else { + path_and_query + }; + format!("{scheme}://{host}:{DEFAULT_SERVERLESS_GRPC_PORT}{path}") + } + Err(_) => url.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_adds_default_port() { + assert_eq!( + normalize_serverless_url("https://serverless.example.qdrant.io"), + "https://serverless.example.qdrant.io:443" + ); + assert_eq!( + normalize_serverless_url("https://serverless.example.qdrant.io:8443"), + "https://serverless.example.qdrant.io:8443" + ); + } + + #[test] + fn client_construction_is_offline() { + let client = QdrantServerless::from_url("https://serverless.example.qdrant.io") + .api_key("secret") + .build() + .unwrap(); + assert_eq!( + client.points.config.uri, + "https://serverless.example.qdrant.io:443" + ); + assert_eq!(client.points.config.api_key.as_deref(), Some("secret")); + assert!(!client.points.config.check_compatibility); + } +} diff --git a/src/serverless/conversions.rs b/src/serverless/conversions.rs new file mode 100644 index 0000000..d55a289 --- /dev/null +++ b/src/serverless/conversions.rs @@ -0,0 +1,299 @@ +//! Conversions between serverless public models and the internal gRPC types. +//! +//! Generated gRPC types are an implementation detail and must not leak into +//! the public interface. + +use crate::qdrant_client::error::QdrantError; +use crate::serverless::grpc::{ + self, payload_index_config, BoolIndex as GrpcBoolIndex, DatetimeIndex as GrpcDatetimeIndex, + DenseVectorConfig as GrpcDenseVectorConfig, Distance as GrpcDistance, + FloatIndex as GrpcFloatIndex, GeoIndex as GrpcGeoIndex, IntegerIndex as GrpcIntegerIndex, + KeywordIndex as GrpcKeywordIndex, PayloadIndexConfig, PrecisionTier as GrpcPrecisionTier, + SparseVectorConfig as GrpcSparseVectorConfig, TextIndex as GrpcTextIndex, + Tokenizer as GrpcTokenizer, UuidIndex as GrpcUuidIndex, +}; +use crate::serverless::models::{ + BoolIndex, CollectionConfig, DatetimeIndex, DenseVectorConfig, Distance, FloatIndex, GeoIndex, + IntegerIndex, KeywordIndex, PayloadIndex, PrecisionTier, SparseVectorConfig, TextIndex, + Tokenizer, UuidIndex, +}; + +fn distance_to_grpc(distance: Distance) -> GrpcDistance { + match distance { + Distance::Cosine => GrpcDistance::Cosine, + Distance::Euclid => GrpcDistance::Euclid, + Distance::Dot => GrpcDistance::Dot, + Distance::Manhattan => GrpcDistance::Manhattan, + } +} + +fn distance_from_grpc(distance: GrpcDistance) -> Result { + match distance { + GrpcDistance::Cosine => Ok(Distance::Cosine), + GrpcDistance::Euclid => Ok(Distance::Euclid), + GrpcDistance::Dot => Ok(Distance::Dot), + GrpcDistance::Manhattan => Ok(Distance::Manhattan), + GrpcDistance::Unspecified => Err(QdrantError::ConversionError( + "serverless Distance is unspecified".into(), + )), + } +} + +fn precision_to_grpc(tier: PrecisionTier) -> GrpcPrecisionTier { + match tier { + PrecisionTier::Low => GrpcPrecisionTier::Low, + PrecisionTier::Medium => GrpcPrecisionTier::Medium, + PrecisionTier::High => GrpcPrecisionTier::High, + } +} + +fn precision_from_grpc(tier: GrpcPrecisionTier) -> Result { + match tier { + GrpcPrecisionTier::Low => Ok(PrecisionTier::Low), + GrpcPrecisionTier::Medium => Ok(PrecisionTier::Medium), + GrpcPrecisionTier::High => Ok(PrecisionTier::High), + GrpcPrecisionTier::Unspecified => Err(QdrantError::ConversionError( + "serverless PrecisionTier is unspecified".into(), + )), + } +} + +fn tokenizer_to_grpc(tokenizer: Tokenizer) -> GrpcTokenizer { + match tokenizer { + Tokenizer::Prefix => GrpcTokenizer::Prefix, + Tokenizer::Whitespace => GrpcTokenizer::Whitespace, + Tokenizer::Word => GrpcTokenizer::Word, + Tokenizer::Multilingual => GrpcTokenizer::Multilingual, + } +} + +fn tokenizer_from_grpc(tokenizer: GrpcTokenizer) -> Result { + match tokenizer { + GrpcTokenizer::Prefix => Ok(Tokenizer::Prefix), + GrpcTokenizer::Whitespace => Ok(Tokenizer::Whitespace), + GrpcTokenizer::Word => Ok(Tokenizer::Word), + GrpcTokenizer::Multilingual => Ok(Tokenizer::Multilingual), + GrpcTokenizer::Unspecified => Err(QdrantError::ConversionError( + "serverless Tokenizer is unspecified".into(), + )), + } +} + +pub(crate) fn dense_vector_to_grpc(model: &DenseVectorConfig) -> GrpcDenseVectorConfig { + GrpcDenseVectorConfig { + size: model.size, + distance: distance_to_grpc(model.distance) as i32, + multivector: model.multivector, + precision_tier: model.precision_tier.map(|t| precision_to_grpc(t) as i32), + } +} + +pub(crate) fn dense_vector_from_grpc( + grpc_model: &GrpcDenseVectorConfig, +) -> Result { + Ok(DenseVectorConfig { + size: grpc_model.size, + distance: distance_from_grpc(GrpcDistance::try_from(grpc_model.distance).map_err( + |_| QdrantError::ConversionError(format!("unknown Distance {}", grpc_model.distance)), + )?)?, + multivector: grpc_model.multivector, + precision_tier: grpc_model + .precision_tier + .map(|t| { + precision_from_grpc(GrpcPrecisionTier::try_from(t).map_err(|_| { + QdrantError::ConversionError(format!("unknown PrecisionTier {t}")) + })?) + }) + .transpose()?, + }) +} + +pub(crate) fn sparse_vector_to_grpc(model: &SparseVectorConfig) -> GrpcSparseVectorConfig { + GrpcSparseVectorConfig { + use_idf: model.use_idf, + precision_tier: model.precision_tier.map(|t| precision_to_grpc(t) as i32), + } +} + +pub(crate) fn sparse_vector_from_grpc( + grpc_model: &GrpcSparseVectorConfig, +) -> Result { + Ok(SparseVectorConfig { + use_idf: grpc_model.use_idf, + precision_tier: grpc_model + .precision_tier + .map(|t| { + precision_from_grpc(GrpcPrecisionTier::try_from(t).map_err(|_| { + QdrantError::ConversionError(format!("unknown PrecisionTier {t}")) + })?) + }) + .transpose()?, + }) +} + +pub(crate) fn payload_index_to_grpc(model: &PayloadIndex) -> PayloadIndexConfig { + let index = match model { + PayloadIndex::Keyword(_) => payload_index_config::Index::Keyword(GrpcKeywordIndex {}), + PayloadIndex::Integer(IntegerIndex { lookup, range }) => { + payload_index_config::Index::Integer(GrpcIntegerIndex { + lookup: *lookup, + range: *range, + }) + } + PayloadIndex::Float(_) => payload_index_config::Index::Float(GrpcFloatIndex {}), + PayloadIndex::Uuid(_) => payload_index_config::Index::Uuid(GrpcUuidIndex {}), + PayloadIndex::Datetime(_) => payload_index_config::Index::Datetime(GrpcDatetimeIndex {}), + PayloadIndex::Text(text) => payload_index_config::Index::Text(GrpcTextIndex { + tokenizer: text.tokenizer.map(|t| tokenizer_to_grpc(t) as i32), + lowercase: text.lowercase, + phrase_matching: text.phrase_matching, + min_token_len: text.min_token_len, + max_token_len: text.max_token_len, + }), + PayloadIndex::Geo(_) => payload_index_config::Index::Geo(GrpcGeoIndex {}), + PayloadIndex::Bool(_) => payload_index_config::Index::Bool(GrpcBoolIndex {}), + }; + PayloadIndexConfig { index: Some(index) } +} + +pub(crate) fn payload_index_from_grpc( + grpc_model: &PayloadIndexConfig, +) -> Result { + match grpc_model.index.as_ref() { + Some(payload_index_config::Index::Keyword(_)) => { + Ok(PayloadIndex::Keyword(KeywordIndex)) + } + Some(payload_index_config::Index::Integer(integer)) => { + Ok(PayloadIndex::Integer(IntegerIndex { + lookup: integer.lookup, + range: integer.range, + })) + } + Some(payload_index_config::Index::Float(_)) => Ok(PayloadIndex::Float(FloatIndex)), + Some(payload_index_config::Index::Uuid(_)) => Ok(PayloadIndex::Uuid(UuidIndex)), + Some(payload_index_config::Index::Datetime(_)) => { + Ok(PayloadIndex::Datetime(DatetimeIndex)) + } + Some(payload_index_config::Index::Text(text)) => Ok(PayloadIndex::Text(TextIndex { + tokenizer: text + .tokenizer + .map(|t| { + tokenizer_from_grpc(GrpcTokenizer::try_from(t).map_err(|_| { + QdrantError::ConversionError(format!("unknown Tokenizer {t}")) + })?) + }) + .transpose()?, + lowercase: text.lowercase, + phrase_matching: text.phrase_matching, + min_token_len: text.min_token_len, + max_token_len: text.max_token_len, + })), + Some(payload_index_config::Index::Geo(_)) => Ok(PayloadIndex::Geo(GeoIndex)), + Some(payload_index_config::Index::Bool(_)) => Ok(PayloadIndex::Bool(BoolIndex)), + None => Err(QdrantError::ConversionError( + "serverless PayloadIndexConfig has no index variant".into(), + )), + } +} + +pub(crate) fn collection_config_to_grpc(model: &CollectionConfig) -> grpc::CollectionConfig { + grpc::CollectionConfig { + dense_vectors: model + .dense_vectors + .iter() + .map(|(name, dense)| (name.clone(), dense_vector_to_grpc(dense))) + .collect(), + sparse_vectors: model + .sparse_vectors + .iter() + .map(|(name, sparse)| (name.clone(), sparse_vector_to_grpc(sparse))) + .collect(), + payload_indexes: model + .payload_indexes + .iter() + .map(|(field, index)| (field.clone(), payload_index_to_grpc(index))) + .collect(), + } +} + +pub(crate) fn collection_config_from_grpc( + grpc_model: &grpc::CollectionConfig, +) -> Result { + Ok(CollectionConfig { + dense_vectors: grpc_model + .dense_vectors + .iter() + .map(|(name, dense)| Ok((name.clone(), dense_vector_from_grpc(dense)?))) + .collect::>()?, + sparse_vectors: grpc_model + .sparse_vectors + .iter() + .map(|(name, sparse)| Ok((name.clone(), sparse_vector_from_grpc(sparse)?))) + .collect::>()?, + payload_indexes: grpc_model + .payload_indexes + .iter() + .map(|(field, index)| Ok((field.clone(), payload_index_from_grpc(index)?))) + .collect::>()?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::serverless::models::{IntegerIndex, KeywordIndex, TextIndex}; + + #[test] + fn collection_config_grpc_roundtrip() { + let config = CollectionConfig::new() + .dense_vector(DenseVectorConfig::new(1536, Distance::Cosine)) + .named_dense_vector( + "colbert", + DenseVectorConfig::new(128, Distance::Dot) + .multivector(true) + .precision_tier(PrecisionTier::Low), + ) + .named_sparse_vector("bm25", SparseVectorConfig::new().use_idf(true)) + .payload_index("user_id", KeywordIndex) + .payload_index("age", IntegerIndex::new().lookup(true).range(false)) + .payload_index( + "description", + TextIndex::new() + .tokenizer(Tokenizer::Word) + .lowercase(false), + ); + + let roundtrip = collection_config_from_grpc(&collection_config_to_grpc(&config)).unwrap(); + assert_eq!(roundtrip, config); + } + + #[test] + fn optional_fields_stay_unset() { + let config = CollectionConfig::new() + .dense_vector(DenseVectorConfig::new(4, Distance::Euclid)) + .payload_index("age", IntegerIndex::new()) + .payload_index("text", TextIndex::new()); + + let grpc_config = collection_config_to_grpc(&config); + let dense = grpc_config.dense_vectors.get("").unwrap(); + assert!(dense.precision_tier.is_none()); + let age = grpc_config.payload_indexes.get("age").unwrap(); + match age.index.as_ref().unwrap() { + payload_index_config::Index::Integer(integer) => { + assert!(integer.lookup.is_none()); + assert!(integer.range.is_none()); + } + other => panic!("expected integer index, got {other:?}"), + } + let text = grpc_config.payload_indexes.get("text").unwrap(); + match text.index.as_ref().unwrap() { + payload_index_config::Index::Text(text) => { + assert!(text.tokenizer.is_none()); + } + other => panic!("expected text index, got {other:?}"), + } + + let roundtrip = collection_config_from_grpc(&grpc_config).unwrap(); + assert_eq!(roundtrip, config); + } +} diff --git a/src/serverless/grpc.rs b/src/serverless/grpc.rs new file mode 100644 index 0000000..da958ef --- /dev/null +++ b/src/serverless/grpc.rs @@ -0,0 +1,564 @@ +// @generated by tonic-prost-build. DO NOT EDIT. +// Regenerate: cargo test --test serverless_protos -- --ignored --nocapture +// This file is @generated by prost-build. +/// Configuration of a single dense (embedding) vector. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DenseVectorConfig { + /// Dimensionality of the embedding, e.g. 512 (CLIP), 1536, 3072. + #[prost(uint64, tag = "1")] + pub size: u64, + /// Distance metric used to compare vectors. + #[prost(enumeration = "Distance", tag = "2")] + pub distance: i32, + /// Store several sub-vectors per point and compare with max-sim, for + /// late-interaction models (ColBERT, ColPali, ...). + #[prost(bool, tag = "3")] + pub multivector: bool, + /// Precision/cost trade-off for this vector. Unset: HIGH. + #[prost(enumeration = "PrecisionTier", optional, tag = "4")] + pub precision_tier: ::core::option::Option, +} +/// Configuration of a single sparse vector. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SparseVectorConfig { + /// Apply the IDF modifier at query time. Enable for BM25-style models that + /// expect inverse-document-frequency weighting. + #[prost(bool, tag = "1")] + pub use_idf: bool, + /// Precision/cost trade-off for this vector. Unset: HIGH. + #[prost(enumeration = "PrecisionTier", optional, tag = "2")] + pub precision_tier: ::core::option::Option, +} +/// Exact match on string values, e.g. `color: "red"`. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct KeywordIndex {} +/// Exact match and/or range filters on integers, e.g. `age: 25`. Both are on +/// by default; turning one off shrinks the index. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct IntegerIndex { + /// Support exact-match filters. + #[prost(bool, optional, tag = "1")] + pub lookup: ::core::option::Option, + /// Support range filters. + #[prost(bool, optional, tag = "2")] + pub range: ::core::option::Option, +} +/// Range filters on floating point (and integer) numbers, e.g. `price: 99.5`. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct FloatIndex {} +/// Exact match on UUID strings; like keyword but stored compactly. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UuidIndex {} +/// Range filters on RFC 3339 datetimes, e.g. `created_at: "2023-02-08T10:49:00Z"`. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DatetimeIndex {} +/// Full-text filtering on string values. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct TextIndex { + /// Tokenizer to split text with. Unset: WHITESPACE. + #[prost(enumeration = "Tokenizer", optional, tag = "1")] + pub tokenizer: ::core::option::Option, + /// Lowercase text before indexing. Default true. + #[prost(bool, optional, tag = "2")] + pub lowercase: ::core::option::Option, + /// Support phrase queries; extra index structure. Default true. + #[prost(bool, optional, tag = "3")] + pub phrase_matching: ::core::option::Option, + /// Minimum token length to index. + #[prost(uint64, optional, tag = "4")] + pub min_token_len: ::core::option::Option, + /// Maximum token length to index. + #[prost(uint64, optional, tag = "5")] + pub max_token_len: ::core::option::Option, +} +/// Geo radius / bounding box / polygon filters on `{lon, lat}` values. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GeoIndex {} +/// Exact match on booleans, e.g. `is_active: true`. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct 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. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PayloadIndexConfig { + /// The kind of filter the field supports. + #[prost(oneof = "payload_index_config::Index", tags = "1, 2, 3, 4, 5, 6, 7, 8")] + pub index: ::core::option::Option, +} +/// Nested message and enum types in `PayloadIndexConfig`. +pub mod payload_index_config { + /// The kind of filter the field supports. + #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Index { + /// Exact match on strings. + #[prost(message, tag = "1")] + Keyword(super::KeywordIndex), + /// Exact match and/or range filters on integers. + #[prost(message, tag = "2")] + Integer(super::IntegerIndex), + /// Range filters on numbers. + #[prost(message, tag = "3")] + Float(super::FloatIndex), + /// Exact match on UUIDs. + #[prost(message, tag = "4")] + Uuid(super::UuidIndex), + /// Range filters on datetimes. + #[prost(message, tag = "5")] + Datetime(super::DatetimeIndex), + /// Full-text filtering. + #[prost(message, tag = "6")] + Text(super::TextIndex), + /// Geo filters. + #[prost(message, tag = "7")] + Geo(super::GeoIndex), + /// Exact match on booleans. + #[prost(message, tag = "8")] + Bool(super::BoolIndex), + } +} +/// The tenant's collection config. Persisted verbatim. At least one dense or +/// sparse vector is required. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct 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. + #[prost(map = "string, message", tag = "1")] + pub dense_vectors: ::std::collections::HashMap< + ::prost::alloc::string::String, + DenseVectorConfig, + >, + /// Keyed by vector name. + #[prost(map = "string, message", tag = "2")] + pub sparse_vectors: ::std::collections::HashMap< + ::prost::alloc::string::String, + SparseVectorConfig, + >, + /// Keyed by payload field name (JSON path, e.g. `user_id` or `meta.tags`). + #[prost(map = "string, message", tag = "3")] + pub payload_indexes: ::std::collections::HashMap< + ::prost::alloc::string::String, + PayloadIndexConfig, + >, +} +/// 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. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CreateCollectionRequest { + /// Tenant-facing name of the collection to create. + #[prost(string, tag = "1")] + pub collection_name: ::prost::alloc::string::String, + /// The collection's configuration. + #[prost(message, optional, tag = "2")] + pub config: ::core::option::Option, +} +/// Result of a create. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CreateCollectionResponse { + /// Tenant-facing name of the collection. + #[prost(string, tag = "1")] + pub collection_name: ::prost::alloc::string::String, + /// Outcome, e.g. "created", "already exists". + #[prost(string, tag = "2")] + pub result: ::prost::alloc::string::String, +} +/// Names the collection to delete. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DeleteCollectionRequest { + /// Tenant-facing name of the collection to delete. + #[prost(string, tag = "1")] + pub collection_name: ::prost::alloc::string::String, +} +/// Result of a delete. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DeleteCollectionResponse { + /// Whether the collection existed and was deleted. + #[prost(bool, tag = "1")] + pub deleted: bool, + /// Number of storage objects removed. + #[prost(uint32, tag = "2")] + pub objects_deleted: u32, +} +/// Names the collection to fetch. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetCollectionRequest { + /// Tenant-facing name of the collection. + #[prost(string, tag = "1")] + pub collection_name: ::prost::alloc::string::String, +} +/// The collection's configuration and stats, if it exists. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetCollectionResponse { + /// Whether the collection exists. + #[prost(bool, tag = "1")] + pub exists: bool, + /// The configuration the collection was created with. + #[prost(message, optional, tag = "2")] + pub config: ::core::option::Option, + /// Available points as of the last applied write (eventually consistent); + /// absent until the updater has written stats for the collection. + #[prost(uint64, optional, tag = "3")] + pub point_count: ::core::option::Option, +} +/// Lists the caller's collections. The tenant travels in metadata, so there is +/// nothing to name here. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ListCollectionsRequest {} +/// One collection in a listing. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CollectionSummary { + /// Tenant-facing name of the collection. + #[prost(string, tag = "1")] + pub collection_name: ::prost::alloc::string::String, + /// Available points as of the last applied write (eventually consistent); + /// absent until the updater has written stats for the collection. + #[prost(uint64, optional, tag = "2")] + pub point_count: ::core::option::Option, +} +/// The caller's collections. +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListCollectionsResponse { + /// Ordered by name. A collection whose creation never published a manifest is + /// not listed: it is not servable. + #[prost(message, repeated, tag = "1")] + pub collections: ::prost::alloc::vec::Vec, +} +/// Distance metric used to compare dense vectors. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum Distance { + /// Unset; a concrete metric is required. + Unspecified = 0, + /// Cosine similarity. + Cosine = 1, + /// Euclidean (L2) distance. + Euclid = 2, + /// Dot product. + Dot = 3, + /// Manhattan (L1) distance. + Manhattan = 4, +} +impl Distance { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "DISTANCE_UNSPECIFIED", + Self::Cosine => "COSINE", + Self::Euclid => "EUCLID", + Self::Dot => "DOT", + Self::Manhattan => "MANHATTAN", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "DISTANCE_UNSPECIFIED" => Some(Self::Unspecified), + "COSINE" => Some(Self::Cosine), + "EUCLID" => Some(Self::Euclid), + "DOT" => Some(Self::Dot), + "MANHATTAN" => Some(Self::Manhattan), + _ => None, + } + } +} +/// 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. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum PrecisionTier { + /// Unset: HIGH. + 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, +} +impl PrecisionTier { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "PRECISION_TIER_UNSPECIFIED", + Self::Low => "LOW", + Self::Medium => "MEDIUM", + Self::High => "HIGH", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "PRECISION_TIER_UNSPECIFIED" => Some(Self::Unspecified), + "LOW" => Some(Self::Low), + "MEDIUM" => Some(Self::Medium), + "HIGH" => Some(Self::High), + _ => None, + } + } +} +/// Full-text tokenizer, mirrors qdrant's `TokenizerType`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum Tokenizer { + /// Unset: WHITESPACE. + 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, +} +impl Tokenizer { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "TOKENIZER_UNSPECIFIED", + Self::Prefix => "PREFIX", + Self::Whitespace => "WHITESPACE", + Self::Word => "WORD", + Self::Multilingual => "MULTILINGUAL", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "TOKENIZER_UNSPECIFIED" => Some(Self::Unspecified), + "PREFIX" => Some(Self::Prefix), + "WHITESPACE" => Some(Self::Whitespace), + "WORD" => Some(Self::Word), + "MULTILINGUAL" => Some(Self::Multilingual), + _ => None, + } + } +} +/// Generated client implementations. +pub mod collections_service_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + /// 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. + #[derive(Debug, Clone)] + pub struct CollectionsServiceClient { + inner: tonic::client::Grpc, + } + impl CollectionsServiceClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl CollectionsServiceClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> CollectionsServiceClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + CollectionsServiceClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + /// Creates a collection with the given tenant-facing configuration. + pub async fn create_collection( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/qdrant.serverless.CollectionsService/CreateCollection", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "qdrant.serverless.CollectionsService", + "CreateCollection", + ), + ); + self.inner.unary(req, path, codec).await + } + /// Deletes a collection and all of its data. + pub async fn delete_collection( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/qdrant.serverless.CollectionsService/DeleteCollection", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "qdrant.serverless.CollectionsService", + "DeleteCollection", + ), + ); + self.inner.unary(req, path, codec).await + } + /// Returns a single collection's configuration and stats. + pub async fn get_collection( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/qdrant.serverless.CollectionsService/GetCollection", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "qdrant.serverless.CollectionsService", + "GetCollection", + ), + ); + self.inner.unary(req, path, codec).await + } + /// Lists the caller's collections. + pub async fn list_collections( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/qdrant.serverless.CollectionsService/ListCollections", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "qdrant.serverless.CollectionsService", + "ListCollections", + ), + ); + self.inner.unary(req, path, codec).await + } + } +} diff --git a/src/serverless/mod.rs b/src/serverless/mod.rs new file mode 100644 index 0000000..a8a77cc --- /dev/null +++ b/src/serverless/mod.rs @@ -0,0 +1,41 @@ +//! Client for Qdrant Serverless. +//! +//! Point-level operations (query, upsert, ...) behave like the regular client; +//! collection management uses the simplified, tenant-facing serverless API. +//! +//! # Example +//! +//! ```no_run +//! use qdrant_client::serverless::{ +//! CollectionConfig, DenseVectorConfig, Distance, QdrantServerless, +//! }; +//! +//! # async fn run() -> Result<(), qdrant_client::QdrantError> { +//! let client = QdrantServerless::from_url("https://serverless.example.cloud.qdrant.io") +//! .api_key("") +//! .build()?; +//! +//! client +//! .create_collection( +//! "my-collection", +//! CollectionConfig::new() +//! .dense_vector(DenseVectorConfig::new(1536, Distance::Cosine)), +//! ) +//! .await?; +//! # Ok(()) +//! # } +//! ``` + +mod client; +mod conversions; +#[allow(clippy::all)] +#[rustfmt::skip] +pub(crate) mod grpc; +pub mod models; + +pub use client::{QdrantServerless, QdrantServerlessBuilder, DEFAULT_SERVERLESS_GRPC_PORT}; +pub use models::{ + BoolIndex, CollectionConfig, CollectionInfo, CollectionSummary, DatetimeIndex, + DenseVectorConfig, Distance, FloatIndex, GeoIndex, IntegerIndex, KeywordIndex, PayloadIndex, + PrecisionTier, SparseVectorConfig, TextIndex, Tokenizer, UuidIndex, +}; diff --git a/src/serverless/models.rs b/src/serverless/models.rs new file mode 100644 index 0000000..0ce52bc --- /dev/null +++ b/src/serverless/models.rs @@ -0,0 +1,349 @@ +//! Public 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. + +use std::collections::HashMap; + +/// Distance metric used to compare dense vectors. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum Distance { + Cosine, + Euclid, + Dot, + Manhattan, +} + +/// How much of the original vector precision may be traded for cost. +/// +/// The manager turns this into a concrete quantization / datatype choice. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum PrecisionTier { + Low, + Medium, + High, +} + +/// Full-text tokenizer, mirrors Qdrant's `TokenizerType`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum Tokenizer { + Prefix, + Whitespace, + Word, + Multilingual, +} + +/// Configuration of a single dense (embedding) vector. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct DenseVectorConfig { + /// Dimensionality of the embedding, e.g. 512, 1536, 3072. + pub size: u64, + /// Distance metric used to compare vectors. + pub distance: Distance, + /// Store several sub-vectors per point and compare with max-sim. + pub multivector: bool, + /// Precision/cost trade-off for this vector. Unset: HIGH. + pub precision_tier: Option, +} + +impl DenseVectorConfig { + /// Create a dense vector config with the given size and distance. + pub fn new(size: u64, distance: Distance) -> Self { + Self { + size, + distance, + multivector: false, + precision_tier: None, + } + } + + /// Enable multi-vector (late-interaction) storage. + pub fn multivector(mut self, multivector: bool) -> Self { + self.multivector = multivector; + self + } + + /// Set the precision/cost trade-off. + pub fn precision_tier(mut self, tier: PrecisionTier) -> Self { + self.precision_tier = Some(tier); + self + } +} + +/// Configuration of a single sparse vector. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct SparseVectorConfig { + /// Apply the IDF modifier at query time. + pub use_idf: bool, + /// Precision/cost trade-off for this vector. Unset: HIGH. + pub precision_tier: Option, +} + +impl SparseVectorConfig { + /// Create a sparse vector config. + pub fn new() -> Self { + Self::default() + } + + /// Enable IDF weighting (BM25-style). + pub fn use_idf(mut self, use_idf: bool) -> Self { + self.use_idf = use_idf; + self + } + + /// Set the precision/cost trade-off. + pub fn precision_tier(mut self, tier: PrecisionTier) -> Self { + self.precision_tier = Some(tier); + self + } +} + +/// Exact match on string values, e.g. `color: "red"`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct KeywordIndex; + +/// Exact match and/or range filters on integers. Both default to enabled. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct IntegerIndex { + pub lookup: Option, + pub range: Option, +} + +impl IntegerIndex { + pub fn new() -> Self { + Self::default() + } + + pub fn lookup(mut self, lookup: bool) -> Self { + self.lookup = Some(lookup); + self + } + + pub fn range(mut self, range: bool) -> Self { + self.range = Some(range); + self + } +} + +/// Range filters on floating point (and integer) numbers. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct FloatIndex; + +/// Exact match on UUID strings; like keyword but stored compactly. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct UuidIndex; + +/// Range filters on RFC 3339 datetimes. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct DatetimeIndex; + +/// Full-text filtering on string values. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct TextIndex { + pub tokenizer: Option, + pub lowercase: Option, + pub phrase_matching: Option, + pub min_token_len: Option, + pub max_token_len: Option, +} + +impl TextIndex { + pub fn new() -> Self { + Self::default() + } + + pub fn tokenizer(mut self, tokenizer: Tokenizer) -> Self { + self.tokenizer = Some(tokenizer); + self + } + + pub fn lowercase(mut self, lowercase: bool) -> Self { + self.lowercase = Some(lowercase); + self + } + + pub fn phrase_matching(mut self, phrase_matching: bool) -> Self { + self.phrase_matching = Some(phrase_matching); + self + } + + pub fn min_token_len(mut self, min_token_len: u64) -> Self { + self.min_token_len = Some(min_token_len); + self + } + + pub fn max_token_len(mut self, max_token_len: u64) -> Self { + self.max_token_len = Some(max_token_len); + self + } +} + +/// Geo radius / bounding box / polygon filters on `{lon, lat}` values. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct GeoIndex; + +/// Exact match on booleans. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct BoolIndex; + +/// One payload index. Only the kind of filter the field supports is chosen; +/// storage placement of the index is the manager's decision. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "snake_case"))] +pub enum PayloadIndex { + Keyword(KeywordIndex), + Integer(IntegerIndex), + Float(FloatIndex), + Uuid(UuidIndex), + Datetime(DatetimeIndex), + Text(TextIndex), + Geo(GeoIndex), + Bool(BoolIndex), +} + +impl From for PayloadIndex { + fn from(value: KeywordIndex) -> Self { + Self::Keyword(value) + } +} + +impl From for PayloadIndex { + fn from(value: IntegerIndex) -> Self { + Self::Integer(value) + } +} + +impl From for PayloadIndex { + fn from(value: FloatIndex) -> Self { + Self::Float(value) + } +} + +impl From for PayloadIndex { + fn from(value: UuidIndex) -> Self { + Self::Uuid(value) + } +} + +impl From for PayloadIndex { + fn from(value: DatetimeIndex) -> Self { + Self::Datetime(value) + } +} + +impl From for PayloadIndex { + fn from(value: TextIndex) -> Self { + Self::Text(value) + } +} + +impl From for PayloadIndex { + fn from(value: GeoIndex) -> Self { + Self::Geo(value) + } +} + +impl From for PayloadIndex { + fn from(value: BoolIndex) -> Self { + Self::Bool(value) + } +} + +/// 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`). +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CollectionConfig { + pub dense_vectors: HashMap, + pub sparse_vectors: HashMap, + pub payload_indexes: HashMap, +} + +impl CollectionConfig { + pub fn new() -> Self { + Self::default() + } + + /// Register a single unnamed dense vector (empty name `""`). + pub fn dense_vector(mut self, config: DenseVectorConfig) -> Self { + self.dense_vectors.insert(String::new(), config); + self + } + + /// Register a named dense vector. + pub fn named_dense_vector( + mut self, + name: impl Into, + config: DenseVectorConfig, + ) -> Self { + self.dense_vectors.insert(name.into(), config); + self + } + + /// Register a single unnamed sparse vector (empty name `""`). + pub fn sparse_vector(mut self, config: SparseVectorConfig) -> Self { + self.sparse_vectors.insert(String::new(), config); + self + } + + /// Register a named sparse vector. + pub fn named_sparse_vector( + mut self, + name: impl Into, + config: SparseVectorConfig, + ) -> Self { + self.sparse_vectors.insert(name.into(), config); + self + } + + /// Add a payload index for the given field path. + pub fn payload_index( + mut self, + field: impl Into, + index: impl Into, + ) -> Self { + self.payload_indexes.insert(field.into(), index.into()); + self + } +} + +/// A collection's configuration and stats, as returned by [`super::QdrantServerless::get_collection`]. +/// +/// `point_count` is eventually consistent and absent until stats have been +/// written for the collection. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CollectionInfo { + pub exists: bool, + pub config: Option, + pub point_count: Option, +} + +/// One collection in a [`super::QdrantServerless::list_collections`] listing. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CollectionSummary { + pub collection_name: String, + pub point_count: Option, +} diff --git a/tests/serverless_protos.rs b/tests/serverless_protos.rs new file mode 100644 index 0000000..9a538f9 --- /dev/null +++ b/tests/serverless_protos.rs @@ -0,0 +1,64 @@ +//! Regenerates `src/serverless/grpc.rs` from `proto/serverless_collections.proto`. +//! +//! Run with: `cargo test --test serverless_protos -- --ignored --nocapture` + +use std::fs; +use std::path::Path; +use std::time::SystemTime; + +fn timestamp(f: impl AsRef) -> SystemTime { + fs::metadata(f).unwrap().modified().unwrap() +} + +const GRPC_OUTPUT_FILE: &str = "src/serverless/grpc.rs"; +const PROTO_FILE: &str = "proto/serverless_collections.proto"; + +#[test] +#[ignore = "run explicitly to regenerate serverless gRPC stubs"] +fn regenerate_serverless_protos() { + tonic_prost_build::configure() + .build_server(false) + .build_client(true) + .out_dir("src/serverless/") + .compile_protos(&[PROTO_FILE], &["proto"]) + .expect("failed to compile serverless protos"); + + // prost writes `.rs` based on the last package component for nested packages, + // or a path. Rename/normalize to grpc.rs. + // package qdrant.serverless -> typically produces qdrant.serverless.rs or nested. + // With tonic-prost-build, the file is named after the proto package path. + let candidates = [ + "src/serverless/qdrant.serverless.rs", + "src/serverless/serverless.rs", + "src/serverless/qdrant.rs", + ]; + let generated = candidates + .into_iter() + .find(|p| Path::new(p).exists()) + .expect("expected generated serverless stub file"); + + if generated != GRPC_OUTPUT_FILE { + fs::rename(generated, GRPC_OUTPUT_FILE).expect("rename generated file"); + } + + // Keep generated types crate-private; the public surface is models + client. + let contents = fs::read_to_string(GRPC_OUTPUT_FILE).unwrap(); + let banner = "// @generated by tonic-prost-build. DO NOT EDIT.\n// Regenerate: cargo test --test serverless_protos -- --ignored --nocapture\n"; + if !contents.starts_with("// @generated") { + fs::write(GRPC_OUTPUT_FILE, format!("{banner}{contents}")).unwrap(); + } + eprintln!("Wrote {GRPC_OUTPUT_FILE}"); +} + +#[test] +fn serverless_protos_are_fresh() { + if !Path::new(GRPC_OUTPUT_FILE).exists() { + panic!("{GRPC_OUTPUT_FILE} missing; run: cargo test --test serverless_protos -- --ignored"); + } + let out_time = timestamp(GRPC_OUTPUT_FILE); + let proto_time = timestamp(PROTO_FILE); + assert!( + proto_time <= out_time, + "{PROTO_FILE} is newer than {GRPC_OUTPUT_FILE}; regenerate with: cargo test --test serverless_protos -- --ignored" + ); +} diff --git a/tools/sync_serverless_proto.sh b/tools/sync_serverless_proto.sh new file mode 100755 index 0000000..d2c69a2 --- /dev/null +++ b/tools/sync_serverless_proto.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Syncs proto/serverless_collections.proto from qdrant-cloud-public-api and +# regenerates src/serverless/grpc.rs. +# +# Usage: ./tools/sync_serverless_proto.sh + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +HEADER='// Source: https://github.com/qdrant/qdrant-cloud-public-api/blob/main/proto/qdrant/serverless/collections.proto +// Renamed to serverless_collections.proto to sit alongside the regular collections.proto. +// Regenerate with: cargo test --test serverless_protos -- --ignored --nocapture +' + +PROTO_PATH="proto/serverless_collections.proto" +{ + echo "$HEADER" + curl -fsSL https://raw.githubusercontent.com/qdrant/qdrant-cloud-public-api/main/proto/qdrant/serverless/collections.proto +} > "$PROTO_PATH" + +cargo test --test serverless_protos regenerate_serverless_protos -- --ignored --nocapture + +echo "Synced $PROTO_PATH and regenerated src/serverless/grpc.rs" From e62c6ef9e789768217605805224e87d17857311f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 08:47:15 +0000 Subject: [PATCH 2/4] style: rustfmt for serverless client --- examples/serverless.rs | 9 ++++----- src/lib.rs | 4 ++-- src/serverless/client.rs | 10 ++++++++-- src/serverless/conversions.rs | 12 +++--------- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/examples/serverless.rs b/examples/serverless.rs index a7ea403..df71ffe 100644 --- a/examples/serverless.rs +++ b/examples/serverless.rs @@ -16,11 +16,10 @@ use qdrant_client::{Payload, QdrantError}; #[tokio::main] async fn main() -> Result<(), QdrantError> { - let client = QdrantServerless::from_url( - "https://serverless.plush-volt.aws.development-cloud.qdrant.io", - ) - .api_key("") - .build()?; + let client = + QdrantServerless::from_url("https://serverless.plush-volt.aws.development-cloud.qdrant.io") + .api_key("") + .build()?; let collection_name = "my-collection"; diff --git a/src/lib.rs b/src/lib.rs index b6541b3..d6b47f0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -144,10 +144,10 @@ mod grpc_macros; mod manual_builder; mod payload; mod qdrant_client; -/// Client for Qdrant Serverless -pub mod serverless; #[cfg(feature = "serde")] mod serde_impl; +/// Client for Qdrant Serverless +pub mod serverless; #[cfg(feature = "serde")] pub mod serde_deser; diff --git a/src/serverless/client.rs b/src/serverless/client.rs index 6be202a..397dd1e 100644 --- a/src/serverless/client.rs +++ b/src/serverless/client.rs @@ -231,7 +231,10 @@ impl QdrantServerless { /// Deletes a collection and all of its data. /// /// Returns `true` if the collection existed and was deleted, `false` otherwise. - pub async fn delete_collection(&self, collection_name: impl Into) -> QdrantResult { + pub async fn delete_collection( + &self, + collection_name: impl Into, + ) -> QdrantResult { let request = DeleteCollectionRequest { collection_name: collection_name.into(), }; @@ -272,7 +275,10 @@ impl QdrantServerless { } /// Checks whether a collection exists. - pub async fn collection_exists(&self, collection_name: impl Into) -> QdrantResult { + pub async fn collection_exists( + &self, + collection_name: impl Into, + ) -> QdrantResult { Ok(self.get_collection(collection_name).await?.exists) } diff --git a/src/serverless/conversions.rs b/src/serverless/conversions.rs index d55a289..e6f6222 100644 --- a/src/serverless/conversions.rs +++ b/src/serverless/conversions.rs @@ -160,9 +160,7 @@ pub(crate) fn payload_index_from_grpc( grpc_model: &PayloadIndexConfig, ) -> Result { match grpc_model.index.as_ref() { - Some(payload_index_config::Index::Keyword(_)) => { - Ok(PayloadIndex::Keyword(KeywordIndex)) - } + Some(payload_index_config::Index::Keyword(_)) => Ok(PayloadIndex::Keyword(KeywordIndex)), Some(payload_index_config::Index::Integer(integer)) => { Ok(PayloadIndex::Integer(IntegerIndex { lookup: integer.lookup, @@ -171,9 +169,7 @@ pub(crate) fn payload_index_from_grpc( } Some(payload_index_config::Index::Float(_)) => Ok(PayloadIndex::Float(FloatIndex)), Some(payload_index_config::Index::Uuid(_)) => Ok(PayloadIndex::Uuid(UuidIndex)), - Some(payload_index_config::Index::Datetime(_)) => { - Ok(PayloadIndex::Datetime(DatetimeIndex)) - } + Some(payload_index_config::Index::Datetime(_)) => Ok(PayloadIndex::Datetime(DatetimeIndex)), Some(payload_index_config::Index::Text(text)) => Ok(PayloadIndex::Text(TextIndex { tokenizer: text .tokenizer @@ -258,9 +254,7 @@ mod tests { .payload_index("age", IntegerIndex::new().lookup(true).range(false)) .payload_index( "description", - TextIndex::new() - .tokenizer(Tokenizer::Word) - .lowercase(false), + TextIndex::new().tokenizer(Tokenizer::Word).lowercase(false), ); let roundtrip = collection_config_from_grpc(&collection_config_to_grpc(&config)).unwrap(); From ce0e99bb4a3f6c3e758f847628f5c975a13caafc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 08:59:48 +0000 Subject: [PATCH 3/4] docs: mark QdrantServerless as in development Warn that the serverless client is experimental and should not be used yet. --- examples/serverless.rs | 3 +++ src/lib.rs | 2 +- src/serverless/client.rs | 6 ++++++ src/serverless/mod.rs | 3 +++ src/serverless/models.rs | 3 +++ 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/examples/serverless.rs b/examples/serverless.rs index df71ffe..5797ccc 100644 --- a/examples/serverless.rs +++ b/examples/serverless.rs @@ -1,5 +1,8 @@ //! 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 like in the regular client (unsupported cluster //! parameters are stripped before each RPC). diff --git a/src/lib.rs b/src/lib.rs index d6b47f0..4a71a4a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -146,7 +146,7 @@ mod payload; mod qdrant_client; #[cfg(feature = "serde")] mod serde_impl; -/// Client for Qdrant Serverless +/// Client for Qdrant Serverless (**in development — do not use yet**) pub mod serverless; #[cfg(feature = "serde")] diff --git a/src/serverless/client.rs b/src/serverless/client.rs index 397dd1e..de0fb67 100644 --- a/src/serverless/client.rs +++ b/src/serverless/client.rs @@ -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 @@ -37,6 +40,9 @@ pub const DEFAULT_SERVERLESS_GRPC_PORT: u16 = 443; /// 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 [`Qdrant`] client, except that /// parameters serverless does not support (read consistency, shard selection, /// write ordering, filtered updates, cross-collection lookups) are cleared diff --git a/src/serverless/mod.rs b/src/serverless/mod.rs index a8a77cc..d2ac4d9 100644 --- a/src/serverless/mod.rs +++ b/src/serverless/mod.rs @@ -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/src/serverless/models.rs b/src/serverless/models.rs index 0ce52bc..9501ed6 100644 --- a/src/serverless/models.rs +++ b/src/serverless/models.rs @@ -1,5 +1,8 @@ //! Public 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 00c567b37921354e8d2c1be6e425813a2b1b8a56 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 09:21:07 +0000 Subject: [PATCH 4/4] refactor: explicitly deconstruct serverless conversion fields Bind every model and gRPC struct field by name so new proto fields become compile errors instead of being silently dropped via `_` wildcards. --- src/serverless/conversions.rs | 200 ++++++++++++++++++++++------------ 1 file changed, 131 insertions(+), 69 deletions(-) diff --git a/src/serverless/conversions.rs b/src/serverless/conversions.rs index e6f6222..935e050 100644 --- a/src/serverless/conversions.rs +++ b/src/serverless/conversions.rs @@ -2,6 +2,9 @@ //! //! Generated gRPC types are an implementation detail and must not leak into //! the public interface. +//! +//! All struct fields are bound by destructuring (never via `.field` or `_`) so +//! adding a proto/model field is a compile error until conversions are updated. use crate::qdrant_client::error::QdrantError; use crate::serverless::grpc::{ @@ -79,26 +82,39 @@ fn tokenizer_from_grpc(tokenizer: GrpcTokenizer) -> Result GrpcDenseVectorConfig { +pub(crate) fn dense_vector_to_grpc( + DenseVectorConfig { + size, + distance, + multivector, + precision_tier, + }: &DenseVectorConfig, +) -> GrpcDenseVectorConfig { GrpcDenseVectorConfig { - size: model.size, - distance: distance_to_grpc(model.distance) as i32, - multivector: model.multivector, - precision_tier: model.precision_tier.map(|t| precision_to_grpc(t) as i32), + size: *size, + distance: distance_to_grpc(*distance) as i32, + multivector: *multivector, + precision_tier: precision_tier.map(|t| precision_to_grpc(t) as i32), } } pub(crate) fn dense_vector_from_grpc( - grpc_model: &GrpcDenseVectorConfig, + GrpcDenseVectorConfig { + size, + distance, + multivector, + precision_tier, + }: &GrpcDenseVectorConfig, ) -> Result { Ok(DenseVectorConfig { - size: grpc_model.size, - distance: distance_from_grpc(GrpcDistance::try_from(grpc_model.distance).map_err( - |_| QdrantError::ConversionError(format!("unknown Distance {}", grpc_model.distance)), - )?)?, - multivector: grpc_model.multivector, - precision_tier: grpc_model - .precision_tier + size: *size, + distance: distance_from_grpc( + GrpcDistance::try_from(*distance).map_err(|_| { + QdrantError::ConversionError(format!("unknown Distance {distance}")) + })?, + )?, + multivector: *multivector, + precision_tier: precision_tier .map(|t| { precision_from_grpc(GrpcPrecisionTier::try_from(t).map_err(|_| { QdrantError::ConversionError(format!("unknown PrecisionTier {t}")) @@ -108,20 +124,27 @@ pub(crate) fn dense_vector_from_grpc( }) } -pub(crate) fn sparse_vector_to_grpc(model: &SparseVectorConfig) -> GrpcSparseVectorConfig { +pub(crate) fn sparse_vector_to_grpc( + SparseVectorConfig { + use_idf, + precision_tier, + }: &SparseVectorConfig, +) -> GrpcSparseVectorConfig { GrpcSparseVectorConfig { - use_idf: model.use_idf, - precision_tier: model.precision_tier.map(|t| precision_to_grpc(t) as i32), + use_idf: *use_idf, + precision_tier: precision_tier.map(|t| precision_to_grpc(t) as i32), } } pub(crate) fn sparse_vector_from_grpc( - grpc_model: &GrpcSparseVectorConfig, + GrpcSparseVectorConfig { + use_idf, + precision_tier, + }: &GrpcSparseVectorConfig, ) -> Result { Ok(SparseVectorConfig { - use_idf: grpc_model.use_idf, - precision_tier: grpc_model - .precision_tier + use_idf: *use_idf, + precision_tier: precision_tier .map(|t| { precision_from_grpc(GrpcPrecisionTier::try_from(t).map_err(|_| { QdrantError::ConversionError(format!("unknown PrecisionTier {t}")) @@ -133,79 +156,107 @@ pub(crate) fn sparse_vector_from_grpc( pub(crate) fn payload_index_to_grpc(model: &PayloadIndex) -> PayloadIndexConfig { let index = match model { - PayloadIndex::Keyword(_) => payload_index_config::Index::Keyword(GrpcKeywordIndex {}), + PayloadIndex::Keyword(KeywordIndex) => { + payload_index_config::Index::Keyword(GrpcKeywordIndex {}) + } PayloadIndex::Integer(IntegerIndex { lookup, range }) => { payload_index_config::Index::Integer(GrpcIntegerIndex { lookup: *lookup, range: *range, }) } - PayloadIndex::Float(_) => payload_index_config::Index::Float(GrpcFloatIndex {}), - PayloadIndex::Uuid(_) => payload_index_config::Index::Uuid(GrpcUuidIndex {}), - PayloadIndex::Datetime(_) => payload_index_config::Index::Datetime(GrpcDatetimeIndex {}), - PayloadIndex::Text(text) => payload_index_config::Index::Text(GrpcTextIndex { - tokenizer: text.tokenizer.map(|t| tokenizer_to_grpc(t) as i32), - lowercase: text.lowercase, - phrase_matching: text.phrase_matching, - min_token_len: text.min_token_len, - max_token_len: text.max_token_len, + PayloadIndex::Float(FloatIndex) => payload_index_config::Index::Float(GrpcFloatIndex {}), + PayloadIndex::Uuid(UuidIndex) => payload_index_config::Index::Uuid(GrpcUuidIndex {}), + PayloadIndex::Datetime(DatetimeIndex) => { + payload_index_config::Index::Datetime(GrpcDatetimeIndex {}) + } + PayloadIndex::Text(TextIndex { + tokenizer, + lowercase, + phrase_matching, + min_token_len, + max_token_len, + }) => payload_index_config::Index::Text(GrpcTextIndex { + tokenizer: tokenizer.map(|t| tokenizer_to_grpc(t) as i32), + lowercase: *lowercase, + phrase_matching: *phrase_matching, + min_token_len: *min_token_len, + max_token_len: *max_token_len, }), - PayloadIndex::Geo(_) => payload_index_config::Index::Geo(GrpcGeoIndex {}), - PayloadIndex::Bool(_) => payload_index_config::Index::Bool(GrpcBoolIndex {}), + PayloadIndex::Geo(GeoIndex) => payload_index_config::Index::Geo(GrpcGeoIndex {}), + PayloadIndex::Bool(BoolIndex) => payload_index_config::Index::Bool(GrpcBoolIndex {}), }; PayloadIndexConfig { index: Some(index) } } pub(crate) fn payload_index_from_grpc( - grpc_model: &PayloadIndexConfig, + PayloadIndexConfig { index }: &PayloadIndexConfig, ) -> Result { - match grpc_model.index.as_ref() { - Some(payload_index_config::Index::Keyword(_)) => Ok(PayloadIndex::Keyword(KeywordIndex)), - Some(payload_index_config::Index::Integer(integer)) => { + match index.as_ref() { + Some(payload_index_config::Index::Keyword(GrpcKeywordIndex {})) => { + Ok(PayloadIndex::Keyword(KeywordIndex)) + } + Some(payload_index_config::Index::Integer(GrpcIntegerIndex { lookup, range })) => { Ok(PayloadIndex::Integer(IntegerIndex { - lookup: integer.lookup, - range: integer.range, + lookup: *lookup, + range: *range, })) } - Some(payload_index_config::Index::Float(_)) => Ok(PayloadIndex::Float(FloatIndex)), - Some(payload_index_config::Index::Uuid(_)) => Ok(PayloadIndex::Uuid(UuidIndex)), - Some(payload_index_config::Index::Datetime(_)) => Ok(PayloadIndex::Datetime(DatetimeIndex)), - Some(payload_index_config::Index::Text(text)) => Ok(PayloadIndex::Text(TextIndex { - tokenizer: text - .tokenizer + Some(payload_index_config::Index::Float(GrpcFloatIndex {})) => { + Ok(PayloadIndex::Float(FloatIndex)) + } + Some(payload_index_config::Index::Uuid(GrpcUuidIndex {})) => { + Ok(PayloadIndex::Uuid(UuidIndex)) + } + Some(payload_index_config::Index::Datetime(GrpcDatetimeIndex {})) => { + Ok(PayloadIndex::Datetime(DatetimeIndex)) + } + Some(payload_index_config::Index::Text(GrpcTextIndex { + tokenizer, + lowercase, + phrase_matching, + min_token_len, + max_token_len, + })) => Ok(PayloadIndex::Text(TextIndex { + tokenizer: tokenizer .map(|t| { tokenizer_from_grpc(GrpcTokenizer::try_from(t).map_err(|_| { QdrantError::ConversionError(format!("unknown Tokenizer {t}")) })?) }) .transpose()?, - lowercase: text.lowercase, - phrase_matching: text.phrase_matching, - min_token_len: text.min_token_len, - max_token_len: text.max_token_len, + lowercase: *lowercase, + phrase_matching: *phrase_matching, + min_token_len: *min_token_len, + max_token_len: *max_token_len, })), - Some(payload_index_config::Index::Geo(_)) => Ok(PayloadIndex::Geo(GeoIndex)), - Some(payload_index_config::Index::Bool(_)) => Ok(PayloadIndex::Bool(BoolIndex)), + Some(payload_index_config::Index::Geo(GrpcGeoIndex {})) => Ok(PayloadIndex::Geo(GeoIndex)), + Some(payload_index_config::Index::Bool(GrpcBoolIndex {})) => { + Ok(PayloadIndex::Bool(BoolIndex)) + } None => Err(QdrantError::ConversionError( "serverless PayloadIndexConfig has no index variant".into(), )), } } -pub(crate) fn collection_config_to_grpc(model: &CollectionConfig) -> grpc::CollectionConfig { +pub(crate) fn collection_config_to_grpc( + CollectionConfig { + dense_vectors, + sparse_vectors, + payload_indexes, + }: &CollectionConfig, +) -> grpc::CollectionConfig { grpc::CollectionConfig { - dense_vectors: model - .dense_vectors + dense_vectors: dense_vectors .iter() .map(|(name, dense)| (name.clone(), dense_vector_to_grpc(dense))) .collect(), - sparse_vectors: model - .sparse_vectors + sparse_vectors: sparse_vectors .iter() .map(|(name, sparse)| (name.clone(), sparse_vector_to_grpc(sparse))) .collect(), - payload_indexes: model - .payload_indexes + payload_indexes: payload_indexes .iter() .map(|(field, index)| (field.clone(), payload_index_to_grpc(index))) .collect(), @@ -213,21 +264,22 @@ pub(crate) fn collection_config_to_grpc(model: &CollectionConfig) -> grpc::Colle } pub(crate) fn collection_config_from_grpc( - grpc_model: &grpc::CollectionConfig, + grpc::CollectionConfig { + dense_vectors, + sparse_vectors, + payload_indexes, + }: &grpc::CollectionConfig, ) -> Result { Ok(CollectionConfig { - dense_vectors: grpc_model - .dense_vectors + dense_vectors: dense_vectors .iter() .map(|(name, dense)| Ok((name.clone(), dense_vector_from_grpc(dense)?))) .collect::>()?, - sparse_vectors: grpc_model - .sparse_vectors + sparse_vectors: sparse_vectors .iter() .map(|(name, sparse)| Ok((name.clone(), sparse_vector_from_grpc(sparse)?))) .collect::>()?, - payload_indexes: grpc_model - .payload_indexes + payload_indexes: payload_indexes .iter() .map(|(field, index)| Ok((field.clone(), payload_index_from_grpc(index)?))) .collect::>()?, @@ -273,16 +325,26 @@ mod tests { assert!(dense.precision_tier.is_none()); let age = grpc_config.payload_indexes.get("age").unwrap(); match age.index.as_ref().unwrap() { - payload_index_config::Index::Integer(integer) => { - assert!(integer.lookup.is_none()); - assert!(integer.range.is_none()); + payload_index_config::Index::Integer(GrpcIntegerIndex { lookup, range }) => { + assert!(lookup.is_none()); + assert!(range.is_none()); } other => panic!("expected integer index, got {other:?}"), } let text = grpc_config.payload_indexes.get("text").unwrap(); match text.index.as_ref().unwrap() { - payload_index_config::Index::Text(text) => { - assert!(text.tokenizer.is_none()); + payload_index_config::Index::Text(GrpcTextIndex { + tokenizer, + lowercase, + phrase_matching, + min_token_len, + max_token_len, + }) => { + assert!(tokenizer.is_none()); + assert!(lowercase.is_none()); + assert!(phrase_matching.is_none()); + assert!(min_token_len.is_none()); + assert!(max_token_len.is_none()); } other => panic!("expected text index, got {other:?}"), }