Skip to content

fix(chunking): cap combine_text_under_n_chars at new_after_n_chars - #4487

Closed
abo3losh1 wants to merge 7 commits into
Unstructured-IO:mainfrom
abo3losh1:fix/combine-text-under-n-chars-cap
Closed

abo3losh1 wants to merge 7 commits into
Unstructured-IO:mainfrom
abo3losh1:fix/combine-text-under-n-chars-cap

Conversation

@abo3losh1

@abo3losh1 abo3losh1 commented Sep 17, 2026 •

Copy link
Copy Markdown

Summary

combine_text_under_n_chars is documented in two places as being capped at new_after_n_chars:

  • ChunkingOptions in base.py: "Note this value is 'capped' at the new_after_n_chars value since a value higher than that would not change this parameter's effect."
  • _ByTitleChunkingOptions.combine_text_under_n_chars itself: "Is reduced to new_after_n_chars when it exceeds that value."

It isn't. The cap was implemented until the title.py refactor in 9ac4445 (#2657), which replaced

# -- `new_after_n_chars` takes precendence on conflict with `combine_text_under_n_chars` --
return soft_max if combine_text_under_n_chars > soft_max else combine_text_under_n_chars

with a plain read of the argument, leaving both docstrings describing behaviour that had gone.

The effect is that a combine-threshold above the soft-max recombines pre-chunks the soft-max has already declared full, and the caller gets chunks several times the size they asked for:

from unstructured.chunking.title import chunk_by_title
from unstructured.documents.elements import Title, Text

elements = [
    Title("Section A"), Text("a" * 110),
    Title("Section B"), Text("b" * 110),
    Title("Section C"), Text("c" * 110),
]

chunks = chunk_by_title(
    elements, max_characters=500, new_after_n_chars=100, combine_text_under_n_chars=400
)
print([len(c.text) for c in chunks])
before:  [367]
after:   [121, 121, 121]

Each section is already over new_after_n_chars=100, so none of them should have been eligible for combining.

Changes

  • _ByTitleChunkingOptions.combine_text_under_n_chars caps the value at soft_max, restoring what the docstrings describe. No documentation changes were needed; the code now matches what is already written.
  • _validate() reads the raw argument instead of the (now capped) property. Without this the cap would swallow the combine_text_under_n_chars > max_characters case and that ValueError would stop being raised. Both existing validation errors keep their exact messages, and Describe_ByTitleChunkingOptions still passes unchanged.

The default path is unaffected: with new_after_n_chars unspecified, soft_max == hard_max == max_characters, so the cap is a no-op. combine_text_under_n_chars=0 and values below the soft-max are likewise unchanged.

Testing

pytest test_unstructured/chunking/ -q

495 passed, 18 skipped. Three of those fail on main:

FAILED test_it_does_not_combine_pre_chunks_past_new_after_n_chars
FAILED Describe_ByTitleChunkingOptions::it_caps_combine_text_under_n_chars_at_new_after_n_chars[400-100-100]
FAILED Describe_ByTitleChunkingOptions::it_caps_combine_text_under_n_chars_at_new_after_n_chars[None-100-100]

The new tests are:

  • it_caps_combine_text_under_n_chars_at_new_after_n_chars : parametrized over the conflicting, equal, lower, unspecified-combine and unspecified-both cases.
  • test_it_does_not_combine_pre_chunks_past_new_after_n_chars : the snippet above, end to end.

ruff check and ruff format --check pass on the touched files. CHANGELOG.md and __version__.py bumped to 0.27.10-dev0. (0.27.9 was released while this was open, so the entry opens a new dev section rather than the closed one.)

To see it by hand, run the snippet above on main and on this branch.

Review in cubic

Update, token-counting mode

The cap is a character-mode rule. combine_text_under_n_chars is a character count, measured against len(text) in PreChunk.can_combine(), while soft_max is a token count when max_tokens is configured, so capping one at the other mixed units:

_ByTitleChunkingOptions.new(
    max_tokens=100, new_after_n_tokens=10,
    tokenizer="cl100k_base", combine_text_under_n_chars=50,
).combine_text_under_n_chars   # -> 10, a token count used as a character threshold

new_after_n_chars has no counterpart in token mode, so the cap now applies only when counting characters, and it_does_not_cap_combine_text_under_n_chars_when_counting_tokens pins that. 495 passed, 18 skipped on current main.

`combine_text_under_n_chars` is documented as capped at `new_after_n_chars`:
a combine-threshold above the soft-max recombines pre-chunks the soft-max
has already declared full, so the caller gets chunks well past the size
they asked for.

