RAG von A bis Z: Dokumente abrufen, dann antworten

1. Übersicht

Ein LLM kennt nur, was in seinen Trainingsdaten enthalten ist. Es kennt weder die Rückgabebedingungen deines Shops noch interne Prozesse in deinem Unternehmen. Wenn es über diese Themen befragt wird, erfindet es oft Antworten. RAG (Retrieval-Augmented Generation) behebt dieses Problem: Das System ruft zunächst relevante Dokumente ab, dann beantwortet das LLM die Frage basierend auf diesen Dokumenten.

In diesem Artikel wirst du eine komplette RAG-Pipeline mit Spring Boot 3 und Spring AI von Grund auf aufbauen. Die Pipeline umfasst die Schritte Dokumente laden, Chunks aufteilen, Embeddings erstellen, Suchen, Prompts mit Quellenangaben zusammenstellen und Tests schreiben. Am Ende des Artikels findest du häufige Fehler und Fälle, in denen RAG nicht geeignet ist.

2. Wie RAG funktioniert

RAG hat zwei separate Phasen.

Die Ingestion-Phase läuft einmal oder regelmäßig:

  1. Lese Dokumente (Markdown, PDF, HTML…).
  2. Teile sie in Chunks von etwa hundert bis mehreren hundert Tokens auf.
  3. Rufe das Embedding-Modell auf, um jeden Chunk in einen numerischen Vektor umzuwandeln.
  4. Speichere den Vektor und den Chunk-Inhalt im Vector Store.

Die Query-Phase läuft jedes Mal, wenn ein Benutzer eine Frage stellt:

  1. Wandle die Frage in einen Vektor um (Embedding).
  2. Finde die Chunks mit den ähnlichsten Vektoren (Ähnlichkeitssuche).
  3. Füge diese Chunks in den Prompt ein. Das ist der Augment-Schritt.
  4. Sende den Prompt an das LLM, um eine Antwort zu generieren. Das ist der Generate-Schritt.

Die Kernidee ist sehr einfach: Das LLM muss deine Dokumente nicht „merken”, es braucht nur den richtigen Abschnitt im Prompt zu lesen.

3. Projekt vorbereiten

Füge Spring AI BOM und den OpenAI Starter hinzu. Dieser Starter stellt sowohl das Chat-Modell als auch das Embedding-Modell bereit.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Konfiguriere dann die Modelle:

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.1
spring.ai.openai.embedding.options.model=text-embedding-3-small

Du solltest temperature niedrig setzen. Bei RAG geht es darum, sich eng an die Dokumente zu halten, nicht darum, kreativ zu sein.

Abschließend deklarierst du zwei Beans: einen Vector Store und einen ChatClient.

@Configuration
public class RagConfig {

    @Bean
    VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }

    @Bean
    ChatClient chatClient(ChatClient.Builder builder) {
        return builder.build();
    }
}

SimpleVectorStore speichert alle Vektoren im Speicher. Es eignet sich für Lernen und Demos. EmbeddingModel wird durch den OpenAI Starter automatisch von Spring AI konfiguriert. Jedes Mal, wenn du ein Dokument hinzufügst, ruft der Store dieses Modell auf, um Vektoren zu erstellen.

Hinweis: SimpleVectorStore verliert alle Daten bei einem Neustart der Anwendung und führt lineare Suche durch. Für Production solltest du PGvector, Qdrant, Elasticsearch … verwenden. Spring AI hat Starter für jeden Typ, und da das VectorStore-Interface gleich bleibt, musst du den restlichen Code nicht ändern.

4. Dokumente laden

Angenommen, du hast eine Datei src/main/resources/docs/return-policy.md:

# Rückgaberichtlinie

Kunden können Produkte innerhalb von 30 Tagen ab Liefertag zurückgeben.
Das Produkt muss noch alle Etiketten, Labels und die Rechnung haben.
Die Rückerstattung wird innerhalb von 5–7 Werktagen auf das Konto überwiesen.
Unterwäsche und Artikel mit über 50% Rabatt können nicht zurückgegeben werden.

4.1. Lesen und Chunks aufteilen

@Component
public class DocumentIngestor implements ApplicationRunner {

    private final VectorStore vectorStore;
    private final ResourcePatternResolver resolver;

    public DocumentIngestor(VectorStore vectorStore, ResourcePatternResolver resolver) {
        this.vectorStore = vectorStore;
        this.resolver = resolver;
    }

