Skip to content

Commit 4386ad8

Browse files
Merge pull request #44 from GoodbyePlanet/feat/add-blog
feat: Add blog section
2 parents c62af84 + c71482f commit 4386ad8

2 files changed

Lines changed: 338 additions & 1 deletion

File tree

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ build/
2121
*.iml
2222
.idea/
2323

24-
blog.md
2524

2625
# Local config (use config.example.yaml as a template)
2726
config.yaml

blog.md

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
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

Comments
 (0)