Как я победил в RAG Challenge: от нуля до SoTA за один конкурс

в 11:54, , рубрики: chatgpt, Docling, faiss, gpt, llm, question answering, rag, retrieval augmented generation, векторный поиск, парсинг PDF
Автор - DarkBones

Автор - DarkBones

Предисловие

В этом посте я расскажу про подход, благодаря которому я занял первое место в обеих призовых номинациях и в общем SotA рейтинге.

Памятка по RAG

RAG - это инструмент, расширяющий возможности LLM через “подключение” к ней базы знаний любого размера.

Путь разработки базовой RAG системы состоит из этапов:

  1. Parsing: Подготавливаем данные для наполнения базы знаний. Собираем документы, парсим в текстовый формат, очищаем их от мусора.

  2. Ingestion: Создаём и наполняем базу знаний.

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

  4. Answering: Обогащаем извлечёнными данными промпт с вопросом юзера и отправляем в LLM, возвращаем ответ.

В чём суть RAG Challenge?

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

  1. Выдаются PDF-годовые отчёты по сотне случайно выбранных компаний и 2.5 часа на парсинг этих отчётов и составление базы данных.

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

Все вопросы должны иметь однозначный ответ:

  • Да/Нет;

  • название компании (или нескольких компаний);

  • названия управляющих позиций, выпущенных продуктов;

  • размер той или иной метрики: выручка, количество магазинов и т.д.

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

Вопросы и ответы на них от моей лучшей системы можно посмотреть тут

Схема работы победившей системы:

Помимо базовых этапов победное решение включает в себя два роутера, и LLM реранкинг

Помимо базовых этапов победное решение включает в себя два роутера, и LLM реранкинг

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


1. Parsing

Чтобы начать составлять хоть какую-то базу, PDF-документы необходимо перевести в простой текст. Парсинг PDF — крайне нетривиальная задача с миллионом неочевидных сложностей:

  • сохранение структуры таблиц;

  • сохранение важных элементов форматирования (например, заголовки и буллит-списки);

  • распознавание многоколоночного текста;

  • обработка графиков, изображений, формул, колонтитулов и т.п.

Интересные проблемы при парсинге PDF, которые я обнаружил (но не успел решить):

  • Крупные таблицы иногда повёрнуты на 90 градусов, парсеры ломают об них зубы:

Как я победил в RAG Challenge: от нуля до SoTA за один конкурс - 3
  • Графики, наполовину состоящие из картинки, наполовину — из текстового слоя;

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

    Как я победил в RAG Challenge: от нуля до SoTA за один конкурс - 4

    Я расследовал этот случай отдельно и оказалось, что этот текст можно расшифровать - это шифр Цезаря с неоднородным сдвигом по ASCII таблице. Величина сдвига определена для каждого слова отдельно.
    Это породило у меня множество вопросов. Если кто-то намеренно защитил копирование публичного отчёта о компании - зачем? Если шрифт сломался при конвертации - почему именно так?

Выбор парсера

Я перепробовал пару десятков PDF-парсеров:

  • нишевых;

  • именитых;

  • новомодных с ML-обучением;

  • проприетарных с доступом по API.

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

Наилучшим парсером для RAG Challenge оказался относительно известный Docling. Оказалось, что за его разработкой стоит один из организаторов соревнования - IBM.

Кастомизация парсера

Однако несмотря на наилучшие результаты, в Docling не было некоторых важных возможностей. Частично они присутствовали, но в разных конфигурациях, которые нельзя было объединить в одну.

Поэтому я засучил рукава, тщательно покопался в исходном коде библиотеки и переписал часть методов под себя, чтобы в результате парсинга получать JSON, содержащий всю нужную мне метаинформацию. На основе JSON я собрал Markdown-документ с скорректированным форматированием и около-идеальным переносом структуры таблиц из PDF не только в MD, но и в HTML-формат, что оказалось важным позднее.

Эта библиотека довольно шустрая, но не настолько, чтобы спарсить 15 тысяч страниц за 2.5 часа на персональном ноутбуке. Поэтому я воспользовался возможностью GPU-ускорения парсинга и для конкурса арендовал виртуальную машину с GPU 4090 за 70 центов в час.

 Runpod оказался крайне удобным для краткосрочной аренды GPU

