|
| 1 | +# Blog Post Plan: How semcode Builds a RAG System for Code Search |
| 2 | + |
| 3 | +## Context |
| 4 | + |
| 5 | +This blog post explains the RAG (retrieval-augmented generation) pipeline behind |
| 6 | +[**semcode**](https://github.com/GoodbyePlanet/semcode), an MCP server that does |
| 7 | +semantic code search across your GitHub repositories. It covers both parts of the pipeline: the **ingestion** side — how |
| 8 | +repositories are found, how code is parsed into symbols with Tree-sitter, how embedding inputs are constructed both |
| 9 | +dense and sparse, and how |
| 10 | +points land in Qdrant incrementally — and the **retrieval** side — how queries are encoded into both dense and sparse |
| 11 | +vectors and fused server-side with RRF (Reciprocal Rank Fusion). Along the way we'll cover why a hybrid dense+sparse |
| 12 | +approach beats either one alone for code, and why the *payload* stored next to each vector matters as much as the vector |
| 13 | +itself. |
| 14 | + |
| 15 | +Audience: engineers familiar with RAG, embeddings, and vector DBs, curious about applying RAG to source code |
| 16 | +specifically (not prose). |
| 17 | + |
| 18 | +--- |
| 19 | + |
| 20 | +## Section 1 — Why RAG for code is different from RAG for documents |
| 21 | + |
| 22 | +Most RAG systems are built around prose — PDFs, internal documentation, wikis... The content is natural language written |
| 23 | +for humans, meaning is carried in sentences, and semantic search over plain text works well, and when you add second |
| 24 | +stage retrieval (reranker), you get a system that can answer your questions with high confidence. |
| 25 | +Software code is different: it's structured, symbolic, it's written for compilers and interpreters. Meaning is |
| 26 | +distributed across structure, not sentences: |
| 27 | + |
| 28 | +- A function name (retryWithBackoff) carries intent |
| 29 | +- The signature (attempts: int, delay_ms: int) carries contract |
| 30 | +- The body carries implementation details |
| 31 | +- Annotations (@Retryable, @CircuitBreaker) carry framework behavior |
| 32 | +- The class it belongs to (OrderProcessingService) carries domain context |
| 33 | + |
| 34 | +None of that is a sentence. You can't chunk code by paragraph — you chunk by symbol (function, class, method). |
| 35 | +Let's see how that is implemented in **semcode**. |
| 36 | + |
| 37 | +--- |
| 38 | + |
| 39 | +## Section 2 — From source files to Code Symbols - Tree-sitter parsing |
| 40 | + |
| 41 | +What is an AST? |
| 42 | + |
| 43 | +An Abstract Syntax Tree is a tree representation of source code's grammatical structure (logical parts of this code and |
| 44 | +how do they relate to each other). Every construct in your code — |
| 45 | +a function definition, a class, an if statement, a variable assignment — becomes a node in the tree, where parent-child |
| 46 | +relationships express nesting and ownership. |
| 47 | + |
| 48 | +For clarity, bellow is a pruned AST. Just to give you a mental model of how a parser sees |
| 49 | +a function: a decorated async definition with typed parameters, a return annotation, and a body containing a |
| 50 | +docstring and a single return. |
| 51 | + |
| 52 | +```shell |
| 53 | +@app.get("/users") |
| 54 | +async def list_users(db: Session) -> list[User]: |
| 55 | + """Return all users.""" |
| 56 | + return db.query(User).all() |
| 57 | + |
| 58 | +module |
| 59 | +└── decorated_definition |
| 60 | + ├── decorator → "@app.get("/users")" |
| 61 | + └── function_definition |
| 62 | + ├── name → "list_users" |
| 63 | + ├── parameters → "(db: Session)" |
| 64 | + ├── return_type → "list[User]" |
| 65 | + └── body |
| 66 | + ├── expression_statement |
| 67 | + │ └── string → '"""Return all users."""' |
| 68 | + └── return_statement |
| 69 | +``` |
| 70 | + |
| 71 | +What is Tree sitter? |
| 72 | + |
| 73 | +Tree-sitter is a parser generator tool and an incremental parsing library. It can build a concrete syntax tree for a |
| 74 | +source file and efficiently update the syntax tree as the source file is edited. |
| 75 | +[Tree-sitter official documentation](https://tree-sitter.github.io/tree-sitter/) |
| 76 | + |
| 77 | +What is a Code Symbol in **semcode**? |
| 78 | + |
| 79 | +A symbol is one named, self-contained unit of code that a language considers meaningful — a function, a class, a method, |
| 80 | +an interface, a React component, a hook... In **semcode** a symbol is a CodeSymbol dataclass, |
| 81 | +which captures everything needed to search, understand, and locate it without reading the surrounding file. |
| 82 | + |
| 83 | +What a `CodeSymbol` carries: |
| 84 | + |
| 85 | +**name / symbol_type / language** — These uniquely describe what kind of thing this is (save, |
| 86 | +method, java) so retrieval can filter by language or type before even looking at embeddings. |
| 87 | + |
| 88 | +**signature** — The declaration line only, e.g. *def save(self, db: Session) -> User*. This is what you'd see in an |
| 89 | +IDE's autocomplete popup — compact enough to show in search results without including the full body. |
| 90 | + |
| 91 | +**source** — The complete raw text of the symbol from open brace to closing brace. This is what gets embedded into the |
| 92 | +vector store, giving the model the full implementation context when a chunk is retrieved. |
| 93 | + |
| 94 | +**start_line / end_line** — Position recorded by Tree-sitter during parsing, used to link a search result back |
| 95 | +to an exact location in the file. |
| 96 | + |
| 97 | +**parent_name / package** — Structural context. **parent_name** says which class owns this method; **package** says |
| 98 | +which Java |
| 99 | +package or Python module the file belongs to. Without these, two methods both named save in different services are |
| 100 | +indistinguishable. |
| 101 | + |
| 102 | +**annotations / extras** — Language-specific enrichment. A Java @GetMapping("/users") lands in annotations; the |
| 103 | +extracted |
| 104 | +HTTP route string (GET /users) lands in extras. For TypeScript, extras flags whether a component uses hooks, or whether |
| 105 | +a function matches the React component signature pattern. |
| 106 | + |
| 107 | +Example: |
| 108 | + |
| 109 | +```shell |
| 110 | +CodeSymbol( |
| 111 | + name="list_users", |
| 112 | + symbol_type="api_route", |
| 113 | + language="python", |
| 114 | + source="async def list_users(db: Session) -> list[User]:\n ...", |
| 115 | + file_path="auth-service/routers/users.py", |
| 116 | + start_line=2, |
| 117 | + end_line=4, |
| 118 | + parent_name=None, |
| 119 | + package="auth-service.routers.users", |
| 120 | + annotations=["app.get(\"/users\")"], |
| 121 | + signature="async def list_users (db: Session) -> list[User]", |
| 122 | + docstring='"""Return all users."""', |
| 123 | + extras={"is_async": True, "http_method": "GET", "http_route": "/users"}, |
| 124 | +) |
| 125 | +``` |
| 126 | + |
| 127 | +So the full pipeline is: |
| 128 | +Tree-sitter parses code into an AST. The parser goes through that AST node by node, asks each node where it starts/ends |
| 129 | +and what it contains, and puts all of that into a **CodeSymbol** — one symbol per meaningful language construct. |
| 130 | +--- |
| 131 | + |
| 132 | +## Section 3 — Building the embedding input |
| 133 | + |
| 134 | +Now, having knowledge about **CodeSymbols**, we can build the input for a vector database. In **semcode** |
| 135 | +[Qdrant](https://qdrant.tech/) is used for to store vectors we have two types of inputs: dense and sparse. |
| 136 | + |
| 137 | +What are dense embeddings? |
| 138 | + |
| 139 | +**Dense embeddings** encode the *meaning* of text into a fixed-size vector of floating-point numbers — typically |
| 140 | +hundreds or thousands of dimensions depending on which embedding provider is chosen. Two pieces of text that express the |
| 141 | +same idea will land close together in that vector space even if they share no words in common. For code search this |
| 142 | +means a query like "find the method that handles payment retries" can surface `retryWithBackoff()` |
| 143 | +without those words appearing anywhere in the source. |
| 144 | + |
| 145 | +```shell |
| 146 | +dense = [0.2, 0.3, 0.5, 0.7, ...] # several hundred floats |
| 147 | +``` |
| 148 | + |
| 149 | +What are sparse embeddings? |
| 150 | + |
| 151 | +**Sparse embeddings** work the opposite way: instead of capturing meaning, they represent text as a large vocabulary |
| 152 | +vector where almost every entry is zero and only the terms that actually appear get a non-zero weight. BM25 is the |
| 153 | +algorithm behind this — it scores each token by how often it appears in a document relative to how common it |
| 154 | +is across the whole corpus. This makes sparse embeddings excellent at exact keyword matching: if you search for |
| 155 | +`PlaceOrderRequest` or `@Transactional`, BM25 will find every document that contains those tokens precisely. |
| 156 | + |
| 157 | +```shell |
| 158 | +# Taken from Qdrant docs |
| 159 | +sparse = [{331: 0.5}, {14136: 0.7}] # 20 key value pairs |
| 160 | +# The numbers 331 and 14136 map to specific tokens in the vocabulary e.g. ['Transactional', 'PlaceOrderRequest']. |
| 161 | +# The rest of the values are zero. This is why it’s called a sparse vector. |
| 162 | +``` |
| 163 | + |
| 164 | +How does **semcode** build the dense input? |
| 165 | + |
| 166 | +The whole `CodeSymbol` object is not embedded directly — it is first serialized into a single text string, and that |
| 167 | +string is what the embedding model sees. One symbol produces one string, which produces one vector: an array of |
| 168 | +floating-point numbers (e.g. 768 or 3072 floats depending on the provider). The `CodeSymbol` fields that carry |
| 169 | +*meaning* go into that string. |
| 170 | +It starts with a human-readable preamble that names the language, symbol type, parent class, and owning service, then |
| 171 | +layers in framework-specific metadata — Spring stereotypes, HTTP method and route, annotations — followed by a truncated |
| 172 | +docstring and the full signature. Finally, the raw source body is appended, capped at ~6,000 characters (~1,500 |
| 173 | +tokens). The goal is to give the embedding model everything it would need to understand the symbol's role, not just |
| 174 | +its implementation. |
| 175 | +The fields that are useful for *displaying* results (like `start_line`, `end_line`, `file_path`, `signature`, `source`) |
| 176 | +or *filtering* them (like `language`, `service`, `symbol_type`) are stored separately as the Qdrant **payload** — |
| 177 | +they sit next to the vector but are never embedded. |
| 178 | + |
| 179 | +How does **semcode** build the sparse input? |
| 180 | + |
| 181 | +Building BM25 text input is minimal — it concatenates only the signature, docstring, and raw source, with no metadata. |
| 182 | +It splits camelCase and snake_case identifiers into their component words while keeping the original form alongside. A |
| 183 | +token like `PlaceOrderRequest` becomes `Place Order Request` — so BM25 can match the exact identifier *and* a |
| 184 | +natural-language query like "place order request" that doesn't use the original casing. |
| 185 | + |
| 186 | +Why does sparse matter when the dense input is already rich? Dense embeddings excel at intent — a query like "find |
| 187 | +the method that retries payments" can surface `retryWithBackoff` even if no query word appears in the source — but that |
| 188 | +power trades precision for meaning, and rare or project-specific identifiers like `PlaceOrderRequest` get smoothed |
| 189 | +toward neighboring concepts in the model's vector space. BM25 fills exactly that gap: it matches tokens literally with |
| 190 | +no compression, and **semcode's** code-aware tokenization splits `PlaceOrderRequest` into `Place Order Request` |
| 191 | +alongside |
| 192 | +the original, so it handles both exact identifier lookups and natural-language queries that dense alone would miss. |
| 193 | + |
| 194 | +So the full picture is: |
| 195 | +Every `CodeSymbol` produces two inputs. The dense input is wide and context-rich — it tells the model the symbol's |
| 196 | +place in the system. The sparse input is narrow and literal — it gives BM25 the exact tokens to match against. Both |
| 197 | +are computed in the same pipeline step and stored together as a single point in Qdrant. |
| 198 | + |
| 199 | +--- |
| 200 | + |
| 201 | +## Section 4 — What goes into Qdrant: the named-vector schema |
| 202 | + |
| 203 | +In Section 3 it's explained that we have two inputs per symbol — dense and sparse — stored together in Qdrant. |
| 204 | +This section explains *how* they are stored: the shape of a single stored point and why that shape matters at query |
| 205 | +time. |
| 206 | + |
| 207 | +### Named vectors: two vectors, one point |
| 208 | + |
| 209 | +Qdrant lets a single point carry multiple vectors under distinct names, each with its own distance metric and index. |
| 210 | +**semcode** uses this directly: the `code_symbols` collection defines two named vectors per point. |
| 211 | + |
| 212 | +- `text-dense` — cosine distance, dimensionality set by the embedding provider. |
| 213 | +- `text-sparse` — Qdrant's native BM25 sparse index. |
| 214 | + |
| 215 | +The advantage of named vectors over two parallel collections is that one point ID identifies one symbol everywhere. |
| 216 | +Dense and sparse retrievers always agree on what "document 42" means, which is what makes server-side fusion (next |
| 217 | +section) possible in a single round-trip. |
| 218 | + |
| 219 | +### Anatomy of a stored point |
| 220 | + |
| 221 | +Alongside the two vectors, there is the payload — the non-embedded half of the point. |
| 222 | +Payload is a JSON object with the following fields: |
| 223 | + |
| 224 | +- **Identity & filtering** — `symbol_name`, `symbol_type`, `language`, `service`, |
| 225 | + `file_path`, `package`, `parent_name`. These uniquely place the symbol in |
| 226 | + the repo, and three of them — `language`, `service`, `symbol_type` — are |
| 227 | + wired as active query-time filters. |
| 228 | +- **Display** — `signature`, `source`, `docstring`, `start_line`, `end_line`, |
| 229 | + `annotations`, `extras` (HTTP method, route, Spring stereotype). These are |
| 230 | + what the MCP client renders back to the user — they are never filtered on, |
| 231 | + just returned alongside the score (`server/tools/search.py:60-71`). |
| 232 | +- **Bookkeeping** — `file_hash`, `indexed_at`. Not exposed at query time, but |
| 233 | + critical for the incremental reindex flow: the hash is how the pipeline |
| 234 | + decides a file hasn't changed and can be skipped (`server/indexer/pipeline.py:122-123`). |
| 235 | + |
| 236 | +### Payload indexes: filters before vectors |
| 237 | + |
| 238 | +By default, when you search Qdrant, it scores vectors first and filters results afterward. That means if you ask for |
| 239 | +"OAuth 2.0 implementation in payment-service", Qdrant would still compare your query vector against *every* stored |
| 240 | +symbol — then throw away the ones that don't match. |
| 241 | + |
| 242 | +Payload indexes flip this order. **semcode** indexes six fields — `language`, `service`, `symbol_type`, `chunk_tier`, |
| 243 | +`parent_name`, `file_path` — so Qdrant can narrow the candidate set *before* any vector math happens. The |
| 244 | +vector search then runs only over the matching symbols, not the whole collection. |
| 245 | + |
| 246 | +### A second, simpler collection |
| 247 | + |
| 248 | +Code symbols aren't the only RAG corpus in **semcode**. A separate `git_commits` collection stores commit messages and |
| 249 | +diff metadata as dense-only points. |
| 250 | + |
| 251 | +--- |
| 252 | + |
| 253 | +## Section 5 — Indexing flow: incremental, content-addressed |
| 254 | + |
| 255 | +Embedding API calls are the dominant cost in any indexing run, and re-embedding an entire repository on every push would |
| 256 | +be expensive at scale. **semcode** avoids this by treating indexing as a diff operation: it uses git blob |
| 257 | +SHAs as content fingerprints to identify which files have changed, and only those files are parsed, embedded, and |
| 258 | +upserted. A service with 1,000 files where 10 changed sends 10 embedding requests, not 1,000. This section describes |
| 259 | +the full indexing pipeline. |
| 260 | + |
| 261 | +### Step 1 — Discovery via the Git Trees API |
| 262 | + |
| 263 | +The pipeline opens by calling GitHub's Trees API. One request returns every file in the repository tree. Crucially, |
| 264 | +each entry already includes the git `blob_sha` — git's own content hash for that file |
| 265 | +— without downloading a single byte of source code. |
| 266 | + |
| 267 | +### Step 2 — Hash comparison before any network I/O |
| 268 | + |
| 269 | +Before fetching any file content, the pipeline loads the `file_hash` values stored in the Qdrant payload for all |
| 270 | +already-indexed symbols in this service. It then compares each file's `blob_sha` |
| 271 | +against that map. If the hashes match, the file is skipped entirely — no HTTP download, no parsing, no embedding call. |
| 272 | +This is the core of the incremental design — instead of re-embedding every symbol on every run, only files whose content |
| 273 | +actually changed are embedded again. |
| 274 | + |
| 275 | +### Step 3 — Fetch, parse, embed, upsert |
| 276 | + |
| 277 | +For every file that is new or has a changed blob SHA, the pipeline fetches the content by SHA, |
| 278 | +parses it into `CodeSymbol` objects, builds both dense and sparse inputs as described in Section 3, |
| 279 | +and calls both embedding providers in a batch. |
| 280 | + |
| 281 | +The upsert is a **delete-then-insert at the file level**: all existing points whose `file_path` matches are removed |
| 282 | +first, then the freshly embedded points are inserted. This keeps the index clean when a file loses methods, |
| 283 | +gains new ones, or is restructured. |
| 284 | + |
| 285 | +### Step 4 — Cleanup pass for deleted files |
| 286 | + |
| 287 | +After the main loop, the pipeline diffs the current repo file set against every `file_path` that exists in Qdrant. |
| 288 | +Any path no longer present in the repo is deleted. |
| 289 | + |
| 290 | +--- |
| 291 | + |
| 292 | +## Section 6 — Hybrid retrieval at query time |
| 293 | + |
| 294 | +At query time, the same two-track split like in the ingestion phase runs in reverse. The query string goes through both |
| 295 | +encoders — the dense model turns it into a floating-point vector, the BM25 turns it into a sparse vector. |
| 296 | +Both are sent to Qdrant in a single call, which runs each retriever independently, ranks the top K×2 candidates |
| 297 | +from each, and produces two separate ranked lists. |
| 298 | + |
| 299 | +Qdrant then uses **Reciprocal Rank Fusion (RRF)** to merge those two ranked lists into one before returning the |
| 300 | +final top K results. For example, using the query _"find the method that retries failed payments"_ merge looks like |
| 301 | +this: |
| 302 | + |
| 303 | +1. Dense retriever returns its ranked list: |
| 304 | + `[retryWithBackoff (rank 1), processPayment (rank 2), PlaceOrderRequest (rank 3), ...]` |
| 305 | +2. Sparse retriever returns its ranked list: |
| 306 | + `[PlaceOrderRequest (rank 1), retryWithBackoff (rank 2), handleTimeout (rank 3), ...]` |
| 307 | +3. RRF scores each result with `1 / (k + rank)` from every list it appears in, then sums those contributions |
| 308 | +4. Everything is re-sorted by that combined score → one final list: |
| 309 | + `[retryWithBackoff, PlaceOrderRequest, processPayment, handleTimeout, ...]` |
| 310 | + |
| 311 | +`retryWithBackoff` ranked first in dense and second in sparse — both retrievers agreed, so it floats to the top. |
| 312 | +`PlaceOrderRequest` ranked first in sparse (exact token match) but third in dense — it still surfaces near the top |
| 313 | +because the sparse retriever was confident. `processPayment` only appeared in one list despite a good dense rank, |
| 314 | +so it scores lower. |
| 315 | + |
| 316 | +RRF rewards consistent rank across retrievers. The score it produces answers a simpler question: |
| 317 | +"how consistently did this result appear near the top across both dense and sparse retrievers?" |
| 318 | +--- |
| 319 | + |
| 320 | +## Conclusion |
| 321 | + |
| 322 | +Building a RAG system for code has its own challenges, is not just RAG with a different file types — |
| 323 | +it requires rethinking every layer of the pipeline, from how you chunk (by symbol, not paragraph) |
| 324 | +to how you embed (rich context for dense vectors, exact tokens for sparse vectors) to how you store |
| 325 | +(named vectors with a payload that carries as much signal as the vectors themselves). Hybrid |
| 326 | +dense+sparse retrieval with server-side RRF bridges the gap between intent-based queries and exact identifier lookups, |
| 327 | +giving you both in a single round-trip. The payload is half the system: without language, service, and type fields |
| 328 | +indexed as filters, every search scans the entire collection regardless of how good the vectors are. And without |
| 329 | +incremental indexing via blob SHAs, the embedding cost alone would make continuous reindexing impractical at any serious |
| 330 | +repository scale. Together these choices form a pipeline that stays accurate, stays fast, and stays affordable as the |
| 331 | +codebase grows. |
| 332 | + |
| 333 | +--- |
| 334 | + |
| 335 | +## Reference |
| 336 | + |
| 337 | +[Sparse Vectors](https://qdrant.tech/articles/sparse-vectors/) |
| 338 | +[Reciprocal Rank Fusion (RRF)](https://www.elastic.co/docs/reference/elasticsearch/rest-apis/reciprocal-rank-fusion) |
0 commit comments