Все мы проходили через это: скармливаешь RAG‑системе сложный PDF на 50 страниц, а она в ответ либо галлюцинирует, либо вываливает на LLM простыню нерелевантного текста, съедая ваш бюджет на токены быстрее, чем вы успеваете сказать «GPT-4o». Проблема в том, что классический подход со статическимtop_k— это костыль, который либо не додает контекста, либо вызывает у модели информационное «ожирение» (заполняет контекст нерелевантным мусором). Нашему RAG нужно помочь адаптироваться к безжалостной среде разрозненных документов!
Я потратил выходные на то, чтобы решить эту проблему фундаментально. В итоге на свет появилсяDRAG with KNEE(Dynamic RAG with Knee‑point pruning) — алгоритм, который не просто ищет «похожее», а выстраивает иерархию документов и безжалостно отсекает лишнее с помощью геометрического анализа «колена». В этой статье я покажу, как с помощью Qdrant, Python и капли математики сделать ваш RAG адаптивным.
Инструкция по применению DRAG with KNEE
Лекарственная форма:Иерархическое дерево (Hierarchical Vector Tree). В отличие от «таблеток‑пустышек» обычного RAG, действующее вещество распределено по узлам: от широких корней (summary документов) до концентрированных листьев (конкретных страниц).
Состав (Действующие вещества):
- Vector‑Dense Complex:для глубокого понимания семантики.
- Sparse‑Keyword Extract:для точно поиска по ключевым словам.
- RRF‑Enzymes:ферменты слияния, которые переваривают результаты разных поисков в единый ранжированный список.
Механизм действия (Фармакодинамика):После введения запроса алгоритм запускает «дышащий луч» (Adaptive Beam). Он прогрызает путь от корней к листьям, попутно подавляя избыточные родительские узлы, если их «дети» оказались эффективнее.
Способ применения (Алгоритм отсечения):В критической точке поиска применяетсяметод Колена. Система строит график релевантности и производит хирургический надрез в месте максимального изгиба кривой. Всё, что ниже колена — считается шумом и выводится из организма (контекста) естественным путем.
Форма выпуска:Попробовать лекарство можно на моей страницеgithub.
Архитектура
Индексация
Для того, чтобы жить в доме, сначала его нужно построить (или купить, если вы очень богаты). В моем случае домом выступает векторная база данных Qdrant, в которой строится фундамент в виде иерархических деревьев документов. Так как система предназначалась для PDF документов, которые можно легко распарсить постранично, был выбран следующий алгоритм построения иерархических деревьев:
- Документ разбивается на страницы, для каждой страницы LLM формирует сжатое смысловое представление и извлекает ключевые слова. Все конечно же через структурированный вывод, чтобы избежать галлюцинаций. Получившиеся описания с ключевыми словами формируют листья дерева.
- Листья объединяются в узлы (количество узлов для объединения можно варьировать, но чтобы избежать сильного сжатия смысла и при этом не получать деревья большой глубины я бы выбирал этот параметр равным 3–5). На основе объединенных описаний и ключевых слов строится описание родительского узла (что‑то подобное можно увидеть в алгоритмеRAPTOR), также происходит наследование ключевых слов — ключевые слова детей в полном составе передаются родителю.
- Узлы объединяются до тех пор, пока не останется один корневой узел, который получает уникальный идентификатор в своих метаданных —parent_id=-1. Так все корни легко и просто могут участвовать в стартовом поиске, который определит, контекст каких документов будет в дальнейшем уточняться нашей чудо‑таблеткой.
В конечном итоге в Qdrant коллекции у нас получится прекрасный плоский список точек, которым можно легко и просто манипулировать с помощью фильтров по метаданным.
Алгоритм поиска
Самое интересное это то, как все же нам использовать так удачно построенные деревья? Мне в голову пришло 3 алгоритма. Во всех методах точки сравниваются друг с другом с помощью гибридного поиска с объединением результатов через RRF —Reciprocal Rank Fusion, значение которого рассчитываются по формуле:
здесь— наши документы,— алгоритмы ранжирования (dense и sparse, например),— коэффициент, который обычно равен 60.
И так, к описанию алгоритмов поиска:
- Находим корни деревьев и идём по каждому дереву отдельно: на каждой ветке сравниваем родителя с его детьми. Если ребёнок оказывается лучше родителя — спускаемся к нему. Если нет — фиксируем родителя как конечную точку ветки. Я бы сказал, что этот алгоритм может быть более полным, чем остальные три, но его проблема — куча мусора. Если вдруг в самом начале или даже в середине было найдено что‑то нерелевантное (будь проклят этотtop_k!), то данный алгоритм будет тащить этот мусор до самого конца, и в большинстве своем это будет целый полностью нерелевантный документ. Но в общем и целом данный алгоритм имеет право на существование, если вопрос стоит только в полноте найденной информации. Вы можете протестировать данный алгоритм через функциюbranch_search.
- Отличие от первого подхода в том, что мы делаем поиск внутри не изолированным, а сравниваем всех со всеми (поколение родителей с предыдущей итерации сражается со всеми своими и чужими детьми, ведь друг друга то они уже победили на прошлой итерации). Получаем алгоритм поиска по лучу (beam_searchв режиме работыfixed). При этом используется фиксированная ширина луча, что является недостатком данного подхода. Сужение луча происходит по следующей логике:Если ребенок победил отца → отец исключается из рассмотрения на уточнение контекста (то есть более узкие домены информации оказались более релевантными к запросу).Если отец победил всех детей → все его дети исключаются из рассмотрения на уточнение контекста (то есть более узкие домены информации оказались менее релевантными для запроса).Все выжившие поборются друг с другом за место в луче.Таким образом мы получаем то, что алгоритм в течении поиска может отбросить наиболее нерелевантную информацию. Это сэкономит вам большое количество токенов контекста. Казалось бы — ПРОФИТ! Но возникает высокая вероятность того, что все же наш лучик света принесет не только тепло, но и лишний контекст (мы же зафиксировалиtop_k!) или наоборот — вполне релевантная информация может выпасть за границы нашего луча (мы же зафиксировалиtop_k!). Оба варианта плохи, поэтому была предложена следующая оптимизация, которая сломает все наши проблемы через «колено».
- Идея: «А давайте избавимся отtop_k! Пусть алгоритм сам решает, какую он хочет ширину луча, не маленький все же». Для реализации столь очевидной идеи нам на помощь приходит не столь очевидный алгоритм поиска «колена» (или «локтя», если так удобнее). Суть довольно проста — найти максимально удаленную точку графика от хорды этого графика. Но у этого метода есть важное ограничение — функция, к которой он применяется, обязательно должна быть выпукла. Неужели это камень преткновения, который мы не сможем преодолеть? Конечно, нет! Ведь нам повезло и гибридный поиск с RRF как раз имеет выпуклую вниз форму, что идеально подходит для того, чтобы оценивать важность узлов. На Рисунке 1 изображены RRF Score точек релевантных случайному запросу, под документами здесь подразумеваются «корни иерархических деревьев». Можно видеть, что мы легким движением колена смогли отбросить 62% нерелевантых документов. Протестировать этот алгоритм вы можете с помощью функцииbeam_searchв режимеadaptive_with_knee. Таким образом мы получаем поиск по лучу с динамической шириной, который сам решает, что релевантно, а что твоей LLM лучше не видеть. При реализации этого алгоритма я столкнулся с логической ошибкой, от которой потом проснулся ночью с тахикардией — очень важно при генерации луча на новой итерации сначала применять алгоритм «колена», а потом реализовывать логику сжатия! Почему? Потому что в данном алгоритме реализован глупый метод остановки — если луч на текущей и предыдущей итерациях не изменился, тогда поиск можно остановить. При такой постановке задачи, если сначала реализовывать логику сужения, а потом обрезать «хвосты» — мы получим то, что алгоритм будет почти всегда выдавать 2 финальные точки в луче (к которым уже нельзя применить алгоритм «колена»), что даже хуже чемtop_k.В целом уже неплохо, но этот алгоритм можно сделать еще более гибким — мы можем сделать алгоритм «колена» менее чувствительным с помощью параметра, который будет определять то, как агрессивно будут отрубаться «хвосты». Этот параметр может задаваться в пределах от 0 до 1 (при 0 вы всегда будете получать целые документы в количестве равном максимальному количеству релевантных корнейmax_num_roots, при 1 мы получим стандартный алгоритм «колена»). Этот параметр можно настраивать под ваши файлы — если файлы схожих тематик, то скорее всего можно отсекать документы менее агрессивно, если файлы очень разнородны, то наоборот. Протестировать эту реализацию алгоритма вы можете с помощью функцииbeam_searchв режимеadaptive_with_sensitive_knee.
Заключение
Небольшая серия экспериментов показала, что поиск по лучу с адаптивной шириной лучше справляется с поиском релевантной информации, чем луч с фиксированной шириной поиска. Он расширяется, когда это необходимо для контекста, и сужается, когда необходимо очистить мусор (ну прям как живой!).
В следующей части статьи будут приведены тесты на различных бенчмарках (предлагайте в комментариях). Также предлагайте идеи и делитесь своими тестами в комментариях (буду рад почитать!).
Открыт вашим PR наgithub, будем развивать адаптивный RAG вместе!