Конечно, прекрасно подключить API от OpenAI и разыграть своего руководителя новым консультантом… Но подобные чат-боты не могут ориентироваться в данных компании и предоставлять адекватные ответы. Можно хотя бы не рассчитывать на увольнение:)

В чем отличие модифицированного чат-бота, нейросотрудника от обычного окошка с GPT 4.0?  — он может ориентироваться в нужной вам информации лучше: составлять подборки резюме для дальнейшего анализа живым HR-ом, общаться с клиентами скриптами, даже подбирать контент-план на основе данных о компании и помогать расписывать ТЗ для сотрудников. 

В этой работе мы попробуем написать своего простого нейросотрудника, а точнее HR-менеджера. Начнем с теоретической части про векторные базы данных и обучение, закончим практикой, разобрав конкретный пример. 

Если не хочется читать теорию – переходите в конец статьи. 

Такие нейросотрудники полезны, когда уровень энтропии и сложности "менеджмента" растет. На этом выезжают некоторые стартапы, предоставляя, например, систему обратной связи или рекомендаций товаров на возвратах. Вернули шелковое платье? – давайте мы учтем "недочеты" и подберем уже подходящий товар. Идей с чат-ботами достаточно. Подобрать задачу для подобного ИИ-консультанта всегда можно. 

Возникают проблемы с ответственностью. А если наш обученный на скриптах нейросотрудник не сможет решить проблему пользователя, адекватно передать запрос в техподдержку? Клиент уйдет и оставит негативный отзыв? – двоякая ситуация, ведь даже человек-менеджер ошибается. 

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

А если у нас нет денег на такого клерка, почему бы не имитировать через LLM?

27% пользователей не были уверены, с кем они в последний раз обращались в службу поддержки клиентов: с человеком или с чат-ботом. Между тем, 62% полагают, что ИИ может ускорить реакцию, одновременно удовлетворяя их конкретные предпочтения.

В декабре некий Иван Жуков насчитал + 2.4 млн прибыли с интеграции нейросетей, опубликовавший статью на VC.  

Многие компании говорят об экономии 2.2 млн рублей. Безусловно, для проектов с солидной выручкой — это не самые большие деньги. Но прибыль скрывается в мелочах. Все зависит, безусловно, от качества модели и ее возможностях. Тут следует исходить из бюджета. 

Но для рядового ML-кодера знать, как написать рабочего нейросотрудника – неплохая практика или, как минимум, теория. Предоставляем подробный гайд. 

Как собрать своего нейросотрудника из трех компонентов?

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

Компании любят хранить данные далеко не в самом структурированном виде. 

Ваш HR руководитель в планерке расписал одни задачи, а директор послал совершенно противоположные требования по найму сотрудника — это все в одних директориях, идентичном контексте. 

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

Векторная форма с индексами и указателями, настоящим механизмом внимания, но в вашем избранном контексте – вот, что нужно для разработки адекватного нейросотрудника. Тут есть несколько вариантов развития событий:

RAG работает просто: мы пришиваем структурированные под базу данных документы компании, вытаскиваем оттуда данные, релевантные нашему запросу и соединяем с LLM как генератором ответов. 

Как создать простого чат-бота с Gradio и GPT, мы писали в нашем маленьком материале. Почитать его можно – тут. 

Обучение на собственных данных со своей языковой моделью или по методу RAG (Retrieval-Augmented Generation), где берется качественная LLM, которая дообучается на базе данных документов, например, представленных в векторной форме. 

Первый вариант — самый дорогостоящий и долгий, хотя эффективен. И он уж точно не поместится в даже крупный гайд на Хабре. 

Мы же будем ориентироваться на работу с RAG, метод и для новичков подходит, и для среднего-малого бизнеса, у которого нет тысяч долларов на разработку своего чат-ПО. 

RAG (Retrieve and Generate) – гибридная модель, объединяющую методы информационного поиска и генерации текста – состоит из нескольких простых компонентов: retriever, generator, интерфейс. 

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

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

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

На этом этапе используются методы векторизации текстов и вычисления сходства между запросом и документами в векторном пространстве. Задача ретривера – собрать контекст для нашего чат-бота. 

Второй компонент — модуль генерации текста (generator). Чаще всего здесь применяются автокодировщики или трансформеры, тот же GPT 4.0 и другие модели. 

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

