Repository navigation
feat(indexing): reuse embeddings, batch large reindexes, prune stale chunks - #8
Merged
Merged
Conversation
Store a SHA-256 content_hash per chunk and look up existing embeddings by hash before calling Voyage, so ingest, sync and reindex only pay for text the namespace has not seen. Duplicate texts within a run are embedded once. Incremental sync/reindex now embed before deleting and swap old chunks for new in one transaction, so a Voyage failure no longer leaves gaps. Existing tables are backfilled with content_hash on next setup. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Full reindexes with at least VOYAGE_BATCH_THRESHOLD (default 2000) new texts are submitted to the Voyage Batch API (33% cheaper, <= 12 h). Sync records the batch in rag_pending_batches, skips the namespace while it is in flight, then loads the output into rag_embedding_cache and runs the full reindex from the cache. Incremental syncs stay real time. ingest.py gains --batch for first-time indexing. voyageai 0.5.0 has no batch support, so rag/voyage_batch.py calls the Files and Batches REST endpoints directly. Spec: specs/pipelines/batch-embedding.md Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A full reindex now deletes chunks the checkout no longer produces (removed or newly skipped files, and chunk indexes past a file's new length) once every upsert has succeeded. It refuses to run when the checkout yields no indexable files, so a bad clone cannot wipe the index. Document the sync cron as hourly: up-to-date namespaces cost one GitHub API call, only changed text is embedded, and Railway skips a run while the previous one is still going. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Spec-conformance pass against the (renamed) embedding-sync spec: - BL-009: a failed batch now drops every pending row for the namespace, not just the failed one, so the next run resubmits uncached texts instead of embedding them in real time - BL-005: document that ingest.py --batch registers no SHA - Add BL-011..015 for hash reuse, in-run dedup, the transactional incremental swap, full-reindex pruning and the empty-checkout guard - Tests named by AC; add AC-006 (mixed batches), AC-007 (model and input_type), AC-008 (output parsing) and AC-010 (SQL backfill hash, runs when TEST_DATABASE_URL is set) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Findings from /code-review --fix: - reindex.py now creates the batch tables it reads (rag_embedding_cache) so it cannot fail with UndefinedTable on a fresh deploy - Batch output is streamed line by line and cached in pages of 1000, so a 100K-line output no longer has to fit in memory - sync rolls back the shared connection after a namespace fails, so one failure no longer aborts every later namespace in the run Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…threshold /code-review --fix (8-angle review) surfaced and this fixes three bugs: - delete_chunks_except(conn, table, []) wiped the whole table instead of deleting nothing, because execute_values is a silent no-op on an empty list, leaving the keep_ids temp table empty and the anti-join DELETE matching every row. A full reindex of a repo whose docs (or code) table produces zero chunks this run silently wiped that table. Confirmed with a direct Postgres repro before and after the fix. Fixed at both layers: the function now no-ops on an empty keep set, and ingest()'s prune loop skips a zero-chunk table rather than calling it with one. - batch_threshold() raised SystemExit, which is a BaseException, not an Exception, so sync.py's per-namespace `except Exception` never caught it. A malformed VOYAGE_BATCH_THRESHOLD would abort the whole cron run instead of failing just the namespace attempting a full reindex. Now raises ValueError. - The collecting-reindex branch of sync_namespace advanced the SHA and cleared batch state without checking whether the reindex it just ran actually succeeded, unlike the other two full-reindex call sites. Hardened to match them; unreachable as a live bug today but closes the gap for any future ingest() change. Adds BL-016..018 / AC-014..017 to specs/pipelines/embedding-sync.md and tests for all three (one against real Postgres). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
All 17 acceptance criteria now pass: by automated test, and AC-001, AC-002 and AC-005 additionally confirmed live — a real-time ingest below threshold, a live Voyage Batch API submission, and its collection into a full reindex that reused 100% of the batch's cached embeddings with zero real-time Voyage calls. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…stuck batches Second /code-review --fix pass (8-angle review) on the pushed PR, with all findings consolidated and verified before fixing: - setup_table() ran its column/backfill/index migration unconditionally on every call. ALTER TABLE takes its lock before checking IF NOT EXISTS, so with sync now hourly this repeated a lock acquisition against a live table the MCP server concurrently reads, on every tick, forever, for no effect after the first run. Gated behind an information_schema existence check; confirmed with a mocked test that the ALTER/backfill/index calls are skipped once content_hash exists. - ingest() never closed its database connection if embedding, batch submission or pruning raised — a real connection leak, worse now that sync.py calls it in a per-namespace loop. Wrapped the function body in try/finally. - get_embeddings_by_hash's DISTINCT ON had no ORDER BY, so its tie-break between duplicate rows for a hash was plan-dependent. Added a deterministic ORDER BY. - register.py --sha (documented as forcing a full reindex) didn't clear a namespace's pending Voyage batch, so a stuck or in-flight batch would silently override it — sync.py checks pending batches before ever looking at last_commit_sha. Now also clears pending batch rows and cached embeddings, giving operators a supported recovery path for a batch that can never resolve. - Restored per-file delete logging and flush=True on progress prints, both dropped by the reindex/sync refactor and useful for diagnosing a stuck job against Railway's piped logs. - Three test names and one module docstring brought in line with the team's conventions (test_ac_XXX prefix; don't justify an implementation by the alternative it replaces). Adds BL-019..021 / AC-018..020 to specs/pipelines/embedding-sync.md. Not fixed, considered and documented as known limitations: a claimed Voyage 'expired' batch status (not in Voyage's documented set, unlike OpenAI's Batch API), and several reuse/duplication suggestions (shared SQL/HTTP helpers) deferred as broad refactors. Co-Authored-By: Claude Sonnet 5 <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.
Summary
content_hash. Ingest, sync and reindex look up an existing embedding by hash before calling Voyage, so unchanged text is never re-embedded, and identical texts within one run are embedded once. Incremental sync/reindex now embed before deleting anything and swap old chunks for new in a single transaction, so a Voyage failure leaves the index as it was.ingest.py --batch) with at leastVOYAGE_BATCH_THRESHOLD(default 2000) new texts submits them to the Voyage Batch API (33% cheaper, up to 12h) instead of embedding in real time. A submitting sync run leaves the namespace as it is; later runs skip it while the batch is in flight, then load the results intorag_embedding_cacheand finish the reindex from the cache. Incremental syncs always stay real time.0 * * * *instead of daily — each run only embeds changed text, a namespace with nothing new costs one GitHub API call, and Railway skips a run while the previous one is still going.specs/pipelines/embedding-sync.md, statusimplemented, 18BLclauses / 17ACs.Linear / spec
specs/pipelines/embedding-sync.mdTest plan
BLclauses checked against the code clause by clause; all 17ACs have a passing automated test (uv run pytest, 27 passed — 3 requireTEST_DATABASE_URL, which the suite skips cleanly without it).python .maui/scripts/check_spec_anchors.py/check_bl_length.py: all clauses anchored, all within two sentences./code-review --fix(8-angle parallel review): found and fixed 3 real bugs —delete_chunks_except(conn, table, [])wiped the whole table instead of deleting nothing (execute_valuessilently skips an emptyINSERT, leaving the "keep" set empty so the anti-join matched everything). Would have silently emptied a namespace's docs (or code) table on any full reindex where that table produced zero chunks. Reproduced and fixed against a real Postgres instance.batch_threshold()raisedSystemExit, whichsync.py's per-namespaceexcept Exceptiondoesn't catch — one badVOYAGE_BATCH_THRESHOLDwould have aborted the whole cron run instead of failing just that namespace. Now raisesValueError.sync_namespaceadvanced state without checking whether its own reindex succeeded, unlike the other two full-reindex call sites. Hardened to match (unreachable as a live bug today, closes a gap for future changes).patient-dedupe-matching):ingest.py --repo ... --batchbelow the default threshold → real-time embed, 567 chunks, namespace registered at its SHA (AC-001).ingest.py --repo ... --batchwithVOYAGE_BATCH_THRESHOLD=1against a fresh namespace → submitted one real batch, no real-time embedding, namespace registered with no SHA (AC-002).sync.pycollected the completed batch, cached all 567 output embeddings, then ran a full reindex that reused 100% of them from the cache with zero real-time Voyage calls, and advanced the SHA to the repo's current HEAD (AC-005, AC-008).Risk / rollback
content_hashbackfill the first time they're set up again after this merges.OQ-001(spec): Voyage doesn't document a max batch upload file size; 100 MB per batch is a conservative, untested assumption — the live test only exercised a 567-line file.0 * * * *in the dashboard (not stored in the repo).rag_pending_batches,rag_embedding_cache) and column (content_hash) are additive.Checklist
DV-XXX/OQ-XXXrows deleted (not annotated) —OQ-001remains open (unresolved, not resolved)🤖 Generated with Claude Code
Update: second
/code-review --fixpassAfter pushing, I re-ran
/code-review --fix(8-angle review) against the updated branch. All findings were consolidated and verified before fixing. Four more real issues, fixed:setup_table()ran its column/backfill/index migration unconditionally on every call.ALTER TABLEtakes its lock before checkingIF NOT EXISTS, so with sync now hourly this repeated a lock acquisition against a live table the MCP server concurrently reads, on every tick, forever, for no effect after the first run. Gated behind aninformation_schemaexistence check.ingest()never closed its database connection if embedding, batch submission or pruning raised — a real connection leak, worse now thatsync.pycalls it in a per-namespace loop. Wrapped the function body intry/finally.get_embeddings_by_hash'sDISTINCT ONhad noORDER BY, making its tie-break between duplicate rows for a hash plan-dependent. Added a deterministicORDER BY.register.py --sha ""(documented as forcing a full reindex) didn't clear a namespace's pending Voyage batch, so a stuck or in-flight batch would silently override it —sync.pychecks pending batches before ever looking atlast_commit_sha. Now also clears pending batch rows and cached embeddings, giving operators a supported recovery path for a batch that can never resolve.Also restored per-file delete logging and
flush=Trueon progress prints (both dropped by the reindex/sync refactor, useful for diagnosing a stuck job against Railway's piped logs), and fixed three test names and one module docstring to match the team's conventions.Adds
BL-019..021/AC-018..020to the spec. Full suite: 33 passed (30 + 3 Postgres-gated, which also ran this time).Considered, not fixed: a claimed Voyage
expiredbatch status (not in Voyage's documented status set, unlike OpenAI's Batch API — checked against their docs, not acted on); several reuse/duplication suggestions (shared SQL/HTTP helpers, a shared batching helper) deferred as broad refactors rather than bugs.