/EVSTYGNEYМаркетинг на языке денег
ИИ и инструменты

RAG против карты и grep: слепое сравнение на своей базе из 2000 заметок

TL;DR В прошлой статье я отмахнулся от RAG для личной базы знаний: «пушка по воробьям». Потом собрал его руками и измерил. Локальная модель bge-m3, sqlite-vec, 29 295 чанков, 18 часов индексации на процессоре, поисковая тула для агента. Дальше — слепое сравнение с моей текущей навигацией (карта main.md + grep) на 24 вопросах, два режима, одинаковый лимит шагов, обезличенные пары и судья, который не знал, чей ответ читает. Итог — ничья 9:9. Внутри ничьей разворот: точечные вопросы семантический поиск берёт 7:1 и вдвое дешевле по шагам, обзорные проигрывает 8:2. Точный тест Фишера на этом развороте даёт p ≈ 0,015. Ниже — стек, методика замера, все числа и раздел про то, где мой тест врёт. Чтение займёт минут десять.

Простыми словами RAG здесь — поиск по смыслу: текст режется на куски, каждый кусок превращается в вектор, вопрос тоже превращается в вектор, дальше ищем ближайшие. Карта (main.md) — файл-оглавление, по строке на каждый проект и раздел базы: агент читает его и понимает, куда идти.

Зачем я полез проверять собственное утверждение

Четыре месяца мой ИИ-агент живёт на файловой памяти: карта воркспейса, память по проекту, база знаний в markdown. Про эту конструкцию была прошлая статья, и в ней я двумя абзацами закрыл тему семантического поиска: инфраструктура ради задачи «не пересказывать проект», результат поиска по эмбеддингам не отревьюишь глазами так же просто, как файл. И оставил себе лазейку: «если у вас тысячи заметок с перекрёстными смысловыми запросами — RAG вернётся в разговор».

Потом я посчитал файлы. Их 3156.

Дальше было два пути: продолжать рассуждать о том, чего не пробовал, или собрать RAG и померить. Выбрал второе, с условием — мерить честно, включая вариант «отрицательный результат». Отрицательный результат меня устраивал: я бы сэкономил вечера и написал, почему не стоит.

База и что в неё вошло

3156 markdown-файлов, 64 МБ текста. Из них 31 МБ — выгрузки таблиц в markdown, куски исследований по 1–2 МБ. Табличные дампы я из индекса выкинул сразу: чанк из такой простыни — набор ячеек без смысла, он будет мусорить выдачу и портить сравнение в пользу карты.

Осталось 1978 файлов и 26 МБ живого текста. При чанках по 600 токенов я прикинул 12–18 тысяч кусков. Реально получилось 29 295 — промах вдвое, потому что заголовков в базе больше, чем я думал, и резка по секциям даёт много коротких чанков. Первый урок дешёвый: считайте чанки токенизатором целевой модели до того, как планировать время индексации.

Замечание В базе есть то, что не хочется отдавать на чужой сервер, поэтому облачные эмбеддинги отпали на входе. Полная индексация через API стоила бы центов тридцать — сумма смешная, но текст при этом уезжает на чужой сервер. Второй довод в пользу локальной модели прагматичнее приватности: индекс намертво привязан к эмбеддеру. Депрекейтнут модель в облаке — поиск умер до полной переиндексации. Модель на диске никуда не денется.

Стек и 18 часов на процессоре

  • Эмбеддер: bge-m3, 1024 измерения, мультиязычная. Веса 2,2 ГБ на диске.
  • Хранилище: SQLite с расширением sqlite-vec для векторов и штатным FTS5 для BM25. Один файл на 120 МБ, никакого сервера. Векторов мало (29 тысяч), приближённый поиск не нужен — полный перебор укладывается в миллисекунды.
  • Поиск: гибрид, вектор и BM25 идут параллельно, результаты сливаются ранговым методом RRF. Это важно для чистоты сравнения: режим RAG получил внутрь и лексический поиск тоже, так что проигрыш нельзя списать на «вектор не знает слова».
  • Чанкинг: по заголовкам, 600 токенов, перекрытие 100, метаданные из frontmatter.
  • Интерфейс для агента: MCP-сервер с одной тулой search_kb(запрос, k, префикс_пути), отдаёт текст чанков с путями.