Модуль генерации может работать через механизмы внимания (attention mechanisms), которые позволяют модели фокусироваться на наиболее релевантных частях входного текста при генерации ответа. А лучше его пока что ничего не придумал…

Но и тут есть свои проблемы. Так как Generator использует подготовленный вами контекст — качество формулируемых ответов будет зависеть от подготовки данных. 

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

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

Вся структура RAG также включает механизмы предобучения и дообучения моделей. На этапе предобучения обе модели (и retriever, и generator) обучаются на больших векторизированных наборах данных для создания первоначальных представлений. 

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

Как работают векторные базы данных?

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

Работа векторных баз данных начинается с этапа индексирования, в ходе которого все векторные представления данных организуются в структуру, обеспечивающую эффективный доступ к ним. 

Индекс – своего рода идентификационный номер, на который можно сослаться. 

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

Таким образом мы унифицируем текст и уменьшаем его размер. 

После токенизации и нормализации мы приходим к построению индексной структуры. В классических текстовых системах индексация часто реализуется через построение инвертированного индекса. 

Инвертированный индекс – способ индексации и структурирования данных, где каждому токену (слову) сопоставляется список документов, содержащих этот токен. Такая структура позволяет быстро находить все документы, где встречается данное слово, что значительно ускоряет процесс поиска по ключевым словам.

Популярных методов индексирования много: KD-деревья (k-dimensional trees) или деревья покрытий (cover trees) и другие “растения”, уводящие к нужным нам токенам. 

KD-деревья помогают организовать и искать данные в многомерных пространствах. 

Представьте себе набор точек на плоскости. KD-дерево разбивает эту плоскость на части, сначала по одной координате (например, по оси X), а потом по другой (например, по оси Y), и так далее, чередуя координаты на каждом уровне. 

Это похоже на то, как вы могли бы разделить лист бумаги сначала вертикальной линией, затем горизонтальной линией, и так далее. 

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

Другой метод – локально чувствительные хэширования (LSH), которые позволяют группировать векторы по близости в пространстве на основе хэш-функций, специально разработанных для сохранения пространственной близости.

Когда пользователь запрашивает данные, запрос также преобразуется в векторное представление. 

Затем база данных ищет векторы, которые наиболее близки к вектору запроса. 

Метрика, используемая для определения близости, часто базируется на расстоянии векторов в пространстве, таким как косинусное расстояние, евклидово расстояние или манхэттенское расстояние. Выбор метрики зависит от конкретных характеристик данных и задачи.

Для ускорения поиска используются различные оптимизационные техники. Одна из таких техник — это использование приближенных методов поиска ближайших соседей (Approximate Nearest Neighbor, ANN). 

Эти методы позволяют значительно сократить время поиска, жертвуя при этом точностью, что в большинстве случаев – приемлемый компромисс. 

HNSW (Hierarchical Navigable Small World) – эффективный ANN-методов, который строят графы для быстрого нахождения ближайших соседей.

Во время выполнения запроса, вектор запроса сравнивается с индексированными векторами, и результаты сортируются по степени их близости к запросу. 

Этот процесс может включать этапы фильтрации и ранжирования, чтобы исключить нерелевантные результаты и улучшить точность выдачи. 

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

Все это нам нужно для адекватного поиска ответов по смыслу. 

Если упростить процесс работы ретривера с векторной базой данных, то всё выглядит примерно так:

Retriever  берет запрос пользователя и ищет наиболее релевантные документы или фрагменты текста в большой коллекции данных. 

Для этого каждый документ в базе данных предварительно преобразуется в векторное представление с помощью моделей трансформеров, таких, как BERT или RoBERTa, которые способны улавливать глубокие смысловые и контекстуальные связи в тексте. 

BERT в контексте ретривера работает следующим образом: он преобразует запрос и текст из документов в высокоразмерные векторные представления, которые улавливают их семантическое содержание. 

В отличие от традиционных моделей, которые обрабатывают текст последовательно, BERT рассматривает весь текст целиком, как слева направо, так и справа налево. 

Когда поступает запрос, он также преобразуется в векторное представление.

Затем используется метод поиска ближайших соседей для сравнения вектора запроса с векторами документов. 

Это сравнение позволяет определить, какие документы наиболее близки к запросу по смыслу. Процесс аналогичен поиску в многомерном пространстве, где каждая точка представляет собой векторное представление документа или запроса. 

Допустим поиск выполняется через метод ближайших соседей: KD-деревья или HNSW (Hierarchical Navigable Small World).

