Skip to content

Qdrant

With the qdrant feature flag, an indexing pipeline writes nodes into a Qdrant collection, and a query pipeline searches that collection by similarity or with hybrid search.

Terminal window
cargo add swiftide@0.34 --features qdrant

Hybrid search also needs fastembed for sparse embeddings, and the filter snippet needs openai.

Start Qdrant in Docker:

Terminal window
docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant

Swiftide talks to Qdrant over gRPC Remote Procedure Call (gRPC) on port 6334, not over the Hypertext Transfer Protocol (HTTP) API on 6333.

examples/docs-qa/src/store.rs
let qdrant = Qdrant::builder()
.batch_size(BATCH_SIZE)
.vector_size(DENSE_VECTOR_SIZE)
// One dense and one sparse vector, both over the chunk *and* its metadata
.with_vector(EmbeddedField::Combined)
.with_sparse_vector(EmbeddedField::Combined)
.collection_name(&collection.0)
.build()?;

Source: examples/docs-qa/src/store.rs#L38-L45

Qdrant::builder() reads the address from QDRANT_URL, falls back to http://localhost:6334, and sends QDRANT_API_KEY when set. Qdrant::try_from_url(url) returns Result<QdrantBuilder>, so add ? before calling builder methods. The builder’s client method takes your own qdrant_client::Qdrant.

vector_size is required and must match your embedder. docs-qa uses 384, the size of FastEmbed’s default dense model. The other settings have defaults:

SettingDefault
collection_nameswiftide
vector_distanceDistance::Cosine
batch_size50 nodes per upsert
vectorsone dense vector for EmbeddedField::Combined

with_vector adds a dense vector and with_sparse_vector adds a sparse one. The first with_vector call replaces the default, so list EmbeddedField::Combined yourself when you still need it. Both take an EmbeddedField, which names the text the vector was computed from. Combined is the chunk together with its metadata.

Qdrant implements Persist, so you hand it to then_store_with:

examples/docs-qa/src/index.rs
indexing::Pipeline::from_loader(loader)
.then_chunk(ChunkMarkdown::from_chunk_range(
MIN_CHUNK_CHARS..MAX_CHUNK_CHARS,
))
// A short title and keywords per chunk; both end up in the embedded text and help the
// sparse (keyword) side of hybrid search in particular.
.then(
MetadataTitle::from_client(llm.clone())
.prompt_template(TITLE_PROMPT)
.build()?,
)
.then(
MetadataKeywords::from_client(llm.clone())
.prompt_template(KEYWORDS_PROMPT)
.build()?,
)
.then_in_batch(Embed::new(embedders.dense.clone()).with_batch_size(EMBED_BATCH_SIZE))
.then_in_batch(SparseEmbed::new(embedders.sparse.clone()).with_batch_size(EMBED_BATCH_SIZE))
.then_store_with(qdrant.clone())
.run()
.await

Source: examples/docs-qa/src/index.rs#L50-L70

Before the first node, setup creates the collection if it is missing. An existing collection is kept as it is. The pipeline then upserts nodes in batches. Every point carries this payload:

  • each metadata field of the node, under its own name
  • path, the node’s source path
  • content, the chunk text
  • last_updated_at, the time of the upsert

The point ID is a universally unique identifier (UUID) computed from the path and the chunk, so indexing the same chunk again overwrites its point. Debug builds wait for each upsert to apply; release builds do not.

A debug build of docs-qa indexed four markdown pages into 43 chunks, with metadata from claude-haiku-5-5 and embeddings from local FastEmbed models:

Indexed 43 chunks into `docs-qa` in 71.7s

A point can hold one dense vector per embedded field:

examples/store_multiple_vectors.rs
indexing::Pipeline::from_loader(FileLoader::new("LICENSE"))
.with_concurrency(1)
.with_embed_mode(EmbedMode::PerField)
.then_chunk(ChunkMarkdown::from_chunk_range(20..2048))
.then(MetadataQAText::new(openai_client.clone()))
.then(MetadataSummary::new(openai_client.clone()))
.then(MetadataTitle::new(openai_client.clone()))
.then(MetadataKeywords::new(openai_client.clone()))
.then_in_batch(Embed::new(openai_client.clone()).with_batch_size(10))
.log_all()
.filter_errors()
.then_store_with(
Qdrant::builder()
.batch_size(50)
.vector_size(1536)
.collection_name("swiftide-multi-vectors")
.with_vector(EmbeddedField::Chunk)
.with_vector(EmbeddedField::Metadata(metadata_qa_text::NAME.into()))
.with_vector(EmbeddedField::Metadata(metadata_summary::NAME.into()))
.with_vector(
VectorConfig::builder()
.embedded_field(EmbeddedField::Metadata(metadata_title::NAME.into()))
.distance(Distance::Manhattan)
.build()?,
)
.with_vector(EmbeddedField::Metadata(metadata_keywords::NAME.into()))
.build()?,
)
.run()
.await?;

Source: examples/store_multiple_vectors.rs#L45-L74

