Головна / Статті

Що зламалося в коригувальному потоці 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-кінцевих точок та розгортання у хмарі.