Метрики поиска без магии: почему recall@k врёт на уровне документов
Метрика растёт, ответы не улучшаются. Разбираю, почему считать надо по кускам, и показываю на измерении собственного корпуса, что делает с текстом фиксированная нарезка.
Знакомая картина: recall@5 подняли с 0.71 до 0.86, отчёт красивый, а пользователи по-прежнему жалуются, что система «не находит очевидного».
Обычно в этот момент начинают менять модель эмбеддингов. Почти всегда зря: метрика измеряет не то, от чего зависит ответ.
Что такое recall@k на самом деле
Определение простое. Берём набор вопросов, для каждого заранее помечено, что является правильным источником. Смотрим первые k результатов выдачи и считаем долю вопросов, для которых правильный источник туда попал.
Вся сложность прячется в словах «правильный источник». Обычно им объявляют документ — файл, страницу, статью. Так размечать быстрее: разметчику достаточно сказать «ответ в регламенте по возвратам».
И вот здесь метрика отрывается от реальности.
Найден документ — не значит найден ответ
Модель не читает документ. Она читает кусок, который ей принёс ретривер. Между «нужный документ попал в выдачу» и «в контекст приехал абзац с ответом» лежит целый шаг, и метрика на уровне документов его не видит.
Разница проявляется в двух направлениях сразу.
Ложный успех. Регламент найден, но в контекст уехал кусок из раздела про оформление, а не про отмену. Recall@5 засчитан, ответа нет. Чем длиннее документы, тем чаще: в стостраничном регламенте попадание «в документ» почти ничего не гарантирует.
Ложная неудача. Нужный абзац приехал, но из другого документа — из инструкции, а не из регламента, который был помечен эталонным. Метрика засчитывает промах, система отвечает правильно.
Обе ошибки смещают оценку в разные стороны и потому не гасят друг друга, а делают метрику попросту нечитаемой: вы не знаете даже знак смещения.
Лечится это одним изменением: эталон размечается на уровне куска, а не документа. Дороже — разметчику приходится указывать конкретный абзац. Зато метрика начинает измерять ровно то, от чего зависит ответ.
Измерение: что фиксированная нарезка делает с текстом
Дальше вопрос, который обычно решают на глаз: как резать документы. Я решил посчитать — на корпусе, который у меня под рукой и который я знаю до последнего абзаца.
Материал: тексты этого сайта. Двадцать девять материалов, 103 695 знаков, 99 смысловых разделов, границы которых расставил автор, а не алгоритм.
Сначала о том, какого размера вообще бывает смысл:
| Показатель | Значение |
|---|---|
| Разделов всего | 99 |
| Медианная длина раздела | 641 знак |
| Средняя длина | 756 знаков |
| Самый короткий | 205 знаков |
| Самый длинный | 4389 знаков |
Медиана — 641 знак. Это и есть естественная единица смысла в таком тексте: столько занимает одна законченная мысль с примером.
Теперь режем фиксированными кусками по 500 знаков, как советуют в большинстве руководств, и смотрим, что получилось:
| Что измеряли | Результат |
|---|---|
| Кусков получилось | 220 |
| Не содержат ни одного заголовка | 119 (54%) |
| Начинаются не с начала предложения | 186 (85%) |
| Смысловых разделов разорвано между кусками | 94 из 99 (95%) |
Цифра 95% — главная. Практически каждый раздел, написанный как единая мысль, оказывается разрезанным: начало в одном куске, конец в другом. Ретривер приносит половину ответа, модель достраивает вторую половину сама, и получается тот самый правдоподобный ответ, который невозможно отличить от верного.
Число 54% не менее важно, хотя выглядит скромнее. Больше половины кусков не содержат ни одного заголовка — то есть в них нет ни слова о том, к какой теме они относятся. Для векторного поиска это значит, что кусок представлен только собственной лексикой, без контекста; для модели, которая потом читает найденное, — что она видит абзац, не зная, откуда он.
Оговорка о корпусе обязательна: двадцать девять материалов одного автора на одну тему — маленькая и однородная выборка. На корпусе договоров или технической документации абсолютные числа будут другими. А вот соотношение — медианный раздел длиннее типичного фиксированного куска — устойчиво по простой причине: размер куска выбирают из ограничений модели, а длину раздела автор выбирает из соображений смысла. Эти два числа не связаны ничем, и совпадать им неоткуда.
Что делать
Резать по структуре. Заголовки, абзацы, пункты списка — границы, которые уже расставил автор. Самый дешёвый рычаг качества поиска, какой я знаю: не требует ни новой модели, ни разметки, ни денег.
Приклеивать путь заголовков к куску. К тексту раздела добавляется строка вида «Регламент возвратов → Отмена заказа → После оплаты». Десять строк кода, и 54% кусков без темы превращаются в ноль.
Ограничивать сверху, а не задавать жёстко. Разделы длиннее лимита резать, но по абзацам и с перекрытием. Разница с фиксированной нарезкой в том, что рез становится исключением, а не правилом: в моём корпусе так пришлось бы резать десять разделов из девяноста девяти при лимите 1200 знаков вместо девяноста четырёх при пятистах.
Считать recall по кускам. Иначе всё вышесказанное невозможно проверить: метрика на документах покажет одинаковые числа до и после.
Условие несогласия
Если ваши документы сами по себе короткие — карточки товаров, ответы на частые вопросы, записи справочника, — вся эта работа не нужна. Документ и есть кусок, разметка на уровне документа корректна, и recall@k измеряет ровно то, что надо.
Граница проходит там, где документ перестаёт быть об одной вещи. Признак простой: если к документу можно задать два вопроса с разными ответами, его надо резать и размечать по кускам.
Чего я делать не стал бы
Не стал бы подбирать размер куска экспериментально, пока нарезка идёт по счётчику знаков. Вы будете искать оптимум между «рвёт мысли» и «тащит лишнее», хотя оба недостатка происходят от одного и того же решения резать не там.
Не стал бы гнаться за recall@20. Большое k почти всегда даёт красивую метрику и худший ответ: в контекст приезжает двадцать кусков, из которых девятнадцать не по делу, а модель, как известно, теряет середину.
Не стал бы менять модель эмбеддингов, не посмотрев глазами на десяток кусков, которые она вернула. В половине случаев видно сразу: модель нашла ровно то, о чём просили, — просто в найденном куске нет ответа.
Открытый вопрос
Нарезка по структуре опирается на то, что структура есть. В аккуратной документации она есть; в переписке, в расшифровках созвонов, в выгрузке из старой системы — нет, и разметить её автоматически я пока не умею. Модель, которую просят расставить границы разделов, справляется прилично, но это ещё один вызов на каждый документ при загрузке — и я не знаю, окупается ли он, потому что честно сравнить не с чем: эталонной нарезки для такого текста тоже никто не размечал.