Ask your codebase
This showcase is a command-line tool with two commands. index turns a codebase into searchable chunks, and ask answers a question from those chunks and lists the files it used. Everything except the large language model (LLM) runs on your machine. You need no Docker and no database server.
Prerequisites
Section titled “Prerequisites”- Rust 1.97.1, which the swiftide workspace pins in
rust-toolchain.toml. OPENAI_API_KEYfor any OpenAI-compatible endpoint.OPENAI_BASE_URLis optional and defaults tohttps://api.openai.com/v1.OPENAI_MODELis optional and defaults togpt-5.4-mini. An emptyOPENAI_API_KEYorOPENAI_BASE_URLcounts as unset. An emptyOPENAI_MODELdoes not: it sets the model to an empty name, so unset it instead.- About 600MB of disk for the code embedding model. FastEmbed downloads it into
.fastembed_cache/in the current directory on first use.
The crate turns off swiftide’s default features and enables the tree-sitter, fastembed, lancedb, redb and openai feature flags. To build the same thing in your own crate:
cargo add swiftide@0.34 --no-default-features --features tree-sitter,fastembed,lancedb,redb,openaicargo add fastembed@5 tree-sitter@0.26 --no-default-featuresThe crate also depends on fastembed to pick the embedding model and tree-sitter to find unit tests, each at the version swiftide uses. TLS needs no setup: swiftide’s HTTP clients bring their own rustls crypto provider (TLS).
Architecture
Section titled “Architecture”The tool runs two pipelines that share one local embedder. The indexing pipeline loads files, chunks them, describes each chunk with the LLM, embeds the result and stores it. The query pipeline embeds your question with the same model, retrieves the closest chunks and asks the LLM to answer from them.
The index lives next to the code, in <path>/.ask-your-codebase/. It holds cache.redb, the node cache, and lancedb/, the vector table.
Indexing pipeline
Section titled “Indexing pipeline”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
FileLoader respects .gitignore. filter_cached skips files whose path and content are unchanged since the last successful run. then_in_batch sends 32 chunks to the embedder per call. The two count closures count files for the summary line. run returns the PipelineStats of the run, and its nodes_stored is the number of chunks in the summary.
The order of the test steps matters. The node cache keys on the file path and its content. If unit tests were removed after filter_cached, the content would differ on every run and the cache would never hit.
Test filtering
Section titled “Test filtering”/// Drops test files before they are cached or chunked: files in a `tests/` directory, test/// modules in their own file (`src/agent/tests.rs`) and test support modules such as/// `src/test_utils.rs`.////// Errors are kept, so they still surface.pub fn not_test_files(codebase: Codebase) -> impl Fn(&Result<TextNode>) -> bool + Send + Sync { move |file| { !file .as_ref() .is_ok_and(|file| is_test_file(codebase.relative_path(&file.path))) }}
/// Removes unit tests (`#[cfg(test)] mod tests { .. }`, `#[test] fn ..`) from Rust files.////// This runs on whole files, before chunking, so a chunk never mixes implementation and tests.pub fn without_unit_tests(mut file: TextNode) -> Result<TextNode> { if file .path .extension() .is_some_and(|extension| extension == "rs") { file.chunk = strip_test_items(&file.chunk)?; } Ok(file)}Source: examples/ask-your-codebase/src/test_code.rs#L16-L41
Pipeline::filter takes a closure over &Result<TextNode> and keeps the result when the closure returns true. The predicate keeps errors on purpose, so a failed load still surfaces. The second function is an ordinary transformer: any Fn(TextNode) -> Result<TextNode> works, and this one rewrites chunk for .rs files.
Node cache and storage
Section titled “Node cache and storage”/// Remembers which files were fully indexed, so unchanged files are skipped next time.pub fn node_cache(&self) -> Result<Redb> { std::fs::create_dir_all(&self.data_dir)?;
Redb::builder() .database_path(self.data_dir.join("cache.redb")) .table_name("indexed_files") .build()}
/// Embedded vector store with one vector per chunk and the source path next to it.pub fn vector_store(&self) -> Result<LanceDB> { let uri = self.lancedb_dir();
LanceDB::builder() .uri(uri.to_str().context("Index path is not valid UTF-8")?) .table_name("code_chunks") .vector_size(embedding::DIMENSIONS) .with_vector(EmbeddedField::Combined) .with_metadata(PATH_FIELD) .build()}Source: examples/ask-your-codebase/src/codebase.rs#L56-L77
Redb is the node cache, stored in one table of a local database file. LanceDB is the storage: one vector column over EmbeddedField::Combined, sized at 768 for the embedding model, plus a path metadata column. Since 0.33, the pipeline marks files in the node cache only after the whole run finishes without an error. If any file fails, or you interrupt the run, nothing from that run is cached. The next run prompts and embeds every file again, not only the one that failed.
Chunking per language
Section titled “Chunking per language”/// Splits each file with a tree-sitter `ChunkCode` for the file's language.////// `ChunkCode` is built for a single language; this chunker picks the right one per file from/// its extension, so one pipeline can index a polyglot codebase.#[derive(Clone)]pub struct ChunkByLanguage { chunkers: Vec<(SupportedLanguages, ChunkCode)>,}
impl ChunkByLanguage { /// Chunks are between `chunk_size.start` and `chunk_size.end` bytes; smaller ones are dropped. pub fn try_new(languages: &SourceLanguages, chunk_size: Range<usize>) -> Result<Self> { let chunkers = languages .0 .iter() .map(|&language| { let chunker = ChunkCode::try_for_language_and_chunk_size(language, chunk_size.clone())?; Ok((language, chunker)) }) .collect::<Result<_>>()?;
Ok(Self { chunkers }) }
fn chunker_for(&self, path: &Path) -> Option<&ChunkCode> { let extension = path.extension()?.to_str()?;
self.chunkers .iter() .find(|(language, _)| language.file_extensions().contains(&extension)) .map(|(_, chunker)| chunker) }}
#[async_trait]impl ChunkerTransformer for ChunkByLanguage { type Input = String; type Output = String;
async fn transform_node(&self, node: TextNode) -> IndexingStream<String> { match self.chunker_for(&node.path) { Some(chunker) => chunker.transform_node(node).await, None => anyhow!("No chunker for {}", node.path.display()).into(), } }}Source: examples/ask-your-codebase/src/languages.rs#L39-L85
ChunkCode is built for one language. This chunker implements ChunkerTransformer, holds one ChunkCode per language and picks one by file extension through SupportedLanguages::file_extensions. The chunker aims for 10 to 4096 bytes, which keeps most functions in one piece. Chunks of 10 bytes or less are dropped. When tree-sitter finds a syntax error, the whole file becomes one chunk, whatever its size.
Question metadata
Section titled “Question metadata”/// Asks for the questions a developer would ask that a chunk answers.////// The default `MetadataQACode` prompt asks generic questions ("What does this code do?")./// Questions about behaviour, phrased the way a developer would ask them, match real questions/// much better.const QUESTIONS_PROMPT: &str = r"# Task
List the questions a developer could ask about this codebase that the code below answers, eachwith a short answer. They are stored with the code to help a search find it.
Focus on behaviour: when and in which order things happen, what starts or ends a process, whichconditions and configuration change the outcome, what happens on errors, and which extensionpoints (traits, hooks, builders, options) exist and when they apply.
# Constraints
- Write {{ questions }} questions and answers.- Phrase questions the way a developer would ask them, e.g. 'When does the agent stop?'.- Name the relevant types, functions and variants in the answers.- Only use what can be derived from the code.- Respond only in this format:
Q1: <question>A1: <answer>
# Code
```{{ node.chunk }}```";Source: examples/ask-your-codebase/src/index.rs#L39-L70
The default MetadataQACode prompt asks generic questions. This template asks for questions about behavior, phrased the way a developer asks them. It is a Tera template: MetadataQACode fills in questions with the count and node with the chunk. The answer is stored under the metadata key Questions and Answers (code). It is embedded with the chunk, but LanceDB has no column for it.
/// Stores the file path, relative to the codebase root, as metadata on the node.////// The vector store keeps this field next to each chunk, so answers can cite their sources.fn record_relative_path( codebase: Codebase,) -> impl Fn(TextNode) -> Result<TextNode> + Clone + Send + Sync + 'static { move |mut node| { let path = codebase .relative_path(&node.path) .to_string_lossy() .into_owned(); node.metadata.insert(PATH_FIELD, path); Ok(node) }}Source: examples/ask-your-codebase/src/index.rs#L127-L141
A closure that returns Result<TextNode> is a transformer too. This one writes the relative path into the metadata, where the path column picks it up.
Local embeddings
Section titled “Local embeddings”/// A code-aware embedding model that runs on the CPU and needs no API key.////// The model (~600MB) is downloaded on first use.pub fn local_model() -> Result<FastEmbed> { let model = TextEmbedding::try_new( InitOptions::new(EmbeddingModel::JinaEmbeddingsV2BaseCode).with_max_length(MAX_TOKENS), )?;
FastEmbed::builder() .embedding_model(model) .batch_size(BATCH_SIZE) .build()}Source: examples/ask-your-codebase/src/embedding.rs#L18-L30
JinaEmbeddingsV2BaseCode is a code embedding model with 768 dimensions. It runs on the CPU and needs no key. Both pipelines call local_model, because query vectors and chunk vectors must come from the same model. After you change the model, delete the index and build it again.
Query pipeline
Section titled “Query pipeline”let search = SimilaritySingleEmbedding::default().with_top_k(top_k);
let pipeline = query::Pipeline::from_search_strategy(search) // Rephrase the question into several, to widen what the search can match .then_transform_query( GenerateSubquestions::builder() .client(llm.clone()) .prompt_template(SUBQUESTIONS_PROMPT.into()) .build()?, ) // Embed with the same local model that embedded the chunks .then_transform_query(Embed::from_client(embedding::local_model()?)) .then_retrieve(codebase.vector_store()?) // Answer the original question from the retrieved code, each chunk under its file path .then_answer( answers::Simple::builder() .client(llm) .prompt_template(ANSWER_PROMPT.into()) .document_template(CHUNK_TEMPLATE) .build()?, );
let answered = pipeline.query(question).await?;Source: examples/ask-your-codebase/src/query.rs#L36-L58
SimilaritySingleEmbedding returns 10 chunks by default, and with_top_k sets the --top-k value instead. with_top_k takes the strategy by value and returns it, so it chains onto default(). default() is only implemented for the unfiltered strategy, so the filter type is inferred as (), meaning no filter. GenerateSubquestions rewrites the question into several related ones. The Embed query transformer embeds the rewritten text, the question and its subquestions together, as one vector with the local model. query consumes the pipeline and returns the answered query.
Prompts and document template
Section titled “Prompts and document template”/// Widens the question into the follow-up questions its answer involves.////// They are phrased like the questions generated for each chunk while indexing, so the two/// embed close together.const SUBQUESTIONS_PROMPT: &str = r"Your job is to help a search find the code that answers a developer's question about a codebase.
Question: {{ question }}
Write {{ num_questions }} short follow-up questions that the code answering this question would alsoanswer. Think about the mechanisms involved: the main loop, the types and enum variants, theconditions that are checked, the functions that are called, and the configuration that changesthe outcome.
## Constraints* Phrase them the way a developer would ask about this codebase, e.g. 'When does the agent stop?'.* Do not guess names of types or functions.* Respond with the original question and the follow-up questions only, one per line, prefixed with '- '.";
/// Renders a retrieved chunk under its file path, so the answer can cite it.////// `metadata.path` is the `PATH_FIELD` metadata; a test keeps the two in step.const CHUNK_TEMPLATE: &str = r"### {{ metadata.path }}
```{{ content }}```";
/// Answers from the retrieved code and cites files.const ANSWER_PROMPT: &str = r"Answer a developer's question about a codebase, using snippets of its source code.
Question: {{ question }}
## Constraints* Only use the snippets below. If they do not answer the question, say briefly what is missing.* Be concrete: name the types, functions and variants involved and explain when they apply.* Describe behaviour: what calls what, in which order, and under which conditions.* Cite the file each statement comes from in backticks, e.g. (`src/lib.rs`).* Use concise markdown. Add a short code example only if it shows how to use the API.
## Snippets
{{ documents }}";Source: examples/ask-your-codebase/src/query.rs#L65-L112
The subquestion template receives question and num_questions, which defaults to 5. The answer template receives question and documents. With document_template, each retrieved document is rendered on its own, with content and metadata.path available, and the results are joined with --- lines. That is how the answer sees which file each chunk came from.
Sources
Section titled “Sources”impl From<Query<states::Answered>> for Answer { /// Keeps the distinct file paths of the retrieved chunks, most relevant first. fn from(query: Query<states::Answered>) -> Self { let mut sources: Vec<String> = Vec::new();
for document in query.documents() { let Some(path) = document.metadata().get(PATH_FIELD).and_then(|v| v.as_str()) else { continue; }; if !sources.iter().any(|source| source == path) { sources.push(path.to_owned()); } }
Self { text: query.answer().to_owned(), sources, } }}Source: examples/ask-your-codebase/src/query.rs#L123-L142
documents returns the retrieved documents in relevance order. The path column comes back as a path metadata entry. The tool keeps each path once and prints the list under the answer.
Model client
Section titled “Model client”impl LlmEndpoint { /// Builds a client for this endpoint. /// /// Rate limits and transient errors are retried with exponential backoff, which matters when /// indexing fires many prompts concurrently. After `retry_budget` the error is returned, so /// an unreachable or broken endpoint fails instead of hanging. pub fn client(&self, retry_budget: Duration) -> Result<Arc<dyn SimplePrompt>> { let openai = OpenAI::builder() .api_base(&self.base_url) .api_key(&self.api_key) .default_prompt_model(&self.model) .use_responses_api(matches!(self.api, OpenAiApi::Responses)) .build()?;
let backoff = BackoffConfiguration { max_elapsed_time_sec: retry_budget.as_secs(), ..BackoffConfiguration::default() };
Ok(Arc::new(LanguageModelWithBackOff::new(openai, backoff))) }}Source: examples/ask-your-codebase/src/llm.rs#L79-L100
The client is always swiftide’s OpenAI integration. api_base points it at OPENAI_BASE_URL and api_key sets the key, without building an async-openai client yourself. use_responses_api switches from Chat Completions to the Responses API when you pass --responses-api. LanguageModelWithBackOff retries rate limits and transient errors with exponential backoff. index keeps retrying a failing prompt for up to 60 seconds, because it sends many prompts at once and expects rate limits. ask stops retrying after 30 seconds.
Commands
Section titled “Commands”The crate lives in examples/ask-your-codebase of the swiftide repository. Clone the repository and run from its root:
git clone https://github.com/bosun-ai/swiftide && cd swiftideexport OPENAI_API_KEY=sk-...cargo run -p ask-your-codebase -- index swiftide-agentscargo run -p ask-your-codebase -- ask --path swiftide-agents "How does an agent decide to stop?"index and ask are separate runs, so each can use its own model. Through a gateway that serves Anthropic models, a small model writes the indexing prompts and a stronger one writes the answers:
export OPENAI_BASE_URL=http://127.0.0.1:8080/v1 # an OpenAI-compatible gateway that serves these modelscargo run -p ask-your-codebase -- index swiftide-agents --model claude-haiku-5-5cargo run -p ask-your-codebase -- ask --path swiftide-agents --model claude-sonnet-5-5 "How does an agent decide to stop?"| Flag | Applies to | Default | Description |
|---|---|---|---|
--model <name> | both | gpt-5.4-mini | Model for chunk metadata or answers. Also read from OPENAI_MODEL. |
--responses-api | both | off | Send prompts to the Responses API. |
--lang <languages> | index | rust | Comma-separated tree-sitter languages, such as rust,python. |
--concurrency <n> | index | 16 | Prompts sent at once while indexing. |
--path <dir> | ask | . | Root of the codebase, as given to index. |
--top-k <n> | ask | 20 | Number of chunks to retrieve. |
By default the tool logs only errors and turns off all logging from swiftide_integrations, which hides errors from LanceDB, FastEmbed, Redb, tree-sitter and OpenAI. Each retried attempt is logged at WARN, so it stays hidden too. Set RUST_LOG=info or RUST_LOG=debug to see what the pipelines do.
Output
Section titled “Output”The captured run indexed the swiftide-agents crate with a debug build. It went through an OpenAI-compatible gateway that forwards to Anthropic models on AWS Bedrock. claude-haiku-5-5 wrote the per-chunk questions, claude-sonnet-5-5 wrote the answers, and FastEmbed ran jina-embeddings-v2-base-code on the CPU.
The first run indexes everything. The second finds nothing changed, so the node cache skips every file and no prompt is sent:
$ ./target/debug/ask-your-codebase index swiftide-agents --model claude-haiku-5-5Indexing Rust files in swiftide-agents ...Indexed 34 chunks from 14 new or changed files (14 files found, 0 unchanged) in 91.9s
$ ./target/debug/ask-your-codebase index swiftide-agents --model claude-haiku-5-5Indexing Rust files in swiftide-agents ...Indexed 0 chunks from 0 new or changed files (14 files found, 14 unchanged) in 0.6sThe start of one answer, and its sources:
$ ./target/debug/ask-your-codebase ask --model claude-sonnet-5-5 --path swiftide-agents "How does an agent decide to stop?"# How an agent decides to stop
An agent stops when something calls `Agent::stop` (or `stop_with_error`), which moves the state to `State::Stopped(StopReason)`. The main loop in `run_agent` checks that state after each step. `Agent::stop` is a no-op if the agent is already stopped (`src/agent.rs`).
[...]
## Triggers, by `StopReason` variant (`src/state.rs`)
| Variant | When it applies ||---|---|| `RequestedByTool(ToolCall, Option<Value>)` | A tool returned `ToolOutput::Stop`. || `FeedbackRequired { tool_call, payload }` | A tool returned `ToolOutput::FeedbackRequired`. || `AgentFailed(Option<Value>)` | A tool returned `ToolOutput::AgentFailed`. || `ToolCallsOverLimit(ToolCall)` | The same tool call failed too many times. || `Error` | `step` returned an `Err`. || `NoNewMessages` | The completion loop ended without another reason. || `Other(String)` | Anything convertible from `AsRef<str>`, e.g. `agent.stop("done")`. |
[...]
Sources: - src/agent.rs - src/tools/control.rs - src/lib.rs - src/state.rs - src/errors.rs - src/task_agent.rs - src/hooks.rs - src/tasks.rs - src/default_context.rsThe answers in OUTPUT.md were checked against the source of swiftide-agents. Where a needed chunk was not among the 20 retrieved, the answer marked its claim as an inference.
Limitations
Section titled “Limitations”- When a file changes, its new chunks are added, but the chunks of the old version stay in LanceDB. Delete
.ask-your-codebase/to start over. - Files under a
testsdirectory, or namedtests,test_utilsortest_helpers, are dropped in every language. Unit tests inside a file are only removed from.rsfiles. - Retrieval is a single vector search. Raise
--top-kfor broad questions. - A file that produces no chunks is never marked in the node cache, so every run loads it again. It costs no LLM calls.
Concept pages
Section titled “Concept pages”- Loading and caching: Loading, Caching
- Chunking: Code-aware indexing, Chunking, Tree-sitter
- Metadata and embeddings: Transformers and metadata, Embedding, FastEmbed
- Storage: Storage, LanceDB
- Querying: Transforming queries, Retrieval and search strategies, Answering
- Models: OpenAI-compatible endpoints, Errors, backoff and usage
Reference
Section titled “Reference”indexing::PipelineFileLoaderChunkCodeChunkerTransformerSupportedLanguagesMetadataQACodeEmbedEmbeddedFieldRedbLanceDBFastEmbedquery::PipelineSimilaritySingleEmbeddingGenerateSubquestionsquery_transformers::Embedanswers::Simplequery::QueryOpenAILanguageModelWithBackOffBackoffConfiguration