Назад
5346

RAG — от первой версии к рабочему решению

5346

Введение

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

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

Например, вы добавили чат-бота на сайт, и пользователь спросил в окне диалога:

«Есть ли у вас скидки?»

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

Пользователь задал вопрос: «Есть ли у вас скидки?». В базе есть информация: «Скидка на всё -20% для студентов». Ответь пользователю.

В итоге для реализации RAG нам необходимо реализовать поиск (Retriever), добавить найденные данные (Augmentation) и сгенерировать моделью ответ (Generation) на основе данных — отсюда и название Retrieval Augmented Generation.

Теперь давайте разберём архитектуру RAG подробнее.

Элементы RAG

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

4 месяца
Large Language Models

Для тех, кто знаком с DL и Pytorch: научитесь использовать LLM в приложениях: обучать, деплоить, ускорять и многое другое на нашем курсе

3 месяца
LLM Pro

Уже профессионально работаете с LLM? Соберите полноценные LLM-системы с учётом требований к качеству и нагрузке, разберите сложные кейсы и дизайны NLP-решений у нас на курсе

0/0
Рисунок 1. Схема работы RAG

Выше упрощённая, но рабочая схема, состоящая из элементов:

  • Чистка и форматирование — убираем лишний текст, добавляем недостающий, правим формат.
  • Чанкирование — разбиваем текст на удобные фрагменты (чанки).
  • Эмбедер — отображает чанки в векторы (эмбеддинги) для оценки схожести двух текстов не по словам, а через сравнение их векторов (например, с помощью оценки косинусного расстояния).
  • Векторная база данных — хранит эмбеддинги (опять же, предполагаем, что пока используем только векторный поиск) и соответствующие им чанки.
  • Ретривер (поиск) — ищет нужные чанки по вопросу пользователя.
  • Реранкер (ранжирование) — упорядочивает найденные чанки по релевантности и отбрасывает лишние.
  • LLM — генерирует ответ, используя найденную информацию.
  • Бенчмарки и тесты — проверяют, насколько хорошо работает система.

На первый взгляд кажется просто, но на практике первая версия RAG никогда (!) не работает так, как ожидают. Потому что есть много мест, где можно ошибиться:

  • использовали плохой эмбеддер (модель, которая отображает текст в вектор), чтобы быстрее искать похожие по смыслу тексты, сравнивая векторы, а не слова (на самом деле не всегда применяется именно векторный поиск, но пока предполагаем именно его);
  • неверно сформулировали поисковый запрос, в таком случае использовать текст пользователя в качестве запроса — приемлемое решение, но можно улучшить результаты поиска, переформулировав вопрос;
  • неудачно разбили данные на кусочки (чанки), значит, слишком маленькие чанки содержат недостаточно информации, а слишком большие — лишнюю;
  • подали данные в модель без предобработки, не выбросив лишнее и не расставив приоритеты между чанками;
  • не дообучили LLM, чтобы модель больше знала о нужном домене и отвечала в нужном стиле.

Получается, чтобы RAG хорошо работал, его нужно тщательно настроить. Ниже мы разберёмся, что именно надо делать.

Бенчмарки и тесты

Начнём мы с последнего пункта! Потому что после каждого изменения необходимо оценить, стала ли система работать лучше. Проверять руками — долго и необъективно, поэтому начинаем с подготовки тестов.

