Skip to content

How indexing works

You can read a Swiftide indexing pipeline line by line and know when each step runs. The example comes from the ask-your-codebase showcase. It prepares the source files of a codebase for retrieval-augmented generation (RAG): it splits them into chunks, describes each chunk with a large language model (LLM), embeds it, and stores it in LanceDB.

examples/ask-your-codebase/src/index.rs
let stats = indexing::Pipeline::from_loader(
FileLoader::new(codebase.root()).with_extensions(&languages.extensions()),
)
.filter(test_code::not_test_files(codebase.clone()))
// Drop unit tests before the cache, which keys on path and content. Changing the content
// after it would make the cache miss on every run.
.then(test_code::without_unit_tests)
.then(files_found.count())
// Skip files whose path and content are unchanged since the last successful run
.filter_cached(codebase.node_cache()?)
.then(files_changed.count())
.then(record_relative_path(codebase.clone()))
// Split along syntax boundaries (functions, impls, ...) with tree-sitter
.then_chunk(ChunkByLanguage::try_new(languages, CHUNK_SIZE)?)
// Ask the LLM which questions each chunk answers; stored as metadata and embedded
.then(
MetadataQACode::from_client(llm)
.prompt_template(QUESTIONS_PROMPT)
.num_questions(QUESTIONS_PER_CHUNK)
.concurrency(concurrent_requests)
.build()?,
)
// Embed chunk + metadata locally, in batches
.then_in_batch(Embed::new(embedding::local_model()?).with_batch_size(embedding::BATCH_SIZE))
.then_store_with(codebase.vector_store()?)
.run()
.await?;

Source: examples/ask-your-codebase/src/index.rs#L89-L115

Indexing pipeline

Each node passes through the steps from top to bottom until a step drops it:

  1. FileLoader walks the codebase root and emits one node per file with a matching extension.
  2. filter drops test files. The closure sees each Result, so errors pass through and still surface.
  3. then(test_code::without_unit_tests) cuts unit tests out of Rust files.
  4. filter_cached drops files whose path and content did not change since the last successful run. codebase.node_cache() is a showcase helper that builds a Redb node cache, see Caching.
  5. then(record_relative_path(..)) stores the file path, relative to the root, as metadata.
  6. then_chunk splits each file along syntax boundaries with tree-sitter. ChunkByLanguage is the showcase’s own wrapper that picks a Swiftide ChunkCode per language. One file becomes several nodes.
  7. MetadataQACode asks the LLM which questions each chunk answers and stores them as metadata.
  8. then_in_batch with Embed embeds each chunk and its metadata with FastEmbed, on the central processing unit (CPU). with_batch_size sets 32 nodes per call. Without it, a batch holds 256 nodes.
  9. then_store_with writes each node to storage. codebase.vector_store() builds a LanceDB store, see Storage.
  10. run().await drives the stream to the end and returns the run’s statistics as stats.

A node is a Node<T>. It carries one piece of content and everything known about it.

FieldHolds
pathThe source path.
chunkThe content, of type T.
metadataKey and value pairs, sorted by key.
vectors, sparse_vectorsEmbeddings, one per embedded field. Embed and SparseEmbed fill them.
embed_modeWhether the chunk and metadata are embedded together, each on its own, or both.
offsetA running position that ChunkCode counts from 0 over the chunks it keeps.
parent_idThe id() of the source node when the first chunker split it.

Since 0.31 the pipeline is generic. A Pipeline<T> streams Node<T>. A chunk is any type that implements the Chunk marker trait, which requires Clone + Send + Sync + Debug + AsRef<[u8]> + 'static. Most built-in steps take TextNode, which is Node<String>.

id() returns a universally unique identifier (UUID) computed from the path and the chunk, so it changes when either changes. The node cache keys on this id, see Cache keys.

StepMethodTrait
loaderPipeline::from_loader, Pipeline::from_streamLoader
filterfiltera closure over &Result<Node<T>>
node cachefilter_cachedNodeCache
transformerthenTransformer, or a closure
chunkerthen_chunk, then_expandChunkerTransformer
batch transformerthen_in_batchBatchableTransformer
embedderthen_in_batch(Embed::new(model))EmbeddingModel
storagethen_store_withPersist

A chunker returns a stream of nodes for each node. then_expand behaves like then_chunk with a different name in traces. Use it when you split a node for a reason other than chunking.

A step can change the node type. then with a transformer whose Output is U turns a Pipeline<T> into a Pipeline<U>.

Nodes keep flowing after storage.

The pipeline methods only describe the stream, but their arguments run while you build the chain. In the snippet, codebase.node_cache()? creates the Redb file and embedding::local_model()? loads the FastEmbed model, which downloads it on first use.

run takes the pipeline by value and does the work:

  1. It calls setup on every storage backend. LanceDB creates its table here if it is missing.
  2. It pulls nodes through the stream until the stream ends. Some steps spawn Tokio tasks, so run needs a Tokio runtime.
  3. It marks the source nodes in the node cache, but only after the stream ended without an error.
  4. It logs Pipeline completed at info level and returns the same numbers as PipelineStats, see Statistics.

run returns Result<PipelineStats>, not the nodes. The showcase reads the number of stored chunks from stats.nodes_stored. It counts files with pass-through closures such as .then(files_found.count()), because the statistics count nodes at the end of the stream, after chunking.

then, then_chunk, then_in_batch and storage work on several nodes at once and pass them on as they finish, see Streams and concurrency. The showcase passes .concurrency(concurrent_requests) to MetadataQACode to bound requests to the LLM.

The first error stops the run, and run returns it, see Errors. It is the error the step failed with, so a LanguageModelError can be recovered with downcast_ref. A source node is cached only when at least one of its chunks reached the end and none of them failed, and When nodes are cached covers the consequences.

Two runs on the swiftide-agents crate. claude-haiku-5-5 wrote the questions, through an OpenAI-compatible gateway to Amazon Web Services (AWS) Bedrock. FastEmbed jina-embeddings-v2-base-code computed the embeddings.

$ ./target/debug/ask-your-codebase index swiftide-agents --model claude-haiku-5-5
Indexing Rust files in swiftide-agents ...
Indexed 34 chunks from 14 new or changed files (14 files found, 0 unchanged) in 91.9s

The second run, with no file changed:

$ ./target/debug/ask-your-codebase index swiftide-agents --model claude-haiku-5-5
Indexing Rust files in swiftide-agents ...
Indexed 0 chunks from 0 new or changed files (14 files found, 14 unchanged) in 0.6s

The node cache drops all 14 files at step 4, so no prompt is sent.

Pipeline, FileLoader and Embed need no feature flag. The showcase turns off the defaults and turns on tree-sitter, fastembed, lancedb, redb and openai. To add the same features to your crate:

Terminal window
cargo add swiftide@0.34 --no-default-features --features tree-sitter,fastembed,lancedb,redb,openai

TLS needs no setup, because swiftide’s HTTP clients bring their own rustls crypto provider (TLS). Feature flags lists every flag.

Loading data