Coverage for haystack/components/preprocessors/recursive_splitter.py: 92%

266 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-09 14:34 +0000

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5import re 

6from copy import deepcopy 

7from typing import Any, Literal 

8 

9from haystack import Document, component, default_to_dict, logging 

10from haystack.components.preprocessors._page_numbers import _leading_page_breaks 

11from haystack.lazy_imports import LazyImport 

12 

13with LazyImport("Run 'pip install tiktoken'") as tiktoken_imports: 

14 import tiktoken 

15 

16logger = logging.getLogger(__name__) 

17 

18 

19@component 

20class RecursiveDocumentSplitter: 

21 """ 

22 Recursively chunk text into smaller chunks. 

23 

24 This component is used to split text into smaller chunks, it does so by recursively applying a list of separators 

25 to the text. 

26 

27 The separators are applied in the order they are provided, typically this is a list of separators that are 

28 applied in a specific order, being the last separator the most specific one. 

29 

30 Each separator is applied to the text, it then checks each of the resulting chunks, it keeps the chunks that 

31 are within the split_length, for the ones that are larger than the split_length, it applies the next separator in the 

32 list to the remaining text. 

33 

34 This is done until all chunks are smaller than the split_length parameter. 

35 

36 Example: 

37 

38 ```python 

39 from haystack import Document 

40 from haystack.components.preprocessors import RecursiveDocumentSplitter 

41 

42 chunker = RecursiveDocumentSplitter(split_length=15, split_overlap=0, separators=["\\n\\n", "\\n", ".", " "]) 

43 text = ('''Artificial intelligence (AI) - Introduction 

44 

45 AI, in its broadest sense, is intelligence exhibited by machines, particularly computer systems. 

46 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.''') 

47 doc = Document(content=text) 

48 doc_chunks = chunker.run([doc]) 

49 print(doc_chunks["documents"]) 

50 # [ 

51 # Document(id=..., content: 'Artificial intelligence (AI) - Introduction\\n\\n', meta: {'source_id': '...', 'parent_id': '...', 'split_id': 0, 'split_idx_start': 0, '_split_overlap': None, 'page_number': 1}) 

52 # Document(id=..., content: 'AI, in its broadest sense, is intelligence exhibited by machines, particularly computer systems.\\n', meta: {'source_id': '...', 'parent_id': '...', 'split_id': 1, 'split_idx_start': 45, '_split_overlap': None, 'page_number': 1}) 

53 # Document(id=..., content: 'AI technology is widely used throughout industry, government, and science.', meta: {'source_id': '...', 'parent_id': '...', 'split_id': 2, 'split_idx_start': 142, '_split_overlap': None, 'page_number': 1}) 

54 # Document(id=..., content: ' Some high-profile applications include advanced web search engines; recommendation systems; interac...', meta: {'source_id': '...', 'parent_id': '...', 'split_id': 3, 'split_idx_start': 216, '_split_overlap': None, 'page_number': 1}) 

55 # Document(id=..., content: 'vehicles; generative and creative tools; and superhuman play and analysis in strategy games.', meta: {'source_id': '...', 'parent_id': '...', 'split_id': 4, 'split_idx_start': 350, '_split_overlap': None, 'page_number': 1}) 

56 # ] 

57 ``` 

58 """ # noqa: E501 

59 

60 def __init__( 

61 self, 

62 *, 

63 split_length: int = 200, 

64 split_overlap: int = 0, 

65 split_unit: Literal["word", "char", "token"] = "word", 

66 separators: list[str] | None = None, 

67 sentence_splitter_params: dict[str, Any] | None = None, 

68 ) -> None: 

