Praxis · RAG

RAG von Hand:
was ein Framework einem abnimmt.

19. August 2026 · Lesezeit ca. 9 Minuten

Ich habe ein Retrieval-Augmented-Generation-System in einer einzigen Python-Datei nachgebaut, ohne LangChain, ohne Vektordatenbank, nur Standardbibliothek und ein lokaler Ollama. Drei Dinge gingen dabei schief, die in keinem Tutorial stehen.

Eine Frage, die kein Modell beantworten kann

Der Korpus ist das Handbuch einer erfundenen Kaffeerösterei: sechs Markdown-Dateien zu Sortiment, Versand, Röstbetrieb, Qualitätssicherung und Ladenöffnungszeiten. Die Firma gibt es nicht, also kann kein Modell die Antworten aus dem Training kennen. Jede richtige Antwort muss aus dem Retrieval stammen.

Das Kommando vergleich stellt dieselbe Frage zweimal, einmal blank ans Modell und einmal mit den gefundenen Auszügen im Prompt.

python rag.py vergleich "Was kostet die Sorte Halligsonne?"

Ohne Kontext erklärt qwen3:8b ausführlich und in Fettschrift, Halligsonne sei eine Kümmelart von Sylt, und liefert eine Preistabelle von 1,50 € bis 5,00 € pro 100 g, mitsamt Bezugsquellen. Alles erfunden, im Tonfall einer Auskunft.

Mit Kontext: „Die Sorte Halligsonne kostet 22,00 Euro pro 500 Gramm.“ Dazu die Quelldatei, aus der die Zahl stammt.

Das ist das Argument für RAG in einem Kommando, und man kann es vorführen, statt es zu behaupten. Ein LLM weiß nichts über Ihre Daten und antwortet trotzdem. RAG dreht die Reihenfolge um: erst die passenden Stellen im eigenen Bestand suchen, dann diese Stellen zusammen mit der Frage an das Modell geben. Das Modell formuliert dann nur noch.

Vier Schritte, mehr ist es nicht

SchrittWas passiertZeilen im Beispiel
1. ChunkingDokumente in Stücke schneiden, die für sich verständlich sind~45
2. EmbeddingJedes Stück in einen Vektor übersetzen, bei bge-m3 mit 1024 Dimensionen~10
3. RetrievalFrage embedden, per Kosinus-Ähnlichkeit die nächsten Stücke finden~25
4. GenerierungStücke als Kontext in den Prompt, Antwort mit Quellenangabe~20

Die ganze Mathematik hinter einer Vektordatenbank steckt in Schritt 3, im Winkel zwischen zwei Vektoren:

def kosinus(a, b):
    punkt = sum(x * y for x, y in zip(a, b))
    norm_a = math.sqrt(sum(x * x for x in a))
    norm_b = math.sqrt(sum(y * y for y in b))
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return punkt / (norm_a * norm_b)

Gemessen wird der Winkel und nicht der Abstand, sonst hinge das Ergebnis an der Textlänge statt an der Bedeutung. Eine Vektordatenbank kann diese Rechnung für Millionen Vektoren schnell, wo hier eine Schleife über ein paar Dutzend läuft. Bei 15 Chunks reicht eine JSON-Datei.

Falle 1: Chunking verliert Inhalte, ohne sich zu beschweren

Meine erste Fassung trennte an Leerzeilen und behandelte jeden Block, der mit # beginnt, als Überschrift. In Markdown steht eine Überschrift aber oft ohne Leerzeile direkt über ihrem Absatz. Beides landet dann im selben Block, und der Absatz wurde zusammen mit der Überschrift verworfen.

Ergebnis: 02-sorten.md fehlte vollständig im Index. Aus sechs Dateien wurden 10 Chunks aus fünf Dateien statt 15 aus 6. Keine Exception, keine Warnung, nur schlechtere Antworten. Aufgefallen ist es, weil ich die Chunks pro Quelldatei gezählt habe.

Konsequenz für den Betrieb: Nach jedem Indexlauf zählen, wie viele Chunks aus welcher Datei stammen, und die Zahl mitloggen. Eine Datei mit null Chunks ist ein Fehler, kein Randfall. Bei sechs Dateien sieht man das noch, bei sechshundert nicht mehr.

Aus demselben Schritt kommt die zweite Lehre: Der Überschriftenpfad muss in den Chunk-Text hinein. Ein Chunk wird allein embeddet, ohne sein Dokument. Steht „Halligsonne“ nur in der Überschrift und nicht im Absatz darunter, ist das Wort für die Suche verloren, und genau die Frage nach dem Preis geht ins Leere.

Falle 2: Das Embedding-Modell muss die Sprache können

nomic-embed-text ist der Standardvorschlag in fast jedem Ollama-Tutorial und englischsprachig trainiert. Deutscher Korpus, deutsche Fragen, acht Frage/Datei-Paare über 15 Chunks:

ModellTop-1Top-3mittlerer RangGröße
bge-m36/87/81,5~1,2 GB
nomic-embed-text5/85/82,9~274 MB

