Проблема, которую решает рекурсивный сплиттер

Представьте статью с чётко выраженными абзацами. Каждый абзац — законченная мысль: вводная фраза, раскрытие, вывод. Fixed-size chunking с размером 500 символов разрежет такой текст так:

Абзац 1: «Токенизация — процесс разбиения текста на...
...минимальные единицы называются токенами. В русском языке...»  ← 480 символов

Абзац 2: «...одно слово может давать несколько токенов из-за...
Именно поэтому важно правильно настроить...»                    ← 420 символов

Границы fixed-size (chunk=500):
  Chunk 1: абзац 1 полностью + первые 20 символов абзаца 2  ← обрывает абзац 2
  Chunk 2: остаток абзаца 2 + начало абзаца 3               ← без начала мысли
  Chunk 3: ...                                               ← потеря контекста
        

Recursive splitting смотрит на текст иначе. Видит \n\n (двойной перенос = граница абзаца) и сначала пробует разбить по нему. Если отдельный абзац сам по себе больше chunk_size — спускается ниже и пробует разбить по \n, затем по . (конец предложения) и т.д. В итоге чанки совпадают с естественными структурными единицами текста.

Иерархия разделителей

Ключевая идея — упорядоченный список разделителей от «крупных» к «мелким». Алгоритм берёт первый подходящий разделитель, которым можно нарезать текст на куски нужного размера. Стандартная иерархия для русского/английского plain text:

1
"\n\n"
Абзац
Двойной перенос строки. Самая сильная граница смысла в любом тексте. Пробуем первым.
2
"\n"
Строка
Одиночный перенос. В Markdown — конец элемента списка, в коде — конец строки.
3
". " / "! " / "? "
Предложение
Конец предложения с пробелом. Разбиваем если абзац всё ещё слишком большой.
4
", " / "; "
Клауза
Запятая / точка с запятой. Нежелательно — теряется связь подлежащего и сказуемого.
5
" " / ""
Слово / символ
Крайний случай. Пробел — режем по словам. Пустая строка — по символам (как fixed-size).
Принцип деградации: алгоритм всегда пробует разбить по самому «крупному» доступному разделителю. Более мелкий используется только тогда, когда крупный не помогает — либо разделитель не найден в тексте, либо получающиеся куски всё равно превышают chunk_size.

Алгоритм: как работает рекурсия

Рассмотрим шаги алгоритма на конкретном примере. Входной текст — 1500 символов, chunk_size = 500, chunk_overlap = 50, разделители ["\n\n", "\n", ". ", " ", ""].

1
Берём первый разделитель из списка: "\n\n"
Делим текст по двойным переносам строки. Получаем сегменты: абзац A (600 симв), абзац B (400 симв), абзац C (500 симв).
splits = text.split("\n\n")
# → ["Абзац A (600 симв)", "Абзац B (400 симв)", "Абзац C (500 симв)"]
2
Проверяем каждый сегмент: вписывается ли в chunk_size?
Абзац B (400) и C (500) — ✅ вписываются, берём как готовые чанки.
Абзац A (600) — ❌ превышает 500. Нужно разбить дальше.
for seg in splits:
    if len(seg) <= chunk_size: good_chunks.append(seg)
    else: need_splitting.append(seg)
3
Рекурсивный вызов для абзаца A: следующий разделитель "\n"
Абзац A содержит одиночные переносы — разбиваем по "\n". Получаем строки: 250, 180, 170 символов — все вписываются.
# Рекурсия: _split(абзац_A, separators[1:], ...)
splits_A = абзац_A.split("\n")
# → ["строка_1 (250)", "строка_2 (180)", "строка_3 (170)"]
4
Сборка чанков с учётом overlap
Итоговые сегменты объединяются жадно: добавляем следующий пока не превышаем chunk_size. Overlap переносит конец предыдущего чанка в начало следующего — так же, как в fixed-size.
# Финальные чанки:
# Chunk 1: строка_1 + строка_2 (430 симв) ✅
# Chunk 2: overlap(50) + строка_3 + Абзац_B (620) → ещё раз дробим
# Chunk 3: Абзац_C (500) ✅
5
Крайний случай: разделитель не найден или не помогает
Если ни один разделитель из оставшегося списка не встречается в сегменте или не уменьшает его до нужного размера — используем пустую строку "": режем посимвольно как fixed-size. Это «запасной парашют».

