Перейти к содержимому

4.3 · Chunking: сравнить 3 стратегии

Неделя 4 · RAG · ⏱ ~4 ч · Практика

Плохой chunking — причина номер один, почему RAG находит не то. Это первое, что опытный инженер проверяет, когда «RAG отвечает ерунду», и частый вопрос на собеседовании: «как нарезать PDF-контракты / базу знаний поддержки / тикеты?». Правильного ответа «512 токенов» не существует — есть умение сравнить стратегии на своих данных, и этому мы здесь научимся. Заодно в этой главе появляются первые страницы базы знаний магазина «Полка» — корпуса, на котором работает весь второй проект.

Что такое чанк и почему его размер — компромисс

Заголовок раздела «Что такое чанк и почему его размер — компромисс»

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

Маленький чанк (100–200 токенов) Большой чанк (1000–2000 токенов)
Эмбеддинг точный, про одну мысль Эмбеддинг «размыт» по нескольким темам
Теряется контекст: «срок — 14 дней» — срок чего? Контекст на месте
В промпт попадает мало лишнего В промпт попадает много шума, дороже
Нужно больше кусков, чтобы собрать ответ Один кусок часто содержит весь ответ

Для справочных текстов и документации разумный старт — 300–800 токенов, но точную цифру подберём экспериментом. Важно помнить лимит входа модели эмбеддингов (гл. 4.1): чанк длиннее него будет обрезан молча.

Токены ≠ символы. Для английского текста грубо 1 токен ≈ 4 символа, для русского и кода — иначе. Для эксперимента достаточно считать в символах (так и задаём размеры в практике), но лимит модели точнее проверять токенизатором (например, tiktoken для моделей OpenAI; про токенизаторы наших моделей — ниже, в разделе про DeepSeek и Ollama).

Дальше — три стратегии, которые сравним в практике, от самой простой к самой «умной»; каждая следующая чинит главный недостаток предыдущей.

Режем каждые N символов (или токенов). Просто, быстро, предсказуемо.

def fixed_chunks(text: str, size: int = 1500) -> list[str]:
return [text[i:i + size] for i in range(0, len(text), size)]

Проблема: разрез приходится куда попало — посреди предложения, посреди таблицы тарифов, посреди блока кода. Факт, разрезанный пополам, не находится ни одной половиной. Первое, что приходит в голову, — сделать соседние куски внахлёст.

Стратегия 2: фиксированный размер с перекрытием (overlap)

Заголовок раздела «Стратегия 2: фиксированный размер с перекрытием (overlap)»

Окно размера N сдвигается на шаг N − overlap. Каждый кусок захватывает хвост предыдущего, поэтому факт на границе целиком попадает хотя бы в один чанк.

текст: |----------------------------------------------|
чанк 1: |==========|
чанк 2: |==========|
чанк 3: |==========|
^^^^^ overlap

Типично overlap = 10–20% от размера. Цена — больше чанков (больше эмбеддингов, больше места) и дубли в выдаче: два соседних чанка с общим куском оба попадают в топ-3 и вытесняют что-то полезное. Улучшение: резать не строго по символам, а по ближайшей границе абзаца или предложения (так работает RecursiveCharacterTextSplitter в LangChain: сначала пытается по \n\n, потом по \n, потом по пробелу). Но и так стратегия не знает, где кончается раздел или таблица. Это знает автор документа — и оставил подсказку в заголовках.

Документация и база знаний уже размечены автором: заголовки #, ##, ### отделяют темы. Режем по ним — каждый раздел становится чанком. Если раздел слишком длинный — делим его дальше по подзаголовкам или абзацам; если слишком короткий — склеиваем с соседом.

Главный бонус — путь заголовков (breadcrumbs) как метаданные и как префикс текста:

Доставка > Курьером по России > Тарифы по весу и зоне
| Вес, кг | Зона 1 | Зона 2 | Зона 3 |
| до 1 | 290 ₽ | 390 ₽ | 490 ₽ |
| 1–3 | 350 ₽ | 470 ₽ | 590 ₽ |

Первая строка — это путь заголовков раздела, в котором стоит таблица. Без неё таблица — просто цифры; с ней — тарифы курьерской доставки магазина по весу и зоне. Так же путь Лампа «Свет мини» > Гарантия не даёт перепутать раздел с гарантией на похожую лампу «Свет». Это дешёвая версия идеи Contextual Retrieval из гл. 4.2: контекст приписан к чанку без всякого LLM.

Набросок:

