Praxis · RAG
RAG von Hand:
was ein Framework einem abnimmt.
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
| Schritt | Was passiert | Zeilen im Beispiel |
|---|---|---|
| 1. Chunking | Dokumente in Stücke schneiden, die für sich verständlich sind | ~45 |
| 2. Embedding | Jedes Stück in einen Vektor übersetzen, bei bge-m3 mit 1024 Dimensionen | ~10 |
| 3. Retrieval | Frage embedden, per Kosinus-Ähnlichkeit die nächsten Stücke finden | ~25 |
| 4. Generierung | Stü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.
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:
| Modell | Top-1 | Top-3 | mittlerer Rang | Größe |
|---|---|---|---|---|
| bge-m3 | 6/8 | 7/8 | 1,5 | ~1,2 GB |
| nomic-embed-text | 5/8 | 5/8 | 2,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.
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
| Weg | Spricht dafür |
|---|---|
| Selber bauen | Korpus 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. |
| Framework | Nachfü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.