Startseite / Artikel

RAG mit LangChain von Grund auf: Lader, Blöcke, Embeddings, Bewertung

Erstellen Sie schrittweise ein Dokumenten-FAQ-System: Laden von Dateien, Aufteilung in Blöcke, Embeddings, die Phasen der Indizierung und Abfrage sowie Methoden zur Bewertung von Abfragen und Antworten.

Verlag: REACTAPP.TOP8 Min. Lesezeit1939 Wörter

Quellen: Building a RAG System with LangChain

Ein System, das Ihre Dokumente liest und Fragen dazu beantwortet, gehört zu den nützlichsten Dingen, die man heutzutage mit einem LLM erstellen kann. Dieses Konzept wird RAG genannt – Retrieval-augmented Generation. Hier erfahren Sie alles über die einzelnen Komponenten, deren Zweck sowie wie Sie feststellen können, ob Ihre Implementierung gut funktioniert.

Was LangChain ist

LangChain ist weder ein Modell noch eine Datenbank. Es handelt sich um einen Framework, der eher einem universellen Adapter ähnelt. Es gibt Dutzende von LLM-Anbietern mit ihren eigenen APIs, Dutzende von Vektordatenbanken sowie Dutzende von Tools zum Laden von Dokumenten. Ohne eine Zwischenschicht bedeutet der Einsatz von GPT-4 heute und Claude morgen, dass man die Hälfte des Codes neu schreiben muss. LangChain bietet eine einheitliche Schnittstelle für all diese Komponenten.

Der Name leitet sich von seiner zweiten Funktion ab. Eine echte LLM-Anwendung ist selten nur ein einziger Modellaufruf. Man lädt möglicherweise ein Dokument, fasst es zusammen, führt einen weiteren Prompt auf der Zusammenfassung aus und formatiert das Ergebnis. Eine Kette verbindet diese Schritte zu einem Pipeline-Prozess. Bei RAG beginnt die Kette mit der Frage des Benutzers. Diese wird eingebettet, das Ergebnis dient als Auslöser für eine Datenbanksuche, und die wichtigsten Treffer werden in ein Prompt-Muster eingefügt. Das LLM wird mit diesem Prompt aufgerufen, seine Antwort wird analysiert und das analysierte Ergebnis an den Benutzer zurückgesendet. LangChain stellt die Bausteine für jeden Schritt bereit, sodass man nicht alles von Hand zusammenfügen muss.

Dokumente laden

Zuerst müssen die Dokumente eingereicht werden – und zwar nicht als rohe Bytes. Egal ob es sich um ein PDF, eine Webseite, eine CSV-Datei, eine Word-Datei oder einen Ordner mit Markdown-Dateien handelt: Ein Lader wandelt sie in ein standardmäßiges Document-Objekt mit zwei Feldern um – page_content für den Text und metadata dafür, woher die Daten stammen (Seite, URL oder weitere nützliche Informationen). Die Metadaten sind später wichtig, denn sie ermöglichen es, den Nutzern anzuzeigen, aus welcher Quelle eine Antwort stammt.

from langchain.document_loaders import PyPDFLoader

loader = PyPDFLoader("handbook.pdf")
documents = loader.load()

Dadurch entsteht pro Seite des PDFs ein Document, das seinen Text sowie seine Seitenzahl enthält. Das ist eine Gewohnheit, die man früh entwickeln sollte: Man sollte immer die Herkunft eines Textabschnitts dabei beibehalten, denn jeder weitere Schritt – von der Aufteilung in Teile bis hin zur Zitierung einer Quelle in der endgültigen Antwort – hängt von dieser Spur ab. Wenn man den Lader auf eine andere Dateityp wechselt, behält die Ausgabe weiterhin dieselbe Struktur.

Aufteilung in Teile

Ein Dokument kann ein 200-seitiges PDF sein. Selbst mit einem sehr großen Kontextfenster ist es verschwenderisch, ganze Bücher in jede Anfrage aufzunehmen – das ist teuer und oft schlechter, als dem Modell fokussierte Auszüge zur Verfügung zu stellen. Lange Dokumente werden daher in Teile mit einigen hundert Tokens aufgeteilt, und nur die relevantesten Teile werden an das LLM weitergeleitet, wenn jemand eine Frage stellt.