import re
HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
def heading_chunks(md: str, url: str) -> list[dict]:
chunks, path, buf = [], [], []
def flush():
body = "\n".join(buf).strip()
if body:
chunks.append({"text": " > ".join(path) + "\n\n" + body,
"section": " > ".join(path), "url": url})
buf.clear()
for line in md.splitlines():
m = HEADING.match(line)
if m: # TODO: ignore '#' lines inside ``` code blocks
flush()
level = len(m.group(1))
path[:] = path[:level - 1] + [m.group(2).strip()]
else:
buf.append(line)
flush()
return chunks

Обратим внимание на TODO: внутри блоков кода (bash, Python) строки, начинающиеся с #, — это комментарии, а не заголовки. Нужно отслеживать, находимся ли мы внутри ```. В kb/ блоков кода немного, но разбиение должно работать на любом Markdown: в документации API и в README они повсюду. Вторая мелочь — front matter в начале файла (--- … ---, блок метаданных страницы): это не текст для поиска, его отрезаем до разбиения и кладём в метаданные. Третья — явный якорь в конце заголовка (## Возврат вскрытого чая {#opened-tea-returns}, такие якоря ставим на страницах kb/ в практике): в путь заголовков он не входит, его отрезаем и сохраняем для ссылки на раздел.

Три стратегии выше покрывают большинство случаев; остальные стоит знать по названиям.

  • Semantic chunking — режут там, где эмбеддинги соседних предложений резко расходятся. Дорого и не всегда лучше структуры, особенно когда структура уже есть.
  • Parent-child (small-to-big) — ищут по маленьким чанкам, а в промпт отдают их родительский большой раздел. Сочетает точный поиск и полный контекст.
  • Контекстное обогащение — LLM пишет к чанку пояснение (Contextual Retrieval, гл. 5.2).
  • Document-specific — для кода режут по функциям, для таблиц — по строкам с повтором заголовка таблицы, для FAQ — по парам вопрос-ответ.

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

  1. Никогда не резать блок кода или таблицу посередине. Считаем их «атомами»: стратегия может сдвинуть границу до или после них.
  2. Если таблица огромная (тарифы на сотню городов, список из 300 кодов ошибок) — режем её по строкам, повторяя строку заголовка таблицы и путь раздела в каждом куске.
  3. Длинный пример (JSON-ответ в документации API, полный текст оферты) можно укоротить или вынести: он раздувает чанк, а ищут обычно по описанию.

Вместе с текстом каждая стратегия возвращает метаданные — по ним потом фильтруют поиск и строят ссылки. Минимум, который понадобится дальше (chunk_key, doc_hash и loaded_at из таблицы в гл. 4.2 добавим вместе с индексом в гл. 4.4):

Поле Пример Зачем
category returns Фильтр (гл. 4.4, гл. 5.1)
url kb/returns.md#opened-tea-returns (файл + якорь раздела) Цитаты в ответе (гл. 5.3)
section Возврат товара > Возврат вскрытого чая Контекст, отладка, отображение
archived false Архивные страницы по умолчанию исключаются из поиска (гл. 4.4)
chunk_index 12 Порядок, соседние чанки
strategy headings Для эксперимента этой задачи

Осталось решить, как понять, какая стратегия лучше. Метрика этой задачи — hit rate@3: для каждого вопроса из golden set (набора вопросов с известными ответами, его черновик мы начали в notes/rag.md в гл. 4.2) проверить, попал ли кусок с правильным ответом в топ-3 поиска. Как определить «правильный кусок», если чанки у стратегий разные? Не по id чанка, а по ключевой строке ответа: для каждого вопроса записываем подстроку, которая обязана быть в нужном куске (например, "вскрыт" + "не принимаем", или "1–3" + "470"). Хит = хотя бы один из топ-3 содержит все ключевые подстроки. Это грубо, но честно и одинаково для всех стратегий.

def hit(top_texts: list[str], must_contain: list[str]) -> bool:
return any(all(s.lower() in t.lower() for s in must_contain) for t in top_texts)

Важно: чем больше чанк, тем легче ему «содержать» ключевую строку — большие чанки получают фору. Поэтому рядом с hit rate выводим средний размер чанка и их число — сравниваем стратегии при сопоставимом размере.

Chunking — чистый Python, провайдер здесь почти не важен. Меняются три мелочи.

1. Эмбеддинги для эксперимента — локальные. Используем бэкенд, выбранный в главе 4.1 · Эмбеддинги руками на numpy: Ollama bge-m3 или модель Sentence Transformers. DeepSeek для поиска не нужен: эмбеддингов у него нет. Пригодится он только для черновиков kb/ (тексты выдуманные и публичные, облаку их показывать можно). Прогон трёх стратегий на 10–20 страницах — это несколько тысяч эмбеддингов, и локально они бесплатны. Кэш на диск всё равно нужен, ради скорости.