Визуализация алгоритма

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Текст 1500 симв chunk=500 split("\n\n") Абзац A 600 симв ❌ Абзац B 400 симв ✅ Абзац C 500 симв ✅ ❌ велик Рекурсия: split("\n") строка 1: 250 симв ✅ строка 2: 180 симв ✅ Жадная сборка + overlap Chunk 1 стр1+стр2 = 430 Chunk 2 ov+Абзац B = 450 Chunk 3 ov+Абзац C = 500 list[Document] 3 чанка все ≤ chunk_size границы — смысловые Иерархия разделителей: "\n\n" → "\n" → ". " → ", " → " " → "" если и \n не помогает split("") — посимвольно

Сравнение с fixed-size chunking

Критерий
Fixed-size
Recursive splitting
Граница чанка
Произвольная позиция в тексте
Ближайшая структурная граница
Размер чанков
Равномерный, строго ≤ chunk_size
Переменный: от 1 слова до chunk_size
Качество текста в чанке
Среднее — обрывы предложений
Высокое — целые абзацы
Скорость
O(n) — прямой срез
O(n·d) — d уровней рекурсии
Настройка
2 параметра: size + overlap
2 параметра + список разделителей
Текст без структуры
Одинаково хорошо
Одинаково хорошо — деградирует до fixed
Markdown / код
Плохо — рвёт блоки
Хорошо — кастомные разделители
Когда recursive splitting — дефолтный выбор: для большинства задач с обычным текстом (статьи, регламенты, FAQ, документация) он даёт лучшее качество при сопоставимой скорости. Fixed-size остаётся актуальным только если нужна строго равномерная нарезка или текст уже хорошо структурирован по абзацам одинакового размера.

Реализация

Ядро алгоритма

Разберём реализацию с нуля, чтобы понять механику. Три ключевых функции: _split_text — рекурсивный шаг, _merge_splits — жадная сборка чанков с overlap, split_documents — публичный API.

from __future__ import annotations

import re
from dataclasses import dataclass, field


@dataclass
class Document:
    page_content: str
    metadata: dict = field(default_factory=dict)