Zwei Einstellungen sind am wichtigsten. Chunk-Größe bezeichnet die Länge jedes Teils: Ist sie zu klein, geht der Kontext verloren; ist sie zu groß, werden irrelevante Texte mit einbezogen. Etwa 500 Token bilden einen angemessenen Ausgangspunkt. Überlappung gibt an, wie viel sich benachbarte Chunks teilen. Ohne Überlappung verliert ein Satz, der sich über zwei Chunks erstreckt, seine Bedeutung, während bei Überlappung beide Chunks den Zusammenhang erkennen.

RecursiveCharacterTextSplitter ist die übliche Wahl. Er bevorzugt natürliche Grenzen und versucht zunächst Absätze, dann Sätze und schließlich Wörter zu verwenden, wobei er nur als letztes Mittel mitten im Wort trennt. Es gibt auch spezialisierte Optionen: MarkdownHeaderTextSplitter für Dokumente mit Überschriften sowie TokenTextSplitter, wenn man echte Token und nicht nur Zeichen zählen möchte.

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50
)
chunks = splitter.split_documents(documents)

Das Aufteilen in Blöcke scheint trivial zu sein, hat aber einen starken Einfluss auf die Qualität. Es ist außerdem die Einstellung, die die Leute zuletzt anpassen, obwohl eine schlechte Aufteilung alles danach stillschweigend beeinträchtigt. Wenn die Blöcke falsch sind, kann kein Retrieval-Trick das System retten.

Embeddings

Computer können nicht direkt an Rohtext fragen: „Welcher Block ähnelt meiner Frage?“ Ein Embedding-Modell, auch Encoder genannt, wandelt Text in einen Vektor – eine Liste von Zahlen – um, wobei die entscheidende Eigenschaft darin besteht, dass Texte mit ähnlicher Bedeutung ähnliche Vektoren erhalten.

Stellen Sie sich vor, jedem Text wird eine Adresse in einem Raum mit Hunderten oder Tausenden von Dimensionen zugewiesen. Ein bellender Hund und ein jaulendes Welpen erhalten benachbarte Adressen, während ein Börsencrash weit entfernt liegt. Das Vergleichen von Bedeutungen wird somit zu einer geometrischen Frage: Wie nah sind zwei Punkte zueinander?

Zwei Klarstellungen helfen. Erstens erzeugt der Encoder die Vektoren, nicht die Vektordatenbank. Chroma, Pinecone, Weaviate und Qdrant speichern lediglich Vektoren und suchen sie schnell ab – keines dieser Systeme versteht Sprache. Die Leute verwechseln das ständig. Zweitens hängt die Größe der Vektoren vom Encoder ab. OpenAI’s text-embedding-3-small liefert 1536 Dimensionen, während ein Open-Source-Modell wie all-MiniLM-L6-v2 384 Dimensionen erzeugt. Mehr Dimensionen ermöglichen es, mehr Nuancen zu erfassen – auf Kosten größerer Speicherkapazitäten und langsamerer Suchvorgänge.

Echte Embeddings sind schwer vorstellbar, weshalb man sie in 2D oder 3D abbildet. Wenn man eine Menge Wörter einbettet und diese abbildet, entstehen Cluster: Tiere zusammen, Früchte an einer anderen Stelle, Fahrzeuge in einer weiteren Ecke. Niemand hat dem Modell etwas über diese Kategorien gesagt. Es hat aus riesigen Mengen an Text gelernt, dass „Hund“ und „Katze“ in ähnlichen Kontexten vorkommen.

Das ist es, was RAG funktionieren lässt. Wenn ein Benutzer fragt, was das Unternehmen über Elternurlaub sagt, landet der Vektor der Frage in der Nähe von Abschnitten über „Familienurlaubsleistungen“ oder „Urlaub für frischgebackene Eltern“, obwohl diese Begriffe unterschiedlich sind.

