Skip to content

fix: embed documents in batches with a per-batch budget and fair scheduling - #140

Merged
alex-feel merged 11 commits into
mainfrom
fix/embedding-batch-budget
Oct 26, 2026
Merged

alex-feel merged 11 commits into
mainfrom
fix/embedding-batch-budget

Conversation

@alex-feel

Copy link
Copy Markdown
Collaborator

Why

The embedding leg sent every chunk of a document to the provider in one request under one EMBEDDING_TIMEOUT_S window, and each retry re-sent the whole document. On a CPU Ollama deployment a text of a few hundred kilobytes therefore could not be stored or updated at all once its chunks needed more than the 300 s ceiling, and a small store issued while another client's document was embedding waited for that whole run, because the embedding slot was held for the entire document.

What changes

  • Documents are embedded in batches. The provider's retry and EMBEDDING_TIMEOUT_S now bound one request (one batch on the write path, one query on search), so a retry never re-sends chunks that are already embedded and the document's size no longer decides whether it can be stored. EMBEDDING_TIMEOUT_S accepts up to 3600 s.
  • The embedding semaphore slot is taken per batch and released between batches, so a caller that arrives while another document is embedding is served after that document's current batch instead of after its last one.
  • EMBEDDING_BATCH_SIZE (default auto) sizes batches from the measured seconds per character toward EMBEDDING_BATCH_TARGET_S (default 5 s), bounded between 1 and 64 chunks after a first batch of 4; an integer from 1 to 512 fixes the size for operators who know their provider.
  • A failed or timed-out batch reports the batch number, its chunk range and how far the document got; the document's outcome is logged at INFO and each provider request at DEBUG.
  • Documentation: the environment variable reference, the semantic search guide, server.json and the project guidance file describe the batches, the per-request timeout and the two settings.

Verification

  • Unit tests cover the sizer, the batch loop (order, sizes, a batch that fails once and is the only one re-sent, the per-batch timeout, slot release on failure, timeout and cancellation), the fairness of the slot handoff, the settings coercion and the server.json parity.
  • The shared real-server harness stores a document that takes three batches on both backends, in both protocol eras and with row-level security on; the full unit and integration suites pass.
  • On the deploy test stacks (CPU Ollama, qwen3-embedding:0.6b, default embedding settings) a 400 KB Markdown document stores and then updates on SQLite and on PostgreSQL: its 341 chunks embed in about 30 batches in about 135 s when the embedding model has the CPU to itself, and in about 70 smaller batches in about 300 s while a summary is generated beside it, the sizer following the measured throughput either way. A 3-chunk store issued during that run gets its embeddings in under 5 s. The per-batch slot bounds the embedding wait only: when summaries run on the same single-request Ollama model, a concurrent store's summary still waits for the running summary to finish.

…duling

A chunked document went to the embedding provider in one request, so one timeout bounded the whole document and a large text could not be stored at any allowed EMBEDDING_TIMEOUT_S.
Each batch of chunks is now one provider request with its own retries and total timeout, so a retry re-sends only the batch that failed.
EMBEDDING_BATCH_SIZE sizes the batches, by default from the measured seconds per chunk toward EMBEDDING_BATCH_TARGET_S, or as a fixed count.
Each batch holds its own embedding semaphore slot, so a caller that arrives during a long document is served after the current batch instead of after the whole document.
EMBEDDING_TIMEOUT_S bounds one attempt of one request and accepts up to 3600 seconds, and the error messages name the failing batch and the chunks embedded so far.
A 280-chunk document at batch 3, chunks 9-15, projects 3 + ceil(265 / 7) = 41 batches, so the examples now read batch 3 of 41.
The progress in a failed batch's error message counted the failing batch's own attempts, backoff and timeout, so a batch that exhausted its retries reported the chunks already embedded as taking at least the whole per-batch timeout.
The time now runs from the document's start to the arrival of the last successful batch's vectors.
The auto sizer learned seconds per chunk, so a one-chunk short text such as a one-line message made the full-size chunks of the next large document look cheap and its first batch ran far past EMBEDDING_BATCH_TARGET_S.
The sizer now keeps decayed sums of seconds and characters, and fills each batch with the document's next chunks while their predicted time stays within the target.
The settings, server.json and documentation describe the per-character sizing.
No test or fixture called _reset_batch_sizer, and tests that need a particular sizer patch _batch_sizer directly.
The test of the lazy build now patches _batch_sizer to None and checks that the fixed and the auto sizer come from the settings.
… three batches

With the shared auto sizer the twelve-chunk document could go out in one request once earlier short stores had measured a fast provider, so the check did not exercise batching.
Every harness server now runs with EMBEDDING_BATCH_SIZE=4, and the check's docstring and recorded detail state what it verifies: the store completes and semantic search finds the entry.
Each embedding batch holds one EMBEDDING_MAX_CONCURRENT slot, and a slot kept after a batch fails would leave every later store waiting without a timeout.
The new tests run a provider that raises, one that outlasts the batch timeout, and a batch whose task is cancelled, and assert the semaphore is free afterwards.
The comment above the offload of the chunk split states what the code does: the splitter's fast path at or below chunk_size decides whether the split runs on a worker thread.
The batching loop sends one embed_documents request per batch, so an INFO line per request repeated once per batch at the default log level without the batch position.
Each provider now logs its request at DEBUG beside the batching loop's per-batch line, and the document's start and completion stay at INFO as the semantic search guide states.
@github-actions

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  app/embeddings
  batching.py
  app/embeddings/providers
  langchain_azure.py
  langchain_huggingface.py
  langchain_ollama.py
  langchain_openai.py
  langchain_voyage.py
  app/settings
  embedding.py
  app/tools
  _generation.py
Project Total  

This report was generated by python-coverage-comment-action

@alex-feel
alex-feel merged commit c45f0be into main Oct 26, 2026
6 checks passed
@alex-feel
alex-feel deleted the fix/embedding-batch-budget branch October 26, 2026 06:01
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