Qdrant connector

Write points to Qdrant collections with single or named dense vectors, sparse vectors, multi-vector support, and schemaless JSON payloads — configured via CollectionSchema, QdrantVectorDef and QdrantSparseVectorDef.

Version
v 1.0.14
Last reviewed
Nov 4, 2026
ExperimentalThe Rust SDK is experimental. It is outside CocoIndex's compatibility promise: any release, including a minor one, may change or remove its API. It is not yet published on crates.io.

The qdrant connector provides utilities for writing points to Qdrant vector databases, with support for single vectors, named dense vectors, sparse vectors, and multi-vector configurations.

use cocoindex::connectors::qdrant::{
    self, CollectionSchema, Distance, NamedPointVector, QdrantConnection,
};
Dependencies

The module is compiled with the qdrant Cargo feature of the cocoindex crate. The qdrant-client crate compiles protobufs, so a protoc binary must be on PATH (or named by PROTOC) to build.

Connection setup

QdrantConnection holds a shared qdrant_client::Qdrant client, which speaks gRPC. It is cheap to clone.

impl QdrantConnection {
    pub async fn connect(url: &str) -> Result<Self>
    pub fn from_client(state_id: impl Into<String>, client: Qdrant) -> Self
    pub fn client(&self) -> &Qdrant
}

