Von Passagen zur fundierten Antwort in RAG

Zu diesem Artikel gibt es ein kurzes Video:YouTubeTikTok

1. Übersicht

Retrieval-Augmented Generation (RAG) bleibt oft beim Schritt „finde die k-nächsten Passagen” stehen und wirft diese direkt in den Prompt. Dieser Ansatz führt leicht zu Halluzinationen, denn die Vektorsuche gibt immer etwas Nächstes zurück, selbst wenn keine Passage die Frage wirklich beantwortet. Dieser Artikel beleuchtet den nächsten Schritt: Kandidaten in vertrauenswürdige Belege umzuwandeln, durch Hybrid Search, Reranking und einen Mechanismus zum Verweigern von Antworten, wenn die Daten unzureichend sind. Am Ende wirst du eine kleine Spring-Boot-Pipeline bauen, die alle diese Schritte umsetzt, mit Tests zur Verhaltensüberprüfung.

2. Das Problem: Nächster bedeutet nicht richtig

Stellen dir vor, du hast einen Chatbot, der Mitarbeiter unterstützt, Richtlinien für Rückerstattungen bei stornierten Flügen zu recherchieren. Die Vektorsuche wird immer 30 Passages zurückgeben, die der Frage am „nächsten” sind, selbst wenn in der Collection keine Passage über Flugstornierungen spricht. Wenn das System die Top-3-Passages direkt an das LLM weitergibt, wird das Modell versuchen, eine Antwort zu „erfinden”, die plausibel klingt, basierend auf Passages über standardmäßige Hotelbuchungen.

Zwei Probleme müssen gelöst werden:

  1. Filterung: Welche Passages aus Dutzenden Kandidaten sind wirklich hilfreich für die Frage?
  2. Kontrolliertes Verweigern: Wenn keine Passage gut genug ist, muss das System „nicht gefunden” sagen, anstatt zu raten.

3. Gesamte Pipeline

Frage
  → Vektorsuche (semantic)              ─┐
  → Keyword-Suche (BM25/exact match)    ├─→ Zusammenführung (Hybrid) → Rerank → Konfidenz-Schwelle → Belege → LLM

Jede Stufe reduziert schrittweise die Menge und erhöht die Qualität: von Dutzenden roher Kandidaten auf eine Handvoll mit klarer Evidenz, zusammen mit Zitierquellen.

4. Grundlegende Datenstruktur

public record Passage(
        String id,
        String text,
        String source,
        double score
) {
    public Passage withScore(double newScore) {
        return new Passage(id, text, source, newScore);
    }
}

Passage stellt ein indiziertes Textsegment dar, mit source zum Zitieren und score zum Vergleichen/Sortieren. withScore hilft, eine Kopie mit einem neuen Punktestand nach jedem Reranking-Schritt zu erstellen, ohne das ursprüngliche Objekt zu verändern.

5. Hybrid Search: Semantik und Keywords kombinieren

Embeddings sind gut im Verbinden von Bedeutungen (z.B. „Flug storniert” ↔ „Reiseunterbrechung”), übersehen aber leicht genaue Token wie Policy-Codes POL-2024-07 oder Eigennamen. Keyword Search (BM25) gleicht diese Schwäche aus.

public interface VectorSearchClient {
    List<Passage> search(String query, int topK);
}

public interface KeywordSearchClient {
    List<Passage> search(String query, int topK);
}

@Service
public class HybridSearchService {

    private final VectorSearchClient vectorClient;
    private final KeywordSearchClient keywordClient;

    public HybridSearchService(VectorSearchClient vectorClient,
                                KeywordSearchClient keywordClient) {
        this.vectorClient = vectorClient;
        this.keywordClient = keywordClient;
    }

    public List<Passage> search(String query) {
        List<Passage> semantic = vectorClient.search(query, 20);
        List<Passage> keyword = keywordClient.search(query, 20);

        Map<String, Passage> merged = new LinkedHashMap<>();
        for (Passage p : semantic) {
            merged.put(p.id(), p);
        }
        for (Passage p : keyword) {
            merged.merge(p.id(), p, (existing, incoming) ->
                    existing.withScore(existing.score() + incoming.score()));
        }
        return new ArrayList<>(merged.values());
    }
}

merge addiert Punkte, wenn eine Passage in beiden Quellen erscheint — das ist ein starkes Signal, dass dieses Segment sowohl semantisch relevant als auch durch exakte Keywords übereinstimmt. Das Ergebnis ist eine zusammengeführte Liste von Kandidaten, noch nicht nach endgültiger Nützlichkeit sortiert.

6. Reranking: Mit einem Pairwise-Vergleichsmodell erneut filtern

