Стратегии разбиения на части в сравнении RAG: альтернативы, компромиссы и примеры

Сравнение стратегий чанкирования в RAG

Содержимое страницы

Чанкинг — это наиболее недооцененный гиперпараметр в Retrieval-Augmented Generation (RAG): он определяет, что видит ваша LLM, насколько дорогостоящим становится индексирование, и сколько контекстного окна LLM тратится на каждый ответ.

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

Если вы новичок в архитектуре RAG, начните с основного Руководство по Retrieval-Augmented Generation (RAG): архитектура, реализация и руководство по производству.

цветной тетрис на столе

TL;DR (Исполнительное резюме)

Системы RAG извлекают чанки, а не документы. Чанкинг определяет единицу извлечения, единицу стоимости эмбеддинга и единицу доказательств, которые можно показать или процитировать. В оригинальной формулировке RAG извлечение предоставляет пассажи для генерации; границы пассажей фактически являются границами ваших чанков.

Хорошая стратегия чанкинга стремится к парето-фронтиру между: качеством извлечения (полнота/точность доказательств), связностью (чанки должны быть интерпретируемыми) и стоимостью (эмбеддинг, хранение и задержка запроса). Нет глобально оптимального размера или метода чанкинга, и производственные системы часто комбинируют стратегии (например, структурно-осознанный чанкинг для PDF + семантические разделения для прозы + AST-чанкинг для кода).

Для большинства систем “документации QA” и внутренних баз знаний безопасным вариантом по умолчанию является рекурсивный сплиттер с учетом структуры с умеренным перекрытием (для уменьшения потерь на границах), поддерживаемый векторным хранилищем с фильтрацией метаданных и опциональным переранжированием. RecursiveCharacterTextSplitter из LangChain — это распространенная реализация этой иерархической-идеи-разделителя; перекрытие специально существует для уменьшения потерь информации, когда релевантный контекст обрезается на границах.

Когда документы имеют четкую структуру (PDF с заголовками, таблицами, списками, подписями), элементный / структурно-осознанный чанкинг может превзойти разбиение по количеству токенов, производя меньше чанков. Исследование 2024 года о документах SEC показало, что чанкинг на основе типа элемента улучшил результаты RAG и также сократил количество чанков (и, следовательно, векторов) примерно на половину по сравнению со структурами, не учитывающими структуру — уменьшая стоимость индексирования и потенциально улучшая задержку запроса.

Если вы можете позволить себе больше вычислительных ресурсов на начальном этапе, семантический чанкинг (разделение на сдвиги тем с использованием сходства эмбеддингов) может значительно улучшить точность извлечения для нарративных текстов и страниц со смешанными темами. Старые алгоритмы сегментации тем, такие как TextTiling, показывают общий принцип: сильные сдвиги лексики/семантики являются хорошими кандидатами для границ.

Для очень длинных, внутренне взаимосвязанных материалов (политики, RFC, стандарты, большие руководства), иерархический чанкинг + иерархическое извлечение/слияние (узлы родитель/ребенок) может восстанавливать более крупный непрерывный контекст по запросу. Иерархический парсер узлов LlamaIndex производит грубые-мелкие иерархии чанков, и AutoMergingRetriever может сливать листья в узлы родителей во время извлечения, когда достаточного количества связанных детей извлечено.

Цели и компромиссы чанкинга

Чанкинг — это не просто “разбить текст, чтобы он поместился в модель эмбеддинга”. Он контролирует множество последующих и операционных поведений.

Граница извлечения vs шум извлечения. Меньшие чанки увеличивают шанс, что точное предложение, содержащее ответ, может быть извлечено (более высокая потенциальная полнота при фиксированном top-k). Но они также производят больше векторов, увеличивая размер индекса и иногда выявляя “близкие совпадения”, которые семантически схожи, но не являются фактически доказательственными (более низкая точность). Плотные извлекатели, такие как DPR, были построены вокруг эффективного извлечения пассажей для QA, подчеркивая, что границы пассажей важны для конечного QA-производительности.

Связность контекста vs потери на границах. Связные чанки помогают LLM правильно рассуждать и уменьшают галлюцинации, предоставляя полный локальный контекст (определения, ограничения, предварительные условия). Перекрытие уменьшает потери на границах, но создает дублирующийся текст, что может привести к избыточным результатам извлечения и увеличению длины запроса, если вы не удаляете дубликаты/не сливаете.

