Как я обучил русский RAG‑сплиттер, который режет документы по индексам, а не по тексту

от автора

TL;DR. Из интереса обучил собственный русский RAG‑сплиттер — захотелось проверить, можно ли сделать context‑aware‑нарезку русских документов лучше готовых чанкеров.

Я взял идею датской context-aware-splitter, пересобрал её под русский на базе T-lite-it-2.1 и изменил главное: модель возвращает индексы границ, а не переписанный текст. Хост потом режет оригинал по этим индексам.

У index‑output оказалось три практических плюса:

Свойство

Что получается

Lossless‑нарезка

чанки совпадают с исходником байт‑в-байт

Дешёвый вывод

около 35–40 токенов JSON вместо переписывания документа

Целые таблицы

таблица режется как атомарный юнит, если upstream‑парсер выделил её корректно

Обучение: bf16-LoRA через Unsloth на RTX 5090, Blackwell. По моим записям: около 3.5 часа, пик 25.4 ГБ VRAM, 2122 шага за 2 эпохи. Разметку задистиллировал от self‑hosted DeepSeek‑V4-Flash.

Деплой: GGUF Q5_K_M, около 5.9 ГБ, AMD Strix Halo, llama.cpp Vulkan, без CUDA. На живом AMD‑стенде, на тестовом документе из 9 юнитов, получилось около 1.2 секунды на документ при 40 ток/с генерации и 947 ток/с prompt eval.

Ограничение метрик. boundary‑F1 @±1 = 0.821 — это teacher‑agreement с метками учителя, а не качество RAG. Downstream по hit‑rate / faithfulness я пока не делал.

По жанру это инженерный разбор. Где код, README, planning‑файлы и живые замеры расходятся, я показываю расхождение.

Зачем я вообще это затеял

У меня self‑hosted AI‑стек: Dify, milvus/qdrant, docling, локальные LLM на AMD. RAG на нём работал нормально. BGE‑M3 dense+sparse плюс русский реранкер уже давали приемлемое качество.

Но дефолтный чанкинг русских документов — техдоков, спецификаций, прайсов, таблиц — выглядел грубовато. Я хотел проверить, можно ли выжать больше, если обучить сплиттер под задачу, язык и рантайм.

Быстро всплыли две проблемы, на которых готовые чанкеры на русском регулярно спотыкаются.

Русский текст дороже в токенах, чем кажется

Многие чанкеры и модели опираются на токенайзеры, где русский — побочный язык. Я не стал верить документации и перемерил на живых токенайзерах.

Тест: один русский абзац, 42 слова / 354 символа, add_special_tokens=False.

токенайзер

токенов

ток/слово

vs T‑lite

T‑lite‑it-2.1

73

1.74

Qwen2.5–7B

114

2.71

×1.56

Llama-2-7b

133

3.17

×1.82

В моих research‑заметках по реальной прозе фигурировала более мягкая оценка для Llama-2: около ×1.5 раздувания. Режимы измерения разные: там — раздувание Llama-2 относительно английского на корпусной средней, здесь — Llama-2 против T‑lite на одном конкретном абзаце. Поэтому я не подаю 3.17 как универсальный множитель для всего русского текста. Локальный sanity‑check, но порядок проблемы он показывает.

Для выбора базы хватило этого: на моём замере T‑lite-2.1 кодирует русский заметно компактнее Llama-2 и ванильного Qwen2.5.

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

Таблицы ломают семантические сплиттеры

Семантический сплиттер режет прозу по cosine‑сдвигу темы между соседними предложениями. Где эмбеддинги расходятся, там граница.

Для прозы это работает. Но у таблицы нет сдвига темы между строками. Соседние строки часто почти одинаковы в эмбеддинг‑пространстве. Порог не получает нормального сигнала, режет произвольно и часто отрывает шапку от тела.

А таблица без хедера в RAG почти бесполезна: «42» без «выручка, Q3, регион Урал» — просто шум.

Почему я не взял готовое

Отправной точкой стала датская mhenrichsen/context‑aware‑splitter-1b. Она делает context‑aware‑чанкинг, и сначала я хотел просто адаптировать её под русский.

Не вышло. У проекта оказались два дисквалификатора.

Первый — токенайзер Llama-2. Базовая модель -1b собрана на TinyLlama, а тот переиспользует токенайзер Llama-2, так что атрибуция корректная. Для кириллицы он дороже T‑lite на моём тестовом абзаце: 3.17 ток/слово против 1.74.

