Design: Semantic Code Search
Search over a large codebase — why code is not prose, and what that changes.
Prerequisites
- How to reason about AI system design — capacity, deep dives, eval planes.
- Hybrid search — fusion of lexical and dense signals.
- Ingestion and chunking — why boundary choice dominates retrieval.
- ANN indexes — HNSW tradeoffs for the vector leg.
The problem
Design semantic search over a company's codebase — or the retrieval layer for an AI coding assistant over a ~30M-LOC monorepo. Users are both humans in an IDE and LLM agents that fire many retrievals per task.
It must support natural-language intent ("where do we validate JWTs"), symbol lookup (validateToken),
and graph questions ("find usages") under a tight retrieval latency budget and a commit-to-searchable
freshness SLA. If policy forbids shipping source outside the VPC, embeddings must be self-hosted too.
In the governed enterprise platform, this is the sibling of document RAG: same retrieval instinct, different artifact. Treat code like PDFs and the system feels smart in demos and wrong in the editor.
Why it is hard
Code is not prose. Four properties break document-RAG defaults.
Structure is meaning. A function is a natural retrieval unit. Fixed 512-token windows that split a function mid-body or glue two unrelated ones produce broken chunks. Boundaries must come from the parse tree, not a token counter.
Identifiers are exact-match tokens. getUserByEmail is one opaque token to an embedding but a
precise handle to a symbol index. Most developer queries contain at least one identifier, so pure
embeddings lose exactly those queries.
Meaning is non-local. What processOrder does depends on callees defined elsewhere. Embeddings
capture "what this text is about," not "what references this."
Churn is constant and localized. Thousands of commits a day may touch only ~0.1% of files. Incremental indexing is the core problem; for prose RAG it is often an afterthought.
| Prose default | Failure on code |
|---|---|
| Fixed-size chunks | Split functions; glue unrelated units |
| Pure embedding retrieval | Miss identifier and find-usages queries |
| Batch reindex nightly | Agents edit against yesterday's truth |
| Click ranking only | Agents need context sufficiency, not click MRR |
Requirements and scale
Clarifying questions (each tied to a design consequence)
- Who queries — human or agent? Agents issue 5–20 retrievals/task at ~100–300 ms; ranking optimizes for context sufficiency, not click MRR.
- Monorepo or many repos? Decides sharding and per-repo ACLs.
- Which query types? NL vs. symbol vs. find-usages — the last is a call-graph query embeddings cannot answer.
- Freshness? Commit-to-searchable SLA drives indexing; stale code misleads agents.
- Can code leave the environment? External embed API vs. self-hosted; plaintext vs. pointers.
- Languages? Tree-sitter for the mainstream; obscure tails get line-window fallback.
Requirements and capacity (assumed)
Illustrative monorepo math (re-derive for your corpus):
| Kind | Figure | Notes |
|---|---|---|
| Corpus | 30M LOC (~400k files) | ~1.2B tokens at ~40 tok/line → ~4M AST chunks (~300 tok avg) |
| Vector storage | ~12 GB int8 | 768-dim; ~3 KB/vector with HNSW overhead |
| Full rebuild | ~4–6 h | ~2 GPUs; blue/green for model upgrades only |
| Daily churn | ~150k chunks | ~2/s if incremental; provision 10× burst |
| Load / latency | ~20 QPS peak; p95 < 300 ms | Commit-to-searchable < 60 s |
| Privacy / cost | Source in VPC; ~$2–3k/month | Self-hosted embedder; illustrative mid-2026 ops figure |
At ~20 QPS this is not a throughput problem. It is a chunking, hybrid routing, and freshness problem.
The design
AST-aware chunking
Parse with tree-sitter (one grammar per language, uniform API). Each top-level function, method,
or class is a chunk; oversized nodes split at child boundaries; tiny siblings merge up to a
~300–500-token budget. Embed each chunk with a context header — file path, enclosing class,
import summary, docstring — because def process(self, item) is meaningless without knowing it
lives in billing/invoices.py. Structure-aligned chunks beat fixed-size windows on retrieval and
codegen; cAST is the measured case (see Go deeper).
Key insight
The chunker is the retrieval model for code. A better embedding on bad boundaries loses to a mediocre embedding on AST-aligned units with path/class headers.
Two indexes, one query plane
- Symbol / keyword. Exact + trigram index over identifiers (camelCase splitting, substring matches — the Zoekt / GitHub Blackbird family) plus a def/ref table for go-to-definition and find-usages. Trigrams are overlapping character n-grams; they make fuzzy identifier lookup cheap at monorepo scale.
- Vector. HNSW over chunk embeddings from a code-specific embedder for NL → code intent. Self-host an open-weight code model when source cannot leave.
- Classifier + fusion. Identifier-shaped queries hit symbols first; NL fans out to both. Merge with reciprocal rank fusion, then a cross-encoder reranks 100 → 20 (~40 ms). For agents, optional graph expansion pulls defs of symbols in top hits — function plus immediate callees beats the function alone.
Pure dense retrieval is not enough. The lexical/symbol half is half the product, not a fallback.
Incremental re-indexing — the deep-dive magnet
Maintain a Merkle tree of file-content hashes per branch head (the approach Cursor documents). It hashes every file, then each directory from its children, so a root-to-leaf walk finds changed files in O(changed), not O(total). On push: compare trees → re-parse only changed files → diff chunks by content hash (one-function edits re-embed one chunk, not twenty) → unchanged hashes hit an embed cache → deletes tombstone immediately; upserts land in the vector write buffer.
Branches: index main fully; serve feature branches as main + an overlay of changed files. Commit-to-searchable p95 ~30 s (parse ~1 s, embed ~2 s typical; rest is 10× burst headroom). SLA is < 60 s. Keep the 4–6 h full rebuild for model upgrades as a blue/green swap.
Serving path and evaluation
| Stage | p95 |
|---|---|
| Query classify + embed | 40 ms |
| Symbol/trigram (∥ ANN) | 30 ms |
| ANN search | 40 ms |
| Fusion + rerank 100 → 20 | 90 ms |
| Graph expansion (agent) | 60 ms |
| Total | ~260 ms |
Parallel symbol + ANN: p95 without expand = max(30, 40) + 40 + 90 = 200 ms. With expand: 260 ms < 300 ms.
Golden set of 300+ (query → expected file:line), scored as three slices — NL, identifier, find-usages — because a mean hides regressions in one path. Online: click position for humans; task success from agent traces. Mine zero-click and agent-retry queries weekly.
Key decisions and tradeoffs
| Decision | Choice | Tradeoff |
|---|---|---|
| Chunking | AST-aligned | Grammar coverage; obscure languages need fallback |
| Indexes | Symbol/trigram + vector + def/ref | Three stores to keep consistent |
| Embeddings | Self-hosted code model in VPC | GPU ops; source stays inside |
| Incremental sync | Merkle + chunk content hashes | Complexity; only viable at monorepo churn |
| Branches / agents | Main + overlay; optional graph expand | Deep-branch history may miss; +20–60 ms for agents |
| Generated/vendored | Exclude by pattern | Often 30–50% noise cut |
Common misconception
"Embeddings capture what code is about, so they answer impact analysis." They cannot. "What breaks if I change this?" needs an exact def/ref graph. A 95%-recall usages list is a broken refactor tool.
The flows
Flow A — Natural-language query
Classifier labels NL → fan out to ANN + symbol/trigram → RRF → cross-encoder 100 → 20 → optional graph expand for agents → ranked snippets with file:line.
Breaks when: headers omit path/class, fusion over-weights dense on identifier-heavy NL, or generated code crowds top ranks.
Flow B — Identifier / find-usages
Classifier detects identifier shape → symbol/trigram and def/ref primary → exact defs/refs. No approximate substitution for usages lists.
Breaks when: extractor missed a construct, or usages are routed through ANN.
Flow C — Commit incremental index
Git event → Merkle file-hash diff → parse changed files → content-hash diff chunks → re-embed changed only; tombstone deletes; update def/ref → searchable in tens of seconds (p95 ~30 s).
Breaks when: git events drop (index divergence), or renames re-embed as full churn (accepted cost — provision above average).
Flow D — Embedding model upgrade
Full rebuild into a shadow index → per-slice eval gate → cut over; keep old index for rollback.
Breaks when: cutover on mean recall only (hides a dead identifier path).
Failure modes and degradation
| Failure | Response |
|---|---|
| Dropped git event / silent staleness | Reconcile Merkle roots vs git every 10 min; replay gaps |
| Parser failure | Line-window fallback; log per-language |
| Generated/vendored noise | Exclude by pattern |
| Embedding upgrade risk | Shadow index, eval gate, keep old for rollback |
| Query overload | Drop graph expand → drop rerank → symbol-only for identifiers |
| Privacy tightening | Embeddings + file:line only; hydrate text from git under caller credentials |
Index divergence is the characteristic failure: the index looks healthy while serving yesterday's truth. Heartbeat reconciliation is not optional.
Common drill-downs
Why not just grep? For identifier queries, lexical search is excellent — that is why symbols are first-class. Grep fails on intent queries where the query vocabulary is absent from the code, and on ranking (10k matches, no order). The design is grep + structure + embeddings.
A rename touches 400 call sites. Defining file: ~1 chunk re-embeds. Call sites each change one token → ~400 chunks re-embed (~30 s). Content hashes cannot tell a rename from a logic change — accepted cost; provision re-embed above average.
Code cannot leave the VPC. Self-hosted embedder and in-VPC store are already assumed. Extra hardening: store only embeddings + file:line; hydrate from git under caller credentials. Filter hits by grants before hydration to avoid oracle leaks via result counts.
NL recall is fine; agents still get useless context. Snippet relevance ≠ context sufficiency: right function, missing helper; or near-duplicates crowd the distinct file. Fix with graph expansion, near-duplicate collapse, and task-success eval — not recall@k alone.
Test yourself
Fixed 512-token chunks with 50-token overlap on a Python monorepo. Name two concrete retrieval failures.
Daily churn is 150k chunks. You batch-reindex all 4M every night. What goes wrong for agents?
p95 is 260 ms with graph expand. Product wants 100 ms. What do you cut first and why?
Mean recall@10 improved after a model change, but find-usages complaints spike. Diagnose.
Vendored and generated code are 40% of the tree. Index them or not?
Go deeper
- The technology behind GitHub's new code search — Blackbird ngram indexing; the lexical half of this design.
- Securely indexing large codebases — Cursor — Merkle-tree incremental sync, syntactic chunks, content-hash embed caching.
- How Cody understands your codebase — Sourcegraph — production context fetching, including the move away from pure embeddings.
- cAST: Structural Chunking via AST — measured AST-aligned chunking gains on retrieval and codegen.
Where this connects
- Enterprise RAG — same hybrid + eval discipline over prose; contrast chunking and freshness.
- Hybrid search — RRF and when lexical wins.
- Agent foundations — coding agents as multi-step retrieval consumers.
- Open source and self-hosting — serving the code embedder inside the VPC.