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

8.2 · Эксперимент: chunking A vs B

Неделя 8 · Evals · ⏱ ~4 ч · Проект

Это первый полный эксперимент Проекта 3: гипотеза → одно изменение → прогон evals → вывод с цифрами. Именно такой цикл и есть ежедневная работа AI-инженера, а не «попробовали, вроде лучше». Чанкинг выбран не случайно: плохое разбиение — частая причина, почему RAG находит не то, и эффект обычно достаточно крупный, чтобы его было видно на нашем наборе.

1. гипотеза (письменно, ДО прогона)
«чанки по заголовкам дадут +X п.п. hit@5,
потому что ответ на вопрос обычно лежит внутри одного раздела»
2. одно изменение chunking: fixed-512 → headings
3. всё остальное фиксировано эмбеддинги, k, ответчик, промпт, судья, набор
4. прогон evals baseline ×3, вариант ×3 (или ≥1 + известный разброс)
5. сравнение общие метрики + парное сравнение + срезы
6. разбор на каких вопросах разница и почему
7. вывод принять / отклонить / «тенденция», строка в таблице

Это та же дисциплина, что в A/B-тесте страницы магазина: меняем один параметр, остальное не трогаем, сравниваем на одних и тех же данных и не объявляем победу, пока результат не выйдет за пределы шума (8.1). Дальше разбираем шаги по порядку на нашем примере: гипотеза, выбор двух вариантов, условия честного сравнения, прогоны, разбор и журнал.

Гипотеза пишется до первого прогона, иначе её легко подогнать под полученную цифру. Хорошая гипотеза содержит три части: что меняем, какую метрику и насколько ожидаем сдвинуть, почему (механизм). Пример:

Если резать kb/ по заголовкам разделов (H2/H3) с путём заголовков в начале куска, ограничением 800 токенов, таблицами как неделимыми «атомами» и склейкой коротких разделов страниц товаров, вместо фиксированных кусков по 512 токенов с перекрытием 64, то hit@5 вырастет на 8–15 п.п., а сильнее всего на вопросах с тегами table и similar_products, потому что сейчас строки таблицы тарифов попадают в кусок без шапки (вес, зона), а раздел «Гарантия» со страницы lamp-light-mini.md — в кусок без названия лампы. Correctness вырастет меньше, чем hit@5. Стоимость ответа может измениться вместе со средним размером куска.

Здесь hit@5 — доля вопросов, у которых хотя бы один релевантный кусок попал в первые 5 найденных, а п.п. — процентные пункты: рост с 0,70 до 0,80 — это +10 п.п. Зачем записывать «почему»: в разборе проверяем не только цифру, но и механизм. Если hit rate вырос, но на других вопросах, чем предсказывали, гипотеза о механизме неверна, даже если цифра совпала.

Гипотеза называет два конкретных способа резать kb/. Стратегии мы уже реализовали в 4.3 и выбрали одну из них для index.db по 10 вопросам. Теперь проверяем выбор на полном наборе и со всеми метриками. Варианты, которые имеет смысл сравнить на базе знаний магазина:

Стратегия Как работает Ожидаемая сильная сторона Риск
A: fixed + overlap Куски по N токенов с перекрытием Предсказуемый размер Режет таблицы тарифов и гарантии; кусок не знает, о каком он товаре
B: по заголовкам Раздел = кусок с путём заголовков («Товары > Лампа «Свет мини» > Гарантия») в начале; длинные делятся по абзацам, короткие склеиваются Смысловая целостность, лампы различаются по пути Куски разного размера
B’: заголовки + LLM-контекст B плюс 1–2 предложения контекста от модели (contextualize.py, index_ctx.db из 5.2) Поиск лучше понимает, о чём кусок Длиннее куски, стоит вызовов модели

Для эксперимента выбираем две: baseline и одну альтернативу. Остальное — следующими экспериментами. Дальше в главе A — fixed-512 с перекрытием 64, B — заголовки. Даже если index.db уже построен по заголовкам, оба варианта собираем заново в отдельные файлы: одна версия kb/, один код загрузки.

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