69 """ 

70 Initializes a RecursiveDocumentSplitter. 

71 

72 :param split_length: The maximum length of each chunk by default in words, but can be in characters or tokens. 

73 See the `split_units` parameter. 

74 :param split_overlap: The number of overlapping units (words, characters, or tokens, per 

75 `split_unit`) between consecutive chunks. 

76 :param split_unit: The unit of the split_length parameter. It can be either "word", "char", or "token". 

77 If "token" is selected, the text will be split into tokens using the tiktoken tokenizer (o200k_base). 

78 Special-token strings in document content are encoded as ordinary text. 

79 :param separators: An optional list of separator strings to use for splitting the text. The string 

80 separators will be treated as regular expressions unless the separator is "sentence", in that case the 

81 text will be split into sentences using a custom sentence tokenizer based on NLTK. 

82 See: haystack.components.preprocessors.sentence_tokenizer.SentenceSplitter. 

83 If no separators are provided, the default separators ["\\n\\n", "sentence", "\\n", " "] are used. 

84 :param sentence_splitter_params: Optional parameters to pass to the sentence tokenizer. 

85 See: haystack.components.preprocessors.sentence_tokenizer.SentenceSplitter for more information. 

86 The chunks keep the whitespace between sentences whether `keep_white_spaces` is True or False; it only 

87 changes the rules the tokenizer uses to decide where a sentence ends. 

88 

89 :raises ValueError: If the overlap is greater than or equal to the chunk size or if the overlap is negative, or 

90 if any separator is not a string. 

91 """ 

92 self.split_length = split_length 

93 self.split_overlap = split_overlap 

94 self.split_units = split_unit 

95 self.separators = separators if separators else ["\n\n", "sentence", "\n", " "] # default separators 

96 self._check_params() 

97 self.nltk_tokenizer = None 

98 self.sentence_splitter_params = ( 

99 {"keep_white_spaces": True} if sentence_splitter_params is None else sentence_splitter_params 

100 ) 

101 self.tiktoken_tokenizer: "tiktoken.Encoding" | None = None 

102 self._is_warmed_up = False 

103 

104 def warm_up(self) -> None: 

105 """ 

106 Warm up the sentence tokenizer and tiktoken tokenizer if needed. 

107 """ 

108 if self._is_warmed_up: 

109 return 

110 if "sentence" in self.separators: 

111 self.nltk_tokenizer = self._get_custom_sentence_tokenizer(self.sentence_splitter_params) 

112 if self.split_units == "token": 

113 tiktoken_imports.check() 

114 self.tiktoken_tokenizer = tiktoken.get_encoding("o200k_base") 

115 self._is_warmed_up = True 

116 

117 def to_dict(self) -> dict[str, Any]: 

118 """ 

119 Serializes the component to a dictionary. 

120 

121 :returns: 

122 Dictionary with serialized data. 

123 """ 

124 return default_to_dict( 

125 self, 

126 split_length=self.split_length, 

127 split_overlap=self.split_overlap, 

128 split_unit=self.split_units, 

129 separators=self.separators, 

130 sentence_splitter_params=self.sentence_splitter_params, 

131 ) 

132 

133 def _check_params(self) -> None: 

134 if self.split_length < 1: 

135 raise ValueError("Split length must be at least 1 character.") 

136 if self.split_overlap < 0: 

137 raise ValueError("split_overlap must be greater than or equal to 0.") 

138 if self.split_overlap >= self.split_length: 

139 raise ValueError("Overlap cannot be greater than or equal to the chunk size.") 

140 if not all(isinstance(separator, str) for separator in self.separators): 

141 raise ValueError("All separators must be strings.") 

142 

143 @staticmethod 

144 def _get_custom_sentence_tokenizer(sentence_splitter_params: dict[str, Any]) -> Any: 

145 from haystack.components.preprocessors.sentence_tokenizer import SentenceSplitter 

146 

147 return SentenceSplitter(**sentence_splitter_params) 

148 

149 def _split_chunk(self, current_chunk: str) -> tuple[str, str]: 

150 """ 

151 Splits a chunk based on the split_length and split_units attribute. 

152 

153 :param current_chunk: The current chunk to be split. 

154 :returns: 

155 A tuple containing the current chunk and the remaining chunk. 

156 """ 

157 if self.split_units == "char": 

158 text = current_chunk 