Стоимость эмбеддинга и индексирования. Стоимость эмбеддинга обычно пропорциональна количеству токенов, которые эмбеддируются, а время индексирования масштабируется с количеством чанков (плюс накладные расходы на запись в векторную БД). Для эмбеддингов OpenAI запросы имеют максимальный лимит токенов на вход (8192 токенов для всех моделей эмбеддингов) и максимальное общее количество токенов, суммированных по всем входам в одном запросе (300 000 токенов). Для больших корпораций Batch API может снизить затраты примерно на 50% с асинхронным сроком выполнения 24 часа — полезно для повторного индексирования и периодического обновления.

Размер векторизованного индекса, оперативная память и задержка. Больше чанков означает больше векторов и потенциально больше памяти и медленнее запросы (в зависимости от типа индекса). FAISS явно рассматривает проектирование индекса как набор компромиссов между временем поиска, качеством поиска и памятью на индексированный вектор; он также предлагает реализации на GPU для быстрого точного и приближенного поиска.

Длина запроса LLM / использование контекстного окна. Выход извлекателя становится бюджетом запроса. Стратегия чанкинга, которая постоянно извлекает “ровно столько” контекста, может улучшить качество ответов и снизить затраты. Наоборот, перекрытие и слишком большие чанки увеличивают длину запроса. На практике вы часто настраиваете: (размер чанка, перекрытие, top-k, переранжирование/слияние) вместе.

Стоимость обновления/индексирования и удаление дубликатов. Чанкинг влияет на стоимость обновления данных. Меньшие чанки делают частичные обновления дешевле (вы можете повторно эмбеддить только измененный раздел), но также усложняют удаление дубликатов, если перекрывающиеся или почти дублирующиеся чанки размножаются.

Где чанкинг находится в рабочем процессе RAG

чанкинг в потоке rag

Стратегии и альтернативы разбиения на части

Ниже приведены основные семейства разбиения на части, с которыми вы столкнетесь в современных системах RAG. На практике часто комбинируют два подхода: структурное разбиение (соблюдение границ документов) и ограничение по бюджету токенов (обеспечение соответствия частей бюджетам встраивания и запросов).

Разбиение на части фиксированного размера

Что это. Разделение текста на блоки равного размера по символам или токенам.

Почему это существует. Это просто, быстро, предсказуемо и легко параллелизуется. Это также самый простой способ для потокового ввода, где нет полного контекста документа.

Где это работает плохо. Оно игнорирует границы (предложения, разделы, блоки кода), поэтому может нарушать определения или разрывать “пары вопрос/ответ” между частями, увеличивая ошибки извлечения.

Операционный профиль. Наименьшая сложность ввода; предсказуемое количество частей; самое простое кэширование. Но обычно требуется наложение (ниже), чтобы избежать потери границ.

Наложение частей

Что это. Любая стратегия, при которой последовательные части имеют фиксированную область наложения (например, 10–20% от размера части). Наложение стандартно во многих фреймворках, так как оно уменьшает потерю информации при делении контекста.

Почему это важно. Наложение эффективно является “мягкой границей” — оно позволяет извлечению захватить факт, который пересекает границу.

Затраты и подводные камни. Больше токенов для встраивания; больше дублирующего текста в индексе; более высокий риск извлечения нескольких почти идентичных частей, если не удалять дубликаты при извлечении (например, объединять по смещениям источника или использовать MMR).

Разбиение на предложения и абзацы

Что это. Разделение текста по границам предложений или абзацев, затем упаковка предложений/абзацев в части до достижения бюджета токенов.

Почему инженеры это любят. Это улучшает связность для естественного языка и устойчиво для документов с традиционной пунктуацией и пробелами.

Инструменты. sent_tokenize() из NLTK использует по умолчанию обнаружение границ предложений Punkt, а spaCy предлагает инструменты на основе правил, такие как Sentencizer (полезно, когда нужны разделения предложений без полной модели зависимостей).

Режимы отказа. Нестандартная пунктуация (логи, транскрипты чатов), таблицы, код и списки с маркерами могут нарушить предположения о сегментации предложений.

Скользящее окно разбиения

Что это. Создание частей с использованием фиксированного размера окна и шага (ход). Это “систематическое наложение” версии разбиения.

Когда это хорошо. Текстовые временные ряды, транскрипты, журналы чатов, протоколы собраний — любая информация, где релевантные факты могут появляться в локальных районах, и вы хотите надежное извлечение.

Когда это плохо. Это увеличивает избыточность и может быть дорого в масштабе. Оно также склонно извлекать избыточные окна, если не удалять дубликаты.

Рекурсивное / иерархическое разбиение

Что это. Начинайте с больших “естественных” разделителей (например, \n\n для абзацев) и рекурсивно разбивайте на более мелкие единицы (предложения, пробелы) только при необходимости, чтобы оставаться в пределах бюджета размера. В документах LangChain это поведение описано явно: рекурсивный разделитель пытается сохранить большие единицы целыми и только переходит к более мелким разделителям, если единица все еще превышает размер части.

