Skip to content

perf: parallelize the code indexing pipeline - #130

Merged
GoodbyePlanet merged 8 commits into
mainfrom
perf/parallel-indexing-pipeline
Sep 14, 2026
Merged

GoodbyePlanet merged 8 commits into
mainfrom
perf/parallel-indexing-pipeline

Conversation

@GoodbyePlanet

Copy link
Copy Markdown
Owner

Closes #67.

Why

IndexPipeline.index_service processed 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:

  1. Concurrent blob fetch — a producer task fetches and parses behind a bounded semaphore (_FETCH_CONCURRENCY = 10, matching the existing _TREE_WALK_CONCURRENCY / _DIFF_CONCURRENCY), feeding a bounded queue so downloads continue while the previous group embeds.
  2. Cross-file batching — symbols accumulate across files up to the provider's batch size, exposed as a new EmbeddingProvider.batch_size property (128 for Voyage/OpenAI/Jina hosted, 32 for self-hosted Jina TEI and Ollama).
  3. Gathered embeds — dense and sparse now run under one 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_get retried 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 since post_with_retry landed. Rate limits still wait for GitHub's own Retry-After / X-RateLimit-Reset window; 5xx and transport errors use a short fixed backoff.
  • Qdrant writes now overlap across files within a batch (_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_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, so a thread hop there would be a data race. Commented in-code so it doesn't look like a missed optimization.

Progress current is now a monotone count of files resolved rather than a loop index, so it reaches total instead of jumping over skipped files. The frame contract is unchanged.

Testing

389 tests pass; ruff check / format clean. 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-code against live repos, so the wall-clock improvement is so far unmeasured.

Docs

Updated ingestion.md and the /reindex frame 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

GoodbyePlanet and others added 8 commits September 13, 2026 13:47
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>
@GoodbyePlanet
GoodbyePlanet merged commit aded285 into main Sep 14, 2026
2 checks passed
@GoodbyePlanet
GoodbyePlanet deleted the perf/parallel-indexing-pipeline branch September 14, 2026 06:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Indexing pipeline is fully sequential — batch symbols and run embeds concurrently

1 participant