Перейти к содержанию
Новое AiManual теперь в MAX Подписаться
Публикация AiManual

Оптимизация эмбеддеров для RAG: как ужать русский retriever до 24 млн параметров и не потерять качество

Как создать быстрый русский dense-эмбеддер для RAG: обрезка RuModernBERT-small до 4 слоёв, контрастивное обучение и разбор ошибок, которые стоили 14 п.п. recall

Коротко

Что будет в материале

  1. 01

    Зачем нужен легкий эмбеддер: проблема «тяжелых» retrievers в RAG

  2. 02

    Архитектура STRIZH: как мы обрезали RuModernBERT-small до 4 слоёв

  3. 03

    Типичные ошибки при оптимизации эмбеддеров и как мы их обошли

  4. 04

    Бенчмарки: STRIZH против тяжелых аналогов на русском retrieval

Зачем нужен легкий эмбеддер: проблема «тяжелых» retrievers в RAG

Retrieval-Augmented Generation стал стандартом для фактологически точных ответов LLM. Схема простая: retriever находит релевантные документы, генеративная модель пишет ответ на их основе. Но за простотой скрывается узкое место - сам retriever. Стандартные dense-эмбеддеры на базе BERT-подобных архитектур содержат 12 и более слоёв, что напрямую бьёт по скорости индексации и инференса.

Типичный сценарий: вы разворачиваете RAG-систему на одном GPU. Тяжелый эмбеддер отъедает вычислительные ресурсы, снижая throughput генеративной LLM. Индексация 100 тысяч документов занимает часы. При совместном размещении latency ответа растёт, а количество обрабатываемых запросов в секунду падает. Решение - облегчить retriever без катастрофической потери качества поиска.

Модель STRIZH (24,4 млн параметров) закрывает эту проблему. Она получена обрезкой 12-слойного RuModernBERT-small до 4 слоёв с последующим контрастивным обучением. Результат: на русском retrieval качество почти не отличается от тяжелых аналогов, индексация ускоряется в 9-10 раз, а throughput генеративной LLM при совместном размещении практически не снижается. Разберём процесс создания, ошибки, которые стоили 14 процентных пунктов recall, и практическое внедрение.

Архитектура STRIZH: как мы обрезали RuModernBERT-small до 4 слоёв

Исходная модель - RuModernBERT-small, 12 трансформерных слоёв, обученная на русскоязычных текстах. Задача: получить максимально легкий эмбеддер, сохранив качество retrieval. Выбор пал на обрезку слоёв, а не на дистилляцию. Почему - разберём отдельно.

Процесс механически прост. Из 12 слоёв оставляем первые 4, остальные 8 удаляем. Веса оставшихся слоёв инициализируются из предобученной модели. Итоговое количество параметров - 24,4 млн. Это примерно втрое меньше исходных ~70 млн у RuModernBERT-small. Но простая обрезка разрушает качество эмбеддингов: модель теряет способность строить семантически осмысленные векторные представления. Нужно дообучение.

Почему именно обрезка, а не дистилляция?

Дистилляция требует teacher-модель, которая генерирует целевые эмбеддинги для student-модели. Это удваивает вычислительный бюджет на этапе обучения: teacher должен прогнать весь датасет, часто с большим batch size. Обрезка лишена этого недостатка - веса уже есть, нужно только восстановить качество после удаления слоёв.

Минус обрезки: потеря знаний, накопленных в верхних слоях. В BERT-подобных архитектурах верхние слои кодируют высокоуровневые семантические признаки, критически важные для retrieval. Без дообучения обрезанная модель даёт эмбеддинги, близкие к случайным. Поэтому следующий шаг - контрастивное обучение.

Контрастивное обучение: ключ к сохранению качества retrieval

Контрастивное обучение решает задачу напрямую: модель учится сближать векторы семантически близких текстов и разносить векторы нерелевантных. Функция потерь - InfoNCE (Noise Contrastive Estimation). Для каждого запроса positive-пара - релевантный документ, negative-пары - нерелевантные документы из батча.

Датасет для дообучения собран из русскоязычных пар «запрос-документ»: новостные статьи, техническая документация, FAQ. Размер батча - 64, learning rate - 2e-5, количество эпох - 3. После контрастивного обучения модель STRIZH восстанавливает качество retrieval до уровня, сопоставимого с полным RuModernBERT-small. Разница в recall@5 - менее 2 процентных пунктов.

