← Blog

RAG

Vector store audit: dit checken we voor een RAG-retrofit

Een founder stuurt de vector database factuur van vorige maand door en vraagt om een offerte voor een RAG-retrofit. Die offerte sturen we die week niet. Eerst draait de audit.

Jacob Molkenboer· Oprichter · A Brand New Company· 17 jun 2026· 8 min
Open eiken kaartenbak met crème kaarten, één groene vlag, koperen scheider, rode lakzegel op papier, ivoren bureau.

Een SaaS-founder uit Utrecht stuurde ons op een dinsdagmiddag de laatste factuur door van zijn managed vector database vendor. Iets meer dan €4.200 per maand, voor een kennisbank waar zijn support agent zo'n veertig keer per dag aan tilt. Hij wilde een offerte voor een fatsoenlijke RAG-retrofit: betere retrieval, minder hallucinaties, en eventueel een migratie naar iets dat hij zelf kan hosten. Die offerte hebben we die week niet gestuurd. Eerst draaide de audit.

Dat doen we voor elke Nederlandse SaaS onder de €30M die ons vraagt aan hun vector store te werken. De audit duurt tussen de twee en vier werkdagen, afhankelijk van hoeveel van de ingestion pipeline in cron scripts woont en hoeveel in de applicatiecode. Hij levert zes getallen op en één go/no-go advies. Hieronder staat wat er op de checklist staat, waarom elke regel erop staat, en hoe je 'm zelf draait als je niemand wil inhuren.

Het 90-dagen ingestion-venster

We vertrouwen niets dat ouder is dan negentig dagen als representatief voor het huidige gedrag. Op die leeftijd is de codebase verschoven, is het ingestion-script minstens één keer herschreven, heeft iemand een nieuw documenttype toegevoegd en vergeten de chunker bij te werken, en is er een modelversie onder de embeddings vandaan geschoven. Alles voor het negentig-dagen-punt is archeologie. We loggen het, we scoren het niet.

Het eerste wat we ophalen is het aantal vectors dat per week is aangemaakt, gegroepeerd op brondocumenttype en op ingestion job. Heeft het histogram een vlak stuk in het midden van het venster, dan is er iets gestopt. Staat er op een dinsdag een verticale muur, dan heeft iemand het hele corpus opnieuw ingelezen zonder dat te melden. Allebei rode vlaggen, maar interessante rode vlaggen. Daar willen we van weten voor we offreren.

Chunk-overlap drift

De eerste kolom die we scoren is of je chunks eruitzien alsof ze van dezelfde chunker komen. In een gezonde index is de verdeling van chunk-lengtes strak: één duidelijke piek, lage variantie, voorspelbare overlap. In een gedrifte index is het histogram bimodaal of erger. Dat is je hint dat iemand de splitter halverwege heeft aangepast, of dat je twee ingestion-paden hebt die in dezelfde collection schrijven met andere instellingen.

Dit is de SQL die we tegen een pgvector store draaien. Hij gaat uit van een document_chunks tabel met een chunk_text kolom en een metadata JSONB kolom die de overlap bevat zoals die was op het moment van ingest. Heb je die metadata niet, dan is dat op zichzelf een finding: het betekent dat je niet kunt reconstrueren welke chunker welke vector heeft geproduceerd.

WITH windows AS (
  SELECT
    date_trunc('week', created_at) AS week,
    COUNT(*) AS chunks,
    AVG(LENGTH(chunk_text))::int AS avg_len,
    STDDEV(LENGTH(chunk_text))::int AS std_len,
    AVG((metadata->>'chunk_overlap')::int)::int AS avg_overlap
  FROM document_chunks
  WHERE created_at > NOW() - INTERVAL '90 days'
  GROUP BY 1
)
SELECT
  week,
  chunks,
  avg_len,
  std_len,
  avg_overlap,
  avg_len - LAG(avg_len) OVER (ORDER BY week) AS week_delta_len
FROM windows
ORDER BY week;

De drift score die we rapporteren is de maximale week-op-week absolute delta in gemiddelde chunk-lengte, gedeeld door het globale gemiddelde. Alles boven 0,15 (een sprong van 15% in gemiddelde chunk-grootte tussen twee opeenvolgende weken) wordt geflagd. We hebben nog geen index boven 0,30 gezien die zonder ingrijpen goede retrieval leverde.

Embedding-model versie pinnen

Dit is de stille moordenaar die we het vaakst aantreffen. Iemand heeft in 2023 de ingestion-pipeline tegen text-embedding-ada-002 aangesloten, in begin 2024 voor de nieuwe ingestion job geüpgraded naar text-embedding-3-small, en het historische corpus nooit opnieuw geëmbed. Het resultaat: ongeveer 70% van de vectors leeft in één embedding-ruimte en 30% in een andere. Cosine similarity ertussen is statistisch willekeurig. De agent haalt de verkeerde chunks op en niemand weet waarom.