159 current_chunk = text[: self.split_length] 

160 remaining_chars = text[self.split_length :] 

161 return current_chunk, remaining_chars 

162 

163 # at this point we know that the tokenizer is already initialized 

164 tokens = self.tiktoken_tokenizer.encode_ordinary(current_chunk) # type: ignore 

165 current_tokens = tokens[: self.split_length] 

166 remaining_tokens = tokens[self.split_length :] 

167 return self.tiktoken_tokenizer.decode(current_tokens), self.tiktoken_tokenizer.decode(remaining_tokens) # type: ignore 

168 

169 def _apply_overlap(self, chunks: list[str]) -> list[str]: 

170 """ 

171 Applies an overlap between consecutive chunks if the chunk_overlap attribute is greater than zero. 

172 

173 Works for character- and token-level splitting, words are handled by `_apply_word_overlap`. It trims the last 

174 chunk if it exceeds the split_length and adds the trimmed content to the next chunk. If the last chunk is still 

175 too long after trimming, it splits it and adds the first chunk to the list. This process continues until the 

176 last chunk is within the split_length. 

177 

178 :param chunks: A list of text chunks. 

179 :returns: 

180 A list of text chunks with the overlap applied. 

181 """ 

182 overlapped_chunks: list[str] = [] 

183 

184 for idx, chunk in enumerate(chunks): 

185 if idx == 0: 

186 overlapped_chunks.append(chunk) 

187 continue 

188 

189 # get the overlap between the current and previous chunk 

190 overlap, prev_chunk = self._get_overlap(overlapped_chunks) 

191 if overlap == prev_chunk: 

192 logger.warning( 

193 "Overlap is the same as the previous chunk. " 

194 "Consider increasing the `split_length` parameter or decreasing the `split_overlap` parameter." 

195 ) 

196 

197 current_chunk = self._create_chunk_starting_with_overlap(chunk, overlap) 

198 

199 # if this new chunk exceeds 'split_length', trim it and move the remaining text to the next chunk 

200 # if this is the last chunk, another new chunk will contain the trimmed text preceded by the overlap 

201 # of the last chunk 

202 if self._chunk_length(current_chunk) > self.split_length: 

203 current_chunk, remaining_text = self._split_chunk(current_chunk) 

204 if idx < len(chunks) - 1: 

205 if self.split_units == "token": 

206 # For token-based splitting, combine at token level 

207 # at this point we know that the tokenizer is already initialized 

208 remaining_tokens = self.tiktoken_tokenizer.encode_ordinary(remaining_text) # type: ignore 

209 next_chunk_tokens = self.tiktoken_tokenizer.encode_ordinary(chunks[idx + 1]) # type: ignore 

210 chunks[idx + 1] = self.tiktoken_tokenizer.decode(remaining_tokens + next_chunk_tokens) # type: ignore 

211 else: # char 

212 chunks[idx + 1] = remaining_text + chunks[idx + 1] 

213 elif remaining_text: 

214 # create a new chunk with the trimmed text preceded by the overlap of the last chunk 

215 overlapped_chunks.append(current_chunk) 

216 chunk = remaining_text 

217 overlap, _ = self._get_overlap(overlapped_chunks) 

218 current_chunk = self._create_chunk_starting_with_overlap(chunk, overlap) 

219 

220 overlapped_chunks.append(current_chunk) 

221 

222 # it can still be that the new last chunk exceeds the 'split_length' 

223 # continue splitting until the last chunk is within 'split_length' 

224 if idx == len(chunks) - 1 and self._chunk_length(current_chunk) > self.split_length: 

225 last_chunk = overlapped_chunks.pop() 

226 first_chunk, remaining_chunk = self._split_chunk(last_chunk) 

227 overlapped_chunks.append(first_chunk) 

228 

229 while remaining_chunk: 

230 # combine overlap with remaining chunk 

231 overlap, _ = self._get_overlap(overlapped_chunks) 

232 current = self._create_chunk_starting_with_overlap(remaining_chunk, overlap) 