После нахождения ближайших векторов, соответствующие им документы или фрагменты текста извлекаются из базы данных.

Эти извлеченные данные затем передаются генеративной модели (generator). 

Генеративная модель, любимая GPT 4.o, использует полученную информацию для формирования ответа, который учитывает контекст запроса и извлеченных документов. 

Какие бывают векторные базы данных?

Безусловно, можно написать свою… Но в интернете уже достаточно решений для энтузиастов мира ИИ. Специализированные векторные базы данных с открытым исходным кодом: Chroma, Vespa, LanceDB, Milvus…  

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

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

К числу коммерческих специализированных векторных баз данных относятся, например, Pinecone и Weaviate. Тут подключается и управление инфраструктурой, и обеспечение высокой доступности, и масштабируемость под большим объемом данных. Но мы бедные, поэтому коммерческие предложения не для нас… 

С другой стороны, базы данных, поддерживающие векторный поиск: OpenSearch и PostgreSQL, так и коммерческие решения, например, Elasticsearch и Redis. 

Эти базы данных изначально не были предназначены исключительно для работы с векторами, но они интегрировали функционал для поиска по векторным пространствам – тоже подходят. Хотя мы бы рекомендовали Vespa или Chroma. 

Этап ранжирования ответов на запросы пользователя

Вы же не хотите, чтобы на экране клиента показался нерелевантный ответ… Есть несколько схем ранжирования. Самая простая – попросить отранжировать LLM. Но есть и другие методы. 

Например, Cross Encoder берет каждый из документов и сопоставляет их с исходным запросом. Он оценивает не только наличие ключевых слов, но и их контекст, что позволяет учесть сложные взаимоотношения между словами и фразами. 

Например, если запрос пользователя связан с последними достижениями в области пиротехники, Cross Encoder способен оценить, насколько каждый документ подробно и релевантно освещает именно эту тему. 

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

 Из этого отсортированного списка выбираются топ 10 документов, которые наиболее точно и полно отвечают на запрос пользователя. Таким образом, Cross Encoder выполняет роль фильтра, улучшая начальный набор результатов, полученных из векторной базы данных.

К слову, если запросы от вашего клиента слишком сложные и он вообще… пишет какую-то чушь – можно воспользоваться методикой RAG Fusion и вытащить из языковой модели разные ответы и через тот же Cross Encoder сверить релевантность ответов на запросы, подбирая лучший. 

Другой вариант, чуть сложнее – использование Reciprocal Rank Fusion (RRF). Другой метод, который используется для комбинирования нескольких списков ранжирования, чтобы создать более точный итоговый список. 

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

Принцип работы RRF заключается в назначении веса каждому результату на основе его позиции в исходных списках.

У вас есть несколько ранжированных списков, полученных разными методами или из разных баз данных. В каждом списке документ на первой позиции получает максимальный вес, например, 1, на второй позиции – 1/2, на третьей – 1/3, и так далее. 

Вес каждого документа обратно пропорционален его позиции в списке. Для каждого документа суммируются его веса из всех списков, в которых он присутствует. Итоговый список ранжируется по суммарным весам документов.

Этот подход позволяет учитывать множественные оценки и сглаживать ошибки или недостатки отдельных методов ранжирования. 

Например, если один алгоритм пропустил важный документ, но другой алгоритм поставил его на высокую позицию, итоговый список, сформированный с помощью RRF, все равно сможет выделить этот документ благодаря суммарному весу. 

Предварительно прописываем необходимые промты нашей модели и готовим веб-интерфейс. 

Интерфейс… мы про него совсем забыли. На питоне написать “коннект” между языковой моделью достаточно сложно, поэтому стоит воспользоваться фреймворком LangChain. 

В фреймворке можно добавить индексы и использовать их в цепочках, прописывать промты, привязывать модели с HuggingFace или GPT через API OpenAI. 

При помощи цепочек создавать мультинейронку с взаимодействием нескольких моделей. Через агентов можно получать доступ к внешним источникам информации. 

Утилита также позволяет собрать долгосрочную и краткосрочную памяти для нейросотрудника. Мы ограничимся индексами (Indexes), моделями (Models) и промтами (Promts) – этих модулей достаточно, чтобы написать простого нейросотрудника. 

Демонстрационный вариант веб-интерфейса можно разработать через JS, но мы вновь воспользуемся Gradio… Благо последний поддерживает блоки и внешний вид можно гибко менять. 