Почему это сильный стандарт. Оно уважает структуру без необходимости в сложном разборе документов. Это практическая золотая середина для Markdown, HTML-как-текст и документации.

Ключевые настройки. chunk_size, chunk_overlap и length_function (символы против токенов), а также пользовательские разделители для многоязычных кодовых баз.

Семантическое (осведомленное о встраивании) разбиение

Что это. Обнаружение смены тем с использованием семантических представлений (встраиваний) и разделение, где падает сходство. Это напоминает классические идеи сегментации, такие как TextTiling, которая использует сдвиги в лексической связности для поиска границ подтем.

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

Затраты. Возможно, потребуются дополнительные встраивания во время разбиения (встраивания уровня предложений или абзацев) перед окончательными встраиваниями частей. Это может удвоить или утроить вызовы встраивания, если не переиспользовать промежуточные встраивания.

Практический трюк. “Семантически осведомленная упаковка”: вычислите встраивания предложений один раз, сгруппируйте предложения в сегменты, согласованные по темам, затем встраивайте каждый окончательный сегмент.

Иерархическое разбиение (родитель/ребенок)

Что это. Построение многоуровневого представления: грубые родительские части (например, размером с раздел) с более мелкими дочерними частями (например, размером с абзац). Иерархический разбор узлов LlamaIndex по умолчанию производит “грубо-мелкие” иерархии (например, масштабы 2048 → 512 → 128 токенов), и AutoMergingRetriever может объединять дочерние узлы в родительские во время извлечения, когда извлекается достаточно связанных детей.

Почему это помогает. Оно избегает выбора между “маленькими частями для извлечения” и “большими частями для связности”, храня оба и выбирая во время запроса.

Затраты. Более сложная логика ввода и извлечения, а также потенциально больше места для хранения (потому что вы храните несколько уровней детализации).

Адаптивное / на основе LLM разбиение

Что это. Использование LLM для принятия решений о границах частей (и, возможно, генерации резюме или контекстных заголовков). Weaviate явно описывает разбиение на основе LLM как создание семантически связных частей, а не как полагаться на фиксированные правила или сходство встраиваний.

Когда это стоит того. Высокоценные корпуса, где правильность превалирует над стоимостью (юридические, соблюдение норм, руководства по поддержке), и где документы беспорядочные, неоднородные и плохо сегментированы.

Риски. Стоимость, задержка и неопределенность. Вам понадобятся кэширование, детерминированное декодирование и регрессионные тесты (см. раздел оценки).

Структурное и элементное разбиение (документы — это не обычный текст)

Что это. Разбор документа на элементы (заголовки, абзацы, списки, таблицы, подписи) с использованием слоя понимания документов, затем разбиение с использованием этих элементов. Функции разбиения Unstructured явно используют метаданные и элементы документов (произведенные разбиением) для создания частей для RAG. HierarchicalChunker Docling создает части для каждого обнаруженного элемента документа и прикрепляет структурные метаданные, такие как заголовки/подписи.

Доказательства из последних исследований. Исследование 2024 года о документах SEC утверждает, что разбиение только по абзацам игнорирует структуру документа и предлагает разбиение по структурным элементам; оно сообщает об улучшенных результатах RAG и меньшем количестве частей/векторов по сравнению с подходами, не учитывающими структуру.

Почему это важно для мультимодальности. Таблицы, фигуры и подписи часто содержат истинные данные. “Сплющивание” их в обычный текст может разрушить сигналы, которые извлечение бы иначе использовало.

Осведомленное о коде разбиение (AST/структура)

Что это. Разбиение кода по синтаксическим единицам (функции, классы, модули), возможно, включая строки документации и комментарии.

Почему это важно. Фиксированные разбиения токенов склонны резать функции пополам и отделять строки документации от реализаций — плохо для поиска кода и случаев использования RAG “объясните эту функцию”.

Варианты реализации. Для Python встроенный модуль ast часто достаточно. Для многоязычных репозиториев распространены чанкеры на основе tree-sitter.

Оценка измерений и сравнение стратегий разбиения

Разбиение на части должно оцениваться как компонент системы.

Метрики качества извлечения

Используйте стандартные метрики ИИ для слоя извлечения:

  • Recall@k / Precision@k: содержал ли топ-k золотое доказательство?
  • MRR / nDCG: было ли золотое доказательство высоко в рейтинге?

BEIR — это широко используемый гетерогенный бенчмарк для оценки ИИ по задачам/областям, и он выделяет компромиссы между разреженными, плотными, поздними взаимодействиями и переранжированием.

Разбиение на части влияет на эти метрики, потому что оно определяет, что считается “релевантным извлеченным элементом”.

