blog/domein-embedding-model-trainen.mdx
Alle notities

18 maart 2026

Zelf een embeddingmodel trainen: niet het model maar de data is het werk

Op de roadmap had ik het trainen van eigen embeddings twee kwartalen lang voor me uit geschoven. Vorige week heb ik het eindelijk gedaan. Het model is 30 regels code. De dataset kostte weken. Recall@50 ging van 51% op de OpenAI baseline naar 84% met hard negatives plus BM25 fusion. Dit is de pipeline, de guardrails voor synthetic data en de nuchtere vraag vooraf: wanneer is dit de moeite waard en wanneer volstaan standaard embeddings gewoon.

Elke week duik ik diep in een AI-onderwerp en test ik het onder echte omstandigheden. Vorige week dook ik in iets wat ik maandenlang had vermeden: zelf een embeddingmodel trainen. Een embeddingmodel zet tekst om in rijen getallen, zodat een zoeksysteem kan zien welke teksten qua betekenis op elkaar lijken. Helemaal vanaf nul, speciaal voor ons interne domein.

Ik had het stilletjes elke sprint verder vooruitgeschoven. "Gebruik gewoon OpenAI embeddings" voelde als het veilige antwoord. En dat werkt, tot het moment dat het niet meer werkt. Tot je gebruikers beginnen te zoeken met intern jargon, productcodenamen en die ene afkorting die alleen drie mensen in het bedrijf begrijpen. Dan begint je "semantische zoekfunctie" verdacht veel te voelen als keyword search in vermomming.

Het omslagpunt kwam toen ik een support engineer vier zoekopdrachten op rij zag mislukken terwijl hij documentatie probeerde te vinden over een specifieke foutconditie. De zin die hij typte kwam nergens in de docs voor, omdat de docs het "throughput-limit excursion" noemden en het team het "TLE-7 spikes" noemde. Een general-purpose embeddingmodel weet niet dat die twee termen hetzelfde bedoelen.

Eerst de nuchtere vraag: moet jij dit willen?

Voor de meeste bedrijven is dit niet de eerste stap: draai je op generieke content, dan kom je met een standaard embedding-API plus klassieke keyword search prima uit. Dit wordt pas interessant als je gebruikers een eigen taal spreken: interne afkortingen, foutcodes, duizenden artikelnummers, documentatie die anders geschreven is dan mensen zoeken.

Zit je wel in die situatie, dan is de realistische eerste stap geen GPU huren maar meten: verzamel een paar honderd echte zoekopdrachten met het juiste antwoord erbij en score daarmee je huidige zoekfunctie. Gaat een standaardmodel daar structureel de mist in op jouw vocabulaire, dan wordt zelf trainen interessant. Bij veel Nederlandse bedrijven komt daar nog een stap voor: documentatie op orde en zoekopdrachten überhaupt loggen, anders valt er niets te meten.

Wat ik daadwerkelijk gebouwd heb

Ik begon met een MiniLM-basismodel, een klein open source model van 22 miljoen parameters dat op een gewone CPU draait en vectoren van 384 dimensies oplevert, en finetunede het met sentence-transformers, de standaardbibliotheek voor dit werk. De trainingsloop zelf is misschien 30 regels code. Het model was niet het lastige deel. De data was dat wel.

Wat weken kostte, was het bouwen van een schone dataset van query/passage-paren (een zoekopdracht gekoppeld aan het stuk tekst met het antwoord) die weergeven hoe onze gebruikers praten, niet hoe onze docs geschreven zijn. Dat zijn twee heel verschillende talen, en een model dat getraind is op de tweede blijft falen op de eerste.

Domain embedding pipeline, corpus, synthetic query generation with filters, contrastive training with hard negatives, hybrid retrieval, and evaluation metrics

Synthetic data, met guardrails

We hadden geen clicklogs: geen echte zoekopdrachten met kliks om trainingsparen uit te halen. Dus moest ik terugvallen op synthetic data: een LLM die plausibele queries genereert vanuit elke passage in onze corpus. Zo'n 18.000 passages, 6 kandidaat-queries per passage, 108K ruwe paren.

Het risico met synthetic data is dat je de fraseringseigenaardigheden van de LLM inbrandt in je model. Als elke query begint met "Hoe doe ik..." en je echte gebruikers fragmentarische zinnen typen zoals "tle-7 retry", dan heb je een model getraind op een populatie die niet bestaat.

Daarom bouwde ik er drie guardrails omheen:

  1. Agressieve deduplicatie. Met MinHash LSH (spoort bijna-identieke teksten snel op) bij 0,85 Jaccard-similarity over genormaliseerde tekst. Dat elimineerde ongeveer 14% van de synthetic queries die parafrases van elkaar waren.
  2. Cross-encoder scoring. Ik liet elk (query, passage)-paar door een cross-encoder lopen, een zwaarder model dat beide samen leest en beoordeelt of ze echt bij elkaar horen, en verwijderde het onderste kwartiel. Sommige queries van de LLM waren simpelweg niet te beantwoorden vanuit de passage waarop ze gebaseerd waren. Makkelijker om ze te droppen dan om ze te debuggen.
  3. Harde cap per document. Niet meer dan 4 queries per passage. Zonder dit onthoudt het model gewoon een paar uitgebreide pagina's en doet het alsof het een zoeksysteem is.

Na alle drie de filters bleven er van de 108K paren nog 47K over. Dat is de dataset waarop ik daadwerkelijk trainde.

Contrastive loss bracht me tot 68%

