Skip to content

Commit dccb2f7

Browse files
committed
Merge branch 'dev'
2 parents dfb68d6 + 7a51580 commit dccb2f7

3 files changed

Lines changed: 55 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
## Changelog 🔄
22
All notable changes to semchunk will be documented here. This project adheres to [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
33

4+
## [4.1.1] - 2026-06-13
5+
### Changed
6+
- Sped up chunking, particularly of real-world documents at typical chunk sizes, by only ever materializing the comparatively rare multi-character whitespace runs when identifying the most structurally meaningful splitter, sparing `_split_text()` from materializing the far more numerous single-character runs (such as the single spaces between words) that can never be the longest run unless no longer run exists.
7+
- Further sped up chunking by fusing the construction of splits' start offsets into a single pass and by iterating directly from the start of one chunk to the next rather than visiting, and skipping over, every split already merged into a chunk.
8+
49
## [4.1.0] - 2026-06-12
510
### Changed
611
- Replaced [`mpire`](https://github.com/sybrenjansen/mpire) with the standard library's `multiprocessing` module for chunking multiple texts with multiple processes. On systems that support forking processes (namely, POSIX systems such as Linux and macOS), this makes [`tqdm`](https://github.com/tqdm/tqdm) semchunk's only dependency. On systems that do not support forking processes (namely, Windows), [`dill`](https://github.com/uqfoundation/dill) is now used to serialize the chunk function for spawned worker processes (as the standard library's `pickle` cannot serialize unpicklable token counters such as closures and lambdas), and so `dill` is now a Windows-only dependency, replacing the heavier `mpire`, which had depended on it.
@@ -191,6 +196,7 @@ All notable changes to semchunk will be documented here. This project adheres to
191196
### Added
192197
- Added the `chunk()` function, which splits text into semantically meaningful chunks of a specified size as determined by a provided token counter.
193198

199+
[4.1.1]: https://github.com/isaacus-dev/semchunk/compare/v4.1.0...v4.1.1
194200
[4.1.0]: https://github.com/isaacus-dev/semchunk/compare/v4.0.0...v4.1.0
195201
[4.0.0]: https://github.com/isaacus-dev/semchunk/compare/v3.2.5...v4.0.0
196202
[3.2.5]: https://github.com/isaacus-dev/semchunk/compare/v3.2.4...v3.2.5

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "semchunk"
7-
version = "4.1.0"
7+
version = "4.1.1"
88
authors = [
99
{name="Isaacus", email="hello@isaacus.com"},
1010
{name="Umar Butler", email="umar@isaacus.com"},

src/semchunk/semchunk.py

Lines changed: 48 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@
7272
re.escape(splitter) for splitter in _NON_WHITESPACE_STRUCTURAL_SPLITTERS
7373
)
7474

75+
# NOTE Whitespace runs are matched with a `{2,}` quantifier rather than a `+` quantifier so that we only ever need the comparatively rare multi-character runs to find the longest run, sparing us from materializing the far more numerous single-character runs (e.g., the single spaces between words), which are never the longest run unless no multi-character run exists.
76+
_NEWLINE_RUNS = re.compile(r"[\r\n]{2,}")
77+
_TAB_RUNS = re.compile(r"\t{2,}")
78+
_WHITESPACE_RUNS = re.compile(r"\s{2,}")
79+
_NEWLINE_CHAR = re.compile(r"[\r\n]")
80+
_WHITESPACE_CHAR = re.compile(r"\s")
81+
_PRECEDER_WHITESPACE_PATTERNS = tuple(
82+
(escaped_preceder, re.compile(rf"{escaped_preceder}(\s)"))
83+
for escaped_preceder in _REGEX_ESCAPED_NON_WHITESPACE_SEMANTIC_SPLITTERS
84+
)
85+
"""A tuple of `(escaped_preceder, compiled_pattern)` pairs used to target whitespace characters preceded by structurally meaningful non-whitespace splitters."""
86+
7587

7688
def _split_text(text: str) -> tuple[str, bool, list[str]]:
7789
"""Split text using the most structurally meaningful splitter possible."""
@@ -84,18 +96,25 @@ def _split_text(text: str) -> tuple[str, bool, list[str]]:
8496
# - the largest sequence of whitespace characters or, if the largest such sequence is only a single character and there exists a whitespace character preceded by a structurally meaningful non-whitespace splitter, then that whitespace character; and
8597
# - a structurally meaningful non-whitespace splitter.
8698
if "\n" in text or "\r" in text:
87-
splitter = max(re.findall(r"[\r\n]+", text), key=len)
99+
runs = _NEWLINE_RUNS.findall(text)
100+
splitter = max(runs, key=len) if runs else _NEWLINE_CHAR.search(text).group()
88101

89102
elif "\t" in text:
90-
splitter = max(re.findall(r"\t+", text), key=len)
103+
runs = _TAB_RUNS.findall(text)
104+
splitter = max(runs, key=len) if runs else "\t"
105+
106+
elif whitespace := _WHITESPACE_CHAR.search(text):
107+
runs = _WHITESPACE_RUNS.findall(text)
91108

92-
elif re.search(r"\s", text):
93-
splitter = max(re.findall(r"\s+", text), key=len)
109+
if runs:
110+
splitter = max(runs, key=len)
94111

95112
# If the splitter is only a single character, see if we can target whitespace characters that are preceded by structurally meaningful non-whitespace splitters to avoid splitting in the middle of sentences.
96-
if len(splitter) == 1:
97-
for escaped_preceder in _REGEX_ESCAPED_NON_WHITESPACE_SEMANTIC_SPLITTERS:
98-
if whitespace_preceded_by_preceder := re.search(rf"{escaped_preceder}(\s)", text):
113+
else:
114+
splitter = whitespace.group()
115+
116+
for escaped_preceder, preceder_pattern in _PRECEDER_WHITESPACE_PATTERNS:
117+
if whitespace_preceded_by_preceder := preceder_pattern.search(text):
99118
splitter = whitespace_preceded_by_preceder.group(1)
100119
escaped_splitter = re.escape(splitter)
101120

@@ -493,20 +512,20 @@ def chunk(
493512

494513
offsets: list = []
495514
splitter_len = len(splitter)
496-
split_lens = [len(split) for split in splits]
497-
local_split_starts = list(accumulate([0] + [split_len + splitter_len for split_len in split_lens]))
498-
split_starts = [start + _start for start in local_split_starts]
499-
num_splits_plus_one = len(splits) + 1
515+
local_split_starts = list(accumulate([len(split) + splitter_len for split in splits], initial=0))
516+
num_splits = len(splits)
517+
num_splits_plus_one = num_splits + 1
500518

501519
chunks = []
502-
skips = set()
503-
"""A set of indices of splits to skip because they have already been added to a chunk."""
504520

505-
# Iterate through the splits.
506-
for i, (split, split_start) in enumerate(zip(splits, split_starts)):
507-
# Skip the split if it has already been added to a chunk.
508-
if i in skips:
509-
continue
521+
# Iterate through the splits, jumping straight to the start of each new chunk so that splits already merged into a chunk are never revisited.
522+
i = 0
523+
524+
while i < num_splits:
525+
split = splits[i]
526+
527+
# Compute the start offset of the split in the original text. NOTE `split_starts[i]` is `local_split_starts[i] + _start`.
528+
split_start = local_split_starts[i] + _start
510529

511530
# If the split is over the chunk size, recursively chunk it.
512531
if token_counter(split) > local_chunk_size:
@@ -522,10 +541,13 @@ def chunk(
522541
chunks.extend(new_chunks)
523542
offsets.extend(new_offsets)
524543

544+
# The next chunk starts at the very next split.
545+
next_i = i + 1
546+
525547
# If the split is equal to or under the chunk size, add it and any subsequent splits to a new chunk until the chunk size is reached.
526548
else:
527-
# Merge the split with subsequent splits until the chunk size is reached.
528-
final_split_in_chunk_i, new_chunk = merge_splits(
549+
# Merge the split with subsequent splits until the chunk size is reached. NOTE `next_i` is the index of the first split not included in the merged chunk and so the start of the next chunk.
550+
next_i, new_chunk = merge_splits(
529551
text=text,
530552
split_starts=local_split_starts,
531553
splitter_len=splitter_len,
@@ -536,20 +558,15 @@ def chunk(
536558
high=num_splits_plus_one,
537559
)
538560

539-
# Mark any splits included in the new chunk for exclusion from future chunks.
540-
skips.update(range(i + 1, final_split_in_chunk_i))
541-
542561
# Add the chunk.
543562
chunks.append(new_chunk)
544563

545564
# Add the chunk's offsets.
546-
split_end = split_starts[final_split_in_chunk_i] - splitter_len
565+
split_end = local_split_starts[next_i] + _start - splitter_len
547566
offsets.append((split_start, split_end))
548567

549-
# If the splitter is not whitespace and the split is not the last split, add the splitter to the end of the latest chunk if doing so would not cause it to exceed the chunk size otherwise add the splitter as a new chunk.
550-
if not splitter_is_whitespace and not (
551-
i == len(splits) - 1 or all(j in skips for j in range(i + 1, len(splits)))
552-
):
568+
# If the splitter is not whitespace and the chunk just added is not the last chunk, add the splitter to the end of the latest chunk if doing so would not cause it to exceed the chunk size otherwise add the splitter as a new chunk.
569+
if not splitter_is_whitespace and next_i < num_splits:
553570
if token_counter(last_chunk_with_splitter := chunks[-1] + splitter) <= local_chunk_size:
554571
chunks[-1] = last_chunk_with_splitter
555572
start, end = offsets[-1]
@@ -561,6 +578,9 @@ def chunk(
561578
chunks.append(splitter)
562579
offsets.append((start, start + splitter_len))
563580

581+
# Advance to the start of the next chunk.
582+
i = next_i
583+
564584
# If this is the first call, remove any empty chunks as well as chunks comprised entirely of whitespace and then overlap the chunks if desired and finally return the chunks, optionally with their offsets.
565585
if is_first_call:
566586
# Remove empty chunks.

0 commit comments

Comments
 (0)