Создание системы вопросов и ответов с цитированием на основе PDF-документов центральных банков с использованием технологии RAG
Практическое создание системы RAG на основе квартальных отчетов центральных банков: загрузка данных, поиск с учетом актуальности, цитируемые ответы и практические ошибки, возникающие на этом пути.
Источники: I Learned RAG by Building a Q&A System
Большинство учебных пособий по технологии RAG работают с упорядоченными, синтетическими данными. Реальные документы ведут себя иначе, и разница между пониманием принципов генерации с использованием дополнительных данных и их фактической реализацией проявляется лишь тогда, когда система создается на основе беспорядочного набора текстов. Именно поэтому была разработана bspQA — система ответов на вопросы, основанная на квартальных отчетах о денежно-кредитной политике Банко Сентрал нг Пилипинас, центрального банка Филиппин.
Этот корпус является отличной целью для обучения. Каждый отчет содержит от 60 до 70 страниц подробного экономического анализа, причем семнадцать из них охватывают период в несколько лет; все они доступны для публичного просмотра. Язык специфичен для данной области, файлов много, а проблема поиска действительно сложна, поскольку невозможно вставить семнадцать отчетов в окно поиска. Цель была скромной: задать вопрос вроде «Каковы прогнозы инфляции на 2026 год?» и получить обоснованный, с цитатами ответ в течение нескольких секунд, вместо того чтобы искать по всей куче PDF-файлов с помощью Ctrl+F.
Шаблон в одном абзаце
RAG связывает ответы большой языковой модели с определённым набором документов, а не с тем, что она усвоила во время обучения. Документы заранее преобразуются в векторные эмбеддинги. Когда поступает вопрос, берутся фрагменты текста, наиболее близкие к нему, которые передаются модели в качестве контекста, и модели указывается отвечать исключительно на основе этого контекста. Преимущество такого подхода заключается в том, что система не пытается выдумать то, чего нет в документах.
Два процесса, работающих отдельно
В дизайне намеренно разделяются две задачи.
- Процесс обработки документов выполняется оффлайн, по одному разу на каждый новый документ. Он загружает PDF-файлы, преобразует их в обычный текст, делят текст на фрагменты, создаёт эмбеддинги и сохраняет всё в Qdrant.
- Обработка запросов происходит в реальном времени при каждом запросе. Вопрос преобразуется в эмбеддинг, извлекаются соответствующие фрагменты, и генерируется ответ. На этом этапе с сырыми PDF-файлами не работают.
Разработка механизма обработки документов
Этап загрузки
Файл manifest.json является источником истинной информации. Каждый отчет имеет запись с его URL, обозначением вида и датой публикации. URL вручную вставлялись вручную, и это настолько неэлегантно, насколько это звучит.
{
"doc_id": "bsp-mpr-2023-q3",
"title": "Monetary Policy Report Q3 2023",
"period_label": "Q3 2023",
"publication_date": "2023-08-17",
"url": "https://www.bsp.gov.ph/..."
}
Инструмент загрузки пропускает файлы, уже находящиеся на диске, поэтому его повторная работа не вредит системе, а добавление отчета означает лишь включение еще одного элемента в список документов.
Шаг парсинга
Инструмент MarkItDown от Microsoft преобразует PDF-файлы в текст. Для длинных отчетов с большим количеством текста он справляется неплохо: заголовки сохраняются, переносы строк формируются корректно, а таблицы сохраняются едва ли не полностью.
MarkItDown также представляет собой тяжелую зависимость, которая влечет за собой множество дополнительных пакетов. Поскольку парсинг происходит только во время загрузки данных, а не при обработке запросов, он находится в группе факультативных зависимостей, поэтому бэкенд, размещенный на Render, не несет этой нагрузки. Стоит с самого начала выработать привычку определять, какие пакеты предназначены для среды загрузки, а какие — для среды обслуживания, поскольку они могут отличаться друг от друга. Между этапами парсинга и разбиения на части также существует шаг очистки, причины которого будут рассмотрены ниже.
Чанкирование, встраивание и обновление данных
Именно здесь принимаются ключевые решения. Чанки формируются из скользящего окна длиной 512 токенов с перекрытием 64 токенов; подсчёт осуществляется с использованием tiktoken.
def chunk_text_by_tokens(text, enc, chunk_size=512, overlap=64):
tokens = enc.encode(text)
stride = chunk_size - overlap
chunks = []
start = 0
while start < len(tokens):
window = tokens[start : start + chunk_size]
chunks.append(enc.decode(window))
if start + chunk_size >= len(tokens):
break
start += stride
return chunks
Функция перекрытия гарантирует, что предложение, обрезанное на границе блока, всё равно будет полностью видно по крайней мере в одном блоке.
Эмбеддинги получены из модели OpenAI text-embedding-3-small, которая дешева и эффективна для написания подробных текстов. Всё проектное обслуживание обошлось примерно в десять долларов в виде кредитов, хотя минимальная сумма пополнения в то время составляла пять долларов, и этого хватает на довольно крупный проект.
Каждому вектору присваивается идентификатор UUID5, формируемый на основе имени исходного файла и индекса блока. Это обеспечивает безопасность повторной обработки, поскольку при повторном запуске существующие записи перезаписываются, а не накапливаются дубликаты.
Каждый сохранённый пакет данных содержит исходный файл, индекс чанка, текст, модель встраивания, метку периода, нормализованный ключ периода вида q3_2023 и дату публикации в формате ISO 8601. Qdrant хранит индексы по ключевым словам исходного файла и ключа периода, а также индекс по дате публикации в формате datetime, что позволяет быстро фильтровать метаданные во время запросов.
Поиск и ловушка свежести
Простая настройка возвращает K лучших результатов на основе косинусного сходства. Этот метод работает до тех пор, пока корпус данных не охватывает несколько лет, и кто-то не запросит информацию о текущих прогнозах инфляции. Если банк использовал практически идентичные формулировки относительно инфляции в первом квартале 2022 года и третьем квартале 2024 года, чанк 2022 года может получить более высокий балл благодаря немного лучшей формулировке, хотя читатель, скорее всего, хочет узнать самые свежие данные.
Решение заключается в получении большего количества кандидатов и их повторной оценке. Система загружает в три раза больше кандидатов, чем было запрошено, затем пересчитывает их рейтинги с использованием комбинации косинусного коэффициента релевантности и нормализованного показателя свежести.
RECENCY_WEIGHT = 0.85
def combined_score(hit):
relevance = hit.score / max_score
recency = (pub_date - min_date).days / date_span_days # 0.0 to 1.0
return (1 - RECENCY_WEIGHT) * relevance + RECENCY_WEIGHT * recency
Вес свежести 0,85 означает, что более новые документы получают значительное преимущество, если только более старый документ не является гораздо более релевантным. В корпусе политик, где каждый отчет фактически заменяет предыдущий, такой баланс кажется разумным.
Дополнительные фильтры позволяют пользователю ограничить поиск определенным периодом или диапазоном дат:
# scope to Q1 2023 only
retrieve_chunks(query, period_label_key="q1_2023")
# scope to a date range
retrieve_chunks(query, publication_date_from="2023-01-01", publication_date_to="2023-12-31")
Без фильтров система функционирует как помощник для просмотра «последних данных». С их использованием она превращается в инструмент для исторических исследований.
Генерация
Генератор специально разработан простым. Полученные фрагменты форматируются как нумерованные источники и передаются в gpt-4o-mini с системным промптом, который, перефразированно, гласит: использовать только предоставленные источники, не привлекать внешних знаний, указывать источник каждого фактического утверждения непосредственно в тексте и указывать на недостаточность источников, когда они этого не позволяют.
Это последнее условие требует особого внимания. Четко ограниченный промпт — это наиболее эффективный способ защиты от галлюцинаций. Если поиск не возвращает ничего релевантного, система отвечает фиксированным сообщением вместо того, чтобы импровизировать.
Ответы API содержат текст ответа вместе со структурированным списком источников, в котором для каждого указан период, к которому он относится, дата публикации и местоположение PDF-файла источника. На фронтенде это преобразуется в кликабельные цитаты, что позволяет отслеживать каждое утверждение.
Проблемы, возникшие на практике
Элементы страницы, загрязняющие блоки данных
На каждой странице каждого отчета имеется заголовок с надписью «Классификация: ОБЩАЯ» и подвал в формате «Отчет о денежно-кредитной политике — 3-й квартал 2023 г. | 47». После парсинга эти строки появляются как обычный текст и включаются в блоки данных, в результате чего токены тратятся впустую. Номер страницы в этом подвале на самом деле мог бы обеспечить более точные цитаты, но эта идея возникла лишь поздно.
Решением является процедура очистки перед формированием блоков:
def _clean_parsed_text(text):
text = text.replace("\x0c", "\n\n")
text = re.sub(r"Classification:\s*GENERAL\s*\n", "", text)
text = re.sub(r"Monetary Policy Report\s*[--][^\n]+\|\s*\d+\s*\n", "", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
Сейчас это очевидно, но легко упустить из виду, если не проверять обработанный текст перед его вставкой. Симптомы были странными: во время тестирования ответов система, казалось, использовала другие фрагменты вместо того, который имел наивысший рейтинг. Подвалы разделяли абзацы посреди предложения, что тихо меняло контекст, который представлял каждый фрагмент.
Непреднамеренное удаление
Qdrant предоставляет флаг recreate-collection, который удаляет и воссоздаёт всю коллекцию. Это удобно во время разработки, но катастрофично в производственной среде. Теперь защитный механизм игнорирует этот флаг, когда среда похожа на производственную:
def _should_recreate_qdrant_collection():
if _is_production_environment():
return False
explicit = os.environ.get("QDRANT_RECREATE_COLLECTION", "").lower()
if explicit in ("1", "true", "yes"):
return True
app_env = (os.environ.get("ENVIRONMENT") or "").lower()
return app_env in ("development", "dev", "local")
Для проекта с одним пользователем такая защита, пожалуй, является чрезмерной, но она стоит почти ничего.
Дубликаты после повторной обработки
После редактирования записи в манифесте и повторной обработки для одного и того же PDF существовали два набора фрагментов. Решение заключается в удалении существующих фрагментов исходного файла перед добавлением новых:
client.delete(
collection_name=collection,
points_selector=models.Filter(must=[
models.FieldCondition(
key="source_file",
match=models.MatchValue(value=source_file)
)
]),
)
Благодаря стабильным идентификаторам UUID5 процесс повторной обработки теперь является идемпотентным.
Запуск приложения с нуля на бесплатном тарифе
Бэкенд работает на бесплатном тарифе Render, который выключается при отсутствии активности. Первый запрос после запуска с нуля может занять от 30 до 60 секунд, что достаточно времени, чтобы посетитель решил, что приложение не работает. Простой эндпоинт /health решает большинство проблем: фронтенд отправляет к нему запрос при загрузке страницы, тем самым пробуждая сервер до того, как будет отправлен первый вопрос. Это добавляет ещё один HTTP-запрос на сессию. В интерфейсе также указана краткая информация о том, что первый запрос может занять от полуминуты до минуты во время пробуждения бэкенда.
Выбор размера блока
Значение в 512 токенов было определено экспериментально. Фрагменты объемом от 128 до 256 токенов обеспечивали большую точность, но часто теряли контекст, необходимый для их индивидуальной интерпретации. Фрагменты объемом 1024 токена и более включали в себя несвязанный окружающий текст. Надлежащее перекрытие в 64 токена смягчает резкие границы между фрагментами, что оправдывает дополнительное использование памяти.
Что изменить в следующий раз
- Сначала создайте набор для оценки. Директория evals существует, но до настройки веса свежести или размера фрагментов не был составлен эталонный набор вопросов, поэтому оба выбора основаны на приблизительных оценках. Настоящий набор для оценки позволил бы иметь более обоснованные решения.
- Добавьте механизм переранжирования. Включение показателя свежести в оценку является лишь эвристикой. Использование кросс-кодеров вроде Cohere Rerank или локальных моделей позволит одновременно оценивать запрос и фрагменты, обеспечивая более надежное ранжирование по степени релевантности.
- Попробуйте гибридный поиск. Плотные векторы могут упускать точные термины. Фразу вроде «7,00% обратной ставки по репо» трудно найти, если слова в ней расположены иначе. Сочетание сопоставления ключевых слов по алгоритму BM25 с векторным поиском поможет устранить эту проблему, причем Qdrant предоставляет такую возможность из коробки.
- Отправляйте ответы постепенно. На ответ от gpt-4o-mini уходит от двух до пяти секунд. Постепенная передача токенов по мере их поступления создаёт впечатление более высокой скорости интерфейса, даже если общее время остаётся прежним.
Повторное использование дизайна
Архитектура является универсальной. Замените манифест на другой набор документов, обновите описание домена в системном промпте — и получится функциональная система вопросов и ответов для любого набора PDF-файлов. Более сложные аспекты, такие как качественное разделение на части, поиск с учётом актуальности, фильтрация метаданных и безопасная повторная обработка данных, уже реализованы.
Связанная литература
- Практические заметки: создание локальной системы RAG производственного уровня — 100% бесплатно — Подробное руководство по созданию локальной системы RAG производственного уровня — 100% бесплатно, включающее информацию о контрактах, проверках и готовых фрагментах кода для команд, внедряющих эту архитектуру.
- Создание систем RAG, работающих в реальных производственных условиях: от ввода данных до возможности отслеживания их работы — Освещаются такие аспекты, как разбиение данных на части, гибридные методы поиска, переранжирование результатов, контроль доступа, наборы для оценки и отслеживание действий — все те элементы, которые отличают демо-версию системы RAG от полноценной производственной системы.