Главная / Статьи

Что пошло не так в корректирующей пайплайне RAG для вопросов и ответов на YouTube с использованием LangGraph.

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

Издатель: REACTAPP.TOP7 мин чтения1520 слов

Источники: Building a Corrective RAG Pipeline for YouTube Q&A: What Broke and Why?

Система RAG с корректировкой — это подход к генерации текста с использованием поиска информации, включающий три дополнительных компонента по сравнению с обычной версией:

  1. Модель-оценщик, которая определяет, является ли каждый найденный фрагмент релевантным для вопроса пользователя.
  2. Модуль переформулирования запроса, преобразующий вопрос для более эффективного поиска информации.
  3. Альтернатива в виде поиска в Интернете, используемая тогда, когда найденная информация оказывается нерелевантной.

Проект, лежащий в основе этих записей, представлял собой бота для ответов на вопросы к видео на YouTube. Вы указываете ему URL видео и вопрос, и он отвечает. Он также справляется с видео, у которых нет субтитров, что важно, поскольку загрузчик YouTube от LangChain работает только при наличии субтитров. Последовательность действий: загрузка видео, транскрипция его с помощью модели распознавания речи (Whisper), встраивание текста, хранение векторов в ChromaDB, а затем ответы на вопросы с использованием графа LangGraph.

Поскольку задача требовала отслеживания состояния, повторных попыток и альтернативных решений, LangGraph оказался более подходящим инструментом, чем цепочка LangChain или набор условий if-else. Работа с узлами, рёбрами и общим объектом состояния также позволяет понять, как на самом деле функционирует приложение типа RAG. Несколько ошибок, возникших на пути, принесли больше знаний, чем успехи, и они приведены ниже.

Разбиение на чанки