Тут можно опираться на разные варианты, но чтобы не возиться с JavaScript лучше пойти по пути простого решения: Gradio (неплохая библиотека для демонстрационных веб-интерфейсов) + интеграция LLM через LangChain. 

Пишем своего собственного нейросотрудника

Использовать мы будем готовую векторную базу данных – Chroma, у которой есть лицензия MIT – она открыта для коммерческого использования. Далее внутрь кода мы будем зашивать комментарии и добавим отдельно пояснения по пунктам, что происходит в коде. Поразбираться все же придется. 

Устанавливаем все утилиты через !pip: от openai для форматирования запросов до langchain для интеграции и gradio – для создания веб-интерфейса. 

!pip install openai gradio tiktoken langchain langchain-openai langchain-community chromadb

Теперь нам нужно разобраться с классом нашего нейросотрудника. 

Просто вписываем models в список. Туда зашиваем необходимые doc., а также выписываем некоторые команды. Посмотреть можете ниже, даже документы по предоставленным ссылкам. 

models = [
              {
                "doc": "https://docs.google.com/document/d/1f7Gfv2PZYACD1PGzlonfBZsWI9Pf9ZOFI_xYp3DRxw0/edit",
                "prompt": '''Ты менеджер поддержки hr отдела, к тебе могут обращаться hr менеджеры и рекрутеры за подсказками и ответами на их вопросы в чате компании.
                        Постарайся дать развернутый ответ, твоя задача ответить так, чтобы у менеджера не осталось больше вопросов к тебе.
                        Отвечай по существу, без лишних эмоций и слов, от тебя нужна только точная информация.
                        Отвечай максимально точно по документу, не придумывай ничего от себя.
                        Документ с информацией для ответа клиенту: ''',
                "name": "Нейро-менеджер поддержки HR-отдела",
                "query": "Для чего нужны шаблоны вакансий?"
              },
              {
                "doc": "https://docs.google.com/document/d/1joE-rKrEmDgQojonmkyTYRihQ6eaoKraqgK_Pav5uLA/edit",
                "prompt": '''Ты сотрудник по подбору персонала. Перед тобой документ, в котором описана инструкция по подбору и оценке на должность руководителя отдела продаж.
                        Твоя задача придумывать вопросы к собеседованию на данную позицию.
                        Документ: ''',
                "name": "Нейро-рекрутер",
                "query": "Придумай 5 вопросов для собеседования"
              },
              {
                "doc": "https://docs.google.com/document/d/1SVi7dVwgJKf7tOljTWlVG-XebYXEv3B0Xjq8ERqjlbA/edit",
                "prompt": '''Перед тобой диалог преподавателя музыкальной школы с родителем ученика.
                        Тебе надо проверить несколько критериев и заполнить отчёт для занесения в календарь уроков.

                        Что надо проверить
                        1. Говорил ли родитель о потребности в обучении
                        2. На каком интрументе умеет играть ребенок
                        3. На какой инструмент записался
                        4. Когда первый пробный урок

                        Не пиши общее сообщение, только заполни отчёт по форме:
                        1. Говорил ли родитель о потребности в обучении - было или нет
                        2. На каком интрументе умеет играть ребенок - название инструмента
                        3. На какой инструмент записался ребенок - название инструмента
                        4. Когда первый пробный урок - на какое время договорились

                        Заполни отчёт и пришли в качестве ответа, коротко и ёмко ''',
                "name": "Нейро-менеджер (Отчет по диалогу)",
                "query": "Пришли отчет"
               },
              {
                "doc": "https://docs.google.com/document/d/1IqGa92RlFiCJvBH7TBKhPpODpru2-RDro8qiVEzoAuA/edit",
                "prompt": '''Ты менеджер контроля качества, твоя задача анализировать диалоги менеджеров по продажам с клиентами и готовить отчеты.
                        Компания продает курсы по машинному обучению.
                        Перед тобой текст диалога сделанный с помощью распознавания речи из записи zoom презентации.
                        Из-за машинного распознавания речи, в тексте могут быть ошибки распознавания, учитывая это.
                        Твоя задача делать отчеты по данному диалогу по запросам пользователя.
                        Составляй вопросы максимально точно по диалогу, не придумывай ничего от себя.
                        Текст диалога: ''',
                "name": "Нейро-менеджер контроля качества (Оценка качества по диалогу)",
                "query": "Напиши отчет, какие были потребности названы клиентом"
              }

            ]

