Когда мы занялись поисковым слоем AI-ассистента в «Первой Форме», договорились на входе: языковую модель, которая отвечает пользователю, не трогаем. Вся эта история — о том, что происходит на шаг раньше: как система решает, что именно показать модели, прежде чем та начнёт отвечать.
Планку себе поставили простую на словах и сложную на деле: семантический поиск промышленного уровня, но без специализированной инфраструктуры, целиком внутри изолированной сети, на том же оборудовании, что и само приложение.
Спойлер: получилось. В статье расскажу, какие решения мы принимали и что перебирали в качестве альтернатив. Но отдельно хочу выделить методику оценки: первые замеры качества нас обманули и увели в неверном направлении, и разбор того, почему так вышло, — возможно, самая полезная часть этого текста.
Задача
Часть наших заказчиков работает в закрытом контуре: без выхода в интернет и без специализированного железа. А стандартная архитектура семантического поиска требует ровно того, чего в этом контуре нет: облачный API эмбеддингов или GPU для локальной модели, выделенную векторную базу или поисковый кластер, и ещё один GPU под переранжирование.
Инженерную цель мы сформулировали просто: тот же класс качества при трёх снятых зависимостях. Поисковый конвейер состоит из трёх компонентов, и каждый из них «по учебнику» требует того, чего у нас нет:
-
Эмбеддинг — превратить запрос и корпус документов в векторы.
-
Векторный поиск — найти кандидатов поблизости.
-
Переранжирование — упорядочить кандидатов по релевантности.
Разберём по компонентам, что мы выбрали и почему.
Размер модели против специализации
Распространено допущение, что релевантность растёт с размером модели: чем больше параметров и размерность вектора, тем лучше поиск. Мы проверили это на собственном корпусе с реальными запросами из логов и отклонили по результатам замеров.
Сравнивали открытые компактные модели — multilingual-e5-base, e5-large, bge-m3 — против облачной Qwen3-Embedding-8B с векторами в 4096 измерений. Компактные модели, работающие на обычном процессоре, обошли облачную по всем метрикам полноты. Причина в том, что retrieval-модели специально обучены под задачу поиска, универсальная большая модель решает её «по совместительству». Размерность вектора сама по себе релевантность не покупает.
Остановились на e5-base, тоже по замеру. По способности не потерять нужный документ в пуле кандидатов она идентична более крупной bge-m3, при этом вдвое легче и быстрее, с чистым экспортом в ONNX. Преимущество крупной модели на верхних позициях выдачи в полном тракте не материализуется: точный порядок наводит переранжировщик, эмбеддеру достаточно надёжно доставить документ в пул кандидатов.
Как реализована архитектура
Прежде чем оптимизировать, мы исследовали, упирается компонент в вычислительную мощность или в архитектуру. Правильным оказался второй вариант — узкое место снимается выбором класса решения, а не более мощным железом.
Переранжирование. Был вариант дать генеративной LLM роль судьи: показать ей запрос и кандидатов, попросить оценить релевантность. И если на GPU это работало, то на CPU — нет, генеративный реранкер обработал пакет кандидатов за 42 секунды. Компактный cross-encoder (mmarco-mMiniLMv2, 118M параметров) обработал тот же пакет за 1.3 секунды, на том же процессоре, с теми же SIMD-инструкциями.
Разница в тридцать раз объясняется архитектурой и тем, что генеративной модели нужно авторегрессивно породить ответ токен за токеном. Cross-encoder делает один проход с классификационной головой. Железом эта разница не компенсируется.
Качество при этом не пострадало: по полноте компактный cross-encoder сравнялся с реранкером в два с половиной раза крупнее, а в A/B на реальном трафике показал паритет с прежним GPU-судьёй, и это при четырёхкратном ускорении.
Векторный поиск. Классический рефлекс — «векторы должны лежать в векторной БД». Мы измерили спектр вариантов, и теперь полный честный перебор корпуса в памяти процесса занимает единицы миллисекунд на запрос. Тот же перебор внутри СУБД длился 86 секунд: в нашей версии SQL Server нет нативного векторного типа, а прямое приведение бинарного представления к числам запрещено, так что косинусная близость считалась через ручной декодер IEEE754 на T-SQL. Четыре порядка разницы.
Вывод для наших условий: скалярное произведение это операция для процесса приложения, а не для SQL-движка. В движках с нативным векторным типом арифметика будет другой, но у нас его не было.
Из того же замера следует судьба ANN-индексов: на корпусе в десятки тысяч фрагментов они решают несуществующую проблему. Приближённый поиск обменивает точность на скорость там, где скорость и так не проблема. Реальное ограничение на этом пути — память, и решается оно квантованием векторов, о чём расскажу дальше.
Как это собрано вместе
Вектор не заменил лексический поиск, а встал рядом с ним. Полнотекстовый движок мы используем на точных формулировках, названиях и терминах. Векторная ветка работает параллельно, выдачи сливаются взаимным рангом (reciprocal rank fusion): документ, высокий у обеих веток, поднимается, уникальные находки каждой не теряются. Поверх объединённого пула кандидатов работает cross-encoder, и итоговый топ уходит большой модели как кандидаты.
Живёт всё это в процессе самого приложения. Мы начинали с отдельного контейнера и сознательно от него ушли не ради скорости, так как выделенному сервису на своих ядрах in-process путь по чистой задержке даже проигрывает, а ради эксплуатации: ноль сетевых хопов и «холодных» соединений, нечему отдельно упасть и нечего отдельно мониторить инфраструктурно. Качество поиска мы мониторим как отдельную дисциплину, о ней в разделе про оценку. В закрытом контуре, где каждый дополнительный сервис — отдельное согласование и отдельное сопровождение, этот аргумент весит больше миллисекунд.
Свежесть корпуса обеспечивается эмбеддингом на запись: вектор фрагмента считается в момент индексации документа, это десятки миллисекунд на фрагмент. По-настоящему дорогой была только разовая операция — эмбеддинг всего накопленного корпуса.
Оценка качества: три замера и почему первые два дали неверный ответ
Когда мы собрали тракт, оставался главный вопрос: стал ли поиск лучше. Мы отвечали на него в три стадии, и эта последовательность оказалась для нас поучительнее любого отдельного результата.
Стадия 1 — A/B на поисковом зонде. Триста с лишним реальных запросов, на которых лексический поиск провалился, прогнали через поисковый слой с включённой и выключенной векторной веткой, с контрольным прогоном для оценки шума. Вердикт поиска улучшился на 2.6 процентного пункта — это уже было отличимо от шума, но практически маргинально. Внутри цифры при этом было противоречие: вектор дотягивал новый документ в топ выдачи в 184 случаях, вердикт улучшался в 25, а в 33 даже ухудшался. Поиск находил, но вердикт этого почти не замечал, и знак эффекта плавал.
Стадия 2 — Принудительное «спасение». Следующим шагом мы попробовали дожать находки: если косинусная близость векторного кандидата превышает порог, нужно перезаписать вердикт и объявить документ найденным. Механизм построили, результаты разметили по содержанию документов. Точность составила 13%: подавляющее большинство «спасённых» кандидатов были топикально похожими, но неверными документами. Перебор порога картину не менял и доля мусора держалась на всём диапазоне. Порог по косинусу не отделяет «то самое» от «про то же самое».
По итогам двух стадий напрашивалось решение: векторная ветка маргинальна, спасательный механизм вреден, направление закрыть. Формально цифры это подтверждали.
И тогда мы решились на стадию 3 — Сквозной прогон. Мы прогнали спорные случаи через реального ассистента полным сценарием: запрос → поиск → большая модель → ответ пользователю. Результат оказался противоположным выводам зонда.
На запросах, где лексика проваливалась, ассистент отвечал по существу, опираясь на документ, поднятый именно вектором. А топикально похожий мусор, составлявший 87% на второй стадии, не дал ни одного ложного ответа: большая модель, видя содержимое документов, отбраковывала их сама. Ассистент в нужные моменты отвечал «в документации этого нет», задавал уточняющий вопрос, а в части случаев игнорировал подсунутый документ и находил ответ другим инструментом.
Причин, по которым зонд ошибался, две, и обе относятся к методике. Во-первых, зонд оценивал вердикт поискового слоя, который в реальном потоке не является финальным судьёй. Во-вторых, он судил по заголовкам и близости, без содержимого документов, а реальная модель это содержимое видит и использует.
Из этой последовательности мы вынесли два принципа, которые теперь применяем ко всей системе.
-
Поиск предлагает, а модель решает. Она хорошо отличает релевантный документ от топикально похожего. Любой механизм, перезаписывающий вердикт по порогу — косинуса, скора реранкера, чего угодно, — превращает «возможно, полезно» в «это ответ», и при точности 13% такое утверждение ложно в 87% случаев. Спасательный механизм мы отключили; принцип распространили на аналогичные развилки в других местах системы.
-
Метрики промежуточного слоя не заменяют оценку конечного ответа. Если бы работа остановилась после второй стадии, векторная ветка была бы закрыта с формулировкой «проверено, не работает». Корректную картину дал только сквозной прогон: дороже и медленнее, но измеряющий то, что видит пользователь.
Результат
Весь поисковый тракт — эмбеддинг запроса, перебор по векторам корпуса, переранжирование — работает внутри процесса приложения на стандартном сервере. Суммарная задержка поискового слоя — десятки-сотни миллисекунд. Для ориентира: всё, что здесь измерено, работает на серверном x86 общего назначения возрастом в несколько лет, на нескольких ядрах. Замеры проводили на одиночных запросах, конкурентную деградацию под параллельной нагрузкой мы отдельно не бенчмаркали — поисковый слой составляет малую долю общей нагрузки приложения.
Качество не снизилось, а на реальных запросах даже выросло: векторная ветка добавила ответы там, где лексический поиск проваливался. Побочный эффект, который мы не закладывали в цели: меньше движущихся частей. Нет внешнего сервиса эмбеддингов — нет его деградаций и сетевого джиттера; нет отдельной поисковой системы — нет её версий, миграций и мониторинга. Предсказуемость стала выше, а стоимость владения наоборот снизилась.
Почему метод требует подгонки
Тут отмечу важную деталь — результат определила калибровка под конкретный корпус и бюджет отклика, а не сам факт запуска открытых моделей на CPU. Вот несколько тезисов.
-
Квантование — не механическая операция. Чтобы корпус помещался в память, векторы сжимаются в int8. Наивное масштабирование «умножить на 127» уронило полноту до 0.86, так как у нормированных эмбеддингов компоненты малы, и лобовое масштабирование расходует меньше половины диапазона int8. Масштабирование по фактическому максимуму компонент вернуло полноту до 0.98 — это сжатие в 4 раза практически без потерь. Разница — одна строка кода.
-
Окно переранжирования определяет, сколько кандидатов и какой длины подавать. Вариант «больше контекста — лучше» замер не прошёл: удлинение фрагментов дало столько же регрессий, сколько и улучшений, а дополнительный текст добавлял шума и сталкивал верный документ с первого места. Центрировать окно вокруг лексического совпадения оказалось бессмысленно — эффект строго нулевой. Дело в том, что релевантность наших документов концентрируется в начале фрагмента, а это свойство корпуса нельзя угадать, можно только измерить.
-
Контракт эмбеддера. У retrieval-моделей есть обязательные неочевидные детали, например, разные служебные префиксы для запроса и для документа. Нарушение контракта тихо просаживает качество, и заметить это можно только на метриках.
-
Инференс реализовали напрямую через ONNX Runtime. Готовые serving-фреймворки мы попробовали, но в итоге отказались от них. Каждый спотыкался на специфике конкретной модели — то неподдерживаемая архитектура, то известный баг экспорта. Тонкая обвязка над рантаймом оказалась надёжнее: чем меньше слоёв, тем меньше мест, где чужая абстракция знает о модели меньше, чем вы сами. Обе модели открытые (MIT у e5, Apache 2.0 у mmarco-mMiniLMv2) и попадают в изолированную сеть как обычный артефакт поставки, в рантайме ничего не скачивается.
Общий знаменатель в том, каждое решение принималось по замеру на собственном корпусе и реальных запросах из логов. Заметная часть «очевидных улучшений» на поверку оказалась нейтральной или вредной, и узнать это заранее было нельзя.
Границы применимости
Полный перебор масштабируется дальше, чем принято думать, но не бесконечно. Если применить квантование, корпус в миллионы фрагментов помещается и в разумный объём памяти, и в интерактивную задержку. А вот на десятках миллионов векторов под постоянной нагрузкой перебор перестаёт справляться — здесь уже есть смысл подключать ANN-индекс и более глубокое сжатие. До этого порога они только добавляют лишние движущиеся части.
Квантование глубже четырёхкратного даётся не бесплатно. Наивная бинаризация нормированных эмбеддингов радикально роняет качество. Чтобы сжать данные сильнее, нужны схемы с ротацией пространства, и применять их стоит только тогда, когда четырёхкратного сжатия действительно не хватает.
У cross-encoder’а есть архитектурный потолок окна: длинные фрагменты он видит только с начала. На нашем корпусе это не вредит, это мы проверили замером. Но на другом корпусе всё может быть иначе, и это нужно проверять до внедрения, а не после.
Метрики полноты не измеряют тонкое ранжирование. Наши замеры показывают, теряем ли мы нужный документ, а не то, как хорошо мы различаем близких кандидатов между собой. Эту задачу мы осознанно вынесли за скобки: финальным судьёй в нашей схеме выступает большая модель.
Вывод
Семантический поиск можно перенести туда, где его обычно не закладывают в архитектуру: в изолированную сеть, на обычный сервер, в процесс самого приложения. Для этого нам пришлось трижды выбрать правильный класс решения: взять специализированную компактную модель вместо большой универсальной, cross-encoder вместо генеративного судьи, перебор в памяти вместо выделенной поисковой системы — и один раз правильно распределить роли. Поиск предлагает кандидатов, а решение принимает основная модель.
Второй вывод оказался не менее практичным. Промежуточные метрики можно сделать честными, воспроизводимыми, без шума — и всё равно прийти к неверному решению, если измерять не тот слой. Если финальное слово в системе остаётся за большой моделью, оценивать нужно именно её ответ. Это дороже, но только так можно измерить то, что видит пользователь.
ссылка на оригинал статьи https://habr.com/ru/articles/1059678/