I built Veles because I kept hitting the same wall on every large codebase: grep is fast but literal, semantic search needs a GPU and an API key, and most "AI code search" tools assume you are willing to ship your source somewhere. None of that fits how I actually work locally, on CPU, with the index living next to the code.
Veles is a hybrid (BM25 + semantic) local code search engine, written in pure Rust. It runs entirely on CPU, persists its index on disk, exposes itself over CLI, MCP and gRPC, and returns results in tens of milliseconds.
What it is
Veles is a small toolbox built around one idea: good code search is hybrid. Pure lexical search misses paraphrases. Pure semantic search misses identifiers. Reciprocal Rank Fusion blends the two and consistently outperforms either alone for code.
On top of that core it adds the things you actually want from a daily-driver:
- A persistent on-disk index under
<repo>/.veles/, with incremental updates. - Tree-sitter
symbols/defs/refsfor Rust, Python, JavaScript, TypeScript, and Go. - An identifier-aware tokenizer that splits
camelCase,snake_case, and mixed scripts. - Query-type detection that biases symbol-like queries toward BM25 and natural-language queries toward semantic.
- Six pipe-friendly output formats (
pretty,compact,ripgrep,paths,json,jsonl). - An MCP server so Claude, Cursor, and other agents can use it as a first-class search tool.
Static embeddings come from the potion family via model2vec-rs. No transformer forward pass at query time — that is what makes the latency predictable.
Install
Pick whichever path fits your environment:
# macOS / Linux — prebuilt binary
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/julymetodiev/Veles/releases/latest/download/veles-cli-installer.sh | sh
# Homebrew
brew install julymetodiev/tap/veles-cli
# From crates.io
cargo install veles-cli
# Windows (PowerShell)
irm https://github.com/julymetodiev/Veles/releases/latest/download/veles-cli-installer.ps1 | iex
Verify the install:
$ veles --version
veles 0.2.2
The three commands
Build the index, search it, refresh it. Here is what each one looks like against the Veles repo itself (32 source files):
$ veles index .
Indexed 32 files / 190 chunks in 0.17s — saved to ./.veles
$ veles search "parse config file" -f compact
crates/veles-cli/src/handlers.rs:46-95 [score=0.015] let path_slice: Option<&[String]> = glob_paths.as_deref();
crates/veles-core/src/walker.rs:46-95 [score=0.010] ".ts",
crates/veles-core/src/ranking/penalties.rs:91-140 [score=0.010] let pool = top_k_indexed(&penalised, pool_size);
crates/veles-core/src/persist.rs:136-185 [score=0.009] dir.join(MANIFEST_FILE).is_file()
crates/veles-core/src/symbols.rs:136-185 [score=0.009] let name = match name_node.utf8_text(source_bytes) {
$ veles update .
Index is up to date (190 chunks, no changes).
The first search downloads the embedding model (~64 MB) into ~/.cache/huggingface/hub/. After that everything is local. update reuses embeddings of files whose (size, mtime) fingerprint has not changed, so refreshing after a small edit is near-instant even on large repos.
veles status is the diagnostic — model, dimension, chunk counts, and an on-disk diff against the manifest:
$ veles status
Index at ./.veles
veles version : 0.2.2
format version : 2
model : minishlab/potion-code-16M
embedding dim : 256
text files : false
indexed at : 1778349001 (unix)
files in manifest: 32
total chunks : 190
On-disk diff:
files seen now : 32
added : 0
modified : 0
removed : 0
Searching
The default mode is hybrid, but you can pin the mode when you know what you want. The clearest way to see the difference is to run a symbol query through BM25 and a concept query through semantic, side by side:
$ veles search "BM25 inverted index" -m bm25 -f compact -t 3
crates/veles-core/examples/bm25_compare.rs:181-230 [score=12.978] let queries: Vec<Vec<String>> = [
crates/veles-core/src/index/sparse.rs:1-50 [score=12.139] //! BM25 sparse index — inverted-index implementation with token interning.
crates/veles-core/examples/bench.rs:1-50 [score=11.649] //! Quick wall-clock benchmark for indexing + querying.
$ veles search "how the index gets persisted to disk" -m semantic -f compact -t 3
crates/veles-core/src/persist.rs:1-50 [score=0.887] //! Persistent on-disk index format.
crates/veles-core/src/persist.rs:46-95 [score=0.886] /// content hashing can be layered on later if needed.
crates/veles-core/src/lib.rs:46-95 [score=0.887] //! # }
BM25 lands on the literal BM25 sparse index doc-comment first; semantic lands on persist.rs even though the query never says "persist". That gap is what hybrid is closing — the default mode runs both and fuses the rankings via RRF.
Filters compose cleanly with globs and language hints:
veles search "auth" -l rust,python # language filter
veles search "X" -g 'src/**/*.rs' -x 'src/legacy/**' # include / exclude
veles search "BM25" --min-score 0.4 # drop weak hits
The output formats are designed to flow into Unix pipelines. rg mode is drop-in compatible with anything that already consumes ripgrep:
$ veles search "rate limiting" -f rg | head -5
crates/veles-core/src/ranking/boosting.rs:451: "PREVIEW_FILE_CACHE",
crates/veles-core/src/ranking/boosting.rs:452: "_private_thing",
crates/veles-core/src/ranking/boosting.rs:453: "foo::bar",
crates/veles-core/src/ranking/boosting.rs:454: "module::Type",
crates/veles-core/src/ranking/boosting.rs:455: "obj->method",
paths mode strips everything except file paths — exactly what xargs wants:
$ veles search "rate limiting" -f paths
crates/veles-core/src/ranking/boosting.rs
crates/veles-core/src/walker.rs
crates/veles-cli/src/tui/app.rs
crates/veles-mcp/src/lib.rs
crates/veles-core/src/veles_index.rs
json is structured for tooling and agents:
$ veles search "rate limiting" -f json -t 1 | jq '.results[0] | {file_path, start_line, end_line, score, language}'
{
"file_path": "crates/veles-core/src/walker.rs",
"start_line": 136,
"end_line": 185,
"score": 0.00970386039132734,
"language": "rust"
}
That paths mode is the small trick that turns search into navigation:
veles search "rate limiting" -f paths | xargs $EDITOR
Symbols
grep -w SymbolName is a poor approximation of "find this definition". Veles uses tree-sitter to do it properly. symbols is the file outline:
$ veles symbols crates/veles-core/src/persist.rs
Symbols in crates/veles-core/src/persist.rs
const INDEX_DIR_NAME crates/veles-core/src/persist.rs:31
const FORMAT_VERSION crates/veles-core/src/persist.rs:35
const MANIFEST_FILE crates/veles-core/src/persist.rs:37
struct FileFingerprint crates/veles-core/src/persist.rs:48
struct Manifest crates/veles-core/src/persist.rs:80
struct PersistedIndex crates/veles-core/src/persist.rs:144
function save crates/veles-core/src/persist.rs:153
function load crates/veles-core/src/persist.rs:173
function load_manifest crates/veles-core/src/persist.rs:207
function clean crates/veles-core/src/persist.rs:213
...
defs resolves a name across the whole repo, optionally narrowed by kind and language:
$ veles defs Manifest
Definitions of "Manifest"
struct Manifest crates/veles-core/src/persist.rs:80
$ veles defs save -k function -l rust
Definitions of "save"
function save crates/veles-core/src/persist.rs:153
function save crates/veles-core/src/veles_index.rs:139
refs does both — it lists the definitions, then the BM25 hits across chunks so you see every callsite, doc-comment, and test that mentions the symbol:
$ veles refs save -t 5 -f compact
crates/veles-core/src/persist.rs:153 function save
crates/veles-core/src/veles_index.rs:139 function save
crates/veles-core/src/veles_index.rs:136-185 [score=3.662] /// Persist the index to `<repo_root>/.veles/`.
crates/veles-cli/src/cli.rs:1-50 [score=3.554] //! Command-line interface definition (clap derives).
crates/veles-core/examples/bm25_compare.rs:181-230 [score=2.838] let queries: Vec<Vec<String>> = [
This is the workflow I use the most when navigating an unfamiliar codebase: defs to land on the canonical definition, then refs to fan out.
The TUI
veles tui

Loads the persistent index once, then debounces queries so each keystroke re-runs in tens of milliseconds. ↑↓ navigate, Tab cycles hybrid / bm25 / semantic, Ctrl-R finds related code, Enter prints path:line to stdout, Ctrl-O opens the result in $EDITOR. ? shows the full keybinding overlay.
The handy bit: because Enter prints path:line, you can use the TUI as a picker:
$EDITOR $(veles tui)
MCP
This is what pushed me to build it in the first place. AI agents are good at writing code, but only if they can find the right code to read. Vague greps and unbounded reads burn the context window fast.
Veles ships an MCP server over stdio. Wire it into Claude Code or any other MCP-aware client and the agent gets two tools: search and find_related. From the client's perspective:
veles serve-mcp # explicit
veles # equivalent — bare `veles` starts MCP when stdin is piped
The agent then asks targeted questions ("where do we parse config?", "what is similar to this function?") and gets back a small, ranked, file-bounded set of chunks instead of a recursive grep dump. In practice this is the difference between an agent that wanders and one that lands on the right file on the first try.
There is also a gRPC service (veles serve-grpc) for non-MCP integrations.
Embedding
If you want the engine without the binary, pull veles-core:
[dependencies]
veles-core = "0.2"
use std::path::Path;
use veles_core::{SearchMode, VelesIndex};
let index = VelesIndex::from_path(Path::new("."), None, None, false)?;
let results = index.search("parse config", 5, SearchMode::Hybrid, None, None, None);
for r in results {
println!("{} [{:.3}]", r.chunk.location(), r.score);
}
The workspace also publishes veles-grpc, veles-mcp, and veles-cli separately, so you can compose only the layers you need.
Index layout
.veles/
manifest.json # model, dim, per-file (size, mtime, chunk_count)
chunks.bin # bincode Vec<Chunk>
bm25.bin # bincode BM25 inverted index
dense.bin # bincode dense matrix
symbols.bin # bincode tree-sitter symbols
Everything is bincode-serialized and lives next to the code. Nothing leaves the machine, nothing depends on a network round-trip at query time, and the on-disk format is small enough to commit-and-forget if you want builds to skip the index step.
CPU-only
A lot of the design decisions in Veles fall out of one constraint: no GPU, no API call, no transformer forward pass at query time. That is what makes static embeddings (model2vec / potion) the right choice — they are precomputed once at indexing time, and queries become a vector lookup plus a BM25 scan plus a fusion step. The result is latency that does not depend on model load, network, or GPU availability.
It also means Veles works the same way on your laptop, in CI, in a dev container, and over SSH on a constrained box. That portability is the thing I value most when I am switching between machines all day.
Future
Veles started as a Rust port of the Semble hybrid retrieval recipe and grew into something I use every day. The roadmap is mostly driven by what I keep wanting at the keyboard: more languages in the symbol layer, smarter query-type detection, and tighter integration with the agents I run locally.
If you try it and something feels off, open an issue — I read all of them.
veles search "what should I read first"