Skip to content

feat(indexing): reuse embeddings, batch large reindexes, prune stale chunks - #8

Merged
julianam-w merged 8 commits into
mainfrom
feature/embedding-cost-reduction
Oct 12, 2026
Merged

julianam-w merged 8 commits into
mainfrom
feature/embedding-cost-reduction

Conversation

@julianam-w

@julianam-w julianam-w commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Embedding reuse: every stored chunk carries a SHA-256 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.
  • Voyage Batch API for large full reindexes: a full reindex (no baseline SHA, a diff too large for GitHub's compare API, or ingest.py --batch) with at least VOYAGE_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 into rag_embedding_cache and finish the reindex from the cache. Incremental syncs always stay real time.
  • Index pruning: a full reindex now deletes chunks the checkout no longer produces (removed/newly-skipped files, truncated chunk ranges) once every upsert has succeeded, and refuses to run on a checkout with no indexable files.
  • Hourly sync: documented the cron as 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.
  • Spec: specs/pipelines/embedding-sync.md, status implemented, 18 BL clauses / 17 ACs.

Linear / spec

  • No Linear issue.
  • Spec: specs/pipelines/embedding-sync.md

Test plan

  • Spec conformance: all 18 BL clauses checked against the code clause by clause; all 17 ACs have a passing automated test (uv run pytest, 27 passed — 3 require TEST_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 —
    1. delete_chunks_except(conn, table, []) wiped the whole table instead of deleting nothing (execute_values silently skips an empty INSERT, 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.
    2. batch_threshold() raised SystemExit, which sync.py's per-namespace except Exception doesn't catch — one bad VOYAGE_BATCH_THRESHOLD would have aborted the whole cron run instead of failing just that namespace. Now raises ValueError.
    3. The batch-collection branch of sync_namespace advanced 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).
  • Live end-to-end, against the real database and Voyage API (patient-dedupe-matching):
    • ingest.py --repo ... --batch below the default threshold → real-time embed, 567 chunks, namespace registered at its SHA (AC-001).
    • ingest.py --repo ... --batch with VOYAGE_BATCH_THRESHOLD=1 against a fresh namespace → submitted one real batch, no real-time embedding, namespace registered with no SHA (AC-002).
    • sync.py collected 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

  • A namespace's existing tables get a one-off content_hash backfill 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.
  • Follow-up outside this PR: the Railway cron schedule still needs changing to 0 * * * * in the dashboard (not stored in the repo).
  • Rollback: revert the merge commit. No destructive migration — the new tables (rag_pending_batches, rag_embedding_cache) and column (content_hash) are additive.

Checklist

  • CI green
  • Docs updated if applicable
  • Spec status updated if implementing/changing a spec
  • Spec change-log squashed to a single merge-dated entry; resolved DV-XXX / OQ-XXX rows deleted (not annotated) — OQ-001 remains open (unresolved, not resolved)

🤖 Generated with Claude Code

Update: second /code-review --fix pass

After 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 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.
  • 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, making its tie-break between duplicate rows for a hash 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.

Also restored per-file delete logging and flush=True on 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..020 to the spec. Full suite: 33 passed (30 + 3 Postgres-gated, which also ran this time).

Considered, not fixed: a claimed Voyage expired batch 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.

julianam-w and others added 8 commits October 12, 2026 17:43
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>
@julianam-w
julianam-w merged commit 496ce5c into main Oct 12, 2026
@julianam-w
julianam-w deleted the feature/embedding-cost-reduction branch October 12, 2026 13:29
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.

1 participant