This document covers how semcode indexes code from GitHub repositories into Qdrant, including incremental change detection and stale-entry cleanup.
Ingestion is managed by IndexPipeline (server/indexer/pipeline.py). For each configured service, the pipeline:
- Discovers all indexable files from GitHub
- Skips files whose content hasn't changed since the last index run
- Downloads changed file content, parses it into
CodeSymbolentries, generates dense and sparse embeddings, and upserts them into Qdrant - Removes index entries for files that have been deleted from the repository
The pipeline is triggered via the /reindex HTTP endpoint (streaming NDJSON progress) or the index_all MCP admin tool.
list_github_files() (server/indexer/github_source.py) issues a single recursive request to the GitHub Trees API:
GET /repos/{org}/{repo}/git/trees/{ref}?recursive=1
This returns the full file tree in one round-trip. Each entry is filtered by three criteria — all three must pass:
| Filter | Mechanism |
|---|---|
| Supported extension | is_supported_path(path) — parser registry knows which extensions are indexable |
| Root prefix | If ServiceConfig.root is set, only paths under that prefix are considered |
| Exclude patterns | ServiceConfig.exclude glob patterns (fnmatch, checked against both full path and basename) |
Surviving files become GitHubFile(rel_path, blob_sha) objects. The blob_sha is the git blob SHA — a content fingerprint that drives incremental indexing in the next stage.
Large-repo fallback: The GitHub Trees API has an undocumented response size limit. When a repo's tree exceeds it, GitHub sets
truncated: trueand returns only a partial tree. In that caselist_github_filesfalls back to a recursive per-subtree walk — fetchinggit/trees/<sha>for each directory (non-recursive, so each listing stays well under the limit), pruning subtrees outsideroot, and fetching siblings concurrently. This guarantees no files are silently dropped from large repos.
Before downloading anything, the pipeline loads every stored {file_path → blob_sha} pair from Qdrant in a paginated scroll:
existing_hashes = await self._store.get_indexed_file_hashes(svc.name)For each discovered file, the current blob_sha is compared against the stored value. If they match, the file is skipped — no download, no re-parsing, no re-embedding. This makes incremental runs cheap: only files whose content has actually changed incur network and CPU cost.
File paths are stored as {service_name}/{path_in_repo} (e.g., catalog-service/src/main/java/Foo.java), prefixed with the service name to prevent collisions when multiple services share a Qdrant collection.
Changed files are fetched by blob SHA (not by path + ref):
content = await fetch_blob_content(
settings.github_token,
svc.github_repo,
f.blob_sha,
client=http_client,
)Fetching by blob SHA is more efficient than path-based fetching during indexing: the SHA is already known from the tree response, and the blob API is a direct content lookup with no ref resolution overhead.
parse_file(content, stored_path) dispatches to the language-specific parser via the registry. The result is a list[CodeSymbol] — one entry per indexable symbol (class, method, function, interface, etc.).
If a file produces no symbols (empty, unsupported format, or parse failure), any existing index entries for that file are cleaned up and the file is skipped.
Two separate texts are produced per symbol — one for dense embeddings, one for sparse (BM25) embeddings. The strategies differ deliberately:
Produces a rich metadata preamble followed by the symbol's source. This text is designed to be semantically rich so the dense model can embed it into a meaningful vector:
Python method `process_order` in class `OrderService` (service: catalog-service)
Package/module: com.example.orders
HTTP endpoint: POST /orders
Annotations: @PostMapping, @Transactional
Processes an order and emits an OrderPlaced event... ← docstring (first 300 chars)
def process_order(self, order: Order) -> Result: ← signature
# full source code... ← source (fills remaining budget)
Metadata included (when present): language, symbol type, name, parent class, service name, package, Spring stereotypes, HTTP method/route, annotations (first 8), Lombok annotations, React.memo flag, docstring.
The whole embedding text (preamble + signature + docstring + source) is budgeted to EMBEDDING_MAX_CHARS, which defaults to a value derived from the configured EMBEDDINGS_PROVIDER's context window (22,000 chars for the 8K-token providers, 86,000 for Voyage's 32K window, 5,500 for Ollama's 2K window) rather than one global default — see configuration.md for the per-provider table. The metadata preamble and signature consume the budget first; the source fills whatever remains. When a symbol's source exceeds its budget it is truncated with a // ... (truncated) marker and a WARNING is logged (naming the symbol and file) so the loss is observable. Set EMBEDDING_MAX_CHARS explicitly to override the derived default.
Future direction — sub-chunking (deferred): Truncation still drops the tail of a genuinely oversized symbol (e.g. a 1,000-line class) from the dense vector; BM25 and the stored payload keep the full source. A complete fix would split such symbols into overlapping windows and emit multiple dense points each. That was deferred because it requires a chunk index in the point ID (
store/qdrant.py,_symbol_point_id) and search-result dedup so sub-chunks of one symbol don't crowd results. Watch the truncation warnings to judge whether it's worth it.
Simpler — only the functional code text, no metadata preamble:
signature + docstring + source
This text is then pre-processed by split_code_identifiers (see sparse-vectors.md) before BM25 encoding.
Both embedding calls are made sequentially per file batch:
dense_vectors = await self._embedder.embed_batch(texts_dense)
sparse_vectors = await self._sparse_embedder.embed_batch(texts_sparse)If either call raises an exception, the file is skipped and existing index entries are preserved until the next successful run.
Before inserting new vectors, all existing entries for the file are removed:
await self._store.delete_by_file(svc.name, stored_path)
await self._store.upsert_chunks(payloads, dense_vectors, sparse_vectors)This ensures clean replacement when symbols are added, removed, or renamed within a file. Each point's ID is a deterministic uuid5 derived from service:file_path:symbol_name:start_line, so symbols moving to a new line produce new IDs (handled correctly by the delete-first approach).
Each point carries a payload with 20+ fields (see Data Model below).
After all files are processed, the pipeline identifies paths that were in the previous index but are no longer present in the current GitHub tree (deleted files):
stale_paths = [p for p in existing_hashes if p not in all_stored_paths]
for stale_path in stale_paths:
await self._store.delete_by_file(svc.name, stale_path)The blob SHA acts as a zero-download change detector. The GitHub Trees API returns a blob SHA per file as part of the tree enumeration — no file download is needed to determine whether content has changed.
A forced reindex (force=True) bypasses the SHA comparison and reindexes every file.
| Field | Type | Description |
|---|---|---|
name |
str |
Symbol name (e.g., processOrder) |
symbol_type |
str |
class, method, function, interface, enum, record, hook, component, type |
language |
str |
java, python, typescript, go, etc. |
source |
str |
Raw source text of the symbol |
file_path |
str |
{service_name}/{path_in_repo} |
start_line / end_line |
int |
Line range in the file |
parent_name |
str | None |
Enclosing class/module name |
package |
str | None |
Java package or Python module path |
annotations |
list[str] |
Decorator/annotation names |
signature |
str |
Declaration line |
docstring |
str | None |
Documentation string |
extras |
dict |
Language-specific metadata (Spring stereotypes, HTTP routes, Lombok annotations, React flags) |
All CodeSymbol fields are stored verbatim, plus:
| Field | Description |
|---|---|
service |
Service name |
chunk_tier |
"method" if symbol has a parent, "class" otherwise |
file_hash |
blob SHA — the content fingerprint used for incremental indexing |
indexed_at |
ISO 8601 UTC timestamp of when this symbol was indexed |
Sequential embedding calls — embed_batch for dense and embed_batch for sparse are awaited sequentially. They are independent operations targeting different providers; wrapping them in asyncio.gather would reduce per-file embedding latency by ~50%.
No embedding retry — a transient API error on either embedding call causes the file to be silently skipped, leaving its existing index stale indefinitely. There is no exponential backoff or retry queue. Reindexing requires either a force reindex or waiting for the file's content to change.
BM25 text excludes metadata — _build_bm25_text produces only signature + docstring + source. Metadata present in the dense text (service name, language, symbol type) is absent. A BM25 query for "Python method" will not match unless the word "Python" or "method" appears in the source code itself.
delete-before-upsert gap — The pipeline deletes all entries for a file before upserting the new ones. If the process is interrupted between delete and upsert, the file has no index entries. The next incremental run will redownload and reindex the file correctly — but until then, queries miss the file entirely.
GitHub Trees truncation — Very large repositories may have their tree response silently truncated by the GitHub API. The pipeline logs a warning but does not retry or paginate to recover the missing entries.
Dense-embedding truncation drops the tail of huge symbols — The embedding text is budgeted to EMBEDDING_MAX_CHARS (provider-aware default, configurable). A symbol larger than the budget has its tail excluded from the dense vector (BM25 and the stored source keep everything), so semantic search over that tail relies on BM25 alone. Truncation now emits a WARNING; the deferred sub-chunking fix is described in the Embedding Text Construction section above.