Skip to content

Commit 5d735bb

Browse files
Do not count whitespace runs as words in RecursiveDocumentSplitter fallback
The word-mode fixed-size fallback split words with re.findall(r"\S+|\s+", text), which also yields multi-character whitespace tokens (" ", "\t", "\f"). The loop counted every token that was not exactly a single space toward the chunk length, so a multi-space or page-break token was counted as a word - producing chunks shorter than split_length and, for a whitespace-only document, a whitespace-only chunk. Count a token only when it contains a real word (word.strip()).
1 parent 007c66b commit 5d735bb

3 files changed

Lines changed: 26 additions & 11 deletions

File tree

haystack/components/preprocessors/recursive_splitter.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,9 @@ def _fall_back_to_fixed_chunking(self, text: str, split_units: Literal["word", "
380380
current_length = 0
381381

382382
for word in words:
383-
if word != " ":
383+
# re.findall above also yields multi-character whitespace tokens (" ", "\t").
384+
# Only count real words toward the length; any whitespace run is a separator.
385+
if word.strip():
384386
current_chunk.append(word)
385387
current_length += 1
386388
if current_length == self.split_length and current_chunk:
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
fixes:
3+
- |
4+
`RecursiveDocumentSplitter`'s word-mode fixed-size fallback no longer counts a run of
5+
whitespace (e.g. a double space, tab, or page break) as a word. Previously such a run was
6+
counted toward the chunk length, producing chunks smaller than `split_length` and, for a
7+
document made only of whitespace, a whitespace-only chunk.

test/components/preprocessors/test_recursive_splitter.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,7 @@ def test_run_split_by_dot_count_page_breaks_word_unit() -> None:
502502

503503
documents = document_splitter.run(documents=[Document(content=text)])["documents"]
504504

505-
assert len(documents) == 8
505+
assert len(documents) == 7
506506
assert documents[0].content == "Sentence on page 1."
507507
assert documents[0].meta["page_number"] == 1
508508
assert documents[0].meta["split_id"] == 0
@@ -533,16 +533,11 @@ def test_run_split_by_dot_count_page_breaks_word_unit() -> None:
533533
assert documents[5].meta["split_id"] == 5
534534
assert documents[5].meta["split_idx_start"] == text.index(documents[5].content)
535535

536-
assert documents[6].content == "\f\f Sentence on page"
536+
assert documents[6].content == "\f\f Sentence on page 5."
537537
assert documents[6].meta["page_number"] == 5
538538
assert documents[6].meta["split_id"] == 6
539539
assert documents[6].meta["split_idx_start"] == text.index(documents[6].content)
540540

541-
assert documents[7].content == " 5."
542-
assert documents[7].meta["page_number"] == 5
543-
assert documents[7].meta["split_id"] == 7
544-
assert documents[7].meta["split_idx_start"] == text.index(documents[7].content)
545-
546541

547542
def test_run_split_by_word_count_page_breaks_word_unit():
548543
splitter = RecursiveDocumentSplitter(split_length=4, split_overlap=0, separators=[" "], split_unit="word")
@@ -899,9 +894,10 @@ def test_run_custom_split_by_dot_and_overlap_3_char_unit():
899894
document_splitter = RecursiveDocumentSplitter(separators=["."], split_length=4, split_overlap=0, split_unit="word")
900895
text = "\x0c\x0c Sentence on page 5."
901896
chunks = document_splitter._fall_back_to_fixed_chunking(text, split_units="word")
902-
assert len(chunks) == 2
903-
assert chunks[0] == "\x0c\x0c Sentence on page"
904-
assert chunks[1] == " 5."
897+
# The leading page breaks are whitespace, not words, so the four real words fit one chunk
898+
# instead of being split mid-sentence.
899+
assert len(chunks) == 1
900+
assert chunks[0] == "\x0c\x0c Sentence on page 5."
905901

906902

907903
def test_run_serialization_in_pipeline():
@@ -1136,3 +1132,13 @@ def test_fallback_overlap_token_unit():
11361132
assert len(result) > 1
11371133
for chunk in result:
11381134
assert splitter._chunk_length(chunk.content) <= 4
1135+
1136+
1137+
def test_word_fallback_does_not_count_multichar_whitespace_as_words():
1138+
# The word-mode fixed-size fallback (used when no configured separator matches) must treat any
1139+
# run of whitespace as a separator; " " or "\t" must not be counted as a word.
1140+
splitter = RecursiveDocumentSplitter(split_length=1, split_overlap=0, split_unit="word", separators=["\n\n"])
1141+
chunks = splitter.run([Document(content="hello world")])["documents"]
1142+
# Exactly one real word per chunk and no whitespace-only chunk.
1143+
assert [chunk.content.strip() for chunk in chunks] == ["hello", "world"]
1144+
assert all(chunk.content.strip() for chunk in chunks)

0 commit comments

Comments
 (0)