2. Тихая обрезка. У Ollama /api/embed параметр truncate по умолчанию true: чанк длиннее окна модели обрезается без ошибки, и хвост чанка (часто как раз таблица тарифов) в поиске не участвует. Окна у моделей разные: у bge-m3 8192 токена, у embeddinggemma 2K (по страницам моделей в библиотеке Ollama, ноябрь 2026). Для этой главы включаем строгий режим:

# Illustrative: fail loudly instead of silently cutting a long chunk
import ollama
def embed_strict(texts: list[str], model: str = "bge-m3") -> tuple[list[list[float]], int]:
res = ollama.embed(model=model, input=texts, truncate=False) # error if a text is too long
return res["embeddings"], res["prompt_eval_count"] # vectors + tokens processed

3. Токены считаем токенизатором своей модели. tiktoken считает токены моделей OpenAI. У DeepSeek, у bge-m3 и у других моделей токенизаторы свои, поэтому цифры будут другими. Варианты: поле prompt_eval_count в ответе Ollama (сколько токенов модель реально обработала) или токенизатор модели в Sentence Transformers (len(model.tokenizer(text)["input_ids"])). Для сравнения стратегий хватит и символов, но лимит модели проверяем в токенах.

Что важно для chunking Claude OpenAI DeepSeek Ollama / Sentence Transformers
Эмбеддинги для эксперимента Нет своих text-embedding-3-* Нет Да, локально
Счёт токенов Эндпоинт подсчёта токенов (смотри документацию) tiktoken Свой токенизатор; фактические токены — в usage ответа prompt_eval_count у Ollama; model.tokenizer у ST
Слишком длинный вход — Ошибка запроса — Ollama по умолчанию молча обрезает (truncate=true)

