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.
Но я устал писать, лучше посмотрите и позвездите мой гитхаб, а я потом напишу как же я получил эту долгожданную победу!
Но перед этим давайте вместе соберем практические выводы:
-
Не приравнивайте локальные маленькие модели к серьезным облачным. Психологически хочется поставить знак эквивалентности, но не надо. Они тоже могут адекватно отвечать, но нет той устойчивости и прогнозируемости. Отдельно изучите их поведение, погоняйте, поменяйте температуру. С ними можно работать, они могут хорошо исполнять целый класс серьезных задач.
-
Наполнение контекстного окна — база. Смотрите, что в него попадает, обрезайте его с умом, с него даже логичнее всего начинать поиск корня проблемы.
ссылка на оригинал статьи https://habr.com/ru/articles/1052280/