RAG с LangChain с нуля: загрузчики, части документов, эмбеддинги, оценка
Постройте систему вопросов и ответов для документов пошагово: загрузка файлов, разбиение на части, эмбеддинги, этапы индексации и поиска, а также способы оценки качества найденных ответов.
Источники: Building a RAG System with LangChain
Система, которая читает ваши документы и отвечает на вопросы о них, — одна из самых полезных вещей, которые вы можете создать с помощью LLM сегодня. Этот подход называется RAG — retrieval-augmented generation. Здесь описаны все составляющие, их назначение, а также способы определения качества вашей разработки.
Что такое LangChain
LangChain — это не модель и не база данных. Это фреймворк, ближе к универсальному адаптеру. Существует десятки поставщиков LLM с собственными API, десятки векторных баз данных и десятки инструментов для загрузки документов. Без промежуточного слоя использование GPT-4 сегодня и Claude завтра означает необходимость переписывания половины кода. LangChain предоставляет единый интерфейс для всех этих инструментов.
Название происходит от его второй функции. Настоящее приложение на основе LLM редко представляет собой просто один вызов модели. Возможно, необходимо загрузить документ, подвести его краткое резюме, применить к этому резюме ещё один запрос и сформатировать результат. Цепочка соединяет эти шаги в единую последовательность. В случае технологии RAG цепочка начинается с вопроса пользователя: он преобразуется в векторное представление, которое используется для поиска в базе данных, а лучшие результаты вставляются в шаблон запроса. Затем вызывается модель LLM с этим запросом, её ответ анализируется, а полученный результат возвращается пользователю. LangChain предоставляет элементы для каждого шага, поэтому не нужно всё собирать вручную.
Загрузка документов
Сначала документы должны быть загружены, причем не в виде сырых байтов. Независимо от того, являются ли данные PDF, веб-страницей, файлом CSV, документом Word или папкой с файлами в формате Markdown, загрузчик преобразует их в стандартный объект Document с двумя полями: page_content для текста и metadata с информацией о его происхождении (страница, URL и любые полезные данные). Метаданные важны позже, поскольку они позволяют показать пользователям, из какого источника взят ответ.
from langchain.document_loaders import PyPDFLoader
loader = PyPDFLoader("handbook.pdf")
documents = loader.load()
Это приводит к тому, что на каждую страницу PDF формируется по одному Document, в котором содержатся текст и номер страницы. Именно эту привычку стоит выработать с самого начала: всегда сохранять информацию о происхождении текста вместе с ним, поскольку каждый последующий шаг — от разбиения на фрагменты до указания источника в окончательном ответе — зависит от этой информации. Если заменить загрузчик на другой тип файла, формат вывода останется прежним.
Разбиение на фрагменты
Документ может состоять из 200 страниц PDF. Даже при большом окне контекста вставка целых книг в каждый запрос является расточительным, дорогостоящим и зачастую хуже, чем предоставление модели сфокусированных фрагментов текста. Длинные документы разделяются на фрагменты по несколько сотен токенов, и только самые релевантные из них передаются в LLM при задании вопроса.
Самые важные настройки — это размер фрагментов и степень перекрытия. Размер фрагментов определяет длину каждой части: если он слишком мал, теряется контекст, а если слишком велик, включается нерелевантный текст. Хорошей отправной точкой будет примерно 500 токенов. Степень перекрытия указывает на то, насколько соседние фрагменты совпадают. При отсутствии перекрытия предложение, разделенное между двумя фрагментами, теряет свой смысл, тогда как при наличии перекрытия оба фрагмента учитывают связь между ними.
RecursiveCharacterTextSplitter — это стандартный выбор. Он предпочитает естественные границы, сначала пытаясь разделить текст на абзацы, затем на предложения, а потом на слова, и только в крайнем случае делит текст посередине слова. Существуют также специализированные варианты: MarkdownHeaderTextSplitter для документов с заголовками и TokenTextSplitter при необходимости учета реальных токенов, а не просто символов.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50
)
chunks = splitter.split_documents(documents)
Разбиение текста на фрагменты кажется простым делом, но оно сильно влияет на качество. Это также параметр, который люди настраивают в последнюю очередь, хотя неправильное разделение тихо снижает качество всей последующей обработки. Если фрагменты выбраны неверно, никакие трюки с поиском не смогут спасти систему.
Эмбеддинги
Компьютеры не могут задать вопрос «какой фрагмент похож на мой запрос?» к сырому тексту. Модель эмбеддингов, также называемая кодировщиком, преобразует текст в вектор — список чисел; ключевая особенность заключается в том, что тексты с похожим значением получают похожие векторы.
Представьте, что каждому тексту присваивается адрес в пространстве с сотнями или тысячами измерений. Лающая собака и щенок, лающий от боли, получают соседние адреса, в то время как крах фондового рынка находится где-то далеко. Сравнение значений превращается в геометрию: насколько близки две точки?
Два уточнения помогут. Во-первых, векторы создаёт кодер, а не база данных векторов. Chroma, Pinecone, Weaviate и Qdrant лишь хранят векторы и быстро их ищут; ни одна из них не понимает языка. Люди постоянно путают это. Во-вторых, размер вектора зависит от кодера: модель OpenAI text-embedding-3-small генерирует векторы с 1536 измерениями, тогда как open-source-модель вроде all-MiniLM-L6-v2 — с 384. Большее количество измерений позволяет улавливать больше нюансов, но это влечёт за собой необходимость большего объёма хранения и более медленные поисковые операции.
Настоящие вкладки невозможно визуализировать, поэтому люди проецируют их в 2D или 3D. Если вкладки множества слов проецировать, появляются кластеры: животные вместе, фрукты в другом месте, транспортные средства в ещё одном углу. Модели никто не рассказывал о таких категориях; она сама научилась на огромном количестве текста тому, что слова «собака» и «кошка» встречаются в похожих контекстах.
Именно это позволяет RAG функционировать. Если пользователь спрашивает, что говорит компания о отпуске по уходу за ребенком, вектор вопроса оказывается рядом с фрагментами текста, касающимися «льгот по семейному отпуску» или «отпуска для новых родителей», хотя эти термины различаются.
Процесс обработки
Существуют два этапа. Индексация происходит один раз при настройке: загружаются данные, они делятся на чанки, каждый чанк встраивается, а векторы сохраняются. Новые документы позже просто встраиваются и добавляются. Поиск выполняется при каждом запросе: вопрос также встраивается с помощью того же кодера, находятся ближайшие чанки в базе векторов, они помещаются в запрос вместе с вопросом и отправляются в LLM, который формирует ответ на основе ваших документов, а не на основе догадок. Одна база векторов обслуживает оба этапа: на неё записываются данные во время индексации, а читаются — во время поиска. Вот вся архитектура. Поскольку индексация — это единоразовые затраты, медленный процесс (встраивание тысяч чанков) не влияет на время ожидания пользователя. Время ожидания зависит только от встраивания запроса, поиска и вызова LLM.
Во время выполнения участвуют четыре компонента: пользователь, код вашего приложения, механизм поиска информации и LLM. Пользователь отправляет вопрос вашему коду. Ваш код запрашивает у механизма поиска соответствующие фрагменты информации, после чего механизм поиска включает вопрос в запрос и проводит поиск в базе данных. Возвращаются K наиболее похожих фрагментов. Ваш код формирует промпт на основе этих фрагментов вместе с первоначальным вопросом и отправляет его LLM, который генерирует ответ на основе предоставленного контекста. Ваш код возвращает этот ответ.
LangChain покрывает большую часть этого процесса с помощью двух абстракций. LLM (или ChatModel) оборачивает вашу модель, а Retriever — процесс поиска в векторном хранилище. Соединив их между собой, фреймворк берет на себя остальное:
from langchain.chains import RetrievalQA
qa_chain = RetrievalQA.from_chain_type(
llm=ChatOpenAI(),
retriever=vectorstore.as_retriever(),
return_source_documents=True
)
response = qa_chain.invoke({"query": "What is our parental leave policy?"})
Это рабочая система RAG, реализованная всего в нескольких строках кода, и именно здесь возникает соблазн остановиться. Не делайте этого. Система, которая отвечает, ещё не является системой, которой можно доверять.
Почему нужна оценка
Ловушка заключается в том, чтобы задать один вопрос, получить правдоподобный ответ и считать, что всё готово. Система RAG может давать ошибки незаметным образом: механизм поиска может выбрать неверные фрагменты информации, большая языковая модель может генерировать ложные ответы даже при наличии хорошего контекста, а разбиение текста на фрагменты может разделить ответ пополам. Оценка помогает определить, в чём проблема: при поиске информации, при генерации ответа или в обоих аспектах, поскольку для каждого из них требуется разное решение. Кроме того, она служит критерием для сравнения, поскольку вы будете пробовать разные размеры фрагментов, кодировщики и подсказки, и без измерений каждая изменение будет лишь догадкой.
Показатели эффективности поиска
Четыре стандартных показателя, каждый из которых сообщает о чём-то своём:
- Recall@K: из документов, которые должны были появиться, сколько из них оказалось в топ-K? При наличии 4 релевантных документов в корпусе, из которых 3 находятся в топ-5, значение Recall@5 составляет 0.75. Высокий показатель реколла значит, что мало чего пропущено.
- Precision@K: из K возвращенных результатов, сколько из них были релевантными? Три подходящих результата из пяти дают значение 0.60. Высокая точность означает мало шума среди результатов.
- MRR (Среднее обратное рангирование): учитывается только позиция первого правильного результата. Первый найденный результат на позиции 2 получает оценку 0.50, а на позиции 1 — 1.0. Этот показатель поощряет быстрое нахождение правильного ответа.
- nDCG (Нормализованный дисконтированный кумулятивный прирост): самый точный показатель. Он учитывает степень релевантности каждого документа и его позицию, поэтому более высокорангированные релевантные элементы имеют больший вес; кроме того, он сравнивает результаты с идеальным возможным рангированием.
На практике используются два пути поиска: один для оценки полноты (Recall), а другой — для оценки качества порядка результатов (MRR или nDCG).
Качество ответа
Даже идеальный поиск может привести к плохому ответу, поэтому оцениваются ответы отдельно. Начинается с того же отобранного набора тестов, где каждая запись соединяет вопрос с фрагментами текста, которые должны быть возвращены, и эталонным ответом. Четыре вышеупомянутых показателя поиска охватывают первую часть оценки. Что касается самих ответов, то их оценивают по другим критериям:
- Адекватность: основан ли ответ на найденном контексте или является выдумкой?
- Релевантность: отвечает ли ответ на вопрос?
- Правильность: как ответ сравнивается с истинным решением?
- Полнота: охватывает ли ответ всё необходимое?
Обычный метод заключается в использовании второго LLM в качестве судьи. Предоставляются ему вопрос, ответ и истинное решение, после чего просится оценка по каждому критерию. Этот метод не идеален, но позволяет масштабироваться.
Набор тестов
Каждый из вышеописанных шагов оценки основан на одном элементе: наборе вопросов с известными правильными ответами.
Это наиболее недооцененный аспект. Без него невозможна оценка чего-либо. Старайтесь собрать от 50 до нескольких сотен примеров, каждый из которых включает реалистичный вопрос, фрагменты с ответом и идеальный результат. Источниками могут служить реальные запросы пользователей, если система работает в режиме онлайн, или синтетические данные: подавайте свои документы в LLM, пусть он сгенерирует пары вопросов и ответов, затем проверьте и отредактируйте их.
Храните этот набор строго. Как только он существует, любые изменения оцениваются по его стандартам, и именно так вы узнаете, помогли ли изменения или нет.
Где возникают сложности
По сути, RAG представляет собой следующий процесс: документы разбиваются на фрагменты, каждый фрагмент векторизуется, затем они хранятся; при этом учитываются запросы пользователей, находятся наиболее близкие векторы, и эти фрагменты передаются в ЯИ-модель. LangChain предоставляет стандартные компоненты для каждого этапа, а результаты оценки показывают эффективность данного подхода. Однако реальный продукт сталкивается с более сложными вопросами: какой размер фрагментов оптимален для документов, какой кодер лучше всего подходит, как обновлять индекс, когда проводить переранжирование, следует ли использовать гибридный поиск. Именно эти решения отличают простую демонстрацию от полезного инструмента, но они основаны на вышеописанных принципах.