Этот материал посвящён тому, как добавлять собственные данные в предварительно обученные LLM (Large Language Model, большая языковая модель) с применением подхода, основанного на промптах, который называется RAG (Retrieval‑Augmented Generation, генерация ответа с использованием результатов поиска).

Большие языковые модели знают о мире многое, но не всё. Так как обучение таких моделей занимает много времени, данные, использованные в последнем сеансе их обучения, могут оказаться достаточно старыми. И хотя LLM знакомы с общеизвестными фактами, сведения о которых имеются в интернете, они ничего не знают о ваших собственных данных. А это — часто именно те данные, которые нужны в вашем приложении, основанном на технологиях искусственного интеллекта. Поэтому неудивительно то, что уже довольно давно и учёные, и разработчики ИИ‑систем уделяют серьёзное внимание вопросу расширения LLM новыми данными.

До наступления эры LLM модели часто дополняли новыми данными, просто проводя их дообучение. Но теперь, когда используемые модели стали гораздо масштабнее, когда обучать их стали на гораздо больших объёмах данных, дообучение моделей подходит лишь для совсем немногих сценариев их использования. Дообучение особенно хорошо подходит для тех случаев, когда нужно сделать так, чтобы модель взаимодействовала бы с пользователем, используя стиль и тональность высказываний, отличающиеся от изначальных. Один из отличных примеров успешного применения дообучения — это когда компания OpenAI доработала свои старые модели GPT-3.5, превратив их в модели GPT-3.5-turbo (ChatGPT). Первая группа моделей была нацелена на завершение предложений, а вторая — на общение с пользователем в чате. Если модели, завершающей предложения, передавали промпт наподобие «Можешь рассказать мне о палатках для холодной погоды», она могла выдать ответ, расширяющий этот промпт: «и о любом другом походном снаряжении для холодной погоды?». А модель, ориентированная на общение в чате, отреагировала бы на подобный промпт чем‑то вроде такого ответа: «Конечно! Они придуманы так, чтобы выдерживать низкие температуры, сильный ветер и снег благодаря…». В данном случае цель компании OpenAI была не в том, чтобы расширить информацию, доступную модели, а в том, чтобы изменить способ её общения с пользователями. В таких случаях дообучение способно буквально творить чудеса!

Но дообучение уже не так хорошо себя показывает при необходимости добавления новых данных в модель. Это, как мне удалось выяснить, гораздо более распространённый бизнес‑сценарий. Кроме того, дообучение LLM требует больших объёмов высококачественных данных, серьёзных затрат на вычислительные ресурсы и много времени. Для большинства пользователей LLM всё это относится к разряду ограниченных ресурсов.

В этом материале мы рассмотрим RAG — альтернативный подход к добавлению собственных данных в LLM. Он основан на промптах и был представлен в 2021 году командой Facebook AI Research (FAIR), работавшей над ним вместе с другими исследователями. Концепция RAG, с одной стороны, даёт пользователю достаточно мощные возможности, которые позволяют применять её в поисковой системе Bing и на других высоконагруженных сайтах для внедрения самых свежих данных в их модели. А, с другой стороны, это — достаточно простая концепция, которую я смогу объяснить читателю в этой статье. Надо отметить, что применение RAG отличается эффективностью и в тех случаях, когда у пользователя нет большого объёма данных, нет серьёзного бюджета и нет большого количества времени на манипуляции с моделями.

Три примера кода, демонстрирующие различные сценарии применения RAG, вы можете найти в этом GitHub‑репозитории. В одном из них API OpenAI используются напрямую, во втором и в третьем применяются опенсорсные API LangChan и Semantic Kernel. Первый фрагмент кода и соответствующий ему сценарий использования RAG мы рассмотрим в этом материале, а два других примера я советую вам изучить самостоятельно.

Но, прежде чем мы займёмся кодом, предлагаю разобраться с основными понятиями RAG.

Обзор RAG

Идеи RAG могут быть реализованы разными способами, но на концептуальном уровне использование RAG в приложении, основанном на технологиях искусственного интеллекта, включает в себя следующие шаги:

  • Пользователь вводит вопрос.

  • Система ищет подходящие документы, которые могут содержать ответ на этот вопрос. Эти документы часто включают в себя собственные данные компании, которая разработала систему, а хранятся они обычно в некоем каталоге документов.

  • Система создаёт промпт для LLM, в котором скомбинированы данные, введённые пользователем, подходящие документы и инструкции для LLM. Модель должна ответить на вопрос пользователя, применив предоставленные ей документы.

  • Система отправляет промпт LLM.

  • LLM возвращает ответ на вопрос пользователя, основанный на предоставленных контекстных сведениях. Это — выходные данные системы.

Вот диаграмма, отражающая эту общую идею, лежащую в основе RAG:

https://miro.medium.com/v2/resize:fit:700/1*bIjsKJchyMJzf--LwWjeMg.png
Общее устройство системы, использующей RAG

Тут приведено описание работы RAG, сделанное простыми словами. Здесь почти нет сведений о реализации этого механизма. Предлагаю перейти к подробностям о нём, а для начала — взглянуть на научную публикацию, в которой была предложена идея RAG.

Публикация, посвящённая RAG

Термин «RAG» был предложен в 2021 году, в публикации «Retrieval‑Augmented Generation for Knowledge‑Intensive NLP Tasks», подготовленной командой FAIR и сотрудничавшими с ней учёными. Идеи, предложенные авторами этой работы, оказали огромное воздействие на ИИ‑решения, которыми мы пользуемся сегодня, поэтому с данными идеями стоит познакомиться поближе.