De eerste trainingsronde gebruikte contrastive loss: het model leert welke query bij welke passage hoort door goede en foute combinaties tegenover elkaar te zetten. De foute kwamen uit de batch zelf: in-batch negatives. Elke batch van 64 paren geeft je 64 positives en 4.032 impliciete negatives: elke andere passage in de batch is iets waar de query niet bij zou moeten matchen.

Na drie epochs was het model al behoorlijk goed. Recall@50, het percentage zoekopdrachten waarbij het juiste document in de top 50 zit, ging van 51% (op de OpenAI baseline) naar 68%. NDCG@10, een maat voor hoe goed de beste resultaten bovenaan staan, ging van 0,42 naar 0,55.

Maar het deed nog steeds vage keyword matching. Queries waarbij het model de bedoeling moest onderscheiden (bijvoorbeeld "reset" in de betekenis van wachtwoord versus "reset" in de betekenis van fabrieksreset) waren voornamelijk hit-or-miss.

Hard negatives brachten me de rest

De doorbraak was hard-negative mining: gericht trainen op antwoorden die er bijna goed uitzien maar het niet zijn. In-batch negatives zijn te makkelijk: het model leert moeiteloos "TLE-7 timeout" te onderscheiden van "handleiding espressomachine ontkalken" omdat ze geen enkele woordenschat delen. De onderscheidingen die er echt toe doen, zitten tussen passages die er net naast zitten: zusterparagrafen onder dezelfde kop, aangrenzende productversies, dezelfde procedure voor een andere SKU.

Ik liet het gedeeltelijk getrainde model los op de corpus, vond de top-50 kandidaten per query en labelde welke niet de gouden passage waren. Dat is 47K queries × ~10 hard negatives = ~470K harde paren. Vervolgens hertrainde ik met zowel random als hard negatives in een 1:1-mix.

Het minen van de negatives duurde ongeveer net zo lang als het bouwen van de oorspronkelijke dataset. Dat deel staat meestal niet in de tutorials.

De tweede trainingsronde bracht Recall@50 van 68% naar 78% en NDCG@10 van 0,55 naar 0,61. In die laatste 10 punten stopte het model met vage keyword matching en begon het daadwerkelijk intentie op te pikken.

Hybride retrieval is geen vals spelen

Een paar dingen die ik mijn vroegere zelf zou vertellen: stuk voor stuk zaken die ik met vallen en opstaan heb geleerd.

Hybride retrieval is geen vals spelen. BM25, de klassieke keyword-zoektechniek in de kern van vrijwel elke zoekmachine, wint nog steeds bij exacte ID's, foutcodes en SKU-strings. Het getrainde embeddingmodel wint bij vage intentie. Reciprocal rank fusion, een simpele formule die de twee ranglijsten samenvoegt, verslaat beide afzonderlijk met 4 tot 6 punten NDCG. Fuseer ze gewoon en ga verder. Er één kiezen is een richtingenstrijd die niets oplost.

Chunk op echte grenzen. Voor indexering knip je documenten in chunks. Ik chunkte op 256 tot 512 tokens met een overlap van 64 tokens op kop- en paragraafgrenzen. Eerdere pogingen met fixed-window chunking splitsten zinnen midden in een instructie en kostten 8 punten NDCG.

Normaliseer je embeddings. L2-normaliseer alles (elke vector dezelfde lengte) voordat je indexeert. Cosine similarity is dan geen gokspel meer.

Versiebeheer alles. Model, tokenizer, index-parameters, corpus-snapshot, hash van de trainingsdata. "Wat was de corpus toen deze score behaald werd?" is de meest gestelde vraag in elk retrieval-project.

Hoe de cijfers eruitzien

De uiteindelijke eval-set bestond uit 800 handmatig beoordeelde queries uit echte supporttickets, gescoord tegen een vaste corpus-snapshot. MRR@10 zegt hoe hoog het eerste goede resultaat gemiddeld staat. Drie rijen die ertoe doen:

OpzetRecall@50NDCG@10MRR@10
Baseline (OpenAI text-embedding-3-small)51%0,420,34
Fine-tuned MiniLM (alleen in-batch negatives)68%0,550,47
Fine-tuned MiniLM + hard negatives + BM25 fusion84%0,670,58

Maar de metriek waar het mij uiteindelijk om gaat, laat zich niet zo makkelijk in cijfers vangen. De zoekopdrachten die ons vroeger in verlegenheid brachten, gaven nu al bij de eerste poging het juiste resultaat. De supporttickets van het type "ik kon niets vinden over..." daalden van ongeveer 3 per dag naar 1 per week.

Wat er veranderde in mijn mentale model

Voor dit experiment behandelde ik embeddings als een commodity: kies een leverancier, roep de API aan, ga verder. Dat werkt voor generieke content. Het valt in duigen zodra je gebruikers een eigen woordenschat hebben, en mijn inschatting is dat dit voor de meeste interne systemen geldt: supportkennisbanken, technische documentatie, productcatalogi met duizenden artikelen.

Drie dingen die ik iedereen zou meegeven die dit overweegt:

  1. De data is het werk. Reken op minstens 2× de tijd die je denkt te besteden aan data en 0,2× de tijd die je denkt te besteden aan het model.
  2. Hard negatives zijn niet optioneel. Daar zit het onderscheidingsvermogen daadwerkelijk in. Als je ze overslaat, train je een vage keyword search.
  3. Kies niet één retrieval-model. Hybride is het saaie, correcte antwoord. Fuseer BM25 met dense, ship het, stop met erover te discussiëren.

De grootste les: je eigen embeddings trainen gaat niet om het najagen van een leaderboardscore. Het gaat om het vastleggen van de taal van je gebruikers en de structuur van je content, op een manier die geen general-purpose model standaard voor je doet. Dat is niet iets wat je kunt downloaden.