Runpod оказался крайне удобным для краткосрочной аренды GPU

На парсинг всей сотни документов ушло около 40 минут, что, судя по отчётам и комментариям других участников, является крайне высокой скоростью.

На этом этапе мы имеем спарсенные в формате JSON отчёты.
Теперь можно набивать базу данных?

Пока что — нет. Сначала нужно почистить текст от мусора и преподготовить таблицы.

Очистка текста и подготовка таблиц

Иногда часть текста парсится из PDF криво и содержит специфический синтаксис, который ухудшает читаемость и осмысленность информации. Я решил эту часть пачкой из пары десятков регулярок.

Пример кривого парсинга

Пример кривого парсинга

Упомянутые чуть выше документы с шифром Цезаря также детектил регулярками. Я пытался их расшифровать, но даже после восстановления они содержали множество артефактов. Поэтому я просто прогонял их через OCR целиком.

Сериализация таблиц

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

Между вертикальным и горизонтальным заголовком 1,5 тысячи нерелевантных токенов.

Между вертикальным и горизонтальным заголовком 1,5 тысячи нерелевантных токенов.

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

Решением стала сериализация таблиц. Исследований на эту тему не очень много, поэтому пришлось нащупывать путь самостоятельно. Можете погуглить Row-wise Serialization, Attribute-Value Pairing или почитать вот это исследование.

Суть сериализации — перевести большую таблицу в набор небольших контекстуально независимых строк.

После долгих экспериментов с промптами и Structured Output схемами, мне удалось найти решение, в котором даже GPT-4o-mini оказалась способной сериализовывать огромные таблицы почти без потерь. Сначала я подавал в LLM таблицы в MD-формате, но затем перешёл на HTML-формат (вот он и пригодился!). Языковые модели понимают его гораздо лучше, плюс он позволяет описывать таблицы со смердженными ячейками, подзаголовками и прочими структурными выкрутасами.

Для ответа на вопрос “What was the company's shareholder's equity in 2021?” достаточно подать в LLM лишь одно предложение, вместо большой структуры с кучей “мусорной” информации.

При сериализации вся таблица переводится в набор подобных независимых блоков:

{'subject_core_entity': "Shareholders' equity",
'information_block': "Shareholders' equity for the years from 2012/3 to 2022/3 are as follows: ¥637,422 million (2012/3), ¥535,422 million (2013/3), ¥679,160 million (2014/3), ¥782,556 million (2015/3), ¥540,951 million (2016/3), ¥571,983 million (2017/3), ¥511,242 million (2018/3), ¥525,064 million (2019/3), ¥513,335 million (2020/3), ¥577,782 million (2021/3), and ¥1,274,570 million (2022/3)."}

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

Промпт и логику сериализации можно посмотреть в репозитории проекта: tables_serialization.py

*Несмотря на потрясающий потенциал сериализации, в победившем решении сериализация не использовалась. О причинах - в конце поста.

2. Ingestion

Отчёты переведены из PDF в обычный md текст и очищены. Теперь создадим из них базы данных.

Договоримся о терминологии

В сфере поисковых систем (google search, full-text-search, elastic search, vector search и вот это всё) документ - это один проиндексированный элемент, возвращаемый системой в результате запроса. Документом может быть одно предложение, абзац, страница, сайт, изображение - без разницы. Но лично меня это название всегда сбивает с толку, из-за более распространённого, бытового определения: Документ как отчёт, контракт, сертификат.

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

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

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

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

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

Из этого логично следует, что искомое высказывание внутри небольшого абзаца даст бОльший similarity score, чем то же высказывание, разбавленное целой страницей слаборелевантного текста.

Я разделил текст на странице на чанки по 300 токенов (примерно 15 предложений).
Для нарезки текста использовал рекурсивный сплиттер с кастомным MD-словарём. Чтобы не потерять инфу, неудачно порезанную между двумя чанками, добавляем небольшое пересечение текста (50 токенов).

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

Однако для моей Retrieval системы аккуратность нарезки практически не играет роли.

Каждый чанк хранит в метадате свой ID и номер родительской страницы.

Векторизация

