Допустим, у вас есть два набора векторов по 1024 числа в каждом. Один получен старой моделью, другой новой. Для базы данных они выглядят одинаково: массивы чисел одной длины. Запрос к такому индексу не вызовет ошибки, вернёт ранжированный список и даже покажет правдоподобные оценки близости. Именно поэтому ловушка так неприятна: система продолжает отвечать, но сравнивает числа, у которых нет общей системы координат.
Размерность говорит только о том, сколько координат у вектора. Что означает каждая координата, определяется обучением конкретной модели. У другой модели первая ось может отвечать за совсем иное, чем у прежней, а косинусная близость между векторами из разных пространств превращается в арифметику без смысла. Совпавшая длина доказывает лишь одно: хранилище не откажется принять данные. Совместимость она не доказывает.
Поэтому смену модели эмбеддингов лучше воспринимать как проект с двумя параллельными версиями поиска, а не как обновление зависимости. Дальше разберём, как построить такой проект, чтобы в любой момент можно было сравнить старый и новый поиск и вернуться назад.
Версия поиска — это больше, чем название модели
Фраза «мы используем такую-то модель» описывает поиск неполно. На итоговый вектор влияет целая цепочка решений, и каждое звено способно изменить результат:
- сама модель и её конкретная редакция;
- подготовка текста: очистка разметки, добавление заголовка документа к фрагменту, префиксы для запроса и для документа, если модель их требует;
- правила нарезки на фрагменты: размер, перекрытие, границы по заголовкам;
- нормализация вектора и выбор метрики сравнения;
- размерность и конфигурация индекса.
Если поменять любое из этих звеньев, получится другое пространство поиска, даже когда имя модели осталось прежним. Допустим, вы решили заодно увеличить размер фрагмента с 500 до 800 токенов. Теперь вы не можете сказать, чему обязан рост или падение качества: новой модели или новой нарезке. Из этого следует практическое правило: одна миграция меняет одну группу причин, либо модель, либо нарезку.
Удобно присвоить всей цепочке единый идентификатор, например emb-v2. Это просто строка, но она превращает расплывчатое «новый поиск» в конкретный объект, на который можно ссылаться в логах, конфигурации и метриках.
Здесь же стоит вспомнить, как устроены коллекции векторных баз. В документации Qdrant сказано, что векторы одной конфигурации коллекции имеют общую размерность и метрику (Qdrant: Collections). Из этого можно сделать архитектурный вывод: конфигурация индекса является частью версии. Если новая модель выдаёт другую размерность, ей в любом случае нужна отдельная конфигурация. А если размерность та же, по описанным выше причинам смешивать нельзя всё равно.
Новый набор рядом, а не поверх старого
Соблазн постепенно перезаписывать векторы на месте понятен: так не нужно держать вдвое больше данных. Но у такого подхода есть тяжёлое последствие. Посреди миграции индекс содержит смесь: часть фрагментов в пространстве старой модели, часть в пространстве новой. Запрос приходит из какого-то одного пространства, поэтому он честно сравнивается лишь с половиной документов. Вторая половина получает случайные оценки и то всплывает в выдаче без причины, то пропадает. Отладить это почти невозможно, ведь каждый отдельный ответ выглядит приемлемо.
Надёжнее построить новый набор представлений независимо. Есть два способа разместить его.
| Способ | Как выглядит | Когда подходит |
|---|---|---|
| Отдельная коллекция или индекс | Старый и новый поиск живут в разных коллекциях со своими конфигурациями | Нужна полная изоляция и простой откат: достаточно сменить имя коллекции в настройках |
| Именованные векторы в одной точке | У каждой записи несколько представлений под разными именами | Хочется держать полезную нагрузку (текст, метаданные) в одном месте и не дублировать её |
Про второй способ нужно сказать аккуратно. В документации Qdrant именованные векторы описаны как возможность хранить разные представления одной точки (Qdrant: Collections). Это даёт место для двух версий, но не выполняет миграцию за вас: новые векторы всё равно нужно посчитать новой моделью и записать под новым именем, а запросы направить в нужное именованное поле. Возможность хранения не равна процессу переноса. Для других хранилищ механизм может называться иначе или отсутствовать, так что перед выбором схемы проверьте документацию именно вашей системы и редакции.
Если сомневаетесь, начинайте с отдельной коллекции. Она проще для понимания: можно открыть обе, посмотреть размеры, удалить одну командой и не гадать, какое именованное поле читает какой сервис.
Схема соответствия: документ, версия преобразования, индекс
Чтобы параллельные наборы не превратились в хаос, нужна небольшая таблица учёта. Она отвечает на вопрос: какой фрагмент какого документа представлен в каком индексе и каким преобразованием получен. Минимальная форма выглядит так:
| Документ | Хеш содержимого | Версия преобразования | Индекс | Состояние |
|---|---|---|---|---|
| doc-101 | a3f9… | emb-v1 | docs_v1 | актуален |
| doc-101 | a3f9… | emb-v2 | docs_v2 | актуален |
| doc-102 | 7be1… | emb-v1 | docs_v1 | актуален |
| doc-102 | 7be1… | emb-v2 | docs_v2 | в очереди |
Каждая строка — отдельный факт: этот текст в этой версии лежит вот здесь. Такая таблица даёт три полезные вещи.
- Прогресс миграции становится измеримым: достаточно посчитать документы без строки для
emb-v2. - Можно проверить совпадение хеша: если содержимое документа изменилось, а строка для новой версии ссылается на старый хеш, её нужно пересчитать.
- Всегда понятно, какой индекс обслуживает какой запрос, потому что маршрут привязан к версии, а не к догадке.
Хранить её можно в обычной реляционной базе рядом с метаданными документов. Ничего экзотического не требуется: это справочник состояния, а не часть векторного поиска.
Документы, которые меняются во время переиндексации
Переиндексация большого корпуса занимает часы, а иногда дни, и всё это время документы живут своей жизнью: кто-то правит регламент, кто-то загружает новый договор, кто-то удаляет устаревшую инструкцию. Если не продумать этот момент, новый индекс будет готов уже частично устаревшим.
Разберём три случая.
Новый документ
Он должен попасть в обе версии. Пока старый индекс обслуживает пользователей, он обязан знать о новых текстах, иначе поиск начнёт деградировать прямо сейчас. А новый индекс должен получить документ, иначе после переключения свежие материалы пропадут. Практически это означает двойную запись: конвейер приёма считает вектор обеими моделями и обновляет обе коллекции и таблицу соответствия. Расходы на вычисления на время миграции вырастут, это честная цена параллельной работы.
Изменённый документ
Здесь помогает хеш содержимого из таблицы. Когда документ обновился, хеш меняется, и записи обеих версий для него считаются устаревшими. Фоновый процесс пересчитывает фрагменты и заменяет их. Важно заменять набор фрагментов документа целиком, а не по одному, иначе в индексе могут остаться хвосты от прежней нарезки: старый фрагмент, которого в новом тексте уже нет, продолжит находиться в поиске.
Удалённый документ
Самый коварный случай, потому что удаление легко забыть. Если документ убрали из источника, его векторы должны исчезнуть из обоих индексов. Иначе после переключения система вежливо процитирует инструкцию, которой давно нет. Полезно периодически сверять множество идентификаторов в таблице соответствия с реальным источником и убирать сирот.
Общий принцип такой: фоновая переиндексация и поток новых изменений не должны мешать друг другу. Сначала работает массовый пересчёт по снимку корпуса, параллельно все новые события идут в обе версии, а в конце выполняется сверка, которая находит расхождения хешей и удалённые документы. Только после чистой сверки новый индекс можно считать полным.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Где разместить вторую версию и сколько ресурсов ей нужно
Параллельный индекс означает удвоение хранимых векторов и дополнительную нагрузку на вычисление. Прикинем на условном примере. Пусть в корпусе 200 тысяч фрагментов, а вектор хранится в 32-битных числах длиной 1024. Один вектор занимает около 4 КБ, значит, весь набор около 800 МБ без учёта графа индекса и полезной нагрузки. Две версии — порядка 1,6 ГБ плюс накладные расходы структуры индекса. Это условный расчёт: реальные цифры зависят от вашей размерности, настроек хранения и сжатия.
Для такой задачи обычно хватает умеренного сервера, но запас оперативной памяти и быстрый диск имеют значение: пока считаются пересчёты и идут рабочие запросы, база читает и пишет одновременно. На VPS MAATRIX удобно, что NVMe-накопитель и root-доступ позволяют развернуть второй экземпляр векторной базы рядом с первым или отдельную коллекцию на том же сервере, а на время миграции при необходимости перейти на тариф крупнее. Линейка начинается с Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и доходит до Djed (16 vCPU, 32 ГБ, 320 ГБ); для небольшого корпуса и теста нового поиска достаточно младших вариантов, а для больших корпусов ресурсы стоит подбирать по замеру на ваших данных, а не по таблице тарифов. Сами по себе цифры ресурсов не обещают определённую ёмкость.
Заметим, что вычисление эмбеддингов новой моделью может потребовать отдельного решения: внешний API или собственный запуск. Если планируете запускать модель сами, учтите, что публичное предложение VPS не обещает GPU, поэтому для тяжёлых моделей оценивайте скорость на CPU заранее и не рассчитывайте на ускорение, которого нет.
Одни вопросы, одни тексты, два поиска
Теперь у вас есть два индекса и можно сравнивать. Главное условие честного сравнения: оба поиска получают одинаковые вопросы и работают над одинаковым набором исходных текстов. Если в новом индексе документов больше или они свежее, результат будет искажён задолго до того, как вы дойдёте до качества моделей. Поэтому перед тестом зафиксируйте снимок корпуса и сверьте, что множества документов в обоих индексах совпадают.
Дальше нужен набор контрольных вопросов. Соберите его из трёх источников: реальные запросы пользователей из журнала (с учётом ваших правил по персональным данным), вопросы, на которые у вас точно есть ответ в документах, и заведомо сложные случаи: сокращения, опечатки, вопросы на стыке двух документов, терминология, которая встречается в корпусе редко. К каждому вопросу приложите, какие фрагменты или документы считаются правильным ответом. Разметка потребует ручной работы, но без неё сравнение превращается в спор о впечатлениях.
Автоматизировать прогон можно инструментом для проверок. В Promptfoo результаты проверяются assertions, в том числе группами условий, а в проверках предусмотрены стоимость и задержка; критерии при этом задаются конфигурацией теста (Promptfoo: Assertions and Metrics). Для нашей задачи это удобно: для каждого вопроса можно описать условие «в выдаче присутствует нужный источник» и тут же ограничить допустимую задержку и стоимость. Конкретные пороги вы выбираете сами, документация их за вас не назначает.
Что именно сравнивать? Несколько мер, каждая отвечает на свой вопрос:
- Попадание нужного фрагмента в первые k результатов. Показывает, находит ли поиск то, что должен найти.
- Позиция первого правильного результата. Важна, потому что в контекст модели обычно попадают только верхние фрагменты.
- Задержка поиска и построения вектора запроса. Новая модель может быть точнее, но медленнее.
- Стоимость вычислений на запрос. Особенно заметна, если модель вызывается через платный API.
- Расхождения. Список вопросов, где старый и новый поиск выдают разное. Это самая поучительная часть.
Не ограничивайтесь средним значением. Допустим, новая модель в среднем лучше, но на запросах с точными кодами товаров стала заметно хуже. Для интернет-магазина с артикулами это может оказаться важнее общего прироста. Разбивайте результаты по типам вопросов и смотрите, где выигрыш, а где потеря.
Помните и о пределе самого метода. Улучшенный поиск повышает шанс, что модель получит нужный контекст, но не гарантирует правдивый ответ: это отдельная тема, которую мы разбирали в материале «Миф: RAG избавляет модель от галлюцинаций». Сравнение двух индексов измеряет качество извлечения, а не итоговую достоверность ответов.
Переключение маршрута запросов
Когда новая версия прошла сравнение, переключаться стоит не рубильником, а маршрутом. Идея в том, что приложение не знает про конкретную коллекцию: оно спрашивает у конфигурации, какую версию поиска использовать, и получает пару «модель и преобразование запроса плюс имя индекса». Это принципиально, потому что запрос должен быть закодирован той же моделью, которой закодированы документы. Перепутать их — значит вернуться к исходной проблеме смешения.
Маршрут удобно описать как запись конфигурации:
search_route:
active: emb-v1
versions:
emb-v1: {collection: docs_v1}
emb-v2: {collection: docs_v2}
shadow: emb-v2Поле shadow здесь означает теневой режим: пользователь получает ответы активной версии, а запрос параллельно выполняется на второй, и результаты только записываются для сравнения. Этот этап ценен тем, что показывает поведение на живых вопросах, которых нет в вашем контрольном наборе, и не рискует пользовательским опытом.
После теневого периода можно направить на новую версию небольшую долю трафика, потом больше, и наконец сделать её активной. Заранее решите условия остановки. Например: если доля запросов без найденного ответа выросла выше согласованного порога или задержка превысила бюджет, маршрут возвращается к emb-v1. Конкретные числа выберите по своим данным и записывайте до начала переключения, а не после, когда хочется увидеть успех.
Откат при такой схеме превращается в одну правку конфигурации. Именно ради этого старый набор не удаляют сразу после переключения: он остаётся обслуживаемым, а новые и изменённые документы продолжают записываться в обе версии. Если писать только в новую, при откате старый индекс окажется устаревшим, и возвращаться будет некуда.
Сколько хранить старый набор
Двойная запись и дублированные векторы стоят ресурсов, поэтому у старого набора должен быть срок жизни. Но срок зависит не от календаря, а от событий. Вот разумные условия, при выполнении которых его можно выводить из эксплуатации:
- Новая версия обслуживает весь трафик в течение периода, который охватывает ваши типичные пики: конец месяца, сезонные запросы, регулярные отчёты.
- За это время не сработал ни один критерий отката.
- Сверка таблицы соответствия показывает, что все актуальные документы имеют строки для
emb-v2, а сирот нет. - Разобраны расхождения из теневого режима: понятно, почему они возникли и приемлемы ли.
Когда всё это выполнено, сначала отключите двойную запись, потом переведите старую коллекцию в режим только для чтения, сохранив её ещё на какое-то время, и лишь затем удалите. Если место на диске ограничено, можно сохранить исходные документы и таблицу учёта, а векторы старой версии при необходимости построить заново, пусть это и дольше, чем вернуть готовую коллекцию. Выбор между скоростью отката и экономией места делайте осознанно.
Не забудьте обновить и всё, что опиралось на старую версию: дашборды, алерты, тесты. Если в конфигурации проверок остались вопросы с ожиданиями, подобранными под старую модель, они начнут давать ложные срабатывания. Контрольный набор Promptfoo или аналогичный стоит оставить как постоянную регрессионную проверку: следующая смена модели пройдёт гораздо быстрее, когда сравнивать уже есть чем.
Когда старую модель можно убрать
Вернёмся к началу истории с одинаковой длиной векторов. Теперь у неё есть простая развязка: каждый вектор в системе принадлежит конкретной версии преобразования, каждая версия живёт в своём индексе, а каждый запрос проходит по маршруту, который заранее называет и модель, и индекс. Смешивать нечего, потому что смешение больше не может произойти молча.
Старую модель можно убрать в тот момент, когда на любой вопрос о происхождении ответа находится прямой ответ из конфигурации и таблицы соответствия: этот запрос был закодирован emb-v2 и найден в docs_v2. Пока такого ответа нет, старая версия остаётся страховкой. Как только он есть, она превращается в груз, и её можно спокойно выключить.