Strona główna / Artykuły

Co poszło nie tak w procesie RAG służącym do pytań i odpowiedzi na YouTube z użyciem LangGraph.

Dzielenie na fragmenty, ograniczenia embeddingów, stan LangGraph, pułapki asynchronicznego działania oraz szybkość transkrypcji: błędy znalezione podczas tworzenia bota do pytań i odpowiedzi na YouTube oraz sposoby ich naprawy.

Wydawca: REACTAPP.TOP7 min czytania1520 słów

Źródła: Building a Corrective RAG Pipeline for YouTube Q&A: What Broke and Why?

System RAG korygujący to rozwiązanie typu retrieval-augmented generation, które zawiera trzy dodatkowe elementy w porównaniu z wersją prostą:

  1. Model oceniający, który określa, czy każdy pobraany fragment jest istotny dla pytania użytkownika.
  2. Narzędzie do przepisywania zapytań, które modyfikuje pytanie w celu uzyskania lepszych wyników.
  3. Rozwiązanie awaryjne w postaci wyszukiwania w internecie, gdy żaden z pobraanych fragmentów nie jest istotny.

Projektem stojącym za tymi informacjami był bot do odpowiadania na pytania dotyczące filmów na YouTube. Wystarczy podać mu URL filmu i pytanie, a on udzieli odpowiedzi. Radzi sobie również z filmami bez napisów, co jest ważne, ponieważ narzędzie do ładowania filmów z YouTube w LangChain działa tylko wtedy, gdy istnieją już napisy. Sekwencja działań wygląda następująco: pobrać film, przepisać jego treść za pomocą modelu rozpoznawania mowy (Whisper), wstawić tekst, przechować wektory w ChromaDB, a następnie odpowiadać na pytania za pomocą grafu LangGraph.

Ponieważ zadanie wymagało śledzenia stanu, prób ponownych oraz rozwiązań awaryjnych, LangGraph lepiej się do niego nadawał niż łańcuch LangChain czy zbiór gałęzi typu if-else. Praca z węzłami, krawędziami oraz wspólnym obiektem stanu pokazuje również, w jaki sposób faktycznie funkcjonuje aplikacja typu RAG. Kilka błędów popełnionych w trakcie pracy nauczyło więcej niż sukcesy, a są one przedstawione poniżej.

Dzielenie na fragmenty

