Как мы прикрутили RAG для интент-классификации, или Трудности перевода на LLM-ский. gemma.. gemma. gemma2.. gemma. gemma2. intent recognition.. gemma. gemma2. intent recognition. llama.cpp.. gemma. gemma2. intent recognition. llama.cpp. llm.. gemma. gemma2. intent recognition. llama.cpp. llm. rag.. gemma. gemma2. intent recognition. llama.cpp. llm. rag. retrieval augmented generation.. gemma. gemma2. intent recognition. llama.cpp. llm. rag. retrieval augmented generation. time to market.. gemma. gemma2. intent recognition. llama.cpp. llm. rag. retrieval augmented generation. time to market. ttm.. gemma. gemma2. intent recognition. llama.cpp. llm. rag. retrieval augmented generation. time to market. ttm. чат-бот.

И не опять, а снова — про этот ваш RAG. Многие продуктовые команды сейчас пробуют приспособить его для своих задач — и мы, команда Speech&Text в компании Домклик, не избежали этой участи. Но не (только) потому, что это модно и молодёжно — попробовать RAG‑подход нас побудила необходимость решить определённые насущные проблемы. Что же это за проблемы, как мы встраивали RAG и что из этого получилось? Если интересно узнать, то милости просим в текст :)

Как мы прикрутили RAG для интент-классификации, или Трудности перевода на LLM-ский - 1

Постановка проблемы

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

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

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

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

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

Пытаясь решить эти проблемы, мы пробовали разные алгоритмы, налаживали процессы — но не меняли общую парадигму решения, продолжая решать задачу текстовой классификации, то есть обучения с учителем. В конечном счёте мы приблизились не просто к желанию, а к необходимости решить вопрос радикально — и делать это не теоретически, а на практике. При смене парадигмы мы хотели в первую очередь сократить время доведения новых интентов до эксплуатации, то есть ускорить Time to Market, уменьшить длину цепочки сотрудников, участвующих в этом процессе, и минимизировать нужное количество разметки.

После недолгих раздумий мы решили рассмотреть возможность применения в нашем бизнес-процессе подхода, основанного на технологии генерации, дополненной результатами поиска, или RAG (Retrieval-Augmented Generation).

Проверка гипотезы

Возможные подходы

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

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

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

Отобрав таким образом некоторое количество интентов-кандидатов, мы подтягиваем из базы соответствующие им ответы сценариев: каждому сценарию соответствует несколько десятков таких ответов. На их основе составляется промпт для языковой модели — ей предлагается выбрать, какие из выбранных на предыдущем этапе ответов лучше всего отвечают на исходный вопрос клиента. Вот схема этого подхода, который постфактум мы решили назвать question-to-answer matching:

Question-to-answer matching

Question-to-answer matching

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

Обдумав это, мы решили модифицировать наш подход и упростить постановку задачи для LLM. Что если мы будем таким же образом отбирать опорники интентов с помощью векторной близости относительно запроса клиента, а затем просить модель выбрать из этих кандидатов наиболее семантически близкие к нему? При таком подходе мы можем контролировать, какое количество опорников попадёт в промпт (а значит, косвенно, и размер самого промпта), и одновременно извлекать пользу из уровня понимания естественного языка большой языковой моделью, который значительно превосходит уровень понимания нашей моделью-векторизатором. Этот подход мы будем называть question-to-question matching:

Question-to-question matching

Question-to-question matching

Эксперименты и результаты

Question-to-answer matching

Для проверки этого подхода мы использовали две большие языковые модели: LLaMA 3 и GigaChat. При первичном тестировании на четырёх интентах обе показали приемлемое качество, при этом GigaChat лучше следовала заданной инструкции и лучше соблюдала формат вывода, поэтому для дальнейших экспериментов решили использовать её. Для получения таких оценок оффлайн-метрик, которым мы можем доверять, составили и разметили отдельный «золотой» датасет из 10 наиболее популярных интентов нашего чат-бота. При тестировании этого подхода на «золотом» датасете и проявились те недостатки, которые мы описали в предыдущем разделе: промпт разрастается до чересчур больших размеров, а качество классификации падает.