class RecursiveCharacterSplitter:
    """
    Иерархический сплиттер: пробует разделители по убыванию «крупности»,
    рекурсивно спускается если кусок всё ещё слишком велик.

    separators  — список разделителей в порядке убывания приоритета.
                  По умолчанию: абзац → строка → предложение → слово → символ.
    keep_separator — включать ли разделитель в конец предыдущего или начало
                     следующего куска (по умолчанию: в конец предыдущего).
    """

    DEFAULT_SEPARATORS = ["\n\n", "\n", ". ", "! ", "? ", "; ", ", ", " ", ""]

    def __init__(
        self,
        chunk_size: int = 700,
        chunk_overlap: int = 70,
        separators: list[str] | None = None,
        keep_separator: bool = True,
    ):
        if chunk_overlap >= chunk_size:
            raise ValueError("chunk_overlap должен быть меньше chunk_size")
        self.chunk_size     = chunk_size
        self.chunk_overlap  = chunk_overlap
        self.separators     = separators or self.DEFAULT_SEPARATORS
        self.keep_separator = keep_separator

    # ──────────────────────────────────────────────────────────────────
    # Публичный API
    # ──────────────────────────────────────────────────────────────────

    def split_text(self, text: str) -> list[str]:
        return self._split_text(text, self.separators)

    def split_documents(self, documents: list[Document]) -> list[Document]:
        result = []
        for doc in documents:
            chunks = self.split_text(doc.page_content)
            for i, chunk in enumerate(chunks):
                result.append(Document(
                    page_content=chunk,
                    metadata={
                        **doc.metadata,
                        "chunk_index": i,
                        "chunk_total": len(chunks),
                        "chunk_size":  len(chunk),
                    },
                ))
        return result

    # ──────────────────────────────────────────────────────────────────
    # Внутренняя рекурсия
    # ──────────────────────────────────────────────────────────────────

    def _split_text(self, text: str, separators: list[str]) -> list[str]:
        """
        Рекурсивный шаг:
        1. Выбираем подходящий разделитель.
        2. Режем по нему.
        3. Рекурсивно обрабатываем куски, которые всё ещё велики.
        4. Собираем чанки жадно с overlap.
        """
        final_chunks: list[str] = []

        # Ищем первый разделитель, который встречается в тексте
        separator = ""
        remaining_separators: list[str] = []
        for i, sep in enumerate(separators):
            if sep == "":
                separator = sep
                break
            if sep in text:
                separator = sep
                remaining_separators = separators[i + 1:]
                break

        # Делим текст по найденному разделителю
        if separator:
            splits = self._split_by_separator(text, separator)
        else:
            splits = list(text)  # посимвольно как fallback

        # Обрабатываем каждый кусок
        good_splits: list[str] = []
        for split in splits:
            split = split.strip()
            if not split:
                continue

            if len(split) <= self.chunk_size:
                # Кусок вписывается — кладём в очередь на сборку
                good_splits.append(split)
            else:
                # Кусок всё ещё велик — рекурсия с более мелкими разделителями
                if good_splits:
                    # Сначала собираем накопленные хорошие куски
                    final_chunks.extend(self._merge_splits(good_splits, separator))
                    good_splits = []
                if remaining_separators:
                    final_chunks.extend(self._split_text(split, remaining_separators))
                else:
                    # Крайний случай: фиксированная нарезка
                    final_chunks.extend(self._fixed_split(split))

        # Собираем оставшиеся хорошие куски
        if good_splits:
            final_chunks.extend(self._merge_splits(good_splits, separator))

        return final_chunks

    def _split_by_separator(self, text: str, separator: str) -> list[str]:
        """
        Делит текст по разделителю с опцией сохранения разделителя
        в конце каждого куска (keep_separator=True).
        """
        if not self.keep_separator:
            return text.split(separator)

        # Сохраняем разделитель: "А.\nБ." → ["А.", "Б."]
        # split с capture-группой включает разделитель в результат
        parts = re.split(f"({re.escape(separator)})", text)
        # Объединяем пары (кусок, разделитель)
        merged: list[str] = []
        i = 0
        while i < len(parts):
            if i + 1 < len(parts) and parts[i + 1] == separator:
                merged.append(parts[i] + parts[i + 1])
                i += 2
            else:
                if parts[i]:
                    merged.append(parts[i])
                i += 1
        return merged

    def _merge_splits(self, splits: list[str], separator: str) -> list[str]:
        """
        Жадно объединяет короткие куски в чанки размером ≤ chunk_size,
        добавляет overlap между соседними чанками.
        """
        chunks: list[str] = []
        current: list[str] = []
        current_len = 0
        sep_len = len(separator) if self.keep_separator else len(separator)

        for split in splits:
            split_len = len(split)

            # Если добавление этого куска превысит лимит — финализируем чанк
            if current_len + split_len + (sep_len if current else 0) > self.chunk_size:
                if current:
                    chunk = separator.join(current).strip() if not self.keep_separator \
                            else "".join(current).strip()
                    if chunk:
                        chunks.append(chunk)

                    # Формируем overlap: берём с конца текущего чанка
                    while current and (
                        current_len > self.chunk_overlap
                        or current_len + split_len > self.chunk_size
                    ):
                        removed = current.pop(0)
                        current_len -= len(removed) + (sep_len if not self.keep_separator else 0)
                    current_len = max(0, current_len)

            current.append(split)
            current_len += split_len + (sep_len if len(current) > 1 and not self.keep_separator else 0)

        # Финальный чанк
        if current:
            chunk = separator.join(current).strip() if not self.keep_separator \
                    else "".join(current).strip()
            if chunk:
                chunks.append(chunk)

        return chunks

    def _fixed_split(self, text: str) -> list[str]:
        """Запасной метод: нарезка по символам если разделители не помогли."""
        step = self.chunk_size - self.chunk_overlap
        return [
            text[i:i + self.chunk_size].strip()
            for i in range(0, len(text), step)
            if text[i:i + self.chunk_size].strip()
        ]