233 

234 # if it fits within split_length we are done 

235 if self._chunk_length(current) <= self.split_length: 

236 overlapped_chunks.append(current) 

237 break 

238 

239 # otherwise split it again 

240 first_chunk, remaining_chunk = self._split_chunk(current) 

241 overlapped_chunks.append(first_chunk) 

242 

243 return overlapped_chunks 

244 

245 def _create_chunk_starting_with_overlap(self, chunk: str, overlap: str) -> str: 

246 if self.split_units == "token": 

247 # For token-based splitting, combine at token level 

248 # at this point we know that the tokenizer is already initialized 

249 overlap_tokens = self.tiktoken_tokenizer.encode_ordinary(overlap) # type: ignore 

250 chunk_tokens = self.tiktoken_tokenizer.encode_ordinary(chunk) # type: ignore 

251 current_chunk = self.tiktoken_tokenizer.decode(overlap_tokens + chunk_tokens) # type: ignore 

252 else: # char 

253 current_chunk = overlap + chunk 

254 return current_chunk 

255 

256 def _get_overlap(self, overlapped_chunks: list[str]) -> tuple[str, str]: 

257 """Get the previous overlapped chunk instead of the original chunk.""" 

258 prev_chunk = overlapped_chunks[-1] 

259 overlap_start = max(0, self._chunk_length(prev_chunk) - self.split_overlap) 

260 

261 if self.split_units == "token": 

262 # For token-based splitting, handle overlap at token level 

263 # at this point we know that the tokenizer is already initialized 

264 tokens = self.tiktoken_tokenizer.encode_ordinary(prev_chunk) # type: ignore 

265 overlap_tokens = tokens[overlap_start:] 

266 overlap = self.tiktoken_tokenizer.decode(overlap_tokens) # type: ignore 

267 else: # char 

268 overlap = prev_chunk[overlap_start:] 

269 

270 return overlap, prev_chunk 

271 

272 def _apply_word_overlap(self, chunks: list[str]) -> list[tuple[int, int]]: 

273 """ 

274 Applies an overlap between consecutive chunks when splitting by words. 

275 

276 Follows `_apply_overlap`: each chunk starts with the last `split_overlap` words of the chunk before it, and a 

277 chunk that grows past `split_length` words is trimmed, its remaining words moving on to the next chunk. The 

278 chunks are cut out of the text rather than rebuilt from their words, so they keep its whitespace and page 

279 breaks. Words are counted as in `_chunk_length`. 

280 

281 :param chunks: A list of text chunks, as returned by `_chunk_text`. 

282 :returns: 

283 The `(start, end)` character offsets of each chunk with the overlap applied, in the concatenated `chunks`. 

284 """ 

285 # The words of every chunk, as offsets into the concatenated chunks. A chunk of only spaces has no words and 

286 # joins the chunk before it (or after it, at the start of the text). 

287 words: list[tuple[int, int]] = [] 

288 chunk_word_ends: list[int] = [] 

289 chunk_starts: list[int] = [] 

290 offset = 0 

291 for chunk in chunks: 

292 chunk_words = [(offset + match.start(), offset + match.end()) for match in re.finditer(r"[^ ]+", chunk)] 

293 if chunk_words: 

294 words.extend(chunk_words) 

295 chunk_word_ends.append(len(words)) 

296 chunk_starts.append(offset) 

297 offset += len(chunk) 

298 if not words: 

299 return [(0, offset)] 

300 # a chunk's text runs up to where the next one starts 

301 chunk_ends = chunk_starts[1:] + [offset] 

302 

303 spans = [(0, chunk_ends[0])] 

304 # the words the latest chunk spans, as indices into `words` 

305 first_word, end_word = 0, chunk_word_ends[0] 

306 for idx in range(1, len(chunk_word_ends)): 

307 if end_word - first_word <= self.split_overlap: 

308 logger.warning( 

309 "Overlap is the same as the previous chunk. " 

310 "Consider increasing the `split_length` parameter or decreasing the `split_overlap` parameter." 

311 ) 

