← Blog

RAG

RAG-drift: Pinecone-rebuild at 38 vrijwaringsclausules

Op dag negen meldde een paralegal één ontbrekende clausule. Die middag stonden er 38. De contract-review agent miste werk sinds de index-rebuild, en niemand merkte het op.

Jacob Molkenboer· Oprichter · A Brand New Company· 13 jun 2026· 9 min
Open eiken kaartenbak met crème kaarten, één gekreukt, groen labeltje aan linnen draad, koperen tussenschot, leren onderlegger.

Dinsdag, 09:14. Een senior paralegal bij een Utrechtse legaltech van 26 mensen leest de review van de agent op een vendor-MSA. Ze komt bij sectie 11 en stopt. De vrijwaringsclausule wordt niet aangevlagd. Ze weet zeker dat dat wel zou moeten: ze schreef in februari mee aan de review-template voor precies dit patroon. Ze opent de batch van gisteren. Zelfde gat op drie andere contracten. Ze opent een Slack-thread met de engineering lead en typt één zin: weten we zeker dat de agent het hele document leest?

Om 16:00 die dag hadden ze 38 gemiste vrijwaringsclausules geteld over negen dagen productieverkeer. De agent was niet gecrasht. Niet gethrottled. Niets in hun dashboards zag er verkeerd uit. Het systeem had vol vertrouwen reviews teruggegeven waarin de duurste paragraaf van een commercieel contract ontbrak.

Dit is de post-mortem. We werden op dag tien gevraagd om de oorzaak te vinden en de retrieval-laag te herbouwen. De bug zat niet in het model, niet in de prompt en niet in de embeddings. Hij zat in één integer-kolom waar al zes maanden niemand meer aan had gedacht.

Hoe de contract-review agent was opgezet

Het product is een niche Nederlandse legaltech-SaaS die een eerste review doet op binnenkomende contracten voor mid-market bedrijven. Een gebruiker uploadt een PDF, het systeem extraheert tekst, splitst die in clausules, en een agent loopt een review-checklist af: indemnity caps, toepasselijk recht, automatische verlenging, verwerkersovereenkomst, het gebruikelijke lijstje. Voor elk punt op de checklist haalt de agent de meest relevante chunks uit het contract op en vraagt het model die te beoordelen tegen een playbook met geaccepteerde formuleringen.

De retrieval-stack was uit het boekje: chunk het contract in vensters van ongeveer 800 tokens met 120 tokens overlap, embed elke chunk, upsert naar Pinecone, query bij review-tijd met de checklist-vraag als query vector. Ze draaiden het in één index met één namespace per tenant, prima keuze. De chunk-metadata bevatte het contract-ID, de sectiekop en de ruwe tekst.

Het stuk dat ze brak was hoe ze een opgehaalde vector terugkoppelden aan een rij in hun Postgres. In plaats van de ID van de vector als join-sleutel te gebruiken, hadden ze de positie van de chunk in het document gepakt: het volgnummer dat tijdens de ingest werd toegekend. Dat volgnummer werd vers gegenereerd bij elke embedding-run. Pinecone kreeg de positie als vector-ID. Postgres kreeg de positie als chunk_seq. Daarop joinden ze.

Maandenlang ging dat goed.

Waarom een index-rebuild positionele IDs stilletjes breekt

Op een vrijdagmiddag, negen dagen voordat de paralegal het opmerkte, had de engineering lead een schone rebuild van de Pinecone-index gedraaid. Daar was een goede reden voor: ze upgraden het embedding-model van text-embedding-3-small naar een groter model met betere prestaties op Nederlandstalige tekst. Het rebuild-script deed precies wat je zou verwachten. Het liep elk contract in Postgres af, chunkte het opnieuw, embedde elke chunk opnieuw en upsertte naar een verse Pinecone-index. Daarna draaide het de index-alias om.

De chunker was alleen niet-deterministisch op een manier die niemand had gedocumenteerd. Hij gebruikte een sentence splitter die bij dezelfde input soms 47 chunks produceerde en soms 48, afhankelijk van of een bepaald afkortingenpatroon matchte. De inhoud van de chunks was tussen runs vrijwel identiek. Het aantal chunks, en daarmee de positionele volgorde, verschoof met één of twee voor ongeveer een kwart van de corpus.

Postgres hield nog steeds de oude chunk_seq-waarden vast. Pinecone had nu nieuwe. Elke join zat een klein, onvoorspelbaar aantal posities ernaast.