Для практики: основной путь — Ollama или Sentence Transformers. Облачная модель эмбеддингов (OpenAI) — только «для сравнения», если хочется увидеть, меняет ли она победителя среди стратегий.

  1. Открываем Pinecone: Chunking strategies → читаем целиком (≈35 мин). Обратим внимание: какие факторы влияют на выбор размера (тип контента, модель эмбеддингов, длина запросов), fixed-size vs content-aware, Markdown splitting, раздел про то, как выбрать размер экспериментом.
  2. Перечитываем раздел «Contextual Retrieval» в главе 4.2 и вводную часть статьи Anthropic — пример проблемы «чанк без контекста» (≈10 мин). Сопоставляем с идеей breadcrumbs.
  3. Открываем справочный центр любого интернет-магазина или службы доставки: правила доставки, возврата, гарантии, страницу товара с FAQ (≈15 мин). Смотрим, как устроены страницы: заголовки, таблицы тарифов, списки условий, вопросы-ответы. Записываем, что будет «атомами», — это же пригодится для брифа kb/.
  4. (Опционально) Смотрим исходники популярного сплиттера — например, RecursiveCharacterTextSplitter или MarkdownHeaderTextSplitter в документации LangChain, чтобы увидеть, как решают те же задачи (≈15 мин). Библиотеку не подключаем — пишем сами.
  5. Открываем справку Ollama /api/embed → параметр truncate и поле prompt_eval_count (≈5 мин), затем окно контекста выбранной модели на её странице в библиотеке Ollama (≈5 мин). Выписываем максимальный размер чанка в токенах, который модель видит целиком.
  • # в блоках кода ломает разбиение по заголовкам — классический баг.
  • Разрезанные таблицы: половина таблицы тарифов без заголовка колонок (вес, зона) бесполезна.
  • Навигация и мусор: если в корпус попадает страница с сайта (в гл. 4.4 это опциональный шаг) и после конвертации HTML → Markdown в каждом чанке есть меню сайта, все чанки становятся похожими друг на друга, и поиск деградирует. Сначала проверяем чистоту текста глазами.
  • Пустые и микро-чанки («## Вопросы и ответы», а под ним ничего) засоряют выдачу. Короткие разделы склеиваем с соседом или выбрасываем.
  • Дубли из-за overlap в топ-3: соседние куски вытесняют другие кандидаты. Можно дедуплицировать выдачу по соседству.
  • Повторяющиеся блоки на каждой странице (абзац «Остались вопросы? Напишите в поддержку» под каждым товаром, примеры на пяти языках программирования под каждым эндпоинтом в доках API) раздувают чанки и делают их похожими. Оставляем один экземпляр или выносим.
  • Ловушки корпуса: архивная политика возврата и похожие товары дают уверенный, но неверный ответ. Без archived и breadcrumbs с названием товара их не отличить.
  • Нечестное сравнение: разный размер чанков у стратегий → большие выигрывают просто размером. Приводим к сопоставимым размерам.
  • Golden set, подогнанный под одну стратегию. Вопросы пишем до того, как смотрели на выдачу, и формулируем их как покупатель, а не копией заголовка.

Работаем в projects/02-support-kb-rag/experiments/chunking/. Используем эмбеддинги и поиск из гл. 4.1 (numpy, без БД). Бэкенд эмбеддингов — локальный (Ollama или Sentence Transformers); облачная модель нужна только для черновиков kb/.

  • Данные: первые страницы kb/. Пишем короткий бриф projects/02-support-kb-rag/kb-brief.md: магазин «Полка», службы доставки и зоны, тарифы по весу и зоне, порог бесплатной доставки, способы оплаты, правила возврата (14 дней; вскрытый чай и кофе не принимаем), гарантия по категориям, 4–6 товаров. По брифу модель генерирует черновики (тексты выдуманные, DeepSeek подойдёт), мы правим их руками: цифры сходятся с брифом, заголовки осмысленные, без воды. Складываем 10–20 страниц в projects/02-support-kb-rag/kb/: delivery.md, payment.md, returns.md, warranty.md, orders.md, promo.md и несколько страниц в products/. Каждый файл начинается с front matter:

    ---
    title: Возврат товара
    category: returns
    updated: 2026-09-01
    ---
    # Возврат товара
    ## Возврат вскрытого чая {#opened-tea-returns}

    У заголовков разделов сразу ставим явные якоря {#...} латиницей: по ним ответы будут ссылаться на конкретный раздел (kb/returns.md#opened-tea-returns), и они не меняются, когда заголовок переформулируют. Встроенные трудности (на них опираются следующие главы, не упрощаем): в delivery.md — таблица тарифов по весу и зоне, в warranty.md — таблица сроков гарантии; returns-2025.md — старая версия правил возврата с другими условиями и archived: true; products/lamp-light.md и products/lamp-light-mini.md — похожие лампы с разной гарантией. Остальные страницы и ловушки (до 30–50 страниц) добавим в гл. 4.4.

  • Golden set. golden.jsonl, 10 строк: берём вопросы из черновика в notes/rag.md (гл. 4.2) и к каждому дописываем ключевые строки. Формат:

    {"q": "можно ли вернуть чай, если пачку уже открыли", "must_contain": ["вскрыт", "не принимаем"], "category": "returns"}

    Вопросы — своими словами, по-русски, как их написал бы покупатель. Ключевые строки берём из своего текста kb/. Среди вопросов — минимум один по таблице тарифов, один про гарантию на «Свет мини» и один, на который может «ответить» архивная страница.

  • Три стратегии в strategies.py:

    1. fixed(text, size=1500);
    2. overlap(text, size=1500, overlap=250) с выравниванием по границе абзаца;
    3. headings(md, url) с breadcrumbs, учётом блоков кода и front matter и склейкой разделов короче ~200 символов; если раздел длиннее ~3000 символов — делим по абзацам, не разрывая таблицы и код.
  • Прогон. run.py: для каждой стратегии нарезаем kb/, эмбеддим (кэшируем эмбеддинги на диск по хэшу текста + имени модели), прогоняем 10 вопросов, считаем hit@3 и hit@1. Печатаем также число чанков и средний размер.

  • Проверка длины. Если эмбеддим через Ollama, вызываем его с truncate=False и сохраняем prompt_eval_count. Выводим, сколько чанков каждой стратегии упёрлись в лимит модели (для fixed 1500 таких быть не должно; для headings без деления длинных разделов — могут быть).

  • (Для сравнения, по желанию) Перепрогоняем лучшую и худшую стратегию с облачной моделью эмбеддингов (OpenAI). Меняется ли порядок стратегий?

  • Таблица результатов в RESULTS.md:

    Стратегия Чанков Ср. размер, симв. hit@3 (из 10) hit@1
    fixed 1500
    overlap 1500/250
    headings
  • Разбор промахов. Для каждого промаха лучшей стратегии записываем одну строку: почему (кусок разрезан, нет контекста, мусор, вопрос разговорным языком, нашлась архивная страница или похожий товар). Это список задач для гл. 5.1 и гл. 5.2.

  • (Опционально) Повторяем с размерами 800 и 2500 для fixed — компромисс размер/точность станет виден в цифрах.

Ожидаемый результат: структурная нарезка с breadcrumbs обычно выигрывает на структурированной базе знаний, overlap чуть лучше чистого fixed. Если вышло иначе — это не ошибка, а находка: разбираемся почему и записываем.

  • В kb/ есть 10–20 страниц с front matter, включая таблицу тарифов, архивную политику возврата и пару похожих товаров.
  • Есть RESULTS.md с таблицей: стратегия → сколько вопросов из 10 нашли правильный кусок в топ-3, плюс число и средний размер чанков.
  • Есть разбор промахов и вывод: какую стратегию берём в индекс гл. 4.4 и с какими параметрами.
  • Ни одна таблица и ни один блок кода не разрезаны пополам в стратегии headings (выборочно проверено глазами на 5 чанках).

Вопрос 1

Какой минус есть у слишком маленьких чанков и какой — у слишком больших?

Маленькие теряют контекст («срок — 14 дней» — срок чего); большие дают размытый эмбеддинг и много шума в промпте.

Вопрос 2

Зачем нужен overlap и какова его цена?

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

Вопрос 3

Что такое breadcrumbs у чанка и чем они помогают поиску?

Путь заголовков раздела (Доставка > Курьером по России > Тарифы по весу и зоне), приписанный к тексту и в метаданные. Он возвращает чанку контекст: страницу, тему, товар — и эмбеддинг, и BM25 начинают это учитывать.

Вопрос 4

Почему нельзя резать таблицу тарифов пополам и что делать с очень длинной таблицей?

Половина таблицы без заголовков колонок и без названия раздела бессмысленна: строка «1–3 | 350 ₽ | 470 ₽» не говорит, что это килограммы, зоны и курьерская доставка. Длинную таблицу режут по строкам, повторяя строку заголовка таблицы и путь раздела в каждом куске.

Вопрос 5

Что не так в этом коде разбиения по заголовкам?

for line in md.splitlines():
if line.startswith("#"):
flush()
path = [line.lstrip("# ")]
else:
buf.append(line)

Три проблемы: #-строки внутри блоков кода (комментарии) примутся за заголовки; уровень заголовка игнорируется — путь всегда из одного элемента, иерархия теряется; lstrip("# ") срежет и решётки, и пробелы с начала — терпимо, но уровень надо считать до этого.

Вопрос 6

Метрика hit@3 — это:

  • a) доля вопросов, где правильный кусок на первом месте
  • b) доля вопросов, где правильный кусок в первых трёх
  • c) среднее сходство топ-3
  • d) доля правильных ответов LLM

