Python & AI Tutorials Logo
LangChain & LangGraph

9. Erstellen Sie Ihr erstes RAG-System

Jede App, die wir bisher erstellt haben, stützte sich ausschließlich auf das vortrainierte Wissen des LLM. Deshalb konnte es keine Fragen zu Informationen beantworten, die das LLM nie gelernt hat – wie die internen Dokumente Ihres Unternehmens oder Produkthandbücher.

RAG (Retrieval-Augmented Generation) löst dieses Problem. Wenn eine Benutzerfrage eingeht, ruft es zunächst relevante Dokumente ab und übergibt dann den abgerufenen Inhalt zusammen mit der Frage an das LLM, damit es auf Basis dieses Inhalts antworten kann. Sie kombinieren die Argumentationsfähigkeit des LLM mit Ihrem Dokumentenwissen.

In diesem Kapitel erstellen wir eine vollständige RAG-Pipeline von der Dokumentenvorbereitung (Laden, Chunking, Embedding) bis zur abrufbasierten Antwortgenerierung. Das fertige System ruft relevante Dokumente ab, wenn eine Frage eingeht, und übergibt sie dann zusammen mit der Frage an das LLM, damit es auf Basis dieses Dokumenteninhalts antwortet. Es antwortet präzise, wenn die Informationen in den Dokumenten vorhanden sind, und sagt ehrlich „Ich weiß es nicht", wenn sie nicht vorhanden sind – das ist die Essenz eines vertrauenswürdigen RAG.

9.1) RAG verstehen

9.1.1) Das Problem: LLMs kennen Ihre Daten nicht

LLMs werden auf Internetdaten wie Wikipedia, Nachrichtenartikel und öffentlichem Code trainiert. Sie kennen die internen Dokumente Ihres Unternehmens oder den Vertrag, den Sie gestern erhalten haben, nicht. Daher können sie keine Fragen beantworten wie:

  • „Was ist unsere Urlaubsregelung?"
  • „Fassen Sie den Verkaufsbericht dieses Quartals zusammen"
  • „Was sind die Rückerstattungsbedingungen in dem Vertrag, den ich gerade erhalten habe?"
python
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
# Frage zu einem privaten Dokument, das das LLM nie gesehen hat
response = llm.invoke("Was ist die Rückerstattungsrichtlinie von Acme Corp?")
print(response.content)

Ausgabe:

Ich habe keine spezifischen Informationen über die Rückerstattungsrichtlinie von Acme Corp. Ich empfehle, deren offizielle Website zu besuchen oder sich direkt an deren Kundensupport zu wenden, um die genauesten und aktuellsten Informationen zu erhalten.

In diesem Beispiel sagt das LLM ehrlich, dass es es nicht weiß. (Oder es könnte eine plausibel klingende Antwort halluzinieren.)

Aber was wäre, wenn wir das Rückerstattungsrichtliniendokument zusammen mit der Frage bereitstellen würden? Das LLM würde eine genaue Antwort basierend auf dem bereitgestellten Inhalt geben. Das ist die Kernidee hinter RAG.

9.1.2) Wie sollten wir das Dokument bereitstellen?

Der einfachste Ansatz besteht darin, das gesamte Dokument in den Prompt zu kopieren und einzufügen. Dies funktioniert tatsächlich gut für kurze Dokumente.

python
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
# In Wirklichkeit wäre dies viel länger, aber nehmen wir an, das Folgende ist das gesamte Dokument
document_text = """
Rückerstattungsrichtlinie (Gültig ab Januar 2026):
- Vollständige Rückerstattung innerhalb von 30 Tagen nach dem Kauf mit Originalbeleg.
- Nach 30 Tagen nur Ladenguthaben.
- Digitale Produkte sind nach dem Download nicht erstattungsfähig.
- Defekte Artikel können jederzeit für eine vollständige Rückerstattung zurückgegeben werden.
"""
 
response = llm.invoke(
    f"""Bitte antworten Sie basierend auf dem folgenden Dokument:
{document_text}
 
Frage: Was ist die Rückerstattungsrichtlinie für digitale Produkte?"""
)
print(response.content)

Ausgabe:

Digitale Produkte sind nach dem Download nicht erstattungsfähig.
...

Dies funktioniert gut für kurze Dokumente. Aber was ist, wenn das Dokument sehr groß ist? Dies verursacht die folgenden ernsthaften Probleme:

1. Kontextfenster-Grenzen: LLMs haben eine begrenzte Anzahl von Tokens, die sie auf einmal verarbeiten können. Für GPT-5-mini sind es 400K Tokens. Ihre gesamte Unternehmensdokumentation kann dies jedoch leicht überschreiten. Selbst wenn sie passt, werden die Antworten langsamer und weniger genau, je länger der Kontext wird.

2. Kosten: LLM-APIs berechnen pro Token. Das Senden des gesamten Dokuments, wenn nur ein oder zwei Absätze benötigt werden, lässt die Kosten in die Höhe schnellen.

3. Genauigkeitsverschlechterung: Wenn Sie das gesamte Dokument einbeziehen, werden die Informationen, die Sie tatsächlich benötigen, in irrelevantem Inhalt begraben. Die Aufmerksamkeit des LLM wird durch nicht verwandte Informationen abgelenkt, was die Antwortqualität verschlechtert.

RAG löst alle drei Probleme, indem es nur die relevanten Teile des Dokuments abruft und bereitstellt.

9.1.3) Kernidee: Relevante Teile abrufen und zusammen mit der Frage bereitstellen

Die Essenz von RAG ist einfach: Bevor Sie die Frage an das LLM senden, finden Sie zunächst die relevanten Teile Ihrer Dokumente und stellen Sie sie zusammen mit der Frage bereit.

