diff --git a/backend/src/main/java/com/dbaagent/service/EmbeddingService.java b/backend/src/main/java/com/dbaagent/service/EmbeddingService.java index 835094b..a2d1742 100644 --- a/backend/src/main/java/com/dbaagent/service/EmbeddingService.java +++ b/backend/src/main/java/com/dbaagent/service/EmbeddingService.java @@ -31,6 +31,10 @@ public class EmbeddingService { /** Retry budget: 2 retries after the first try, matching the pre-delegation service. */ private static final int MAX_RETRY_ATTEMPTS = 2; + /** Halvings allowed before giving up: 30,000 chars reaches ~1,875 in four. */ + private static final int MAX_SHRINK_ATTEMPTS = 4; + /** Floor for shrinking. Below this the document is too small to be worth indexing. */ + private static final int MIN_EMBEDDING_CHARS = 1_000; private static final long RETRY_BACKOFF_MS = 750L; /** @@ -87,9 +91,23 @@ public EmbeddingService( this.maxRetryAfter = maxRetryAfter; } - /** text-embedding-3-large accepts 8192 tokens, roughly 4 chars per token. */ - private String truncate(String text) { - return (text != null && text.length() > maxChars) ? text.substring(0, maxChars) : text; + /** + * Cut {@code text} to a character budget. + * + *

The budget is a guess at the model's token window, and it is wrong often + * enough to matter. The old default assumed "roughly 4 chars per token", which holds + * for prose but not for what this service actually embeds: schema and relationship + * documents are dense identifiers — {@code ORDERS.customer_id}, underscores, + * punctuation, repeated scaffolding — that tokenize closer to 2-3 chars per token. At + * that density the 30,000-char default is 10,000-15,000 tokens, well past the 8,192 a + * text-embedding-3-large call accepts, and the provider rejects the request outright. + * + *

No fixed ratio is safe across content, so the budget is not trusted to be right. + * {@link #embedWithShrink} lets the provider's own CONTEXT_LENGTH rejection drive the + * budget down until the call fits. + */ + private String truncate(String text, int budget) { + return (text != null && text.length() > budget) ? text.substring(0, budget) : text; } /** @@ -98,8 +116,62 @@ private String truncate(String text) { public List createEmbedding(String text) { LlmCredentials credentials = requireCredentials(); LlmEmbeddingProvider provider = registry.embeddingProvider(credentials.providerId()); - return attempt(provider, credentials, - () -> provider.embed(truncate(text), credentials), List.of()); + return embedWithShrink(provider, credentials, + budget -> provider.embed(truncate(text, budget), credentials), + List.of(), + text == null ? 0 : text.length()); + } + + /** + * Run an embedding call, halving the character budget each time the provider says the + * input is too long. + * + *

{@link LlmErrorCategory#CONTEXT_LENGTH} already documents itself as "never retry; + * the caller may trim" — but until now no caller trimmed. The rejection fell through to + * fail-open, the document was skipped, and because the input is deterministic it was + * skipped again on every subsequent rebuild. That is a permanent hole in the index + * wearing the costume of a transient blip. + * + *

Shrinking is deliberately driven by the provider rather than by counting tokens + * locally: it needs no tokenizer dependency, and it stays correct for any content and + * any model window, including ones whose ratio we have never measured. + * + *

Only CONTEXT_LENGTH shrinks. Every other category keeps its existing behaviour — + * retries and fail-open are unchanged — because shrinking the input answers nothing + * about a rate limit or a bad credential. + */ + private T embedWithShrink(LlmEmbeddingProvider provider, LlmCredentials credentials, + java.util.function.IntFunction call, T failOpenValue, + int longestInput) { + // Anchor to what is actually being sent, not to the configured ceiling. Seeding + // from maxChars makes the first halvings no-ops whenever the input is already + // under it — the same bytes, resent and rejected — and leaves anything below + // maxChars/2^MAX_SHRINK_ATTEMPTS unable to shrink at all, which is the very + // document this method exists to rescue. + int budget = Math.min(maxChars, Math.max(longestInput, 1)); + for (int shrink = 0; ; shrink++) { + final int attemptBudget = budget; + try { + // attemptOrThrow, not attempt: a CONTEXT_LENGTH rejection has to reach us + // unlogged. Fail-open would turn it into an empty vector indistinguishable + // from a real one, and the logging path would report a failure for a call + // that is about to succeed. + return attemptOrThrow(provider, credentials, () -> call.apply(attemptBudget)); + } catch (RuntimeException e) { + LlmErrorCategory category = provider.classify(e); + if (category != LlmErrorCategory.CONTEXT_LENGTH + || shrink >= MAX_SHRINK_ATTEMPTS + || budget <= MIN_EMBEDDING_CHARS) { + // Out of room to shrink, or not a length problem: honour the operator's + // fail-open setting exactly as before this method existed. This is the + // one place a terminal failure is logged. + return handleFailure(category, credentials, e, failOpenValue, failOpen); + } + int next = Math.max(MIN_EMBEDDING_CHARS, budget / 2); + log.warn("Embedding rejected as too long at {} chars sent; retrying at {}", budget, next); + budget = next; + } + } } /** @@ -110,10 +182,23 @@ public List createEmbedding(String text) { public List> createEmbeddings(List texts) { LlmCredentials credentials = requireCredentials(); LlmEmbeddingProvider provider = registry.embeddingProvider(credentials.providerId()); - List truncated = texts.stream().map(this::truncate).toList(); - return attempt(provider, credentials, - () -> provider.embedBatch(truncated, credentials), - Collections.nCopies(texts.size(), List.of())); + // The budget is per-request, not per-member, so a halving forced by one oversized + // text also trims every other member above the new budget. That is a real cost and + // not something this method can avoid: the provider rejects the request, not a + // document, and it does not say which member was at fault. Seeding from the + // longest member keeps the first halving meaningful; callers that cannot afford + // collateral truncation should embed individually. + // + // Note also that a provider may reject on the request's AGGREGATE token count, in + // which case trimming members is the right lever but the floor may be reached + // before the batch fits. + int longest = texts.stream().filter(java.util.Objects::nonNull) + .mapToInt(String::length).max().orElse(0); + return embedWithShrink(provider, credentials, + budget -> provider.embedBatch( + texts.stream().map(t -> truncate(t, budget)).toList(), credentials), + Collections.nCopies(texts.size(), List.of()), + longest); } /** @@ -166,10 +251,6 @@ public int dimensions() { *

The decision is {@link LlmErrorCategory#isRetryable()}, not a message substring: * that taxonomy exists precisely so retry policy stops being provider-specific. */ - private T attempt(LlmEmbeddingProvider provider, LlmCredentials credentials, - Supplier call, T failOpenValue) { - return attempt(provider, credentials, call, failOpenValue, failOpen); - } /** * As above, but with fail-open decided per call site rather than by configuration. @@ -180,6 +261,25 @@ private T attempt(LlmEmbeddingProvider provider, LlmCredentials credentials, */ private T attempt(LlmEmbeddingProvider provider, LlmCredentials credentials, Supplier call, T failOpenValue, boolean mayFailOpen) { + try { + return attemptOrThrow(provider, credentials, call); + } catch (RuntimeException e) { + return handleFailure(provider.classify(e), credentials, e, failOpenValue, mayFailOpen); + } + } + + /** + * The retry loop with no opinion about failure: exhausted retries rethrow. + * + *

Separating this from {@link #handleFailure} is what lets {@link #embedWithShrink} + * treat a CONTEXT_LENGTH rejection as a step in a working algorithm rather than an + * incident. Routing intermediate attempts through the logging path made every + * successful shrink emit "Embedding failed" and a stack trace for a call that then + * succeeded — noise that would fire any alert keyed on that string, and log a terminal + * failure twice with contradictory failOpen values. + */ + private T attemptOrThrow(LlmEmbeddingProvider provider, LlmCredentials credentials, + Supplier call) { for (int retries = 0; ; retries++) { try { return call.get(); @@ -187,7 +287,7 @@ private T attempt(LlmEmbeddingProvider provider, LlmCredentials credentials, LlmErrorCategory category = provider.classify(e); if (retries >= MAX_RETRY_ATTEMPTS || !category.isRetryable() || !backoff(retries, category, credentials, retryAfterHint(provider, e))) { - return handleFailure(category, credentials, e, failOpenValue, mayFailOpen); + throw e; } } } diff --git a/backend/src/test/java/com/dbaagent/service/EmbeddingServiceTest.java b/backend/src/test/java/com/dbaagent/service/EmbeddingServiceTest.java index 56fadea..c51391f 100644 --- a/backend/src/test/java/com/dbaagent/service/EmbeddingServiceTest.java +++ b/backend/src/test/java/com/dbaagent/service/EmbeddingServiceTest.java @@ -476,4 +476,123 @@ void cosineSimilarityOfIdenticalVectorsIsOne() { assertThat(service.cosineSimilarity(List.of(1.0, 2.0, 3.0), List.of(1.0, 2.0, 3.0))) .isCloseTo(1.0, org.assertj.core.data.Offset.offset(1e-9)); } + + @Test + void shrinksTheInputWhenTheProviderRejectsItAsTooLong() { + var resolver = mock(LlmConfigResolver.class); + var registry = mock(LlmProviderRegistry.class); + var provider = mock(LlmEmbeddingProvider.class); + + when(resolver.resolveEmbedding()).thenReturn(creds()); + when(registry.embeddingProvider("openai")).thenReturn(provider); + // Stands in for a model whose real token window is reached well before the + // character budget: anything over 15,000 chars is rejected outright. + when(provider.embed(anyString(), any())).thenAnswer(call -> { + String sent = call.getArgument(0); + if (sent.length() > 15_000) { + throw new RuntimeException("maximum context length is 8192 tokens"); + } + return List.of(0.5); + }); + when(provider.classify(any())).thenReturn(LlmErrorCategory.CONTEXT_LENGTH); + + var service = new EmbeddingService(resolver, registry, 30_000, false); + + // Without shrinking this document is dropped forever; the point of the fix is + // that it comes back embedded rather than empty. + assertThat(service.createEmbedding("x".repeat(50_000))).containsExactly(0.5); + + var captor = ArgumentCaptor.forClass(String.class); + verify(provider, times(2)).embed(captor.capture(), any()); + assertThat(captor.getAllValues().get(0)).hasSize(30_000); + assertThat(captor.getAllValues().get(1)).hasSize(15_000); + } + + @Test + void givesUpAfterTheAttemptCapAndStillHonoursFailOpen() { + var resolver = mock(LlmConfigResolver.class); + var registry = mock(LlmProviderRegistry.class); + var provider = mock(LlmEmbeddingProvider.class); + + when(resolver.resolveEmbedding()).thenReturn(creds()); + when(registry.embeddingProvider("openai")).thenReturn(provider); + when(provider.embed(anyString(), any())) + .thenThrow(new RuntimeException("maximum context length is 8192 tokens")); + when(provider.classify(any())).thenReturn(LlmErrorCategory.CONTEXT_LENGTH); + + var service = new EmbeddingService(resolver, registry, 30_000, true); + + // Bounded by MAX_SHRINK_ATTEMPTS, not by MIN_EMBEDDING_CHARS: at a 30,000 + // ceiling the cap is reached first. The floor is covered separately below. + assertThat(service.createEmbedding("x".repeat(50_000))).isEmpty(); + verify(provider, times(5)).embed(anyString(), any()); // 30k, 15k, 7.5k, 3.75k, 1.875k + } + + @Test + void doesNotShrinkForFailuresThatShrinkingCannotFix() { + var resolver = mock(LlmConfigResolver.class); + var registry = mock(LlmProviderRegistry.class); + var provider = mock(LlmEmbeddingProvider.class); + + when(resolver.resolveEmbedding()).thenReturn(creds()); + when(registry.embeddingProvider("openai")).thenReturn(provider); + when(provider.embed(anyString(), any())).thenThrow(new RuntimeException("nope")); + when(provider.classify(any())).thenReturn(LlmErrorCategory.AUTH); + + var service = new EmbeddingService(resolver, registry, 30_000, true); + + assertThat(service.createEmbedding("x".repeat(50_000))).isEmpty(); + // A rejected credential says nothing about input size; retrying smaller would + // just multiply the failed calls. + verify(provider, times(1)).embed(anyString(), any()); + } + + @Test + void shrinksRelativeToTheInputNotTheConfiguredCeiling() { + var resolver = mock(LlmConfigResolver.class); + var registry = mock(LlmProviderRegistry.class); + var provider = mock(LlmEmbeddingProvider.class); + + when(resolver.resolveEmbedding()).thenReturn(creds()); + when(registry.embeddingProvider("openai")).thenReturn(provider); + when(provider.embed(anyString(), any())).thenAnswer(call -> { + String sent = call.getArgument(0); + if (sent.length() > 2_000) { + throw new RuntimeException("maximum context length is 8192 tokens"); + } + return List.of(0.5); + }); + when(provider.classify(any())).thenReturn(LlmErrorCategory.CONTEXT_LENGTH); + + // 4,000 chars against a 30,000 ceiling. Seeding the budget from the ceiling would + // make the first halvings no-ops — 30,000 and 15,000 both send the same 4,000 + // bytes — and burn the attempt cap on identical rejected calls. + new EmbeddingService(resolver, registry, 30_000, true) + .createEmbedding("x".repeat(4_000)); + + var captor = ArgumentCaptor.forClass(String.class); + verify(provider, times(2)).embed(captor.capture(), any()); + assertThat(captor.getAllValues().get(0)).hasSize(4_000); + assertThat(captor.getAllValues().get(1)).hasSize(2_000); + } + + @Test + void stopsAtTheCharacterFloorRatherThanEmbeddingATokenOfContent() { + var resolver = mock(LlmConfigResolver.class); + var registry = mock(LlmProviderRegistry.class); + var provider = mock(LlmEmbeddingProvider.class); + + when(resolver.resolveEmbedding()).thenReturn(creds()); + when(registry.embeddingProvider("openai")).thenReturn(provider); + when(provider.embed(anyString(), any())) + .thenThrow(new RuntimeException("maximum context length is 8192 tokens")); + when(provider.classify(any())).thenReturn(LlmErrorCategory.CONTEXT_LENGTH); + + // A ceiling low enough that MIN_EMBEDDING_CHARS (1,000) is what stops the loop, + // not the attempt cap: 1,500 -> 1,000 -> give up. + var service = new EmbeddingService(resolver, registry, 1_500, true); + + assertThat(service.createEmbedding("x".repeat(5_000))).isEmpty(); + verify(provider, times(2)).embed(anyString(), any()); + } }