Метрики качества ответа RAG от начала до конца

Если вы создаете системы QA или ассистентов, метрики извлечения необходимы, но недостаточны. Вам также нужны:

  • Recall / Precision контекста: содержат ли извлеченные контексты релевантные доказательства и избегают шума.
  • Достоверность: поддерживается ли сгенерированный ответ извлеченным контекстом.

RAGAS предоставляет конкретные определения и реализации для “достоверности” и других метрик, ориентированных на RAG.

Системные издержки и показатели производительности

Разбиение на части изменяет эти рычаги:

Задержка (p50/p95). Задержка запроса обычно увеличивается с большим количеством векторов и большей постобработкой. Ваш векторный индекс также имеет значение: типы индексов FAISS балансируют время поиска, качество, память и время обучения/добавления.[^faiss]

Стоимость и пропускная способность встраивания. Встраивания OpenAI оплачиваются за токены; API встраивания имеет явные ограничения на вход и запрос.[^openai_embed_create] Для офлайн-ввода Batch API снижает стоимость и предлагает более высокий квоту в обмен на нереальное время обработки.[^openai_batch]

Размер индекса и память. Примерно, хранение N векторов float32 размером d стоит ~4 * N * d байт только для сырых векторов (плюс метаданные + накладные расходы индекса). Разбиение на части влияет на N. Размерность встраивания влияет на d, и API встраивания OpenAI позволяет контролировать выходную размерность через параметр dimensions.[^openai_embed_create]

Бюджет запроса LLM. Более крупные части и наложение увеличивают количество токенов запроса. Это может увеличить задержку и стоимость и увеличить “потерянные в середине” режимы отказа, когда модели уделяют меньше внимания части контекста. На практике вы часто:

  1. извлекаете маленькие части,
  2. объединяете/удаляете дубликаты,
  3. по желанию резюмируете,
  4. отправляете компактный набор доказательств в LLM.

Стоимость обновления/ввода. Меньшие части позволяют частичное повторное встраивание, но увеличивают учет. Для потокового ввода предпочтительны детерминированные, инкрементальные разбиения (фиксированные или скользящие окна) и прикрепление стабильных идентификаторов (document_id, диапазоны смещений, хеш).

Экспериментальный дизайн: практический цикл бенчмаркинга

Воспроизводимый бенчмарк разбиения на части обычно имеет:

  • Фиксированный снимок корпуса + фиксированный набор запросов с золотыми доказательствами (или хотя бы ожидаемыми диапазонами ответов).
  • Фиксированная модель встраивания и конфигурация векторного индекса.
  • Оценка “только извлечение” (recall@k, nDCG) плюс “RAG” оценка (достоверность, релевантность ответа).
  • Телеметрия затрат: количество частей, встроенных токенов, $/месяц хранения, p95 задержка запроса, токены запроса.

Бумага Unstructured о документах SEC является хорошим примером оценки стратегий разбиения на части как с метриками, ориентированными на извлечение, так и с метриками точности QA.

Практические рекомендации, матрица решений и рекомендуемые значения по умолчанию

Рекомендуемые значения по умолчанию, которые работают удивительно хорошо

Если вам нужна надежная стратегия “первого дня” для общего контроля качества документации:

  1. Легкий парсинг: сохраняйте заголовки и базовую метаданные (источник, название раздела, URL/путь, временная метка).
  2. Чанкинг с рекурсивным разделителем (абзац → предложение → слово), с умеренным перекрытием.
  3. Встраивание с сильной общей моделью встраивания.
  4. Индексация с метаданными (идентификатор документа, раздел, ACL) и удаление дубликатов при извлечении.
  5. Добавьте ранжирование или иерархическое слияние только если ваша оценка показывает пробел.

Это соответствует тому, как общие фреймворки RAG описывают перекрытие чанков и структурно-сохраняющее разбиение.

Какую методологию чанкинга использовать - матрица решений

Какую методологию чанкинга использовать - Диаграмма

