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

Ответы 2.3

Теория

  1. Цель разбиения документов — получить осмысленные «чанки» для эффективного поиска и анализа.
  2. Размер чанка влияет на гранулярность: больше — больше контекста, меньше — проще обрабатывать, но можно потерять связь.
  3. Перекрытие чанков сохраняет контекст на границах и предотвращает потерю важной информации.
  4. CharacterTextSplitter режет по символам; TokenTextSplitter — по токенам (удобно под лимиты LLM).
  5. Рекурсивный сплиттер использует иерархию разделителей (абзацы/предложения/слова), чтобы сохранять семантику.
  6. Для кода/Markdown есть спец‑сплиттеры: LanguageTextSplitter (учитывает синтаксис), MarkdownHeaderTextSplitter (по уровням заголовков, добавляет метаданные).
  7. Окружение: библиотеки, ключи, зависимости, импорты — для устойчивой обработки.
  8. RecursiveCharacterTextSplitter сохраняет семантику и адаптируется под структуру; настраивайте размер/перекрытие/глубину.
  9. Демонстрация на «алфавите» показывает разницу подходов: равномерное деление vs. семантическое.
  10. Выбор символы vs. токены зависит от лимитов модели, важности семантики и природы текста.
  11. Деление по заголовкам Markdown сохраняет логическую структуру документа.
  12. Практики: держать смысл, подобрать перекрытие (без лишней избыточности), обогащать метаданные чанков.

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

1.

def split_by_char(text, chunk_size):
    """
    Разбивает текст на чанки указанного размера.

    Параметры:
    - text (str): Текст для разбиения.
    - chunk_size (int): Размер каждого чанка.

    Возвращает:
    - list: Список текстовых чанков.
    """
    chunks = []  # Инициализируем пустой список для хранения чанков
    for start_index in range(0, len(text), chunk_size):
        # Добавляем чанк (подстроку текста) в список, начиная
        # с индекса `start_index` до `start_index + chunk_size`
        chunks.append(text[start_index:start_index + chunk_size])
    return chunks

# Пример использования функции
text = "This is a sample text for demonstration purposes."
chunk_size = 10

chunks = split_by_char(text, chunk_size)
for i, chunk in enumerate(chunks):
    print(f"Чанк {i+1}: {chunk}")

2.

def split_by_char(text, chunk_size):
    """
    Разбивает текст на чанки указанного размера.

    Параметры:
    - text (str): Текст для разбиения.
    - chunk_size (int): Размер каждого чанка.

    Возвращает:
    - list: Список текстовых чанков.
    """
    chunks = []  # Инициализируем пустой список для хранения чанков
    for start_index in range(0, len(text), chunk_size):
        # Добавляем чанк (подстроку текста) в список, начиная
        # с индекса `start_index` до `start_index + chunk_size`
        chunks.append(text[start_index:start_index + chunk_size])
    return chunks

# Пример использования функции
text = "This is a sample text for demonstration purposes."
chunk_size = 10

chunks = split_by_char(text, chunk_size)
for i, chunk in enumerate(chunks):
    print(f"Чанк {i+1}: {chunk}")

3.

class TokenTextSplitter:
    def __init__(self, chunk_size, chunk_overlap=0):
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap

    def split_text(self, text):
        tokens = text.split()  # Разбиваем текст на токены по пробелам
        chunks = []
        start_index = 0

        while start_index < len(tokens):
            # Убеждаемся, что конечный индекс не превышает общую длину токенов
            end_index = min(start_index + self.chunk_size, len(tokens))
            chunk = ' '.join(tokens[start_index:end_index])
            chunks.append(chunk)
            # Обновляем `start_index` для следующего чанка, учитывая перекрытие
            start_index += self.chunk_size - self.chunk_overlap
            if self.chunk_overlap >= self.chunk_size:
                print("Предупреждение: `chunk_overlap` должен быть меньше `chunk_size`, чтобы избежать проблем с перекрытием.")
                break

        return chunks

4.

