В последнее время о RAG порой отзываются пренебрежительно: одни считают эту технологию совершенно элементарной (начать и правда легко, а вот масштабировать — уже нет), а другие полагают, что её вытеснили «агентные системы» (хотя во многих случаях стоит присмотреться — и они быстро начинают напоминать RAG…).
В этой статье мы разберём несколько практических примеров и покажем, как решаем распространённые задачи, а именно:
Работа со смешанными текстовыми и числовыми данными и причины, по которым они ставят простейшие RAG-системы в тупик: ключевые слова совпадают, а числа сами по себе не несут семантического смысла.
Почему полезно строить эмбеддинги по кратким описаниям: для каждого фрагмента создаётся краткое описание, по которому затем выполняются векторизация и поиск.
Как создавать контекстные описания: добавляйте контекст исходного документа, чтобы различать похожие наборы статистики.
Когда полагаться на код и модели Pydantic: если важно дословно сохранить содержимое, для надёжности сочетайте вызовы LLM с пользовательским кодом и/или моделью Pydantic.
Основы
RAG-системы лежат в основе самых разных решений — от ботов поддержки до внутренних помощников по работе с базами знаний.
Обычно внутри происходит следующее:
Разбиение исходных документов на фрагменты
Преобразование каждого фрагмента в векторное представление
Извлечение K наиболее подходящих фрагментов при обработке запроса
Генерация ответа с учётом этих фрагментов
Популярные инструменты — LangChain, LlamaIndex и Filestore от OpenAI — делают эти этапы почти элементарными. Но в реальных конвейерах встречаются не только насыщенные текстом данные, и простейшие RAG-системы не всегда с ними справляются. Далее мы на конкретных примерах рассмотрим трудности при работе с данными и будем постепенно развивать решение по мере усложнения задачи.
Когда данные состоят не только из текста (а это совсем не редкость)
Рассмотрим следующий фрагмент игровых данных:
JSON
Эмбеддинги работают благодаря усвоенным связям между словами, основанным на семантике и грамматике. В приведённых выше данных смешаны текст и числа, причём вне этого конкретного контекста числа никак не связаны со словами. Таким образом, этот фрагмент данных — по сути сочетание нескольких описательных слов и следующих за ними случайных чисел.
Если бы у нас были только данные такого типа, проблемы бы не возникло: их всё ещё можно было бы находить по эмбеддингам немногих доступных описательных слов (или просто использовать преобразование текста в SQL). Но что, если этот фрагмент затерялся среди множества насыщенных текстом фрагментов, где встречаются те же слова? Например:
JSON
Теперь представим, что нам нужно найти ответ на вопрос: «What is the attack range with Draconic Ascension?» Скорее всего, нужный фрагмент найти не удастся: он затеряется среди других фрагментов с теми же ключевыми словами.
Главная проблема в том, что мы не можем уверенно различать эти фрагменты, хотя они содержат разные типы информации по одной теме. Можно ли как-то обогатить или улучшить эти данные? Конечно можно:smile:
Обогащайте данные с помощью кратких описаний — да, вы всё правильно прочитали
Вместо того чтобы сразу строить эмбеддинг самого фрагмента, можно сначала создать краткое описание его содержания, а затем выполнять векторизацию и поиск по этому описанию. На этапе генерации мы по-прежнему используем исходные данные, связанные с описанием.
Для двух приведённых выше фрагментов можно создать примерно такие описания:
Характеристики дальности, скорости и урона атаки (по умолчанию и с Draconic Ascension).
Описание и подробности Draconic Ascension, включая условия активации, визуальные эффекты и историю мира.
Затем мы также дополняем запрос, чтобы «согласовать» его с описанием. Например, запрос «What is the attack range with Draconic Ascension?» мы преобразуем в «What is the statistics of attack range with Draconic Ascension?» Это особенно важно, когда поисковые запросы поступают от пользователей без технической подготовки, которые выражаются в ~~«свободной форме»~~ обычным человеческим языком. В конце концов, им не нужно знать, как работает RAG и как добиться максимальной точности и полноты поиска.