So funktioniert es:

  1. Der Benutzer stellt eine Frage.
  2. Das System ruft (Retrieval) relevanten Inhalt aus dem Dokumentenspeicher ab.
  3. Der abgerufene Inhalt wird hinzugefügt (Augmentation) zum Prompt zusammen mit der Frage.
  4. Das LLM generiert (Generation) eine Antwort basierend auf dem abgerufenen Inhalt.

Diese drei Schritte sind der Ursprung des Namens RAG (Retrieval-Augmented Generation).

9.1.4) Wie rufen wir relevanten Inhalt ab? (Einschränkungen des Keyword-Matchings)

Der Abrufschritt ist entscheidend für RAG. Sie müssen relevanten Inhalt bereitstellen, um angemessene Antworten zu erhalten. Wie rufen wir also Inhalte ab, die mit der Frage zusammenhängen?

Die einfachste Methode ist Keyword-Matching: Dokumente finden, die Wörter aus der Frage enthalten. Wenn beispielsweise jemand fragt „Was ist die Rückerstattungsrichtlinie für digitale Produkte?", würden Sie nach Dokumenten suchen, die die Wörter „Rückerstattung", „digital" und „Produkte" enthalten.

Aber Keyword-Matching hat eine kritische Schwäche: Es kann nur exakte Wortübereinstimmungen finden.

Nehmen wir an, Sie haben ein Rückerstattungsrichtliniendokument mit diesem Inhalt:

„Vollständige Rückerstattung innerhalb von 30 Tagen nach dem Kauf verfügbar."

Was passiert, wenn ein Benutzer fragt „Wie bekomme ich mein Geld zurück?" Dieses Dokument wird nicht abgerufen. Das Dokument enthält nicht die Phrase „Geld zurückbekommen". Menschen verstehen, dass „Rückerstattung" und „Geld zurückbekommen" dieselbe Bedeutung haben, aber die Keyword-Suche gleicht nur Wörter ab, sodass sie es nicht findet.

Die Keyword-Suche gleicht nur Wörter ab. Selbst wenn die Bedeutung dieselbe ist, wird sie nicht gefunden, wenn die Wörter unterschiedlich sind.

Die Lösung ist semantische Suche. Und was dies ermöglicht, sind Embeddings.

9.1.5) Embeddings: Text in numerische Vektoren umwandeln

Embeddings repräsentieren die Bedeutung von Text als Liste von Zahlen (ein Vektor). Wenn Sie Text in ein Embedding-Modell eingeben, wandelt es ihn in einen Vektor von Hunderten bis Tausenden von Zahlen um.

python
from langchain_openai import OpenAIEmbeddings
 
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# Einen einzelnen Satz einbetten
vector = embeddings_model.embed_query("Wie bekomme ich mein Geld zurück?")
 
print(f"Vektordimensionen: {len(vector)}")
print(f"Erste 5 Werte: {vector[:5]}")

Ausgabe:

Vektordimensionen: 1536
Erste 5 Werte: [0.0123, -0.0456, 0.0789, -0.0234, 0.0567]

Dimension ist die Anzahl der Werte, aus denen der Vektor besteht. Das Modell text-embedding-3-small repräsentiert allen Text als 1.536 Zahlen.

Warum brauchen wir so viele Zahlen? Weil jede Dimension verschiedene Aspekte der Bedeutung erfasst:

  • Einige Dimensionen könnten „Aktion/Zustand" unterscheiden
  • Andere könnten Grade von „konkret/abstrakt" darstellen
  • Wieder andere könnten „positive/negative" Stimmung anzeigen
  • ... (1.536 semantische Merkmale – obwohl wir nicht wirklich interpretieren können, was jede Dimension darstellt)

So wie 2D-Koordinaten (x, y) einen Punkt auf einer Ebene darstellen, repräsentiert ein 1.536-dimensionaler Vektor einen Punkt in einem 1.536-dimensionalen „Bedeutungsraum". Mehr Dimensionen ermöglichen feinere Unterscheidungen in der Bedeutung.

Ähnliche Bedeutungen befinden sich nahe beieinander im Bedeutungsraum. „Rückerstattungsmethode" und „Geld zurückbekommen" verwenden unterschiedliche Wörter, aber weil sie ähnliche Bedeutungen haben, werden sie nahe beieinander im Bedeutungsraum platziert.

9.1.6) Semantische Suche: Ähnliche Bedeutung, geringerer Abstand

Sobald Sie sowohl Dokumente als auch Abfragen in Vektoren umgewandelt haben, können Sie die relevantesten Dokumente finden, indem Sie die Ähnlichkeit zwischen Vektoren messen. Dies wird semantische Suche genannt – Suche nach semantischer Ähnlichkeit statt nach Keyword-Matching.

Das gebräuchlichste Ähnlichkeitsmaß ist die Kosinus-Ähnlichkeit, die den Winkel zwischen zwei Vektoren misst. Wenn Vektoren in ähnliche Richtungen zeigen, ist die Ähnlichkeit höher. Näher an 1,0 bedeutet sehr ähnliche Bedeutung, während näher an 0 geringe Relevanz bedeutet.

Was ist die Rückerstattungsfrist?

[0.12, -0.45, 0.78, ...]

Vollständige Rückerstattung innerhalb von 30 Tagen nach dem Kauf verfügbar.

[0.14, -0.42, 0.80, ...]

Unser Büro ist in Seattle

[-0.67, 0.33, -0.11, ...]

Nah!
(Ähnlichkeit ≈ 0,6415)

Weit entfernt
(Ähnlichkeit ≈ 0,1706)

Lassen Sie uns dies tatsächlich berechnen:

