Skip to content
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 0.23.4

### Enhancements

- **Add `max_page` parameter to `chunk_by_title` for page-count-bounded chunking**: `chunk_by_title()` now accepts an optional `max_page: int` argument. When set, a chunk is closed and a new one started whenever an element's page number is more than `max_page - 1` pages past the page where the current chunk began. This gives callers a hard upper bound on how many pages a single chunk may span, independent of character or token limits. A `Title` element always resets the page counter (it already starts a new chunk via the existing boundary logic), so each section's page span is counted independently. The constraint is additive — it combines with all other `by_title` boundaries. Must be `>= 1`; raises `ValueError` otherwise. `max_page=1` is equivalent to the deprecated `multipage_sections=False`.
- **Deprecate `multipage_sections` in `chunk_by_title`**: `multipage_sections` is superseded by `max_page` and will be removed in a future release. It continues to work and emits a `DeprecationWarning`. Use `max_page=1` instead of `multipage_sections=False`; omit `max_page` entirely instead of `multipage_sections=True`. When both are provided, `max_page` takes priority.

## 0.23.3

### Fixes
Expand Down
169 changes: 160 additions & 9 deletions test_unstructured/chunking/test_title.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,8 @@ def test_chunk_by_title_separates_by_page_number():
Text("It is storming outside."),
CheckBox(),
]
chunks = chunk_by_title(elements, multipage_sections=False, combine_text_under_n_chars=0)
with pytest.warns(DeprecationWarning, match="multipage_sections"):
chunks = chunk_by_title(elements, multipage_sections=False, combine_text_under_n_chars=0)

assert len(chunks) == 5
assert chunks[0] == CompositeElement("A Great Day")
Expand Down Expand Up @@ -304,7 +305,8 @@ def test_chuck_by_title_respects_multipage():
Text("It is storming outside."),
CheckBox(),
]
chunks = chunk_by_title(elements, multipage_sections=True, combine_text_under_n_chars=0)
with pytest.warns(DeprecationWarning, match="multipage_sections"):
chunks = chunk_by_title(elements, multipage_sections=True, combine_text_under_n_chars=0)
assert len(chunks) == 4
assert chunks[0] == CompositeElement(
"A Great Day\n\nToday is a great day.\n\nIt is sunny outside."
Expand Down Expand Up @@ -333,7 +335,8 @@ def test_chunk_by_title_groups_across_pages():
Text("It is storming outside."),
CheckBox(),
]
chunks = chunk_by_title(elements, multipage_sections=True, combine_text_under_n_chars=0)
with pytest.warns(DeprecationWarning, match="multipage_sections"):
chunks = chunk_by_title(elements, multipage_sections=True, combine_text_under_n_chars=0)

assert len(chunks) == 4
assert chunks[0] == CompositeElement(
Expand All @@ -349,6 +352,124 @@ def test_chunk_by_title_groups_across_pages():
)


def test_max_page_1_is_equivalent_to_multipage_sections_false():
"""max_page=1 produces identical output to the deprecated multipage_sections=False."""
elements: list[Element] = [
Title("A Great Day", metadata=ElementMetadata(page_number=1)),
Text("Today is a great day.", metadata=ElementMetadata(page_number=2)),
Text("It is sunny outside.", metadata=ElementMetadata(page_number=2)),
Title("An Okay Day"),
Text("Today is an okay day."),
]

chunks_new = chunk_by_title(elements, max_page=1, combine_text_under_n_chars=0)
with pytest.warns(DeprecationWarning):
chunks_old = chunk_by_title(
elements, multipage_sections=False, combine_text_under_n_chars=0
)

assert chunks_new == chunks_old


def test_chunk_by_title_respects_max_page():
"""A chunk is closed when its elements would span more than max_page pages."""
elements: list[Element] = [
Title("Section A", metadata=ElementMetadata(page_number=1)),
Text("Page one text.", metadata=ElementMetadata(page_number=1)),
Text("Page two text.", metadata=ElementMetadata(page_number=2)),
Text("Page three text.", metadata=ElementMetadata(page_number=3)),
Text("Page four text.", metadata=ElementMetadata(page_number=4)),
Text("Page five text.", metadata=ElementMetadata(page_number=5)),
]

