Skip to content

fix: shrink oversized embedding input instead of dropping the document - #90

Merged
venkateshsakamuri-lab merged 2 commits into
mainfrom
fix/embedding-context-length-shrink
Aug 29, 2026
Merged

fix: shrink oversized embedding input instead of dropping the document#90
venkateshsakamuri-lab merged 2 commits into
mainfrom
fix/embedding-context-length-shrink

Conversation

@geekypunk

Copy link
Copy Markdown
Contributor

Problem

createEmbeddingOrEmpty logs a failed embedding as "transient/non-fatal" and moves on. For a rate limit that's right. For an over-length input it isn't: the input is deterministic, so the same document fails on every rebuild, and the index keeps a permanent hole that looks like a passing run.

Observed on a 567-table MySQL connection during a full reindex:

Skipping embedding for RELATIONSHIP document b7efe9d2-… due to
transient/non-fatal error: 400: Invalid 'input': maximum context length is 8192 tokens.

Cause: the character budget, not a missing one

truncate already cut input to app.embedding.max-chars (default 30,000) on the stated assumption of "roughly 4 chars per token" — which would be 7,500 tokens, safely under 8,192.

That ratio holds for prose. It does not hold for what this service actually embeds: schema and relationship documents are dense identifiers, underscores, punctuation and repeated scaffolding, which tokenize closer to 2–3 chars per token. At that density 30,000 chars is 10,000–15,000 tokens, and the provider rejects the call.

Fix

No fixed ratio is safe across content, so this stops betting on one.

LlmErrorCategory.CONTEXT_LENGTH already documents itself as "never retry; the caller may trim" — until now nothing trimmed. embedWithShrink halves the budget on each CONTEXT_LENGTH rejection (30,000 → 15,000 → … → 1,875, floor 1,000) and lets the provider decide when the call fits. No tokenizer dependency, and correct for any content and any model window.

Deliberately narrow

  • Only CONTEXT_LENGTH shrinks. Retries and fail-open are untouched for every other category — a smaller input answers nothing about a rate limit or a rejected credential.
  • The inner attempt runs with fail-open off so the rejection reaches the shrink loop; fail-open would convert it into an empty vector indistinguishable from a real one. The operator's fail-open setting is still honoured once shrinking is exhausted.
  • Batch shrinking only affects members longer than the budget, so one oversized text costs the short ones nothing.
  • Bounded at 4 halvings, so a pathological document cannot loop.

Test plan

  • shrinksTheInputWhenTheProviderRejectsItAsTooLong — succeeds after one halving; asserts the two calls were 30,000 then 15,000 chars.
  • givesUpAfterTheShrinkFloorAndStillHonoursFailOpen — 5 attempts then empty, proving the bound.
  • doesNotShrinkForFailuresThatShrinkingCannotFix — AUTH gets exactly one call.

Follow-up (not in this PR)

The "transient/non-fatal" wording in TrainingService.createEmbeddingOrEmpty is what made this invisible for so long — it asserts transience for six call sites without knowing the category. Worth revisiting separately.

🤖 Generated with Claude Code

`createEmbeddingOrEmpty` logs a failed embedding as "transient/non-fatal" and
moves on. For a rate limit that is right. For an over-length input it is not:
the input is deterministic, so the same document fails on every rebuild, and
the index keeps a permanent hole that looks like a passing run.

Observed on a 567-table MySQL connection during a full reindex:

    Skipping embedding for RELATIONSHIP document b7efe9d2-... due to
    transient/non-fatal error: 400: Invalid 'input': maximum context length
    is 8192 tokens.

The cause is the character budget, not a missing one. `truncate` already cut
input to `app.embedding.max-chars` (default 30,000) on the stated assumption of
"roughly 4 chars per token", which would be 7,500 tokens. That ratio holds for
prose. It does not hold for what this service embeds: schema and relationship
documents are dense identifiers, underscores, punctuation and repeated
scaffolding, which tokenize closer to 2-3 chars per token. At that density
30,000 chars is 10,000-15,000 tokens and the provider rejects the call.

No fixed ratio is safe across content, so this stops betting on one.
`LlmErrorCategory.CONTEXT_LENGTH` already documents itself as "never retry; the
caller may trim" — until now nothing trimmed. `embedWithShrink` halves the
budget on each CONTEXT_LENGTH rejection (30,000 -> 15,000 -> ... -> 1,875,
floor 1,000) and lets the provider decide when the call fits. That needs no
tokenizer dependency and stays correct for any content and any model window.

Deliberately narrow:
- Only CONTEXT_LENGTH shrinks. Retries and fail-open are untouched for every
  other category, since a smaller input answers nothing about a rate limit or
  a rejected credential.
- The inner attempt runs with fail-open off so the rejection reaches the shrink
  loop; fail-open would convert it into an empty vector indistinguishable from
  a real one. The operator's fail-open setting is still honoured once shrinking
  is exhausted.
- Shrinking a batch only affects members longer than the budget, so one
  oversized text costs the short ones nothing.
- Bounded at 4 halvings, so a pathological document cannot loop.

Tests cover all three paths: shrink-then-succeed, exhaust-then-fail-open, and
no-shrink for a category shrinking cannot fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@geekypunk
geekypunk requested a review from a team as a code owner August 28, 2026 21:53
…e ceiling

Six issues from review, one of which partly defeated the original fix.

1. The budget was seeded from `maxChars` rather than from the text being sent,
   so `truncate` (which only cuts when length > budget) made the first halvings
   byte-identical resends. A document below maxChars/2^4 — 1,875 chars at the
   default — could never shrink at all: five identical rejected calls and then
   the same permanent index hole this change exists to close, at 5x the cost.
   The budget now starts at min(maxChars, longest input).

2. The batch comment claimed one oversized member "cannot cost the short ones
   any content". Untrue: the budget is per-request, so a halving forced by one
   text also trims every other member above the new budget. The comment now
   says so, and seeds from the longest member. Also notes that a provider may
   reject on the request's aggregate token count, where trimming members is the
   right lever but the floor may arrive before the batch fits.

3. Intermediate attempts ran through `attempt(..., mayFailOpen=false)`, whose
   `handleFailure` logs before it rethrows — so every successful shrink emitted
   "Embedding failed" with a stack trace for a call that then succeeded, and a
   terminal failure logged twice with contradictory failOpen values. The retry
   loop is now `attemptOrThrow`, with logging left to the single terminal
   `handleFailure`.

4. The 4-arg `attempt` overload became unreachable once both callers moved to
   `embedWithShrink`; removed.

5. `givesUpAfterTheShrinkFloor...` asserted the attempt cap, not the floor —
   at a 30,000 ceiling the cap is always reached first. Renamed to say what it
   tests, and a real floor test added at a 1,500 ceiling.

6. The shrink log printed the budget as though it were the payload size, which
   would mislead exactly the person debugging (1).

New tests: shrinksRelativeToTheInputNotTheConfiguredCeiling (regression for 1,
fails on the previous commit) and stopsAtTheCharacterFloorRatherThanEmbedding
ATokenOfContent (coverage for 5).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@venkateshsakamuri-lab
venkateshsakamuri-lab merged commit 64a0085 into main Aug 29, 2026
9 checks passed
@venkateshsakamuri-lab
venkateshsakamuri-lab deleted the fix/embedding-context-length-shrink branch August 29, 2026 05: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.

2 participants