Blogs / Multi-Tenant RAG: Metadaten-Filterung ohne Datenlecks

Multi-Tenant RAG: Metadaten-Filterung ohne Datenlecks

Veröffentlicht
30. September 2026
Autor
Faizan Nadeem
Tags
RAG AI Engineering Backend Development System Design
Nahaufnahme eines Vorhängeschlosses, das eine Tür sichert
Photo by Kaffeebart on Unsplash

Ein Multi-Tenant-RAG-System hat einen Fehlermodus, den Single-Tenant-Systeme nicht haben: Es kann eine Antwort liefern, die flüssig, gut zitiert und völlig korrekt aussieht – und dabei leise die Daten eines anderen Kunden enthält. Das ist kein hypothetischer Grenzfall – es ist die direkte, vorhersehbare Konsequenz einer sehr verbreiteten Abkürzung, bei der die Tenant-Eingrenzung als Filter auf Suchergebnisse angewendet wird, statt als harte Einschränkung der Suche selbst. Die Vektorähnlichkeitssuche kennt das Konzept eines Tenants nicht; wenn du ihr nicht auf Index-Ebene sagst, was eine gegebene Abfrage sehen darf und was nicht, gibt sie fröhlich den ähnlichsten Vektor im gesamten Index zurück – egal, wem er gehört.

Die Lösung klingt einfach – „einfach nach Tenant-ID filtern" – und die Implementierungsdetails sind das Schwierige daran. Approximate-Nearest-Neighbor-Indizes (HNSW, IVFFlat) sind nicht mit Row-Level-Security im Sinn gebaut, das Filtern nach dem Retrieval statt davor erzeugt ein echtes Datenexpositionsrisiko, und aggressives Filtern für garantierte Isolation kann den Recall still und leise zusammenbrechen lassen, was wie ein Relevanzfehler aussieht statt wie der Sicherheits-Kompromiss, der es tatsächlich ist. Dieser Post behandelt Pre-Filterung versus Post-Filterung mechanisch, wo die Tenant-Isolation wirklich bricht, und wie man über die Recall-Kosten nachdenkt, die damit verbunden sind, es sicher zu machen.

Du lernst:

  • Warum Post-Filterung (zuerst suchen, dann Ergebnisse vom falschen Tenant verwerfen) ein echtes Datenexpositionsrisiko ist, nicht nur eine Ineffizienz
  • Den mechanischen Unterschied zwischen Pre-Filterung und Post-Filterung in einem ANN-Index und warum sie nicht austauschbar sind
  • Wie man harte Tenant-Isolation in Postgres mit pgvector und Row-Level-Security implementiert
  • Warum aggressive Pre-Filterung einen messbaren Recall-Kollaps verursachen kann und wie man diesen Kompromiss einschätzt
  • Index-per-Tenant versus geteilter Index mit Filterung, und wann welche Architektur die richtige ist
  • Eine konkrete Checkliste zur ĂśberprĂĽfung eines bestehenden Multi-Tenant-RAG-Systems auf Leckarisiken

Inhaltsverzeichnis

  1. Das eigentliche Risiko: Ergebnisse filtern vs. die Suche filtern
  2. Pre-Filterung vs. Post-Filterung, mechanisch
  3. Wo Tenant-Metadaten angehängt werden: Zurück zum Chunking
  4. Harte Tenant-Isolation mit pgvector und Row-Level-Security
  5. Der Recall-Kollaps: Was strenge Filterung kostet
  6. Index-per-Tenant vs. geteilter Index mit Filterung
  7. Ein bestehendes System auf Leckarisiken prĂĽfen
  8. Häufige Fallstricke
  9. Fazit

Das gefährliche Muster sieht auf den ersten Blick vernünftig aus: Führe eine Ähnlichkeitssuche über den gesamten Index durch, erhalte die Top-k-Ergebnisse, filtere dann alles heraus, das nicht zum anfragenden Tenant gehört, bevor du den Rest ans LLM übergibst.

# The unsafe pattern — do not use this for tenant isolation
def unsafe_search(query_embedding, tenant_id, k=5):
    results = vector_index.search(query_embedding, k=k)          # searches everything
    return [r for r in results if r.metadata["tenant_id"] == tenant_id]  # filters after