def recursive_split(text, max_chunk_size, separators):
    if not separators:  # Базовый случай: если разделителей больше нет, возвращаем текст как единый чанк
        return [text]

    if len(text) <= max_chunk_size:  # Если текущий чанк не превышает максимальный размер, возвращаем его
        return [text]

    # Пытаемся разбить текст, используя первый разделитель из списка
    separator = separators[0]
    parts = text.split(separator)

    if len(parts) == 1:  # Если текст не содержит текущий разделитель, переходим к следующему
        return recursive_split(text, max_chunk_size, separators[1:])

    chunks = []
    current_chunk = ""
    for part in parts:
        # Проверяем, не превысит ли добавление текущей части максимальный размер чанка
        if len(current_chunk + part) > max_chunk_size and current_chunk:
            # Если превышает, сохраняем текущий чанк и начинаем новый с текущей части
            chunks.append(current_chunk.strip())
            current_chunk = part + separator
        else:
            # Иначе добавляем часть к текущему чанку
            current_chunk += part + separator

    # Убеждаемся, что добавляем последний сформированный чанк, если он не пустой
    if current_chunk.strip():
        chunks.extend(recursive_split(current_chunk.strip(), max_chunk_size, separators))

    # Выравниваем список в случае вложенных списков, полученных из рекурсивных вызовов
    flat_chunks = []
    for chunk in chunks:
        if isinstance(chunk, list):
            flat_chunks.extend(chunk)
        else:
            flat_chunks.append(chunk)

    return flat_chunks

5. Для реализации класса MarkdownHeaderTextSplitter, как описано, нам нужно следовать этим шагам:

  1. Инициализация: Инициализатор класса будет хранить шаблоны заголовков для разбиения, вместе с их связанными именами или уровнями, для последующего использования в процессе разбиения текста.

  2. Разбиение текста: Метод split_text будет анализировать входной Markdown текст, идентифицировать заголовки на основе указанных шаблонов и разбивать текст на чанки. Каждый чанк будет начинаться с заголовка и включать весь последующий текст до следующего заголовка того же или более высокого приоритета.

Вот как может быть реализован класс:

import re

class MarkdownHeaderTextSplitter:
    def __init__(self, headers_to_split_on):
        # Сортируем заголовки по длине в убывающем порядке для корректного сопоставления
        self.headers_to_split_on = sorted(headers_to_split_on, key=lambda x: len(x[0]), reverse=True)
        self.header_regex = self._generate_header_regex()

    def _generate_header_regex(self):
        # Генерируем шаблон регулярного выражения, который соответствует любому из указанных заголовков
        header_patterns = [re.escape(header[0]) for header in self.headers_to_split_on]
        combined_pattern = '|'.join(header_patterns)
        return re.compile(r'(' + combined_pattern + r')\s*(.*)')

    def split_text(self, markdown_text):
        chunks = []
        current_chunk = []
        lines = markdown_text.split('\n')

        for line in lines:
            # Проверяем, начинается ли строка с одного из указанных заголовков
            match = self.header_regex.match(line)
            if match:
                # Если текущий чанк уже содержит текст, сохраняем его перед началом нового
                if current_chunk:
                    chunks.append('\n'.join(current_chunk).strip())
                    current_chunk = []

            # Добавляем текущую строку к формируемому чанку
            current_chunk.append(line)

        # Не забываем добавить последний сформированный чанк, если он не пустой
        if current_chunk:
            chunks.append('\n'.join(current_chunk).strip())

        return chunks

# Пример использования:
headers_to_split_on = [
    ("#", "Header 1"),
    ("##", "Header 2"),
    ("###", "Header 3"),
]

splitter = MarkdownHeaderTextSplitter(headers_to_split_on)
markdown_text = """
# Header 1
This is some text under header 1.
## Header 2
This is some text under header 2.
### Header 3
This is some text under header 3.
"""

chunks = splitter.split_text(markdown_text)
for i, chunk in enumerate(chunks):
    print(f"Чанк {i+1}:\n{chunk}\n---")

Эта реализация делает следующее:

  • Во время инициализации она сортирует заголовки по их длине в убывающем порядке, чтобы обеспечить соответствие более длинным (и, следовательно, более специфичным) шаблонам Markdown-заголовков в первую очередь. Это важно, потому что в Markdown заголовки различаются количеством символов #, и мы хотим соответствовать наиболее специфичному заголовку.
  • Она компилирует регулярное выражение, которое может соответствовать любому из указанных шаблонов заголовков в начале строки.
  • Метод split_text проходит через каждую строку входного Markdown текста, проверяя соответствия заголовков. Когда он находит заголовок, он начинает или заканчивает чанк соответствующим образом. Этот метод обеспечивает, что каждый чанк включает свой начальный заголовок и весь последующий текст до следующего заголовка того же или более высокого уровня.