RAG
RAG tenant leakage: anatomie van een 7-uurs incident
Om 10:40 op een dinsdag vroeg een Nederlandse expediteur waarom onze agent een tarievenblad van een concurrent citeerde. Het antwoord was een bucket-rename die niemand meldde.

Om 10:40 op een dinsdag stuurde een accountmanager bij een Nederlandse expediteur ons een Slack-screenshot door. Hun interne RAG-agent, die we zes weken eerder hadden opgeleverd om vragen als "wat is ons tarief van Tilburg naar Lyon voor een 13,6m trailer" te beantwoorden, had hen net tarieven van een concurrent geciteerd. Geen vergelijkbare tarieven. Letterlijk overgenomen uit een PDF van een concurrent, inclusief het bronvermeldingsblok.
De klant was beleefd. Ze wilden weten of het een eenmalig incident was, of dat hun eigen tarieven de andere kant op werden gelekt. We zeiden dat we het binnen een uur zouden weten. Het werden er zeven.
Wat volgt is wat er gebeurde, in de volgorde waarin we het begrepen, met de code die we daarna hebben aangepast. De bug was niet exotisch. De lessen zijn niet nieuw. Het zijn wel het soort lessen dat door elk team dat ze heeft meegemaakt in design docs wordt opgeschreven, en door elk team dat ze niet heeft meegemaakt wordt overgeslagen.
De architectuur, voordat er iets stuk ging
Het systeem was een vrij typische retrieval-augmented opzet, het soort opstelling dat je bij elke middelgrote B2B-kennisagent tegenkomt.
Eén S3-bucket per tenant, met de bron-PDFs van de klant: tarievenbladen, SLA-addenda, routehandboeken, douanedocumentatie. Een sync worker die S3-events via SQS in de gaten hield, elk nieuw of bijgewerkt object chunkte en embedde, en vervolgens upsertte in een gedeelde Pinecone-index met de tenant-ID gestempeld op de metadata van elke chunk. Een retrieval service die de tenant uit de JWT las en de index-query filterde op metadata.tenant voordat de top-k chunks aan het LLM werden doorgegeven. Volstrekt standaard.
De bucket-naar-tenant mapping stond in een YAML-bestand dat bij het opstarten van de worker werd geladen:
BUCKET_TO_TENANT = {
"vondel-logistiek-docs": "vondel",
"rijnpoort-cargo-docs": "rijnpoort",
"kanaal-zuid-shipping-docs": "kanaalzuid",
# ...
}
def resolve_tenant(bucket_name: str) -> str:
return BUCKET_TO_TENANT.get(bucket_name, "general-knowledge")
Die fallback is de eerste helft van het verhaal. Op de tweede helft komen we zo terug.
Er was ook een namespace in de index met de naam general-knowledge. De bedoeling was redelijk: een gedeelde laag waarin we echt publieke documenten indexeerden, zoals Incoterms 2020-referenties, CMR-vrachtbrief-toelichtingen en de FENEX-algemene voorwaarden. De retrieval service nam die altijd mee in het filter, zodat de agent generieke logistieke vragen kon beantwoorden zonder dat elke tenant dezelfde publieke referenties opnieuw moest uploaden:
results = index.query(
vector=question_embedding,
top_k=8,
filter={"tenant": {"$in": [tenant_id, "general-knowledge"]}},
)
Dit patroon is gangbaar. De Pinecone-documentatie over metadata-filtering toont $in als de standaardmanier om tenants met een gedeelde laag te combineren. We hadden niets vreemds verzonnen. Wel hadden we twee fouten in serie ingebouwd, en op dinsdagochtend kwamen die op één lijn te liggen.
De rename
De IT-lead van de klant mailde ons on-call kanaal om 09:14: "We hernoemen onze docs-bucket van vondel-logistiek-docs naar vondel-knowledge-2026, willen jullie je kant bijwerken."
S3 kent geen rename. Je maakt een nieuwe bucket en kopieert. De IT-lead van de klant deed precies dat. Hun interne sync-tool begon om 09:14 met het verplaatsen van objecten en was rond 09:22 klaar. Vanuit S3 gezien waren dat geen hernoemde bestanden. Het waren gloednieuwe objecten in een gloednieuwe bucket, die elk een ObjectCreated-event afvuurden.
Onze SQS-subscription was binnen enkele minuten omgezet naar de nieuwe bucket. Dat was het deel waar de IT-lead om vroeg, en dat hebben we afgehandeld zoals je zou verwachten. Wat niemand bijwerkte, was de BUCKET_TO_TENANT-map in onze worker-config. De worker begon honderden ObjectCreated-events voor vondel-knowledge-2026 te ontvangen, zocht ze op in een dict die nog steeds vondel-logistiek-docs vermeldde, miste elke keer, en viel terug op de default. Elke tarieven-PDF die de klant ooit had geüpload, werd opnieuw geëmbed in general-knowledge.
Als je tenant resolver een default branch heeft, is hij één storing verwijderd van je datalek-vector. Fail closed. Een onbekende bucket is een exception, geen namespace.
Hoe een PDF van een concurrent al in de gedeelde namespace stond
Het opnieuw indexeren van de documenten van één klant in general-knowledge was op zichzelf al erg. Maar de namespace was niet leeg op de manier waarop wij dachten.
Acht maanden eerder, tijdens een prospect-pitch bij een andere expediteur, had een van onze engineers met de hand de publieke tarievenbrochure van die prospect in general-knowledge geüpload, om de ranking-kwaliteit af te zetten tegen de Incoterms-baseline. Het was een experiment van vijf minuten. De PDF had na de demo verwijderd moeten worden. Dat is niet gebeurd. Hij stond sindsdien in de gedeelde namespace, door geen enkele agent aangeraakt, omdat geen enkele productietenant ooit naar de specifieke tarieven van dat bedrijf had gevraagd.
Tot 10:38, toen de accountmanager van Vondel hun agent vroeg: "wat is ons tarief Tilburg naar Lyon, 13,6m trailer, wekelijks". De query embedde dicht bij drie dingen tegelijk:
- Vondels eigen tarievenblad van 2026, vers opnieuw geëmbed onder
general-knowledgedoor de kapotte worker. - Een Incoterms 2020-toelichting, de rechtmatige bewoner van de gedeelde laag.
- De brochure van de concurrent uit de acht maanden oude demo.
De brochure van de concurrent had nettere sectie-headers, het soort "Tarieven binnenland en intra-EU, per kilometer en per stop"-structuur dat embedt in strakkere clusters en hoger rankt onder cosine similarity dan Vondels eigen, minder gestructureerde interne handboek. De agent koos hem. Het bronvermeldingsblok onderaan het antwoord vermeldde "Bron: Tarieven 2024, [Naam Concurrent]". De accountmanager maakte er een screenshot van. Vier minuten later landde het Slack-bericht in onze inbox.
De tijdlijn van zeven uur
We schrijven geen postmortems met uur-na-uur drama, maar de vorm is wel het tonen waard, want het grootste deel van die zeven uur ging niet op aan fixen. Het ging op aan vinden.
- 10:40. Ticket komt binnen. We bevestigen binnen 90 seconden.
- 10:55. Eerste hypothese: embedding drift door een recente modelwissel. Fout. We hadden in geen maand een model gewisseld.
- 11:30. We bevestigen dat het bronvermeldingsblok echt is. De bron-PDF staat in
general-knowledge. Vraag: hoe is hij daar gekomen? - 12:15. Audit log laat zien dat de PDF acht maanden geleden is geüpload door een inmiddels vertrokken engineer. We markeren hem voor quarantaine, maar begrijpen nog steeds niet waarom de query van Vondel hem bereikte.
- 13:10. De IT-lead van de klant noemt de bucket-rename "eerder vandaag" in een follow-up over iets anders. Het kwartje valt.
- 13:45. We traceren de resolver en vinden de fallback. We stoppen de worker.
- 14:30. Volledige audit van welke Vondel-chunks naar
general-knowledgewaren geschreven: 1.847 vectoren. We verwijderen ze via een metadata-filter. - 15:10. Brochure van de concurrent definitief verwijderd. Audit bevestigt dat geen enkele andere tenant hem ooit had opgehaald.
- 16:14. Volledige resync vanuit
vondel-knowledge-2026naar de juiste namespace. Smoke tests slagen. Worker weer aangezet. - 17:38. Geschreven postmortem afgeleverd bij de klant. Ze antwoorden: "Dank, dat was snel", waar we blij mee waren en wat we niet verdiend hadden.
Wat in die tijdlijn opvalt, is het gat tussen 11:30 en 13:10. We hebben een uur en veertig minuten besteed aan het behandelen van het symptoom (een vervuilde gedeelde namespace) zonder de trigger te begrijpen (de bucket-rename). De klant noemde de rename terloops, bijna als small talk. Hadden ze dat niet gedaan, dan hadden we de rest van de dag op de verkeerde plek gezocht.
Wat we hebben veranderd
Drie wijzigingen uitgerold binnen 48 uur. Geen ervan is slim. Het zijn allemaal dingen die we tijdens het ontwerp al hadden moeten doen.
De resolver faalt closed
Geen default namespace. Een onbekende bucket gooit een exception, de worker logt, het bericht keert terug naar SQS, en on-call krijgt een page. De kosten van een handvol false-positive pages in het komende jaar zijn een fractie van de kosten van één echt lek.
class UnknownBucketError(Exception):
pass
def resolve_tenant(bucket_name: str) -> str:
try:
return BUCKET_TO_TENANT[bucket_name]
except KeyError as e:
raise UnknownBucketError(
f"No tenant mapping for bucket {bucket_name!r}. "
f"Add it to config or quarantine the message."
) from e
Het retrieval-filter is strikt
We hebben de general-knowledge namespace afgeschoten. Elke tenant heeft nu een privé-kopie van het gedeelde corpus: de Incoterms-referenties, de CMR-toelichtingen, de FENEX-voorwaarden. De opslagkosten stegen met ongeveer veertig euro per tenant per maand. Dat is, ronduit, de prijs van het niet draaien van een cross-tenant union in een retrieval-filter. Die betalen we met plezier.
results = index.query(
vector=question_embedding,
top_k=8,
filter={"tenant": {"$eq": tenant_id}}, # exact, not $in
)
Betrap je jezelf erop dat je een tenant-filter verbreedt met $in of $or, behandel het dan als een security review, niet als een feature. OWASP noemt deze klasse problemen in de LLM Top 10 als LLM06: Sensitive Information Disclosure. Het is de makkelijkste bug om te schrijven en de moeilijkste om in code review te zien, omdat de code leest als correct Engels.
De bucket is zijn eigen map
We hebben de YAML-mapping volledig verwijderd. Elke bucket draagt zijn tenant-ID nu als S3-tag, en de worker leest die tag in plaats van een sidecar-configbestand. Een bucket hernoemen zonder hem opnieuw te taggen kan nu fysiek geen verkeerde routering meer veroorzaken, want een ongetagde bucket faalt closed bij de resolver. De map en de bucket zijn hetzelfde artefact, wat betekent dat ze niet uit elkaar kunnen lopen.
Adversarial retrieval-tests, eindelijk
De bug die ons beet was met één enkele test te vangen geweest: stel een vraag die alleen matcht met de PDF van de concurrent, en assert vervolgens dat retrieval nul resultaten teruggeeft voor tenant Vondel. Die test hadden we niet. We hadden functionele tests ("antwoordt de agent correct?") maar geen negatieve tests ("weigert de agent op te halen uit documenten die hij nooit mag zien?").
Dit is hetzelfde gat waar recent open-source werk rond AI-gedreven kwetsbaarheidsonderzoek op aanvalt: adversarial probes draaien tegen een systeem om de faalmodes te vinden die mensen in review missen. Voor RAG is het equivalent een kleine fuzz harness. Genereer vragen die gekoppeld zijn aan elk stuk out-of-tenant content. Query het systeem als elke tenant. Assert dat de verkeerde tenant het nooit ophaalt. We draaien er nu één elke nacht, over alle tenants en het volledige gedeelde corpus. In de eerste week kwamen er twee andere latente issues bovendrijven, beide klein, beide het fixen waard voordat ze groter werden.
Elk RAG-systeem dat per tenant filtert, heeft een test nodig die bewijst dat het filter standhoudt. Zonder die test vertrouw je op een metadata-veld dat je niet kunt zien.
Het kleinste wat je vandaag kunt doen
Open het bestand in je RAG-codebase dat een binnenkomende request vertaalt naar een retrieval-filter. Lees het regel voor regel. Zit er een branch in die het filter verbreedt, een $in, een $or, een fallback naar een "shared" of "default" of "public" namespace, schrijf dan vandaag één test die assert dat de verbreding niet bij data van een andere tenant kan komen. De test hoeft niet geavanceerd te zijn. Hij moet bestaan.
Toen we de RAG-laag voor deze expediteur bouwden, liepen we tegen een architectuur aan die er stilletjes van uitging dat buckets stabiel waren en dat gedeelde namespaces te vertrouwen waren. We losten het op door beide aannames te schrappen en de worker hard te laten falen wanneer de wereld hem verrast.
Kern
Een bucket-rename legde twee latente fouten bloot: een tenant resolver met een default fallback, en een retrieval-filter dat tenants combineerde met een gedeelde namespace.
FAQ
Hoe kan een PDF van een concurrent in de RAG-antwoorden van een klant terechtkomen?
Meestal via een gedeelde namespace die retrieval combineert met het tenant-filter, plus een stuk content dat per ongeluk in die namespace is beland. Beide zijn op te lossen, en op beide moet je testen.
Verplaatst het hernoemen van een S3-bucket de objecten?
Nee. S3 kent geen rename. Je maakt een nieuwe bucket en kopieert. Alles downstream dat aan de oude bucketnaam hangt, ziet de nieuwe niet, tenzij je het expliciet bijwerkt.
Is een gedeelde "general knowledge" namespace in RAG altijd een slecht idee?
Niet altijd, maar wel riskant. Houd je er een, zet dan een review voor writes en draai 's nachts tests die asserten dat geen enkele tenant er een document uit haalt dat ze niet zouden moeten zien.
Wat kostten die zeven uur eigenlijk?
Eén klant met een gelekte concurrent-citatie in één agent-antwoord, geen tweerichtingsverkeer aan tariefdata, en een week aan vertrouwen om opnieuw op te bouwen. De financiële schade was klein. Het reputatierisico niet.