chunks = chunk_by_title(elements, max_page=2, combine_text_under_n_chars=0)

# max_page=2: chunk 1 covers pages 1-2, page 3 triggers new chunk covering pages 3-4,
# page 5 triggers another new chunk.
assert len(chunks) == 3
assert chunks[0] == CompositeElement(
"Section A\n\nPage one text.\n\nPage two text."
)
assert chunks[1] == CompositeElement("Page three text.\n\nPage four text.")
assert chunks[2] == CompositeElement("Page five text.")


def test_chunk_by_title_max_page_resets_on_title():
"""Title boundaries reset the max_page page-counter so each section is counted independently."""
elements: list[Element] = [
Title("Section A", metadata=ElementMetadata(page_number=1)),
Text("Page one.", metadata=ElementMetadata(page_number=1)),
Title("Section B", metadata=ElementMetadata(page_number=2)),
Text("Page two.", metadata=ElementMetadata(page_number=2)),
Text("Page three.", metadata=ElementMetadata(page_number=3)),
Text("Page four.", metadata=ElementMetadata(page_number=4)),
]

chunks = chunk_by_title(elements, max_page=2, combine_text_under_n_chars=0)

# Section A: pages 1 only → 1 chunk.
# Section B: pages 2-3 (2 pages) → 1 chunk, then page 4 triggers a new chunk.
assert len(chunks) == 3
assert chunks[0] == CompositeElement("Section A\n\nPage one.")
assert chunks[1] == CompositeElement("Section B\n\nPage two.\n\nPage three.")
assert chunks[2] == CompositeElement("Page four.")


def test_chunk_by_title_max_page_1_breaks_on_every_page():
"""max_page=1 produces one chunk per page-group, breaking on every page change."""
elements: list[Element] = [
Title("Intro", metadata=ElementMetadata(page_number=1)),
Text("Page one.", metadata=ElementMetadata(page_number=1)),
Text("Page two.", metadata=ElementMetadata(page_number=2)),
Text("Page three.", metadata=ElementMetadata(page_number=3)),
]

chunks = chunk_by_title(elements, max_page=1, combine_text_under_n_chars=0)

assert len(chunks) == 3
assert chunks[0] == CompositeElement("Intro\n\nPage one.")
assert chunks[1] == CompositeElement("Page two.")
assert chunks[2] == CompositeElement("Page three.")


def test_chunk_by_title_max_page_not_undone_by_combiner():
"""PreChunkCombiner must not re-merge pre-chunks that were split by max_page.

By default combine_text_under_n_chars == max_characters (500). Without the page-span
guard in PreChunk.can_combine(), the combiner would merge small pre-chunks back
together, silently violating the max_page limit.
"""
elements: list[Element] = [
Title("Section", metadata=ElementMetadata(page_number=1)),
Text("short", metadata=ElementMetadata(page_number=1)),
Text("short", metadata=ElementMetadata(page_number=2)),
Text("short", metadata=ElementMetadata(page_number=3)),
Text("short", metadata=ElementMetadata(page_number=4)),
]

# Do NOT pass combine_text_under_n_chars=0 — that's the whole point: the combiner
# must be blocked by the max_page guard, not suppressed manually by the caller.
chunks = chunk_by_title(elements, max_page=2)

assert len(chunks) == 2
assert chunks[0] == CompositeElement("Section\n\nshort\n\nshort")
assert chunks[1] == CompositeElement("short\n\nshort")


def test_chunk_by_title_max_page_none_does_not_add_page_boundary():
"""Omitting max_page leaves all existing by-title behavior unchanged."""
elements: list[Element] = [
Title("Section", metadata=ElementMetadata(page_number=1)),
Text("Page one.", metadata=ElementMetadata(page_number=1)),
Text("Page five.", metadata=ElementMetadata(page_number=5)),
]

chunks = chunk_by_title(elements, combine_text_under_n_chars=0)

assert len(chunks) == 1
assert chunks[0] == CompositeElement("Section\n\nPage one.\n\nPage five.")


