Skip to content

fix(cascade): embed a file's changed entries in one batched call - #489

Merged
gloryfromca merged 1 commit into
mainfrom
fix/cascade-embed-batch
Oct 13, 2026
Merged

gloryfromca merged 1 commit into
mainfrom
fix/cascade-embed-batch

Conversation

@gloryfromca

Copy link
Copy Markdown
Member

Summary

Fixes #486. Every daily-log cascade handler embedded one entry per HTTP request.
BaseDailyLogHandler._embed_entries gathered _build_row over the changed
entries, and each _build_row awaited embed_or_none -> provider.embed(text)
-> _embed_chunk([text]). embed_batch, which chunks by
[embedding].batch_size, was reachable only from the vector backfill, so
batch_size had no effect on the live write path.

Because the provider (and its semaphore) is a process-wide singleton, a turn
that writes ~20 atomic facts put ~20 single-input requests in front of the next
search's query embedding. The query then waited roughly ceil(N / max_concurrent)
endpoint round trips instead of one.

Subclasses now declare what they embed in a new _embed_texts(entry); the base
flattens one file's texts into a single embed_batch call and hands each entry
its vectors, so _build_row became a pure field mapping. Two adjacent changes
came out of review:

  • OpenAIEmbeddingProvider._embed_chunk now rejects a response whose vector
    count differs from the input count. Callers slice a batch back per input, so a
    short list would silently shift every later vector onto the wrong text, and
    the row's content_sha256 already matches, so a later sync would not re-embed
    it.
  • [embedding].max_concurrent now defaults to 10, and the EmbeddingSettings
    class default (a dead 50, always overridden by the shipped TOML) is aligned to
    the same value.

Not addressed here: #473 (query embedding shares the cascade's semaphore). The
two fixes are complementary -- batching shortens the queue, a separate lane
keeps the query out of it.

Area

  • Architecture method
  • Benchmark
  • Use case
  • Documentation
  • Developer experience
  • CI, build, or release

Verification

make ci passes on this branch (exit 0): lint + import-linter contracts +
2678 unit passed / 4 skipped + 186 integration passed / 7 skipped.

Measured end to end through the real EpisodeHandler.handle_added_or_modified
and the shipped OpenAIEmbeddingProvider, pointed at a local stub
/v1/embeddings of fixed 1 s latency, with 23 entries in one file at
max_concurrent=5, batch_size=10. A query embedding is issued concurrently
while the cascade pass runs:

                          before    after
HTTP requests (incl. query)   24        4   (10 + 10 + 3 + query)
query embedding wait        5.13 s   1.05 s
cascade pass                5.32 s   1.24 s

An independent reviewer reproduced this against a baseline rebuilt from
origin/main and extended it: with a latency model calibrated to the DeepInfra
measurement in the issue (0.3 s + 0.07 s per input), the query wait goes
2.22 s -> 0.38 s; across a 50-file worker drain, 54.35 s -> 11.25 s.

The three new tests were each verified to fail against a mutated source (per-text
embed loop; embed_batch_or_none returning [] when unavailable; reversed
batch order) and to pass after restoring.

Checklist

  • I kept the change scoped to the relevant area.
  • I am opening this from a separate branch, not pushing directly to main.
  • I updated docs, examples, or setup notes when behavior changed.
  • I added or updated tests when the change affects behavior.
  • I did not commit secrets, .env files, dependency folders, or generated output.
  • Active relative links in Markdown files resolve.

Notes for Reviewers

The contract change. BaseDailyLogHandler gained an abstract
_embed_texts, and _build_row gained a vectors keyword. All four subclasses
are in this diff; no other subclass or caller exists in src, tests, or the
sibling repos. agent_skill / knowledge_topic still use embed_or_none on
purpose: they are single-file kinds with one text each, so there is no fan-out
to batch.

Failure scope is unchanged. Before and after, an embedding error leaves
_embed_entries before any LanceDB write, so the whole file fails and nothing
is written. The per-row fallback that _backfill.py uses for poison rows was
deliberately not copied to the live path; a 400 now takes down a chunk of up to
batch_size rather than one request, which is invisible at file granularity.

One request now carries up to batch_size texts while timeout_seconds
still bounds one request.
On a slow serialized endpoint (CPU Ollama, a single
GPU) queued batch requests can exceed the 30 s default where single-input
requests did not. docs/configuration.md now says to lower batch_size and
max_concurrent together, or raise timeout_seconds, for that deployment
shape. Scaling the timeout with chunk length would be the thorough fix and is
left out of this PR.

On max_concurrent 5 -> 10. It is not what fixes the reported case: with
batching, single-file query wait measures the same at 5 and at 10. It halves the
wait only for a backlog on an endpoint that can serve 10 requests in parallel.
Reviewers who favour keeping 5 should say so -- the batching change stands on
its own.

🤖 Generated with Claude Code

The four daily-log handlers each called the embedding endpoint once per
entry: _embed_entries gathered _build_row over the changed entries and
every _build_row awaited embed_or_none. embed_batch, which chunks by
[embedding].batch_size, was only ever used by the vector backfill, so
batch_size had no effect on the live write path.

One turn that writes ~20 atomic facts therefore queued ~20 single-input
requests on the process-wide embedding semaphore, and the next search's
query embedding waited behind all of them -- about five times the
endpoint's latency at the previous max_concurrent of 5. Measured against
a stub endpoint of 1 s per request, 23 entries: 24 requests and 5.13 s
of query wait before, 4 requests and 1.05 s after.

Subclasses now declare the texts they embed in _embed_texts; the base
sends every text of a file in one embed_batch call and hands each entry
its vectors. _build_row became a pure mapping. The provider now rejects
a response whose vector count differs from the input count: callers
slice the batch back per input, so a short list would silently shift
every later vector onto the wrong text.

[embedding].max_concurrent now defaults to 10, and the EmbeddingSettings
class default (a dead 50, always overridden by the shipped TOML) is
aligned to it.

Refs: #486

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gloryfromca
gloryfromca merged commit a345d08 into main Oct 13, 2026
11 checks passed
@gloryfromca
gloryfromca deleted the fix/cascade-embed-batch branch October 13, 2026 02:25
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.

[Bug]: cascade embeds one request per entry -- _embed_entries never uses embed_batch, inflating the shared semaphore queue ~Nx

2 participants