RAG
RAG over 41.000 Jupyter notebooks: een Groningse casus
Een junior scheepsbouwer zocht de ballasttank-berekening uit 2017. Die zat in één van 41.000 notebooks die de senioren weigerden te migreren. Dit deden we.

Een junior scheepsbouwer bij een Gronings adviesbureau klapt haar laptop open om 09:14 op een dinsdag. De opdracht: een stabiliteitsbeoordeling voor een 132 meter lang offshore service vessel. Ergens in het archief van het bureau ligt een ballasttank-berekening uit 2017, gemaakt door een inmiddels gepensioneerde senior engineer die precies deze rompvorm onder handen had. Ze weet dat hij bestaat. Drie keer is haar gezegd: vraag het aan de notebook op de share drive. Op die share drive staan 41.000 .ipynb-bestanden, terug tot 2009.
Dat was de situatie waarin we in maart binnenstapten. De opdracht, na een ochtend whiteboarden met de partners: een RAG-agent over het volledige notebook-archief, zonder dat de senioren er iets in hoefden te voeden, hoefden te hertrainen of naartoe hoefden te migreren.
De notebook waar de senioren niet vanaf wilden
De klant is een adviesbureau met 28 mensen, vlakbij de Zernike Campus, dat constructieve en hydrodynamische berekeningen doet voor offshore wind, baggeraars en binnenvaart. Hun senior engineers werken zoals ze sinds 2011 werken: Jupyter notebooks, pandas, NumPy, een zelfgebouwde finite-element wrapper rond Code_Aster. Elk project is een boom van notebooks. Elke berekening is een cel. Elke cel heeft een output: een pandas dataframe met de wanddikte per spant, een matplotlib-plot van het buigend moment, een enkele scalar die het klassebureau wil zien.
Toen wij erbij werden gehaald, stond het bureau op het punt om binnen anderhalf jaar twee senior engineers kwijt te raken aan pensioen. De junioren kenden de theorie. Ze wisten niet waar het institutionele antwoord lag. Het voorstel op tafel: alles migreren naar een gestructureerde engineering-database, hertaggen, refactoren, herschrijven. Budget met zes cijfers. Twee jaar doorlooptijd. De senioren hadden dat voorstel al drie keer afgeschoten. Ze hielden van hun notebooks.
Wij waren het met de senioren eens.
Waarom een standaard chunker faalt op een Jupyter notebook
Een .ipynb-bestand is een JSON-document. Het naïeve RAG-ingestiepad: lees de JSON, plak alle source- en markdown-cellen aan elkaar, chunk per 1.000 tokens, embed, klaar. Plug dat in een willekeurige off-the-shelf RAG-starter en je krijgt iets dat goed demonstreert en waardeloos is in productie.
De reden zit in de outputs. Een notebook voor een ballasttank-berekening uit 2017 ziet er zo uit:
import pandas as pd
import numpy as np
# Tank geometry from drawing 17-042 rev C
tanks = pd.read_csv("tanks_17042c.csv")
tanks["volume_m3"] = tanks.apply(
lambda r: r["length"] * r["beam"] * r["depth"] * r["k_fill"],
axis=1,
)
ballast = tanks[tanks["service"] == "ballast"]
ballast.groupby("location")["volume_m3"].sum().round(1)
De redenering zit in de code. Het antwoord staat in de output eronder, die de notebook heeft opgeslagen als een stream zoals:
location
aft 1284.6
forepeak 412.1
midship 2106.3
Name: volume_m3, dtype: float64
Als de chunker alleen source-cellen leest, heeft de retrieval-laag wel de vraag, maar niet het antwoord. Leest hij ruwe cell.outputs, dan krijgt hij bytes aan base64 PNG-plotdata, ANSI-gekleurde tracebacks en repr()-strings waar geen embedding model schoon mee omgaat. We hebben beide een week geprobeerd. Geen van beide werkte.
Pandas-outputs als first-class chunks
Wat we veranderden was: elke output behandelen als een eigen, ophaalbare unit. Een notebook wordt geparsed tot een reeks getypeerde chunks.
- Markdown-cel: bewaard als proza, normaal geëmbed.
- Code-cel: alleen de source, geëmbed met een korte prefix die noteert welke variabelen erin gedefinieerd worden.
- DataFrame-output: terug geparsed naar een pandas-tabel, en daarna geserialiseerd naar Markdown met kolomtypes en een samenvattende zin voor de machine (Gemiddeld volume 1267,7 m³ verdeeld over 3 ballastlocaties).
- Plot-output: caption uit de omringende markdown plus een beschrijving van de gerenderde PNG door een vision-model.
- Scalar-output: geïndexeerd met de variabelenaam aan de linkerkant en de source-cel als context.
Elke chunk draagt metadata mee: pad naar de notebook, cel-index, projectcode, scheepsklasse, oorspronkelijke auteur, jaar, de SHA van de gebruikte CSV-inputs. Scheepsklasse en jaar zijn cruciaal. Een junior die vraagt welke ballastverdeling hebben we gebruikt voor een PSV van 130 meter wil werk uit 2015 tot 2019, niet de bargestudies uit 2009.
Het metadataschema kostte twee iteraties. Versie één had auteur, jaar, projectcode en scheepsklasse. Versie twee voegde de SHA van iedere upstream CSV toe, de gepinde library-versies waar de notebook van uitging, en een vrij-veld-tag die de senior engineer bij commit aan een notebook kon hangen. De meesten deden dat nooit. De twee die het wel deden bespaarden hun junior collega's uren raadwerk. De verborgen kost in elk RAG-systeem is metadata waarvan je drie maanden na livegang merkt dat je hem nodig hebt. Plan ervoor op dag één en je bent een middag bezig. Voeg hem later toe en je herindexeert het hele corpus.
In een notebook-corpus is de dataframe-output het antwoord. Alleen de source-code indexeren is alsof je de vraag indexeert en het antwoord weggooit.
Pyodide als deterministische re-renderlaag
Een notebook is een snapshot. Open je een bestand uit 2017 in 2026, dan zijn de outputs nog steeds die de senior engineer zag op zijn Lenovo in november 2017. Het kan kloppen, het kan ook niet meer kloppen. De CSV kan verhuisd zijn. De library-versie is weg. De grafiek kan onleesbaar zijn omdat matplotlib zijn defaults heeft veranderd.
Wij bouwen elke output opnieuw op in een sandbox vóór indexering.
De sandbox is Pyodide, de CPython-distributie die in WebAssembly draait. Elke notebook wordt uitgevoerd tegen een gepinde omgeving, de outputs worden gevangen, de ingebakken outputs in het bestand op disk blijven onaangeroerd, en alleen de vers gerenderde gaan de index in. Faalt een notebook bij re-execution (gebroken pad, ontbrekend inputbestand, deprecated API), dan krijgt hij een vlag en blijft hij in een aparte stale-bak waar retrieval naar kan vallen met lagere prioriteit en een waarschuwing voor de lezer.
Op deze bouwbeslissing zijn we tweemaal teruggekomen. Versie één gebruikte een Docker-pool met CPython. Werkte, maar containers opspinnen voor 41.000 notebooks was pijnlijk, en elk CSV-pad binnenin de notebooks was een security-hoofdpijn. Senior engineers hadden absolute paden naar netwerkshares ingebakken, zoiets als \\srv-calc-01\projects\2017\17-042\inputs\tanks_17042c.csv. Pyodide gaf ons de deterministische omgeving zonder het IO-oppervlak. We schreven een kleine import-hook die die UNC-paden herschrijft naar een virtueel bestandssysteem dat vanuit object storage gemount wordt. De notebooks van de senioren draaien ongemodificeerd. De pipeline raakt de live share nooit aan, en de agent krijgt geen kans om een pad te lekken dat naar een echte drive wijst.
Het tempo van het Pyodide-project helpt. Release 314.0 voegde de mogelijkheid toe dat Python-packages WebAssembly wheels direct op PyPI publiceren, wat betekent dat ons gepinde environment-bestand nu een gewone requirements.txt is in plaats van een eigen asset-bundle. We hebben de pipeline herbouwd in de week dat de release verscheen.
Render geen productiedata opnieuw in een publieke sandbox. Pyodide draait standaard in de browser, wat betekent dat het corpus bereikbaar moet zijn voor browser-code. Wij draaien Pyodide in een Node-worker op onze eigen infrastructuur, zonder externe netwerk-egress.
Retrieval die cellen kent
Als een junior een vraag stelt, embed de agent niet alleen de query en haalt hij de top acht chunks op. Hij doet drie dingen.
- Embed de query tegen het chunk-corpus en haal de top vijftig kandidaten op.
- Groepeer kandidaten per notebook. Eén notebook met vier matchende chunks (één markdown, één source-cel, één dataframe, één scalar) wint vrijwel altijd van vier ongerelateerde notebooks met elk één matchende chunk.
- Voor de top drie notebooks: haal een gestructureerde buurt op. De markdown-header boven de matchende cel, de cel zelf, de volgende twee cellen, plus elke output die die twee volgende cellen produceren.
Die fan-out naar vijftig vóór de groepering doet ertoe. In de vroege experimenten embedden we de query en haalden we de top acht chunks rechtstreeks op. Die acht kwamen ruwweg de helft van de tijd uit acht verschillende notebooks, en de agent breide er een Frankenstein-antwoord uit elkaar dat geen echte engineer zou opschrijven. Groeperen per notebook vóór re-ranking was de simpelste verandering met de grootste accuracysprong in onze interne eval.
Het antwoord dat de engineer ziet is een korte tekst plus een link die de notebook opent bij de matchende cel in een read-only viewer. We wilden niemand opnieuw JupyterLab leren gebruiken. De viewer toont de originele 2017-outputs naast de opnieuw gerenderde, met een diff-badge als ze verschillen. De senioren zijn fan van die diff-badge. Hij vangt drift op die ze zelf nooit zouden opmerken.
Wat de junioren werkelijk vragen
Acht weken na live verwerkte de agent in week acht 1.260 queries. We hebben er 200 met de hand doorgelopen.
Ongeveer 35% is waar zit de berekening voor X. Een junior weet dat het project bestaat en wil de notebook hebben. Ongeveer 28% is welke waarde hebben we voor X gebruikt in vergelijkbaar werk. De junior is een eigen berekening aan het maken en wil een sanity check. Ongeveer 18% zijn woordenschat-vragen: een senior heeft een term gebruikt (equivalent design pressure, GZ-arm bij 30 graden) die het leerboek niet haalde. De rest is een lange staart.
De audit van 200 vragen leverde drie foute antwoorden op. Alle drie hetzelfde patroon: de agent haalde een notebook uit 2014 op die een tabel met rompvormcoëfficiënten gebruikte die het bureau inmiddels heeft laten varen. We hebben een deprecated_coefficient-vlag aan de notebook-metadata toegevoegd en die uitgesloten van de standaard-retrieval. De fix kostte een middag.
Wat we niet hadden voorzien: de senioren begonnen hem ook te gebruiken. Niet voor nieuwe vragen. Voor wat heb ik in 2019 geschreven. Eén van hen gaf toe dat hij het inzet als een extern geheugen voor zijn eigen werk.
De cijfers, acht weken na live
- 41.107 notebooks geïndexeerd. 38.924 schoon opnieuw gerenderd in Pyodide, 2.183 in de stale-bak.
- Mediane query-latency: 1,4 seconde tot eerste token, 4,8 seconde tot volledig antwoord inclusief opgehaalde notebook-buurt.
- Query-volume in week acht: 1.260, op van 84 in week één. De groei komt doordat junioren elkaar in Slack tippen.
- Gemiddelde tijd-tot-antwoord voor waar zit de X-berekening: van 22 minuten (een senior aanschieten in Slack, wachten) naar onder de 2 minuten.
De kosten lagen gemiddeld op ongeveer €0,011 per query, gedomineerd door het answer-model en de per-cel vision-beschrijvingen die we bij ingest cachen. Een volledige corpus-rebuild kost €38 als we alles opnieuw renderen, en dat doen we elke nacht omdat notebooks die overdag gecommit zijn op verrassende manieren kunnen falen en we het binnen vierentwintig uur willen weten, niet aan het einde van een sprint.
Het migratieproject dat drie jaar op tafel had gelegen is nu van tafel. De senioren schrijven nog steeds notebooks. De junioren vinden ze. Het institutionele geheugen heeft twee pensioenen overleefd die nog niet eens hebben plaatsgevonden.
Waar dit naar generaliseert
Zit je op een corpus aan notebooks, CAD-tekeningen of Word-documenten waar de senior engineers niet vanaf willen, dan ligt de hefboom niet bij migratie. De hefboom is de outputs als first-class behandelen. Toen we deze RAG-agent bouwden voor het Groningse bureau, was de zet die ertoe deed Pyodide-gebaseerde re-rendering plus de pandas-dataframes als eigen chunks indexeren. Pak de tien meest gestelde engineering-vragen uit het afgelopen kwartaal en check of er antwoorden tussen zitten die in een dataframe wonen dat vandaag niemand doorzoekt. Dat is je audit van vijf minuten.
Kern
Als je kennisbank uit Jupyter notebooks bestaat, zitten de antwoorden in de pandas-outputs. Indexeer die als first-class chunks, niet de source-cellen.
FAQ
Waarom niet gewoon de notebooks naar een gestructureerde database migreren?
Omdat de senioren er niet in willen schrijven. Elk migratieproject in dit bureau is gesneuveld omdat de mensen die de kennis produceren weigerden van tool te wisselen. RAG over het bestaande corpus overleeft die weigering.
Waarom outputs opnieuw renderen met Pyodide in plaats van vertrouwen op wat in het .ipynb-bestand staat?
Opgeslagen outputs zijn snapshots uit 2017. Inputs verhuizen, libraries veranderen, en verouderde outputs vergiftigen de retrieval. Opnieuw uitvoeren in een gepinde WebAssembly-omgeving geeft deterministische, actuele antwoorden.
Hoe ga je in retrieval om met plots en figuren?
Elke plot-output krijgt een beschrijving van een vision-model plus de omringende markdown als caption, geïndexeerd als eigen chunk. De junior krijgt de figuur inline gerenderd en kan de notebook openen bij die cel.
Welke stack draait de agent in productie?
Een Node-workerpool draait Pyodide voor re-rendering, chunks landen in een Postgres + pgvector-index, retrieval is hybride (BM25 plus dense), en de chat-laag zit achter de bestaande SSO van het bureau.