def test_add_chunking_strategy_on_partition_html():
filename = "example-docs/example-10k-1p.html"
chunk_elements = partition_html(filename, chunking_strategy="by_title")
Expand Down Expand Up @@ -691,15 +812,45 @@ def it_does_not_complain_when_specifying_new_after_n_chars_by_itself(self):

assert opts.soft_max == 200

def it_translates_multipage_sections_false_to_max_page_1(self):
"""multipage_sections=False is internally translated to max_page=1."""
opts = _ByTitleChunkingOptions(multipage_sections=False)
assert opts.max_page == 1

def it_leaves_max_page_none_when_multipage_sections_is_true(self):
opts = _ByTitleChunkingOptions(multipage_sections=True)
assert opts.max_page is None

def it_prefers_max_page_over_multipage_sections_when_both_are_set(self):
"""max_page takes priority; multipage_sections is ignored without error."""
opts = _ByTitleChunkingOptions(multipage_sections=False, max_page=3)
assert opts.max_page == 3

def it_emits_deprecation_warning_when_multipage_sections_is_used(self):
with pytest.warns(DeprecationWarning, match="multipage_sections.*deprecated"):
chunk_by_title([], multipage_sections=False)

def it_does_not_warn_when_multipage_sections_is_not_passed(self):
import warnings

with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
chunk_by_title([]) # should not raise