В kb/ ловушка смотрит в другую сторону: разделы короткие (страница товара — несколько разделов по паре абзацев), средний кусок B (~370 токенов) меньше, чем у A (512), и при одинаковом k фору получает baseline.

Два способа сравнивать честно:

  1. Равный бюджет контекста. Подбираем k так, чтобы средний объём контекста в токенах был близок: fixed-512 при k=5 (~2560 токенов) против headings (средний кусок ~370 токенов) при k=7 (~2590 токенов).
  2. Показываем оба: hit@k при одинаковом k и среднее число токенов контекста. Тогда читатель видит компромисс.

В таблицу результатов обязательно добавляем колонки «средний размер куска» и «токенов контекста на вопрос».

Пересчёт релевантности после смены чанкинга

Заголовок раздела «Пересчёт релевантности после смены чанкинга»

Вторая вещь, которую смена чанкинга ломает незаметно, — сама разметка релевантности: по ней считаются hit@k и MRR. Ключи чанков в новом индексе другие: chunk_key = sha256(url + section + text) меняется вместе с текстом и путём заголовков, поэтому source_chunk_keys из набора не годятся. Именно для этого в 6.3 мы храним evidence: функция is_relevant из 6.4 находит релевантные чанки в любом индексе по цитате. Перед прогоном проверяем:

  • для каждого вопроса (кроме unanswerable) в новом индексе есть хотя бы один чанк, содержащий цитату;
  • если нет, причина либо в разрезанной цитате (fixed-чанкинг разрезал фразу или строку таблицы | 1–3 | 350 ₽ | 470 ₽ | 590 ₽ | пополам), либо в отличии загрузки текста (по-разному обработан front matter).

Если в варианте A цитату разрезало у 5 вопросов, а в B нет, A получит 5 промахов, которые на самом деле не промахи поиска. Учитываем это: либо считаем частичное совпадение (большая часть цитаты внутри куска), либо отмечаем такие случаи в разборе.

Проверять цитаты, сравнивать при разном k и повторять прогоны удобно, только если оба варианта доступны одновременно. Поэтому единственный индекс не перестраиваем: держим оба варианта одновременно, отдельными файлами sqlite-vec, с именем в конфиге:

projects/02-support-kb-rag/
index.db # week-4 index, untouched
index_fixed512.db # A: fixed 512 tokens, overlap 64
index_headings.db # B: headings + breadcrumbs, max 800 tokens
eval/configs/
chunk_fixed512.json {"index": "index_fixed512.db", "k": 5, ...}
chunk_headings.json {"index": "index_headings.db", "k": 5, ...}
chunk_headings_k7.json {"index": "index_headings.db", "k": 7, ...} # equal context budget

Так любой прогон воспроизводим, и через неделю можно вернуться к baseline без пересборки. Папки прогонов получают имя по индексу: eval/runs/2027-01-05T10-12_index_headings_hybrid_k5/. Эмбеддинги локальные и денег не стоят, но перестройка занимает время: заранее считаем число кусков и токенов в каждом варианте.

Индексы и конфиги готовы; осталось решить, что именно прогонять. Чанкинг напрямую влияет на поиск, поэтому главная метрика эксперимента — hit@k/MRR/recall. Но обязательно смотрим и на correctness и faithfulness: бывает, что hit rate вырос, а correctness нет, потому что модель тонет в длинных кусках или ответ уже упирается в генерацию. Вывод тогда честный: «поиск стал лучше, но узкое место — генерация». Счётчик archived_in_context тоже проверяем: без метаданных archived в контекст вернётся returns-2025.md.

Метрики поиска детерминированы и дёшевы: их можно прогнать без генерации и судьи (флаг --retrieval-only у eval/run.py), быстро перебрать несколько размеров и только для финалистов запустить полный прогон.

