Code Repository: github.com/tonsnoei/custom-rag.
RAG = zoeken in document met behulp van AI
Er zijn van die technologieën waar je een tijdje omheen fietst zonder er echt in te duiken. Bij mij was dat RAG. Retrieval-Augmented Generation. Iedereen gebruikt het, iedereen praat erover, en ik knikte netjes mee in gesprekken alsof ik precies wist wat er onder de motorkap gebeurde. Spoiler: dat wist ik niet.
Dus deed ik wat ik altijd doe als ik iets écht wil snappen: ik bouwde het zelf. Geen tutorial die alles voor je wegstopt achter één pip install magic-rag, maar helemaal from scratch, in Python. Een paar uur werden een paar dagen, en aan het eind had ik niet alleen een werkende RAG, maar ook een veel scherper beeld van hoe deze techniek werkt.
In dit artikel neem ik je mee door dat proces. Niet als droge documentatie, maar als het verhaal van iemand die het gewoon wilde snappen.
Eerst even: wat is RAG nou eigenlijk?
Stel je een AI-assistent voor die alleen kan putten uit wat hij ooit geleerd heeft tijdens zijn training. Handig, tot het moment dat je iets vraagt over gisteren, over jouw interne bedrijfsdocumenten, of over iets dat simpelweg niet in die trainingsdata zat. Dan gaat zo'n model "hallucineren": het verzint met droge overtuiging een antwoord dat er goed uitziet, maar nergens op gebaseerd is.
RAG lost dat op met een simpel maar slim trucje, in drie stappen:
- Retrieval – Bij een vraag zoekt het systeem eerst in een bron die je zelf vertrouwt: interne documenten, een kennisbank, recente artikelen.
- Augmentation – De gevonden tekstfragmenten worden toegevoegd aan de oorspronkelijke vraag.
- Generation – Het taalmodel combineert zijn eigen kennis met die aangeleverde, kloppende informatie tot een antwoord.
Het resultaat: een AI die actueler, accurater en veel minder "verzinnerig" is. En omdat je zelf bepaalt welke bronnen erin gaan, kun je 'm ook prima op eigen, privédata loslaten zonder dat je die hoeft te delen met de buitenwereld.
Mooi in theorie. Maar hoe bouw je zoiets nou echt?
De bouwstenen: van los document naar bruikbaar antwoord
Ik heb het project stap voor stap opgebouwd, in precies de volgorde waarin je er ook tegenaan loopt als je zelf begint. Hieronder loop ik ze een voor een langs.
Stap 1: Welke documenten ondersteun ik?
De allereerste keuze die je maakt is eigenlijk een hele praktische: met welk documentformaat ga je werken? Ik koos voor Markdown. Geen exotische reden, gewoon omdat het een standaard is met een structuur die zowel mensen als code makkelijk kunnen volgen, kopjes, secties, subsecties, allemaal netjes te herkennen.
Om de software te testen heb ik een documentje gemaakt over een fictief apparaat:
# BrewMate Mini – Productinformatie
## Over het apparaat
De BrewMate Mini is een compacte espressomachine, ontworpen voor gebruik in kleine keukens en studentenkamers.
Het apparaat weegt slechts 1,8 kilogram en heeft een waterreservoir van 0,6 liter, genoeg voor twee tot drie
kopjes espresso zonder bijvullen.
## Werking
De machine werkt met een pompdruk van 15 bar en bereikt de ideale zettemperatuur binnen ongeveer 25 seconden na het
inschakelen. Dankzij het compacte formaat past de BrewMate Mini eenvoudig op een smal aanrecht, en het lichtgewicht
ontwerp maakt het apparaat ook geschikt om mee te nemen op reis of naar kantoor.
## Kleuren en prijs
BrewMate Home levert de Mini standaard in de kleur mat zwart, al is er ook een witte uitvoering verkrijgbaar tegen
een kleine meerprijs. De adviesprijs bedraagt 79,95 euro.
## Garantie
Op het apparaat zit een garantie van één jaar, geldig vanaf de aankoopdatum bij een erkende verkoper.
Stap 2: Tekst in behapbare stukken hakken (chunking)
Een heel document in één keer aan een taalmodel voeren werkt niet. Je moet het opknippen in kleinere, betekenisvolle stukken: chunks. Dat klinkt simpel, maar er zit meer denkwerk in dan je zou verwachten. Knip je te grof, dan verlies je precisie. Knip je te fijn, dan verlies je context.
Ik gebruikte hiervoor twee chunking-methodieken en bouwde dat in een aparte MarkDownChunkerService. Het resultaat van zo'n chunk ziet er in de praktijk zo uit:
Dit tekst fragment is onderdeel van "BrewMate", sectie: BrewMate Mini –
Productinformatie, sub-section: Kleuren en prijs. BrewMate Home levert
de Mini standaard in de kleur mat zwart, al is er ook een witte
uitvoering verkrijgbaar tegen een kleine meerprijs. De adviesprijs
bedraagt 79,95 euro.
Zie je wat er gebeurt? Het fragment draagt zijn eigen context met zich mee: welk document, welke sectie, welke subsectie. Dat is geen toeval, dat is precies wat je later helpt om relevante stukken terug te vinden én om het taalmodel genoeg houvast te geven.
Stap 3: Tokens tellen (en waarom karakters niet hetzelfde zijn)
Chunks moeten binnen een bepaalde grootte blijven, in mijn geval tussen de 300 en 500 tokens. Klinkt logisch, tot je je realiseert: een token is niet hetzelfde als een letter of een woord. Grofweg reken je op zo'n 4 karakters per token, maar dat verschilt per taal en per tekst.
Ik bouwde hiervoor twee varianten: een simpele TokenCounterSimple en een preciezere TokenCounterTikToken. Welke van de twee je gebruikt, stel je centraal in op één plek, de Dependencies. En dat brengt me meteen bij een designkeuze waar ik zelf best happy mee ben.
Stap 4: Embeddings maken - tekst omzetten naar getallen
Dit is het stuk waar RAG écht magisch wordt. Een embedding is niets anders dan een vector: een rij met floating point-getallen die de betekenis van een stuk tekst representeert. Twee zinnen die inhoudelijk op elkaar lijken, krijgen vectoren die dicht bij elkaar liggen, ook al gebruiken ze compleet andere woorden.
Voor het genereren van deze embeddings heb je een model nodig. Ik draaide dat lokaal via LM Studio, met het model text-embedding-nomic-embed-text-v1.5. Zo'n embedding ziet er dan zo uit:
[-0.0009645704994909465, 0.035120341926813126, ...]
Een rijtje getallen dat voor mensen nietszeggend is, maar voor een computer een schat aan betekenis bevat.
Stap 5: De vectordatabase - waar alles samenkomt
Alle embeddings moeten ergens opgeslagen worden, samen met de bijbehorende tekst. Daarvoor bouwde ik een simpele in-memory vectordatabase met numpy, plus een repository eromheen.
Een leuk detail: het nomic-embed-model verwacht dat je documenten prefixt met search_document: en zoekopdrachten met search_query: . Vergeet je dat, dan werkt de similarity search opeens een stuk minder goed. Dat soort kleine, makkelijk over het hoofd te ziene details zijn precies waar je tegenaan loopt als je dit zelf bouwt in plaats van een kant-en-klare library gebruikt, en precies waarom ik het zo leerzaam vond.
Met deze vijf stappen kun je al een perfecte similarity search draaien: stel een vraag, en het systeem vindt de tekstfragmenten die er inhoudelijk het dichtst bij liggen. Bijvoorbeeld:
[Vector Search: Pompdruk]
['search_document: ... De machine werkt met een pompdruk van 15 bar
en bereikt de ideale zettemperatuur binnen ongeveer 25 seconden...']
Precies het juiste fragment, zonder dat er ergens een taalmodel aan te pas is gekomen. Puur wiskunde.
Stap 6: De chat-service - waar het antwoord ontstaat
De laatste stap is de plek waar alles samenkomt: een service die de gevonden fragmenten en de oorspronkelijke vraag combineert en aan een taalmodel voorlegt. Bij mij is dat een lokaal model via LM Studio (qwen3.6-35b-a3b-mlx), aangestuurd via de LocalLmStudioChatService.
Het mooie is: het model krijgt expliciet de instructie om zich uitsluitend te baseren op de aangeleverde context. Vraag je naar de kleur van de BrewMate Mini, dan graaft het model niet in zijn eigen trainingskennis, maar leunt volledig op het fragment dat via retrieval is gevonden. Resultaat:
"De BrewMate Mini is standaard verkrijgbaar in mat zwart. Daarnaast is er ook een witte uitvoering beschikbaar tegen een kleine meerprijs."
Kort, correct, en volledig herleidbaar naar de brontekst. Precies waar RAG voor bedoeld is.
Architectuur: SOLID, en waarom dat hier echt loont
Iets waar ik bewust voor gekozen heb: elke stap in de pijplijn is een losstaande service, gebouwd volgens een eigen protocol (interface). Dat is de 'D' uit SOLID, Dependency Inversion, en het helpt meteen mee aan de 'O', het Open/Closed principe.
Concreet betekent dit: wil ik de in-memory vectordatabase inruilen voor bijvoorbeeld ChromaDB? Dan pas ik dat op precies één plek aan, in de Dependencies-klasse die fungeert als service locator. Dezelfde flexibiliteit geldt voor de token counter en de embeddings-service.
Voor een weekendproject voelde dit misschien wat overengineered. Maar juist omdat ik dit deed om te léren, wilde ik elk onderdeel kunnen isoleren en vervangen zonder de rest van het systeem overhoop te halen. Dat bleek achteraf een van de beste beslissingen, ik kon namelijk rustig experimenteren met een andere token counter zonder ergens anders iets te breken.
Hoe je het zelf aan de praat krijgt
Kort samengevat, als je het zelf wilt proberen:
- Installeer LM Studio (Ollama kan ook, maar vraagt wat extra configuratie).
- Download het embeddingmodel
text-embedding-nomic-embed-text-v1.5. - Download een chatmodel naar keuze (standaard staat
qwen3.6-35b-a3b-mlxingesteld). pip install -r requirements.txtvanuit decode-map.- Draai
main.py.
De volledige code staat op GitHub: github.com/tonsnoei/custom-rag.
Eerlijk zijn over AI in dit project
Nog een puntje dat ik niet wil overslaan: ik heb Claude gebruikt om de RAG-principes aan mij uit te leggen tijdens het bouwen. Maar de code zelf? Volledig zelf geschreven, geen regel gegenereerd. De testdocumenten (novalink-x1.md en terraglide-r7.md) zijn wel AI-gegenereerd, puur als voorbeeldmateriaal om de pijplijn mee te testen.
Dat onderscheid vind ik zelf belangrijk. Ik wilde niet alleen weten dat RAG werkt, maar ook waarom, en dat leer je niet door code te laten genereren en te hopen dat het klopt.
Wat ik zou toevoegen (en waar ik jouw hulp bij nodig heb)
Heb je zin om met deze repo aan de slag te gaan. Dan zijn er nog de volgende dingen die beter kunnen:
- Vervangen van de in-memory vector database. Vervang de in memory vector database met een 'echte' vector-database om het project naar een hoger plan te tillen.
- Verbeteren van de chunker. De chunker is nog niet getest met markdown tabellen en bullets. Hier zou best nog wel eens wat stuk kunnen gaan. Het project testen met
novalink-x1.mdenterraglide-r7.mden kijken wat er misgaat.
Tot slot
Wat dit project me vooral leerde: RAG is geen zwarte doos vol magie, maar een keten van hele logische, losstaande stappen. Chunking, tokens tellen, embeddings maken, opslaan, terugzoeken, en pas hélemaal aan het eind een taalmodel erbij halen om er een leesbaar antwoord van te maken.
Het was superleuk om te maken en als je opmerkingen of ander commentaar heb zou ik dat zeer op prijs stellen. Of gewoon een discussie wilt starten wordt dat ook gewaardeerd.