Салат из чанков нарезан, переходим к созданию векторной базы. Вернее, не базы, а баз. 100 баз. 1 база = 1 документ.
Потому что зачем смешивать информацию о компаниях в одну кучу и потом пытаться отделить revenue одной компании от revenue другой? Искомые данные для ответа всегда находятся строго в рамках одного документа.
Нужно только определить, в какую базу данных стучаться для заданного вопроса (об этом позже).

Для создания, хранения и поиска по векторной базе я использовал FAISS

Немного про формат векторной базы

Базы созданы методом IndexFlatIP
Плюс Flat базы - все вектора хранятся в базе as is, без сжатия или квантизации.
По ним производится Brute-force search, который даёт бОльшую точность. Минус - такой поиск гораздо более Compute и Memory intensive.

Если в вашей базе хотя бы сотня тысяч элементов, рассмотрите IVFFlat или HNSW. Эти форматы гораздо более шустрые (хоть и требуют больше ресурсов при создании базы). Но за скорость приходится платить точностью из-за ANN поиска (Approximate Nearest Neighbor).

Отказ от хранения чанков всех документов в одном индексе позволил мне использовать Flat базы.

IP - Inner product, используется для определения relevance score через Cosine similarity. Помимо IP есть ещё L2 - подсчёт relevance score через евклидово расстояние. IP оценивает релевантность получше.

Для преобразования чанков и вопросов в векторное представление я использовал text-embedding-3-large.

3. Retrieval

После того как мы создали базы, можно переходить к части “R” нашей RAG системы.

Retriever - это общая система поиска, которая принимает на вход запрос, а на выход выдаёт релевантный текст, в котором должна находиться необходимая для ответа информация.

В базовом варианте это просто запрос в векторную базу данных с извлечением top_n результатов.

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

Junk in - Junk out.

Качество ретривера можно поднять множеством методов. Рассказываю про те, которые я рассматривал в этом соревновании.

Hybrid search vDB + BM25

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

Этот вариант мне не особо понравился: в минимальной реализации он чаще снижал качество поиска, чем повышал.

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

У кого если положительный опыт с этим подходом - расскажите в комментариях. Особенно про потенциальные проблемы и как вы их разрешали.

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

Cross-encoder reranking

Реранжирование результатов векторного поиска через Cross-encoder модель показался мне многообещающей модификацией. Если коротко, он более точно оценивает similarity score, но при этом более медленный.

Cross-encoders — нечто среднее между embedding-моделями (bi-encoders) и LLM. В отличие от сравнения текстов через их векторное представление, которое по определению lossy (часть информации теряется при векторизации), cross-encoders оценивают семантическую схожесть между двумя текстами напрямую. Это даёт более точную оценку.

Но попарное сравнение вопроса со всеми элементами базы для каждого вопроса занимает слишком много времени.

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

В последний момент я отказался от них из-за низкой распространённости cross-encoder реранкеров с доступом по API. У OpenAI таких моделей нет и никогда не было, как и у остальных гигантов, а желания возиться с контролем ещё одного API-баланса у меня не было.

Если вам интересно попробовать cross-encoder reranking, могу посоветовать Jina Reranker. У него хорошие результаты по бенчмарком, плюс Jina даёт довольно большое количество запросов при регистрации.

Отказался я ещё и потому, что у меня была более интересная альтернатива - LLM reranking!

LLM reranking

Всё просто. Отправляем в LLM текст и вопрос, спрашиваем: «Этот текст полезен для ответа? Насколько? Определи релевантность от 0 до 1».

До недавнего времени такой подход был нецелесообразен из-за дороговизны хороших LLM моделей такой. Теперь же у нас есть быстрые, дешёвые и при этом достаточно умные LLMки.

Как и в случае с Cross-encoder реранкингом, применяем его после предварительного сужения круга подозреваемых векторным поиском.

Я создал для реранкинга подробный промпт, в котором расписал общие гайдлайны оценки и критерии оценки с шагом в одну десятую:

0 = Completely Irrelevant: The block has no connection or relation to the query.
0.1 = Virtually Irrelevant: Only a very slight or vague connection to the query.
0.2 = Very Slightly Relevant: Contains an extremely minimal or tangential connection.