Дальше начались измерения, которые я в план не закладывал. Бенчмарк bge-m3 на моём ноутбуке (12 логических ядер, дискретной видеокарты нет) — 0,45 чанка в секунду. Это 18 часов на полный прогон.

Я честно попробовал разогнать. ONNX через onnxruntime — 0,46 чанка/с. Динамическое int8-квантование — 0,42, то есть медленнее. Векторы при этом совпали с исходными до косинуса 1,0, так что квантование как минимум ничего не сломало, просто не помогло. Вывод для тех, кто пойдёт тем же путём: такая модель упирается в пропускную способность памяти, поэтому типовые процессорные ускорители на ней не дают ничего. Индекс собирался ночами, полтора суток по календарю; инкрементальность по SHA-256 спасла, когда прогон оборвался на 60% — второй запуск продолжил с того же места.

Плюсы запросы работают мгновенно. Эмбеддинг вопроса — десятые доли секунды, поиск по 29 тысячам векторов — миллисекунды. Медленная только индексация, и она разовая.

Грабли, на которые я потратил вечер:

Грабля Что происходит Фикс
fastembed не поддерживает bge-m3 модели нет в списке, а хочется именно мультиязычную sentence-transformers + torch с процессорного индекса
В пакете mcp 2.0 нет FastMCP ModuleNotFoundError на импорте по любому туториалу класс MCPServer из mcp.server.mcpserver, API тот же
Модель грузится 13 секунд при импорте клиент считает сервер мёртвым по таймауту рукопожатия ленивая загрузка при первом запросе, старт мгновенный
Индексер и поиск дерутся за файл базы «database is locked» посреди прогона читателю — соединение read-only и busy_timeout
Логи модели в stdout ломают протокол обмена с агентом всё, кроме ответа, гнать в stderr

Как я мерил, чтобы не обмануть себя

Соблазн при таком замере один: подсознательно подыграть системе, в которую вложил три дня. Поэтому методику я зафиксировал до первого прогона.

Эталонный набор — 24 вопроса, замороженные заранее. 14 точечных (ответ лежит в одном конкретном месте: значение параметра, правило, шаблон ссылки) и 10 обзорных, где ответ собирается из 2–5 файлов разных проектов. К каждому вопросу — список эталонных файлов и ключевой факт, который обязан прозвучать.

Эталоны собирал отдельный агент-разведчик обычными Grep и Glob. Если искать источники правды тем же инструментом, который потом тестируешь, набор молча подгонится под его выдачу. Разведка по двум вопросам поправила меня: я помнил цифры не так, как они записаны в базе. Формулировки этих двух вопросов я привёл к тому, что реально лежит в файлах.

Два режима, одинаковые условия Режим R: агенту доступна только поисковая тула, читать можно лишь файлы из её выдачи. Режим G: только main.md, Grep, Glob и чтение. Одна и та же модель, один и тот же промпт, жёсткий лимит 15 вызовов инструментов, свежая сессия на каждый вопрос — 48 прогонов.

Слепое судейство Судья получал вопрос, эталон и два обезличенных ответа со сменным порядком внутри пары. Какой ответ чьей системы — он не знал.

Отдельная защита от протечки Сам проект сравнения лежит в той же базе, и к моменту прогонов эталонный набор с ответами уже был проиндексирован. Поисковый сервер жёстко исключает каталог проекта из выдачи, иначе режим R получил бы шпаргалку.

Весь эксперимент — 72 агентских прогона и около 3 миллионов токенов.

Числа

