diff --git a/examples/serverless.rs b/examples/serverless.rs index 5797ccc..6621317 100644 --- a/examples/serverless.rs +++ b/examples/serverless.rs @@ -13,7 +13,8 @@ use qdrant_client::qdrant::{PointStruct, QueryPointsBuilder, UpsertPointsBuilder}; use qdrant_client::serverless::{ - CollectionConfig, DenseVectorConfig, Distance, KeywordIndex, QdrantServerless, + CollectionConfig, DenseVectorConfig, Distance, KeywordIndex, ListCollectionsBuilder, + QdrantServerless, }; use qdrant_client::{Payload, QdrantError}; @@ -38,12 +39,17 @@ async fn main() -> Result<(), QdrantError> { collection_name, CollectionConfig::new() .dense_vector(DenseVectorConfig::new(4, Distance::Cosine)) - .payload_index("color", KeywordIndex), + .payload_index("color", KeywordIndex::new()), ) .await?; println!("create_collection: {result}"); - println!("collections: {:?}", client.list_collections().await?); + println!( + "collections: {:?}", + client + .list_collections(ListCollectionsBuilder::new()) + .await? + ); println!("info: {:?}", client.get_collection(collection_name).await?); let points = vec![ diff --git a/proto/serverless_collections.proto b/proto/serverless_collections.proto index 680e8cf..a864b8d 100644 --- a/proto/serverless_collections.proto +++ b/proto/serverless_collections.proto @@ -1,10 +1,13 @@ // 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 +// Client copy: buf.validate options are stripped (server-side only; wire format unchanged). +// Regenerate with: cargo test --test serverless_protos -- --ignored --nocapture + 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 @@ -85,7 +88,15 @@ enum Tokenizer { } // Exact match on string values, e.g. `color: "red"`. -message KeywordIndex {} +message KeywordIndex { + // If set, enable prefix matching (`match: { "prefix": ... }`) on this field. + // Presence of this message enables prefix matching; it has no options yet. + optional KeywordPrefixParams prefix = 1; +} + +// Prefix matching options for the keyword index. Has no options yet: +// presence of this message enables prefix matching. +message KeywordPrefixParams {} // Exact match and/or range filters on integers, e.g. `age: 25`. Both are on // by default; turning one off shrinks the index. @@ -105,6 +116,35 @@ message UuidIndex {} // Range filters on RFC 3339 datetimes, e.g. `created_at: "2023-02-08T10:49:00Z"`. message DatetimeIndex {} +// Tokens ignored by a full-text index. Language names match qdrant (e.g. +// "english"); predefined lists and custom tokens are merged. +message StopwordsSet { + // Languages whose predefined stopword lists to apply. + repeated string languages = 1; + // Extra stopwords to ignore, merged with the language lists. + repeated string custom = 2; +} + +// Snowball stemming for a full-text index. +message SnowballParams { + // Language for the snowball algorithm, e.g. "english". + string language = 1; +} + +// Explicitly disable stemming (overrides any language default). +message DisabledStemmer {} + +// Stemming algorithm for a full-text index. Unset: no stemming. +message StemmingAlgorithm { + // Which stemming algorithm to use. + oneof stemming_params { + // Snowball stemmer for the given language. + SnowballParams snowball = 1; + // Explicitly disable stemming. + DisabledStemmer disabled = 2; + } +} + // Full-text filtering on string values. message TextIndex { // Tokenizer to split text with. Unset: WHITESPACE. @@ -117,6 +157,12 @@ message TextIndex { optional uint64 min_token_len = 4; // Maximum token length to index. optional uint64 max_token_len = 5; + // Fold accented characters to ASCII. Default false. + optional bool ascii_folding = 6; + // Tokens to ignore at index and query time. + optional StopwordsSet stopwords = 7; + // Stemming algorithm. Unset: engine default (no stemming). + optional StemmingAlgorithm stemmer = 8; } // Geo radius / bounding box / polygon filters on `{lon, lat}` values. @@ -211,9 +257,15 @@ message GetCollectionResponse { optional uint64 point_count = 3; } -// Lists the caller's collections. The tenant travels in metadata, so there is -// nothing to name here. -message ListCollectionsRequest {} +// Lists the caller's collections. The tenant travels in metadata. +message ListCollectionsRequest { + // Maximum number of collections to return. Defaults to 20 and must not + // exceed 100. + optional uint32 limit = 1; + // Opaque token returned as `next_offset_token` by the previous page. Clients + // must not interpret this value. + optional string offset_token = 2; +} // One collection in a listing. message CollectionSummary { @@ -226,7 +278,9 @@ message CollectionSummary { // The caller's collections. message ListCollectionsResponse { - // Ordered by name. A collection whose creation never published a manifest is - // not listed: it is not servable. + // Collections in this page. repeated CollectionSummary collections = 1; + // Opaque token to pass as `offset_token` to retrieve the next page. Absent + // when there are no more results. + optional string next_offset_token = 2; } diff --git a/src/serverless/client.rs b/src/serverless/client.rs index de0fb67..ca5bcf2 100644 --- a/src/serverless/client.rs +++ b/src/serverless/client.rs @@ -30,7 +30,9 @@ use crate::serverless::grpc::collections_service_client::CollectionsServiceClien use crate::serverless::grpc::{ CreateCollectionRequest, DeleteCollectionRequest, GetCollectionRequest, ListCollectionsRequest, }; -use crate::serverless::models::{CollectionConfig, CollectionInfo, CollectionSummary}; +use crate::serverless::models::{ + CollectionConfig, CollectionInfo, CollectionSummary, CollectionsList, ListCollections, +}; use crate::Qdrant; /// Default gRPC port for serverless when the URL omits an explicit port. @@ -70,7 +72,7 @@ pub const DEFAULT_SERVERLESS_GRPC_PORT: u16 = 443; /// "my-collection", /// CollectionConfig::new() /// .dense_vector(DenseVectorConfig::new(4, Distance::Cosine)) -/// .payload_index("color", KeywordIndex), +/// .payload_index("color", KeywordIndex::new()), /// ) /// .await?; /// @@ -288,23 +290,51 @@ impl QdrantServerless { Ok(self.get_collection(collection_name).await?.exists) } - /// Lists the collections of the space. + /// Lists a page of collections in the space. + /// + /// Defaults to the server page size (20, max 100). Pass `offset_token` from a + /// previous response's `next_offset_token` to fetch the next page. /// - /// Returns summaries (name and eventually consistent point count), ordered by name. - pub async fn list_collections(&self) -> QdrantResult> { + /// ```no_run + /// # use qdrant_client::serverless::{ListCollectionsBuilder, QdrantServerless}; + /// # async fn run(client: QdrantServerless) -> Result<(), qdrant_client::QdrantError> { + /// let page = client + /// .list_collections(ListCollectionsBuilder::new().limit(50)) + /// .await?; + /// if let Some(token) = page.next_offset_token { + /// let next = client + /// .list_collections(ListCollectionsBuilder::new().offset_token(token)) + /// .await?; + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn list_collections( + &self, + request: impl Into, + ) -> QdrantResult { + let ListCollections { + limit, + offset_token, + } = request.into(); + let request = ListCollectionsRequest { + limit, + offset_token, + }; + let request = &request; 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()) + let response = api.list_collections(request.clone()).await?.into_inner(); + Ok(CollectionsList { + collections: response + .collections + .into_iter() + .map(|c| CollectionSummary { + collection_name: c.collection_name, + point_count: c.point_count, + }) + .collect(), + next_offset_token: response.next_offset_token, + }) }) .await } diff --git a/src/serverless/conversions.rs b/src/serverless/conversions.rs index 935e050..5b2f99c 100644 --- a/src/serverless/conversions.rs +++ b/src/serverless/conversions.rs @@ -8,17 +8,20 @@ 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, + self, payload_index_config, stemming_algorithm, BoolIndex as GrpcBoolIndex, + DatetimeIndex as GrpcDatetimeIndex, DenseVectorConfig as GrpcDenseVectorConfig, + DisabledStemmer as GrpcDisabledStemmer, Distance as GrpcDistance, FloatIndex as GrpcFloatIndex, + GeoIndex as GrpcGeoIndex, IntegerIndex as GrpcIntegerIndex, KeywordIndex as GrpcKeywordIndex, + KeywordPrefixParams as GrpcKeywordPrefixParams, PayloadIndexConfig, + PrecisionTier as GrpcPrecisionTier, SnowballParams as GrpcSnowballParams, + SparseVectorConfig as GrpcSparseVectorConfig, StemmingAlgorithm as GrpcStemmingAlgorithm, + StopwordsSet as GrpcStopwordsSet, 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, + IntegerIndex, KeywordIndex, KeywordPrefixParams, PayloadIndex, PrecisionTier, SnowballParams, + SparseVectorConfig, StemmingAlgorithm, StopwordsSet, TextIndex, Tokenizer, UuidIndex, }; fn distance_to_grpc(distance: Distance) -> GrpcDistance { @@ -82,6 +85,55 @@ fn tokenizer_from_grpc(tokenizer: GrpcTokenizer) -> Result GrpcStopwordsSet { + GrpcStopwordsSet { + languages: languages.clone(), + custom: custom.clone(), + } +} + +fn stopwords_from_grpc(GrpcStopwordsSet { languages, custom }: &GrpcStopwordsSet) -> StopwordsSet { + StopwordsSet { + languages: languages.clone(), + custom: custom.clone(), + } +} + +fn stemmer_to_grpc(stemmer: &StemmingAlgorithm) -> GrpcStemmingAlgorithm { + match stemmer { + StemmingAlgorithm::Snowball(SnowballParams { language }) => GrpcStemmingAlgorithm { + stemming_params: Some(stemming_algorithm::StemmingParams::Snowball( + GrpcSnowballParams { + language: language.clone(), + }, + )), + }, + StemmingAlgorithm::Disabled => GrpcStemmingAlgorithm { + stemming_params: Some(stemming_algorithm::StemmingParams::Disabled( + GrpcDisabledStemmer {}, + )), + }, + } +} + +fn stemmer_from_grpc( + GrpcStemmingAlgorithm { stemming_params }: &GrpcStemmingAlgorithm, +) -> Result { + match stemming_params { + Some(stemming_algorithm::StemmingParams::Snowball(GrpcSnowballParams { language })) => { + Ok(StemmingAlgorithm::Snowball(SnowballParams { + language: language.clone(), + })) + } + Some(stemming_algorithm::StemmingParams::Disabled(GrpcDisabledStemmer {})) => { + Ok(StemmingAlgorithm::Disabled) + } + None => Err(QdrantError::ConversionError( + "serverless StemmingAlgorithm has no stemming_params variant".into(), + )), + } +} + pub(crate) fn dense_vector_to_grpc( DenseVectorConfig { size, @@ -156,8 +208,12 @@ pub(crate) fn sparse_vector_from_grpc( pub(crate) fn payload_index_to_grpc(model: &PayloadIndex) -> PayloadIndexConfig { let index = match model { - PayloadIndex::Keyword(KeywordIndex) => { - payload_index_config::Index::Keyword(GrpcKeywordIndex {}) + PayloadIndex::Keyword(KeywordIndex { prefix }) => { + payload_index_config::Index::Keyword(GrpcKeywordIndex { + prefix: prefix + .as_ref() + .map(|KeywordPrefixParams| GrpcKeywordPrefixParams {}), + }) } PayloadIndex::Integer(IntegerIndex { lookup, range }) => { payload_index_config::Index::Integer(GrpcIntegerIndex { @@ -176,12 +232,18 @@ pub(crate) fn payload_index_to_grpc(model: &PayloadIndex) -> PayloadIndexConfig phrase_matching, min_token_len, max_token_len, + ascii_folding, + stopwords, + stemmer, }) => 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, + ascii_folding: *ascii_folding, + stopwords: stopwords.as_ref().map(stopwords_to_grpc), + stemmer: stemmer.as_ref().map(stemmer_to_grpc), }), PayloadIndex::Geo(GeoIndex) => payload_index_config::Index::Geo(GrpcGeoIndex {}), PayloadIndex::Bool(BoolIndex) => payload_index_config::Index::Bool(GrpcBoolIndex {}), @@ -193,8 +255,12 @@ pub(crate) fn payload_index_from_grpc( PayloadIndexConfig { index }: &PayloadIndexConfig, ) -> Result { match index.as_ref() { - Some(payload_index_config::Index::Keyword(GrpcKeywordIndex {})) => { - Ok(PayloadIndex::Keyword(KeywordIndex)) + Some(payload_index_config::Index::Keyword(GrpcKeywordIndex { prefix })) => { + Ok(PayloadIndex::Keyword(KeywordIndex { + prefix: prefix + .as_ref() + .map(|GrpcKeywordPrefixParams {}| KeywordPrefixParams), + })) } Some(payload_index_config::Index::Integer(GrpcIntegerIndex { lookup, range })) => { Ok(PayloadIndex::Integer(IntegerIndex { @@ -217,6 +283,9 @@ pub(crate) fn payload_index_from_grpc( phrase_matching, min_token_len, max_token_len, + ascii_folding, + stopwords, + stemmer, })) => Ok(PayloadIndex::Text(TextIndex { tokenizer: tokenizer .map(|t| { @@ -229,6 +298,9 @@ pub(crate) fn payload_index_from_grpc( phrase_matching: *phrase_matching, min_token_len: *min_token_len, max_token_len: *max_token_len, + ascii_folding: *ascii_folding, + stopwords: stopwords.as_ref().map(stopwords_from_grpc), + stemmer: stemmer.as_ref().map(stemmer_from_grpc).transpose()?, })), Some(payload_index_config::Index::Geo(GrpcGeoIndex {})) => Ok(PayloadIndex::Geo(GeoIndex)), Some(payload_index_config::Index::Bool(GrpcBoolIndex {})) => { @@ -302,11 +374,16 @@ mod tests { .precision_tier(PrecisionTier::Low), ) .named_sparse_vector("bm25", SparseVectorConfig::new().use_idf(true)) - .payload_index("user_id", KeywordIndex) + .payload_index("user_id", KeywordIndex::new().with_prefix()) .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) + .ascii_folding(true) + .stopwords(StopwordsSet::new().languages(["english"])) + .stemmer(StemmingAlgorithm::Snowball(SnowballParams::new("english"))), ); let roundtrip = collection_config_from_grpc(&collection_config_to_grpc(&config)).unwrap(); @@ -318,6 +395,7 @@ mod tests { let config = CollectionConfig::new() .dense_vector(DenseVectorConfig::new(4, Distance::Euclid)) .payload_index("age", IntegerIndex::new()) + .payload_index("user_id", KeywordIndex::new()) .payload_index("text", TextIndex::new()); let grpc_config = collection_config_to_grpc(&config); @@ -331,6 +409,13 @@ mod tests { } other => panic!("expected integer index, got {other:?}"), } + let keyword = grpc_config.payload_indexes.get("user_id").unwrap(); + match keyword.index.as_ref().unwrap() { + payload_index_config::Index::Keyword(GrpcKeywordIndex { prefix }) => { + assert!(prefix.is_none()); + } + other => panic!("expected keyword index, got {other:?}"), + } let text = grpc_config.payload_indexes.get("text").unwrap(); match text.index.as_ref().unwrap() { payload_index_config::Index::Text(GrpcTextIndex { @@ -339,12 +424,18 @@ mod tests { phrase_matching, min_token_len, max_token_len, + ascii_folding, + stopwords, + stemmer, }) => { 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()); + assert!(ascii_folding.is_none()); + assert!(stopwords.is_none()); + assert!(stemmer.is_none()); } other => panic!("expected text index, got {other:?}"), } diff --git a/src/serverless/grpc.rs b/src/serverless/grpc.rs index da958ef..ef58d04 100644 --- a/src/serverless/grpc.rs +++ b/src/serverless/grpc.rs @@ -31,7 +31,16 @@ pub struct SparseVectorConfig { } /// Exact match on string values, e.g. `color: "red"`. #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] -pub struct KeywordIndex {} +pub struct KeywordIndex { + /// If set, enable prefix matching (`match: { "prefix": ... }`) on this field. + /// Presence of this message enables prefix matching; it has no options yet. + #[prost(message, optional, tag = "1")] + pub prefix: ::core::option::Option, +} +/// Prefix matching options for the keyword index. Has no options yet: +/// presence of this message enables prefix matching. +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct KeywordPrefixParams {} /// 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)] @@ -52,8 +61,49 @@ 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. +/// Tokens ignored by a full-text index. Language names match qdrant (e.g. +/// "english"); predefined lists and custom tokens are merged. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct StopwordsSet { + /// Languages whose predefined stopword lists to apply. + #[prost(string, repeated, tag = "1")] + pub languages: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// Extra stopwords to ignore, merged with the language lists. + #[prost(string, repeated, tag = "2")] + pub custom: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +/// Snowball stemming for a full-text index. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SnowballParams { + /// Language for the snowball algorithm, e.g. "english". + #[prost(string, tag = "1")] + pub language: ::prost::alloc::string::String, +} +/// Explicitly disable stemming (overrides any language default). #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DisabledStemmer {} +/// Stemming algorithm for a full-text index. Unset: no stemming. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct StemmingAlgorithm { + /// Which stemming algorithm to use. + #[prost(oneof = "stemming_algorithm::StemmingParams", tags = "1, 2")] + pub stemming_params: ::core::option::Option, +} +/// Nested message and enum types in `StemmingAlgorithm`. +pub mod stemming_algorithm { + /// Which stemming algorithm to use. + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum StemmingParams { + /// Snowball stemmer for the given language. + #[prost(message, tag = "1")] + Snowball(super::SnowballParams), + /// Explicitly disable stemming. + #[prost(message, tag = "2")] + Disabled(super::DisabledStemmer), + } +} +/// Full-text filtering on string values. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct TextIndex { /// Tokenizer to split text with. Unset: WHITESPACE. #[prost(enumeration = "Tokenizer", optional, tag = "1")] @@ -70,6 +120,15 @@ pub struct TextIndex { /// Maximum token length to index. #[prost(uint64, optional, tag = "5")] pub max_token_len: ::core::option::Option, + /// Fold accented characters to ASCII. Default false. + #[prost(bool, optional, tag = "6")] + pub ascii_folding: ::core::option::Option, + /// Tokens to ignore at index and query time. + #[prost(message, optional, tag = "7")] + pub stopwords: ::core::option::Option, + /// Stemming algorithm. Unset: engine default (no stemming). + #[prost(message, optional, tag = "8")] + pub stemmer: ::core::option::Option, } /// Geo radius / bounding box / polygon filters on `{lon, lat}` values. #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] @@ -79,7 +138,7 @@ pub struct GeoIndex {} 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)] +#[derive(Clone, 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")] @@ -88,7 +147,7 @@ pub struct PayloadIndexConfig { /// 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)] + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] pub enum Index { /// Exact match on strings. #[prost(message, tag = "1")] @@ -201,10 +260,18 @@ pub struct GetCollectionResponse { #[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 {} +/// Lists the caller's collections. The tenant travels in metadata. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ListCollectionsRequest { + /// Maximum number of collections to return. Defaults to 20 and must not + /// exceed 100. + #[prost(uint32, optional, tag = "1")] + pub limit: ::core::option::Option, + /// Opaque token returned as `next_offset_token` by the previous page. Clients + /// must not interpret this value. + #[prost(string, optional, tag = "2")] + pub offset_token: ::core::option::Option<::prost::alloc::string::String>, +} /// One collection in a listing. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct CollectionSummary { @@ -219,10 +286,13 @@ pub struct CollectionSummary { /// 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. + /// Collections in this page. #[prost(message, repeated, tag = "1")] pub collections: ::prost::alloc::vec::Vec, + /// Opaque token to pass as `offset_token` to retrieve the next page. Absent + /// when there are no more results. + #[prost(string, optional, tag = "2")] + pub next_offset_token: ::core::option::Option<::prost::alloc::string::String>, } /// Distance metric used to compare dense vectors. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] diff --git a/src/serverless/mod.rs b/src/serverless/mod.rs index d2ac4d9..4814d9d 100644 --- a/src/serverless/mod.rs +++ b/src/serverless/mod.rs @@ -38,7 +38,9 @@ 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, + BoolIndex, CollectionConfig, CollectionInfo, CollectionSummary, CollectionsList, DatetimeIndex, + DenseVectorConfig, Distance, FloatIndex, GeoIndex, IntegerIndex, KeywordIndex, + KeywordPrefixParams, ListCollections, ListCollectionsBuilder, PayloadIndex, PrecisionTier, + SnowballParams, SparseVectorConfig, StemmingAlgorithm, StopwordsSet, TextIndex, Tokenizer, + UuidIndex, }; diff --git a/src/serverless/models.rs b/src/serverless/models.rs index 9501ed6..c4844d6 100644 --- a/src/serverless/models.rs +++ b/src/serverless/models.rs @@ -110,10 +110,30 @@ impl SparseVectorConfig { } } +/// Prefix matching options for a keyword index. Presence enables prefix matching. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct KeywordPrefixParams; + /// 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; +pub struct KeywordIndex { + /// If set, enable prefix matching (`match: { "prefix": ... }`) on this field. + pub prefix: Option, +} + +impl KeywordIndex { + pub fn new() -> Self { + Self::default() + } + + /// Enable prefix matching on this keyword field. + pub fn with_prefix(mut self) -> Self { + self.prefix = Some(KeywordPrefixParams); + self + } +} /// Exact match and/or range filters on integers. Both default to enabled. #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -154,6 +174,59 @@ pub struct UuidIndex; #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct DatetimeIndex; +/// Tokens ignored by a full-text index. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct StopwordsSet { + /// Languages whose predefined stopword lists to apply (e.g. `"english"`). + pub languages: Vec, + /// Extra stopwords to ignore, merged with the language lists. + pub custom: Vec, +} + +impl StopwordsSet { + pub fn new() -> Self { + Self::default() + } + + pub fn languages(mut self, languages: impl IntoIterator>) -> Self { + self.languages = languages.into_iter().map(Into::into).collect(); + self + } + + pub fn custom(mut self, custom: impl IntoIterator>) -> Self { + self.custom = custom.into_iter().map(Into::into).collect(); + self + } +} + +/// Snowball stemming for a full-text index. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct SnowballParams { + /// Language for the snowball algorithm, e.g. `"english"`. + pub language: String, +} + +impl SnowballParams { + pub fn new(language: impl Into) -> Self { + Self { + language: language.into(), + } + } +} + +/// Stemming algorithm for a full-text index. Unset: no stemming. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum StemmingAlgorithm { + /// Snowball stemmer for the given language. + Snowball(SnowballParams), + /// Explicitly disable stemming. + Disabled, +} + /// Full-text filtering on string values. #[derive(Debug, Clone, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -163,6 +236,9 @@ pub struct TextIndex { pub phrase_matching: Option, pub min_token_len: Option, pub max_token_len: Option, + pub ascii_folding: Option, + pub stopwords: Option, + pub stemmer: Option, } impl TextIndex { @@ -194,6 +270,21 @@ impl TextIndex { self.max_token_len = Some(max_token_len); self } + + pub fn ascii_folding(mut self, ascii_folding: bool) -> Self { + self.ascii_folding = Some(ascii_folding); + self + } + + pub fn stopwords(mut self, stopwords: StopwordsSet) -> Self { + self.stopwords = Some(stopwords); + self + } + + pub fn stemmer(mut self, stemmer: StemmingAlgorithm) -> Self { + self.stemmer = Some(stemmer); + self + } } /// Geo radius / bounding box / polygon filters on `{lon, lat}` values. @@ -343,6 +434,67 @@ pub struct CollectionInfo { pub point_count: Option, } +/// Request for [`super::QdrantServerless::list_collections`]. +/// +/// Defaults to the server page size (20, max 100). Pass `offset_token` from a +/// previous response's `next_offset_token` to fetch the next page. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ListCollections { + /// Maximum number of collections to return. Unset: server default (20). + pub limit: Option, + /// Opaque token from a previous page's `next_offset_token`. + pub offset_token: Option, +} + +impl ListCollections { + /// Create an empty request (server defaults). + pub fn new() -> Self { + Self::default() + } +} + +/// Builder for [`ListCollections`]. +#[must_use] +#[derive(Clone, Default)] +pub struct ListCollectionsBuilder { + limit: Option, + offset_token: Option, +} + +impl ListCollectionsBuilder { + /// Create an empty builder (server defaults). + pub fn new() -> Self { + Self::default() + } + + /// Maximum number of collections to return (must be 1..=100). + pub fn limit(mut self, limit: u32) -> Self { + self.limit = Some(limit); + self + } + + /// Opaque token from a previous page's `next_offset_token`. + pub fn offset_token(mut self, offset_token: impl Into) -> Self { + self.offset_token = Some(offset_token.into()); + self + } + + /// Build the [`ListCollections`] request. + pub fn build(self) -> ListCollections { + self.into() + } +} + +impl From for ListCollections { + fn from(builder: ListCollectionsBuilder) -> Self { + ListCollections { + limit: builder.limit, + offset_token: builder.offset_token, + } + } +} + /// One collection in a [`super::QdrantServerless::list_collections`] listing. #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -350,3 +502,12 @@ pub struct CollectionSummary { pub collection_name: String, pub point_count: Option, } + +/// A page of collections returned by [`super::QdrantServerless::list_collections`]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CollectionsList { + pub collections: Vec, + /// Opaque token to pass as `offset_token` for the next page. Absent when done. + pub next_offset_token: Option, +} diff --git a/tools/sync_serverless_proto.sh b/tools/sync_serverless_proto.sh index d2c69a2..c23129a 100755 --- a/tools/sync_serverless_proto.sh +++ b/tools/sync_serverless_proto.sh @@ -11,14 +11,32 @@ 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. +// Client copy: buf.validate options are stripped (server-side only; wire format unchanged). // 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" +TMP_PROTO="$(mktemp)" +curl -fsSL https://raw.githubusercontent.com/qdrant/qdrant-cloud-public-api/main/proto/qdrant/serverless/collections.proto \ + > "$TMP_PROTO" + +# Drop server-side protovalidate import/options; clients do not need them and +# vendoring buf/validate would pull an extra dependency into the sync path. +python3 - "$TMP_PROTO" "$PROTO_PATH" "$HEADER" <<'PY' +import re, sys +src_path, out_path, header = sys.argv[1], sys.argv[2], sys.argv[3] +src = open(src_path).read() +src = re.sub(r'\nimport "buf/validate/validate\.proto";\n', '\n', src) +src = re.sub( + r' \[\(buf\.validate\.field\)\.uint32 = \{\s*gt: 0\s*lte: 100\s*\}\]', + '', + src, +) +if 'buf.validate' in src: + raise SystemExit('failed to strip buf.validate annotations from collections.proto') +open(out_path, 'w').write(header + '\n' + src) +PY +rm -f "$TMP_PROTO" cargo test --test serverless_protos regenerate_serverless_protos -- --ignored --nocapture