Запрос в LLM посылается в формате Structured output с двумя полями: reasoning (чтобы модель порассуждала перед ответом) и relevance_score, чтобы вытаскивать число напрямую из json поля, без парсинга.

Также я немного оптимизировал процесс, отправляя по 3 страницы в одном запросе, чтобы LLM возвращала сразу 3 оценки. Это увеличило скорость, уменьшило стоимость, и слегка подняло качество оценки. Наличие соседних блоков текста “приземляет” модель, обеспечивая бОльшую консистентность оценок.

Корректировку relevance score производил взвешенным средним
vector_weight = 0.3, llm_weight = 0.7

Теоретически, можно отказаться от векторного поиска и прогонять через LLM вообще все страницы из документа. Некоторые участники так и делали, и вполне успешно.
Но я считаю, что дешёвый и быстрый способ отсева через эмбеддинги всё-таки нужен. Для документа из 1000 страниц (а был и такой) один вопрос обойдётся в четверть доллара - дороговато.

Ну и в конце концов, у нас тут RAG конкурс, или что?)

Реранкинг через gpt-4o-mini стоил мне меньше цента на вопрос! Этот метод показал отличное соотношение качества, скорости и стоимости. Именно поэтому я остановился на нём.

Промпт для реранкинга можете посмотреть тут

Parent Page Retrieval

Помните, я говорил про разбивку текста на меньшие чанки? Тут есть небольшая проблема.

Да-да, основная часть информации для ответа сконцентрирована в небольшом чанке и это увеличивает качество поиска.

Но остальной текст со страницы всё ещё может содержать второстепенную, но важную информацию.

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

Собранный ретривер

image.png

Пройдёмся по итоговому ретриверу:

  1. Векторизируем вопрос

  2. Ищем по вектору вопроса топ 30 релевантных чанков.

  3. По метаданным чанков извлекаем страницы (не забываем их дедуплицировать)

  4. Передаём страницы в LLM реранкер

  5. Корректируем relevance score страниц

  6. Возвращаем топ 10, добавив в текст каждой страницы её номер и объединив в их одну строку

Наш ретривер готов!

4. Augmentation

image.png

Векторная база собрана, ретривер тоже готов к работе, часть “R” (retrieval) в RAG - позади. Часть “A” (augmentation) довольно простая, и в основном состоит из f-strings и их конкатенаций.

Из интересного - расскажу про структуру хранения промптов. Я довольно долго мучился и пробовал разные варианты, и в итоге пришёл к следующему:

Промпты я храню в отдельном prompts.py файлике. Обычно разбиваю промпты на логические блоки:

  • Основная системная инструкция;

  • Pydantic-схема, которая задаёт формат ответа, ожидаемый от LLM;

  • Примеры пар “запрос - ответ” для создания one/few-shot промптов;

  • Template для вставки контекста и вопроса.

Небольшая функция собирает блоки в финальный промпт в необходимой конфигурации. Такой подход позволяет гибко собирать разные конфигурации промпта при тестировании гипотез (например, при сравнении эффективности разных примеров для one-shot).

Часть инструкций может повторяться среди нескольких промптов. Раньше при изменении такой части мне приходилось синхронизировать в каждом использующем её промпте. Было очень легко что-то пропустить.
Блочный подход убрал эту проблему. Теперь я помещаю повторяющуюся инструкцию в Shared блок и переиспользую его в нескольких промптах.

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

Все промпты можно посмотреть в репозитории проекта: prompts.py

5. Generation

Третья часть “G” в RAG - самая трудоёмкая. Для её качественной реализации нужно грамотно использовать несколько базовых техник.

Роутинг запросов к базе данных

image.png

Это одна из самых простых и при этом самых полезных частей RAG-системы.

Напомню, для каждого отчёта создана отдельная векторная база. Генератор вопросов устроен так, что имя компании в вопросе всегда упоминается явно.

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

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

Но концептуально подход не меняется.

Подытожим: Нашли имя → сопоставили с БД → ищем только в ней. Наша область поиска сократилась в 100 раз.

Роутинг запросов к промпту

image.png

Одно из требований соревнования - формат ответов. На каждый вопрос система должна отвечать односложно, строго соблюдая тип данных, как если бы ответы помещались в базу данных по компании.
Вместе с каждым вопросом идёт ожидаемый тип - int/float, bool, str, list[str].