Der offensichtliche Bug ist, dass dies weniger als k Ergebnisse oder gar keine zurückgeben kann, wenn die Top-Treffer alle anderen Tenants gehören – das allein wäre Grund genug, es zu vermeiden. Aber das tiefere Problem ist, was in der Lücke zwischen „alles durchsuchen" und „danach filtern" passiert: Wenn diese Logik jemals einen Fehler hat – der Filter wird unter einem Fehlerpfad übersprungen, eine Caching-Schicht gibt Ergebnisse vor dem Filtern zurück, ein Logging-Statement erfasst die Rohantwort vor dem Filtern – wurden die privaten Daten eines anderen Tenants bereits in den Speicher abgerufen und befinden sich einen Fehler davon entfernt, beim falschen Kunden anzukommen. Post-Filterung macht die Tenant-Isolation zu einer Eigenschaft von Anwendungscode, der bei jeder einzelnen Anfrage korrekt ausgeführt werden muss, statt zu einer Eigenschaft der Suche selbst. Das ist eine wesentlich schwächere Garantie, und es ist der falsche Ort für eine Sicherheitsgrenze.

Pre-Filterung vs. Post-Filterung, mechanisch

Post-Filterung durchsucht zuerst den gesamten Index und verwirft danach nicht übereinstimmende Ergebnisse – das oben stehende Muster. Sein einziger Vorteil ist Implementierungseinfachheit, und es sollte für alles vermieden werden, wo Tenant-Isolation eine harte Anforderung ist statt einer weichen Relevanzpräferenz.

Pre-Filterung wendet die Tenant-Einschränkung vor oder während der Ähnlichkeitssuche an, sodass Vektoren anderer Tenants nie gegen die Abfrage verglichen werden – nicht abgerufen, nicht bewertet, nicht als Kandidat im Speicher vorhanden. Das ist das einzige Muster, das eine tatsächliche Sicherheitsgarantie statt einer Best-Effort-Garantie bietet.

Die mechanische Komplikation ist, dass Approximate-Nearest-Neighbor-Strukturen (HNSW-Graphen, IVFFlat-Cluster) dafür gebaut sind, den gesamten Index effizient zu traversieren – eine naive Schnittmenge von „nur Vektoren von Tenant X" mit „den nächsten Nachbarn in diesem Graph" ist nicht kostenlos. Abhängig von der Index-Implementierung wird echte Pre-Filterung auf eine von zwei Arten gehandhabt: gefiltertes Graph-Traversal, bei dem die ANN-Suche selbst nicht übereinstimmende Kandidaten während der Traversal überspringt (nativ unterstützt von einigen Vektordatenbanken und neueren pgvector-Versionen), oder metadaten-partitionierte Indizes, bei denen die Tenant-Grenze strukturell durchgesetzt wird – ein Index oder eine Partition pro Tenant, sodass es keinen Graph gibt, in den man versehentlich traversieren könnte.

Wo Tenant-Metadaten angehängt werden: Zurück zum Chunking

Tenant-Filterung kann nur so zuverlässig sein wie die Metadaten, die bei der Ingestion an jeden Chunk angehängt wurden – was bedeutet, dass dieses Problem lange vor dem Ausführen einer Suchabfrage beginnt. Wenn ein Dokument in Chunks aufgeteilt wird und ein tenant_id-Feld nicht an jeden einzelnen Chunk-Datensatz angehängt wird – nicht nur an das übergeordnete Dokument –, hat ein bei der Abfragezeit angewendeter Filter nichts Konsistentes, worauf er filtern kann. Das ist dieselbe Ingestions-Disziplin, die in Chunking-Strategien für RAG behandelt wird: Egal wie ein Dokument aufgeteilt wird, der Chunking-Schritt ist auch der natürliche Ort, jeden resultierenden Chunk mit seinen vollständigen Scoping-Metadaten zu stempeln, da das der letzte Punkt in der Pipeline ist, an dem der Chunk noch eindeutig mit seinem Quelldokument und Tenant verknüpft ist.

def chunk_with_tenant_metadata(document: dict, chunk_fn) -> list[dict]:
    chunks = chunk_fn(document["body"])
    return [
        {
            "text": chunk,
            "tenant_id": document["tenant_id"],
            "document_id": document["id"],
            "document_version": document["version"],
            "access_tier": document.get("access_tier", "default"),
        }
        for chunk in chunks
    ]

