perf(knowledge): the write batch was tied to the embed batch, and they want opposites - #243
Merged
Conversation
…y want opposites
Moving the vector store to Postgres made `code_symbol_embed` go 38 min -> 65 min on a
full rebuild. One number governed both the embedding and the write, which cost nothing
while ChromaDB's write was a local file and cost a great deal once it was a statement
over a network.
The two constraints are opposed. Embedding is memory-bound: the ONNX model pads every
document to 256 tokens, so activations are sized by the batch — 967 MiB at 200 against
415 MiB at 8, and an unbounded batch is what SIGKILLed the worker. Writing is
round-trip-bound. Measured against the production database with the embedder stubbed
out, 800 rows:
batch= 8 16.67 s (100 round-trips, ~167 ms each)
batch=100 2.31 s ( 8 round-trips)
batch=400 1.92 s ( 2 round-trips)
A fixed ~167 ms per statement, paid 3 196 times over a full rebuild.
PGVECTOR_WRITE_BATCH_SIZE defaults to 100, the embed batch stays at 8, and the write
batch can never be smaller than the embed batch. End to end on the same 800 rows:
13.89 s -> 2.96 s, ranking unchanged to four decimal places (0.2234 / 0.7867 / 1.0161).
HNSW maintenance was the first suspect and is NOT the cause. Worth recording, because
it is the plausible answer: measured server-side, 8 000 rows of 384 dimensions cost
1 376 ms with no index against 12 348 ms with one — 9x per row, but only ~44 s across
the whole corpus, nowhere near the tens of minutes observed. Batching the write would
have been the wrong fix had the index been the cause; it is the right one only because
it was not.
repo_index_job_timeout_seconds follows the measurement to 21600. The pgvector rebuild
measured 15 051 s end to end (15:52 -> 20:02, chained code<->DB sync included) against
12 039 s on ChromaDB. The write fix should bring that down and has not been re-measured
end to end, so the ceiling is sized from what WAS measured rather than what is expected.
Verified in production, with the event that used to empty the store:
before restart 34 038 vectors, visible to a fresh one-off dyno
restart both dynos
after restart 34 038 vectors — handle.count() = 34 038, force_full would not fire
Both halves matter. Surviving the restart is the fix; being visible to a DIFFERENT dyno
is the half ChromaDB could never do. The rebuild reached pipeline_end, the checkpoint
was deleted (which only happens on success), and the chained sync ran unprompted —
code_db_sync 20:01:51 -> 20:02:51. Map after: 136 matched / 97 db_only / 23 code_only /
0 mismatch.
Suppression ceiling: `# noqa` 128 -> 129 for the DocEmbedding re-export, which carries
the convention the other forty-two imports in that file already carry. A second one was
added and then deleted — annotating an inner function's argument cost one word, which
is the answer this ratchet exists to provoke.
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.
The regression, and the wrong suspect
Moving the vector store to Postgres (#241) made
code_symbol_embedgo 38 min → 65 min on a full rebuild.The obvious culprit is HNSW index maintenance on insert. Measured server-side, 8 000 rows of 384 dimensions:
9× is real — and it comes to only ~44 s across our whole corpus. Nowhere near the tens of minutes observed. The index is not the cause, and batching the write would have been the wrong fix had it been.
The real one
One number governed both the embedding and the write. That cost nothing while ChromaDB's write was a local file, and a great deal once it was a statement over a network. The two constraints are opposed:
A fixed ~167 ms per statement, paid 3 196 times over a full rebuild.
PGVECTOR_WRITE_BATCH_SIZEdefaults to 100, the embed batch stays at 8, and the write batch can never be smaller than the embed batch. End to end on the same 800 rows: 13.89 s → 2.96 s, ranking unchanged to four decimal places (0.2234 / 0.7867 / 1.0161).Ceiling follows the measurement
repo_index_job_timeout_seconds→ 21600. The pgvector rebuild measured 15 051 s end to end (15:52 → 20:02 on 2026-08-28, chained code↔DB sync included) against 12 039 s on ChromaDB. The write fix should bring that down and has not been re-measured end to end, so the ceiling is sized from what was measured rather than from what is expected.Verified in production, with the event that used to empty the store
Both halves matter. Surviving the restart is the fix; being visible to a different dyno is the half ChromaDB could never do, because
webandworkerhold separate filesystems.The rebuild reached
pipeline_end, the checkpoint was deleted (which only happens on success), and the chained sync ran unprompted —code_db_sync20:01:51 → 20:02:51. Map after: 136 matched / 97 db_only / 23 code_only / 0 mismatch.Verification
pytest tests/— 7003 passed, 4 skipped, 1 xfailed.ruff format --check,ruff check,mypy app/— clean, 0 errors.# noqaceiling 128 → 129 for theDocEmbeddingre-export, which carries the convention the other forty-two imports in that file already carry. A second suppression was added and then deleted — annotating an inner function's argument cost one word, which is the answer the ratchet exists to provoke.