Вот — обзор архитектуры ИИ‑системы, предложенной в работе:

https://miro.medium.com/v2/resize:fit:700/1*WPNPxlCxh6Nw4muUyfULTQ.png
Архитектура ИИ-системы, использующей RAG

Ниже мы подробно обсудим каждую из составных частей этой архитектуры. Если смотреть в общем, то предложенная в работе структура системы составлена из двух компонентов: из поискового модуля и из модуля генератора. Поисковый компонент преобразует входной текст в последовательность чисел с плавающей запятой (вектор), используя кодировщик запроса. Далее, он, используя единый подход, трансформирует каждый из документов, применяя кодировщик документов, после чего сохраняет закодированные документы в виде поискового индекса. Затем, в поисковом индексе, поисковый компонент выполняет поиск векторов документов, которые имеют отношение к входному вектору. После этого он преобразует векторы документов обратно в их текстовое представление и возвращает эти тексты в качестве результата своей работы. Теперь в дело вступает генератор, который принимает текст, введённый пользователем, и соответствующие ему документы, комбинирует всё это в единый промпт и предлагает LLM дать ответ на вопрос пользователя с учётом информации, имеющейся в найденных ранее документах. То, что выдаст после этого LLM, и будет выходными данными всей этой системы.

Вы могли заметить, что кодировщик запроса, кодировщик документов и LLM на предыдущем изображении показаны с использованием похожих фигур. Это из‑за того, что все они реализованы с использованием трансформеров. Традиционные трансформеры состоят из двух частей: из кодировщика и декодера. Кодировщик отвечает за трансформацию входного текста в вектор (или в последовательность векторов), которая, в общих чертах, отражает смысл слов. А цель декодера — сгенерировать новый текст, основываясь на входных данных. В архитектуре системы, предложенной в вышеупомянутой публикации, кодировщик запроса и кодировщик документов реализованы с помощью трансформеров, в состав которых входит лишь кодировщик. Дело в том, что этим компонентам системы нужно лишь преобразовывать фрагменты текста в векторы, состоящие из чисел. А LLM в модуле генератора реализована на базе традиционного трансформера, содержащего и кодировщик, и декодер.

Как обучают модель, обладающую такой архитектурой? В публикации предлагается использовать предварительно обученные трансформеры и проводить совместное дообучение только кодировщика запросов и LLM из блока генератора. Это дообучение выполняется с использованием пар фрагментов данных, один из которых — это то, что ввёл пользователь, а второй — это то, что ожидается получить на выходе LLM. Кодировщик документов не дообучают, так как это может быть достаточно затратным, и из‑за того, что авторы работы обнаружили, что в этом нет необходимости для обеспечения хороших результатов работы системы.

В публикации предлагается два подхода для реализации этой архитектуры:

  • RAG‑последовательность: получают k документов и используют их для генерирования всех выходных токенов, которые служат ответом на запрос пользователя.

  • RAG‑токен: получают k документов, используют их для генерирования следующего токена, затем получают ещё k документов, которые используют для генерации следующего токена, и так далее. Это значит, что всё может свестись к нахождению нескольких разных наборов документов при генерировании единственного ответа на запрос пользователя.

Теперь вы получили достаточно хорошее общее представление об архитектуре системы, предложенной в публикации о RAG. Подобный шаблон проектирования достаточно широко распространён в ИИ‑индустрии. Но оказалось, что не всё то, о чём ведётся речь в публикации, реализовано в точности так же, как в этой публикации предложено.

Использование RAG на практике

На практике реализации RAG, которые широко используются в различных организациях, основаны на том, что было предложено в вышеупомянутой публикации, но при этом для них характерны некоторые особенности:

  • Из двух подходов к реализации архитектуры RAG, предложенных в публикации, почти всегда выбирают RAG‑последовательность. Дело в том, что этот подход оказывается дешевле и проще, чем другой. При этом он даёт отличные результаты.

  • На практике обычно не занимаются дообучением каких‑либо трансформеров, входящих в состав системы. Те предварительно обученные LLM, которыми мы можем пользоваться в наши дни, достаточно хороши для того чтобы пользоваться ими в их исходном виде. Их, кроме того, слишком затратно дообучать самостоятельно.

Стоит добавить, что применяемые методы поиска документов не всегда реализованы именно так, как предложено в публикации. Поиск обычно выполняется с применением некоего поискового сервиса — наподобие FAISS или Azure Cognitive Search. Такие сервисы поддерживают различные способы поиска документов, которые хорошо сочетаются с RAG. В работе поисковых сервис обычно можно выделить два следующих шага:

  • Поиск информации. На этом шаге производится сопоставление запроса пользователя с документами, находящимися в поисковом индексе. После этого осуществляется извлечение наиболее подходящих документов. Существуют три самых распространённых подхода к поиску документов: поиск по ключевым словам, векторный поиск, гибридный поиск.

  • Ранжирование информации. Это — необязательный шаг, который следует за шагом поиска информации. При его выполнении берётся список документов, которые были сочтены подходящими при поиске информации, после чего, по более точным алгоритмам, чем ранее, определяется их соответствие запросу пользователя.

Поговорим о поиске и ранжировании документов подробнее, начав с трёх подходов к поиску информации.

Поиск по ключевым словам

