ripgrep was still the standard I did not want to lose: almost no ceremony between the question and the evidence. You type the string, it walks the tree, and a second later you are looking at the files that matter. For code work, especially agentic code work, that lack of ceremony is a product feature.

Exact search only works after you know the local word for the thing. A human can usually recover from a bad first query by trying adjacent nouns: session, auth, identity, principal, account, tenant, user. An agent can do that too, but it burns context and tool calls while it learns the codebase’s vocabulary. Those first searches decide whether the agent opens the right files or spends the next ten minutes learning synonyms.

code-search is a small Rust attempt at that middle ground: keep the fast exact-search path, add regex for shape, and add a ranked BM25 path for the moment before the exact name is known. Calling that “semantic” is slightly provocative, but the ambition is practical rather than mystical. No embedding index. No model floating meaning over the repository. Old information-retrieval machinery answers a more ordinary question: which files deserve the first read?

That was the constraint: make the first move less brittle without turning search into an event.

Keep the fast path

Exact search deliberately stays close to the shape of ripgrep. It walks the tree with the ignore crate, so .gitignore, global ignores, hidden files, language filters, and explicit extension filters are part of traversal rather than a polite afterthought. Files are opened and memory-mapped with memmap2, then searched as bytes with memchr’s memmem::Finder.

If the agent already has a symbol, a route, an error string, or a type name, the fastest respectable thing is still to search for it directly. No model call belongs between the question and SearchArgs, /api/turns/start, or a panic string that is sitting in the source tree with its coat on.

Implementation follows that discipline. It searches bytes, skips huge files, runs work in parallel with Rayon, and avoids building line tables until a match exists. Context extraction happens only when requested. --files-only and --count get faster paths. None of that is glamorous, but it is exactly the kind of boring engineering that keeps a search tool in the agent’s inner loop rather than turning it into a special occasion.

Local sanity check was deliberately unceremonious. Against a real workspace, with hidden and ignored files included so node_modules was part of the walk, I ran eight warm searches for useState. rg averaged about 254 ms. code-search averaged about 262 ms. Not a benchmark suite, and nobody gets a slide deck from eight warm runs. The check changed the status of the idea: the exact-search path was close enough to keep in the loop.

Beating ripgrep at being ripgrep is how a project wastes its life. Staying close enough on exact search buys permission for the second path.

Add ranking without pretending

BM25 carries the ranked path. The index lives in .code-search/bm25_index.json. Initialization walks the repository, reads source files into strings, tokenizes them, stores term frequencies per path, records modification times, calculates inverse document frequency, and keeps the average document length. Subsequent indexing is incremental in the simple sense: unchanged files stay in the index; disappeared files are removed.

BM25 is not semantic in the modern marketing sense. It will not magically know that “sign in” and “login” are related unless the repository gives those words enough shared surroundings. Its strengths are older and duller: rare terms beat common ones, repeated terms help until they start shouting, and long files lose the advantage of being merely large and vaguely acquisitive, which is the search equivalent of awarding the meeting to the person who booked the biggest room.

For code, that almost-semantics is often enough. Source trees are full of repeated names, comments, tests, imports, routes, errors, and file paths. A query like retry timeout backoff error logging will not understand the system, but it can find where those ideas cluster. Then the agent can read the top files, learn the repository’s nouns, and switch back to exact search with better aim.

After documents are scored in parallel and sorted, code-search opens the winners again and returns the best matching lines, with optional context. Still modest: not AST-aware, still weak at splitting identifiers, unable to distinguish declarations from incidental mentions. Even with those limits, it returns a ranked shortlist with snippets, line numbers, and JSON output.

Humans can enjoy colored terminal output. Agents consume structure: the difference between “some text went past” and “open these five files in this order.” Tool output becomes working memory rather than transcript sediment.

A painterly editorial collage for BM25 code search for agents, showing the concrete objects and system relationships around a trail through the papers.
A trail through the papers.

Location and orientation

Agent search has two phases that are easy to confuse. First comes location: where is the thing? Exact search and regex remain undefeated here. If the user gave you the word, use the word.

Orientation is the second phase: what does this codebase call the thing I am thinking about? Where does authentication become session state? Where do retries become backoff policy? Where does a “project” become a workspace, tree, run, or job? Exact search becomes brittle there because the first query is a translation from the user’s language into a codebase the agent has not yet learned.

BM25 helps because it is cheap, local, and literal enough to inspect. No remote embedding store, no model-side memory of the repository, no background index whose contents are hard to explain. The index is a JSON file under the project root. It can be deleted, rebuilt, ignored, committed by mistake and then removed with mild embarrassment. Dullness is a feature when the caller is an agent.

The compromise is semantic-ish search before exact search: ranking to learn where to look, then byte search, file reads, tests, and compiler errors to do the real work.

Rank first, grep next

One fair criticism is accurate: this is not really semantic search.

Correct. The test for coding agents is whether the tool improves the first move without making the second move worse. code-search preserves the fast exact-search habit and adds a ranked discovery pass for the moment when the local vocabulary is still unknown.

Identifier-aware tokenization would help with camelCase, snake_case, and namespaced symbols. Snippet selection could reward definitions, exports, tests, proximity, and call sites. The index could distinguish comments from code and declarations from incidental mentions. None of that changes the shape of the tool. It makes the shortlist less naive.

Ambition stays small enough to inspect: feel like ripgrep when the agent knows the word, and leave a decent first trail when it does not. No oracle. Just a cheaper first guess.

Chris Chabot · May 2026