Случай использования Рекомендуемый чанкинг по умолчанию Ключевые параметры для настройки Обычная точка отказа Путь улучшения
Короткие ответы по документам (FAQ, внутренний вики) Рекурсивный/разделительный чанкинг + перекрытие размер_чанка, перекрытие, top‑k Отсутствие межпредложенных доказательств на границе Добавьте семантический чанкинг или ранжировщик
Длинные ответы (политики, стандарты, руководства) Иерархический чанкинг + объединенный извлекатель размеры родителя/ребенка, порог слияния Извлекаются небольшие фрагменты; LLM не имеет полного контекста Автоматическое слияние/иерархическое извлечение
Суммирование (на документ / на раздел) Чанки с учетом структуры (разделы) обнаружение разделов, максимальное количество токенов Суммарии пропускают межраздельные ссылки Иерархическое суммирование + граф разделов
Поиск кода и “объясните эту функцию” Чанки на уровне AST/функции включение docstring/комментариев, максимальное количество токенов Функция разбита; теряется сигнатура/использование Иерархия с учетом репозитория (модуль→класс→функция)
Мультимодальные PDF (таблицы/рисунки) Чанки на основе элементов (с учетом заголовков/таблиц/подписей) сериализация таблиц, слияние подписей Потеря или искажение содержимого таблицы Используйте Docling/Unstructured + структурированные сериализаторы
Потоковое ingestion (логи, чаты, билеты) Скользящее окно или фиксированное количество токенов окно, шаг, удаление дубликатов Чрезмерное извлечение избыточных окон Добавьте семантическое обнаружение границ на пакетах

Чанкинг - качественное сравнение производительности

Считайте это “ожидаемым направлением изменения” (проверяйте на своих данных).

Стратегия Потенциал точности извлечения Связность извлеченного контекста Сложность ingestion Количество векторов / размер индекса Стоимость встраивания Влияние на задержку запроса Лучше всего для
Фиксированный размер (без перекрытия) Средний Низкий Низкий Средний Низкий Средний быстрые прототипы, однородный текст
Фиксированный размер + перекрытие Средний–Высокий Средний Низкий Высокий Средний–Высокий Средний–Высокий QA, где потеря границ имеет значение
Упаковка предложений/абзацев Высокий (проза) Высокий Средний Средний Средний Средний документы, статьи, чистая проза
Скользящее окно Высокая полнота Средний Средний Очень высокий Очень высокий Высокий транскрипты, логи, чаты
Рекурсивный/разделительный Высокий Высокий Средний Средний Средний Средний “стандартный” RAG для документов
Семантический чанкинг Высокий–Очень высокий Высокий Высокий Средний Высокий Средний многотемные страницы, повествовательный текст
Иерархический (родитель/ребенок) Очень высокий Очень высокий Высокий Высокий Высокий Средний длинные руководства / стандарты
На основе LLM/адаптивный Очень высокий Очень высокий Очень высокий Средний Очень высокий Средний–Высокий корпоративные данные высокой важности
На основе элементов/структуры Высокий–Очень высокий Высокий Высокий Низкий–Средний Средний Средний PDF, отчеты, таблицы, смешанные макеты
С учетом кода (AST) Высокий (код) Высокий Средний Средний Средний Средний поиск кода, помощники репозиториев

Примечания по DevOps и оборудованию (часто упускаемые из виду)

Выбор чанкинга влияет на сколько инфраструктуры вам нужно:

  • Меньшие чанки → больше векторов → большие индексы и больше RAM/диск. Для самоуправляемого FAISS это может потребовать шардинга или индексов на диске.
  • Если вы встраиваете локально, пропускная способность встраивания становится проблемой планирования GPU; если вы встраиваете через API, объем токенов становится проблемой FinOps (Batch API — ваш друг для обновлений).
  • Некоторые движки (FAISS) предоставляют ускоренный поиск на GPU; это может перераспределить затраты с ограниченного CPU RAM на память GPU и пропускную способность PCIe.
  • Парсинг с учетом структуры (макет PDF, OCR, извлечение таблиц) часто ограничен CPU и может превышать стоимость встраивания для отсканированных документов; бюджетируйте это отдельно.

Чанкирование - Python реализации

Все примеры разработаны так, чтобы быть читаемыми и исполняемыми. Если вам нужен API ключ или работающая база данных, это будет ясно из кода.

Общие утилиты: подсчет токенов и стабильные идентификаторы чанков

from __future__ import annotations

import hashlib
from dataclasses import dataclass
from typing import Any, Iterable, Optional

from transformers import AutoTokenizer  # pip install transformers

@dataclass(frozen=True)
class Chunk:
    text: str
    meta: dict[str, Any]

def sha1_id(*parts: str) -> str:
    h = hashlib.sha1()
    for p in parts:
        h.update(p.encode("utf-8"))
        h.update(b"\x1e")
    return h.hexdigest()

# Используйте любой токенизатор, который приблизительно соответствует токенизации вашей LLM/встроенной модели.
TOKENIZER = AutoTokenizer.from_pretrained("bert-base-uncased")

def token_len(text: str) -> int:
    return len(TOKENIZER.encode(text, add_special_tokens=False))

Чанкирование с фиксированным размером токенов