Самый простой способ поиска документов, имеющих отношение к запросу пользователя — это так называемый «поиск по ключевым словам» (ещё известный как «полнотекстовый поиск»). Поиск по ключевым словам использует те ключевые слова, которые ввёл пользователь, для поиска по индексу документов, содержащих эти слова. Сравнение содержимого документов с запросом выполняется исключительно на основе текста, без применения векторов. Этот подход к поиску существует уже давно, но он не потерял актуальности и в наши дни. Он весьма полезен в тех случаях, когда ищут идентификаторы пользователей, коды продуктов, адреса и любые другие данные, при поиске которых важно точное совпадение с текстом запроса. Вот — общая схема системы, в которой используется поиск по ключевым словам.

https://miro.medium.com/v2/resize:fit:700/1*epQPSuH6PvolP9LBZE-2YQ.png
Система, в которой используется поиск по ключевым словам

При таком подходе поисковый сервис поддерживает работу инвертированного индекса, хранящего сведения о соответствии слов и документов, в которых используются эти слова. Текстовые данные, введённые пользователем, разбирают, извлекая из них ключевые слова. Эти слова анализируют, находя их базовые формы. Затем выполняют поиск по инвертированному индексу, каждому совпадению назначают оценку, а после этого наиболее релевантные документы, соответствующие запросу, возвращают из поискового сервиса.

Векторный поиск

«Векторный поиск», который ещё называют «плотным поиском информации» («dense retrieval»), отличается от поиска по ключевым словам. Один из аспектов этого отличия заключается в том, что при векторном поиске возможно нахождение соответствия поисковому запросу в документах, в которых нет ключевых слов из запроса, но общий смысл которых близок к смыслу запроса. Например, представьте, что создаёте чат‑бота, который планируется использовать в службе поддержки сайта для сдачи недвижимости в аренду. Пользователь задаёт боту вопрос: «Do you have recommendations for a spacious apartment close to the sea», интересуясь, может ли он порекомендовать просторное жильё, расположенное близко к морю. В документе, содержащем сведения о подходящем жилище, имеется следующий текст: «4000 sq ft home with ocean view» — тут описан дом площадью 4000 квадратных футов с видом на океан. При поиске по ключевым словам такой документ найден не будет. А вот система векторного поиска его найдёт. Векторный поиск лучше всего показывает себя в тех случаях, когда в неструктурированном тексте ищут некие общие идеи, а не точные ключевые слова.

Вот — общий обзор RAG‑системы, в которой используется векторный поиск:

https://miro.medium.com/v2/resize:fit:700/1*aAd2Np3BvPt_uaUTpo6Z1w.png
Система, в которой используется векторный поиск

Взглянув на эту схему, можно понять, что векторный поиск, используемый на практике, это — практически то же самое, что было предложено в публикации по RAG. За исключением того, что в данном случае не проводится дообучение трансформеров.

В подобных системах для кодирования запроса и документов обычно используют предварительно обученную модель эмбеддингов, такую, как text‑embedding‑ada-002 от OpenAI. А для формирования итогового результата применяют предварительно обученные LLM, например — gpt-35-turbo (ChatGPT), тоже созданную OpenAI. Модель эмбеддингов используется для преобразования входного текста и текста документов в соответствующие «эмбеддинги». Что такое «эмбеддинг»? Это — вектор чисел с плавающей запятой. Он, в общих чертах, «схватывает» смысл текста, который в нём закодирован. Если два фрагмента текста как‑то связаны, тогда можно предположить, что и соответствующие им эмбеддинги будут похожи друг на друга.

Как определить то, что векторы похожи? Для ответа на этот вопрос рассмотрим пример. Мы исходим из предположения о том, что для нахождения следующих векторов эмбеддингов используется наша собственная модель эмбеддингов:

  • a = (0, 1) представляет «Do you have recommendations for a spacious apartment close to the sea?» — «Можете ли вы порекомендовать просторное жильё, расположенное близко к морю?».

  • b = (0.12, 0.99) представляет «4000 sq ft home with ocean view» — «дом площадью 4000 квадратных футов с видом на океан»

  • c = (0.96, 0.26) представляет «I want a donut» — «Я хочу пончик».

Эти векторы можно нарисовать на диаграмме:

https://miro.medium.com/v2/resize:fit:700/1*i9on0CCqt6gx-62HlQVz7A.png
Графическое представление векторов

Просто глядя на это изображение, можно догадаться о том, что вектор, который сильнее всего похож на вектор a — это b (а не c). Если прибегнуть к математическим методам выявления схожести векторов, то можно сказать, что существуют три распространённых подхода для решения этой задачи: скалярное произведение векторов, косинусный коэффициент векторов и евклидово расстояние между векторами. Найдём показатели, характеризующие схожесть векторов, используя эти методы, и посмотрим, подтвердят ли они нашу догадку.

При применении метода скалярного произведения векторов, как можно ожидать из его названия, просто вычисляют скалярное произведение (его ещё называют внутренним произведением) векторов. Чем больше результат — тем ближе друг к другу векторы:

https://miro.medium.com/v2/resize:fit:700/1*lvuIIQnVTfXlT2nUUZJxKg.png
Скалярное произведение векторов

Скалярное произведение векторов a и b больше, чем скалярное произведение a и c. Это подтверждает нашу догадку относительно того, что векторы a и b больше похожи друг на друга, чем a и c. Применяя этот метод, надо помнить о том, что скалярное произведение очень сильно зависит от длины векторов. Оно применимо только для сравнения схожести векторов, имеющих одинаковую длину. Эмбеддинги OpenAI всегда представлены единичными векторами — векторами, имеющими длину, равную единице. Чтобы наши эмбеддинги согласовывались бы с теми, что используются в OpenAI, они тоже должны быть единичными.

