Skip to content

Commit f93f483

Browse files
geekypunkclaude
andcommitted
fix: shrink oversized embedding input instead of dropping the document
`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>
1 parent 4366ef0 commit f93f483

2 files changed

Lines changed: 141 additions & 8 deletions

File tree

backend/src/main/java/com/dbaagent/service/EmbeddingService.java

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ public class EmbeddingService {
3131

3232
/** Retry budget: 2 retries after the first try, matching the pre-delegation service. */
3333
private static final int MAX_RETRY_ATTEMPTS = 2;
34+
/** Halvings allowed before giving up: 30,000 chars reaches ~1,875 in four. */
35+
private static final int MAX_SHRINK_ATTEMPTS = 4;
36+
/** Floor for shrinking. Below this the document is too small to be worth indexing. */
37+
private static final int MIN_EMBEDDING_CHARS = 1_000;
3438
private static final long RETRY_BACKOFF_MS = 750L;
3539

3640
/**
@@ -87,9 +91,23 @@ public EmbeddingService(
8791
this.maxRetryAfter = maxRetryAfter;
8892
}
8993

90-
/** text-embedding-3-large accepts 8192 tokens, roughly 4 chars per token. */
91-
private String truncate(String text) {
92-
return (text != null && text.length() > maxChars) ? text.substring(0, maxChars) : text;
94+
/**
95+
* Cut {@code text} to a character budget.
96+
*
97+
* <p>The budget is a <em>guess</em> at the model's token window, and it is wrong often
98+
* enough to matter. The old default assumed "roughly 4 chars per token", which holds
99+
* for prose but not for what this service actually embeds: schema and relationship
100+
* documents are dense identifiers — {@code ORDERS.customer_id}, underscores,
101+
* punctuation, repeated scaffolding — that tokenize closer to 2-3 chars per token. At
102+
* that density the 30,000-char default is 10,000-15,000 tokens, well past the 8,192 a
103+
* text-embedding-3-large call accepts, and the provider rejects the request outright.
104+
*
105+
* <p>No fixed ratio is safe across content, so the budget is not trusted to be right.
106+
* {@link #embedWithShrink} lets the provider's own CONTEXT_LENGTH rejection drive the
107+
* budget down until the call fits.
108+
*/
109+
private String truncate(String text, int budget) {
110+
return (text != null && text.length() > budget) ? text.substring(0, budget) : text;
93111
}
94112

95113
/**
@@ -98,8 +116,52 @@ private String truncate(String text) {
98116
public List<Double> createEmbedding(String text) {
99117
LlmCredentials credentials = requireCredentials();
100118
LlmEmbeddingProvider provider = registry.embeddingProvider(credentials.providerId());
101-
return attempt(provider, credentials,
102-
() -> provider.embed(truncate(text), credentials), List.of());
119+
return embedWithShrink(provider, credentials,
120+
budget -> provider.embed(truncate(text, budget), credentials),
121+
List.of());
122+
}
123+
124+
/**
125+
* Run an embedding call, halving the character budget each time the provider says the
126+
* input is too long.
127+
*
128+
* <p>{@link LlmErrorCategory#CONTEXT_LENGTH} already documents itself as "never retry;
129+
* the caller may trim" — but until now no caller trimmed. The rejection fell through to
130+
* fail-open, the document was skipped, and because the input is deterministic it was
131+
* skipped again on every subsequent rebuild. That is a permanent hole in the index
132+
* wearing the costume of a transient blip.
133+
*
134+
* <p>Shrinking is deliberately driven by the provider rather than by counting tokens
135+
* locally: it needs no tokenizer dependency, and it stays correct for any content and
136+
* any model window, including ones whose ratio we have never measured.
137+
*
138+
* <p>Only CONTEXT_LENGTH shrinks. Every other category keeps its existing behaviour —
139+
* retries and fail-open are unchanged — because shrinking the input answers nothing
140+
* about a rate limit or a bad credential.
141+
*/
142+
private <T> T embedWithShrink(LlmEmbeddingProvider provider, LlmCredentials credentials,
143+
java.util.function.IntFunction<T> call, T failOpenValue) {
144+
int budget = maxChars;
145+
for (int shrink = 0; ; shrink++) {
146+
final int attemptBudget = budget;
147+
try {
148+
// mayFailOpen=false: a CONTEXT_LENGTH rejection has to reach us. Fail-open
149+
// would turn it into an empty vector indistinguishable from a real one.
150+
return attempt(provider, credentials, () -> call.apply(attemptBudget), failOpenValue, false);
151+
} catch (RuntimeException e) {
152+
LlmErrorCategory category = provider.classify(e);
153+
if (category != LlmErrorCategory.CONTEXT_LENGTH
154+
|| shrink >= MAX_SHRINK_ATTEMPTS
155+
|| budget <= MIN_EMBEDDING_CHARS) {
156+
// Out of room to shrink, or not a length problem: honour the operator's
157+
// fail-open setting exactly as before this method existed.
158+
return handleFailure(category, credentials, e, failOpenValue, failOpen);
159+
}
160+
int next = Math.max(MIN_EMBEDDING_CHARS, budget / 2);
161+
log.warn("Embedding input rejected as too long at {} chars; retrying at {}", budget, next);
162+
budget = next;
163+
}
164+
}
103165
}
104166

105167
/**
@@ -110,9 +172,11 @@ public List<Double> createEmbedding(String text) {
110172
public List<List<Double>> createEmbeddings(List<String> texts) {
111173
LlmCredentials credentials = requireCredentials();
112174
LlmEmbeddingProvider provider = registry.embeddingProvider(credentials.providerId());
113-
List<String> truncated = texts.stream().map(this::truncate).toList();
114-
return attempt(provider, credentials,
115-
() -> provider.embedBatch(truncated, credentials),
175+
// Shrinking the budget only touches texts longer than it, so one oversized member
176+
// of a batch cannot cost the short ones any content.
177+
return embedWithShrink(provider, credentials,
178+
budget -> provider.embedBatch(
179+
texts.stream().map(t -> truncate(t, budget)).toList(), credentials),
116180
Collections.nCopies(texts.size(), List.of()));
117181
}
118182

backend/src/test/java/com/dbaagent/service/EmbeddingServiceTest.java

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,4 +476,73 @@ void cosineSimilarityOfIdenticalVectorsIsOne() {
476476
assertThat(service.cosineSimilarity(List.of(1.0, 2.0, 3.0), List.of(1.0, 2.0, 3.0)))
477477
.isCloseTo(1.0, org.assertj.core.data.Offset.offset(1e-9));
478478
}
479+
480+
@Test
481+
void shrinksTheInputWhenTheProviderRejectsItAsTooLong() {
482+
var resolver = mock(LlmConfigResolver.class);
483+
var registry = mock(LlmProviderRegistry.class);
484+
var provider = mock(LlmEmbeddingProvider.class);
485+
486+
when(resolver.resolveEmbedding()).thenReturn(creds());
487+
when(registry.embeddingProvider("openai")).thenReturn(provider);
488+
// Stands in for a model whose real token window is reached well before the
489+
// character budget: anything over 15,000 chars is rejected outright.
490+
when(provider.embed(anyString(), any())).thenAnswer(call -> {
491+
String sent = call.getArgument(0);
492+
if (sent.length() > 15_000) {
493+
throw new RuntimeException("maximum context length is 8192 tokens");
494+
}
495+
return List.of(0.5);
496+
});
497+
when(provider.classify(any())).thenReturn(LlmErrorCategory.CONTEXT_LENGTH);
498+
499+
var service = new EmbeddingService(resolver, registry, 30_000, false);
500+
501+
// Without shrinking this document is dropped forever; the point of the fix is
502+
// that it comes back embedded rather than empty.
503+
assertThat(service.createEmbedding("x".repeat(50_000))).containsExactly(0.5);
504+
505+
var captor = ArgumentCaptor.forClass(String.class);
506+
verify(provider, times(2)).embed(captor.capture(), any());
507+
assertThat(captor.getAllValues().get(0)).hasSize(30_000);
508+
assertThat(captor.getAllValues().get(1)).hasSize(15_000);
509+
}
510+
511+
@Test
512+
void givesUpAfterTheShrinkFloorAndStillHonoursFailOpen() {
513+
var resolver = mock(LlmConfigResolver.class);
514+
var registry = mock(LlmProviderRegistry.class);
515+
var provider = mock(LlmEmbeddingProvider.class);
516+
517+
when(resolver.resolveEmbedding()).thenReturn(creds());
518+
when(registry.embeddingProvider("openai")).thenReturn(provider);
519+
when(provider.embed(anyString(), any()))
520+
.thenThrow(new RuntimeException("maximum context length is 8192 tokens"));
521+
when(provider.classify(any())).thenReturn(LlmErrorCategory.CONTEXT_LENGTH);
522+
523+
var service = new EmbeddingService(resolver, registry, 30_000, true);
524+
525+
// Shrinking is bounded, so a pathological document cannot loop forever.
526+
assertThat(service.createEmbedding("x".repeat(50_000))).isEmpty();
527+
verify(provider, times(5)).embed(anyString(), any()); // 30k, 15k, 7.5k, 3.75k, 1.875k
528+
}
529+
530+
@Test
531+
void doesNotShrinkForFailuresThatShrinkingCannotFix() {
532+
var resolver = mock(LlmConfigResolver.class);
533+
var registry = mock(LlmProviderRegistry.class);
534+
var provider = mock(LlmEmbeddingProvider.class);
535+
536+
when(resolver.resolveEmbedding()).thenReturn(creds());
537+
when(registry.embeddingProvider("openai")).thenReturn(provider);
538+
when(provider.embed(anyString(), any())).thenThrow(new RuntimeException("nope"));
539+
when(provider.classify(any())).thenReturn(LlmErrorCategory.AUTH);
540+
541+
var service = new EmbeddingService(resolver, registry, 30_000, true);
542+
543+
assertThat(service.createEmbedding("x".repeat(50_000))).isEmpty();
544+
// A rejected credential says nothing about input size; retrying smaller would
545+
// just multiply the failed calls.
546+
verify(provider, times(1)).embed(anyString(), any());
547+
}
479548
}

0 commit comments

Comments
 (0)