@pytest.mark.parametrize(
("multipage_sections", "expected_value"),
[(True, True), (False, False), (None, CHUNK_MULTI_PAGE_DEFAULT)],
("max_page", "expected_value"),
[(1, 1), (3, 3), (None, None)],
)
def it_knows_whether_to_break_chunks_on_page_boundaries(
self, multipage_sections: bool, expected_value: bool
def it_accepts_valid_max_page_values(
self, max_page: Optional[int], expected_value: Optional[int]
):
opts = _ByTitleChunkingOptions(multipage_sections=multipage_sections)
assert opts.multipage_sections is expected_value
opts = _ByTitleChunkingOptions(max_page=max_page)
assert opts.max_page == expected_value

@pytest.mark.parametrize("max_page", [0, -1, -10])
def it_rejects_max_page_less_than_one(self, max_page: int):
with pytest.raises(ValueError, match=f"'max_page' argument must be >= 1, got {max_page}"):
_ByTitleChunkingOptions.new(max_page=max_page)


# ================================================================================================
Expand Down
1 change: 0 additions & 1 deletion test_unstructured/partition/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,6 @@ def expected_call_():
include_page_breaks=False,
languages=None,
max_characters=None,
multipage_sections=True,
new_after_n_chars=None,
ocr_languages=None,
output_format=shared.OutputFormat.APPLICATION_JSON,
Expand Down
2 changes: 1 addition & 1 deletion unstructured/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.23.3" # pragma: no cover
__version__ = "0.23.4" # pragma: no cover
85 changes: 84 additions & 1 deletion unstructured/chunking/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,16 @@ def combine_text_under_n_chars(self) -> int:
arg_value = self._kwargs.get("combine_text_under_n_chars")
return arg_value if arg_value is not None else 0

@cached_property
def max_page(self) -> Optional[int]:
"""Maximum number of pages a chunk may span; `None` means no page-span limit.

Overridden by `_ByTitleChunkingOptions` to respect the `max_page` argument. The default
here is `None` so that `PreChunk.can_combine()` can check this property without needing to
know which chunking strategy is in use.
"""
return None

@cached_property
def hard_max(self) -> int:
"""The maximum size for a chunk (in characters or tokens depending on mode).
Expand Down Expand Up @@ -433,7 +443,7 @@ class PreChunker:
- **Segregate semantic units.** Identify semantic unit boundaries and segregate elements on
either side of those boundaries into different sections. In this case, the primary indicator
of a semantic boundary is a `Title` element. A page-break (change in page-number) is also a
semantic boundary when `multipage_sections` is `False`.
semantic boundary when `max_page` is set.

- **Minimize chunk count for each semantic unit.** Group the elements within a semantic unit
into sections as big as possible without exceeding the chunk window size.
Expand Down Expand Up @@ -681,6 +691,17 @@ def can_combine(self, pre_chunk: PreChunk) -> bool:
return False
if len(self._text) >= self._opts.combine_text_under_n_chars:
return False
# -- refuse to combine when doing so would violate the max_page page-span limit.
# -- This prevents PreChunkCombiner from undoing boundaries placed by the
# -- `is_on_page_exceeding_max` predicate.
if self._opts.max_page is not None:
self_span = self._page_span
other_span = pre_chunk._page_span
if self_span is not None and other_span is not None:
combined_first = min(self_span[0], other_span[0])
combined_last = max(self_span[1], other_span[1])
if combined_last - combined_first + 1 > self._opts.max_page:
return False
# -- avoid duplicating length computations by doing a trial-combine which is just as
# -- efficient and definitely more robust than hoping two different computations of combined
# -- length continue to get the same answer as the code evolves. Only possible because
Expand Down Expand Up @@ -731,6 +752,20 @@ def overlap_tail(self) -> str:
overlap = self._opts.inter_chunk_overlap
return self._text[-overlap:].strip() if overlap else ""

@cached_property
def _page_span(self) -> tuple[int, int] | None:
"""Return `(first_page, last_page)` across elements in this pre-chunk.

Returns `None` when no element carries a page number, in which case no page-span
enforcement is possible and the caller should not block combination on that basis.
"""
pages = [
e.metadata.page_number
for e in self._elements
if e.metadata.page_number is not None
]
return (min(pages), max(pages)) if pages else None

def _iter_text_segments(self) -> Iterator[str]:
"""Generate overlap text and each element text segment in order.

Expand Down Expand Up @@ -1874,6 +1909,54 @@ def page_number_incremented(element: Element) -> bool:
return page_number_incremented


def is_on_page_exceeding_max(max_page: int) -> BoundaryPredicate:
"""Returns a predicate that fires when a chunk would span more than `max_page` pages.

The lifetime of the returned callable cannot extend beyond a single element-stream because it
stores current state (chunk-start page and current page) that is particular to that stream.

The returned predicate tracks the page where the current chunk began. It fires (returns True)
when the current element's page number is more than `max_page - 1` pages past the chunk-start
page, i.e. when adding the element would make the chunk span more than `max_page` pages.

When it fires, it resets the chunk-start page to the current element's page so that the next
chunk begins fresh from that page.

A `Title` element resets the chunk-start page without firing, because the `is_title` predicate
already handles the boundary there; this keeps the two predicates consistent.
"""
chunk_start_page: int = 1
current_page: int = 1
is_first: bool = True

def page_count_exceeded(element: Element) -> bool:
nonlocal chunk_start_page, current_page, is_first

page_number = element.metadata.page_number

if is_first:
current_page = page_number or 1
chunk_start_page = current_page
is_first = False
return False

if page_number is not None:
current_page = page_number

# A Title element resets the chunk-start page (is_title handles the actual boundary).
if isinstance(element, Title):
chunk_start_page = current_page
return False

if current_page - chunk_start_page >= max_page:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
chunk_start_page = current_page
return True

return False

return page_count_exceeded


def is_title(element: Element) -> bool:
"""True when `element` is a `Title` element, False otherwise."""
return isinstance(element, Title)
5 changes: 3 additions & 2 deletions unstructured/chunking/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ def add_chunking_strategy(func: Callable[_P, list[Element]]) -> Callable[_P, lis
+ "\n\tStrategy used for chunking text into larger or smaller elements."
+ "\n\tDefaults to `None` with optional arg of 'basic' or 'by_title'."
+ "\n\tAdditional Parameters:"
+ "\n\t\tmultipage_sections"
+ "\n\t\t\tIf True, sections can span multiple pages. Defaults to True."
+ "\n\t\tmax_page"
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
+ "\n\t\t\tMaximum pages a single chunk may span. Only applies to 'by_title' strategy."
+ "\n\t\t\tUse max_page=1 to break on every page change. Defaults to None."
+ "\n\t\tcombine_text_under_n_chars"
+ "\n\t\t\tCombines elements (for example a series of titles) until a section"
+ "\n\t\t\treaches a length of n characters. Only applies to 'by_title' strategy."
Expand Down
Loading