---
title: "Структурный чанкинг и контекстные эмбеддинги: что на самом деле даёт контекст документа"
date: 2026-10-25T11:00:00+03:00
type: post
lang: ru
domains: ["ai","architecture"]
tags: ["rag","chunking","embeddings","knowledge-graph"]
series: rag-in-production
series_weight: 3
summary: "Нарезка по структуре документа вместо окон по длине и перелинковка графа знаний: +6.5 п.п. ключевых документов в топ-10 и +2.7 п.п. nDCG@10 — самый большой вклад за цикл."
llm_summary: "Третья статья цикла «RAG в проде». Переход с нарезки markdown окнами по длине на структурный чанкинг (по дереву блоков, до 600 токенов, таблицы построчно с шапкой, заголовок с путём раздела) вместе с перелинковкой графа знаний дал +6.5 п.п. key-doc R@10 [+2.6; +10.6] и +2.7 п.п. nDCG@10 [+0.4; +5.1] на 111 эталонных запросах. Документы векторизуются контекстной моделью эмбеддингов внешнего провайдера; найден артефакт клиента, который резал документ на пачки по 64 чанка и терял контекст. Разобраны ловушка неоценённых документов (judged@10), инвариант проекции документа в графе, потолок LLM-извлечения сущностей и переиндексация ~30 тыс. документов на живом проде за час."
---


Переход с нарезки по длине на структурный чанкинг вместе с перелинковкой графа знаний дал +6.5 п.п. доли найденных ключевых документов в топ-10 и +2.7 п.п. nDCG@10, оба прироста статистически значимы. Это самый большой вклад среди всех изменений конвейера за цикл. В контексте документа работают три вещи: границы чанков по смыслу, заголовок с путём раздела в каждом чанке и контекстные эмбеддинги. Их вклады по отдельности мы честно не разделили, и ниже сказано почему.

> [!BIZ]
>
> - **Самое заметное улучшение за цикл:** доля ключевых документов в топ-10 выросла на 6.5 п.п. (с 0.647 до 0.712), качество порядка — на 2.7 п.п.
> - **Длинные документы и таблицы стали находимыми:** раньше документ обрезался на 32 тысячах токенов, а строка таблицы теряла шапку. Теперь каждая строка знает, из какой она таблицы и раздела.
> - **Корпус переиндексирован за час вместо шести** без остановки поиска; узким местом был внешний API эмбеддингов, а не наш код.
> - **Урок про измерения:** первое сравнение показало просадку, которой не было. Новая система находила документы, которые ещё никто не оценил.

> [!NOTE] О цикле «RAG в проде»
> Четыре статьи о том, как за две-три недели октября 2026 года мы перестроили поиск корпоративного RAG-ассистента. «Мы» — это автор и AI-агенты, которым он делегировал часть исследований и правок. Компания и системы обезличены, цифры настоящие.
>
> 1. [Гибридный поиск: как «ноги» усиливают друг друга](/posts/hybrid-search-rag)
> 2. [Индексы под гибридный поиск в PostgreSQL](/posts/postgres-hybrid-indexes)
> 3. **Структурный чанкинг и контекстные эмбеддинги** ← вы здесь
> 4. [Граф знаний в RAG](/posts/knowledge-graph-rag)