Далее мы готовим рабочую среду для обработки текстовых данных с использованием фреймворка LangChain. 

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

Затем подключаются инструменты для разбиения текста на части и отправки HTTP-запросов. 

Также включены библиотеки для взаимодействия с OpenAI API и создания пользовательских интерфейсов с помощью Gradio. Дополнительно импортируются средства для подсчета токенов и работы с регулярными выражениями.

 Блок библиотек фреймворка LangChain

# Работа с документами в langchain
from langchain.docstore.document import Document
# Эмбеддинги для OpenAI
from langchain.embeddings.openai import OpenAIEmbeddings
# Доступ к векторной базе данных
from langchain.vectorstores import Chroma
# Разделение текста на куски или чанки (chunk)
from langchain.text_splitter import CharacterTextSplitter

# Отправка запросов
import requests

#Доступ к OpenAI
from openai import OpenAI

# Отприсовка интерфейса с помощью grad
import gradio as gr

# Библиотека подсчёта токенов
# Без запроcов к OpenAI, тем самым не тратим деньги на запросы
import tiktoken

# Для работы с регулярными выражениями
import re

Теперь приступаем к написанию тела нашего нейросотрудника. Объявим глобальную функцию GPT – да, мы будем работать с детищем OpenAI. 

Конструктор класса GPT:

  • При инициализации объекта класса задается модель (по умолчанию "gpt-3.5-turbo").

  • Инициализируются атрибуты для логов (self.log), хранения модели (self.model), базы знаний (self.search_index).

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

class GPT():
    # Объявляем конструктор класса, для передачи имени модели и инициализации атрибутов класса
    def __init__(self, model="gpt-3.5-turbo"):
        self.log = ''               # атрибут для сбора логов (сообщений)
        self.model = model          # атрибут для хранения выбранной модели OpenAI
        self.search_index = None    # атрибут для хранения ссылки на базу знаний (если None, то модель не обучена)
        self.client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) # при инициализации запрашиваем ключ от OpenAI

Метод load_search_indexes:

  • Принимает URL Google Docs документа.

  • С помощью регулярных выражений извлекает ID документа из URL.

  • Если ID не найден, генерирует исключение.

  • Загружает документ в текстовом формате по его ID через HTTP-запрос.

  • Если запрос неудачен, генерирует исключение.

  • Извлекает текст из ответа и вызывает метод для создания эмбеддингов текста и сохранения их в векторную базу данных.

 # Метод загрузки текстового документа в векторную базу знаний
    def load_search_indexes(self, url):
        # Извлекаем document ID гугл документа из URL с помощью регулярных выражений
        match_ = re.search('/document/d/([a-zA-Z0-9-_]+)', url)

        # Если ID не найден - генерируем исключение
        if match_ is None:
            raise ValueError('Неверный Google Docs URL')

        # Первый элемент в результате поиска
        doc_id = match_.group(1)

        # Скачиваем гугл документ по его ID в текстовом формате
        response = requests.get(f'https://docs.google.com/document/d/{doc_id}/export?format=txt')

        # При неудачных статусах запроса будет вызвано исключение
        response.raise_for_status()

        # Извлекаем данные как текст
        text = response.text

        # Вызываем метод векторизации текста и сохранения в векторную базу данных
        return self.create_embedding(text)

Метод num_tokens_from_string:

  • Принимает строку и возвращает количество токенов в ней.

  • Использует библиотеку tiktoken для получения кодировщика на основе имени модели.

  • Кодирует строку и считает количество токенов.

 # Подсчет числа токенов в строке по имени модели
    def num_tokens_from_string(self, string):
            """Возвращает число токенов в строке"""
            encoding = tiktoken.encoding_for_model(self.model)  # получаем кодировщик по имени модели
            num_tokens = len(encoding.encode(string))           # расчитываем строку с помощью кодировщика
            return num_tokens   