EmbedMode::PerField embeds the chunk and every metadata field separately. EmbedMode::Both adds the combined text. When the collection has named vectors, Qdrant stores only the ones you configured and skips the rest. The embed mode and the configured vectors must agree. A node with exactly one dense vector and no sparse vector is always sent as the unnamed vector. With several configured vectors, use EmbedMode::PerField or Both. With one configured vector, keep the default embed mode. Sparse vectors are only stored for fields that also have a configured dense vector.

VectorConfig::builder() overrides the size or distance of one vector, here Distance::Manhattan for the title.

With one dense vector and no sparse vector, the collection uses Qdrant’s unnamed vector. Otherwise vectors are named after their field: Combined, Chunk or Metadata: <field name>. Sparse vectors get a _sparse suffix, such as Combined_sparse.

A query pipeline searches Qdrant through then_retrieve. This one narrows the search with a payload filter:

// Payload keys are the node's metadata plus `path`, `content` and `last_updated_at`
let guides = Filter {
must: vec![Condition::matches("path", ["README.md", "CONTRIBUTING.md"])],
must_not: vec![Condition::matches("deprecated", true)],
..Default::default()
};
let similarity = SimilaritySingleEmbedding::from_filter(guides.clone()).with_top_k(5);
let pipeline = query::Pipeline::from_search_strategy(similarity)
.then_transform_query(query_transformers::Embed::from_client(openai.clone()))
.then_retrieve(qdrant)
.then_answer(answers::Simple::from_client(openai));
// On a hybrid search, the filter applies to the dense and the sparse candidates
let hybrid = HybridSearch::from_filter(guides).with_top_n(20);

Source: snippets/src/integrations/qdrant.rs#L20-L35

SimilaritySingleEmbedding returns the 10 nearest chunks unless you set with_top_k. Only then_retrieve(qdrant) is Qdrant-specific. The query must already carry a dense embedding from query_transformers::Embed, made with the model you indexed with.

Swiftide picks the vector to search from the configuration of the Qdrant value in the query pipeline, not from the collection. With more than one vector or any sparse vector configured, it searches the vector named Combined. Build the retriever with the same vector configuration as the indexing side; docs-qa calls the same store::connect for both. The collection in the previous section has no Combined vector, so it cannot serve this search.

Hybrid search adds a sparse, keyword-like search to the dense one:

examples/docs-qa/src/query.rs
query::Pipeline::from_search_strategy(
HybridSearch::default()
.with_top_n(CANDIDATES)
.with_top_k(CANDIDATES),
)

Source: examples/docs-qa/src/query.rs#L108-L112

The query then needs both embeddings before retrieval:

examples/docs-qa/src/query.rs
pipeline
// Hybrid search needs both a dense and a sparse embedding of the question
.then_transform_query(query_transformers::Embed::from_client(
self.embedders.dense.clone(),
))
.then_transform_query(query_transformers::SparseEmbed::from_client(
self.embedders.sparse.clone(),
))
.then_retrieve(self.qdrant.clone())
// A local cross-encoder reorders the fused candidates; only the best few remain
.then_transform_response(self.reranker.clone())
.then_answer(self.answerer.clone())

Source: examples/docs-qa/src/query.rs#L121-L134

Qdrant fetches top_n candidates from the dense and from the sparse vector and fuses them with Reciprocal Rank Fusion (RRF); see search strategies for top_n and top_k. Both vector fields default to Combined; with_dense_vector_field and with_sparse_vector_field select other named vectors.

The collection needs a sparse vector for the same field. Without a sparse embedding on the query, retrieval fails with “No sparse embedding for query”.

Both search strategies take a Qdrant Filter, as in the similarity snippet. Swiftide re-exports the client crate at swiftide::integrations::qdrant::qdrant_client. Conditions match payload keys, including any metadata field. An array of strings matches any of its values as keywords. An owned String that contains whitespace becomes a full-text match instead of a keyword match. A full-text match needs a full-text payload index on that field, which Swiftide does not create. Without one, Qdrant matches the text as an exact substring.

Qdrant implements hybrid search only for HybridSearch<Filter>. Write that type even when you set no filter. For SimilaritySingleEmbedding, Qdrant accepts both Filter and no filter. SimilaritySingleEmbedding::default() is the search without a filter and needs no type annotation. Before 0.34, it needed one.

  • Retrieved content is Debug-quoted, with escaped newlines. docs-qa wraps the retriever in CleanQdrant (the self.qdrant in the query pipeline snippet above), which restores the text before reranking and answering.
  • A changed vector size, distance or set of vectors needs a new collection.
  • A changed chunk gets a new point ID, and the old point stays. docs-qa’s --recreate flag drops the collection first.
  • Retrieved documents keep every payload key except content as metadata.
  • client() returns a shared reference to the inner client, &Arc<qdrant_client::Qdrant>. docs-qa uses it for a health check, collection_exists and delete_collection.
  • If Qdrant is down, run fails during setup. See unreachable stores.

PgVector