De fix is om het embedding-model en zijn versie bij het schrijven in de metadata te pinnen, en daar later op te kunnen queryen. OpenAI documenteert expliciet dat nieuwere embedding-modellen niet uitwisselbaar zijn met oudere; dezelfde regel geldt voor Cohere, Voyage, en elk self-hosted model waarvan je de gewichten opnieuw kunt quantiseren. Pinnen. Altijd.

SELECT
  metadata->>'embedding_model' AS model,
  metadata->>'embedding_model_version' AS version,
  COUNT(*) AS vectors,
  ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 2) AS pct
FROM document_chunks
GROUP BY 1, 2
ORDER BY vectors DESC;

Geeft deze query meer dan één rij met een noemenswaardig percentage terug, dan moet de index opnieuw geëmbed worden voordat enig retrieval-verbeteringswerk de moeite waard is. Wij offreren geen optimalisatie tegen een gemengde-model-index. Dat is hetzelfde als een carburateur afstellen op een motor die twee verschillende brandstoffen in twee van de cilinders heeft.

Waarschuwing

Bevat je metadata niet de naam en versie van het embedding-model op elke vector, behandel de hele index dan als onbetrouwbaar tot je het tegendeel kunt aantonen. Er is geen eerlijke manier om over twee embedding-ruimtes te retrieven, en geen eerlijke manier om retrieval-kwaliteit te evalueren zonder te weten in welke ruimte elke vector leeft.

Per-tenant ACL filters op een multi-org index

Bijna elke Nederlandse SaaS die we auditen draait een multi-org index: één collection met documenten van elke klant, en een tenant_id veld om queries te scopen. Dat werkt prima tot het niet meer werkt. De audit checkt drie dingen, in deze volgorde.

Eerst: staat tenant_id op elke vector? We tellen de nulls en zetten dat af tegen de tenant-tabel van de applicatie. Heeft één vector een null tenant_id, dan kan het systeem die aan iedereen teruggeven. Dat is een datalek dat wacht op zijn eerste audit.

Twee: gebruikt het query plan daadwerkelijk een filter index? In pgvector wil je een HNSW index op de embedding-kolom plus een B-tree op tenant_id, en het plan moet het tenant-filter als pre-filter toepassen, niet als post-filter. Het executieplan moet eruitzien als een index scan op tenant_id die een vector search voedt, niet als een vector search die een tenant-filter voedt.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content,
       embedding <=> $1::vector AS distance
FROM document_chunks
WHERE tenant_id = $2
ORDER BY distance
LIMIT 10;

Drie: wat gebeurt er als de applicatiecode vergeet tenant_id mee te geven? Wij grepen de codebase. Is het antwoord dat de database alle rijen over alle tenants teruggeeft, dan schrijven we die finding in rood en gaan we niet verder tot het is opgelost. De juiste default is een functie of een view die de tenant-scope verplicht stelt, of een row-level security policy die het server-side afdwingt. ACL's die alleen in de applicatielaag leven, zijn één ontbrekende parameter verwijderd van een data-incident.

De pgvector-naar-Qdrant overlevingstest

De vraag die de founder meestal stelt aan het einde van het eerste gesprek: moeten we van pgvector af, naar iets specialistisch? Qdrant, Weaviate, Pinecone, kies een logo. Ons antwoord hangt volledig af van wat de audit laat zien, en we framen het als een overlevingstest.

We pakken drie tenants uit de klantenset. De grootste qua aantal documenten. De actiefste qua queryvolume over de laatste dertig dagen. En één die in de laatste zestig dagen is onboardd, omdat nieuwe tenants het huidige ingestion-pad het stevigst belasten. Voor alleen deze drie tenants simuleren we de migratie, tegen een wegwerp-Qdrant-collection, en we vragen: overleven hun ACL-filters zonder aanpassingen aan de data?

In Qdrant wordt per-tenant ACL een payload filter op elke search call. De vector data en de tenant_id schuiven schoon mee als de tenant_id bij ingest in de metadata stond. Zat hij gebakken in de collection-naam (één collection per tenant), dan is de migratie ook schoon, maar verandert de kostencalculatie: Qdrant-collections hebben elk een niet-triviale geheugenvoetafdruk, en een SaaS met driehonderd tenants in een per-tenant collection model verstookt meer RAM dan de pgvector-setup die hij achterlaat.

from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue

client = QdrantClient(url="http://localhost:6333")

hits = client.search(
    collection_name="chunks",
    query_vector=query_vec,
    query_filter=Filter(
        must=[
            FieldCondition(
                key="tenant_id",
                match=MatchValue(value=tenant_id),
            ),
        ],
    ),
    limit=10,
)