Прогоны дают две общие цифры, но их разница — ещё не вывод. Общая цифра говорит «сколько», анализ по вопросам — «где» и «почему». После прогона строим таблицу переходов: для каждого вопроса смотрим, нашёл ли его вариант A и нашёл ли вариант B. Пример: dev-набор из 56 вопросов, без 8 unanswerable — 48:

B: hit B: miss
A: hit 34 3 ← регрессии: что B сломал?
A: miss 9 2 ← улучшения: что B починил?

hit@5: A — 37/48 = 0,77, B — 43/48 = 0,90, разница +12,5 п.п. Дальше смотрим глазами:

  • 9 улучшений: подтверждают ли они механизм гипотезы (таблица тарифов теперь в одном куске с шапкой, гарантия «Свет мини» — в куске с названием лампы)?
  • 3 регрессии: почему новый вариант их потерял (крупный раздел размыл эмбеддинг, микро-раздел товара остался без контекста)?
  • 2 «оба промахнулись»: проблема не в чанкинге. Это материал для 8.4.
  • Срезы по type, category и тегам tags: может, заголовки помогают delivery.md и warranty.md с таблицами и вредят страницам products/, где разделы по две строки.

Тут же применяем тест знаков из 8.1 на расхождениях (9 против 3): совпавшие вопросы ничего не говорят о разнице, считаются только те, где варианты разошлись. Здесь p ≈ 0,15, то есть по правилу значимости это пока «тенденция».

Всё, что выше, — гипотеза, условия, прогоны, цифры, разбор — должно остаться в одном месте, иначе через неделю не восстановить, что сравнивали. Каждый эксперимент оформляем коротко и одинаково, в experiments/EXP-01-chunking.md:

# EXP-01: chunking fixed512-o64 vs headings (index_fixed512.db vs index_headings.db)
Date: 2027-01-05 | dataset <sha> (dev, n=56) | git abc1234
## Hypothesis
...
## Setup
Changed: chunking. Fixed: embeddings bge-m3, hybrid search, k=5, answer prompt answer_v2, judge judge_v2.
Runs: eval/runs/2027-01-05T...
## Results
| config | hit@5 | MRR | correctness | faithfulness | ctx tokens | cost/100q | p50 latency |
|---|---|---|---|---|---|---|---|
Paired hit@5: improved 9 / regressed 3, sign test p=0.15
## Analysis
Where it helped / hurt, examples (question ids), by type, by category, by tags (table, similar_products).
## Conclusion
Accept / reject / trend. Next step.

Из этих журналов потом собирается таблица в README (8.5).

Теория не зависела от провайдера; на нашем стеке у неё есть две практические стороны: чем строить два индекса и как размер кусков отражается на ответчике и его цене. Чанкинг — эксперимент про поиск, и главный провайдер здесь не DeepSeek, а модель эмбеддингов. Сверено в ноябре 2026 по api-docs.deepseek.com и docs.ollama.com.

Эндпоинта эмбеддингов в официальной документации DeepSeek нет, поэтому оба индекса строятся локально: Ollama (/api/embed) или Sentence Transformers — тем же бэкендом, что индекс недели 4 (EMBED_BACKEND, EMBED_MODEL). Деньги это не стоит, стоит времени, и это хорошо для эксперимента: перестроить индекс с другим размером кусков можно сколько угодно раз. База знаний публичная, но правило проекта прежнее: настоящие сообщения клиентов в облако не уходят.

# illustrative: build index B with the same embedding model as index A
import ollama
EMBED_MODEL = "bge-m3" # fixed in config for both indexes; never change it together with chunking
def embed_batch(texts: list[str]) -> list[list[float]]:
# /api/embed returns L2-normalized vectors
return ollama.embed(model=EMBED_MODEL, input=texts)["embeddings"]