По каждому типу нужно учитывать 3-6 разных нюансов при ответе.

Например, если спрашивается размер метрики, ожидается только число, без комментариев, знаков валюты и т.п. Если это денежная метрика, то валюта в отчёте должна соответствовать валюте в вопросе, а само число должно быть нормализовано - в отчётах часто пишут что-то вроде $1352 (в тысячах) — система должна ответить 1352000.

Как обеспечить, чтобы LLM соблюдала все подобные нюансы разом и ничего не напутала? Да никак. Чем больше правил вы даёте LLM, тем выше вероятность, что модель будет их игнорировать. Даже 8 правил - уже опасно много для моделей текущего дня. Когнитивная ёмкость модели - ограниченный ресурс, а дополнительные правила “отвлекают” её от главной задачи - ответа на заданный вопрос.

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

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

Я написал 4 вариации промптов, и выбирал нужный простым if else

Роутинг многосоставных запросов

image.png

В соревновании есть вопросы, требующие сравнить метрики нескольких компаний. Они не укладываются в парадигму остальных вопросов, так как требуют больше действий для ответа.

Пример вопроса: Who has higher revenue, Apple or Microsoft?

Задумываемся: как человек решит такую задачу? Сначала он найдёт выручку для каждой из компаний, и потом сравнит её.

Внедряем идентичное поведение в нашу систему.
Подаём в LLM изначальный вопрос-сравнение и просим составить вопрос, который позволит найти метрику в каждой компании.
В рамках нашего примера вопрос поиска будет таким: What is Apple's revenue?, What is Microsoft's revenue?

Теперь можно провести этот вопрос по стандартному пайплайну для каждой компании.

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

Этот паттерн применим к любым сложным вопросам. Главное - распознать такие вопросы и определить для них необходимые подшаги.

Chain of Thoughts

CoT - это способ значительно увеличить качество ответов, заставив модель "порассуждать" перед тем, как выдать окончательный результат. Вместо того, чтобы давать ответ сразу, LLM генерирует цепочку промежуточных шагов, помогающих прийти к ответу.

LLM, как и люди, лучше справляются со сложными задачами, если разбивают их на более мелкие и простые. CoT помогает модели не упустить важные детали, последовательно обработать информацию и прийти к верному выводу.
Это особенно важно, когда в контексте есть "ловушки", которые могут повести модель по ложному пути.

Уверен, вы слышали культовую фразу Think step by step. Это одна из самых ранних попыток увеличить качество ответа через промптинг. Можно даже сказать, что именно она породила модный “промпт инжиниринг”. Однако для серьёзных задач такой общей инструкцией не обойдёшься.

LLM иногда могут "имитировать" рассуждения. Например, сразу выдать ответ, а затем "задним числом" подгонять под него объяснения. Или начать галлюцинировать, придумывая несуществующие факты. Особенно эта проблема актуальна для слабеньких моделей типа gpt-4o-mini или llama 8b.

Чтобы CoT приносила пользу, необходимо чётко объяснить, как именно модель должна рассуждать. Расписать шаги, цель ответа, дать пример.
Шаги размышления могут строиться от заданных в промпте инструкций - это улучшает следование правилам.

В случае RAG, когда в отчёте отсутствует информация об искомой метрике, в контекст для ответа помещается что-то другое, но максимально близкое по смыслу. В такой ситуации модели склонны притянуть метрику за уши и засчитать как искомую. Модели дообучены быть полезными и поэтому имеют bias в сторону приведения хоть какого-то ответа вместо “Я не знаю”.

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

Я указал модели, что она должна уделять отдельное внимание сопоставимости метрики из вопроса с метриками из контекста. Это значительно уменьшило количество галлюцинаций.

Пример рассуждений при работе с неоднозначным контекстом:

Вопрос:

What is the Research and development equipment, at cost, for Ritter Pharmaceuticals, Inc.?