Второй риск серьёзнее: модель выдаёт полный текст каждого чанка.

Для RAG это скрытая проблема. Когда модель переписывает текст, она может «починить» его по дороге: заменить ё на е, кавычки‑ёлочки на прямые, нормализовать пробелы. После этого чанк уже не совпадает побайтно с исходником.

А цитирование в RAG держится именно на совпадении. Вы показываете пользователю источник, подсвечиваете фрагмент, считаете оффсеты. Байт‑рассинхрон ломает всё это тихо.

И ещё: переписывать весь документ дорого. По methodology проекта — примерно в 10 раз медленнее и дороже по токенам вывода, чем вернуть короткий список индексов.

От датского проекта я взял только идею context‑aware‑границ. Реализацию пришлось пересобрать.

Ключевая идея: выводить индексы, а не текст

Архитектура сводится к одному правилу:

Модель возвращает индексы границ, а хост режет оригинал.

Схема инференса:

документ  -> [хост]  деление на нумерованные юниты ([1]..[N])  -> [модель] {"splits":[i,...],"topic":"..."}  -> [хост]  нарезка ОРИГИНАЛА по индексам  -> чанки

На вход модели идут нумерованные юниты. Проза разбивается на предложения через razdel. Таблицы и блоки кода становятся едиными атомарными юнитами. Markdown‑заголовки тоже выделяются отдельно.

Сырой документ как поток текста модель не видит. Она видит список и должна вернуть номера юнитов, после которых проходит граница чанка, плюс короткий topic.

{"splits": [3, 7], "topic": "о чём документ"}

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

В demo.py стоит проверка 0 < int(x) < len(units), которая отбрасывает out‑of‑range значения. В serving/eval‑пути JSON вытаскивается non‑greedy‑регуляркой вида \{.*?\}. В demo есть более грубый greedy‑вариант, но это не основной serving‑путь.

Свойство

Index‑output

Text‑output

Совпадение с исходником

байт‑в-байт, потому что текст режет хост

риск рассинхрона при переписывании

Стоимость вывода

около 35–40 токенов JSON

переписывание всего документа

Таблицы

не режутся внутри юнита

зависят от поведения модели

Lossless получается потому, что модель физически не трогает текст. Она оперирует числами.

Дешевизна получается потому, что вместо N тысяч токенов документа модель генерирует короткий JSON. На живом замере было 35 токенов вывода.

Целостность таблиц модель не «выучивала» — это инвариант конструкции: таблица — один атомарный юнит, граница может пройти до неё или после неё, но не внутри.

Гарантия начинается после того, как upstream‑парсер корректно выделил таблицу как блок. Если парсер сам развалил таблицу на мусор, LLM‑сплиттер не восстановит структуру из воздуха.

Та же логика лежит за response‑only loss при обучении: полезный сигнал — короткий JSON‑ответ, а не воспроизведение большого входа.

Базовая модель: не самая новая, а та, что проходит фильтры

Базу я выбирал как пересечение четырёх требований.

Требование

Что отсекает

llama.cpp Vulkan на AMD, без CUDA

SSM/linear‑attention‑гибриды без Vulkan‑ядер (например, Granite-4)

Кириллически‑эффективный токенайзер

Llama-2 и часть ванильных токенайзеров

Apache-2.0 / commercial‑OK

модели с NC‑ или ограничительными лицензиями

Официальный GGUF

риск сломать round‑trip кастомного токенайзера

Первым фильтром был рантайм. Целевое железо инференса: AMD Strix Halo, gfx1151, llama.cpp Vulkan, без CUDA. Новые архитектуры могут быть хорошими моделями, но если под них нет Vulkan‑ядер в нужном стеке, они не подходят.

Правило получилось жёстким: новее не значит лучше, если архитектура ломает рантайм.

Среди dense + Vulkan‑safe + кириллица + Apache моделей самым практичным вариантом оказалась t‑tech/T‑lite‑it-2.1. Её я и взял.

Fallback‑кандидатом оставался RefalMachine/RuadaptQwen3-8B-Hybrid: хороший кириллический fertility, Apache, большой словарь. Но GGUF round‑trip требовал отдельной проверки. У T‑lite-2.1 уже был официальный GGUF, а значит, путь через llama.cpp у вендора уже проходил.