Ловушка, которой нет у облачных эмбеддингов: окно модели эмбеддингов. У каждой модели эмбеддингов ограниченный контекст: у bge-m3 8192 токена, у embeddinggemma 2K, у маленьких моделей бывает всего несколько сотен токенов. Если кусок длиннее, хвост может быть отброшен при вычислении эмбеддинга, и это ломает сравнение: вариант «по заголовкам», где таблица тарифов — неделимый атом в конце раздела, будет искаться только по началу куска. Перед построением индекса:

  • смотрим контекст модели: ollama show <model> или страница модели на ollama.com/library;
  • считаем, сколько кусков каждого индекса длиннее этого окна;
  • помним про truncate: по справочнику API Ollama у /api/embed он по умолчанию true, то есть вход длиннее окна молча обрезается (какая часть отбрасывается, справочник не уточняет). С truncate: false придёт ошибка — для эксперимента это лучше, так мы узнаем о каждом слишком длинном куске. promptfoo, например, специально шлёт truncate: false.

Если в варианте B много кусков длиннее окна, это не «чанкинг по заголовкам хуже», а «модель эмбеддингов не видит конец куска». Либо ограничиваем размер куска окном модели, либо берём модель с большим окном для обоих индексов.

Ответчик и судья не меняются (deepseek-flash и deepseek-v4-pro без thinking, как в главе 6.4 · Скрипт оценки: retrieval + faithfulness). Два эффекта от размера кусков:

  • Стоимость DeepSeek растёт с токенами контекста, а кеш почти не помогает: в кеш попадает общий префикс (системный промпт), а куски у каждого вопроса свои. Считаем «$ / 100 q» через eval/cost.py с полями кеша и временем пик/вне пика; прогоны A и B делаем в одно и то же окно цен, иначе разница в цене будет про время суток.
  • Если ответчик локальный, крупные куски могут не влезть в контекст сервера Ollama (по умолчанию 4k токенов, если видеопамяти меньше 24 ГБ) — проиграет вариант с более длинным контекстом, из-за обрезки, а не из-за чанкинга. Проверяем ollama ps; поднимать OLLAMA_CONTEXT_LENGTH на машине с 8 ГБ можно лишь осторожно, поэтому для этого эксперимента ответчиком лучше оставить deepseek-flash.