В любой системе RAG разбиение на чанки — это этап, который молча дает сбой при неправильном выполнении. Распространённые подходы включают:

  1. Рекурсивное разделение текста по символам: проходят по списку разделителей и режут текст до тех пор, пока фрагменты не соответствуют установленному размеру.
  2. Семантическое разбиение: группировка предложений по смыслу, например, путём оценки сходства предложений с помощью модели в стиле BERT и объединения наиболее близких к друг другу.
  3. Разбиение с учётом специфики документа: следование формату исходного текста, что характерно для кода.
  • Иерархическое разделение на родительские и дочерние фрагменты позволяет сохранять большой родительский сегмент и более мелкие дочерние фрагменты, отделенные от него.
  • Сначала казалось, что семантическое разделение — естественный выбор. Видео охватывают все возможные темы, поэтому группировка по смыслу казалась разумной. Однако возникла странность: для транскрипций продолжительностью около десяти минут инструмент разделения часто возвращал всего один фрагмент. При более детальном анализе стало ясно почему. Короткие видео обычно посвящены одной теме в деталях, и при семантическом разделении нечего делить, если весь текст касается одного вопроса.

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

    Выбранным подходом стало рекурсивное разделение с реальным перекрытием, без использования семантического разбиения на фрагменты для данного случая применения. Этот метод справлялся как с короткими, так и с длинными видео и генерировал более качественные фрагменты транскрипции. На небольшом наборе из десяти запросов с параметром k=5 система достигла среднего обратного ранга 0,81. Поскольку этот пример крайне мал, его следует рассматривать как приблизительный показатель, а не как эталон.

    Выбор модели эмбеддингов

    Бот должен обрабатывать любые видео, поэтому темы охватывают все области. Модель эмбеддингов, специализированная под конкретную область, может быстро перестать работать эффективно, если видео выходит за рамки области её обучения. Для общего использования и при ограниченных ресурсах аппаратного обеспечения была выбрана модель all-MiniLM-L6-v2 — быстрая и легкая модель, которая работает приемлемо практически везде.

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

    Решение — держать значение chunk_size ниже лимита модели. При разделении текста по символам необходимо соблюдать лимит токенов, исходя из правила, согласно которому один токен состоит примерно из четырех символов. Значение 800 символов соответствует примерно 200 токенам, что вписывается в лимит.

    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=800,      # Notice: 800 characters limit here (~200 tokens)
        chunk_overlap=200,
        length_function=len,
        is_separator_regex=False,
    )
    docs = text_splitter.create_documents([text])
    sentence_transformer = HuggingFaceEmbeddings(
        model_name="sentence-transformers/all-MiniLM-L6-v2"
    )
    vector_store = Chroma(
        collection_name=get_config("vectorDB_collection_name"),
        embedding_function=sentence_transformer,
    )
    

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

    Состояние LangGraph и ChromaDB

    Каждый узел в приложении LangGraph обменивается данными через объект состояния. Каждый узел получает это состояние и обновляет его. Существует два распространенных способа его определения:

    1. TypedDict — схема типов для словарей. Она позволяет выполнять статическую проверку, но не предусматривает проверку во время выполнения.
    2. Pydantic — инструмент, который проверяет данные во время выполнения и выдает ошибки при несоответствии типов.

    Когда состояние представляет собой объект Pydantic BaseModel, каждый атрибут проходит проверку. Если в него поместить сложный объект, такой как клиент ChromaDB, сразу появится ошибка. Установка параметра arbitrary_types_allowed=True в классе состояния позволяет использовать пользовательские типы без проблем.

    Остается более серьезная проблема. Такие функции LangGraph, как инструменты контроля состояния, постоянная память и механизмы отслеживания LangSmith, иногда сериализуют и копируют состояние. Простые типы сериализуются без проблем, но экземпляр ChromaDB может вызвать ошибку сериализации. Сериализация — это просто преобразование данных в форму, которую можно сохранить или отправить.

    Более безопасный подход заключается в полном исключении ChromaDB из состояния системы. Это также способствует более чистой архитектуре, поскольку ресурсы базы данных не должны переплетаться с обычными данными. Узлы, которым нужна база данных, всё равно могут к ней обращаться через аргумент config, передаваемый в функцию aupdate_state():

    app.aupdate_state(
        config={
            "configurable": {
                "thread_id": thread_id,
                "recursion_limit": 30,
                "vector_store": vector_store,      # Pass the Vector DB here
            }
        }
    )
    

    Асинхронная обработка и ловушка функции input()

    Одним из значительных преимуществ LangGraph является возможность асинхронной обработки. Он предоставляет асинхронные аналоги синхронных функций: ainvoke() вместо invoke() и aupdate_state() вместо update_state(). С их помощью можно создавать асинхронные узлы и более отзывчивые приложения.

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

    Решение заключается в сборе данных от пользователя вне узлов и их отправке в граф с помощью aupdate_state():

    await app.aupdate_state(
        config={
            "configurable": {"thread_id": thread_id, "recursion_limit": 30}
        },  # adjust as needed
        values={
            "user_query": user_query  # Send the user inputs using the 'values' property
        },
    )
    

    Локальная или облачная транскрипция

    Транскрипция является отправной точкой всего проекта, но стала узким местом. Первая попытка использовала локальную модель преобразования речи в текст whisper-small, что привело к двум проблемам:

    1. Точность. Это приемлемая модель для локальных установок с низкими требованиями к оборудованию, но её точность остается средней по сравнению с другими вариантами.
    2. Задержка. При ограниченных ресурсах создание транскрипции занимало очень много времени. Показатель в реальном времени на тестовом устройстве составлял примерно 1,03, что означало, что транскрипция генерировалась столько же времени, сколько длилось видео. Для видео длительностью более пяти минут пользователи вынуждены были ждать столько же времени, прежде чем получить первый результат.

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

    Данные не были конфиденциальными, поэтому использование облачного сервиса через API Groq легко оправдывалось. Это решило оба проблемных вопроса. Точность улучшилась благодаря тому, что Groq использует модель whisper-large-v3-turbo, которая в большинстве случаев превосходит whisper-small. Задержка сократилась из-за более мощного облачного оборудования, что снизило время отклика до примерно 0.15.

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

    Краткий чек-лист

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

    • Узнайте, сколько фрагментов генерируется из типичного короткого транскрипта. Количество один является тревожным сигналом, независимо от того, что обещает инструмент разделения.
  • Убедитесь, что размер фрагментов, полученных с помощью сплиттера и преобразованных в токены, укладывается в пределы допустимого размера входных данных для модели эмбеддингов. Отрезание текста никогда не приводит к ошибкам, поэтому его необходимо проверять специально.
  • Прочитайте несколько полученных фрагментов по отдельности. Если какой-либо из них не имеет смысла без своих соседей, значит уровень перекрытия слишком низок.
  • Изолируйте клиентов и соединения, такие как хранилище векторов, от состояния графа и передавайте их через настройки.
  • Перед тем как винить фреймворк в медленной работе, проверьте узлы графа на наличие блокирующих вызовов, таких как input() или синхронные сетевые запросы.
  • Измерьте скорость транскрипции на машине, на которой она будет выполняться, и определите, позволяют ли данные использование хостингового сервиса.
  • Что не учтено

    Эти заметки охватывают лишь некоторые из проблем, с которыми сталкиваются при первом использовании LangGraph. Другие темы требуют отдельного рассмотрения: системные промпты, оценка работы системы и её компонентов, создание REST-конечных точек и развертывание в облаке.