312 # only the last chunk emits more than one chunk; the others hand their remaining words to the next one 

313 while True: 

314 first_word = max(first_word, end_word - self.split_overlap) 

315 end_word = min(first_word + self.split_length, chunk_word_ends[idx]) 

316 # a trimmed chunk ends after its last word, and the next chunk picks up from its overlap 

317 end = chunk_ends[idx] if end_word == chunk_word_ends[idx] else words[end_word - 1][1] 

318 spans.append((words[first_word][0], end)) 

319 if idx < len(chunk_word_ends) - 1 or end_word == chunk_word_ends[idx]: 

320 break 

321 

322 return spans 

323 

324 def _chunk_length(self, text: str) -> int: 

325 """ 

326 Get the length of the chunk in the specified units (words, characters, or tokens). 

327 

328 :param text: The text to measure. 

329 :returns: The length of the text in the specified units. 

330 """ 

331 if self.split_units == "word": 

332 words = [word for word in text.split(" ") if word] 

333 return len(words) 

334 if self.split_units == "char": 

335 return len(text) 

336 # token 

337 # at this point we know that the tokenizer is already initialized 

338 return len(self.tiktoken_tokenizer.encode_ordinary(text)) # type: ignore 

339 

340 def _chunk_text(self, text: str) -> list[str]: 

341 """ 

342 Recursive chunking algorithm that divides text into smaller chunks based on a list of separator characters. 

343 

344 It starts with a list of separator characters (e.g., ["\n\n", "sentence", "\n", " "]) and attempts to divide 

345 the text using the first separator. If the resulting chunks are still larger than the specified chunk size, 

346 it moves to the next separator in the list. This process continues recursively, progressively applying each 

347 specific separator until the chunks meet the desired size criteria. 

348 

349 :param text: The text to be split into chunks. 

350 :returns: 

351 A list of text chunks. 

352 """ 

353 if self._chunk_length(text) <= self.split_length: 

354 return [text] 

355 

356 for curr_separator in self.separators: 

357 # text after the last split that the splits leave out 

358 trailing_text = "" 

359 if curr_separator == "sentence": 

360 # re. ignore: correct SentenceSplitter initialization is checked at the initialization of the component 

361 sentence_with_spans = self.nltk_tokenizer.split_sentences(text) # type: ignore 

362 # Each split runs from where its sentence starts to where the next one does, so the splits keep the 

363 # whitespace between sentences even where the tokenizer leaves it out (keep_white_spaces=False). 

364 # Leaving it out would glue sentences together, lose page breaks and shift every later chunk's offset. 

365 split_bounds = [sentence["start"] for sentence in sentence_with_spans] 

366 if split_bounds: 

367 split_bounds.append(sentence_with_spans[-1]["end"]) 

368 trailing_text = text[split_bounds[-1] :] 

369 splits = [text[start:end] for start, end in zip(split_bounds, split_bounds[1:], strict=False)] 

370 else: 

371 # add escape "\" to the separator and wrapped it in a group so that it's included in the splits as well 

372 escaped_separator = re.escape(curr_separator) 

373 escaped_separator = f"({escaped_separator})" 

374 

375 # split the text and merge every two consecutive splits, i.e.: the text and the separator after it 

376 splits = re.split(escaped_separator, text) 

377 splits = [ 

378 "".join([splits[i], splits[i + 1]]) if i < len(splits) - 1 else splits[i] 

379 for i in range(0, len(splits), 2) 

380 ] 

381 

382 # remove last split if it's empty 

383 splits = splits[:-1] if splits[-1] == "" else splits 

384 

385 if len(splits) == 1: # go to next separator, if current separator not found in the text 

386 continue 

387 

388 chunks = [] 

389 current_chunk: list[str] = [] 

390 current_length = 0 

391 

392 # check splits, if any is too long, recursively chunk it, otherwise add to current chunk 

393 for split in splits: 

394 split_text = split 

395 

396 # if adding this split exceeds chunk_size, process current_chunk 