Цепочка мыслей модели:

  1. The question asks for 'Research and development equipment, at cost' for Ritter Pharmaceuticals, Inc. This indicates a specific value from the balance sheet, representing the original purchase price of equipment specifically used for R&D, without any accumulated depreciation.

  2. The context (page 35) shows 'Property and equipment, net' at $12,500. This is a net value (after depreciation), and it's a broader category, encompassing all property and equipment, not just R&D equipment.

  3. The context (page 37) also mentions 'Accumulated Depreciation' of $110,000 for 'Machinery and Equipment'. This represents the total depreciation, not the original cost, and, importantly, it doesn't specify that this equipment is exclusively for R&D.

  4. Neither of these metrics exactly matches the requested metric. 'Property and equipment, net' is too broad and represents the depreciated value. 'Accumulated Depreciation' only shows depreciation, not cost, and lacks R&D specificity.

  5. Since the context doesn't provide the original cost of only R&D equipment, and we cannot make assumptions, perform calculations, or combine information, the answer is 'N/A'.

Structured outputs

SO — это возможность задать модели строгий формат ответа. Обычно передаётся в API отдельным полем в виде Pydantic или JSON schema.

Он заставляет модель всегда отвечать валидным JSON, полностью соответствующим заданному формату.

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

Вот, например pydantic схема для LLM реранкинга:

class RetrievalRankingSingleBlock(BaseModel):
    """Rank retrieved text block relevance to a query."""
    reasoning: str = Field(description="Analysis of the block, identifying key information and how it relates to the query")
    relevance_score: float = Field(description="Relevance score from 0 to 1, where 0 is Completely Irrelevant and 1 is Perfectly Relevant")

С ней LLM всегда отвечает JSON'ом с двумя полями, первое - строка, второе - число.

CoT SO

Приведённые выше методы идеально сочетаются друг с другом.

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

Реализовать CoT в SO можно по-разному. Например, через несколько JSON-полей, в каждом из которых модель будет приходить к полезному выводу, а их совокупность приведёт модель к верному ответу.

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

В моей основной схеме для ответа на вопросы из соревнования всего 4 поля:

  • step_by_step_analysis — предварительное рассуждение (та самая CoT).

  • reasoning_summary — сжатый пересказ предыдущего поля (для удобного отслеживания логики рассуждения модели перед ответом).

  • relevant_pages — номера страниц из отчёта, на которые ссылается ответ.

  • final_answer — односложный ответ в формате, заданном ТЗ.

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

Например, вот таким образом обеспечивается, что в поле final_answer будет либо число, либо N/A: final_answer: Union[float, int, Literal['N/A']]

SO Reparser

Не все LLM поддерживают Structured Outputs, который гарантирует следование схеме на 100% .

Если модель не имеет отдельного поля для SO, ей всё ещё можно подать схему ответа в самом промпте. Модели достаточно умны, чтобы в большинстве случаев отвечать валидным JSON. Но какая-то часть ответов не будет соответствовать схеме, что сломает наш код. Маленькие модельки так вообще будут ошибаться через раз.

Я написал отдельный fallback метод, который проверяет ответ модельки на соответствие схемы через schema.model_validate(answer). В случае ошибки валидации метод возвращает ответ обратно в LLM с запросом привести его к заданной схеме.

Этот метод подтянул следование формату обратно до 100%, даже для 8b модели.

Вот сам промпт

One-shot промпты

Это ещё одна распространённая методика, она довольно проста и очевидна:
Если помимо инструкций добавить в промпт пример ответа, качество и консистентность ответов повысится.

В каждый промпт я добавил пару «вопрос → ответ», причём ответ писал в формате JSON, задаваемым через SO.

Пример преследует сразу несколько целей:

  • Демонстрирует образцовый процесс step-by-step reasoning.

  • Дополнительно артикулирует правильное поведение в сложных кейсах (для перекалибровки Bias’ов модели).

  • Демонстрирует структуру JSON, которой должен соответствовать ответ модели (особенно важно для моделей без нативной поддержки SO).

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

Поле step-by-step reasoning в примерах я вычитывал и корректировал почти что вручную, прорабатывая структуру рассуждений и формулировку каждой фразы.

Проработка инструкций

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

Анализ вопросов

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

