LLMs sind wahnsinnig nützliche Tools, die viel Weltwissen mitbringen. Wissen, das während des Trainings des LLMs gelernt wurde. Dadurch beherrschen sie nicht nur Programmiersprachen, sondern bringen auch viele Kenntnisse über Frameworks und Bibliotheken mit. Benchmarks wie Humanity's Last Exam demonstrieren, dass dies auch immer mehr sehr spezielles Nischenwissen umfasst. Wenn ich ein Problem habe, wie ich Gipsreste von meinem 3D-Druck reinige, frage ich Claude und bekomme eine funktionierende Antwort.
Warum klassische LLMs nicht ausreichen
Aber bei Fragen zu Firmenprozessen und Richtlinien oder ob ich meinen Hund mit ins Büro bringen darf, können sie allein keine Auskunft geben, da diese Daten in der Regel nicht öffentlich und somit nicht Teil des Trainingssets sind. Hier müssen wir dem LLM eine Möglichkeit an die Hand geben, Wissen nachzuschlagen und die generierten Antworten mit diesem externen Wissen zu verbessern. Der Fachbegriff dafür lautet Retrieval Augmented Generation, kurz RAG.
Technisch gesehen ist zwar jede Einbeziehung von externer Information vom Begriff RAG abgedeckt -- sei es eine Websuche oder das Lesen von Skill-Markdown-Dateien. Aber meist ist mit RAG das Durchsuchen von großen Datenmengen gemeint, um eine Frage zu beantworten. Der klassische Fall ist eine Ansammlung von Dokumentation interner Prozesse. Beispielsweise ein Confluence-Dump oder ein SharePoint-Ordner mit sehr vielen PDF-Dateien. Manchmal ist es auch nur ein einzelnes Dokument, das zu lang ist, um auf einmal in den Kontext eines LLMs zu passen, aus dem man relevante Informationen extrahieren möchte.
Bevor wir in die Details einsteigen, sollten wir uns einen Überblick über den Gesamtprozess verschaffen. Ein RAG-System zerfällt in zwei Phasen, die zeitlich getrennt ablaufen. Die eine ist die Offline-Phase, die nur, wenn neue Dokumente dazukommen, deren Indexierung übernimmt. In dem Diagramm auf der linken Seite oben. Die andere ist die Online-Phase, die jedes Mal durchlaufen wird, wenn eine Frage gestellt wird, gekennzeichnet durch grüne Linien.
Die Schritte, die dabei in jeder der Phasen durchgeführt werden, werden wir uns in den folgenden Abschnitten ansehen und jeweils ein Diagramm zeigen, in dem der Schritt, über den wir gerade reden, hervorgehoben ist. Wie die roten Punkte auf öffentlichen Karten mit der "Du bist hier"-Beschriftung.
Die folgenden Abschnitte arbeiten genau diesen Weg ab: erst offline die Indexierung bestehend aus Chunking und Embedding, also Dokumente in Stücke zerlegen und zur Speicherung in einer Vektordatenbank vorbereiten. Dann online die Query-Konstruktion, Suche in der Vektordatenbank, Reranking und Generierung. Wer eine kompakte Implementierung dieser Pipeline sehen möchte, findet sie in der Serie GenAI für Full Stack EntwicklerInnen ab Teil 2 mit TypeScript und Ollama.
Dokumentvorbereitung und Chunking
Generell werden wir uns in diesem Artikel auf rein textbasiertes RAG konzentrieren, ohne darauf einzugehen, wie wir diesen Text bspw. aus PDF-Dateien extrahieren. Speziell PDFs haben eigene Herausforderungen, über die man einen eigenen Blog-Artikel schreiben könnte. Das beginnt bei zuverlässiger Textextraktion und endet noch lange nicht mit eingebetteten Bildern. Und wo wir gerade bei Bildern sind -- auch für die gibt es interessante Ansätze, wie bspw. direkte Bild-Embeddings (etwa mit Modellen wie CLIP) zu nutzen, auf die wir hier aber auch nicht näher eingehen werden.
Warum Chunking notwendig ist
Es gibt zwei voneinander unabhängige Gründe, lange Dokumente in kleine Stücke, die sogenannten Chunks, aufzuteilen. Der eine betrifft das Retrieval: Wie wir gleich sehen werden, übersetzt ein Embedding-Modell ein Stück Text in einen Vektor. Zwängt man ein ganzes Dokument hinein, wird dessen vielfältiger Inhalt zu einem einzigen, verwaschenen Mittelwert. Dieser passt entsprechend auch nur mittelgut zu einer spezifischen Frage. Hinzu kommt, dass Embedding-Modelle eine maximale Eingabelänge haben und alles darüber hinaus schlicht verwerfen müssen. Kleinere Chunks bedeuten also eine feinere Granularität, mit der die Suche genau die passende Stelle treffen kann.
Der andere Grund betrifft die spätere Generierung: Für das LLM ist es am besten, wenn genau die benötigte Information geliefert wird, damit der Kontext nicht unnötig mit irrelevantem Text gefüllt wird. Diese irrelevanten Informationen sind nicht nur unnötige Token, die Geld kosten, sondern auch Ablenkung für das LLM, die die Qualität des generierten Textes verschlechtern kann.
Chunking-Strategien für verschiedene Dokumenttypen
Wie klein diese Chunks sein sollten, ist eine Abwägung: Sie sollten lang genug sein, damit der Kontext klar ist, aber kurz genug, dass nicht zu viele irrelevante Informationen den LLM-Kontext füllen. Als Faustregel für Fließtext sollte ein Chunk etwa einen Absatz umfassen. Für konkrete Parametervorschläge kann ich den Artikel von meinen Kollegen Daniel Töws und Daniel Ladischenski "Retrieval Augmented Generation" empfehlen.
Ein verwandter Parameter ist der Overlap: Statt das Dokument an festen Grenzen sauber zu schneiden, lässt man aufeinanderfolgende Chunks ein Stück weit überlappen. Damit verhindert man, dass ein Satz, der unglücklich genau auf einer Chunk-Grenze liegt, auseinandergerissen wird und in keinem der beiden Chunks mehr verständlich ist. Üblich sind Überlappungen von etwa 10 bis 20 Prozent der Chunk-Größe. Auch hier gilt der übliche Trade-off: zu viel Overlap bläht den Index auf und liefert bei der Suche redundante Treffer. Zu wenig riskiert, dass Kontext an den Nahtstellen verloren geht.
Nun ist aber leider nicht jeder Text in gute Absätze gegliedert. Oftmals liegen Tabellen und Listen vor. In manchen Anwendungen wollen wir das LLM mit Wissen aus YAML- oder JSON-Dateien anreichern. Und mein persönlicher Endgegner sind Excel-Dateien, die sehr lohnenswerte Informationen beinhalten, sich aber auf generischem Wege nur extrem schwer in für das LLM verständliche Chunks zerlegen lassen.
Hier werden wir eine Auswahl von Chunking-Methoden vorstellen, die ein grobes Gefühl vermitteln sollen und vielleicht als Inspiration für Dein spezielles Problem dienen können.
Recursive Character Splitter
Dies ist (abgesehen von "einfach alle X Buchstaben splitten") einer der einfachsten Splitter. Die Parameter für so einen Splitter sind die Maximallänge der resultierenden Chunks in Zeichen und eine hierarchische Liste von Zeichen, an denen der Text getrennt werden soll.
Für das Beispiel eines Markdown-Textes könnte man sich vorstellen, dass eine einfache Liste so aussieht: ["---", "#", "\n\n"]. In Markdown erzeugt --- eine horizontale Linie, die oft genutzt wird, um Abschnitte zu trennen. # leitet Überschriften ein (und um dieses Beispiel einfach zu halten, verzichten wir hier auf eine Unterscheidung von unterschiedlichen Überschriften-Leveln wie ##). Als Letztes kommt der schwächste Indikator \n\n: eine Leerzeile, die Absätze trennt.
Der Splitter nimmt das gesamte Dokument als ersten Chunk-Kandidaten. Er erkennt, dass dieser Chunk zu lang ist, und splittet ihn an jedem ---. Sagen wir, dass wir in unserem Beispiel nun 3 Chunk-Kandidaten haben. Der erste ist bereits unterhalb des Zeichenlimits, sodass dieser Chunk fertig ist. Die anderen beiden werden nun rekursiv weiter gesplittet. Zunächst bei Überschriften an #. Falls einer der übrigen Chunks noch über dem Zeichenlimit liegt, wird dieser nun rekursiv an \n\n in Absätze gesplittet.
Der Vorteil für ein Format wie Markdown ist offensichtlich: Wir können in semantisch sinnvolle Chunks splitten und gehen gleichzeitig sicher, dass kein Chunk zu groß bleibt. Aber diese Art von Splitting stößt bei manchen Formaten sehr schnell an Grenzen. Man stelle sich eine Tabelle mit Spaltenbeschriftung vor. Man könnte nun an den Zeilenenden splitten, aber dann hätte nur der erste Chunk die Spaltenbeschriftung und alle anderen wären für sich allein sehr schwer bis unmöglich für das LLM zu interpretieren.
Format-spezifische Splitter
Für solche Fälle brauchen wir also Strategien, die die Struktur des Quelldokuments bewahren.
Hier möchte ich einmal anhand von JSON ein paar mögliche Strategien zeigen. Betrachten wir dieses Beispiel-JSON.
1{
2 "Enten": [
3 {
4 "name": "Dagobert",
5 "Geldspeicher": true
6 },
7 {
8 "name": "Donald"
9 }
10 ],
11 "Gaense": [
12 {
13 "name": "Gustav"
14 }
15 ]
16}
Offensichtlich wäre ein Chunk "name": "Donald" } ], "Gaense" nicht nur nutzlos, sondern direkt schädlich.
Wir müssen also sicherstellen, dass die einzelnen Chunks keine relevanten Informationen verwerfen.
Wir könnten uns also vorstellen, größere JSON-Objekte in mehrere einzelne Objekte zu teilen:
1{
2 "Enten": [
3 {
4 "name": "Dagobert",
5 "Geldspeicher": true
6 },
7 {
8 "name": "Donald"
9 }
10 ]
11}
1{
2 "Gaense": [
3 {
4 "name": "Gustav"
5 }
6 ]
7}
Ein anderer Ansatz ist Flattening. Man konvertiert die JSON-Struktur direkt in flache Schlüssel-Wert-Paare, von denen dann einige zu einem Chunk zusammengefasst werden können.
1Enten[0].name: Dagobert
2Enten[0].Geldspeicher: true
3Enten[1].name: Donald
4Gaense[0].name: Gustav
Wenn man vorher die Struktur des JSON kennt, kann man die Index-Einträge hier auch direkt durch einen relevanten Schlüssel ersetzen, um besseren Kontext zu haben. Als Faustregel gilt, je besser ein Mensch einen Chunk versteht, desto besser verstehen sowohl das Embedding-Modell als auch das LLM den Chunk.
1Enten.Dagobert.name: Dagobert
2Enten.Dagobert.Geldspeicher: true
Und für besonders große und tiefe Strukturen ist eine Kombination von beidem denkbar: Ähnlich wie beim Recursive Character Splitter gehen wir in den JSON-Baum hinab und prüfen an jeder Stelle, ob der Teilbaum klein genug ist, um in einen Chunk zu passen. Sobald das der Fall ist, schreiben wir den ganzen Teilbaum als Wert zu dem geflatteten Schlüssel:
1Enten: [
2 { "name": "Dagobert", "Geldspeicher": true },
3 { "name": "Donald" }
4]
1Gaense: [
2 { "name": "Gustav" }
3]
Das Problem mit Tabellen und Excel
Allerdings muss man immer stark aufpassen, dass die Chunks angemessen für den Anwendungsfall sind. Angenommen, wir haben eine Excel-Tabelle mit Spaltenbeschriftung. Wir können sie in sinnvolle Chunks aufteilen, indem wir immer ein paar Zeilen zusammen mit den Spaltenbeschriftungen zu einem Chunk verarbeiten. So kann jede einzelne Zahl vom LLM gut interpretiert werden.
Aber eine typische Frage an eine Excel-Tabelle wäre so etwas wie "Was ist die Summe von Spalte X?". Nun, dann hat das LLM nur dann eine Chance, eine korrekte Antwort zu geben, wenn alle Chunks, die zu der Tabelle gehören, geliefert werden. Für solche Fälle ist diese Form des textbasierten RAG nicht die richtige Lösung. (Solche fortgeschrittenen Auswertungen könnten vielleicht mit einem Tool/MCP-Server gelöst werden, der die Anforderung vom User via LLM in ein Python-Skript oder einen SQL-Befehl übersetzt und gegen die Daten auswertet. Das würde auch unter den Begriff RAG fallen, aber ist nicht Thema dieses Artikels.)
LLM-gestütztes Semantic Chunking
Für überschaubare Datenmengen, bei denen eine hohe Antwortqualität wichtig ist, kann es auch sinnvoll sein, die Chunks von einem LLM (oder manuell) bestimmen zu lassen. Schließlich kann ein LLM den für einen Chunk sinnvoll zusammenhängenden Kontext besser beurteilen als die bloße syntaktische Analyse der zuvor genannten Methoden. Dies ist besonders dann der Fall, wenn die syntaktischen Marker nicht existieren, was oftmals bei aus PDF oder per OCR extrahierten Texten der Fall sein kann.
Jeden Schnitt von einem LLM setzen zu lassen, ist bei großen Textsammlungen allerdings langsam und teuer. Ein Mittelweg ist embedding-basiertes Semantic Chunking. Man embeddet die einzelnen Sätze mit einem Embedding-Modell (auf die wir im nächsten Abschnitt noch genauer eingehen werden) und schneidet immer dort, wo der Abstand zwischen aufeinanderfolgenden Satz-Vektoren groß wird -- also dort, wo das Thema springt. Das ist günstiger als ein LLM bei jedem Schnitt und bei bestimmten Textsammlungen besser als reine Syntax, bei anderen jedoch nicht.
Retrieval: Vektorsuche und hybride Suche
Nun haben wir unsere Chunks, die die Informationen mit sinnvollem Kontext beinhalten. Aber wie geben wir dem LLM den passenden Chunk, wenn der User eine Frage stellt?
Wenn wir also unser JSON aus dem Beispiel oben gechunkt haben und der User fragt "Wer ist die reichste Ente?", woher weiß unser System dann, welcher Chunk relevant ist und an das LLM geliefert werden sollte?
Embeddings: Text als Vektorrepräsentation
Das etablierte Standardwerkzeug ist es, die Chunks per Embedding-Modell in eine Vektorrepräsentation zu überführen. Dieser Vektor zeigt dann in einem hochdimensionalen Vektorraum an einen Ort, der zu einer semantischen Bedeutung korrespondiert. Das ist sehr abstrakt. Wir können es uns mit einem vereinfachten Modell anschaulich machen. Wir stellen uns ein Diagramm mit 2 Dimensionen vor, in die wir unsere Chunks einsortieren. Eine Dimension zeigt, wie sehr es um eine Ente geht, und die andere, wie sehr es um eine Gans geht. Unser Embedding würde unseren beiden Beispiel-JSONs von oben also vermutlich Vektoren nahe $(1, 0)$ und $(0, 1)$ geben: Der erste Chunk enthält das Wort "Enten", hat also semantisch mehr mit Enten zu tun. Der zweite entsprechend mehr mit Gänsen. Diese Vektoren merken wir uns zusammen mit dem Chunk selbst.
Wenn nun das LLM gefragt wird "Wer ist die reichste Ente?", wird diese Frage embedded und sollte einen Vektor haben, der stark in die Enten-Richtung zeigt. Wir nehmen diesen Vektor und suchen von allen Vektoren, die unsere Chunks bekommen haben, den nächsten (oder die $k$ nächsten). Wir finden also den Vektor, der zu dem ersten JSON-Snippet gehört. Also liefern wir dieses Snippet an das LLM, das aus dem "Geldspeicher"-Feld ableiten wird, dass Dagobert die reichste Ente ist und nicht Donald.
Wenn wir uns vorstellen, dass wir noch eine weitere Dimension haben, die anzeigt, wie sehr es um "Reichtum" geht, würde der Chunk, der "Geldspeicher": true enthält, weiter in dieser Reichtum-Richtung liegen als die anderen. Sodass wir, wenn wir mehr Chunks haben, in denen es um Enten geht, dennoch den Chunk, in dem es um Dagobert mit dem Geldspeicher geht, als relevantesten finden. Wir finden also über viele Dimensionen den passendsten Vektor.
Echte Embeddings leben nicht in zwei oder drei, sondern in Hunderten bis Tausenden Dimensionen, und deren Achsen entsprechen leider nicht sauber benennbaren Konzepten wie "Ente" oder "Gans" -- die Bedeutung ist über viele Dimensionen verteilt und für uns nicht direkt interpretierbar.
Die entscheidende Eigenschaft ist, dass das Embedding dafür sorgt, dass zwei Texte mit ähnlicher Bedeutung Vektoren bekommen, die nahe zueinander im Vektorraum sind. Synonyme machen also keine Probleme, selbst andere Sprachen funktionieren (bei mehrsprachigen Embedding-Modellen) sehr gut.
Das bedeutet übrigens auch, dass die Chunks und die Frage mit demselben Embedding-Modell embedded werden müssen, sonst landen sie in unvergleichbaren Vektorräumen und die Suche liefert Unsinn. Wer das Embedding-Modell wechselt, muss den gesamten Bestand neu embedden. Die Wahl des Modells selbst ist also eine zentrale Design-Entscheidung: Faktoren wie die Dimensionalität der Vektoren (mehr Dimensionen kosten Speicher und Suchzeit), Mehrsprachigkeit, die maximale Eingabelänge und nicht zuletzt, wie gut das Modell zur eigenen Fachdomäne passt, prägen die Qualität des gesamten Systems. Eine Strategie zur Migration des Datenbestandes in ein anderes Embedding-Modell sollte daher auch früh bedacht werden.
Semantic Search mit Vektordatenbanken
Nachdem wir nun also das Konzept von Embeddings verstanden haben, stellt sich die Frage, wie wir sie für eine Suche benutzen.
Wir müssen offenbar sehr viele Vektoren speichern (einen für jeden Chunk) und wir müssen innerhalb dieser gespeicherten Vektoren die Vektoren finden, die am ähnlichsten zu einem gegebenen Vektor (dem der Frage) sind.
Dafür ist eine Vektordatenbank die richtige Lösung. Mittlerweile gibt es sehr viele Angebote, von Extensions für Postgres über Angebote der Hyperscaler bis zu sehr spezialisierten Vektordatenbanken. Ein Review der Möglichkeiten würde diesen Artikel sprengen. Wir werden stattdessen die grundlegenden Konzepte betrachten.
Similarity Metrics
Zuerst stellt sich die Frage, was Ähnlichkeit von zwei Vektoren eigentlich bedeutet. Nun, jeder Chunk/Vektor repräsentiert einen Punkt $P_i$ und wir müssen die Punkte finden, die am nächsten an dem Punkt $P_Q$ liegen, der unsere Frage repräsentiert. Die intuitive Idee, einfach die euklidische Distanz zwischen den Punkten zu nutzen, ist aber nicht die beste Wahl. In der Regel wird stattdessen die Cosine Similarity genutzt, die nicht den Abstand, sondern den Winkel zwischen zwei Vektoren misst -- sie ist 1, wenn die beiden Vektoren in dieselbe Richtung zeigen, 0, wenn ein rechter Winkel zwischen ihnen ist, und -1, wenn sie in entgegengesetzte Richtungen zeigen.
Aber warum wird Cosine Similarity genutzt? Der Hauptgrund ist, dass die Embedding-Modell-Anbieter dies empfehlen, bspw. OpenAI für ihre Embedding-Modelle. Das beliebte offene Modell all-MiniLM-L6-v2 gibt an, dass es speziell mit Cosine Similarity trainiert wurde.
Die OpenAI-Embedding-Modelle sind außerdem normiert, also alle Vektoren sind gleich lang. Daraus folgt, dass Cosine Similarity und euklidischer Abstand sowieso die gleiche Reihenfolge erzeugen, wenn man die Vektoren nach dem Abstand sortiert. Der große Vorteil von Cosine Similarity bei normierten Vektoren ist, dass sie mathematisch sehr effizient als Skalarprodukt berechnet werden kann.
Approximate Nearest Neighbor (ANN)
Wir müssen davon ausgehen, dass in unserer Datenbank sehr viele Vektoren vorliegen. Daher ist eine exakte Nearest-Neighbor-Suche, die den Abstand von allen Vektoren zu unserem Vektor berechnen muss, oftmals zu langsam und es wird auf Näherungen zurückgegriffen.
Der heute verbreitetste Algorithmus ist HNSW (Hierarchical Navigable Small World). Die Grundidee: Statt blind alle Vektoren durchzugehen, baut man die Vektoren in einen navigierbaren Graphen ein, in dem benachbarte Vektoren verbunden sind. Bei einer Suche "hangelt" man sich dann von einem Startpunkt immer zum jeweils näher an dem Frage-Vektor liegenden Nachbarn weiter, bis man nicht mehr näher kommt.
Das Hierarchical im Namen steht für einen Trick, der dieses Hangeln beschleunigt: Der Graph liegt in mehreren Ebenen übereinander. Die oberste enthält genau einen Eingangsknoten, der mit wenigen, aber weit verteilten Nachbarn verbunden ist. Jeder dieser Nachbarknoten ist Teil der nächsten Ebene, wo die Verbindungen etwas weniger langreichweitig sind. Bis zur untersten Ebene, in der alle Verbindungen sehr kurz sind. Die Suche beginnt oben und steigt Ebene für Ebene ab. Diese Mischung aus langen und kurzen Verbindungen führt dazu, dass jeder Knoten mit wenigen Sprüngen erreichbar ist. Das ist die Small-World-Eigenschaft im Namen. Wenn man durch diese Ebenen hindurchgeht, fokussiert man seine Suche also auf ein immer kleineres Gebiet, sodass man nicht den Abstand von allen Vektoren zum Frage-Vektor berechnen muss, sondern nur von relativ wenigen Kandidaten, die grob im richtigen Gebiet liegen.
Das findet die nächsten Nachbarn meist, aber eben nicht garantiert. Der Algorithmus und seine Parameter lassen sich in der Regel einstellen und haben Auswirkungen auf den Recall (also ob tatsächlich alle nächsten Nachbarn gefunden werden) sowie auf Suchgeschwindigkeit und Speicherbedarf. Hier liegt also ein klassischer Trade-off vor, den man im Hinterkopf behalten sollte.
Hybride Suche: Keyword + Vektor
Aber es gibt auch Fälle, bei denen die semantische Suche über Vektoren versagt. Ein klassisches Beispiel sind Akronyme, Namen oder Jargon-Begriffe, die im internen Kontext klar definiert und allen Nutzern wohlbekannt sind, aber dem Embedding-Modell unbekannt sind. Für diese brauchen wir nicht die Stärken unserer semantischen Suche, sondern eine einfache Keyword-Suche. Wir können also eine Keyword-Suche parallel ausführen und die Ergebnisse beider Suchen an das LLM zurückgeben.
Dadurch findet die Suche immer unseren ersten JSON-Chunk, wenn Fragen nach Dagobert gestellt werden, auch wenn das Embedding-Modell den Namen "Dagobert" nie gesehen hat und ihn deshalb nicht sinnvoll im Vektorraum verorten kann.
Während die semantische Suche auf Bedeutung abzielt, basiert die Keyword-Suche auf der buchstäblichen Übereinstimmung von Termen. Die etablierten Verfahren dafür sind BM25 und das klassische TF-IDF, die bewerten, wie häufig die Suchbegriffe in einem Chunk vorkommen, gewichtet danach, wie selten ein Begriff im Gesamtbestand ist. Nicht jede Vektordatenbank bringt eine solche Keyword-Suche von Haus aus mit. Manchmal kombiniert man die Vektorsuche daher mit einer separaten Volltext-Suchmaschine wie Elasticsearch oder OpenSearch. Die Ergebnisse beider Welten werden anschließend zusammengeführt, etwa per Reciprocal Rank Fusion (RRF): Statt die unterschiedlich skalierten Scores beider Verfahren direkt zu vergleichen, zählt hier nur der Rangplatz eines Treffers in jeder der beiden Listen. Ein Dokument, das in beiden Listen weit oben steht, landet auch in der kombinierten Liste oben.
Filter und Metadaten
Noch schnell zurück zu den Datenbanken: Zusammen mit den Chunks und ihren korrespondierenden Vektoren sollten wir auch wichtige Metadaten speichern.
- Aus welchem Dokument und von welcher Seite kommt dieser Chunk?
- Möglicherweise ist das Erstellungsdatum oder der Autor interessant.
- Welche User dürfen den Inhalt dieses Dokuments sehen?
Alle diese Metadaten können wir während der Suche für Filterung nutzen. Dabei können die Filterkriterien im Falle der Zugriffskontrolle durch das System vorgegeben werden, in Spezialanwendungen vom User eingestellt werden (bspw. Suche nur in dieser einen Datei) oder aber vom LLM spezifiziert werden (bspw. Datumsbereiche).
Und alle diese Metadaten können am Ende zusammen mit dem Chunk an das LLM für die Antwort gegeben werden, was insbesondere für Quellenangaben interessant ist. Damit kann das LLM in seiner Antwort sagen, auf welcher Seite von welchem Dokument die relevante Information steht. Und natürlich kann man diesen Mechanismus weiter ausbauen, um bspw. den genutzten Text direkt im Original-PDF hervorzuheben.
Reranking: Von ähnlichen zu relevanten Treffern
Nun haben wir $k$ Ergebnisse aus der hybriden Suche. Tendenziell wollen wir $k$ natürlich hoch wählen, damit keine relevanten Ergebnisse verloren gehen, aber wir wollen dem LLM nicht zu viele und keine irrelevanten Informationen geben.
Eine Lösung für dieses Dilemma ist Reranking. Die fundamentale Idee von Reranking ist, dass wir nun nicht mehr den großen Satz an Dokumenten (möglicherweise Millionen von Chunks) betrachten müssen, sondern rechenintensivere Methoden auf die $k$ Ergebnisse (typischerweise $10$ bis $100$) anwenden können.
Durch das Reranking wird die Qualität der Ergebnisse neu bewertet und nur die relevanten Ergebnisse werden an das LLM zurückgeleitet.
Typischerweise nutzt man einen Cross-Encoder, der sowohl mit der Frage als auch mit dem Chunk aufgerufen wird und dann beurteilt, wie relevant die beiden Texte zueinander sind. Technisch gesehen sind Cross-Encoder heute zumeist Transformer -- aber wichtig ist nur ihre Funktion: die Relevanz zweier Texte zueinander zu beurteilen.
Genau hier liegt auch der Grund, warum Reranking eine eigene Stufe ist und nicht gleich die Suche erledigt. Unsere Embeddings sind sogenannte Bi-Encoder: Frage und Chunk werden jeweils getrennt in einen Vektor übersetzt. Das ist die Voraussetzung dafür, die Chunk-Vektoren schon bei der Indexierung vorberechnen zu können. Zur Suchzeit muss nur noch die Frage embedded werden. Ein Cross-Encoder dagegen schaut sich Frage und Chunk gemeinsam an und kann so feine Wechselwirkungen zwischen beiden Texten erfassen, die zwei isolierte Vektoren so nicht einfangen. Das macht ihn deutlich genauer, aber eben auch nicht vorberechenbar: Für jedes Frage-Chunk-Paar ist ein eigener Durchlauf nötig. Bei Millionen Chunks ist das nicht durchführbar -- bei den $k$ Kandidaten aus der ersten Suche dagegen problemlos machbar. Reranking ist also die Stelle, an der wir uns die teure, aber präzise Methode endlich leisten können.
LLM-basiertes Reranking
Für bestimmte Fälle kann es auch sinnvoll sein, die Relevanz eines Ergebnisses direkt von einem LLM bewerten zu lassen. Zwar sind LLMs in der Regel deutlich langsamer und teurer als einfachere Cross-Encoder, aber auch allgemeiner einsetzbar und flexibler. So kann man dem Reranker-LLM auch spezialisierte Kriterien für das Reranking geben.
Angenommen, unser RAG durchsucht die Dokumentation einer Software, die über die Jahre viele Versionen durchlaufen hat. Dann könnte die Anleitung für das LLM sein, bei zwei inhaltlich gleich passenden Chunks denjenigen höher zu ranken, der sich auf eine neuere Version bezieht, und Hinweise auf veraltete oder als deprecated markierte Funktionen abzuwerten. Anders als bei einem reinen Ähnlichkeitsmaß kann das LLM hier unstrukturierte Signale aus dem Chunk-Text selbst lesen -- eine genannte Versionsnummer, ein "seit Version 3.0" oder ein "veraltet" -- und sie in die Bewertung einfließen lassen.
Durch die Relevanz-Scores erhält man nicht nur eine neue Sortierung der Ergebnisse, sondern kann auch einen Cutoff für irrelevante Ergebnisse festlegen, die nicht in der Antwort des RAG-Systems an das anfragende LLM enthalten sein sollen. Dadurch reduziert sich die Menge der Ergebnisse auf nur noch die besten, was den Kontext des LLMs sauber hält und seine Antwortqualität verbessert.
Query Construction: Was wird eigentlich gesucht?
Bisher sind wir eher oberflächlich darüber hinweggegangen, was denn nun die "Frage" ist, mit der wir unsere semantische Suche starten. Diese Query ist der Input-Parameter unserer Suche und bestimmt somit entscheidend das Ergebnis der Suche.
Und wie so oft ist die Antwort hier "it depends" und hängt von der Natur der Datengrundlage ab, sowie von dem Aufwand (der sich in Kosten und Latenz bemerkbar macht), den man bereit ist zu leisten.
Direkte Nutzung der Nutzerfrage
Die einfachste Möglichkeit ist es, einfach den Prompt, mit dem der User die Frage stellt, unverändert zu nutzen. Dies funktioniert in der Regel passabel für die semantische Suche. Allerdings kann es passieren, dass die Ähnlichkeit der Vektoren abnimmt, wenn der Nutzer die Frage umgangssprachlich stellt, aber der Datenbestand eher formal formuliert ist.
Außerdem versteckt dies den Kontext der Konversation vor der Suche. Betrachten wir diese Beispielkonversation:
User: "Wo ist unsere Niederlassung in Frankfurt?"
LLM: "In der Lise-Meitner-Straße 4."
User: "Und darf ich dort meinen Hund mitbringen?"
Wenn wir nur den unveränderten Prompt "Und darf ich dort meinen Hund mitbringen?" nutzen, verliert die Query den Kontext, dass es um die Niederlassung in Frankfurt geht, und liefert möglicherweise nur die Hunde-Policy der Niederlassungen in Berlin und München. Damit hat das LLM dann keine Chance, die richtige Antwort zu geben.
Query Rewriting und Normalisierung
Eine Möglichkeit, dies zu verhindern, ist es, ein LLM die Frage neu formulieren zu lassen. Dabei sollte Normalisierung stattfinden, also ggf. Umgangssprache durch formale Sprache ersetzt, Referenzen aufgelöst und irrelevante Details entfernt werden.
Genau hier lösen wir dann das Hunde-Problem: Bekommt das umformulierende LLM die bisherige Konversation mit, kann es die zweite Frage "Und darf ich dort meinen Hund mitbringen?" zu einer eigenständigen Query wie "Sind Hunde in der Niederlassung Frankfurt erlaubt?" auflösen. Das mehrdeutige "dort" ist verschwunden, und die Suche hat wieder eine Chance, den richtigen Chunk zu finden.
Hypothetical Document Embeddings (HyDE)
Eine interessante Strategie, um das Matching des Query-Vektors mit den Dokumenten in der Datenbank zu erhöhen und damit treffsicherer die passende Information zu finden, ist es, die Query so zu formulieren, wie man sie im Datenbestand erwartet: nämlich als Antwort.
Statt also den Vektor der Frage "Wer ist die reichste Ente?" zu suchen, lässt man ein LLM zunächst eine hypothetische Antwort halluzinieren ("Peter Müller ist die reichste Ente.") und embedded diese. Dass diese Antwort möglicherweise faktisch falsch ist, spielt keine Rolle -- selbst eine erfundene Antwort wie "Peter Müller ist die reichste Ente" landet im Vektorraum näher an den tatsächlich gespeicherten Chunks, die ja ebenfalls Aussagesätze und keine Fragen sind. Die eigentliche, belastbare Antwort generiert am Ende ohnehin das LLM (ohne die erfundenen Antworten zu sehen) aus den gefundenen, echten Chunks.
Um sich nicht von einer einzelnen Halluzination in die Irre führen zu lassen, generiert man meist gleich mehrere hypothetische Antworten. Was man dann damit tut, ist eine eigene Designentscheidung. In der klassischen HyDE-Variante mittelt man die einzelnen Vektoren (oft inklusive der echten Frage) zu einem Suchvektor und führt damit nur eine Suche aus. Das ist günstig und glättet Ausreißer. Die Alternative ist, mit jedem Vektor einzeln zu suchen und die Trefferlisten zusammenzuführen. Das deckt mehrere Regionen des Vektorraums gleichzeitig ab, kostet aber mehrere Suchen -- und ist im Grunde schon das Multi-Query-Retrieval des nächsten Abschnitts.
Multi-Query-Retrieval
Wenn der Recall des RAG zu schlecht ist, also nicht alle relevanten Dokumente gefunden werden, kann es helfen, mehrere Queries von einem LLM konstruieren zu lassen, die dann alle über die semantische Suche gesucht werden.
Die Idee ist, dass wir mit einem simplen Prompt wie
Generiere 3 alternative Formulierungen dieser Frage: {query}
mehrere Vektoren erhalten, die jeweils einen etwas anderen Teil des Vektorraums abdecken. Wo eine einzelne Formulierung vielleicht einen relevanten Chunk knapp verfehlt, fängt ihn eine der Varianten ein. Die Treffer aller Queries werden anschließend zusammengeführt -- und damit sind wir wieder bei Reranking und Cutoff angekommen, um aus der nun größeren Treffermenge die besten Chunks herauszufiltern.
Generierung: Aus Chunks wird eine Antwort
Nun zum eigentlichen Zweck! Am Ende soll das LLM eine Antwort generieren -- das "G" in RAG. Die einfachste Variante ist es, einen Prompt zu bauen, der die gefundenen Chunks und die Frage zusammenführt, etwa so:
Beantworte die folgende Frage ausschließlich anhand des bereitgestellten Kontexts.
Wenn der Kontext die Antwort nicht hergibt, sage, dass du es nicht weißt.
Kontext:
{chunks}
Frage: {query}
So unscheinbar dieser Prompt aussieht, in ihm stecken zwei wichtige Aspekte, die die Qualität des RAG-Systems sicherstellen.
Der erste ist das Grounding: Die Anweisung, sich ausschließlich auf den Kontext zu stützen, soll das LLM davon abhalten, sein -- möglicherweise veraltetes oder schlicht erfundenes -- Weltwissen einzustreuen. Noch wertvoller wird das, wenn wir die Chunks mit ihrer Herkunft annotieren (Dokumenttitel, Seitenzahl, URL) und das LLM bitten, seine Aussagen mit diesen Quellen zu belegen. Dann kann der User die Antwort nachprüfen, statt sie blind zu glauben.
Der zweite ist die Erlaubnis, "ich weiß es nicht" zu sagen. RAG schützt nämlich keineswegs zuverlässig vor Halluzinationen. Wenn die Suche keinen passenden Chunk findet, füllt ein hilfsbereites LLM die Lücke nur zu gerne mit einer plausibel klingenden Erfindung. Keine Antwort zu geben ist in diesem Fall allerdings die beste Antwort, die das System geben kann. Und wenn die RAG-Query dem LLM als Tool bereitgestellt wurde, kann das LLM auch eine weitere, modifizierte Query starten.
Für Spezialfälle kann man auch andere Methoden wählen. Falls die Fragen oft Zusammenfassungen von größeren Datenmengen anfordern, ist ein Map-Reduce-Ansatz denkbar. Man würde die Parameter so anpassen, dass man viele Chunks als Ergebnis zulässt, sie jeweils von einem LLM mit Blick auf die Frage zusammenfassen lässt und dann dem LLM, das mit dem User spricht, nur noch die Zusammenfassungen liefert. Dass die Frage schon in dieser Zwischenstufe bekannt ist, ist dabei wichtig: Fasst man blind zusammen, fällt womöglich genau das Detail weg, auf das es ankam. Den Cutoff aus dem Reranking hebeln wir damit bewusst wieder aus. Bezahlt wird das mit einem LLM-Call pro Chunk statt eines einzigen -- und damit, dass Querverbindungen verloren gehen: Eine Frage, deren Antwort auf zwei Chunks verteilt liegt, übersteht diese Zwischenstufe nicht.
Evaluation: Woran misst man, ob es gut ist?
Wir haben nun an jeder Station Stellschrauben kennengelernt -- Chunk-Größe, hybride Suche, Reranking, Query-Rewriting. Doch woher wissen wir, ob eine Änderung das System besser oder schlechter macht? Gefühl ist hier ein schlechter Ratgeber, denn die Effekte sind oftmals subtil und eine Änderung an einer Stelle kann an anderer Stelle etwas kaputt machen. Wir brauchen also Zahlen.
Dieses Thema ist allerdings so umfangreich, dass es einen eigenen Blogeintrag braucht -- oder mehrere:
- RAG Evaluation mit TypeScript -- Robin Schlenker baut Faithfulness, Answer Relevance und LLM-as-a-judge von Hand nach und macht damit sichtbar, wie sich Änderungen am System auf die Antwortqualität auswirken.
- Reliable by Design: Building an Evaluation Harness -- Niklas Niggemann legt einen Golddatensatz samt Regressionserkennung an. Das Beispiel ist ein Text-zu-SQL-System, der Aufbau überträgt sich aber.
- Closing the Loop: A Prompt Iteration Workflow -- was man mit den Zahlen dann tut: eine Änderung pro Durchlauf, die Suite komplett neu laufen lassen, vergleichen, behalten oder verwerfen.
Das passende RAG bauen
Damit haben wir den Weg einmal komplett abgeschritten: vom rohen Dokument über Chunking, Embedding und hybride Suche bis zum Reranking und der Frage, was wir überhaupt suchen. An nahezu jeder Station lautet die Antwort "es kommt darauf an". Die richtige Chunk-Größe, die passende Such-Strategie, ob sich ein LLM-Reranker lohnt -- all das hängt von den Daten, den Fragen und dem Budget ab.
Die gute Nachricht ist, dass man nicht unbedingt alles auf einmal braucht. Ein RAG-System lässt sich gut inkrementell aufbauen: Erst ein simpler Recursive Character Splitter und eine reine Vektorsuche, und dann misst man. Schlechter Recall? Multi-Query einbauen. Zu viel Rauschen im Kontext? Reranking mit Cutoff. Akronyme, die niemand findet? Keyword-Suche dazu. So wächst aus einem simplen Prototyp Schritt für Schritt ein System, das zuverlässig beantworten kann, ob der Hund mit ins Büro darf.
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Blog-Autor*in
Hendrik Schawe
IT-Consultant
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.