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

18 maart 2026

Zelf een embeddingmodel trainen: recall van 51 naar 84 procent op ons eigen jargon

Op de roadmap had ik het trainen van eigen embeddings twee kwartalen lang voor me uit geschoven. Vorige week heb ik het gedaan. De trainingsloop is 30 regels code, de dataset eronder 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.

Jermaya Leijen

Jermaya Leijen

Google Ads-specialist & AI-engineer

Elke week duik ik diep in een AI-onderwerp en test ik het onder echte omstandigheden. Vorige week was dat 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 prima, totdat je gebruikers gaan zoeken met intern jargon, productcodenamen en die ene afkorting die alleen drie mensen in het bedrijf begrijpen. Dan gedraagt je "semantische zoekfunctie" zich alsnog als een gewone keyword search.

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. De weken gingen op aan de dataset eronder.

Die dataset bestaat uit query/passage-paren: een zoekopdracht gekoppeld aan het stuk tekst met het antwoord. Ze moeten weergeven hoe onze gebruikers praten, in plaats van 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. Ze weggooien was makkelijker dan uitzoeken waar het misging.
  3. Harde cap per document. Niet meer dan 4 queries per passage. Zonder die cap leert het model vooral een paar uitgebreide pagina's uit het hoofd.

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 grotendeels gokwerk.

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.

Wat ik met vallen en opstaan leerde

Vier dingen die ik mijn vroegere zelf zou vertellen.

Hybride retrieval is geen vals spelen. BM25, de klassieke keyword-scoringsmethode die standaard in Elasticsearch en OpenSearch zit, 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 en ga verder; de keuze tussen die twee hoef je niet te maken.

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. Een inner-product-index geeft dan dezelfde volgorde als cosine similarity, en dat scheelt een klasse verwarring bij het afstemmen van je index.

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 kijkt naar de positie van het eerste goede resultaat: hoe hoger die gemiddeld staat, hoe hoger de score. 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

Het resultaat waar het mij uiteindelijk om gaat, staat niet in die tabel. 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 standaardonderdeel dat je inkoopt: kies een leverancier, roep de API aan, ga verder. Dat werkt voor generieke content, en het valt uit elkaar zodra je gebruikers een eigen woordenschat hebben. 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 dataset bepaalt je planning. 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 en juiste antwoord: fuseer BM25 met dense en zet het live.

De grootste les: eigen embeddings trainen levert je geen hoge leaderboardscore op. Het legt de taal van je gebruikers en de structuur van je content vast, op een manier die een general-purpose model standaard niet voor je doet.

Jermaya Leijen

Over de auteur

Jermaya Leijen

Hoi, ik ben Jermaya. Sinds 2013 zit ik in Google Ads en de laatste jaren bouw ik AI-agents die het repeterende werk overnemen. Hier schrijf ik op wat ik in de praktijk tegenkom: wat werkt, wat niet, en hoe ik het zelf zou aanpakken. Een vraag of gewoon even sparren? Ik lees alles. Bekijk mijn werk of stuur me een bericht.