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.

CI crates.io docs.rs downloads License: MIT

semtree indexing a crate and answering natural-language code searches


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.