Produktionsreife RAG-Systeme bauen: ein Symfony-+-pgvector-Architektur-Blueprint
Ein Prototyp für Retrieval-Augmented Generation ist eine Sache eines Nachmittags. Ein RAG-System, das echte Nutzer, echte Dokumente und echte Kostendisziplin überlebt, braucht ein Quartal. Die Lücke zwischen diesen beiden Dingen ist dort, wo fast jedes B2B-KI-Feature, das 2025 ausgeliefert wurde, still unterdurchschnittlich abschnitt — falsche Chunks abgerufen, irrelevante Zitate, Latenzspitzen, wenn Korpora über ein paar Tausend Dokumente wuchsen, und OpenAI-Rechnungen, die der Finanzabteilung nicht erklärt werden konnten. Nichts davon ist ein Modellproblem. Es ist ein Architekturproblem.
Dieser Beitrag ist ein konkreter Symfony-+-pgvector-Blueprint für produktionsreifes RAG: die Pipeline-Form, das Datenbankschema, die Retrieval-Logik, die Index-Strategie und die operativen Leitplanken, die eine Demo von einem System trennen, das du einem zahlenden Enterprise-Kunden vorsetzen kannst. Der Stack ist bewusst langweilig — PostgreSQL mit pgvector, Symfony für die Anwendungsschicht, ein Embeddings-Modell hinter einer Schnittstelle, ein LLM hinter einer weiteren. Langweilig skaliert; clever nicht.
Was "produktionsreif" für RAG tatsächlich bedeutet
Vor jedem Code: lege die Definition fest. Ein produktionsreifes RAG-System hat vier Eigenschaften, die die typische Demo nicht hat. Es hat messbare Retrieval-Qualität — ein eingefrorenes Evaluationsset mit antwortstützenden Dokumenten, in der CI ausgeführt, mit einer Recall@k-Zahl, die niemand ohne schriftliche Begründung verschlechtern darf. Es hat begrenzte Kosten pro Query — ein Token-Budget, eine Obergrenze für die Kontextgröße und einen Circuit Breaker, der sich weigert, das LLM aufzurufen, wenn der Retrieval-Schritt nichts Brauchbares zurückgibt. Es hat Observability — jede Query loggt die abgerufenen Chunk-IDs, die Reranker-Scores, die Prompt-Größe und die Modelllatenz, pro Mandant abfragbar. Und es hat eine menschenlesbare Antwort auf "Warum hat das Modell das gesagt?" — zitatverfolgte Outputs, die das Support-Team debuggen kann, ohne Vektor-Embeddings zu lesen.
Alles, was auf dieser Liste fehlt, ist technische Schuld, die sich in dem Moment summiert, in dem dein Korpus zehntausend Dokumente überschreitet oder deine Nutzung tausend Anfragen pro Tag überschreitet. Ein ernsthaftes Code-Quality-Audit eines RAG-Features in der Frühphase findet fast immer mindestens drei der vier abwesend.
Die Referenzarchitektur
Der Blueprint hat vier Laufzeitkomponenten und eine Offline-Pipeline. Die Ingestion-Pipeline liest Quelldokumente, chunkt sie, embeddet die Chunks und schreibt sie in pgvector. Die Symfony-Anwendung nimmt eine Nutzer-Query entgegen, embeddet sie, führt hybrides Retrieval gegen pgvector und einen Volltextindex aus, reranked die Ergebnisse, baut einen begrenzten Prompt, ruft das LLM auf und gibt eine zitierte Antwort zurück. PostgreSQL ist die einzige zustandsbehaftete Komponente — kein Pinecone, kein Weaviate, kein separater Suchcluster. Für Korpora bis zu rund zehn Millionen Chunks auf Standardhardware ist das eine vertretbare Voreinstellung und eine erhebliche operative Vereinfachung.
Das Schema ist klein genug, um auf einen Bildschirm zu passen:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
source_uri TEXT NOT NULL,
title TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE chunks (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL,
ord INT NOT NULL,
content TEXT NOT NULL,
content_tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
embedding vector(1024) NOT NULL,
token_count INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX chunks_embedding_hnsw
ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
CREATE INDEX chunks_tsv_gin ON chunks USING gin (content_tsv);
CREATE INDEX chunks_tenant ON chunks (tenant_id);
Zwei Dinge sind in diesem Schema jenseits des Offensichtlichen wichtig. Die Spalte tenant_id wird überallhin propagiert, weil Mandantenfähigkeit auf Zeilenebene der einzig vernünftige Weg ist, die Dokumente eines Kunden davon abzuhalten, in das Retrieval eines anderen zu lecken — Filterung auf Anwendungsebene reicht nicht, wenn eine Indexer-Query eine WHERE-Klausel vergisst. Und die Spalte content_tsv gibt uns einen kostenlosen Volltextindex, der dem Retrieval erlaubt, Vektorähnlichkeit mit klassischem BM25-artigem Keyword-Matching zu kombinieren, was den Recall bei Queries mit Produktnamen, Fehlercodes oder Eigennamen, bei denen Embeddings allein schwächeln, deutlich verbessert.
Die Ingestion-Pipeline
Ingestion ist dort, wo die meisten RAG-Systeme still scheitern. Schlechte Chunks in diesem Stadium können durch keine noch so geschickte Retrieval-Cleverness flussabwärts gerettet werden. Drei Entscheidungen dominieren.
Chunk-Größe und Overlap. Ziel sind 512–1.024 Token-Chunks mit 10–15 % Overlap. Kleinere Chunks verbessern die Präzision, fragmentieren aber den Kontext; größere Chunks verschwenden Tokens auf irrelevanten Text. Nutze einen strukturbewussten Splitter — splitte zuerst an Überschriften, dann an Absätzen, dann an Sätzen, und falle nur auf Fenster fester Größe zurück, wenn das Dokument keine nützliche Struktur hat. Rein nach Zeichenanzahl zu splitten ist der häufigste Bug in ausgelieferten RAG-Systemen.
Metadaten-Extraktion. Jeder Chunk sollte den Titel des Elterndokuments, die Abschnittsüberschrift, die Quell-URL und alle mandantenrelevanten Filter (Produkt, Region, Datum) in metadata tragen. Diese Felder steuern später gefiltertes Retrieval. Ein Chunk ohne Metadaten ist in einem Multi-Produkt-Korpus fast nutzlos.
Wahl des Embedding-Modells. Wähle eines und friere es ein. Das Embedding-Modell ist Teil deiner Daten, nicht deines Codes — es zu wechseln erfordert ein Neu-Embedden des gesamten Korpus. Für europäische Deployments sind die praktischen Optionen 2026 OpenAI text-embedding-3-large, Cohere embed-multilingual-v3 oder ein selbst gehostetes bge-m3 für vollständig On-Prem-Anforderungen. Welches du auch wählst, abstrahiere es hinter einer Schnittstelle, damit die Wahl zu Refactor-Kosten statt zu Neuschreib-Kosten umkehrbar ist.
Ein Symfony-Ingestion-Command sieht so aus:
#[AsCommand(name: 'rag:ingest')]
final class IngestCommand extends Command
{
public function __construct(
private readonly DocumentLoader $loader,
private readonly Chunker $chunker,
private readonly EmbeddingClient $embeddings,
private readonly ChunkRepository $chunks,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$sourceUri = $input->getArgument('source');
$tenantId = $input->getArgument('tenant');
$document = $this->loader->load($sourceUri);
$chunks = $this->chunker->split($document, maxTokens: 800, overlap: 100);
// Batch-Embedding - 96 Chunks pro Request sind der Sweet Spot fuer OpenAI.
foreach (array_chunk($chunks, 96) as $batch) {
$vectors = $this->embeddings->embedBatch(
array_map(fn ($c) => $c->content, $batch)
);
foreach ($batch as $i => $chunk) {
$this->chunks->save($chunk->withEmbedding($vectors[$i], $tenantId));
}
}
return Command::SUCCESS;
}
}
In Produktion läuft dieser Command innerhalb einer Messenger-Queue mit Retries und Idempotenz-Schlüsseln; Ingestion-Fehler sind normal, und ein halb eingelesenes Dokument darf bis zum Abschluss nicht abrufbar sein.
Hybrides Retrieval und Reranking
Reine Vektorsuche verliert auf realen B2B-Korpora fast jedes Mal gegen hybrides Retrieval. Die Implementierung in pgvector sind zwei SQL-Queries plus ein Fusionsschritt:
public function retrieve(string $query, string $tenantId, int $k = 20): array
{
$queryVector = $this->embeddings->embed($query);
$semantic = $this->db->fetchAllAssociative(
'SELECT id, content, document_id,
1 - (embedding <=> :v) AS score
FROM chunks
WHERE tenant_id = :tenant
ORDER BY embedding <=> :v
LIMIT :k',
['v' => $queryVector, 'tenant' => $tenantId, 'k' => $k * 2]
);
$lexical = $this->db->fetchAllAssociative(
"SELECT id, content, document_id,
ts_rank_cd(content_tsv, plainto_tsquery('english', :q)) AS score
FROM chunks
WHERE tenant_id = :tenant
AND content_tsv @@ plainto_tsquery('english', :q)
ORDER BY score DESC
LIMIT :k",
['q' => $query, 'tenant' => $tenantId, 'k' => $k * 2]
);
// Reciprocal Rank Fusion - die einfachste Fusion, die konsistent funktioniert.
$fused = $this->fuse($semantic, $lexical, k: 60);
return $this->reranker->rerank($query, array_slice($fused, 0, $k));
}
Der Reranker ist ein Cross-Encoder-Call (Cohere Rerank, Voyage Rerank oder ein selbst gehosteter bge-reranker), der jeden Kandidaten-Chunk einzeln gegen die Query bewertet. Die Top-20-Kandidaten auf die Top 5 herunterzureranken hebt die Retrieval-Qualität typischerweise um 15–30 Punkte auf Standard-Evaluationssets. Es kostet eine Millisekunde und einen Bruchteil eines Cents und ist die wirkungsvollste einzelne Änderung, die du nach der hybriden Suche vornehmen kannst.
Index-Strategie: HNSW versus IVF
pgvector unterstützt zwei Indextypen, und die Wahl hat echte Konsequenzen. HNSW (Hierarchical Navigable Small World) bietet den besten Recall-Latenz-Tradeoff für Korpora bis zu rund zehn Millionen Vektoren und ist die richtige Voreinstellung. IVFFlat baut schneller und nutzt weniger Speicher, opfert aber spürbaren Recall, und es neu zu bauen, während der Korpus wächst, ist mühsam. Nutze HNSW mit m = 16 und ef_construction = 200 als Ausgangspunkt und tune ef_search pro Query, wenn das Latenzbudget es erlaubt. Benchmarke immer auf deinen eigenen Daten — veröffentlichte Benchmarks sagen sehr wenig über deinen Korpus aus.
Für Korpora über zehn Millionen Chunks verschiebt sich das Gespräch. Entweder partitionierst du die Tabelle nach Mandant oder Domäne (was HNSW pro Partition handhabbar hält), oder du verlagerst die Vektor-Last von PostgreSQL auf einen dedizierten Store. Beides sind echte Optionen; die bequeme Wahl ist, anzunehmen, dass du den dedizierten Store brauchst, bevor du gemessen hast, ob du es tust.
Kosten, Observability und Evaluation
Drei operative Disziplinen verwandeln ein funktionierendes RAG in ein vertretbares Produkt. Kostenobergrenzen gehören in Code: eine maximale Kontextgröße pro Query, ein Tagesbudget pro Mandant, ein Fallback, der eine deterministische "keine Antwort mit ausreichender Konfidenz"-Antwort zurückgibt, wenn die Retrieval-Scores unter eine Schwelle fallen. Observability gehört auf die Chunk-Ebene: logge abgerufene Chunk-IDs, Fusionsränge, Reranker-Scores, finale Prompt-Token-Anzahl und Modelllatenz für jede Query, abfragbar nach Mandant und Zeitfenster. Evaluation gehört in die CI: ein kleines eingefrorenes Set von Frage-und-Stützdokument-Paaren, ausgeführt bei jeder Änderung des Embedding-Modells oder der Chunking-Strategie, mit einer harten Untergrenze für Recall@5, die Merges blockiert.
Die Teams, die RAG 2026 richtig hinbekommen, sind die, die diese drei Dinge vom ersten Tag an als nicht optional behandeln, statt sie nachzurüsten, nachdem die erste Beschaffungsfrage gelandet ist. Die Architektur oben ist, wie es aussieht, wenn sie es tun — langweilige Datenbank, instrumentierte Pipeline, Evaluation in der Versionsverwaltung und ein LLM hinter einer Schnittstelle, die dünn genug ist, dass sein Austausch eine Änderung von einem Tag statt einem Quartal ist.
Wenn du ein RAG-Feature für ein B2B-Produkt skopst und ein zweites Paar Augen auf die Architektur möchtest, bevor der erste Commit landet, ist das genau die Art von Sache, für die unsere Custom-Software-Entwicklung-Praxis gebaut ist. Kontaktiere uns unter hello@wolf-tech.io oder besuche wolf-tech.io — achtzehn Jahre europäische Softwarearbeit, einschließlich substanzieller KI-Integrations- und Symfony-Architektur-Engagements, stehen hinter jeder Empfehlung, die wir geben.