    @Override
    public void run(ApplicationArguments args) throws IOException {
        // chunkSize=300 Token, minChunkSizeChars=100, minChunkLengthToEmbed=5,
        // maxNumChunks=10000, keepSeparator=true
        var splitter = new TokenTextSplitter(300, 100, 5, 10_000, true);

        for (Resource file : resolver.getResources("classpath:docs/*.md")) {
            var reader = new TextReader(file);
            reader.getCustomMetadata().put("source", file.getFilename());

            List<Document> chunks = splitter.apply(reader.get());
            vectorStore.add(chunks);
            System.out.printf("%s geladen: %d Chunks%n", file.getFilename(), chunks.size());
        }
    }
}

Der obige Code macht folgendes:

  • ApplicationRunner wird einmal ausgeführt, nachdem der Kontext gestartet ist. Daher werden Dokumente immer vor der ersten Anfrage geladen.
  • TextReader liest eine Datei als Document. Wir fügen die Metadaten source als Dateiname hinzu, um sie später bei der Quellenangabe zu verwenden.
  • TokenTextSplitter teilt Text in Chunks von etwa 300 Token auf. Metadaten werden auf jeden Chunk kopiert.
  • vectorStore.add(...) ruft das Embedding-Modell für jeden Chunk auf und speichert dann den Vektor.

Beim Start siehst du:

return-policy.md geladen: 1 Chunks
shipping.md geladen: 3 Chunks

4.2. Chunk-Größe wählen

Die Chunk-Größe beeinflusst direkt die Qualität der Antworten:

  • Chunk zu groß: Ein Chunk mit vielen Themen führt zu „verwässerten” Vektoren, was genaue Übereinstimmungen schwierig macht. Der Prompt verbraucht auch mehr Token.
  • Chunk zu klein: Sätze werden ohne ihren Kontext zerlegt. Zum Beispiel könnte „innerhalb von 30 Tagen” in einem Chunk sein und „zurückgeben” in einem anderen.

200–500 Token sind ein guter Startpunkt für Fließtext. Bei strukturierten Dokumenten (FAQ, Inhaltsverzeichnis) solltest du nach Überschriften aufteilen, damit jeder Chunk eine in sich geschlossene Idee ist.

5. Relevante Dokumente abrufen

Der Abruf-Schritt braucht nur einen Funktionsaufruf:

List<Document> docs = vectorStore.similaritySearch(
        SearchRequest.builder()
                .query("Wie lange kann ich zurückgeben?")
                .topK(4)
                .similarityThreshold(0.5)
                .build());
  • query: die Frage. Der Vector Store embedded die Frage automatisch mit demselben Modell, das beim Laden verwendet wurde.
  • topK(4): hole die 4 nächsten Chunks.
  • similarityThreshold(0.5): filtere Chunks mit Cosine-Ähnlichkeit unter 0,5 aus. Ohne Schwellenwert gibt der Store immer topK Ergebnisse zurück, auch wenn sie nichts mit der Frage zu tun haben.

Es gibt keinen universellen optimalen Schwellenwert. Du solltest Scores für dutzende echte Fragen protokollieren und dann einen Schwellenwert wählen, der richtige Ergebnisse von falschen trennt.

6. Prompt zusammenstellen und Antwort generieren

6.1. Prompt-Builder

Das Extrahieren der Prompt-Zusammenstellung in eine reine Funktion macht das Testen einfacher:

public final class PromptBuilder {

    private static final String TEMPLATE = """
            Du bist ein Kundenservice-Assistent.
            Antworte nur basierend auf dem unten stehenden KONTEXT.
            Wenn der Kontext keine Informationen enthält, antworte mit:
            "Ich kann diese Informationen in den Dokumenten nicht finden."
            Gib am Ende deiner Antwort die Quelle in eckigen Klammern an, z.B. [return-policy.md].

            KONTEXT:
            {context}

            FRAGE: {question}
            """;

    private PromptBuilder() {}

    public static String build(String question, List<Document> docs) {
        String context = docs.stream()
                .map(d -> "[" + d.getMetadata().getOrDefault("source", "unknown") + "]\n" + d.getText())
                .collect(Collectors.joining("\n---\n"));
        return TEMPLATE.replace("{context}", context)
                       .replace("{question}", question);
    }
}

