Билол Саидумаров
Все статьи

Когда BM25 неожиданно уделывает векторный поиск

30 января 2025 · 5 минут · поиск, RAG, Postgres
Если коротко
  • Класс запросов, где векторный поиск проигрывает с разгромом — и это не баг, а архитектура.
  • Reciprocal rank fusion — одна формула, ноль настроек, оба ранкинга остаются на равных.
  • Реальные числа из эвал-сета: BM25 vs vector vs hybrid.
GIF · ranking · 800×400
Две колонки — BM25 и Vector — выдают свои топ-5. Третья колонка Hybrid собирается из обеих по очереди, подсвечивая, как RRF выбирает лучших.
~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.