Produktionsreifes RAG bauen: Ein Architektur-Blueprint mit Symfony und pgvector
Ein SaaS-Team in München lieferte letzten Herbst sein erstes Retrieval-Augmented-Generation-Feature in zwei Wochen aus: ein "Chatte mit unserer Dokumentation"-Panel, gebaut aus einem LangChain-Quickstart, einem OpenAI-Key und einem verwalteten Pinecone-Index. Der Demo-Tag lief gut. Sechs Wochen später war dasselbe Feature ihr größter Einzelposten auf der KI-Rechnung, die Antworten hatten sich still verschlechtert, während die Dokumentation wuchs, der Index war mit zwei Produktbereichen aus dem Takt geraten, was niemand bemerkt hatte, und das Rechtsteam hatte gerade entdeckt, dass Kundensupport-Tickets, inklusive Namen, E-Mail-Adressen und Konto-IDs, eingebettet und an einen US-Anbieter geschickt wurden, ohne dass ein Auftragsverarbeitungsvertrag diesen Datenfluss abdeckte. Der Prototyp funktionierte. Die produktionsreife RAG-Architektur existierte nicht.
Das ist die wiederkehrende Gestalt von RAG-Projekten im Jahr 2026. Eine funktionierende Retrieval-Pipeline zu bauen ist heute eine Wochenend-Übung. Eine zu bauen, die unter realem Dokumentvolumen, regulierten Daten, sich entwickelnden Inhalten und einem Finanzteam standhält, das spitze Fragen zu den Kosten pro Abfrage stellt, ist ein anderes Engineering-Problem. Dieser Beitrag führt durch einen produktionsreifen Blueprint, den wir wiederholt für europäische Kunden ausgeliefert haben: PostgreSQL mit der pgvector-Erweiterung, Symfony als Anwendungsrückgrat, hybride BM25- und Vektorsuche, bewusste Evaluierung und die betrieblichen Teile, die entscheiden, ob das System in sechs Monaten noch funktioniert.
Warum Symfony und pgvector statt einer dedizierten Vektor-DB
Das Standard-Stack-Diagramm für RAG im Jahr 2026 umfasst eine verwaltete Vektordatenbank (Pinecone, Weaviate Cloud, Qdrant Cloud) und einen Python-Dienst, der LangChain oder LlamaIndex ausführt. Für viele europäische SaaS-Teams ist dieses Diagramm in zwei Punkten falsch.
Der erste ist Datengravitation. Die Dokumente, über die sich Retrieval am meisten lohnt (Produktinhalte, Kundendaten, Support-Historie, internes Wissen), liegen bereits in PostgreSQL. Sie einzubetten und die Embeddings an ein separates System zu schicken bedeutet, dass du nun zwei Speicher, zwei Backup-Regime, zwei Zugriffskontrollmodelle und einen Synchronisierungsjob zwischen ihnen betreibst, der driften wird. Die pgvector-Erweiterung macht Postgres zu einem kompetenten Vektorspeicher mit HNSW-Indizes, Cosinus- und L2-Distanz und vorhersehbarer Performance bis in die zweistelligen Millionen Zeilen auf Standard-Hardware. Für die meisten B2B-SaaS-Workloads liegt diese Decke weit über dem, was das Produkt je sehen wird.
Der zweite ist betriebliche Passung. Eine Symfony-Anwendung hat bereits die Teile, die du brauchst: Doctrine für das Datenmodell, die Messenger-Komponente für asynchrone Embedding- und Re-Indexing-Jobs, einen ausgereiften Dependency-Injection-Container, die Security-Komponente für Zugriffskontrolle und eine vernünftige HTTP-Schicht. Vektorsuche-PHP-Code in einen bestehenden Dienst einzubauen ist schneller, günstiger und einfacher zu betreiben als einen Python-Sidecar einzuführen, den der Rest des Teams nicht pflegt. PHP ist kein seltsamer Ort mehr für RAG. Die SDKs der LLM-Anbieter arbeiten über HTTP, der Flaschenhals ist das Modell, nicht die Sprache, und das Ergebnis ist ein bewegliches Teil weniger.
Wo dieser Blueprint zusammenbricht: Workloads über etwa 50 Millionen Chunks, Sub-50-ms-p99-Retrieval bei sehr hoher QPS oder spezialisierte Vektoroperationen wie Multi-Vektor-Retrieval im ColBERT-Stil. Für alle anderen ist RAG mit PostgreSQL die langweilige Wahl, die ausliefert.
Das Datenmodell: Dokumente, Chunks, Embeddings
Ein sauberes Schema ist das gesamte Fundament. Drei Tabellen, jede mit einer klaren Aufgabe.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE rag_document (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
source_uri TEXT NOT NULL,
source_type VARCHAR(32) NOT NULL, -- 'help_article', 'pdf', 'ticket', ...
content_hash CHAR(64) NOT NULL, -- sha256 of normalised source content
title TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
indexed_at TIMESTAMPTZ,
UNIQUE (tenant_id, source_uri)
);
CREATE TABLE rag_chunk (
id BIGSERIAL PRIMARY KEY,
document_id UUID NOT NULL REFERENCES rag_document(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL,
chunk_index INT NOT NULL,
content TEXT NOT NULL,
content_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED,
embedding vector(1536), -- adjust to your model dimension
token_count INT NOT NULL,
embedding_model VARCHAR(64) NOT NULL,
UNIQUE (document_id, chunk_index)
);
CREATE INDEX rag_chunk_tenant_idx ON rag_chunk (tenant_id);
CREATE INDEX rag_chunk_tsv_idx ON rag_chunk USING GIN (content_tsv);
CREATE INDEX rag_chunk_embedding_idx
ON rag_chunk USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Drei Details verdienen sich ihren Platz in der Produktion. Den content_hash auf Dokumenten zu speichern lässt den Indexer das erneute Einbetten überspringen, wenn sich die Quelle nicht geändert hat, eine enorme Kostenersparnis bei inkrementellen Syncs. content_tsv als gespeicherte Spalte zu generieren liefert dir Volltextsuche gratis, was hybrides Retrieval später günstig hinzufügbar macht. Und jeden Chunk mit embedding_model zu taggen lässt dich während einer Migration zwei Embedding-Modelle nebeneinander betreiben, ohne die alten Vektoren wegzuwerfen.
Die HNSW-Parameter oben sind sinnvolle Defaults für ein Korpus von wenigen Millionen Chunks. Für größere Workloads erhöhe ef_construction zur Index-Bauzeit und tune hnsw.ef_search pro Abfrage für die Recall/Latenz-Kurve, die du willst.
Chunking: Wo Qualität gewonnen oder verloren wird
Die Chunking-Strategie entscheidet, was der Retriever überhaupt finden kann. Sie entscheidet auch, was nicht gefunden werden kann, und genau dort entstehen die meisten RAG-Qualitätsfehler.
Der Standard "512 Tokens mit 50 Tokens Überlappung, an Leerzeichen getrennt" ist eine vernünftige Ausgangsbasis und eine schlechte Produktionswahl. Für technische Dokumentation produziert das Trennen an der natürlichen Struktur des Dokuments (Überschriften und Absätze, mit einer maximalen Chunk-Größe als Sicherheitslimit) Chunks, die der Retriever tatsächlich sauber ranken kann. Für Support-Tickets ist die richtige Einheit meist ein Ticket pro Chunk bis zu einer Größenobergrenze, wobei längere Threads an Sprecherwechseln getrennt werden. Für PDFs von Verträgen schlägt das Trennen an Klauselgrenzen jeden Token-Fenster-Ansatz.
Eine nützliche Symfony-Abstraktion:
interface Chunker
{
/** @return Chunk[] */
public function chunk(SourceDocument $doc): array;
}
final class StructuralMarkdownChunker implements Chunker
{
public function __construct(
private readonly int $maxTokens = 800,
private readonly int $overlapTokens = 80,
) {}
public function chunk(SourceDocument $doc): array
{
$sections = $this->splitOnHeadings($doc->getContent());
$chunks = [];
foreach ($sections as $section) {
foreach ($this->packParagraphs($section, $this->maxTokens, $this->overlapTokens) as $idx => $text) {
$chunks[] = new Chunk(
index: count($chunks),
content: $this->prefixWithBreadcrumb($section, $text),
metadata: ['heading' => $section->heading],
);
}
}
return $chunks;
}
}
Zwei Produktionstricks stecken in diesem Prefixing-Schritt. Die Abschnittsüberschrift (oder den Dokumenttitel) als Breadcrumb an den Anfang jedes Chunks zu setzen gibt dem Embedding-Modell Kontext, der reinem Fließtext fehlt, was das Retrieval bei kurzen Abfragen spürbar verbessert. Und Chunks unter dem Kontextfenster deines Re-Rankers zu halten (typischerweise 512 Tokens für einen Cross-Encoder) bedeutet, dass du ohne Trunkierung re-ranken kannst.
Hybride Suche: Warum BM25 plus Vektoren beide einzeln schlägt
Eine reine Vektorsuche glänzt bei semantischer Ähnlichkeit ("wie kündige ich" findet "Abonnementbeendigung") und scheitert an exakten Identifikatoren ("Fehler E_AUTH_412" liefert nichts Nützliches, weil das Embedding den Token nicht kodiert). Eine reine BM25-Keyword-Suche ist das Gegenteil. Hybride Suche, BM25 und Vektoren kombiniert, übertrifft in Produktions-Benchmarks konsistent jede Methode allein, und pgvector plus Postgres-Volltext liefert dir beides in einer Abfrage.
// src/Repository/ChunkRepository.php
public function hybridSearch(
string $query,
array $queryEmbedding,
string $tenantId,
int $limit = 50,
): array {
$sql = <<<SQL
WITH vector_hits AS (
SELECT id, 1 - (embedding <=> :embedding) AS vector_score
FROM rag_chunk
WHERE tenant_id = :tenant
ORDER BY embedding <=> :embedding
LIMIT :limit
),
keyword_hits AS (
SELECT id, ts_rank_cd(content_tsv, plainto_tsquery('simple', :query)) AS bm25_score
FROM rag_chunk
WHERE tenant_id = :tenant
AND content_tsv @@ plainto_tsquery('simple', :query)
ORDER BY bm25_score DESC
LIMIT :limit
),
unioned AS (
SELECT id FROM vector_hits
UNION
SELECT id FROM keyword_hits
)
SELECT
c.id,
c.content,
c.metadata,
COALESCE(v.vector_score, 0) AS vector_score,
COALESCE(k.bm25_score, 0) AS bm25_score
FROM unioned u
JOIN rag_chunk c ON c.id = u.id
LEFT JOIN vector_hits v ON v.id = u.id
LEFT JOIN keyword_hits k ON k.id = u.id
SQL;
return $this->em->getConnection()->executeQuery($sql, [
'query' => $query,
'embedding' => '[' . implode(',', $queryEmbedding) . ']',
'tenant' => $tenantId,
'limit' => $limit,
])->fetchAllAssociative();
}
Der Fusion-Schritt passiert in PHP, nicht in SQL, weil er dorthin gehört. Reciprocal Rank Fusion ist die langweilig korrekte Wahl, robust gegen Skalenunterschiede zwischen den beiden Scores und leicht zu tunen:
public function fuse(array $results, float $k = 60.0): array
{
$vectorRank = array_flip(array_keys($this->sortBy($results, 'vector_score')));
$bm25Rank = array_flip(array_keys($this->sortBy($results, 'bm25_score')));
foreach ($results as $i => &$r) {
$r['rrf_score'] =
1 / ($k + ($vectorRank[$i] ?? PHP_INT_MAX)) +
1 / ($k + ($bm25Rank[$i] ?? PHP_INT_MAX));
}
usort($results, fn($a, $b) => $b['rrf_score'] <=> $a['rrf_score']);
return $results;
}
Nach der Fusion verfeinert ein Cross-Encoder-Re-Ranker auf den Top-20-bis-40-Kandidaten (Cohere Rerank, BGE-Reranker oder ein selbst gehostetes Äquivalent) die finale Reihenfolge. Diese dreistufige Pipeline (lexikalisch plus Vektor, dann Fusion, dann Re-Rank) ist es, die die Lücke zwischen "Demo-Qualität" und "Leute nutzen es tatsächlich" schließt.
Evaluierung: Der Schritt, der alles andere real macht
Ohne Evaluierung ist jede RAG-Änderung ein Bauchgefühl. Mit Evaluierung kannst du eine Chunking-Strategie, ein Embedding-Modell oder einen Re-Ranker gegen das vorherige vergleichen und anhand von Zahlen entscheiden.
Der minimal brauchbare RAG-Evaluierungs-Harness hat zwei Teile. Erstens ein kuratiertes Set von 50 bis 200 repräsentativen Abfragen mit den Chunk-IDs (oder Dokument-IDs), die für jede idealerweise abgerufen werden sollten, einmal mit einer Fachexpertin erstellt und dann gepflegt. Zweitens ein CI-Job, der die Retrieval-Pipeline gegen jede Abfrage im Set laufen lässt und recall@k, MRR und einen Antwortqualitäts-Score von einem LLM-Judge meldet, der mit einer strengen Bewertungsrubrik geprompted wird.
final class RetrievalEvaluator
{
public function evaluate(EvalSet $set, Pipeline $pipeline): EvalReport
{
$hitsAt5 = $hitsAt10 = $reciprocalRankSum = 0.0;
foreach ($set->cases() as $case) {
$retrieved = array_column($pipeline->retrieve($case->query, k: 10), 'id');
$rank = array_search($case->goldChunkId, $retrieved, true);
if ($rank !== false) {
$reciprocalRankSum += 1 / ($rank + 1);
if ($rank < 5) $hitsAt5++;
if ($rank < 10) $hitsAt10++;
}
}
$n = count($set->cases());
return new EvalReport(
recallAt5: $hitsAt5 / $n,
recallAt10: $hitsAt10 / $n,
mrr: $reciprocalRankSum / $n,
);
}
}
Binde das in deine CI ein. Eine Änderung, die recall@5 um mehr als zwei Punkte senkt, lässt den Build fehlschlagen, bis jemand eine verteidigbare Antwort hat. RAG-Evaluierung ist es, die den langsamen, unsichtbaren Qualitätsverfall stoppt, der diese Systeme nach sechs Monaten umbringt.
Die betrieblichen Themen, die Tutorials auslassen
Drei betriebliche Themen entscheiden, ob das System in der Produktion überlebt.
Index-Updates. Quelldokumente ändern sich ständig. Das richtige Muster ist ereignisgesteuert: Ein Domänenereignis ("Artikel veröffentlicht", "Ticket geschlossen") stellt eine ReindexDocument-Messenger-Nachricht in die Queue, der Handler berechnet einen neuen Content-Hash und bettet nur die Chunks neu ein, deren Inhalt sich tatsächlich geändert hat. Ein nächtlicher Drift-Erkennungsjob prüft eine Stichprobe von Dokumenten erneut gegen ihre gespeicherten Hashes, um verpasste Ereignisse abzufangen. Ohne das verrottet der Index still.
Datenschutz und Tenant-Isolation. Jede Chunk-Zeile trägt eine tenant_id, jede Retrieval-Abfrage filtert darauf, jede Doctrine-Abfrage läuft durch einen Tenant-Filter, und personenbezogene Daten (Namen, E-Mail-Adressen, Konto-Identifikatoren) werden entweder vor dem Embedding redigiert oder in einer separat verschlüsselten Spur mit strengerer Aufbewahrung gehalten. Für europäische Kunden sind die Auftragsverarbeitungsbedingungen des Embedding-Anbieters Teil der Architekturüberprüfung, kein nachträglicher Gedanke. Für regulierte Branchen bedeutet das oft ein selbst gehostetes Embedding-Modell statt einer externen API. Das ist dieselbe langweilige Disziplin, die ein ernsthaftes Code-Quality-Consulting verlangen würde, angewandt auf einen neuen Datenfluss.
Kostentracking. Embedding-Aufrufe und LLM-Completion-Aufrufe sind pro Token abrechenbar und leicht zu missbrauchen. Verdrahte eine Metrik-Middleware in jeden Anbieter-Aufruf, die Tokens-rein, Tokens-raus, Latenz, Modell und ein feature-Tag aufzeichnet. Ein einfaches Dashboard von Kosten-pro-Feature und Kosten-pro-Tenant verwandelt eine undurchsichtige KI-Rechnung in einen Posten, über den das Finanzteam nachdenken und den das Produktteam optimieren kann. Die Teams, die das auslassen, finden es am Monatsende heraus.
Was man zuerst baut
Ein pragmatischer Sechs-Wochen-Plan: Woche eins, Schema, Ingestion-Job und struktureller Chunker für einen Dokumenttyp. Woche zwei, Embeddings und reines Vektor-Retrieval, end-to-end. Woche drei, Volltext-Indizierung und die hybride Abfrage. Woche vier, Re-Ranking und der Evaluierungs-Harness mit 50 kuratierten Abfragen. Woche fünf, die Betriebsschicht, ereignisgesteuertes Re-Indexing, Tenant-Filter, Kostenmetriken. Woche sechs, Härtung, Lasttests und Rollout hinter einem Feature-Flag.
Diese Reihenfolge liefert ein produktionsreifes RAG-System aus, das messbar gut, beobachtbar, kostenbewusst und auf einem Stack gebaut ist, den das bestehende Team betreiben kann. Es ist außerdem dramatisch günstiger zu warten als die LangChain-plus-verwaltete-Vektor-DB-plus-Python-Sidecar-Version, zu der die meisten Teams zuerst greifen.
Wenn du RAG-Architekturentscheidungen für ein reguliertes europäisches Produkt bewertest oder einen Prototyp rettest, der die Schwelle von "cooler Demo" zu "wir hängen davon ab und die Rechnung ist alarmierend" überschritten hat, ist das die Art von Arbeit, für die ein Projekt zur individuellen Softwareentwicklung gemacht ist. Kontaktiere uns unter hello@wolf-tech.io oder besuche wolf-tech.io, um dein RAG-System-Design zu besprechen.