Es gibt drei wichtige Punkte in der Vorlage:

  1. „Antworte nur basierend auf dem KONTEXT” begrenzt das LLM auf die Dokumente und reduziert Halluzinationen.
  2. Eine feste Ablehnungsantwort gibt dem LLM eine gültige Ausstiegsroute, wenn die Dokumente keine Informationen enthalten.
  3. Jeder Chunk hat einen Quellennamen, sodass das LLM die Quelle zitieren kann und der Benutzer diese überprüfen kann.

Hier verwenden wir replace statt String.formatted. Grund: Der Dokumentinhalt kann %-Zeichen enthalten (z.B. „50% Rabatt”), und formatted würde dann eine UnknownFormatConversionException werfen.

6.2. RagService

@Service
public class RagService {

    static final String NOT_FOUND = "Ich kann diese Informationen in den Dokumenten nicht finden.";

    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    public RagService(VectorStore vectorStore, ChatClient chatClient) {
        this.vectorStore = vectorStore;
        this.chatClient = chatClient;
    }

    public String answer(String question) {
        List<Document> docs = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(question)
                        .topK(4)
                        .similarityThreshold(0.5)
                        .build());

        if (docs == null || docs.isEmpty()) {
            return NOT_FOUND;
        }

        return chatClient.prompt()
                .user(PromptBuilder.build(question, docs))
                .call()
                .content();
    }
}

Wenn keine Chunks gefunden werden, lehnt der Service sofort ab, ohne das LLM aufzurufen. Dies spart Kosten und schließt die Möglichkeit aus, dass das LLM eine Antwort aus allgemeinem Wissen erfindet.

Zum Abschluss der Controller:

@RestController
public class AskController {

    private final RagService ragService;

    public AskController(RagService ragService) {
        this.ragService = ragService;
    }

    @GetMapping("/ask")
    public String ask(@RequestParam String q) {
        return ragService.answer(q);
    }
}

Jetzt kannst du die API aufrufen:

curl -G "http://localhost:8080/ask" --data-urlencode "q=Wie lange kann ich zurückgeben?"
curl -G "http://localhost:8080/ask" --data-urlencode "q=Verkauft der Shop Laptops?"
Du kannst innerhalb von 30 Tagen ab Liefertag zurückgeben, vorausgesetzt das Produkt hat alle Etiketten, Labels und die Rechnung. [return-policy.md]
Ich kann diese Informationen in den Dokumenten nicht finden.

Die erste Antwort hält sich eng an die Dokumente und enthält eine Quellenangabe. Die zweite Antwort passt zu keinem Dokument, also lehnt das System ab, statt etwas zu erfinden.

7. Tests schreiben

Du solltest echte LLMs nicht in Unit Tests aufrufen, da sie langsam sind, Kosten verursachen und instabile Ergebnisse liefern. Teste stattdessen die beiden deterministischen Teile: den Prompt-Builder und die Ablehnungsverzweigung, wenn es keine Dokumente gibt.

class RagServiceTest {

    @Test
    void promptContainsContextSourceAndQuestion() {
        var doc = new Document("Rückgabe innerhalb von 30 Tagen möglich.", Map.of("source", "return-policy.md"));

        String prompt = PromptBuilder.build("Wie lange kann ich zurückgeben?", List.of(doc));

        assertThat(prompt)
                .contains("[return-policy.md]")
                .contains("Rückgabe innerhalb von 30 Tagen möglich.")
                .contains("FRAGE: Wie lange kann ich zurückgeben?")
                .doesNotContain("{context}", "{question}");
    }

    @Test
    void percentSignInDocumentDoesNotBreakPrompt() {
        var doc = new Document("Artikel mit über 50% Rabatt können nicht zurückgegeben werden.", Map.of("source", "return-policy.md"));

        assertThat(PromptBuilder.build("Sale-Artikel?", List.of(doc)))
                .contains("über 50%");
    }

    @Test
    void returnsNotFoundWithoutCallingLlmWhenNoDocuments() {
        VectorStore vectorStore = mock(VectorStore.class);
        ChatClient chatClient = mock(ChatClient.class);
        when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());

        String answer = new RagService(vectorStore, chatClient).answer("Verkauft ihr Laptops?");

        assertThat(answer).isEqualTo(RagService.NOT_FOUND);
        verifyNoInteractions(chatClient);
    }
}

