RAG ohne Magie: Auditierbare Wissensintegration für lokale LLMs

RAG ohne Magie: Auditierbare Wissensintegration für lokale LLMs
By Matthias Petermann / on 21.11.2025

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)?

ℹ️ Definition: Retrieval-Augmented Generation (RAG)
RAG ist ein Architekturansatz, bei dem ein Sprachmodell während der Anfrage zusätzliche externe Informationen erhält. Das Modell generiert Antworten nicht rein aus Parametern, sondern auf Basis von dynamisch abgerufenen Kontextdaten.

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:

  1. Die Wiki-Dateien einlesen, in Chunks zerlegen und in Embeddings umwandeln
  2. Eine geeignete Datenbank, um diese Embeddings transparent und lokal zu speichern
  3. Die Nutzerfrage einbetten und die relevantesten Wiki-Chunks auswählen
  4. Einen Kontext-Prompt bauen und an den llama-server senden

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
ℹ️ Was sind Embeddings?
Embeddings sind numerische Vektoren, die die Bedeutung eines Textes im semantischen Raum repräsentieren. Ähnliche Texte liegen in diesem Raum nahe beieinander – gemessen wird dies z. B. mit der Cosine Similarity.
ℹ️ Wissenschaftlicher Hintergrund: Cosine Similarity

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.

ℹ️ Warum SQLite?

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.

🤔 Wann ist ein Umstieg auf echte Vektordatenbanken sinnvoll

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.
ℹ️ Was bedeutet Top-k-Chunks?

„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.

ℹ️ Was ist im Kontextprompt enthalten?

Ein guter RAG-Prompt enthält:

  1. Systeminstruktion – Wie das Modell antworten soll (faktisch, kurz, kontextbasiert).

  2. Kontextblock – Die Top-k-Chunks aus der Wissensbasis.

  3. Benutzerfrage – Die eigentliche Query.

  4. 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:

  1. build_index.py — Wiki einlesen, Chunks erzeugen, Embeddings berechnen, SQLite befüllen
  2. ask_rag.py — Nutzerfrage einbetten, Top-k-Chunks abrufen, Kontextprompt bauen und an llama-server senden

Schritt 1 – Markdown einlesen

ℹ️ Was passiert hier?
Wir lesen Markdown-Dateien ein, entfernen optionales Frontmatter und erzeugen sauberen Ausgangstext. Noch keine Embeddings — reiner Text.
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

ℹ️ Warum dieser Schritt?
LLMs können besser mit kleinen, semantisch stabilen Abschnitten arbeiten. Wir zerlegen den Text in Blöcke von ~200–350 Token.
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()
💡 Optional: 50–150 Token Overlap verwenden
Viele RAG-Systeme nutzen beim Chunking einen kleinen Overlap (z. B. 50–150 Tokens), damit zusammenhängende Inhalte nicht versehentlich auf zwei Chunks verteilt werden. Für klar gegliederte Markdown-Wikis ist Overlap meist nicht zwingend, kann aber bei technischen Dokumenten die Antwortqualität erhöhen.

Schritt 3 – Embeddings erzeugen

ℹ️ Was passiert hier?
Jeder Chunk wird mittels 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

ℹ️ Was passiert hier?
Wir speichern jeden Chunk + Embedding als JSON in SQLite. Es entsteht eine vollständig transparente Wissensbasis.

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)

ℹ️ Was passiert hier?
Die Nutzerfrage wird selbst eingebettet und per Cosine Similarity gegen alle Chunk-Vektoren in SQLite verglichen. Ergebnis: die Top-k relevantesten Wissensbausteine.

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

ℹ️ Was passiert hier?
Die Top-k-Chunks werden zu einem Kontextblock kombiniert. Daraus entsteht ein kontrollierter, faktenbasierter System-Prompt, der anschließend an den llama-server gesendet wird. Antworten basieren ausschließlich auf dem eigenen Wiki.

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

ℹ️ Was passiert hier?
Wir ergänzen ein minimales CLI, das eine Nutzerfrage als Argument annimmt und direkt eine Antwort vom lokalen LLM liefert. Damit können Sie Ihr lokales RAG-System wie ein echtes Werkzeug verwenden.
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

ℹ️ Hinweis

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.
...
🤔 Bewertung der Beispiel-Antwort

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.

⚠️ Realitätsabgleich

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
✅ Ergebnis

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.