Code RAG Garden

A codebase RAG built end to end — Turkish questions over English code, incremental sync, and every decision measured.

The whiteboard needs a wider screen — here are the notes in order.

schema

Four hard things about code RAG

Vector search is the easy part of a codebase RAG. The four things that are actually hard decided every part of this system.

what breaks first
exact symbolshandleAuthCallback
Turkish → Englishprose vs. code
stalenessrepo moved on
saying "no"kNN never says no
ProblemWhat we didMeasured
Symbolsroute symbol-shaped queries to BM251.0 / 1.0 recall@8 / MRR
Turkish questionsmultilingual embedder, optional LLM descriptions0.04 → 0.684 → 1.0 (subset)
Stalenesssha256 manifest diff on push38 changed files in 23 s
No answerthree score bands + negative golden casesabstain 0.846, false alarm 0.048

Every row is a decision with a number next to it. The rest of this garden is the story of those numbers — including the ones that changed our minds.

#rag#code-search
experiment

The throwaway experiment

Before the real system there was a throwaway one: MiniLM-L6-v2, a small Milvus, 30 questions. It was wrong about almost everything and that was the point.

recall@5symbol queryplain sentence
dense (MiniLM)0.600.62
BM250.800.36
RRF of both0.600.52

What survived into the real build

  • Symbols want BM25. An embedding has nothing to say about QUEUE_NAMES; a lexical index has everything.
  • Fusing always is not free. RRF promoted the losing channel's best guess at full strength and came out below either channel alone.
  • Turkish over English code scored 0.04. Not a retrieval bug — there was simply no Turkish text to match. Two fixes were carried forward: a multilingual embedder, and LLM-written descriptions as an option.
  • Local models drift. qwen2.5 slid into Chinese mid-sentence under load and did not say so. Every generated description is script-checked since.
#baseline#bm25#multilingual
schema

The stack, and why

One process, one collection, one database file. Each piece was picked against the four hard problems, not against a feature list.

MCP · /mcpsearch_code · read_code · list_repos, same process
FastAPI + typer/search /ask, webhooks, CLI
Retrieverroute → dense | BM25 → (RRF, rerank behind flags)
Milvus 2.6dense + BM25 sparse in one collection, repo_id partition key
BGE-M3 · tree-sitter · sha256 manifestembed · chunk · diff
SQLite + one workerrepos, files, jobs, enrichment cache
one process, one collection, one file
one FastAPI processStreamable HTTPpushqueryjob per repohybrid searchupsertmanifest · jobsAgentClaude Code · CursorGitHubwebhook · pollerMCP · /mcpsearch · read · listFastAPI/search /ask · CLIRetrieverroute → dense | BM25Indexertree-sitter · BGE-M3Milvus 2.6dense + sparseSQLiterepos · jobs · cacheBackend3Database2Queue1External2
PieceWhyRejected
Milvus 2.6BM25 is a built-in Function → no second index; partition key makes repo filters cheappgvector (no BM25), Qdrant (BM25 client-side), Elasticsearch (a second world)
BGE-M3, local, 1024dmultilingual: Turkish prose 0.684 where MiniLM had 0.04; code never leaves the machineOpenAI / Voyage embeddings (code leaves; kept as a flag)
tree-sitterchunk = code unit, citation = file:line — symbolfixed windows, LLM chunking
sha256 manifestrename, mode, submodule edge cases vanish; local dirs take the same pathgit diff
webhook + pollerpush-time freshness with a safety netcron only, webhook only
SQLite + one workerone file, no Redis, one pending job per repoPostgres + Celery

The whole thing talks to the world over HTTP and never writes into the repos it indexes. Personal access tokens travel as a git header and are redacted from every error message.

#milvus#bge-m3#tree-sitter#architecture
schema

Incremental sync by content hash

A push should cost seconds, not a full re-index. Change detection is by content hash, not by git diff.

what a push triggers
pushwebhook or poll
jobdeduped per repo
fetch + reset
sha256 diffvs manifest
delete + rewritechanged paths only
manifestupdated