Косинусный коэффициент векторов вычисляют, находя косинус угла между двумя векторами. Чем этот коэффициент больше — тем сильнее векторы похожи друг на друга. Формула для вычисления этого коэффициента предусматривает вычисление скалярного произведения векторов, которое делят на произведение длин векторов. Это дополнение предыдущей формулы, нормализующее результаты, позволяет эффективно измерять схожесть векторов, учитывая их направления и не обращая внимания на их длины.

https://miro.medium.com/v2/resize:fit:700/1*Nw9GicUgdlh9z8uSqQEN5w.png
Косинусный коэффициент векторов

Так как мы пользуемся единичными векторами, их скалярное произведение и косинусный коэффициент дают в точности одинаковые результаты.

Вычисляя евклидово расстояние между векторами, находят расстояние между ними — в том смысле, в котором понятие «расстояние» используется в обычной жизни. Чем меньше расстояние между двумя векторами — тем сильнее они друг на друга похожи. В отличие от косинусного коэффициента, при вычислении евклидового расстояния во внимание принимаются длина и направление векторов.

https://miro.medium.com/v2/resize:fit:700/1*3WEwPbQfGtMqcL57dIG69A.png
Евклидово расстояние между векторами

Видно, что расстояние между векторами a и b меньше, чем между векторами a и c. Это говорит нам о том, что векторы a и b больше похожи друг на друга, чем a и c.

В примере используются двумерные векторы, что позволяет нам, просто глядя на их изображения, строить догадки об их схожести. Но в случае с эмбеддингами часто бывает так, что речь идёт о векторах гораздо большей размерности. Например, модель text‑embedding‑ada-002, созданная OpenAI, генерирует векторы, размерность которых равна 1536. Не забывайте о том, что количество измерений, используемых для представления эмбеддингов, не зависит от длины входного текста. Поэтому и короткий запрос, и длинный документ будут преобразованы в векторы эмбеддингов одинаковой размерности.

Большинство поисковых сервисов, существующих в наши дни, поддерживают все три рассмотренные нами метода определения схожести векторов. Они позволяют пользователю выбрать метод, который ему нужен. Какой из этих методов лучше выбрать?

  • Если ваши эмбеддинги имеют разную длину, и вы хотите учитывать их длину при определении их схожести, тогда лучше всего будет выбрать евклидово расстояние между векторами, так как при его вычислении учитывается и длина векторов, и их направление.

  • Если все ваши эмбеддинги нормализованы, приведены к единичной длине, тогда все три рассмотренных нами подхода дадут, как вы уже видели, схожие результаты. Но при этом скалярное произведение векторов вычисляется быстрее. Предположим, вы работаете с системой (с приложение или сервисом), которая знает о том, что имеет дело с единичными векторами. В такой ситуации вычисление косинусного коэффициента векторов, скорее всего, будет оптимизировано и сведено к вычислению их скалярного произведения. Поэтому, скорее всего, в подобном случае вычислительная нагрузка на систему при использовании косинусного коэффициента векторов будет столь же скромной, как и при использовании скалярного произведения.

Для того чтобы найти векторы документов, лучше всего соответствующие вектору запроса, поисковый сервис может пойти самым простым и неэффективным, в плане траты вычислительных ресурсов, путём. А именно, он определит схожесть входного вектора с вектором каждого из документов, отсортирует список документов в соответствии с их оценкой, и вернёт верхнюю часть этого списка. Этот простой подход, правда, не масштабируется. Он не подходит для больших корпоративных приложений, в которых используется очень много документов. В результате поисковые сервисы обычно используют некую разновидность алгоритма ANN (Approximate Nearest Neighbor, приближённый поиск ближайшего соседа). В этом алгоритме применяются продуманные оптимизации, которые позволяют ему возвращать приблизительные результаты быстрее, чем при использовании других методов. Одной из популярных реализаций ANN является алгоритм HNSW (Hierarchical Navigable Small World, иерархический маленький мир).

Гибридный поиск

Гибридный поиск подразумевает применение и поиска по ключевым словам, и векторного поиска. Например, представим себе ситуацию, когда имеется идентификатор клиента и текстовый запрос. Нужно провести поиск, учитывающий и точные данные в виде идентификатора, и общий смысл текста, введённого пользователем. Это — отличный сценарий для применения гибридного поиска. Два типа поиска выполняются самостоятельно, после чего полученные данные комбинируются с помощью специального алгоритма, который выбирает лучшее из того, что дали разные подходы к поиску. Этот метод часто используется на практике, особенно — в достаточно сложных приложениях.

Семантическое ранжирование

Семантическое ранжирование (или переранжирование) — это необязательный шаг работы RAG‑системы, который следует за шагом нахождения документов. На шаге нахождения документов система делает всё возможное для ранжирования возвращённых документов на основании их релевантности запросу пользователя. А семантическое ранжирование часто способно улучшить результаты первоначальной оценки документов. При его выполнении берётся подмножество документов, возвращённое после их поиска, после чего, с помощью LLM, обученной специально для выполнения этой задачи, вычисляются коэффициенты релевантности более высокого качества, чем ранее. Эти коэффициенты применяются для переранжирования документов.

