semtree
Semantic code search for any codebase: tree-sitter parsing, local embeddings, and hybrid vector + BM25 retrieval. Multi-language, multi-backend.
semtree
On-device semantic code intelligence - a composable Rust library, a CLI, and an MCP server.
Parse, embed, and search any codebase by meaning. No daemon, no API key.
Code search tools force a tradeoff. Grep finds exact strings. Language servers require a running daemon and IDE integration. Cloud AI search sends your code to a third party. None of those work well inside a program you're building.
semtree uses tree-sitter to parse your codebase into structured chunks (functions, structs, methods), embeds them locally via fastembed, and stores them in an HNSW vector index, all on-device, no API key required, no daemon. Search is hybrid by default: it fuses vector similarity with BM25 keyword matching, so it catches concepts a grep misses and keeps the exact-identifier precision a pure vector search loses.
Most tools in this space ship as one monolithic binary. semtree is instead a set of small crates with clean traits (Embedder, VectorStore), so you can drop the pipeline into your own tool or LLM context provider. The semtree CLI and the semtree-mcp server are both thin wrappers over that library. See examples/build_your_own.rs.
Working with an AI agent? semtree-mcp gives Claude Code, Cursor, Windsurf or Zed a semantic search tool over your codebase in one line of config.
$ semtree index ./my-project
⠿ [========================================] 87/87 files (3s)
Done (incremental). Indexed 312 chunks → .semtree/
$ semtree search "how is authentication handled"
1. [Function] validate_token (score: 0.921)
src/auth/jwt.rs:14
pub fn validate_token(token: &str, secret: &[u8]) -> Result<Claims> {
2. [Function] middleware (score: 0.887)
src/auth/middleware.rs:28
pub async fn middleware(req: Request, next: Next) -> Response {
$ semtree stats
=== Index: .semtree ===
Chunks : 312
Files : 87
Size : 1.8 MB
By language:
rust: 200 (64%)
typescript: 80 (26%)
go: 32 (10%)
No daemon. No Python. Embeddings run on CPU via ONNX, cached after first use. Supports OpenAI and Ollama as drop-in embedding backends when you need higher quality.
Install
CLI:
cargo install semtree-cli
MCP server (for AI agents):
cargo install semtree-mcp
Library (batteries included):
[dependencies]
semtree = "0.5" # umbrella: default stack + prelude, one dependency
Library (pick your own pieces):
[dependencies]
semtree-rag = "0.5" # pipeline: index, hybrid search, LLM context
semtree-embed = "0.5" # Embedder trait + fastembed / OpenAI / Ollama
semtree-store = "0.5" # VectorStore trait + usearch / Qdrant
CLI
semtree init # create .semtree.toml
semtree index ./my-project # index (incremental by default)
semtree index ./my-project --full # force full re-scan
semtree search "error handling strategy" -t 5 # hybrid search (default)
semtree context "authentication flow" # RAG context block for LLMs
semtree stats # chunks, languages, index size
semtree analyze # complexity metrics, largest functions
Search modes and filters:
semtree search "retry logic" --mode semantic # vector similarity only
semtree search "retry logic" --mode lexical # BM25 keyword only
semtree search "retry logic" --mode hybrid # fused (default)
semtree search "parse" --lang rust --kind fn # filter by language / chunk kind
semtree search "config" --path src/settings # filter by path substring
All commands accept --config <path> to point to a custom .semtree.toml.
Incremental indexing
Re-running semtree index only processes files whose content has changed. A manifest (manifest.json) is stored alongside the index to track per-file hashes. Pass --full to force a complete re-scan.
Configuration
semtree init creates a .semtree.toml in the current directory:
[embed]
backend = "fastembed" # fastembed | openai | ollama
# model = "text-embedding-3-small"
# url = "http://localhost:11434" # ollama only
# api_key = "sk-..." # or set OPENAI_API_KEY
[store]
backend = "usearch" # usearch | qdrant
# url = "http://localhost:6333"
# collection = "semtree"
index_dir = ".semtree"
Embedding backends
| Backend | Default model | Notes |
|---|---|---|
fastembed (default) |
AllMiniLML6V2 (384-dim) |
On-device, no key needed |
openai |
text-embedding-3-small |
Set OPENAI_API_KEY or embed.api_key |
ollama |
nomic-embed-text |
Requires local Ollama server |
Vector store backends
| Backend | Notes |
|---|---|
usearch (default) |
In-process HNSW, saved to disk |
qdrant |
Remote Qdrant server - set QDRANT_URL or store.url |
Library
For the common case, the semtree umbrella crate wires the default stack for you:
semtree::default_backends() returns a fastembed embedder and a usearch store
sized to it, and semtree::prelude::* brings in the indexing and search types.
To assemble the pipeline from its building blocks instead - backends are traits,
so FastEmbedder/UsearchStore swap for OpenAI/Ollama/Qdrant without touching
the rest. Full runnable version: examples/build_your_own.rs.
use std::sync::Arc;
use semtree_embed::fastembed::FastEmbedder;
use semtree_store::usearch::UsearchStore;
use semtree_rag::{ChunkRegistry, HybridSearcher, Indexer, LexicalIndex, SearchEngine, SearchMode};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let embedder = Arc::new(FastEmbedder::new()?);
let store = Arc::new(UsearchStore::new(384)?);
// Index: parse -> chunk -> embed -> store.
let mut registry = ChunkRegistry::default();
Indexer::new(embedder.clone(), store.clone())
.index_dir("./src".as_ref(), &mut registry, None, |done, total| {
eprint!("\r{done}/{total}");
})
.await?;
// Hybrid search: vector similarity fused with BM25 keyword matching.
let engine = SearchEngine::new(embedder, store);
let lexical = LexicalIndex::from_chunks(registry.iter());
let searcher = HybridSearcher::new(engine, lexical);
for hit in searcher.search("error handling", 5, SearchMode::Hybrid).await? {
if let Some(chunk) = registry.get(&hit.id) {
println!("{} - {}:{} (score: {:.3})",
chunk.name.as_deref().unwrap_or("?"),
chunk.path.display(),
chunk.span.start_line + 1,
hit.score);
}
}
Ok(())
}
Run it against this repo:
cargo run --example build_your_own -- ./src "how are errors handled"
Use it from an AI agent
semtree-mcp serves a codebase to any Model Context Protocol client - Claude Code, Cursor, Windsurf, Zed - so the agent can find code by meaning instead of guessing filenames. Nothing leaves the machine.
cargo install semtree-mcp
Register it with your agent (e.g. Claude Code's mcp.json):
{
"mcpServers": {
"semtree": {
"command": "semtree-mcp",
"args": ["/abs/path/to/my-project"]
}
}
}
That is the whole setup: the server indexes the project on first run and catches up on changed files every time it starts, so there is no separate semtree index step to remember.
| Tool | What the agent asks it |
|---|---|
search_code |
"where is the code that does X?" - returns file:line locations, no source |
get_context |
"show me that code" - returns the source, prompt-shaped |
index_status |
"what is actually indexed here?" |
reindex |
"I just edited files, catch up" |
The split between the first two is deliberate: locating code costs a few tokens per result, and the agent only pays for source text once it knows what it wants to read.
semtree-mcp ./my-project # index dir from .semtree.toml, or ./my-project/.semtree
semtree-mcp ./my-project --no-refresh # serve the index exactly as it is on disk
semtree-mcp ./my-project --full # rebuild from scratch at startup
Building your own MCP server on top of the library instead is still a short file - see semtree-mcp for the whole thing.
Architecture
Each crate is independently published to crates.io - use only what you need.
semtree-core # shared types: Language, Span, Chunk, ChunkKind
semtree-parse # tree-sitter parsing + chunk extraction (query-driven)
semtree-embed # Embedder trait + fastembed / OpenAI / Ollama backends
semtree-store # VectorStore trait + usearch / Qdrant backends
semtree-rag # index, search, LLM context, incremental manifest
semtree-analyze # complexity metrics, large-function detection
semtree # umbrella: re-exports the default stack (batteries included)
semtree-cli # CLI binary (semtree)
semtree-mcp # MCP server binary (semtree-mcp)
Supported languages
Twenty languages extract structured chunks. Each is a tree-sitter query in semtree-parse/src/lang/queries; adding one is a grammar dependency plus a .scm file, with no per-language Rust.
| Language | Extracted chunks |
|---|---|
| Rust | functions, structs, enums, traits, impls, modules |
| Python | functions, classes |
| JavaScript | functions, classes, methods, arrow-function bindings |
| TypeScript / TSX | functions, classes, interfaces, enums, type aliases, methods |
| Go | functions, methods, structs, interfaces |
| Java | classes, interfaces, enums, records, methods, constructors |
| C | functions, structs, unions, enums |
| C++ | functions, classes, structs, unions, enums, namespaces |
| C# | classes, interfaces, structs, records, enums, methods, namespaces |
| Ruby | classes, modules, methods |
| PHP | classes, interfaces, traits, enums, functions, methods |
| Kotlin | classes, functions, objects |
| Scala | classes, objects, traits, functions, types |
| Swift | classes, protocols, functions, type aliases |
| OCaml | values, types, modules, classes |
| Solidity | contracts, interfaces, libraries, functions, modifiers, structs, enums |
| Lua | functions |
| Zig | functions |
| Emacs Lisp | functions, macros |
Plain text files (.md, .json, .toml, .yaml, ...) are chunked into overlapping 40-line windows.
Custom backends
Custom embedder:
use async_trait::async_trait;
use semtree_embed::{Embedder, Embedding, EmbedError};
struct MyEmbedder;
#[async_trait]
impl Embedder for MyEmbedder {
async fn embed(&self, texts: &[&str]) -> Result<Vec<Embedding>, EmbedError> {
todo!() // call your API or local model
}
fn dimension(&self) -> usize { 384 }
fn model_id(&self) -> &str { "my-embedder" }
}
Custom vector store:
use async_trait::async_trait;
use semtree_store::{VectorStore, Hit, Metric, StoreError};
use semtree_embed::Embedding;
struct MyStore;
#[async_trait]
impl VectorStore for MyStore {
async fn insert(&self, id: &str, emb: &Embedding) -> Result<(), StoreError> { todo!() }
async fn search(&self, query: &Embedding, top_k: usize) -> Result<Vec<Hit>, StoreError> { todo!() }
async fn delete(&self, id: &str) -> Result<(), StoreError> { todo!() }
fn save(&self, _path: &std::path::Path) -> Result<(), StoreError> { Ok(()) }
fn load(&mut self, _path: &std::path::Path) -> Result<(), StoreError> { Ok(()) }
fn len(&self) -> usize { 0 }
fn metric(&self) -> Metric { Metric::Cosine }
}
License
MIT - see LICENSE.
Part of rustkit-ai - open source Rust tools for the AI development era.