Why a hash and not a diff

  • Renames, mode changes, submodules, force-pushes: none of them are special cases when you compare file contents.
  • A local directory that is not a git repo goes through the exact same path.
  • The manifest is the single source of truth for read_code too — the agent can only read files that are actually indexed.

Crash safety by ordering

A file's manifest row is removed before its chunks are deleted and put back after the new chunks are written. A job killed halfway leaves the file looking "unindexed", so the next sync simply redoes it. Nothing is ever half there.

#webhook#manifest#freshness
experiment

Route by query shape

Two retrievers, one regex. A query that looks like a symbol goes to BM25; a sentence goes to the dense index. Hybrid fusion and reranking stay behind flags — both were measured and both lost.

SettingRecall@8MRRTR prose R@8p50
BM25 only0.4050.2400.2632 ms
hybrid (dense + BM25 → RRF)0.7860.6040.68440 ms
dense only0.7860.6780.68432 ms
auto: symbol → BM25, prose → dense ✓0.7860.6900.68434 ms

Why always-hybrid loses

RRF adds up ranks. When one channel has nothing useful — dense on QUEUE_NAMES, BM25 on "how does auth work" — its best guess is still promoted at full strength and pollutes the top of the list. Routing removes the channel that would have guessed.

What counts as a symbol

One token with an internal boundary: camelCase, snake_case, kebab-case, a.b.c, a/b, A::B, or ALLCAPS. Deliberately narrow — a false positive sends a real question to BM25, which is measurably bad; a false negative only forgoes a small win. Symbols score 1.0 / 1.0 on BM25 and 1.0 / 0.875 on dense.

An LLM router was on the table. A regex got the same MRR gain for free and adds no latency.

#routing#bm25#dense#rrf
experiment

The reranker that hurt

The textbook says: retrieve 40, rerank with a cross-encoder, hand over 8. On this corpus the textbook was wrong, and the numbers said so.

SettingRecall@8MRRTR prosep50
auto + dense (no rerank)0.7860.6900.68434 ms
hybrid, k=40 candidates0.9520.89539 ms
hybrid + bge-reranker-v2-m30.7620.5080.5794389 ms
auto + bge-reranker-v2-m30.7620.5140.5792050 ms
  • The headroom is real. Recall@40 is 0.95 — the right chunk is almost always in the candidate pool. A good reranker has 17 points to win.
  • This reranker spent them. MRR dropped from 0.690 to 0.514 and the Turkish slice fell furthest. It also cost two to four seconds per query.
  • As a "no answer" gate it was worse. It caught 12/12 negatives — and flagged 12/42 real answers as junk. A 29% false alarm rate.

A reranker is a hypothesis about your corpus. Test it like one.

#rerank#cross-encoder#latency
experiment

Turkish questions, English code

The users ask in Turkish; the code, comments and commit messages are English. Nothing in a bi-encoder bridges that gap unless the text does.

TR prose recall@8 — MiniLM4%
TR prose recall@8 — BGE-M368%
TR prose MRR — subset, no enrichment78%
TR prose MRR — subset, enriched93%

Step one: a multilingual embedder

BGE-M3 alone took Turkish prose from 0.04 to 0.684. English prose sits at 0.842 on the same corpus, so a gap of ~16 points remained.

Step two: write the missing text

Contextual retrieval, applied to code: one LLM call per file returns a 2–3 sentence Turkish description of each chunk. The description goes only into the embedded/BM25 text — the chunk shown to the model stays the real source. Cached by chunk hash + model, so a re-index never pays twice.

Same 46 files / 423 chunksRecall@8MRRTR prose MRRfalse weak-match
no enrichment0.9290.8390.7780.119
enriched (local qwen3.5:9b)1.0000.9120.9320.048

Negatives were unchanged (abstain 0.923 both ways) — the descriptions did not manufacture confidence on unrelated questions — and the score calibration did not move.

#multilingual#contextual-retrieval#enrichment
schema

