Read in:
Русский

Построили бенчмарк — нашли пять багов в поиске

Сначала результаты — потом история.

Изменение Recall@10 nDCG@10 MRR en→ru nDCG
Базовая версия 0.983 0.916 0.942 0.845
F1: расширение пула fusion 1.000 0.922 0.942 0.860
F2: языковой анализатор BM25 1.000 0.922 0.942 0.860
F3: cross-encoder rerankер 1.000 0.888 0.871 0.879
→ отправили reranker отключённым 1.000 0.922 0.942 0.860
F4: хлебные крошки + токен-размер чанков 0.992 0.926 0.950 0.867
F5: dot-product вместо косинуса 0.992 0.926 0.950 0.867
F5✗: AND→OR в BM25 — откатили 0.675 0.674 0.956 0.606

Трек для длинных документов (6–10 чанков на заметку):

Изменение nDCG@10 en→en nDCG Δ nDCG
База (после F1–F3) 0.931 0.816
F4: хлебные крошки 0.954 0.908 +0.023
F5✗: AND→OR — откатили 0.766 0.908 −0.188

Все числа из зафиксированных артефактов в docs/superpowers/eval-runs/.


Как устроен поиск и зачем нам понадобился бенчмарк

Поиск в trip2g — гибридный: BM25 на bleve + векторный поиск по чанкам (модель bge-m3, 1024 измерения), результаты объединяются через Reciprocal Rank Fusion при k=60.

Мы подозревали баги, но не знали, насколько они критичны. Поэтому сначала построили измерительный стенд.

Корпус — 48 заметок по шести темам: закваска, ферментированный чай, горутины, каналы, квашеная капуста, зелёный чай. На каждую тему — одна правильная заметка и три заметки-дистрактора (близкие по теме, но не те). Запрос про закваску должен найти заметку про закваску, а не про дрожжевой хлеб или квашеную капусту. Все заметки — на русском и английском. Золотой набор — 60 запросов с ручной разметкой по четырём языковым направлениям.

Модель не менялась. Каждое изменение применялось изолированно, числа фиксировались до следующего шага.


F1: отрезанный пул кандидатов съедал весь кросс-языковой recall

Первый баг — самый неловкий.

Код вычислял косинусное сходство для каждого чанка в индексе. Это работало. Но потом он брал только vectorTopK = 5 уникальных заметок и передавал их в RRF-слияние. Заметка на месте #6–50 в векторном ранге, сколько бы высоко она ни стояла в BM25, не попадала в слияние вообще — хотя её сходство уже было вычислено.

Кросс-языковые результаты страдали систематически. Русская заметка по английскому запросу устойчиво попадала в диапазон #6–50 по вектору, за пределы усечённого пула. BM25 через языковую границу не работает. Заметка просто исчезала из выдачи.

Исправление: поднять vectorTopK до 50. Финальный лимит (20 результатов) не изменился — расширился только пул перед слиянием.

Recall@10 вырос с 0.983 до 1.000. nDCG +0.006, en→ru +0.015. MRR не изменился — ожидаемо: F1 добавляет кандидатов ниже топа, а первый результат и так был правильным.


F2: английский текст стемился по правилам русского языка

BM25-индекс обрабатывал весь контент русским анализатором. Запрос "embedding" стемился русскими правилами — и не совпадал с документами, где написано "embeddings".

Дополнительный баг: маппинг документов был зарегистрирован под именованным типом, но заметки индексировались как простые структуры без поля типа. Bleve молча падал на дефолтный маппинг, именованный не применялся.

Исправление: индексировать поля Title и Body дважды — с русским анализатором и с английским (Title_en, Body_en). Запрос — через disjunction по всем полям, каждое обрабатывается своим анализатором.

nDCG Δ 0.000 на этом бенчмарке. Нулевой результат честный. Кросс-языковые запросы едут на векторном канале — BM25 там в принципе не работает. bge-m3 и так хорошо ранжирует монолингвальные запросы и доминирует в RRF. F2 помогает другому классу запросов — точные термины, идентификаторы, редкие слова, где векторная модель слабее лексики. Таких запросов в текущем золотом наборе нет. Регрессии нет, исправление корректное — отправили.


F3: reranker сделал хуже

По учебнику cross-encoder reranker — главный рычаг качества. Мы добавили bge-reranker-v2-m3 вторым этапом за флагом: берёт объединённый топ-N от RRF, переранжирует, возвращает OutputK лучших.

Это единственное изменение, которое ухудшило результаты.

С отрывками 512 символов: nDCG 0.922 → 0.888, MRR 0.942 → 0.871. С полными текстами заметок — nDCG рухнул до 0.388. Тексты превысили окно cross-encoder (~512 токенов), после обрезки заметки выглядели одинаково, и reranker упорядочил их случайно.

