4.3 · Chunking: сравнить 3 стратегии
Неделя 4 · RAG · ⏱ ~4 ч · Практика
Зачем это AI-инженеру
Заголовок раздела «Зачем это AI-инженеру»Плохой chunking — причина номер один, почему RAG находит не то. Это первое, что опытный инженер проверяет, когда «RAG отвечает ерунду», и частый вопрос на собеседовании: «как нарезать PDF-контракты / базу знаний поддержки / тикеты?». Правильного ответа «512 токенов» не существует — есть умение сравнить стратегии на своих данных, и этому мы здесь научимся. Заодно в этой главе появляются первые страницы базы знаний магазина «Полка» — корпуса, на котором работает весь второй проект.
Что такое чанк и почему его размер — компромисс
Заголовок раздела «Что такое чанк и почему его размер — компромисс»В схеме RAG из гл. 4.2 chunking стоит между загрузкой документов и эмбеддингами: прежде чем искать, страницу базы знаний нужно разрезать на куски. Чанк — кусок документа, который отдельно эмбеддится, отдельно ищется и отдельно попадает в промпт. Размер чанка тянет в две стороны:
| Маленький чанк (100–200 токенов) | Большой чанк (1000–2000 токенов) |
|---|---|
| Эмбеддинг точный, про одну мысль | Эмбеддинг «размыт» по нескольким темам |
| Теряется контекст: «срок — 14 дней» — срок чего? | Контекст на месте |
| В промпт попадает мало лишнего | В промпт попадает много шума, дороже |
| Нужно больше кусков, чтобы собрать ответ | Один кусок часто содержит весь ответ |
Для справочных текстов и документации разумный старт — 300–800 токенов, но точную цифру подберём экспериментом. Важно помнить лимит входа модели эмбеддингов (гл. 4.1): чанк длиннее него будет обрезан молча.
Токены ≠ символы. Для английского текста грубо 1 токен ≈ 4 символа, для русского и кода — иначе. Для эксперимента достаточно считать в символах (так и задаём размеры в практике), но лимит модели точнее проверять токенизатором (например, tiktoken для моделей OpenAI; про токенизаторы наших моделей — ниже, в разделе про DeepSeek и Ollama).
Дальше — три стратегии, которые сравним в практике, от самой простой к самой «умной»; каждая следующая чинит главный недостаток предыдущей.
Стратегия 1: фиксированный размер
Заголовок раздела «Стратегия 1: фиксированный размер»Режем каждые 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, потом по пробелу). Но и так стратегия не знает, где кончается раздел или таблица. Это знает автор документа — и оставил подсказку в заголовках.
Стратегия 3: по структуре (заголовкам)
Заголовок раздела «Стратегия 3: по структуре (заголовкам)»Документация и база знаний уже размечены автором: заголовки #, ##, ### отделяют темы. Режем по ним — каждый раздел становится чанком. Если раздел слишком длинный — делим его дальше по подзаголовкам или абзацам; если слишком короткий — склеиваем с соседом.
Главный бонус — путь заголовков (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; в технической документации — таблицы параметров, коды ошибок и примеры кода. Правила:
- Никогда не резать блок кода или таблицу посередине. Считаем их «атомами»: стратегия может сдвинуть границу до или после них.
- Если таблица огромная (тарифы на сотню городов, список из 300 кодов ошибок) — режем её по строкам, повторяя строку заголовка таблицы и путь раздела в каждом куске.
- Длинный пример (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@k
Заголовок раздела «Как сравнивать стратегии: hit rate@k»Осталось решить, как понять, какая стратегия лучше. Метрика этой задачи — 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 выводим средний размер чанка и их число — сравниваем стратегии при сопоставимом размере.
DeepSeek и Ollama: как это сделать у нас
Заголовок раздела «DeepSeek и Ollama: как это сделать у нас»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 chunkimport 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 processed3. Токены считаем токенизатором своей модели. 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) — только «для сравнения», если хочется увидеть, меняет ли она победителя среди стратегий.
Маршрут изучения
Заголовок раздела «Маршрут изучения»- Открываем Pinecone: Chunking strategies → читаем целиком (≈35 мин). Обратим внимание: какие факторы влияют на выбор размера (тип контента, модель эмбеддингов, длина запросов), fixed-size vs content-aware, Markdown splitting, раздел про то, как выбрать размер экспериментом.
- Перечитываем раздел «Contextual Retrieval» в главе 4.2 и вводную часть статьи Anthropic — пример проблемы «чанк без контекста» (≈10 мин). Сопоставляем с идеей breadcrumbs.
- Открываем справочный центр любого интернет-магазина или службы доставки: правила доставки, возврата, гарантии, страницу товара с FAQ (≈15 мин). Смотрим, как устроены страницы: заголовки, таблицы тарифов, списки условий, вопросы-ответы. Записываем, что будет «атомами», — это же пригодится для брифа
kb/. - (Опционально) Смотрим исходники популярного сплиттера — например,
RecursiveCharacterTextSplitterилиMarkdownHeaderTextSplitterв документации LangChain, чтобы увидеть, как решают те же задачи (≈15 мин). Библиотеку не подключаем — пишем сами. - Открываем справку 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: returnsupdated: 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:fixed(text, size=1500);overlap(text, size=1500, overlap=250)с выравниванием по границе абзаца;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 — это:
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 страниц, сотни чанков), метаданные и фильтры.