> [!TERM]- Термины и метрики за минуту
>
> - **Чанк** — фрагмент документа, единица поиска и контекста модели.
> - **Чанкинг по длине** — нарезка текста окнами фиксированного размера, без учёта разделов и таблиц.
> - **Структурный чанкинг** — нарезка по дереву документа: разделы, списки, блоки кода, таблицы.
> - **Эмбеддинг** — вектор, кодирующий смысл текста; близкие по смыслу тексты дают близкие векторы.
> - **Контекстный эмбеддинг** — вектор чанка, посчитанный с учётом всего документа, а не одного куска.
> - **Late chunking** — родственный приём: сначала кодируется весь документ, потом выход модели нарезается на чанки.
> - **tsvector и веса A/C** — полнотекстовое представление в PostgreSQL; совпадение в поле с весом A весит больше, чем в поле с весом C.
> - **Пулинг оценок** — LLM-судья [[6]](#литература) размечает пары «запрос — документ», собранные из выдач всех сравниваемых прогонов.
> - **judged@10** — доля документов топ-10, у которых есть судейская оценка. Неоценённое метрики считают нерелевантным.
> - **nDCG@10** — качество порядка в первой десятке с учётом степени релевантности; **key-doc R@10 (g3)** — доля ключевых документов (оценка 3 из 0–3) в топ-10; **nR@10** — полнота в топ-10, нормированная на min(10, число релевантных).
> - **п.п.** — процентные пункты. **95%-й бутстреп-интервал [a; b]** — если он не включает ноль, эффект статистически значим.

## Что терял чанкинг по длине {audience="biz arch"}

Исходная схема резала markdown-текст окнами фиксированной длины. На вики-корпусе это давало три системные потери.

- **Хвосты длинных документов.** Документ обрезался на 32 тысячах токенов, остаток не попадал в индекс. В нашем корпусе p99 документа — 78 тысяч токенов, максимум — 309 тысяч.
- **Таблицы.** Окно разрезало строки посередине, а шапка оставалась в первом куске. Строка «3.2 | 01.03.2025 | Уточнены требования…» без шапки не сообщает, что это версия и дата документа.
- **Контекст раздела.** Кусок из середины стандарта не знал, что он из «Каталога типовых решений», раздел «Технические требования». Эмбеддинг и реранкер видели одинаковые куски соседних стандартов и не могли их различить.

## Исходные условия {audience="biz eng"}

К началу работы над чанкингом конвейер уже пережил перф-исправления и первую работу над релевантностью, а данные в индексе оставались прежними.

- **Все документы нарезаны окнами по длине** с обрезкой на 32 тысячах токенов. У чанка не было ни пути раздела, ни терминов, ни второго лексического вектора; реранкер видел только название документа.
- **Контекстная модель эмбеддингов уже стояла,** но клиент резал контекст документа на куски по 64 чанка. Запросы векторизовались обычной моделью и так и остались.
- **Граф знаний** связывал сущности с 70.8% живых чанков; сущности извлекались из первых 16 тысяч символов документа.
- **Корпус был неполным.** У одной из вики API не отдавал тело примерно трети статей через основной эндпоинт, и новые такие статьи в индекс не попадали. После перехода на запасной эндпоинт добавилось около 2 тысяч документов.

| Метрика | До всех работ | Перед перечанкингом | После перечанкинга и графа |
| --- | ---: | ---: | ---: |
| nDCG@10 | 0.542 | 0.756 | 0.783 |
| key-doc R@10 (g3) | 0.439 | 0.647 | 0.712 |
| nR@10 | 0.497 | 0.747 | 0.790 |
| Чанков с сущностями графа | — | 70.8% | 73.3% |
| Связей графа на удалённые чанки | — | 59 461 | 0 |
{caption="Три точки цикла. Метрики качества пересчитаны на одном наборе судейских оценок, 111 запросов"}

«До всех работ» — самый ранний сохранившийся прогон эталонного набора. Между ним и точкой «перед перечанкингом» лежит работа над реранкером и глубиной пула из [первой статьи цикла](/posts/hybrid-search-rag).

## Анатомия структурного чанка {audience="arch eng"}

Структурный чанкер режет документ по его дереву блоков: вводная часть, разделы, списки, блоки кода, таблицы [[3]](#литература). Потолок — 600 токенов на чанк: от размера единицы поиска заметно зависит качество выдачи [[4]](#литература); короткие соседние абзацы склеиваются. Таблица режется по строкам, и каждая строка разворачивается в пары «колонка: значение», так что шапка едет с каждой строкой. В вики-корпусе строки таблиц — почти половина всех чанков.

Главное в чанке — заголовок: название документа, путь в вики, путь раздела и диапазон строк таблицы. Иерархия документа сама по себе источник контекста [[5]](#литература). Он хранится отдельно от тела и подаётся каждому потребителю в своей форме.

```mermaid {caption="Три поля структурного чанка (слева) и три их потребителя (справа). Пример условный"}
flowchart LR
  h["Заголовок чанка<br/>Документ › Раздел › Подраздел ›<br/>Таблица (строки 5–7)"]
  b["Тело чанка<br/>Версия: 3.2; Дата: 01.03.2025;<br/>Описание изменений: …"]
  t["Термины чанка<br/>коды, артикулы, номера стандартов"]
  h --> v["Вектор (контекстная модель)<br/>короткий заголовок + тело<br/>в контексте всех чанков документа"]
  b --> l["Лексика (взвешенный tsvector)<br/>заголовок и термины — вес A<br/>тело — вес C"]
  t --> r["Реранкер<br/>полный заголовок как префикс<br/>+ текст чанка"]
  classDef key stroke-width:3px
  class h key
```

В вектор идёт короткий заголовок без пути в вики: длинный путь одинаков у всех чанков документа и стягивает их векторы друг к другу. Реранкер получает полный заголовок как префикс, и строка таблицы перестаёт быть безымянной. В лексическом индексе заголовок и извлечённые термины весят больше тела (подробнее о втором лексическом векторе — во [второй статье цикла](/posts/postgres-hybrid-indexes)). Из тех же полей строится привязка сущностей графа знаний — о ней ниже.

> [!ARCH]
> Заголовок чанка — это не часть текста, а отдельное поле с разными «проекциями» для разных потребителей. Одно и то же знание о структуре документа по-разному полезно вектору, лексике и реранкеру, и смешивать их в одну строку значит проиграть хотя бы одному из них.

## Контекстные эмбеддинги: контекст легко потерять в клиенте {audience="ml eng"}

Документы мы векторизуем контекстной моделью эмбеддингов внешнего провайдера (4B параметров, 2000 измерений, int8). Ей передаётся не отдельный чанк, а документ как массив чанков. Каждый вектор считается с учётом всего документа, поэтому кусок «см. таблицу ниже» всё равно оказывается рядом со своим документом. Запросы векторизуются обычной моделью того же семейства: у запроса нет документа, из которого брать контекст.

Идея близка к late chunking [[1]](#литература) и Contextual Document Embeddings [[2]](#литература): оба подхода переносят контекст документа в вектор чанка без дополнительной LLM-генерации. Этим они отличаются от схемы, где к каждому чанку дописывается сгенерированная справка.

### Лимиты API {audience="eng"}

| Ограничение | Значение |
| --- | ---: |
| Документов в запросе | до 512 |
| Чанков в запросе | до 16 000 |
| Токенов на документ | до 32 000 |
| Токенов на запрос | до 120 000 |
| Пропускная способность, чанков/с | 415 / 835 / 1 670 по уровням аккаунта |
{caption="Лимиты API контекстной модели (по документации провайдера)"}

Документ длиннее 32 тысяч токенов приходится резать на окна, и каждое окно кодируется как отдельный документ. Мы считаем токены своим токенизатором, а не токенизатором провайдера, поэтому держим запас 5% и при отказе «слишком длинно» делим окно пополам.

### Артефакт: контекст резал клиент {audience="eng ml"}

При разборе пропускной способности нашлась неочевидная потеря. Клиент делил чанки одного документа на пачки по 64 и отправлял каждую отдельным запросом. Для провайдера это были разные документы. У документа из 215 чанков (наш p99) четвёртый кусок кодировался, не видя первых трёх. Это касалось примерно 5% документов, и именно самых длинных, где контекст нужен больше всего.

Мы подняли пачку до 256 чанков. Теперь документ режется только по лимиту в 32 тысячи токенов, который не обойти. На 512 запросы у нас работали нестабильно, хотя документация это допускает.

> [!ENG] Проверьте у себя
>
> - У контекстной модели размер пачки — не параметр производительности, а параметр качества: он задаёт границу контекста. Убедитесь, что клиент не дробит документ на независимые запросы.
> - Если токены считаются не токенизатором провайдера, держите запас и обрабатывайте отказ «слишком длинно» делением окна, а не падением пачки.

## Качество: что измерено, а что нет {audience="ml biz"}

Измерен суммарный эффект структурного чанкинга с контекстными эмбеддингами, вклад каждого по отдельности — нет. Контекстная модель стояла и до, и после; поменялось то, что в неё подаётся.

| Замер, 111 запросов | Δ nDCG@10, п.п. | Δ key-doc R@10, п.п. |
| --- | ---: | ---: |
| Пилот: перечанковано подмножество, ~5 тыс. документов | +1.5 [−0.4; +3.5] | +2.6 [−0.8; +6.0] |
| Весь корпус + перелинковка графа | +2.7 [+0.4; +5.1] | +6.5 [+2.6; +10.6] |
{caption="Эффект структурного чанкинга, в скобках 95%-й бутстреп-интервал"}

Пилот дал направление, но не значимость: перечанкована была лишь часть документов, релевантных эталонным запросам. Полный прогон подтвердил эффект. MRR@10 при этом не изменился (~0.97): первый правильный документ система находила и раньше. Растёт полнота: в топ чаще попадают нужные части длинных документов и строки таблиц.

### Ловушка неоценённых документов {audience="ml"}

Первое сравнение после пилота показало просадку nDCG@10 на 4.8 п.п. Причина — в оценке, а не в поиске. Новая нарезка привела в топ-10 документы, которых не было в пуле оценок, и judged@10 упал с 0.99 до 0.87. Метрики считают неоценённое нерелевантным, поэтому любое изменение, которое находит «другие» документы, систематически выглядит хуже. После дооценки 2 126 новых пар тем же LLM-судьёй [[6]](#литература) [[7]](#литература) просадка превратилась в +1.5 п.п.

> [!ML]
> Правило, которое мы из этого вынесли: сравнение недействительно, пока judged@10 ниже 0.9 у любого из прогонов. Инструмент сравнения сам предупреждает об этом, а дооценка идёт с кэшем судейских оценок, так что старые пары не переоцениваются. Чем сильнее изменение меняет состав выдачи, тем выше риск принять неполноту разметки за регресс.

### Чего мы не знаем {audience="ml arch"}

- **Вклад контекстной модели против обычной на тех же чанках.** Чистый эксперимент — вторая колонка векторов от обычной модели и прогон на одних и тех же оценках.
- **Эффект исправления пачки 64 → 256.** Затронуты только ~5% длинных документов, и на 111 запросах такой эффект не различим.
- **Разложение по классам запросов.** По 22–23 запроса на класс дают интервалы шире самих эффектов.

## Граф знаний поверх новых чанков {audience="arch eng"}

Смена чанкинга ломает граф знаний тихо: сущности привязаны к чанкам, и после перенарезки часть связей указывает на чанки, которых больше нет. У нас таких связей набралось 59 тысяч. Обычная чистка их не видела: она удаляла связи чужих версий документа, а перенарезка переписывала ту же версию.

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

Итог перелинковки тех же ~30 тысяч документов: связей на удалённые чанки — 0, доля живых чанков, связанных хотя бы с одной сущностью, выросла с 70.8% до 73.3%. В графе нет узлов-чанков и текста: связь — это свойство упоминания сущности, а текст остаётся в реляционной БД. Почему так — в [четвёртой статье цикла](/posts/knowledge-graph-rag).

> [!ARCH]
> «Сначала записать новое, потом удалить всё чужое» — общий приём для любых производных проекций (граф, поисковый индекс, кэш). Читатель никогда не видит пустого состояния, а повторный прогон идемпотентен.

### Потолок LLM-извлечения {audience="ml"}

Почему не больше 73%? Сущности извлекает LLM из первых 16 тысяч символов документа, и чанки дальних разделов остаются без своих сущностей. Мы проверили очевидное решение — дать модели 49 тысяч символов — и оно не сработало. Модель возвращает 10–35 сущностей независимо от длины текста: втрое больше текста даёт те же сущности, размазанные тоньше. Покрытие на 10 пробных длинных документах выросло на 1 п.п., а у части документов сущностей стало меньше.

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

## Переиндексация корпуса на живом проде {audience="eng"}

Перечанковка ~30 тысяч документов заняла около часа вместо ожидавшихся шести. Узкое место было не в нашем коде и не в базе, а в ожидании внешнего API эмбеддингов.

- **Переиндексация идёт внутри текущей версии документа, одна транзакция на документ.** Старый набор чанков выключается, новый включается атомарно. Инвариант «у версии ровно один живой набор» проверяется после каждого прогона и еженочным сторожевым заданием.
- **Один документ за запрос — это задержка, а не лимит.** При 8 одновременных запросах по ~4.5 с выходило около 36 чанков в секунду, а лимит аккаунта — от 415. Шесть параллельных процессов по непересекающимся спискам документов дали ~690 документов в минуту без единой ошибки лимита.
- **Одна проблемная страница не должна ронять пачку.** Страница с ~500 вложенными inline-тегами вызывала переполнение рекурсии в рендере и теряла все 100 документов пачки. Обход стал итеративным, а ошибка рендера изолируется до одной страницы.
- **Ревизии источника меняют формат.** Перечанкинг пропускает документ, если его ревизия в источнике изменилась, — пусть его заберёт обычная синхронизация. Одна из вики сменила формат ревизии с ISO-даты на epoch, и ~940 статей навсегда считались «изменившимися», а синхронизация их не трогала. Понадобился явный операторский флаг, при этом проверка ревизии на момент записи осталась.
- **Старые чанки держатся до проверки качества и перелинковки графа, но не дольше.** Пока они лежат в таблице, лексический скан медленнее: p50 вырос со 175 до 252 мс (+44%) (подробности — во [второй статье цикла](/posts/postgres-hybrid-indexes)).

> [!WARNING] Известные недоработки чанкера
> Жирный абзац вместо заголовка не всегда распознаётся как раздел, оглавление страницы иногда становится отдельным чанком, 0.16% чанков — одиночные строки таблиц длиннее лимита в 600 токенов. В худшем случае такой чанк ведёт себя как старый чанк по длине, только с заголовком.

## Литература {audience="ml" .references}

1. Günther M. et al. [Late Chunking: Contextual Chunk Embeddings Using Long-Context Embedding Models](https://arxiv.org/abs/2409.04701). arXiv:2409.04701, 2024.
2. Morris J. X., Rush A. M. [Contextual Document Embeddings](https://arxiv.org/abs/2410.02525). arXiv:2410.02525, 2024.
3. Jimeno Yepes A. et al. [Financial Report Chunking for Effective Retrieval Augmented Generation](https://arxiv.org/abs/2402.05131). arXiv:2402.05131, 2024 — чанкинг по элементам структуры документа вместо окон.
4. Chen T. et al. [Dense X Retrieval: What Retrieval Granularity Should We Use?](https://arxiv.org/abs/2312.06648). arXiv:2312.06648, 2023 — влияние гранулярности единицы поиска.
5. Sarthi P. et al. [RAPTOR: Recursive Abstractive Processing for Tree-Organized Retrieval](https://arxiv.org/abs/2401.18059). arXiv:2401.18059, 2024 — иерархия документа как источник контекста.
6. Upadhyay S. et al. [UMBRELA: UMbrela is the (Open-Source Reproduction of the) Bing RELevance Assessor](https://arxiv.org/abs/2406.06519). arXiv:2406.06519, 2024 — шкала 0–3 и промпт LLM-судьи.
7. Thomas P. et al. [Large language models can accurately predict searcher preferences](https://arxiv.org/abs/2309.10621). arXiv:2309.10621, 2023.

Идентификаторы arXiv сверены с API arXiv 25 октября 2026.