397 if current_length + self._chunk_length(split_text) > self.split_length: 

398 # process current_chunk 

399 if current_chunk: # keep the good splits 

400 chunks.append("".join(current_chunk)) 

401 current_chunk = [] 

402 current_length = 0 

403 

404 # recursively handle splits that are too large 

405 if self._chunk_length(split_text) > self.split_length: 

406 if curr_separator == self.separators[-1]: 

407 # tried last separator, can't split further, do a fixed-split based on word/character/token 

408 fall_back_chunks = self._fall_back_to_fixed_chunking(split_text, self.split_units) 

409 chunks.extend(fall_back_chunks) 

410 else: 

411 chunks.extend(self._chunk_text(split_text)) 

412 

413 else: 

414 current_chunk.append(split_text) 

415 current_length += self._chunk_length(split_text) 

416 else: 

417 current_chunk.append(split_text) 

418 current_length += self._chunk_length(split_text) 

419 

420 if current_chunk: 

421 chunks.append("".join(current_chunk)) 

422 

423 if chunks: 

424 # like the whitespace left over by _fall_back_to_fixed_chunking, attach it to the last chunk rather 

425 # than making it a chunk of its own 

426 chunks[-1] += trailing_text 

427 return chunks 

428 

429 # if no separator worked, fall back to word- or character-level chunking 

430 return self._fall_back_to_fixed_chunking(text, self.split_units) 

431 

432 def _fall_back_to_fixed_chunking(self, text: str, split_units: Literal["word", "char", "token"]) -> list[str]: 

433 """ 

434 Fall back to a fixed chunking approach if no separator works for the text. 

435 

436 Splits the text into smaller chunks based on the split_length and split_units attributes, either by words, 

437 characters, or tokens. 

438 

439 :param text: The text to be split into chunks. 

440 :param split_units: The unit of the split_length parameter. It can be either "word", "char", or "token". 

441 :returns: 

442 A list of text chunks. 

443 """ 

444 chunks = [] 

445 

446 if split_units == "word": 

447 words = re.findall(r"\S+|\s+", text) 

448 current_chunk = [] 

449 current_length = 0 

450 

451 for word in words: 

452 # re.findall above also yields multi-character whitespace tokens (" ", "\t"). 

453 # Only count real words toward the length; any whitespace run is a separator. 

454 if word.strip(): 

455 current_chunk.append(word) 

456 current_length += 1 

457 if current_length == self.split_length and current_chunk: 

458 chunks.append("".join(current_chunk)) 

459 current_chunk = [] 

460 current_length = 0 

461 else: 

462 current_chunk.append(word) 

463 

464 if current_length > 0: 

465 chunks.append("".join(current_chunk)) 

466 elif current_chunk: 

467 # Only whitespace is left over (e.g. trailing whitespace): attach it to the 

468 # previous chunk instead of emitting a whitespace-only chunk on its own. 

469 if chunks: 

470 chunks[-1] += "".join(current_chunk) 

471 else: 

472 chunks.append("".join(current_chunk)) 

473 elif split_units == "char": 

474 for i in range(0, self._chunk_length(text), self.split_length): 

475 chunks.append(text[i : i + self.split_length]) 

476 else: # token 

477 # at this point we know that the tokenizer is already initialized 

478 tokens = self.tiktoken_tokenizer.encode_ordinary(text) # type: ignore 

479 for i in range(0, len(tokens), self.split_length): 

480 chunk_tokens = tokens[i : i + self.split_length] 

481 chunks.append(self.tiktoken_tokenizer.decode(chunk_tokens)) # type: ignore 

482 return chunks 

483 

484 def _add_overlap_info(self, curr_pos: int, new_doc: Document, new_docs: list[Document]) -> None: 

485 prev_doc = new_docs[-1] 

486 # curr_pos and split_idx_start are character offsets, so measure the 

487 # overlap and range in characters too (not via _chunk_length, which returns a word/token count). 

488 prev_doc_length = len(prev_doc.content) # type: ignore 

