# Qdrant

> Store indexed chunks in a Qdrant collection with dense and sparse vectors, then retrieve them with similarity or hybrid search and payload filters.

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.

```sh
cargo add swiftide@0.34 --features qdrant
```

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

## Local instance and connection

Start Qdrant in Docker:

```sh
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.

## Builder

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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

`Qdrant` implements `Persist`, so you hand it to `then_store_with`:

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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.

### 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:

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

## Named vectors

A point can hold one dense vector per embedded field:

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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

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

```rust
// 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`](https://github.com/bosun-ai/swiftide-website/blob/master/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

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

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/docs-qa/src/query.rs#L108-L112)

The query then needs both embeddings before retrieval:

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/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](/querying/retrieval-and-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

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

- Retrieved content is `Debug`-quoted, with escaped newlines. docs-qa wraps the retriever in [`CleanQdrant`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/docs-qa/src/retrieval.rs) (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](/reference/troubleshooting-and-faq/#unreachable-stores).

## Reference

- [`Qdrant`](https://docs.rs/swiftide/0.34/swiftide/integrations/qdrant/struct.Qdrant.html)
- [`QdrantBuilder`](https://docs.rs/swiftide/0.34/swiftide/integrations/qdrant/struct.QdrantBuilder.html)
- [`VectorConfig`](https://docs.rs/swiftide/0.34/swiftide/integrations/qdrant/struct.VectorConfig.html)
- [`SparseVectorConfig`](https://docs.rs/swiftide/0.34/swiftide/integrations/qdrant/struct.SparseVectorConfig.html)
- [`Distance`](https://docs.rs/swiftide/0.34/swiftide/integrations/qdrant/type.Distance.html)
- [`qdrant_client`](https://docs.rs/swiftide/0.34/swiftide/integrations/qdrant/qdrant_client/index.html)
- [`EmbeddedField`](https://docs.rs/swiftide/0.34/swiftide/indexing/enum.EmbeddedField.html)
- [`EmbedMode`](https://docs.rs/swiftide/0.34/swiftide/indexing/enum.EmbedMode.html)
- [`Persist`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.Persist.html)
- [`Retrieve`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.Retrieve.html)
- [`SimilaritySingleEmbedding`](https://docs.rs/swiftide/0.34/swiftide/query/search_strategies/struct.SimilaritySingleEmbedding.html)
- [`HybridSearch`](https://docs.rs/swiftide/0.34/swiftide/query/search_strategies/struct.HybridSearch.html)
- [docs-qa source](https://github.com/bosun-ai/swiftide/tree/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/docs-qa)

## Next

[PgVector](/integrations/pgvector/)