Технологии

Гибридный поиск: почему ключевые слова всё ещё нужны рядом с векторами

Есть классы запросов, где векторный поиск проигрывает точному совпадению вчистую — имена, коды, артикулы, отрицания. Разбираем, какие и почему гибрид не роскошь.

Запрос «ошибка E-4021» в системе с векторным поиском вернёт три абзаца про коды E-4019, E-4023 и общее рассуждение об обработке ошибок. Нужного не будет ни в одном. Запрос «что я обсуждал с Олегом» вернёт разговоры с Олегом, Ольгой и, если повезёт, письмо от Алексея про Олега. Это не плохая настройка и не слабая модель-эмбеддер. Это устройство операции: векторный поиск ищет похожее, а вам нужно точное.

Гибрид — не улучшение ради процентов качества, а способ закрыть классы запросов, которые один источник не закрывает в принципе.

#Два поиска ищут разное

Полнотекстовый поиск с ранжированием bm25 работает со словами как с ключами: слово либо есть в документе, либо нет, а вес определяется тем, насколько слово редкое и насколько часто встречается в конкретном документе. Разбор самой формулы — в справке про bm25.

Векторный поиск работает с текстом как с точкой на карте смыслов и возвращает ближайших соседей независимо от того, какие в них слова.

Отсюда прямое следствие: они ошибаются в разные стороны. Полнотекстовый промахивается, когда вы формулируете запрос не теми словами, что в документе. Векторный промахивается, когда важны именно слова. Пересечение их ошибок гораздо меньше, чем ошибки каждого по отдельности, — на этом и построен гибрид.

#Какие запросы кому отдавать

Тип запроса Пример Кто выигрывает
Имя человека «что говорил Олег» ключевые слова
Код, артикул, идентификатор «ошибка E-4021» ключевые слова
Точная цитата «фраза про дедлайн в мае» ключевые слова
Редкий термин «бемоль в третьем такте» ключевые слова
Формулировка своими словами «что я решил насчёт отпуска» векторы
Тема без общих слов «как я справлялся с выгоранием» векторы
Перефраз известного текста «та штука про кэширование» векторы
Отрицание «встреча, которая не состоялась» не выигрывает никто

Верхняя половина таблицы — это ровно то, из чего состоит персональная память. Имена, названия проектов, номера договоров, коды ошибок. Именно поэтому в Persona режим поиска по ключевым словам выбран основным: функция _recall_terms в app/chat/sessions.py сначала вытаскивает из вопроса имена собственные — слова с заглавной буквы, — и только потом значимые слова длиной от четырёх букв. Приоритет имён здесь не случайность, а прямое следствие того, о чём люди спрашивают свою память.

Нижняя строка таблицы честно говорит, что гибрид не всесилен. Отрицание плохо кодируется вектором и не ловится ключевыми словами, потому что частица «не» — стоп-слово, которое отбрасывается на первом же шаге. Это открытая проблема, а не решённая.

#Как объединить два списка

Наивное решение — сложить оценки — не работает, и это стоит понимать до, а не после реализации. У полнотекстового ранжирования шкала одна (в SQLite bm25 к тому же отрицательный: меньше значит релевантнее), у косинусной близости другая, диапазоны не совпадают, распределения тоже. Сумма таких чисел — это тихое доминирование того источника, у которого шкала шире.

Рабочий приём — объединять не оценки, а места в списках. Каждый источник выдаёт свой топ, дальше документ получает вклад, обратно пропорциональный его позиции у каждого источника. Это Reciprocal Rank Fusion, и его главное свойство — независимость от шкал: складываются ранги, а не баллы. Формула и разбор константы — в отдельном материале про RRF.

В Persona это реализовано в app/memory_vec.py: каждый источник даёт до сорока кандидатов, вклад считается как единица, делённая на сумму константы 60 и ранга, результаты складываются по идентификатору сообщения. Отдельно там же зафиксирована ловушка, на которой спотыкаются при работе с SQLite: bm25() возвращает отрицательные значения, поэтому сортировка идёт по возрастанию, а не по убыванию.

Дальше стоит признать честную деталь. В том же продукте есть вторая реализация слияния — для поиска по распознанному тексту скриншотов, — и она сделана иначе: там к обратному рангу полнотекстовой выдачи просто прибавляется косинусная близость (_merge_results в app/web/routes/search.py). Это ровно тот наивный способ, от которого я предостерегаю абзацем выше. Он работает приемлемо, потому что списки короткие и обе шкалы случайно оказались одного порядка, но опираться на такое совпадение неправильно. Показываю это не ради самобичевания, а потому что так выглядит реальная система: в ней сосуществуют правильное и историческое решение одной задачи.

#Что происходит после слияния

Слитый список — ещё не ответ. Три шага, которые сильно влияют на итог.

Дедупликация. Если один и тот же факт повторялся в переписке пять раз, он займёт пять мест из шести. В Persona выдача схлопывается по первым восьмидесяти символам текста, а отдельный фильтр выбрасывает «эхо» — сообщения, которые по сути повторяют сам вопрос, а не отвечают на него.