https://miro.medium.com/v2/resize:fit:700/1*LrSPog4UlVa05CthUJASdA.png
RAG-система, в которой используется модуль семантического ранжирования документов

Здесь показан модуль семантического ранжирования, скомбинированный с модулем векторного поиска, но этот модуль хорошо сочетается и с подсистемой поиска по ключевым словам. Я решила показать здесь совместную работу семантического ранжирования и векторного поиска из‑за того, что именно так сделано в примере реализации RAG‑системы, подготовленном для этой статьи. Рассмотрим этот пример.

Простая реализация RAG-системы

В этом разделе мы рассмотрим код, который позволяет, используя RAG и сервис Azure Cognitive Search, добавлять собственные данные в ChatGPT. Код, который я тут продемонстрирую, использует API OpenAI для прямого взаимодействия с СhatGPT. Он находится в папке 1_openai репозитория с кодом к статье. В этом репозитории вы можете найти ещё две реализации той же идеи, одна из которых основана на LangChain (папка 2_langchain), а другая — на Semantic Kernel (папка 3_semantic_kernel). Это — два популярных опенсорсных фреймворка, которые помогают разработчикам создавать приложения, использующие LLM.

Цель этого проекта — создание чат-бота, к которому пользователи смогут обращаться для получения дополнительной информации о товарах, которые продаёт некая компания. Данные, которыми мы будем пользоваться, представлены несколькими markdown-файлами. В них содержатся подробные сведения о товарах.

Начнём с разбора файла init_search_1.py:

"""
Инициализирует индекс Azure Cognitive Search нашими данными, используя векторный поиск
и семантическое ранжирование.

Для запуска этого кода в вашей учётной записи Azure уже должны быть созданы ресурсы
"Cognitive Search" и "OpenAI".
"""
import os

import openai
from azure.core.credentials import AzureKeyCredential
from azure.search.documents import SearchClient
from azure.search.documents.indexes import SearchIndexClient
from azure.search.documents.indexes.models import (
    HnswParameters,
    HnswVectorSearchAlgorithmConfiguration,
    PrioritizedFields,
    SearchableField,
    SearchField,
    SearchFieldDataType,
    SearchIndex,
    SemanticConfiguration,
    SemanticField,
    SemanticSettings,
    SimpleField,
    VectorSearch,
)
from dotenv import load_dotenv
from langchain.document_loaders import DirectoryLoader, UnstructuredMarkdownLoader
from langchain.text_splitter import Language, RecursiveCharacterTextSplitter

# Конфигурация Azure Search.
AZURE_SEARCH_ENDPOINT = os.getenv("AZURE_SEARCH_ENDPOINT")
AZURE_SEARCH_KEY = os.getenv("AZURE_SEARCH_KEY")
AZURE_SEARCH_INDEX_NAME = "products-index-1"

# Конфигурация Azure OpenAI.
AZURE_OPENAI_API_TYPE = "azure"
AZURE_OPENAI_API_BASE = os.getenv("AZURE_OPENAI_API_BASE")
AZURE_OPENAI_API_VERSION = "2023-03-15-preview"
AZURE_OPENAI_API_KEY = os.getenv("AZURE_OPENAI_API_KEY")
AZURE_OPENAI_EMBEDDING_DEPLOYMENT = os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT")

DATA_DIR = "data/"


def load_and_split_documents() -> list[dict]:
    """
    Загружает наши документы с диска и разбивает их на фрагменты.
    Возвращает список словарей.
    """
    # Загрузка данных.
    loader = DirectoryLoader(
        DATA_DIR, loader_cls=UnstructuredMarkdownLoader, show_progress=True
    )
    docs = loader.load()

    # Разбиение документов на фрагменты.
    splitter = RecursiveCharacterTextSplitter.from_language(
        language=Language.MARKDOWN, chunk_size=6000, chunk_overlap=100
    )
    split_docs = splitter.split_documents(docs)

    # Преобразование документов в список словарей.
    final_docs = []
    for i, doc in enumerate(split_docs):
        doc_dict = {
            "id": str(i),
            "content": doc.page_content,
            "sourcefile": os.path.basename(doc.metadata["source"]),
        }
        final_docs.append(doc_dict)

    return final_docs


def get_index(name: str) -> SearchIndex:
    """
    Возвращает индекс Azure Cognitive Search с заданным именем.
    """
    # Поля, которые мы хотим индексировать. Поле "embedding" - это векторное поле,
    # которое будет использоваться для векторного поиска.
    fields = [
        SimpleField(name="id", type=SearchFieldDataType.String, key=True),
        SimpleField(name="sourcefile", type=SearchFieldDataType.String),
        SearchableField(name="content", type=SearchFieldDataType.String),
        SearchField(
            name="embedding",
            type=SearchFieldDataType.Collection(SearchFieldDataType.Single),
            # Размер вектора, созданного моделью text-embedding-ada-002.
            vector_search_dimensions=1536,
            vector_search_configuration="default",
        ),
    ]

    # Поле "content" должно иметь приоритет при семантическом ранжировании.
    semantic_settings = SemanticSettings(
        configurations=[
            SemanticConfiguration(
                name="default",
                prioritized_fields=PrioritizedFields(
                    title_field=None,
                    prioritized_content_fields=[SemanticField(field_name="content")],
                ),
            )
        ]
    )

    # Для векторного поиска мы хотим пользоваться алгоритмом HNSW (Hierarchical Navigable Small World)
    # (разновидность алгоритма приближённого поиска ближайшего соседа) с
    # применением косинусного коэффициента векторов.
    vector_search = VectorSearch(
        algorithm_configurations=[
            HnswVectorSearchAlgorithmConfiguration(
                name="default",
                kind="hnsw",
                parameters=HnswParameters(metric="cosine"),
            )
        ]
    )

    # Создание поискового индекса.
    index = SearchIndex(
        name=name,
        fields=fields,
        semantic_settings=semantic_settings,
        vector_search=vector_search,
    )

    return index