A chunk is a code unit

A chunk is a function, class, method, interface, type or exported constant — with boundaries from a real parser, not from counting braces.

chunker rules
parsetree-sitter
units≤ 2000 B
splitbig class → members
mergesmall ones ≥ 200 B
headeronly for the index

The four rules

  • Big containers split into members. A 5 KB class becomes a header chunk plus one chunk per method, each carrying parent = class.
  • Small things merge with a neighbour. One-line types, short consts, import blocks and doc comments stick to the next unit.
  • Nothing exceeds ~2000 bytes (≈ 500 tokens). The model accepts 8192, but a long chunk's embedding averages toward nothing. A single oversized function is windowed by lines and keeps its symbol.
  • Text stays clean. The "what am I, where do I live" header — file, symbol, parent, imports — is prepended only to the text that gets embedded and BM25-indexed. Citations show real source.

Identifiers for BM25

The standard analyser keeps handleAuthCallback as one token, so "auth callback" never touches it. Every chunk's identifiers are split into lowercase sub-words and appended to the indexed text.

#chunking#tree-sitter
experiment

AST chunks vs. plain windows

What does syntax-aware chunking actually buy? We indexed the same repo twice — once with tree-sitter units, once with blank-line windows of the same size — and ran the same 42 questions.

ChunkerRecall@8MRREN proseTR prosesymbol R / MRR
tree-sitter units0.7860.6900.842 / 0.7440.684 / 0.5701.0 / 1.0
blank-line windows0.7620.5980.895 / 0.7370.632 / 0.5180.75 / 0.32

Reading it honestly

  • Recall moved by one question. MRR moved by 0.09 — and almost all of it is symbol queries (0.32 → 1.0) plus the Turkish slice.
  • On English prose the plain windows were equal or better.
  • So AST chunking does not find more. It puts the right piece on top and knows its name. That is worth having; it is not a 20-point lever.

The literature agrees

The cAST paper reports +1 to +4 points over same-size line windows. An independent controlled replication found ≈ 0, and a third found the fixed windows slightly ahead. Naive function-per-chunk is reliably worse (−4 to −6 pp) — the value sits in merging small siblings and capping size, not in the syntactic boundary itself. Reranking and LLM-written context move retrieval 3–10× more.

#ablation#chunking#cast
note

Language coverage is a rule, not a table

The tempting design was a table: extension → language, folder → role (controllers/ = controller), a regex per language for symbols. We surveyed what production systems do instead, and measured what a table would buy.

What everyone else does

GitHub code search, Sourcegraph, Cursor, aider, Continue, Sweep, Qodo, Greptile — all of them outsource language coverage to a parser ecosystem (tree-sitter grammars with upstream tags.scm, or universal-ctags), chunk with a grammar-generic size cap, keep a universal line-window fallback, and carry path + symbol as metadata. None of them has framework-specific logic. None of them keeps a folder→role taxonomy.

ProposedVerdictWhy
extension → language tablereplaced by a rule371 grammars ship in the pack; if the extension name is a grammar name (.lua .vue .razor .zig) it just works; a small alias table (.ts .cs .kt) is validated against the pack at import
regex symbol extractorsnotree-sitter already yields symbol, kind, parent for 20+ languages
folder → role tagsnoa role is a deterministic function of the path, and the path is already in the embedded text; it fixed none of the 11 misses in the golden set
vendor-name skip lists (jquery, bootstrap…)noa per-repo ignore file generalises; a name list encodes one company
per-framework rules (Next.js, Vue, Razor)noroute files already carry the route in their path; Razor blocks fall out of the grammar

What we kept hand-written, and why

  • Deliberately plain: json, yaml, css, html — grammars exist, but windows measured equal and a parser is only crash surface.
  • Never indexed: csv, tsv, diff, po, pem — grammars exist for those too.
  • Grammar exists, parser refused: sql (the generated grammar segfaults on migration files) and cobol (hangs past 300 s on unbalanced brackets). Both were found by a smoke test that runs every grammar in its own subprocess on degenerate input — a crash kills the child, not the indexer.