python
from langchain_openai import OpenAIEmbeddings
import numpy as np
 
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# Abfrage und zwei Kandidatendokumente einbetten
query_vec = embeddings_model.embed_query("Was ist die Rückerstattungsfrist?")
doc1_vec = embeddings_model.embed_query("Vollständige Rückerstattung innerhalb von 30 Tagen nach dem Kauf verfügbar.")  # Verwandt
doc2_vec = embeddings_model.embed_query("Unser Büro befindet sich in der Innenstadt von Seattle.")  # Nicht verwandt
 
def cosine_similarity(a, b):
    """Berechnet die Kosinus-Ähnlichkeit zwischen zwei Vektoren."""
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
 
sim1 = cosine_similarity(query_vec, doc1_vec)
sim2 = cosine_similarity(query_vec, doc2_vec)
 
print(f"Abfrage vs 'Rückerstattung' Dok:    {sim1:.4f}")
print(f"Abfrage vs 'Büro' Dok:   {sim2:.4f}")

Ausgabe:

Abfrage vs 'Rückerstattung' Dok:    0.6415
Abfrage vs 'Büro' Dok:   0.1706

(Tatsächliche Werte können je nach Modell variieren)

Das Rückerstattungsdokument erzielt einen viel höheren Score. Das Embedding-Modell versteht, dass „Rückerstattungsfrist" und „vollständige Rückerstattung innerhalb von 30 Tagen" semantisch verwandt sind. Dies ist semantische Suche und der Kernmechanismus von RAG.

9.1.7) RAG-Pipeline-Übersicht

Die Kombination der Konzepte, die wir gelernt haben, ergibt die folgende RAG-Pipeline:

Phase 2: Abruf und Antwortgenerierung

Phase 1: Wissensaufnahme

📄 Vollständiges Dokument

✂️ In Chunks aufteilen

🔢 Chunks einbetten

Vector Store

❓ Benutzerfrage

🔢 Frage einbetten

🔍 Ähnlichkeitssuche

📋 Relevante Chunks extrahieren

📝 Prompt-Augmentation
(Frage + Relevante Chunks)

🤖 LLM

Antwort basierend auf relevanten Chunks

Die Pipeline besteht aus zwei Phasen:

Wissensaufnahme (einmalig zu Beginn durchgeführt oder wenn sich Dokumente ändern):

  1. Dokumentenladen: Textdaten aus verschiedenen Quellen extrahieren (Markdown, PDF usw.).
  2. Text-Splitting (Chunking): Lange Dokumente in kleinere Chunks aufteilen, um die Abrufpräzision zu verbessern und die LLM-Eingabegrenzen einzuhalten.
  3. Vektorkonvertierung (Embedding): Ein Embedding-Modell verwenden, um Chunks in bedeutungsbasierte numerische Vektoren umzuwandeln.
  4. Vektorspeicherung: Die konvertierten Vektoren und den Originaltext in einer Vektordatenbank speichern (Indizierung).

Abruf und Antwortgenerierung (für jede Benutzerfrage durchgeführt):

  1. Frage-Embedding: Die Frage des Benutzers mit demselben Modell, das während der Aufnahme verwendet wurde, in einen numerischen Vektor umwandeln.
  2. Ähnlichkeitssuche (Retrieval): Die Top-K-Chunks extrahieren, die dem Fragevektor aus der Vektordatenbank semantisch am nächsten sind.
  3. Prompt-Augmentation: Die ursprüngliche Frage mit den abgerufenen Chunks kombinieren, um den Prompt zu erweitern.
  4. Antwortgenerierung: Das LLM referenziert die bereitgestellten Chunks, um eine fundierte Antwort zu generieren.

9.2) Dokumentenladen und Chunking

Dieser Abschnitt behandelt die ersten beiden Schritte der Wissensaufnahme-Phase der RAG-Pipeline:

  1. Dokumentenladen: Textdaten aus Dateien lesen
  2. Text-Splitting (Chunking): Textdaten in kleine, durchsuchbare Teile aufteilen

Im nächsten Abschnitt (9.3) lernen wir, wie man diese Chunks in Vektoren umwandelt und speichert.

9.2.1) Dokumente aus Dateien laden

Der erste Schritt in einer RAG-Pipeline besteht darin, Dokumente in Python-Objekte zu laden. LangChain bietet Document Loader – Klassen, die eine Vielzahl von Dateiformaten unterstützen. Die wichtigsten Loader sind:

  • TextLoader: Klartext (.txt) und Markdown (.md) Dateien
  • PyPDFLoader: PDF (.pdf) Dateien, seitenweise geladen
  • CSVLoader: CSV (.csv) Dateien, wobei jede Zeile als separates Dokument geladen wird
  • UnstructuredMarkdownLoader: Markdown (.md) Dateien mit Strukturbewusstsein (Überschriften, Listen usw.)

Unabhängig davon, welchen Loader Sie verwenden, wird das Ergebnis immer als Liste von Document-Objekten zurückgegeben. Jedes Document hat zwei Schlüsselattribute:

  • page_content: Der Textinhalt des Dokuments
  • metadata: Ein Dictionary mit Metainformationen wie Dateipfad und Seitenzahl

In diesem Tutorial verwenden wir TextLoader, um Markdown-Dateien zu laden.

Beispieldokumente vorbereiten

Erstellen Sie zunächst einige Beispieldokumente, mit denen wir arbeiten können. Erstellen Sie einen Ordner data/docs/ in Ihrem Projekt und fügen Sie die folgenden Dateien hinzu:

bash
mkdir -p data/docs

Erstellen Sie data/docs/refund_policy.md:

markdown
# Rückerstattungsrichtlinie
 
**Gültigkeitsdatum**: 1. Januar 2026
 
## Standardrückgaben
 
Alle physischen Produkte können innerhalb von 30 Tagen nach dem Kauf für eine vollständige Rückerstattung zurückgegeben werden.
Der Originalbeleg oder die Bestellbestätigungs-E-Mail ist erforderlich. Artikel müssen sich in ihrer
Originalverpackung und in unbenutztem Zustand befinden.
 