Примеры использования

text = """
Токенизация — фундаментальная операция в NLP.

Она преобразует сырой текст в последовательность токенов,
которые модель умеет обрабатывать. Каждый токен — это не
обязательно слово: в русском языке «несмотря» может стать
двумя токенами, а «AI» — одним.

Типы токенизаторов:
Первый тип — символьный. Режет по каждому символу. Простой,
но даёт очень длинные последовательности.

Второй тип — словарный (BPE). Используется в GPT и Claude.
Учится на корпусе, строит словарь подслов.
""".strip()

splitter = RecursiveCharacterSplitter(
    chunk_size=200,
    chunk_overlap=30,
)
chunks = splitter.split_text(text)

for i, chunk in enumerate(chunks):
    print(f"── Chunk {i+1} ({len(chunk)} симв) ──")
    print(chunk)
    print()

# ── Chunk 1 (198 симв) ──
# Токенизация — фундаментальная операция в NLP.
#
# Она преобразует сырой текст в последовательность токенов,
# которые модель умеет обрабатывать. Каждый токен — это не
# обязательно слово...
#
# ── Chunk 2 (185 симв) ──
# Типы токенизаторов:
# Первый тип — символьный. Режет по каждому символу. Простой,
# но даёт очень длинные последовательности.
# ...

Пресеты разделителей под разные форматы

Иерархия разделителей должна отражать структуру конкретного формата. Markdown, код и plain text имеют разные «единицы смысла» — разделители нужно подбирать под каждый случай.

Plain Text (RU/EN)
"\n\n" — абзац
"\n" — строка
". " / "! " / "? " — предложение
"; " / ", " — клауза
" " / "" — слово / символ
Markdown
"\n## " / "\n### " — заголовки h2/h3
"\n\n" — абзац
"\n- " / "\n* " — элемент списка
"\n" — строка
". " / " " / "" — fallback
Исходный код (Python)
"\nclass " — определение класса
"\ndef " / "\nasync def " — функция
"\n\n" — пустая строка (логический блок)
"\n" — строка кода
" " / "" — fallback
Юридический / официальный RU
"\n\n" — статья / пункт
"\n" — подпункт
". " — предложение
"; " — перечисление условий
" " / "" — fallback
class RecursiveCharacterSplitter:
    """... (как выше) ..."""

    # ── Готовые пресеты ──────────────────────────────────────────────

    @classmethod
    def for_plain_text(cls, chunk_size: int = 700, chunk_overlap: int = 70):
        return cls(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            separators=["\n\n", "\n", ". ", "! ", "? ", "; ", ", ", " ", ""],
        )

    @classmethod
    def for_markdown(cls, chunk_size: int = 700, chunk_overlap: int = 70):
        return cls(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            separators=[
                # Заголовки — самые крупные структурные единицы в Markdown
                "\n## ", "\n### ", "\n#### ",
                # Горизонтальные разделители
                "\n---\n", "\n***\n",
                # Абзацы и строки
                "\n\n", "\n",
                # Элементы списков
                "\n- ", "\n* ", "\n1. ",
                # Fallback
                ". ", " ", "",
            ],
        )

    @classmethod
    def for_python(cls, chunk_size: int = 1000, chunk_overlap: int = 100):
        return cls(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            separators=[
                # Классы и функции — крупнейшие единицы
                "\nclass ", "\ndef ", "\nasync def ",
                # Вложенные определения
                "\n    def ", "\n    async def ",
                # Логические блоки
                "\n\n", "\n",
                # Выражения
                " ", "",
            ],
        )

    @classmethod
    def for_legal_ru(cls, chunk_size: int = 800, chunk_overlap: int = 80):
        """Юридические документы RU: статьи, пункты, подпункты."""
        return cls(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            separators=[
                # Статьи
                "\nСтатья ", "\nГлава ", "\nРаздел ",
                # Пункты
                "\n\n", "\n",
                # Перечисления
                "; ", ". ",
                " ", "",
            ],
        )


