Was bei einem korrigierenden RAG-Pipeline für YouTube-FAQs mit LangGraph schiefging.
Chunking, Einschränkungen bei der Embedding-Verarbeitung, LangGraph-Zustand, Probleme bei asynchroner Verarbeitung und Transkriptionsgeschwindigkeit: Die Fehler beim Aufbau eines YouTube-FAQ-Bots und wie jeder davon behoben wurde.
Quellen: Building a Corrective RAG Pipeline for YouTube Q&A: What Broke and Why?
Ein korrigierendes RAG-System ist eine retrieval-augmented generation-Anlage, die im Vergleich zur einfachen Version drei zusätzliche Komponenten aufweist:
- Ein Bewertungsmodell, das feststellt, ob jeder abgerufene Textabschnitt zur Frage des Benutzers relevant ist.
- Ein Umformulator für Anfragen, der die Frage so anpasst, dass bessere Ergebnisse erzielt werden können.
- Eine Alternative zur Websuche, falls keine der abgerufenen Informationen relevant sind.
Das Projekt hinter diesen Notizen war ein Frage-Antwort-Bot für YouTube-Videos. Man gibt ihm eine Video-URL und eine Frage, und er antwortet darauf. Er kann auch mit Videos umgehen, die keinen Transkript haben – was wichtig ist, da LangChains YouTube-Loader nur dann funktioniert, wenn bereits ein Transkript vorhanden ist. Der Ablauf ist folgender: Das Video wird heruntergeladen, mit einem Sprach-zu-Text-Modell (Whisper) transkribiert, der Text eingebettet, die Vektoren in ChromaDB gespeichert und anschließend werden Fragen über ein LangGraph-Graph beantwortet.
Weil die Aufgabe das Tracking des Zustands, Wiederholungsversuche sowie Rückfallstrategien erforderte, eignete sich LangGraph besser dafür als eine LangChain-Kette oder eine Ansammlung von if-else-Schleifen. Die Arbeit mit Knoten, Kanten und einem gemeinsamen Zustandsobjekt zeigt außerdem auf, wie eine RAG-Anwendung tatsächlich funktioniert. Mehrere Fehler im Laufe des Prozesses lehrten mehr als die Erfolge, und sie sind unten aufgelistet.
Chunking
In jedem RAG-System ist das Chunking der Schritt, der schweigend versagt, wenn er falsch durchgeführt wird. Die gängigen Ansätze sind:
- Durch rekursive Zeichensegmentierung wird eine Liste von Trennzeichen durchlaufen, wobei der Text so zerschnitten wird, bis die Teile in einen bestimmten Größenlimit passen.
- Semantisches Chunking gruppiert Sätze nach Bedeutung – beispielsweise durch Bewertung der Ähnlichkeit der Sätze mit einem BERT-ähnlichen Modell und Zusammenfassung der ähnlichsten Sätze.
- Dokumentsspezifische Aufteilung folgt dem Format der Quelle und ist typisch für Code.
Zuerst schien semantisches Chunking eine natürliche Wahl zu sein. Videos behandeln jedes denkbare Thema, daher erschien eine Gruppierung nach Bedeutung sinnvoll. Dann trat jedoch etwas Seltsames auf: Bei Transkripten von etwa zehn Minuten lieferte der Splitter oft nur einen einzigen Chunk. Genauere Untersuchungen erklärten den Grund dafür. Kurze Videos befassen sich in der Regel ausführlich mit einem einzigen Thema, und bei semantischem Chunking gibt es nichts zu teilen, wenn der gesamte Text sich auf eine Sache bezieht.
Dann kam die rekursive Zeichensegmentierung, zusammen mit einem weiteren Fehler: chunk_overlap wurde auf null gesetzt. Die Chunks an sich waren in Ordnung, doch die Ergebnisse verschlechterten sich. Ohne Überlappung enthielt ein abgerufener Chunk keinerlei Hinweise auf das, was davor kam, wodurch das Generierungsmodell keinen Kontext darüber hatte, worauf sich der Chunk bezog, und oft Details miteinander vermischte.
Der gewählte Ansatz bestand aus rekursivem Aufteilen mit echtem Überlappungsbereich, wobei für diesen Anwendungsfall keine semantische Aufteilung vorgenommen wurde. Er konnte sowohl kurze als auch lange Videos verarbeiten und erzeugte bessere Transkriptabschnitte. Bei einer kleinen Testgruppe von zehn Abfragen mit k=5 erreichte der Prozess einen durchschnittlichen umgekehrten Rang von 0,81. Da diese Stichprobe sehr klein ist, sollte sie als grober Indikator und nicht als Benchmark angesehen werden.
Auswahl des Embedding-Modells
Der Bot muss mit jedem Video umgehen können, wodurch die Themen alle Bereiche abdecken. Ein auf einen bestimmten Bereich spezialisiertes Embedding-Modell kann schnell versagen, wenn ein Video außerhalb seines Trainingsbereichs liegt. Für allgemeine Verwendung und bei begrenzter Hardware wurde all-MiniLM-L6-v2 gewählt – ein schnelles, leichtes Modell, das fast überall akzeptabel funktioniert.
Das Problem ist eine Eingabegrenze. Dieses Modell akzeptiert nur etwa 256 Token, und alles Längere wird abgeschnitten, was bedeutet, dass ein langer Datensatz nur mit seinen ersten 256 Token eingebettet wird. Die Folge sind stille Datenverluste und ungleichmäßige Einbettungen, was die Qualität der Anwendung beeinträchtigt.
Die Lösung besteht darin, chunk_size unter der Grenze des Modells zu halten. Beim Aufteilen nach Zeichen sollte man unter der Token-Grenze bleiben, wobei als Faustregel gilt, dass ein Token etwa vier Zeichen umfasst. Ein Splitter mit 800 Zeichen entspricht etwa 200 Token, was passt:
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,
)
Sobald die beiden häufigsten Probleme bei der Datenabfrage beseitigt sind, beziehen sich die nächsten auf LangGraph selbst.
LangGraph-Zustand und ChromaDB
Jeder Knoten in einer LangGraph-Anwendung teilt Daten über ein Zustandsobjekt. Jeder Knoten erhält diesen Zustand und aktualisiert ihn. Es gibt zwei gängige Wege, ihn zu definieren:
TypedDict, ein Typ-Hint-Schema für Wörterbücher. Es ermöglicht eine statische Überprüfung, bietet aber keine Validierung zur Laufzeit.- Pydantic, das die Daten zur Laufzeit validiert und bei Typunterschieden Fehler auslöst.
Wenn der Zustand ein Pydantic BaseModel ist, werden alle Attribute überprüft. Wenn man ein komplexes Objekt wie einen ChromaDB-Client hineinsetzt, tritt sofort ein Fehler auf. Durch das Setzen von arbitrary_types_allowed=True in der Zustandsklasse werden benutzerdefinierte Typen ohne Probleme akzeptiert.
Ein tieferliegendes Problem bleibt bestehen. LangGraph-Funktionen wie Checkpointer, persistente Speicherung sowie LangSmith-Tools zur Überwachung serialisieren und kopieren manchmal den Zustand. Einfache Datentypen lassen sich problemlos serialisieren, doch eine ChromaDB-Instanz kann zu einem Serialisierungsfehler führen. Unter Serialisierung versteht man dabei lediglich die Umwandlung von Daten in eine Form, die gespeichert oder übertragen werden kann.
Ein sichererer Ansatz besteht darin, ChromaDB vollständig außerhalb des Zustands zu belassen. Dadurch entsteht auch eine sauberere Architektur, da Ressourcen einer Datenbank nicht mit gewöhnlichen Daten vermischt werden sollten. Knoten, die auf die Datenbank zugreifen müssen, können diese weiterhin über den config-Parameter erreichen, der an aupdate_state() übergeben wird:
app.aupdate_state(
config={
"configurable": {
"thread_id": thread_id,
"recursion_limit": 30,
"vector_store": vector_store, # Pass the Vector DB here
}
}
)
Asynchrone Ausführung und die Falle von input()
Ein großer Vorteil von LangGraph ist die asynchrone Ausführung. Es bietet asynchrone Entsprechungen zu den synchronen Aufrufen: ainvoke() anstelle von invoke() und aupdate_state() anstelle von update_state(). Mit diesen Funktionen können Sie asynchrone Knoten sowie eine reaktivere Anwendung schreiben.
Die einfachste Möglichkeit, dies zu ruinieren, besteht darin, input() innerhalb eines Knotens aufzurufen. Dadurch wird die Ausführung blockiert, während auf den Benutzer gewartet wird – selbst eine vollständig asynchrone Anwendung verliert somit den Vorteil der Asynchronität.
Die Lösung besteht darin, die Benutzereingaben außerhalb der Knoten zu sammeln und sie über aupdate_state() in den Graphen zu senden:
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
},
)
Lokale oder Cloud-Transkription
Die Transkription ist der Ausgangspunkt des gesamten Projekts und wurde zu einem Engpass. Der erste Versuch nutzte ein lokales Sprach-zu-Text-Modell, whisper-small, mit zwei Problemen:
- Auflösung. Es handelt sich um ein geeignetes Modell für lokale Umgebungen mit geringen Hardwareanforderungen, doch seine Auflösung ist im Vergleich zu anderen Optionen nur mäßig.
- Verzögerung. Bei begrenzten Ressourcen dauerte die Erstellung der Transkription sehr lange. Der Echtzeitfaktor auf dem Testgerät lag bei etwa 1,03, was bedeutet, dass die Transkription genauso lange dauerte wie das Video. Bei Videos über fünf Minuten mussten die Nutzer so lange warten, bis sie eine erste Antwort erhielten.
Der Echtzeitfaktor ist die Transkriptionsdauer geteilt durch die Länge des Audios. Da das Q&A-Verfahren erst ab Erstellung der Transkription möglich ist, beeinträchtigt jeder Wert über 1 die Benutzererfahrung.
Die Daten waren nicht vertraulich, daher war es einfach zu rechtfertigen, auf einen Cloud-Dienst über die Groq API umzusteigen. Dadurch wurden beide Probleme gelöst. Die Genauigkeit verbesserte sich, da Groq whisper-large-v3-turbo bereitstellt, das in den meisten Fällen besser ist als whisper-small. Die Latenz verringerte sich, da leistungsstärkeres Cloud-Hardware das Echtzeit-Niveau auf etwa 0,15 senkte.
Diese Entscheidung hing davon ab, dass die Daten nicht sensibel waren. Für private oder vertrauliche Materialien würde sich die Entscheidung völlig anders gestalten.
Kurze Überprüfungsliste
Die oben genannten Erkenntnisse lassen sich auf einige Überprüfungen zusammenfassen, die vor dem Vertrauen in eine solche Pipeline durchgeführt werden sollten:
- Erstellen Sie eine Auflistung der Anzahl der Blöcke, die ein typischer kurzer Transkriptabschnitt erzeugt. Eine Anzahl von eins ist ein Warnsignal, unabhängig davon, was der Splitter verspricht.
input() oder synchronen Netzwerkanfragen, bevor Sie das Framework für die Langsamkeit verantwortlich machen.Was fehlt
Diese Notizen behandeln nur einige der Probleme, auf die man bei der ersten Anwendung von LangGraph stößt. Andere Themen erfordern eine eigene Behandlung: Systemanfragen, die Bewertung des Systems sowie seiner einzelnen Komponenten, das Erstellen von REST-Endpunkten und der Einsatz in der Cloud.