Nach 30 Tagen werden Rückgaben nur für Ladenguthaben akzeptiert. Ladenguthaben verfällt nicht.
 
## Digitale Produkte
 
Digitale Produkte (Softwarelizenzen, E-Books, Online-Kurse) sind nicht erstattungsfähig,
sobald der Download- oder Zugriffslink aktiviert wurde. Wenn Sie technische
Probleme haben, die den Zugriff verhindern, wenden Sie sich innerhalb von 7 Tagen an den Support für einen Ersatz oder eine Rückerstattung.
 
## Defekte Artikel
 
Defekte Artikel können jederzeit für eine vollständige Rückerstattung oder einen Ersatz zurückgegeben werden.
Bitte fügen Sie eine Beschreibung des Defekts bei. Versandkosten für defekte Rückgaben
werden vom Unternehmen übernommen.
 
## Abonnementdienste
 
Monatliche Abonnements können jederzeit gekündigt werden. Rückerstattungen werden anteilig basierend auf
den verbleibenden Tagen im Abrechnungszeitraum berechnet. Jahresabonnements können innerhalb der ersten 14 Tage vollständig erstattet werden. Nach 14 Tagen ist keine Rückerstattung verfügbar, aber der Zugriff bleibt bis zum Ende des Abrechnungszeitraums bestehen.

Erstellen Sie data/docs/shipping_info.md:

markdown
# Versandinformationen
 
## Inlandsversand
 
Standardversand (5-7 Werktage): Kostenlos bei Bestellungen über 50 $, sonst 5,99 $.
Expressversand (2-3 Werktage): 12,99 $.
Über-Nacht-Versand (nächster Werktag): 24,99 $.
 
## Internationaler Versand
 
Internationale Bestellungen werden per verfolgter Luftpost versandt. Die Lieferzeiten variieren je nach
Zielort, typischerweise 10-21 Werktage. Internationale Versandkosten werden
beim Checkout basierend auf Gewicht und Zielort berechnet.
 
Zollgebühren und Einfuhrsteuern liegen in der Verantwortung des Käufers und sind nicht in den Versandkosten enthalten.
 
## Bestellverfolgung
 
Alle Bestellungen enthalten eine Tracking-Nummer, die innerhalb von 24 Stunden nach dem Versand per E-Mail gesendet wird.
Verfolgen Sie Ihre Bestellung über den Tracking-Link in Ihrer E-Mail oder über die Website des Spediteurs.
 
## Verlorene oder beschädigte Pakete
 
Wenn Ihr Paket verloren geht oder beschädigt ankommt, wenden Sie sich innerhalb von 48 Stunden an den Support.
Wir versenden einen Ersatz ohne zusätzliche Kosten. Bei beschädigten Artikeln geben Sie bitte
Fotos des Schadens und der Verpackung an.

Laden Sie nun diese Dateien mit TextLoader:

python
from pathlib import Path
from langchain_community.document_loaders import TextLoader
 
# Alle .md-Dateien aus dem Verzeichnis data/docs laden
docs_dir = Path("data/docs")
 
for md_file in docs_dir.glob("*.md"):
    loader = TextLoader(str(md_file), encoding="utf-8")
    docs = loader.load()
 
    if docs:  # Prüfen, dass die Datei nicht leer ist
        doc = docs[0]  # Einzelne Datei = einzelnes Document
        print(f"Datei: {doc.metadata['source']}")
        print(f"Länge: {len(doc.page_content)} Zeichen")
        print(f"Vorschau: {doc.page_content[:80]}...")
        print()

Ausgabe:

Datei: data/docs/refund_policy.md
Länge: 1166 Zeichen
Vorschau: # Rückerstattungsrichtlinie
...
 
Datei: data/docs/shipping_info.md
Länge: 972 Zeichen
Vorschau: # Versandinformationen
...

Hinweis: TextLoader nimmt einen einzelnen Dateipfad als Eingabe, gibt aber List[Document] für eine konsistente Schnittstelle mit anderen Loadern zurück. (Zum Beispiel gibt PDFLoader mehrere Documents zurück – eines pro Seite.)

9.2.2) Dokumente in Chunks aufteilen: Chunking

Die beiden obigen Dokumente sind absichtlich kurz für dieses Tutorial. In realen Anwendungen arbeiten Sie oft mit Dokumenten, die Hunderte oder Tausende von Seiten lang sind. Wenn Sie ein gesamtes Dokument als einzelnen Vektor einbetten, werden Tausende von Konzepten in eines komprimiert – was es unmöglich macht, genau das abzurufen, was Sie tatsächlich benötigen.

Chunking ist der Prozess, Dokumente in kleine, bedeutungsvolle Teile aufzuteilen. Das Ziel ist einfach: Wenn ein Benutzer eine Frage stellt, sollten nur die spezifischen Absätze, die direkt für die Antwort relevant sind, abgerufen werden – nicht das gesamte Dokument.

Die Chunk-Größe wirkt sich direkt sowohl auf den Abruf als auch auf die Antwortqualität aus:

  • Zu groß: Mehrere Themen werden in einen Chunk gemischt, was Embeddings weniger genau macht und den Abruf erschwert. Selbst wenn der richtige Chunk gefunden wird, wird irrelevanter Inhalt an das LLM übergeben, was die Antwortqualität verschlechtert.
  • Zu klein: Das LLM erhält möglicherweise nicht genügend Informationen, um korrekt zu antworten. Wenn beispielsweise nur der Satz „Standardversand kostet 5,99 $" abgerufen wird, kann das LLM nicht wissen, dass dies nur für Bestellungen unter 50 $ gilt.
  • Genau richtig: Jeder Chunk deckt ein Thema mit ausreichend Kontext ab, was genauen Abruf und Antworten ermöglicht.