def initialize(search_index_client: SearchIndexClient):
    """
    Инициализирует индекс Azure Cognitive Search нашими данными, используя 
векторный поиск.
    """
    # Загрузка данных.
    docs = load_and_split_documents()
    for doc in docs:
        doc["embedding"] = openai.Embedding.create(
            engine=AZURE_OPENAI_EMBEDDING_DEPLOYMENT, input=doc["content"]
        )["data"][0]["embedding"]

    # Создание индекса Azure Cognitive Search.
    index = get_index(AZURE_SEARCH_INDEX_NAME)
    search_index_client.create_or_update_index(index)

    # Выгрузка данных в индекс.
    search_client = SearchClient(
        endpoint=AZURE_SEARCH_ENDPOINT,
        index_name=AZURE_SEARCH_INDEX_NAME,
        credential=AzureKeyCredential(AZURE_SEARCH_KEY),
    )
    search_client.upload_documents(docs)


def delete(search_index_client: SearchIndexClient):
    """
    Удаляет индекс Azure Cognitive Search.
    """
    search_index_client.delete_index(AZURE_SEARCH_INDEX_NAME)


def main():
    load_dotenv()

    openai.api_type = AZURE_OPENAI_API_TYPE
    openai.api_base = AZURE_OPENAI_API_BASE
    openai.api_version = AZURE_OPENAI_API_VERSION
    openai.api_key = AZURE_OPENAI_API_KEY

    search_index_client = SearchIndexClient(
        AZURE_SEARCH_ENDPOINT, AzureKeyCredential(AZURE_SEARCH_KEY)
    )

    initialize(search_index_client)
    # delete(search_index_client)


if __name__ == "__main__":
    main()

Наша первая задача — загрузить markdown‑файлы с данными и разбить их на фрагменты по 6000 символов, сделав это так, чтобы данные соседних фрагментов перекрывались бы на 100 символов. Каждый из этих фрагментов данных позже будет закодирован с помощью одного эмбеддинга и добавлен в поисковый индекс. Если файлы достаточно малы — не нужно разбивать их на части, их можно преобразовывать в эмбеддинги целиком. Но если они велики — лучше разделить их на фрагменты, так как эмбеддинги имеют фиксированные размеры, поэтому не смогут адекватно представить всю информацию из слишком больших текстовых файлов. Мы добавляем во фрагменты повторяющуюся информацию для того чтобы не терять смысл текстов, разбиваемых границами фрагментов.

Далее — создаётся список, в котором содержатся словари, каждый из которых представляет отдельный фрагмент текста, уникальный идентификатор, имя файла, являющегося источником текста. Затем для каждого фрагмента вычисляется эмбеддинг. Для этого используется модель text‑embedding‑ada-002 от OpenAI. Найденный эмбеддинг добавляется в словарь, соответствующий тому фрагменту текста, на основе которого он создан. Полученный список словарей содержит всю информацию, необходимую для заполнения поискового индекса.

Теперь мы создаём поисковый индекс, используя API Azure Cognitive Search. Для этого осуществляется настройка полей, которые будут созданы в индексе. При этом нужно не забыть указать то, что поле с эмбеддингами должно поддерживать векторный поиск. Векторный поиск настраивается посредством указания нескольких параметров. Первый — это размер эмбеддинга — 1536 (это всегда так при работе с используемой нами моделью от OpenAI). Второй задаёт использование косинусного коэффициента векторов для проверки их сходства (именно этот метод рекомендует OpenAI). Третий отражает наше желание ускорить поиск похожих векторов, воспользовавшись алгоритмом HNSW. Мы, кроме того, указываем, что хотим включить семантическое ранжирование для контекстных текстовых полей. В итоге мы даём индексу имя и сохраняем его.

И наконец — наши данные выгружаются в только что созданный поисковый индекс. Проверить, все ли фрагменты были загружены в поисковый индекс, можно, зайдя на портал Azure, щёлкнув по ресурсу Cognitive Search, затем — по имени ресурса, а потом — по Indexes. Там, справа, должен находиться нужный индекс со сведениями о количестве документов, которое соответствует количеству фрагментов документов, загруженных в индекс. В моём случае в индекс было загружено 45 фрагментов документов. Если вы попали на страницу индекса, щёлкнув по его имени, просмотреть загруженные в него данные можно, щёлкнув по кнопке Search в блоке Search Explorer.

https://miro.medium.com/v2/resize:fit:700/1*eZXu8FOoWShxki4pxDPkng.png
Данные, загруженные в поисковый индекс

Если, в вышеупомянутом репозитории, посмотреть на файлы init_search из папок проектов, основанных на LangChain и Semantic Kernel, эквивалентные тому, который мы только что разобрали, то окажется, что в них содержится гораздо меньше кода. Непосредственное использование API Azure Cognitive Search даёт нам более широкие возможности управления настройками индекса. А библиотека, применяемая в проекте, играет роль промежуточного слоя между программистом и Azure, который скрывает множество сложных механизмов. Ответ на вопрос о том, что лучше использовать в том или ином проекте, всецело зависит от того, что именно нужно программисту.