Das ist besonders wichtig beim hierarchischen Chunking, wo die Metadaten eines Kind-Chunks mit denen seines Elternteils übereinstimmen müssen – eine Pipeline, die tenant_id an den übergeordneten Datensatz anhängt, aber vergisst, sie an jeden Kind-Chunk zu propagieren, erzeugt genau die Art von Inkonsistenz, die dazu führt, dass eine gefilterte Abfrage still und leise Zeilen verpasst oder noch schlimmer, einen Kind-Chunk abruft, der nie gegen eine Tenant-Grenze geprüft wurde, weil das Feld, das er benötigte, einfach nicht vorhanden war.

Harte Tenant-Isolation mit pgvector und Row-Level-Security

FĂĽr ein Postgres-Setup mit geteilter Tabelle und geteiltem Index ist die sicherste Basislinie die Kombination eines WHERE-Klausel-Pre-Filters mit Postgres’s Row-Level-Security (RLS), sodass Tenant-Isolation auf Datenbankebene durchgesetzt wird, unabhängig davon, was der Anwendungscode korrekt tut oder vergisst zu tun:

ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON documents
    USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
-- set once per request/connection, before any query runs
SET app.current_tenant_id = '3fa85f64-5717-4562-b3fc-2c963f66afa6';

SELECT id, body, embedding <=> :query_embedding AS distance
FROM documents
ORDER BY embedding <=> :query_embedding
LIMIT 5;

Mit aktiviertem RLS sieht die obige Abfrage nie die Zeilen eines anderen Tenants – die Policy wird von Postgres selbst auf Zeilenebene durchgesetzt, bevor der Vektorabstand überhaupt berechnet wird. Das bedeutet, dass ein Anwendungscode-Fehler, der vergisst, eine WHERE tenant_id = ...-Klausel hinzuzufügen, dennoch keine Daten leaken kann, weil die Datenbankschicht die Grenze unabhängig durchsetzt. Das ist dasselbe Prinzip hinter Scoping-Metadaten als harten Pre-Filter statt einer nachträglichen Prüfung, angewendet speziell auf den Fall, wo die Scoping-Dimension eine Sicherheitsgrenze ist statt nur ein Relevanzsignal – die Mechaniken sind ähnlich, aber die Einsätze bei Fehlern sind kategorisch anders.

RLS fügt einen Pro-Abfrage-Overhead für die Policy-Evaluierung hinzu und benötigt, dass current_setting bei jeder Verbindung korrekt gesetzt ist – ein Connection-Pool, der Verbindungen zwischen Tenants wiederverwendet, ohne diese Einstellung bei jedem Checkout zurückzusetzen, führt genau das Leck-Risiko wieder ein, das RLS verhindern soll. Das muss also in das Connection-Lifecycle-Management integriert werden, nicht nur zum Schema hinzugefügt.

Der Recall-Kollaps: Was strenge Filterung kostet

Pre-Filterung nach Tenant ist notwendig, interagiert aber auf eine Weise schlecht mit ANN-Index-Approximation, die es wert ist, konkret zu verstehen, statt sie in der Produktion zu entdecken. HNSW und IVFFlat sind approximativ – sie tauschen ein kleines Maß an Recall gegen große Geschwindigkeitsgewinne, indem sie nicht jeden Vektor erschöpfend vergleichen, sondern stattdessen einen Graph oder eine Menge von Clustern traversieren, die gebaut wurden, um normalerweise die wahren nächsten Nachbarn zu finden, aber nicht immer.

Wenn die Daten eines Tenants ein kleiner Bruchteil eines großen geteilten Index sind, muss die gefilterte Suche härter arbeiten, um genug übereinstimmende Kandidaten zu finden, weil die Graph- oder Cluster-Struktur über den gesamten Index gebaut wurde, nicht für die Untermenge eines bestimmten Tenants abgestimmt. Im schlimmsten Fall – ein kleiner Tenant teilt sich einen Index mit viel größeren – kann eine gefilterte ANN-Suche weniger relevante Ergebnisse als erwartet oder niedrigwertigere Treffer zurückgeben, schlicht weil das approximative Traversal nicht genug Vektoren dieses Tenants innerhalb seines normalen Suchbudgets gefunden hat, bevor es stoppte.

Illustrative Zahlen aus einem kleinen ausgearbeiteten Test – ein geteilter HNSW-Index aus einem Spielzeug-Corpus aufgeteilt auf drei simulierte Tenants unterschiedlicher Größe, abgefragt mit derselben Recall@5-Methodik wie früher in dieser Serie, gefilterte Suche gegen den eigenen dedizierten Index jedes Tenants als Baseline verglichen:

TenantAnteil am geteilten IndexGefilterter Recall@5 (geteilter Index)Recall@5 (dedizierter Index)
GroĂź~80%0,860,88
Mittel~18%0,790,85
Klein~2%0,610,84

Das ist eine Illustration im Spielzeugmaßstab, kein allgemeines Benchmark, aber es demonstriert den realen Mechanismus klar: Der große Tenant bemerkt den geteilten Index kaum, während der kleine Tenant über zwanzig Punkte Recall verliert, schlicht weil er ein winziger Ausschnitt einer viel größeren approximativen Struktur ist. Filterung kostet nicht jeden Tenant gleich, und kleine Tenants in einem großen geteilten Index tragen den größten Teil der Recall-Kosten – was genau die Art von Regression ist, die eine einzelne aggregierte Recall-Zahl, gemittelt über alle Tenants, nie aufdecken würde.

Der wichtigste Fix ist, dies auf deiner eigenen Tenant-Verteilung zu messen statt es wegzuassumieren – das Erhöhen des ANN-Suchbudget-Parameters (ef_search in HNSW) bei der Abfragezeit tauscht etwas Latenz gegen wesentlich besseren gefilterten Recall, und es lohnt sich, ihn speziell für deine kleinsten Tenants zu tunen statt einen einzelnen globalen Standard für den Durchschnittsfall zu verwenden.

Index-per-Tenant vs. geteilter Index mit Filterung

Es gibt zwei strukturell verschiedene Architekturen, und die richtige Wahl hängt von der Tenant-Anzahl und der Tenant-Größenverteilung ab, nicht von einer allgemeinen Best Practice.

Geteilter Index mit Filterung (eine Tabelle, ein HNSW-Index, Tenant-ID als gefilterte Spalte) ist operativ einfacher – ein Index zum Bauen, Tunen und Warten – und funktioniert gut, wenn Tenants zahlreich und einzeln relativ klein sind, da ein dedizierter Index pro Tenant in diesem Maßstab das Warten von Tausenden kleiner Indizes mit bedeutendem Pro-Index-Overhead bedeuten würde.

Index-per-Tenant (eine separate Tabelle oder ein separater HNSW-Index pro Tenant, manchmal über separate Schemas oder separate physische Datenbanken) umgeht das Recall-Kollaps-Problem vollständig, da die ANN-Suche jedes Tenants nur die eigenen Daten traversiert – kein Approximationsmalus für ein kleiner Ausschnitt einer viel größeren Struktur zu sein. Es bietet auch die sauberste mögliche Isolationsgeschichte, da es überhaupt keine geteilte Struktur gibt, die falsch konfiguriert werden könnte. Die Kosten sind operativ: Provisioning, Migration und Monitoring eines Index pro Tenant skaliert nicht sauber über einige hundert Tenants hinaus ohne echte Infrastrukturinvestition.

Eine häufige Mitte für ein System mit einigen sehr großen Tenants und einem langen Schwanz kleiner: dedizierte Indizes für die großen Tenants (wo Recall-Kollaps am kostspieligsten wäre und der Overhead eines dedizierten Index leicht gerechtfertigt ist) und ein geteilter, RLS-geschützter Index für die kleineren Tenants, speziell für diese Verteilung in der Größe und im ef_search abgestimmt.

Ein bestehendes System auf Leckarisiken prĂĽfen

FĂĽr ein bereits in der Produktion befindliches System eine konkrete Checkliste:

  1. Trace genau, wo der Tenant-Filter angewendet wird, von Ende zu Ende, von der API-Anfrage bis zur tatsächlichen SQL- oder Vektorindex-Abfrage. Wenn der Filter im Anwendungscode nach einem Suchaufruf angewendet wird, ist das das Post-Filterungs-Muster – behandle es als Prioritätsfix, nicht als Stilpräferenz.
  2. Prüfe das Connection-Pooling-Verhalten gegen RLS oder sitzungsgesteuerte Einstellungen. Bestätige, dass current_setting (oder das Äquivalent) bei jedem Connection-Checkout zurückgesetzt wird, nicht nur bei der ersten Verbindung – ein veralteter Tenant-Kontext auf einer wiederverwendeten Verbindung ist ein echter Leck-Pfad.
  3. Teste mit einer adversariellen Abfrage. Erstelle absichtlich eine Abfrage, die wahrscheinlich eng mit dem Inhalt eines anderen Tenants übereinstimmt (ähnliche Produktnamen, ähnliche Formulierung) und bestätige null cross-Tenant-Ergebnisse, nicht nur „das Top-Ergebnis sah okay aus".
  4. Messe gefilterten Recall@k pro Tenant-Größen-Bucket, nicht nur aggregiert – eine globale Recall-Zahl kann gesund aussehen, während deine kleinsten Tenants still schlechter versorgt werden, genau das Versagen, das der obige illustrative Test demonstriert.
  5. Bestätige, dass Caching-Schichten den Tenant-Scope respektieren. Ein Cache, der nur auf dem Abfragetext ohne die Tenant-ID als Teil des Schlüssels basiert, kann die gecachten Ergebnisse eines Tenants einem anderen liefern – eine leicht zu übersehende Schwachstelle, wenn Caching nachträglich zur bestehenden Retrieval-Logik hinzugefügt wird.