Что нужно эксперименту Claude OpenAI DeepSeek Ollama
Эмбеддинги для индексов своих нет эндпоинт эмбеддингов, платно в документации нет /api/embed, бесплатно, локально
Стоимость перестройки индекса — за токены — $0, только время
Риск обрезки длинного куска — лимит модели смотрим в документации — окно модели эмбеддингов; поведение truncate проверяем
Генерация на крупных кусках платим за вход платим за вход платим за вход, кеш почти не помогает бесплатно, но нужен достаточный контекст сервера

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

  1. Перечитываем свои итоги 4.3 в experiments/chunking/RESULTS.md (≈10 мин): какая стратегия выиграла на 10 вопросах и почему. Это исходная точка для гипотезы.
  2. Открываем Pinecone: Chunking strategies → разделы про структурный (document-specific) чанкинг и выбор размера (≈20 мин). Обратим внимание на мысль, что размер зависит от длины запросов и от того, что делается с найденным дальше.
  3. Открываем Anthropic: Contextual Retrieval → раздел, где добавление контекста к куску сравнивается с обычным (≈15 мин). Это обоснование варианта B’ и пример того, как оформлять сравнение с цифрами.
  4. Открываем Hamel: A Field Guide to Rapidly Improving AI Products → раздел про эксперименты и порядок работы (≈20 мин): почему сначала разбор ошибок, потом изменение, потом измерение.
  5. Перечитываем правило значимости из 8.1 (≈5 мин) и держим его перед глазами при выводе.
  6. Открываем Ollama: Embeddings и справочник Ollama API: /api/embed (≈15 мин). Находим параметр truncate (по умолчанию true) и поле prompt_eval_count; выписываем контекст своей модели эмбеддингов (ollama show <model>).
  • Изменение двух вещей сразу (чанкинг и k, или чанкинг и промпт) делает вывод невозможным: непонятно, что сработало.
  • Крупные куски выигрывают hit@k «бесплатно». Сравниваем при равном бюджете контекста или показываем токены. В kb/ крупнее обычно куски fixed, а не заголовков.
  • Цитата разрезана границей куска — ложный промах поиска; проверяем до прогона.
  • Таблицы тарифов и сроков гарантии: если чанкер режет markdown-таблицу, кусок «| 1–3 | 350 ₽ | 470 ₽ |» без шапки и пути раздела бесполезен. Проверяем глазами несколько кусков нового индекса, прежде чем гонять evals.
  • Похожие товары: кусок «Гарантия — 6 месяцев» без названия одинаково близок к «Свет» и «Свет мини»; различает их путь заголовков.
  • Микро-разделы страниц товаров («## Уход» из двух строк) засоряют выдачу — склеиваем с соседом.
  • Смена загрузки текста вместе с чанкингом (иначе обработан front matter, вырезан повторяющийся блок «Остались вопросы?») — это второе изменение. rag/load.py должен быть одинаковым.
  • Смена модели эмбеддингов вместе с чанкингом — второе изменение. embed_model в конфиге одинаковый у обоих индексов.
  • Модель эмбеддингов не видит конец длинного куска. Окно локальной модели эмбеддингов может быть короче кусков варианта B. Считаем заранее, сколько кусков длиннее окна.
  • Потерянные метаданные: без archived у новых кусков archived_in_context перестанет быть нулём.
  • Прогон на holdout в этом эксперименте не нужен: holdout — только для финальной конфигурации.
  • Рапорт «стало лучше на 4 п.п.» без разброса — нарушение собственного правила из 8.1.
  • Прогоны A и B в разные окна цен DeepSeek — «стоимость» начинает зависеть от времени суток. Запускаем подряд или пересчитываем по одной цене.
  • Создаём experiments/EXP-01-chunking.md и до любых прогонов записываем гипотезу: что меняем, какая метрика, ожидаемая величина, механизм.
  • Выписываем контекст своей модели эмбеддингов (ollama show <model>) и в конфиги прогонов добавляем "embed_model" (точный тег Ollama); проверяем, что он одинаковый у chunk_fixed512.json и chunk_headings.json.
  • Строим оба индекса рядом с index.db: index_fixed512.db и index_headings.db (ingest.py с параметрами стратегии и файла индекса); та же загрузка kb/, те же эмбеддинги, truncate: false, чтобы слишком длинный кусок падал с ошибкой, а не обрезался молча. Читаем глазами по 10 кусков каждого, включая delivery.md, warranty.md и лампы.
  • Считаем для обоих индексов: число кусков, средний и максимальный размер в токенах, сколько таблиц разрезано и сколько кусков длиннее окна модели эмбеддингов. Записываем в журнал эксперимента.
  • Проверка цитат: для каждого вопроса находим релевантные куски в обоих индексах по evidence; выписываем вопросы, где цитата не находится ни в одном куске.
  • Добавляем в eval/run.py флаг --retrieval-only и поле ctx_tokens (токенов контекста на вопрос).
  • Прогоняем только поиск для: fixed k=5, headings k=5, headings с k под равный бюджет контекста.
  • Полный прогон (с генерацией и судьёй) для baseline и лучшего варианта; если уже есть 3 прогона baseline из 8.1, делаем 3 прогона и варианта (или минимум 1 и сравниваем с разбросом). Оба полных прогона делаем в одном окне цен DeepSeek (пик или вне пика), стоимость считаем eval/cost.py с полями кеша.
  • eval/compare.py: парное сравнение hit@5 и correctness, тест знаков, срезы по type, category и тегам (table, similar_products).
  • Таблица переходов hit/miss; читаем все улучшения и регрессии, выписываем по 2–3 характерных примера (id вопроса + объяснение).
  • Заполняем разделы Results, Analysis, Conclusion. Вывод формулируем по своему правилу: принято / отклонено / тенденция.
  • Добавляем строку в таблицу экспериментов (experiments/README.md или будущий README проекта).
  • Гипотеза записана до прогона, с механизмом и ожидаемой величиной.
  • Есть строка в таблице экспериментов: hit@5, MRR, correctness, faithfulness, токены контекста, стоимость, латентность, разброс, расхождения +/−, вывод.
  • В журнале перечислены конкретные вопросы, где разница, и объяснение почему.
  • archived_in_context равен 0 в прогонах обоих вариантов.

Вопрос 1

Какие три части обязательны в хорошей гипотезе?

Что меняется; какая метрика и на сколько должна сдвинуться; механизм (почему).

Вопрос 2

Почему нельзя одновременно менять чанкинг и k?

Нельзя будет понять, какое изменение дало эффект; эксперимент должен менять одну переменную.

Вопрос 3

Вариант с кусками по 1500 токенов дал hit@5 выше, чем по 500. Почему это ещё не значит, что он лучше?

Крупные куски при том же k покрывают больше текста: это больший контекст, а не лучший поиск; растут стоимость, латентность, и модель хуже находит нужное в длинном контексте.

Вопрос 4

Какие два способа позволяют честно сравнить стратегии чанкинга?

Сравнивать при равном бюджете контекста (подобрать k по токенам) или показывать hit@k вместе со средним числом токенов контекста.

Вопрос 5

Почему для эксперимента с чанкингом нужны цитаты evidence, а не source_chunk_keys?

chunk_key = sha256(url + section + text) меняется при новом разбиении, ключи из набора в новом индексе не существуют; цитата позволяет найти релевантные куски в любом индексе.

Вопрос 6

Таблица переходов: A hit → B miss = 3, A miss → B hit = 9. Что делать с этими числами?

  • a) просто посчитать разницу 6 и объявить улучшение
  • b) тест знаков и чтение всех 12 вопросов
  • c) игнорировать, смотреть только общую цифру
  • d) прогнать на holdout.