9.2.3) Chunk-Größe und Überlappung steuern

Um Dokumente in Chunks aufzuteilen, benötigen Sie einen Text-Splitter. Ein Text-Splitter ist ein LangChain-Tool, das lange Dokumente in kleinere Teile aufteilt. Die Wahl des richtigen Splitters ist wichtig.

  • RecursiveCharacterTextSplitter: Versucht mehrere Trennzeichen in hierarchischer Reihenfolge, um so viel Kontext wie möglich zu bewahren. Der am häufigsten verwendete Splitter für allgemeine Zwecke.
  • CharacterTextSplitter: Teilt an einem einzelnen Trennzeichen (Standard: \n\n). Geeignet für Dokumente mit einfacher Struktur.
  • MarkdownHeaderTextSplitter: Teilt an Markdown-Überschriften (#, ##). Effektiv, wenn Sie die Inhaltsverzeichnisstruktur des Dokuments bewahren möchten.

Warum ist RecursiveCharacterTextSplitter effektiv?

Dieser Splitter funktioniert, indem er Trennzeichen von der größten zur kleinsten Einheit ausprobiert, um den besten Teilungspunkt zu finden. Die Standardreihenfolge ist wie folgt (kann über den Parameter separators geändert werden):

Absatz (\n\n) → Zeilenumbruch (\n) → Wort ( )

Er versucht immer zuerst, an der größten bedeutungsvollen Einheit zu teilen. Wenn ein Absatz chunk_size überschreitet, fällt er auf Zeilenumbrüche zurück, dann auf Wörter. Weil er immer den natürlichsten Teilungspunkt findet, anstatt willkürlich mitten in einem Wort zu schneiden, enthalten die resultierenden Chunks eher semantisch vollständige Informationen.

Schlüsselparameter

  • chunk_size: Die maximale Anzahl von Zeichen pro Chunk. Zum Beispiel bedeutet chunk_size=400, dass kein Chunk 400 Zeichen überschreitet.
  • chunk_overlap: Die Anzahl überlappender Zeichen zwischen benachbarten Chunks. Zum Beispiel bedeutet chunk_overlap=80, dass die letzten 80 Zeichen eines Chunks am Anfang des nächsten wiederholt werden.
  • separators: Die Liste der Trennzeichen, die zum Aufteilen des Textes verwendet werden, in Prioritätsreihenfolge ausprobiert. Wenn das Aufteilen am aktuellen Trennzeichen chunk_size überschreiten würde, wird das nächste Trennzeichen ausprobiert, um ein Überschreiten von chunk_size zu vermeiden.

Was ist Überlappung und warum wird sie benötigt?

Überlappung bedeutet, dass benachbarte Chunks einige Inhalte teilen – das Ende eines Chunks ist am Anfang des nächsten enthalten.

Der Grund dafür ist, sicherzustellen, dass jeder Chunk für sich allein mit ausreichend Kontext stehen kann. Wenn man ein Stück eines Dokuments liest, ohne zu wissen, was vorher kam, kann es schwer sein zu verstehen, warum bestimmte Inhalte erwähnt werden. Die Überlappung hält das Ende eines Chunks fließend in den nächsten, sodass, welcher Chunk auch immer abgerufen wird, der Inhalt natürlich lesbar ist.

📄 Originaldokument
Abs 1 | Abs 2 | Abs 3 | Abs 4

✂️ Aufteilen

📋 Chunk 1

Abs 1

📋 Chunk 2

Ende von Abs 1
+ Abs 2

📋 Chunk 3

Ende von Abs 2
+ Abs 3

📋 Chunk 4

Ende von Abs 3
+ Abs 4

Lassen Sie uns nun tatsächlich ein Dokument aufteilen:

python
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
 
# Dokument laden
loader = TextLoader("data/docs/refund_policy.md", encoding="utf-8")
docs = loader.load()
 
# Splitter konfigurieren
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " "],
)
 
chunks = text_splitter.split_documents(docs)
 
print(f"In {len(chunks)} Chunks aufgeteilt\n")
for i, chunk in enumerate(chunks):
    print(f"--- Chunk {i} (Quelle: {chunk.metadata['source']}) ---")
    print(f"Länge: {len(chunk.page_content)} Zeichen")
    print(chunk.page_content[:120])
    print()

Ausgabe:

In 4 Chunks aufgeteilt
 
--- Chunk 0 (Quelle: data/docs/refund_policy.md) ---
Länge: 374 Zeichen
# Rückerstattungsrichtlinie
...
 
--- Chunk 1 (Quelle: data/docs/refund_policy.md) ---
Länge: 267 Zeichen
## Digitale Produkte
...
 
--- Chunk 2 (Quelle: data/docs/refund_policy.md) ---
Länge: 204 Zeichen
## Defekte Artikel
...
 
--- Chunk 3 (Quelle: data/docs/refund_policy.md) ---
Länge: 315 Zeichen
## Abonnementdienste
...

Hinweis: Im obigen Beispiel trat keine Überlappung auf. Dies liegt daran, dass jeder Absatz sauber basierend auf dem ersten Trennzeichen (\n## ) aufgeteilt wurde, während er innerhalb der chunk_size blieb. Überlappung tritt nur auf, wenn ein bestimmter Absatz länger als die chunk_size ist und in zwei oder mehr Teile aufgeteilt werden muss.

9.3) Vektorspeicherung und Abruf mit ChromaDB

9.3.1) Was ist ein Vector Store?

Ein Vector Store (auch Vektordatenbank genannt) ist eine Datenbank, die für das Speichern und Durchsuchen von Daten mithilfe von Embedding-Vektoren optimiert ist. Im Gegensatz zu einer traditionellen Datenbank, bei der Sie nach exakten Feldwerten abfragen (SELECT * FROM products WHERE category = 'electronics'), findet ein Vector Store die Elemente mit der ähnlichsten Bedeutung zu Ihrer Abfrage.