De retriever haalde de top twaalf chunks uit Pinecone, mapte hun IDs terug naar rijen in Postgres en voerde het model de rijen die terugkwamen. Op de meeste contracten waren twaalf grotendeels-juiste chunks goed genoeg om de vrijwaringsclausule te vinden. Op de contracten waar de chunk met de indemnity-paragraaf toevallig één positie over de grens viel, kreeg het model een chunk uit twee paragrafen eerder, concludeerde het dat indemnity er niet was, en ging verder. Geen error. Geen retry. Geen logregel die er ongewoon uitzag.

Waarschuwing

Als je retriever joint op iets dat kan veranderen wanneer je de index opnieuw bouwt (positionele offsets, autoincrement-IDs, regelnummers in bestandsvolgorde), zit er een silent-failure landmijn in je systeem. Het eerste symptoom is geen exception. Het is een klant die merkt dat er werk ontbreekt.

De stille drift van negen dagen

Wat dit gemeen maakte, is dat de foutkans niet constant was. Ongeveer 18% van de contracten die in het venster van negen dagen werden verwerkt, kreeg minstens één verkeerde chunk terug op de indemnity-vraag. De rest was goed. Hun monitoring volgde latency, tokengebruik, foutpercentage en gemiddelde confidence score van het model. Geen van die getallen bewoog. Het model schreef nog steeds vol vertrouwen samenvattingen. Het waren alleen samenvattingen van de verkeerde paragraaf.

Dit is het type fout dat ons het meest benauwt wanneer we de RAG-stack van een klant doorlichten: de agent zit ernaast, de agent is zeker, en de oppervlaktemetrics zien er gezond uit. Het is precies de omgekeerde vorm van het OWASP LLM06-risico rond het lekken van gevoelige informatie: in plaats van iets te zeggen wat het niet mag zeggen, laat het weg wat het juist wel moet noemen.

Het patroon duikt op overal waar autonome agents in productie reiken: een zelfverzekerd systeem doet werk dat niemand dubbelcheckt, tot de rekening op de een of andere manier binnenkomt. Voor onze Utrechtse klant was die rekening een rustige e-mail van een eindklant wiens contract niet goed was beoordeeld. Die ene mail was meer waard dan negen maanden Pinecone-hosting.

De fix: content-addressed chunk-IDs en metadata-only retrieval

De reparatie kent twee delen. Eén: gebruik nergens meer positionele IDs. Twee: join op het leespad helemaal niet meer terug naar Postgres.

Het kapotte patroon, vereenvoudigd:

def retrieve(query_text: str, k: int = 12) -> list[Chunk]:
    q = embed(query_text)
    res = index.query(vector=q, top_k=k, include_values=False)
    # join back to Postgres on the position-derived sequence
    rows = db.execute(
        "SELECT * FROM contract_chunks WHERE chunk_seq = ANY(:ids)",
        ids=[int(m.id) for m in res.matches],
    )
    return [Chunk.from_row(r) for r in rows]

De fix:

import hashlib

def chunk_id(contract_id: str, text: str) -> str:
    # content-addressed: rebuilds are idempotent
    h = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
    return f"{contract_id}:{h}"

def upsert(contract_id: str, chunks: list[str]) -> None:
    vectors = [
        {
            "id": chunk_id(contract_id, c),
            "values": embed(c),
            "metadata": {
                "contract_id": contract_id,
                "text": c,
                "section": section_of(c),
            },
        }
        for c in chunks
    ]
    index.upsert(vectors=vectors, namespace=tenant_namespace())

def retrieve(query_text: str, k: int = 12) -> list[Chunk]:
    q = embed(query_text)
    res = index.query(
        vector=q, top_k=k,
        include_metadata=True,
        namespace=tenant_namespace(),
    )
    return [
        Chunk(
            id=m.id,
            contract_id=m.metadata["contract_id"],
            text=m.metadata["text"],
            section=m.metadata["section"],
            score=m.score,
        )
        for m in res.matches
    ]

Er veranderen twee dingen. De vector-ID wordt nu afgeleid uit de inhoud van de chunk (een afgekapte SHA-256, met het contract-ID als prefix), zodat een rebuild dezelfde ID oplevert voor dezelfde paragraaf. En het leespad raakt Postgres niet meer aan. De tekst die het model ziet komt uit de metadata van de vector, opgehaald in dezelfde call. Dit is wat de upsert-docs van Pinecone zelf al jaren stilletjes aanraden: behandel metadata als de bron van waarheid voor wat de LLM leest.

Kerngedachte

De vector-ID is een join-sleutel die meereist met de embedding. Maak hem deterministisch op basis van inhoud, niet van volgorde. Als je niet dezelfde ID kunt regenereren uit dezelfde paragraaf, is je index niet echt idempotent.

