Skip to content

Commit 346cfff

Browse files
badGarnetclaude
andauthored
feat: add option for table chunking (#4354)
## Summary Adds `isolate_tables` (default `True`) to `chunk_elements` and `chunk_by_title`, making the PR #4307 table-isolation behavior optional. - `True` (default): preserves current behavior — `Table`/`TableChunk` elements are always staged alone and never combined with neighbors by `PreChunkCombiner`. - `False`: restores the pre-#4307 behavior — tables can share pre-chunks with adjacent text and be combined into a `CompositeElement`. Rejects `skip_table_chunking=True` + `isolate_tables=False` with a `ValueError`, since the pass-through path only fires when a table is alone in its pre-chunk. <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds `isolate_tables` to `chunk_elements` and `chunk_by_title` to toggle table isolation; default `True` preserves current behavior, while `False` lets tables share pre-chunks and merge with adjacent text into `CompositeElement`s. Validation now uses bool-coerced options and raises `ValueError` when `skip_table_chunking=True` with `isolate_tables=False` (including falsy values like `0`) for predictable behavior. <sup>Written for commit 1da0506. Summary will update on new commits. <a href="https://cubic.dev/pr/Unstructured-IO/unstructured/pull/4354?utm_source=github">Review in cubic</a></sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bfd78b2 commit 346cfff

10 files changed

Lines changed: 192 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 0.22.30
2+
3+
### Enhancements
4+
5+
- **Toggle table isolation in chunking**: Add `isolate_tables` to basic/title chunking options. Defaults to `True` (the post-#4307 behavior: `Table`/`TableChunk` elements always staged alone). Set to `False` to allow tables to share pre-chunks with adjacent non-table elements and be combined by `PreChunkCombiner`.
6+
17
## 0.22.29
28

39
### Fixes

test_unstructured/chunking/test_base.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,34 @@ def it_knows_whether_to_repeat_table_headers_by_default(
127127
def it_knows_whether_to_skip_table_chunking(self, kwargs: dict[str, Any], expected_value: bool):
128128
assert ChunkingOptions(**kwargs).skip_table_chunking is expected_value
129129

130+
@pytest.mark.parametrize(
131+
("kwargs", "expected_value"),
132+
[
133+
({"isolate_tables": True}, True),
134+
({"isolate_tables": False}, False),
135+
({"isolate_tables": None}, True),
136+
({}, True),
137+
],
138+
)
139+
def it_knows_whether_to_isolate_tables(self, kwargs: dict[str, Any], expected_value: bool):
140+
assert ChunkingOptions(**kwargs).isolate_tables is expected_value
141+
142+
@pytest.mark.parametrize(
143+
("skip", "isolate"),
144+
[
145+
(True, False),
146+
(1, 0),
147+
(True, 0),
148+
(1, False),
149+
],
150+
)
151+
def it_rejects_skip_table_chunking_when_isolation_is_disabled(self, skip: Any, isolate: Any):
152+
with pytest.raises(
153+
ValueError,
154+
match="'skip_table_chunking=True' requires 'isolate_tables=True'",
155+
):
156+
ChunkingOptions(skip_table_chunking=skip, isolate_tables=isolate)._validate()
157+
130158
@pytest.mark.parametrize("n_chars", [-1, -42])
131159
def it_rejects_new_after_n_chars_for_n_less_than_zero(self, n_chars: int):
132160
with pytest.raises(

test_unstructured/chunking/test_basic.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,23 @@ def it_supports_the_skip_table_chunking_option(
284284
_, opts = _chunk_elements_.call_args.args
285285
assert opts.skip_table_chunking is expected_value
286286

287+
@pytest.mark.parametrize(
288+
("kwargs", "expected_value"),
289+
[
290+
({"isolate_tables": True}, True),
291+
({"isolate_tables": False}, False),
292+
({"isolate_tables": None}, True),
293+
({}, True),
294+
],
295+
)
296+
def it_supports_the_isolate_tables_option(
297+
self, kwargs: dict[str, Any], expected_value: bool, _chunk_elements_: Mock
298+
):
299+
chunk_elements([], **kwargs)
300+
301+
_, opts = _chunk_elements_.call_args.args
302+
assert opts.isolate_tables is expected_value
303+
287304
# -- fixtures --------------------------------------------------------------------------------
288305

289306
@pytest.fixture()

test_unstructured/chunking/test_table_isolation.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,62 @@ def it_never_produces_a_composite_element_that_lists_a_table_in_orig_elements(
238238
assert not any(isinstance(e, Table) for e in orig)
239239

240240

241+
class DescribeTableIsolationDisabled:
242+
"""When `isolate_tables=False`, the pre-#4307 behavior is restored."""
243+
244+
def it_lets_a_table_share_a_pre_chunk_with_adjacent_text_when_disabled(self):
245+
opts = ChunkingOptions(max_characters=500, isolate_tables=False)
246+
builder = PreChunkBuilder(opts=opts)
247+
builder.add_element(Text("Short preamble."))
248+
249+
assert builder.will_fit(Table("Heading\nCell text"))
250+
251+
def it_lets_text_follow_a_table_in_the_same_pre_chunk_when_disabled(self):
252+
opts = ChunkingOptions(max_characters=500, isolate_tables=False)
253+
builder = PreChunkBuilder(opts=opts)
254+
builder.add_element(Table("Heading\nCell text"))
255+
256+
assert builder.will_fit(Text("Follow-up paragraph."))
257+
258+
def it_combines_a_table_pre_chunk_with_text_neighbors_when_disabled(self):
259+
opts = ChunkingOptions(
260+
max_characters=500, combine_text_under_n_chars=500, isolate_tables=False
261+
)
262+
stream = [
263+
PreChunk([Text("Hello world.")], overlap_prefix="", opts=opts),
264+
PreChunk([Table("H\nC")], overlap_prefix="", opts=opts),
265+
PreChunk([Text("Goodbye world.")], overlap_prefix="", opts=opts),
266+
]
267+
268+
combined = list(PreChunkCombiner(stream, opts=opts).iter_combined_pre_chunks())
269+
270+
assert len(combined) == 1
271+
assert combined[0]._elements == [
272+
Text("Hello world."),
273+
Table("H\nC"),
274+
Text("Goodbye world."),
275+
]
276+
277+
def it_wraps_a_table_into_a_composite_element_with_neighbors_when_disabled(self):
278+
"""End-to-end: opting out collapses tiny table + text into one CompositeElement."""
279+
elements = [
280+
Text("preamble"),
281+
Table("H\nC"),
282+
Text("post"),
283+
]
284+
chunks = chunk_elements(
285+
elements,
286+
max_characters=500,
287+
isolate_tables=False,
288+
)
289+
290+
assert len(chunks) == 1
291+
assert isinstance(chunks[0], CompositeElement)
292+
text = chunks[0].text
293+
assert "preamble" in text
294+
assert "post" in text
295+
296+
241297
class DescribeTableIsolationOverlapAll:
242298
"""With overlap_all=True, overlap must not cross table / narrative boundaries."""
243299

test_unstructured/chunking/test_title.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,23 @@ def it_supports_the_skip_table_chunking_option(
609609
_, opts = _chunk_by_title_.call_args.args
610610
assert opts.skip_table_chunking is expected_value
611611

612+
@pytest.mark.parametrize(
613+
("kwargs", "expected_value"),
614+
[
615+
({"isolate_tables": True}, True),
616+
({"isolate_tables": False}, False),
617+
({"isolate_tables": None}, True),
618+
({}, True),
619+
],
620+
)
621+
def it_supports_the_isolate_tables_option(
622+
self, kwargs: dict[str, Any], expected_value: bool, _chunk_by_title_: Mock
623+
):
624+
chunk_by_title([], **kwargs)
625+
626+
_, opts = _chunk_by_title_.call_args.args
627+
assert opts.isolate_tables is expected_value
628+
612629
# -- fixtures --------------------------------------------------------------------------------
613630

614631
@pytest.fixture()

unstructured/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.22.29" # pragma: no cover
1+
__version__ = "0.22.30" # pragma: no cover

unstructured/chunking/base.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ class ChunkingOptions:
125125
repeat_table_headers
126126
Default: `True`. When `True`, repeated table-header behavior is enabled for chunked table
127127
continuations. Specify `False` to opt out and preserve legacy table-chunk behavior.
128+
isolate_tables
129+
Default: `True`. When `True`, `Table` and `TableChunk` elements are always staged in
130+
their own pre-chunk and never combined with adjacent non-table elements. Specify
131+
`False` to allow tables to share pre-chunks with adjacent elements (the pre-#4307
132+
behavior), which is sometimes useful when downstream consumers expect mixed-content
133+
composite chunks.
128134
text_splitting_separators
129135
A sequence of strings like `("\n", " ")` to be used as target separators during
130136
text-splitting. Text-splitting only applies to splitting an oversized element into two or
@@ -208,6 +214,17 @@ def skip_table_chunking(self) -> bool:
208214
arg_value = self._kwargs.get("skip_table_chunking")
209215
return False if arg_value is None else bool(arg_value)
210216

217+
@cached_property
218+
def isolate_tables(self) -> bool:
219+
"""When True, `Table`/`TableChunk` elements are staged in their own pre-chunk.
220+
221+
Default value is `True`. When `False`, table-family elements are allowed to share a
222+
pre-chunk with adjacent non-table elements (and may be merged by `PreChunkCombiner`),
223+
restoring the pre-#4307 behavior.
224+
"""
225+
arg_value = self._kwargs.get("isolate_tables")
226+
return True if arg_value is None else bool(arg_value)
227+
211228
@cached_property
212229
def inter_chunk_overlap(self) -> int:
213230
"""Characters of overlap to add between chunks.
@@ -349,6 +366,17 @@ def _validate(self) -> None:
349366
if new_after_n_chars is not None and new_after_n_chars < 0:
350367
raise ValueError(f"'new_after_n_chars' argument must be >= 0, got {new_after_n_chars}")
351368

369+
# -- `skip_table_chunking` requires `isolate_tables` because the pass-through path only
370+
# -- fires when the pre-chunk contains a single `Table` element. With isolation disabled,
371+
# -- tables can fold into `CompositeElement` alongside neighbors and the skip would be
372+
# -- silently ignored, breaking the contract.
373+
if self.skip_table_chunking and not self.isolate_tables:
374+
raise ValueError(
375+
"'skip_table_chunking=True' requires 'isolate_tables=True' (the default);"
376+
" tables cannot be passed through unchanged while also sharing a pre-chunk with"
377+
" adjacent elements"
378+
)
379+
352380
# -- overlap must be less than max-chars or the chunk text will never be consumed --
353381
if self.overlap >= hard_max:
354382
raise ValueError(
@@ -502,7 +530,11 @@ def __init__(self, opts: ChunkingOptions) -> None:
502530
def add_element(self, element: Element) -> None:
503531
"""Add `element` to this section."""
504532
# -- do not prefix a table-only pre-chunk with narrative overlap from the prior chunk --
505-
if len(self._elements) == 0 and _element_is_table_family(element):
533+
if (
534+
self._opts.isolate_tables
535+
and len(self._elements) == 0
536+
and _element_is_table_family(element)
537+
):
506538
self._overlap_prefix = ""
507539
self._text_segments = []
508540
self._text_len = 0
@@ -531,7 +563,11 @@ def flush(self) -> Iterator[PreChunk]:
531563
# -- iterator is exhausted and can add elements for the next pre-chunk immediately.
532564
overlap_for_next = pre_chunk.overlap_tail
533565
# -- table tails must not prefix the following narrative pre-chunk (overlap_all) --
534-
if len(elements) == 1 and _element_is_table_family(elements[0]):
566+
if (
567+
self._opts.isolate_tables
568+
and len(elements) == 1
569+
and _element_is_table_family(elements[0])
570+
):
535571
overlap_for_next = ""
536572
self._reset_state(overlap_for_next)
537573
yield pre_chunk
@@ -548,13 +584,15 @@ def will_fit(self, element: Element) -> bool:
548584
- A text-element will not fit when together with the elements already present it would
549585
exceed the hard-max (aka. max_characters/max_tokens).
550586
"""
551-
# -- a `Table` can only start a pre-chunk; it is never appended to a non-empty pre-chunk --
552-
if _element_is_table_family(element):
553-
return len(self._elements) == 0
587+
if self._opts.isolate_tables:
588+
# -- a `Table` can only start a pre-chunk; it is never appended to a non-empty
589+
# -- pre-chunk --
590+
if _element_is_table_family(element):
591+
return len(self._elements) == 0
554592

555-
# -- no non-table element may share a pre-chunk with a `Table` --
556-
if _elements_contain_table_family(self._elements):
557-
return False
593+
# -- no non-table element may share a pre-chunk with a `Table` --
594+
if _elements_contain_table_family(self._elements):
595+
return False
558596

559597
# -- an empty pre-chunk will accept any element (including an oversized-element) --
560598
if len(self._elements) == 0:
@@ -637,7 +675,9 @@ def __eq__(self, other: Any) -> bool:
637675

638676
def can_combine(self, pre_chunk: PreChunk) -> bool:
639677
"""True when `pre_chunk` can be combined with this one without exceeding size limits."""
640-
if _table_isolation_forbids_side_by_side_merge(self._elements, pre_chunk._elements):
678+
if self._opts.isolate_tables and _table_isolation_forbids_side_by_side_merge(
679+
self._elements, pre_chunk._elements
680+
):
641681
return False
642682
if len(self._text) >= self._opts.combine_text_under_n_chars:
643683
return False

unstructured/chunking/basic.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ def chunk_elements(
3434
tokenizer: Optional[str] = None,
3535
repeat_table_headers: Optional[bool] = None,
3636
skip_table_chunking: Optional[bool] = None,
37+
isolate_tables: Optional[bool] = None,
3738
) -> list[Element]:
3839
"""Combine sequential `elements` into chunks, respecting specified text-length limits.
3940
@@ -84,6 +85,11 @@ def chunk_elements(
8485
skip_table_chunking
8586
Default: `False`. When `True`, `Table` elements are passed through unchanged without
8687
being split into `TableChunk` elements, regardless of their size.
88+
isolate_tables
89+
Default: `True`. When `True`, `Table` and `TableChunk` elements are always staged in
90+
their own pre-chunk and never combined with adjacent non-table elements. Specify
91+
`False` to allow tables to share pre-chunks with adjacent elements (the pre-#4307
92+
behavior).
8793
"""
8894
# -- raises ValueError on invalid parameters --
8995
opts = _BasicChunkingOptions.new(
@@ -97,6 +103,7 @@ def chunk_elements(
97103
tokenizer=tokenizer,
98104
repeat_table_headers=repeat_table_headers,
99105
skip_table_chunking=skip_table_chunking,
106+
isolate_tables=isolate_tables,
100107
)
101108

102109
return _chunk_elements(elements, opts)

unstructured/chunking/dispatch.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ def add_chunking_strategy(func: Callable[_P, list[Element]]) -> Callable[_P, lis
7474
+ "\n\t\tskip_table_chunking"
7575
+ "\n\t\t\tDefault: False. When True, Table elements are passed through"
7676
+ "\n\t\t\tunchanged without being split into TableChunk elements."
77+
+ "\n\t\tisolate_tables"
78+
+ "\n\t\t\tDefault: True. When True, Table/TableChunk elements are always"
79+
+ "\n\t\t\tstaged in their own pre-chunk. Set to False to allow tables to"
80+
+ "\n\t\t\tshare pre-chunks with adjacent non-table elements."
7781
)
7882

7983
@functools.wraps(func)

unstructured/chunking/title.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def chunk_by_title(
3535
tokenizer: Optional[str] = None,
3636
repeat_table_headers: Optional[bool] = None,
3737
skip_table_chunking: Optional[bool] = None,
38+
isolate_tables: Optional[bool] = None,
3839
) -> list[Element]:
3940
"""Uses title elements to identify sections within the document for chunking.
4041
@@ -91,6 +92,11 @@ def chunk_by_title(
9192
skip_table_chunking
9293
Default: `False`. When `True`, `Table` elements are passed through unchanged without
9394
being split into `TableChunk` elements, regardless of their size.
95+
isolate_tables
96+
Default: `True`. When `True`, `Table` and `TableChunk` elements are always staged in
97+
their own pre-chunk and never combined with adjacent non-table elements. Specify
98+
`False` to allow tables to share pre-chunks with adjacent elements (the pre-#4307
99+
behavior).
94100
"""
95101
opts = _ByTitleChunkingOptions.new(
96102
combine_text_under_n_chars=combine_text_under_n_chars,
@@ -105,6 +111,7 @@ def chunk_by_title(
105111
tokenizer=tokenizer,
106112
repeat_table_headers=repeat_table_headers,
107113
skip_table_chunking=skip_table_chunking,
114+
isolate_tables=isolate_tables,
108115
)
109116
return _chunk_by_title(elements, opts)
110117

0 commit comments

Comments
 (0)