In RAG enthält der Vector Store Dokumenten-Chunks zusammen mit ihren Embeddings. Wenn ein Benutzer eine Frage stellt, wird die Frage in einen Vektor umgewandelt, und der Vector Store ruft die Chunks mit den ähnlichsten Vektoren ab.

9.3.2) Auswahl eines Vector Store und Einrichtung von ChromaDB

Beliebte Vector Stores sind ChromaDB, Pinecone, Weaviate und pgvector (PostgreSQL-Erweiterung). Sie unterscheiden sich im Hosting-Modell (lokal vs. Cloud), Skalierung und Betriebskomplexität. Für dieses Buch verwenden wir ChromaDB – es ist Open Source, läuft vollständig auf Ihrem lokalen Rechner ohne Server-Setup und ist nicht nur für die Entwicklung, sondern auch für kleine bis mittlere Produktionsworkloads nützlich.

ChromaDB kann auf verschiedene Arten verwendet werden:

  • Lokaler Modus (pip): Installieren Sie es als Python-Bibliothek und verwenden Sie es sofort. Sie können Daten in einem lokalen Verzeichnis speichern und laden, ohne separate Server-Infrastruktur.
  • Standalone-Server (Docker): Führen Sie ChromaDB als separaten Serverprozess aus. Nützlich, wenn mehrere Anwendungen denselben Vector Store teilen müssen.
  • Verwalteter Cloud-Service (Chroma Cloud): Verwenden Sie ChromaDB als Cloud-Service. Chroma Cloud übernimmt Hosting, Skalierung und Wartung, sodass Sie einen stabilen Service ohne Infrastrukturverwaltungsaufwand bereitstellen können.

Lassen Sie uns ChromaDB mit pip installieren:

bash
pip install chromadb langchain-chroma

chromadb ist die Kern-Vector-Store-Bibliothek, und langchain-chroma ist ein Integrationspaket, mit dem Sie ChromaDB direkt innerhalb der LangChain-Bibliothek verwenden können.

9.3.3) Auswahl des Embedding-Modells

Das erste, was zu entscheiden ist, ist, welches Embedding-Modell verwendet werden soll. Embedding-Vektoren können nur verglichen werden, wenn sie vom selben Modell generiert werden. Daher müssen Sie dasselbe Embedding-Modell sowohl zum Speichern von Dokumenten als auch zum Abfragen verwenden.

OpenAI bietet die folgenden Embedding-Modelle:

ModellDimensionenHinweise
text-embedding-3-small1536Gutes Gleichgewicht von Qualität und Kosten
text-embedding-3-large3072Höhere Qualität, höhere Kosten

Für dieses Buch verwenden wir OpenAIs text-embedding-3-small-Modell. Es bietet hohe Effizienz bei niedrigen Kosten und ist eine praktische Wahl für allgemeine Suche, RAG und kostenbewusste Projekte.

python
from langchain_openai import OpenAIEmbeddings
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# Überprüfen, ob es funktioniert
test_vector = embedding_model.embed_query("test")
print(f"Embedding-Dimensionen: {len(test_vector)}")

Ausgabe:

Embedding-Dimensionen: 1536

Kostenhinweis: Embedding-API-Aufrufe sind viel günstiger als LLM-Aufrufe, verursachen aber Kosten. Beim Speichern von Dokumenten in der Datenbank (Indizierung) ist ein API-Aufruf pro Chunk erforderlich, und wenn ein Benutzer eine Frage stellt (Abruf), ist ein API-Aufruf für die Frage erforderlich. Für aktuelle Preise siehe die OpenAI-Preisseite.

9.3.4) Chunks in ChromaDB speichern

Lassen Sie uns nun alles zusammenfügen. Wir laden Dokumente, teilen sie in Chunks auf, betten die Chunks ein und speichern sie zusammen mit ihren Embedding-Vektoren in ChromaDB.

python
# ingest.py - Vollständige Aufnahme-Pipeline
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# Schritt 1: Dokumente laden
# DirectoryLoader: scannt ein Verzeichnis und lädt übereinstimmende Dateien.
# Das eigentliche Laden wird an den in loader_cls angegebenen Loader delegiert.
loader = DirectoryLoader(
    "data/docs/", glob="**/*.md",
    loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"{len(documents)} Dokumente geladen")
 
# Schritt 2: In Chunks aufteilen
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"{len(chunks)} Chunks erstellt")
 
# Schritt 3: Embedding-Modell erstellen
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# Schritt 4: Vector Store erstellen und Chunks aufnehmen
vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=embedding_model,
    persist_directory="data/chroma_db",
    collection_name="company_docs",
)
 
print(f"{len(chunks)} Chunks in ChromaDB unter data/chroma_db/ gespeichert")

Ausgabe:

2 Dokumente geladen
8 Chunks erstellt
8 Chunks in ChromaDB unter data/chroma_db/ gespeichert

Die Methode Chroma.from_documents() führt zwei Aufgaben in einem einzigen Aufruf aus:

  1. Übergibt die über den Parameter documents bereitgestellten Chunks durch das Embedding-Modell, um Embedding-Vektoren zu erhalten.
  2. Speichert jeden Chunk zusammen mit seinem Embedding-Vektor in ChromaDB.

9.3.5) Einen persistierten Vector Store laden

Im vorherigen Abschnitt haben wir Dokumente im Vector Store gespeichert. Dieser Speichervorgang muss nur einmal zu Beginn durchgeführt werden (oder wenn sich Dokumente ändern). Danach können Sie einfach den persistierten Vector Store laden und direkt verwenden.