def chunk_fixed_tokens(
    text: str,
    *,
    chunk_size: int = 512,
) -> list[Chunk]:
    token_ids = TOKENIZER.encode(text, add_special_tokens=False)
    out: list[Chunk] = []

    for i in range(0, len(token_ids), chunk_size):
        window = token_ids[i : i + chunk_size]
        chunk_text = TOKENIZER.decode(window)
        out.append(
            Chunk(
                text=chunk_text,
                meta={"strategy": "fixed_tokens", "start_token": i, "end_token": i + len(window)},
            )
        )
    return out

Чанкирование с фиксированным размером + скользящее окно

def chunk_sliding_window(
    text: str,
    *,
    window_tokens: int = 512,
    stride_tokens: int = 384,  # меньший шаг = больше перекрытия
) -> list[Chunk]:
    assert 1 <= stride_tokens <= window_tokens, "шаг должен быть в пределах (0, окно]"
    token_ids = TOKENIZER.encode(text, add_special_tokens=False)
    out: list[Chunk] = []

    start = 0
    while start < len(token_ids):
        end = min(start + window_tokens, len(token_ids))
        window = token_ids[start:end]
        out.append(
            Chunk(
                text=TOKENIZER.decode(window),
                meta={"strategy": "sliding_window", "start_token": start, "end_token": end},
            )
        )
        if end == len(token_ids):
            break
        start += stride_tokens

    return out

Чанкирование на основе предложений (NLTK) с упаковкой по бюджету токенов

# pip install nltk
import nltk
from nltk.tokenize import sent_tokenize

nltk.download("punkt", quiet=True)

def chunk_by_sentences_nltk(
    text: str,
    *,
    max_tokens: int = 512,
    overlap_sentences: int = 1,
) -> list[Chunk]:
    sents = [s.strip() for s in sent_tokenize(text) if s.strip()]
    out: list[Chunk] = []

    buf: list[str] = []
    buf_tokens = 0

    def flush():
        nonlocal buf, buf_tokens
        if not buf:
            return
        chunk_text = " ".join(buf).strip()
        out.append(
            Chunk(
                text=chunk_text,
                meta={"strategy": "sentences_nltk", "sent_count": len(buf)},
            )
        )
        # Перекрытие за счет сохранения последних N предложений
        if overlap_sentences > 0:
            buf = buf[-overlap_sentences:]
            buf_tokens = token_len(" ".join(buf))
        else:
            buf, buf_tokens = [], 0

    for s in sents:
        s_tokens = token_len(s)
        # Если одно предложение превышает бюджет, переходим к фиксированному чанкированию на этом участке
        if s_tokens > max_tokens:
            flush()
            out.extend(chunk_fixed_tokens(s, chunk_size=max_tokens))
            continue

        if buf_tokens + s_tokens > max_tokens and buf:
            flush()

        buf.append(s)
        buf_tokens += s_tokens

    flush()
    return out

Чанкирование на основе предложений (spaCy) при использовании правил или моделей для SBD

# pip install spacy
# python -m spacy download en_core_web_sm
import spacy

def chunk_by_sentences_spacy(
    text: str,
    *,
    max_tokens: int = 512,
) -> list[Chunk]:
    # Для легковесного разделения по правилам (без синтаксического разбора), используйте sentencizer.
    nlp = spacy.blank("en")
    nlp.add_pipe("sentencizer")  # разделение предложений на основе правил
    doc = nlp(text)

    sents = [sent.text.strip() for sent in doc.sents if sent.text.strip()]
    return chunk_by_sentences_nltk(" ".join(sents), max_tokens=max_tokens, overlap_sentences=1)

Рекурсивное чанкирование по разделителям (LangChain)

# pip install langchain-text-splitters
from langchain_text_splitters import RecursiveCharacterTextSplitter

def chunk_recursive_langchain(
    text: str,
    *,
    chunk_size: int = 1200,
    chunk_overlap: int = 150,
) -> list[Chunk]:
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=token_len,  # учет токенов при бюджетировании
        separators=["\n\n", "\n", ". ", " ", ""],  # настройте для вашего контента (например, код)
    )
    pieces = splitter.split_text(text)

    return [
        Chunk(text=p, meta={"strategy": "recursive_langchain", "chunk_size": chunk_size, "overlap": chunk_overlap})
        for p in pieces
    ]

Семантическое чанкирование с использованием сходства встроенных моделей (OpenAI embeddings)

Этот подход вычисляет встроенные представления для кандидатных единиц (предложений/абзацев), затем находит семантические “точки разрыва”.

# pip install openai numpy
import os
import numpy as np
from openai import OpenAI

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY", "YOUR_OPENAI_API_KEY"))

def embed_texts_openai(texts: list[str], *, model: str = "text-embedding-3-small") -> np.ndarray:
    # ЗАМЕЧАНИЕ: для больших пакетов соблюдайте ограничения на токены запроса и размер пакета.
    resp = client.embeddings.create(model=model, input=texts)
    embs = np.array([d.embedding for d in resp.data], dtype=np.float32)
    return embs