Der Prozessablauf

Es gibt zwei Phasen. Die Indizierung findet einmal bei der Einrichtung statt: Es werden die Dokumente geladen, in Blöcke aufgeteilt, jeder Block eingebettet und die Vektoren gespeichert. Neue Dokumente werden später einfach eingebettet und hinzugefügt. Die Auswertung erfolgt bei jeder Anfrage: Die Anfrage wird mit demselben Encoder eingebettet, die nächstgelegenen Blöcke in der Vektordatenbank gefunden, zusammen mit der Anfrage in einen Prompt eingefügt und an ein LLM gesendet. Dieses basiert seine Antwort auf Ihren Dokumenten und nicht auf Vermutungen. Eine einzige Vektordatenbank dient beiden Phasen – sie wird während der Indizierung geschrieben und während der Auswertung gelesen. Das ist die gesamte Architektur. Da die Indizierung nur einmalig Kosten verursacht, beeinflusst der langsame Teil (das Einbetten von Tausenden von Blöcken) die Wartezeit des Benutzers nicht. Nur das Einbetten der einzelnen Anfrage, die Suche und der Aufruf des LLMs haben Auswirkungen auf die Wartezeit.

Zur Laufzeit sind vier Akteure beteiligt: der Benutzer, Ihr Anwendungscode, der Retriever und das LLM. Der Benutzer sendet eine Frage an Ihren Code. Ihr Code bittet den Retriever um relevante Datenabschnitte, und der Retriever fügt die Frage ein und durchsucht die Datenbank. Die K top ähnlichsten Abschnitte werden zurückgegeben. Ihr Code erstellt auf Basis dieser Abschnitte sowie der ursprünglichen Frage einen Prompt und sendet ihn an das LLM, welches aus dem bereitgestellten Kontext eine Antwort generiert. Ihr Code gibt anschließend diese Antwort zurück.

LangChain abdeckt den größten Teil davon mit zwei Abstraktionen. Der LLM (oder ChatModel) umhüllt Ihr Modell, und der Retriever kümmert sich um die Abfrage im Vektorlager. Wenn Sie diese miteinander verknüpfen, übernimmt das Framework den Rest:

from langchain.chains import RetrievalQA

qa_chain = RetrievalQA.from_chain_type(
    llm=ChatOpenAI(),
    retriever=vectorstore.as_retriever(),
    return_source_documents=True
)
response = qa_chain.invoke({"query": "What is our parental leave policy?"})

Das ist ein funktionsfähiges RAG-System in nur wenigen Zeilen – und genau hier ist der Reiz, aufzuhören. Tun Sie das nicht. Ein System, das antwortet, ist noch kein System, dem man vertrauen kann.

Warum bewerten?

Die Falle besteht darin, eine Frage zu stellen, eine plausibel klingende Antwort zu erhalten und das für erledigt zu halten. RAG versagt auf subtile Weise: Der Informationsabrufmechanismus kann falsche Datenabschnitte heranziehen, das große Sprachmodell kann selbst bei gutem Kontext Halluzinationen erzeugen, und die Aufteilung in Abschnitte kann eine Antwort in zwei Teile spalten. Die Bewertung zeigt Ihnen an, welcher Teil fehlerhaft ist – der Informationsabruf, die Generierung oder beides, da jede Komponente eine andere Lösung erfordert. Zudem liefert sie Ihnen einen Maßstab, denn Sie werden weiterhin unterschiedliche Abschnittsgrößen, Encoder und Anfragen ausprobieren – ohne Messung bleibt jede Veränderung ein Raten.

Metriken für den Informationsabruf