Метод create_embedding:

  • Принимает текстовые данные и создает эмбеддинги.

  • Разделяет текст на части (чанки) с помощью CharacterTextSplitter.

  • Преобразует чанки в объекты Document.

  • Считает количество токенов в тексте без запроса к OpenAI для экономии средств.

  • Создает индексы документов, применяя эмбеддинги OpenAI и загружает их в векторную базу данных Chroma.

  • Возвращает ссылку на базу данных.

 # Метод разбора текста и его сохранение в векторную базу знаний
    def create_embedding(self, data):
        # Список документов, полученных из фрагментов текста
        source_chunks = []
        # Разделяем текст на строки по \n (перенос на новую строку) или длине фрагмента (chunk_size=1024) с помощью сплитера
        # chunk_overlap=0 - означает, что фрагменты не перекрываются друг с другом.
        # Если больше нуля, то захватываем дополнительное число символов от соседних чанков.
        splitter = CharacterTextSplitter(separator="\n", chunk_size=1024, chunk_overlap=0)

        # Применяем splitter (функцию расщепления) к данным и перебираем все получившиеся чанки (фрагменты)
        for chunk in splitter.split_text(data):
            # LangChain работает с документами, поэтому из текстовых чанков мы создаем фрагменты документов
            source_chunks.append(Document(page_content=chunk, metadata={}))

        # Подсчет числа токенов в документах без запроса к OpenAI (экономим денежные средства)
        count_token = self.num_tokens_from_string(' '.join([x.page_content for x in source_chunks]))
        # Вместо вывода print, мы формируем переменную log для дальнейшего вывода в gradio информации
        self.log += f'Количество токенов в документе : {count_token}\n'

        # Создание индексов документа. Применяем к нашему списку документов эмбеддингов OpenAi и в таком виде загружаем в базу ChromaDB
        self.search_index = Chroma.from_documents(source_chunks, OpenAIEmbeddings(), )
        # Вместо вывода print, мы формируем переменную log для дальнейшего вывода в gradio информации
        self.log += f'Данные из документа загружены в в векторную базу данных\n'

        # Возвращаем ссылку на базу данных
        return self.search_index

Метод num_tokens_from_messages:

  • Принимает список сообщений и модель, возвращает количество токенов.

  • Получает кодировщик на основе имени модели, либо базовый, если модель не поддерживается.

  • В зависимости от модели устанавливает дополнительные токены на сообщение и имя.

  • Считает количество токенов для каждого сообщения, учитывая дополнительные токены.

  • Возвращает общее количество токенов.

# Демонстрация более аккуратного расчета числа токенов в зависимости от модели
    def num_tokens_from_messages(self, messages, model):
        """Возвращает число токенов из списка сообщений"""
        try:
            encoding = tiktoken.encoding_for_model(model) # получаем кодировщик по имени модели
        except KeyError:
            print("Предупреждение: модель не создана. Используйте cl100k_base кодировку.")
            encoding = tiktoken.get_encoding("cl100k_base") # если по имени не нашли, то используем базовый для моделей OpenAI
        # Выбор модели
        if model in {
            "gpt-3.5-turbo-0613",
            "gpt-3.5-turbo-16k-0613",
            "gpt-4-0314",
            "gpt-4-32k-0314",
            "gpt-4-0613",
            "gpt-4-32k-0613",
            "gpt-4o",
            "gpt-4o-2024-05-13"
            }:
            tokens_per_message = 3 # дополнительное число токенов на сообщение
            tokens_per_name = 1    # токенов на имя
        elif model == "gpt-3.5-turbo-0301":
            tokens_per_message = 4  # каждое сообщение содержит <im_start>{role/name}\n{content}<im_end>\n
            tokens_per_name = -1  # если есть имя, то роль не указывается
        elif "gpt-3.5-turbo" in model:
            self.log += f'Внимание! gpt-3.5-turbo может обновиться в любой момент. Используйте gpt-3.5-turbo-0613. \n'
            return self.num_tokens_from_messages(messages, model="gpt-3.5-turbo-0613")
        elif "gpt-4" in model:
            self.log += f'Внимание! gpt-4 может обновиться в любой момент. Используйте gpt-4-0613. \n'
            return self.num_tokens_from_messages(messages, model="gpt-4-0613")
        else: # исключение, если модель не поддерживается
            raise NotImplementedError(
                f"""num_tokens_from_messages() не реализован для модели {model}."""
            )

        # Запускаем подсчет токенов
        num_tokens = 0                        # счетчик токенов
        for message in messages:              # цикл по всем сообщениям
            num_tokens += tokens_per_message  # прибовляем число токенов на каждое сообщение
            for key, value in message.items():
                num_tokens += len(encoding.encode(value)) # считаем токены в сообщении с помощью кодировщика
                if key == "name":                     # если встретили имя
                    num_tokens += tokens_per_name     # то добавили число токенов на
        num_tokens += 3                               # каждый ответ оборачивается в <|start|>assistant<|message|>
        return num_tokens    