def cosine_sim(a: np.ndarray, b: np.ndarray) -> float:
    denom = (np.linalg.norm(a) * np.linalg.norm(b)) + 1e-8
    return float(np.dot(a, b) / denom)

def chunk_semantic(
    text: str,
    *,
    max_tokens: int = 800,
    breakpoint_threshold: float = 0.70,
) -> list[Chunk]:
    # 1) Начинаем с кандидатных предложений
    sents = [s.strip() for s in sent_tokenize(text) if s.strip()]
    if len(sents) <= 1:
        return [Chunk(text=text, meta={"strategy": "semantic", "note": "no_split"})]

    # 2) Встраиваем каждое предложение
    embs = embed_texts_openai(sents)

    # 3) Вычисляем падения сходства
    sims = [cosine_sim(embs[i], embs[i + 1]) for i in range(len(sents) - 1)]

    # 4) Создаем сегменты на точках разрыва
    out: list[Chunk] = []
    buf: list[str] = []
    buf_tokens = 0

    for i, s in enumerate(sents):
        # Добавляем предложение
        s_tok = token_len(s)
        if buf and buf_tokens + s_tok > max_tokens:
            out.append(Chunk(text=" ".join(buf), meta={"strategy": "semantic", "reason": "max_tokens"}))
            buf, buf_tokens = [], 0
        buf.append(s)
        buf_tokens += s_tok

        # Решаем точку разрыва после предложения i (на основе сходства с следующим)
        if i < len(sims) and sims[i] < breakpoint_threshold:
            out.append(
                Chunk(
                    text=" ".join(buf),
                    meta={"strategy": "semantic", "reason": "sim_drop", "sim_to_next": sims[i]},
                )
            )
            buf, buf_tokens = [], 0

    if buf:
        out.append(Chunk(text=" ".join(buf), meta={"strategy": "semantic", "reason": "final"}))

    return out

Иерархическое чанкирование + объединение извлечения (LlamaIndex)

# pip install llama-index llama-index-llms-openai
from llama_index.core import Document, StorageContext, VectorStoreIndex
from llama_index.core.node_parser import HierarchicalNodeParser, get_leaf_nodes
from llama_index.core.retrievers import AutoMergingRetriever
from llama_index.core.storage.docstore import SimpleDocumentStore

def build_hierarchical_index(text: str):
    docs = [Document(text=text)]

    node_parser = HierarchicalNodeParser.from_defaults()  # по умолчанию используется размеры от грубых к мелким
    nodes = node_parser.get_nodes_from_documents(docs)

    docstore = SimpleDocumentStore()
    docstore.add_documents(nodes)
    storage_context = StorageContext.from_defaults(docstore=docstore)

    leaf_nodes = get_leaf_nodes(nodes)
    base_index = VectorStoreIndex(leaf_nodes, storage_context=storage_context)

    base_retriever = base_index.as_retriever(similarity_top_k=6)
    retriever = AutoMergingRetriever(base_retriever, storage_context, verbose=True)
    return retriever

Чанкирование на основе элементов для PDF (Docling)

# pip install docling
# ЗАМЕЧАНИЕ: Качество разбора PDF зависит от вашей среды (шрифты, OCR и т.д.).
from docling.document_converter import DocumentConverter
from docling.transforms.chunker.hierarchical_chunker import HierarchicalChunker

def chunk_pdf_docling(pdf_path: str) -> list[Chunk]:
    converter = DocumentConverter()
    doc = converter.convert(pdf_path).document  # DoclingDocument
    chunker = HierarchicalChunker()
    doc_chunks = list(chunker.chunk(doc))

    out: list[Chunk] = []
    for c in doc_chunks:
        # c.text содержит сериализованное содержимое чанка; c.meta несет информацию о структуре
        out.append(Chunk(text=c.text, meta={"strategy": "docling_hierarchical", **dict(c.meta)}))
    return out

Чанкирование с учетом кода для Python (AST)

import ast

def chunk_python_by_ast(code: str, *, filepath: str = "<memory>") -> list[Chunk]:
    tree = ast.parse(code)
    out: list[Chunk] = []

    for node in tree.body:
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
            start = getattr(node, "lineno", None)
            end = getattr(node, "end_lineno", None)
            if start is None or end is None:
                continue

            lines = code.splitlines()
            snippet = "\n".join(lines[start - 1 : end])

            kind = "class" if isinstance(node, ast.ClassDef) else "function"
            name = getattr(node, "name", "<anon>")
            out.append(
                Chunk(
                    text=snippet,
                    meta={
                        "strategy": "python_ast",
                        "kind": kind,
                        "name": name,
                        "filepath": filepath,
                        "start_line": start,
                        "end_line": end,
                    },
                )
            )

    return out