b. 9:3 может быть шумом (p ≈ 0,15); нужно проверить тестом знаков и прочитать вопросы, чтобы понять механизм улучшений и причины регрессий.

Вопрос 7

Hit@5 вырос на 12 п.п., correctness не изменилась. Как это интерпретировать?

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

Вопрос 8

Что не так с этим журналом эксперимента?

EXP-01. Поменял чанкинг на заголовки, поправил загрузку текста и увеличил k до 8.
hit@8 = 0.91 (было hit@5 = 0.78). Стало лучше, оставляю.

Три изменения сразу (чанкинг, загрузка текста, k); метрики не сравнимы (hit@8 против hit@5); нет гипотезы, разброса, парного сравнения, correctness, стоимости, токенов контекста и анализа по вопросам.

Вопрос 9

Зачем хранить оба индекса одновременно?

Для воспроизводимости и быстрых сравнений: любой прогон можно повторить, к baseline можно вернуться без пересборки.

Вопрос 10

Как быстро и дёшево перебрать несколько размеров кусков?

Прогон только поиска (--retrieval-only): он детерминирован и не требует генерации и судьи; полный прогон — только для финалистов.

Вопрос 11

Вариант «по заголовкам» с таблицами-атомами неожиданно проиграл по hit@5 на вопросах про тарифы, хотя глазами куски выглядят лучше. Какая причина специфична для локальных эмбеддингов и как её проверить?

Окно модели эмбеддингов короче куска: по умолчанию /api/embed молча обрезает длинный вход (truncate: true), эмбеддинг считается по началу куска, и таблица тарифов в конце раздела в поиске не участвует. Проверить контекст модели (ollama show), долю кусков длиннее окна и поведение truncate; при необходимости ограничить размер куска (длинную таблицу резать по строкам с повтором шапки) или взять модель с большим окном для обоих индексов.

Вопрос 12

Почему автоматический кеш DeepSeek почти не снижает стоимость ответов в этом эксперименте?

Кеш срабатывает на совпадающем префиксе запросов, а это в основном системный промпт; куски контекста у каждого вопроса разные и идут по цене кеш-промаха.

Следующий эксперимент меняет не поиск, а модели: в 8.3 · Эксперимент: модели и реранкер сравниваем большую и маленькую модель и вариант с реранкером по качеству, цене и скорости.