LLMs brauchen Beweis, um richtig zu antworten: RAG erklärt mit Beispielen

Zu diesem Artikel gibt es ein kurzes Video:YouTubeTikTok

1. Übersicht

Egal wie mächtig ein LLM ist, es kennt von Natur aus nicht den Inhalt der internen Dokumente deines Unternehmens – es kennt nur das, was in den Input eingegeben wird. Dieser Artikel erklärt den Mechanismus von Retrieval-Augmented Generation (RAG): warum du es brauchst, wie Retrieval funktioniert, und warum die Qualität der Antwort fast ganz vom Retrievalschritt vor der Textgenerierung abhängt. Nach dem Lesen wirst du den Datenfluss in einem RAG-System verstehen und ein einfaches Retrieval-Pipeline in Java selbst schreiben können.

2. Das Problem: LLMs haben kein „Gedächtnis” für private Daten

Das Modell wurde auf einem öffentlichen Datensatz trainiert, der zu einem bestimmten Zeitpunkt eingefroren ist. Es kennt nicht die neue Rückerstattungsrichtlinie, die dein Unternehmen letzte Woche ausgegeben hat, und kennt auch nicht den Inhalt irgendeines internen Vertrags.

Es gibt zwei Wege, um private Informationen einzuspeisen:

  1. Direkt in den Prompt einzufügen – nur machbar, wenn das Dokument klein ist und in das Context Window passt.
  2. Abrufen (Retrieval) – wenn die Dokumentensammlung groß ist, wählt die Anwendung nur die relevantesten Abschnitte aus und gibt sie dann in den Prompt ein.
// Ansatz 1: alle Dokumente einfügen – funktioniert nur bei kurzem Text
String prompt = """
    Basierend auf der folgenden Richtlinie, antworte auf die Frage des Kunden.
    Richtlinie: %s
    Frage: %s
    """.formatted(fullPolicyText, userQuestion);

Dieser Ansatz ist einfach, skaliert aber nicht: das Context Window hat ein Token-Limit, und je mehr irrelevanten Text man einfügt, desto eher gerät das Modell aus dem Fokus (lost in the middle). Wenn die Dokumentensammlung Tausende von Seiten umfasst, muss man Retrieval verwenden.

3. RAG: Was es ist und wie es funktioniert

RAG (Retrieval-Augmented Generation) teilt das Problem in zwei unabhängige Phasen auf:

  • Retrieval: die richtige Textpassage für die Frage finden.
  • Generation: Das LLM nutzt diese Abschnitte, um eine Antwort mit Quellenangaben zu generieren.
Benutzerfrage
      │
      ▼
[Embedding-Modell] ── konvertiert die Frage in einen Vektor
      │
      ▼
[Vektordatenbank] ── findet die semantisch ähnlichsten Textpassagen
      │
      ▼
[Original-Passagen] ── den Rohtext in den Prompt einfügen
      │
      ▼
[LLM] ── generiert eine Antwort + Quellenangaben

Wichtiger Punkt: Das LLM führt keine direkte Abfrage der Vektordatenbank durch. Es empfängt nur Rohtext (plain text), den die Anwendung nach dem Retrieval bereitstellt. Die Vektordatenbank ist nur ein Suchewerkzeug, nicht ein Speicherort für „Wissen”, das das Modell ausliest.

3.1. Embedding – die Frage in Zahlen verwandeln

Das Embedding-Modell konvertiert einen Textabschnitt in einen Zahlenvektor (z.B. 1536-dimensional). Zwei Textabschnitte mit ähnlicher Bedeutung ergeben Vektoren, die sich im Vektorraum nahekommen.

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
@Service
public class PolicyEmbeddingService {

    private final EmbeddingModel embeddingModel;

    public PolicyEmbeddingService(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }

    public float[] embed(String text) {
        EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text));
        return response.getResult().getOutput();
    }
}

embed() nimmt einen Textabschnitt entgegen (z.B. den Inhalt der Frage „Wie ist die Richtlinie für die Stornierung von Hotelzimmern?“) und gibt ein float[]-Array zurück – einen Vektor, der die Semantik des Satzes darstellt. Dies ist das, was mit den bereits in der Vektordatenbank gespeicherten Vektoren abgeglichen wird.

3.2. Vektordatenbank – die semantisch ähnlichsten Abschnitte finden

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
@Service
public class PolicyRetrievalService {

    private final VectorStore vectorStore;