Анализ по запросам объяснил механику. Cross-encoder поощряет поверхностное совпадение терминов с запросом. «Медленное брожение теста» — reranker поднял квашеную капусту выше закваски: у капусты больше словаря про брожение. «Каналы вместо общей памяти» — reranker поднял мьютексы выше горутин. Первый этап (bi-encoder + RRF) правильно поставил их ниже правильного ответа. Reranker эту работу отменил.

Вывод: reranker не бесплатный. Когда первый этап уже сильный (~0.92 nDCG) и корпус плотно набит тематически близкими документами, «переупорядочить всё» вредит. Мы отправили reranker отключённым (vector_search.reranker.enabled=false), сохранили код и конфигурацию для A/B-тестирования на конкретных деплоях. Более принципиальный подход — смешивать оценку reranker с рангом RRF вместо полной замены — оставлен как направление для дальнейшей работы.


F4: чанк без контекста — это слепой

Два исправления в internal/mdchunk.

Размер по токенам. Раньше размер чанка ограничивался 2000 символами. Для кириллицы 2000 символов — около 1000 токенов. Окно bge-m3 — 512 токенов. Хвост каждого длинного русского чанка молча обрезался на стороне сервера и никогда не встраивался. Переключились на оценку токенов (~450 как целевой размер) — вошли в окно с запасом.

Хлебные крошки. Каждый чанк теперь начинается с пути по заголовкам: {title} > {h1} > {h2}\n\n{body}. Раньше глубокий чанк из длинной заметки встраивался без контекста о том, где в документе он находится. Теперь эмбеддинг несёт этот контекст. Бонус: крошка совпадает с TOC в результатах поиска — агент может точечно открыть нужный раздел через note_html(toc_path=[...]).

На коротких заметках эффект почти нулевой — они умещаются в один чанк, крошка добавляет информацию, но границы не меняются. На треке длинных документов (6 глав Марка Аврелия, 6–10 чанков каждая): nDCG@10 0.931 → 0.954 (+0.023), en→en 0.816 → 0.908 (+0.092). F4 требует полного перевстраивания.


F5: одно изменение отправили, второе откатили

Dot-product вместо косинусного сходства — отправили. Сервер эмбеддингов возвращает L2-нормализованные векторы (normalize_embeddings=True). Для единичных векторов косинусное сходство алгебраически тождественно скалярному произведению. Исходный код пересчитывал обе нормы и делал деление на каждую пару чанков. Заменили на plain dot product: один проход multiply-add вместо трёх.

Результат: nDCG/Recall/MRR идентичны F4 до последней цифры — ожидаемо для алгебраически эквивалентного изменения. Только скорость.

AND→OR-фолбэк в BM25 — пробовали, получили хуже, откатили. BM25-запрос требует все термины (AND). Идея: когда AND возвращает мало результатов, повторить с OR — «бесплатное восстановление recall».

Реальные числа: короткий корпус nDCG@10 0.926 → 0.674 (−0.252), Recall@10 0.992 → 0.675. Длинные документы: nDCG 0.954 → 0.766 (−0.188). MRR почти не изменился (0.950 → 0.956) — первый результат остался правильным, а всё что ниже стало мусором.

Механика: RRF объединяет по рангу, не по баллу. Золотой набор — семантический, AND почти никогда не находит все термины. OR-фолбэк срабатывал на каждом запросе и заполнял BM25-канал до 20 слабых совпадений (документы с одним общим словом). RRF назначал им позиции 1/(60+1), 1/(60+2) — сильные сигналы. Эти низкоточные совпадения вытеснили релевантные документы, которые векторный канал правильно нашёл. Recall рухнул. MRR уцелел, потому что векторный канал всё ещё доминирует на первой позиции.

В гибридном поиске, где recall несёт векторный канал, задача BM25 — точность. Молчать на семантических запросах (AND без совпадений → пустой результат) — это не слабость, а нагрузка. «Бесплатное восстановление recall» через OR превращается в активное загрязнение выдачи. Мы узнали это, потому что измерили до мержа. Иначе потеряли бы половину качества поиска молча.


Что мы поняли

Бенчмарк появился после пайплайна, а не до. Harness для оценки, корпус и 60 запросов с разметкой мы написали и только потом запустили базовую линию. Reranker и AND→OR-фолбэк не прошли бы quality gate — вместо этого нам пришлось их откатывать постфактум.

Второй вывод — про гибридный поиск. У двух каналов разные роли: вектор несёт recall, BM25 несёт точность на точных терминах и редких словах. Тюнинг порознь и измерение вместе выявляют взаимодействия, которые интуиция по одному каналу пропускает.