# Embedding

> Add dense and sparse embeddings to nodes with Embed and SparseEmbed, choose the embedded text with EmbedMode, and store each embedded field as a named vector in Qdrant.

Here every chunk in your indexing pipeline ends up with a dense and a sparse vector, stored in a Qdrant collection that a retriever can search. You add an embedder as a batch transformer, choose which text it embeds, and configure the storage to keep the vectors you need.

Add the `fastembed` and `qdrant` feature flags:

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

## Dense and sparse embeddings

The docs-qa showcase gives every chunk a dense and a sparse embedding:

```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)

`Embed::new` takes any `EmbeddingModel` and fills the node's `vectors`. `SparseEmbed::new` takes any `SparseEmbeddingModel` and fills `sparse_vectors`. A dense vector captures what the text means. A sparse vector weights the words in it, which helps keyword-like matches in hybrid search.

Both are batch transformers, so you add them with `then_in_batch`. Each batch becomes one model call. `with_batch_size` sets the nodes per batch, with a pipeline default of 256. `with_concurrency` sets how many batches run at the same time.

Both work on text nodes (`TextNode`) only. A second `Embed` step replaces the dense vectors of the first one instead of adding to them.

## Embed mode

A node's embed mode decides which texts the embedder sees, each under an embedded field:

| `EmbedMode`                    | Embedded fields                                       |
| ------------------------------ | ----------------------------------------------------- |
| `SingleWithMetadata` (default) | `Combined`                                            |
| `PerField`                     | `Chunk`, plus one `Metadata(name)` per metadata field |
| `Both`                         | all of the above                                      |

`Combined` is the metadata as `key: value` lines, a newline, then the chunk. Metadata keys come in sorted order. `Chunk` is the chunk alone. `Metadata(name)` is the value of one metadata field. Values that are not strings are embedded as JSON (JavaScript Object Notation).

`Pipeline::with_embed_mode` sets the mode on every node that passes that point. Chunkers that use `Node::into_chunks` copy the mode to each chunk, so you can set it right after the loader.

Qdrant names each vector after its field: `Combined`, `Chunk` or `Metadata: Title`. Sparse vectors add `_sparse`, as in `Combined_sparse`. LanceDB prefixes the column with `vector_`, as in `vector_combined`.

## Named vectors

With `EmbedMode::PerField`, every node carries several dense vectors. Qdrant stores each one as a named vector:

```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)

The example embeds the chunk and four metadata fields that a large language model (LLM) writes. Each `with_vector` call adds a named vector for one embedded field. Each metadata transformer module has a `NAME` constant, such as `metadata_title::NAME`, so the vector names match. `VectorConfig::builder()` overrides the distance or the size of one vector.

When a node has several vectors, Qdrant stores only the fields you configured and skips the rest. Without `with_vector`, the collection has one vector for `Combined`. The first `with_vector` call replaces that default.