Для понимания контекста: аналогичные подходы к облегчению моделей применяются и в других задачах. Например, методология A.L.F.R.E.D. показывает, как дистилляция шаблонов позволяет моделям на 2B параметров превзойти 35B-гигантов в эффективности. В нашем случае обрезка слоёв даёт схожий выигрыш по скорости без сложной инфраструктуры дистилляции.

Типичные ошибки при оптимизации эмбеддеров и как мы их обошли

Создание STRIZH не было гладким. Три ошибки едва не похоронили проект. Каждая из них - самостоятельный урок для ML-инженера, работающего с retrieval-системами.

Почему MSE не работает для retrieval: история одного провала

Первая идея выглядела логично: обучим обрезанную модель предсказывать эмбеддинги teacher-модели (полного RuModernBERT-small) через регрессию на MSE. Результат - катастрофа. Качество retrieval упало ниже приемлемого уровня.

Причина в природе функции потерь. MSE минимизирует попиксельное (в нашем случае - поэлементное) расстояние между векторами. Но retrieval опирается на относительные расстояния: важно, чтобы вектор запроса был ближе к правильному документу, чем к неправильным. MSE игнорирует эту структуру. Контрастивный loss, напротив, оптимизирует относительный порядок - и это работает. Оценка качества embedding-моделей - отдельная сложная тема; новый бенчмарк RTEB 2026 как раз решает проблему разрыва между лабораторными метриками и реальной производительностью.

Leaky evaluation: когда тестовые данные подглядывают в обучение

После перехода на контрастивное обучение метрики взлетели. Recall@5 показал +8 процентных пунктов к baseline. Эйфория длилась недолго - при ручной проверке качество поиска оказалось посредственным. Диагноз: leaky evaluation.

Проблема в пересечении документов между train и test. Один документ может содержать несколько фактов, релевантных разным запросам. Если документ попал в train, модель запоминает его содержание, а не учится семантическому поиску. На тесте она находит «знакомый» документ, а не релевантный по смыслу. Решение: разбиение датасета по документам, а не по парам «запрос-документ». Все запросы, связанные с одним документом, попадают либо в train, либо в test. После исправления recall@5 упал на 6 пунктов - это была реальная оценка, а не завышенная утечкой данных.

Как model_max_length в 256 токенов убил 14 п.п. recall

Самая неочевидная ошибка. После обрезки модели захотелось дополнительно ускорить инференс. Решение: уменьшить model_max_length в tokenizer_config.json с 512 до 256 токенов. Логика простая - меньше токенов, быстрее вычисления. Результат: recall рухнул на 14 процентных пунктов.

Анализ показал, что многие русскоязычные документы в датасете длиннее 256 токенов. Обрезание отбрасывало вторую половину текста, где часто находилась ключевая информация для retrieval. Модель просто не видела релевантные фрагменты. Решение: вернуть model_max_length к исходному значению 512. Ускорение от обрезки токенов оказалось мнимым - потеря качества перечеркнула весь выигрыш. Вывод: параметры токенизатора критичны, их изменение требует обязательной валидации на целевом датасете.

Бенчмарки: STRIZH против тяжелых аналогов на русском retrieval

Сравнение проводилось на трёх русскоязычных датасетах: новостные статьи, техническая документация и пользовательские вопросы из FAQ. Baseline - полный RuModernBERT-small (12 слоёв) и multilingual-e5-base.

Качество retrieval: почти без потерь

Модель Recall@1 Recall@5 Recall@10 MRR
RuModernBERT-small (12 слоёв) 0.72 0.89 0.93 0.80
multilingual-e5-base 0.70 0.87 0.91 0.78
STRIZH (4 слоя) 0.70 0.87 0.91 0.78

Разница между STRIZH и полным RuModernBERT-small - 2 процентных пункта по recall@1 и recall@5. Для большинства RAG-сценариев это приемлемая цена за десятикратное ускорение.

Скорость индексации и инференса: ускорение в 9-10 раз

Модель Документов/сек (индексация) Запросов/сек (инференс)
RuModernBERT-small (12 слоёв) 120 85
STRIZH (4 слоя) 1150 810

Индексация ускоряется в 9.6 раз, инференс - в 9.5 раз. Замеры на NVIDIA A10, batch size 32, документы средней длиной 350 токенов.

