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 по касательной, для эмбеддера выглядят одинаково релевантными; карта же прямо указывает: вот источник правды.
Отсюда три следствия, которые видно в числах:
- Мета-слой не попадает в чанки. Статус, владелец, дата, «что уже закрыто» лежат во frontmatter и README — короткими строками, не похожими на содержательный вопрос. Поиск по смыслу их системно недооценивает.
- Чанк теряет место в дереве. Принадлежность файла проекту записана структурой каталогов; в тексте самого чанка её нет. Grep со списком путей эту структуру видит бесплатно.
- Карта — уже посчитанный слой синтеза. Кто-то (я или агент по моим правилам) один раз проделал работу по агрегации и записал результат. RAG каждый раз выводит картину заново из фрагментов и на этом теряет.
Отдельно отмечу: BM25 у режима R был внутри гибрида. Проигрыш на обзорных вопросах не объясняется тем, что «вектор не знает точных слов» — не хватало именно курирующего слоя.
Где мой тест врёт
- 24 вопроса — мало. Разворот значим, абсолютные величины разрывов — нет. На другой базе цифры поедут.
- Судья — модель, и один на вопрос. Я перепроверил выборку оценок руками и согласился с ними, но полноценной второй разметки человеком не было.
- Число вызовов — самоотчёт агента. Телеметрию рантайма я не снимал: порядок величин верный, точность до десятых — нет.
- Время не сравнивал. У режима R оно искажено загрузкой модели и очередью к общему серверу; честный замер требовал бы отдельной обвязки.
- Соперник — не голый grep, а ухоженная карта. Это сознательный выбор: я сравнивал с реальной своей навигацией. На базе без карты и README расклад на обзорных вопросах почти наверняка сместился бы в пользу RAG. Если вы прикидываете RAG на свалку из тысячи заметок — мой результат к вам применим только наполовину.
- Вопросы придумывал я, зная базу. Я старался держаться человеческого языка и подальше от лексики документов, но полностью снять этот эффект нельзя.
- Прогоны шли в два захода (упёрся в лимит сессии), продолжение делалось с кэшем. На слепоту судейства это не влияло.
Что с этим делать
Если вы стоите перед тем же выбором для своей базы:
Оставить карту руками Слой синтеза окупается на обзорных вопросах и не воспроизводится поиском по смыслу автоматически. Двадцать минут в неделю на обновление статусов дают то, чего 18 часов индексации не дали.
Подключить семантический поиск как инструмент точечных запросов. Он дешевле по шагам и не требует угадывать слово из документа. Как единственный интерфейс к базе — не годится, агент теряет рамку.
Считать чанки заранее — токенизатором целевой модели. Оценка на глаз промахнётся: разница между «прикинул 15 тысяч» и «получил 29 тысяч» — это разница между вечером и двумя ночами.
Проверить на своей базе Методика переносится за вечер: 20–25 вопросов с эталонами (самая дорогая часть, часа полтора), два режима с одинаковым лимитом шагов, обезличенные пары, слепой судья. Заморозьте набор до первого прогона — иначе замер превратится в подгонку.
Краткие выводы
- Семантический поиск по личной базе окупается на точечных фактах: то же качество за половину шагов. На вопросах «собери картину» он теряет статусы и рамку и проигрывает курируемой карте 8:2.
- Тип вопроса определяет победителя сильнее, чем сам инструмент. Общий счёт 9:9 без разбивки по типам не значит ничего.
- Локальный bge-m3 на процессоре — 0,45 чанка в секунду, и ускорители тут не помогают. Планируйте ночи, делайте индексацию инкрементальной.
- Курируемая карта оказалась сильным соперником, которого в сравнениях RAG обычно не измеряют: меряются с поиском по ключевым словам, а живой человек, поддерживающий оглавление, в сравнение не попадает.
Инструмент я оставил подключённым, карту продолжаю вести руками. Три дня на эксперимент вернулись знанием, какому вопросу какой инструмент отдавать, — это дешевле, чем год пользоваться поиском, который тихо теряет половину контекста на обзорных запросах.
Свою базу вы знаете лучше меня: посчитайте, каких вопросов вы задаёте агенту больше — «где точно записано вот это» или «собери всё, что мы знаем о теме»? От ответа зависит, стоит ли вам эти 18 часов вообще тратить.
Отдельно интересно: если у кого-то есть замер, где RAG выигрывает обзорные вопросы у живой карты, — покажите методику, я хочу понять, что у вас устроено иначе.