Когда BM25 неожиданно уделывает векторный поиск
Если коротко
- Класс запросов, где векторный поиск проигрывает с разгромом — и это не баг, а архитектура.
- Reciprocal rank fusion — одна формула, ноль настроек, оба ранкинга остаются на равных.
- Реальные числа из эвал-сета: BM25 vs vector vs hybrid.
~5s loop, ease-out: three vertical columns titled "BM25", "Vector", "Hybrid". The first two show top-5 ranked items as small rectangles with different shades of indigo. The hybrid column populates in sequence, pulling rectangles from both source columns, with a small "RRF" formula floating above. Transparent background, clean line work, 800x400, ~24fps.
Векторный поиск — это умница. Понимает смысл, не путается в синонимах, спокойно достанет «расторжение договора», когда вы спросили про «прекращение соглашения».
А потом вы спрашиваете «договор № ARA-2026-014», и умница превращает номер в нечто среднее по всем номерам мира. Тогда из-за угла спокойно выходит старушка BM25 и забирает раунд.
Где BM25 побеждает векторный поиск
- Номер документа — «договор ARA-2026-014». Векторы усредняют, BM25 находит точное совпадение.
- Имя собственное — «ООО Бином Сервис». Эмбеддер сваливает в кучу всё похожее.
- Точная цитата — «дата подписания 14.03.2026». Векторы дают близкое по контексту, не по содержанию.
- Редкая терминология / жаргон — того, чего мало в обучающем корпусе эмбеддера, для него просто нет.
Заметьте — это не «иногда». Это класс запросов. На юридических и финансовых документах их доля считается десятками процентов.
Reciprocal Rank Fusion в одну формулу
Простой способ объединить два разных ранкинга, не пытаясь нормализовать их скоры:
score(d) = 1/(k + rank_BM25(d)) + 1/(k + rank_vector(d))
где k обычно 60. Что получается:
- Документ, попавший в топ обоих — поднимается вверх.
- Документ только из одного топа — остаётся в выдаче, но ниже.
- Настраиваемых весов нет — баланс задаётся одним
k.
Десять строк Python. Никаких ML, никаких re-rank'ов.
Реальные числа
Скрипт eval_search.py прогоняет 10 запросов против 10 документов (узбекский + русский + английский в одном корпусе):
- Keyword (BM25): MRR@5 = 1.000, Recall@5 = 1.000
- Vector: MRR@5 = 0.173, Recall@5 = 0.600
- Hybrid (RRF): MRR@5 = 0.950, Recall@5 = 1.000
На этом маленьком сэте BM25 выигрывает за счёт точных форм (номера, имена). Vector проседает. Hybrid сохраняет выигрыш BM25 и не теряет на смысловых запросах.
Это причина не выбирать одно из двух. На прод-сэте дисбаланс будет меньше, но гибрид всё равно остаётся правильным дефолтом.
Где это живёт в Postgres
BM25-подобный ранкинг — это ts_rank поверх tsvector. Один GIN-индекс, и поиск на миллионах строк отвечает за миллисекунды.
tsvector создаём с конфигурацией simple — без стемминга, чтобы одинаково обращаться с тремя языками. Стеммер для русского сломает узбекский, стеммер для английского сломает оба. Простой токенайзер — единственный честный вариант.
- Добавьте
tsvector+ GIN-индекс рядом с эмбеддингами. - Реализуйте RRF — это правда десять строк.
- Соберите 20 реальных запросов и замерьте все три режима. Цифры расскажут больше, чем спор.
- Переключатель «Hybrid / Vector / Keyword» можно потрогать в демо ARA.