Ключ к хорошей системе с LLM под капотом — понимание потребностей клиента. Для этого, как правило, необходимо погрузиться в профессиональную отрасль и скрупулёзно разбирать вопросы. Я убеждён, что нельзя написать действительно качественную QA-систему для бизнеса, если ты сам не понимаешь сути вопроса и того, как найти на него ответ (я буду рад, если кто-то сможет меня переубедить).

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

Рассмотрим на примере вопроса “Who is the CEO of ACME inc?”

В идеальном мире отчёт всегда содержит прямой ответ, исключающий какие-либо мисинтерпретации:

CEO responsibilities are held by John Doe

RAG-система находит эту фразу в отчёте, кладёт её в контекст к вопросу, и пользователь получает однозначный ответ: John Doe

Но мы живём в реальном мире, в котором десятки тысяч компаний могут выражать информацию в неограниченном числе вариаций и с кучей дополнительных нюансов.

И это порождает вопрос: а что вообще можно отнести к термину «CEO»?

  • Насколько дословно система должна воспринимать вопрос заказчика?

  • Он хочет узнать имя человека, занимающего управляющую роль с похожими обязанностями, или исключительно по этому названию роли?

  • Шаг влево, шаг вправо — расстрел? А два шага можно сделать? А три?

Под эту роль потенциально подпадают:

  • Chief Executive Officer — тут всё очевидно, просто расшифровка аббревиатуры.

  • Managing Director (MD), President, Executive Director — тут чуть менее очевидно. В разных странах используют другое название для этой роли (в UK и Европе — MD, в Америке и Японии — President, в UK, странах Азии и non-profit компаниях — Executive Director).

  • Chief Operating Officer, Principal Executive Officer, General Manager, Administrative Officer, Representative Director — ещё менее очевидно. В зависимости от страны и структуры компании там может не быть прямой альтернативы CEO, и хотя упомянутые должности ближе всего к CEO, но имеют разный уровень пересечения обязанностей и полномочий: от 90 до 50%.

Я не знаю, существует ли для этого термин, но для себя я называю это проблемой «порога свободы интерпретации».

При ответах в свободном формате проблема «порога свободы интерпретации» разрешается относительно легко. В случае неоднозначностей LLM старается охватить все невыраженные смыслы в запросе пользователя, добавив несколько оговорок.
Вот реальный пример ответа ChatGPT:

В предоставленном контексте не упоминается должность CEO (Chief Executive Officer). Однако указано, что Ethan Caldwell является Managing Director (Управляющим директором). В данный момент он отстранён от исполнительных обязанностей, и управление компанией передано старшему руководству под наблюдением совета директоров.

Если же архитектура системы требует односложного ответа, как это было в RAG Challenge, то в подобных ситуациях модель будет отвечать непредсказуемо, исходя из своих внутренних “ощущений”.

Поэтому порог свободы интерпретации приходится определять и калибровать заранее. Но так как его нельзя определить и задать количественно, приходится выявлять все основные edge cases, формулировать общие правила интерпретации запросов и выспрашивать у заказчика, как поступать в случае неоднозначностей.

Помимо сложностей с интерпретацией, встречаются ещё и дилеммы общего характера.
Например: Did ACME inc announce any changes to its dividend policy?
Должна ли система засчитывать отсутствие информации в отчёте как информацию об отсутствии?

Ринат (организатор соревнования) не даст соврать, я закидывал его десятками подобных вопросов и дилемм при подготовке к соревнованию)

Создание промптов

За неделю до начала соревнования код генератора вопросов был выложен в общий доступ.
Я сразу же нагенерировал сотню вопросов и создал из них валидационный сет.

Отвечать на вопросы вручную - довольно нудное занятие, но оно помогло сразу в двух вещах:

  1. Валидационный сет позволяет объективно замерять качество системы при внесении изменений. Я прогонял систему на этом сете и отслеживал, на сколько вопросов она отвечает правильно, и в каких случаях чаще всего ошибается. Эта петля обратной связи помогает в итеративном улучшении промптов и остальных частей пайплайна.

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

Все полученные уточнения я вносил в промпты в виде набора директив.

Примеры директив:

Answer type = Number

Return 'N/A' if metric provided is in a different currency than mentioned in the question. Return 'N/A' if metric is not directly stated in context EVEN IF it could be calculated from other metrics in the context. Pay special attention to any mentions in the context about whether metrics are reported in units, thousands, or millions, to adjust the number in final answer with no changes, three zeroes or six zeroes accordingly. Pay attention if the value is wrapped in parentheses; it means the value is negative.