Vier Standardmetriken, von denen jede etwas anderes verrät:

  • Recall@K: Von den Dokumenten, die erscheinen sollten, wie viele befinden sich unter den besten K? Bei 4 relevanten Dokumenten im Korpus und 3 davon in den Top 5 beträgt Recall@5 0,75. Ein hoher Recall bedeutet, dass nur wenige Dokumente übersehen werden.
  • Precision@K: Von den K zurückgegebenen Ergebnissen, wie viele waren relevant? Drei relevante Ergebnisse von insgesamt fünf ergeben 0,60. Hohe Präzision bedeutet wenig „Rauschen“.
  • MRR (Mean Reciprocal Rank): Nur die Position des ersten korrekten Ergebnisses zählt. Ein erster Treffer an Position 2 erhält eine Bewertung von 0,50, an Position 1 von 1,0. Er belohnt das schnelle Finden einer richtigen Antwort.
  • nDCG (Normalized Discounted Cumulative Gain): Der am präzisesten formulierte Wert. Er berücksichtigt, wie relevant jedes Dokument ist und wo es in der Rangliste steht, sodass höher rangierte relevante Elemente stärker gewichtet werden. Zudem wird er mit der besten möglichen Rangliste verglichen.

In der Praxis gibt es zwei Bewertungskriterien: eines für die Vollständigkeit (Recall) und eines für die Qualität der Sortierung (MRR oder nDCG).

Antwortqualität

Auch eine perfekte Suche kann zu einer schlechten Antwort führen, daher sollten die Antworten getrennt bewertet werden. Man beginnt mit derselben sorgfältig ausgewählten Testmenge, in der jede Eintragung eine Frage mit den zu liefernden Antwortteilen sowie einer Referenzantwort verbindet. Die vier oben genannten Suchmetriken decken die erste Hälfte ab. Für die Antworten selbst werden weitere Kriterien herangezogen:

  • Aufrichtigkeit: Ist die Antwort auf dem gefundenen Kontext basiert oder erfunden?
  • Relevanz: Beantwortet sie die Frage?
  • Korrektheit: Wie unterscheidet sie sich von der richtigen Antwort?
  • Vollständigkeit: Umfasst sie alles, was sie beinhalten sollte?

Die übliche Methode ist ein zweiter LLM, der als Bewertungsinstanz fungiert. Geben Sie ihm die Frage, die Antwort sowie die korrekte Lösung und bitten Sie ihn um eine Bewertung in jeder Dimension. Es ist nicht perfekt, aber es lässt sich skalieren.

Der Testdatensatz

Jeder der oben genannten Bewertungsschritte stützt sich auf eines: eine Sammlung von Fragen mit bekannten, richtigen Antworten.

Das ist der am meisten unterschätzte Bestandteil. Ohne ihn lässt sich nichts bewerten. Ziel ist es, 50 bis einige hundert Beispiele zu haben, wobei jedes ein realistisches Problem, die Abschnitte mit der Antwort sowie eine ideale Reaktion enthält. Die Quellen sind echte Benutzeranfragen, sofern das System im Betrieb ist, oder synthetische Beispiele: Geben Sie Ihre Dokumente an einen LLM weiter, lassen Sie ihn Frage-Antwort-Paare erstellen und überprüfen sowie bearbeiten Sie diese anschließend.

Schützen Sie diesen Datensatz. Sobald er existiert, wird jede Änderung daran gemessen, und so können Sie feststellen, ob eine Änderung hilfreich war.

Wo es schwieriger wird

Kurz gesagt ist RAG folgendes: Die Dokumente werden in Abschnitte aufgeteilt, jeder Abschnitt wird vektorisiert und gespeichert, Benutzerabfragen werden eingebettet, die nächstgelegenen Vektoren gefunden und diese Abschnitte an ein LLM übergeben. LangChain stellt für jeden Schritt Standardkomponenten bereit, und Bewertungen zeigen, wie gut es funktioniert. Ein echtes Produkt stellt schwierigere Fragen: Die richtige Größe der Abschnitte für die Dokumente, welcher Encoder am besten geeignet ist, wie man den Index aktuell hält, wann erneut gerankt werden sollte und ob eine hybride Suche verwendet werden soll. Diese Entscheidungen unterscheiden eine einfache Demo von etwas Nützlichem, basieren aber auf der oben beschriebenen Grundlage.