Примеры индексирования

FAISS (локальный) — минимальный, быстрый базовый вариант

# pip install faiss-cpu numpy
import numpy as np
import faiss

def build_faiss_index(vectors: np.ndarray) -> faiss.Index:
    # vectors: форма (N, d), тип float32
    d = vectors.shape[1]
    index = faiss.IndexFlatIP(d)  # внутреннее произведение; косинусное сходство, если векторы нормализованы
    faiss.normalize_L2(vectors)
    index.add(vectors)
    return index
def faiss_search(index: faiss.Index, query_vec: np.ndarray, k: int = 5):
    q = query_vec.astype(np.float32).reshape(1, -1)
    faiss.normalize_L2(q)
    scores, ids = index.search(q, k)
    return ids[0].tolist(), scores[0].tolist()

Chroma (локальный) — простая сохранность для прототипирования RAG

# pip install chromadb
import chromadb

def build_chroma_collection(chunks: list[Chunk], embeddings: np.ndarray, *, path: str = "./chroma_store"):
    client = chromadb.PersistentClient(path=path)
    col = client.get_or_create_collection(name="docs")

    ids = [sha1_id(c.meta.get("strategy", "chunk"), str(i), c.text[:50]) for i, c in enumerate(chunks)]
    col.upsert(
        ids=ids,
        documents=[c.text for c in chunks],
        metadatas=[c.meta for c in chunks],
        embeddings=embeddings.tolist(),
    )
    return col

Weaviate (самостоятельный хостинг / облако) — предоставьте собственные векторы

# pip install weaviate-client
import weaviate
from weaviate.classes.config import Configure

def weaviate_upsert_self_provided(
    chunks: list[Chunk],
    embeddings: np.ndarray,
):
    client = weaviate.connect_to_local()  # или connect_to_weaviate_cloud(...)
    try:
        collection = client.collections.create(
            name="Chunk",
            vector_config=Configure.Vectors.self_provided(),  # вы предоставляете векторы
        )

        with collection.batch.dynamic() as batch:
            for c, v in zip(chunks, embeddings):
                batch.add_object(
                    properties={"text": c.text, **{f"m_{k}": str(vv) for k, vv in c.meta.items()}},
                    vector=v.tolist(),
                )

        # Поиск по ближайшему вектору
        q = embeddings[0]
        res = collection.query.near_vector(near_vector=q.tolist(), limit=5)
        for obj in res.objects:
            print(obj.properties.get("text")[:200])
    finally:
        client.close()

Некоторые исходные документы

  • Патрик Льюис и др., “Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks”, NeurIPS 2020; arXiv:2005.11401.
  • Владимир Карпухин и др., “Dense Passage Retrieval for Open-Domain Question Answering”, EMNLP 2020; arXiv:2004.04906.
  • OpenAI API Reference: Create embeddings (/v1/embeddings) — ограничения по токенам (8192 на вход; 300k всего на запрос) и параметр dimensions.
  • OpenAI Batch API guide + OpenAI API pricing page (Batch экономит ~50% с 24-часовой обработкой).
  • LangChain docs: RecursiveCharacterTextSplitter и руководство по интеграции сплиттеров (размер чанков/перекрытие, рекурсивная иерархия разделителей).
  • LlamaIndex docs: HierarchicalNodeParser и AutoMergingRetriever (узлы от грубого к тонкому; слияние во время извлечения).
  • Weaviate blog: “Chunking Strategies to Improve LLM RAG Pipeline Performance” (описание чанкирования на основе LLM и компромиссы).
  • Docling docs: HierarchicalChunker создает чанки из структуры элементов документа и прикрепляет метаданные заголовков/подписей.
  • Химено Йепес и др., “Financial Report Chunking for Effective Retrieval Augmented Generation” (arXiv:2402.05131v3, 2024).
  • Марти А. Херст, “TextTiling: Segmenting Text into Multi-Paragraph Subtopic Passages”, Computational Linguistics, 1997 (ACL Anthology: J97-1003).
  • FAISS documentation / GitHub repository: компромиссы между временем поиска, качеством и памятью; опциональная поддержка GPU.
  • Нандан Тхакур и др., “BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models”, NeurIPS 2021; arXiv:2104.08663.
  • RAGAS documentation: метрика “Faithfulness” и связанные метрики оценки RAG.
  • NLTK documentation: nltk.tokenize.sent_tokenize - рекомендуемый токенизатор предложений на основе Punkt.
  • spaCy API docs: Sentencizer - обнаружение границ предложений на основе правил без синтаксического разбора.

Другие полезные ссылки