Einführung
In den bisherigen Beiträgen habe ich gezeigt, wie ein lokal betriebenes LLM in der Praxis unterstützen kann – zunächst am Beispiel eines Aufgaben-Roboters (Lokale LLM in der Praxis – Redmine-Ticketanalyse). Anschließend habe ich mich in Local LLM mit Tiefgang tiefer in die Materie eingearbeitet, um die gesamte Lieferkette besser zu verstehen und meine eigenen LLM-Pipelines von unnötigem oder unkontrollierbarem Ballast zu befreien.
Heute folgt der nächste Praxisteil. Diesmal geht es um einen besonders nützlichen Aspekt moderner LLM-Architekturen: Retrieval Augmented Generation (RAG). Dabei erhält ein Sprachmodell nicht nur Zugriff auf sein trainiertes Wissen, sondern auf genau die Informationen, die für den jeweiligen Anwendungsfall wirklich relevant sind. RAG verbindet also die Stärken eines LLM mit gezielter Informationssuche in den eigenen Datenbeständen.
Das Ergebnis sind Antworten, die kontextreich, aktuell, präzise und vollständig innerhalb des eigenen Sicherheits-Perimeters generiert werden – ein zentraler Baustein für robuste, souveräne und nachvollziehbare KI-Systeme.
Im Folgenden zeige ich, wie RAG praktisch umgesetzt wird, welche Komponenten dazugehören und wie sich dieser Ansatz nahtlos in lokale LLM-Workflows integrieren lässt.
Grundlagen: Was ist Retrieval-Augmented Generation (RAG)?
Kurz zusammengefasst:
- Retrieval = Relevante Informationen aus einer Wissensbasis heraussuchen
- Augmented = Diese Informationen werden dem Modell zusätzlich zum Prompt gegeben
- Generation = Das LLM erzeugt daraufhin seine Antwort
RAG ist besonders geeignet für:
- technische Wikis
- interne Dokumentation
- Policies & Playbooks
- Wissensdatenbanken
- Produktkataloge
Aufgabenstellung
Wir betreiben ein lokales LLM (z. B. llama.cpp mit einem Qwen-Modell)
und haben unsere internen Wiki-Dokumente als Markdown-Dateien in einem Verzeichnis abgelegt.
Diese Inhalte sollen so aufbereitet werden, dass das Modell sie gezielt durchsuchen und als Wissensbasis nutzen kann.
Lösungsansatz
Um unser Wiki für das LLM erschließbar zu machen, benötigen wir vier aufeinander aufbauende Schritte:
- Die Wiki-Dateien einlesen, in Chunks zerlegen und in Embeddings umwandeln
- Eine geeignete Datenbank, um diese Embeddings transparent und lokal zu speichern
- Die Nutzerfrage einbetten und die relevantesten Wiki-Chunks auswählen
- Einen Kontext-Prompt bauen und an den
llama-serversenden
Im Folgenden gehen wir diese Schritte der Reihe nach und im Detail durch.
1. Eine Pipeline zum Vektorisieren der Wiki-Dokumente (Embeddings)
Die Pipeline übernimmt:
- Markdown-Dateien einlesen
- den Inhalt in sinnvolle Chunks zerlegen
- für jeden Chunk ein Embedding erzeugen
- alles gemeinsam in eine Datenbank speichern
Die Cosine Similarity misst den Winkel zwischen zwei Vektoren. Nicht der Betrag zählt, sondern die Richtung. Das ist ideal für Sprachmodelle, weil zwei Texte mit ähnlicher Bedeutung zwar unterschiedlich lang sein können, aber dennoch in dieselbe „Richtung" im semantischen Raum zeigen.
Formel: cos(θ) = (A · B) / (||A|| * ||B||)
- 1.0 → identisch
- 0.0 → orthogonal (keine Ähnlichkeit)
- −1.0 → gegenteilig
2. Eine Datenbank für die Embeddings
Sobald alle Wiki-Chunks in Embeddings umgewandelt wurden, müssen sie in einer Datenbank abgelegt werden, damit später effizient darauf zugegriffen werden kann. Für unser Projekt setzen wir bewusst auf SQLite, weil unsere erwartete Datenmenge überschaubar bleibt:
- realistisch 2.000–20.000 Chunks,
- vollständig im RAM durchsuchenbar,
- keine hohen Latenzanforderungen,
- keine Skalierungs- oder Clusteranforderungen.
Eine klassische Vektor-Datenbank wäre für diese Größenordnung Overengineering, da ihre Stärken erst bei mehreren hunderttausend oder Millionen Embeddings zum Tragen kommen.
SQLite ist für unseren Anwendungsfall ideal:
- einfach, portabel, transparent
- ein einziges File, perfekt debugbar (SELECT * FROM docs)
- keine Infrastruktur notwendig
- leicht versionierbar
- für 2k–20k Embeddings absolut performant
Wir speichern die Embeddings einfach als JSON in einer SQLite-Tabelle und führen die Cosine Similarity im Retrieval-Schritt in Python durch.
Zum Vergleich: Für sehr große Wissensbasen (> 100k Embeddings) nutzt man häufig echte Vektor-Datenbanken wie:
- Chroma
- Faiss (Meta)
- Milvus
- Weaviate
- Qdrant
- Pinecone (SaaS)
- Elastic Vector Search
- pgvector für PostgreSQL
Diese Systeme bieten hochoptimierte ANN-Indizes (z. B. HNSW, IVF, PQ) und verteilen Daten über Cluster – all das brauchen wir im Moment noch nicht.
3. Die Nutzerfrage einbetten und passende Wiki-Chunks abrufen
Sobald ein Benutzer eine Frage stellt, wird:
- die Frage selbst vektorisiert (Embedding),
- gegen alle Wiki-Embeddings verglichen,
- die Top-k-passendsten Chunks ausgewählt.
„Top-k" bedeutet: Die k relevantesten Textabschnitte aus der Wissensbasis - sortiert nach höchster Cosine Similarity.
Beispiel:
- k = 5 → die fünf am besten passenden Chunks
- Diese Chunks bilden den Kontext, den das LLM versteht.
4. Kontext-Prompt bauen und an den llama-server senden
Nachdem die relevanten Chunks gefunden wurden, werden sie in einen Kontextprompt eingebettet:
- Der Prompt enthält die Frage des Nutzers
- plus die Top-k-Chunks aus dem Wiki
- plus eine Instruktion, dass das Modell nur auf Basis dieses Kontextes antworten darf.
Dieser Prompt wird anschließend an den lokalen llama-server geschickt.
Ein guter RAG-Prompt enthält:
-
Systeminstruktion – Wie das Modell antworten soll (faktisch, kurz, kontextbasiert).
-
Kontextblock – Die Top-k-Chunks aus der Wissensbasis.
-
Benutzerfrage – Die eigentliche Query.
-
Hinweis auf Grenzen
– Wenn etwas nicht im Kontext steht: „Dazu habe ich keine Information."
Architekturüberblick (High-Level)
┌──────────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐
│ Markdown-Wiki│ → │ Chunking │ → │Embedding │ → │ SQLite │
└──────────────┘ └──────────┘ └──────────┘ │ (Vectors) │
└──────┬───────┘
▼
┌──────────┐ ┌───────────────────────┐ ┌──────────────┐
│ Query │ → │ Query-Embedding │ → │ Top-k │
└──────────┘ │ Cosine Similarity │ └──────┬───────┘
└───────────────────────┘ ▼
┌──────────────┐
│ Prompt │
└──────┬───────┘
▼
┌──────────────┐
│ llama-server │
└──────────────┘
Die Pipeline im Detail – Schritt für Schritt (PoC)
Im folgenden Proof-of-Concept orchestrieren wir die gesamte Wissensintegration mit nur zwei kleinen Python-Skripten. Die Dateien sind bewusst minimal gehalten und sollen vor allem den Ablauf demonstrieren, nicht eine produktive Architektur darstellen:
build_index.py— Wiki einlesen, Chunks erzeugen, Embeddings berechnen, SQLite befüllenask_rag.py— Nutzerfrage einbetten, Top-k-Chunks abrufen, Kontextprompt bauen und anllama-serversenden
Schritt 1 – Markdown einlesen
from pathlib import Path
def strip_frontmatter(text: str) -> str:
if text.startswith("---"):
parts = text.split("---", 2)
if len(parts) == 3:
return parts[2].lstrip()
return text
text = Path("wiki/intro.md").read_text(encoding="utf-8")
cleaned = strip_frontmatter(text)
Schritt 2 – Chunking
MAX_CHARS = 1200
def chunk_text(text: str):
paragraphs = text.split("\n\n") # richtiger Absatztrenner
current = ""
for para in paragraphs:
if len(current) + len(para) + 2 <= MAX_CHARS:
# Absatz anhängen
current += ("\n\n" if current else "") + para
else:
# aktuelles Chunk ausgeben
if current:
yield current.strip()
# zu großer Absatz -> hart splitten
while len(para) > MAX_CHARS:
yield para[:MAX_CHARS].strip()
para = para[MAX_CHARS:]
current = para
# Rest ausgeben
if current:
yield current.strip()
Schritt 3 – Embeddings erzeugen
llama-embedding in einen Float-Vektor übersetzt.
import subprocess
import re
def run_embedding(text: str):
CMD = "/home/mpeterma/Projekte/ggml-org/llama.cpp/build/bin/llama-embedding"
MODEL = "/home/mpeterma/Library/models/Qwen/Qwen2.5-3B-Instruct-GGUF/qwen2.5-3b-instruct-q5_k_m.gguf"
proc = subprocess.run(
[CMD, "-m", MODEL, "-p", text],
capture_output=True, text=True
)
if proc.returncode != 0:
raise RuntimeError(proc.stderr)
for line in proc.stdout.splitlines():
line = line.strip()
if line.startswith("embedding 0:"):
# nur die Zahlen extrahieren
numbers = re.findall(r"-?\d+\.\d+", line)
return [float(n) for n in numbers]
raise RuntimeError("No embedding vector found in output")
Schritt 4 – SQLite als Wissensdatenbank
Schema
CREATE TABLE docs (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
embedding TEXT NOT NULL
);
CREATE INDEX idx_docs_path ON docs(path);
Indexer
import sqlite3, json
from pathlib import Path
def index_wiki():
conn = sqlite3.connect("knowledge.db")
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS docs (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
chunk_index INTEGER NOT NULL,
content TEXT NOT NULL,
embedding TEXT NOT NULL
)
""")
for path in Path("./wiki").rglob("*.md"):
text = strip_frontmatter(path.read_text(encoding="utf-8"))
for idx, chunk in enumerate(chunk_text(text)):
emb = run_embedding(chunk)
cur.execute(
"INSERT INTO docs (path, chunk_index, content, embedding) VALUES (?, ?, ?, ?)",
(str(path), idx, chunk, json.dumps(emb))
)
conn.commit()
conn.close()
if __name__ == "__main__":
index_wiki()
Schritt 5 – Retrieval (Embedding-Vergleich)
Cosine Similarity
import math
def cosine(a, b):
dot = sum(x*y for x, y in zip(a, b))
na = math.sqrt(sum(x*x for x in a))
nb = math.sqrt(sum(y*y for y in b))
return dot / (na * nb) if na > 0 and nb > 0 else 0
Retrieval-Funktion
import json, sqlite3
def retrieve(query: str, top_k=5):
# 1) Anfrage als Embedding
q_emb = run_embedding(query)
# 2) Alle Chunks aus DB ziehen
conn = sqlite3.connect("knowledge.db")
rows = conn.execute(
"SELECT path, chunk_index, content, embedding FROM docs"
).fetchall()
conn.close()
# 3) Similarity berechnen
scored = []
for path, idx, content, emb_json in rows:
emb = json.loads(emb_json)
score = cosine(q_emb, emb)
scored.append((score, content))
# 4) Top-k zurückgeben
scored.sort(reverse=True)
return [content for score, content in scored[:top_k]]
Schritt 6 – Promptbau + Anfrage an den llama-server
Prompt erstellen
def build_prompt(query: str, chunks: list[str]) -> str:
context = "\n\n".join(chunks)
return f"""
Sie sind ein Assistent, der ausschließlich auf Basis des folgenden Wikis antwortet.
Wenn etwas nicht im Kontext steht, sag: "Dazu habe ich keine Information."
Kontext:
{context}
Frage: {query}
Antwort:
""".strip()
Anfrage an den llama-server
import requests
def ask_llama(query: str):
chunks = retrieve(query, top_k=4)
prompt = build_prompt(query, chunks)
resp = requests.post(
"http://localhost:8080/v1/completions",
json={
"model": "qwen2.5-3b",
"prompt": prompt,
"max_tokens": 600
}
)
resp.raise_for_status()
return resp.json()["choices"][0]["text"]
Schritt 7 - Main-Funktion mit CLI-Argumenten
import sys
def main():
if len(sys.argv) < 2:
print("Usage: python3 rag.py \"Ihre Frage\"")
sys.exit(1)
query = " ".join(sys.argv[1:])
answer = ask_llama(query)
print(answer)
if __name__ == "__main__":
main()
Ende-zu-Ende-Ablauf im Einsatz
Für die Anfrage muss der lokale llama-server bereits laufen.
Er stellt das Modell bereit, das den finalen Antworttext erzeugt.
Startbeispiel:
llama-server -m ./models/Qwen/Qwen2.5-3B-Instruct-GGUF/qwen2.5-3b-instruct-q5_k_m.gguf --host 0.0.0.0 --port 8080
1. Index aufbauen (Markdown → Chunks → Embeddings → SQLite)
python3 build_index.py
2. Beispielanfrage stellen (Query → Retrieval → Kontext → Antwort)
python3 ask_rag.py "Wie lege ich in Cyrus IMAP eine neue Mailbox an?"
3. Antwort empfangen
Mit cyradm können Sie für Cyrus IMAP neue Mailboxen anlegen.
...
Die Beispielabfrage „Wie lege ich in Cyrus IMAP eine neue Mailbox an?“ liefert ein Ergebnis – weil ich früher intensiv mit Cyrus IMAP gearbeitet habe und meine interne Dokumentation dazu präzise und gut strukturierte Beschreibungen enthält.
Das generierte Resultat ist inhaltlich korrekt, wenn auch – gemessen an der sehr einfachen aktuellen Pipeline – noch klar ausbaufähig. Trotz der Minimalarchitektur zeigt der Prototyp jedoch: Der Ansatz funktioniert, und ein erster funktionsfähiger End-to-End-Durchstich ist geschafft.
Das zufriedenstellende Ergebnis ist auch darauf zurückzuführen, dass ich die enthaltenen Chunks bereits kenne und die Abfrage im Sinne eines ersten Tests bewusst „provoziert“ habe.
Andere, weniger gezielte Queries liefern derzeit deutlich schwächere Ergebnisse.Für eine robuste Nutzbarkeit braucht es nicht nur Feintuning, sondern ein tieferes technisches Nachschärfen:
- Verifizierung aller verwendeten Algorithmen – idealerweise mit Unit Tests
- Verbesserung der Datenbasis, da viele Chunks aktuell ungünstig oder unsauber geschnitten sind
- Embedding-Qualität, Retrieval-Strategie und Prompting
Kurz: Der Prototyp zeigt Potenzial, aber er ist noch weit entfernt von produktiver Qualität.
Fazit
Wir haben einen durchgängigen, transparenten Wissens-Pipeline-Stack gebaut:
- Markdown-Wiki eingelesen
- Chunks erzeugt
- Embeddings generiert
- SQLite als transparente Wissensbasis verwendet
- Query → Embedding → Cosine Similarity → Top-k
- RAG-Prompting
- Antwort über
llama-server
Damit entsteht eine schlanke, schnelle und vollständig lokale Wissensintegration — unabhängig von Cloud-Diensten, frei von Blackbox-Komponenten und (vorerst) ganz ohne Vektor-DB.
Ein Stack, der überschaubar bleibt, auditierbar ist und sich jederzeit erweitern oder austauschen lässt.