Question-to-question matching

Первые эксперименты с этим подходом мы проводили с несколько наивным способом отбора опорных вопросов, которые попадают в промпт: сортировали их по убыванию косинусной близости, затем, итерируясь по вопросам, брали N ближайших интентов (в экспериментах значение N равнялось пяти). Затем мы брали все опорники для каждого из этих N интентов и клали их в промпт, предлагая модели вывести наиболее подходящие интенты в порядке убывания. Используя для оценки качества модели F‑меру на «золотом» датасете, мы получили значение 0,72 с микро‑ и макроусреднением. Неплохо, но есть куда расти.

Стало ясно, что алгоритм отбора кандидатов требует улучшения. Хочется отбирать меньше опорников, при этом закладывая в промпт заведомо больше вариантов, чем можно было бы получить, просто взяв N ближайших. Для этого мы придумали следующий подход. Сперва отфильтруем FIRST_K кандидатов, которые мы вообще будем рассматривать. Также их можно отсеивать по значению косинусной близости относительно входящего запроса, выбрав некоторый минимальный порог, — впрочем, по большому счёту эти подходы эквивалентны. Затем из этих FIRST_K мы выбираем уже TOP_N опорников, которые попадут в промпт для модели, соблюдая принцип: не больше TOP_N_PER_INTENT опорников на каждый интент. Это позволит нам внести разнообразие в предлагаемый модели набор опорников и предоставить ей более широкое пространство для выбора, не опираясь таким образом слишком сильно на качество модели-векторизатора.

Схема отбора опорников

Схема отбора опорников

Улучшение алгоритма отбора дало свои плоды, и мы смогли получить значения F‑меры 0,87 и 0,88 с микро‑ и макроусреднением соответственно. А это уже хорошо конкурировало с метриками нашего текущего решения.

Модели-ранжировщики: какие тестировали

Бейзлайны: что тестировали перед fine-tuning

В ходе экспериментов мы смогли показать, что GigaChat хорошо справляется с поставленной задачей. Однако для практической эксплуатации он неудобен: задержка ответа составляет от 1,5 секунд, а за использованные токены нужно платить, что при необходимости ежедневной классификации в реальном времени на десятках тысяч запросов оказывается непрактичным.

В связи с этим мы решили внимательнее посмотреть на имеющиеся в открытом доступе LLM: сравнить задержку (latency) и пропускную способность (throughput) моделей и оценить их качество на нашей задаче для дальнейшего дообучения с помощью LoRA. Было понятно, что совсем большие модели для пилота брать нецелесообразно, поэтому в ходе предварительных экспериментов мы ограничились моделями размером до 9 млрд параметров. Протестировали следующие модели:

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

Модель

F1 macro

Mistral 7B, Mistral 7B Instruct

0,65

saiga_mistral_7b

0,55

saiga_llama3_8b

0,57

saiga_tlite_8b

0,67

saiga_gemma2_9b

0,76

Vikhr-Gemma-2B-instruct

0,71

Лучше всего себя показали модели семейства Gemma 2 от GoogleAI, дообученные на русскоязычных данных, при этом более тяжёлая модель с 9 млрд параметров ожидаемо оказалась лучше, чем модель с 2 млрд параметров. В результате мы решили использовать для дальнейшего fine tuning-а модель saiga_gemma2_9b.

Fine-tuning Gemma 2

Итак, базовая модель выбрана, дело за малым: дообучить её под специфику нашей задачи. Так как мы не обладаем вычислительными ресурсами для того, чтобы обучать 9-миллиардную модель целиком, решили тюнить LoRA‑адаптер. Едва ли стоит здесь погружаться в подробности, что такое LoRA (Low Rank Adaptation) — этот подход используется сейчас повсеместно. Конкретнее, мы использовали метод QLoRA, в котором веса языковой модели хранятся в квантованном виде, а адаптер обучается в формате числа с плавающей запятой.

