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