
TL;DR. У меня локальный RAG на AMD Strix Halo. Памяти там достаточно, но вычислительно всё упирается в один iGPU: его делят эмбеддер, реранкер и периодическая переиндексация, а в одноузловой конфигурации туда же встаёт генеративная LLM. Поэтому мне понадобился не самый точный retriever вообще, а максимально лёгкий русский retriever, который быстрее завершает первую стадию поиска на том же GPU. Насколько это больно, я в итоге померил на живом узле.
На синтетической индексирующей нагрузке полной тяги bge‑m3 снизил generation throughput Qwen3.6-35B-A3B на 92%, STRIZH на 28% при примерно 12,6-кратном темпе индексации (окна замера там неравные, 20,6 против 173,6 секунды, потому что задаются временем пяти генераций; равнооконный прогон RAG‑конвейера ниже даёт по батчам в секунду 9,4-кратный отрыв). В полном RAG‑конвейере с четырьмя пользовательскими потоками и фоновой индексацией STRIZH удержал 14,0 транзакции в минуту против 6,0 у bge‑m3 и индексировал 46 батчей в секунду против 4,9. При фиксированных 200 онлайн‑запросах в секунду генерация проседала на 2% со STRIZH и на 7% с bge‑m3. Оговорка сразу: без фоновой индексации STRIZH преимущества не показал: в этом профиле bge‑m3 оказался немного быстрее, а пропускную способность определяли реранк (0,6–0,7 с на транзакцию), prefill и генерация.
Я взял 12-слойный RuModernBERT-small, сначала неудачно попытался повторить пространство большого учителя через MSE, затем обучил retrieval‑донор на контрастивной задаче, выбрал из него четыре слоя [0, 5, 9, 11] и доучил student на русских, английских и смешанных парах с hard negatives от BGE‑M3.
Получился STRIZH: 24,4 млн параметров, четыре слоя, вектор 384, mean pooling, без обязательных query: / passage:‑префиксов. На подвыборке после фильтрации известных прямых совпадений gold‑passages с training positives donor‑линии STRIZH модель получила Recall@10 = 0.751; на полном локальном наборе 0.800. Отфильтрованный результат даёт более консервативную оценку, но это не полный near‑duplicate audit. На прогретом llama.cpp/Vulkan (Strix Halo, concurrency 16) модель обрабатывала около четырёх тысяч коротких запросов в секунду; по разным прогонам 3614–4450, и почему так широко, разобрано ниже. Более крупные USER2-small, multilingual-e5-small и bge-m3 качественнее. STRIZH нужен не вместо них, а для другого режима: русский RAG, общий GPU и жёсткое ограничение на стоимость первой стадии поиска.
Главная ошибка обнаружилась уже после публикации модели. В tokenizer_config.json остался model_max_length=256. На собственной RAG‑обкатке тот же checkpoint при принудительном обрезании до 256 токенов упал с Recall@10 = 0.589 до 0.448, то есть на 14,1 процентного пункта, до уровня rubert-tiny2. Одна цифра в конфиге почти обнулила смысл всей работы.
Ниже: ход обучения, неудачные методы, утечка в evaluation и путь от checkpoint до рабочего GGUF/Vulkan‑сервинга.
Память не заканчивалась. Заканчивалось GPU‑время
Мой целевой узел: AMD Strix Halo с 128 ГБ unified memory. Проектная одноузловая топология выглядит так, и именно под неё выбирался эмбеддер:
один iGPU├── генеративная LLM├── dense retrieval├── reranker└── периодическая переиндексация корпуса
Текущая топология такая: прямо сейчас генеративная LLM у меня вынесена на соседнюю ноду кластера, а на этом узле iGPU делят эмбеддер, реранкер и переиндексация. Основной embedding‑слот в проде пока занимает тяжёлый bge‑m3 из сравнения ниже: к его размерности привязаны существующие векторные индексы, и смена эмбеддера означает переиндексацию. STRIZH при этом уже задеплоен на том же узле вторым embedding‑эндпоинтом; перевод основного слота запланирован после переиндексации баз. Но стек проектировался одноузловым, поэтому для этой статьи я поднял Qwen3.6-35B-A3B (MoE: 35B параметров всего, около 3B активных на токен) обратно на этот бокс и померил co‑residency напрямую. Дельты tok/s генерации под embedding‑нагрузкой приведены в разделе «Co‑resident» ниже.
На первый взгляд обучать маленький эмбеддер для такого железа странно. Даже bge-m3 туда помещается без проблем. Несколько сотен мегабайт на фоне 128 ГБ ничего не решают.
Но память и вычисления ограничивают систему по‑разному. Когда большая embedding‑модель обрабатывает пакет документов, она занимает тот же GPU, на котором в это время должна генерировать LLM. При переиндексации большого корпуса это уже не «эмбеддер работает на фоне», а заметная конкуренция за compute. Забегая вперёд: замер показал, что одной онлайн‑нагрузки для этого недостаточно: заметная конкуренция появляется на индексирующей.
Поэтому исходный вопрос был таким:
Насколько маленьким можно сделать русский dense‑retriever, чтобы сократить стоимость первой стадии на общем GPU, но ещё не скатиться к компромиссу уровня «лишь бы что‑то вернул»?
Я зафиксировал требования до обучения:
-
меньше 30 млн параметров;
-
основной язык: русский;
-
dense single‑vector retrieval;
-
работа без обязательных префиксов;
-
mean pooling и L2-нормированный вектор;
-
GGUF и
llama.cpp/Vulkanкак целевой рантайм; -
качество заметно выше
rubert-tiny2; -
английский и mixed‑текст не должны полностью развалиться;
-
архитектура должна принимать длинные входы, даже если само contrastive‑обучение идёт на более коротких последовательностях.
Последний пункт требует оговорки. База поддерживает контекст до 8192 токенов, и этот лимит сохранён в архитектуре STRIZH. Но финальные обучающие прогоны делались с max_seq_length=256. Поэтому корректная формулировка звучит так: архитектура поддерживает до 8192 токенов, а не «модель специально обучена retrieval на 8192 токенах». Важная деталь: качество я мерил на входах до ~1024 токенов; способность принимать 8192 унаследована от базы и retrieval‑замером на таких длинах не проверена.
Технически STRIZH является retriever, а не универсальной моделью sentence similarity. Дальше я иногда называю его эмбеддером, потому что в RAG‑стеке он выглядит как обычный endpoint /embeddings, но оптимизировался он под поиск релевантного документа.
Почему не взять готовую модель
Готовые варианты закрывают разные углы задачи, но ни один не совпал с моими ограничениями целиком.
rubert‑tiny2
Маленький, простой, хорошо известный и быстрый на CPU. Удобная нижняя планка. Но это не специализированный современный retriever, и на русском retrieval он у меня оказался слишком слабым.
USER2-small
Близкий и очень сильный вариант на той же архитектурной семье. Всего примерно на 10 млн параметров крупнее STRIZH, при этом заметно лучше на английском и cross‑lingual поиске. На русском картина зависит от корпуса: на отфильтрованном MIRACL‑ru перевес у USER2 (0.82 против 0.75), а на моём корпусе документации (замер описан ниже, в разделе про dogfood) русская документация и длинные документы уходят к STRIZH, правда с перевесом в один вопрос на страту, то есть в пределах шума; общий отрыв USER2 там собран на cross‑lingual страте, где разрыв уже велик.
Но у него есть зарегистрированный режим с search_query: и search_document:. Для нового проекта это не проблема. В моём случае no‑prefix был эксплуатационным ограничением: один OpenAI‑compatible embedding endpoint, одинаковая обработка запросов и документов, несколько клиентов, которые не должны знать о конкретной модели.
Можно сказать, что добавить префикс значит написать всего одну строку кода. В изолированной функции так и есть. В готовом стеке это уже изменение контракта: нужно различать роли текстов, не забыть префикс при переиндексации, синхронно обновить все клиенты и не смешать старые векторы с новыми.
multilingual‑e5-small
Качественный мультиязычный retriever, но он крупнее, использует query: / passage: и рассчитан на другой компромисс. Это хороший выбор, когда мультиязычность важнее минимального compute.
bge‑m3
Качественно это лучший вариант из моего прикладного сравнения. Он поддерживает dense, sparse и multi‑vector режимы, хорошо работает на русском и cross‑lingual запросах. Но это уже другая весовая категория.
Если на узле есть свободный compute и нужна максимальная полнота поиска, обучать STRIZH вместо bge-m3 незачем. Моя задача начиналась именно там, где скорость первой стадии и конкуренция за общий GPU стали ограничением.
Иными словами, я искал не «лучшую модель», а пустое место между двумя классами:
совсем маленькая и быстрая, но слабая ↕компактная и ещё пригодная для русского retrieval ↕качественная, но более тяжёлая
База: почему четыре слоя всё равно весят 24 млн параметров
В качестве базы я взял deepvk/RuModernBERT-small:
-
12 transformer‑слоёв;
-
hidden size 384;
-
словарь 50 368 токенов;
-
контекст до 8192;
-
около 35 млн параметров.
Изначально казалось, что сокращение глубины с 12 слоёв до четырёх должно уменьшить модель примерно втрое. По общему числу параметров этого не произошло: финальный STRIZH весит 24,4 млн.
Причина в embedding table:
50 368 токенов × 384 измерения = 19 341 312 параметров
То есть около 79% всех параметров четырёхслойной модели находится только в таблице токенов. Сокращение глубины сильно уменьшает число вычислений в encoder‑блоках, но почти не трогает словарь.
Это важное различие:
-
по размеру весов модель уменьшилась с примерно 35 до 24,4 млн параметров;
-
по глубине вычислительного тракта: с 12 до четырёх слоёв;
-
следовательно, главный выигрыш ожидался не в памяти, а в latency и throughput.
Я сохранил hidden size 384, mean pooling и нормализацию:
mask = attention_mask.unsqueeze(-1).float()embedding = (last_hidden_state * mask).sum(1) / mask.sum(1)embedding = normalize(embedding, p=2, dim=1)
Никаких query: и passage:. Запрос и документ проходят через один и тот же encoder одинаково.
Первая попытка: loss красивый, retrieval мёртвый
Первую версию я строил по очевидной логике: взять сильного учителя и заставить маленькую модель повторять его embedding‑пространство.
Учителем была FRIDA с векторами размерности 1536. В роли студента выступал полный 12-слойный RuModernBERT-small с mean pooling и линейной проекцией 384 → 1536.
Схема выглядела так:
12,07 млн текстов ↓FRIDA → нормированный вектор 1536 ↓RuModernBERT-small → mean pool → Linear(384, 1536) ↓normalized MSE
Параметры прогона:
|
Параметр |
Значение |
|---|---|
|
Текстов |
12,07 млн |
|
Эпох |
1 |
|
Batch size |
256 |
|
Learning rate |
|
|
Max length |
256 |
|
Скорость |
около 1800–1900 примеров/с |
Loss стабилизировался примерно на 0.00062. Сначала число казалось крошечным, но MSE усредняется по 1536 координатам нормированных векторов, а для единичных векторов ‖x−y‖² = 2 − 2·cos. В пересчёте это соответствует среднему cosine около 0.52 (cos ≈ 1 − 0.00062·1536/2): студент приблизился к пространству учителя лишь умеренно, а не «почти совпал». По логу обучение не расходилось и шло быстро.
Retrieval‑проверка показала другое:
|
Модель |
Recall@10 |
MRR@10 |
|---|---|---|
|
Student после vector regression |
0.029 |
0.012 |
То есть почти ноль.
Низкий MSE между отдельными векторами не сохранил главное, то есть относительный порядок документов для конкретного запроса. Для retrieval важно не просто приблизить точку студента к точке учителя, а сформировать геометрию, в которой положительный документ систематически выше тысяч похожих отрицательных.
Это не доказательство, что embedding regression не работает вообще. В моей постановке не хватило сигнала, напрямую связанного с ранжированием. Loss стабильно снижался, но оптимизировал не ту цель, которая была нужна retrieval.
После Recall@10 = 0.029 MSE‑ветку я закрыл и перешёл к retrieval‑парам:
Снижение regression loss в моей постановке не переносилось в качество поиска.
Сначала пришлось обучить нормального 12-слойного донора
После провала прямой регрессии я перешёл на contrastive retrieval. В основе лежит MultipleNegativesRankingLoss: положительная пара должна сближаться, а остальные документы батча становятся отрицательными примерами.
Это была отдельная исследовательская линия s1…s6 на 12 слоях. Её цифры нельзя считать результатами финального STRIZH: она нужна была, чтобы найти работающий рецепт и получить donor для последующего сокращения.
Здесь важна методическая деталь: у меня было два харнесса. Первый назывался train‑side smoke: 1000 запросов против 30 582 passage‑кандидатов, я гонял его прямо в ходе ранних экспериментов. Regression‑модель S1 на нём получила Recall@10 = 0.029, а первая MNRL‑модель S2 на том же smoke‑харнессе дала 0.717. Начиная с S2 все результаты ниже сняты на отдельном dev‑харнессе: 1000 MIRACL‑ru dev‑запросов против 9274 passage‑кандидатов, eval cap 512. Это closed‑candidate‑проверка для сравнения итераций в одном окружении, а не официальный MIRACL leaderboard.
|
Этап, 12 слоёв (dev‑харнесс 1000×9274) |
Что изменилось |
Recall@10 |
|---|---|---|
|
S2 |
MNRL, около 110 тыс. retrieval‑пар |
0.706 |
|
S3 |
GPL‑смесь, около 260 тыс. пар |
0.738 |
|
S3,5 / S4 |
Ещё данные того же типа |
0.737 |
|
S5 |
Hard negatives, отобранные FRIDA |
0.746 |
|
S5M |
MarginMSE на тех же данных |
0.721 |
|
S6 |
Рост корпуса ~2,44×, шесть negatives на запрос |
0.749 |
GPL здесь означает Generative Pseudo‑Labeling, но в усечённом виде: LLM читает пассаж из корпуса и генерирует к нему реалистичный поисковый запрос, пара «запрос → пассаж» становится обучающим позитивом. Оригинальный GPL (Wang et al.) дополнительно размечает пары кросс‑энкодером и учит на MarginMSE, у меня этого шага нет, так что честнее называть это генерацией запросов в духе GenQ. Для своего языка этот шаг придётся воспроизводить своим корпусом и любой инструкционной моделью: мои пары и генератор не опубликованы. Полные конфиги донор‑стадий тоже остались в локальных скриптах (для ориентира: S3 обучался на GPL 150 тыс. плюс 100 тыс. готовых пар с негативами из существующего датасета и MIRACL‑train 10 тыс. (FRIDA‑mining появился только на этапе S5), batch 256, lr 3e-5, 2 эпохи; S4 брал свежие GPL той же природы при lr 2e-5); опубликован конвейер от обрезки донора и дальше.
Смена постановки оживила retrieval
Тут поменялся не один loss, а вся постановка сразу: я убрал 1536-мерную projection‑head, перешёл с одиночных текстов на retrieval‑пары и обучил backbone через MNRL. Скачок 0.029 → 0.717 на одном и том же smoke‑харнессе означает, что новый retrieval‑рецепт заработал, но не позволяет приписать весь прирост одному только выбору loss. (Оба smoke‑числа сняты на train‑side данных, то есть это диагностика, а не независимый holdout; независимое подтверждение S2 даёт 0.706 на dev‑харнессе.)
GPL довольно быстро вышел на плато
Увеличение смеси примерно до 260 тыс. пар дало 0.738, но следующий прогон на данных той же природы ничего не добавил: 0.737.
Больше строк не означало больше нового сигнала. Модель видела дополнительные примеры, но не более сложные решения.
Hard negatives сработали лучше простого наращивания корпуса
Я отбирал документы, которые сильный учитель ставил высоко, но которые не были размечены как gold для данного запроса. Такой mining создаёт сложные negatives и заставляет модель проводить границу между «похоже по теме» и «действительно отвечает на запрос», хотя полностью исключить false negatives (неразмеченный, но релевантный passage) он не может.
После FRIDA‑mining результат вырос до 0.746.
MarginMSE ухудшил результат
Следующая идея выглядела разумно: учить не только порядок, но и margin между положительным и отрицательным документом. На моём наборе тот же сигнал с MarginMSE дал 0.721, то есть минус 2,5 процентного пункта относительно S5.
Моя рабочая гипотеза: численная шкала cosine‑margin большого учителя плохо перенеслась в пространство маленького студента. Но это именно гипотеза по одному эксперименту, а не общий приговор MarginMSE.
Шесть negatives на запрос дали только +0,3 п.п.
S5 содержал 894 924 строки, S6 вырос до 2 182 536, то есть примерно в 2,44 раза больше; каждый запрос получил до шести negatives. Recall@10 вырос с 0.746 до 0.749.
Это и стало сигналом остановиться. Donor уже умел retrieval, но дальнейшее масштабирование того же рецепта давало слишком мало.
Побочная грабля: batch 256, WSL и девять падений
Обучение шло на RTX 5090 под WSL2. MNRL с batch 256 и последовательностями до 256 токенов периодически раздувал потребление до 20–25 ГБ. Когда WDDM начинал выталкивать память в shared RAM, скорость падала примерно до 2,3 секунды на шаг, затем следовали OOM или чёрный экран.
До нормальной конфигурации я дошёл после девяти падений.
gradient_checkpointing=True снизил пик с 20–25 ГБ примерно до 5 ГБ. Но важнее оказалось другое: после исчезновения свопа обучение ускорилось примерно в четыре раза. Формально checkpointing добавляет recompute и должен замедлять шаг. В моей среде он убрал намного более дорогой путь через shared memory.
Это не универсальный benchmark checkpointing, а напоминание: «модель помещается в VRAM» и «обучение не уходит в системный своп» означают разные вещи.
Как из двенадцати слоёв остались четыре
Просто взять последние четыре слоя нельзя было считать обоснованным решением. Я собрал несколько warm‑start вариантов, скопировав блоки из обученного S6-donor в четырёхслойную модель.
Варианты выглядели так:
last4 = [8, 9, 10, 11]strided = [2, 5, 8, 11]late = [0, 5, 9, 11]spread = [0, 4, 8, 11]
До дополнительного обучения надёжно сохранились такие результаты:
|
Warm‑start |
Слои |
Recall@10 до обучения |
|---|---|---|
|
|
|
0.306 |
|
|
|
0.281 |
|
|
|
0.153 |
Абсолютное качество после механической обрезки ожидаемо было низким. Мне был нужен не готовый retriever, а старт, который меньше всего разрушил структуру donor.
Победил вариант [0, 5, 9, 11]. (Результат strided надёжно не сохранился, поэтому в таблице его нет; выбор шёл из трёх задокументированных стартов.)
Можно предположить, что первый слой сохраняет полезную раннюю обработку токенов, а поздние отвечают за retrieval‑геометрию. Но это интерпретация после факта. Эксперимент доказал только то, что данный набор оказался лучшим из проверенных стартов.
У ModernBERT здесь была ещё одна неочевидная деталь: слои используют разные layer_types с local/global attention и связанными RoPE‑параметрами. При копировании нужно было перенести не только веса, но и соответствующий тип каждого выбранного слоя. Простое переименование state_dict без согласованного config давало бы уже другую архитектуру.
Корректное название этого этапа:
layer selection / pruning + warm‑start, после которого student заново обучается на contrastive objective.
Это не классическая distillation с постоянным teacher‑forward и distillation loss на каждом батче. Учителя использовались для подготовки donor и mining hard negatives, но финальный student не повторял logits учителя онлайн.
Первая четырёхслойная версия
В качестве старта я взял ws_late и обучил четырёхслойный student на тех же FRIDA hard negatives плюс MIRACL‑train.
|
Параметр |
Значение |
|---|---|
|
Обучающих строк |
около 2,19 млн |
|
Эпох |
3 |
|
Batch size |
256 |
|
Learning rate |
|
|
Precision |
BF16 |
|
Max sequence length |
256 |
|
Время на RTX 5090 |
4131 с, около 69 мин |
|
Скорость обучения |
1592 примера/с |
На полном локальном dev‑harness четырёхслойная версия получила 0.763 против 0.749 у 12-слойного donor и 0.678 у rubert-tiny2. Затем я исключил запросы, у которых хотя бы один gold passage раньше встречался среди training positives к другим запросам. На этой отфильтрованной подвыборке результаты составили 0.712, 0.700 и 0.633 соответственно.
Но вывод «четыре слоя лучше двенадцати» был бы неверным. Student видел другой объём и состав обучающих примеров, другой learning rate и три эпохи дообучения. Сравнение показывает только то, что после сокращения качество удалось восстановить и немного улучшить новым рецептом.
Главный результат этого этапа был не +0.014 к donor, а то, что encoder с четырьмя слоями вообще сохранил практически полезный retrieval.
V2: русский сохранился, английский пришлось добавлять явно
Первая четырёхслойная версия уже что‑то понимала по‑английски благодаря базовому RuModernBERT-small, обученному на русском, английском и коде. На NanoBEIR она получила средний nDCG@10 = 0.296 без отдельного английского retrieval‑обучения.
Но этого было мало для технической документации, где русский текст постоянно перемешан с именами библиотек, параметрами CLI, traceback и фрагментами кода.
Для V2 я собрал единый перемешанный поток из 220 тыс. пар:
|
Часть |
Количество |
Назначение |
|---|---|---|
|
Русские GPL‑пары |
120 000 |
Сохранить основную RU‑специализацию |
|
|
60 000 |
Русский вопрос, код и английские идентификаторы в документе |
|
MIRACL‑en train |
40 000 |
Явный английский retrieval‑сигнал |
В одном из ранних README у меня оставалось название GooAQ. Это было расхождение документации с кодом: финальный data_v2.py загружает MIRACL‑en, и именно его использовал фактический прогон.
Первый V2-проход (две эпохи MNRL, batch 256, lr=3e-5) занял 166,8 секунды. Маленький encoder на 220 тыс. парах обучается быстрее, чем успеваешь нормально оформить эксперимент.
Результаты относительно V1:
|
Ось |
V1 |
V2, итерация 1 |
|---|---|---|
|
RU, Recall@10 (полный dev) |
0.763 |
0.797 |
|
EN NanoBEIR, mean nDCG@10 |
0.296 |
0.340 |
|
Mixed diagnostic, Recall@10 |
0.520 |
0.605 |
Оба RU‑числа здесь сняты на полном dev‑наборе (не на отфильтрованной подвыборке), чтобы сравнение было корректным. Свежий co‑train дал прирост по трём осям, но по‑русски скромный: 0.797 − 0.763 = 0.034, то есть 3,4 п.п. Наивное сравнение отфильтрованного V1 (0.712) с полным V2 (0.797) дало бы ложные 8,5 п.п.: это ошибка смешения выборок, которую легко не заметить. Основной вклад пришёлся на английский и mixed.
Hard negatives от BGE‑M3
Для второй итерации я использовал bge-m3 как teacher‑miner.
Для каждого запроса:
-
кодировал пул уникальных положительных документов;
-
брал top-50 по cosine similarity;
-
пропускал top-3 и известный gold;
-
выбирал шесть negatives из позиций 4–40.
В итоге 220 тыс. пар превратились в 220 тыс. triplets и 1,32 млн строк для MNRL. Финальный проход занял 814,5 секунды.
В следующем прогоне я бы использовал grouped negatives или NO_DUPLICATES batch sampler. Текущая развёртка по шесть строк допускает повторные anchors и positives внутри MNRL‑батча: они могут стать ложными in‑batch negatives. Это одна из возможных причин, почему дорогой этап почти ничего не добавил.
|
Ось |
V2 iter-1 |
V2 iter-2 |
|---|---|---|
|
RU, Recall@10 |
0.797 |
0.800 |
|
EN NanoBEIR, mean nDCG@10 |
0.340 |
0.341 |
|
Mixed diagnostic, Recall@10 |
0.605 |
0.624 |
|
OPUS-100 RU→EN, Recall@10 |
н/д |
0.777 |
|
OPUS-100 EN→RU, Recall@10 |
н/д |
0.751 |
Hard‑negative этап почти не изменил локальную RU‑метрику и NanoBEIR: 0.797 → 0.800, что на 1000-запросном RU‑харнессе означает три дополнительных попадания в top-10, и 0.340 → 0.341 на NanoBEIR, сдвиг того же масштаба; без confidence intervals такое лучше читать как «практически не изменилось». Единственное заметное изменение дало +0.019 на mixed‑диагностике, которая, как выяснилось позже, пересекается с train.
Наиболее сложный по инфраструктуре этап не обязательно даёт основной прирост: co‑train‑смесь оказалась важнее.
Проверка, на которой я поймал утечку
При подготовке статьи я заново сопоставил генератор train‑данных и mixed‑eval.
Оказалось, что оба скрипта читают один и тот же ru_stackoverflow train split, используют одинаковый фильтр и начинают с первых подходящих записей:
# trainif c > 50 and l > 30 and l / (c + l) > 0.15: rows.append(...) if nmix >= 60000: break# evalif c > 50 and l > 30 and l / (c + l) > 0.15: items.append(...) if len(items) >= 3000: break
Следовательно, mixed‑набор не является независимым holdout. В нём почти наверняка есть прямое пересечение с обучением V2. (Оговорка о проверяемости: train‑сторона этой цитаты лежит в опубликованном data_v2.py, а eval‑генератор mixed‑набора я не публиковал, но сам mixed из результатов и так исключён.)
Поэтому 0.624 нельзя подавать как доказательство обобщения на русско‑английскую техническую документацию. Я оставляю эту цифру только как внутреннюю метрику на наборе с пересечением с train, полезную для сравнения итераций одного эксперимента.
В основную сравнительную таблицу mixed‑ось не включаю. Для нормального утверждения о mixed/code нужен новый holdout из другого источника либо хотя бы жёсткое разделение dataset по идентификаторам до построения train.
Воспроизводимый benchmark всё равно может отвечать не на тот вопрос.
Как я сравнивал качество
Один общий «score модели» здесь был бы манипуляцией, потому что разные оси используют разные метрики.
Русский
Локальный harness на MIRACL‑ru dev:
-
фиксированные первые 1000 запросов;
-
dev passage corpus;
-
текст пассажей ограничен 2000 символами при подготовке;
-
encoder eval cap: 512 токенов;
-
метрика:
Recall@10.
Это не официальный полный MIRACL score, а одинаковая локальная проверка всех моделей. Жёсткого пересечения dev‑запросов с train‑запросами не было. Но в ранней 12-слойной линии 429 из 2635 dev‑gold passages, около 16%, встречались как training positives к другим запросам. Четырёхслойная V1 и финальная V2 наследуют веса этой линии. Поэтому 0.800 на полном наборе потенциально оптимистичен из‑за обнаруженного passage exposure.
Чтобы получить более консервативную оценку, я применил уже описанный выше фильтр (исключены запросы, у которых хотя бы один gold‑passage раньше встречался среди training positives) и прогнал на этой отфильтрованной подвыборке (758 из 1000 запросов) все модели в одном харнессе:
|
Модель |
RU Recall@10, полный (1000) |
RU Recall@10, отфильтр. (758) |
|---|---|---|
|
|
0.872 |
0.831 |
|
|
0.870 |
0.829 |
|
|
0.858 |
0.819 |
|
STRIZH |
0.800 |
0.751 |
|
|
0.678 |
0.633 |
Фильтр исключает только обнаруженные прямые совпадения первых 200 символов gold‑пассажа с известными train‑позитивами donor‑линии STRIZH: это не полный near‑duplicate‑аудит и он не контролирует обучающие и pretraining‑корпуса самих baseline‑моделей. Кроме того, отфильтрованная подвыборка просто сложнее: число падает у всех моделей, поэтому 0.800 − 0.751 = 0.049 нельзя читать как «цену утечки»: разница отражает и exposure, и возросшую сложность. Осторожная трактовка: полный набор потенциально оптимистичен из‑за обнаруженного passage‑exposure, а отфильтрованное число даёт более консервативную оценку. Дальше в сравнительной таблице я использую отфильтрованные числа. Сохранённый лог этого прогона (окружение, prefix‑политика каждой модели, все пять результатов) лежит в репозитории: eval/results-ru-filtered-2026-07-23.txt, скрипт лежит в eval/dev_clean_ru.py. Дополнительно ниже опираюсь на корпус собственного репозитория из другого домена.
Английский
NanoBEIR через MTEB. В таблице используется средний main_score, то есть nDCG@10, а не recall. Оговорка о прослеживаемости: для prefix‑моделей (USER2, mE5) я рассчитывал на prompt‑механику MTEB и зарегистрированные в model metadata промпты, но сохранённые артефакты не фиксируют фактические строки, поступавшие на вход. Поэтому английские значения baseline‑моделей я считаю ориентировочными. Для точной native‑mode таблицы нужен повторный прогон с явными query/document prefix‑обёртками.
OPUS-100: поиск парного перевода
1615 отфильтрованных параллельных RU‑EN пар. Для каждого текста модель ищет соответствующий перевод среди 1615 кандидатов; Recall@10 считался отдельно для RU→EN и EN→RU. Это узкий translation‑pair benchmark, а не общая оценка cross‑lingual retrieval.
Префиксы и pooling
В русском и OPUS-100 харнессах prefix policy и pooling задавались явно для каждой модели:
|
Модель |
Query |
Document |
Pooling |
|---|---|---|---|
|
STRIZH |
без префикса |
без префикса |
mean |
|
|
без префикса |
без префикса |
CLS |
|
|
|
|
как в model card |
|
|
|
|
mean |
|
|
без префикса |
без префикса |
нативный режим |
Это важно. На раннем черновом замере я принудительно применил mean pooling к rubert-tiny2 и занизил его. В финальном харнессе этот артефакт исправлен.
Результат: быстрее на моём Vulkan‑стеке, но не точнее крупных моделей
Разные столбцы ниже нельзя усреднять между собой: RU и OPUS-100 pair retrieval меряются в Recall@10, EN в nDCG@10.
|
Модель |
Параметры |
RU Recall@10 (отфильтр.) |
EN nDCG@10 |
OPUS-100 pair R@10 (ср. RU→EN/EN→RU) |
Vulkan, коротких HTTP‑запросов/с |
|---|---|---|---|---|---|
|
|
568M |
0.831 |
≈0.60 |
0.96 |
≈448* |
|
|
118M |
0.829 |
≈0.55 |
0.94 |
1837 |
|
|
34M |
0.819 |
≈0.53 |
0.95 |
2635 |
|
STRIZH |
24,4M |
0.751 |
0.341 |
0.76 |
≈4000 |
|
|
29M |
0.633 |
≈0.17 |
не измерял |
2362 |
RU‑столбец рассчитан на отфильтрованной подвыборке (758 из 1000 запросов MIRACL‑ru dev, closed‑candidate: 9274 пассажа, не полный MIRACL leaderboard), одинаково для всех моделей, и приведён с тремя знаками намеренно. Фактический разрыв bge-m3 и multilingual-e5-small составляет 0.0026, примерно два дополнительных попадания в top-10 на 758 запросах: содержательного победителя здесь выделять не стоит.
Значения со знаком ≈ округлены и считаются ориентировочными. Для USER2-small и multilingual-e5-small есть дополнительное ограничение: сохранённые артефакты не фиксируют фактические строки MTEB‑префиксов, а этим моделям префиксы обязательны. Собственное английское значение STRIZH снято мной и потому приведено точно. Колонка скорости относится к одной рабочей точке, concurrency 16 при -np 8. Две ячейки колонки опираются на опубликованные логи, а не на исходный пятимодельный прогон: у bge-m3 контрольный лог даёт 446–450 запросов/с (в исходном прогоне было 435, звёздочка про это), а воспроизведённые точки STRIZH лежат в диапазоне 3614–4450, поэтому в таблице стоит округлённое ≈4000. Значения multilingual-e5-small, USER2-small и rubert-tiny2 остаются из несохранившегося прогона: контрольных логов для них у меня нет.
USER2-small, multilingual-e5-small и bge-m3 лучше по качеству. Особенно велик разрыв на английском и в RU↔EN‑тестах. STRIZH является RU‑first моделью с частично сохранённым английским, а не полноценной двуязычной моделью.
Зато в протестированном Vulkan‑стеке он оказался самым быстрым из этой сетки и заметно обошёл rubert-tiny2 по русскому retrieval.
Но столбец скорости требует длинной сноски.
Что означают эти тысячи запросов в секунду
Замер сделан так:
-
AMD Strix Halo / Ryzen AI Max;
-
llama.cpp, backend Vulkan; -
один прогретый сервер;
-
concurrency 16;
-
один короткий текст на HTTP‑запрос;
-
steady‑state, не первый cold run;
-
STRIZH, USER2, mE5 и BGE использовали GGUF Q8_0;
-
rubert-tiny2брался в F16, потому что hidden size 312 не прошёл мой конкретный Q8-путь.
То есть это benchmark готовых deployment‑артефактов для коротких online‑запросов, а не чистое сравнение архитектур при одинаковой квантизации.
Тысячи коротких HTTP‑запросов в секунду не означают, что STRIZH индексирует тысячи длинных документов в секунду. Длина входа, batching и режим нагрузки полностью меняют результат.
Первый load‑test оказался непригоден из‑за cold‑start jitter. В таблицу пошёл повторный прогретый прогон. Для STRIZH p95 на коротком запросе был около 6 мс; у bge-m3 около 37 мс.
У этого замера два ограничения. Во‑первых, сырой лог исходного пятимодельного прогона не сохранился, но ключевую пару я перепрогнал контрольно и опубликовал лог с окружением, SHA-256 моделей и флагами серверов (eval/results-speed-strix-2026-07-26.txt): STRIZH дал 3614 emb/s прогретый и 4450 на sustained‑минуте против 446–450 у bge-m3, p95 6 против 37 мс; соотношение из таблицы воспроизвелось. Числа USER2/mE5/tiny2 остаются из моих заметок: их GGUF‑сборки лежат на офлайн‑ноде, клиентские скрипты (eval/loadtest_v2.py, eval/batch_thr.py) открыты.
Во‑вторых, число из таблицы относится к точке concurrency 16, и в опубликованных прогонах эта точка гуляет сильно. В контрольном логе два измеряемых прогона подряд дали 3028 и 3614 запросов/с (у первого p95 17,7 мс против 6,1 у второго, то есть сервер ещё не вышел на steady state, и в сравнение он не идёт), свип по конкуренции дал 4313, sustained‑минута 4450. Между вышедшими на режим точками разброс достигает 23% от нижней, поэтому честнее читать всё это как «около четырёх тысяч». Показательно, что bge‑m3 в том же логе стабилен (445,7 / 445,8 / 449,8), то есть общий дрейф хоста весь разброс STRIZH не объясняет. Конкретный источник вариативности лёгкой модели я отдельно не изолировал.
Свип по конкуренции для STRIZH и bge‑m3 (1–64) лежит в логе кривых; для rubert-tiny2 такого опубликованного свипа у меня нет, поэтому сравнивать их на высокой конкуренции я не берусь.
Всё выше относится к изолированным замерам каждого сервера, причём в одной рабочей точке: concurrency 16 при -np 8. Изолированным в смысле «нагрузка идёт только в один сервер»: соседние прод‑контейнеры при этом оставались резидентными на том же iGPU, просто простаивали, о чём написано в шапке лога. Точки насыщения у моделей разные (у STRIZH плато к conc 32, у bge‑m3 уже к conc 16), а свипы опубликованы только для этой пары, поэтому универсальный титул «самый быстрый эмбеддер» из таблицы не следует. Прямое доказательство исходной мотивации, то есть влияние на одновременно работающую LLM, идёт в следующем разделе.
Co‑resident: что embedding‑нагрузка делает с генерацией на самом деле
Изолированный throughput ещё не доказывает тезис «лёгкий эмбеддер возвращает GPU генерации». Поэтому я поднял Qwen3.6-35B-A3B (MoE: 35B всего, около 3B активных на токен; Q4_K_M, ~21 ГБ, llama.cpp/Vulkan) на тот же узел и померил напрямую: генерация 200 токенов (5 промптов на условие, медианы, temperature 0, streaming, TTFT по первому чанку) при разной embedding‑нагрузке на том же iGPU.
Дизайн нагрузки важен. Онлайн‑режим устроен как open‑loop с фиксированным QPS = 200: запросы уходят по таймеру независимо от скорости сервера, обе модели получают одинаковую работу (closed‑loop сломал бы сравнение, потому что быстрая модель просто сделала бы больше). Индексационная нагрузка синтетическая и идёт closed‑loop на полной тяге: 6 воркеров, батчи по 4 фрагмента ~350 токенов. Это профиль насыщения, а не однократная переиндексация реального корпуса.
|
Режим |
tok/s LLM |
Δ к baseline |
TTFT |
Эмбеддер под нагрузкой |
|---|---|---|---|---|
|
Без нагрузки |
71,3 |
н/д |
152 мс |
н/д |
|
+ STRIZH, 200 QPS онлайн |
69,8 |
−2% |
151 мс |
200,0 достигнутых rps, p95 3 мс |
|
+ |
66,3 |
−7% |
166 мс |
200,1 достигнутых rps, p95 52 мс |
|
+ STRIZH, индексационная нагрузка |
51,1 |
−28% |
189 мс |
83,2 батча/с, p95 77 мс |
|
+ |
5,8 |
−92% |
636 мс |
6,6 батча/с, p95 1,01 с |
|
Замыкающий baseline |
71,3 |
0% |
151 мс |
н/д |
Столбец tok/s здесь показывает серверную метрику llama.cpp (timings.predicted_per_second), скорость самой генерации без учёта доставки; клиентские значения из того же лога ниже примерно на 5% (по точкам матрицы от 1,7 до 5,3%; 67,6 против 71,3 на baseline). TTFT в соседнем столбце, наоборот, клиентский: время до первого SSE‑чанка.
При фиксированных 200 QPS деградация генерации осталась небольшой: около 2% со STRIZH и 7% с bge‑m3, причём обе модели действительно вытянули заданный темп (200,0 и 200,1 достигнутых запросов в секунду), то есть работа была одинаковой. Разница появляется под индексационной нагрузкой: тяжёлый эмбеддер на полной тяге практически останавливает генерацию 35B‑модели (5,8 tok/s против 71,3, при этом собственный p95 эмбеддера вырастает до секунды), а лёгкий стоит генерации 28% при p95 77 мс и обрабатывает в 12,6 раза больше батчей. Сравнивать здесь нужно осторожно: окна замера неравные, 20,6 секунды под нагрузкой STRIZH против 173,6 под нагрузкой bge‑m3, потому что окно задаётся временем пяти генераций, а при тяжёлом эмбеддере они идут дольше. Делить деградацию на достигнутый темп индексации я не стал: обе величины взаимозависимы и сняты в разных точках насыщения, так что осмысленной цены одного батча из них не получится.
Открывающий и замыкающий baseline практически совпали: 71,34 и 71,33 tok/s. Заметного расхождения конечных точек за время матрицы не видно.
Про флаги серверов нужно сказать отдельно, потому что это первый вопрос к любому A/B. Серверы поднимались асимметрично: у STRIZH -c 65536 -b 8192 -ub 8192, у bge‑m3 -c 8192 -b 4096 -ub 4096. Я это проконтролировал уже после прогонов, и с первого раза сделал контроль неправильно: погонял батчи последовательным клиентом, где в полёте около 1400 токенов, а они влезают в один ubatch при обеих конфигурациях. То есть я мерил ровно тот режим, в котором проверяемый флаг не может проявиться. Правильный контроль повторяет режим спорных прогонов: три closed‑loop воркера, батч 4, около 4200 токенов в полёте. Там bge‑m3 даёт 12,33 батча в секунду со своими флагами и те же 12,33 с флагами STRIZH, совпадение до сотых; при шести воркерах 11,64. STRIZH в том же режиме выдаёт 229 батчей в секунду. В этом closed‑loop контроле изменение -c/-b/-ub не поменяло throughput bge‑m3 вообще, значит наблюдаемый в этих прогонах разрыв меньшим ubatch не объясняется (eval/results-flags-control-strix-2026-07-28.txt).
Оговорки: это одна конфигурация и один прогон матрицы (по 5 промптов на условие), не многодневное исследование. В моём прогоне сборка b9049 не загрузила эту GGUF из‑за SSM‑тензоров, поэтому LLM запускалась на более свежем образе, а эмбеддеры остались на b9049. Опубликованы лог с окружением, SHA-256 моделей и флагами серверов, а также сам скрипт: eval/results-coresident-strix-2026-07-27.txt, eval/coresident_bench.py.
Полный RAG‑конвейер под мультиюзером
Отдельно я прогнал полный RAG‑конвейер под мультиюзером. Каждая транзакция проходит цепочку целиком: эмбеддинг запроса, косинусный поиск по векторному индексу из 99 чанков моей же документации (индекс строится тем же эмбеддером на старте), реранк восьми найденных кандидатов, top-4 в prompt и генерация 128 токенов. На iGPU одновременно резидентны Qwen, реранкер и оба embedding‑сервера; в каждой точке пользовательская и индексирующая нагрузка направляется только в один embedding‑эндпоинт. Окно измерения жёсткое: после дедлайна новые транзакции не стартуют, уже запущенные дожидаются, и в throughput идут только завершённые внутри окна. Это структурно полный RAG‑путь, но не тест масштабирования векторной базы: индекс содержит 99 чанков и ищется линейным cosine‑сканом в Python, поэтому сам поиск занимает 1–4 мс и ничего не говорит про Milvus, HNSW или миллионы документов.
Основной прогон: 4 пользовательских потока, окно 420 секунд на точку, каждая точка снята дважды, причём во втором раунде порядок точек обратный (контроль порядка и прогрева). В таблице медиана двух повторов, оба значения лежат в логе. Ошибок ни в одной точке не было.
|
Профиль |
Транзакций/мин |
e2e p50 |
TTFT p50 |
tok/s на поток |
Завершено в окне |
Индексация |
|---|---|---|---|---|---|---|
|
4 юзера, без индексации (STRIZH / bge‑m3) |
17,7 / 19,7 |
13,4 / 12,1 с |
7,9 / 6,9 с |
31,5 / 33,5 |
124 / 138 |
нет |
|
4 юзера + индексация, STRIZH |
14,0 |
16,6 с |
9,5 с |
22,8 |
98 |
46,0 батча/с, 772 прохода корпуса |
|
4 юзера + индексация, |
6,0 |
37,0 с |
13,7 с |
6,5 |
42 |
4,9 батча/с, 83 прохода |
Метрики в таблице разного происхождения: tok/s на поток это серверная timings.predicted_per_second, а e2e и TTFT клиентские, замеренные на стороне нагрузчика.
Читается так: фоновая индексация лёгким эмбеддером стоит юзерам 21% транзакций (17,7 → 14,0), тяжёлым 70% (19,7 → 6,0). Когда каждый эмбеддер работал на собственной полной тяге, STRIZH удержал в 2,3 раза больше пользовательских транзакций, прошёл корпус в 9,3 раза чаще и оставил генерации 22,8 токена в секунду на поток против 6,5. Отрыв по индексации здесь скромнее, чем 12,6 раза в предыдущем разделе, и это ожидаемо: там индексация была единственной нагрузкой на шести воркерах, здесь она идёт тремя воркерами и делит чип ещё и с четырьмя пользовательскими потоками. Полное время цепочки выросло с 13,4 до 16,6 секунды у STRIZH и с 12,1 до 37,0 секунды у bge‑m3.
Верхняя строка таблицы требует отдельного разбора. Без фоновой индексации bge‑m3 не проигрывает, а слегка выигрывает: 19,7 транзакции в минуту против 17,7 и e2e 12,1 против 13,4 секунды. Скорость самого эмбеддера тут ни при чём: эмбеддинг запроса стоит 3,4 мс у STRIZH против 30 мс у bge‑m3, то есть даже у тяжёлой модели это четверть процента от 12-секундной транзакции.
Разгадка оказалась в промпте, но добрался я до неё со второй попытки, и первая попытка полезнее результата. Сначала я померил длину извлечённого контекста в символах и получил у STRIZH +7,2% в среднем. Число выглядело объяснением, пока я не посмотрел на попарные разности: среднее держалось на двух запросах из десяти, по медиане попарной разности контекст STRIZH был даже короче на 44 символа, а длиннее он оказывался лишь в трёх случаях из десяти. Символы для этого не годятся вообще: prompt processing считается в токенах, корпус смешанный, русская проза с кодом и YAML, и символов на токен в двух выборках вышло разное, 2,84 против 3,05. Прокси не просто грубый, он перевернул знак эффекта.
Правильная метрика лежала в самом сервере. llama-server возвращает timings.prompt_n, фактическое число токенов промпта. Прогон той же цепочки последовательно, без конкуренции, десять запросов по три повтора на модель:
|
|
токенов промпта, медиана |
TTFT одиночного потока |
|---|---|---|
|
STRIZH |
1886 |
1973 мс |
|
|
1716 |
1825 мс |
На этих десяти запросах STRIZH подтянул в промпт больше токенов: медиана попарной разности +136, длиннее в семи запросах из девяти ненулевых. Сразу оговорюсь про силу утверждения, иначе повторю ошибку символьного прокси: знаковый тест на такой выборке не значим (7 из 9, p ≈ 0.18). Это наблюдение на конкретном корпусе, а не установленное свойство модели.
Зато связь между длиной промпта и TTFT в измеренном диапазоне 712–2175 токенов видна отчётливо. По 60 сырым замерам общий наклон около 0,94 мс на токен при R² = 0.973. Это говорит о существенном вкладе объёма prefill в одиночном прогоне, но не об универсальной линейности для других длин, сборок и уровней конкуренции.
Есть и приятная случайность: на одном из запросов обе модели после реранка собрали побайтово одинаковый промпт. Это не догадка по равной длине, а проверенный факт: у обоих промптов совпал SHA-256 и совпал сам набор отобранных чанков вместе с их порядком, 1799 токенов и 5619 символов (eval/results-prompt-identity-strix-2026-07-28.txt, остальные девять запросов дали разные промпты). TTFT на этой паре разошёлся на 22 мс, причём в пользу STRIZH. Это полезная проверка на вменяемость, но одна точка не исключает других зависящих от модели эффектов.
Полностью разрыв это не закрывает. Без конкуренции разница TTFT 149 мс, а под четырьмя потоками она вырастает примерно до секунды. Разделить вклад более длинного prefill и ожидания между слотами имеющийся контроль не позволяет: он снят на одиночном потоке, и раздельно эти два вклада я не мерил. Практический вывод из этого профиля скромнее лозунга: в конфигурации без фоновой индексации стоимость embedding‑стадии не определяла пропускную способность конвейера, а системная разница между моделями проявилась при одновременной индексирующей нагрузке. Обобщать это на любой RAG нельзя: индекс здесь 99 чанков, retrieval‑качество в этом прогоне вообще не измерялось, а по разделу про качество bge‑m3 находит лучше. Речь только про стоимость первой стадии на общем GPU.
Оговорка про статистику: два повтора на точку показывают направление и масштаб эффекта, но это не доверительный интервал SLO. Разброс между повторами очень неравномерен по метрикам, и его стоит привести целиком. Транзакции в минуту и e2e p50 стабильны везде, кроме одной точки: 0–4% на трёх профилях и 10–12% у bge‑m3 под индексацией (5,7 против 6,3 транзакции в минуту, e2e 34,8 против 39,1 секунды). Самая шумная метрика это генерация на поток: 1–2% на чистых профилях, 8% у STRIZH под индексацией и 28% у bge‑m3 под индексацией (5,6 против 7,4 токена в секунду). То есть числа 22,8 и 6,5 в таблице разделены кратно и порядок эффекта устойчив, но само значение 6,5 по двум повторам определено плохо. Более ранний короткий прогон с окном 90 секунд дополнительно свипал число юзеров от 1 до 8: без индексации обе модели там держались в одном коридоре 14–19 транзакций в минуту, что согласуется с выводом выше. Тот лог тоже опубликован.
Расширенные кривые (изолированный эмбеддер, отдельный лог): по конкуренции STRIZH выходит на плато ~4500 запросов/с к conc 32 (p95 около 11 мс), bge-m3 выходит на ~448 к conc 16 (p95 37 мс, а к conc 64 латентность растёт до 147 мс при том же потолке); по размеру батча на длинных ~350-токенных чанках STRIZH насыщается на ~600 текстов/с уже с батча 4 (одним последовательным клиентом; под тремя воркерами тот же сервер выдаёт больше), bge-m3 держится в коридоре ~35–58 (плато ~45).
Все артефакты этого раздела опубликованы. Прогоны и логи: eval/results-rag-final-strix-2026-07-27.txt, eval/results-rag-pipeline-strix-2026-07-27.txt, eval/results-embed-curves-strix-2026-07-27.txt, eval/pipeline_bench.py, eval/results-prompt-tokens-strix-2026-07-28.txt, eval/prompt_tokens_check.py, eval/analyze_prompt_tokens.py (наклон и R² считает он, а не я руками).
Негодный символьный контроль тоже оставлен в репозитории (context_len_check.py) как след того, что прокси перевернул знак. Каждый лог во второй строке шапки называет точный харнесс и его SHA-256. Для матричных прогонов там же указан оркестратор из eval/runners/; прямые контрольные замеры несут команды запуска либо явную пометку, что оркестратора нет.
На CPU rubert‑tiny2 быстрее
Чтобы не превратить Vulkan‑результат в универсальное утверждение, я отдельно прогнал PyTorch CPU (device=cpu, batch 32, max_seq 256, тексты до 400 символов, после прогрева) на 300 текстах:
|
Модель |
CPU, emb/s |
|---|---|
|
|
357 |
|
STRIZH |
265 |
|
|
5,6 |
На CPU rubert-tiny2 быстрее STRIZH примерно в 1,35 раза. У STRIZH пока нет отдельного ONNX/OpenVINO‑пути, поэтому называть его CPU‑чемпионом нельзя.
Его преимущество проявилось именно в целевом llama.cpp/Vulkan deployment на Strix Halo.
ModernBERT → GGUF: веса были самой простой частью
После обучения модель нужно было довести до рантайма, ради которого она создавалась.
С ModernBERT путь оказался не полностью автоматическим:
-
убрать MLM‑head и оставить чистый encoder;
-
исправить распознавание BPE pre‑tokenizer в конвертере;
-
добавить
layer_norm_epsilonв GGUF metadata; -
сохранить корректные RoPE и layer types;
-
явно включить mean pooling;
-
проверить retrieval после конвертации, а не только cosine пары случайных предложений.
Первая версия команды сервинга финального Q8_0 выглядела так:
llama-server \ -m strizh-ru-retriever.Q8_0.gguf \ --embeddings \ --pooling mean \ -ngl 999 \ -c 8192 \ -b 4096 \ -ub 4096 \ -np 8
И сразу ловушка того же класса, что и весь сюжет этой статьи. В использованной сборке (b9049) -c задаёт суммарный контекст сервера, который делится между -np слотами. При -c 8192 -np 8 каждый слот получает всего 1024 токена, и вход длиннее вернёт 400 exceed_context_size_error; я поймал это на собственном задеплоенном сервере. Вторая граница: для эмбеддингов весь вход должен помещаться в один ubatch (-ub). Рабочая версия для документов до 8192 токенов при восьми слотах:
llama-server \ -m strizh-ru-retriever.Q8_0.gguf \ --embeddings \ --pooling mean \ -ngl 999 \ -c 65536 \ -b 8192 \ -ub 8192 \ -np 8
Проверено: входы на ~5000 и ~8100 токенов проходят. Жёсткий лимит живёт не только в tokenizer config.
Флаг --pooling mean обязателен. С CLS pooling мой retrieval‑harness падал примерно до Recall@10 = 0.02.
Модель при этом не «слегка ухудшалась». Она фактически переставала быть тем retriever, который обучался.
Та же грабля была в vLLM: без явного pooler config рантайм мог выбрать CLS. Рабочий запуск:
vllm serve AGmind/strizh-ru-retriever \ --pooler-config '{"pooling_type":"MEAN"}' \ --max-model-len 8192
Вывод здесь шире конкретного флага:
Контракт embedding‑модели включает веса, tokenizer, pooling, нормализацию, truncation и runtime. Один неверный default меняет модель сильнее, чем ещё одна эпоха обучения.
Почему Q8_0 почти такого же размера, как Q4_0
Получились такие GGUF‑файлы:
|
Формат |
Размер |
RU Recall@10, полный dev |
|---|---|---|
|
F16 |
52 МБ |
0.800 |
|
Q8_0 |
29 МБ |
0.800 |
|
Q5_0 |
27 МБ |
0.790 |
|
Q4_0 |
26 МБ |
0.785 |
Качество по квантам мерено тем же dev‑харнессом через llama-server, но сырого лога этого прогона я не выложил, поэтому числа идут на тех же правах, что и колонка скорости для внешних baseline: из моих заметок, а не из опубликованного файла.
Квантизация ниже восьми бит почти ничего не экономит: разница между Q8_0 и Q4_0 составляет около 3 МБ.
Причина снова в словаре на 50 368 токенов. Таблица embeddings доминирует в файле и не сжимается так же выгодно, как большая масса transformer‑блоков у LLM.
В моём тракте K‑quants также не собрались для размеров hidden 384 / intermediate 576: они не укладывались в требуемые super‑block размеры, и оставались legacy‑кванты.
Для 29-мегабайтной модели экономить ещё три мегабайта ценой риска деградации бессмысленно, поэтому рекомендованным релизным квантом сделал Q8_0 (в GGUF‑репозитории для полноты лежат также F16, Q5_0 и Q4_0, но опускаться ниже Q8_0 незачем).
Одна цифра, которая почти испортила модель
К этому моменту checkpoint был обучен, GGUF собирался, Vulkan‑сервер отвечал, короткий dev‑harness показывал 0.800. Можно было считать работу законченной.
Затем я проверил модель на собственном RAG‑корпусе и увидел странно слабый результат на длинных документах.
Причина оказалась в опубликованном tokenizer_config.json:
{ "model_max_length": 256}
Это значение осталось от обучающего режима. База архитектурно поддерживает до 8192 токенов, но клиенты, которые вызывали tokenizer с truncation=True и не передавали собственный max_length, брали лимит из tokenizer config и молча обрезали вход до 256 токенов.
Важно не подменять одну ошибку другой. Замена 256 на 8192 не превращает STRIZH в специально long‑context‑finetuned retriever. Финальный contrastive train всё равно был на 256 токенах. Исправление только убирает случайный жёсткий cap и позволяет модели использовать унаследованную от базы способность обрабатывать более длинный вход.
На dogfood‑тесте (163 вопроса по корпусу моего репозитория, сам харнесс подробно описан ниже, в разделе про dogfood) с eval cap 1024 получилась такая разница (в RU‑харнессе пассажи заранее обрезаны до 2000 символов, там хватает cap 512; dogfood‑чанки медианно ~631 токен, поэтому общий для всех моделей cap 1024, иначе не увидеть эффект cap 256):
|
Режим одного checkpoint |
Recall@10 |
После rerank |
|---|---|---|
|
STRIZH, вход до 1024 |
0.589 |
0.620 |
|
STRIZH, принудительно 256 |
0.448 |
0.528 |
|
|
0.460 |
0.528 |
Одна цифра в конфиге уронила retrieval на 0.141, на 14,1 процентного пункта. В ошибочном режиме STRIZH опустился до уровня rubert-tiny2, который должен был заменить: формально даже чуть ниже (0.448 против 0.460), но это разница в два вопроса из 163, то есть в пределах шума, а после rerank ровно поровну.
Про воспроизводимость именно этой таблицы нужно сказать прямо: на большинство замеров в статье выложены сырые логи, а на эту таблицу лога нет. Опубликован харнесс целиком, но не сам прогон: dogfood‑корпус собран из моего репозитория, а 163 вопроса сгенерированы к нему локальной LLM, и этот набор я не выкладывал. Проверить число на моих данных нельзя, повторить процедуру на своих можно: харнесс натравливается на любой репозиторий одной командой.
Не каждая страта ухудшилась одинаково: на небольшом synthetic cross‑lingual поднаборе cap 256 неожиданно дал лучший результат. Я не вижу оснований делать из этого содержательный вывод: набор маленький и синтетический. Зато overall, long и основная русская часть показали очевидную потерю от обрезания.
Я исправил model_max_length и добавил dogfood‑прогон в release checklist. Если модель предназначена для RAG, её нужно хотя бы раз прогнать через тот путь, по которому реально идут документы пользователя.
Чек‑лист перед релизом своего эмбеддера
Вот этот чек‑лист целиком, чтобы не собирать по тексту. Каждый пункт оплачен реальной граблей: большинство из этой статьи, пара из соседних релизов той же линейки:
-
model_max_lengthвtokenizer_config.jsonравен архитектурному лимиту, а не train max length (у меня: 256 вместо 8192 → −14,1 п.п. на dogfood‑обкатке); -
pooling зафиксирован в каждом целевом рантайме: ST‑конфиг,
llama-server --pooling, vLLM--pooler-config(CLS вместо mean →Recall@10 ≈ 0.02); -
после каждой конвертации (GGUF, квант) прогнан retrieval‑харнесс, а не только cosine на паре предложений;
-
каждый опубликованный квант замерен тем же quality‑харнессом, а не просто собран;
-
eval проверен на пересечение с train по всей линии весов, включая donor, а не только по датасету последнего прогона;
-
команды из model card выполнены на уже опубликованном артефакте, так, как их выполнит чужой человек (у меня так всплывал сломанный
tokenizer_classпод vLLM); -
модель прогнана через реальный RAG‑путь с документами реальной длины, а не только через eval с обрезанными пассажами;
-
в карточке нет превосходных степеней («самый быстрый», «на всех осях»), не покрытых исчерпывающим замером.
Dogfood: проверка на собственном репозитории
Для отдельной прикладной проверки я собрал корпус из собственного репозитория AGmind:
-
README и русская документация;
-
Python;
-
service YAML;
-
Ansible;
-
англоязычные идентификаторы и команды;
-
длинные конфигурационные фрагменты.
Получилось 1699 чанков. Этот репозиторий я не включал в retrieval‑датасеты STRIZH, и он относится к другому прикладному домену (я не заявляю полный provenance‑аудит базовых и teacher‑моделей).
Для него локальная Qwen‑модель (Qwen3.6-35B-A3B, Q4_K_M через llama.cpp; temperature 0,3, фиксированный seed выборки чанков) сгенерировала 163 вопроса по четырём стратам. Правило gold: вопрос генерируется по конкретному чанку с требованием, чтобы ответ содержался именно в нём; этот чанк и назначается единственным gold. Такой single‑gold может занижать recall, если другой чанк тоже содержит ответ, причём не обязательно одинаково для всех моделей. Отдельного аудита всех 163 пар в сохранённых материалах нет. Чанкер рекурсивный по абзацам: целевой размер 2200–2600 символов в зависимости от типа файла, overlap 150–200 символов; грубая оценка медианного размера чанка: около 631 токена.
|
Страта |
Вопросов |
|---|---|
|
Русская документация |
55 |
|
Русский → код/конфиг |
28 |
|
Длинные документы |
40 |
|
Cross‑lingual |
40 |
После dense retrieval я отдавал top-20 в bge-reranker-v2-m3 и считал попадание одного заранее назначенного gold chunk в top-10.
|
Модель |
Retrieval Recall@10 |
После rerank |
|---|---|---|
|
|
0.791 |
0.816 |
|
|
0.650 |
0.712 |
|
STRIZH |
0.589 |
0.620 |
|
|
0.460 |
0.528 |
|
STRIZH с ошибочным cap 256 |
0.448 |
0.528 |
По отдельным стратам до rerank:
|
Страта |
STRIZH |
tiny2 |
USER2 |
bge‑m3 |
|---|---|---|---|---|
|
Русская документация |
0.673 |
0.491 |
0.655 |
0.782 |
|
Русский → код |
0.750 |
0.714 |
0.786 |
0.857 |
|
Длинные документы |
0.600 |
0.575 |
0.575 |
0.825 |
|
Cross‑lingual |
0.350 |
0.125 |
0.625 |
0.725 |
Этот тест подтвердил основное позиционирование:
-
STRIZH увереннее
rubert-tiny2на русском техническом корпусе; -
более крупные
USER2-smallиbge-m3в целом лучше; -
cross‑lingual остаётся слабым местом;
-
reranker не может вернуть документ, который retriever вообще не включил в кандидаты.
Но и эту проверку нельзя превращать в «реальный production benchmark». Вопросы синтетические, а gold для каждого вопроса один. В репозитории могут быть другие релевантные чанки, которые метрика посчитает ошибкой. К тому же вопросы генерировались тем же приёмом (LLM‑вопрос по конкретному пассажу), что и GPL‑часть обучения STRIZH: это могло сместить сравнение в его пользу. Поэтому dogfood здесь работает как независимый sanity‑check и способ поймать интеграционные дефекты, а не замена человеческой evaluation‑разметке.
Весь этот харнесс опубликован: eval/rag-battletest: конвейер build_corpus → gen_gold → embed → eval_retrieve → eval_answer. Он натравливается на любой ваш репозиторий (python build_corpus.py /path/to/repo) и меняет только эмбеддер, держа корпус, чанкер, вопросы, reranker и answering‑LLM фиксированными. То есть сравнить кандидатов можно на собственных документах, а не на моих цифрах.
Где STRIZH имеет смысл
После всех замеров область применения получилась достаточно узкой и поэтому понятной.
STRIZH имеет смысл, когда одновременно выполняются несколько условий:
-
корпус в основном русский;
-
dense retriever делит один GPU с LLM и reranker;
-
корпус регулярно переиндексируется, то есть эмбеддер борется за GPU не только на онлайн‑запросах (по замерам именно это, а не латентность одиночного запроса, решает исход);
-
нужен единый no‑prefix embedding contract;
-
модель работает внутри hybrid retrieval и/или перед reranker;
-
качество
rubert-tiny2уже недостаточно, а более крупный retriever слишком дорог по compute.
Не стоит брать STRIZH, когда:
-
нужен максимум retrieval‑качества независимо от размера;
-
основная нагрузка англоязычная;
-
важен cross‑lingual поиск EN↔RU;
-
можно безболезненно использовать query/document prefixes;
-
inference идёт только на CPU;
-
bge-m3илиUSER2-smallуже укладываются в latency и не мешают LLM.
В этих случаях более крупная модель будет рациональнее.
Что в итоге получилось
Финальная модель:
|
Параметр |
STRIZH |
|---|---|
|
Параметры |
24,4 млн |
|
Transformer‑слои |
4 |
|
Hidden / output dim |
384 |
|
Словарь |
50 368 |
|
Архитектурный context limit |
8192 |
|
Train max length |
256 |
|
Pooling |
mean |
|
Нормализация |
L2 |
|
Префиксы |
не требуются |
|
Retrieval |
dense single‑vector |
|
Основной язык |
русский |
|
Целевой deployment |
GGUF / llama.cpp / Vulkan |
Я не получил маленький retriever, который заменяет крупные модели во всех сценариях. Получился другой компромисс:
-
существенно лучше
rubert-tiny2на русском retrieval; -
самый высокий isolated short‑query throughput среди проверенных вариантов на моём Strix Halo/Vulkan‑стеке;
-
заметно слабее крупных моделей на английском и cross‑lingual поиске;
-
не лучший вариант для CPU;
-
крайне чувствительный к корректным pooling и truncation‑настройкам.
Главные выводы для меня оказались не про конкретную архитектуру.
-
Снижение MSE к векторам учителя само по себе не дало retrieval. MSE стабильно снижался, но retrieval остался почти мёртвым:
Recall@10 = 0.029. -
Рост сложности сигнала дал больше, чем рост объёма того же сигнала. В donor‑линии hard negatives дали +0,9 п.п. (
0.737 → 0.746) там, где 2,44-кратный рост корпуса дал +0,3; обе дельты малы, и вывод локален для donor‑линии: в V2 главный вклад дал состав свежих данных, а не mining (см. урок 4). -
Сокращать слои нужно вместе с архитектурным config. Для ModernBERT важны не только веса блоков, но и local/global layer types и RoPE.
-
Самый дорогой этап не обязан дать главный прирост. В V2 обычный co‑train улучшил модель сильнее второго прохода с BGE‑M3 hard negatives.
-
Evaluation может быть воспроизводимой и всё равно загрязнённой. Mixed‑score пришлось разжаловать из результата во внутреннюю диагностику после проверки пересечения с train. Причём exposure наследуется через веса: датасет финального прогона выглядел чистым, а пересечение сидело в donor‑линии, поэтому проверять нужно всю цепочку donor → student, а не только последний train.
-
Модель заканчивается не на checkpoint. Mean вместо CLS и корректный
model_max_lengthоказались важнее нескольких пунктов, добытых обучением.
Ссылки
ссылка на оригинал статьи https://habr.com/ru/articles/1064138/