The cap was implemented until the `title.py` refactor in 9ac4445 (Unstructured-IO#2657),
which replaced

    return soft_max if combine_text_under_n_chars > soft_max else combine_text_under_n_chars

with a plain read of the argument, while leaving both the property's own
docstring ("Is reduced to `new_after_n_chars` when it exceeds that value")
and the public option documentation in `base.py` ("Note this value is
'capped' at the `new_after_n_chars` value") describing the old behavior.

    chunk_by_title(
        elements, max_characters=500, new_after_n_chars=100,
        combine_text_under_n_chars=400,
    )

returned one 367-character chunk where three 121-character chunks were
asked for.

Restore the cap. Validation now reads the argument as the caller gave it
rather than the capped property, so a negative value and a value greater
than `max_characters` are both still rejected with the same messages; the
cap would otherwise have hidden the second case.
An options-level test over the conflicting, equal, lower, and unspecified
cases, plus an end-to-end test asserting three already-full sections are
not recombined into one oversized chunk.
0.27.8 was released while this was open, so the entry opens a new
0.27.9-dev0 section instead of the closed one.
@abo3losh1
abo3losh1 force-pushed the fix/combine-text-under-n-chars-cap branch from 8b79038 to d79456b Compare September 22, 2026 15:33
Copilot AI lite review requested due to automatic review settings September 22, 2026 15:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Resolve the token/character unit mismatch, reconcile the version target, and add the requested validation regression case.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Low severity

Open (2)
What changed in this PR

Restores the documented cap on combine_text_under_n_chars and adds regression coverage for title chunking.

Changes:

  • Caps the combine threshold at the soft maximum.
  • Preserves raw-argument validation.
  • Adds tests and updates changelog/version metadata.
File Summary
unstructured/​chunking/​title.py Implements capping and validation. Critical concern (2 votes): token-mode unit mismatch. Moderate concern (1 vote): add coverage for capped values exceeding max_characters.
unstructured/​__version__.py Version is 0.27.9-dev0, conflicting with the described 0.27.8-dev1 target (nit, 3 votes).
test_unstructured/​chunking/​test_title.py Adds cap and integration regression tests.
CHANGELOG.md Documents the fix and uses the updated development version.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.


# -- NOTE(abo3losh1): `new_after_n_chars` takes precedence on conflict. A threshold above
# -- the soft-max would combine pre-chunks the soft-max has already declared full.
return min(combine_text_under_n_chars, self.soft_max)
Comment thread unstructured/__version__.py Outdated
@@ -1 +1 @@
__version__ = "0.27.8" # pragma: no cover
__version__ = "0.27.9-dev0" # pragma: no cover
`combine_text_under_n_chars` is a character count, measured against
`len(text)` in `PreChunk.can_combine()`, but `soft_max` is a token count
when `max_tokens` is configured. Capping one at the other mixed units:

    _ByTitleChunkingOptions.new(
        max_tokens=100, new_after_n_tokens=10,
        tokenizer="cl100k_base", combine_text_under_n_chars=50,
    ).combine_text_under_n_chars

returned 10, turning a 50-character threshold into a 10-character one and
changing token-mode behavior for no reason. The documented cap is against
`new_after_n_chars`, which has no counterpart in token mode, so the cap now
applies only when counting characters.
Fails before the fix: a 50-character combine-threshold came back as 10
under `new_after_n_tokens=10`.
…r-n-chars-cap

# Conflicts:
#	CHANGELOG.md
#	unstructured/__version__.py
@abo3losh1

Copy link
Copy Markdown
Author

Closing this since it hasn't been reviewed. Happy to reopen if it's still useful.

@abo3losh1 abo3losh1 closed this Sep 29, 2026
cragwolfe pushed a commit that referenced this pull request Oct 9, 2026
…4501)

No open issue covers this. None. Found while reading the three sibling
validators in `_validate()` against the parameters they guard.

`ChunkingOptions._validate()` at `unstructured/chunking/base.py` rejects
a negative `new_after_n_chars` and a negative `new_after_n_tokens`. It
never checks `overlap`. A negative overlap then corrupts output two
ways, with no error and no warning.

`base.py:2731` computes the remainder as `s[maxlen - self._opts.overlap
:]`. With `overlap = -5` that remainder starts five characters past the
end of the fragment, so five characters of the document disappear at
every split boundary.

`base.py:755` computes `self._text[-overlap:]`. With `overlap = -5` that
is a forward slice, so the tail carried into the next chunk is most of
the current chunk.

Before, on a 60-character input of unique characters at
`max_characters=20`:

```
$ python repro.py
overlap=   0: ['ABCDEFGHIJKLMNOPQRST', 'UVWXYZabcdefghijklmn', 'opqrstuvwxyz01234567']
           dropped from output entirely: ''
overlap=  -5: ['ABCDEFGHIJKLMNOPQRST', 'Zabcdefghijklmnopqrs', 'yz01234567']
           dropped from output entirely: 'UVWXYtuvwx'
```

The `overlap=0` line is the control: same input, same commit, nothing
lost.

With `overlap_all=True`, three 10-character elements at
`max_characters=12`:

```
overlap=  0 -> ['AAAAAAAAAA', 'BBBBBBBBBB', 'CCCCCCCCCC']
overlap= -4 -> ['AAAAAAAAAA', 'AAAAAA\n\nBBBB', 'BB', 'AA\n\nBBBBBBBB', 'CCCCCCCCCC']
```

Text duplicated and out of order.

Reachable from all three public entry points: `chunk_elements(...)`,
`chunk_by_title(...)`, and `partition(filename=...,
chunking_strategy="basic", overlap=-5)`. All three drop the same
characters.

After, the four added lines mirror the existing `new_after_n_chars`
check and raise `ValueError`.

```
$ python -m pytest test_unstructured/chunking -q
484 passed, 24 skipped in 15.04s
```

Baseline on `1bedf7b` is `482 passed, 24 skipped`, so the delta is the
two new parametrised cases. Reverting `unstructured/chunking/base.py`
and keeping the test gives `2 failed, 482 passed, 24 skipped`, both of
them `Failed: DID NOT RAISE ValueError`. `ruff check` is clean on both
touched files.

I did not run `make test` or `make check` in full, and no PDF, image or
OCR test ran here: the install was `-e ".[csv,docx,md,pptx,xlsx]"`
without the inference extras. `make check-version` passes, with
`CHANGELOG.md` and `__version__.py` both at `0.27.10`.

Duplicate check: all 130 open pull requests enumerated, 10 touching
`unstructured/chunking/`. I grepped each diff for added lines matching
`_validate|overlap.*must be|overlap_arg`. The only hit is #4487, which
caps `combine_text_under_n_chars` in `title.py::_validate`: different
file, different function, different parameter. #4392 is the nearest
neighbour and changes `_split_by_tokens` rather than validation, so the
two do not collide.

@cragwolfe you merged the last chunking change here. Would you rather
this raised, or clamped a negative overlap to zero with a warning?


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/Unstructured-IO/unstructured/pull/4501?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
cragwolfe pushed a commit that referenced this pull request Oct 22, 2026
…s set (#4528)

`PreChunk.can_combine()` sizes its text with `len()`, so the
`combine_text_under_n_chars` threshold and the hard-max check count
characters even when `max_tokens` has selected token counting. Every
sibling computation in the class — `will_fit`, `_text_length` — goes
through `ChunkingOptions.measure()`, which branches on the counting
mode; `can_combine` is the one that doesn't. In token mode a section of
a few tokens is many more characters than the token budget, so every
section looks too large to combine and becomes its own chunk. With
`max_tokens=60`, `chunk_elements` fills the window while
`chunk_by_title` produces twelve ten-token chunks — about 17%
utilization — and the obvious workaround,
`combine_text_under_n_chars=240`, is rejected because it exceeds the
hard max.

The fix is the two `measure()` substitutions. Character-mode behavior is
unchanged (`measure()` is `len()` there): a 3,000-chunk-list corpus
across both chunkers and five option sets hashes identically before and
after, and the existing character-mode `can_combine` cases pass
untouched.

Tests: a deterministic offline token counter fixture (no tiktoken
download), a token-mode `can_combine` parametrization mirroring the
character-mode sibling, a `chunk_by_title` case asserting the token
budget is filled and the chunk texts equal `chunk_elements()` on the
same input, and a discriminating character-mode case that would flip if
the measure were swapped. The first two fail on `main`.
`test_unstructured/chunking/test_base.py` + `test_title.py`: 391 passed,
16 skipped; ruff clean. CHANGELOG entry under a new `0.27.18` section
with the matching `__version__` bump, following the one-patch-per-PR
pattern of the recent entries.

Related but different: #4487 changes what the
`combine_text_under_n_chars` threshold *is* (caps it at
`new_after_n_chars`); this changes which *unit* it's compared in. No
textual overlap.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/Unstructured-IO/unstructured/pull/4528?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
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