perf: parallelize the code indexing pipeline - #130
Merged
Merged
Conversation
The indexing pipeline needs to know how many texts a provider accepts per request so it can accumulate symbols into full batches. The value already existed as a private `_BATCH_SIZE` in each provider module but was not reachable through the protocol. The protocol property carries a concrete default body rather than `...`: every bundled provider subclasses EmbeddingProvider, so an ellipsis would silently return None for any provider that forgot to override it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The indexing loop processed one file at a time: blob fetch, then dense embed, then sparse embed, then upsert. Network round-trips never overlapped, and a single file yields only a handful of symbols, so the pipeline sent dozens-to-hundreds of tiny embedding requests to providers that accept 128 inputs each. Restructured into three stages inside index_service: - a producer task fetches and parses with a bounded semaphore, following the shape already used by fetch_commits_with_diffs - a batcher accumulates symbols across files up to the provider's batch_size and runs the dense and sparse embeds under one gather — dense is network-bound and sparse runs in a thread executor, so they overlap for free - a writer keeps upsert-before-delete per file The unit of store mutation stays one file, so per-file error isolation is unchanged. A batch-level embedding failure falls back to embedding that batch file by file, so one bad file costs an extra round-trip rather than dropping its whole batch. parse_file deliberately stays on the event loop: registry.py shares one parser instance per language and tree-sitter parsers are not safe for concurrent use. Progress `current` is now a monotone count of files resolved rather than a loop index, which reaches `total` instead of jumping over skipped files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each test was checked against a mutated pipeline to confirm it fails for the defect it targets: reusing one slice for every file breaks the vector split test, a semaphore of 1 breaks the concurrency test, and removing the per-file retry breaks the batch-containment test. _symbols_for names symbols uniquely per file on purpose — _build_embedding_text does not include the file path, so same-named symbols in different files produce identical embedding texts and make a text-to-vector lookup ambiguous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also corrects two entries that were already stale before this change: the upsert section still described delete-before-upsert, which the pipeline stopped doing in 2cdf49c, and the matching "delete-before-upsert gap" observation described a window that no longer exists. Replaced the latter with the rate-limit exposure that concurrent fetching does introduce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The final emit after the drain loop repeated the last batch's frame verbatim. Progress frames are now strictly increasing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There is nothing to isolate, so the retry only repeats the call that just failed. Keeps the single-file failure path to one attempt, as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_gh_get already retried 403/429 but let transport errors, timeouts and 5xx fall straight through. Concurrent blob fetching makes exactly those more likely, and the embedding path has handled all three since post_with_retry landed. Rate limits keep waiting for the window GitHub names; 5xx and transport errors use a short fixed backoff, since they carry no such hint and usually clear immediately. Also corrects the ingestion doc note added in 95c8da5, which claimed fetch_blob_content had no rate-limit handling at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each file's own upsert-then-delete stays ordered, so the index never has a window with zero symbols for a file. Only different files overlap, and _symbol_point_id includes the file path, so two files can never contend for the same point. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #67.
Why
IndexPipeline.index_serviceprocessed one file at a time: blob fetch → parse → dense embed → sparse embed → upsert. Network round-trips never overlapped, and since a single file yields only a handful of symbols, the pipeline sent dozens-to-hundreds of tiny embedding requests to providers that accept 128 inputs each.What changed
All three recommendations from the issue:
_FETCH_CONCURRENCY = 10, matching the existing_TREE_WALK_CONCURRENCY/_DIFF_CONCURRENCY), feeding a bounded queue so downloads continue while the previous group embeds.EmbeddingProvider.batch_sizeproperty (128 for Voyage/OpenAI/Jina hosted, 32 for self-hosted Jina TEI and Ollama).asyncio.gather. Dense is network-bound and sparse runs in a thread executor, so they overlap for free.Plus two follow-ups found while implementing:
_gh_getretried only rate limits. Transport errors, timeouts and 5xx fell straight through — exactly what concurrent fetching makes more likely, and what the embedding path has handled sincepost_with_retrylanded. Rate limits still wait for GitHub's ownRetry-After/X-RateLimit-Resetwindow; 5xx and transport errors use a short fixed backoff._WRITE_CONCURRENCY = 4).Design notes
The unit of store mutation stays one file. Batching happens only in the embedding stage, which touches no store state — that is what preserves per-file error isolation and the upsert-before-delete invariant. A batch-level embedding failure falls back to embedding that batch file by file, so one bad file costs an extra round-trip instead of dropping every file batched alongside it.
parse_filedeliberately stays on the event loop.registry.pyshares one parser instance per language and tree-sitter parsers are not safe for concurrent use, so a thread hop there would be a data race. Commented in-code so it doesn't look like a missed optimization.Progress
currentis now a monotone count of files resolved rather than a loop index, so it reachestotalinstead of jumping over skipped files. The frame contract is unchanged.Testing
389 tests pass;
ruff check/formatclean. All 26 pre-existing pipeline tests pass byte-identical — a deliberate constraint, since needing to edit one would have signalled semantic drift.Each new test was mutation-checked rather than trusted on a green run. Reusing one slice for every file, a semaphore of 1, dropping the per-file fallback, removing the progress dedupe, serializing writes, flipping delete-before-upsert, and removing either retry path each fail exactly the intended test and nothing else.
Not yet run: the end-to-end
make index-codeagainst live repos, so the wall-clock improvement is so far unmeasured.Docs
Updated
ingestion.mdand the/reindexframe docs. This also corrects two entries that were already stale: the upsert section still described delete-before-upsert, which the pipeline stopped doing in 2cdf49c.🤖 Generated with Claude Code