Praxisbeispiele für maßgeschneiderte RAG-Lösungen

Praxisbeispiele zeigen, wie maßgeschneiderte RAG-Systeme komplexe Wissensprobleme in Unternehmen lösen.

RAG hat heute manchmal einen schlechten Ruf. Manche halten es inzwischen für völlig trivial (der Einstieg ist einfach, die Skalierung weniger), andere glauben, es sei von „agentischen Systemen“ überholt worden. Doch bei genauerem Hinsehen ähneln diese in vielen Fällen sehr schnell einem RAG-System …

Dieser Blogbeitrag zeigt anhand konkreter Beispiele, wie wir einige typische Herausforderungen lösen:

  • Gemischte Text- und Zahlendaten verarbeiten und warum naives RAG daran scheitert: Schlüsselwörter überschneiden sich, Zahlen haben keine semantische Bedeutung.

  • Warum Zusammenfassungen vor dem Erstellen von Embeddings helfen: Erstelle für jeden Chunk eine kurze Beschreibung und nutze sie für Embedding und Abfrage.

  • Kontextbezogene Zusammenfassungen erstellen: Beziehe den Kontext des übergeordneten Dokuments ein, damit ähnlich aufgebaute Statistiken eindeutig bleiben.

  • Wann Code und Pydantic-Modelle sinnvoll sind: Wenn Inhalte wortgetreu erhalten bleiben müssen, kombiniere eigenen Code und/oder ein Pydantic-Modell mit LLM-Aufrufen, um zuverlässige Ergebnisse zu erzielen.

Individuelle RAG-Lösungen entwickeln

Die Grundlagen

RAG-Systeme bilden die Grundlage für viele Anwendungen, von Support-Bots bis zu internen Wissensassistenten.

Im Hintergrund läuft typischerweise Folgendes ab:

  1. Unterteile deine Quelldokumente in Chunks

  2. Überführe jeden Chunk per Embedding in einen Vektorraum

  3. Rufe zum Abfragezeitpunkt die Top-K-Chunks ab

  4. Generiere anhand dieser Chunks eine Antwort

Gängige Toolkits wie LangChain, LlamaIndex und OpenAI Filestore machen diese Schritte beinahe trivial. In realen Pipelines bestehen Daten jedoch nicht nur aus dichtem Fließtext. Einfaches RAG stößt hier schnell an Grenzen. In den folgenden Abschnitten zeigen wir konkrete Datenprobleme und entwickeln die Lösung schrittweise weiter, während die Komplexität zunimmt.

Wenn es komplizierter wird

  1. Wenn deine Daten nicht nur aus Text bestehen (was durchaus häufig vorkommt)

Betrachten wir den folgenden Daten-Chunk aus einem Gaming-Kontext:

JSON

{ "Attack": { "Range": { "default": 5, "with_Draconic_Ascension": 5.5, }, "Speed": { "default": "2 seconds", "with_Draconic_Ascension": "2.2 seconds", }, "Damage": { "default": 10, "with_Draconic_Ascension": 12, } }} 

Embeddings funktionieren dank erlernter Beziehungen zwischen Wörtern, die sich aus Semantik und Grammatik ergeben. Die obigen Daten enthalten Text und Zahlen. Außerhalb dieses konkreten Kontexts stehen die Zahlen in keiner Beziehung zu den Wörtern. Dieser Daten-Chunk ist also im Wesentlichen eine Kombination aus einigermaßen beschreibenden Wörtern und scheinbar zufälligen Zahlen.

Wäre dies unser einziger Datentyp, wäre das kein Problem. Wir könnten die wenigen beschreibenden Wörter weiterhin per Embedding abrufen oder einfach Text-to-SQL verwenden. Was aber, wenn dieser Chunk zwischen vielen textreichen Chunks liegt, in denen dieselben Wörter vorkommen? Zum Beispiel:

JSON

{"Draconic_Ascension": { "description": "Transform into dragon form. Attack range and speed are increased by 10%, damage is boosted by 20%.", "details": { "activation_conditions": "Can only be activated when HP is below 50%or Fury meter is full.", "visual_effects": "Wings unfurl, scales shimmer with embers, voice lines change to echoing growls.", "lore": "An ancient bloodline awakens. The bearer of the mark channels the soul of the last Flamewing Wyrm, becoming a living storm of fire and fury." } }}