Vector Search vergleicht die Frage mit jedem Segment unabhängig (Bi-Encoder), kann also die tiefe Beziehung zwischen ihnen nicht „lesen”. Ein Reranker (normalerweise ein Cross-Encoder) erhält Frage und Segment zusammen, gibt präzisere Punktzahlen, ist aber langsamer — daher wird es nur auf eine kleine Anzahl von Kandidaten angewendet, die bereits durch das grobe Filter gegangen sind.

public interface Reranker {
    List<Passage> rerank(String query, List<Passage> candidates, int topN);
}

@Component
public class CrossEncoderReranker implements Reranker {

    private final CrossEncoderClient client; // Aufruf des Reranking-Modells extern

    public CrossEncoderReranker(CrossEncoderClient client) {
        this.client = client;
    }

    @Override
    public List<Passage> rerank(String query, List<Passage> candidates, int topN) {
        return candidates.stream()
                .map(p -> p.withScore(client.relevanceScore(query, p.text())))
                .sorted(Comparator.comparingDouble(Passage::score).reversed())
                .limit(topN)
                .toList();
    }
}

client.relevanceScore gibt die tatsächliche Relevanzpunktzahl (z.B. 0–1) zwischen Frage und jedem Segment zurück. Nach dem Sortieren in absteigender Reihenfolge behalten wir nur die besten topN Segmente — normalerweise 3 bis 5, kurz genug, um in den Prompt aufgenommen zu werden, ohne den Kontext zu verwässern.

Hinweis: Reranke nicht tausende Passages — Cross-Encoder sind Dutzende bis hundertfach langsamer als Vector Search. Begrenzen Sie immer die eingehenden Kandidaten (20–50), bevor Sie reranken.

7. Belege mit Zitaten für das LLM erstellen

Nach dem Reranking hast du ein paar hochwertige Segmente. Der nächste Schritt besteht darin, sie in einen klaren, quellengestützten Kontext zu verpacken, damit das LLM bei der Antwort zitiert, anstatt einfach zu spekulieren.

@Component
public class EvidenceBuilder {

    public String build(List<Passage> topPassages) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < topPassages.size(); i++) {
            Passage p = topPassages.get(i);
            sb.append("[Quelle %d - %s]%n%s%n%n"
                    .formatted(i + 1, p.source(), p.text()));
        }
        return sb.toString();
    }
}

Jedes Segment wird nummeriert und mit seinem Quellnamen (source) versehen. Wenn du das in den System Prompt integrierst, forderst du das LLM auf, nur auf Basis dieser Segmente zu antworten und die entsprechenden Quellennummern zu zitieren — dies ermöglicht Benutzern, die Evidenz zu überprüfen, anstatt die Antwort blind zu vertrauen.

[Quelle 1 - policy-2024-07.pdf]
Mitarbeiter erhalten 100% Rückerstattung der Hotelausgaben, wenn ein Flug aufgrund von Wetterbedingungen storniert wird...

[Quelle 2 - policy-2024-07.pdf]
Anwendungsbedingungen: Der Flug muss storniert sein, gilt nicht für Verspätungen unter 6 Stunden...

8. Umgang mit dem Fall, dass keine Antwort vorhanden ist

Dies ist der am leichtesten zu übersehende Teil. Jede Collection hat eine „nächste Nachbarschaft” — auch wenn keine Passage die Frage wirklich beantwortet. Ohne Kontrolle wird das LLM den irrelevanten Kontext nutzen, um eine plausibel klingende, aber falsche Antwort abzuleiten.

Die einfache Lösung: Setze einen Vertrauensschwellenwert (Confidence Threshold) basierend auf dem höchsten Reranking-Score.

@Service
public class AnswerService {

    private static final double CONFIDENCE_THRESHOLD = 0.55;

    private final HybridSearchService searchService;
    private final Reranker reranker;
    private final EvidenceBuilder evidenceBuilder;
    private final LlmClient llmClient;

    public AnswerService(HybridSearchService searchService,
                          Reranker reranker,
                          EvidenceBuilder evidenceBuilder,
                          LlmClient llmClient) {
        this.searchService = searchService;
        this.reranker = reranker;
        this.evidenceBuilder = evidenceBuilder;
        this.llmClient = llmClient;
    }

    public String answer(String question) {
        List<Passage> candidates = searchService.search(question);
        List<Passage> topPassages = reranker.rerank(question, candidates, 4);

        boolean hasEvidence = !topPassages.isEmpty()
                && topPassages.get(0).score() >= CONFIDENCE_THRESHOLD;

        if (!hasEvidence) {
            return "Ich konnte keine Richtlinie finden, die diese Frage eindeutig beantwortet. "
                    + "Sie sollten sich an die Personalabteilung wenden, um dies zu bestätigen.";
        }

        String context = evidenceBuilder.build(topPassages);
        return llmClient.generate(question, context);
    }
}