QdrantConnection::connect(url) takes the gRPC URL (for example http://localhost:6334) and uses the client’s defaults. For an API key, TLS or timeouts, build a qdrant_client::Qdrant yourself and wrap it with from_client; state_id should name the server, not hold a secret.

Example:

let conn = QdrantConnection::connect(&url).await?;

// With an API key
let url = "https://example.cloud.qdrant.io:6334";
let client = qdrant_client::Qdrant::from_url(url)
    .api_key(std::env::var("QDRANT_API_KEY").ok())
    .build()?;
let conn = QdrantConnection::from_client(url, client);

As target

The qdrant connector provides target state APIs for writing points to collections. CocoIndex tracks what points should exist and automatically handles upserts and deletions.

Declaring target states

Setting up a connection

Create a ContextKey<QdrantConnection> to identify your Qdrant connection, then provide it in your lifespan:

Note

The key name is load-bearing across runs — it’s the stable identity CocoIndex uses to track managed collections. See ContextKey as stable identity before renaming.

use cocoindex::connectors::qdrant::QdrantConnection;

cocoindex::context_key!(static DB: QdrantConnection);

let conn = QdrantConnection::connect(&url).await?;
let app = Environment::builder()
    .db_path(tempdir.path().join(".cocoindex_db"))
    .provide_key(&DB, conn)
    .build()
    .await?
    .app("QdrantTargetTest")
    .await?;

context_key!(static DB: QdrantConnection) declares the key; its name is the stable identity. provide_key(&DB, conn) provides the connection, in a #[cocoindex::lifespan] function or on Environment::builder() as here.

Collections (parent state)

Declares a collection as a target state. Returns a CollectionTarget for declaring points.

pub fn declare_collection_target(
    ctx: &Ctx,
    conn: &ContextKey<QdrantConnection>,
    collection_name: impl Into<String>,
    schema: CollectionSchema,
) -> Result<CollectionTarget>

pub async fn mount_collection_target(
    ctx: &Ctx,
    conn: &ContextKey<QdrantConnection>,
    collection_name: impl Into<String>,
    schema: CollectionSchema,
) -> Result<CollectionTarget>

pub async fn mount_collection_target_with_options(
    ctx: &Ctx,
    conn: &ContextKey<QdrantConnection>,
    collection_name: impl Into<String>,
    schema: CollectionSchema,
    options: ManagedTargetOptions,
) -> Result<CollectionTarget>

managed_by is set with ManagedTargetOptions::user_managed() on the _with_options variants. declare_collection_target returns a handle whose points resolve when the current component commits; mount_collection_target(...).await? returns a handle that accepts points at once.

Parameters:

  • ctx — The current component’s Ctx.
  • conn — A ContextKey<QdrantConnection> identifying the Qdrant connection to use.
  • collection_name — Name of the collection.
  • schema — Schema definition specifying vector configurations (see Collection Schema).
  • options — ManagedTargetOptions; ManagedTargetOptions::user_managed() assumes the collection exists instead of creating and dropping it.

Points (child states)

Once a CollectionTarget is resolved, declare points to be upserted with one of its declare_*point methods:

impl CollectionTarget {
    /// A single-vector point.
    pub fn declare_point(
        &self,
        ctx: &Ctx,
        id: impl Into<QdrantPointId>,
        vector: Vec<f32>,
        payload: Map<String, JsonValue>,
    ) -> Result<()>

    /// A point for a named-vector collection: each value is a single vector,
    /// a multivector or a sparse vector.
    pub fn declare_named_vectors_point<I, K>(
        &self,
        ctx: &Ctx,
        id: impl Into<QdrantPointId>,
        vectors: I,
        payload: Map<String, JsonValue>,
    ) -> Result<()>
    where
        I: IntoIterator<Item = (K, NamedPointVector)>,
        K: Into<String>

    /// A multi-vector point for a collection created with
    /// `CollectionSchema::multivector`.
    pub fn declare_multivector_point(
        &self,
        ctx: &Ctx,
        id: impl Into<QdrantPointId>,
        vectors: Vec<Vec<f32>>,
        payload: Map<String, JsonValue>,
    ) -> Result<()>
}

The id, the vector and the payload are separate arguments, and the shape of the vector picks the method. The payload is a serde_json::Map<String, Value>. Each method checks the point against the collection schema and the id rules (see Point IDs) when it is called, and returns an error for a mismatch: a vector of the wrong length, an empty multivector, a NaN or infinite element, or a payload integer beyond the i64 range Qdrant stores. Writes wait until Qdrant has applied them.

Collection schema

Define vector configurations for a collection using CollectionSchema. Unlike row-oriented databases, Qdrant uses a point-oriented model where each point has schemaless payload and one or more vectors with predefined dimensions.

impl CollectionSchema {
    // One unnamed dense vector per point
    pub fn new(vector_size: u64, distance: Distance) -> Self
    pub fn from_vector_schema(schema: VectorSchema, distance: Distance) -> Result<Self>
    pub async fn from_vector_provider(
        provider: &(impl VectorSchemaProvider + ?Sized),
        distance: Distance,
    ) -> Result<Self>

    // One unnamed multivector per point
    pub fn multivector(vector_size: u64, distance: Distance) -> Self
    pub fn from_multivector_schema(schema: MultiVectorSchema, distance: Distance) -> Result<Self>
    pub async fn from_multivector_provider(
        provider: &(impl MultiVectorSchemaProvider + ?Sized),
        distance: Distance,
    ) -> Result<Self>

    // Named vectors, dense and sparse
    pub fn named<I, K, V>(vectors: I) -> Result<Self>
    where
        I: IntoIterator<Item = (K, V)>,
        K: Into<String>,
        V: Into<QdrantNamedVectorDef>

    // Named dense vectors from a `#[derive(SchemaFields)]` row
    pub fn from_row<T: SchemaFields>(distance: Distance) -> Result<Self>
    pub fn with_vector_dim(self, field_name: &str, dim: usize) -> Result<Self>
}

from_row turns each #[coco(vector)] field of a row struct into a named dense vector; the other fields stay payload. with_vector_dim sets the dimension of one such field, for a field whose size the struct does not fix.

Different from Python

The Rust schema has one constructor per shape instead of one create with a union argument.

Dense and sparse vectors share one namespace in Qdrant (the server rejects duplicate names across the two kinds), so CollectionSchema::named takes both in one list of (name, QdrantNamedVectorDef) entries and returns an error for a name that appears twice. Sparse vectors are always named.

QdrantVectorDef

Specifies vector configuration including dimension, distance metric, and multi-vector settings:

pub struct QdrantVectorDef {
    pub distance: Distance,
    pub multivector_comparator: MultivectorComparator,
    // the vector schema, set by the constructors
}

impl QdrantVectorDef {
    pub fn new(schema: VectorSchema, distance: Distance) -> Result<Self>
    pub fn f32(size: u64, distance: Distance) -> Result<Self>
    pub async fn from_vector_provider(
        provider: &(impl VectorSchemaProvider + ?Sized),
        distance: Distance,
    ) -> Result<Self>
    pub fn multivector(schema: MultiVectorSchema, distance: Distance) -> Result<Self>
    pub async fn from_multivector_provider(
        provider: &(impl MultiVectorSchemaProvider + ?Sized),
        distance: Distance,
    ) -> Result<Self>
}

pub enum Distance { Cosine, Dot, Euclid }
pub enum MultivectorComparator { MaxSim }

The constructor picks the shape: new and f32 for a dense vector, multivector for a multi-vector field. from_vector_provider and from_multivector_provider take the dimension from a provider, typically the embedder. multivector_comparator is MaxSim, the only comparator.

QdrantSparseVectorDef

Specifies a named sparse vector configuration:

pub struct QdrantSparseVectorDef {
    pub modifier: Option<SparseModifier>,
}

pub enum SparseModifier { Idf }

Sparse vectors use Qdrant’s native sparse-vector storage and dot-product scoring. The optional modifier: Some(SparseModifier::Idf) enables Qdrant’s IDF modifier for sparse vectors.

Single (unnamed) vector

For collections with a single unnamed vector:

let target = qdrant::mount_collection_target(
    &ctx,
    &DB,
    &collection,
    CollectionSchema::new(size, Distance::Cosine),
)
.await?;
for (id, v, filename, text) in &points {
    target.declare_point(&ctx, *id, v.clone(), payload(filename, text))?;
}

CollectionSchema::new(size, distance) takes the dimension as a number; CollectionSchema::from_vector_provider(&embedder, distance).await? takes it from an embedder.

Named vectors

For collections with multiple named vectors:

use cocoindex::connectors::qdrant::{
    CollectionSchema, Distance, QdrantNamedVectorDef, QdrantSparseVectorDef,
    QdrantVectorDef,
};

let schema = CollectionSchema::named([
    (
        "dense",
        QdrantNamedVectorDef::from(QdrantVectorDef::f32(384, Distance::Cosine)?),
    ),
    ("sparse", QdrantSparseVectorDef::default().into()),
])?;

Or derive the named vectors from a row struct, as the text_embedding_qdrant example does:

/// A computed point: id + vector + payload fields.
#[derive(Clone, Serialize, Deserialize, SchemaFields)]
struct PointData {
    id: u64,
    #[coco(vector)]
    vector: Vec<f32>,
    filename: String,
    chunk_start: i64,
    chunk_end: i64,
    text: String,
}

let vector_dim = ctx.get_key(&EMBEDDER)?.dimension();
let target = qdrant::mount_collection_target(
    &ctx,
    &DB,
    COLLECTION,
    CollectionSchema::from_row::<PointData>(Distance::Cosine)?
        .with_vector_dim("vector", vector_dim)?,
)
.await?;

let payload = json!({
    "filename": p.filename,
    "chunk_start": p.chunk_start,
    "chunk_end": p.chunk_end,
    "text": p.text,
})
.as_object()
.unwrap()
.clone();
target.declare_named_vectors_point(
    &ctx,
    p.id,
    [("vector", NamedPointVector::Single(p.vector.clone()))],
    payload,
)?;

Dense + sparse vectors

For hybrid retrieval, declare a named dense vector and a named sparse vector in the same collection:

Points put both vectors in the named-vector list. The sparse vector is a native Qdrant sparse vector, not payload:

let schema = CollectionSchema::named([
    (
        "dense",
        QdrantNamedVectorDef::from(QdrantVectorDef::f32(3, Distance::Cosine)?),
    ),
    ("sparse", QdrantSparseVectorDef { modifier }.into()),
])?;
let target =
    qdrant::mount_collection_target(&ctx, &DB, &collection, schema).await?;
for (id, dense, indices, values, text) in points {
    target.declare_named_vectors_point(
        &ctx,
        id,
        [
            ("dense", NamedPointVector::Single(dense.to_vec())),
            ("sparse", NamedPointVector::Sparse { indices, values }),
        ],
        payload("f.md", text),
    )?;
}

modifier is Some(SparseModifier::Idf) or None. NamedPointVector::Sparse { indices, values } takes Vec<u32> indices and Vec<f32> values; the indices must be unique.

VectorSchemaProvider

QdrantVectorDef::new(VectorSchema::f32(384), distance) takes an explicit schema and QdrantVectorDef::from_vector_provider(&embedder, distance).await? takes a VectorSchemaProvider. An f16 schema is stored as Qdrant’s Float16 datatype. See Vector Schema for details.

Multi-vector support

For multi-vector configurations (multiple vectors per point stored together):

let target = qdrant::mount_collection_target(
    &ctx,
    &DB,
    COLLECTION,
    CollectionSchema::multivector(EMBED_DIM, Distance::Cosine),
)
.await?;

target.declare_multivector_point(&ctx, p.id, p.vectors.clone(), payload)?;

CollectionSchema::multivector(size, distance) scores with MAX_SIM, the only comparator. See examples/rust/image_search_colpali for the full program.

Distance metrics

The Distance argument specifies the similarity metric:

  • Distance::Cosine — Cosine similarity (normalized dot product)
  • Distance::Dot — Dot product similarity
  • Distance::Euclid — Euclidean distance (L2)
Different from Python

Every Rust constructor takes the distance explicitly; there is no default.

Example: single vector

The single-vector shape is CollectionSchema::new(size, Distance::Cosine) with declare_point, shown under Single (unnamed) vector. The end-to-end example, examples/rust/text_embedding_qdrant, uses a named vector derived from a row struct; see Example: named vectors.

Example: named vectors

use std::path::PathBuf;

use cocoindex::connectors::qdrant::{
    self, CollectionSchema, Distance, NamedPointVector, QdrantConnection,
};
use cocoindex::ops::sentence_transformers::SentenceTransformerEmbedder;
use cocoindex::prelude::*;
use serde_json::json;

const COLLECTION: &str = "TextEmbedding";

cocoindex::context_key!(static DB: QdrantConnection);
cocoindex::context_key!(
    static EMBEDDER: SentenceTransformerEmbedder,
    detect_change
);

/// A computed point: id + vector + payload fields.
#[derive(Clone, Serialize, Deserialize, SchemaFields)]
struct PointData {
    id: u64,
    #[coco(vector)]
    vector: Vec<f32>,
    filename: String,
    chunk_start: i64,
    chunk_end: i64,
    text: String,
}

#[cocoindex::function]
async fn process_file(ctx: &Ctx, file: FileEntry) -> Result<Vec<PointData>> {
    // chunk and embed the file ...
}

async fn app_main(ctx: Ctx, sourcedir: PathBuf) -> Result<()> {
    let vector_dim = ctx.get_key(&EMBEDDER)?.dimension();
    let target = qdrant::mount_collection_target(
        &ctx,
        &DB,
        COLLECTION,
        CollectionSchema::from_row::<PointData>(Distance::Cosine)?
            .with_vector_dim("vector", vector_dim)?,
    )
    .await?;

    let files = walk_items(&sourcedir, &["**/*.md"])?;
    let points_by_file = use_mount_each!(files, |file| process_file(ctx, file)).await?;

    for points in &points_by_file {
        for p in points {
            let payload = json!({
                "filename": p.filename,
                "chunk_start": p.chunk_start,
                "chunk_end": p.chunk_end,
                "text": p.text,
            })
            .as_object()
            .unwrap()
            .clone();
            target.declare_named_vectors_point(
                &ctx,
                p.id,
                [("vector", NamedPointVector::Single(p.vector.clone()))],
                payload,
            )?;
        }
    }
    Ok(())
}

See examples/rust/text_embedding_qdrant for the full program.

Example: dense + sparse hybrid vectors

See the snippet under Dense + sparse vectors: CollectionSchema::named with a QdrantSparseVectorDef, and declare_named_vectors_point with NamedPointVector::Sparse.

Point IDs

Qdrant only accepts two point ID types — this is enforced by the Qdrant server itself (see Qdrant’s Point IDs documentation):

  • unsigned 64-bit integers — a u64 id becomes QdrantPointId::Num
  • UUIDs — a uuid::Uuid, String or &str id becomes QdrantPointId::Uuid; a string may be in any textual form Qdrant accepts (hyphenated, upper case, 32-character hex, URN or braced)

The id argument of each declare_*point method is impl Into<QdrantPointId>. The method validates the ID when it is called and returns an error for a string that is not a UUID, rather than letting the write fail later at the Qdrant server.

Qdrant stores every textual form of one UUID as the same point. CocoIndex therefore tracks a UUID point ID in its canonical form (hyphenated, lower case). If you declare two forms of one UUID in the same run, the second declaration fails as a duplicate.

To derive a stable point ID from an arbitrary string key (e.g. a document path or chunk key), use a deterministic UUID:

let point_id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, format!("doc/{chunk_key}").as_bytes());

The uuid crate needs its v5 feature for Uuid::new_v5.

Payloads

Point payloads are schemaless JSON objects: a serde_json::Map<String, Value>. The json! macro builds one:

let payload = json!({
    "filename": p.filename,
    "chunk_start": p.chunk_start,
    "chunk_end": p.chunk_end,
    "text": p.text,
})
.as_object()
.unwrap()
.clone();

The connector focuses on writing points to Qdrant. For vector search, use the Qdrant client directly or the connector’s search helpers:

// Single unnamed vector
let hits = qdrant::vector_search(&conn, &collection, vec![0.0, 0.9, 0.1], 1).await?;

// Named vector
let hits = qdrant::named_vector_search(conn, COLLECTION, "vector", query_vec, TOP_K).await?;
for hit in hits {
    let filename = hit
        .payload
        .get("filename")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    println!("[{:.3}] {filename}", hit.score);
}

The connector has search helpers: vector_search, named_vector_search, multivector_search and named_multivector_search, each returning Vec<QdrantHit> with a score and a payload. For any other query, such as a hybrid query with prefetch and fusion, use conn.client(), the underlying qdrant_client::Qdrant.