Angenommen, wir möchten die Frage „Welche Angriffsreichweite gilt bei Draconic Ascension?“ beantworten.Sehr wahrscheinlich können wir den gewünschten relevanten Chunk nicht abrufen, weil er im Rauschen anderer Chunks mit denselben Schlüsselwörtern untergeht.

Das Grundproblem: Wir können diese Daten-Chunks nur schwer unterscheiden, obwohl sie zum selben Thema unterschiedliche Arten von Informationen enthalten. Können wir die Daten irgendwie anreichern oder verbessern? Natürlich können wir das :smile:

  1. Reichere deine Daten mit Zusammenfassungen an. Ja, richtig gelesen

Statt direkt ein Embedding des Chunks zu erstellen, können wir zunächst eine Zusammenfassung seines Inhalts generieren. Für Embedding und Abruf verwenden wir dann diese Zusammenfassung. Bei der Generierung verwenden wir weiterhin die mit der Zusammenfassung verknüpften Originaldaten.

Für die beiden obigen Chunk-Beispiele würden wir etwa folgende Zusammenfassungen generieren:

  1. Angriffsstatistiken zu Reichweite, Geschwindigkeit und Schaden (standardmäßig und mit Draconic Ascension).

  2. Beschreibung und Einzelheiten zu Draconic Ascension, einschließlich Aktivierungsbedingungen, visueller Effekte und Hintergrundgeschichte.

Anschließend erweitern wir auch die Abfrage, um sie an die Zusammenfassung „anzugleichen“. Aus „Welche Angriffsreichweite gilt bei Draconic Ascension?“wird beispielsweise „Wie lautet die Statistik zur Angriffsreichweite bei Draconic Ascension?“Das ist besonders wichtig, wenn die Abfrage von Menschen ohne technischen Hintergrund stammt, die in ~~„frei Schnauze“~~ normaler Alltagssprache fragen. Schließlich müssen sie weder wissen noch berücksichtigen, wie RAG funktioniert, um Precision und Recall zu maximieren.

Diagramm zu einem komplexeren Szenario.

  1. Nichts aus dem Zusammenhang reißen (gilt übrigens auch fürs Leben)

Betrachten wir nun ein weiteres Szenario mit sehr vielen Daten-Chunks, die wie im folgenden Beispiel gleich aussehen:

Plain Text

# Chunk one{ "Attack": { "Range": { "default": 9, }, ... }}# Chunk two{ "Attack": { "Range": { "default": 6, }, ... }}# Chunk three{ "Attack": { "Range": { "default": 7, }, ... }}

Bleiben wir bei diesem Ansatz und stellen uns die Frage: „Welche Angriffsreichweite hat Charakter X?“Bei den gerade generierten Zusammenfassungen wäre die Antwort ein Glücksspiel, weil auch sie einander sehr ähnlich wären. Wie können wir sie also unterscheiden?

Die einfache Antwort lautet: durch Kontext. Wir könnten im Daten-Chunk einfach auf das übergeordnete Dokument verweisen, in diesem Fall etwa mit {”character”: “X”}. So könnten wir die richtigen Daten für Charakter X präzise abrufen, selbst wenn dieselben Daten auch für die Charaktere Y und Z vorliegen.

Ein besserer und allgemeiner nutzbarer Ansatz wäre jedoch, eine kontextbezogene Zusammenfassung des Chunks zu generieren. Statt nur den Daten-Chunk zusammenzufassen, könnten wir also sowohl das übergeordnete Dokument als auch den Chunk übergeben und eine allgemeine kontextbezogene Zusammenfassung generieren. Darin beschreiben wir, wie sich der Chunk in das übergeordnete Dokument einfügt, zum Beispiel:

  1. Dieser Chunk enthält detaillierte Statistiken zu … für Charakter X. Der Chunk ergänzt das Gesamtdokument, indem er die Stärke von X bei der Angriffsgeschwindigkeit zeigt …

  2. Dieser Chunk enthält detaillierte Statistiken zu … für Charakter Y. Der Chunk ergänzt das Gesamtdokument, indem er die durch ihre Spezialfähigkeit erhöhten Werte von Y zeigt …

  3. Dieser Chunk enthält detaillierte Statistiken zu … für Charakter Z. Der Chunk ergänzt das Gesamtdokument, indem er zeigt, dass sich die Werte von Z gut für eine Tank-Rolle in Teamkämpfen eignen …

