Skip to content

fix(retain): bound and recover degenerate fact-extraction output - #3657

Open
kamilfurtak wants to merge 7 commits into
vectorize-io:mainfrom
kamilfurtak:agent/retain-saturation-and-recovery
Open

fix(retain): bound and recover degenerate fact-extraction output#3657
kamilfurtak wants to merge 7 commits into
vectorize-io:mainfrom
kamilfurtak:agent/retain-saturation-and-recovery

Conversation

@kamilfurtak

@kamilfurtak kamilfurtak commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Bounds and recovery for degenerate fact-extraction output. Depends on #3656 — see
below.

The failure

Against a local OpenAI-compatible backend, the same 2376-token prompt produced
16 384 output tokens with finish_reason=length on five attempts and completed
normally in 554 tokens on the sixth. Input size, sampling parameters and chunk
size were all ruled out: the model stochastically degenerates into repeating
structurally valid fact objects, and nothing in the schema tells the grammar to
stop. This repo already documents the same conclusion for the split floor:

if a chunk this small still overflows the model's output cap, the cause is
degenerate or looping model output rather than genuinely dense input

1. Bound the facts array, and never accept the bound as success

The response schema gains a maxItems bound so a capable structured-output
backend stops the array itself. Reaching the bound is not treated as a
result — it raises OutputTooLongError and enters the existing split path, so the
bound can never silently truncate a genuinely dense chunk. In the worst case a
chunk is split unnecessarily; no facts are lost.

The bound is derived from retain_max_completion_tokens rather than fixed:

budget 4096  -> 24 facts
budget 65536 -> 385 facts
unset        -> 16 facts (floor)

An earlier revision hard-coded 16, which was tuned against a 4096-token ceiling. At
that ceiling a fact object costs roughly 85 output tokens, so a bound of 48 would
have landed exactly where the token limit already bites and achieved nothing.
Halving the budget keeps the item bound biting first. Field data behind the floor,
from 168 single-chunk extractions: median 2 facts, p95 10, max 29.

2. Fail instead of dropping, but keep what succeeded

When a chunk cannot be reduced further, the splitter previously returned an empty
result that the caller counted as a successful extraction — the operation reported
completed with the sub-chunk's data gone. It now raises.

The recursive split also gathered sub-chunk results without return_exceptions, so
one unsplittable half discarded facts the other half had already extracted. Results
are now collected tolerantly:

  • some halves fail, others succeed → keep the facts, log the partial failure at
    error level with counts
  • every half fails → re-raise, so the operation cannot report success

The remaining trade-off is deliberate: a partially-failed chunk still completes,
with the loss visible in logs rather than silent at the operation level.

Depends on #3656

That PR is what makes splits succeed for conversation payloads wrapped in an extra
array. This PR increases how often the split path is entered and makes the
unsplittable case fatal, so merging it alone would turn recoverable cases into hard
failures.

Tests

43 passed

Verified end-to-end on a document that previously lost its content: saturation at
the bound → OutputTooLongError → recursive split (2747 → 1824 + 922 → …) →
8/2/7/11 facts extracted, every call finish_reason=stop, no dropped sub-chunk.

Merge order

Both PRs are open at once and do not conflict in source. Merging #3656 first leaves
exactly one conflict here, in tests/test_fact_extraction_retry.py, where both
branches append new test functions at the end of the file — resolution is "keep
both". Happy to rebase once #3656 lands.

The fixed 16-fact bound was tuned against a small local model with a 4096-token
retain ceiling. Deployments with a larger ceiling would be pushed into constant
splitting, since 16 facts is a small fraction of what their budget allows.

Derive the bound from `retain_max_completion_tokens` instead, using an approximate
per-fact output cost and halving the budget so the item bound bites before the
token limit rather than at the same point. A conservative floor still applies when
no ceiling is configured.

    budget 4096  -> 24 facts
    budget 65536 -> 385 facts
    unset        -> 16 facts (floor)
The recursive split gathered sub-chunk results without return_exceptions, so a
single unsplittable half discarded the facts the other half had already
extracted, and the failure propagated to the whole document.

Losing good data is exactly the failure mode this path exists to prevent. Collect
sub-chunk results tolerantly: salvage what succeeded, log the partial failure at
error level with counts, and only re-raise when the split produced nothing at all.
@kamilfurtak
kamilfurtak force-pushed the agent/retain-saturation-and-recovery branch from aeadffd to fbdafc8 Compare August 20, 2026 12:33
@kamilfurtak

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (post-#3656). The only conflict was in tests/test_fact_extraction_retry.py, where both branches append new tests at the end of the file — resolved by keeping both sets. No changes to the fix itself.

tests/test_fact_extraction_retry.py: 32 passed, 1 failed; tests/test_fact_extraction_entities_schema.py: 12 passed. The single failure is test_output_too_long_error_is_a_single_class_across_modules, which fails identically on a clean checkout of main in my environment (an optional-dependency import), so it is unrelated to this branch.

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