Skip to content

Commit dd45324

Browse files
committed
feat: Add section on indexing flow
1 parent fcfa7f5 commit dd45324

1 file changed

Lines changed: 42 additions & 33 deletions

File tree

blog.md

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -250,16 +250,54 @@ diff metadata as dense-only points.
250250

251251
---
252252

253-
## Section 5 — Hybrid retrieval at query time
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
254293

255294
At query time, the same two-track split like in the ingestion phase runs in reverse. The query string goes through both
256295
encoders — the dense model turns it into a floating-point vector, the BM25 turns it into a sparse vector.
257296
Both are sent to Qdrant in a single call, which runs each retriever independently, ranks the top K×2 candidates
258297
from each, and produces two separate ranked lists.
259298

260299
Qdrant then uses **Reciprocal Rank Fusion (RRF)** to merge those two ranked lists into one before returning the
261-
final top K results. The merge looks like this step by step, using the query _"find the method that retries
262-
failed payments"_ as an example:
300+
final top K results. For example, using the query _"find the method that retries failed payments"_ merge looks like this:
263301

264302
1. Dense retriever returns its ranked list:
265303
`[retryWithBackoff (rank 1), processPayment (rank 2), PlaceOrderRequest (rank 3), ...]`
@@ -278,36 +316,7 @@ RRF rewards consistent rank across retrievers. The score it produces answers a s
278316
"how consistently did this result appear near the top across both dense and sparse retrievers?"
279317
---
280318

281-
## Section 6 — Indexing flow: incremental, content-addressed
282-
283-
- Walk the repo (GitHub API or local), apply excludes
284-
- For each file: compute blob SHA → compare against payload's `file_hash` → skip if unchanged
285-
- Parse → build dense + sparse inputs → batch-embed → upsert (delete-then-insert per file path)
286-
- Cleanup pass removes stale symbols for files no longer in the repo
287-
- Reference: `server/indexer/pipeline.py:128-249`
288-
- Why this matters: embedding API costs amortize across reindexes; large monorepos stay tractable
289-
290-
---
291-
292-
## Section 7 — Bonus: indexing git history as a second RAG corpus
293-
294-
- Separate pipeline embeds **commit messages + file deltas** into the `git_commits` collection
295-
- Dense-only (commit messages are short, sparse adds little)
296-
- Enables "when was retry logic introduced?" style queries
297-
- Reference: `server/indexer/git_history.py:24-63`, `server/tools/history.py`
298-
299-
---
300-
301-
## Section 8 — What I'd do differently / open questions
302-
303-
- Re-ranker on top of RRF (cross-encoder) — worth the latency?
304-
- Per-language collections vs single collection — when does the trade-off flip?
305-
- Embedding the *call graph* (cross-symbol relationships), not just symbols in isolation
306-
- Tuning the 6000-char source cap per language
307-
308-
---
309-
310-
## Section 9 — Takeaways
319+
## Section 7 — Takeaways
311320

312321
- Symbol-level chunking + rich, language-aware embedding inputs are the foundation
313322
- Hybrid dense+sparse with RRF gives you both "intent" and "exact name" search for free, server-side

0 commit comments

Comments
 (0)