RAG
Supabase RAG-audit: onze checklist voordat we offreren
Het is dinsdag 14:00 in Utrecht. Een CTO geeft ons read-only Supabase-credentials en vraagt een prijs. Voor we een RAG-retrofit offreren, auditen we.

Het is dinsdag 14:00 in Utrecht. De CTO van een logistieke SaaS van 40 man geeft ons read-only credentials voor hun Supabase-project en vraagt om een prijs. Ze willen een RAG-agent over hun supportarchief van 11.000 documenten, gescheiden per tenant, live in Q3. Voor we offreren, draaien we een audit. Geen demo. Geen 'laten we even een n8n-flow opzetten.' Een audit, want een RAG-retrofit op een cluster dat je niet zelf hebt gebouwd is bijna altijd gevaarlijker dan een greenfield-project: de bugs zitten er al, alleen slapend, wachtend op een auth.uid() die je vergeten bent toe te voegen.
Dit is de checklist die we gebruiken. Een senior engineer is er ongeveer vier uur mee bezig en ze heeft ons, voorzichtig geschat, drie rebuilds bespaard.
Waarom we auditen voor we offreren
Een RAG-agent-retrofit staat op drie dingen waarvan de klant meestal denkt dat ze al werken: row-level security, embedding-lineage en tenant-isolatie in back-ups. Geen van die drie werkt doorgaans zoals het team zich herinnert.
Als we offreren zonder te auditen, prijzen we óf een spookrebuild in en verliezen we de deal, óf we prijzen het happy path en eten we de rebuild op uit onze marge. Allebei geen verdienmodel. Daarom is de audit zelf de deal. We rekenen een vast bedrag voor een geschreven rapport, en als de klant ons de build gunt, verrekenen we dat bedrag.
De audit heeft drie bewegingen. RLS-drift. Embedding-versionering. Back-up-integriteit.
De RLS-drift-sweep op de top 25 tabellen
De eerste beweging is mechanisch. We lijsten de 25 grootste tabellen in het public-schema op rij-aantal, en checken voor elk vier dingen:
- Staat RLS aan?
- Verwijst minstens één policy naar
auth.uid()of eentenant_id-lookup? - Bestaat er een
service_role-policy die de tenant-filter omzeilt? - Is er een policy recenter vervangen dan de DDL van de tabel zelf?
Die laatste is degene waar mensen op betrapt worden. Een team zet RLS aan in februari, levert een feature op in april, en ergens daartussenin hernoemt een junior een policy van tenant_isolation naar read_own_rows en laat per ongeluk de WITH CHECK-helft vallen. De select-kant blijft werken. De insert-kant krijgt een gat.
We draaien dit met één query. Hier is degene die we klanten aan het eind van het traject meegeven, direct uitvoerbaar:
-- top 25 public tables and their RLS posture
WITH sized AS (
SELECT c.oid, n.nspname, c.relname,
pg_total_relation_size(c.oid) AS bytes
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r' AND n.nspname = 'public'
ORDER BY bytes DESC
LIMIT 25
)
SELECT s.relname,
c.relrowsecurity AS rls_on,
c.relforcerowsecurity AS rls_forced,
COUNT(p.polname) FILTER (
WHERE p.polqual::text ILIKE '%tenant_id%'
OR p.polqual::text ILIKE '%auth.uid%'
) AS tenant_policies,
COUNT(p.polname) FILTER (
WHERE p.polroles::text ILIKE '%service_role%'
) AS svc_policies
FROM sized s
JOIN pg_class c ON c.oid = s.oid
LEFT JOIN pg_policy p ON p.polrelid = c.oid
GROUP BY s.relname, c.relrowsecurity, c.relforcerowsecurity
ORDER BY rls_on, tenant_policies;
Daarna scoren we. RLS uit op een tabel met gebruikersdata: rood. RLS aan maar nul tenant-bewuste policies: rood. RLS aan, tenant-policy aanwezig, maar een service_role-policy die selecteert zonder WHERE: geel, want een RAG-agent die draait als service_role mengt stilletjes tenants zodra iemand vergeet auth.uid() door een SECURITY DEFINER-functie te halen.
We zien het patroon van geel-wordt-rood bij ongeveer zes van de laatste tien audits. De Supabase-docs zijn duidelijk dat policies standaard permissive zijn en elke matchende policy toegang verleent, maar in de praktijk leest het team 'RLS staat aan' als 'we zijn veilig' en stopt daar.
Als jouw RAG-agent 'voor het gemak' verbindt als service_role, moet elke opgehaalde chunk zijn eigen tenant_id meedragen door de filter op SQL-niveau, niet op promptniveau. Een model negeert vrolijk een system instruction. Postgres negeert geen WHERE-clausule.
Embedding-modelversionering tegen het AVG-herindexvenster
De tweede beweging is degene waar Nederlandse teams meestal niet over hebben nagedacht. De AVG, met de UAVG als uitvoeringswet, geeft een betrokkene het recht op vergetelheid. Voor een RAG-systeem betekent dat één delete moet doorwerken naar de bronregel, elke embedding-rij die ernaar verwijst, elke cached chunk, en elke vector die in een back-up is beland die ouder is dan je bewaartermijn.
Als je embeddingmodel versioned is (en na de overstap naar text-embedding-3-large in 2024 en de open-weights-wisselingen van 2025 hoort dat zo te zijn) moet je ook herindexering aankunnen. Modellen driften. Dimensies veranderen. We hebben inmiddels drie productieclusters opnieuw moeten indexeren omdat het team upgradde van text-embedding-3-small (1536d) naar een 3072d-model en vergat dat de pgvector-index hard-coded op de oude dimensie stond.
De audit checkt hier twee dingen:
- Staat er een
embeddings_model-kolom op de vectortabel, of een aparte lineage-tabel diechunk_id→model_name→model_version→embedded_atmapt? - Wat is het worst-case herindexvenster voor het hele corpus, en past dat met marge binnen de 30 dagen die de AVG geeft voor een verwijderverzoek?
Een snelle manier om dat worst-case venster te krijgen: embed een sample van 1.000 chunks tegen het nieuwe model, meet tokens per seconde onder je echte rate limit, vermenigvuldig, en tel er 25% bij op. Komt het resultaat boven de 21 dagen voor het hele corpus, dan heb je geen marge voor één mislukte run.
-- lineage check: are chunks tagged with the model that embedded them?
SELECT model_name, model_version,
COUNT(*) AS chunks,
MIN(embedded_at) AS first_seen,
MAX(embedded_at) AS last_seen,
pg_size_pretty(SUM(pg_column_size(embedding))) AS vector_bytes
FROM rag.chunk_embeddings
GROUP BY model_name, model_version
ORDER BY last_seen DESC;
Als die query 'column does not exist' gooit, heb je je eerste bevinding. We schrijven hem kaal op: 'geen embedding-lineage; een modelupgrade vereist een volledige re-embed zonder fallback, geschat 14 dagen bij de huidige rate limits; een AVG-verwijderverzoek in dat venster kan niet binnen de wettelijke termijn worden afgehandeld.' Dat scherpt het gesprek meestal aan.
Voor de pgvector-setup zelf verwijzen we teams nog steeds naar de pgvector-README over indextypes en dimensies, want de afweging HNSW versus IVFFlat is niet wat de audit beslist. De lineage-kolom wel.
De pg_dump-overlevingstest om 02:00
De derde beweging is degene die het rapport serieus genomen krijgt. We pakken de nachtelijke pg_dump van de klant (vrijwel altijd tussen 02:00 en 03:00 Europe/Amsterdam), halen de dump van vannacht in een sandboxcluster, restoren hem, en draaien één query:
SELECT COUNT(DISTINCT tenant_id) AS tenants_in_dump,
COUNT(*) FILTER (WHERE tenant_id IS NULL) AS orphans,
MIN(created_at) AS earliest,
MAX(created_at) AS latest
FROM rag.documents;
Daarna dezelfde query tegen het live cluster. Wijken de getallen meer dan een afronding af, dan is de dump geen echte back-up van het RAG-corpus. Meestal omdat iemand een gepartitioneerde tabel heeft toegevoegd nadat de oorspronkelijke pg_dump-flags waren ingesteld, en --exclude-schema nu stilletjes de partitie laat vallen waar 80% van de documenten van de nieuwste tenant in staan. De pg_dump-docs zijn expliciet over hoe --schema en gepartitioneerde tabellen op elkaar inwerken, maar het script in cron.daily is geschreven in 2022 en niemand heeft er sindsdien naar gekeken.
Daarna testen we het omgekeerde: pak de dump, restore hem, verwijder de documenten van één tenant met één DELETE WHERE tenant_id = $1, en bevestig dat het rij-aantal van geen enkele andere tenant beweegt. Beweegt er nog iets, dan zit er een foreign-key-cascade in die je niet had willen geven, of een materialized view die cross-tenant data cachet.
De drie kennisbanken die het overleefden
Van de twaalf audits die we in 2025 draaiden, kwamen drie RAG-kennisbanken bij de eerste poging schoon door de pg_dump-en-delete-test. Wat hadden die gemeen?
Eén. Een enkele tenant_id uuid not null op elke tabel in het RAG-schema, jointabellen inbegrepen. Geen nullable tenant-kolommen 'voor de gedeelde documenten.' Gedeelde documenten leefden in hun eigen schema met hun eigen retrieval path.
Twee. Elke embedding-rij had een foreign key naar het document, en het document had een foreign key naar de tenant, met ON DELETE CASCADE expliciet uitgeschreven. Geen triggers die de cascade in applicatiecode regelen. De database deed het, atomair, in dezelfde transactie als het verwijderverzoek van de gebruiker.
Drie. De nachtelijke dump gebruikte --schema=rag --schema=public, plus een aparte logische export per tenant die elke zondag draaide. Die export per tenant was het echte disaster-recovery-verhaal; de cluster-brede dump was alleen voor snelle restore. Moest het team één tenant migreren, dan was er een bestand. Moesten ze het hele cluster restoren, dan was er een ander bestand. Andere klus, ander bestand.
De andere negen clusters hadden werk nodig voordat een RAG-retrofit veilig was. Niets daarvan was dramatisch. Hier een kolom toegevoegd, daar een policy herschreven, een cron job bijgewerkt. Maar het was werk dat geprijsd moest worden, en de audit was wat ons in staat stelde het eerlijk te prijzen.
Wat je morgenochtend kunt doen
Draai de top-25 RLS-query hierboven tegen je eigen cluster. Lees de output. Toont een rij met gebruikersdata rls_on = false, of tenant_policies = 0, dan heb je een bevinding. Schrijf hem op. Dat is het eerste uur van de audit, en je kunt het doen voor de standup.
Toen we deze audit vorig kwartaal draaiden voor een HR-tech-klant in Rotterdam, was het de derde beweging die brak: hun nachtelijke dump liet de partitie weg waarin de onboarding-documenten van de vorige maand zaten, dus een tenant-restore zou drie weken aan data hebben gemist. We herschreven het dumpscript, voegden de lineage-kolom toe, en bouwden de RAG-agent op een cluster dat we nu wel vertrouwden. De audit kostte een halve dag; de rebuild die hij voorkwam zou zes weken hebben gekost.
Kern
Een RAG-retrofit is geen modelprobleem. Het is een tenancy-probleem met een vectorindex eraan vast. Audit de tenancy voor je de build offreert.
FAQ
Hoe lang duurt de RAG-audit voor de offerte eigenlijk?
Ongeveer vier uur voor een senior engineer op een cluster met minder dan 200 tabellen. Het grootste deel is bestaande policies lezen en de dump-restore-delete-cyclus in een sandbox draaien.
Waarom scoren jullie de top 25 tabellen en niet allemaal?
Coverage gewogen op rij-aantal. De top 25 op grootte bevat vrijwel altijd de tenant-gescheiden data; kleinere tabellen zijn meestal lookup of config. We scannen de rest ook, alleen minder diep.
Vereist de AVG echt het opnieuw indexeren van embeddings?
De AVG vereist dat verwijdering binnen een wettelijke termijn doorwerkt in alle kopieën van persoonsgegevens. Is een vector afgeleid van persoonsgegevens, dan betekent het verwijderen van de bron ook het verwijderen van de vector. Met de timing van een herindexering bewijs je dat je dat aankunt.
Kunnen we de audit zelf draaien voor we met ABN praten?
Ja. De drie SQL-queries in deze post zijn degene die wij als eerste draaien. Komen ze schoon terug, dan heb je waarschijnlijk geen externe audit nodig voor een RAG-retrofit.