python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# Einen persistierten Vector Store laden – kein erneutes Embedding erforderlich
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
print(f"Vector Store mit {len(vector_store.get()['ids'])} Chunks geladen")

Ausgabe:

Vector Store mit 8 Chunks geladen

Jetzt können Sie sofort mit der Suche beginnen, indem Sie einfach den persistierten Vector Store laden, ohne Ihre Dokumente erneut einbetten zu müssen.

9.3.6) Ähnlichkeitssuche

Mit dem geladenen Vector Store können Sie nun nach Chunks suchen, die einer Abfrage semantisch ähnlich sind. Der Parameter top-K gibt an, wie viele Ergebnisse zurückgegeben werden sollen:

python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
# Nach Chunks suchen, die mit einer Frage zusammenhängen
query = "Kann ich ein digitales Produkt zurückgeben?"
results = vector_store.similarity_search(query, k=2)
 
print(f"Abfrage: {query}")
print(f"{len(results)} Ergebnisse gefunden\n")
 
for i, doc in enumerate(results):
    print(f"--- Ergebnis {i + 1} (Quelle: {doc.metadata['source']}) ---")
    print(doc.page_content[:200])
    print()

Ausgabe:

Abfrage: Kann ich ein digitales Produkt zurückgeben?
2 Ergebnisse gefunden
 
--- Ergebnis 1 (Quelle: data/docs/refund_policy.md) ---
## Digitale Produkte
 
...
 
--- Ergebnis 2 (Quelle: data/docs/refund_policy.md) ---
# Rückerstattungsrichtlinie
 
**Gültigkeitsdatum**: 1. Januar 2026
 
## Standardrückgaben
 
...

Für diese Abfrage wurde der Chunk zu digitalen Produkten mit der höchsten Ähnlichkeit abgerufen, gefolgt vom Rückerstattungsrichtlinien-Chunk.

Sie können auch Ergebnisse mit ihren Ähnlichkeits-Scores mit similarity_search_with_score abrufen:

python
results_with_scores = vector_store.similarity_search_with_score(query, k=2)
 
for doc, score in results_with_scores:
    # ChromaDB gibt Distanz zurück (niedriger = ähnlicher)
    print(f"Score: {score:.4f} | Quelle: {doc.metadata['source']}")
    print(f"  {doc.page_content[:200]}...")
    print()

Ausgabe:

Score: 0.5942 | Quelle: data/docs/refund_policy.md
  ## Digitale Produkte
 
...
 
Score: 0.9577 | Quelle: data/docs/refund_policy.md
  # Rückerstattungsrichtlinie
 
...

Beachten Sie, dass ChromaDB Distanz-Scores verwendet (niedriger ist ähnlicher), nicht Ähnlichkeits-Scores (höher ist ähnlicher). Der Chunk zu digitalen Produkten hat die niedrigste Distanz von 0,5942, was ihn zum relevantesten Ergebnis macht.

9.4) Erstellen der vollständigen RAG-Chain

Jetzt erstellen wir ein vollständiges RAG-System: Rufen Sie zuerst relevante Dokumente ab und übergeben Sie sie dann zusammen mit der Frage an das LLM, um Antworten basierend auf den bereitgestellten Informationen zu generieren.

9.4.1) Entwurf der Prompt-Vorlage

Der wichtigste Teil der Prompt-Vorlage besteht darin, das LLM anzuweisen, nur auf Basis des bereitgestellten Kontexts zu antworten. Ohne diese Anweisung kann das LLM die Suchergebnisse ignorieren und Antworten basierend auf seinen Trainingsdaten erfinden.

python
from langchain_core.prompts import ChatPromptTemplate
 
rag_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "Sie sind ein Kundendienstmitarbeiter. "
     "Beantworten Sie die Frage des Benutzers NUR unter Verwendung des bereitgestellten Kontexts. "
     "Wenn der Kontext nicht genügend Informationen enthält, um zu antworten, "
     "sagen Sie \"Ich habe nicht genügend Informationen, um diese Frage zu beantworten.\"\n\n"
     "Kontext:\n{context}"),
    ("human", "{question}"),
])

Die Systemnachricht zwingt das LLM, nur unter Verwendung des bereitgestellten Kontexts zu antworten. Entscheidend ist die Anweisung, „Ich habe nicht genügend Informationen" zu sagen, wenn der Kontext unzureichend ist, was verhindert, dass das LLM plausibel klingende, aber nicht unterstützte Antworten erfindet.

9.4.2) Erstellen der RAG-Chain

Wir haben jetzt alle Komponenten bereit. Wir müssen nur den Retriever, die Prompt-Vorlage und das LLM verbinden.

Das fertige RAG-System funktioniert wie folgt:

  1. Empfängt die Frage des Benutzers
  2. Ruft relevante Chunks aus dem Vector Store ab
  3. Übergibt die Chunks und die Frage an die Prompt-Vorlage, um den Prompt zu generieren
  4. Generiert eine Antwort mit dem LLM

context

question

Benutzerfrage

Vector Store-Suche

Abgerufene Chunks
(zu einem einzelnen String kombiniert)

Prompt-Generierung

LLM

Antwort

Lassen Sie uns die RAG-Chain mit dem LCEL-Operator | aus Kapitel 6 verbinden.

python
# rag_chain.py - Vollständige RAG-Pipeline
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
 
 
def format_docs(docs):
    """Verbindet abgerufene Dokumente zu einem einzelnen Kontext-String."""
    return "\n\n---\n\n".join(doc.page_content for doc in docs)
 
 
