Русский
Построили бенчмарк — нашли пять багов в поиске
Сначала результаты — потом история.
| Изменение | 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 несёт точность на точных терминах и редких словах. Тюнинг порознь и измерение вместе выявляют взаимодействия, которые интуиция по одному каналу пропускает.