Где план разошёлся с итогом

В исходном билд‑плане табличным лидером был RefalMachine/RuadaptQwen3-8B-Hybrid:

Модель из плана

Роль

Лицензия

Словарь

Контекст

Кириллический fertility

RuadaptQwen3-8B‑Hybrid

рекомендованная база

Apache-2.0

146 260

40 960

~1.6 tok/word

В прозе плана также фигурировал fallback RuadaptQwen2.5-7B-Lite-Beta со словарём 145 152 и более зрелым llama.cpp‑путём. А про t-tech/T-lite там было прямо написано: «не брать», потому что на тот момент он считался моделью со стоковым токенайзером и нулевым выигрышем fertility.

Предположение устарело. В релизной версии T‑lite‑it-2.1 словарь оказался переработан под кириллицу — я подтвердил это замером.

Токенайзер / модель

Статус

Fertility

RuadaptQwen3-8B‑Hybrid

плановый лидер

~1.6 tok/word (из research)

Qwen2.5–7B

живой замер ванильного Qwen

2.71 tok/word

Qwen2.5–7B

плановая оценка

~2.6 tok/word

T‑lite‑it-2.1

финальный выбор, живой замер

1.74 tok/word

Llama-2-7B tokenizer

живой замер на 42-словном абзаце

3.17 tok/word

План устарел между написанием и реализацией — поэтому я и проверяю токенайзер измерением, а не названием модели.

Ещё одна поправка: исходная формулировка «Llama-2 слепа к кириллице» неверна. У Llama-2 есть byte‑fallback. Она видит русский, просто кодирует его дороже. На моём коротком тесте — ×1.82 к T‑lite. На корпусной прозе из research‑заметок — ближе к ×1.5. Речь про цену, а не про слепоту.

Данные: дистилляция от учителя вместо ручной разметки

Ручная разметка границ на десятках тысяч документов не подходила. Я задистиллировал разметку от модели‑учителя.

Учитель — DeepSeek-V4-Flash, self‑hosted на кластере, узел Spark, через OpenAI‑совместимый эндпоинт:

# data/generate.pyDS_URL = "http://192.168.1.45:8000/v1"MODEL  = "deepseek-v4-flash-spark"SCHEMA = {    "type": "object",    "properties": {        "splits": {"type": "array", "items": {"type": "integer"}},        "topic":  {"type": "string"},    },    "required": ["splits", "topic"],}

Учитель вызывается с guided_json=SCHEMA. При grammar‑constrained decoding декодер не может выйти за схему, поэтому структура JSON гарантируется конструкцией, а не дисциплиной модели.

Здесь нужно развести два этапа. Grammar‑констрейнт есть только у учителя при генерации данных. На инференсе обученной модели его нет — там хост вытаскивает JSON из свободного вывода.

Дистилляция конкурентная: ThreadPoolExecutor на 24 воркера, durable incremental output в _raw.jsonl с flush, overshoot цели в 1.4x для компенсации реджектов гейтами, фиксированные сиды 7 и 13 для воспроизводимости.

Температура: README говорит одно, код делает другое

README заявляет temperature=0. В коде функция label() имеет дефолт temp=0.2 и вызывается без переопределения.

Фактически разметка шла на 0.2, а не на 0.

Я не доказывал, что это не вредит. В реализованном пайплайне компенсация такая: жёсткие гейты, точный дедуп, до 3 ретраев на пример. Голосования по нескольким сэмплам нет — self‑consistency была плановой идеей, но в v1-пайплайн не попала (раннее упоминание в доках репозитория я после сверки поправил).

Корректная формулировка простая: плохие метки гейты отбрасывают, но temp=0.2 я не маскирую и не оправдываю задним числом.

Формат данных — Alpaca JSONL: instruction / input / outputinput — документ, разбитый на нумерованные юниты; output — JSON с границами. splits — 1-индексные номера юнитов, после которых проходит граница; topic — одно предложение‑саммари.