Bei „Wie lange dauert die Ruhezeit vor dem Verpacken?“ landete der richtige Absatz mit nomic auf Rang 7 von 15. In den Kontext kommen die ersten vier, das Modell hätte also geantwortet, es wisse es nicht. Alle Scores dieses Laufs lagen zwischen 0,56 und 0,67: Das Modell sah kaum einen Unterschied zwischen der passenden und der unpassendsten Stelle im ganzen Korpus.

Daraus wurde für mich eine Faustregel. Wenn Bester und Schlechtester nah beieinanderliegen, versteht das Embedding-Modell den Korpus nicht. Der Abstand zwischen Rang 1 und dem letzten Rang ist ein Diagnosewert und gehört in die Ausgabe der Suche.

Zur Belastbarkeit dieser Zahlen: acht Fragen, 15 Chunks, ein Korpus, ein Durchlauf. Ein Unterschied von einem Treffer ist bei dieser Größe Rauschen. Belastbar ist der Abstand im mittleren Rang (1,5 gegen 2,9) und der Ausreißer auf Rang 7. Für Ihre Modellwahl gilt dieses Ergebnis nicht, das Verfahren schon.

Falle 3: Task-Präfixe, und was sie nicht erklären

Manche Embedding-Modelle wollen wissen, ob sie gerade eine Frage oder ein Dokument vor sich haben, und erwarten das als Präfix im Text. nomic-embed-text will search_document: vor Dokumenten und search_query: vor Fragen, mxbai-embed-large nur eine Instruktion vor der Frage, bge-m3 gar nichts. Wer das übersieht, betreibt das Modell außerhalb seines Trainings.

Ich hatte die fehlenden Präfixe zuerst für die Ursache der schlechten Treffer gehalten. Sie waren es nicht: Nachgerüstet blieb nomic bei mittlerem Rang 2,9. Die Trainingssprache war der Grund. Die Präfixe gehören trotzdem gesetzt, sie erklären hier nur nichts.

Ein Testset kostet fast nichts

Acht Paare aus Frage und erwarteter Quelldatei, zwanzig Zeilen Auswertung, Ausgabe von Top-1, Top-3 und mittlerem Rang. Das ist der billigste Teil des Projekts und der einzige, der die Modellwahl von einer Geschmacksfrage in eine Zahl verwandelt.

TESTSET = [
    (u"Wie lange dauert die Ruhezeit vor dem Verpacken?", "04-roesterei-betrieb.md"),
    (u"Was kostet der Versand nach Österreich?", "03-versand.md"),
    (u"Welche Sorte gibt es nur im Winter?", "02-sorten.md"),
    # ... fünf weitere Paare
]

Ohne dieses Testset hätte ich drei Fragen gestellt, die Antworten gut gefunden und wäre bei nomic geblieben. Der Unterschied zwischen mittlerem Rang 1,5 und 2,9 ist durch Draufschauen nicht zu erahnen.

An dieser Stelle stehen die meisten RAG-Prototypen, die ich sehe: Sie funktionieren in der Demo, niemand hat eine Zahl für die Retrieval-Qualität, und niemand merkt, wenn eine Änderung an der Chunking-Logik die Treffer verschlechtert. Das Testset ist die Regressionsprüfung, die ein RAG-System braucht, bevor es in den Betrieb geht.

Wann selber bauen, wann Framework

WegSpricht dafür
Selber bauenKorpus bis ein paar tausend Chunks, Wunsch zu verstehen, woran schlechte Antworten liegen, Kunden ohne Appetit auf zusätzliche Abhängigkeiten im Stack. Der Kern sind rund 150 Zeilen.
FrameworkNachführen des Index bei geänderten Dateien, Hybrid-Suche aus BM25 und Vektoren, Reranking, mehrere Dateiformate, Nebenläufigkeit. Diese Teile selbst zu bauen ist Arbeit ohne Erkenntnisgewinn.

Im Beispiel fehlen bewusst: eine persistente Vektordatenbank, Hybrid-Suche, Reranking, Query-Umschreibung, Chunk-Overlap, inkrementelles Indexieren und Streaming der Antwort. Die index.json wird komplett in den Speicher geladen, bei ein paar tausend Chunks ist Schluss.

Offen ist bei mir vor allem die Hybrid-Suche. Zahlen, Chargennummern und Angaben wie „25813 Husum“ sind für Embeddings schwach und für Volltextsuche stark. Ob BM25 daneben die Trefferquote hebt, misst dasselbe Testset, das oben die Modellwahl entschieden hat.

Der fertige Index für das Handbuch der Rösterei: 15 Chunks aus 6 Dateien, je 1024 Dimensionen, 209 KB JSON. Das gesamte Programm sind 375 Zeilen, gut die Hälfte davon Kommentar.

RAG auf eigenen Dokumenten?

Ich baue Wissensbasen aus Handbüchern, Tickets und Verträgen, self-hosted und DSGVO-konform, mit Testset und Messwerten statt Demoeffekt. 30 Minuten Erstgespräch, kostenlos.