# Использование
md_splitter   = RecursiveCharacterSplitter.for_markdown(chunk_size=600)
py_splitter   = RecursiveCharacterSplitter.for_python(chunk_size=1000)
text_splitter = RecursiveCharacterSplitter.for_plain_text(chunk_size=700)

Сохранение разделителя: keep_separator

Параметр keep_separator=True (умолчание) включает разделитель в конец предыдущего куска. Это важно для читаемости: чанк заканчивается на ". " (точка + пробел) вместо обрыва посередине.

Текст: "Первое предложение. Второе предложение. Третье."
Разделитель: ". "

keep_separator=False:
  splits → ["Первое предложение", "Второе предложение", "Третье."]
  Chunk 1: "Первое предложение"   ← предложение без точки
  Chunk 2: "Второе предложение"   ← без контекста откуда взялось

keep_separator=True (рекомендуется):
  splits → ["Первое предложение. ", "Второе предложение. ", "Третье."]
  Chunk 1: "Первое предложение."  ← законченное предложение
  Chunk 2: "Второе предложение."  ← тоже законченное
        

Для Markdown с заголовками логика обратная — разделитель "\n## " логичнее оставлять в начале следующего куска, чтобы заголовок шёл вместе с содержимым раздела. Для этого нужно слегка изменить алгоритм сборки — или использовать специализированные сплиттеры следующего урока.

Кастомная функция длины

По умолчанию длина считается через len() — в символах. Можно подменить функцию длины на токенную, чтобы все ограничения считались в токенах, но при этом разделители оставались «умными» как в recursive splitting.

import tiktoken


def make_token_length_fn(encoding: str = "cl100k_base"):
    """
    Возвращает функцию длины, которая считает токены вместо символов.
    Используется как drop-in замена len() в сплиттере.
    """
    enc = tiktoken.get_encoding(encoding)

    def token_len(text: str) -> int:
        return len(enc.encode(text))

    return token_len


class RecursiveCharacterSplitter:
    def __init__(
        self,
        chunk_size: int = 256,       # теперь в токенах
        chunk_overlap: int = 32,
        separators: list[str] | None = None,
        keep_separator: bool = True,
        length_function=len,         # можно подменить на token_len
    ):
        self._len = length_function
        # ... остальные параметры ...

    def _fits(self, text: str) -> bool:
        """Проверяет, вписывается ли кусок в chunk_size."""
        return self._len(text) <= self.chunk_size


# Использование с токенным счётом
token_len = make_token_length_fn("cl100k_base")

splitter = RecursiveCharacterSplitter(
    chunk_size=256,        # 256 токенов
    chunk_overlap=32,
    length_function=token_len,
)

# Бенчмарк: символьный vs токенный счёт
import time

text = "Слово " * 5000  # ~30 000 символов

t = time.perf_counter()
chunks_char = RecursiveCharacterSplitter(chunk_size=700).split_text(text)
t_char = time.perf_counter() - t

t = time.perf_counter()
chunks_tok = RecursiveCharacterSplitter(
    chunk_size=256, length_function=token_len
).split_text(text)
t_tok = time.perf_counter() - t

print(f"Символьный: {len(chunks_char)} чанков за {t_char*1000:.1f}ms")
print(f"Токенный:   {len(chunks_tok)} чанков за {t_tok*1000:.1f}ms")
# Символьный: 45 чанков за 1.2ms
# Токенный:   89 чанков за 38.4ms  ← в 30× медленнее

Типичные ошибки