Теперь, когда мы подготовили к работе поисковый индекс, поговорим о том, как воспользоваться им в чат‑боте. Разберём файл main_1.py. Это — главный исполняемый файл, обеспечивающий функционирование чат‑бота:

"""
Точка входа для чат-бота.
"""
from chatbot_1 import Chatbot


def main():
    chatbot = Chatbot()
    chatbot.ask("I need a large backpack. Which one do you recommend?")
    chatbot.ask("How much does it cost?")
    chatbot.ask("And how much for a donut?")


if __name__ == "__main__":
    main()

Функция main играет роль пользователя, который задаёт чат-боту несколько вопросов. «Задать вопрос» — значит вызвать метод чат-бота ask. Эквивалентные файлы в папках проектов LangChain и Semantic Kernel выглядят точно так же.

Теперь посмотрим на файл chatbot_1.py. Он содержит код для чат-бота, который помнит историю общения и пользуется возможностями RAG:

"""
Чат-бот, поддерживающий контекстные сведения и запоминающий историю беседы.
"""
import os

import openai
from azure.core.credentials import AzureKeyCredential
from azure.search.documents import SearchClient
from azure.search.documents.models import Vector
from dotenv import load_dotenv

# Настройки Azure Search.
AZURE_SEARCH_ENDPOINT = os.getenv("AZURE_SEARCH_ENDPOINT")
AZURE_SEARCH_KEY = os.getenv("AZURE_SEARCH_KEY")
AZURE_SEARCH_INDEX_NAME = "products-index-1"

# Настройки Azure OpenAI.
AZURE_OPENAI_API_TYPE = "azure"
AZURE_OPENAI_API_BASE = os.getenv("AZURE_OPENAI_API_BASE")
AZURE_OPENAI_API_VERSION = "2023-03-15-preview"
AZURE_OPENAI_API_KEY = os.getenv("AZURE_OPENAI_API_KEY")
AZURE_OPENAI_CHATGPT_DEPLOYMENT = os.getenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT")
AZURE_OPENAI_EMBEDDING_DEPLOYMENT = os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT")

# Роли чата
SYSTEM = "system"
USER = "user"
ASSISTANT = "assistant"


class Chatbot:
    """Чат с LLM с применением RAG. Хранит в памяти историю чата."""

    chat_history = []

    def __init__(self):
        load_dotenv()
        openai.api_type = AZURE_OPENAI_API_TYPE
        openai.api_base = AZURE_OPENAI_API_BASE
        openai.api_version = AZURE_OPENAI_API_VERSION
        openai.api_key = AZURE_OPENAI_API_KEY

    def _summarize_user_intent(self, query: str) -> str:
        """
        Создаёт сообщение пользователя, выражающее его реальное намерение, путём объединения
истории чата и запроса пользователя.
        """
        chat_history_str = ""
        for entry in self.chat_history:
            chat_history_str += f"{entry['role']}: {entry['content']}\n"
        messages = [
            {
                "role": SYSTEM,
                "content": (
                    "You're an AI assistant reading the transcript of a conversation "
                    "between a user and an assistant. Given the chat history and "
                    "user's query, infer user real intent."
                    f"Chat history: ```{chat_history_str}```\n"
                    f"User's query: ```{query}```\n"
                ),
            }
        ]
        chat_intent_completion = openai.ChatCompletion.create(
            deployment_id=AZURE_OPENAI_CHATGPT_DEPLOYMENT,
            messages=messages,
            temperature=0.7,
            max_tokens=1024,
            n=1,
        )
        user_intent = chat_intent_completion.choices[0].message.content

        return user_intent

    def _get_context(self, user_intent: str) -> list[str]:
        """
        Получает подходящие документы из Azure Cognitive Search.
        """
        query_vector = Vector(
            value=openai.Embedding.create(
                engine=AZURE_OPENAI_EMBEDDING_DEPLOYMENT, input=user_intent
            )["data"][0]["embedding"],
            fields="embedding",
        )

        search_client = SearchClient(
            endpoint=AZURE_SEARCH_ENDPOINT,
            index_name=AZURE_SEARCH_INDEX_NAME,
            credential=AzureKeyCredential(AZURE_SEARCH_KEY),
        )

        docs = search_client.search(search_text="", vectors=[query_vector], top=1)
        context_list = [doc["content"] for doc in docs]

        return context_list

    def _rag(self, context_list: list[str], query: str) -> str:
        """
        Просит LLM сформировать ответ на запрос пользователя с использованием предоставленных контекстных сведений.
        """
        user_message = {"role": USER, "content": query}
        self.chat_history.append(user_message)

        context = "\n\n".join(context_list)
        messages = [
            {
                "role": SYSTEM,
                "content": (
                    "You're a helpful assistant.\n"
                    "Please answer the user's question using only information you can "
                    "find in the context.\n"
                    "If the user's question is unrelated to the information in the "
                    "context, say you don't know.\n"
                    f"Context: ```{context}```\n"
                ),
            }
        ]
        messages = messages + self.chat_history

        chat_completion = openai.ChatCompletion.create(
            deployment_id=AZURE_OPENAI_CHATGPT_DEPLOYMENT,
            messages=messages,
            temperature=0.7,
            max_tokens=1024,
            n=1,
        )

        response = chat_completion.choices[0].message.content
        assistant_message = {"role": ASSISTANT, "content": response}
        self.chat_history.append(assistant_message)

        return response

    def ask(self, query: str) -> str:
        """
        Выполняет запросы к LLM с использованием RAG.
        """
        user_intent = self._summarize_user_intent(query)
        context_list = self._get_context(user_intent)
        response = self._rag(context_list, query)
        print(
            "*****\n"
            f"QUESTION:\n{query}\n"
            f"USER INTENT:\n{user_intent}\n"
            f"RESPONSE:\n{response}\n"
            "*****\n"
        )

        return response

