2.3 Углубление в текстовое разбиение
Разбиение (сегментация) выполняется после загрузки данных в формат «документа», но до индексации или хранения: цель — получить семантически осмысленные фрагменты (чанки), с которыми удобно работать в поиске и аналитике, не нарушая целостность смысла на границах. На качество результата влияют размер чанка и перекрытие: первый измеряют в символах или токенах (большие фрагменты дают больше контекста, малые упрощают обработку), второе — это «нахлёст» между соседними частями, который помогает поддерживать связность. В LangChain доступны разные стратегии: сплит по символам и по токенам, рекурсивный подход по иерархии разделителей (абзацы → предложения → слова), а также специализированные решения для кода и Markdown, учитывающие синтаксис и заголовки; при этом существуют два режима работы — Create Documents (принимает список сырого текста и отдаёт набор чанков‑документов) и Split Documents (делит уже загруженные документы), так что выбор зависит от того, с чем вы работаете — со строками или с объектами. На практике чаще всего используются CharacterTextSplitter (простое разбиение по символам, когда семантика не критична) и TokenTextSplitter (разбиение по токенам, удобное для соблюдения лимитов LLM), а когда важна структура, выручает рекурсивный сплиттер по иерархии; из специализированных — LanguageTextSplitter для кода и MarkdownHeaderTextSplitter для деления по заголовкам с сохранением этой структуры в метаданных.
Перед тем как применять сплиттеры, имеет смысл быстро настроить окружение: импорты, ключи API и зависимости.
import os
from openai import OpenAI
import sys
from dotenv import load_dotenv, find_dotenv
# Добавляем путь для доступа к пользовательским модулям
sys.path.append('../..')
# Загружаем переменные окружения из файла .env
load_dotenv(find_dotenv())
# Устанавливаем ключ OpenAI API из переменных окружения
client = OpenAI()
Стратегия сплиттинга сильно влияет на качество поиска и аналитики, поэтому параметры подбирают так, чтобы сохранить релевантность и связность текста. Базовые варианты — CharacterTextSplitter и RecursiveCharacterTextSplitter; выбор опирается на структуру и характер данных. Ниже — компактные примеры настройки: сначала простой сплиттер с опциональным перекрытием между чанками для поддержки контекста,
from langchain.text_splitter import CharacterTextSplitter
# Определяем размер чанка и перекрытие для разбиения
chunk_size = 26
chunk_overlap = 4
# Инициализируем Character Text Splitter
character_text_splitter = CharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap
)
а затем рекурсивный сплиттер, который для «общих» текстов аккуратно сохраняет семантику благодаря иерархии разделителей — от абзацев к предложениям и словам.
from langchain.text_splitter import RecursiveCharacterTextSplitter
# Инициализируем Recursive Character Text Splitter
recursive_character_text_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap
)
Далее — несколько практических примеров. Начнём с простых строк,
# Пример текстовой строки алфавита
alphabet_text = 'abcdefghijklmnopqrstuvwxyz'
# Пытаемся разбить alphabet_text с помощью обоих сплиттеров
recursive_character_text_splitter.split_text(alphabet_text)
character_text_splitter.split_text(alphabet_text, separator=' ')
и заглянем «под капот», где показана минимальная реализация сплиттеров и их поведение на базовых входах.
# Определяем класс для разбиения текста на чанки на основе количества символов.
class CharacterTextSplitter:
def __init__(self, chunk_size, chunk_overlap=0):
"""
Инициализирует сплиттер с указанным размером чанка и перекрытием.
Параметры:
- chunk_size: Количество символов, которое должен содержать каждый чанк.
- chunk_overlap: Количество символов для перекрытия между соседними чанками.
"""
self.chunk_size = chunk_size
self.chunk_overlap = chunk_overlap
def split_text(self, text):
"""
Разбивает заданный текст на чанки в соответствии с инициализированным размером чанка и перекрытием.
Параметры:
- text: Строка текста для разбиения.
Возвращает:
Список текстовых чанков.
"""
chunks = [] # Инициализируем пустой список для хранения чанков текста.
start_index = 0 # Начальный индекс для среза текста.
# Цикл для разбиения текста до достижения конца текста.
while start_index < len(text):
end_index = start_index + self.chunk_size # Вычисляем конечный индекс для текущего чанка.
chunks.append(text[start_index:end_index]) # Срезаем текст и добавляем чанк в список.
# Обновляем начальный индекс для следующего чанка, учитывая перекрытие чанков.
start_index = end_index - self.chunk_overlap
return chunks # Возвращаем список текстовых чанков.
# Наследуется от CharacterTextSplitter для добавления функциональности рекурсивного разбиения.
class RecursiveCharacterTextSplitter(CharacterTextSplitter):
def split_text(self, text, max_depth=10, current_depth=0):
"""
Рекурсивно разбивает текст на меньшие чанки до тех пор, пока каждый чанк не будет ниже порога размера чанка или не будет достигнута максимальная глубина.
Параметры:
- text: Строка текста для разбиения.
- max_depth: Максимальная глубина рекурсии для предотвращения бесконечной рекурсии.
- current_depth: Текущая глубина рекурсии.
Возвращает:
Список текстовых чанков.
"""
# Базовое условие: если достигнута максимальная глубина или длина текста в пределах размера чанка, возвращаем текст как один чанк.
if current_depth == max_depth or len(text) <= self.chunk_size:
return [text]
else:
# Разбиваем текст на две половины для рекурсивного разбиения.
mid_point = len(text) // 2
first_half = text[:mid_point]
second_half = text[mid_point:]
# Рекурсивно разбиваем каждую половину и объединяем результаты.
return self.split_text(first_half, max_depth, current_depth + 1) + \
self.split_text(second_half, max_depth, current_depth + 1)
# Пример использования вышеуказанных классов:
# Определяем размер чанка и перекрытие для разбиения.
chunk_size = 26
chunk_overlap = 4
# Инициализируем Character Text Splitter с указанным размером чанка и перекрытием.
character_text_splitter = CharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
# Инициализируем Recursive Character Text Splitter с указанным размером чанка.
recursive_character_text_splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size)
# Определяем пример текстовой строки для разбиения.
alphabet_text = 'abcdefghijklmnopqrstuvwxyz'
# Используем оба сплиттера для разбиения примера текста и сохраняем результаты.
recursive_chunks = recursive_character_text_splitter.split_text(alphabet_text)
simple_chunks = character_text_splitter.split_text(alphabet_text)
# Выводим результирующие чанки от рекурсивного сплиттера.
print("Чанки рекурсивного сплиттера:")
for chunk in recursive_chunks:
print(chunk)
# Выводим результирующие чанки от простого сплиттера.
print("\nЧанки простого сплиттера:")
for chunk in simple_chunks:
print(chunk)
Пример выше показывает характер разбиения на базовых строках, с разделителями и без них. Теперь рассмотрим пару продвинутых приёмов. Во‑первых, работа со сложными текстами, где уместно явно задать иерархию разделителей и размер чанка,
# Определяем пример сложного текста
complex_text = """When writing documents, writers will use document structure to group content...\nSentences have a period at the end, but also, have a space."""
# Применяем рекурсивное разбиение с настроенным размером чанка и разделителями
recursive_character_text_splitter = RecursiveCharacterTextSplitter(
chunk_size=450,
chunk_overlap=0,
separators=["\n\n", "\n", " ", ""]
)
recursive_character_text_splitter.split_text(complex_text)
что даёт осмысленные чанки с учётом внутренней структуры документа. Во‑вторых, специализированное разбиение — по токенам, когда контекстное окно LLM задаётся в токенах и важно строго соблюдать лимиты,
from langchain.text_splitter import TokenTextSplitter
# Инициализируем Token Text Splitter
token_text_splitter = TokenTextSplitter(chunk_size=10, chunk_overlap=0)
# Разбиение страниц документа по токенам
document_chunks_by_tokens = token_text_splitter.split_documents(pages)
и по заголовкам Markdown, где логическая организация текста становится «навигацией» для сегментации, а найденные заголовки сохраняются в метаданных чанков.
from langchain.text_splitter import MarkdownHeaderTextSplitter
# Определяем заголовки для разбиения в документе Markdown
markdown_headers = [
("#", "Header 1"),
("##", "Header 2"),
]
# Инициализируем Markdown Header Text Splitter
markdown_header_text_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=markdown_headers
)
# Разбиваем реальный документ Markdown, сохраняя метаданные заголовков
markdown_document_splits = markdown_header_text_splitter.split_text(markdown_document_content)
В качестве кратких рекомендаций: сохраняйте семантику и учитывайте структуру исходного документа; управляйте перекрытием — ровно столько, чтобы поддержать связность без лишней избыточности; используйте и обогащайте метаданные, чтобы улучшать контекст на шагах поиска и ответов.
Теоретические вопросы
- Какова цель разбиения документов?
- Как размер чанка влияет на обработку?
- Зачем нужно перекрытие и как оно помогает анализу?
- Чем отличаются
CharacterTextSplitter
иTokenTextSplitter
и где они применяются? - Что такое рекурсивный сплиттер и чем он отличается от базовых?
- Какие специализированные сплиттеры существуют для кода и Markdown и в чём их польза?
- Что необходимо для настройки окружения перед началом разбиения?
- Перечислите плюсы и минусы
RecursiveCharacterTextSplitter
и укажите, какие параметры важно настраивать. - Что демонстрирует пример с «алфавитом» при сравнении простого и рекурсивного подходов?
- На что следует обратить внимание при выборе между символами и токенами для LLM?
- Как деление по заголовкам Markdown сохраняет логическую структуру и почему это важно?
- Какие лучшие практики существуют для сохранения семантики и управления перекрытием?
Практические задания
- Напишите функцию
split_by_char(text, chunk_size)
, возвращающую список чанков фиксированного размера. - Добавьте параметр
chunk_overlap
к функцииsplit_by_char
и реализуйте механизм перекрытия. - Реализуйте класс
TokenTextSplitter(chunk_size, chunk_overlap)
с методомsplit_text
, который делит текст по токенам (токены разделены пробелами). - Напишите функцию
recursive_split(text, max_chunk_size, separators)
, рекурсивно делящую текст по списку заданных разделителей. - Реализуйте класс
MarkdownHeaderTextSplitter(headers_to_split_on)
с методомsplit_text
, который делит Markdown по заданным заголовкам и возвращает чанки с соответствующими метаданными.