Quiz on tool use with Claude
Введение в генерацию с дополненным извлечением (RAG)
Генерация с дополненным извлечением (RAG) — это техника, которая помогает вам работать с большими документами, слишком объемными для одного промпта. Вместо того чтобы втискивать все в один огромный промпт, RAG разбивает документы на чанки и включает только наиболее релевантные части при ответе на вопросы.
Проблема с большими документами
Представьте, что у вас есть 800-страничный финансовый документ, и вы хотите задать Claude конкретные вопросы о нем, например: "Какие факторы риска есть у этой компании?" Вам нужно каким-то образом передать соответствующую информацию из документа Claude, но существуют ограничения на объем текста, который можно включить в промпт.
Вариант 1: Включить все в промпт
Первый подход прост — извлеките весь текст из документа и поместите его в свой промпт вместе с вопросом пользователя. Этот подход имеет серьезные ограничения:
- Существует жесткое ограничение на длину промпта — ваш документ может быть слишком длинным
- Claude становится менее эффективным при очень длинных промптах
- Обработка более крупных промптов стоит дороже
- Обработка более крупных промптов занимает больше времени
Вариант 2: Разбиение документов на чанки
RAG использует более умный подход. Сначала вы разбиваете документ на более мелкие чанки на этапе предварительной обработки. Затем, когда пользователь задает вопрос, вы находите чанки, наиболее релевантные его вопросу, и включаете в промпт только их.
Преимущества этого подхода:
- Claude может сосредоточиться только на наиболее релевантном контенте
- Масштабируется до очень больших документов
- Работает с несколькими документами
- Меньшие промпты стоят дешевле и выполняются быстрее
Проблемы с RAG
- Требуется этап предварительной обработки для разбиения документов на чанки
- Необходим механизм поиска для нахождения "релевантных" чанков
- Включенные чанки могут не содержать весь контекст, необходимый Claude
- Множество способов разбиения текста на чанки — какой подход лучше?
RAG обменивает простоту на масштабируемость и эффективность. Хотя для правильной реализации требуется больше предварительной работы, он позволяет работать с коллекциями документов, которые было бы невозможно обработать с помощью простого "запихивания" в промпт.
Стратегии разбиения текста на чанки
Разбиение текста на чанки является одним из наиболее важных шагов при построении конвейера RAG. То, как вы разбиваете свои документы, напрямую влияет на качество всей вашей системы. Плохая стратегия разбиения на чанки может привести к вставке нерелевантного контекста в ваши промпты.
Разбиение на чанки по размеру
Разбиение на чанки по размеру — это самый простой подход: вы делите текст на строки одинаковой длины.
Недостатки:
- Слова обрезаются посреди предложения
- Чанки теряют важный контекст из окружающего текста
- Заголовки разделов могут быть отделены от их содержимого
Чтобы решить эти проблемы, вы можете добавить перекрытие между чанками. Это означает, что каждый чанк включает часть символов из соседних чанков, обеспечивая лучший контекст и гарантируя полноту слов и предложений.
def chunk_by_char(text, chunk_size=150, chunk_overlap=20):
chunks = []
start_idx = 0
while start_idx < len(text):
end_idx = min(start_idx + chunk_size, len(text))
chunk_text = text[start_idx:end_idx]
chunks.append(chunk_text)
start_idx = (
end_idx - chunk_overlap if end_idx < len(text) else len(text)
)
return chunks
Разбиение на чанки по структуре
Разбиение на чанки по структуре делит текст на основе естественной структуры документа — заголовков, абзацев и разделов. Это отлично работает, когда у вас есть хорошо отформатированные документы, такие как файлы Markdown.
Для документа Markdown вы можете разделить его по маркерам заголовков. Этот подход дает наиболее чистые и осмысленные чанки, потому что каждый из них представляет собой полный раздел. Однако он работает только тогда, когда у вас есть гарантии относительно структуры вашего документа.
Разбиение на чанки по семантике
Разбиение на чанки по семантике — это самый сложный подход. Вы делите текст на предложения, затем используете обработку естественного языка для определения степени связанности последовательных предложений. Чанки строятся из групп связанных предложений.
Этот метод вычислительно затратен, но производит наиболее релевантные чанки.
Разбиение на чанки по предложениям
Практическим компромиссом является разбиение на чанки по предложениям. Вы делите текст на отдельные предложения, затем группируете их в чанки с необязательным перекрытием:
def chunk_by_sentence(text, max_sentences_per_chunk=5, overlap_sentences=1):
sentences = re.split(r"(?<=[.!?])\s+", text)
chunks = []
start_idx = 0
while start_idx < len(sentences):
end_idx = min(start_idx + max_sentences_per_chunk, len(sentences))
current_chunk = sentences[start_idx:end_idx]
chunks.append(" ".join(current_chunk))
start_idx += max_sentences_per_chunk - overlap_sentences
if start_idx < 0:
start_idx = 0
return chunks
Выбор стратегии
- По структуре: Лучшие результаты, когда вы контролируете форматирование документа (например, внутренние отчеты компании)
- По предложениям: Хороший компромисс для большинства текстовых документов
- По размеру с перекрытием: Самый надежный запасной вариант, который работает с любым типом контента, включая код
Разбиение на чанки по размеру с перекрытием часто является предпочтительным выбором в производстве, потому что оно простое, надежное и работает с любым типом документов.
Векторные представления текста
После разбиения документа на чанки, следующим шагом в конвейере RAG является поиск наиболее релевантных чанков для вопроса пользователя.
Семантический поиск
Наиболее распространенный подход для поиска релевантных чанков — семантический поиск. В отличие от поиска по ключевым словам, который ищет точные совпадения слов, семантический поиск использует векторные представления текста (text embeddings) для понимания значения и контекста как вопроса пользователя, так и каждого текстового чанка.
Что такое векторное представление текста?
Векторное представление текста (text embedding) — это числовое представление смысла, содержащегося в некотором тексте. Представьте это как преобразование слов и предложений в формат, с которым компьютеры могут работать математически.
Вот как работает процесс:
- Вы подаете текст в модель эмбеддингов
- Модель выдает длинный список чисел (эмбеддинг)
- Каждое число находится в диапазоне от -1 до +1
- Эти числа представляют различные качества или особенности входного текста
Понимание чисел
Каждое число в эмбеддинге по сути является "оценкой" для некоторого качества входного текста. Однако важное предостережение: мы не знаем точно, что представляет каждое число. Фактическое значение каждого измерения изучается моделью во время обучения и не может быть непосредственно интерпретировано человеком.
Использование VoyageAI для эмбеддингов
Поскольку Anthropic в настоящее время не предоставляет генерацию эмбеддингов, рекомендуемым провайдером является VoyageAI. Вам потребуется:
- Зарегистрироваться для отдельной учетной записи VoyageAI
- Получить ключ API (бесплатно для начала)
- Добавить ключ в переменные окружения:
VOYAGE_API_KEY="your_key_here"
Сначала установите библиотеку VoyageAI:
%pip install voyageai
Затем настройте клиент и создайте функцию для генерации эмбеддингов:
from dotenv import load_dotenv
import voyageai
load_dotenv()
client = voyageai.Client()
def generate_embedding(text, model="voyage-3-large", input_type="query"):
result = client.embed([text], model=model, input_type=input_type)
return result.embeddings[0]
Когда вы запустите эту функцию на текстовом чанке, вы получите список чисел с плавающей запятой, представляющих эмбеддинг.
Полный рабочий процесс RAG
Давайте пошагово пройдемся по полному конвейеру RAG, где все части работают вместе для извлечения релевантной информации и генерации ответов.
Шаг 1: Разбейте исходный текст на чанки
Сначала мы берем наш исходный документ и разбиваем его на управляемые чанки.
Шаг 2: Сгенерируйте эмбеддинги
Далее мы преобразуем каждый текстовый чанк в числовые эмбеддинги с помощью модели эмбеддингов. API эмбеддингов обычно выполняет шаг нормализации, который масштабирует каждый вектор до величины 1.0.
Шаг 3: Хранение в векторной базе данных
Мы храним эти эмбеддинги в векторной базе данных — специализированной базе данных, оптимизированной для хранения, сравнения и поиска по длинным спискам чисел, таких как наши эмбеддинги.
Шаг 4: Обработка запроса пользователя
Когда пользователь задает вопрос, мы пропускаем его запрос через ту же модель эмбеддингов.
Шаг 5: Поиск похожих эмбеддингов
Мы отправляем эмбеддинг запроса пользователя в нашу векторную базу данных и просим ее найти наиболее похожие сохраненные эмбеддинги.
Косинусное сходство
Векторная база данных использует косинусное сходство для определения наиболее похожих эмбеддингов. Оно измеряет косинус угла между двумя векторами.
Ключевые моменты о косинусном сходстве:
- Результаты варьируются от -1 до 1
- Значения, близкие к 1, означают высокое сходство
- Значения, близкие к -1, означают очень большое различие
- 0 означает перпендикулярность (отсутствие связи)
Часто встречается "косинусное расстояние", которое рассчитывается как (1 - cosine similarity). При косинусном расстоянии:
- Значения, близкие к 0, означают высокое сходство
- Большие значения означают меньшее сходство
Шаг 6: Создание итогового промпта
Наконец, мы берем вопрос пользователя и наиболее релевантный текстовый чанк, который мы нашли, объединяем их в промпт и отправляем его Claude для получения ответа.
И это полный конвейер RAG! Система успешно извлекла наиболее релевантную информацию на основе семантического сходства и предоставила ее в качестве контекста для генерации точного ответа.
Реализация потока RAG
Теперь давайте реализуем поток RAG шаг за шагом. Вот полный пример, который демонстрирует, как разбивать текст на чанки, генерировать эмбеддинги, хранить их в векторной базе данных и выполнять поиск по сходству.
Пятишаговая реализация RAG
- Разбить текст на чанки по разделам
- Сгенерировать эмбеддинги для каждого чанка
- Создать векторное хранилище и добавить в него каждый эмбеддинг
- Сгенерировать эмбеддинг для вопроса пользователя
- Выполнить поиск в хранилище, чтобы найти наиболее релевантные чанки
Шаг 1: Разбиение текста на чанки
Сначала мы загружаем наш документ и разбиваем его на управляемые разделы:
with open("./report.md", "r") as f:
text = f.read()
chunks = chunk_by_section(text)
Шаг 2: Генерация эмбеддингов
Далее мы создаем эмбеддинги для всех наших чанков одновременно:
embeddings = generate_embedding(chunks)
Шаг 3: Хранение в векторной базе данных
Теперь мы создаем наше векторное хранилище и заполняем его эмбеддингами и связанным с ними текстом:
store = VectorIndex()
for embedding, chunk in zip(embeddings, chunks):
store.add_vector(embedding, {"content": chunk})
Важно: мы храним как эмбеддинг, так и исходное текстовое содержимое. Это крайне важно, потому что при последующем поиске нам нужно будет вернуть фактический текст, а не только числовые значения эмбеддинга.
Шаг 4: Обработка пользовательских запросов
Когда пользователь задает вопрос, мы генерируем эмбеддинг для его запроса:
user_embedding = generate_embedding("What did the software engineering dept do last year?")
Шаг 5: Поиск релевантного контента
Наконец, мы ищем в нашем векторном хранилище наиболее похожие чанки:
results = store.search(user_embedding, 2)
for doc, distance in results:
print(distance, "\n", doc["content"][0:200], "\n")
Этот поиск возвращает два наиболее релевантных чанка вместе с их оценками сходства (косинусными расстояниями).
Понимание результатов
Меньшие значения расстояния указывают на более высокое сходство. RAG по своей сути заключается в преобразовании текста в числа (эмбеддинги), эффективном хранении этих чисел, а затем использовании математического сходства для поиска релевантного контента, когда пользователи задают вопросы.
Лексический поиск BM25
При создании конвейеров RAG вы быстро обнаружите, что один только семантический поиск не всегда дает наилучшие результаты. Иногда вам нужны точные совпадения терминов, которые семантический поиск может пропустить.
Проблема только с семантическим поиском
Предположим, вы ищете конкретный идентификатор инцидента, например "INC-2023-Q4-011", в документе. Хотя семантический поиск отлично справляется с пониманием контекста и значения, он может вернуть разделы, которые семантически связаны, но на самом деле не содержат точного термина, который вы ищете.
Гибридная стратегия поиска
Решение состоит в том, чтобы выполнять как семантический, так и лексический поиск параллельно, а затем объединять результаты. Это дает вам лучшее из обоих миров:
- Семантический поиск находит концептуально связанный контент с использованием эмбеддингов
- BM25 находит точные совпадения терминов с использованием классического текстового поиска
- Объединение обоих подходов повышает точность
Как работает BM25
BM25 (Best Match 25) — популярный алгоритм для лексического поиска в системах RAG. Вот как он обрабатывает поисковый запрос:
Шаг 1: Токенизация запроса
Разбейте вопрос пользователя на отдельные термины. Например, "What is INC-2023-Q4-011?" становится ["What", "is", "INC-2023-Q4-011"].
Шаг 2: Подсчет частоты терминов
Посмотрите, как часто каждый термин встречается во всех ваших документах. Общие слова, такие как "is", могут встречаться часто, в то время как специфические термины, такие как "INC-2023-Q4-011", могут встречаться только один раз.
Шаг 3: Взвешивание терминов по важности
Термины, которые встречаются реже, получают более высокие оценки важности. Слово "is" получает низкую важность, потому что оно распространено, в то время как "INC-2023-Q4-011" получает высокую важность, потому что оно редко.
Шаг 4: Поиск наилучших совпадений
Возвращает документы, которые содержат больше вхождений терминов с более высоким весом.
Реализация поиска BM25
Вот как настроить базовую систему поиска BM25:
# 1. Chunk your text by sections
chunks = chunk_by_section(text)
# 2. Create a BM25 store and add documents
store = BM25Index()
for chunk in chunks:
store.add_document({"content": chunk})
# 3. Search the store
results = store.search("What happened with INC-2023-Q4-011?", 3)
# Print results
for doc, distance in results:
print(distance, "\n", doc["content"][:200], "\n----\n")
Почему это работает лучше
BM25 превосходно находит точные совпадения, потому что он:
- Придает больший вес редким, специфическим терминам
- Игнорирует общие слова, которые не добавляют ценности для поиска
- Фокусируется на частоте терминов, а не на семантическом значении
- Особенно хорошо работает для технических терминов, идентификаторов и конкретных фраз
Ключевая идея заключается в том, что оба метода поиска имеют взаимодополняющие сильные стороны. Семантический поиск понимает контекст и значение, в то время как лексический поиск гарантирует, что вы не пропустите точные совпадения терминов. Объединяя их, вы создаете более надежную систему поиска, которая эффективно обрабатывает как концептуальные запросы, так и специфические поиски.