Неправильный порядок разделителей
Если поставить " " (пробел) перед "\n\n", алгоритм найдёт пробел в тексте и сразу нарежет по словам, никогда не дойдя до абзацных границ. Структура документа будет проигнорирована.
✓ Порядок критичен: от крупных единиц к мелким. "\n\n" → "\n" → ". " → " " → ""
Потеря Markdown-заголовка при разбивке
При разделении по "\n## Заголовок\n" заголовок попадает в начало нового куска. Но если keep_separator=False, заголовок исчезает — чанк начинается с тела раздела без контекста.
✓ Для заголовков используйте keep_separator=True (умолчание) или специальный MD-сплиттер, который прикрепляет заголовок к следующему чанку.
Слишком много мелких чанков из-за короткого chunk_size
Если chunk_size=200 для текста с длинными абзацами, рекурсия неизбежно доберётся до разбивки по предложениям и словам. Получится сотни чанков по 5–15 слов — слишком мало контекста для эффективного embedding.
✓ Минимально разумный chunk_size для смыслового текста — ~100 слов (≈500 символов). Для технических FAQ — ~50 слов (≈250 символов).
Не адаптировать разделители под формат
Дефолтные разделители для plain text применяются к Markdown или коду. Результат: блок кода с \n внутри разрезается на отдельные строки, каждая из которых — отдельный чанк без контекста функции.
✓ Используйте пресеты: for_markdown(), for_python() — или создайте кастомный список под ваш формат.
Бесконечная рекурсия на очень длинных словах
Технические тексты с URL длиной 300+ символов или Base64-строками не поддаются разбивке ни по одному разделителю, кроме пустой строки "". Если "" не включён в конец списка — рекурсия вылетает по исчерпанию разделителей.
✓ Всегда завершайте список разделителей пустой строкой: [..., " ", ""] — это гарантированный fallback.

Шпаргалка

Когда выбирать recursive splitting вместо fixed-size:
  • Текст с абзацами, заголовками, списками — почти всегда
  • Когда важно, чтобы чанки заканчивались на границе предложения
  • Markdown-документация, юридические тексты, технические статьи
  • Исходный код — с кастомными разделителями по class/def
Оставляйте fixed-size когда:
  • Нужна строго равномерная нарезка (бенчмарки, аблации)
  • Текст — сплошной поток без структуры (транскрипты без пунктуации)
АЛГОРИТМ (псевдокод):

def _split(text, separators):
  sep = первый разделитель, который есть в тексте
  pieces = text.split(sep)                  # делим по найденному

  good, result = [], []
  for p in pieces:
    if len(p) <= chunk_size:
      good.append(p)                        # мал — в очередь сборки
    else:
      result += merge(good)                 # сбрасываем накопленные
      good = []
      result += _split(p, separators[1:])   # рекурсия вглубь

  result += merge(good)                     # последние куски
  return result

def merge(pieces):
  # жадно объединяем пока не превышаем chunk_size
  # между соседними чанками — overlap

ПРЕСЕТЫ РАЗДЕЛИТЕЛЕЙ:
  plain_text:  ["\n\n", "\n", ". ", "! ", "? ", "; ", ", ", " ", ""]
  markdown:    ["\n## ", "\n### ", "\n\n", "\n", "\n- ", ". ", " ", ""]
  python_code: ["\nclass ", "\ndef ", "\nasync def ", "\n\n", "\n", " ", ""]
  legal_ru:    ["Статья ", "Глава ", "\n\n", "\n", "; ", ". ", " ", ""]
        

Практические задания

  1. Сравнение на реальном тексте. Возьмите любую Markdown-статью (например, README крупного open-source проекта). Нарежьте её CharacterSplitter(700, 70) и RecursiveCharacterSplitter.for_markdown(700, 70). Для каждого метода выведите первые 3 чанка и оцените: сохраняются ли структурные границы (заголовки, абзацы)?
  2. Кастомный пресет для HTML. Напишите метод RecursiveCharacterSplitter.for_html() с разделителями, которые учитывают структуру HTML: теги <h1>, <h2>, <p>, <li>. Протестируйте на странице с заголовками и списками.
  3. Детектор аномальных чанков. Напишите функцию find_bad_chunks(chunks, min_words=10), которая находит: чанки с менее чем min_words словами (слишком маленькие), чанки, начинающиеся со строчной буквы (возможный обрыв предложения), и чанки без знаков препинания в конце (обрыв посередине). Выводите их с индексом и содержимым для ручной проверки.