Wenn das beste Segment nach dem Reranking unter CONFIDENCE_THRESHOLD liegt, gibt das System eine Ablehnungsantwort zurück, anstatt das LLM aufzurufen, um eine unbegründete Antwort zu generieren. Der spezifische Schwellenwert sollte mit echten Daten abgestimmt werden — zu niedrig führt zu Halluzinationen, zu hoch lehnt sogar beantwortbare Fragen ab.

9. Verhaltensprüfung

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
class AnswerServiceTest {

    @Test
    void shouldAbstainWhenBestScoreBelowThreshold() {
        HybridSearchService search = mock(HybridSearchService.class);
        Reranker reranker = mock(Reranker.class);
        EvidenceBuilder builder = new EvidenceBuilder();
        LlmClient llm = mock(LlmClient.class);

        Passage weakMatch = new Passage("p1", "Standardmäßige Hotelreservierungsrichtlinie",
                "hotel-faq.pdf", 0.3);

        when(search.search(anyString())).thenReturn(List.of(weakMatch));
        when(reranker.rerank(anyString(), anyList(), anyInt()))
                .thenReturn(List.of(weakMatch));

        AnswerService service = new AnswerService(search, reranker, builder, llm);
        String result = service.answer("Wie erfolgt die Rückerstattung bei Flugstornierung?");

        assertThat(result).contains("keine Richtlinie gefunden");
        verifyNoInteractions(llm);
    }

    @Test
    void shouldAnswerWhenConfidenceIsHigh() {
        HybridSearchService search = mock(HybridSearchService.class);
        Reranker reranker = mock(Reranker.class);
        EvidenceBuilder builder = new EvidenceBuilder();
        LlmClient llm = mock(LlmClient.class);

        Passage strongMatch = new Passage("p2", "100% Rückerstattung, wenn der Flug storniert wird",
                "policy-2024-07.pdf", 0.82);

        when(search.search(anyString())).thenReturn(List.of(strongMatch));
        when(reranker.rerank(anyString(), anyList(), anyInt()))
                .thenReturn(List.of(strongMatch));
        when(llm.generate(anyString(), anyString())).thenReturn("Sie erhalten 100% Rückerstattung [Quelle 1].");

        AnswerService service = new AnswerService(search, reranker, builder, llm);
        String result = service.answer("Wie erfolgt die Rückerstattung bei Flugstornierung?");

        assertThat(result).contains("Quelle 1");
        verify(llm).generate(anyString(), anyString());
    }
}

Der erste Test überprüft das Ablehnungsverhalten bei niedrigen Scores — llm wird überhaupt nicht aufgerufen, spart Kosten und vermeidet Halluzinationen. Der zweite Test bestätigt, dass das System bei starker Evidenz das LLM aufruft und die Antwort Quellenverweise enthält.

10. Häufige Fehler

  • Hybrid Search ignorieren und nur pure Vektoren verwenden: Leicht verfehlt man exakte Übereinstimmungen bei Codes und Eigennamen — Dinge, die Embeddings nicht gut unterscheiden.
  • Den gesamten Datensatz reranken anstatt einer gefilterten Teilmenge: Verursacht unnötige Langsamkeit und Kosten.
  • Keinen Vertrauensschwellenwert setzen: Immer antworten, auch wenn der Kontext nicht relevant ist, führt zu Halluzinationen, die Benutzer schwer erkennen, weil die Antwort „sehr zuversichtlich” klingt.
  • Zu lange Kontexte: Stopfe alle 30 ursprünglichen Passages in den Prompt, anstatt nur 3–5 regeordnete, verdünnst das Signal und erhöhst die Token-Kosten.

Hinweis: Wenn deine Domain stabiles Vokabular und wenige spezialisierte Keywords hat (z.B. gewöhnliche Chat-Konversationen), könnte Hybrid Search redundant sein — pure Vektorsuche reicht aus und ist einfacher zu betreiben.

11. Fazit

RAG ist nur vertrauenswürdig, wenn der Retrieval-Schritt korrekt stoppt: Semantik- und Keyword-Ergebnisse zusammenführen, reranken um wirklich nützliche Segmente auszuwählen, und ablehnen, wenn nicht genug Evidenz vorhanden ist. Die kleine Pipeline in diesem Artikel — HybridSearchService, Reranker, EvidenceBuilder, AnswerService — ist das Skelett, in das du echte Modelle einbauen kannst (Embeddings, BM25-Index, Cross-Encoder), um einen ehrlichen Assistenten mit Zitaten zu bauen, anstatt einer bloßen Buchstaben-Ratemaschine.

ragllmspring-bootsearchjava