Чат‑бот хранит список chat_history, содержащий вопросы и ответы. Поэтому он может рассматривать вопросы в контексте всей беседы. Класс Chatbot обладает единственной публичной функцией — ask, при выполнении которой используются следующие механизмы, представленные несколькими функциями:

  • Функция _summarize_user_intent использует LLM для перефразирования вопроса пользователя с учётом истории чата. Зачем это нужно? Представьте себе, что пользователь задаёт вопрос, который сам по себе особого смысла не имеет, но при этом, если рассмотреть его в контексте истории чата, это — совершенно нормальный вопрос. Например — если в вопросе упоминается «это» (it) по отношению к чему‑то, о чём речь шла ранее. Если поискать в индексе документы, имеющие отношение исключительно к вопросу пользователя, то, вероятно, хороших результатов получить не удастся. Но если перефразировать вопрос, включив в него историю чата, мы получим гораздо более качественный набор документов. Скоро, демонстрируя сеанс общения с чат‑ботом, я это вам покажу.

  • Функция _get_context производит поиск по ранее созданному индексу. Она ищет документы, имеющие отношение к намерению пользователя, сформулированному на предыдущем шаге.

  • Функция _rag предлагает LLM дать ответ на запрос пользователя, основываясь на документах, возвращённых системой поиска, и на истории чата. На этом шаге мы обновляем историю чата, добавляя в неё сообщения пользователя и ассистента.

Если посмотреть на файлы, эквивалентные этому, расположенные в папках проектов LangChain и Semantic Kernel, то окажется, что и там и там используются API для работы с шаблонами, с помощью которых конструируют промпты, отправляемые LLM. Вы, кроме того, заметите, что в библиотеке LangChain имеется встроенная поддержка хранения истории чата. А фреймворк Semantic Kernel, с другой стороны, построен вокруг концепции функций (фрагментов кода, пригодных для многократного использования) и плагинов (коллекций функций, которые могут вызываться внешними приложениями стандартизированным способом).

Вот как выглядит сеанс общения с чат‑ботом:

*****
QUESTION:
I need a large backpack. Which one do you recommend?
USER INTENT:
User's intent: The user is looking for a recommendation for a large backpack.
RESPONSE:
Based on the information in the context, the SummitClimber Backpack has a dedicated laptop compartment that can accommodate laptops up to 17 inches and it also has a hydration sleeve and tube port, making it compatible with most hydration bladders for convenient on-the-go hydration. However, it's important to keep in mind the cautionary notes and warranty information provided as well. If you're looking for a backpack larger than the SummitClimber Backpack, I don't have that information available.
*****

*****
QUESTION:
How much does it cost?
USER INTENT:
User's real intent: The user wants to know the price of the SummitClimber Backpack that the assistant recommended.
RESPONSE:
The price of the SummitClimber Backpack is $120.
*****

*****
QUESTION:
And how much for a donut?
USER INTENT:
User's real intent: This query does not seem related to the previous conversation about backpacks and may be a joke or a non-serious question.
RESPONSE:
I'm sorry, I don't understand your question. Is there anything else I can assist you with?
*****

В дополнение к вопросам и ответам я привожу тут и сведения о намерении пользователя, что позволит вам лучше почувствовать то, в чём польза включения таких сведений в запрос к LLM. Как видите, второй вопрос, если не принимать во внимание историю чата, выглядит неясным, но сведения о намерении пользователя не оставляют неоднозначностей, включая в себя знания о беседе, полученные чат‑ботом после первого вопроса.

Последний ответ показывает, что система отвечает на наши вопросы, полагаясь только на предоставленные ей документы, а не на общую информацию, которую LLM могла усвоить в процессе обучения.

Итоги

В этом материале мы рассмотрели паттерн RAG, который позволяет расширять предварительно обученные LLM, давая им доступ к пользовательским данным. Мы поговорили об исследовательской работе, где был представлен RAG, о том, как этот паттерн применяется на практике, о том, какие способы поиска информации обычно используются при его реализации. В конце статьи мы разобрали пример кода, демонстрирующий реализацию RAG с использованием OpenAI и Azure Cognitive Search.

Если вам интересно взглянуть на полноценное чат‑приложение, основанное на RAG, в состав которого входит клиентский код, в котором использовался передовой опыт создания проектов корпоративного уровня, рекомендую обратиться к репозиторию Azure Chat, созданному моими коллегами из Microsoft. Этот проект демонстрирует решение множества распространённых задач, и, уверена, знакомство с ним сэкономит вам немало времени.

Надеюсь, этот материал вдохновил вас на использование RAG в своих разработках. Спасибо, что дочитали до этого места и удачи вам с вашими ИИ‑проектами!

О, а приходите к нам работать? ???? ????

Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.

Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.

Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.

Присоединяйтесь к нашей команде

Комментарии (1)


  1. AlexanderAnisimov
    11.12.2023 17:12

    Хотелось бы чтобы кто-нибудь ответил по существу на этот комментарий https://habr.com/ru/articles/779526/#comment_26248910