///|
/// QdrantClient: a minimal, dependency-light MoonBit client for the
/// Qdrant vector database REST API.
///
/// Transport is provided by `gaato/mttp_async` (HTTP/1.1 with retries,
/// deadlines and decompression over the `moonbitlang/core/async` runtime);
/// JSON is handled by `@json` from core. This file intentionally keeps the
/// API surface small and explicit so each method maps 1:1 to a documented
/// Qdrant REST endpoint.
///|
/// HTTP verbs used by the Qdrant REST API.
priv enum Method {
Get
Post
Put
Delete
Patch
}
///|
fn Method::to_string(self : Method) -> String {
match self {
Get => "GET"
Post => "POST"
Put => "PUT"
Delete => "DELETE"
Patch => "PATCH"
}
}
///|
/// Configuration for a QdrantClient.
pub struct QdrantClient {
base_url : String
api_key : String?
http : @mttp.Client
}
///|
/// Create a client for the given base URL.
///
/// Example: `QdrantClient::new("http://localhost:6333")`
///
/// `api_key` is optional and, when set, is sent as the `api-key` header
/// expected by Qdrant's API-key authentication.
///
/// `timeout_ms` is the per-attempt deadline; the whole call (retries
/// included) is bounded by 12x that, with a floor of mttp_async's 120 s
/// default when the per-attempt timeout is large.
pub fn QdrantClient::new(
base_url : String,
api_key~ : String? = None,
timeout_ms~ : Int32 = 10_000,
) -> QdrantClient {
let base = if base_url.has_suffix("/") {
base_url[:base_url.length() - 1].to_owned()
} else {
base_url
}
let total = timeout_ms * 12
let http = @mttp.client(
base_url=base,
timeout_ms~,
total_ms=if total > 120_000 { total } else { 120_000 },
)
{ base_url: base, api_key, http, }
}
///|
/// Release the client's connection pool. The client is unusable afterwards.
pub fn QdrantClient::close(self : QdrantClient) -> Unit {
self.http.close()
}
///|
/// Join a path fragment onto the base URL, keeping a single slash between them.
pub fn join_url(base : String, path : String) -> String {
if path.has_prefix("/") {
base + path
} else {
base + "/" + path
}
}
///|
/// Percent-encode a path segment for use in a Qdrant URL. Qdrant accepts a
/// broad character set in collection, field and snapshot names (not just
/// ASCII alphanumerics), so unreserved characters pass through and everything
/// else is percent-encoded. Supplementary-plane characters (emoji and friends)
/// arrive as UTF-16 surrogate pairs and are combined into their full code
/// point, then encoded as 4-byte UTF-8.
pub fn url_encode(input : String) -> String {
let mut out = ""
let n = input.length()
let mut i = 0
while i < n {
let unit = input[i]
if is_unreserved(unit) {
out = out + Int32::unsafe_to_char(unit.to_int()).to_string()
i = i + 1
} else if unit >= 0xD800 && unit <= 0xDBFF {
// High surrogate: combine with a following low surrogate into the
// full supplementary-plane code point.
let cp = if i + 1 < n {
let lo = input[i + 1]
if lo >= 0xDC00 && lo <= 0xDFFF {
0x10000 + ((unit.to_int() - 0xD800) << 10) + (lo.to_int() - 0xDC00)
} else {
unit.to_int()
}
} else {
unit.to_int()
}
out = out + encode_codepoint(cp)
if cp >= 0x10000 {
i = i + 2
} else {
i = i + 1
}
} else {
// Lone low surrogate or a regular BMP character.
out = out + encode_codepoint(unit.to_int())
i = i + 1
}
}
out
}
///|
/// RFC 3986 unreserved characters pass through a URL path unencoded.
/// `s[i]` yields a UTF-16 code unit (UInt16), which for ASCII and BMP
/// characters equals the code point.
fn is_unreserved(c : UInt16) -> Bool {
let n = c.to_int()
(n >= 48 && n <= 57) ||
(n >= 65 && n <= 90) ||
(n >= 97 && n <= 122) ||
c == '-' ||
c == '_' ||
c == '.' ||
c == '~'
}
///|
/// Percent-encode one Unicode code point as `%XX` per UTF-8 byte:
/// 1 byte for ASCII, 2 bytes up to U+07FF, 3 bytes up to U+FFFF and 4 bytes
/// for supplementary-plane code points. (A lone surrogate is encoded as if
/// it were a code point — best effort for malformed input.)
fn encode_codepoint(cp : Int32) -> String {
if cp < 0x80 {
"%" + hex_digit(cp / 16) + hex_digit(cp % 16)
} else if cp < 0x800 {
let b1 = 0xC0 | (cp >> 6)
let b2 = 0x80 | (cp & 0x3F)
"%" +
hex_digit(b1 / 16) +
hex_digit(b1 % 16) +
"%" +
hex_digit(b2 / 16) +
hex_digit(b2 % 16)
} else if cp < 0x10000 {
let b1 = 0xE0 | (cp >> 12)
let b2 = 0x80 | ((cp >> 6) & 0x3F)
let b3 = 0x80 | (cp & 0x3F)
"%" +
hex_digit(b1 / 16) +
hex_digit(b1 % 16) +
"%" +
hex_digit(b2 / 16) +
hex_digit(b2 % 16) +
"%" +
hex_digit(b3 / 16) +
hex_digit(b3 % 16)
} else {
let b1 = 0xF0 | (cp >> 18)
let b2 = 0x80 | ((cp >> 12) & 0x3F)
let b3 = 0x80 | ((cp >> 6) & 0x3F)
let b4 = 0x80 | (cp & 0x3F)
"%" +
hex_digit(b1 / 16) +
hex_digit(b1 % 16) +
"%" +
hex_digit(b2 / 16) +
hex_digit(b2 % 16) +
"%" +
hex_digit(b3 / 16) +
hex_digit(b3 % 16) +
"%" +
hex_digit(b4 / 16) +
hex_digit(b4 % 16)
}
}
///|
/// One uppercase hex digit for a nibble value in 0..=15.
fn hex_digit(v : Int32) -> String {
let hex = "0123456789ABCDEF"
Int32::unsafe_to_char(hex[v].to_int()).to_string()
}
///|
/// Send a request and return the HTTP status code together with the raw
/// text body (before JSON parsing). The path is resolved against the
/// client's base URL. Network failures raise; non-2xx responses are
/// returned like any other status so callers can inspect the error body.
async fn QdrantClient::send_raw(
self : QdrantClient,
verb : Method,
path : String,
body : Json?,
) -> (Int32, String) {
let request = match verb {
Get => @mttp.Request::get(path)
Post => @mttp.Request::post(path)
Put => @mttp.Request::put(path)
Delete => @mttp.Request::delete(path)
Patch => @mttp.Request::patch(path)
}
let request = match self.api_key {
Some(key) => request.header("api-key", key)
None => request
}
// mttp_async decompresses gzip/deflate/br/zstd transparently, so no
// `Accept-Encoding: identity` workaround is needed (unlike the old mio
// transport, whose deflate path failed on Qdrant payloads).
let request = match body {
Some(json) => request.body_json(json)
None => request
}
// `any_status` keeps non-2xx responses as values: Qdrant reports errors
// (404, 400, ...) with a JSON body that callers parse, not as failures.
let reply = @mttp.request(request)
.response(@mttp.as_string().any_status())
.send(self.http) catch {
error =>
raise Failure(
"transport error on \{verb.to_string()} \{path}: \{error}",
)
}
match reply.body {
Ok(text) => (reply.status, text)
Err(failure) => (failure.head().status, "\{failure}")
}
}
///|
/// Truncate a raw response body for inclusion in an error message.
fn json_preview(text : String) -> String {
if text.length() > 200 {
text[:200].to_owned()
} else {
text
}
}
///|
/// Send a request and return the HTTP status code together with the parsed
/// JSON body.
async fn QdrantClient::send(
self : QdrantClient,
verb : Method,
path : String,
body : Json?,
) -> (Int32, Json) {
let (code, text) = self.send_raw(verb, path, body)
let json = @json.parse(text) catch {
err =>
raise Failure(
"invalid JSON in Qdrant response (HTTP " +
code.to_string() +
" on " +
path +
"): " +
err.to_string() +
" (raw: " +
json_preview(text) +
")",
)
}
(code, json)
}
///|
/// Send a request and return only the HTTP status code, without touching
/// the body. Used by endpoints that answer with plain text (e.g. /healthz).
async fn QdrantClient::send_status(
self : QdrantClient,
verb : Method,
path : String,
body : Json?,
) -> Int32 {
let (code, _) = self.send_raw(verb, path, body)
code
}
///|
/// Send a request and return the parsed JSON body.
/// Raises on transport errors or when the server answers with a non-2xx code.
async fn QdrantClient::request_json(
self : QdrantClient,
verb : Method,
path : String,
body : Json?,
) -> Json {
let (code, json) = self.send(verb, path, body)
if code < 200 || code >= 300 {
raise Failure(
"Qdrant API error " +
code.to_string() +
" on " +
path +
": " +
json.stringify(),
)
}
json
}
///|
/// Check the server health via `GET /healthz`.
/// Qdrant answers this endpoint with plain text ("healthz check passed"),
/// so only the HTTP status code is inspected: 200 means healthy. Transport
/// failures (server not reachable) also report unhealthy instead of raising.
pub async fn QdrantClient::health(self : QdrantClient) -> Bool {
let code = self.send_status(Get, "/healthz", None) catch { _ => return false }
code == 200
}
///|
/// List all collection names via `GET /collections`.
pub async fn QdrantClient::list_collections(
self : QdrantClient,
) -> Array[String] {
let json = self.request_json(Get, "/collections", None)
match CollectionList::from_json(json) {
Some(list) => list.names
None =>
raise Failure("unexpected /collections response: " + json.stringify())
}
}
///|
/// Create a collection via `PUT /collections/{name}`.
pub async fn QdrantClient::create_collection(
self : QdrantClient,
name : String,
config : CollectionConfig,
) -> Unit {
let path = "/collections/" + url_encode(name)
let json = self.request_json(Put, path, Some(config.to_json()))
// Qdrant answers with {"result": true} on success; treat any 2xx as success.
ignore(json)
}
///|
/// Get detailed information about a collection via `GET /collections/{name}`.
/// Raises when the collection does not exist or the server reports an error.
pub async fn QdrantClient::collection_info(
self : QdrantClient,
name : String,
) -> CollectionInfo {
let path = "/collections/" + url_encode(name)
let json = self.request_json(Get, path, None)
match CollectionInfo::from_json(json) {
Some(info) => info
None =>
raise Failure("unexpected collection info response: " + json.stringify())
}
}
///|
/// Delete a collection via `DELETE /collections/{name}`.
/// Raises when the collection does not exist or the server reports an error.
pub async fn QdrantClient::delete_collection(
self : QdrantClient,
name : String,
) -> Unit {
let path = "/collections/" + url_encode(name)
let json = self.request_json(Delete, path, None)
ignore(json)
}
///|
/// Check whether a collection exists via `GET /collections/{name}`.
/// Returns false when the server answers 404; raises on other errors.
pub async fn QdrantClient::collection_exists(
self : QdrantClient,
name : String,
) -> Bool {
let path = "/collections/" + url_encode(name)
let (code, _) = self.send(Get, path, None)
if code >= 200 && code < 300 {
true
} else if code == 404 {
false
} else {
raise Failure("Qdrant API error " + code.to_string())
}
}
///|
/// Upsert points into a collection via `PUT /collections/{name}/points`.
/// Pass `wait=false` to return immediately; the operation may still be
/// propagating on the server.
pub async fn QdrantClient::upsert_points(
self : QdrantClient,
name : String,
points : Array[PointStruct],
wait~ : Bool = true,
) -> Unit {
let path = "/collections/" +
url_encode(name) +
"/points?wait=" +
wait.to_string()
let point_json : Array[Json] = []
for p in points {
point_json.push(p.to_json())
}
let body : Json = { "points": Json::array(point_json) }
let json = self.request_json(Put, path, Some(body))
ignore(json)
}
///|
/// Upsert points through the batch (column-oriented) format via
/// `PUT /collections/{name}/points`.
/// The batch format packs ids, vectors and payloads into parallel arrays,
/// which Qdrant applies as a single request. This is the classic batch
/// loading endpoint; `/points/batch` is the newer multi-operation endpoint
/// (body `{"operations": [...]}`) and is NOT used here.
pub async fn QdrantClient::upsert_points_batch(
self : QdrantClient,
name : String,
points : Array[PointStruct],
wait~ : Bool = true,
) -> Unit {
let path = "/collections/" +
url_encode(name) +
"/points?wait=" +
wait.to_string()
if points.is_empty() {
return
}
let ids : Array[Json] = []
let payloads : Array[Json] = []
for p in points {
ids.push(int64_to_json(p.id))
payloads.push(p.payload)
}
// The batch endpoint accepts a transposed vector layout: for dense points
// `vectors` is an array of per-point vectors; for named points it is an
// object mapping each vector name to the array of per-point vectors.
let vectors = match points[0].named_vectors {
Some(first_named) => {
let by_name : Map[String, Json] = Map([])
for name in first_named.keys() {
let rows : Array[Json] = []
for p in points {
match p.named_vectors {
Some(named) =>
match named.get(name) {
Some(vec) => rows.push(vector_to_json(vec))
None =>
raise Failure(
"inconsistent named vectors in batch: missing \"" +
name +
"\"",
)
}
None => raise Failure("mixed dense/named points in batch upsert")
}
}
by_name[name] = Json::array(rows)
}
Json::object(by_name)
}
None => {
let rows : Array[Json] = []
for p in points {
if p.named_vectors is Some(_) {
raise Failure("mixed dense/named points in batch upsert")
}
rows.push(vector_to_json(p.vector))
}
Json::array(rows)
}
}
let body : Json = {
"batch": {
"ids": Json::array(ids),
"vectors": vectors,
"payloads": Json::array(payloads),
},
}
let json = self.request_json(Put, path, Some(body))
ignore(json)
}
///|
/// Fetch a single point via `GET /collections/{name}/points/{id}`.
/// Returns None when the point does not exist (404).
pub async fn QdrantClient::get_point(
self : QdrantClient,
name : String,
id : Int64,
with_payload~ : Bool = true,
with_vector~ : Bool = true,
) -> PointStruct? {
let path = "/collections/" +
url_encode(name) +
"/points/" +
id.to_string() +
"?with_payload=" +
with_payload.to_string() +
"&with_vector=" +
with_vector.to_string()
let (code, json) = self.send(Get, path, None)
if code >= 200 && code < 300 {
match parse_result(json) {
Some(result) => PointStruct::from_json(result)
None => None
}
} else if code == 404 {
None
} else {
raise Failure(
"Qdrant API error " + code.to_string() + ": " + json.stringify(),
)
}
}
///|
/// Serialize point ids as a JSON number array without losing precision.
///
/// Qdrant point ids are u64 values. Above 2^53 a Double can no longer hold
/// every integer: `Json::number(id.to_double())` turns 9007199254740993 into
/// 9007199254740992, so a lookup returns the neighbouring point and a delete
/// removes it. `int64_to_json` keeps the exact decimal text in the JSON
/// number's `repr`, which is what the wire serializer emits.
pub fn point_ids_to_json(ids : Array[Int64]) -> Json {
let out : Array[Json] = []
for id in ids {
out.push(int64_to_json(id))
}
Json::array(out)
}
///|
/// Delete points by id via `POST /collections/{name}/points/delete`.
/// Pass `wait=false` to return immediately.
pub async fn QdrantClient::delete_points(
self : QdrantClient,
name : String,
ids : Array[Int64],
wait~ : Bool = true,
) -> Unit {
let path = "/collections/" +
url_encode(name) +
"/points/delete?wait=" +
wait.to_string()
let body : Json = { "points": point_ids_to_json(ids) }
let json = self.request_json(Post, path, Some(body))
ignore(json)
}
///|
/// Search the most similar points via `POST /collections/{name}/points/search`.
///
/// `limit` bounds how many hits are returned. `filter` is an optional
/// Qdrant filter JSON object, for example
/// `{ "must": [{ "key": "tag", "match": { "value": "alpha" } }] }`.
///
/// Advanced options: `score_threshold` keeps only hits above the score,
/// `offset` paginates search results, `using_vector` selects which named
/// vector to query against, and `search_params` carries search-time tuning
/// (`hnsw_ef`, `exact`).
pub async fn QdrantClient::search_points(
self : QdrantClient,
name : String,
vector : Array[Double],
limit : Int32,
filter~ : Json? = None,
with_payload~ : Bool = true,
with_vector~ : Bool = false,
score_threshold~ : Double? = None,
offset~ : Int64? = None,
using_vector~ : String? = None,
search_params~ : SearchParams? = None,
) -> Array[ScoredPoint] {
self.search_points_raw(
name,
vector_to_json(vector),
limit,
filter,
with_payload,
with_vector,
score_threshold,
offset,
using_vector,
search_params,
)
}
///|
/// Search with a named vector as input: the query vector is matched against
/// the named vector with the given name (Qdrant supports several vectors per
/// point). `using` is implied by the name carried in `named_vector`.
pub async fn QdrantClient::search_points_named(
self : QdrantClient,
name : String,
named_vector : NamedVector,
limit : Int32,
filter~ : Json? = None,
with_payload~ : Bool = true,
with_vector~ : Bool = false,
score_threshold~ : Double? = None,
offset~ : Int64? = None,
search_params~ : SearchParams? = None,
) -> Array[ScoredPoint] {
self.search_points_raw(
name,
named_vector.to_json(),
limit,
filter,
with_payload,
with_vector,
score_threshold,
offset,
None,
search_params,
)
}
///|
/// Core search implementation shared by the dense and named variants.
/// `vector` is the already-serialized vector field of the request body.
async fn QdrantClient::search_points_raw(
self : QdrantClient,
name : String,
vector : Json,
limit : Int32,
filter : Json?,
with_payload : Bool,
with_vector : Bool,
score_threshold : Double?,
offset : Int64?,
using_vector : String?,
search_params : SearchParams?,
) -> Array[ScoredPoint] {
let path = "/collections/" + url_encode(name) + "/points/search"
let base : Map[String, Json] = Map([])
// On a collection with named vectors the search API requires the vector
// name inside the vector object ({"name": ..., "vector": [...]}); there is
// no top-level "using" field in the search request (that is a query-API
// field).
base["vector"] = match using_vector {
Some(u) => Json::object({ "name": Json::string(u), "vector": vector })
None => vector
}
// `limit` is a required JSON body field for the search API (it is not a
// query parameter).
base["limit"] = Json::number(limit.to_double())
base["with_payload"] = Json::boolean(with_payload)
base["with_vector"] = Json::boolean(with_vector)
match filter {
Some(f) => base["filter"] = f
None => ()
}
match score_threshold {
Some(t) => base["score_threshold"] = Json::number(t)
None => ()
}
match offset {
Some(o) => base["offset"] = int64_to_json(o)
None => ()
}
match search_params {
Some(p) => base["params"] = p.to_json()
None => ()
}
let body = Json::object(base)
let json = self.request_json(Post, path, Some(body))
match parse_search_result(json) {
Some(hits) => hits
None => raise Failure("unexpected search response: " + json.stringify())
}
}
///|
/// Search with a type-safe `Filter` built from `Condition` helpers.
/// This is the preferred way to build payload filters; the raw-`Json`
/// `search_points` variant remains for advanced cases.
pub async fn QdrantClient::search_points_with_filter(
self : QdrantClient,
name : String,
vector : Array[Double],
limit : Int32,
filter : Filter,
with_payload~ : Bool = true,
with_vector~ : Bool = false,
score_threshold~ : Double? = None,
offset~ : Int64? = None,
using_vector~ : String? = None,
search_params~ : SearchParams? = None,
) -> Array[ScoredPoint] {
self.search_points(
name,
vector,
limit,
filter=Some(filter.to_json()),
with_payload~,
with_vector~,
score_threshold~,
offset~,
using_vector~,
search_params~,
)
}
///|
/// Scroll through all points of a collection via
/// `POST /collections/{name}/points/scroll`.
///
/// Returns one page of points together with `next_page_offset`, which is
/// Some(offset) when more pages remain and None on the last page. Pass it
/// back as `offset` to fetch the following page.
pub async fn QdrantClient::scroll_points(
self : QdrantClient,
name : String,
limit : Int32,
filter~ : Filter? = None,
offset~ : Int64? = None,
with_payload~ : Bool = true,
with_vector~ : Bool = true,
) -> ScrollResult {
let path = "/collections/" + url_encode(name) + "/points/scroll"
let body = scroll_request_body(
limit, filter, offset, with_payload, with_vector,
)
let json = self.request_json(Post, path, Some(body))
match parse_result(json) {
Some(result) =>
match ScrollResult::from_json(result) {
Some(page) => page
None => raise Failure("unexpected scroll response: " + json.stringify())
}
None => raise Failure("unexpected scroll response: " + json.stringify())
}
}
///|
/// Count points in a collection via
/// `POST /collections/{name}/points/count`.
///
/// When `exact` is false (default) Qdrant answers with an estimate from
/// the index; pass `exact=true` for an exact count, which is slower on
/// large collections. An optional filter narrows the counted subset.
pub async fn QdrantClient::count_points(
self : QdrantClient,
name : String,
filter~ : Filter? = None,
exact~ : Bool = false,
) -> Int64 {
let path = "/collections/" + url_encode(name) + "/points/count"
let body = count_request_body(filter, exact)
let json = self.request_json(Post, path, Some(body))
match parse_result(json) {
Some(result) =>
match parse_count_result(result) {
Some(n) => n
None => raise Failure("unexpected count response: " + json.stringify())
}
None => raise Failure("unexpected count response: " + json.stringify())
}
}
///|
/// Fetch several points at once by id via
/// `POST /collections/{name}/points`.
///
/// Points that do not exist are simply omitted from the returned list;
/// check the length against the requested ids to detect missing ones.
pub async fn QdrantClient::retrieve_points(
self : QdrantClient,
name : String,
ids : Array[Int64],
with_payload~ : Bool = true,
with_vector~ : Bool = true,
) -> Array[PointStruct] {
let path = "/collections/" + url_encode(name) + "/points"
let body : Json = {
"ids": point_ids_to_json(ids),
"with_payload": Json::boolean(with_payload),
"with_vector": Json::boolean(with_vector),
}
let json = self.request_json(Post, path, Some(body))
match parse_retrieve_result(json) {
Some(points) => points
None => raise Failure("unexpected retrieve response: " + json.stringify())
}
}
///|
/// Apply alias change actions via `POST /collections/aliases`.
async fn QdrantClient::apply_alias_actions(
self : QdrantClient,
actions : Array[Json],
) -> Unit {
let body : Json = { "actions": Json::array(actions) }
let json = self.request_json(Post, "/collections/aliases", Some(body))
ignore(json)
}
///|
/// Create an alias for a collection: `POST /collections/aliases` with a
/// `create_alias` action.
pub async fn QdrantClient::create_alias(
self : QdrantClient,
alias_name : String,
collection : String,
) -> Unit {
let action : Json = {
"action": Json::string("create_alias"),
"create_alias": {
"collection_name": Json::string(collection),
"alias_name": Json::string(alias_name),
},
}
self.apply_alias_actions([action])
}
///|
/// Delete an alias: `POST /collections/aliases` with a `delete_alias` action.
pub async fn QdrantClient::delete_alias(
self : QdrantClient,
alias_name : String,
) -> Unit {
let action : Json = {
"action": Json::string("delete_alias"),
"delete_alias": { "alias_name": Json::string(alias_name) },
}
self.apply_alias_actions([action])
}
///|
/// List all collection aliases via `GET /aliases`.
pub async fn QdrantClient::list_aliases(
self : QdrantClient,
) -> Array[AliasInfo] {
let json = self.request_json(Get, "/aliases", None)
match parse_aliases(json) {
Some(aliases) => aliases
None => raise Failure("unexpected aliases response: " + json.stringify())
}
}
///|
/// Delete points selected by a filter via
/// `POST /collections/{name}/points/delete`.
///
/// This is the bulk-delete variant: every point matching `filter` is
/// removed in one operation, without listing ids first.
pub async fn QdrantClient::delete_points_by_filter(
self : QdrantClient,
name : String,
filter : Filter,
wait~ : Bool = true,
) -> Unit {
let path = "/collections/" +
url_encode(name) +
"/points/delete?wait=" +
wait.to_string()
let body : Json = { "filter": filter.to_json() }
let json = self.request_json(Post, path, Some(body))
ignore(json)
}
///|
/// Run several searches in one round trip via
/// `POST /collections/{name}/points/search/batch`.
///
/// Each element is a search request JSON object, for example
/// `{ "vector": [...], "limit": 5, "with_payload": true }`; the returned
/// array has one entry (itself an array of hits) per request.
pub async fn QdrantClient::search_points_batch(
self : QdrantClient,
name : String,
searches : Array[Json],
) -> Array[Array[ScoredPoint]] {
let path = "/collections/" + url_encode(name) + "/points/search/batch"
let body : Json = { "searches": Json::array(searches) }
let json = self.request_json(Post, path, Some(body))
let result : Array[Array[ScoredPoint]]? = match json {
Object(members) =>
match members.get("result") {
Some(Array(groups)) => {
let out : Array[Array[ScoredPoint]] = []
for group in groups {
match group {
Array(hits_json) => {
let hits : Array[ScoredPoint] = []
for item in hits_json {
match ScoredPoint::from_json(item) {
Some(hit) => hits.push(hit)
None =>
raise Failure(
"unexpected batch search hit: " + json.stringify(),
)
}
}
out.push(hits)
}
_ =>
raise Failure(
"unexpected batch search response: " + json.stringify(),
)
}
}
Some(out)
}
_ => None
}
_ => None
}
match result {
Some(out) => out
None =>
raise Failure("unexpected batch search response: " + json.stringify())
}
}
///|
/// Set (merge) a payload on the given points via
/// `POST /collections/{name}/points/payload`.
///
/// Pass either `ids` or `filter` to select the target points. The payload
/// fields are merged into each point's existing payload; fields not in the
/// given object are kept.
pub async fn QdrantClient::set_payload(
self : QdrantClient,
name : String,
payload : Json,
ids~ : Array[Int64]? = None,
filter~ : Filter? = None,
wait~ : Bool = true,
) -> Unit {
let path = "/collections/" +
url_encode(name) +
"/points/payload?wait=" +
wait.to_string()
let body = payload_operation_body(payload, ids, filter)
let json = self.request_json(Post, path, Some(body))
ignore(json)
}
///|
/// Overwrite the payload of the given points via
/// `PUT /collections/{name}/points/payload`.
///
/// Unlike `set_payload`, the whole payload is replaced by the given object;
/// existing fields not in the given object are removed.
pub async fn QdrantClient::overwrite_payload(
self : QdrantClient,
name : String,
payload : Json,
ids~ : Array[Int64]? = None,
filter~ : Filter? = None,
wait~ : Bool = true,
) -> Unit {
let path = "/collections/" +
url_encode(name) +
"/points/payload?wait=" +
wait.to_string()
let body = payload_operation_body(payload, ids, filter)
let json = self.request_json(Put, path, Some(body))
ignore(json)
}
///|
/// Delete the whole payload of the selected points via
/// `POST /collections/{name}/points/payload/clear`.
///
/// Pass either `ids` or `filter` to select the target points. To remove only
/// some keys, use `delete_payload_by_keys`.
pub async fn QdrantClient::delete_payload(
self : QdrantClient,
name : String,
ids~ : Array[Int64]? = None,
filter~ : Filter? = None,
wait~ : Bool = true,
) -> Unit {
let path = "/collections/" +
url_encode(name) +
"/points/payload/clear?wait=" +
wait.to_string()
let body = point_selector_body(ids, filter)
let json = self.request_json(Post, path, Some(body))
ignore(json)
}
///|
/// Delete only the listed payload keys from the selected points via
/// `POST /collections/{name}/points/payload/delete`.
///
/// Pass either `ids` or `filter` to select the target points.
pub async fn QdrantClient::delete_payload_by_keys(
self : QdrantClient,
name : String,
keys : Array[String],
ids~ : Array[Int64]? = None,
filter~ : Filter? = None,
wait~ : Bool = true,
) -> Unit {
let path = "/collections/" +
url_encode(name) +
"/points/payload/delete?wait=" +
wait.to_string()
let key_json : Array[Json] = []
for key in keys {
key_json.push(Json::string(key))
}
let body = point_selector_body(ids, filter)
let members = match body {
Object(m) => m
_ => Map([])
}
members["keys"] = Json::array(key_json)
let json = self.request_json(Post, path, Some(Json::object(members)))
ignore(json)
}
///|
/// Build the shared point selector for payload delete/clear operations:
/// either `{ "points": [ids] }` or `{ "filter": ... }`. Unlike the write
/// operations, delete/clear bodies must not carry a `"payload"` field.
fn point_selector_body(ids : Array[Int64]?, filter : Filter?) -> Json {
let members : Map[String, Json] = Map([])
match ids {
Some(ids_arr) => members["points"] = point_ids_to_json(ids_arr)
None => ()
}
match filter {
Some(f) => members["filter"] = f.to_json()
None => ()
}
Json::object(members)
}
///|
/// Build the shared body for payload write operations: `{ "payload": ... }`
/// plus either `"points": [ids]` or `"filter": ...`.
fn payload_operation_body(
payload : Json,
ids : Array[Int64]?,
filter : Filter?,
) -> Json {
let members : Map[String, Json] = Map([])
members["payload"] = payload
match ids {
Some(ids_arr) => members["points"] = point_ids_to_json(ids_arr)
None => ()
}
match filter {
Some(f) => members["filter"] = f.to_json()
None => ()
}
Json::object(members)
}
///|
/// Update collection settings via `PATCH /collections/{name}`.
///
/// See `CollectionUpdate` for which settings can be changed.
pub async fn QdrantClient::update_collection(
self : QdrantClient,
name : String,
update : CollectionUpdate,
) -> Unit {
let path = "/collections/" + url_encode(name)
let json = self.request_json(Patch, path, Some(update.to_json()))
ignore(json)
}
///|
/// Create a payload index on a field via
/// `PUT /collections/{name}/index?wait={wait}`.
///
/// `schema` is the field schema, for example `"keyword"`, `"integer"`,
/// `"float"`, `"geo"`, `"bool"`, `"text"` or `"uuid"`. Creating an index
/// speeds up filtered access to that payload field.
pub async fn QdrantClient::create_payload_index(
self : QdrantClient,
name : String,
field : String,
schema : String,
wait~ : Bool = true,
) -> Unit {
let path = "/collections/" +
url_encode(name) +
"/index?wait=" +
wait.to_string()
let body : Json = {
"field_name": Json::string(field),
"field_schema": Json::string(schema),
}
let json = self.request_json(Put, path, Some(body))
ignore(json)
}
///|
/// Delete a payload index via `DELETE /collections/{name}/index/{field}`.
pub async fn QdrantClient::delete_payload_index(
self : QdrantClient,
name : String,
field : String,
wait~ : Bool = true,
) -> Unit {
let path = "/collections/" +
url_encode(name) +
"/index/" +
url_encode(field) +
"?wait=" +
wait.to_string()
let json = self.request_json(Delete, path, None)
ignore(json)
}
///|
/// List the payload indexes of a collection via
/// `GET /collections/{name}` (the `payload_schema` field). Qdrant has no
/// dedicated list-indexes endpoint.
pub async fn QdrantClient::list_payload_indexes(
self : QdrantClient,
name : String,
) -> Array[PayloadIndexInfo] {
let path = "/collections/" + url_encode(name)
let json = self.request_json(Get, path, None)
match parse_payload_indexes(json) {
Some(indexes) => indexes
None =>
raise Failure("unexpected payload indexes response: " + json.stringify())
}
}
///|
/// List the snapshots of a collection via
/// `GET /collections/{name}/snapshots`.
pub async fn QdrantClient::list_snapshots(
self : QdrantClient,
name : String,
) -> Array[SnapshotInfo] {
let path = "/collections/" + url_encode(name) + "/snapshots"
let json = self.request_json(Get, path, None)
match parse_snapshots(json) {
Some(snapshots) => snapshots
None => raise Failure("unexpected snapshots response: " + json.stringify())
}
}
///|
/// Create a snapshot of a collection via `POST /collections/{name}/snapshots`.
/// Snapshots can be downloaded from the storage and used to restore or
/// migrate a collection.
pub async fn QdrantClient::create_snapshot(
self : QdrantClient,
name : String,
wait~ : Bool = true,
) -> SnapshotInfo {
let path = "/collections/" +
url_encode(name) +
"/snapshots?wait=" +
wait.to_string()
let json = self.request_json(Post, path, None)
match parse_result(json) {
Some(result) =>
match SnapshotInfo::from_json(result) {
Some(info) => info
None =>
raise Failure("unexpected snapshot response: " + json.stringify())
}
None => raise Failure("unexpected snapshot response: " + json.stringify())
}
}
///|
/// Delete a snapshot via `DELETE /collections/{name}/snapshots/{snapshot}`.
pub async fn QdrantClient::delete_snapshot(
self : QdrantClient,
name : String,
snapshot : String,
wait~ : Bool = true,
) -> Unit {
let path = "/collections/" +
url_encode(name) +
"/snapshots/" +
url_encode(snapshot) +
"?wait=" +
wait.to_string()
let json = self.request_json(Delete, path, None)
ignore(json)
}
///|
/// Run a query against the Qdrant query API via
/// `POST /collections/{name}/points/query`.
///
/// `query` is the raw query JSON, for example
/// `{ "nearest": [0.1, 0.2, 0.3, 0.4] }` or
/// `{ "recommend": { "positive": [[...]], "negative": [[...]] } }`.
/// Prefer the `query_nearest` / `query_recommend` helpers for common cases.
pub async fn QdrantClient::query_points(
self : QdrantClient,
name : String,
query : Json,
limit : Int32,
filter~ : Filter? = None,
using_vector~ : String? = None,
score_threshold~ : Double? = None,
with_payload~ : Bool = true,
with_vector~ : Bool = false,
) -> Array[ScoredPoint] {
let path = "/collections/" + url_encode(name) + "/points/query"
let members : Map[String, Json] = Map([])
members["query"] = query
members["limit"] = Json::number(limit.to_double())
members["with_payload"] = Json::boolean(with_payload)
members["with_vector"] = Json::boolean(with_vector)
match filter {
Some(f) => members["filter"] = f.to_json()
None => ()
}
match using_vector {
Some(u) => members["using"] = Json::string(u)
None => ()
}
match score_threshold {
Some(t) => members["score_threshold"] = Json::number(t)
None => ()
}
let body = Json::object(members)
let json = self.request_json(Post, path, Some(body))
match parse_query_result(json) {
Some(hits) => hits
None => raise Failure("unexpected query response: " + json.stringify())
}
}
///|
/// Nearest-neighbor query via the query API: find points closest to `vector`.
/// Equivalent to `search_points` but routed through `/points/query`.
pub async fn QdrantClient::query_nearest(
self : QdrantClient,
name : String,
vector : Array[Double],
limit : Int32,
filter~ : Filter? = None,
using_vector~ : String? = None,
score_threshold~ : Double? = None,
with_payload~ : Bool = true,
with_vector~ : Bool = false,
) -> Array[ScoredPoint] {
self.query_points(
name,
{ "nearest": vector_to_json(vector) },
limit,
filter~,
using_vector~,
score_threshold~,
with_payload~,
with_vector~,
)
}
///|
/// Recommendation query via the query API: find points similar to the
/// `positive` inputs and dissimilar to the `negative` ones.
///
/// Each element of `positive` / `negative` is either a dense vector
/// (`Json::array([...])`) or a point id (`Json::number(id)`).
pub async fn QdrantClient::query_recommend(
self : QdrantClient,
name : String,
positive : Array[Json],
negative~ : Array[Json]? = None,
limit : Int32,
filter~ : Filter? = None,
using_vector~ : String? = None,
score_threshold~ : Double? = None,
with_payload~ : Bool = true,
with_vector~ : Bool = false,
) -> Array[ScoredPoint] {
let rec : Map[String, Json] = Map([])
rec["positive"] = Json::array(positive)
match negative {
Some(neg) => rec["negative"] = Json::array(neg)
None => ()
}
self.query_points(
name,
{ "recommend": Json::object(rec) },
limit,
filter~,
using_vector~,
score_threshold~,
with_payload~,
with_vector~,
)
}
///|
/// Count the distinct values of a payload field via
/// `POST /collections/{name}/facet`.
///
/// `key` is the payload field to facet on; `limit` bounds how many distinct
/// values are returned (by descending count). Pass `exact=true` for exact
/// counts instead of the faster approximate estimation.
pub async fn QdrantClient::facet_points(
self : QdrantClient,
name : String,
key : String,
limit : Int32,
filter~ : Filter? = None,
exact~ : Bool = false,
) -> FacetResult {
let path = "/collections/" + url_encode(name) + "/facet"
let members : Map[String, Json] = Map([])
members["key"] = Json::string(key)
members["limit"] = Json::number(limit.to_double())
members["exact"] = Json::boolean(exact)
match filter {
Some(f) => members["filter"] = f.to_json()
None => ()
}
let body = Json::object(members)
let json = self.request_json(Post, path, Some(body))
match parse_result(json) {
Some(result) =>
match FacetResult::from_json(result) {
Some(fr) => fr
None => raise Failure("unexpected facet response: " + json.stringify())
}
None => raise Failure("unexpected facet response: " + json.stringify())
}
}
///|
/// Grouped search via `POST /collections/{name}/points/search/groups`.
///
/// Points are grouped by the payload field `group_by`; `limit` bounds the
/// hits returned per group and `group_size` bounds the number of groups.
/// The result is one entry per group with its top hits.
pub async fn QdrantClient::search_points_groups(
self : QdrantClient,
name : String,
vector : Array[Double],
limit : Int32,
group_by : String,
group_size : Int32,
filter~ : Json? = None,
with_payload~ : Bool = true,
with_vector~ : Bool = false,
score_threshold~ : Double? = None,
using_vector~ : String? = None,
search_params~ : SearchParams? = None,
) -> Array[ScoredPointGroup] {
let path = "/collections/" + url_encode(name) + "/points/search/groups"
let members : Map[String, Json] = Map([])
// Like the search API, the groups endpoint has no top-level "using"
// field; on a named-vector collection the name goes inside the vector
// object.
members["vector"] = match using_vector {
Some(u) =>
Json::object({ "name": Json::string(u), "vector": vector_to_json(vector) })
None => vector_to_json(vector)
}
members["limit"] = Json::number(limit.to_double())
members["group_by"] = Json::string(group_by)
members["group_size"] = Json::number(group_size.to_double())
members["with_payload"] = Json::boolean(with_payload)
members["with_vector"] = Json::boolean(with_vector)
match filter {
Some(f) => members["filter"] = f
None => ()
}
match score_threshold {
Some(t) => members["score_threshold"] = Json::number(t)
None => ()
}
match search_params {
Some(p) => members["params"] = p.to_json()
None => ()
}
let body = Json::object(members)
let json = self.request_json(Post, path, Some(body))
match parse_search_groups(json) {
Some(groups) => groups
None =>
raise Failure("unexpected grouped search response: " + json.stringify())
}
}
///|
/// Read the global write lock state via `GET /locks`. Qdrant locks are
/// service-wide (not per-collection); when `write` is true the service
/// rejects write operations.
pub async fn QdrantClient::get_locks(self : QdrantClient) -> CollectionLock {
let json = self.request_json(Get, "/locks", None)
match parse_result(json) {
Some(result) =>
match CollectionLock::from_json(result) {
Some(lock) => lock
None => raise Failure("unexpected lock response: " + json.stringify())
}
None => raise Failure("unexpected lock response: " + json.stringify())
}
}
///|
/// Set the global write lock state via `POST /locks`.
///
/// When `write` is true the service rejects write operations; provide an
/// optional `error_message` returned to clients on rejected writes.
pub async fn QdrantClient::set_locks(
self : QdrantClient,
write : Bool,
error_message~ : String? = None,
) -> Unit {
let members : Map[String, Json] = Map([])
members["write"] = Json::boolean(write)
match error_message {
Some(msg) => members["error_message"] = Json::string(msg)
None => ()
}
let body = Json::object(members)
let json = self.request_json(Post, "/locks", Some(body))
ignore(json)
}
///|
/// Read server info via `GET /`: the Qdrant version and commit hash.
pub async fn QdrantClient::service_info(self : QdrantClient) -> ServiceInfo {
let json = self.request_json(Get, "/", None)
match ServiceInfo::from_json(json) {
Some(info) => info
None =>
raise Failure("unexpected service info response: " + json.stringify())
}
}
///|
/// Build the request body for a collection cluster operation
/// (`POST /collections/{name}/cluster`).
///
/// Qdrant's `ClusterOperations` is an untagged enum of single-key objects:
/// the operation name is the key and its parameters are the value, e.g.
/// `{"replicate_shard": {"shard_id": 0, "from_peer_id": 1, "to_peer_id": 2}}`.
/// A flat `{"method": "replicate_shard", ...}` body matches no variant and is
/// rejected with HTTP 400. `op` must be a real Qdrant operation name such as
/// `replicate_shard`, `move_shard`, `drop_replica` or `abort_transfer`.
/// Peer ids are exact decimal texts of Qdrant's u64 values.
pub fn collection_cluster_op_body(
op : String,
shard_id : Int64,
peer_id~ : String? = None,
from_peer_id~ : String? = None,
to_peer_id~ : String? = None,
) -> Json {
let params : Map[String, Json] = Map([])
params["shard_id"] = int64_to_json(shard_id)
match peer_id {
Some(v) => params["peer_id"] = peer_id_to_json(v)
None => ()
}
match from_peer_id {
Some(v) => params["from_peer_id"] = peer_id_to_json(v)
None => ()
}
match to_peer_id {
Some(v) => params["to_peer_id"] = peer_id_to_json(v)
None => ()
}
let members : Map[String, Json] = Map([])
members[op] = Json::object(params)
Json::object(members)
}
///|
/// Read the overall cluster state via `GET /cluster`: status, local peer id,
/// the peer map and raft consensus info.
pub async fn QdrantClient::cluster_info(self : QdrantClient) -> ClusterInfo {
let json = self.request_json(Get, "/cluster", None)
match parse_result(json) {
Some(result) =>
match ClusterInfo::from_json(result) {
Some(info) => info
None =>
raise Failure("unexpected cluster response: " + json.stringify())
}
None => raise Failure("unexpected cluster response: " + json.stringify())
}
}
///|
/// Read the per-collection cluster layout via
/// `GET /collections/{name}/cluster`: local and remote shard replicas plus
/// in-flight transfers.
pub async fn QdrantClient::collection_cluster_info(
self : QdrantClient,
name : String,
) -> CollectionClusterInfo {
let path = "/collections/" + url_encode(name) + "/cluster"
let json = self.request_json(Get, path, None)
match parse_result(json) {
Some(result) =>
match CollectionClusterInfo::from_json(result) {
Some(info) => info
None =>
raise Failure(
"unexpected collection cluster response: " + json.stringify(),
)
}
None =>
raise Failure(
"unexpected collection cluster response: " + json.stringify(),
)
}
}
///|
/// Run a collection cluster operation via `POST /collections/{name}/cluster`
/// and return whether Qdrant reported the operation as started.
async fn QdrantClient::cluster_op(
self : QdrantClient,
name : String,
body : Json,
) -> Bool {
let path = "/collections/" + url_encode(name) + "/cluster"
let json = self.request_json(Post, path, Some(body))
match parse_result(json) {
Some(True) => true
Some(False) => false
_ => raise Failure("unexpected cluster op response: " + json.stringify())
}
}
///|
/// Replicate a shard to another peer via
/// `POST /collections/{name}/cluster` with the `replicate_shard` operation.
/// Peer ids are exact decimal texts of Qdrant's u64 values.
pub async fn QdrantClient::replicate_shard(
self : QdrantClient,
name : String,
shard_id : Int64,
from_peer_id : String,
to_peer_id : String,
) -> Bool {
self.cluster_op(
name,
collection_cluster_op_body(
"replicate_shard",
shard_id,
from_peer_id=Some(from_peer_id),
to_peer_id=Some(to_peer_id),
),
)
}
///|
/// Pick a peer that already holds `shard_id` as the replication source.
/// `local_shards` belong to `layout.peer_id`; remote entries carry their own
/// peer id. Falls back to the local peer when the shard is not listed.
pub fn shard_source_peer(
layout : CollectionClusterInfo,
shard_id : Int64,
) -> String {
for shard in layout.remote_shards {
if shard.shard_id == shard_id {
return shard.peer_id
}
}
for shard in layout.local_shards {
if shard.shard_id == shard_id {
return layout.peer_id
}
}
layout.peer_id
}
///|
/// Add a replica of a shard on `peer_id`.
///
/// Qdrant has no `create_shard_replica` cluster operation: a replica is added
/// with `replicate_shard`, which needs a source peer that holds the shard.
/// When `from_peer_id~` is omitted the client looks one up through
/// `GET /collections/{name}/cluster`; pass it explicitly to skip the lookup.
pub async fn QdrantClient::create_shard_replica(
self : QdrantClient,
name : String,
shard_id : Int64,
peer_id : String,
from_peer_id~ : String? = None,
) -> Bool {
let source = match from_peer_id {
Some(v) => v
None => shard_source_peer(self.collection_cluster_info(name), shard_id)
}
self.cluster_op(
name,
collection_cluster_op_body(
"replicate_shard",
shard_id,
from_peer_id=Some(source),
to_peer_id=Some(peer_id),
),
)
}
///|
/// Drop a shard replica from `peer_id` via the `drop_replica` operation.
/// `delete_shard_replica` is kept as the public method name, but Qdrant's
/// cluster operation is called `drop_replica`.
pub async fn QdrantClient::delete_shard_replica(
self : QdrantClient,
name : String,
shard_id : Int64,
peer_id : String,
) -> Bool {
self.cluster_op(
name,
collection_cluster_op_body("drop_replica", shard_id, peer_id=Some(peer_id)),
)
}