Метрика Точечные (14): RAG Точечные: карта+grep Обзорные (10): RAG Обзорные: карта+grep
Победы по судье 7 1 2 8
Ключевой факт найден 1,00 1,00 0,50 0,80
Покрытие эталонных файлов 0,93 0,89 0,69 0,77
Вызовов инструментов (среднее) 2,3 4,1 5,6 5,2
Галлюцинации 0 0 0 0

Ничьих по решению судьи — 6, все на точечных вопросах. Общий счёт по всем 24 вопросам: 9:9.

Про значимость сразу и честно. Ничья 9:9 сама по себе не говорит ничего. Каждая половина по отдельности тоже не дотягивает до значимости: двусторонний знаковый тест даёт p ≈ 0,07 на точечных и p ≈ 0,11 на обзорных. А вот разворот между типами вопросов держится: точный тест Фишера на таблице 2×2 (тип вопроса × победитель) даёт p ≈ 0,015. То есть выборка мала для утверждения «RAG лучше на точечных на столько-то», но достаточна для утверждения «тип вопроса меняет победителя».

Где семантический поиск выиграл

Точечный факт достаётся с тем же качеством, что у grep, и почти вдвое дешевле по шагам. На четырёх вопросах хватило одного вызова: спросил человеческим языком — получил канонический файл с нужной строкой. Режим G на тех же вопросах шёл цепочкой: карта → раздел → файл → чтение, четыре-пять шагов.

Выигрыш накапливается там, где формулировка вопроса не совпадает с лексикой документа. Спрашиваешь «какое значение параметра принято в расчёте», а в файле он назван техническим именем переменной — вектор такой мост строит, grep требует угадать слово.

В шагах 2,3 против 4,1 на точечных вопросах. Для агента шаг — это чтение файла в контекст, то есть деньги и место в окне. На сотне точечных запросов в неделю разница заметна в счёте.

Где проиграл

На обзорных вопросах RAG находил правильные файлы (0,69 против 0,77 — почти паритет), но ключевой факт передавал вдвое реже: 0,50 против 0,80. Два показательных случая.

Потерянный статус Вопрос: «где всё, что касается интеграции с внешним рекламным API, и в каком она состоянии». Режим R собрал механику целиком — порядок вызовов, авторизацию, ограничения. И не сказал главного: работа стоит на паузе. Строка про статус живёт во frontmatter памяти проекта, короткая, тематически не похожая на вопрос о содержании — в топ выдачи она не попала. Режим G прочитал README проекта и увидел статус в шапке файла.

Полный промах Вопрос: «какие материалы у нас есть по такой-то теме и в каком проекте они лежат». Режим R сжёг 12 вызовов из 15, не достал ни одного эталонного файла и собрал ответ по косвенным упоминаниям в соседних проектах — с ошибками в деталях (насчитал три материала вместо четырёх и потерял ещё один). Канонический README проекта утонул среди тематически похожих чанков. Режим G дошёл через карту за 4 вызова с полным попаданием.

Ноль галлюцинаций на 48 прогонов — единственная метрика, где системы одинаково хороши. Оба режима отвечали строго от источников, разница была в полноте.

Почему так вышло

Гипотеза механизма, которую я вынес из разбора: вектор ищет похожее, карта хранит каноническое. На вопрос «где правда про X» похожесть — плохой заменитель авторитетности. Канонический документ и десять чанков, упоминающих X по касательной, для эмбеддера выглядят одинаково релевантными; карта же прямо указывает: вот источник правды.

Отсюда три следствия, которые видно в числах:

  1. Мета-слой не попадает в чанки. Статус, владелец, дата, «что уже закрыто» лежат во frontmatter и README — короткими строками, не похожими на содержательный вопрос. Поиск по смыслу их системно недооценивает.
  2. Чанк теряет место в дереве. Принадлежность файла проекту записана структурой каталогов; в тексте самого чанка её нет. Grep со списком путей эту структуру видит бесплатно.
  3. Карта — уже посчитанный слой синтеза. Кто-то (я или агент по моим правилам) один раз проделал работу по агрегации и записал результат. RAG каждый раз выводит картину заново из фрагментов и на этом теряет.

