💬🦙 LlamaIndex: создаем чат-бота без боли и страданий. Часть 2
Продолжаем изучать фреймворк для создания AI-ботов. В этой части узнаем про тонкости индексирования собственной базы документов.
В предыдущей статье мы познакомились с LlamaIndex — мощным инструментом, предназначенным для работы с большими языковыми моделями. Мы рассмотрели основные концепции и принципы работы этого фреймворка, а также увидели его в действии на простом примере поиска ответа в заданном тексте. Это были цветочки: в этой статье погрузимся в его более продвинутые возможности.
Современные чат-боты становятся все более интеллектуальными, однако, чтобы действительно раскрыть их потенциал, необходимо обеспечить их доступом к обширным базам данных и документации. Именно здесь и кроется основное преимущество llamaindex.
Один из подписчиков моего блога задал справедливый вопрос: «А зачем нужен этот ваш ламаиндекс, если можно напрямую обращаться к языковым моделям?»
Да, cам фреймворк всего лишь обертка над множеством апишек – от моделей OpenAI до векторных баз данных типа Pinecone Но именно благодаря этой «простой» обертке разработчики получают уникальную возможность интегрировать разнообразные источники данных и модели в единую систему, что значительно упрощает процесс разработки чат-ботов.
В этой части мы рассмотрим, как с помощью llamaIndex правильно проиндексировать собственную базу документов. В статье вы увидите примеры кода. Чтобы у вас тоже всё заработало, настройте окружение по инструкциям из предыдущей статьи.
Создание синтетических данных
Мы будем строить базу данных для учебного чат-бота на основе договоров в формате pdf
. Я возьму реальный пример, с которым я столкнулся в моей практике – договоры ПИР (проектные и изыскательские работы). Конечно, настоящие документы я, пожалуй, не буду здесь публиковать, поэтому создам синтетические примеры с помощью ChatGPT. После генерации текста договора я сохраняю его в формате pdf
для большего соответствия реальной ситуации.
Вот как выглядит один из примеров:
Работа с PDF в LlamaIndex
LlamaIndex предоставляет инструменты для работы с различными форматами данных, включая pdf
. Для этого используется специальный коннектор. Коннектор в контексте llamaIndex — это инструмент, который позволяет интегрировать и обрабатывать данные из различных источников или форматов. Это своего рода адаптер, который обеспечивает совместимость между llamaIndex и внешними данными.
Например SimpleDirectoryReader
, позволяет загружать данные в форматах: .pdf
, .jpg
, .png
, .docx
. Для чтения pdf
надо будет еще дополнительно поставить пакет pypdf
import os from llama_index import SimpleDirectoryReader # Не забываем указать ключ к апи os.environ['OPENAI_API_KEY'] = 'sk-L0xrKrmzb2KufE*' # Создаем объект для работы с PDF reader = SimpleDirectoryReader(input_dir='./pir_samples/') # Загружаем наши документы docs = reader.load_data() print(f'Loaded {len(docs)} docs')
На самом деле я подгрузил только 5 договоров, но некоторые разбились на 2 страницы:
Коннекторов данных в ламаиндекс существует огромное количество, можно подгружать данные из Википедии, Jira, даже из YouTube. Все коннекторы можно поcмотреть здесь.
Разбиение документов на ноды
После того как мы загрузили наши документы в llamaIndex, следующий шаг — это разбиение их на ноды.
Что такое нода?
Нода — это базовая единица информации в llamaIndex. Каждая нода представляет собой фрагмент текста из документа, который может быть использован для ответа на запрос пользователя. Например, если у нас есть договор, то каждый пункт или подраздел этого договора может быть представлен в виде отдельной ноды.
Зачем разбивать документы на ноды?
Разбиение документов на ноды позволяет улучшить качество и точность ответов чат-бота. Вместо того чтобы анализировать весь документ целиком, фреймворк может быстро найти и использовать конкретную ноду, которая наиболее релевантна запросу пользователя.
Разбиение документов на ноды — это ключевой этап в подготовке данных для LlamaIndex. Правильное разбиение может значительно повысить качество ответов чат-бота. Но как определить, какой фрагмент текста стоит сделать нодой? На мой взгляд это искусство :) И разбиение будет очень сильно зависеть от структуры ваших данных, но, тем не менее можно выделить какие-то общие принципы:
- Определить ключевые разделы документа. Обычно в документах есть четко выделенные разделы или подразделы. В контексте договора ПИР, это могут быть пункты, например, такие как «Обязанности Сторон», «Стоимость работ» и т. д.
- Размер ноды имеет значение. Если нода слишком велика, модель может утонуть в избыточной информации и не выделить наиболее релевантный фрагмент в ответ на запрос пользователя, в то время как слишком маленькие ноды могут не содержать достаточно информации для формирования полноценного ответа. Идеальный размер ноды — это фрагмент текста, который полностью и ясно отвечает на конкретный запрос.
- Использовать метаданные. Метаинформация — это дополнительные данные, которые могут быть прикреплены к ноде. Она может включать в себя дату создания документа, автора, заголовок, ключевые слова и многое другое.
Для начала попробуем самое простое деление на ноды:
from llama_index.node_parser import SimpleNodeParser # Cоздаем парсер parser = SimpleNodeParser() # Разбиваем на ноды nodes = parser.get_nodes_from_documents(docs) print(len(nodes))
Теперь мы можем проиндексировать наши документы
from llama_index import GPTVectorStoreIndex # Создаем индекс index = GPTVectorStoreIndex([]) # Индексируем ноды index.insert_nodes(nodes) # Создаем движок запросов engine = index.as_query_engine() # Пробуем задать вопрос response = engine.query('Какова сумма договора с ТатарГеоCтрой?') print(response)
Нам соврали, т. к. в тексте фигурирует другая сумма – 900 тысяч, попробуем задать еще один вопрос:
А здесь уже лучше. Так в чем же причина ошибки? Для этого надо посмотреть на ноды, которые были отправлены для получения ответа модели: первая нода была выбрана правильно, но вот вторая взята из другого договора, поэтому и сумма получилась другой.
Добавляем метаданные в ноды
В примере выше мы увидели, что поисковый движок выбрал ноды из различных договоров, что не совсем корректно отражает наш запрос. Чтобы улучшить результаты, нам стоит доработать механизм так, чтобы он более точно учитывал контекст конкретного договора.
Для решения этой проблемы мы можем задействовать метаинформацию. В этом случае модель сможет более точно фильтровать и выбирать релевантные фрагменты, учитывая контекст и специфику каждого договора. В llamaindex для этой цели есть модуль MetadataExtractor
, в котором реализованы следующие классы:
- TitleExtractor: этот класс предназначен для извлечения заголовков, особенно полезен для длинных документов. Он извлекает поле метаданных
document_title
- KeywordExtractor: извлекает ключевые слова на уровне ноды.
- QuestionsAnsweredExtractor: генерирует вопросы, на которые может ответить данный узел.
- SummaryExtractor: создает резюме ноды, в том числе с возможностью совместного использования соседних узлов.
Для начала попробуем самое простое решение – добавим заголовок документа в ноды:
from llama_index.node_parser.extractors import ( MetadataExtractor, TitleExtractor, ) # Создаем тип сборщика метаинформации metadata_extractor = MetadataExtractor( extractors=[ TitleExtractor(nodes=5) # указываем количество нод с одним title ] ) # Создаем парсер для нод с нужным свойством node_parser = SimpleNodeParser( metadata_extractor=metadata_extractor ) # Получаем ноды nodes_with_meta = node_parser.get_nodes_from_documents(docs) print(nodes_with_meta[0])
Ну а теперь попробуем еще раз задать тот же самый вопрос:
new_index = GPTVectorStoreIndex(nodes_with_meta) new_engine = new_index.as_query_engine() response = new_engine.query('Какова сумма договора с ТатарГеоCтрой?') print(response)
Сработало! Попробуем добавить еще больше метаинформации:
from llama_index.node_parser.extractors import KeywordExtractor metadata_extractor = MetadataExtractor( extractors=[ TitleExtractor(nodes=5), KeywordExtractor(keywords=10) # задаем количество ключевых слов ] ) node_parser = SimpleNodeParser( metadata_extractor=metadata_extractor, ) nodes_with_meta = node_parser.get_nodes_from_documents(docs) print(nodes_with_meta[0].metadata)
Теперь наш узел содержит ключевые слова, что позволит поисковой модели еще лучше оценить релевантность для заданного запроса.
Эту часть можно завершать. В следующей – посмотрим на разные типы ретриверов, а также стратегии их использования.
Спасибо за внимание!
Пишу про AI и NLP в телеграм.