Adding a language now costs zero lines. The ceiling of that whole axis, per the ablation, is about one question of recall — so zero is the right price.

#languages#tree-sitter#survey
schema

Saying no: three bands

Nearest-neighbour search has no concept of "nothing is close". Ask a codebase whether you have been to a five-star resort and it returns eight chunks (best score 0.366). Hallucination starts right there.

CRAG-style bands on the top dense score
below 0.45dropped, counted
0.45 – 0.55returned with a weak-match note
0.55 and upnormal

Why not one threshold

Cosine does not separate the grey zone on this model and corpus: real answers have a top-1 dense median of 0.636 and a minimum of 0.526; irrelevant questions have a median of 0.524 and a maximum of 0.587. They overlap. A single cut either drops real answers or lets junk through.

Gateabstain on 13 negativesfalse alarm on 42 positivesextra latency
note below 0.550.8330.0480
floor 0.45 + note 0.55 ✓0.8460.0480
reranker score below 0.051.0000.286+550 ms

The floor removed nothing from the golden set and turned the resort question into an empty result. The two negatives that still slip through are questions with genuinely similar code in the repo — there the call belongs to the reader.

#abstention#crag#calibration
note

MCP in the same process

The consumer is an agent — Claude Code, Cursor — so the retriever is exposed as three MCP tools mounted at /mcp inside the same FastAPI process.

tools
search_codequery, repo, path prefix, category
read_codeindexed file, line range
list_reposfreshness

Design choices

  • Same process, same retriever. No stdio sidecar loading the embedding model twice; blocking work (embedding, Milvus, disk) runs in threads so the MCP session's event loop keeps flowing.
  • Output is data, not instructions. Code returned to the agent is wrapped as untrusted content.
  • Signals travel with the hits. Weak-match band, DOC label, "N candidates dropped", last index time per repo.
  • read_code reads only indexed files. The path must be in the manifest — so the agent cannot read node_modules or .env, and cannot be told "that file exists" when it does not. If the file on disk no longer matches the indexed hash, the slice comes back flagged stale.
GotchaFix
mounted sub-app lifespans never runsession_manager.run() wraps the parent app's lifespan
transport rejects foreign Host headers with 421 (DNS rebinding)extra hosts via config
/mcp → /mcp/ is a 307; redirect-averse clients lose the POST bodya pure-ASGI middleware rewrites the path instead

The agent gets candidates and evidence, then reads. Retrieval that tries to be the last word is retrieval that hallucinates on the agent's behalf.

#mcp#agent#signals
note

Measure, or it didn't happen

The rule of the repo: no retrieval flag changes its default without a JSON in evals/results/. "It seems better" is not a result.

the harness
golden42 positives + 13 negatives
runauto · k=8
scoreRecall@8 · MRR · abstain · false-weak
ledgerREADME table

The golden set is 19 English prose questions, 19 Turkish, 4 symbols, and 13 questions whose answer is not in the repo. Every run also prints p50/p95 and a calibration block. Repo-specific sets stay out of git — they contain internal paths.

What the numbers overturned

BeliefMeasuredDecision
always fuse dense + BM25MRR 0.604 vs 0.690 routedroute by query shape
a cross-encoder reranker helpsMRR 0.690 → 0.514, +2–4 sflag, default off
a reranker can gate 'no answer'29% false alarmthree cosine bands instead
folder → role tags make embeddings smarterfixed 0 of 11 missesnot built
more languages = big winAST vs windows: 1 question, MRR +0.09generic rule, zero per-language code
Turkish needs a better matchertext was missing, not matching: 0.04 → 0.684 → 0.932multilingual embedder + optional enrichment

Half the table is things we were sure about. That is the reason the table exists.

#evals#golden-set#ledger
snippet

Gotchas

Things that cost an afternoon each and are now one line in a config or a test.

None of these show up in a benchmark. All of them show up in production at 2 a.m.