[Similarity search](/integrations/qdrant/#similarity-search) on a collection with named vectors always queries `Combined`, which the example above lacks. To search it, use `EmbedMode::Both` and add `with_vector(EmbeddedField::Combined)`.

### Single vector collections

With exactly one dense vector and no sparse vector, Qdrant uses one unnamed vector, both in the collection and in each node that has a single vector. That node skips the field filter. So `with_vector(EmbeddedField::Chunk)` alone breaks in two ways:

- `PerField` or `Both` nodes with several vectors arrive as named vectors, and Qdrant rejects the write.
- Nodes in the default mode get their `Combined` vector stored without an error.

With a single configured vector, configure the field your embed mode produces.

## Hybrid collections

Hybrid search queries a dense and a sparse vector. `HybridSearch` uses `Combined` for both by default, and `with_dense_vector_field` and `with_sparse_vector_field` change each one on its own. The hybrid search example loads both models and configures both vectors for `Combined`:

```rust title="examples/hybrid_search.rs"
// Ensure all batching is consistent
let batch_size = 64;

let fastembed_sparse = FastEmbed::try_default_sparse().unwrap().to_owned();
let fastembed = FastEmbed::try_default().unwrap().to_owned();

// Set up openai with the mini model, which is great for indexing
let openai = openai::OpenAI::builder()
    .default_prompt_model("gpt-4o-mini")
    .build()
    .unwrap();

// Set up qdrant and use the combined fields (metadata + chunks) for both sparse and dense
// vectors
let qdrant = Qdrant::builder()
    .batch_size(batch_size)
    .vector_size(384)
    .with_vector(EmbeddedField::Combined)
    .with_sparse_vector(EmbeddedField::Combined)
    .collection_name("swiftide-hybrid-example")
    .build()?;
```

Source: [`examples/hybrid_search.rs#L23-L43`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/hybrid_search.rs#L23-L43)

`FastEmbed::try_default()` loads BGE (BAAI General Embedding) small, a dense model with 384 dimensions. `FastEmbed::try_default_sparse()` loads a SPLADE (Sparse Lexical and Expansion) model for sparse vectors. `vector_size` must match the dense model, here 384. Qdrant stores a sparse vector only when its field also has a dense vector configured. So `with_sparse_vector(EmbeddedField::Chunk)` without `with_vector(EmbeddedField::Chunk)` adds a sparse vector that never gets filled.

## Embedding models

[FastEmbed](/integrations/fastembed/) runs models on your central processing unit (CPU). It is the only integration that produces sparse embeddings. One instance holds one model, and passing a sparse instance to `Embed` fails with "Expected dense model, got sparse". [FastEmbed](/integrations/fastembed/#model-choice) shows how to load another model, such as a code-trained one.

Provider embeddings come from a client with an embedding model. The named vectors example uses OpenAI, which needs the `openai` feature flag:

```rust title="examples/store_multiple_vectors.rs"
let openai_client = integrations::openai::OpenAI::builder()
    .default_embed_model("text-embedding-3-small")
    .default_prompt_model("gpt-4o")
    .build()?;
```

Source: [`examples/store_multiple_vectors.rs#L38-L41`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/store_multiple_vectors.rs#L38-L41)

Without `default_embed_model`, every `embed` call fails with "Model not set". The example sets `vector_size(1536)` to match `text-embedding-3-small`. [Models](/models/overview/) lists which client implements `EmbeddingModel`.

Index and query with the same model. A new model needs a new index.

## Batch sizes

Batching happens at three levels:

- `with_batch_size` on `Embed` sets the nodes per model call, and `batch_size` on `FastEmbed` the texts per model run, as [FastEmbed](/integrations/fastembed/#batch-size) explains.
- `batch_size` on the storage sets the nodes per write. Qdrant defaults to 50. [Batching](/indexing/storage/#batching) covers how the storage receives them.

With `PerField`, a node with four metadata fields gives five texts, so a batch of 10 nodes sends 50 texts in one call.

## Gotchas

- A failed model call turns the whole batch into one error. `filter_errors` then drops every node of that batch, see [Errors before a batch step](/indexing/streams-and-concurrency/#errors-before-a-batch-step).
- `LanguageModelWithBackOff` does not retry `embed` calls, as [errors and backoff](/models/errors-backoff-and-usage/) explains.
- `Combined` puts the metadata before the chunk. A model that truncates long input cuts the end of the chunk first. FastEmbed truncates at its token limit without an error. `fastembed::TextInitOptions::with_max_length` raises that limit, as ask-your-codebase does.
- `PerField` embeds every metadata field, including the ones your storage skips. Each one costs embedding time.
- A chunker that builds nodes with `TextNode::new` instead of `Node::into_chunks` resets the embed mode to the default.
- Clones of a `FastEmbed` share one model behind a lock, so a higher `with_concurrency` does not speed it up.

## Reference

- [`Embed`](https://docs.rs/swiftide/0.34/swiftide/indexing/transformers/struct.Embed.html)
- [`SparseEmbed`](https://docs.rs/swiftide/0.34/swiftide/indexing/transformers/struct.SparseEmbed.html)
- [`EmbedMode`](https://docs.rs/swiftide/0.34/swiftide/indexing/enum.EmbedMode.html)
- [`EmbeddedField`](https://docs.rs/swiftide/0.34/swiftide/indexing/enum.EmbeddedField.html)
- [`Pipeline::then_in_batch`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.Pipeline.html#method.then_in_batch)
- [`Pipeline::with_embed_mode`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.Pipeline.html#method.with_embed_mode)
- [`EmbeddingModel`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.EmbeddingModel.html)
- [`SparseEmbeddingModel`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.SparseEmbeddingModel.html)
- [`FastEmbed`](https://docs.rs/swiftide/0.34/swiftide/integrations/fastembed/struct.FastEmbed.html)
- [`Qdrant`](https://docs.rs/swiftide/0.34/swiftide/integrations/qdrant/struct.Qdrant.html)
- [`QdrantBuilder::with_vector`](https://docs.rs/swiftide/0.34/swiftide/integrations/qdrant/struct.QdrantBuilder.html#method.with_vector)
- [`QdrantBuilder::with_sparse_vector`](https://docs.rs/swiftide/0.34/swiftide/integrations/qdrant/struct.QdrantBuilder.html#method.with_sparse_vector)
- [`VectorConfig`](https://docs.rs/swiftide/0.34/swiftide/integrations/qdrant/struct.VectorConfig.html)
- [`HybridSearch`](https://docs.rs/swiftide/0.34/swiftide/query/search_strategies/struct.HybridSearch.html)
- [`OpenAI`](https://docs.rs/swiftide/0.34/swiftide/integrations/openai/struct.OpenAI.html)
- [`LanguageModelWithBackOff`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.LanguageModelWithBackOff.html)
- [`Node::into_chunks`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.Node.html#method.into_chunks)
- [Feature flags](/reference/feature-flags/)

## Next

[Caching](/indexing/caching/)