# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """Page-aware recursive-separator chunking with token overlap. Each chunk records its ``[page_char_start, page_char_end)`` span and ``source_page_index``, used by the locator pass to highlight it on the PDF page.""" from __future__ import annotations from dataclasses import dataclass from typing import Callable from .parsers import Page TokenCounter = Callable[[str], int] SEPARATORS = ("\n# ", "\n## ", "\n### ", "\n\n", "\n", ". ", " ", "") @dataclass(frozen = True) class Chunk: text: str token_count: int page_number: int | None source_page_index: int chunk_index: int page_char_start: int page_char_end: int def _split(text: str, seps: tuple[str, ...], max_tokens: int, count: TokenCounter) -> list[str]: """Recursively split into pieces each <= max_tokens (best effort). Pieces rejoin to ``text`` exactly, so offsets are a running length.""" if count(text) <= max_tokens: return [text] for i, sep in enumerate(seps): parts = list(text) if sep == "" else text.split(sep) if len(parts) <= 1: continue if sep: # re-attach the separator parts = [p + sep for p in parts[:-1]] + parts[-1:] out: list[str] = [] for p in parts: out.extend( [p] if count(p) <= max_tokens else _split(p, seps[i + 1 :], max_tokens, count) ) return [p for p in out if p] n = max(1, max_tokens * 4) return [text[j : j + n] for j in range(0, len(text), n)] def _merge( pieces: list[str], starts: list[int], max_tokens: int, overlap: int, count: TokenCounter ) -> list[tuple[str, int, int]]: """Greedy-merge pieces into <= max_tokens chunks with token overlap. ``starts[i]`` is ``pieces[i]``'s page char offset; returns ``(chunk_text, char_start, char_end)`` spans.""" chunks: list[tuple[str, int, int]] = [] buf: list[str] = [] buf_starts: list[int] = [] buf_tok = 0 def _flush() -> None: raw = "".join(buf) stripped = raw.strip() if not stripped: return lead = len(raw) - len(raw.lstrip()) trail = len(raw) - len(raw.rstrip()) start = buf_starts[0] + lead end = buf_starts[0] + len(raw) - trail chunks.append((stripped, start, end)) for piece, start in zip(pieces, starts): pt = count(piece) if buf and buf_tok + pt > max_tokens: _flush() # Bound the carry so carry + this piece fits max_tokens; else a full # overlap before a near-max piece overflows the embedder. carry_budget = min(overlap, max(0, max_tokens - pt)) carry, carry_starts, run = [], [], 0 for prev, prev_start in zip(reversed(buf), reversed(buf_starts)): if run + count(prev) > carry_budget: break carry.insert(0, prev) carry_starts.insert(0, prev_start) run += count(prev) buf, buf_starts, buf_tok = carry, carry_starts, run buf.append(piece) buf_starts.append(start) buf_tok += pt if buf: _flush() return chunks def chunk_pages( pages: list[Page], *, max_tokens: int, overlap: int, count: TokenCounter ) -> list[Chunk]: """Split each page into overlapping chunks, tracking per-page char offsets.""" out: list[Chunk] = [] for page_index, page in enumerate(pages): pieces = _split(page.text, SEPARATORS, max_tokens, count) # _split preserves offsets, so a running cursor gives exact ones. starts: list[int] = [] cursor = 0 for piece in pieces: starts.append(cursor) cursor += len(piece) for text, char_start, char_end in _merge(pieces, starts, max_tokens, overlap, count): out.append( Chunk( text = text, token_count = count(text), page_number = page.page_number, source_page_index = page_index, chunk_index = len(out), page_char_start = char_start, page_char_end = char_end, ) ) return out