Створення системи Q&A з посиланнями на джерела на основі PDF-файлів центральних банків за допомогою RAG
Практична реалізація системи RAG на основі квартальних звітів центральних банків: обробка даних, пошук з урахуванням актуальності, посилані відповіді та практичні проблеми, які виникають під час роботи.
Більшість навчальних посібників з 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/..."
}
Захід fetcher пропускає файли, які вже знаходяться на диску, тому його повторна робота не завдає шкоди, а додавання звіту означає додавання ще одного елемента до списку документів.
Крок парсингу
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 зберігає індекси ключових слів для вихідного файлу та ключа періоду, а також індекс дати-часу для дати публікації, тому фільтрація метаданих відбувається швидко під час запиту.
Отримання даних та пастка свіжості
Проста налаштування повертає K найкращих результатів за критерієм косинусової схожості. Це працює доти, доки корпус охоплює кілька років, і хтось запитує про поточні прогнози інфляції. Якщо банк використовував майже ідентичні формулювання щодо інфляції у I кварталі 2022 року та у III кварталі 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 від справжньої виробничої системи.