Files
deepset-ai--haystack/haystack/components/preprocessors/recursive_splitter.py
T
wehub-resource-sync c56bef871b
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

485 lines
24 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
import re
from copy import deepcopy
from typing import Any, Literal
from haystack import Document, component, logging
from haystack.lazy_imports import LazyImport
with LazyImport("Run 'pip install tiktoken'") as tiktoken_imports:
import tiktoken
logger = logging.getLogger(__name__)
@component
class RecursiveDocumentSplitter:
"""
Recursively chunk text into smaller chunks.
This component is used to split text into smaller chunks, it does so by recursively applying a list of separators
to the text.
The separators are applied in the order they are provided, typically this is a list of separators that are
applied in a specific order, being the last separator the most specific one.
Each separator is applied to the text, it then checks each of the resulting chunks, it keeps the chunks that
are within the split_length, for the ones that are larger than the split_length, it applies the next separator in the
list to the remaining text.
This is done until all chunks are smaller than the split_length parameter.
Example:
```python
from haystack import Document
from haystack.components.preprocessors import RecursiveDocumentSplitter
chunker = RecursiveDocumentSplitter(split_length=260, split_overlap=0, separators=["\\n\\n", "\\n", ".", " "])
text = ('''Artificial intelligence (AI) - Introduction
AI, in its broadest sense, is intelligence exhibited by machines, particularly computer systems.
AI technology is widely used throughout industry, government, and science. Some high-profile applications include advanced web search engines; recommendation systems; interacting via human speech; autonomous vehicles; generative and creative tools; and superhuman play and analysis in strategy games.''')
doc = Document(content=text)
doc_chunks = chunker.run([doc])
print(doc_chunks["documents"])
# [
# Document(id=..., content: 'Artificial intelligence (AI) - Introduction\\n\\n', meta: {'original_id': '...', 'split_id': 0, 'split_idx_start': 0, '_split_overlap': []})
# Document(id=..., content: 'AI, in its broadest sense, is intelligence exhibited by machines, particularly computer systems.\\n', meta: {'original_id': '...', 'split_id': 1, 'split_idx_start': 45, '_split_overlap': []})
# Document(id=..., content: 'AI technology is widely used throughout industry, government, and science.', meta: {'original_id': '...', 'split_id': 2, 'split_idx_start': 142, '_split_overlap': []})
# Document(id=..., content: ' Some high-profile applications include advanced web search engines; recommendation systems; interac...', meta: {'original_id': '...', 'split_id': 3, 'split_idx_start': 216, '_split_overlap': []})
# ]
```
""" # noqa: E501
def __init__(
self,
*,
split_length: int = 200,
split_overlap: int = 0,
split_unit: Literal["word", "char", "token"] = "word",
separators: list[str] | None = None,
sentence_splitter_params: dict[str, Any] | None = None,
) -> None:
"""
Initializes a RecursiveDocumentSplitter.
:param split_length: The maximum length of each chunk by default in words, but can be in characters or tokens.
See the `split_units` parameter.
:param split_overlap: The number of characters to overlap between consecutive chunks.
:param split_unit: The unit of the split_length parameter. It can be either "word", "char", or "token".
If "token" is selected, the text will be split into tokens using the tiktoken tokenizer (o200k_base).
:param separators: An optional list of separator strings to use for splitting the text. The string
separators will be treated as regular expressions unless the separator is "sentence", in that case the
text will be split into sentences using a custom sentence tokenizer based on NLTK.
See: haystack.components.preprocessors.sentence_tokenizer.SentenceSplitter.
If no separators are provided, the default separators ["\\n\\n", "sentence", "\\n", " "] are used.
:param sentence_splitter_params: Optional parameters to pass to the sentence tokenizer.
See: haystack.components.preprocessors.sentence_tokenizer.SentenceSplitter for more information.
:raises ValueError: If the overlap is greater than or equal to the chunk size or if the overlap is negative, or
if any separator is not a string.
"""
self.split_length = split_length
self.split_overlap = split_overlap
self.split_units = split_unit
self.separators = separators if separators else ["\n\n", "sentence", "\n", " "] # default separators
self._check_params()
self.nltk_tokenizer = None
self.sentence_splitter_params = (
{"keep_white_spaces": True} if sentence_splitter_params is None else sentence_splitter_params
)
self.tiktoken_tokenizer: "tiktoken.Encoding" | None = None
self._is_warmed_up = False
def warm_up(self) -> None:
"""
Warm up the sentence tokenizer and tiktoken tokenizer if needed.
"""
if self._is_warmed_up:
return
if "sentence" in self.separators:
self.nltk_tokenizer = self._get_custom_sentence_tokenizer(self.sentence_splitter_params)
if self.split_units == "token":
tiktoken_imports.check()
self.tiktoken_tokenizer = tiktoken.get_encoding("o200k_base")
self._is_warmed_up = True
def _check_params(self) -> None:
if self.split_length < 1:
raise ValueError("Split length must be at least 1 character.")
if self.split_overlap < 0:
raise ValueError("Overlap must be greater than zero.")
if self.split_overlap >= self.split_length:
raise ValueError("Overlap cannot be greater than or equal to the chunk size.")
if not all(isinstance(separator, str) for separator in self.separators):
raise ValueError("All separators must be strings.")
@staticmethod
def _get_custom_sentence_tokenizer(sentence_splitter_params: dict[str, Any]) -> Any:
from haystack.components.preprocessors.sentence_tokenizer import SentenceSplitter
return SentenceSplitter(**sentence_splitter_params)
def _split_chunk(self, current_chunk: str) -> tuple[str, str]:
"""
Splits a chunk based on the split_length and split_units attribute.
:param current_chunk: The current chunk to be split.
:returns:
A tuple containing the current chunk and the remaining chunk.
"""
if self.split_units == "word":
words = current_chunk.split()
current_chunk = " ".join(words[: self.split_length])
remaining_words = words[self.split_length :]
return current_chunk, " ".join(remaining_words)
if self.split_units == "char":
text = current_chunk
current_chunk = text[: self.split_length]
remaining_chars = text[self.split_length :]
return current_chunk, remaining_chars
# at this point we know that the tokenizer is already initialized
tokens = self.tiktoken_tokenizer.encode(current_chunk) # type: ignore
current_tokens = tokens[: self.split_length]
remaining_tokens = tokens[self.split_length :]
return self.tiktoken_tokenizer.decode(current_tokens), self.tiktoken_tokenizer.decode(remaining_tokens) # type: ignore
def _apply_overlap(self, chunks: list[str]) -> list[str]:
"""
Applies an overlap between consecutive chunks if the chunk_overlap attribute is greater than zero.
Works for both word- and character-level splitting. It trims the last chunk if it exceeds the split_length and
adds the trimmed content to the next chunk. If the last chunk is still too long after trimming, it splits it
and adds the first chunk to the list. This process continues until the last chunk is within the split_length.
:param chunks: A list of text chunks.
:returns:
A list of text chunks with the overlap applied.
"""
overlapped_chunks: list[str] = []
for idx, chunk in enumerate(chunks):
if idx == 0:
overlapped_chunks.append(chunk)
continue
# get the overlap between the current and previous chunk
overlap, prev_chunk = self._get_overlap(overlapped_chunks)
if overlap == prev_chunk:
logger.warning(
"Overlap is the same as the previous chunk. "
"Consider increasing the `split_length` parameter or decreasing the `split_overlap` parameter."
)
current_chunk = self._create_chunk_starting_with_overlap(chunk, overlap)
# if this new chunk exceeds 'split_length', trim it and move the remaining text to the next chunk
# if this is the last chunk, another new chunk will contain the trimmed text preceded by the overlap
# of the last chunk
if self._chunk_length(current_chunk) > self.split_length:
current_chunk, remaining_text = self._split_chunk(current_chunk)
if idx < len(chunks) - 1:
if self.split_units == "word":
chunks[idx + 1] = remaining_text + " " + chunks[idx + 1]
elif self.split_units == "token":
# For token-based splitting, combine at token level
# at this point we know that the tokenizer is already initialized
remaining_tokens = self.tiktoken_tokenizer.encode(remaining_text) # type: ignore
next_chunk_tokens = self.tiktoken_tokenizer.encode(chunks[idx + 1]) # type: ignore
chunks[idx + 1] = self.tiktoken_tokenizer.decode(remaining_tokens + next_chunk_tokens) # type: ignore
else: # char
chunks[idx + 1] = remaining_text + chunks[idx + 1]
elif remaining_text:
# create a new chunk with the trimmed text preceded by the overlap of the last chunk
overlapped_chunks.append(current_chunk)
chunk = remaining_text
overlap, _ = self._get_overlap(overlapped_chunks)
current_chunk = self._create_chunk_starting_with_overlap(chunk, overlap)
overlapped_chunks.append(current_chunk)
# it can still be that the new last chunk exceeds the 'split_length'
# continue splitting until the last chunk is within 'split_length'
if idx == len(chunks) - 1 and self._chunk_length(current_chunk) > self.split_length:
last_chunk = overlapped_chunks.pop()
first_chunk, remaining_chunk = self._split_chunk(last_chunk)
overlapped_chunks.append(first_chunk)
while remaining_chunk:
# combine overlap with remaining chunk
overlap, _ = self._get_overlap(overlapped_chunks)
current = self._create_chunk_starting_with_overlap(remaining_chunk, overlap)
# if it fits within split_length we are done
if self._chunk_length(current) <= self.split_length:
overlapped_chunks.append(current)
break
# otherwise split it again
first_chunk, remaining_chunk = self._split_chunk(current)
overlapped_chunks.append(first_chunk)
return overlapped_chunks
def _create_chunk_starting_with_overlap(self, chunk: str, overlap: str) -> str:
if self.split_units == "word":
current_chunk = overlap + " " + chunk
elif self.split_units == "token":
# For token-based splitting, combine at token level
# at this point we know that the tokenizer is already initialized
overlap_tokens = self.tiktoken_tokenizer.encode(overlap) # type: ignore
chunk_tokens = self.tiktoken_tokenizer.encode(chunk) # type: ignore
current_chunk = self.tiktoken_tokenizer.decode(overlap_tokens + chunk_tokens) # type: ignore
else: # char
current_chunk = overlap + chunk
return current_chunk
def _get_overlap(self, overlapped_chunks: list[str]) -> tuple[str, str]:
"""Get the previous overlapped chunk instead of the original chunk."""
prev_chunk = overlapped_chunks[-1]
overlap_start = max(0, self._chunk_length(prev_chunk) - self.split_overlap)
if self.split_units == "word":
word_chunks = prev_chunk.split()
overlap = " ".join(word_chunks[overlap_start:])
elif self.split_units == "token":
# For token-based splitting, handle overlap at token level
# at this point we know that the tokenizer is already initialized
tokens = self.tiktoken_tokenizer.encode(prev_chunk) # type: ignore
overlap_tokens = tokens[overlap_start:]
overlap = self.tiktoken_tokenizer.decode(overlap_tokens) # type: ignore
else: # char
overlap = prev_chunk[overlap_start:]
return overlap, prev_chunk
def _chunk_length(self, text: str) -> int:
"""
Get the length of the chunk in the specified units (words, characters, or tokens).
:param text: The text to measure.
:returns: The length of the text in the specified units.
"""
if self.split_units == "word":
words = [word for word in text.split(" ") if word]
return len(words)
if self.split_units == "char":
return len(text)
# token
# at this point we know that the tokenizer is already initialized
return len(self.tiktoken_tokenizer.encode(text)) # type: ignore
def _chunk_text(self, text: str) -> list[str]:
"""
Recursive chunking algorithm that divides text into smaller chunks based on a list of separator characters.
It starts with a list of separator characters (e.g., ["\n\n", "sentence", "\n", " "]) and attempts to divide
the text using the first separator. If the resulting chunks are still larger than the specified chunk size,
it moves to the next separator in the list. This process continues recursively, progressively applying each
specific separator until the chunks meet the desired size criteria.
:param text: The text to be split into chunks.
:returns:
A list of text chunks.
"""
if self._chunk_length(text) <= self.split_length:
return [text]
for curr_separator in self.separators:
if curr_separator == "sentence":
# re. ignore: correct SentenceSplitter initialization is checked at the initialization of the component
sentence_with_spans = self.nltk_tokenizer.split_sentences(text) # type: ignore
splits = [sentence["sentence"] for sentence in sentence_with_spans]
else:
# add escape "\" to the separator and wrapped it in a group so that it's included in the splits as well
escaped_separator = re.escape(curr_separator)
escaped_separator = f"({escaped_separator})"
# split the text and merge every two consecutive splits, i.e.: the text and the separator after it
splits = re.split(escaped_separator, text)
splits = [
"".join([splits[i], splits[i + 1]]) if i < len(splits) - 1 else splits[i]
for i in range(0, len(splits), 2)
]
# remove last split if it's empty
splits = splits[:-1] if splits[-1] == "" else splits
if len(splits) == 1: # go to next separator, if current separator not found in the text
continue
chunks = []
current_chunk: list[str] = []
current_length = 0
# check splits, if any is too long, recursively chunk it, otherwise add to current chunk
for split in splits:
split_text = split
# if adding this split exceeds chunk_size, process current_chunk
if current_length + self._chunk_length(split_text) > self.split_length:
# process current_chunk
if current_chunk: # keep the good splits
chunks.append("".join(current_chunk))
current_chunk = []
current_length = 0
# recursively handle splits that are too large
if self._chunk_length(split_text) > self.split_length:
if curr_separator == self.separators[-1]:
# tried last separator, can't split further, do a fixed-split based on word/character/token
fall_back_chunks = self._fall_back_to_fixed_chunking(split_text, self.split_units)
chunks.extend(fall_back_chunks)
else:
chunks.extend(self._chunk_text(split_text))
else:
current_chunk.append(split_text)
current_length += self._chunk_length(split_text)
else:
current_chunk.append(split_text)
current_length += self._chunk_length(split_text)
if current_chunk:
chunks.append("".join(current_chunk))
if self.split_overlap > 0:
chunks = self._apply_overlap(chunks)
if chunks:
return chunks
# if no separator worked, fall back to word- or character-level chunking
chunks = self._fall_back_to_fixed_chunking(text, self.split_units)
if self.split_overlap > 0:
chunks = self._apply_overlap(chunks)
return chunks
def _fall_back_to_fixed_chunking(self, text: str, split_units: Literal["word", "char", "token"]) -> list[str]:
"""
Fall back to a fixed chunking approach if no separator works for the text.
Splits the text into smaller chunks based on the split_length and split_units attributes, either by words,
characters, or tokens.
:param text: The text to be split into chunks.
:param split_units: The unit of the split_length parameter. It can be either "word", "char", or "token".
:returns:
A list of text chunks.
"""
chunks = []
if split_units == "word":
words = re.findall(r"\S+|\s+", text)
current_chunk = []
current_length = 0
for word in words:
if word != " ":
current_chunk.append(word)
current_length += 1
if current_length == self.split_length and current_chunk:
chunks.append("".join(current_chunk))
current_chunk = []
current_length = 0
else:
current_chunk.append(word)
if current_chunk:
chunks.append("".join(current_chunk))
elif split_units == "char":
for i in range(0, self._chunk_length(text), self.split_length):
chunks.append(text[i : i + self.split_length])
else: # token
# at this point we know that the tokenizer is already initialized
tokens = self.tiktoken_tokenizer.encode(text) # type: ignore
for i in range(0, len(tokens), self.split_length):
chunk_tokens = tokens[i : i + self.split_length]
chunks.append(self.tiktoken_tokenizer.decode(chunk_tokens)) # type: ignore
return chunks
def _add_overlap_info(self, curr_pos: int, new_doc: Document, new_docs: list[Document]) -> None:
prev_doc = new_docs[-1]
# curr_pos and split_idx_start are character offsets, so measure the
# overlap and range in characters too (not via _chunk_length, which returns a word/token count).
prev_doc_length = len(prev_doc.content) # type: ignore
overlap_length = prev_doc_length - (curr_pos - prev_doc.meta["split_idx_start"])
if overlap_length > 0:
prev_doc.meta["_split_overlap"].append({"doc_id": new_doc.id, "range": (0, overlap_length)})
new_doc.meta["_split_overlap"].append(
{"doc_id": prev_doc.id, "range": (prev_doc_length - overlap_length, prev_doc_length)}
)
def _run_one(self, doc: Document) -> list[Document]:
chunks = self._chunk_text(doc.content) # type: ignore # the caller already check for a non-empty doc.content
chunks = chunks[:-1] if len(chunks[-1]) == 0 else chunks # remove last empty chunk if it exists
current_position = 0
current_page = 1
new_docs: list[Document] = []
for split_nr, chunk in enumerate(chunks):
meta = deepcopy(doc.meta)
meta["parent_id"] = doc.id
meta["split_id"] = split_nr
meta["split_idx_start"] = current_position
meta["_split_overlap"] = [] if self.split_overlap > 0 else None
new_doc = Document(content=chunk, meta=meta)
# add overlap information to the previous and current doc
if split_nr > 0 and self.split_overlap > 0:
self._add_overlap_info(current_position, new_doc, new_docs)
# count page breaks in the chunk
current_page += chunk.count("\f")
# if there are consecutive page breaks at the end with no more text, adjust the page number
# e.g: "text\f\f\f" -> 3 page breaks, but current_page should be 1
consecutive_page_breaks = len(chunk) - len(chunk.rstrip("\f"))
if consecutive_page_breaks > 0:
new_doc.meta["page_number"] = current_page - consecutive_page_breaks
else:
new_doc.meta["page_number"] = current_page
# keep the new chunk doc and update the current position
new_docs.append(new_doc)
# Advance current_position by chunk length minus overlap.
# split_overlap is in split_units, not chars, so get the actual
# overlap string from _get_overlap() and use its char length.
if self.split_overlap > 0 and split_nr < len(chunks) - 1:
overlap_str, _ = self._get_overlap([doc.content for doc in new_docs]) # type: ignore[misc]
overlap_char_len = len(overlap_str)
else:
overlap_char_len = 0
current_position += len(chunk) - overlap_char_len
return new_docs
@component.output_types(documents=list[Document])
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
"""
Split a list of documents into documents with smaller chunks of text.
:param documents: List of Documents to split.
:returns:
A dictionary containing a key "documents" with a List of Documents with smaller chunks of text corresponding
to the input documents.
"""
if not self._is_warmed_up and ("sentence" in self.separators or self.split_units == "token"):
self.warm_up()
docs = []
for doc in documents:
if not doc.content or doc.content == "":
logger.warning("Document ID {doc_id} has an empty content. Skipping this document.", doc_id=doc.id)
continue
docs.extend(self._run_one(doc))
return {"documents": docs}