b).

Вопрос 7

Почему при сравнении стратегий нужно смотреть на средний размер чанка, а не только на hit rate?

Большой чанк легче «содержит» ключевую строку и выигрывает размером, а не качеством разбиения; к тому же он дороже в промпте. Сравнение честное только при сопоставимом размере.

Вопрос 8

Что такое parent-child (small-to-big) chunking?

Ищут по маленьким точным чанкам, а в промпт отдают их родительский раздел целиком — точность поиска плюс полный контекст для ответа.

Вопрос 9

Почему мусор навигации сайта в каждом чанке ухудшает поиск?

Одинаковый текст во всех чанках делает их эмбеддинги похожими друг на друга и сдвигает их к «теме меню», различия между разделами сглаживаются.

Вопрос 10

Получилось hit@3 = 10/10 у всех трёх стратегий. Что, скорее всего, не так с экспериментом?

Golden set слишком лёгкий (вопросы почти копируют заголовки или ключевые строки слишком общие), либо проверка must_contain слишком мягкая; нужен вопрос, сформулированный по-пользовательски, и более специфичные ключевые строки.

Вопрос 11

Размер чанков считается через tiktoken, а эмбеддинги — через Ollama bge-m3. Что не так с этими цифрами и как узнать реальное число токенов?

tiktoken — токенизатор моделей OpenAI; у bge-m3 свой токенизатор, и число токенов будет другим. Реальное число — prompt_eval_count в ответе /api/embed или токенизатор модели (model.tokenizer в Sentence Transformers).

Вопрос 12

Стратегия headings дала чанки по 4000 токенов, эмбеддинги — embeddinggemma через Ollama с параметрами по умолчанию. Ошибок нет, но таблицы тарифов в конце разделов не находятся. Почему?

У embeddinggemma окно 2K токенов, а /api/embed по умолчанию (truncate=true) молча обрезает длинный вход: хвост раздела с таблицей в эмбеддинг не попал. Лечится делением длинных разделов, моделью с большим окном (bge-m3 — 8192) и truncate=False, чтобы такие случаи падали с ошибкой.

Выбранная стратегия идёт в настоящий индекс: 4.4 · Векторная БД + загрузка базы знаний — вся база знаний kb/ (30–50 страниц, сотни чанков), метаданные и фильтры.