Отдельно отмечу: BM25 у режима R был внутри гибрида. Проигрыш на обзорных вопросах не объясняется тем, что «вектор не знает точных слов» — не хватало именно курирующего слоя.

Где мой тест врёт

  • 24 вопроса — мало. Разворот значим, абсолютные величины разрывов — нет. На другой базе цифры поедут.
  • Судья — модель, и один на вопрос. Я перепроверил выборку оценок руками и согласился с ними, но полноценной второй разметки человеком не было.
  • Число вызовов — самоотчёт агента. Телеметрию рантайма я не снимал: порядок величин верный, точность до десятых — нет.
  • Время не сравнивал. У режима R оно искажено загрузкой модели и очередью к общему серверу; честный замер требовал бы отдельной обвязки.
  • Соперник — не голый grep, а ухоженная карта. Это сознательный выбор: я сравнивал с реальной своей навигацией. На базе без карты и README расклад на обзорных вопросах почти наверняка сместился бы в пользу RAG. Если вы прикидываете RAG на свалку из тысячи заметок — мой результат к вам применим только наполовину.
  • Вопросы придумывал я, зная базу. Я старался держаться человеческого языка и подальше от лексики документов, но полностью снять этот эффект нельзя.
  • Прогоны шли в два захода (упёрся в лимит сессии), продолжение делалось с кэшем. На слепоту судейства это не влияло.

Что с этим делать

Если вы стоите перед тем же выбором для своей базы:

Оставить карту руками Слой синтеза окупается на обзорных вопросах и не воспроизводится поиском по смыслу автоматически. Двадцать минут в неделю на обновление статусов дают то, чего 18 часов индексации не дали.

Подключить семантический поиск как инструмент точечных запросов. Он дешевле по шагам и не требует угадывать слово из документа. Как единственный интерфейс к базе — не годится, агент теряет рамку.

Считать чанки заранее — токенизатором целевой модели. Оценка на глаз промахнётся: разница между «прикинул 15 тысяч» и «получил 29 тысяч» — это разница между вечером и двумя ночами.

Проверить на своей базе Методика переносится за вечер: 20–25 вопросов с эталонами (самая дорогая часть, часа полтора), два режима с одинаковым лимитом шагов, обезличенные пары, слепой судья. Заморозьте набор до первого прогона — иначе замер превратится в подгонку.

Краткие выводы

  1. Семантический поиск по личной базе окупается на точечных фактах: то же качество за половину шагов. На вопросах «собери картину» он теряет статусы и рамку и проигрывает курируемой карте 8:2.
  2. Тип вопроса определяет победителя сильнее, чем сам инструмент. Общий счёт 9:9 без разбивки по типам не значит ничего.
  3. Локальный bge-m3 на процессоре — 0,45 чанка в секунду, и ускорители тут не помогают. Планируйте ночи, делайте индексацию инкрементальной.
  4. Курируемая карта оказалась сильным соперником, которого в сравнениях RAG обычно не измеряют: меряются с поиском по ключевым словам, а живой человек, поддерживающий оглавление, в сравнение не попадает.

Инструмент я оставил подключённым, карту продолжаю вести руками. Три дня на эксперимент вернулись знанием, какому вопросу какой инструмент отдавать, — это дешевле, чем год пользоваться поиском, который тихо теряет половину контекста на обзорных запросах.

Свою базу вы знаете лучше меня: посчитайте, каких вопросов вы задаёте агенту больше — «где точно записано вот это» или «собери всё, что мы знаем о теме»? От ответа зависит, стоит ли вам эти 18 часов вообще тратить.

Отдельно интересно: если у кого-то есть замер, где RAG выигрывает обзорные вопросы у живой карты, — покажите методику, я хочу понять, что у вас устроено иначе.