Files
RAG_helper/docs/CHUNKER_v2_TZ.md
T
AR 15 M4 4aac59313d feat(sprint8.5+8.6): чанкер v2 (иерархия H1/H2/H3) + регрессия 4 веток в UI
Sprint 8.5 — чанкер v2 (services/document_processor.py):
- markdown-it-py для md-входа: каждый H2 открывает свою секцию, H3 идёт в тело
- множественные H1 — штатный кейс (new_booking.md = 8 H1, шаги воронки + группы);
  H1 без H2 → секция heading=H1; преамбула H1 (тело до первого H2) игнорируется
- YAML frontmatter (--- ... ---) отрезается, в индекс не попадает
- breadcrumb «## {H2}» как первая строка каждого subchunk'а
- merge коротких хвостов и sentence-overlap — только внутри одной H2-секции
- excluded_section_headings в config.py
- 17 unit-тестов на stdlib unittest (tests/test_document_processor_v2.py),
  включая smoke по реальным general_info.md (тимпанометрия → правильная секция)
  и new_booking.md (защита от регрессии множественных H1)
- ТЗ: docs/CHUNKER_v2_TZ.md

Sprint 8.6 — регрессия остальных 4 веток (static/regression.html):
- 4 опции в селекторе режима: branch:price_question (40 кейсов),
  branch:medical_question (29), branch:escalate_human (14), branch:reschedule (16)
- бэкенд из 8b уже параметрический — правок в сервисе не потребовалось
- new_booking вне скоупа — state-machine, под него отдельный 8c (multi-turn)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:59:12 +05:00

18 KiB
Raw Blame History

ТЗ: чанкер v2 для wiki-датасетов

Автор: Аксей · Дата: 2026-05-03 · Контекст: фейл регрессии по тимпанометрии (eval branch_cases_general_info.jsonl) — expected_doc_section: "Направления приёма" не находится, вместо неё в retrieved топ-5 попадают мета-секции и обрывки из других секций.