Не вырывайте данные из контекста (да и в жизни это полезный совет)
Теперь рассмотрим следующий сценарий: у нас множество фрагментов данных, которые выглядят одинаково, как показано ниже:
Plain Text
Если придерживаться того же подхода, представьте запрос: «what is character X’s attack range?» С только что созданными описаниями нам пришлось бы полагаться на удачу и пытаться угадать, ведь они тоже выглядели бы очень похоже. Как же их различать?
Ответ прост: добавить контекст. Можно просто добавить во фрагмент данных ссылку на его исходный документ — например, в данном случае {”character”: “X”}. Тогда мы сможем точно найти нужные данные о персонаже X, даже если располагаем такими же данными о персонажах Y и Z.
Однако более универсальный и масштабируемый подход — создавать контекстное описание фрагмента. То есть вместо описания только самого фрагмента данных можно передать и исходный документ, и фрагмент, чтобы создать общее контекстное описание. В нём следует указать, какое место фрагмент занимает в исходном документе, например:
Этот фрагмент содержит подробные характеристики … персонажа X. В полном документе этот фрагмент показывает преимущество X в скорости атаки…
Этот фрагмент содержит подробные характеристики … персонажа Y. В полном документе этот фрагмент показывает улучшенные характеристики Y при использовании её особой способности…
Этот фрагмент содержит подробные характеристики … персонажа Z. В полном документе этот фрагмент показывает, что характеристики Z хорошо подходят для роли танка в командных матчах…
Для приведённого выше примера этот метод (отчасти вдохновлённый подходом Anthropic) может показаться избыточным. Однако он очень эффективен для фрагментов, которые легко неверно истолковать «вне контекста», а также предлагает единый подход ко всем фрагментам и помогает сохранить аккуратную архитектуру конвейера.


Когда нужно быть ~~помешанным на контроле~~ предельно строгим
Обычно мы получаем цельные данные и разбиваем их на фрагменты для RAG-системы. В этом примере ситуация немного иная: данные уже разбиты, но неудачно. Это случайные части логически цельного фрагмента, которые необходимо снова объединить. Логический фрагмент — это содержимое, которое естественным образом должно оставаться единым целым, например подраздел документа или связный абзац.


Сначала мы попытались передать все данные в одном вызове LLM, попросить её сгруппировать их по своему усмотрению и вернуть объединённое содержимое. LLM должна неплохо с этим справиться, верно? И да и нет.
Здесь, как и в нескольких других случаях, мы обнаружили, что LLM склонны лениться и ненадёжны, когда требуется вернуть всё содержимое в точности, особенно при длинном контексте. И это вполне объяснимо. Но для этого сценария такой недостаток оказался критическим: нам требовалось дословно сохранить всё содержимое — без обобщений и пропусков каких-либо частей исходного материала. Нельзя было упустить ни одной детали.
Положительная сторона заключалась в том, что LLM прекрасно понимала смысл и структуру разрозненных фрагментов. Вот только дословно возвращать содержимое она соглашалась не всегда. Вот же чёрт:/
Как же использовать сильные стороны LLM и при этом не зависеть от того, в чём она ненадёжна? Мы обратились к старому доброму другу — коду (то есть пользовательской функции Python). А ещё к «проще некуда» модели Pydantic. Вот наше решение:
Последовательно обходить разделы, сохраняя текущий логический фрагмент
Для каждого раздела спрашивать LLM: относится ли он к текущему логическому фрагменту? Ответ должен быть «да» или «нет» в соответствии с моделью Pydantic.
Если да — добавить раздел к фрагменту. Если нет — сохранить завершённый текущий логический фрагмент и начать новый с этого раздела.


Разумеется, здесь расходуется немного больше токенов, чем при однократной обработке всего содержимого. Но в этом сценарии точное сохранение материала было главным приоритетом, поэтому небольшие дополнительные затраты полностью себя оправдали.
Это очень простое решение, но оно следует важному принципу: когда требуется строгость, не стоит полагаться исключительно на LLM, поскольку они по своей природе вероятностны.
Пользовательский код и функции вместе с моделями Pydantic позволяют получить предсказуемый и надёжный результат, не отказываясь от возможностей LLM.
Создание решения на основе генеративного ИИ — в равной степени инженерная задача и задача в области ИИ. Надеемся, эти примеры вдохновили вас взяться за собственные уникальные задачи. Чтобы узнать больше об инженерном подходе к решениям на основе генеративного ИИ, прочитайте нашу статью о проектировании агентных систем на основе маршрутизаторов.