489 overlap_length = prev_doc_length - (curr_pos - prev_doc.meta["split_idx_start"]) 

490 if overlap_length > 0: 

491 prev_doc.meta["_split_overlap"].append({"doc_id": new_doc.id, "range": (0, overlap_length)}) 

492 new_doc.meta["_split_overlap"].append( 

493 {"doc_id": prev_doc.id, "range": (prev_doc_length - overlap_length, prev_doc_length)} 

494 ) 

495 

496 def _run_one(self, doc: Document) -> list[Document]: 

497 content = doc.content or "" # run() skips documents without content, so this is a non-empty string 

498 chunks = self._chunk_text(content) 

499 chunks = chunks[:-1] if len(chunks[-1]) == 0 else chunks # remove last empty chunk if it exists 

500 

501 # apply the overlap once, on the fully chunked list, so that chunks produced 

502 # at inner recursion levels don't get the overlap applied a second time 

503 if self.split_overlap > 0 and self.split_units == "word": 

504 text = "".join(chunks) 

505 spans = self._apply_word_overlap(chunks) 

506 chunks = [text[start:end] for start, end in spans] 

507 chunk_starts = [start for start, _ in spans] 

508 else: 

509 if self.split_overlap > 0: 

510 chunks = self._apply_overlap(chunks) 

511 # each chunk starts where the previous one ends, minus the overlap it repeats 

512 chunk_starts = [0] 

513 for chunk in chunks[:-1]: 

514 overlap = self._get_overlap([chunk])[0] if self.split_overlap > 0 else "" 

515 chunk_starts.append(chunk_starts[-1] + len(chunk) - len(overlap)) 

516 

517 new_docs: list[Document] = [] 

518 # Chunk starts never decrease, so the page breaks before each chunk are counted on from the previous one 

519 # rather than from the start of the document every time. 

520 page_breaks_before = 0 

521 

522 for split_nr, (chunk, current_position) in enumerate(zip(chunks, chunk_starts, strict=True)): 

523 if split_nr > 0: 

524 page_breaks_before += content.count("\f", chunk_starts[split_nr - 1], current_position) 

525 meta = deepcopy(doc.meta) 

526 meta["source_id"] = doc.id 

527 meta["parent_id"] = doc.id 

528 meta["split_id"] = split_nr 

529 meta["split_idx_start"] = current_position 

530 meta["_split_overlap"] = [] if self.split_overlap > 0 else None 

531 new_doc = Document(content=chunk, meta=meta) 

532 

533 # add overlap information to the previous and current doc 

534 if split_nr > 0 and self.split_overlap > 0: 

535 self._add_overlap_info(current_position, new_doc, new_docs) 

536 

537 # The page the chunk's first non-page-break character is on: breaks before the chunk, plus the 

538 # ones it opens with. 

539 new_doc.meta["page_number"] = 1 + page_breaks_before + _leading_page_breaks(chunk) 

540 

541 new_docs.append(new_doc) 

542 

543 return new_docs 

544 

545 @component.output_types(documents=list[Document]) 

546 def run(self, documents: list[Document]) -> dict[str, list[Document]]: 

547 """ 

548 Split a list of documents into documents with smaller chunks of text. 

549 

550 :param documents: List of Documents to split. 

551 :returns: 

552 A dictionary containing a key "documents" with a List of Documents with smaller chunks of text corresponding 

553 to the input documents. Each chunk carries a metadata field `page_number` with the page the chunk 

554 starts on, counting form feed ("\f") characters in the original document. 

555 """ 

556 if not self._is_warmed_up and ("sentence" in self.separators or self.split_units == "token"): 

557 self.warm_up() 

558 

559 docs = [] 

560 for doc in documents: 

561 if not doc.content or doc.content == "": 

562 logger.warning("Document ID {doc_id} has an empty content. Skipping this document.", doc_id=doc.id) 

563 continue 

564 docs.extend(self._run_one(doc)) 

565 

566 return {"documents": docs}