Мы дообучали этот адаптер на тех же размеченных данных, на которых обучаются наши основные модели интент‑классификации, но аккуратнее отобранных. Так как часть разметки могла утратить актуальность или попросту быть грязной, мы выбрали 12 наиболее важных интентов и попросили наших бизнес‑аналитиков разметить по паре сотен сообщений, попавших в эти интенты за последние несколько недель. Используя эти примеры как эталон, мы, попарно вычисляя косинусное расстояние между ними и размеченными данными, смогли выбрать и отправить на переразметку некоторое подмножество запросов из наших обучающих датасетов. Также мы добавили в данные определённое количество негативных примеров, целевой меткой для которых было NaN, — их количество составило около 20 % от размера итогового датасета.

Создав и зафиксировав датасет, мы провели несколько экспериментов с гиперпараметрами нашей модели‑адаптера, попробовав разные комбинации значений ранга, размера батча и темпа обучения, остановившись в итоге на R=512, batch_size_per_device=16 и lr=1e-5. Для управления темпом обучения использовали техники разогрева (warmup) и косинусного затухания (cosine annealing). Обучали распределённо на двух GPU NVIDIA A100, используя gradient accumulation с шагом 4: таким образом, эффективный размер батча равнялся 128. Значение параметра lora_alpha мы зафиксировали как половину ранга, то есть 256.

Для того чтобы уместить батчи в видеопамять, мы убрали из обучающих и тестовых данных длинный хвост из тех сообщений, которые по количеству токенов оказались выше 99-го перцентиля, а также использовали метод gradient checkpointing. Попробовав два оптимизатора, AdamW и Lion, мы всё же остановились на проверенном временем AdamW, так как в предварительных экспериментах он показал лучшие метрики сходимости. Наконец, мы использовали LoRA dropout с dropout_rate=0,3.

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

График обучения

График обучения

Обучение прошло успешно, и оффлайн‑метрику F1 с макроусреднением на «золотом» датасете удалось поднять почти на 10 процентных пунктов: с 0,76 до 0,857. Дело за (не)малым: встроить модель в прод и замерить онлайн‑метрики автоматизации.

Встраивание инференса

Выбор формата

Ключевыми трудностями при вводе больших языковых моделей в эксплуатацию являются получение приемлемых значений метрик latency и throughput, а также возможность инференса не только на GPU, но и на CPU (в нашем случае это нужно для прогона тестов на машинах, используемых для сборки образа наших библиотек, где нет доступа к GPU). Формат HuggingFace, полученный после fine tuning-а нашего адаптера, не подходит ни по одному из этих критериев. Нужно было думать, в какой другой формат мы можем конвертировать наши модели, и выбирали мы между тремя вариантами: 1) OpenVINO, 2) TensorRT и 3) GGUF (формат пакета llama.cpp).

Вариант с OpenVINO мы отвергли практически сразу, так как из коробки он не поддерживает инференс на NVIDIA GPU, а возиться с велосипедами от сторонних разработчиков не хотелось. Что касается формата TensorRT, то он известен своей сложностью встраивания, поэтому для пилотного эксперимента мы решили взять формат GGUF из пакета llama.cpp. Для большинства open‑source языковых моделей можно без труда найти их квантованные версии с весами, сконвертированными в этот формат, в том числе и для нашей Gemma 2. Ну а сконвертировать веса адаптера в этот формат не составляет труда: это легко делается с помощью Python‑скриптов, доступных в репозитории этой библиотеки.

Конвертация весов моделей в формат GGUF и последующий инференс в этом формате прошли на удивление безболезненно. Так как сам пакет llama.cpp написан на C++, мы использовали его питоновскую обёртку llama‑cpp‑python. Несмотря на «LLaMa» в названии пакета, он поддерживает не только модели из этого семейства от Meta AI, но и другие LLM.