Метод answer_index:

  • Принимает системное сообщение, тему запроса и температуру генерации ответа.

  • Проверяет, обучена ли модель.

  • Выполняет поиск документов по схожести с запросом из векторной базы данных.

  • Формирует сообщение с фрагментами документов.

  • Создает форматированные сообщения для модели с ролями "system" и "user". Кстати, что такое роли читайте в нашей предыдущей статье для новичков. 

  • Считает количество токенов для сообщений.

  • Отправляет запрос к языковой модели OpenAI с указанными параметрами.

  • Логирует информацию о токенах, использованных в запросе и ответе.

  • Возвращает сгенерированный ответ модели.

# Метод запроса к языковой модели
    def answer_index(self, system, topic, temp = 1):
        # Проверяем обучена ли наша модель
        if not self.search_index:
            self.log += 'Модель необходимо обучить! \n'
            return ''

        # Выборка документов по схожести с запросом из векторной базы данных, topic- строка запроса, k - число извлекаемых фрагментов
        docs = self.search_index.similarity_search(topic, k=5)
        # Вместо вывода print, мы формируем переменную log для дальнейшего вывода в gradio информации
        self.log += 'Выбираем документы по степени схожести с вопросом из векторной базы данных: \n '
        # Очищаем запрос от двойных пустых строк. Каждый фрагмент подписываем: Отрывок документа № и дальше порядковый номер
        message_content = re.sub(r'\n{2}', ' ', '\n '.join([f'Отрывок документа №{i+1}:\n' + doc.page_content + '\\n' for i, doc in enumerate(docs)]))
        # Вместо вывода print, мы формируем переменную log для дальнейшего вывода в gradio информации
        self.log += f'{message_content} \n'

        # В системную роль помещаем найденные фрагменты и промпт, в пользовательскую - вопрос от пользователя
        messages = [
            {"role": "system", "content": system + f"{message_content}"},
            {"role": "user", "content": topic}
        ]

        # Вместо вывода print, мы формируем переменную log для дальнейшего вывода в gradio информации
        self.log += f"\n\nТокенов использовано на вопрос по версии TikToken: {self.num_tokens_from_messages(messages, self.model)}\n"


        # Запрос к языковой моделе
        completion = self.client.chat.completions.create(
            model=self.model,   # используемая модель
            messages=messages,  # список форматированных сообщений с ролями
            temperature=temp    # точность ответов модели
        )


        # Вместо вывода print, мы формируем переменную log для дальнейшего вывода в gradio информации
        self.log += '\nСтатистика по токенам от языковой модели:\n'
        self.log += f'Токенов использовано всего (вопрос): {completion.usage.prompt_tokens} \n'       # Число токенов на вопрос по расчетам LLM
        self.log += f'Токенов использовано всего (вопрос-ответ): {completion.usage.total_tokens} \n'  # Число токенов на вопрос и ответ по расчетам LLM

        return completion.choices[0].message.content # возвращаем результат предсказания

Теперь можно приступать к написанию самого интерфейса на Gradio, так как все данные вывода у нас есть, чтобы показать результаты нашей работы.  

Инициализация Gradio и блока интерфейса:

blocks = gr.Blocks()

with blocks as demo:
    # В этом контексте описываются элементы интерфейса
gr.Blocks() создает блок для организации элементов интерфейса в Gradio.

Элементы интерфейса:

# Работаем с блоком
with blocks as demo:
    # Объявляем элемент выбор из списка (с подписью Данные), список выбирает из поля name нашей переменной models
    subject = gr.Dropdown([(elem["name"], index) for index, elem in enumerate(models)], label="Данные")
    # Здесь отобразиться выбранное имя name из списка
    name = gr.Label(show_label=False)
    # Промпт для запроса к LLM (по умолчанию поле prompt из models)
    prompt = gr.Textbox(label="Промт", interactive=True)
    # Ссылка на файл обучения (по умолчанию поле doc из models)
    link = gr.HTML()
    # Поле пользовательского запроса к LLM (по умолчанию поле query из models)
    query = gr.Textbox(label="Запрос к LLM", interactive=True)


    # Функция на выбор нейро-сотрудника в models
    # Ей передается параметр subject - выбранное значение в поле списка
    # А возвращаемые значения извлекаются из models