def build_rag_chain():
    """Erstellt und gibt die vollständige RAG-Chain zurück."""
    # Vector Store laden
    embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
    vector_store = Chroma(
        persist_directory="data/chroma_db",
        collection_name="company_docs",
        embedding_function=embedding_model,
    )
 
    # Retriever erstellen (k=3 bedeutet die Top-3-Chunks zurückgeben)
    retriever = vector_store.as_retriever(search_kwargs={"k": 3})
 
    # Prompt definieren
    rag_prompt = ChatPromptTemplate.from_messages([
        ("system",
         "Sie sind ein Kundendienstmitarbeiter. "
         "Beantworten Sie die Frage des Benutzers NUR unter Verwendung des bereitgestellten Kontexts. "
         "Wenn der Kontext nicht genügend Informationen enthält, um zu antworten, "
         "sagen Sie \"Ich habe nicht genügend Informationen, um diese Frage zu beantworten.\"\n\n"
         "Kontext:\n{context}"),
        ("human", "{question}"),
    ])
 
    # LLM initialisieren
    llm = ChatOpenAI(model="gpt-5-mini")
 
    # Chain mit LCEL zusammensetzen
    rag_chain = (
        {"context": retriever | format_docs, "question": lambda x: x}
        | rag_prompt
        | llm
        | StrOutputParser()
    )
 
    return rag_chain
 
 
if __name__ == "__main__":
    chain = build_rag_chain()
    answer = chain.invoke("Kann ich ein digitales Produkt zurückgeben?")
    print(answer)

Ausgabe:

Digitale Produkte sind nicht erstattungsfähig, sobald der Download- oder Zugriffslink aktiviert wurde.
Wenn Sie technische Probleme haben, die den Zugriff verhindern, wenden Sie sich innerhalb von 7 Tagen an den Support für einen Ersatz oder eine Rückerstattung.

Lassen Sie uns die Chain-Komposition Schritt für Schritt aufschlüsseln:

python
rag_chain = (
    {"context": retriever | format_docs, "question": lambda x: x}
    | rag_prompt
    | llm
    | StrOutputParser()
)

Wenn Sie chain.invoke("Kann ich ein digitales Produkt zurückgeben?") aufrufen, passiert Folgendes:

  1. Dictionary-Schritt:
    • retriever | format_docs: Durchsucht den Vector Store mit der Frage und kombiniert die Chunks zu einem einzelnen String
    • lambda x: x: Übergibt die Frage unverändert durch
    • Ergebnis: {"context": "abgerufene Chunks (zu einem einzelnen String kombiniert)", "question": "Kann ich ein digitales Produkt zurückgeben?"}
  2. rag_prompt: Füllt die Platzhalter {context} und {question} in der Prompt-Vorlage mit den Dictionary-Werten
  3. llm: Sendet den vollständigen Prompt an das LLM
  4. StrOutputParser(): Extrahiert nur den Text aus der Antwort des LLM

Für weitere Details zur Funktionsweise von LCEL siehe Kapitel 6.

9.4.3) Testen mit beantwortbaren und nicht beantwortbaren Fragen

Ein RAG-System muss sowohl Fragen behandeln, die es beantworten kann (Informationen existieren in den Dokumenten), als auch Fragen, die es nicht beantworten kann (Informationen sind nicht in den Dokumenten). Lassen Sie uns beide Szenarien testen:

python
# test_rag.py - RAG-Chain mit verschiedenen Fragen testen
from rag_chain import build_rag_chain
 
chain = build_rag_chain()
 
test_questions = [
    # Beantwortbar — Informationen sind in den Dokumenten
    "Was ist die Rückerstattungsrichtlinie für physische Produkte?",
    "Wie viel kostet der Expressversand?",
    "Kann ich einen defekten Artikel nach 6 Monaten zurückgeben?",
    # Nicht beantwortbar — Informationen sind NICHT in den Dokumenten
    "Was ist die Urlaubsregelung für Mitarbeiter?",
    "Welche Programmiersprachen werden verwendet?",
]
 
for question in test_questions:
    print(f"F: {question}")
    answer = chain.invoke(question)
    print(f"A: {answer}\n")
    print("-" * 60)

Ausgabe:

F: Was ist die Rückerstattungsrichtlinie für physische Produkte?
A: Alle physischen Produkte können innerhalb von 30 Tagen nach dem Kauf für eine vollständige Rückerstattung zurückgegeben werden. ...
 
------------------------------------------------------------
F: Wie viel kostet der Expressversand?
A: Der Expressversand (2–3 Werktage) kostet 12,99 $.
 
------------------------------------------------------------
F: Kann ich einen defekten Artikel nach 6 Monaten zurückgeben?
A: Ja. Defekte Artikel können jederzeit für eine vollständige Rückerstattung oder einen Ersatz zurückgegeben werden. ...
 
------------------------------------------------------------
F: Was ist die Urlaubsregelung für Mitarbeiter?
A: Ich habe nicht genügend Informationen, um diese Frage zu beantworten.
 
------------------------------------------------------------
F: Welche Programmiersprachen werden verwendet?
A: Ich habe nicht genügend Informationen, um diese Frage zu beantworten.
 
------------------------------------------------------------

Die Ergebnisse zeigen genau das Verhalten, das wir wollen:

  • Beantwortbare Fragen: Liefern genaue Antworten basierend auf den abgerufenen Dokumenten. Das LLM fügt keine Informationen hinzu, die nicht in den Dokumenten enthalten sind.
  • Nicht beantwortbare Fragen: Antworten mit „Ich habe nicht genügend Informationen, um diese Frage zu beantworten." Das LLM erkennt korrekt, dass dem abgerufenen Kontext relevante Informationen fehlen, und weigert sich, eine Antwort zu erfinden.

Das ist die Kraft von RAG. Ihr LLM beantwortet Fragen zu Ihren Daten und gibt ehrlich zu, wenn es es nicht weiß. Jede Antwort wird durch Dokumente gestützt, was das System weitaus vertrauenswürdiger macht als ein Standard-LLM.