Какие же метрики по latency и throughput нам удалось получить? Ускорение ответа модели на один запрос оказалось значительным. Посчитав среднюю скорость ответа на тысяче запросов, мы увидели ускорение почти в 6,5 раз: с 1,34 секунды на итерацию (HuggingFace) до 205 миллисекунд (GGUF). Замечу, что в эти 205 миллисекунд входит также векторизация запроса и retrieval‑часть нашего подхода. Кроме того, формат GGUF позволяет установить произвольное ограничение на размер контекста при инициализации модели (для нашей модели этот параметр равен 8192 токенам). Если ограничить размер контекста 512 токенами, то можно получить ещё более значительное ускорение, однако в таком случае наш промпт не всегда влезает в это количество токенов, а обрезка клиентского запроса, как мы выяснили, негативно сказывается на значениях метрики качества.

Что касается throughput, то он ограничен количеством GPU, доступных в продовом кластере. 16 Гб VRAM вполне достаточно для инференса этой модели с размером батча 1, поэтому подойдёт не только NVIDIA A100, на которой мы обучали адаптер, но и более доступная NVIDIA V100.

Ввод в эксплуатацию

Перед нами, командой разработки, была поставлена задача: развернуть в проде очередной сервис с моделью. Сначала нам казалось, что в этом не будет ничего сложного. Собрал Docker‑образ, как мы делали уже сотню раз, и вперёд — раскатываться на dev‑стенд и в продакшн. Но всё оказалось не так просто, ведь нам необходимо работать в разных окружениях, с разным набором доступных ресурсов. На локальных машинах, сборочных серверах и dev‑стенде нам доступен только инференс на CPU, а вот в prod‑среде уже есть нужные нам ноды с GPU.

Проблему унификации кодовой базы для обоих типов инференса решила конвертация весов модели в уже упомянутый формат GGUF из пакета llama.cpp. Однако в ходе дальнейшего развёртывания мы столкнулись с несколькими проблемами инфраструктурного характера: на некоторых стендах не оказалось нод с нужным количеством RAM для работы сервиса на CPU. Что касается стендов с GPU, то для корректного запуска модели на них пришлось несколько повозиться с установкой нужной версии CUDA, сборкой колеса для библиотеки llama‑cpp‑python и прописыванием правильных значений переменных окружения. К счастью, когда модель всё-таки удалось поднять, дальнейших проблем с инференсом не возникло.

Итоги А/Б-тестирования

Решив все проблемы инфраструктурного характера, мы смогли наконец раскатить нашу RAG‑модель на 50%, используя метод канареечного развёртывания (canary deployment), и запустить А/Б‑тестирование. После того как она проработала на проде чуть больше недели, собралось достаточно данных, чтобы сравнить интересующие нас бизнес‑метрики и оценить влияние нового типа модели на автоматизацию клиентских обращений.

И вот здесь нарисовалась весьма любопытная картина. С одной стороны, мы обнаружили, что общая метрика автоматизации не упала — по некоторым интентам она даже оказалась чуть выше, однако этот рост не был статистически значимым. С другой стороны, баланс точности и полноты нашей интент‑классификации сместился: модель стала чаще предсказывать какой‑либо интент, но реже попадать в правильный.

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

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

  2. Точечное улучшение количества и качества опорников.

  3. Наконец, улучшение retrieval‑части: обучение нового, более качественного векторизатора, тонкая настройка параметров семплирования.

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

Авторы текста:

  • Никита Сыхраннов (@nsykhr), Senior DS @ Домклик, Speech&Text

  • Алексей Миронов (@alex_mir), Senior DS @ Домклик, Speech&Text

  • Егор Мышко (@EgorMyshko), Tech Lead Python-разработки @ Домклик, Speech&Text

Автор: nsykhr

Источник

Rambler's Top100