Юниты выделяет сегментатор: проза → предложения через razdel.sentenize; таблицы и блоки кода → единые атомарные юниты; markdown‑заголовки (#..######) → отдельные юниты. Типы: sentcodetablehead.

Фильтры и гейты качества

Сначала входные фильтры документа (в iter_docs, до вызова учителя): доля кириллицы > 0.6; длина сырого текста 800–7000 символов; число юнитов 5–120.

Потом гейты выхода учителя (в gate()): что не прошло, выбрасывается.

# data/generate.py — фрагменты gate()if not (1 <= len(sp) <= 60):            # число границ 1..60    return Noneif b != len(units) and ntab == 0 and wc < 8:   # не-последний прозовый чанк < 8 слов    return None

Гейт выхода

Порог

Валидный JSON

обязателен

Число границ

1–60

Не‑последний прозовый чанк

≥ 8 слов

Длина topic

≤ 200 символов, ≥ 5 кириллических букв

Валидность индексов

1 <= x < nunits

Дедуп

точный MD5 по первым 500 символам

Раньше в документации проскакивала формулировка near‑dup, но в кодовом пути генерации — точный MD5, а не fuzzy‑дедуп. Поэтому в статье я называю только то, что реально делает код.

Chunk‑sanity по словам считается только по юнитам типа sent. Чанк с таблицей или кодом не реджектится из‑за короткости. Иначе пайплайн сам выбрасывал бы валидные атомарные таблицы.

Корпус‑микс

Источник

Роль

Лицензия

Итоговая доля

Входной вес генерации

deepvk/cultura_ru_edu

web/edu проза

Apache-2.0

~47%

0.45

IlyaGusev/habr

техтекст + блоки кода

unspecified, training‑only

~34%

0.30

синтетика таблиц/кода

гарантия атомарности

сгенерировано

~19%

0.25

README показывает наблюдаемый состав после гейтов: 47/34/19%. train_v1.log подтверждает тот же порядок: cultura 8775, habr 6221, synthetic 3469.

В коде iter_docs стоят входные веса генерации: 0.45/0.30/0.25.

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

Есть ещё одна нестыковка. В docstring generate.py корпус описан как cultura_ru_edu + habr_qna(CC0) + habr-tables + synthetic. Реально код грузит deepvk/cultura_ru_edu и IlyaGusev/habr, не habr_qnahabr_qna/CC0 остался в комментарии и research‑файле как планируемый источник, который в итог не попал.

В .planning лежит более широкий планируемый микс: fineweb-2, cultura, habr_qna, MOT, DocAtlas‑RU, habr‑tables, синтетика, wikipedia, taiga. Он не совпадает с реализованным.

Для статьи про реальный датасет источник истины — README, generate.py и train_v1.log. Research — это обзор и план, не факт реализации.

Синтетика

Синтетика нужна для одной цели: научить модель держать таблицы и код целиком.

В ней 12 доменов таблиц: продажи по регионам, прайс‑лист, спецификация оборудования, метрики мониторинга, складские остатки, курсы валют и похожие структуры. Плюс code‑документы: около 22% синтетических документов, 6 типов блоков — python, bash, sql, json, yaml, python‑class. Multi‑table документы появляются с вероятностью 0.45.

Так модель регулярно видит структуры, которых в реальных корпусах мало и которые там оформлены нерегулярно.

Размеры

Артефакт

Размер

Основной train

~17k примеров

Synthetic‑only top‑up

~12k примеров (10.8k train + 1.2k holdout)

Сэмпл в репо, train

600

Сэмпл в репо, holdout

120

Holdout‑кап в коде

max(1, min(len/10, 1500))

Synthetic‑only top‑up включается env‑переменной SYNTH_ONLY. Тогда генерится только синтетика с весом 1.0, без загрузки корпусов.

Полные сеты лежат на HuggingFace. В git они слишком велики.

Про лицензии

Почему я вообще допустил training‑only источник с неуказанной лицензией?

Моя рабочая трактовка — не юридическое заключение — такая: обученные веса учат политику разметки индексов границ, а не воспроизведение исходной прозы. Copyleft на корпусе цепляется к прозе, а не к весам или JSON‑выводу.

Поэтому для редистрибутируемого датасета я предпочитаю apache/CC0/CC‑BY, а источники с неуказанной лицензией держу training‑only — из‑за habr‑части карточка датасета на HF помечена license: other, не Apache (Apache — это код и веса). Следующий шаг — версия датасета вообще без habr.

Обучение: bf16-LoRA на Blackwell, без QLoRA

Железо для обучения — RTX 5090, NVIDIA Blackwell, compute capability sm_120, 32 ГБ, WSL2 на Ubuntu 24.04.

Нативный Windows для Blackwell‑стека обучения я считаю ненадёжным.

Метод — bf16 LoRA, не QLoRA. Причина простая: 8B‑модель влезает в 32 ГБ в bf16, а bitsandbytes-4bit на Blackwell хрупок и штрафует по скорости. cu130‑torch конфликтует с bitsandbytes по ABI. Если модель помещается без квантизации, нет смысла тащить нестабильность в обучение.

# train.pymodel = FastLanguageModel.get_peft_model(    model,    r=32, lora_alpha=32, lora_dropout=0.05,    target_modules=["q_proj","k_proj","v_proj","o_proj",                    "gate_proj","up_proj","down_proj"],    use_rslora=True,    use_gradient_checkpointing="unsloth",    random_state=42,)# response-only loss: маскируем instruction+input, в лоссе только JSONtrainer = train_on_responses_only(    trainer,    instruction_part="### Instruction:\n",    response_part="### Response:\n",)

Response‑only loss здесь обязателен. Output — короткий boundary JSON. Input — большой нумерованный документ. Без маскировки почти весь лосс ушёл бы на воспроизведение входа, и модель училась бы пересказывать документ, а не находить границы.

Поэтому instruction+input маскируются, а лосс считается только на ответе.

learning_rate=2e-4, lr_scheduler_type="cosine", warmup_ratio=0.05,weight_decay=0.01, optim="adamw_8bit", bf16=True,max_length=4096, packing=False,# per_device_train_batch_size=2, grad_accum=8 -> эффективный batch 16# num_train_epochs=2

Параметр

Значение

LoRA r / alpha / dropout

32 / 32 / 0.05

rsLoRA

вкл, проверяется assert’ом

Optimizer

adamw_8bit

LR / scheduler / warmup

2e-4 / cosine / 5%

weight_decay

0.01

micro‑batch / grad‑accum / эфф. batch

2 / 8 / 16

Эпохи

2

max_seq_len

4096

Gradient checkpointing

unsloth

Attention

SDPA (дефолт; flash‑attn не имеет сборки под sm_120)

LoRA random_state

42

logging / save / eval steps

10 / 200 / 100

По моим записям и README/config:

Метрика прогона

Значение

Время

~3.5 ч

Пик VRAM

25.4 ГБ

Шаги

2122

Эпохи

2

Машинный stdout обучения в репозитории не заархивирован. Поэтому финальный train loss я не публикую как измеренную метрику. В config есть пороговая эвристика train loss < 0.2 как зона риска переобучения, но это не лог конкретного прогона.

Blackwell‑специфика

С Blackwell «просто поставить зависимости» у меня не получилось. Нужны точные пины и sanity‑чек до запуска.

# torch только cu129, не cu130pip install torch==2.11.0 --index-url https://download.pytorch.org/whl/cu129# sanity 1: убедиться, что torch собран под sm_120python -c "import torch; print('sm_120' in torch.cuda.get_arch_list())"# sanity 2: torch.compile/inductor открывает много файловulimit -n 1048576

Плюс нужны triton >= 3.3.1, свежие unsloth/unsloth_zoo, transformers/trl/peft/accelerate latest stable, python 3.12, драйвер NVIDIA R570+ и CUDA ≥ 12.8. torchaudio нужно выровнять под версию torch.

Отдельная бытовая проблема: WSL‑машина с RTX 5090 засыпает и рвёт SSH‑сессию посреди прогона.

Чекпоинт нужно выбирать по boundary‑F1, а не по eval_loss

Из обучения я вынес вот что: eval_loss плохо выбирал чекпоинт.

По сводным таблицам eval/results.md, README и model card, task‑метрика продолжила расти во второй эпохе:

boundary‑F1@0

boundary‑F1@±1

exact‑set

эпоха 1, шаг 1000

0.610

0.800

23%

эпоха 2, финал

0.656

0.821

29%

Сырой stdout этого eval‑прогона теперь заархивирован: я перегнал eval и закоммитил логи рядом с таблицей (eval/results.md, commit c0f57f9). Снапшот N=300 воспроизвёлся точно (0.654 / 0.821 / 29.0), а на полном holdout-1500 числа держатся и даже чуть выше.

Вывод не меняется: выбирать checkpoint по task‑метрике полезнее, чем по одному loss.

Почему не третья эпоха? В config зафиксирована эвристика: 1–2 эпохи, а >2 быстро повышает риск overfit для этого режима. Следующий рычаг — больше разнообразных реальных данных; третья эпоха на том же сете даст меньше.


Деплой на AMD Vulkan: GGUF без CUDA

Обучение шло на NVIDIA. Целевой инференс — AMD Strix Halo, gfx1151, llama.cpp Vulkan, без CUDA. Именно это требование заранее отсеяло модели, которые не едут на Vulkan.

Путь сборки GGUF:

# 1) зарегистрировать pre-tokenizer T-litepython training/patch_tokenizer_hash.py# 2) merged FP16 -> GGUF f16python llama.cpp/convert_hf_to_gguf.py out_merged \    --outfile model-f16.gguf --outtype f16# 3) квантизация в Q5_K_M (~5.9 ГБ)./llama.cpp/build/bin/llama-quantize model-f16.gguf model-Q5_K_M.gguf Q5_K_M

Запуск на AMD:

llama-server -m model-Q5_K_M.gguf -ngl 99 -c 8192 \    --host 0.0.0.0 --port 8085

Грабля токенайзера в GGUF

Хэш BPE‑pre‑tokenizer’а T‑lite не зарегистрирован в upstream llama.cpp. Поэтому convert_hf_to_gguf.py падает с BPE pre-tokenizer was not recognized.

Фикс — идемпотентный скрипт patch_tokenizer_hash.py. Он вставляет в conversion/base.py ветку, которая маппит хэш T‑lite на qwen2.

H = "e9b7dbd66e0308c6e89983d5b6e1ca047106d862879a0fd33a12c8491b91ec5c"# вставляется ветка:#   if chkhsh == H:#       res = "qwen2"  # T-lite-it-2.1 (Qwen3 + ext RU vocab)

Кажущееся противоречие: если официальный вендорский GGUF показывает, что round‑trip проходит, почему моя конвертация упала на хэше? Потому что вендор собирал GGUF на сборке llama.cpp, которая этот хэш уже знала; на моём свежем convert_hf_to_gguf он ещё не был зарегистрирован — отсюда разовый патч. Сам round‑trip от этого не ломается, регистрируется только хэш. Наличие официального GGUF от вендора как раз и было сигналом, что round‑trip кастомного токенайзера в принципе проходит.

Производительность на живом AMD‑стенде

Этого замера в исходных доках не было. Я снял его на развёрнутом AMD‑стенде: Strix Halo, Vulkan, Q5_K_M, temperature=0, медиана 3 прогонов. Документ — 9 юнитов: проза плюс markdown‑таблица.

Метрика

Значение

Полная задержка на документ

~1.2 с

Генерация

40 ток/с

Prompt eval

947 ток/с

Вход → выход

317 prompt‑токенов → 35 generated

Оговорка: это один тестовый документ, медиана трёх прогонов, а не широкий бенчмарк.

Вывод модели:

{"splits": [5, 6], "topic": "Сравнение реляционных и NoSQL СУБД..."}

JSON валидный, topic осмысленный, таблица — юнит #6 — уехала отдельным чанком целиком.

35 токенов вывода — практический смысл index‑output: на этом 9-юнитовом документе полный прогон занял около 1.2 секунды вместо переписывания всего текста.

Что не стоит путать с воспроизводимой инструкцией

В моём рабочем стенде были ещё бытовые грабли: права на HF‑кэш, xet при upload, контейнерный restart‑policy. Полезные operational notes, но пока они не оформлены в репозитории как воспроизводимый setup, поэтому я не подаю их как часть официального пути сборки.

Критичная проверка другая: тестировать развёрнутый GGUF на целевом AMD‑железе, а не только HF‑модель на тренировочной NVIDIA. Именно так появились реальные latency‑цифры и сравнение HF‑модели с GGUF.


Что получилось: метрики и оговорки

Все числа в таблице ниже — исходный snapshot на N=300 из holdout-1500, против меток DeepSeek‑V4-Flash, greedy‑декодинг. Полный holdout-1500 я перегнал отдельно — эти числа чуть ниже, в блоке «Перепроверка». Для дистилляции такая метрика годится, но пользу для retrieval не доказывает. Для этого нужен отдельный downstream‑eval по hit‑rate / faithfulness.

exact-set-match тоже считается против меток учителя. Размер holdout в коде — кап: max(1, min(len/10, 1500)), то есть до 10% сета, но не более 1500 примеров.

Метрика

HF bf16, RTX 5090

GGUF Q5_K_M, AMD Vulkan

Валидный JSON

100%

100%

boundary‑F1 @0

0.656

0.639

boundary‑F1 @±1

0.821

0.817

exact‑set‑match

29%

25%

Замер HF‑модели шёл в bf16 (eval_hf.py: dtype=torch.bfloat16); f16 появляется только как промежуточный формат при конвертации в GGUF.

Перепроверка (2026–06-26): я перегнал eval и закоммитил сырой stdout рядом с eval/results.md (commit c0f57f9). Снапшот N=300 воспроизводится точно, а на полном holdout-1500 обе модели чуть выше и практически совпадают: HF: 0.665 @0 / 0.825 @±1; GGUF: 0.661 @0 / 0.826 @±1. То есть на целевом AMD квантизованный GGUF не уступает HF — прежний разрыв (0.817 vs 0.821) скорее всего был артефактом меньшего N (GGUF на N=300 я заново не перемерял).

Как читать таблицу:

100% валидного JSON — это выученное поведение, а не грамматически принудительный вывод. На инференсе grammar‑констрейнтов нет, хост парсит JSON из свободной генерации.

boundary‑F1 @±1 = 0.821 — F1 совпадения предсказанных границ с границами учителя при допуске ±1 предложение. Речь о гармоническом среднем precision/recall по множеству индексов границ, а не о доле идеально размеченных документов.

Разрыв @0 → @±1 (0.656 → 0.821) я читаю так: значительная часть ошибок похожа на сдвиг границы на одно предложение, а не на грубо неверную сегментацию. Это инженерная интерпретация — отдельный error‑analysis по величине сдвига я пока не делал.

Для RAG‑чанкинга ±1 предложение часто косметика, но это тоже нужно проверять downstream.

GGUF ≈ базовая модель — 0.817 vs 0.821 @±1 на снапшоте N=300, и 0.826 vs 0.825 на полном holdout-1500. Q5_K_M и Vulkan качество границ практически не съедают.

exact‑set‑match низкий — 29%/25%. По смыслу это полное совпадение множества границ с множеством учителя: одна лишняя или смещённая граница — и пример не засчитывается. Смотреть на неё стоит как на индикатор идеальных попаданий, а не как на общую оценку нарезки.

Ещё одно наблюдение из инференса: модель слегка пере‑сегментирует. Иногда она вставляет лишнюю границу. Похоже на наследие гранулярности учителя. Лечится дешёвым пост‑мёржем чанков на хосте или промптом учителя.


Ограничения: где этот сплиттер не нужен или вреден

Сплиттер рассчитан на прозу плюс мелкие и средние таблицы. Он держит таблицы и код атомарными юнитами, даёт самодостаточные смысловые чанки и сохраняет lossless‑реконструкцию — если парсер корректно выделил структурные блоки.

Граница применимости жёсткая.

1. Метрики — teacher‑agreement, не ground‑truth. 0.821 — согласие с учителем, а не доказанное качество RAG.

2. Модель слегка пере‑сегментирует. Похоже на наследие учителя. На практике лечится пост‑мёржем.

3. Очень большие таблицы — не задача boundary‑модели. Если таблица превышает бюджет эмбеддера, такой сплиттер не помогает. Он может даже ухудшить результат.

Огромные таблицы — это другая задача

Boundary‑модель ищет места, где в документе меняется тема. У таблицы тема между строками не меняется. Строки похожи, эмбеддинги близки, сигнала для границы нет.

Поэтому семантический сплиттер режет таблицу произвольно и теряет хедер. Проблема структурная, а не модельная.

Для больших таблиц мне пришлось развести две вещи: что эмбеддить и что возвращать пользователю.

Эмбеддить нужно маленькие прокси: саммари таблицы и row‑группы с повторённым хедером. А на hit возвращать всю таблицу целиком по table_id через parent‑document.

Набор рычагов здесь такой:

  • hybrid retrieval: dense+sparse;

  • rerank — на таблицах только measure‑don’t‑assume: прогнать с ним и без, выигрыш не гарантирован;

  • детерминированная аугментация чанка хедером и caption;

  • row‑group chunking с повторённым хедером;

  • table‑level summary как отдельный retrieval‑прокси.

Внешние benchmark‑числа по table‑RAG я здесь намеренно не привожу: в моих заметках есть ориентиры, но перед тем как на них опираться, их нужно перепроверить по первоисточнику и конкретной версии.

Где здесь остаётся LLM‑сплиттер? Не в пути чанкинга огромной таблицы.

Структуру таблицы должен знать детерминированный парсер. docling и похожие инструменты умеют работать с табличной сеткой и row‑группами, но propagation хедера нужно тестировать на своей версии и своих документах. Вокруг этого класса задач есть открытые edge‑cases, поэтому формулировка «оно всегда повторит header правильно» была бы слишком сильной.

Для моего Dify‑стека правило такое:

  • небольшие и средние таблицы → можно держать parent‑document вокруг таблицы;

  • таблицы, которые превышают лимиты конкретной версии Dify/эмбеддера → выносить в отдельный retrieval‑путь через внешний storage и table_id.

Dify‑лимиты и поведение full‑doc parent лучше фиксировать по конкретной версии — между релизами они меняются. В моём пайплайне это отдельная проверка перед продом.


Что дальше: v2

Дальше нужен downstream‑eval. Teacher‑agreement полезен на этапе дистилляции, но он не отвечает на главный вопрос: улучшает ли сплиттер retrieval.

План v2:

  • обучить на ~30k примеров: долить не только синтетику, но и реальную прозу;

  • добавить обработчик больших таблиц: table summary + parent‑document retrieval;

  • сделать downstream RAG‑eval по hit‑rate / faithfulness;

  • выпустить вариант датасета без habr, потому что у habr лицензия unspecified/mixed.

Следующий эксперимент: русский guardian для RAG

Сплиттер стоит на входе RAG‑пайплайна. Следующий эксперимент — узкая русская модель на выходе: guardian, который проверяет groundedness ответа по контексту и релевантность ответа вопросу.

Пока это research, не интегрированный код. Рабочий путь такой же, как у сплиттера: permissive general‑instruct база, дистилляция от учителя, LoRA, GGUF Q5 на Strix Halo Vulkan.

PII и topical‑rails я не хочу делать guardian‑LLM. Для этого лучше подходят отдельные CPU‑сайдкары: Presidio / GLiNER для PII, NLI‑ или classifier‑подходы для тем. Пихать всё в одну LLM удобно на схеме, но плохо в эксплуатации.


Выводы

Слабые места дефолтного чанкинга на русском можно измерить. Кириллица часто дороже в токенах. На моём коротком замере Llama-2 дал 3.17 ток/слово против 1.74 у T‑lite‑it-2.1; на корпусной прозе разрыв может быть мягче, но направление остаётся.

Таблицы ломаются из‑за того, что между их строками нет семантического сдвига — на этом сигнале бессилен любой sentence‑level сплиттер.

Самая полезная часть проекта — index‑output вместо text‑output. Модель не переписывает чанки, а возвращает границы. Поэтому нарезка остаётся lossless, вывод стоит около 35 токенов на документ, latency на AMD — примерно 1.2 секунды на тестовом документе, а таблицы не рвутся внутри атомарного юнита.

Выбор базы оказался не гонкой за самой новой моделью, а пересечением требований: рантайм, лицензия, токенайзер, GGUF. Модель, которая не запускается на целевом железе, может быть хорошей в вакууме, но бесполезной в проекте.

Дистилляция от self‑hosted учителя снимает ручную разметку, но требует дисциплины: гейты, точный дедуп, проверка расхождений между README и кодом, честное описание temperature 0 vs 0.2 и входных весов vs наблюдаемых долей.

Чекпоинт нужно выбирать по task‑метрике, а не по eval_loss. Eval‑таблицы воспроизведены, сырой stdout закоммичен рядом с results.md — числа держатся (на полном holdout даже чуть выше).

Blackwell и GGUF — отдельная инженерная работа: torch cu129, не cu130; хэш токенайзера в llama.cpp; проверка не только на тренировочной NVIDIA, но и на целевой AMD.

Последняя оговорка важнее красивой цифры: teacher‑agreement ещё не доказывает пользу для RAG. Я выкладываю модель с этим предупреждением прямо в README.

Веса, датасет и код открыты. Забирайте, ломайте, перепроверяйте цифры. За первые полторы недели после выкладки на HuggingFace — 95 скачиваний модели и 60 скачиваний датасета (снимок на 4 июля 2026).


Ссылки

ссылка на оригинал статьи https://habr.com/ru/articles/1055628/