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.
cargo add swiftide@0.34 --features qdrantHybrid search also needs fastembed for sparse embeddings, and the filter snippet needs openai.
Local instance and connection
Section titled “Local instance and connection”Start Qdrant in Docker:
docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrantSwiftide talks to Qdrant over gRPC Remote Procedure Call (gRPC) on port 6334, not over the Hypertext Transfer Protocol (HTTP) API on 6333.
Builder
Section titled “Builder”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:
| Setting | Default |
|---|---|
collection_name | swiftide |
vector_distance | Distance::Cosine |
batch_size | 50 nodes per upsert |
| vectors | one 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.
Stored points
Section titled “Stored points”Qdrant implements Persist, so you hand it to then_store_with:
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() .awaitSource: 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 pathcontent, the chunk textlast_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.
Captured output
Section titled “Captured output”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.7sNamed vectors
Section titled “Named vectors”A point can hold one dense vector per embedded field:
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.
Similarity search
Section titled “Similarity search”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 candidateslet 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
Section titled “Hybrid search”Hybrid search adds a sparse, keyword-like search to the dense one:
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:
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”.
Payload filters
Section titled “Payload filters”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.
Gotchas
Section titled “Gotchas”- Retrieved content is
Debug-quoted, with escaped newlines. docs-qa wraps the retriever inCleanQdrant(theself.qdrantin 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
--recreateflag drops the collection first. - Retrieved documents keep every payload key except
contentas metadata. client()returns a shared reference to the inner client,&Arc<qdrant_client::Qdrant>. docs-qa uses it for a health check,collection_existsanddelete_collection.- If Qdrant is down,
runfails during setup. See unreachable stores.