Это ТЗ — что должен делать новый services/document_processor.py (точечно: parse_text + chunk_sections), чтобы чанкинг markdown-датасетов (data/datasets/*.md) был предсказуемым и совместимым с eval-контрактом «section в metadata == заголовок H2 раздела».

Что сейчас не так (наблюдения на текущем коде)

Сейчас parse_text режет markdown по любому #-заголовку без учёта иерархии и применяет несколько эвристик-заголовков, которые в наших данных дают false-positives. chunk_sections затем мерджит короткие чанки и подмешивает overlap из соседнего чанка независимо от того, из какой секции этот сосед. Конкретно:

  1. YAML frontmatter попадает в индекс. Парсер не знает про --- ... --- в начале файла. Frontmatter становится body первой «псевдо-секции» с heading="", режется на 1–2 чанка по 1.0–1.5 кб, ложится в Chroma с пустым section. Запросы пациентов с похожей лексикой («список источников», «Кузнецова», даты) могут это поднимать в топ. Воспроизведено на data/datasets/general_info.md — два мусорных чанка section="" длиной 1116 и 1488.
  2. Нумерованные элементы списков парсятся как заголовки. numbered_heading_re = ^(\d+(?:\.\d+)*\.?)\s+([А-ЯЁA-Z].*) ловит «1. Лично — в клинике…», «3. Клиника отправляет справку…». Как итог — section в metadata получает в качестве заголовка тело пункта списка, и весь раздел «Справки для налогового вычета» развалился на 4 несвязанных чанка с разными section'ами, в один из которых случайно подсосался хвост следующего раздела (см. ниже).
  3. ### подсекции парсятся как самостоятельные секции наравне с ## разделами. Это ломает инвариант eval-кейсов «section == заголовок логического раздела». Например, ### Аллергологи-иммунологи (под ## Список врачей по специальностям) становится отдельной секцией, и кейс с expected_doc_section: "Список врачей по специальностям" его не примет.
  4. Merge коротких чанков склеивает соседей через границы H2. Если очередной чанк короче min_chunk_size, к нему доклеивается следующий, при этом section берётся от первого. Реальный кейс: «Отоневролог» (1 строка тела) склеилась с «Сурдоакустик», «Анестезиолог», «Что взять с собой на приём» → один чанк длиной ~900 символов с section="Отоневролог". Содержательно мусор; для eval-кейса «что взять с собой на приём» секция не найдётся.
  5. Overlap подмешивает текст соседнего чанка без учёта секции. В final цикле берётся хвост merged[i-1] и приклеивается к chunk.text. После этого внутри чанка с section="3. Клиника отправляет…" оказывается тело раздела «Направления приёма» (со словом «тимпанометрия»). С точки зрения eval section не совпадает, с точки зрения retrieval эмбеддинг растащен по двум темам.
  6. Мета-секции уровня H1 индексируются наравне с контентом. В general_info.md после раздела «Юридические реквизиты» шёл второй H1 «Анализ материалов вики и предложения по дополнению» — рабочие пометки редактора. Парсер не знает, что после второго H1 идёт нерелевантная пациенту служебка, и индексирует её как обычный текст. (Сейчас этот раздел перенесён в docs/wiki_meta_general_info.md вручную, но защита нужна на уровне парсера — иначе следующий редактор снова положит редакторские заметки прямо в датасет.)
  7. Заголовок H2 не дублируется внутрь тела чанка. В chunk_sections есть heading_prefix = f"{section.heading}\n\n", то есть заголовок попадает в текст. Это правильно для эмбеддинга, но если секция режется на N чанков, заголовок есть только в первом — последующие части теряют контекст. Для секций > max_chunk_size это критично: e5 видит «- Тимпанометрия — да, делаем…» без префикса «Направления приёма» и эмбеддит только лексику конкретной строки.

Что должно быть в v2

Цели в порядке важности: (1) metadata.section в чанке всегда равен заголовку H2-раздела, к которому чанк относится; (2) ничего нерелевантного из frontmatter и из мета-блоков в индекс не попадает; (3) overlap и merge работают только в пределах одной H2-секции.

1. Парсинг markdown — иерархия H1/H2/H3

Для .md входа использовать строгий markdown-парсер вместо набора регэкспов: mistletoe, markdown-it-py, или взять commonmark-py. На выходе нужно дерево с типами Heading(level, text) и Paragraph/List/.... Регэкспные эвристики на нумерованные списки, FAQ-паттерн «В:/Вопрос:» и ALL-CAPS строки в .md режиме отключить — они нужны были для txt-выгрузок, в md-датасете создают только мусор. Для .txt входа эвристики оставить как есть, в отдельной ветке.

Логика обхода:

  • Каждый H1 трактуется как «корневая секция» документа. Тело самого H1 — обычно пустое или служебное.
  • Внутри одного H1 каждый H2 открывает новую секцию, её heading = текст H2. Следующий H2 (или следующий H1) закрывает текущую.
  • H3 и ниже не открывают новой секции. Заголовок H3 уходит в тело текущей H2-секции в виде строки ### {текст} (или **{текст}**, как удобнее для эмбеддинга).
  • Когда H2-секции внутри H1 нет (документ сразу пишет body под H1) — body H1 идёт в одну секцию с heading = текст H1.

Множественные H1 поддерживаются как штатный кейс — у new_booking.md 8 H1 (шаги воронки intro / qualify / book / close плюс «Особенности по специальностям», «Особые сценарии», «Технические подсказки»). Это не баг, а структура датасета. Старая идея «всё после второго H1 — служебка» отозвана: для отделения мета-материала используется отдельный механизм — см. ниже про data/datasets/ vs docs/wiki_meta_*.md.

Конвенция вместо правила «второй H1 = служебка»: служебные/редакторские блоки (что нужно дополнить, что не должно попадать в датасет, источники, история правок) держим в отдельном файле docs/wiki_meta_<branch>.md, не в data/datasets/. Это уже сделано вручную для всех веток (general_info, price_question, medical_question, escalate_human, reschedule, new_booking) — парсеру не нужно их различать, он просто индексирует то, что физически лежит в data/datasets/.

2. YAML frontmatter

Если файл начинается со строки --- (без BOM/whitespace до неё), всё до следующей строки --- — frontmatter. Распарсить как YAML (pyyaml), сохранить в отдельное поле document_metadata (отдельная сущность, в Chroma не пишется), из текста удалить полностью. Это даст возможность Аксею держать в начале файла intent, sources, update_log без риска утечки в индекс.

3. Чанкинг внутри H2-секции

Для каждой H2-секции:

  • Если её тело (вместе с заголовком-префиксом) ≤ max_chunk_size — один чанк целиком. section = heading H2, text = "{H2}\n\n{body}".
  • Если > max_chunk_size — резать по абзацам (split по \n\n, не по \n!). Это уже само по себе закроет проблему текущего split по одному \n, который рвал bullet-списки.
  • В каждый получившийся subchunk обязательно добавлять breadcrumb-префикс: первая строка чанка = ## {heading H2} (буквально с двумя #, чтобы эмбеддинг видел иерархию). Это решает п. 7 — заголовок есть в каждом куске.
  • section во всех subchunk'ах одинаков и равен heading H2.

4. Merge коротких хвостов — только внутри одной H2-секции

Текущее правило «если предыдущий чанк короче min_chunk_size, к нему дописать следующий» применять только когда оба чанка из одной H2-секции. Через границу H2 склеивать запрещено. Если итоговый чанк секции получился короче min_chunk_size и склеить его не с чем — оставить как есть, не трогать.

5. Overlap — только внутри H2-секции

Sentence-overlap (overlap_sentences=2) применять только между subchunk'ами одной H2-секции. Между чанками разных секций overlap'а не должно быть — он размывает эмбеддинг и провоцирует ложные совпадения.

6. Чёрный список заголовков (опционально, на будущее)

Добавить в config.py поле excluded_section_headings: list[str] (по умолчанию пусто). Если heading H2 из этого списка — секция не индексируется. Это пригодится, когда подключим живую вики и нельзя будет вручную чистить файлы.

Что не делаем в этой итерации

  • Не трогаем embeddings, не вводим reranker, не делаем гибридный retrieval. Это отдельный спринт. v2 — про предсказуемость нарезки.
  • Не вводим smart-summarization чанков (HyDE и т.п.).
  • Не меняем формат хранения в Chroma — metadata.section остаётся строкой.

Точки в коде

Все правки локализуются в services/document_processor.py. Изменения по функциям:

  • parse_text(file_bytes, is_markdown) — для is_markdown=True переписать на markdown-it-py (или mistletoe). Вернуть (frontmatter_dict, list[ParsedSection]). Для is_markdown=False оставить старую реализацию.
  • chunk_sections(sections) — переписать резку по абзацам, добавить breadcrumb-префикс, ограничить merge и overlap пределами секции.
  • process_document(...) — учесть, что parse_text теперь возвращает frontmatter; пробросить его как side-output, в текущем pipeline можно просто игнорировать (Chroma и так его не получает).
  • parse_pdf / parse_docx — пока не трогаем; если в них всплывут аналогичные проблемы — отдельной итерацией.

Тест-план

В eval/branch_cases_general_info.jsonl уже есть нужные кейсы. После v2 должны позеленеть как минимум:

  • Добрый день, вы делаете тимпанометрию?section="Направления приёма", текст содержит «тимпанометр».
  • Делают ли в клинике КТ височных костей? (если контент будет добавлен в датасет — это уже задача Натальи).
  • Какой график работы у Терво С.О.?section="Список врачей по специальностям" (после v2 H3 не отделится в свою секцию).
  • Созонова Людмила Альбертовна работает у Вас? — то же самое (eval-кейс уже считает её частью раздела «Список врачей»).

Юнит-тесты для парсера/чанкера сейчас в репо отсутствуют (в eval/ только evaluation cases). Нужно завести tests/test_document_processor_v2.py:

  • general_info.md → ровно столько чанков, сколько H2 в файле (плюс split на длинных секциях); все section непустые; нет ни одного чанка с section, начинающимся на цифру; section="Направления приёма" встречается ровно у тех чанков, что содержат «тимпанометр», «эндоскоп», «спирограф».
  • Файл с frontmatter → frontmatter не в выходных чанках; первый чанк начинается с ## {первый H2}.
  • Файл со вторым H1 → всё после второго H1 отсутствует в чанках; в логе есть WARN.
  • H3 внутри H2 → один чанк с section H2, в теле строка ### {H3}.
  • Длинная H2-секция → N subchunk'ов, все с одинаковым section, в каждом первая строка ## {H2}.

Связанная работа в данных

В data/datasets/general_info.md уже сделаны точечные обходные правки под текущий парсер: вынесен YAML-frontmatter, нумерованный список «Способы получения справки» переведён на маркированный, мета-блок «Анализ материалов вики» вынесен в docs/wiki_meta_general_info.md, раздел «Направления приёма» расширен FAQ-формулировками («Тимпанометрия — да, делаем» и т.п.). Эти правки нужны были, чтобы регрессия не падала прямо сейчас. После v2 чанкера нумерованные списки и frontmatter будут безопасны — но обратно нумерацию возвращать не обязательно: bullet-список здесь не хуже по смыслу.