Предисловие
Всем привет!
Вводные слова выделены в отдельный блок, который можно пропустить и перейти непосредственно к сути.
Содержание
- Вводные сведения
- Описание пайплайна
- Подготовительные мероприятия
- Клиент-серверная часть
- Реализация простого RAG-пайплайна
- Результат
- Системные требования
- Программный код серверной части
- Программный код клиентской части
- Общая схема пайплайна
Вводные сведения
Дано
- Корпус специализированной текстовой информации на заданную тему.
Цель
- Быстрый и удобный поиск информации по базе знаний.
Постановка задачи и требования
- Пользователь должен иметь возможность вводить в текстовое поле информацию в произвольной форме (в контексте тематики базы знаний).
- Система должна отвечать на вопрос "по-человечески" со ссылками на источники.
- Система должна работать автономно, не обращаясь к внешним источникам (через API или любым другим способом).
- Из пункта 3 следует, что система, развернутая локально, должна уметь работать без доступа к интернету.
- Система должна работать как на Windows, так и на Linux.
- Система, развернутая локально на обычном пользовательском ноутбуке, должна работать с приемлемой производительностью и с приемлемой точностью ответов (на данный момент точность достаточно оценивать "на глаз" без использования методик и дополнительных средств).
- Для написания вопросов и получения ответов может быть использована обычная html-страница и/или любое другое средство, реализующее отправку вопроса и получение ответа. Например, форма в 1С.
Средства реализации
Для решения поставленной задачи будем делать RAG-пайплайн на базе готовой LLM на языке программирования Python.
В теорию и терминологию вдаваться здесь смысла нет - информации и так предостаточно. Здесь пройдемся по основным шагам реализации и покажем то, что у нас в итоге получилось.
Возможно, кому-то данная информация окажется интересной и полезной.
Описание пайплайна
Общий порядок функционирования поискового инструмента следующий:
- Пользователь вводит свой вопрос.
- Пайплайн в базе знаний ищет топ-3 наиболее релевантных ответов, семантически их ранжируя.
- Формируется текстовый запрос к LLM, которая содержит:
- исходный вопрос пользователя,
- промпт - задание для LLM в виде "Сгенерируй ответ на вопрос, используя только информацию ниже." + дополнительные инструкции.
- исходные тексты топ-3 релевантных ответов.
- LLM генерирует человеческий ответ на основе промпта и релевантных ответов.
- Полученный ответ вместе со ссылками на источники возвращается пользователю.
Итак, приступим к созданию нашего инструмента.
Подготовительные мероприятия
Самое главное на первоначальном этапе - это подготовить сырые исходники текстов в такой формат, чтобы наш пайплайн мог с ними работать.
Для этого тексты нужно превратить в эмбеддинги - в набор чисел (вектора), с которыми можно будет работать как с точками в многомерном пространстве.
Подготовка базы знаний
Во-первых, перевели всю имеющуюся информацию в txt-формат. Форматирование, картинки и прочее - все это удаляется. Часть перенесена простым Ctrl+C Ctrl+V из файлов, часть уже была в текстовом формате (тексты для видео).
Затем разбили на блоки по принципу 1 блок - 1 тема. Также, сделано это было вручную. Ссылки были заменены на [link], адреса электронной почты на [email] и т.п.
При этом, размер блоков подбирали, чтобы его размер был не более 700-900 токенов. Проверка размеров - с помощью программы на Python, которая считает токены для каждого блока. Если размер превышен - вручную делим его на части, чтобы блоки сохраняли смысл и контекст.
Формат текстовых файлов:
- [вопрос/тема/контекст]
- [ссылка на первоисточник]
- [информация]
Пример:
На самом деле формат исходных raw-файлов вторичен - это может быть txt, json или любой другой формат. Всё равно дальше программа для формирования эмбеддингов будет эти файлы парсить и забирать из них все тексты.
Формат здесь вторичен, первично качество текстов. Всю второстепенную информацию (незначимые ссылки в текстах, незначимые для поиска символы и прочий шум) из текстов надо убирать.
Чем чище текст, тем лучше будет результат.
Размер нашей базы знаний: > 230 000 токенов, разбитых на 572 блока.
Формирование эмбеддингов
Выбор модели
Для формирования эмбеддингов взяли готовую модель intfloat-e5-base.
Почему она:
- открытая лицензия,
- мультиязычная, знает русский язык,
- достаточно легкая по размерам и требуемым ресурсам, работает оффлайн (о ресурсах см. Системные требования),
- позволяет получать ответы на вопросы на приемлемом уровне.
Из того же семейства еще есть intfloat-e5-small и intfloat-e5-large. Выбран средний вариант в качестве компромисса для баланса между точностью и ресурсами.
Реализация
Небольшая программа на Python (158 строк кода) делает следующее:
- разбивает каждый блок текста на кусочки - чанки (chunk) заданного размера с перекрытием.
- для каждого чанка считает эмбеддинг - 768 чисел на каждый чанк.
- сохраняет эмбеддинги в json.
- сохраняет исходные тексты со ссылками в json.
- Если тематический блок исходного текста небольшой, по нему строится 1 эмбеддинг.
- Если размер блока превышает заданный в программе размер, блок разбивается на несколько чанков. Для каждого чанка считается свой эмбеддинг.
- Кроме указания размера чанка, следует указать размер перекрытия. Перекрытие - это часть текста заданной длины из предыдущего чанка. Т.е. чанки одного блока идут внахлёст. Это даёт возможность сохранять контекст и связь между чанками одного исходного блока.
- Размер чанков и перекрытий - настраиваемые параметры, которыми можно регулировать "фокус" для попадания в тему и в контекст.
- Слишком большие чанки будут давать размытые эмбеддинги, в которые вопрос пользователя может не попасть или попасть неточно.
- В слишком маленьких будет теряться смысл.
- Размер перекрытий влияет на принадлежность чанка контексту.
Вот здесь на примере видно перекрытие: начало нового чанка содержит часть старого в рамках одного исходного блока.
Здесь же хорошо видно, что конец 1-го чанка и начало 2-го - не обрезаны и содержат кусочки незаконченных предложений, которые кроме шума вряд ли что-то добавляют.
Поэтому, обрезать лучше по концу ближайшего предложения, а не размеру.
Когда эмбеддинги готовы их можно использоваться в пайплайне.
Повторное формирование эмбеддингво потребуется только, если исходные тексты будут изменены или потребуется изменить модель и/или параметры формирования.
Визуализация эмбеддингов
Каждый чанк - это вектор в 768-ом пространстве (количество координат зависит от выбранной модели).
Представить такую точку мысленно мы не можем, но может с помощью алгоритма UMAP снизить её размерность, например до 2 или до 3 и вывести на графике, который будет наглядной визуализацией нашей базы знаний.
Каждая точка на графике - это 1 чанк.
С помощью этого графика можно уже оценить корректность формирования эмбеддингов: точки близких тем в базе знаний на графике также расположены близко друг к другу.
Схожие по смыслу кусочки текстов сбиваются в кластеры.
Тексты из разных файлов-источников (выделены разными цветами) тоже могут оказаться близко за счет схожести тематики.
Если увеличить участок и посмотреть расшифровки точек, то видно, что темы точек совпадают и/или пересекаются.
Значит, можно предположить, что эмбеддинги построены правильно и можно их пробовать использовать дальше.
Кроме этого, визуально можно оценить не только точность эмбеддингов, но и избыточность данных - когда на одну и ту же тему находится много одинаковых эмбеддингов. Возможно, в этом случае можно уменьшить исходный корпус информации.
Трехмерная визуализация оказалась не такой информативной, как могло показать на первый взгляд. Хотя бы потому, что её приходится крутить мышкой, чтобы рассмотреть все точки.
Выводы
Оставив за скобками факт, что "векторные отпечатки" разнообразных данных можно использовать для разных целей, в т.ч. классификации, группировок, поиска и т.п., в контексте темы статьи можно сделать выводы:
- формирование эмбеддингов, на первый взгляд, выглядит правдоподобным.
- можно переходить к следующему этапу.
Клиент-серверная часть
Векторное пространство, в которое будет погружаться наш пайплайн в поисках ответов на вопросы, готово.
Можно приступить к созданию самого инструмента.
Делаем его максимально универсальным. Таким, чтобы он мог работать и локально, и на веб-сервере.
Поэтому, архитектура решения - клиент-серверная.
Далее в статье сервер запущен локально и доступ к нему будет через http://127.0.0.1:8000. Если бы он был размещен где-нибудь на vds, сути бы это не изменило.
Подготовительные мероприятия
- Устанавливаем Uvicorn+FastAPI.
- Пишем программу server.py.
Чем будет заниматься server.py:
- Загружает при запуске в память модели для работы пайплайна.
- В фоновом режиме ждет подключения клиента.
- Получает от клиента вопросы.
- Передает вопросы в пайплайн для обработки.
- Отправляет клиенту полученные результаты.
При первом подключении клиента, server.py отдает ему шаблон страницы, которая будет использоваться для взаимодействия клиента с языковой моделью.
Самая главная часть шаблона html-страницы - это отправка вопроса и получение ответа:
Суть server.py сводится к запуску асинхронного сервера, который принимает запросы от клиента, передаёт их в RAG-пайплайн для обработки и возвращает сгенерированные ответы:
Запускаем сервер:
uvicorn server_fastapi:app
Подключаемся как клиенты через браузер и сервер отдает нам шаблон страницы, на которой мы можем задавать вопросы:
Реализация RAG-пайплайна
Программный код здесь не приводится. Если заинтересует - желающие могут получить ссылку на github.
Первый этап работы RAG-пайплайна: поиск близких эмбеддингов
- полученный от клиента вопрос очищается от лишних символов, приводится к нижнему регистру.
- по очищенному вопросу формируется эмбеддинг.
question_emb = encode_text([normalize_text(question)])
- по эмбеддингу вопроса ищется топ-3 самых релевантных ответов из базы эмбеддингов, которую подготовили выше. Используется cosine similarity. Чем ближе полученное значение к 1, тем ближе текст вопроса к тексту источника.
top_answers = search_top_k(question_emb, top_k=TOP_K_RETRIEVE)
Текущий результат уже можно отправлять клиенту.
Система будет просто отправлять клиенту ссылки на самые релевантные источники по заданному вопросу.
Но данных шагов недостаточно, потому что результат поиска может быть неадекватным.
Здесь, например, просто нарушен порядок - хотелось бы, чтобы источник о ценах был на 1-ом месте:
А здесь на 1-ое место попал вообще посторонний источник:
В первом проходе система формирует топ-3 релевантных ответов не только с учетом текста ответов, но и с учетом темы. При этом поиск по эмбеддингам не понимает смысла, а ищет только близость слов.
Поэтому на первые места в поиске могут выходить совершенно неподходящие по смыслу источники.
Во втором примере слова "установка программы" и "программная установка" для нашего ретривера очень близки, поэтому ссылка на источник, которая не является по смыслу релевантной, выводится на первое место.
Дополнительно выведем сюда рассчитанные значения сходства вопроса и источника:
Видим, что благодаря совпадению слов вопроса и слов заголовка источника, совершенно неподходящий источник попал в выборку.
Второй этап работы пайплайна: reranker
- Для того, чтобы повысить общую адекватность поиска после нахождения топ-3 релевантных ответа, дополнительно ранжируем источники, но уже будем учитывать эмбеддинги источников без учета заголовков/тем. Т.е. будем анализировать только тексты.
- Результатом будет повторно отсортированный топ-3.
Теперь ретривер показывает немного лучший результат - на первые места встают источники, наиболее подходящие к вопросу:
Но проблема с попаданием нерелевантного источника все равно остаётся и score у неадекватного источника по-прежнему высок.
Третий этап работы пайплайна: cross-encoder
- Вместо обычного реранкера, который использовали на предыдущем этапе, будем использовать cross-encoder, который будет считать релевантность, учитывая весь контекст и структуру предложения.
Для этого будет использовать модель "jinaai/jina-reranker-v2-base-multilingual" (см. Системные требования).
Передадим в модель пару (вопрос, ответ) и получим оценку релевантности.
После этого, оценки источников выглядят уже по-другому и нерелевантный источник действительно оказывается нерелевантным:
Четвертый этап работы пайплайна: RAG
Будем считать, что в подавляющем числе случаев ретривер дает нам самые релевантные куски текстов по заданному вопросу. И может возвращать их нам в виде ссылок на первоисточники.
Теперь можно вдохнуть жизнь в наш пайплайн, наделив его небольшой каплей искусственного интеллекта.
Для этого, передадим найденные источники вместе с исходным вопросом, языковой модели "qwen2.5-1.5b-instruct-q5_k_m.gguf" (см. Системные требования).
Вместе с вопросом и источниками ответов передадим модели и задание - что именно от неё требуется.
Пусть промпт будет таким:
Инициализация и настройки модели базовые/простые без особых тонкостей: работает на cpu, 4 потока.
Ограничение размера ответа, низкое значение температуры и использование наиболее вероятных слов дают нам в итоге сдержанный, детерминированный стиль ответов.
Результат
В результате мы получаем первый человеческий ответ от нашего RAG-пайплайна:
Чудес, конечно, не бывает. И наш маленький друг случается пишет невпопад.
Но учитывая, что даже в таком простом пайплайне уже есть много настроек, есть смысл покрутить их. И начать надо с самого начала - с эмбеддингов.
Если, например, какая-то мысль в источниках размазана по нескольким чанкам, то система этот смысл может просто не уловить между ними. При этом, она может дать релевантную выборку, но собрать адекватный ответ из которой будет проблематично.
Справедливости ради, надо сказать, что ссылки на источники система даёт вполне релевантные. А это вполне соответствует основной цели ("Быстрый и удобный поиск информации по базе знаний"), преследуемой при разработке данной системы.
Системные требования
Отдельно хочется остановиться на том, сколько ресурсов потребляет данный инструмент.
Учитывая, что тестовая клиент-серверная версия разворачивалась на обычном старом ноутбуке, можно сказать, что требования - минимальны. В этом плане соотношение цена/качество очень приемлемое.
Используется: Intel(R) Core(TM) i7-8565U CPU @ 1.80GHz 1.99 GHz, 4 ядра, 8.00Гб ОЗУ. Все расчеты: на 4 ядрах CPU.
Ниже будет приведена информация о потреблении ресурсов именно на нём, как на минимально возможном оборудовании. Будет легко экстраполировать эти показатели на любое оборудование.
Модели
В пайплайне используется 3 модели. Все 3 модели работают оффлайн. Доступ к сторонним серверам им не нужен.
При первом их использовании они загружаются из интернета, а все остальное время лежат где-нибудь в кэше или любом другом месте, которое можно указать при настройках сервера.
Список используемых моделей:
- Для эмбеддингов: models--intfloat--multilingual-e5-base. Размер на жестком диске: 1,05 Гб
- Для кросс-энкодинга: models--jinaai--jina-reranker-v2-base-multilingual. Размер: 0,55 Гб
- Для генерации: qwen2.5-1.5b-instruct-q5_k_m.gguf. Размер: 1,26 Гб
К этому еще нужно добавить Python-библиотеку Torch. Размер ~1,3-2.5Гб.
ОЗУ
При запуске server.py в ОЗУ сразу загружаются все модели.
uvicorn server_fastapi:app
Первое время при работе пайплайн занимает ~1.2Гб ОЗУ, а затем, после стабилизации, усаживается в несколько раз:
Запас ОЗУ, конечно, нужен, если всё это будет крутиться где-то на web-сервере, иначе oom-killer может прийти раньше, чем система будет готова к работе.
Формирование эмбеддингов
230 000 токенов исходного текста для модели с 768-мерным вектором формируются за 4-5 минут.
Делается это 1 раз, а затем готовые эмбеддинги просто используются.
Время обработки запросов
RAG-пайплайн функционирует в нескольких режимах.
- Режим поиска релевантных источников без кросс-энкодинга и генерации ответа. Время выполнения ~0сек.:
Для удобства тестирования добавили специальные ключи, которые можно указывать в вопросе.
Здесь указан ключ "-s", который выводит score по каждому источнику:
- Режим с кросс-энкодингом (ключ "-ce"). Время выполнения ~5-10 сек.:
- Режим генерации ответа (флаг "AI-ответ" + ключ "-2", ограничивающий количество источников для генерации ответа). Время выполнения ~20-30 сек.:
Программный код серверной части
Серверная часть реализована на Flask и FastAPI. В проекте есть выбор: server_fastapi.py или server_flask.py.
Функционал пайплайна будет идентичен.
Проект, в целом, совсем небольшой. Общий объем программного кода: 980 строк
Программный код клиентской части
Клиентская часть может быть любой. В статье приводится пример html-страницы для взаимодействия с сервером.
Если говорить про 1С, то минимальный код для работы будет состоять из нескольких строк.
&НаСервере
Процедура ВопросПриИзмененииНаСервере()
HTTPСоединение = Новый HTTPСоединение("127.0.0.1", 8000);
СтруктураЗапроса = Новый Структура;
СтруктураЗапроса.Вставить("question", Вопрос);
СтруктураЗапроса.Вставить("use_RAG", ИспользоватьRAG);
СтруктураЗапроса.Вставить("format", Форматировать);
ЗаписьJSON = Новый ЗаписьJSON;
ЗаписьJSON.УстановитьСтроку();
ЗаписатьJSON(ЗаписьJSON, СтруктураЗапроса);
ЗапросJSON = ЗаписьJSON.Закрыть();
HTTPЗапрос = Новый HTTPЗапрос("/ask");
HTTPЗапрос.Заголовки.Вставить("Content-type", "application/json");
HTTPЗапрос.УстановитьТелоИзСтроки(ЗапросJSON);
HTTPОтвет = HTTPСоединение.ВызватьHTTPМетод("POST", HTTPЗапрос);
ЧтениеJSON = Новый ЧтениеJSON;
ЧтениеJSON.УстановитьСтроку(HTTPОтвет.ПолучитьТелоКакСтроку());
Ответ = ПрочитатьJSON(ЧтениеJSON);
ЧтениеJSON.Закрыть();
Результат.УстановитьHTML(Ответ.answer, Новый Структура)
КонецПроцедуры
Для работы надо только текстовое поле и поле HTML-документа для вывода результата.
Если форматирование не требуется, можно получать просто json и дальше программно с ним что-то делать:
СтруктураЗапроса.Вставить("format", Ложь);
Или можно вообще не задействовать базу эмбеддингов, а напрямую просить RAG сгенерировать ответ по заданному промпту, используя свои данные:
Если вынести код запроса в отдельную функцию и делать подобные запросы фоново по выборке объектов, можно делать какую-нибудь классификацию/сигнализацию в рамках учетной системы, если возможности модели позволяют получать приемлемый уровень достоверности. Например, маркировать входящие письма в CRM.
Общая схема пайплайна
В целом, клиент-серверное взаимодействие можно представить в виде такой схемы:
А сам RAG-трубопровод можно представить следующим образом.
Пайплайн имеет 2 входа:
- для векторного/семантического поиска по корпусу данных.
- для простой rag-генерации с промптом и произвольными данными.
Наверно, на этом данную статью можно закончить. По ходу подготовки статьи возникло много идей, которые хочется реализовать🙂
Надеемся, данная информация окажется для вас полезной!
Вступайте в нашу телеграмм-группу Инфостарт