Сегодня it-сообщество предлагает большое количество любопытных инструментов для создания RAG-систем. Среди них особенно выделяются два фреймворка — LangChain и LlamaIndex. Как понять, какой из них подходит лучше для вашего проекта? Давайте разбираться вместе!
Меня зовут София, я сотрудница компании Raft, работаю на стыке backend и ML. Сегодня мы затронем сразу несколько вопросов. План таков:
Обсудим, что такое RAG и зачем он нужен;
Рассмотрим side-by-side, как написать простую реализацию чат-бота на основе RAG с помощью каждого из фреймворков (LangChain и LlamaIndex);
Поговорим о преимуществах и слабых местах каждого из инструментов.
Начнем с того, что освежим в памяти основные понятия. RAG (Retrieval-Augmented Generation) — это метод, позволяющий большим языковым моделям (LLM) улучшать качество ответов за счет использования актуальной информации из внешних источников, таких как базы данных, документы или API. Этот подход снижает вероятность ошибок модели (галлюцинаций) и обеспечивает более точные и контекстуально обоснованные ответы, даже если сама модель обучалась на устаревших данных.
Что у RAG внутри? Концептуально можно выделить несколько ключевых компонентов и этапов, которые обеспечивают работу RAGа:
Ввод пользовательского запроса.
Запрос преобразуется в векторное представление (эмбеддинг), которое математически описывает смысл запроса. Обычно используется предобученная модель для генерации эмбеддингов, например, мы сегодня будем использовать модель от OpenAI text-embedding-ada-002.
Далее вектор запроса сравнивается с хранящимися в базе векторами для поиска наиболее релевантных данных. В качестве баз для хранения часто используют Qdrant, Pinecone или Weaviate.
Из базы данных извлекаются фрагменты текста или документов, которые лучше всего соответствуют запросу. Эти данные формируют контекст для ответа.
LLM получает извлеченный контекст и запрос. Из этих данных она генерирует ответ, который после возвращается пользователю.
Когда дело касается продуктовых решений, важно помнить о вопросах безопасности и оптимизации. Чтобы сделать RAG более продвинутым и эффективным, можно добавить предобработку пользовательского запроса и постобработку ответа модели. Что касается безопасности, я всегда рекомендую при использовании LLM где-либо ознакомиться с топом уязвимостей LLM от OWASP.
Тема с использованием RAG далеко не нова, впервые об этом подходе заговорили еще в 2020 году. За такой короткий срок сфера получила огромный буст в развитии, а также появились и успели себя зарекомендовать инструменты для эффективной разработки RAG-систем.
Одним из таких фреймворков является LangChain. Фреймворк был представлен в 2022 году. Он быстро стал популярным инструментом для специалистов, работающих с языковыми моделями. Большинство разработчиков любят его за гибкость и широким возможностям для интеграций. Забегая вперед, скажу, что LangChain более универсальный инструмент в сравнении с LlamaIndex.
Чтобы соорудить минимальную реализацию RAG с помощью LangChain, мне потребовалось примерно 10 минут и 25 строчек кода на питоне:
from langchain.chains import RetrievalQA
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAI, OpenAIEmbeddings
text_data = [
"Котики — любимые животные Сони.",
"Котики не любят цитрусовые.",
"Котики ласковые, усатые и пугливые."
]
embeddings = OpenAIEmbeddings()
vector_store = FAISS.from_texts(text_data, embeddings)
retriever = vector_store.as_retriever()
qa_chain = RetrievalQA.from_chain_type(
llm=OpenAI(temperature=0),
retriever=retriever
)
query = "Что ты знаешь о котиках?"
response = qa_chain.invoke(query)
print("Ответ:", response["result"])
После выполнения кода получаю вот такой ответ:
Ответ: Я знаю, что котики могут быть ласковыми, усатыми и пугливыми, они являются любимыми животными Сони и не любят цитрусовые.
Другим, не менее популярным решением для написания RAG и чат-ботов является фреймворк LlamaIndex. Впервые фреймворк был представлен в 2022 году под именем GPT Index как инструмент, позволяющий пользователям эффективно индексировать и извлекать информацию из различных источников данных для использования с лингвистическими моделями. В 2023 году инструмент был переименован и здорово подрос благодаря комьюнити.
Чтобы создать тот же самый простейший RAG с помощью LlamaIndex у меня ушло еще меньше времени и строчек кода:
from llama_index.core import SimpleDirectoryReader, GPTVectorStoreIndex, Document
text_data = [
Document(text="Котики — любимые животные Сони."),
Document(text="Котики не любят цитрусовые."),
Document(text="Котики ласковые, усатые и пугливые.")
]
index = GPTVectorStoreIndex.from_documents(text_data)
query_engine = index.as_query_engine()
response = query_engine.query("Что ты знаешь о котиках?")
print("Ответ:", response)
Выполняем код и получаем ответ:
ПримечаниеОтвет: Котики обладают ласковым характером, у них есть усы, и они склонны к пугливости. Котики являются любимыми животными Сони.
Чтобы воспроизвести примеры выше локально, вам понадобится api-ключ OpenAI. Для примера на LangChain его нужно будет передать в качестве параметра внутрь OpenAIEmbeddings(), а для примера на LlamaIndex ключ нужно добавить в переменные окружения.
Либо можно воспользоваться другим поставщиком эмбеддингов на ваше усмотрение.
Чтобы погрузиться в тему и лучше понять, каково работать с каждым из фреймворком, напишем не очень сложного чат-бота (вернее, даже двух). Реализуем одну и ту же функциональность и на LangChain, и на LlamaIndex! В конце статьи оставлю ссылку на репозиторий со всем исходным кодом.
У меня на ноутбуке завалялась классная методичка по инфекционным болезням кошек и собак в формате .pdf
, используем ее в качестве источника данных. Вектора будем хранить в облачной базе Qdrant, а общаться с чат-ботом будем через консоль. Концептуально архитектура приложения будет выглядеть так:
Перед тем, как начинать готовить, нужно убедиться, что в холодильнике есть все ингредиенты. Нам потребуется api-ключ OpenAI, хост и api-ключ от кластера в Qdrant, путь к PDF-файлу, а имя для коллекции можно выдумать любое (дальше мы будем создавать ее прямо из кода). Заполним переменные окружения по шаблону:
OPENAI_API_KEY=
OPENAI_MODEL=gpt-3.5-turbo
QDRANT_HOST=
QDRANT_KEY=
COLLECTION_NAME=
PDF_PATH=
PDF_FOLDER=
VECTOR_SIZE=1536
Начнем с того, что нам нужно подготовить PDF-файл ко всем трансформациям и отправке в хранилище. Для этого нужно разбить большой файл на кусочки поменьше, а также привести к типу данных Document
.
Для LangChain сделаем это с помощью PyPDFLoader
:
Реализация на LangChain
import os
from dotenv import load_dotenv
from langchain.document_loaders import PyPDFLoader
load_dotenv()
class PdfLoader:
"""
A class for loading and processing PDF files using PyPDFLoader.
"""
def __init__(self):
self.file_path = os.getenv("PDF_PATH")
def load_and_process_pdf(self):
"""
Load and process the PDF file.
Uses PyPDFLoader to extract documents from the file.
:return: A list of Documents.
"""
try:
loader = PyPDFLoader(self.file_path)
documents = loader.load()
return documents
except Exception as e:
raise RuntimeError(f"Failed to load and process PDF: {e}")
Для реализации на LlamaIndex я использую SimpleDirectoryReader
. Это самый простой способ загрузить данные из локальных файлов в LlamaIndex. Для производственных задач авторы документации рекомендуют воспользоваться каким-либо из специализированных ридеров, доступных на LlamaHub. Тем не менее, SimpleDirectoryReader
будет достаточно для нашего проекта.
Реализация на LlamaIndex
import os
from dotenv import load_dotenv
from llama_index.core import Document, SimpleDirectoryReader
load_dotenv()
class PdfLoader:
"""
A class for loading and processing PDF files using SimpleDirectoryReader.
"""
def __init__(self):
self.file_path = os.getenv("PDF_FOLDER")
def load_and_process(self):
"""
Load and process the PDF file.
Extracts text from pages and returns a list of Document objects.
:return: A list of Document objects containing the text of PDF pages.
"""
try:
reader = SimpleDirectoryReader(input_dir=self.file_path)
documents = reader.load_data()
return documents
except Exception as e:
raise RuntimeError(f"Failed to load and process PDF: {e}")
Работа с файлами в обоих фреймворках реализована одинаково лаконично.
Далее нам нужно написать логику работы с векторным хранилищем. Потребуются следующие методы: создание коллекции, создание индекса, а также загрузка уже существующего в базе индекса. Можно придерживаться такого сценария:
При запуске бота вызывается метод загрузки существующего индекса.
Внутри метода происходит проверка, существует ли коллекция в векторной базе.
Если коллекция отсутствует в Qdrant, мы создаем ее, а затем вызываем метод по созданию индекса.
Если коллекция уже есть, значит, мы загружаем индекс и используем его.
Я не стану представлять код класса целиком, потому что он несложный, но при этом довольно массивный. Тем не менее, я бы хотела обратить ваше внимание на метод, отвечающий за создание индекса, поскольку именно в нем будет заметно самое любопытное отличие.
Реализация на LangChain
def create_index(self, documents):
"""
Creates an index for the provided documents
in the specified Qdrant collection.
"""
try:
client = self.get_qdrant_client()
logger.info("Uploading documents to the collection...")
vectorstore = Qdrant.from_documents(
documents,
self.embeddings,
url=self.qdrant_host,
api_key=self.qdrant_api_key,
collection_name=self.collection_name
)
logger.info(
f"Index successfully created for collection '{self.collection_name}'."
)
return vectorstore
except Exception as e:
logger.error(f"Error creating index: {e}")
raise
За преобразование документов в векторные представления и их загрузку в Qdrant здесь отвечает метод Qdrant.from_documents()
. Теперь реализуем ту же самую механику, но уже с помощью LlamaIndex:
Реализация на LlamaIndex
def create_index(self, documents):
"""
Creates an index for the provided documents
in the specified Qdrant collection.
"""
try:
client = self.get_qdrant_client()
logger.info("Uploading documents to the collection...")
pipeline = IngestionPipeline(
transformations=[
SentenceSplitter(chunk_size=512, chunk_overlap=0),
TitleExtractor(),
embeddings,
],
vector_store=client,
)
pipeline.run(documents=documents)
index = VectorStoreIndex.from_vector_store(
llm=Settings.llm,
vector_store=vector_store,
embed_model=Settings.embed_model,
)
logger.info(
f"Index successfully created for collection '{self.collection_name}'."
)
return index
except Exception as e:
logger.error(f"Error creating index: {e}")
raise
Здесь обратим внимание на IngestionPipeline
. Внутрь этого объекта мы передаем параметры, в соответствии с которыми будет происходить обработка входных данных. Обработанные узлы сохраняются в векторное хранилище.
Самое сложное позади, осталось собрать детали воедино и написать простейший консольный интерфейс. Приведу чуть более подробную реализацию на LangChain:
Реализация на LangChain
vector_db = VectorDatabase()
pdf_loader = PdfLoader()
model_name = os.getenv("OPENAI_MODEL")
try:
documents = pdf_loader.load_and_process_pdf()
retriever = vector_db.load_index(documents)
except Exception as e:
raise Exception(f"Error occured while loading index: {e}")
llm = ChatOpenAI(
openai_api_key=os.getenv("OPENAI_API_KEY"),
model=model_name,
temperature=0
)
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
retriever=retriever.as_retriever(
search_type="similarity",
search_kwargs={"k": 5, "score_threshold": 0.5}
)
)
logger.info("Chatbot is ready! Enter a query (or type 'e' to quit):")
while True:
query = input("> ")
if query.lower() == "e":
break
try:
response = qa_chain.invoke({"query": query})
logger.info(f"Response: {response['result']}")
except Exception as e:
logger.error(f"Error during query execution: {e}")
Для LlamaIndex код будет практически идентичным, разве что ответ на свой вопрос мы будем получать вот таким образом:
response = index.as_query_engine(similarity_top_k=5, llm=llm).query(query)
Перед самым стартом возникает вопрос, а как же нам понять, что модель взяла данные для ответа именно из нашего документа? Что если модель не нашла информацию и вернулась с ответом, не имеющим отношение к нашим данным?
Есть несколько способов обезопасить себя от такой ситуации, но самый лаконичный из них — установка трешхолда. Например, в LangChain мы делаем это при настройке ретривера "score_threshold": 0.5
Помните, я рассказывала, что в качестве источника данных использую справочник инфекционных болезней кошек? Я придумала хороший вопрос для того, чтобы достоверно определить даже без дополнительных проверок, что модель генерирует ответ на основе документа. Если мы спросим бота, у которого нет доступа к этой методичке, о том, что такое конституция, то он ответит нам, что это документ.
Если же ответ сформулирован на основе методички, то чат-бот ответит, что это совокупность анатомо-морфологических особенностей организма. При этом, если в процессе векторного поиска не удастся найти достаточного совпадения, чат-бот честно ответит, что он не знает ответа на вопрос. Например, в методичке нет информации о том, что такое кефир:
И чат-бот на LangChain, и чат-бот на LlamaIndex одинаково хорошо справляются с вопросами. Можно дополнительно управлять ответом модели, например, с помощью промпта, чтобы чат-бот при ответе придерживался определенного стиля речи, был лаконичнее или использовал конкретные выражения в случае, если ответ не был найден.
Агентные системы всё еще вызывают интерес. Определим, что AI-агент — это программная сущность, которая использует методы ИИ для анализа среды, принятия решений и выполнения действий. Агент действует автономно, адаптируется к изменяющимся условиям и может взаимодействовать с другими агентами или пользователями.
Оба фреймворка предоставляют инструментарий для создания агентов, и мне также хочется его опробовать. Иногда нам может не хватать однообразного сценария работы чат-бота для решения всех наших проблем. Зачастую запросы могут быть составными или содержать в себе необходимость обратиться к дополнительным источникам.
Допустим, у нас в методичке может содержаться формула, но если мы попросим классического чат-бота на основе RAG произвести по ней расчеты, то бот нам здесь помочь не сумеет. В такой ситуации можно построить агентный RAG, это позволит и получать информацию из специфического источника, и выполнять какие-либо дополнительные действия. Например, подсчеты или походы в другие сервисы.
В рамках нашего примера сделаем агента, который будет выполнять две глобальные задачи: либо он найдет для вас информацию об инфекционных болезнях в методичке, либо поможет понять, есть у вашего котика проблемы с лишним весом. Чтобы наш чат-бот смог решать вторую задачу, нужно подготовить почву. Напишем очень простую функцию:
def is_cat_obese(weight_kg: float) -> str:
"""
Determines the weight status of a cat (normal, underweight, or obese).
:param weight_kg: Weight of the cat in kilograms.
:return: Message describing the cat's weight status.
"""
if float(weight_kg) < 3.5:
return f"Кошка с весом {weight_kg} кг имеет недостаточный вес."
elif 3.5 <= float(weight_kg) <= 5.5:
return f"Кошка с весом {weight_kg} кг находится в пределах нормы."
else:
return f"Кошка с весом {weight_kg} кг имеет избыточный вес."
Теперь нам нужно настроить Tools
для нашего агента. Agent Tools — это функциональные компоненты, которые предоставляют AI-агенту доступ к дополнительным возможностям или ресурсам для выполнения задач. В нашем случае будет два компонента: поиск по документу и определение степени упитанности кошки.
В LangChain метод, создающий набор инструментов, будет выглядеть так:
Реализация на LangChain
def get_tools(qa_chain: RetrievalQA) -> list:
"""
Creates a list of tools for the agent.
:param qa_chain: The RetrievalQA chain for document retrieval.
:return: List of tools.
"""
return [
Tool(
name="Document Retrieval",
func=lambda q: qa_chain({"query": q})["result"],
description="Retrieves info about cat and dog diseases from document."
),
Tool(
name="Cat Weight Tool",
func=is_cat_obese,
description="Determines if a cat's weight is within a healthy range."
)
]
Реализация с помощью LlamaIndex не будет сильно отличаться:
Реализация на LlamaIndex
def get_tools(index) -> list:
"""
Creates a list of tools for the agent.
:param index: LlamaIndex instance for document retrieval.
:return: List of tools.
"""
agent_tools = [
QueryEngineTool(
query_engine=index,
metadata=ToolMetadata(
name="Document",
description="Retrieves info about cat and dog diseases from document.",
),
),
FunctionTool.from_defaults(
fn=is_cat_obese, name="Weight"
)
]
return agent_tools
Далее нам потребуется промпт. Задание системного промпта является опциональным, но почему бы не напомнить лишний раз модели о том, какой ответ нам бы хотелось видеть? Не будем изобретать велосипед и напишем простые инструкции:
def get_agent_instructions() -> str:
"""
Provides instructions for the agent to always respond in Russian.
:return: Instruction string.
"""
return (
"Ты умный и добрый ассистент, который ВСЕГДА отвечает на русском языке."
"Если пользователь задаёт вопрос, дай ответ на русском."
"Верни пользователю ответы на все заданные им вопросы."
"Если в вопросе есть вес кошки, передавай на обработку ТОЛЬКО число."
)
Осталось только собрать всё воедино! Вот так это будет смотреться для LangChain:
Реализация на LangChain
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
retriever=retriever.as_retriever(
search_type="similarity",
search_kwargs={"k": 5, "score_threshold": 0.5}
),
return_source_documents=True
)
tools = get_tools(qa_chain)
instructions = get_agent_instructions()
agent = initialize_agent(
tools=tools,
llm=llm,
agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
verbose=True
)
query_with_instructions = f"{instructions}\n{query}"
response = agent.run(query_with_instructions)
И вот так эта же самая функциональность будет выглядеть на другом фреймворке:
Реализация на LlamaIndex
query_engine = index.as_query_engine(llm=llm, similarity_top_k=5)
tools = get_tools(query_engine)
instructions = get_agent_instructions()
agent = ReActAgent.from_tools(
tools,
llm=llm,
verbose=True,
system_prompt=instructions
)
response = agent.chat(query)
Теперь запускаем наш код и видим, что теперь чат-бот не только расскажет нам, что такое конституция, но и поведает о том, что кошка весом 5 кг находится в отличной форме:
При этом мы все также можем задавать вопросы, задействующие только один из инструментов. А если мы в рамках данной реализации поручим чат-боту задачу за пределами предусмотренной функциональности (например, попросим сделать двойное сальто), то агент сначала отправится искать информацию в документе, не найдет ничего релевантного и ответит, что не сможет нам помочь в этом вопросе. Сделать процесс более элегантным можно путем предобработки запроса. Например, перед поиском ответа можно проводить классификацию.
Мне кажется, что для создания несложных систем можно использовать любой из фреймворков. Они одинаково дружелюбны. Муки выбора начинаются там, где есть больше специфических деталей.
Одним из важных критериев выбора технологий лично для меня является количество информации об инструменте и полнота документации. Поскольку LangChain более популярный фреймворк, найти решение почти любой проблемы, связанной с ним, на форумах или в документации намного проще.
Тем не менее, и в том, и в другом фреймворке я заметила черту сходства: относительно быстрый цикл устаревания. Поскольку оба фреймворка появились не так давно и продолжают активно развиваться, в них часто вносятся заметные изменения, которые могут дестабилизировать или даже ломать уже существующий код. Очевидным решением здесь будет жесткая фиксация версий пакетов, но если проектов на LangChain или LlamaIndex у вас много, будьте готовы к тому, что в голове придется удерживать информацию об изменениях.
В LlamaIndex действительно заметно, что разработчики делают акцент на инструментах для работы с данными. Зачастую, когда мы хотим построить базу знаний, информация раскидана по разным источникам и даже может храниться в разных форматах. LlamaIndex хорошо справится с тем, чтобы привести все данные в нужный для дальнейшей обработки языковой моделью вид.
LangChain больше похож на конструктор. С ним несколько проще подружить другие инструменты разработки, но LangChain определенно проигрывает по скорости. Пока я исследовала предметную область, я неоднократно натыкалась на размышления о том, что LangChain не очень удобен для продакшна из-за медлительности на больших объемах данных и частоты вносимых во фреймворк изменений. Тем не менее, он все еще является одним из самых популярных решений. Если инструментом широко пользуются, значит, он достаточно хорошо справляется с задачами.
Функция | LangChain | LlamaIndex |
---|---|---|
Основная функция | Создание AI-приложений из базовых компонентов с возможностью объединения различных LLM и инструментов | Обработка больших объемов данных и управление графами знаний |
Работа с данными | Предоставляет инструменты для связывания промптов, управления памятью и взаимодействия с внешними инструментами и API | Сильный акцент на подключении LLM к различным структурированным и неструктурированным источникам данных (базы данных, API, документы) |
Примеры использования | Подходит для динамических процессов, включающих агентов LLM, работу с разными инструментами | Подходит для создания приложений, которые требуют графов знаний, структурированных запросов и индексирования документов |
Простота использования | Универсальный, но может потребовать более высокого уровня знаний для разработки сложных систем | Высокая специализация для работы с данными и индексирования; простой подход для процессов, ориентированных на данные |
Поддерживаемые языки разработки | Python, JavaScript | Python, TypeScript |
Комьюнити и распространенность | Более крупное сообщество и бóльшая распространенность | Растущее open-source сообщество, но меньшее по сравнению с LangChain |
Производительность | Оптимизирован для связывания множества действий и задач LLM | Оптимизирован для загрузки данных в больших масштабах |
Мы все это время сравнивали фреймворки между собой, но ни разу не упомянули, что их можно использовать в связке. Несмотря на то, что оба инструмента хорошо справляются по отдельности, можно скомбинировать их сильные стороны для лучшего перформанса.
Например, в кейсе с созданием чат-бота, который дает ответы на основе документов, функциональность, связанную с обработкой данных можно поручить LlamaIndex, а цепочку взаимодействий создать с помощью LangChain, но это уже совсем другая история...
А какой из инструментов предпочитаете использовать вы? Делитесь в комментариях, мне будет очень интересно почитать.
Спасибо, что прогулялись со мной по этой теме! Код для обоих чат-ботов и методичку, как и обещала, можно найти вот здесь.