Проблема, которую решает рекурсивный сплиттер
Представьте статью с чётко выраженными абзацами. Каждый абзац — законченная мысль: вводная фраза, раскрытие, вывод. 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:
chunk_size.
Алгоритм: как работает рекурсия
Рассмотрим шаги алгоритма на конкретном примере. Входной текст — 1500 символов,
chunk_size = 500, chunk_overlap = 50,
разделители ["\n\n", "\n", ". ", " ", ""].
"\n\n"# → ["Абзац A (600 симв)", "Абзац B (400 симв)", "Абзац C (500 симв)"]
Абзац A (600) — ❌ превышает 500. Нужно разбить дальше.
if len(seg) <= chunk_size: good_chunks.append(seg)
else: need_splitting.append(seg)
"\n""\n".
Получаем строки: 250, 180, 170 символов — все вписываются.
splits_A = абзац_A.split("\n")
# → ["строка_1 (250)", "строка_2 (180)", "строка_3 (170)"]
chunk_size. Overlap переносит конец предыдущего чанка в начало
следующего — так же, как в fixed-size.
# Chunk 1: строка_1 + строка_2 (430 симв) ✅
# Chunk 2: overlap(50) + строка_3 + Абзац_B (620) → ещё раз дробим
# Chunk 3: Абзац_C (500) ✅
"":
режем посимвольно как fixed-size. Это «запасной парашют».
Визуализация алгоритма
Сравнение с fixed-size chunking
Реализация
Ядро алгоритма
Разберём реализацию с нуля, чтобы понять механику. Три ключевых функции:
_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 имеют разные «единицы смысла» — разделители нужно подбирать под каждый случай.
"\n" — строка
". " / "! " / "? " — предложение
"; " / ", " — клауза
" " / "" — слово / символ
"\n\n" — абзац
"\n- " / "\n* " — элемент списка
"\n" — строка
". " / " " / "" — fallback
"\ndef " / "\nasync def " — функция
"\n\n" — пустая строка (логический блок)
"\n" — строка кода
" " / "" — fallback
"\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" заголовок попадает
в начало нового куска. Но если keep_separator=False, заголовок
исчезает — чанк начинается с тела раздела без контекста.
chunk_size=200 для текста с длинными абзацами,
рекурсия неизбежно доберётся до разбивки по предложениям и словам.
Получится сотни чанков по 5–15 слов — слишком мало контекста для
эффективного embedding.
\n внутри разрезается на отдельные
строки, каждая из которых — отдельный чанк без контекста функции.
"".
Если "" не включён в конец списка — рекурсия вылетает по
исчерпанию разделителей.
[..., " ", ""] — это гарантированный fallback.Шпаргалка
- Текст с абзацами, заголовками, списками — почти всегда
- Когда важно, чтобы чанки заканчивались на границе предложения
- Markdown-документация, юридические тексты, технические статьи
- Исходный код — с кастомными разделителями по
class/def
- Нужна строго равномерная нарезка (бенчмарки, аблации)
- Текст — сплошной поток без структуры (транскрипты без пунктуации)
АЛГОРИТМ (псевдокод):
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", "; ", ". ", " ", ""]
Практические задания
-
Сравнение на реальном тексте. Возьмите любую Markdown-статью
(например, README крупного open-source проекта). Нарежьте её
CharacterSplitter(700, 70)иRecursiveCharacterSplitter.for_markdown(700, 70). Для каждого метода выведите первые 3 чанка и оцените: сохраняются ли структурные границы (заголовки, абзацы)? -
Кастомный пресет для HTML. Напишите метод
RecursiveCharacterSplitter.for_html()с разделителями, которые учитывают структуру HTML: теги<h1>,<h2>,<p>,<li>. Протестируйте на странице с заголовками и списками. -
Детектор аномальных чанков. Напишите функцию
find_bad_chunks(chunks, min_words=10), которая находит: чанки с менее чемmin_wordsсловами (слишком маленькие), чанки, начинающиеся со строчной буквы (возможный обрыв предложения), и чанки без знаков препинания в конце (обрыв посередине). Выводите их с индексом и содержимым для ручной проверки.