Answer type = Names

If the question asks about positions (e.g., changes in positions), return ONLY position titles, WITHOUT names or any additional information. Appointments to new leadership positions also should be counted as changes in positions. If several changes related to a position with the same title are mentioned, return the title of such position only once. Position title always should be in singular form.

If the question asks about newly launched products, return ONLY the product names exactly as they are in the context. Candidates for new products or products in the testing phase are not counted as newly launched products.

Одним директивам модель следовала сходу, другим сопротивлялась из-за перекошенных Bias’ов, третьи она понимала с трудом и поэтому совершала ошибки.

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

Example for numbers in thousands:

Value from context: 4970,5 (in thousands $)
Final answer: 4970500

В итоге у меня были составлены промпты на каждый формат вопроса и несколько вспомогательных:

  • Финальный вопрос типа Number

  • Финальный вопрос типа Name

  • Финальный вопрос типа Names

  • Финальный вопрос типа Boolean

  • Финальный вопрос типа Comparative (для сопоставления ответов от нескольких компаний из Multiquery routing)

  • Перефразирование вопросов типа Comparative (для предварительного поиска метрик в отчётах)

  • LLM реранкинг

  • SO Reparser

Скрупулёзная проработка инструкций вместе с применением one-shot и SO CoT дали свои плоды. Итоговые промпты полностью перекалибровывали нежелательные Bias’ы системы и вылечили невнимательность к нюансам даже у слабых моделей.

Скорость системы

В изначальных, более строгих условиях RAG Challenge для номинирования на денежный приз было необходимо ответить на все 100 вопросов за 10 минут.
Я отнёсся к этому серьёзно и постарался использовать Token Per Minute rate limit от OpenAI на полную.

Уже на tier 2 лимиты очень щедрые — 2 млн tok/min для gpt-4o-mini и 450к tok/min для gpt-4o. Я прикинул, сколько токенов расходуется на один вопрос и в итоге обрабатывал по 25 вопросов за раз. Система обрабатывала все 100 вопросов за 2 минуты.

*В итоге условия по сроку сдачи решения сильно увеличили - остальные участники не успевали :)

Качество системы

Наличие валидационного сета помогло не только с промптами, но и с улучшением системы в целом.

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

class RunConfig:
    use_serialized_tables: bool = False
    parent_document_retrieval: bool = False
    use_vector_dbs: bool = True
    use_bm25_db: bool = False
    llm_reranking: bool = False
    llm_reranking_sample_size: int = 30
    top_n_retrieval: int = 10
    api_provider: str = "openai"
    answering_model: str = "gpt-4o-mini-2024-07-18"

Именно при тестировании конфигов я с удивлением обнаружил, сериализация таблиц, на которую я возлагал столько надежд, вообще не улучшила систему, а даже наоборот — немного её ухудшила.
Полагаю, Docling достаточно хорошо парсит таблицы из PDF, ретривер хорошо их находит, а LLM достаточно хорошо понимает их структуру. И никому из них эта помощь не нужна. А добавление текста на страницу лишь ухудшает signal-to-noise ratio.

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

Итоговая система отлично отработала и с открытыми, и с проприетарными моделями: Llama 3.3 70b отстала от OpenAI o3-mini всего на пару баллов. Даже малышка Llama 8b обошла 80% участников в общем зачёте.

6. Итог

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

Главный вывод из этого соревнования прост: магия RAG скрыта в мелочах. Чем больше ты понимаешь задачу, тем точнее можешь настроить каждую часть пайплайна, и тем больше получаешь выгоды даже от самых простых техник.

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

Я не веду соц сети, так что посоветую свои любимые ТГ каналы:

LLM под капотом - Ринат рассказывает о внедрении систем с LLM в реальные бизнес процессы. Именно он дал старт этому конкурсу.

Сиолошная - Многие знают Игоря по лонгридам про GPT на хабре. В тг он выкладывает интересности гораздо чаще.

Если вам нужна консультация по LLM системам, пишите в личку

Автор: IlyaRice

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js