Функция onchange:

  # Функция на выбор нейро-сотрудника в models
    # Ей передается параметр subject - выбранное значение в поле списка
    # А возвращаемые значения извлекаются из models
    def onchange(dropdown):
      return [
          models[dropdown]['name'],                               # имя возвращается без изменения
          re.sub('\t+|\s\s+', ' ', models[dropdown]['prompt']),   # в промте удаляются двойные пробелы \s\s+ и табуляция \t+
          models[dropdown]['query'],                              # запрос возвращается без изменения
          f"<a target='_blank' href = '{models[dropdown]['doc']}'>Документ для обучения</a>" # ссылка на документ оборачивается в html тег <a>  (https://htmlbook.ru/html/a)
          ]

    # При изменении значения в поле списка subject, вызывается функция onchange
    # Ей передается параметр subject - выбранное значение в поле списка
    # А возвращаемые значения устанавливаются в элементы name, prompt, query и link
    subject.change(onchange, inputs = [subject], outputs = [name, prompt, query, link])

    # Строку в gradio можно разделить на столбцы (каждая кнопка в своем столбце)
    with gr.Row():
        train_btn = gr.Button("Обучить модель")       # кнопка запуска обучения
        request_btn = gr.Button("Запрос к модели")    # кнопка отправки запроса к LLM

Эта функция вызывается при изменении значения в выпадающем списке subject и обновляет значения элементов интерфейса name, prompt, query и link в соответствии с выбранной моделью.

Функции train и predict:

 # функция обучения
    def train(dropdown):
        # парсим документ и сохраняем его в базу данных
        gpt.load_search_indexes(models[dropdown]['doc'])
        return gpt.log

    # Вызываем метод запроса к языковой модели из класса GPT
    def predict(p, q):
        #
        result = gpt.answer_index(
            p,
            q
        )
        # возвращает список из ответа от LLM и log от класса GPT
        return [result, gpt.log]

    # Выводим поля response с ответом от LLM и log (вывод сообщений работы класса GPT) на 2 колонки
    with gr.Row():
        response = gr.Textbox(label="Ответ LLM") # Текстовое поле с ответом от LLM
        log = gr.Textbox(label="Логирование")    # Текстовое поле с выводом сообщений от GPT


    # При нажатии на кнопку train_btn запускается функция обучения train_btn с параметром subject
    # Результат выполнения функции сохраняем в текстовое поле log - лог выполнения
    train_btn.click(train, [subject], log)

    # При нажатии на кнопку request_btn запускается функция отправки запроса к LLM request_btn с параметром prompt, query
    # Результат выполнения функции сохраняем в текстовые поля  response - ответ модели, log - лог выполнения
    request_btn.click(predict, [prompt, query], [response, log])
  • train(dropdown): Функция, которая загружает индексы поиска из документа, связанного с выбранной моделью, используя метод load_search_indexes объекта gpt.

  • predict(p, q): Функция, которая отправляет запрос p и q к модели через метод answer_index объекта gpt и возвращает результат и лог операции.

Кнопки и их действия:

 with gr.Row():
        response = gr.Textbox(label="Ответ LLM") # Текстовое поле с ответом от LLM
        log = gr.Textbox(label="Логирование")    # Текстовое поле с выводом сообщений от GPT


    # При нажатии на кнопку train_btn запускается функция обучения train_btn с параметром subject
    # Результат выполнения функции сохраняем в текстовое поле log - лог выполнения
    train_btn.click(train, [subject], log)

    # При нажатии на кнопку request_btn запускается функция 
отправки запроса к LLM request_btn с параметром prompt, query
    # Результат выполнения функции сохраняем в текстовые поля  response - ответ модели, log - лог выполнения
    request_btn.click(predict, [prompt, query], [response, log])
  • Кнопки train_btn и request_btn запускают функции train и predict соответственно при их нажатии. Результаты операций отображаются в текстовых полях log (для лога) и response (для ответа от модели).

Запуск интерфейса Gradio:

# Запуск приложения
demo.launch()

Запускаем и смотрим на результат:)

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


  1. Fzero0
    28.06.2024 12:23
    +3

    Спасибо познавательно, но как дело доходит до

    "OPENAI_API_KEY", интерес угасает...


  1. Web4ik_2301
    28.06.2024 12:23

    Хорощая статья, а можете сделать чат-помощника на JavaScript?


    1. turbotankist
      28.06.2024 12:23

      Llama_index, на питоне и жс работает. Хорошая документация