W każdym systemie RAG dzielenie tekstu na fragmenty to etap, który w przypadku błędnego wykonania działa bez żadnych objawów. Powszechne metody to:

  1. Rrecesywne dzielenie tekstu na znaki polega na przeglądaniu listy separatorów i cięciu tekstu, aż fragmenty będą odpowiadać określonemu limitowi rozmiaru.
  2. Dzielenie semantyczne grupuje zdania według znaczenia, na przykład poprzez ocenę podobieństwa zdań za pomocą modelu w stylu BERT i łączenie najbliższych do siebie.
  3. Dzielenie specyficzne dla dokumentu uwzględnia format źródła i jest typowe dla kodu.
  • Hierarchiczne dzielenie na elementy rodzicielskie i potomne polega na zachowaniu dużego segmentu rodzicielskiego oraz mniejszych segmentów potomnych wyciętych z niego.
  • Początkowo dzielenie semantyczne wydawało się naturalnym rozwiązaniem. Filmy obejmują każdy możliwy temat, więc grupowanie według znaczenia wydawało się sensowne. Następnie pojawił się dziwny problem: dla nagrań trwających nieco ponad dziesięć minut narzędzie dzielące często zwracało tylko jeden segment. Głębsze badanie wyjaśniło dlaczego – krótkie filmy zazwyczaj traktują w szczegółach jeden temat, a dzielenie semantyczne nie ma czego dzielić, gdy cały tekst dotyczy jednej rzeczy.

    Następnie zastosowano rekurencyjne dzielenie na znaki, co przyniosło drugi błąd: ustawienie chunk_overlap na zero. Same segmenty były w porządku, ale jakość odpowiedzi się pogorszyła. Bez nakładania się segmentów uzyskany fragment nie zawierał żadnych wskazówek na temat tego, co było przed nim, więc model generujący nie miał kontekstu dotyczącego tego, do czego odnosi się dany fragment, i często łączył ze sobą różne szczegóły.

    Wybranym podejściem było rekurencyjne dzielenie z rzeczywistym nakładaniem się fragmentów, bez wykorzystywania mechanizmów segmentacji semantycznej w tym przypadku użycia. Metoda ta radziła sobie zarówno z krótkimi, jak i długimi filmami, tworząc lepsze fragmenty transkrypcji. Na małej próbie dziesięciu zapytań z k=5 proces osiągnął średnią rangę odwrotną wynoszącą 0,81. Ta próba jest bardzo mała, więc należy ją traktować jako przybliżony wskaźnik, a nie jako standard oceny.

    Wybór modelu embeddingu

    Bot musi obsługiwać dowolne filmy, więc tematy obejmują wszystkie dziedziny. Model embeddingu specyficzny dla danej dziedziny może szybko przestać być skuteczny, gdy film wykracza poza obszar jego szkolenia. Do zastosowań ogólnych, przy umiarkowanej mocy obliczeniowej, wybrano all-MiniLM-L6-v2 – szybki i lekki model, który sprawdza się prawie wszędzie.

    Problemem jest ograniczenie ilości danych wejściowych. Ten model przyjmuje jedynie około 256 tokenów, a wszystko dłuższe jest skracane, co oznacza, że długi fragment jest embedowany wykorzystując tylko pierwsze 256 tokenów. Skutkiem tego jest cicha utrata danych oraz nierównomierne embedowanie, co negatywnie wpływa na jakość aplikacji.

    Rozwiązaniem jest utrzymywanie wartości chunk_size poniżej limitu modelu. Podczas dzielenia na fragmenty według znaków należy zachować się w granicach limitu tokenów, kierując się zasadą, że jeden token odpowiada mniej więcej czterem znakom. Dzielnik ustawiony na 800 znaków daje około 200 tokenów, co jest wystarczające.

    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,
    )
    

    Gdy dwa najczęstsze problemy z wyszukiwaniem zostały rozwiązane, kolejne dotyczą samego LangGraph.

    Stan LangGraph i ChromaDB

    Każdy węzeł w aplikacji LangGraph dzieli się danymi za pośrednictwem obiektu stanu. Każdy węzeł otrzymuje ten stan i go aktualizuje. Istnieją dwa powszechne sposoby jego zdefiniowania:

    1. TypedDict – schemat wskazówek typu dla słowników. Umożliwia sprawdzanie statyczne, ale nie ma walidacji w czasie wykonywania.
    2. Pydantic – narzędzie walidujące dane w czasie wykonywania i wywołujące błędy przy niezgodnościach typów.

    Gdy stanem jest Pydantic BaseModel, każda atrybutyka jest walidowana. Jeśli umieścimy w nim złożony obiekt, np. klienta ChromaDB, natychmiast pojawi się błąd. Ustawienie arbitrary_types_allowed=True w klasie stanu pozwala na przepuszczenie niestandardowych typów bez żadnych problemów.

    Po wciąż istnieje poważniejszy problem. Funkcje LangGraph, takie jak mechanizmy kontrolne, pamięć trwała oraz narzędzia do obserwacji LangSmith, czasami serializują i kopiują stan. Proste typy radzą sobie dobrze z serializacją, ale instancja ChromaDB może spowodować błąd serializacji. Serializacja oznacza po prostu przekształcenie danych w formę, którą można przechować lub wysłać.

    Bardziej bezpieczny projekt całkowicie wyklucza ChromaDB ze struktury stanu. Jest to również czystsza architektura, ponieważ zasób bazy danych nie powinien być łączony z zwykłymi danymi. Węzły, które potrzebują bazy danych, mogą do niej nadal uzyskać dostęp poprzez argument config przekazywany do funkcji aupdate_state():

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

    Wykonywanie asynchroniczne i pułapka funkcji input()

    Jedną z głównych zalet LangGraph jest możliwość wykonywania operacji asynchronicznie. Oferuje on asynchroniczne odpowiedniki funkcji synchronicznych: ainvoke() zamiast invoke() oraz aupdate_state() zamiast update_state(). Dzięki nim można tworzyć węzły asynchroniczne oraz aplikację bardziej responsywną.

    Najprostszym sposobem na zniszczenie tej zalety jest wywołanie funkcji input() wewnątrz węzła. Blokuje ona wykonywanie kodu, czekając na działanie użytkownika, przez co nawet w pełni asynchroniczna aplikacja traci korzyści wynikające z charakteru asynchronicznego.

    Rozwiązaniem jest zbieranie danych wprowadzanych przez użytkownika poza węzłami i przesyłanie ich do grafu za pomocą 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
        },
    )
    

    Tłumaczenie lokalne lub w chmurze

    Tłumaczenie jest punktem wyjścia całego projektu, ale stało się wąskim gardłem. Pierwsza próba wykorzystania lokalnego modelu konwertującego mowę na tekst, whisper-small, napotkała dwa problemy:

    1. Dokładność. Jest to przyzwoity model do użycia w lokalnych środowiskach o niewielkich wymaganiach sprzętowych, ale jego dokładność jest jedynie umiarkowana w porównaniu z innymi opcjami.
    2. Czas reakcji. Przy ograniczonych zasobach tworzenie transkrypcji trwało bardzo długo. Czas reakcji na maszynie testowej wynosił około 1,03, co oznaczało, że tłumaczenie trwało tak długo, jak długi był film. W przypadku nagrań trwających ponad pięć minut użytkownicy musieli czekać tak długo, zanim otrzymali pierwszą odpowiedź.

    Czas reakcji to czas tłumaczenia podzielony przez czas trwania dźwięku. Ponieważ funkcja pytań i odpowiedzi jest blokowana, dopóki nie istnieje transkrypcja, każda wartość powyżej 1 pogarsza doświadczenie użytkownika.

    Dane nie były poufne, więc uzasadnienie przeniesienia ich do usługi chmurowej za pośrednictwem API Groq było proste. Rozwiązało to oba problemy. Dokładność się poprawiła, ponieważ Groq używa modelu whisper-large-v3-turbo, który w większości przypadków przewyższa whisper-small. Opóźnienia również się zmniejszyły, dzięki silniejszemu sprzętowi chmurowemu, który skrócił czas reakcji do około 0.15.

    To rozwiązanie opierało się na tym, że dane nie były wrażliwe. W przypadku materiałów prywatnych lub poufnych decyzja wyglądałaby zupełnie inaczej.

    Krótki lista kontrolna

    Powyższe wnioski sprowadzają się do kilku sprawdzeń, które warto przeprowadzić przed zaufaniem takiej architekturze:

    • Sprawdź, ile fragmentów generuje typowy krótki transkrypt. Liczba jeden to sygnał ostrzegawczy, niezależnie od obietnic narzędzia do dzielenia tekstu.
  • Upewnij się, że rozmiar fragmentów generowanych przez splitter, przeliczony na tokeny, mieści się w limicie wejściowym modelu embedding. Skracanie tekstu nigdy nie powoduje błędu, dlatego należy je celowo sprawdzić.
  • Przeczytaj kilka uzyskanych fragmentów osobno. Jeśli jeden z nich nie ma sensu bez sąsiednich, oznacza to, że stopień nakładania się jest zbyt mały.
  • Zachowaj klienety i połączenia, takie jak magazyn wektorów, poza stanem grafu i przekazuj je przez konfigurację.
  • Zbadaj węzły grafu pod kątem blokujących wywołań, takich jak input() lub synchronicznych żądań sieciowych, zanim obwiniasz framework za wolne działanie.
  • Zmierz czas rzeczywisty transkrypcji na maszynie, na której faktycznie będzie ona wykonywana, i zdecyduj, czy dane pozwalają na korzystanie z usługi hostowanej.
  • Czego brakuje

    Te notatki obejmują jedynie niektóre z problemów napotykanych podczas pierwszego stosowania LangGraph. Inne tematy wymagają osobnego omówienia: komendy systemowe, ocena systemu oraz poszczególnych komponentów, tworzenie punktów końcowych REST oraz wdrażanie w chmurze.