Praxis · RAG
RAG von Hand:
ein kleiner Einstieg ohne Framework.
EN Read in English
Dieses Beispiel beantwortet Fragen über eigene Markdown-Dateien. Es besteht aus einer Python-Datei, einer JSON-Datei und einem lokal laufenden Ollama-Modell.
Was ein RAG ist
Ein Sprachmodell kennt Ihre Dokumente nicht automatisch. Bei einer Frage kann es daher eine plausible, aber falsche Antwort formulieren. Ein RAG sucht vor der Antwort passende Stellen in einem eigenen Dokumentbestand und legt sie dem Modell zusammen mit der Frage vor.
| Schritt | Im Beispiel |
|---|---|
| 1. Speichern | Absätze aus Markdown-Dateien werden als Chunks in index.json gespeichert. |
| 2. Suchen | Frage und Chunks werden als Zahlenreihen verglichen. Die drei ähnlichsten Absätze gewinnen. |
| 3. Antworten | Diese Absätze gehen mit der Frage an das Chat-Modell. Es nennt die Quelldatei. |
Die Zahlenreihen heißen Embeddings. Sie beschreiben grob die Bedeutung eines Textes. Damit findet die Suche auch Absätze, die dieselbe Sache mit anderen Wörtern ausdrücken. Das Modell bekommt trotzdem nur die gefundenen Absätze, nicht den gesamten Dokumentbestand.
Ordner anlegen
Erstellen Sie einen leeren Ordner mit diesem Aufbau. Die Markdown-Dateien sind Ihr Wissen. Jeder Absatz wird in diesem kleinen Beispiel ein Chunk.
mein-rag/
├── rag.py
└── wissen/
├── versand.md
└── produkte.md
Zum Testen reicht eine Datei wie wissen/versand.md:
# Versand
Bestellungen nach Deutschland kommen in zwei bis vier Werktagen an.
Der Versand nach Österreich kostet 8 Euro.
Das vollständige Programm
Der vollständige Code liegt weiterhin als rag.py zum Herunterladen bereit. Hier ist er in derselben Reihenfolge aufgeteilt: erst der Code, dann seine Erklärung.
embeddinggemma für die Suche und gemma3 für Antworten. Beide Modelle laufen lokal.ollama pull embeddinggemma
ollama pull gemma3
python rag.py index
python rag.py ask "Was kostet der Versand nach Österreich?"
Der erste Befehl baut den Index. Dabei wird jeder Absatz einmal an das Embedding-Modell gesendet und mit seiner Zahlenreihe in index.json abgelegt. Nach Änderungen in wissen/ führen Sie python rag.py index erneut aus.
Code und Erklärung
1. Importe und Einstellungen
import json
import math
import sys
import urllib.request
from pathlib import Path
DOCS = Path("wissen")
INDEX = Path("index.json")
OLLAMA = "http://localhost:11434"
EMBED_MODEL = "embeddinggemma"
CHAT_MODEL = "gemma3"
Path behandelt Ordner und Dateinamen. json schreibt den Index in eine Datei, math wird für die Ähnlichkeit gebraucht. Die fünf Konstanten darunter legen Dokumentordner, Indexdatei, lokale Ollama-Adresse und die beiden Modellnamen fest. Wenn Sie andere Modelle geladen haben, ändern Sie nur EMBED_MODEL und CHAT_MODEL.
2. JSON an Ollama senden
def post(path, data):
request = urllib.request.Request(
OLLAMA + path,
data=json.dumps(data).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request) as response:
return json.load(response)
post() nimmt einen API-Pfad und Python-Daten entgegen. json.dumps(...).encode() macht daraus JSON-Bytes für die HTTP-Anfrage. urlopen() schickt sie an den lokalen Server. Die letzte Zeile wandelt dessen JSON-Antwort wieder in ein Python-Dictionary um. Diese kleine Funktion ersetzt hier ein SDK.
3. Text in Zahlen übersetzen und vergleichen
def embed(text):
return post("/api/embed", {"model": EMBED_MODEL, "input": text})["embeddings"][0]
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
length_a = math.sqrt(sum(x * x for x in a))
length_b = math.sqrt(sum(y * y for y in b))
return dot / (length_a * length_b) if length_a and length_b else 0
embed() sendet Text an /api/embed und nimmt die erste Zahlenreihe aus der Antwort. cosine() berechnet daraus einen Ähnlichkeitswert: dot multipliziert die Werte paarweise und addiert sie, length_a und length_b normalisieren den Wert. Die Abfrage am Ende schützt vor einer Division durch null.
4. Markdown-Dateien in Chunks zerlegen
def chunks():
for file in DOCS.glob("*.md"):
for paragraph in file.read_text(encoding="utf-8").split("\n\n"):
text = paragraph.strip()
if text:
yield {"source": file.name, "text": text}
DOCS.glob("*.md") geht alle Markdown-Dateien im Ordner wissen durch. split("\n\n") teilt bei zwei aufeinanderfolgenden Zeilenumbrüchen, also an einer Leerzeile. Ein einzelner Zeilenumbruch wäre \n. yield gibt einen Chunk nach dem anderen zurück, statt erst eine große Liste aufzubauen. Das ist absichtlich schlicht. Für strukturierte Handbücher sollten Überschriften später mit in den Chunk aufgenommen werden.
5. Den Index bauen
def build_index():
entries = []
for chunk in chunks():
chunk["embedding"] = embed(chunk["text"])
entries.append(chunk)
print("indexiert:", chunk["source"])
INDEX.write_text(json.dumps(entries, ensure_ascii=False), encoding="utf-8")
print(f"Fertig: {len(entries)} Chunks in {INDEX}")
build_index() durchläuft die Chunks, ergänzt jeweils den Schlüssel embedding und hängt das Ergebnis an entries. json.dumps(..., ensure_ascii=False) erhält Umlaute in der JSON-Datei. Bei wenigen Dateien ist das verständlich und ausreichend. Eine Vektordatenbank wird erst interessant, wenn die lineare Suche zu langsam wird oder Sie Filter und häufige Aktualisierungen brauchen.
6. Passende Chunks suchen
def search(question, limit=3):
question_embedding = embed(question)
entries = json.loads(INDEX.read_text(encoding="utf-8"))
return sorted(
entries,
key=lambda entry: cosine(question_embedding, entry["embedding"]),
reverse=True,
)[:limit]
Auch die Frage wird eingebettet. sorted(..., key=..., reverse=True) sortiert alle Einträge nach ihrem Ähnlichkeitswert, der lambda-Ausdruck berechnet ihn für jeden Eintrag. [:limit] behält die ersten drei Treffer. Der Wert selbst ist kein Qualitätsurteil; wichtig ist, ob die passenden Quellen in den ersten Treffern stehen.
7. Kontext und Frage an das Modell senden
def answer(question):
found = search(question)
context = "\n\n".join(f"[{entry['source']}]\n{entry['text']}" for entry in found)
prompt = f"""Beantworte die Frage nur mit dem folgenden Kontext.
Wenn die Antwort nicht im Kontext steht, schreibe das deutlich.
Nenne die Quelldatei in Klammern.
KONTEXT:
{context}
FRAGE: {question}"""
result = post("/api/chat", {
"model": CHAT_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
})
print(result["message"]["content"])
Die gefundenen Chunks werden mit ihrer Quelldatei in context zusammengesetzt. Der f-String fügt diesen Kontext und die Frage in den Prompt ein. stream=False lässt Ollama eine vollständige JSON-Antwort zurückgeben, die letzte Zeile gibt deren Text aus. Die Prompt-Anweisung reduziert Halluzinationen, verhindert sie aber nicht. Prüfen Sie bei wichtigen Antworten die angegebene Quelle.
8. Befehle ausführen
if __name__ == "__main__":
if sys.argv[1:] == ["index"]:
build_index()
elif len(sys.argv) > 2 and sys.argv[1] == "ask":
answer(" ".join(sys.argv[2:]))
else:
print('Nutze: python rag.py index | python rag.py ask "Frage"')
Dieser Block läuft nur, wenn Sie rag.py direkt starten. sys.argv enthält die Wörter hinter python rag.py. Bei index wird der Index erstellt. Bei ask werden alle folgenden Wörter wieder zu einer Frage zusammengesetzt.
Was ein Framework später übernimmt
| Dieses Beispiel | Bei mehr Anforderungen |
|---|---|
| Alle Dateien neu indexieren | Nur geänderte Dateien aktualisieren |
| Absätze an Leerzeilen teilen | Überschriften, Seiten und überlappende Chunks berücksichtigen |
| Alle Embeddings nacheinander vergleichen | Schneller Vektorindex, Filter und parallele Anfragen |
| Eine Suche | Stichwortsuche, Reranking und Tests mit festen Fragen |
Der kleine Aufbau ist ein guter Start, weil jeder Schritt sichtbar bleibt. Legen Sie zuerst zehn bis zwanzig echte Fragen mit erwarteter Quelle an. Erst wenn die Treffer damit nicht reichen, lohnt sich der nächste Baustein.
RAG auf eigenen Dokumenten?
Ich baue Wissensbasen aus Handbüchern, Tickets und Verträgen, self-hosted und DSGVO-konform.
Robin.Steltmann@googlemail.com