Embedding
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:
cargo add swiftide@0.34 --features fastembed,qdrantDense and sparse embeddings
Section titled “Dense and sparse embeddings”The docs-qa showcase gives every chunk a dense and a sparse embedding:
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
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
Section titled “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
Section titled “Named vectors”With EmbedMode::PerField, every node carries several dense vectors. Qdrant stores each one as a named vector:
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
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 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
Section titled “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:
PerFieldorBothnodes with several vectors arrive as named vectors, and Qdrant rejects the write.- Nodes in the default mode get their
Combinedvector stored without an error.
With a single configured vector, configure the field your embed mode produces.
Hybrid collections
Section titled “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:
// Ensure all batching is consistentlet 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 indexinglet 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// vectorslet 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
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
Section titled “Embedding models”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 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:
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
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 lists which client implements EmbeddingModel.
Index and query with the same model. A new model needs a new index.
Batch sizes
Section titled “Batch sizes”Batching happens at three levels:
with_batch_sizeonEmbedsets the nodes per model call, andbatch_sizeonFastEmbedthe texts per model run, as FastEmbed explains.batch_sizeon the storage sets the nodes per write. Qdrant defaults to 50. 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
Section titled “Gotchas”- A failed model call turns the whole batch into one error.
filter_errorsthen drops every node of that batch, see Errors before a batch step. LanguageModelWithBackOffdoes not retryembedcalls, as errors and backoff explains.Combinedputs 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_lengthraises that limit, as ask-your-codebase does.PerFieldembeds every metadata field, including the ones your storage skips. Each one costs embedding time.- A chunker that builds nodes with
TextNode::newinstead ofNode::into_chunksresets the embed mode to the default. - Clones of a
FastEmbedshare one model behind a lock, so a higherwith_concurrencydoes not speed it up.