Diese Methode (die teilweise von Anthropic inspiriert ist) mag für das obige Beispiel übertrieben wirken. Sie ist jedoch sehr effektiv bei Chunks, die ohne Kontext missverstanden werden könnten. Außerdem bietet sie einen einheitlichen Ansatz für alle Chunks und sorgt für eine übersichtliche Engineering-Pipeline.

Diagramm zu einem komplexeren Szenario.

  1. Wenn du ~~alles kontrollieren~~ sorgfältig vorgehen musst

Normalerweise erhalten wir vollständige Dateneinheiten und unterteilen sie für ein RAG-System in Chunks. In diesem Beispiel zeigen wir einen etwas anderen Fall: Die Daten sind bereits in Chunks aufgeteilt, allerdings in ungeeignete. Es handelt sich um zufällige Abschnitte eines logischen Chunks, die wieder zusammengeführt werden müssen. Ein logischer Chunk ist eine inhaltliche Einheit, die von Natur aus zusammengehört, etwa ein Unterabschnitt eines Dokuments oder ein zusammenhängender Absatz.

Diagramm zu einem komplexeren Szenario.

Bei unserem ersten Versuch übergeben wir sämtliche Daten an ein LLM. Es soll die Abschnitte nach eigenem Ermessen gruppieren und anschließend die zusammengeführten Inhalte zurückgeben. Ein LLM sollte darin ziemlich gut sein, oder? Nun ja, teils, teils.

Hier und bei mehreren anderen Gelegenheiten haben wir festgestellt, dass LLMs zu Nachlässigkeit neigen und unzuverlässig werden, wenn vollständige und exakte Inhalte erforderlich sind, insbesondere bei langem Kontext. Das ist durchaus nachvollziehbar. Für diesen speziellen Anwendungsfall war das jedoch ein Ausschlusskriterium. Wir benötigen den exakten Inhalt Wort für Wort, ohne Zusammenfassungen und ohne Teile des Originals auszulassen. Kein Detail darf fehlen.

Die positive Seite: Das LLM verstand die Semantik und Struktur der aufgeteilten Chunks hervorragend. Vorausgesetzt, es weigert sich nicht, den exakten Inhalt wiederzugeben. Verdammt :/

Wie können wir also die Stärken eines LLM nutzen und zugleich seine unzuverlässigen Seiten umgehen? Wir wandten uns an unseren guten alten Freund: Code, sprich eine maßgeschneiderte Python-Funktion. Dazu kam ein denkbar einfaches Pydantic-Modell. So sieht die Lösung aus:

  • Durchlaufe die Abschnitte und verwalte dabei den jeweils aktuellen logischen Chunk

  • Frage das LLM bei jedem Abschnitt: Gehört dieser Abschnitt zum aktuellen logischen Chunk? Die Antwort muss gemäß dem Pydantic-Modell „Ja“ oder „Nein“ lauten.

  • Bei „Ja“ wird der Abschnitt an den Chunk angehängt. Bei „Nein“ wird der nun vollständige logische Chunk ausgegeben und mit dem Abschnitt ein neuer begonnen.

Diagramm zu einem komplexeren Szenario.

Natürlich verbrauchen wir damit etwas mehr Tokens als bei einem einzelnen Durchlauf durch den gesamten Inhalt. In diesem Anwendungsfall hat die exakte Bewahrung des Inhalts jedoch höchste Priorität, sodass sich die geringen Mehrkosten lohnen.

Diese Lösung ist sehr einfach, folgt aber einem wichtigen Prinzip: Wenn Sorgfalt gefragt ist, sollten wir uns nicht allein auf LLMs verlassen, denn sie arbeiten letztlich probabilistisch.

Mit eigenem Code, eigenen Funktionen und Pydantic-Modellen lassen sich vorhersehbare, zuverlässige Ergebnisse erzielen und zugleich die Stärken von LLMs nutzen.

Fazit

Die Entwicklung einer Lösung mit generativer KI ist ebenso sehr eine Engineering- wie eine KI-Herausforderung. Wir hoffen, dass diese Beispiele dich dazu anregen, deine eigenen besonderen Herausforderungen anzugehen. Mehr über Engineering-orientierte Lösungen mit generativer KI erfährst du in unserem Blogbeitrag zum Design Router-basierter agentischer Systeme.

Autor

Cynthia Yu