Der dritte Test prüft zwei Dinge. Erstens, dass die Antwort die Ablehnungsmeldung ist. Zweitens, dass ChatClient überhaupt nicht aufgerufen wird. Wenn jemand später versehentlich die isEmpty()-Abfrage löscht, schlägt der Test sofort fehl.

Um die Qualität des Abrufs zu bewerten, kannst du einen separaten Integrationtest schreiben. Bereite eine Reihe von Fragen mit erwarteten Quelldateien vor und überprüfe, ob diese Dateien in den topK-Ergebnissen vorkommen. Diese Tests sollten periodisch ausgeführt werden, nicht bei jedem Build.

8. Mit QuestionAnswerAdvisor vereinfachen

Sobald du jeden Schritt verstanden hast, kannst du den mitgelieferten Advisor von Spring AI verwenden. Dieser Advisor kapselt den Abruf und die Prompt-Zusammenstellung ein. Du musst diese Abhängigkeit hinzufügen:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
String answer = chatClient.prompt()
        .advisors(QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder().topK(4).similarityThreshold(0.5).build())
                .build())
        .user("Wie lange kann ich zurückgeben?")
        .call()
        .content();

Der Advisor embedded die Frage automatisch, findet Chunks und fügt sie nach einer Standardvorlage ein. Das ist bequem, aber du hast weniger Kontrolle über das Quellenformat und die frühe Ablehnung. Daher ist die manuelle Methode aus Abschnitt 6 immer noch wertvoll, wenn du diese Anforderungen brauchst.

9. Häufige Fehler und wann RAG nicht geeignet ist

Embedding-Modell wechseln, ohne Daten neu zu laden. Vektoren aus verschiedenen Modellen sind nicht vergleichbar. Jedes Mal, wenn du das Modell wechselst, musst du alle Dokumente neu embedden.

Keinen similarityThreshold setzen. Der Vector Store gibt immer topK Ergebnisse zurück. Das LLM erhält dann irrelevanten Kontext und antwortet falsch, aber mit großer Zuversicht.

Dokumente bei jedem Start mit persistentem Store neu laden. Mit PGvector erstellt der obige ApplicationRunner Duplikate nach jedem Deploy. Du solltest feste IDs für Chunks verwenden (z.B. einen Hash aus Inhalt und Dateiname) oder Chunks nach source löschen, bevor du sie neu lädst.

Zu viele Chunks in den Prompt packen. topK auf 20 zu erhöhen hilft nicht. Das LLM übersieht leicht Informationen in langem Kontext, und die Kosten steigen mit der Token-Anzahl.

Dem Dokumentinhalt blind vertrauen. Wenn Dokumente von Benutzern stammen, können sie Befehle wie „ignoriere alle vorherigen Anweisungen” enthalten (Prompt Injection). Du musst kontrollieren, welche Datenquellen in den Store geladen werden dürfen.

Hinweis: RAG ist nicht für alle Probleme geeignet. Fragen, die eine Aggregation über alle Daten erfordern (z.B. „wie viele Bestellungen wurden im September storniert”), sollten als SQL-Abfragen geschrieben werden. Die Ähnlichkeitssuche gibt nur wenige Chunks zurück und kann nicht zählen oder aggregieren. Wenn alle deine Dokumente nur ein paar Seiten sind, ist es oft einfacher und genauer, sie direkt in den Prompt zu schreiben, statt eine ganze RAG-Pipeline zu bauen.

10. Fazit

RAG trennt Wissen vom Modell. Dokumente liegen im Vector Store, und das LLM liest bei jeder Frage nur den relevanten Teil. In diesem Artikel hast du beide Phasen aufgebaut: Ingestion (Lesen, Chunks aufteilen, Embeddings erstellen, Speichern) und Query (Schwellenwert-Suche, Prompt mit Quellenangaben zusammenstellen, LLM aufrufen). Du hast auch Tests für den Prompt und die Ablehnungsverzweigung geschrieben.

Von hier aus kannst du weitergehen: SimpleVectorStore durch PGvector ersetzen, Chunks nach Überschriften aufteilen, nach Metadaten filtern (z.B. nur in Dokumenten einer Abteilung suchen), oder einen Reranking-Schritt hinzufügen, um Ergebnisse vor dem Einfügen in den Prompt neu zu ordnen.

ragspring-aillmvector-storespring-boot