    public PolicyRetrievalService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public void indexPolicy(String policyId, String content) {
        Document document = new Document(content, Map.of("policyId", policyId));
        vectorStore.add(List.of(document));
    }

    public List<Document> search(String question) {
        SearchRequest request = SearchRequest.query(question).withTopK(3);
        return vectorStore.similaritySearch(request);
    }
}
  • indexPolicy() speichert einen Richtlinienabschnitt in der Vektordatenbank mit Metadaten (policyId), um später zu wissen, zu welchem Dokument dieser Textabschnitt gehört.
  • search() nimmt die Benutzerfrage entgegen, embeddet sie intern automatisch und findet die 3 semantisch ähnlichsten Abschnitte (topK(3)).
Ergebnis von search(„Wie ist die Hotelstornierungsrichtlinie?"):
1. „Der Gast kann das Zimmer kostenlos 24 Stunden vorher stornieren..."
2. „Für nicht rückerstattbare Buchungen (non-refundable)..."
3. „Die Stornierungsgebühr gilt für Gruppenbuchungen ab 5 Personen..."

3.3. Generation – LLM antwortet basierend auf Belegen

@Service
public class PolicyAnswerService {

    private final ChatClient chatClient;
    private final PolicyRetrievalService retrievalService;

    public PolicyAnswerService(ChatClient.Builder builder,
                                PolicyRetrievalService retrievalService) {
        this.chatClient = builder.build();
        this.retrievalService = retrievalService;
    }

    public String answer(String question) {
        List<Document> passages = retrievalService.search(question);

        String context = passages.stream()
            .map(Document::getContent)
            .collect(Collectors.joining("\n---\n"));

        String prompt = """
            Antworte nur basierend auf den folgenden Textabschnitten. Wenn die Informationen nicht ausreichen, sag klar, dass du es nicht weißt.
            Textabschnitte:
            %s

            Frage: %s
            """.formatted(context, question);

        return chatClient.prompt(prompt).call().content();
    }
}

Beachte die Anweisung „Antworte nur basierend auf den folgenden Textabschnitten” – dies ist eine wichtige Einschränkung, um Halluzinationen zu reduzieren: Das Modell wird aufgefordert, sich an Belege (Evidence) zu halten, statt vom vorhandenen Trainingswissen hinzuzufügen.

Hinweis: Wenn man nicht klar „nur basierend auf dem Context” einschränkt, kann das Modell sein Hintergrundwissen mit dem abgerufenen Inhalt vermischen, was zu Antworten führt, die plausibel klingen, aber vom tatsächlichen Dokument abweichen.

4. Warum schlechtes Retrieval zu schlechten Antworten führt

Das wird oft übersehen: LLMs sind nur gut darin, das zu interpretieren, was sie erhalten – sie haben keine Möglichkeit zu prüfen, ob die Information korrekt oder ausreichend ist.

Zwei häufige Szenarien:

Szenario 1 – Retrieval ruft veraltete Dokumente ab. Falls die Vektordatenbank noch alte Versionen der Richtlinien indexiert hat (nicht aktualisiert/gelöscht), gibt das System alte Textabschnitte zurück. Das LLM liest den Abschnitt und antwortet völlig selbstbewusst – aber falsch.

Szenario 2 – Retrieval fehlt wichtiger Kontext. Angenommen, das Originaldokument hat folgende Struktur:

Abschnitt 5. Stornierungsrichtlinie
Der Gast kann das Zimmer kostenlos 24 Stunden vor dem Einchecken stornieren.

Abschnitt 5.1. Ausnahmen
Für Buchungen während des Tết-Feiertags gilt die kostenlose Stornierung nicht;
die Stornierungsgebühr beträgt 50% des Buchungswerts.

Falls der Schritt zum Aufteilen des Dokuments (Chunking) „Abschnitt 5” und „Abschnitt 5.1” in separate Chunks trennt, und Retrieval nur „Abschnitt 5” abruft (weil die Frage nicht die Wörter „Tết” oder „Ausnahmen” enthält), wird das LLM antworten „kann kostenlos 24 Stunden vorher storniert werden” – klingt sehr plausibel, übersieht aber die wichtige Ausnahmebedingung.

@Test
void khiThieuChunkNgoaiLe_LLMSeBoSotDieuKien() {
    // Simuliere, dass Retrieval nur 1 Chunk zurückgibt und den Ausnahmeteil vermisst
    List<Document> incompletePassages = List.of(
        new Document("Der Gast kann das Zimmer kostenlos 24 Stunden vor dem Einchecken stornieren.")
    );

    String answer = fakeAnswerService.answerFromPassages(
        "Ich buche ein Zimmer zum Tết-Fest, kostet die Stornierung?",
        incompletePassages
    );

    // Die Antwort wird falsch sein, da der Beweis für die Feiertagsausnahme fehlt
    assertThat(answer).doesNotContain("50%");
}

Dieser Test soll das LLM nicht überführen – er zeigt, dass das Problem beim Retrieval liegt, nicht bei der Reasoning-Fähigkeit des Modells. Wenn du ausreichend Belege bereitstellst (beide Chunks), wird die Antwort auch mit einem mittelmäßigen Modell korrekt sein.

5. Chunking: der Schlüssel zum „gerade richtig” detaillierten Retrieval

Da das Beispiel oben zeigt, dass falsches Chunking Konsequenzen hat, ist dies der Bereich, in den du beim Aufbau von RAG am meisten investieren solltest.

@Service
public class PolicyChunkingService {

    public List<String> splitByHeading(String document) {
        // Nach dem Muster „Abschnitt X" oder „Abschnitt X.Y" aufteilen, um die Parent-Child-Kontexte zu bewahren
        String[] rawChunks = document.split("(?=Abschnitt \\d+)");
        return Arrays.stream(rawChunks)
            .map(String::trim)
            .filter(chunk -> !chunk.isBlank())
            .toList();
    }
}

Statt nach fester Zeichenanzahl zu teilen (z.B. alle 500 Zeichen – leicht, mitten zwischen Klauseln zu schneiden), teilt diese Funktion nach semantischen Grenzen („Abschnitt X”). Dies hilft jedem Chunk, eine vollständige Informationseinheit zu behalten und verringert das Risiko, dass Hauptregeln von ihren Ausnahmen getrennt werden.

@Test
void splitByHeading_PhaiGomCaDieuChinhVaNgoaiLe() {
    String document = """
        Abschnitt 5. Stornierungsrichtlinie
        Der Gast kann das Zimmer kostenlos 24 Stunden im Voraus stornieren.

        Abschnitt 5.1. Ausnahmen
        Zum Tết-Fest beträgt die feste Stornierungsgebühr 50%.
        """;

    List<String> chunks = chunkingService.splitByHeading(document);

    assertThat(chunks).hasSize(2);
    assertThat(chunks.get(1)).contains("Ausnahmen", "50%");
}

Hinweis: Heading-basiertes Chunking ist nur effektiv, wenn die Dokumente klar strukturiert sind (Verträge, Richtlinien, technische Dokumentation). Bei freiem Text (E-Mails, Chat-Protokolle) brauchst du eine andere Strategie – z.B. nach Absätzen mit Überlap teilen, oder Semantic Chunking basierend auf Ähnlichkeit benachbarter Sätze verwenden.

6. Wann RAG NICHT die richtige Lösung ist

RAG ist nicht die Universallösung:

  • Kleine, feste Datensätze: Wenn alle Dokumente nur wenige Tausend Token umfassen, ist es einfacher und weniger fehleranfällig, sie direkt in den Prompt einzufügen, statt ein ganzes Retrieval-Pipeline aufzubauen.
  • Fragen, die komplexes mehrstufiges Reasoning erfordern: deren Antwort nicht direkt in einem einzelnen Textabschnitt liegt – RAG findet „Abschnitte, die der Frage ähneln”, nicht „Abschnitte, die die abgeleitete Antwort enthalten”.
  • Daten, die sich sekündlich ändern (Aktienkurse, Echtzeit-Inventar): Rufe die API direkt auf (Function Calling), statt die Vektordatenbank ständig neu zu indexieren.

7. Fazit

LLMs „kennen” deine privaten Daten überhaupt nicht – sie sind nur gut darin, die bereitgestellten Belege zu interpretieren. RAG löst dieses Problem durch die Trennung von Retrieval (das richtige Textabschnitt finden) und Generation (die Antwort generieren), mit Embedding-Modell und Vektordatenbank als Bindeglied. Die Qualität der endgültigen Antwort hängt fast ganz davon ab, wie gut du Daten indexierst und aufteilst – auch ein starkes LLM kann einen schlechten Retrievalschritt nicht retten. Wenn du RAG aufbaust, investiere Zeit in das Testen von Chunking und der Retrieval-Genauigkeit, bevor du erwägst, zu einem größeren Modell zu wechseln.

ragllmvector-databasespring-aijava