Overleven alle drie de test-tenants de migratie met hun ACL-filters intact en is hun retrieval-kwaliteit meetbaar gelijk of beter, dan is de migratie mechanisch engineering-werk. Dat offreren we per dag. Faalt er ook maar één (mismatchende embeddings, ontbrekende tenant_id, ACL-lek in de payload filter), dan zeggen we tegen de founder: fix eerst het ingestion-pad en kijk over een kwartaal opnieuw naar de migratie. Een kapotte index migreren levert een snellere kapotte index op.

Lokale modellen en de budgetvraag

Drie van de laatste vijf audits eindigden met dezelfde vervolgvraag: kunnen we de embeddings lokaal draaien in plaats van per token een vendor te betalen? De Hacker News voorpagina-draad van deze week, waarin iemand vraagt of er mensen zijn die GPT of Claude voor dagelijks coderen door een lokaal model hebben vervangen, blijft niet voor niets bovenaan hangen. De hardwarecurve en de open-modelcurve kruisen elkaar voor een specifiek soort workload, en embedding generation zit stevig aan de kant waar lokaal nu realistisch is voor een mid-size SaaS.

De audit beantwoordt dit concreet. We rekenen het maandelijkse tokenvolume uit over corpus en query stream, vermenigvuldigen dat met het tarief van de huidige vendor, en zetten het af tegen de geamortiseerde kosten van één werkstation dat een sentence-transformers model op een consumenten-GPU draait. Voor twee van de laatste vijf audits won lokaal met een factor drie over een horizon van twee jaar. Voor de andere drie was de engineering-tijd om het lokale inferentie-pad te draaien en te monitoren de dominante kostenpost en bleef de vendor de juiste keuze. Er is geen universeel antwoord. Er is de audit.

Wat het kost om de checklist te draaien

De hele audit bestaat uit zes SQL queries, één Python script, en een halve dag besteed aan het lezen van de ingestion-code. Je kunt het deze week zelf doen, als je de toegang hebt. De output is zes getallen (drift score, modelzuiverheid, tenant_id-dekking, juistheid van het query plan, drie-tenant overlevingsresultaat, geschatte migratiekosten) en een advies van één regel. Draai het voor je iets tekent: voor je het vector database contract verlengt, voor je je vastlegt op een migratie, voor je een AI-bureau een retrofit laat offreren.

Toen we precies deze audit vorige maand voor een Rotterdamse logistieke SaaS draaiden, was de hoofdvondst dat 41% van hun vectors was geëmbed met een model dat al negen maanden deprecated was, en dat hun per-tenant ACL een Django view-filter was zonder row-level policy erachter. We hebben het historische corpus over een weekend opnieuw geëmbed en de row-level security policy op maandag uitgerold. De offerte voor de retrofit kwam na de audit, niet andersom: zo draaien we ongeveer elke AI-agents en RAG opdracht.

Het kleinste wat je vandaag kunt doen: open een SQL client, draai de embedding-model verdeling query hierboven tegen je eigen vector store, en zoek uit hoeveel embedding-ruimtes er in jouw index door elkaar zitten. Is het antwoord meer dan één, dan heb je je eerste finding.

Kern

Draai de audit voordat je offreert, migreert of verlengt. Zes SQL queries en een halve dag code lezen verslaan zes maanden mysterieus retrieval-debuggen.

FAQ

Wat is chunk-overlap drift en waarom is dat belangrijk?

Het is variatie in chunk-grootte en overlap door je ingestion-historie heen. Het is belangrijk omdat cosine similarity tussen vectors die met verschillende chunkers zijn gebouwd ruisiger is dan het lijkt, wat retrieval verslechtert op manieren die lastig te debuggen zijn.

Moeten we pgvector houden of migreren naar een dedicated vector database?

Hangt af van de audit. Is je ingestion-pad gezond en is de tenant_id-metadata schoon, dan handelt pgvector lage miljoenen vectors prima af. Migreer wanneer query latency of ACL-complexiteit groter wordt dan wat Postgres aankan, niet eerder.

Hoe vaak moeten we deze audit opnieuw draaien?

Per kwartaal is het juiste ritme voor een actief product, of na elke wijziging in de ingestion-pipeline. Het scoringsvenster van negentig dagen sluit daar bewust op aan.

Wat als onze metadata de naam van het embedding-model niet bevat?

Dat is finding nummer één. Voeg het embedding-model en de versie bij het schrijven aan elke nieuwe vector toe, en plan een re-embedding pass voor het historische corpus tijdens een rustig moment. Behandel de bestaande index tot die tijd als onbetrouwbaar.

ragknowledge basearchitecturemigrationoperationssaas

Iets bouwen?

Start een project