- Главная
- Блог
- Как поменять «мозг» RAG-системы и не уронить поиск: миграция эмбеддингов на живом проекте
Как поменять «мозг» RAG-системы и не уронить поиск: миграция эмбеддингов на живом проекте

Утро понедельника. Руководитель отдела продаж пишет в чат поддержки: «Поиск сломался. Ищу „поставщик гидравлики“ — выдаёт стоматологов и автосервис. Верните как было». Иду смотреть — за выходные катнули новую модель эмбеддингов. На синтетических тестах она была ощутимо лучше, а на проде — жалобы посыпались с первого рабочего часа.
Такое случалось не только у нас. Когда клиенты приходят с запросом «настрой ИИ-агента, чтобы искал по нашей базе», за красивой формулировкой почти всегда прячется одна и та же боль: RAG-система когда-то была собрана на определённой модели эмбеддингов, база проиндексирована, всё работает — а теперь надо поменять «мозг». Метрики на тестовом датасете говорят «лучше», пользователи говорят «хуже», а векторный индекс на пару миллионов документов не согласен ни с теми, ни с другими — он просто продолжает отвечать так, будто старая модель никогда никуда не уходила. Расскажу, как переезжаем без даунтайма и истерик в чате поддержки.
Почему вообще приходится менять модель
Эмбеддинги в проде живут долго. Один из наших заказчиков — дистрибьютор промышленного оборудования — индексировал каталог на sentence-transformers paraphrase-multilingual-MiniLM-L12-v2. Жил с этим года два. Потом появились задачи, на которых старая модель начала проседать: поиск по техническим описаниям с числовыми параметрами и поиск на смеси русского и английского (артикулы, торговые марки). Плюс сверху навесили ИИ-агента, который должен был не просто искать, а собирать ответ по нескольким документам — и его точность упиралась в качество ретривера.
Причин для миграции обычно три. Первая — качество: вышла модель, которая лучше понимает доменный язык. Вторая — лицензия и импортозамещение: переход на эмбеддинги GigaChat, YandexGPT или отечественные опенсорс-варианты (ru-en-RoSBERTa, USER-base, multilingual-e5). Это, кстати, отдельный повод пересмотреть весь пайплайн — многие заказчики только сейчас разбираются, какие вообще доступные ИИ-агенты можно собрать на российском стеке без риска однажды упереться в отключённый API. Третья — размерность и скорость: уменьшить вектор с 1024 до 384 измерений — это втрое меньше места в pgvector и заметно быстрее HNSW-поиск.
Дальше начинается весёлое. Эмбеддинги от разных моделей живут в разных векторных пространствах. Это не вопрос «новая модель чуть точнее» — это другая система координат. Косинусное расстояние между вектором запроса от модели А и вектором документа от модели Б — математически валидное число, физически — мусор.
Почему «остановить мир и переиндексировать» — плохая идея
Самый честный план выглядит так: ночью выключаем поиск, прогоняем всю базу через новую модель, перезаписываем колонку embedding, запускаем заново. На бумаге — час работы. Первый раз мы так и попробовали.
Получилось грустно. Прогон через эмбеддинг-API занял не час, а полсмены — упирались в rate limit, ловили таймауты, часть батчей уходила на ретрай. Перестройка HNSW-индекса в pgvector тоже съела заметно больше, чем ожидали. Часть карточек — небольшая, но неприятная — вообще упала с ошибкой парсинга: поля с битой кодировкой, которые старый индекс молча скушал, а новая модель отказалась переваривать.
А на утро менеджеры пожаловались, что поиск по свежим артикулам «как будто забыл всё, что вчера загружали». Это было самое обидное. Пока мы всю ночь пересчитывали эмбеддинги, в систему продолжали приходить новые карточки и заявки. Они индексировались старой моделью — код-то ещё не перекатили — записывались в ту же колонку, а потом затирались скриптом миграции, который работал по слепку «на момент старта». Классический race condition, который при ручной ночной миграции не сразу замечаешь: ошибка тихая, лога нет, данные просто исчезли.
После этого случая мы дали себе слово так больше не делать.
Двухколоночная схема: пусть старое и новое сосуществуют
Правило, которое родилось из того инцидента: эмбеддинги от разных моделей живут в разных колонках, миграция идёт фоном, переключение — флагом в конфиге. Никаких «ночей длинных ножей».
Схема таблицы получается примерно такая:
ALTER TABLE documents
ADD COLUMN embedding_v2 vector(1024),
ADD COLUMN embedding_v2_model text,
ADD COLUMN embedding_v2_built_at timestamptz;
CREATE INDEX documents_embedding_v2_hnsw
ON documents USING hnsw (embedding_v2 vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Старая колонка embedding (vector(384)) живёт как жила. Новая пустая, ждёт фонового воркера. Поиск пока ходит в старую. Пользователи ничего не замечают — и это цель.
Параллельно поднимается воркер, который пачками берёт записи с embedding_v2 IS NULL, прогоняет через новую модель, обновляет строку. Размер батча подбираем под лимиты API, ошибки складываем в отдельную таблицу embedding_migration_errors с причиной — потом разберём руками. Скорость управляемая, нагрузка на эмбеддинг-API контролируется, ничего не горит.
Важный момент, который легко забыть: новые документы, которые приходят во время миграции, должны индексироваться обеими моделями сразу. Иначе образуется дыра — вечером всё было ок, к утру пара сотен свежих карточек не находится ни там, ни сям. На уровне приложения это пишется как:
async def index_document(doc_id: int, text: str):
vec_v1 = await embed_v1(text)
vec_v2 = await embed_v2(text)
await db.execute("""
UPDATE documents
SET embedding = $1,
embedding_v2 = $2,
embedding_v2_model = $3,
embedding_v2_built_at = now()
WHERE id = $4
""", vec_v1, vec_v2, MODEL_V2_NAME, doc_id)
Двойная запись — это плата за бесшовность. На время миграции эмбеддинг-бюджет заметно растёт, потому что каждый документ считается дважды. На неделю-две — терпимо, за спокойный сон стоит доплатить.
Теневое сравнение: пусть метрики говорят, а не вкусовщина
Здесь начинается самая интересная часть, которую обычно пропускают в туториалах. Прежде чем переключать поиск на новую колонку, мы примерно две недели гоняем теневые запросы.
На каждый реальный поисковый запрос пользователя система делает два обращения к базе: основной — в старую колонку (его результат отдаётся в UI), теневой — в новую (результат логируется, но пользователю не показывается). В лог пишется: запрос, топ-5 от модели A, топ-5 от модели B, ID документа, на который пользователь в итоге кликнул, время отклика. Если поверх поиска уже висит ИИ-агент, туда же имеет смысл добавить, какой из двух топов агент в итоге использовал для генерации ответа — это отдельный ценный сигнал.
По логу считаем три вещи:
- Recall@5 на «правде пользователя»: попал ли реально выбранный документ в топ-5 каждой модели? Это куда честнее, чем тесты на синтетике, где правильный ответ размечен вручную и обычно слишком удобен.
- Overlap@5: насколько списки совпадают. Если совпадений мало — поведение поиска изменится драматически, готовьтесь к жалобам и заранее переобучайте пользователей. Люди привыкают к тому, «что где лежит»; когда порядок выдачи резко меняется, им кажется, что систему сломали, даже если формально стало точнее.
- Latency p95: новый эмбеддинг с большей размерностью может ощутимо просадить отклик. Один переход с 384 на 1024 измерений добавил нам заметные десятки миллисекунд к p95 — для интерактивного поиска, да ещё внутри цепочки агента, это чувствуется.
На том самом дистрибьюторе после двух недель shadow-режима картина оказалась такой: recall@5 на новой модели вырос, но скромнее, чем обещала синтетика. А overlap@5 был низкий — больше чем в половине случаев топ-5 менялся существенно. Это сигнал: нельзя катить кнопкой, надо предупреждать пользователей и катить постепенно.
Постепенный rollout и кнопка отката
Финальный шаг — переключение. Делается через флаг в конфиге, который читается на каждый запрос:
def choose_embedding_column(user_id: int) -> str:
if user_id in EARLY_ADOPTERS:
return "embedding_v2"
if hash(user_id) % 100 < ROLLOUT_PERCENT:
return "embedding_v2"
return "embedding"
Сначала небольшая доля пользователей — те, с кем договорились заранее, плюс единицы процентов случайной аудитории. Через пару дней — четверть. Через неделю — все. На каждом этапе смотрим в админке: доля «нулевых» поисков (0 результатов), среднее время до клика, количество обращений в поддержку с тегом «поиск». Если хоть один из показателей уходит в минус — откатываемся, разбираемся, пробуем снова.
Кнопка отката — ROLLOUT_PERCENT = 0 в конфиге. Без миграций, без редеплоя, за секунды. Старая колонка embedding ещё месяц-два после полного переезда живёт в базе как страховка. Только после этого её можно дропнуть и освободить место. Спешить с удалением нет смысла — диск дешевле, чем повторная переиндексация всей базы.
Что мы вынесли из нескольких таких миграций
Эмбеддинги — это не «параметр модели», это актив, который у вас в базе и стоит времени и денег. Относиться к нему стоит как к схеме БД: версионировать, мигрировать постепенно, никогда не перезаписывать без отката. Особенно если поверх этой базы уже работает личный ИИ-агент, которому вы доверяете часть решений — цена одной неудачной ночи тогда становится не «менеджеры пожаловались», а «агент две недели уверенно отвечал ерунду, и никто сразу не заметил».
Двухколоночная схема плюс пара недель теневого режима плюс постепенный rollout — это плюс примерно три недели к проекту и заметный рост расходов на эмбеддинги на время миграции. И минус один инцидент, где утром в понедельник руководитель отдела продаж пишет в чат «верните как было». По нашему опыту, второе перевешивает первое многократно.
И небольшая практическая ремарка на будущее. Когда сейчас приходят с задачей «ИИ-агенты купить под наш процесс» или «соберите нам агента поверх нашей базы», мы почти всегда закладываем именно такую схему миграции эмбеддингов сразу, с первого дня. Не потому что модель точно поменяется — а потому что если она однажды поменяется, а инфраструктуры под это нет, переезд превращается в тот самый понедельник в чате поддержки.
А когда вы последний раз меняли модель эмбеддингов в проде — и решились ли вообще?
Похожие статьи
- Как мы научили ИИ-агента на GigaChat Pro не путать живого клиента с ночным батчем
- Как мы настроили ИИ-агента для поиска в 1С: MCP-сервер вместо метаний между окнами
- Один вопрос — три темы: как мы научили ИИ-агента разбирать составные запросы
- Text2SQL для 340 таблиц: как мы собирали ИИ-агента для анализа корпоративных данных на GigaChat Pro




