8 Architektur und Deployment¶
In den bisherigen Kapiteln haben wir die wesentlichen Bausteine einer RAG-Anwendung kennengelernt. Wir können Dokumente einlesen, in Chunks zerlegen, Embeddings erzeugen und relevante Textabschnitte mit Chroma oder BM25 finden. Anschließend übergeben wir die gefundenen Informationen an ein Sprachmodell und erzeugen daraus eine Antwort.
In Kapitel 6 haben wir untersucht, wie sich die Qualität dieser Antworten bewerten lässt. Kapitel 7 beschäftigte sich mit den Sicherheitsanforderungen, die entstehen, sobald Benutzer, Berechtigungen und vertrauliche Dokumente ins Spiel kommen.
Damit besitzen wir die wichtigsten funktionalen Grundlagen.
Unsere bisherigen Python-Experimente sind allerdings noch keine vollständige Anwendung.
Wir starten ein Skript, führen eine Frage aus und betrachten das Ergebnis im Terminal. Dokumente werden in einem vorbereitenden Schritt indexiert. Die einzelnen Komponenten laufen weitgehend innerhalb desselben Python-Prozesses.
Für das Lernen ist diese Struktur ideal. Wir können jeden Verarbeitungsschritt nachvollziehen und verändern.
Eine produktive Anwendung muss jedoch zusätzliche Aufgaben übernehmen. Sie muss Anfragen mehrerer Benutzer entgegennehmen, Dokumente regelmäßig aktualisieren, Hintergrundaufgaben ausführen und mit Fehlern externer Dienste umgehen. Außerdem benötigt sie eine nachvollziehbare Konfiguration und eine Infrastruktur, auf der ihre Komponenten zuverlässig zusammenarbeiten.
In diesem Kapitel betrachten wir, wie aus unserer bisherigen RAG-Pipeline eine solche Anwendung werden kann.
Dabei verwenden wir weiterhin den Teralune-Corpus und unsere bekannten Technologien: Python, Chroma und die OpenAI-API. Als zusätzliche Bausteine lernen wir unter anderem FastAPI, Docker und eine einfache Architektur für asynchrone Dokumentverarbeitung kennen.
Wir werden keine vollständige Produktionsanwendung implementieren. Stattdessen untersuchen wir die wichtigsten Architekturentscheidungen anhand ausgewählter Codebeispiele.
Das Ziel ist, die Zusammenhänge zu verstehen und anschließend selbst entscheiden zu können, welche Komponenten eine konkrete RAG-Anwendung benötigt.
8.1 Vom RAG-Prototyp zur produktiven Anwendung¶
Unsere bisherige RAG-Pipeline folgt einem einfachen Ablauf:
- Eine Frage wird als Text übergeben.
- Wir erzeugen ein Embedding der Frage.
- Chroma liefert passende Dokumentabschnitte.
- Wir erstellen daraus einen Kontext.
- Ein Sprachmodell formuliert die Antwort.
Ein vereinfachter Python-Ausschnitt könnte folgendermaßen aussehen:
question = "How long does Orbit 3.0 retry failed webhooks?"
query_embedding = embed(question)
results = collection.query(
query_embeddings=[query_embedding],
n_results=5,
)
context = build_context(results)
answer = generate_answer(question, context)
print(answer)
Dieser Ablauf enthält bereits die fachliche Kernfunktion unserer Anwendung.
Er beantwortet jedoch noch nicht, wie ein Benutzer die Anwendung erreicht, wo die Dokumente gespeichert werden oder was geschieht, wenn während einer Anfrage die Vektordatenbank nicht erreichbar ist.
Ein Prototyp kennt nur wenige Zustände¶
In unseren Experimenten kontrollieren wir den gesamten Ablauf selbst.
Wir wissen, wann ein Skript gestartet wird. Wir kennen die verwendeten Dokumente und entscheiden, wann eine Collection neu aufgebaut werden soll.
Eine produktive Anwendung besitzt dagegen mehrere voneinander unabhängige Abläufe.
Während ein Benutzer eine Frage stellt, könnte ein anderer Benutzer eine zweite Anfrage ausführen. Gleichzeitig könnte ein Hintergrundprozess neue Dokumente indexieren.
Eine Anwendung muss diese Vorgänge koordinieren, ohne dass sie sich gegenseitig beeinträchtigen.
Außerdem können einzelne Komponenten ausfallen.
Die Modell-API könnte vorübergehend nicht erreichbar sein. Ein Dokument könnte sich nicht parsen lassen. Die Vektordatenbank könnte einen Fehler melden oder eine Anfrage könnte länger dauern als erwartet.
Solche Situationen dürfen nicht dazu führen, dass der gesamte Dienst unkontrolliert ausfällt.
Funktionale und nichtfunktionale Anforderungen¶
Bei der Architektur einer Anwendung unterscheiden wir häufig zwischen funktionalen und nichtfunktionalen Anforderungen.
Funktionale Anforderungen beschreiben, was die Anwendung leisten soll.
Für unsere Teralune-Anwendung wären das beispielsweise das Beantworten von Fragen, das Auffinden relevanter Dokumente und die Ausgabe von Quellenangaben.
Nichtfunktionale Anforderungen beschreiben dagegen Eigenschaften des Systems.
Dazu gehören Verfügbarkeit, Antwortzeit, Sicherheit, Skalierbarkeit und Wartbarkeit.
| Anforderung | Beispiel für Teralune |
|---|---|
| Funktionalität | Fragen zur Orbit-API anhand dokumentierter Quellen beantworten |
| Aktualität | Neue Dokumentversionen im Suchindex berücksichtigen |
| Verfügbarkeit | Auch bei einzelnen fehlgeschlagenen Anfragen weiterarbeiten |
| Performance | Antworten innerhalb einer akzeptablen Zeit bereitstellen |
| Sicherheit | Nur autorisierte Dokumente für eine Anfrage verwenden |
| Wartbarkeit | Komponenten unabhängig aktualisieren und Fehler nachvollziehen |
| Skalierbarkeit | Mehrere gleichzeitige Benutzeranfragen verarbeiten |
Nicht jede Anwendung benötigt dieselben Eigenschaften.
Ein interner Assistent für zehn Mitarbeiter stellt andere Anforderungen als ein öffentlich zugänglicher Dokumentationsdienst mit Tausenden Benutzern.
Deshalb gibt es auch keine universell richtige RAG-Architektur.
Die Trennung von Datenverarbeitung und Anfrageverarbeitung¶
Eine der wichtigsten Architekturentscheidungen besteht darin, zwei Abläufe voneinander zu trennen.
Die Ingestion-Pipeline verarbeitet Dokumente und baut den Suchindex auf.
Die Query-Pipeline verarbeitet Benutzerfragen und erzeugt Antworten.
Diese beiden Abläufe besitzen unterschiedliche Eigenschaften.
Die Ingestion kann relativ lange dauern. Das Parsen umfangreicher PDF-Dateien, das Chunking und die Erzeugung vieler Embeddings müssen nicht innerhalb weniger Sekunden abgeschlossen sein.
Eine Benutzeranfrage soll dagegen möglichst schnell beantwortet werden.
Deshalb ist es sinnvoll, die aufwendige Dokumentverarbeitung nicht bei jeder Frage erneut auszuführen.
Zwei unterschiedliche Pipelines
Die Ingestion-Pipeline bereitet Wissen für spätere Anfragen vor. Die Query-Pipeline verwendet dieses vorbereitete Wissen, um aktuelle Fragen zu beantworten.
Beide Pipelines greifen auf gemeinsame Datenbestände zu, sollten aber unabhängig voneinander ausgeführt werden können.
Diese Trennung bildet die Grundlage unserer Architektur.
8.2 Architektur einer RAG-Anwendung¶
Betrachten wir eine mögliche Architektur für unseren Teralune-Assistenten.
Ein Benutzer stellt über eine Weboberfläche eine Frage. Die Anfrage erreicht eine Python-Anwendung, die eine API bereitstellt.
Diese Anwendung prüft die erforderlichen Berechtigungen, erzeugt ein Query-Embedding und durchsucht Chroma. Anschließend stellt sie den Kontext zusammen und ruft das Sprachmodell auf.
Parallel dazu verarbeitet ein Hintergrunddienst neue oder geänderte Dokumente.
Die Referenzarchitektur¶
flowchart TB
U["Benutzer / Weboberfläche"]
subgraph APP["RAG-Anwendung"]
API["API / FastAPI"]
AUTH["Authentifizierung und Autorisierung"]
RAG["RAG-Service"]
JOB["Job-Verwaltung"]
end
subgraph DATA["Datenhaltung"]
DOC[("Dokumentenspeicher")]
DB[("Relationale Datenbank")]
VDB[("Chroma / Vektordatenbank")]
Q[("Job-Queue")]
end
subgraph WORK["Hintergrundverarbeitung"]
W["Ingestion-Worker"]
PARSE["Parsing und Chunking"]
end
subgraph AI["Modellzugriff"]
EMB["Embedding-Modell"]
LLM["Sprachmodell"]
end
U --> API
API --> AUTH
AUTH --> RAG
API --> JOB
RAG --> VDB
RAG --> EMB
RAG --> LLM
JOB --> DB
JOB --> Q
Q --> W
W --> DOC
W --> PARSE
PARSE --> EMB
PARSE --> VDB
W --> DB
%% Benutzer / Oberfläche – Blau
style U fill:#dbeafe,stroke:#2563eb,color:#1e40af
%% RAG-Anwendung – Grün
style APP fill:#f0fdf4,stroke:#16a34a
style API fill:#dcfce7,stroke:#16a34a
style AUTH fill:#dcfce7,stroke:#16a34a
style RAG fill:#dcfce7,stroke:#16a34a
style JOB fill:#dcfce7,stroke:#16a34a
%% Datenhaltung – Orange
style DATA fill:#fffbeb,stroke:#d97706
style DOC fill:#fef3c7,stroke:#d97706
style DB fill:#fef3c7,stroke:#d97706
style VDB fill:#fef3c7,stroke:#d97706
style Q fill:#fef3c7,stroke:#d97706
%% Hintergrundverarbeitung – Violett
style WORK fill:#faf5ff,stroke:#9333ea
style W fill:#f3e8ff,stroke:#9333ea
style PARSE fill:#f3e8ff,stroke:#9333ea
%% Modellzugriff – Rot
style AI fill:#fef2f2,stroke:#dc2626
style EMB fill:#fee2e2,stroke:#dc2626
style LLM fill:#fee2e2,stroke:#dc2626
Die Farben unterscheiden die fünf Architekturbereiche:
- 🔵 Blau: Benutzer und Weboberfläche
- 🟢 Grün: Anwendungslogik und API
- 🟠 Orange: Datenhaltung und Job-Queue
- 🟣 Violett: Asynchrone Hintergrundverarbeitung
- 🔴 Rot: KI-Modelle und externe Modellzugriffe
Dieses Diagramm ist eine mögliche Referenzarchitektur, keine zwingende Vorgabe.
Kleine Anwendungen können mehrere dieser Komponenten in einem einzigen Prozess zusammenfassen. Größere Systeme können einzelne Dienste getrennt betreiben und unabhängig skalieren.
Wichtig ist zunächst, welche Aufgaben die Komponenten übernehmen.
Die API als Eingangspunkt¶
Die API nimmt Anfragen entgegen und gibt Ergebnisse zurück.
Sie bildet die Schnittstelle zwischen der Benutzeroberfläche und der eigentlichen RAG-Logik.
Eine API sollte nicht selbst sämtliche Details von Embedding, Retrieval und Generierung implementieren. Stattdessen delegiert sie diese Aufgaben an einen RAG-Service.
Dadurch lässt sich dieselbe fachliche Logik später auch aus anderen Komponenten verwenden, beispielsweise aus einem Hintergrundjob oder einer internen Anwendung.
Der RAG-Service¶
Der RAG-Service enthält den fachlichen Ablauf unserer Query-Pipeline.
Er koordiniert die Erzeugung des Query-Embeddings, das Retrieval, die Kontextaufbereitung und den Modellaufruf.
Die einzelnen Funktionen kennen wir bereits aus unseren bisherigen Experimenten.
Neu ist lediglich, dass wir sie nicht mehr direkt aus einem Skript aufrufen, sondern über eine definierte Anwendungsschnittstelle.
Eine mögliche interne Struktur wäre:
app/
├── api/
│ └── routes.py
├── services/
│ ├── rag.py
│ ├── retrieval.py
│ └── generation.py
├── ingestion/
│ ├── parser.py
│ ├── chunking.py
│ └── indexing.py
├── repositories/
│ ├── documents.py
│ └── vectors.py
└── config.py
Diese Aufteilung ist bewusst einfach.
Sie trennt die HTTP-Schnittstelle, die fachliche Verarbeitung und den Zugriff auf externe Datenbestände.
Wir müssen dafür nicht sofort Microservices entwickeln.
Die Ingestion-Pipeline¶
Die Ingestion verarbeitet Dokumente unabhängig von den Benutzeranfragen.
Sie lädt ein Dokument, extrahiert dessen Inhalt, erzeugt Chunks und berechnet Embeddings.
Anschließend werden die Chunks mit ihren Metadaten in Chroma gespeichert.
Für unsere Teralune-Anwendung könnten wir weiterhin den bekannten Corpus verwenden.
Dabei ist wichtig, zwischen dem vollständigen Corpus und dem tatsächlich indexierten Laborbestand zu unterscheiden. Unsere bisherigen Experimente verwendeten eine Chroma-Collection mit einem ausgewählten Teil der Teralune-Dokumente.
Eine produktive Ingestion müsste für alle unterstützten Dateiformate eine konsistente Verarbeitung sicherstellen.
Die Datenhaltung¶
Die Architektur enthält mehrere unterschiedliche Speicherkomponenten.
Der Dokumentenspeicher enthält die ursprünglichen Dateien.
Die Vektordatenbank speichert Embeddings, Chunks und die für das Retrieval benötigten Metadaten.
Eine relationale Datenbank kann zusätzliche Informationen verwalten, beispielsweise Dokumentstatus, Importaufträge, Benutzerzuordnungen oder den Verlauf von Hintergrundjobs.
Diese Aufgaben müssen nicht zwangsläufig in getrennten Datenbanken umgesetzt werden. Ihre fachliche Trennung hilft jedoch, Verantwortlichkeiten klar zu definieren.
Warum nicht alles in einem Service?¶
Eine einzige große Anwendung kann zunächst einfacher erscheinen.
Wenn jedoch Dokumentimport, Vektorsuche, Modellaufrufe und Hintergrundverarbeitung vollständig miteinander vermischt werden, entstehen schnell unerwünschte Abhängigkeiten.
Ein Fehler beim Parsen eines Dokuments sollte beispielsweise nicht verhindern, dass Benutzer weiterhin Fragen zu bereits indexierten Dokumenten stellen können.
Ebenso sollte ein langsamer Import nicht sämtliche verfügbaren Ressourcen für die Verarbeitung von Benutzeranfragen belegen.
Die Trennung der Komponenten verbessert daher nicht nur die Wartbarkeit, sondern auch die Kontrolle über Ressourcen und Fehler.
Mit einem modularen Monolithen beginnen
Für viele RAG-Anwendungen ist ein modular aufgebauter Python-Service ein sinnvoller Ausgangspunkt. Die Komponenten besitzen klar definierte Aufgaben, laufen aber zunächst innerhalb derselben Anwendung.
Erst wenn konkrete Anforderungen entstehen, müssen einzelne Teile als unabhängige Dienste betrieben werden.
8.3 RAG als API bereitstellen¶
Unsere bisherigen Experimente starten wir direkt über Python.
Eine Webanwendung oder ein anderer Client benötigt dagegen eine Schnittstelle, über die Fragen an den RAG-Service übergeben werden können.
Eine häufig verwendete Möglichkeit ist eine HTTP-API.
Für Python bietet sich beispielsweise FastAPI an.
FastAPI unterstützt typisierte Request- und Response-Modelle, automatische API-Dokumentation und asynchrone Endpunkte.
Ein einfacher Frage-Endpunkt¶
Stellen wir uns vor, unsere Anwendung bietet einen Endpunkt /ask an.
Der Client übermittelt eine Frage. Die API ruft den RAG-Service auf und gibt die Antwort zurück.
Ein vereinfachtes Beispiel könnte so aussehen:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class AskRequest(BaseModel):
question: str
class AskResponse(BaseModel):
answer: str
sources: list[str]
@app.post("/ask", response_model=AskResponse)
def ask(request: AskRequest) -> AskResponse:
result = rag_service.answer(request.question)
return AskResponse(
answer=result.answer,
sources=result.sources,
)
Der Ausschnitt zeigt die grundlegende Struktur.
AskRequest definiert die erwartete Eingabe. AskResponse beschreibt die Ausgabe.
FastAPI übernimmt die Validierung der übermittelten Daten anhand dieser Modelle.
Der eigentliche RAG-Ablauf befindet sich dagegen im rag_service.
Dieser Service ist hier eine bereits vorhandene Anwendungskomponente. Der Ausschnitt ist deshalb kein eigenständig ausführbares Beispiel.
Ein Response-Modell mit Quellenangaben¶
Unsere bisherige RAG-Pipeline kann nicht nur eine Antwort erzeugen, sondern auch die verwendeten Dokumente identifizieren.
Eine API sollte diese Informationen strukturiert zurückgeben.
Eine mögliche Antwort wäre:
{
"answer": "Orbit 3.0 retries failed webhooks for 72 hours.",
"sources": [
"D003-E01",
"D005-E01"
]
}
Die Evidence-IDs kennen wir aus dem Teralune-Corpus.
Sie ermöglichen es, die Antwort auf bestimmte Belegstellen zurückzuführen.
In einer umfangreicheren Anwendung könnten wir zusätzlich Dokumenttitel, Versionsinformationen oder interne Quellenverweise zurückgeben.
Dabei müssen wir die in Kapitel 7 besprochenen Berechtigungsregeln beachten. Auch Quellenangaben dürfen keine Informationen über Dokumente offenlegen, auf die der Benutzer nicht zugreifen darf.
HTTP-Statuscodes und fachliche Ergebnisse¶
Nicht jede Anfrage führt zu einer normalen Antwort.
Die Anwendung könnte beispielsweise feststellen, dass die Frage nicht ausreichend belegt ist oder dass eine erforderliche Berechtigung fehlt.
Wir sollten deshalb zwischen dem technischen Ergebnis einer HTTP-Anfrage und dem fachlichen Ergebnis der RAG-Verarbeitung unterscheiden.
Ein HTTP-Statuscode beschreibt zunächst, ob die API-Anfrage technisch erfolgreich verarbeitet werden konnte.
Ein fachlicher Status beschreibt dagegen, ob die Anwendung eine Antwort gefunden hat.
Eine mögliche Struktur wäre:
Eine solche Antwort kann mit HTTP 200 zurückgegeben werden, wenn die Anfrage technisch korrekt verarbeitet wurde und das Ergebnis lediglich lautet, dass keine ausreichende Informationsgrundlage vorhanden ist.
Fehlende Berechtigungen sollten dagegen durch die Autorisierungsschicht behandelt werden. Je nach Schnittstelle kann beispielsweise HTTP 403 angemessen sein.
Technische Fehler wie eine vorübergehend nicht erreichbare Modell-API benötigen wiederum eine andere Behandlung.
Synchrone und asynchrone Verarbeitung¶
FastAPI unterstützt sowohl gewöhnliche Funktionen als auch asynchrone Endpunkte.
Eine asynchrone Funktion kann beispielsweise mit async def definiert werden.
Das bedeutet jedoch nicht, dass sämtliche darin ausgeführten Operationen automatisch nicht blockierend sind.
Wenn eine asynchrone Funktion einen synchronen, blockierenden Datenbank- oder API-Aufruf ausführt, kann sie den Event Loop blockieren.
Deshalb müssen die verwendeten Bibliotheken und Aufrufmethoden zum gewählten Ausführungsmodell passen.
Für eine einfache RAG-Anwendung ist ein synchroner Endpunkt zunächst vollkommen ausreichend.
Bei vielen gleichzeitigen Anfragen können asynchrone Clients und eine geeignete Serverkonfiguration Vorteile bieten.
Die Entscheidung sollte sich an den tatsächlichen Anforderungen orientieren und nicht allein daran, dass async moderner klingt.
Streaming von Antworten¶
Bei einer gewöhnlichen API-Antwort wartet der Client, bis das Sprachmodell seine gesamte Ausgabe erzeugt hat.
Für längere Antworten kann das unangenehm wirken.
Viele Modell-APIs unterstützen deshalb Streaming. Dabei werden Teile der Antwort bereits während der Generierung übertragen.
Eine Weboberfläche kann diese Teile unmittelbar anzeigen.
Technisch lässt sich das beispielsweise über Server-Sent Events (SSE) umsetzen.
Streaming verkürzt nicht zwangsläufig die gesamte Generierungsdauer. Es kann aber die wahrgenommene Antwortzeit deutlich verbessern, weil der Benutzer früher erste Inhalte sieht.
Allerdings entstehen zusätzliche Anforderungen.
Die Anwendung muss mit abgebrochenen Verbindungen umgehen und sicherstellen, dass bereits ausgegebene Inhalte nicht nachträglich zurückgenommen werden müssen.
Gerade bei sicherheitsrelevanten Ausgabeprüfungen kann Streaming deshalb schwieriger sein als die Verarbeitung einer vollständig generierten Antwort.
Streaming ist eine Architekturentscheidung
Streaming verbessert häufig die Benutzererfahrung, erhöht aber die Komplexität der Fehlerbehandlung und Ausgabeprüfung.
Für eine erste produktive RAG-Anwendung kann eine gewöhnliche HTTP-Antwort die einfachere und robustere Wahl sein.
8.4 Datenhaltung und Infrastruktur¶
Eine RAG-Anwendung verarbeitet unterschiedliche Arten von Daten.
Dazu gehören ursprüngliche Dokumente, extrahierte Texte, Chunks, Embeddings, Metadaten und Informationen über laufende Verarbeitungsvorgänge.
Es wäre möglich, viele dieser Informationen in einem einzigen Speichersystem abzulegen.
In der Praxis ist es jedoch häufig sinnvoll, die verschiedenen Aufgaben zu unterscheiden.
Dokumentenspeicher¶
Der Dokumentenspeicher enthält die ursprünglichen Dateien.
Für Teralune können das beispielsweise Markdown-, HTML-, CSV-, PDF- oder DOCX-Dokumente sein.
Die Dateien können lokal auf einem Dateisystem liegen oder in einem Objektspeicher gespeichert werden.
Ein Objektspeicher wie Amazon S3 eignet sich insbesondere dann, wenn mehrere Dienste auf dieselben Dateien zugreifen müssen.
Wichtig ist, dass die Anwendung die ursprünglichen Dokumente nicht mit den daraus erzeugten Chunks verwechselt.
Ein Chunk ist eine abgeleitete Repräsentation. Das Originaldokument bleibt die maßgebliche Quelle.
Wenn wir ein Dokument neu parsen oder mit einer anderen Chunking-Strategie verarbeiten möchten, benötigen wir idealerweise weiterhin die Originaldatei.
Vektordatenbank¶
Die Vektordatenbank übernimmt die Ähnlichkeitssuche.
In unseren Experimenten verwenden wir Chroma mit einem persistenten lokalen Speicher.
Das ist für die Entwicklung und für kleinere Anwendungen ein sinnvoller Ansatz.
Sobald mehrere Prozesse oder Container auf denselben Index zugreifen sollen, müssen wir jedoch die Betriebsform der Datenbank berücksichtigen.
Chroma kann auch als eigenständiger Server betrieben werden.
Die Anwendung verbindet sich dann über einen Client mit diesem Dienst, statt den lokalen Speicher direkt zu öffnen.
Ein entsprechender Client könnte so aussehen:
import chromadb
client = chromadb.HttpClient(
host="chroma",
port=8000,
)
collection = client.get_collection("teralune_corpus")
Der Hostname chroma kann beispielsweise innerhalb eines Docker-Compose-Netzwerks verwendet werden.
Der Vorteil besteht darin, dass API und Worker über denselben Datenbankdienst auf den Suchindex zugreifen können.
Dabei müssen wir weiterhin auf die Zugriffskontrolle achten. Ein gemeinsam erreichbarer Chroma-Dienst darf nicht automatisch von beliebigen Clients ungefiltert abgefragt werden können.
Relationale Datenbank¶
Eine relationale Datenbank wie PostgreSQL eignet sich für strukturierte Anwendungsdaten.
Dazu gehören beispielsweise:
- Dokumentkennungen und Importstatus,
- Informationen über Dokumentversionen,
- Verweise auf gespeicherte Originaldateien,
- Hintergrundjobs und deren Status,
- Benutzer- und Berechtigungszuordnungen.
Die relationale Datenbank muss nicht sämtliche Dokumenttexte enthalten.
Sie kann vielmehr die Verwaltungsinformationen bereitstellen, die für einen kontrollierten Betrieb erforderlich sind.
Ein Dokument könnte beispielsweise die Zustände uploaded, processing, indexed oder failed besitzen.
Damit kann die Anwendung nachvollziehen, ob eine Datei bereits erfolgreich in den Suchindex übernommen wurde.
Redis als zusätzliche Komponente¶
Redis ist ein schneller In-Memory-Datenspeicher.
Er wird häufig für Caching, kurzlebige Zustände oder als Bestandteil einer Queue-Infrastruktur eingesetzt.
Für unsere RAG-Anwendung könnte Redis beispielsweise einen Broker für Hintergrundaufgaben bereitstellen.
Dabei sollten wir Redis nicht automatisch als dauerhaftes System of Record betrachten.
Welche Daten nach einem Neustart erhalten bleiben müssen, hängt von der Konfiguration und vom jeweiligen Einsatzzweck ab.
Welche Komponenten benötigen wir wirklich?¶
Eine kleine RAG-Anwendung kann mit wenigen Komponenten beginnen:
| Komponente | Einfache Variante | Erweiterte Variante |
|---|---|---|
| API | Ein Python-Prozess | Mehrere API-Instanzen |
| Dokumente | Lokales Dateisystem | Objektspeicher |
| Vektorsuche | Chroma PersistentClient | Separater Chroma-Server |
| Metadaten | SQLite oder einfache Datenhaltung | PostgreSQL |
| Hintergrundjobs | Direkte Verarbeitung | Queue und Worker |
| Modelle | Externe Modell-API | Externe oder selbst betriebene Modelle |
Die erweiterte Variante ist nicht automatisch besser.
Jeder zusätzliche Dienst benötigt Konfiguration, Überwachung, Updates und eine Strategie für Fehlerfälle.
Für eine Anwendung mit wenigen Benutzern kann eine einfache Architektur deshalb langfristig die vernünftigere Lösung sein.
8.5 Containerisierung mit Docker¶
Unsere bisherigen Python-Experimente verwenden eine lokale Entwicklungsumgebung.
Wir installieren Abhängigkeiten, konfigurieren Umgebungsvariablen und starten die Skripte über Python.
Für den Betrieb auf einem Server müssen wir sicherstellen, dass die Anwendung dort unter vergleichbaren Bedingungen ausgeführt werden kann.
Eine verbreitete Lösung dafür ist Docker.
Was ist ein Container?¶
Ein Container verpackt eine Anwendung zusammen mit ihrer Laufzeitumgebung und den benötigten Abhängigkeiten.
Dadurch lässt sich dieselbe Anwendung auf unterschiedlichen Systemen mit weitgehend konsistenten Voraussetzungen ausführen.
Ein Container ist allerdings keine vollständige virtuelle Maschine. Er verwendet den Kernel des Hostsystems und isoliert Prozesse über Betriebssystemmechanismen.
Für unsere RAG-Anwendung bedeutet das, dass wir beispielsweise die API, Chroma und einen Hintergrundworker in getrennten Containern betreiben können.
Ein einfaches Dockerfile¶
Ein Dockerfile beschreibt, wie ein Container-Image erstellt wird.
Für eine Python-Anwendung mit uv könnte eine vereinfachte, nicht produktionsreife Variante so aussehen:
FROM python:3.14-slim
COPY --from=ghcr.io/astral-sh/uv:0.9.0 /uv /uvx /bin/
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY . .
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Dieses Beispiel verdeutlicht den grundlegenden Aufbau.
Zunächst wählen wir ein Python-Basisimage.
Anschließend kopieren wir uv in das Image, übernehmen die Projektdefinition und installieren die benötigten Abhängigkeiten.
Erst danach kopieren wir den Anwendungscode.
Diese Reihenfolge kann den Build beschleunigen, weil Docker seine Images schichtweise aus sogenannten Layern aufbaut. Unveränderte Layer können bei einem erneuten Build aus dem Cache übernommen werden. Ändert sich beispielsweise nur der Anwendungscode, müssen die Python-Abhängigkeiten nicht erneut installiert werden.
Weitere Informationen dazu bietet die offizielle Docker-Dokumentation zum Build-Cache und Layer-Caching.
Das Beispiel ist bewusst kompakt. Für eine produktive Anwendung sollten wir zusätzlich unter anderem einen nicht privilegierten Benutzer, geeignete Dateirechte, eine kontrollierte Abhängigkeitsversionierung und einen passenden Startprozess berücksichtigen.
Auch die verwendeten Image- und Werkzeugversionen sollten für ein konkretes Projekt überprüft und möglichst reproduzierbar festgelegt werden.
Mehrere Container mit Docker Compose¶
Unsere Referenzarchitektur besitzt mehrere Komponenten.
Docker Compose ermöglicht es, diese Komponenten gemeinsam zu konfigurieren und zu starten.
Eine vereinfachte compose.yaml könnte so aussehen:
services:
api:
build: .
ports:
- "8000:8000"
environment:
CHROMA_HOST: chroma
CHROMA_PORT: "8000"
depends_on:
- chroma
chroma:
image: chromadb/chroma:1.0.0
volumes:
- chroma_data:/data
expose:
- "8000"
volumes:
chroma_data:
Die API und Chroma laufen hier in getrennten Containern.
Innerhalb des von Docker Compose automatisch erstellten Docker-Netzwerks kann die API den Chroma-Dienst über den Hostnamen chroma erreichen.
Das Volume chroma_data soll sicherstellen, dass Daten nicht allein im beschreibbaren Dateisystem des Containers liegen.
Die tatsächlichen Speicherpfade und Konfigurationsparameter hängen von der verwendeten Chroma-Version ab und müssen mit deren Dokumentation abgeglichen werden.
Dieses Beispiel zeigt das Architekturprinzip, ist aber noch keine vollständig abgesicherte Produktionskonfiguration.
Konfiguration über Umgebungsvariablen¶
Anwendungen benötigen häufig unterschiedliche Einstellungen für Entwicklung und Produktion.
Dazu gehören beispielsweise Datenbankadressen, Modellnamen oder die Anzahl der Retrieval-Treffer.
Solche Einstellungen sollten nicht fest im Anwendungscode stehen.
Ein einfaches Python-Beispiel könnte so aussehen:
import os
CHROMA_HOST = os.getenv("CHROMA_HOST", "localhost")
CHROMA_PORT = int(os.getenv("CHROMA_PORT", "8000"))
RETRIEVAL_TOP_K = int(os.getenv("RETRIEVAL_TOP_K", "5"))
Die Anwendung verwendet damit Standardwerte für die lokale Entwicklung, kann aber in einer anderen Umgebung abweichend konfiguriert werden.
Secrets gehören nicht ins Image¶
API-Schlüssel und andere Zugangsdaten dürfen nicht fest in ein Dockerfile oder in den Anwendungscode geschrieben werden.
Ein Image kann in Registries gespeichert, kopiert oder von mehreren Personen verwendet werden.
Fest eingebaute Zugangsdaten könnten dadurch unbeabsichtigt offengelegt werden.
Für lokale Entwicklung können Umgebungsvariablen oder geeignete lokale Secret-Dateien verwendet werden.
In produktiven Umgebungen bieten sich dedizierte Secret-Management-Lösungen an.
Auch Umgebungsvariablen sind nicht automatisch ein sicherer Secret-Speicher. Entscheidend sind die Zugriffsrechte auf die Laufzeitumgebung und die Prozesse, die diese Informationen auslesen können.
Docker löst nicht sämtliche Betriebsprobleme¶
Container vereinfachen die Bereitstellung einer Anwendung.
Sie garantieren jedoch weder Verfügbarkeit noch Datensicherheit.
Eine Datenbank benötigt weiterhin eine Strategie für persistente Speicherung und Backups.
Eine API benötigt eine geeignete Konfiguration für Netzwerkzugriffe, Ressourcenlimits und Fehlerbehandlung.
Auch Container-Images müssen aktualisiert werden, wenn Sicherheitsprobleme in ihren Abhängigkeiten bekannt werden.
Docker ist deshalb ein wichtiges Werkzeug für Deployment und Reproduzierbarkeit, ersetzt aber keine durchdachte Betriebsarchitektur.
Weiterführende Informationen bieten die Docker-Dokumentation und die Docker-Compose-Dokumentation.
8.6 Skalierung und asynchrone Verarbeitung¶
Eine RAG-Anwendung muss zwei unterschiedliche Arten von Arbeitslast bewältigen.
Die erste besteht aus interaktiven Benutzeranfragen.
Die zweite umfasst Hintergrundaufgaben wie Dokumentimport, Parsing, Chunking und Indexierung.
Beide Arbeitslasten sollten unterschiedlich behandelt werden.
Warum Dokumentimporte nicht in eine HTTP-Anfrage gehören¶
Angenommen, ein Benutzer lädt eine umfangreiche PDF-Datei hoch.
Die Anwendung muss das Dokument speichern, seinen Text extrahieren, Chunks erzeugen und Embeddings berechnen.
Je nach Dokumentgröße kann dieser Vorgang längere Zeit dauern.
Würde die API sämtliche Schritte innerhalb derselben HTTP-Anfrage ausführen, müsste der Client entsprechend lange warten.
Außerdem könnte die Verbindung abbrechen, obwohl die Verarbeitung noch läuft.
Eine bessere Architektur trennt deshalb das Entgegennehmen des Dokuments von seiner eigentlichen Verarbeitung.
Die API speichert die Datei und legt einen Hintergrundjob an.
Ein Worker übernimmt anschließend die aufwendigen Verarbeitungsschritte.
Eine Queue zwischen API und Worker¶
flowchart LR
U[Benutzer] --> API[API]
API --> DB[(Job-Status)]
API --> Q[(Queue)]
Q --> W[Worker]
W --> P[Parsing und Chunking]
P --> E[Embeddings]
E --> V[(Chroma)]
W --> DB
classDef user fill:#DBEAFE,stroke:#2563EB,color:#1E3A8A,stroke-width:2px
classDef api fill:#EDE9FE,stroke:#7C3AED,color:#4C1D95,stroke-width:2px
classDef queue fill:#FEF3C7,stroke:#D97706,color:#78350F,stroke-width:2px
classDef worker fill:#D1FAE5,stroke:#059669,color:#064E3B,stroke-width:2px
classDef processing fill:#CCFBF1,stroke:#0D9488,color:#134E4A,stroke-width:2px
classDef storage fill:#FCE7F3,stroke:#DB2777,color:#831843,stroke-width:2px
class U user
class API api
class Q queue
class W worker
class P,E processing
class DB,V storage
Die Queue nimmt Arbeitsaufträge entgegen und stellt sie einem Worker zur Verfügung.
Dadurch muss die API nicht warten, bis die gesamte Dokumentverarbeitung abgeschlossen ist.
Der Client kann stattdessen eine Jobkennung erhalten und den Status später abfragen.
Ein vereinfachter Job-Ablauf¶
Eine API könnte auf einen erfolgreichen Upload folgendermaßen reagieren:
Der Worker verarbeitet anschließend den Auftrag.
Währenddessen kann der Status beispielsweise auf running wechseln.
Nach erfolgreicher Indexierung wird der Job als done markiert.
Tritt ein Fehler auf, erhält er den Status failed.
Eine mögliche Zustandsfolge lautet:
Dieses Modell lässt sich bei Bedarf erweitern, beispielsweise um einen Zustand für abgebrochene Aufträge.
Wichtig ist, dass die Anwendung den Zustand einer Verarbeitung nachvollziehen kann.
Celery als Beispiel¶
In Python wird für Hintergrundaufgaben häufig Celery verwendet.
Celery kann Aufgaben über einen Broker an Worker verteilen.
Ein stark vereinfachter Task könnte so aussehen:
from celery import shared_task
@shared_task
def index_document(document_id: str) -> None:
document = document_repository.get(document_id)
text = parse_document(document)
chunks = create_chunks(text)
embeddings = embed_chunks(chunks)
vector_repository.upsert(
document_id=document_id,
chunks=chunks,
embeddings=embeddings,
)
Die verwendeten Funktionen stehen stellvertretend für die bereits bekannten Schritte unserer Ingestion-Pipeline.
Der Task selbst enthält keine neue RAG-Logik.
Er sorgt lediglich dafür, dass die Verarbeitung außerhalb der HTTP-Anfrage ausgeführt werden kann.
Wiederholungen und Idempotenz¶
Hintergrundaufgaben können fehlschlagen.
Ein Embedding-Dienst könnte vorübergehend nicht erreichbar sein. Eine Datenbankverbindung könnte abbrechen oder ein Worker könnte während der Verarbeitung beendet werden.
Deshalb benötigen wir eine Strategie für Wiederholungen.
Dabei entsteht ein wichtiges Problem: Eine Aufgabe könnte bereits teilweise erfolgreich gewesen sein, bevor sie erneut ausgeführt wird.
Wenn ein Dokument bei jeder Wiederholung zusätzliche Chunks erhält, entstehen Duplikate im Suchindex.
Eine robuste Ingestion sollte deshalb möglichst idempotent sein.
Idempotente Dokumentindexierung und stabile Chunk-IDs¶
Hintergrundjobs können fehlschlagen und erneut ausgeführt werden. Das ist zunächst kein Problem, solange die wiederholte Ausführung nicht zu unerwünschten Veränderungen führt.
Stellen wir uns vor, ein Worker verarbeitet das Teralune-Dokument D003. Er zerlegt den Text in fünf Chunks, erzeugt die entsprechenden Embeddings und speichert alles in Chroma. Kurz danach bricht der Worker ab, bevor er den Job als erfolgreich markieren konnte.
Die Job-Verwaltung erkennt den Fehler und startet die Verarbeitung erneut.
Wenn wir bei jedem Durchlauf neue, zufällige Chunk-IDs erzeugen und die Ergebnisse einfach hinzufügen, könnten anschließend zehn statt fünf Chunks in der Collection liegen. Inhaltlich wären sie identisch, hätten aber unterschiedliche IDs.
Das wollen wir verhindern.
Eine Operation wird als idempotent bezeichnet, wenn ihre wiederholte Ausführung zum selben gewünschten Ergebnis führt wie ihre einmalige Ausführung.
Für unsere Dokumentindexierung bedeutet das: Unabhängig davon, wie oft wir denselben Dokumentstand verarbeiten, soll die Collection anschließend genau die Chunks enthalten, die zu diesem Dokumentstand gehören.
Stabile Chunk-IDs erzeugen¶
Chroma identifiziert jeden Eintrag innerhalb einer Collection über eine eindeutige ID.
Statt bei jeder Indexierung zufällige UUIDs zu erzeugen, können wir die IDs deterministisch aus der Dokumentkennung und der Chunk-Position ableiten.
Für das Dokument D003 mit drei Chunks ergeben sich beispielsweise:
Solange wir dieselbe Dokumentkennung verwenden, entstehen für dieselben Chunk-Positionen dieselben IDs.
Die IDs beschreiben dabei die Position eines Chunks innerhalb eines Dokuments, nicht seinen unveränderlichen Inhalt. Wird beispielsweise am Anfang des Dokuments ein neuer Absatz eingefügt, kann sich der Text aller nachfolgenden Chunks verändern, obwohl ihre IDs gleich bleiben.
Das ist für eine vollständige Neuindexierung kein Problem, solange wir die alten Einträge konsequent ersetzen.
Ein Dokument vollständig neu indexieren¶
Für eine einfache Implementierung verwenden wir zwei Chroma-Operationen:
delete()entfernt die bisherigen Chunks des Dokuments.add()speichert die neu erzeugten Chunks.
Der Python-Code bleibt überschaubar:
def reindex_document(
collection,
document_id: str,
chunks: list[str],
embeddings: list[list[float]],
) -> None:
if len(chunks) != len(embeddings):
raise ValueError("Chunks and embeddings must have equal lengths.")
chunk_ids = [
f"{document_id}:chunk:{index:04d}"
for index in range(len(chunks))
]
metadatas = [
{
"document_id": document_id,
"chunk_index": index,
}
for index in range(len(chunks))
]
# Remove all previously indexed chunks of this document.
collection.delete(
where={"document_id": document_id}
)
# Store the current document version.
if chunks:
collection.add(
ids=chunk_ids,
documents=chunks,
embeddings=embeddings,
metadatas=metadatas,
)
Die Funktion erhält die bereits erzeugten Chunks und Embeddings. Parsing, Chunking und Embedding-Berechnung haben also vorher stattgefunden.
Zunächst erzeugen wir für jeden Chunk eine stabile ID und die zugehörigen Metadaten.
Anschließend entfernen wir alle bisherigen Einträge, deren document_id dem aktuellen Dokument entspricht.
Erst danach speichern wir die neuen Chunks mit add().
Warum verwenden wir hier add() und nicht upsert()?
Weil wir die alten Einträge bereits gelöscht haben. Es gibt deshalb keine vorhandenen Chunks, die aktualisiert werden müssten. Wir möchten ausschließlich neue Einträge hinzufügen.
upsert() wäre an dieser Stelle unnötig.
Wo werden die Chunk-IDs gespeichert?¶
Die Chunk-IDs werden direkt in der Chroma-Collection gespeichert.
Jeder Eintrag besitzt eine ID, einen Dokumenttext, ein Embedding und die zugehörigen Metadaten.
Vereinfacht sieht unsere Collection nach der Indexierung so aus:
| ID | Dokumenttext | Metadaten |
|---|---|---|
D003:chunk:0000 |
Erster Textabschnitt | document_id=D003, chunk_index=0 |
D003:chunk:0001 |
Zweiter Textabschnitt | document_id=D003, chunk_index=1 |
D003:chunk:0002 |
Dritter Textabschnitt | document_id=D003, chunk_index=2 |
Chroma verwaltet diese IDs innerhalb der Collection. Wir müssen dafür keine zusätzliche Tabelle in PostgreSQL anlegen.
Eine relationale Datenbank kann allerdings weiterhin sinnvoll sein, um Informationen über das ursprüngliche Dokument zu speichern, beispielsweise seine Version, den Zeitpunkt der letzten erfolgreichen Indexierung oder den aktuellen Verarbeitungsstatus.
Die Metadaten document_id und chunk_index erfüllen unterschiedliche Aufgaben: Über document_id finden wir sämtliche Chunks eines Dokuments wieder. Über chunk_index können wir ihre ursprüngliche Reihenfolge nachvollziehen.
Was passiert, wenn sich die Anzahl der Chunks ändert?¶
Angenommen, D003 besteht zunächst aus fünf Chunks.
Nach einer Überarbeitung enthält das Dokument weniger Text. Beim erneuten Chunking entstehen nur noch drei Chunks.
Unsere Funktion entfernt zunächst alle fünf bisherigen Einträge und speichert anschließend die drei neuen.
Das Ergebnis ist korrekt:
Before reindexing:
D003:chunk:0000
D003:chunk:0001
D003:chunk:0002
D003:chunk:0003
D003:chunk:0004
After reindexing:
D003:chunk:0000
D003:chunk:0001
D003:chunk:0002
Die überzähligen Chunks verschwinden automatisch.
Auch wenn wir die Funktion anschließend erneut mit denselben Daten ausführen, erhalten wir wieder genau diese drei Einträge.
Damit besitzt unsere einfache Neuindexierung die gewünschte Idempotenz im Hinblick auf den vollständig und erfolgreich hergestellten Indexzustand.
Die wiederholte Ausführung erzeugt keine zusätzlichen Chunks.
Wann ist upsert() sinnvoll?¶
Chroma bietet neben add() auch die Methode upsert() an.
Der Unterschied ist einfach:
| Methode | Verhalten |
|---|---|
add() |
Fügt neue Einträge hinzu. |
upsert() |
Fügt neue Einträge hinzu oder aktualisiert vorhandene Einträge mit derselben ID. |
delete() |
Entfernt vorhandene Einträge. |
upsert() ist insbesondere dann interessant, wenn wir bestehende Chunks gezielt aktualisieren möchten, ohne vorher das gesamte Dokument zu löschen.
Angenommen, wir wissen, dass sich ausschließlich ein bestimmter Chunk verändert hat. Dann könnten wir diesen Eintrag direkt aktualisieren:
collection.upsert(
ids=["D003:chunk:0001"],
documents=[updated_chunk],
embeddings=[updated_embedding],
metadatas=[{
"document_id": "D003",
"chunk_index": 1,
}],
)
Chroma aktualisiert den vorhandenen Eintrag mit dieser ID oder legt ihn an, falls er noch nicht existiert.
Allerdings müssen wir bei einer solchen inkrementellen Aktualisierung selbst berücksichtigen, ob andere Chunks inzwischen weggefallen sind.
upsert() entfernt keine veralteten Einträge, die in der neuen Chunk-Liste nicht mehr vorkommen.
Für unsere einfache vollständige Neuindexierung ist delete() gefolgt von add() deshalb leichter zu verstehen.
Die Grenze unserer einfachen Lösung¶
Unsere Implementierung besitzt allerdings eine wichtige Einschränkung.
Das Löschen und anschließende Hinzufügen sind keine atomare Operation.
Angenommen, der Worker führt delete() erfolgreich aus, stürzt aber unmittelbar danach ab.
Die bisherigen Chunks wurden bereits entfernt. Die neuen Chunks sind noch nicht gespeichert.
Bis der Job erfolgreich wiederholt wird, fehlen die Informationen dieses Dokuments im Suchindex.
Auch während einer laufenden Neuindexierung könnte eine Benutzeranfrage auf einen unvollständigen Datenbestand treffen.
Ein weiteres Problem entsteht, wenn zwei Worker dasselbe Dokument gleichzeitig indexieren. Ihre Lösch- und Schreiboperationen könnten sich überschneiden.
Unsere einfache Funktion sollte deshalb nur verwendet werden, wenn die Anwendung sicherstellt, dass nicht mehrere Indexierungsjobs für dasselbe Dokument gleichzeitig laufen und ein fehlgeschlagener Job zuverlässig erneut verarbeitet wird.
Für produktive Anwendungen mit höheren Verfügbarkeitsanforderungen ist eine versionierte Indexierung robuster.
Dabei gehen wir folgendermaßen vor:
- Wir speichern die neuen Chunks unter einer neuen Dokumentversion, ohne die bisherigen Chunks zu löschen.
- Wir überprüfen, ob die neue Version vollständig indexiert wurde.
- Wir markieren die neue Version in einer vertrauenswürdigen Verwaltungsdatenbank als aktuell.
- Das Retrieval verwendet ausschließlich die freigegebene Version.
- Erst danach entfernen wir die alten Chunks.
Die Chunk-IDs könnten dafür zusätzlich eine Versionskennung enthalten:
Die neue Version wird zunächst vollständig aufgebaut, während die bisherige Version weiterhin verfügbar bleibt.
Erst nach erfolgreicher Verarbeitung wird die neue Version für das Retrieval freigegeben. Die Anwendung muss diesen Versionswechsel verbindlich durchsetzen und verhindern, dass parallele Worker ältere Versionen nachträglich wieder aktivieren.
Eine relationale Datenbank wie PostgreSQL kann verwalten, welche Version eines Dokuments aktuell freigegeben ist. Chroma speichert weiterhin die eigentlichen Chunks und Embeddings.
Wichtig ist außerdem, dass die neue Version bereits vollständig für das Retrieval verfügbar sein muss, bevor die Anwendung auf sie umschaltet. Bei mehreren Suchindizes, Caches oder Replikaten müssen diese Komponenten ebenfalls berücksichtigt werden.
Dieses Vorgehen benötigt mehr Verwaltungslogik, vermeidet aber das Zeitfenster, in dem bei unserer einfachen delete()-und-add()-Variante sämtliche Chunks eines Dokuments fehlen können.
Idempotenz und sichere Neuindexierung
Für eine einfache vollständige Neuindexierung können wir die bisherigen Chunks eines Dokuments mit delete() entfernen und die neuen mit add() speichern. Stabile Chunk-IDs sorgen dabei für nachvollziehbare und reproduzierbare Kennungen.
upsert() eignet sich dagegen für gezielte Aktualisierungen vorhandener Einträge.
Beide Verfahren müssen mit möglichen Fehlern und Wiederholungen umgehen können. Eine versionierte Indexierung bietet zusätzliche Sicherheit, weil eine neue Dokumentversion erst nach erfolgreicher Verarbeitung für das Retrieval freigegeben wird.
Skalierung der Benutzeranfragen¶
Auch die Query-Pipeline kann bei steigender Benutzerzahl an ihre Grenzen gelangen.
Eine Möglichkeit besteht darin, mehrere API-Instanzen parallel zu betreiben.
Ein Load Balancer verteilt eingehende Anfragen auf diese Instanzen.
Das funktioniert besonders gut, wenn die API möglichst wenig lokalen Zustand besitzt.
Die gemeinsam benötigten Informationen liegen dann in geeigneten externen Speichern.
Allerdings lassen sich nicht sämtliche Komponenten beliebig skalieren.
Die Vektordatenbank, externe Modell-APIs und verfügbare Ressourcen können weiterhin Engpässe bilden.
flowchart TB
U["Benutzeranfragen"] --> LB["Load Balancer"]
LB --> API1["API-Instanz 1"]
LB --> API2["API-Instanz 2"]
LB --> API3["API-Instanz 3"]
subgraph API["Horizontal skalierte Query-Pipeline"]
API1
API2
API3
end
API1 --> DB[("Vektordatenbank")]
API2 --> DB
API3 --> DB
API1 --> LLM["Externe LLM-API"]
API2 --> LLM
API3 --> LLM
DB --> LIMIT1["Möglicher Engpass:<br/>Datenbankkapazität"]
LLM --> LIMIT2["Möglicher Engpass:<br/>Rate Limits und API-Kosten"]
style LB fill:#dbeafe,stroke:#2563eb
style API fill:#f0fdf4,stroke:#16a34a
style DB fill:#fef3c7,stroke:#d97706
style LLM fill:#fef3c7,stroke:#d97706
style LIMIT1 fill:#fee2e2,stroke:#dc2626
style LIMIT2 fill:#fee2e2,stroke:#dc2626
Mehr API-Instanzen verbessern deshalb nicht automatisch die gesamte Leistung.
Skalierung beginnt bei der Messung
Bevor wir zusätzliche Container oder Worker starten, sollten wir wissen, welche Komponente tatsächlich überlastet ist.
Eine langsame Modellgenerierung wird nicht schneller, nur weil wir mehr API-Instanzen betreiben.
8.7 Performance und Latenz¶
Bei einer interaktiven RAG-Anwendung spielt die Antwortzeit eine wichtige Rolle.
Benutzer erwarten nicht unbedingt, dass eine komplexe Frage sofort beantwortet wird. Eine unnötig lange Wartezeit verschlechtert jedoch die Benutzererfahrung.
Um die Performance zu verbessern, müssen wir zunächst verstehen, wo die Zeit verbraucht wird.
Die Latenz einer RAG-Anfrage¶
Eine typische Anfrage besteht aus mehreren Schritten:
flowchart LR
Q[Frage] --> E[Query-Embedding]
E --> R[Retrieval]
R --> C[Kontextaufbereitung]
C --> G[Generierung]
G --> A[Antwort]
style Q fill:#dbeafe,stroke:#2563eb
style A fill:#dbeafe,stroke:#2563eb
style E fill:#fef3c7,stroke:#d97706
style R fill:#fef3c7,stroke:#d97706
style C fill:#dcfce7,stroke:#16a34a
style G fill:#dcfce7,stroke:#16a34a
Jeder Schritt benötigt Zeit.
Die gesamte Antwortzeit lässt sich vereinfacht als Summe der einzelnen Verarbeitungsschritte betrachten:
$$ T_{\text{gesamt}} = T_{\text{embedding}} + T_{\text{retrieval}} + T_{\text{context}} + T_{\text{generation}} + T_{\text{overhead}} $$
Dabei steht overhead beispielsweise für Netzwerkkommunikation, Serialisierung und weitere Anwendungsschritte.
Diese Darstellung gilt für einen überwiegend sequenziellen Ablauf. Werden unabhängige Schritte parallel ausgeführt, verändert sich die Berechnung entsprechend.
Wo entstehen typische Engpässe?¶
Die Erzeugung eines Query-Embeddings benötigt häufig einen externen API-Aufruf.
Das Retrieval durchsucht anschließend den Suchindex.
Die Kontextaufbereitung kombiniert die gefundenen Chunks und entfernt gegebenenfalls ungeeignete oder doppelte Informationen.
Die Generierung kann einen erheblichen Teil der Gesamtdauer beanspruchen, insbesondere wenn das Sprachmodell eine längere Antwort erzeugt.
Welche Komponente tatsächlich dominiert, hängt jedoch vom verwendeten Modell, der Infrastruktur und der konkreten Anfrage ab.
Wir sollten deshalb keine allgemeingültigen Annahmen treffen.
Mehr Kontext ist nicht automatisch besser¶
Eine naheliegende Idee zur Qualitätsverbesserung besteht darin, mehr Chunks abzurufen.
Wenn fünf Treffer nicht ausreichen, könnten wir beispielsweise zehn oder zwanzig verwenden.
Dadurch wächst allerdings auch der Kontext, den wir an das Sprachmodell übergeben.
Das kann mehrere Nachteile besitzen.
Erstens steigt die Anzahl der Eingabetokens.
Zweitens können längere Kontexte die Verarbeitung verteuern.
Drittens enthalten zusätzliche Chunks möglicherweise redundante oder irrelevante Informationen.
Ein größerer Kontext garantiert deshalb weder bessere Antworten noch eine bessere Performance.
Die Wahl von top_k bleibt ein Kompromiss zwischen Informationsabdeckung, Antwortqualität und Ressourcenverbrauch.
Wie sich Retrieval-Konfigurationen fachlich vergleichen lassen, haben wir bereits in Kapitel 6 untersucht.
Caching als Performance-Werkzeug¶
Caching kann wiederholte Berechnungen vermeiden.
Für RAG kommen unterschiedliche Cache-Ebenen infrage.
| Cache | Möglicher Nutzen |
|---|---|
| Embedding-Cache | Bereits berechnete Embeddings identischer Texte wiederverwenden |
| Retrieval-Cache | Suchergebnisse wiederholter Anfragen zwischenspeichern |
| Antwort-Cache | Vollständige Antworten für geeignete wiederkehrende Fragen speichern |
| Dokument-Cache | Wiederholtes Laden oder Parsen unveränderter Dokumente vermeiden |
Für die technische Umsetzung stehen unterschiedliche Möglichkeiten zur Verfügung. In Python eignen sich beispielsweise functools.lru_cache aus der Standardbibliothek oder die Bibliothek cachetools für einfache In-Memory-Caches. Mit joblib.Memory lassen sich Ergebnisse aufwendiger Berechnungen oder Verarbeitungsschritte auf dem Dateisystem zwischenspeichern.
Sollen mehrere API-Instanzen auf einen gemeinsamen Cache zugreifen, kann ein zentraler Cache-Dienst wie Redis eingesetzt werden. Der Zugriff erfolgt in Python beispielsweise über die Bibliothek redis-py.
Nicht jede Cache-Ebene ist gleichermaßen sinnvoll.
Ein Embedding-Cache kann besonders dann helfen, wenn dieselben Texte häufig verarbeitet werden. Dabei muss berücksichtigt werden, mit welchem Embedding-Modell die Vektoren erzeugt wurden.
Ein Antwort-Cache ist schwieriger, weil eine Antwort von Dokumentversionen, Berechtigungen und weiteren Kontextinformationen abhängen kann.
Die Sicherheitsanforderungen an solche Caches haben wir bereits in Abschnitt 7.3 behandelt.
Für die Performance gilt zusätzlich: Ein Cache verursacht selbst Verwaltungsaufwand und benötigt Speicher. Außerdem müssen zwischengespeicherte Daten bei Änderungen gegebenenfalls aktualisiert oder verworfen werden.
Ein Cache sollte deshalb nur eingesetzt werden, wenn ein konkreter Nutzen zu erwarten ist.
Timeouts und begrenzte Ressourcen¶
Externe Dienste können langsam reagieren oder vorübergehend nicht erreichbar sein.
Eine API sollte deshalb nicht unbegrenzt auf Antworten warten.
Timeouts legen fest, wie lange eine Anwendung auf eine Operation wartet, bevor sie den Vorgang als fehlgeschlagen behandelt.
Dabei müssen wir unterschiedliche Situationen berücksichtigen.
Ein kurzer Timeout kann dazu führen, dass eigentlich erfolgreiche Modellanfragen unnötig abgebrochen werden.
Ein zu langer Timeout kann dagegen Ressourcen blockieren und die Benutzererfahrung verschlechtern.
Auch Wiederholungsversuche müssen begrenzt werden.
Wenn ein externer Dienst überlastet ist, können unkontrollierte Wiederholungen die Situation zusätzlich verschärfen.
Ein einfaches Retry-Verfahren in Python¶
Für vorübergehende Fehler können wir einen fehlgeschlagenen API-Aufruf automatisch wiederholen. Dabei sollten wir allerdings nicht sofort beliebig viele neue Anfragen senden.
Eine verbreitete Strategie ist der exponentielle Backoff. Dabei wird die Wartezeit zwischen den Versuchen schrittweise erhöht.
Das folgende Beispiel verwendet den OpenAI-Client:
import random
import time
from openai import (
APIConnectionError,
APITimeoutError,
OpenAI,
RateLimitError,
)
client = OpenAI(
timeout=10.0,
max_retries=0,
)
def generate_with_retry(prompt: str) -> str:
max_attempts = 3
for attempt in range(max_attempts):
try:
response = client.responses.create(
model="gpt-4.1-mini",
input=prompt,
)
return response.output_text
except (APITimeoutError, APIConnectionError, RateLimitError):
if attempt == max_attempts - 1:
raise
delay = min(2 ** attempt, 8)
delay += random.uniform(0, 0.5)
print(f"Retrying in {delay:.1f} seconds...")
time.sleep(delay)
raise RuntimeError("Maximum retry attempts exceeded.")
Der Client besitzt einen Timeout von zehn Sekunden pro Anfrage. Mit max_retries=0 deaktivieren wir seine eingebauten Wiederholungsversuche, damit ausschließlich unsere eigene Retry-Logik greift.
Schlägt eine Anfrage wegen eines Timeouts, eines Verbindungsproblems oder einer Rate-Limit-Überschreitung fehl, wartet die Funktion zunächst eine kurze Zeit und versucht es erneut.
Die Wartezeit wächst exponentiell:
| Versuch | Wartezeit vor dem nächsten Versuch |
|---|---|
| 1 | ca. 1 Sekunde |
| 2 | ca. 2 Sekunden |
| 3 | Kein weiterer Versuch |
Die kleine zufällige Verzögerung wird als Jitter bezeichnet. Sie verhindert, dass viele Clients nach einem gemeinsamen Fehler exakt gleichzeitig neue Anfragen senden.
Nach spätestens drei Versuchen wird der letzte Fehler weitergegeben. Die aufrufende Anwendung kann ihn anschließend behandeln und dem Benutzer eine geeignete Fehlermeldung anzeigen.
Wichtig ist, dass wir nur vorübergehende Fehler wiederholen. Ungültige Zugangsdaten oder fehlerhafte Anfragen werden durch zusätzliche Versuche normalerweise nicht behoben.
Das Beispiel ist bewusst einfach gehalten. In einer produktiven Anwendung sollten wir außerdem vom Server vorgegebene Wartezeiten, etwa über den HTTP-Header Retry-After, berücksichtigen. Zusätzlich kann ein übergeordnetes Zeitbudget verhindern, dass sich mehrere einzelne Timeouts zu einer unzumutbar langen Gesamtdauer addieren.
Retries kontrolliert einsetzen
Wiederholungsversuche können die Zuverlässigkeit einer RAG-Anwendung verbessern. Sie benötigen jedoch eine feste Obergrenze, geeignete Wartezeiten und eine klare Unterscheidung zwischen vorübergehenden und dauerhaften Fehlern.
Besonders bei externen Modell-APIs sollten wir vermeiden, einen bereits überlasteten Dienst durch unkontrollierte Wiederholungen zusätzlich zu belasten.
Performance optimieren, ohne die Qualität zu verlieren¶
Nicht jede Beschleunigung ist sinnvoll.
Eine Verringerung von top_k kann die Kontextgröße reduzieren, aber gleichzeitig wichtige Belegstellen entfernen.
Ein kleineres Sprachmodell kann günstiger und schneller sein, aber bei komplexen Fragen schlechtere Antworten erzeugen.
Ein aggressiver Cache kann die Latenz reduzieren, aber veraltete Informationen zurückgeben.
Deshalb sollten Performance-Änderungen immer gemeinsam mit den fachlichen Qualitätsanforderungen betrachtet werden.
Die schnellste Antwort ist nicht automatisch die beste Antwort.
8.8 Kosten und Betriebsentscheidungen¶
Neben Qualität, Sicherheit und Performance beeinflussen auch die Kosten die Architektur einer RAG-Anwendung.
Dabei entstehen Kosten an mehreren Stellen.
Ein Teil entfällt auf die Verarbeitung der Dokumente. Ein weiterer Teil entsteht bei jeder Benutzeranfrage.
Hinzu kommen Infrastruktur und Betrieb.
Kosten der Dokumentverarbeitung¶
Bei der Ingestion erzeugen wir Embeddings für die Dokumentchunks.
Wenn ein Corpus vollständig neu indexiert wird, müssen gegebenenfalls sämtliche Embeddings erneut berechnet werden.
Die Kosten hängen unter anderem von der Anzahl und Länge der Chunks sowie vom verwendeten Embedding-Modell ab.
Eine einfache Abschätzung lautet:
$$ K_{\text{ingestion}} = \frac{N_{\text{tokens}}}{1\,000\,000} \cdot P_{\text{embedding}} $$
Dabei bezeichnet $N_{\text{tokens}}$ die Anzahl der zu verarbeitenden Token und $P_{\text{embedding}}$ den Preis pro Million Token.
Die Formel setzt ein tokenbasiertes Preismodell voraus.
Da Anbieter ihre Preise ändern können, sollten wir aktuelle Werte immer aus der jeweiligen Preisdokumentation übernehmen.
Für OpenAI finden wir diese Informationen auf der offiziellen API-Preisseite.
Kosten pro Benutzeranfrage¶
Bei einer Frage entstehen möglicherweise Kosten für das Query-Embedding und für die Antwortgenerierung.
Die Generierung kann sowohl Eingabe- als auch Ausgabetokens verursachen.
Eine vereinfachte Kostenrechnung lautet:
$$ K_{\text{request}} = K_{\text{query embedding}} + K_{\text{input}} + K_{\text{output}} $$
Für die Modellgenerierung können wir schreiben:
$$ K_{\text{generation}} = \frac{N_{\text{input}}}{1\,000\,000}P_{\text{input}} + \frac{N_{\text{output}}}{1\,000\,000}P_{\text{output}} $$
Die Preise für Eingabe- und Ausgabetokens können unterschiedlich sein.
Außerdem können Anbieter weitere Preisbestandteile oder Vergünstigungen für bestimmte Verarbeitungsarten anbieten.
Unsere Formel dient deshalb zunächst der grundlegenden Abschätzung.
Ein einfaches Rechenbeispiel¶
Nehmen wir an, eine Anfrage benötigt durchschnittlich 3.000 Eingabetoken und 500 Ausgabetokens.
Bei 10.000 Anfragen pro Monat ergeben sich:
$$ 10\,000 \cdot 3\,000 = 30\,000\,000 $$
Eingabetoken und
$$ 10\,000 \cdot 500 = 5\,000\,000 $$
Ausgabetoken.
Die monatlichen Generierungskosten lassen sich damit aus den jeweiligen Modellpreisen berechnen.
Das Beispiel zeigt vor allem, warum die Kontextgröße eine wichtige Rolle spielt.
Wenn wir pro Anfrage unnötig viele Chunks übergeben, erhöhen wir die Eingabetoken bei jeder einzelnen Generierung.
Bei wenigen Testanfragen fällt das kaum auf.
Bei Tausenden Anfragen pro Tag kann es einen erheblichen Unterschied machen.
Infrastrukturkosten¶
Neben Modellkosten entstehen Ausgaben für die Infrastruktur.
Dazu können gehören:
- Server oder Containerplattformen,
- Datenbanken und Speicher,
- Netzwerkverkehr,
- Backups,
- Monitoring und Logging,
- Betrieb und Wartung.
Bei kleinen Anwendungen können die Modellkosten dominieren.
Bei größeren Dokumentbeständen oder hohen Anforderungen an Verfügbarkeit und Isolation kann dagegen die Infrastruktur einen erheblichen Kostenanteil ausmachen.
Auch die Arbeitszeit für Betrieb und Wartung sollte nicht unterschätzt werden.
Cloud-Modelle oder Self-Hosting?¶
Unsere bisherigen Experimente verwenden externe Modell-APIs.
Das hat mehrere Vorteile.
Wir müssen keine eigene GPU-Infrastruktur betreiben und können Modelle über eine vergleichsweise einfache Schnittstelle verwenden.
Dafür entstehen Abhängigkeiten von externen Diensten, deren Verfügbarkeit, Preisen und Nutzungsbedingungen.
Eine Alternative besteht darin, geeignete Modelle selbst zu betreiben.
Dadurch gewinnen wir mehr Kontrolle über die Infrastruktur und bestimmte Datenflüsse.
Allerdings müssen wir dann auch Modellbereitstellung, Hardware, Skalierung und Wartung übernehmen.
| Kriterium | Externe Modell-API | Selbst betriebenes Modell |
|---|---|---|
| Einstieg | Häufig einfacher | Höherer Einrichtungsaufwand |
| Infrastruktur | Anbieter übernimmt Modellbetrieb | Eigene Infrastruktur erforderlich |
| Skalierung | Abhängig vom Angebot des Anbieters | Muss selbst organisiert werden |
| Kostenstruktur | Häufig nutzungsabhängig | Häufig stärker durch Infrastruktur geprägt |
| Datenverarbeitung | Externer Dienst beteiligt | Mehr Kontrolle über den Verarbeitungsort |
| Wartung | Modellbetrieb beim Anbieter | Eigene Verantwortung |
Keine der beiden Varianten ist grundsätzlich überlegen.
Die Entscheidung hängt von Anforderungen an Datenschutz, Modellqualität, Kosten, Verfügbarkeit und technische Betriebsfähigkeit ab.
Architekturentscheidungen sind Kompromisse¶
Eine produktive RAG-Anwendung muss mehrere Ziele gleichzeitig berücksichtigen.
Wir möchten gute Antworten, kurze Wartezeiten, kontrollierbare Kosten und einen zuverlässigen Betrieb.
Diese Ziele lassen sich nicht immer unabhängig voneinander optimieren.
Ein größeres Modell kann die Antwortqualität verbessern, aber höhere Kosten verursachen.
Mehr Retrieval-Treffer können die Informationsabdeckung erhöhen, aber zusätzliche Tokens benötigen.
Eine komplexere Infrastruktur kann bestimmte Skalierungsanforderungen erfüllen, erhöht aber gleichzeitig den Betriebsaufwand.
Deshalb sollte die Architektur aus den tatsächlichen Anforderungen entstehen.
Nicht jede technisch mögliche Optimierung ist für jede Anwendung sinnvoll.
8.9 Zusammenfassung und Ausblick¶
In diesem Kapitel haben wir unsere bisherige RAG-Pipeline aus einer neuen Perspektive betrachtet.
Bisher stand die fachliche Verarbeitung im Mittelpunkt: Dokumente laden, Chunks erzeugen, Embeddings berechnen, Informationen finden und Antworten generieren.
Für eine produktive Anwendung müssen wir diese Verarbeitung in eine geeignete Architektur einbetten.
Wir haben dafür eine mögliche Teralune-Referenzarchitektur kennengelernt, die aus einer API, einem RAG-Service, einer getrennten Ingestion-Pipeline und mehreren Speicherkomponenten besteht.
Die wichtigsten Erkenntnisse lassen sich zusammenfassen:
| Bereich | Zentrale Erkenntnis |
|---|---|
| Prototyp und Produktion | Eine funktionierende Pipeline ist noch keine vollständige Anwendung. |
| Architektur | Ingestion und Query-Verarbeitung sollten unabhängig voneinander organisiert werden. |
| API | Eine definierte Schnittstelle trennt Clients von der eigentlichen RAG-Logik. |
| Datenhaltung | Originaldokumente, Vektoren und Verwaltungsdaten besitzen unterschiedliche Aufgaben. |
| Docker | Container erleichtern reproduzierbare Bereitstellung und die Trennung von Diensten. |
| Hintergrundjobs | Aufwendige Dokumentverarbeitung gehört häufig in eine Queue mit Workern. |
| Skalierung | Zusätzliche Ressourcen helfen nur, wenn sie den tatsächlichen Engpass adressieren. |
| Performance | Antwortzeit, Kontextgröße und Antwortqualität müssen gemeinsam betrachtet werden. |
| Kosten | Modellnutzung, Infrastruktur und Betriebsaufwand beeinflussen Architekturentscheidungen. |
Eine sinnvolle Ausgangsarchitektur¶
Für unsere Teralune-Anwendung wäre ein modularer Python-Service mit FastAPI ein nachvollziehbarer Ausgangspunkt.
Chroma könnte als separater Dienst betrieben werden. Die ursprünglichen Dokumente würden in einem geeigneten Dokumentenspeicher liegen.
Ein Hintergrundworker könnte neue Dokumente verarbeiten und den Suchindex aktualisieren.
Eine relationale Datenbank würde Verwaltungsinformationen und Jobzustände speichern.
Diese Architektur wäre deutlich umfangreicher als unsere bisherigen Python-Skripte, bliebe aber überschaubar.
Vor allem müssten wir nicht sofort eine komplexe Microservice-Landschaft aufbauen.
Was wir noch nicht behandelt haben¶
Unsere bisherige RAG-Pipeline folgt einem vergleichsweise geradlinigen Verfahren:
Eine Frage wird eingebettet, relevante Chunks werden gesucht und anschließend an ein Sprachmodell übergeben.
Dieses Verfahren ist für viele Anwendungen bereits ausreichend.
Es gibt jedoch Situationen, in denen ein einzelner Retrieval-Schritt nicht die besten Ergebnisse liefert.
Eine Frage könnte beispielsweise mehrere Teilfragen enthalten. Ein erster Suchlauf könnte wichtige Informationen übersehen oder eine andere Formulierung der Suchanfrage könnte bessere Treffer liefern.
Für solche Situationen existieren weiterführende Verfahren.
In Kapitel 9 beschäftigen wir uns mit Advanced RAG.
Dort untersuchen wir unter anderem Reranking, Multi-Hop Retrieval, GraphRAG und agentenbasierte Ansätze.
Dabei werden wir eine wichtige Frage immer wieder stellen:
Verbessert die zusätzliche Komplexität tatsächlich unsere Anwendung?
Denn eine produktive RAG-Architektur sollte nicht möglichst viele Technologien enthalten.
Sie sollte die Anforderungen ihrer Benutzer mit einer angemessenen Kombination aus Qualität, Sicherheit, Performance und Wartbarkeit erfüllen.