Het verloren werk reconstrueren

Het live systeem fixen was stap één. De klanten van de klant vertellen wat we hadden gemist was stap twee, en daar lag de engineering lead 's nachts wakker van. We schreven een reconciliatiejob: voor elk contract dat in het venster van negen dagen was gereviewd, draai de indemnity-clausule check opnieuw door de nieuwe retrieval-pipeline, diff de output, en produceer een lijst contracten waar een clausule die aangevlagd had moeten worden, dat niet was. Dat gaf ons de 38.

De CEO van de legaltech stuurde de volgende ochtend een persoonlijke e-mail aan elke geraakte klant. Twee zegden op. De rest bleef, en één van hen vertelde haar dat de openheid de reden was. Dat is geen getal waar je een dashboard omheen bouwt, maar wel het getal dat er hier toe deed.

Wat je vandaag op je eigen RAG kunt controleren

Als je een retrieval-augmented agent in productie draait, kosten drie checks samen minder dan een uur en hadden ze dit voorkomen:

  • Open je upsert-code en zoek wat je doorgeeft als vector-ID. Is dat een autoincrement, een rijpositie, een file-order index of iets dat is afgeleid van "de volgorde waarin chunks uit de chunker kwamen", dan zit dezelfde landmijn in jouw systeem. Vervang het door een content-addressed hash.
  • Open je leespad en vraag jezelf af of je terugjoint op een primaire database op iets anders dan de vector-ID. Zo ja, waarom? Het metadata-veld bestaat juist hiervoor.
  • Schrijf een reproduceerbaarheidstest van vijf minuten: ingest hetzelfde document twee keer, dump de IDs van beide runs, diff ze. Is de diff niet leeg, dan is je index niet idempotent en zal je volgende rebuild de join stilletjes corrumperen.

Toen we de retrieval-laag voor de Utrechtse legaltech herbouwden, bleven we hangen op één punt: de agent had geen enkele manier om ons te vertellen dat hij ernaast zat. Hij had geen concept van "ik had een vrijwaringsclausule moeten vinden en heb dat niet gedaan". We voegden een tweede pass toe die voor elk checklist-item expliciet aanwezigheid-of-afwezigheid asserteert, tegen een aparte retrieval met een sectie-keyword filter, en die een warning logt wanneer pass één en pass twee het oneens zijn. Dat is het soort werk dat we leveren op de AI-agents die we bouwen: niet het gelukkige pad, het stille-fouten pad.

Audit van vijf minuten die je kunt draaien zodra je dit tabblad sluit: grep je retrieval-code op de letterlijke string chunk_seq, position of idx die als vector-ID wordt doorgegeven. Vind je er één, dan heb je je vrijdag te pakken.

Kern

Joint je retriever op positionele chunk-IDs, dan corrumpeert een index-rebuild stilletjes elke query. Gebruik content-addressed IDs en lees tekst uit metadata.

FAQ

Waarom ving standaard monitoring de gemiste clausules niet op?

Latency, foutpercentage en tokengebruik zagen er allemaal normaal uit. Het model gaf elke keer met overtuiging output terug. Het enige signaal was een domeinexpert die merkte dat een clausule ontbrak in de review, en dat duurde negen dagen.

Is dit een Pinecone-specifieke bug?

Nee. Hetzelfde foutpatroon ontstaat in elke vector store als je positionele of autoincrement-IDs gebruikt en de index opnieuw bouwt. Weaviate, Qdrant, pgvector, Chroma: allemaal kwetsbaar zodra je retriever sleutelt op iets dat kan verschuiven.

Hoe maak je chunk-IDs deterministisch?

Hash de tekstinhoud van de chunk (SHA-256 voldoet prima) en prefix die met het ID van het bovenliggende document. Dezelfde paragraaf opnieuw ingesten levert dan dezelfde vector-ID op, dus een rebuild is idempotent en je downstream joins blijven kloppen.

Hoort de chunk-tekst in de metadata van Pinecone of in Postgres?

Beide mag, maar het leespad moet tekst uit de vector-metadata trekken. Behandel Postgres als system of record voor governance en audit, en de metadata van de vector store als bron van waarheid voor wat de LLM daadwerkelijk ziet.

Hoe spot je dit soort drift voordat klanten het doen?

Voeg per hoog-risico checklist-item een aanwezigheid-of-afwezigheid assertie toe, draai die tegen een tweede retrieval met een ander filter, en alert wanneer beide het oneens zijn. Onenigheid is je vroege-waarschuwingssignaal, niet je foutpercentage.

ragai agentscase studyarchitectureknowledge basetooling

Iets bouwen?

Start een project