Перейти к содержанию

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)

В качестве кратких рекомендаций: сохраняйте семантику и учитывайте структуру исходного документа; управляйте перекрытием — ровно столько, чтобы поддержать связность без лишней избыточности; используйте и обогащайте метаданные, чтобы улучшать контекст на шагах поиска и ответов.

Теоретические вопросы

  1. Какова цель разбиения документов?
  2. Как размер чанка влияет на обработку?
  3. Зачем нужно перекрытие и как оно помогает анализу?
  4. Чем отличаются CharacterTextSplitter и TokenTextSplitter и где они применяются?
  5. Что такое рекурсивный сплиттер и чем он отличается от базовых?
  6. Какие специализированные сплиттеры существуют для кода и Markdown и в чём их польза?
  7. Что необходимо для настройки окружения перед началом разбиения?
  8. Перечислите плюсы и минусы RecursiveCharacterTextSplitter и укажите, какие параметры важно настраивать.
  9. Что демонстрирует пример с «алфавитом» при сравнении простого и рекурсивного подходов?
  10. На что следует обратить внимание при выборе между символами и токенами для LLM?
  11. Как деление по заголовкам Markdown сохраняет логическую структуру и почему это важно?
  12. Какие лучшие практики существуют для сохранения семантики и управления перекрытием?

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

  1. Напишите функцию split_by_char(text, chunk_size), возвращающую список чанков фиксированного размера.
  2. Добавьте параметр chunk_overlap к функции split_by_char и реализуйте механизм перекрытия.
  3. Реализуйте класс TokenTextSplitter(chunk_size, chunk_overlap) с методом split_text, который делит текст по токенам (токены разделены пробелами).
  4. Напишите функцию recursive_split(text, max_chunk_size, separators), рекурсивно делящую текст по списку заданных разделителей.
  5. Реализуйте класс MarkdownHeaderTextSplitter(headers_to_split_on) с методом split_text, который делит Markdown по заданным заголовкам и возвращает чанки с соответствующими метаданными.