Häufige Fallstricke

Tenant-Filterung als rein anwendungsseitige Angelegenheit behandeln. Wenn das einzige, was zwischen einer Abfrage und den Daten eines anderen Tenants steht, eine Zeile Anwendungscode ist, die durch ein Refactoring gelöscht oder umgangen werden kann, ist das eine fragile Garantie. Schiebe die Grenze nach unten zur Datenbank- oder Index-Schicht, wo die zugrundeliegende Technologie es unterstützt.

Gepoolte Verbindungen ohne Zurücksetzen des sitzungsgesteuerten Tenant-Kontexts wiederverwenden. RLS und current_setting sind nur so sicher wie der Connection-Lifecycle um sie herum – überprüfe explizit mit einem Test, dass eine neue Anfrage eines Tenants auf einer wiederverwendeten Verbindung nicht den Kontext des vorherigen Tenants erben kann.

ef_search (oder den entsprechenden ANN-Suchbudget-Parameter) einmalig global konfigurieren und nie pro Tenant überarbeiten. Ein Wert, der gegen das Datenvolumen deines größten Tenants abgestimmt ist, ist sehr wahrscheinlich zu gering für den gefilterten Recall deiner kleinsten Tenants, genau wie die obigen ausgearbeiteten Zahlen zeigen.

Davon ausgehen, dass eine aggregierte Recall-Metrik jeden Tenant fair repräsentiert. Der durchschnittliche Recall eines geteilten Index kann völlig gesund aussehen, während bestimmte Tenants – normalerweise die kleinsten – still schlechtere Ergebnisse bekommen. Teile Recall-Messungen nach Tenant-Größen-Bucket auf, nicht nur nach Gesamtdurchschnitt, genauso wie die Evaluierung einer RAG-Pipeline immer an den tatsächlich wichtigen Traffic-Mustern gemessen werden sollte, nicht an einem praktischen Aggregat.

Vergessen, dass Caching und Logging ebenfalls Tenant-Grenzen leaken können. Ein Cache-Schlüssel ohne Tenant-Komponente oder ein Debug-Log, der Pre-Filter-Suchergebnisse erfasst, kann eine ansonsten korrekte Filterimplementierung zunichte machen. Auditiere den gesamten Request-Pfad, nicht nur die Abfrage selbst.

Fazit

Tenant-Isolation in RAG ist kein Filterungsproblem, das du einmalig im Anwendungscode löst – es ist eine Eigenschaft, die strukturell durchgesetzt werden muss, auf der Datenbank- oder Index-Schicht, sodass eine einzige fehlende WHERE-Klausel oder ein vergessener Filter-Schritt nicht zu einem Datenleck werden kann. Pre-Filterung, Row-Level-Security und (für deine größten oder sensitivsten Tenants) dedizierte Indizes sind die Mechanismen, die Isolation zu einer Garantie statt einer Konvention machen. Und sobald die Isolation stabil ist, messe die eingeführten Recall-Kosten pro Tenant, nicht nur aggregiert – die Tenants, die am wahrscheinlichsten still von einem geteilten, gefilterten Index schlechter versorgt werden, sind genau die kleinsten, was normalerweise der letzte Ort ist, an dem jemand nachschaut.

Faizan Nadeem
Autor
Faizan Nadeem

AI/GenAI engineer specialising in agentic RAG, LangGraph, and production LLM systems with Python and FastAPI. 2 years building AI agents, RAG pipelines, and the backends behind them.

Weitere Artikel