TLDR
Я хотел написать маленький локальный RAG для научных статей: графы, hybrid search, HyDE, reranker, всё красиво. В итоге Full Pipeline проиграл почти всем простым baseline’ам, графы начали портить контекст, HyDE вредил, а локальная LLM уверенно делала вид, что всё хорошо. Потом я разобрался, что ломалось, выкинул лишние LLM‑вызовы, починил trimming и получил систему, которая наконец начала выигрывать там, где должна.
Что происходит?
Я начал писать с виду простенький проект: чуть‑чуть графов, чуть‑чуть retrieve, чуть‑чуть генерации и ждать ответ. Почему‑то я сразу решил сконцентрироваться на таком scientific‑purpose‑RAG, возможно желал облегчить себе студенческие годы, но тем не менее…
Идея
Она была проста и элегантна:
Берем что‑то адекватного формата: (I)путь до pdf/md/docx/doc/pptx, (II)ссылку на что‑то познавательное (медиум, хабр), (III) ссылку на ютуб с нужной информацией.
Обрабатываем everything2text.
Проводим шаманства, связанные с извлечением метаданных для графов. Режем на чанки, делаем overlap.
Задаем вопрос LLM.
Получаем ответ на проиндексированной базы.
Вот так выглядела моя мечта, но с одним небольшим добавлением: все должно было происходить локально.
Выбор технологий
По сути я видел необходимость правильно выбрать ML‑сущности так, чтобы они адекватно исполняли свои функции, достаточно быстро работали на macmini m4 16gb unified memory.
На первую итерацию проекта я взял:
gemma4-4B-4bit — LLM движок, он давал около 80 ток/сек, занимался извлечением метаинформации из файлов, перефразировал вопрос, генерировал ответ, даже пытался делать Hypothetical Document Embedding.
wikineural‑multilingual‑ner — NER, чтобы извлекать поименованные сущности (авторы, институты и так далее).
intfloat‑multilingual‑e5-base — эмбеддер, который нормально работает с русским и английским языком.
А остальное, как я тогда думал, доберу по дороге. Чего париться о мелочах
Как понять, что мой baseline хорош?
Идея была проста — накрутить конфиг так, чтобы на лету можно было отключать различные модули, посмотреть, что другие конфигурации действительно плохи и радоваться тому, что я оказался прав.
Итоговая конфигурация RAG’а оказалась примерно такая:
rag_components: citation_repair: context_trimming: dense_search: dynamic_alpha_blending: graph_expansion: graph_ontology_lookup: hyde: intent_classifier: lexical_search: llm_query_expansion: reranker: rrf: score_blending:
True/False я убрал, чтобы сохранить интригу:)
Далее я планировал проиндексировать статей 50, чтобы был полезный статистический шум (не очень интересно смотреть, когда всего один документ, по которому задаются вопросы), взять n докумеynов, загнать в LLM и нагенерировать вопросов.
А кстати каких? Если вопрос соотносится только с одним документом — интереса и доверия такая система не вызывает. В научной литературе неплохо справиться и обычный BM25 (есть специальные термины, которые достаточно узки, можно предположить, что термин + ключевое слово уже будет давать хорошие результаты).
Потому было принято решение нагенерировать такие вопросы:
Single‑hop — вопрос относится только к одному документу.
Multi‑hop — вопрос относится только к двум документам (Multi выбрано для маркетинговых целей, dual звучит скучно и неинтересно).
Таков был путь.
Итоговый пайплайн
Я в принципе уже дал достаточно контекста, но для явности приведу описание одной итерации:
Пользователь задаёт вопрос. На вход приходит обычный query: что‑то вроде «какой метод использовали авторы статьи X?» или «чем отличаются подходы из двух документов?».
Intent & Filter Extract.LLM пытается понять намерение пользователя и вытащить полезные фильтры: автора, год, название статьи, институт, ключевые сущности и другие ограничения, если они есть в вопросе.
Query Expansion & HyDE. Запрос расширяется: добавляются близкие формулировки, связанные концепты из онтологии и, при включённом HyDE, гипотетический ответ, который потом используется для поиска похожих чанков.
Параллельный поиск по базе. Дальше запрос уходит сразу в два поиска:
Dense Vector Search — семантический поиск по эмбеддингам через USearch HNSW.
Sparse FTS5 Search — классический keyword‑search по текстовому индексу.
Adaptive RRF Blending. Результаты dense‑ и sparse‑поиска объединяются через Reciprocal Rank Fusion. Идея простая: если чанк хорошо поднялся в разных поисковых стратегиях, он заслуживает больше доверия.
Cross‑Encoder Reranker. После первичного объединения кандидаты дополнительно пересортировываются reranker’ом. Он уже смотрит не просто на близость вектора или совпадение слов, а на пару «вопрос — найденный фрагмент» целиком.
Graph Context & Trim. К найденным чанкам добавляется графовый контекст: связанные сущности, соседние узлы, пути между концептами. Потом всё это обрезается с учётом лимита контекстного окна, чтобы в LLM не улетела простыня мусора.
Local LLM Generation. Локальная LLM получает итоговый контекст и генерирует ответ через MLX.
Citation Repair Engine. Последним шагом отдельный модуль проверяет ссылки вида
[1],[2]и пытается убедиться, что они действительно подтверждают соответствующие утверждения в ответе.
На бумаге это выглядело как взрослая RAG‑система: hybrid retrieval, графы, reranker, HyDE, citation repair. На практике оказалось, что каждый новый «умный» компонент не только помогает, но и добавляет новый способ всё испортить.
Много тысяч строк кода спустя…
Я закончил, немногочисленные тесты озарились приятным зеленым свечением, я руками отдельно потестил отдельные компоненты, но пришел ужас от понимания одной вещи.
Правильность работы была видимой: в БД был полный ужас, авторы почти все было et. all, текст из PDF’а кривой, но система устойчиво делала вид, что все хорошо. На вопросы прилетали какие‑то ответы (которые действительно существовали в исходниках), были ответы, что информации нет. Видилась нормальная и адекватная работа.
И я решил тестировать нетестируемое: ответы от ЛЛМ. Мой друг, ИИ‑агент, писал edge‑тесты на форматирование, вредное содержимое (прим. автор — «Samsung» и прочие очевидные ошибки). И все озарилось красной краской.
Зафиксируем положение
Оно работало, но не работало. И это требовало решение. Такой pipeline стыдно пускать в bench.
Основные проблемы уже нельзя было отследить в полуавтоматическом режиме, требовалось ручками смотреть/тыкаться, искать вредное поведение, формализовывать его в тестах и выпиливать.
Но было ощущение, что оно почти работает.
Пробы, ошибки, тесты
Далее был долгий путь, который я описывал ранее, много коммитов, много работы и оно даже начало работать как надо, это было видно, по записям в yaml‑файл, куда записывались: вопросы, чанки, ответы.
Настоящее тестирование!
Настал тот час, оно работало, тесты горели приятным цветом, и я полез писать модуль бенчмарка. Я мерил несколько основных метрик, которые показывали бы качество ответов на выборке вопросов:
Основные метрики качества RAG‑системы
Метрика | Что означает |
|---|---|
Retrieval Recall | Показывает, насколько хорошо система находит все необходимые документы или фрагменты для ответа. Высокое значение означает, что среди извлечённого контекста присутствует большая часть релевантной информации. |
Context Precision | Оценивает, насколько извлечённый контекст действительно релевантен запросу. Чем выше значение, тем меньше лишней информации попадает в контекст. |
Faithfulness | Измеряет, насколько ответ модели основан на предоставленном контексте и не содержит «галлюцинаций». Высокое значение означает, что утверждения в ответе подтверждаются найденными документами. |
Answer Relevance | Показывает, насколько ответ соответствует исходному вопросу пользователя. Метрика оценивает полноту и релевантность ответа независимо от качества поиска. |
Citation Fidelity | Оценивает корректность ссылок на источники: действительно ли приведённые цитаты или ссылки подтверждают соответствующие утверждения в ответе. |
Semantic Accuracy | Измеряет смысловую близость ответа к эталонному ответу. В отличие от буквального совпадения текста, учитывается сохранение смысла. |
Дополнительные метрики
Метрика | Что означает |
|---|---|
Context Fillness | Доля доступного контекстного окна, заполненная извлечёнными документами. Более высокое значение означает, что система использует больше пространства для передачи информации модели. |
Latency | Среднее время генерации ответа системой. Меньшее значение соответствует более высокой скорости работы. |
Token Output | Общее количество токенов, сгенерированных моделью за один запрос (ответ + внутренние рассуждения, если они учитываются). |
Token Answer | Количество токенов, вошедших непосредственно в итоговый ответ пользователю. |
Token Reasoning | Количество токенов, затраченных моделью на внутренний процесс рассуждения (reasoning). Большие значения обычно свидетельствуют о более сложном процессе вывода, но также увеличивают стоимость и задержку выполнения. |
По окончанию этого тяжелого этапа скажу, что этих метрик достаточно, они достаточно четко позволяют сравнивать различные вариации той или иной RAG‑системы.
Тут важно уточнить, после того, как знающие люди вдоволь посмеялись, я поменял модель на Qwopus3.5–9B-4bit, gemma была слишком маленькая, а это квен, дообученный на рассуждениях opus’а, выглядит как чистая победа.
Так и что, я запустил бенчмарк на ночь, утром проснулся замотивированным сразу идти смотреть что же там вышло и увидел …
Полный провал
Baseline | Recall | Precision | Faithfulness | Relevance | Citation | Accuracy | Latency |
|---|---|---|---|---|---|---|---|
B0: Zero‑Shot | 0.000 | 0.000 | 0.000 | 0.446 | 0.000 | 0.078 | 405.17s |
B1: Lexical | 0.730 | 0.749 | 0.794 | 0.512 | 0.812 | 0.386 | 196.64s |
B2: Dense | 0.800 | 0.822 | 0.737 | 0.516 | 0.771 | 0.386 | 227.99s |
B3: HyDE | 0.620 | 0.657 | 0.543 | 0.400 | 0.520 | 0.273 | 237.88s |
B4: Hybrid | 0.860 | 0.853 | 0.742 | 0.612 | 0.835 | 0.485 | 210.75s |
B5: Graph | 0.860 | 0.853 | 0.596 | 0.536 | 0.664 | 0.372 | 278.61s |
B6: Full Pipeline | 0.660 | 0.758 | 0.485 | 0.438 | 0.408 | 0.346 | 463.93s |
Mean | 0.647 | 0.670 | 0.557 | 0.494 | 0.573 | 0.332 | 288.71s |
Median | 0.730 | 0.758 | 0.596 | 0.512 | 0.664 | 0.372 | 237.88s |
Эта таблица краше всех слов, насколько мой пайплайн показал себя плохо, я себя так не показывал, хотя тоже не подарок.
Я проиграл по всем метрикам, ни одной не забрал.
А что же это за baselines?
Это обозначение пришло в мою жизнь через различные статьи и публикации, если пытаться его определить — разные способы исполнения одной задачи. У них разные преимущества, особенности и так далее, но так как они реализуют одну функцию, то мы вправе их сравнивать по формализованным критериям.
Baseline | Что включено |
|---|---|
B1 | только lexical |
B2 | только dense |
B3 | HyDE |
B4 | hybrid |
B5 | hybrid + graph |
B6 | full pipeline |
Разбор полетов
Почему так? Я очень злой пошел смотреть, что извлекалось в логах и увидел, что контекст обрезался абсолютно страшно, вверху оказывались бесполезные, вредные графовые данные, которые квантованная модель, видимо, не в состоянии обрабатывать.
С этим я разобрался, добавив иерархию тримминга: сначала граф, потом наименее вероятные чанки по RRF.
Потом я посмотрел на третий baseline — HyDE, я думал, что он станет вишенкой на торте, которая покажет какая это гениальная идея: попросить маленькую глупенькую квантованную модель написать гипотетический ответ на сложный комплексный уникальный научный вопрос. Проблем же не возникнет?
Возникнут, конечно, поэтому я сразу же отключил в конфигурации все дополнительные вызовы LLM. Осталась только для генерации ответа.
Что было потом
Дни мук спустя получилось вот так:
Качество ответов
Baseline | Retrieval Recall | Context Precision | Faithfulness | Answer Relevance | Citation Fidelity | Semantic Accuracy |
|---|---|---|---|---|---|---|
B1 | 0.690 | 0.736 | 0.713 | 0.380 | 0.040 | 0.158 |
B2 | 0.800 | 0.822 | 0.583 | 0.321 | 0.047 | 0.166 |
B3 | 0.720 | 0.704 | 0.651 | 0.282 | 0.050 | 0.090 |
B4 | 0.840 | 0.837 | 0.618 | 0.376 | 0.060 | 0.197 |
B5 | 0.840 | 0.837 | 0.624 | 0.314 | 0.057 | 0.230 |
B6 | 0.840 | 0.898 | 0.675 | 0.382 | 0.017 | 0.219 |
Производительность
Baseline | Context Fillness | Latency | Token Output | Token Answer | Token Reasoning |
|---|---|---|---|---|---|
B1 | 0.223 | 71.02s | 1341.8 | 967.1 | 374.7 |
B2 | 0.216 | 71.76s | 1380.5 | 1009.8 | 370.7 |
B3 | 0.227 | 124.54s | 2365.8 | 1362.2 | 1003.6 |
B4 | 0.226 | 48.12s | 943.6 | 506.9 | 436.8 |
B5 | 0.256 | 101.25s | 1861.3 | 537.5 | 1323.8 |
B6 | 0.230 | 71.98s | 1405.5 | 1041.2 | 364.3 |
А это победа, друзья мои! Да не все метрики мои, есть явные проседания по Answer Relevance, но я не могу осознать как она считается, чтобы исправить ее. Faithfulness подупал, но, как мне кажется, он упал не так сильно, плюс это такой trade‑off с более высоким precision/recall.
Но я устал писать, лучше посмотрите и позвездите мой гитхаб, а я потом напишу как же я получил эту долгожданную победу!
Но перед этим давайте вместе соберем практические выводы:
Не приравнивайте локальные маленькие модели к серьезным облачным. Психологически хочется поставить знак эквивалентности, но не надо. Они тоже могут адекватно отвечать, но нет той устойчивости и прогнозируемости. Отдельно изучите их поведение, погоняйте, поменяйте температуру. С ними можно работать, они могут хорошо исполнять целый класс серьезных задач.
Наполнение контекстного окна — база. Смотрите, что в него попадает, обрезайте его с умом, с него даже логичнее всего начинать поиск корня проблемы.