Для этого нужно:

  • сформировать набор вопросов и правильных ответов (Ground Truth), похожих на настоящие сценарии использования;
  • выбрать метрики для оценки качества, например, насколько сгенерированный ответ корректен (аналог точности и насколько ответ достаточен, не упустила ли модель какой-то факт (аналог полноты;
  • применить LLM, которая по вопросу, идеальному ответу и заданному критерию оценит ответ RAG-системы по шкале от 0 до 1;
  • усреднить результат и записать его в отдельный файл вместе с кратким описанием улучшений.

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

В стратегии выше мы оцениваем всю систему end-to-end, но чтобы понять, в чём именно проблема (в генерации или поиске), можно добавить следующие метрики:

  • context relevance — по вопросу и найденным чанкам проверяем, что нашли что-то нужное;
  • context recall — проверяем, что нашли всё для ответа на вопрос;
  • faithfullness — проверяем, что LLM генерирует ответ, основываясь на данных в промпте, а не выдумывает факты.

Если ваши ответы уходят внешним пользователям — не лишним будет добавить «Этичность».

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

Чистка и форматирование

Чистота данных напрямую влияет на результаты поиска по базе и на качество генерации ответа. В машинном обучении чистые данные — обязательное условие успеха. RAG-системы — не исключение.

Но если вы собираете первую версию — не стыдитесь пропустить этот пункт 🙂

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

Что нужно сделать в первую очередь:

  • удалить неинформативные элементы: навигационные панели, меню, рекламные вставки; например, если вы парсите статьи с сайта, убирайте всё, кроме заголовка и текста;
  • исправить орфографические ошибки: используйте инструменты по типу LanguageTool или Yandex Speller для автоматической корректировки орфографических ошибок;
  • стандартизировать текст: приводите данные к одному формату (например, удалите лишние пробелы, унифицируйте даты и числовые форматы). Если где-то написано «1 декабря 2010 года», а где-то — «01.12.10», лучше исправить это на единый формат.

Если вы уже сделали пункты выше, но качество поиска и генерации ответов вас не удовлетворило, можно попробовать следующее:

  • добавить связи между документами для большего контекста в промпт. Например, если у вас система поиска по задачам в трекере — можно прилинковать описание проекта;
  • добавить выделенный список ключевых слов к чанку: это поможет при гибридном поиске (поговорим об этом чуть позже).

Чанкирование

Чанкирование — первый этап, на котором текст разбивают на подходящие фрагменты (чанки).

Разбить текст можно тремя алгоритмами:

  • По длине чанка — самый простой и популярный вариант. При нём мы рискуем неудачно обрезать важную информацию: половина будет в одном чанке, половина — в другом. При этом во время поиска один из полезных чанков может не извлечься, и ответ LLM будет неправильным.
  • По структуре документа — тоже простой алгоритм, немного снижающий вероятность разбить важную информацию на несколько чанков, но не всегда применимый из-за отсутствия структуры.
  • По смыслу — более сложный вариант, требующий LLM и/или ручную разметку. Применим, когда есть необходимость, деньги и время улучшать качество.
Рисунок 2. Варианты чанкирования

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

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

Если результаты вас не устраивают — переходите сначала к разбиению по структуре, а затем по смыслу. Есть ещё средний вариант: разбив текст по символам, посчитайте для чанков эмбеддинги. Соедините соседние чанки в один, если расстояние между эмбеддингами выше порога (который надо будет подобрать).

При разбиении по смыслу не подключайте сразу людей, а начните разделять текст на чанки другой LLM, и уже последний шаг — разметка людьми. Есть краудсорс-платформы, например, Яндекс.Задания, позволяющие создавать задания и платить небольшие деньги за выполнение разметки данных. Но рекомендуем прибегать к краудсорсу только в крайнем случае. Это отдельная область знаний, в которой легко потерять деньги, оставшись без результата.

Эмбеддер

Ликбез для менее опытных читателей

Тут стоит объяснить, как текст превращается в вектор. Сначала его надо представить в виде токенов, а потом каждый токен отобразить в вектор. После можно усреднить векторы токенов текста и получить эмбеддинг этого текста. Ниже представим чуть подробнее.

Токенайзер

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

  • В корпусе текстов, который берут для «обучения», выделяют самые популярные последовательности символов, например, «-ing» в английском или «-ый» в русском, таким образом, чтобы ими можно было представить весь текст. Это и будет токен. При этом токеном может быть целое слово или один символ, просто редкий.
  • Каждому токену присваивают порядковый номер.
  • Текст представляют в виде последовательности номеров токенов, например, «building» → разбиваем на токены «build-» + «-ing» → выписываем их порядковые номера [1432, 11].
  • А затем используют эмбеддер, чтобы отобразить каждый токен в эмбеддинг (вектор фиксированного размера, например, 384 числа) — то есть слово «building» уже из последовательности чисел [1432, 11] превратится в последовательность векторов [<вектор 1>, <вектор 2>].

Для простоты представляйте, что эмбеддер — это таблица, которая хранит самый подходящий вектор для каждого токена. Самый подходящий — значит тот, что лучше всего отражает его суть относительно других токенов. Например, чтобы вектор для токена «build» был похож на «create», но не похож на вектор токена «banana».

Как правило, токенизатор уже встроен в библиотеку, из которой вы берёте модель. Для моделей GPT (например, GPT-3.5, GPT-4) — это токенизатор tiktoken. И поэтому отдельно обучать или настраивать токенизатор не нужно.

Эмбеддер

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

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

Но как получить эмбеддинг не для одного токена, а для всего текста?

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

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

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

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

  • BGE-M3 — популярная многозадачная модель для retrieval и rerank-задач, которая поддерживает несколько языков;
  • text-embedding-3-large — решение от компании OpenAI;
  • FRIDA — модель, хорошо работающая на русском языке.

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

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

В таких случаях можно адаптировать эмбеддер под домен. Это можно сделать через дополнительное обучение (continual pre-training) или контрастивное обучение (contrastive learning). Но вряд ли вам это понадобится. Понять, что эмбеддер всё-таки надо дообучать, можно по нескольким причинам:

  • много нерелевантных чанков в выдаче при поиске;
  • пользователи жалуются, что бот «не понял» вопрос или дал неполный ответ;
  • метрики качества не растут при улучшении других компонентов (чанкирования, чистки).

База данных

Большая часть RAG-систем построена на векторном поиске, поэтому пока мы предполагаем, что используем именно её.

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

Если чанков мало (например, пара тысяч), можно обойтись локальным решением (in-memory списком или FAISS), потому что можно «в лоб» сравнить вектор запроса со всеми векторами.

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

Для старта и экспериментов можно хранить векторы как матрицу в numpy.ndarray, умножать её на вектор запроса и находить самый близкий результат:

import numpy as np

# Матрица эмбеддингов документов (shape: num_docs x dim)
emb_matrix = np.array([
    # Пример: три документа, каждый с эмбеддингом размерности 4
    [0.1, 0.2, 0.3, 0.4],
    [0.0, 0.5, 0.2, 0.1],
    [0.3, 0.1, 0.0, 0.6]
])

# Эмбеддинг запроса (shape: dim)
query_vector = np.array([0.2, 0.1, 0.3, 0.4])

# Нормализация (если используем косинусное сходство)
emb_matrix_norm = emb_matrix / np.linalg.norm(emb_matrix, axis=1, keepdims=True)
query_vector_norm = query_vector / np.linalg.norm(query_vector)

# Считаем скалярное произведение (косинусное сходство)
scores = emb_matrix_norm @ query_vector_norm

# Находим индекс самого близкого документа
best_idx = np.argmax(scores)

print("Индекс наиболее близкого документа:", best_idx)
print("Схожесть:", scores[best_idx])

Другой вариант — использовать FAISS, в котором можно делать как и полный перебор подобно алгоритму выше, так и более быстрый поиск за счёт предварительной работы с векторами: построения дерева или кластеризации.

Помимо поиска по векторам часто применяют гибридный поиск: векторный + текстовый. Например, когда база большая, можно выбрать первую группу кандидатов текстовым поиском, а потом отбросить менее релевантные векторным. Также текстовый поиск необходим, когда надо учитывать редкие термины или точные ключевые слова. При этом текстовый поиск — отдельная объёмная тема на несколько статей. Но если надо с чего-то начать, хорошая точка входа — алгоритм BM25.

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

  • Qdrant — российский open-source проект, хорошо работает для гибридных сценариев;
  • Pinecone — облачный сервис, не нужно настраивать серверы;
  • Weaviate — поддерживает гибридный поиск, можно запускать у себя;
  • Milvus — популярное open-source решение, подходит для больших нагрузок.

Ретривер

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

Что надо сделать на этом шаге:

  • Переформулировать запрос: пользователи пишут в свободной форме, и потому запрос может быть расплывчатым, содержать ошибки, лишние слова или не подходить под формат данных (храним инструкции, а запрос не похож на текст инструкций). Поэтому зачастую запрос лучше переформулировать. Можно сделать это с помощью LLM, написав промпт: «Переформулируй запрос в форме короткого заголовка справочной статьи, чтобы по нему можно было найти документ в базе знаний». В этом пункте большой простор для экспериментов. Главное помните, что запрос должен быть похож на текст в чанках.
  • Прогнать запрос через эмбеддер: тут всё просто, но стоит прочитать документацию к эмбеддеру. Например, e5-large-instruct требует описания задачи, иначе работает хуже. А FRIDA — правильных префиксов.
  • Найти ближайшие векторы в базе: обычно хватает 3-10 кандидатов, но количество зависит от домена и задачи. Иногда уместно варьировать количество кандидатов в зависимости от запроса.
  • Дополнительно обработать результат: например, можно удалить дубликаты или слишком короткие чанки.

Ретривер можно назвать самой главной частью RAG-системы. Именно от качества поиска будет сильно зависеть ответ модели. Поэтому в первую очередь рекомендуем настроить именно этот этап и после браться за остальные.

Реранкер

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

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

Фильтрацию по смыслу с ранжированием и делает реранкер. И да, кажется, должно хватить информации от ретривера: мы же знаем, насколько каждый чанк близок в векторном пространстве к запросу — почему бы не отфильтровать и не упорядочить чанки по косинусному расстоянию? Ответ: просто с реранкером работает лучше 🙂

В первой версии RAG-системы можно обойтись и без реранкера — просто используйте топ-N кандидатов, отсортированных по сходству эмбеддингов и отсекайте кандидатов по порогу.

Если вы хотите повысить качество — можно добавить реранкер на основе LLM или даже специальную модель-классификатор. В обоих случаях надо подать на вход пару «запрос + чанк» и получить оценку релевантности (например, от 0 до 1).

Модель для ранжированияПлюсыМинусы
LLMлегко использовать, просто написать промпт: «Оцени, насколько текст ниже отвечает на запрос. Верни число от 0 до 1.»дольше работает и дороже, чем отдельная модель-классификатор
Классификатордешевле, чем LLMнадо научиться использовать, зато один раз и навсегда

Существуют публичные модели реранкеров. Например:

  • bge-reranker — серия моделей от BAAI (есть версии для разных языков, в том числе мультиязычные). Они выложены в открытый доступ, например, на Hugging Face.
  • colbert — модель, где поиск и ранжирование делаются с учётом токенов внутри чанков, а не только глобального эмбеддинга. Более продвинутый вариант, но требует настройки.

Более продвинутый вариант — дообучить публичную модель на собственных данных. Обычно это делается как pairwise learning:

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

Для обучения можно использовать open-source библиотеки (например, Sentence-Transformers), но на старте это избыточно.

LLM

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

Начать можно с инструкции:

Ответь на вопрос, используя только предоставленные ниже фрагменты. 
Если информации недостаточно, напиши: "Не знаю".

Вопрос: {user_question}

Фрагменты:
{chunk_1}
{chunk_2}
{chunk_3}

Ответ:

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

Чтобы снизить галлюцинации:

  • добавьте идентификаторы чанкам и инструкцию, чтобы LLM выводила идентификатор фрагмента, на основе которого она сгенерировала эту часть ответа; на постобработке можно их убрать, если не хотите показывать пользователю;
  • уменьшите температуру и top-k (или top-p), чтобы сделать ответы более детерменированными;
  • добавьте ещё одну модель, которая перепроверит, действительно ли ответ использует только информацию из чанков;
  • и в последнюю очередь дообучите LLM.

Для дообучения вам понадобится собрать пары «запрос + чанки → правильный ответ», где ответ составлен строго на основе содержания чанков (без импровизации). Так модель научится формулировать ответы именно по предоставленным данным, а не по своим внутренним знаниям.

Обычно начинают с SFT (Supervised Fine-Tuning), а при необходимости добавляют выравнивание по предпочтениям (pairwise-сравнения, например, с DPO или RLHF). Но это уже материал для более опытных читателей 🙂

Заключение и пара советов

Если RAG не работает на определённой категории вопросов — не старайтесь сделать универсальную версию, добавьте классификатор, который определит эту категорию, и направляйте такие вопросы через отдельный пайплайн обработки.

Не забудьте в начале подготовить тесты и метрики!

Начинайте с простой реализации: чанкирование по количеству символов → публичный эмбеддер → in-memory база чанков → публичная LLM.

А потом улучшайте, оставив тюнинг моделей на конец:

  • очищайте чанки и находите оптимальное разбиение;
  • добавляйте реранкер;
  • улучшайте поиск;
  • дообучайте LLM;
  • дообучайте эмбеддер.
4 месяца
Large Language Models

Научитесь использовать LLM в приложениях: обучать, деплоить, ускорять и многое другое на нашем курсе

3 месяца
LLM Pro

Уже профессионально работаете с LLM? Соберите полноценные LLM-системы с учётом требований к качеству и нагрузке, разберите сложные кейсы и дизайны NLP-решений у нас на курсе

0/0

Телеграм-канал

DeepSchool

Короткие посты по теории ML/DL, полезные
библиотеки и фреймворки, вопросы с собеседований
и советы, которые помогут в работе

Открыть Телеграм

Увидели ошибку?

Напишите нам в Telegram!