Разнообразие. Даже после дедупликации выдача склонна собираться вокруг одной темы. Приём под названием MMR жадно выбирает следующий элемент так, чтобы он был одновременно релевантным и непохожим на уже выбранные; в Persona это опциональная пересортировка, выключенная по умолчанию.

Переранжирование. Отдельная модель может пересчитать релевантность пары «запрос — фрагмент» точнее, чем любое слияние рангов, — ценой заметной задержки на каждом запросе. В Persona такой шаг есть, но выключен по умолчанию: он включается отдельным ключом настроек и требует установленного пакета с cross-encoder-моделью.

#Почему гибрид не работает из коробки

Здесь я иду против общего места «гибрид всегда лучше». Он лучше по качеству выдачи — и хуже по числу условий, которые должны сойтись.

Чтобы векторная половина заработала, нужно одновременно: установленное расширение для векторов в базе, запущенная модель-эмбеддер, доступная приложению по сети, и однократная индексация всего накопленного архива. Ни одно из трёх не выполняется само, и расширение для векторов не входит в зависимости Persona — его ставят отдельно. Если хоть одно условие отсутствует, Persona молча возвращается к поиску по ключевым словам — по построению, чтобы чат не падал из-за не поднятого эмбеддера. Сам режим при этом может числиться гибридным: настройка и фактическая работа — разные вещи.

Это решение имеет цену, и она в неочевидности: пользователь, у которого векторная половина отвалилась, видит просто «поиск стал хуже». Диагностика тут одна — проверять наличие расширения и полноту индекса явно, а не по ощущению от выдачи. Стоит ли вообще заводить векторный слой на вашем объёме, разобрано в обзоре векторных баз данных.

Есть и вторая причина, специфическая для многопользовательской установки: эмбеддинги считаются по конфигурации владельца инстанса, поэтому для остальных участников векторный режим недоступен вовсе — их текст не должен уходить на чужое железо. Для них гибрида нет, и поиск по ключевым словам — единственный режим.

#Как проверить, что гибрид у вас что-то даёт

Метод простой и не требует размеченного корпуса.

  1. Соберите двадцать реальных вопросов к своей памяти, половину — с именами и точными терминами, половину — своими словами.
  2. Прогоните их в режиме только ключевых слов, запишите, нашлось ли нужное в первых шести результатах.
  3. Включите гибрид, прогоните те же двадцать.
  4. Сравните не общее число попаданий, а по половинам. Гибрид обязан выиграть на второй половине и не проиграть на первой.

Если гибрид проигрывает на первой половине — значит слияние настроено так, что векторная выдача вытесняет точные попадания, и константу слияния надо править. Если он не выигрывает на второй — вероятно, индекс неполон.

Устройство слоёв, внутри которых живёт весь этот поиск, описано в статье о том, как работает память у ИИ.

#Частые вопросы

#Почему нельзя просто сложить релевантность двух поисков?

Потому что их числа несопоставимы: bm25 и косинус измеряются в разных единицах и лежат в разных диапазонах. Сумма даёт результат, в котором доминирует источник с более широкой шкалой, причём это доминирование меняется от запроса к запросу и не видно в логах.

#Всегда ли гибрид лучше одного источника?

По качеству выдачи — обычно да, при условии, что оба источника здоровы. По совокупной стоимости — нет: векторная половина требует расширения, эмбеддера и индексации. Для персонального архива, где большая часть запросов содержит имена и точные термины, честный ответ такой: сначала доведите ключевые слова, потом добавляйте векторы.

#Что делать с отрицанием в запросе?

Пока — ничего автоматического. Ни один из двух источников его не ловит. Практический обход — искать по существительным запроса и просматривать выдачу самому, а в ассистенте формулировать вопрос утвердительно: не «встреча, которая не состоялась», а «перенос встречи».

#Сколько кандидатов брать от каждого источника?

Заметно больше, чем нужно на выходе: слияние и дедупликация съедают часть списка. В Persona каждый источник даёт до сорока кандидатов, а в промпт попадает до шести сообщений. Пропорция примерно такая и разумна.

#Как понять, что векторная половина не работает?

По симптому: находится только то, что сформулировано дословно как в записи. Проверяется прямо — возьмите факт, который точно есть в архиве, и спросите о нём другими словами. Если не находится, векторной половины у вас фактически нет, чем бы ни было написано в настройках. Дополнительный разбор различий между режимами — в сравнении семантического поиска и ключевых слов.

#Вывод

Ключевые слова закрывают запросы с именами, кодами и точными формулировками — то есть большую часть вопросов к личной памяти. Векторы закрывают запросы своими словами. Гибрид нужен, когда у вас есть и то и другое, и он окупается только при здоровых обеих половинах. Начинать надо с полнотекстового индекса в SQLite — он работает без внешних зависимостей; добавлять векторы имеет смысл, когда вы уже видите конкретные промахи, которые они бы починили, и умеете проверить, что они действительно чинятся, а не заменяются другими.

Хотите ИИ, который помнит вас?

Persona держит контекст между чатами и работает на вашем железе — если развернуть её у себя и подключить локальную модель.

Создать аккаунт