Совместное размещение с генеративной LLM: влияние на throughput

Эксперимент: на одном GPU (NVIDIA A10) одновременно работают генеративная LLM (Qwen2.5-7B) и retriever. Замеряем throughput генеративной модели в токенах/сек при фоновой нагрузке от эмбеддера.

Конфигурация Throughput LLM (токенов/сек)
LLM без эмбеддера 45
LLM + RuModernBERT-small (12 слоёв) 28 (-38%)
LLM + STRIZH (4 слоя) 42 (-7%)

STRIZH снижает throughput генеративной LLM всего на 7%, тогда как полный RuModernBERT-small - на 38%. Для production-среды с ограниченными GPU-ресурсами это решающий фактор.

Практическое руководство: как внедрить STRIZH в ваш RAG пайплайн

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

Загрузка модели и tokenizer: правильная конфигурация

from transformers import AutoTokenizer, AutoModel
import torch

model_name = "ai-manual/strizh-embedder-v1"
tokenizer = AutoTokenizer.from_pretrained(model_name)

# КРИТИЧНО: не переопределяйте model_max_length без валидации
tokenizer.model_max_length = 512

model = AutoModel.from_pretrained(model_name)
model.eval()
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)

Проверьте tokenizer_config.json после загрузки. Если model_max_length переопределён на 256 или иное значение - верните 512. Эта ошибка стоила 14 п.п. recall, не повторяйте её.

Индексация документов и поиск: минимальный рабочий пример

import faiss
import numpy as np

def get_embedding(text):
    inputs = tokenizer(text, return_tensors="pt", truncation=True, max_length=512, padding=True)
    inputs = {k: v.to(device) for k, v in inputs.items()}
    with torch.no_grad():
        outputs = model(**inputs)
    # mean pooling
    attention_mask = inputs["attention_mask"]
    embeddings = (outputs.last_hidden_state * attention_mask.unsqueeze(-1)).sum(1)
    embeddings = embeddings / attention_mask.sum(1, keepdim=True)
    return embeddings.cpu().numpy()

# Индексация
documents = ["Документ 1 текст...", "Документ 2 текст...", "..."]
doc_embeddings = np.vstack([get_embedding(doc) for doc in documents])
index = faiss.IndexFlatIP(doc_embeddings.shape[1])
index.add(doc_embeddings.astype('float32'))

# Поиск
query = "Как оптимизировать эмбеддер?"
query_embedding = get_embedding(query)
scores, indices = index.search(query_embedding.astype('float32'), k=5)
print(f"Top-5 документов: {indices[0]}")

Для production-среды используйте батчинг. Оптимальный batch size для STRIZH на GPU с 16 ГБ VRAM - 64. Это даёт максимальную утилизацию без out-of-memory.

Ограничения STRIZH и когда стоит выбрать более тяжелую модель

STRIZH не универсальное решение. Сценарии, где она уступает:

  • Документы длиннее 512 токенов. Модель обучена с model_max_length=512. Для длинных документов используйте чанкинг или более тяжелый эмбеддер с поддержкой extended context.
  • Узкоспециализированные домены без дообучения. Медицина, юриспруденция, технические стандарты - если данные сильно отличаются от общего русского языка, дообучите STRIZH на доменных парах «запрос-документ». Процесс контрастивного обучения описан выше, он применим и к вашим данным.
  • Требования к максимальному recall. Если потеря 2 п.п. recall критична (например, legal-tech с обязательностью нахождения всех релевантных документов), используйте полный RuModernBERT-small или ансамбль моделей.

Для оценки качества retrieval на ваших данных используйте метрики recall@k и MRR. Избегайте leaky evaluation: разбивайте датасет по документам, а не по запросам. Подробнее о метриках и подходах к оценке - в разборе бенчмарка RUMBA, где аналогичные принципы применяются к диалоговым системам.

STRIZH - это компромисс, выверенный практикой. 24,4 млн параметров, 9-10-кратное ускорение индексации и минимальное влияние на throughput генеративной LLM делают её рабочим выбором для продакшена. Три ошибки, которые мы совершили в процессе - MSE-регрессия, leaky evaluation и обрезание model_max_length - обошлись в недели отладки. Не наступайте на те же грабли.

Подписаться на канал