Settings


How Fine-Tuning Embeddings Beats OpenAI's Flagship Embedder on Dutch Legal Retrieval

A practical deep-dive into fine-tuning embedding models for domain-specific RAG, from structural chunking to Matryoshka loss, hard negative mining, and lessons learned on bleeding-edge GPU hardware.

Daniel Noumon19-04-2026

Context

Introduction

Retrieval-augmented generation (RAG) over Dutch legal documents requires an embedding model that can match a user's question to the exact article or paragraph that answers it, out of hundreds of chunks.

Off-the-shelf embedding models like OpenAI's text-embedding-3-large are impressively general. They handle 100+ languages, score near the top of MTEB benchmarks, and work out of the box. But "general" isn't "specialised". When your corpus is a 144-page Dutch regulation full of cross-references, numbered paragraphs, and domain-specific terminology, a general model leaves a lot of retrieval quality on the table.

This post documents my experiments fine-tuning open-source embedding models on the EU AI Act (Dutch translation), a single legal document, and how a few hours of GPU time turned generic models into domain specialists that outperform proprietary SOTA by a wide margin.

The headline result: my best fine-tuned model (Qwen3-Embedding-4B with LoRA) achieved NDCG@10 = 0.9658, compared to OpenAI's text-embedding-3-large at 0.8635, a gap of over 10 points on the same evaluation set. Even my smallest model (560M parameters) beat OpenAI by 8.6 points after fine-tuning.

The Document

The EU AI Act (eu_ai_act_NL.pdf) is a 144-page Dutch legal regulation with three distinct zones:

SectionShareContent
Recitals (Overwegingen)~42%180 numbered recitals providing legislative intent
Articles (Artikelen)~49%113 articles across 13 chapters, the binding provisions
Annexes (Bijlagen)~8%13 annexes with reference lists and technical requirements

It's a challenging retrieval target: dense legal language, extensive cross-referencing between articles, and a hierarchy of chapters, articles, paragraphs (leden), and sub-items that carry semantic meaning.

Data Preparation

Structural Chunking

The first decision was how to split the document into retrievable chunks. The naive approach (fixed-size splitting at 512 tokens) would cut articles mid-sentence and lose legal context. A structural chunker respects the document's natural boundaries.

My approach:

  1. Clean extraction: strip headers/footers, fix column-break word splits
  2. Parse by structure: recitals by number, articles by paragraph, definitions individually, annexes by item
  3. Size guardrails: target 50–1,000 tokens. Oversized chunks split at sentence boundaries with overlap; tiny chunks merged with neighbours
  4. Rich metadata: each chunk carries section_type, chapter, article_number, paragraph_number, hierarchy_path

Result: 573 chunks (223 recitals, 329 articles, 21 annexes). Token range 50–1,015, average 283.

Synthetic Training Data

I needed (query, relevant_chunk) pairs to train the embedding model. Real user queries don't exist yet, so I generated them with an LLM.

For each chunk, I generated 3–5 diverse Dutch queries across types: factual, definitional, procedural, and scenario-based.

The result: 2,284 query-chunk pairs from 571 unique chunks. I split by chunk ID (not by pair) to prevent data leakage.

SplitPairsChunks
Train1,944486 (85%)
Eval34085 (15%)

Evaluation Methodology

Metric: NDCG@10 (Normalized Discounted Cumulative Gain at rank 10). It measures how well the model ranks the correct passage within the top 10 results for each query.

Eval set: 340 queries mapped to 85 unique corpus chunks (15% of the data, split by chunk ID to prevent leakage).

Protocol: Sentence Transformers' InformationRetrievalEvaluator encodes all queries and corpus chunks, ranks by cosine similarity, and computes NDCG@10, MRR@10, Recall@10, and Accuracy@k. The best checkpoint (by NDCG@10 at dim=1024) is kept.

Later cross-domain evaluation uses a much harder benchmark: 912 chunks across two documents (EU AI Act + Dutch GDPR), with 5,472 queries.

Training

Baselines: How Good Is Zero-Shot?

ModelNDCG@10 (dim=1024)Notes
multilingual-e5-large0.8612Open-source encoder, 560M params
Qwen3-Embedding-0.6B0.8013Open-source decoder, 620M params
Qwen3-Embedding-4B~0.88Open-source decoder, 4B params
Qwen3-Embedding-8B0.8836Open-source decoder, 8B params
OpenAI text-embedding-3-large0.8635Proprietary, via Azure API

The proprietary model and the open-source e5-large start at virtually the same level (0.8635 vs 0.8612). None break 0.90 without fine-tuning.

The Training Pipeline

Stage 1: In-Batch Negatives (MNRL)

Multiple Negatives Ranking Loss (MNRL) treats every other passage in the batch as a negative example. With batch size 128, each query gets 127 negatives "for free".

I wrapped MNRL in MatryoshkaLoss, which trains the model to produce useful embeddings at multiple truncated dimensionalities (1024, 768, 512, 256, 128, 64) simultaneously.

Matryoshka Representation Learning

Matryoshka embeddings: like Russian nesting dolls, each prefix of the full embedding vector is trained to be independently useful. Source: Hugging Face blog.

Stage 2: Hard Negative Mining

After Stage 1, the model may struggle with confusing near-misses: passages that are topically similar but answer a different question.

I used the Stage 1 model to mine hard negatives: encode all queries and chunks, rank by cosine similarity, exclude the correct positive, take the most similar wrong chunks. Mining from the adapted model produces more informative negatives than from the base.

Training loss curves

W&B training loss for the four Qwen3 runs. Stage 1 trains for 3 epochs vs 2 for Stage 2. All runs converge to ~2.

The Models

multilingual-e5-large (560M, encoder)

A well-established multilingual retrieval model with 1024-dim embeddings and a maximum of 512 tokens.

Qwen3-Embedding-0.6B (620M, decoder)

A decoder-based embedding model from Alibaba. Despite being decoder-based, it performs comparably to encoder models thanks to massive pre-training.

Qwen3-Embedding-4B (4B, decoder, LoRA)

Too large for full fine-tuning on 32GB. With LoRA (rank 16), I trained just 11.8M parameters (0.29% of the total) — enough to outperform every other model.

Qwen3-Embedding-8B (8B, decoder, LoRA)

The largest model: 7.6B parameters, #1 on MTEB multilingual leaderboard. Pushed my 32GB RTX 5090 to its absolute limit: mini_batch_size=1 for both stages.

Results

Results: The EU AI Act Benchmark

The Headline Numbers

ModelZero-shotStage 1Stage 2Total Δ
multilingual-e5-large (Colab, batch 64)0.86120.94360.9465+0.0853
multilingual-e5-large (RTX, batch 8)0.86120.93270.9492+0.0880
Qwen3-Embedding-0.6B0.80130.94190.9467+0.1454
Qwen3-Embedding-4B (LoRA)~0.880.96310.9658~+0.09
Qwen3-Embedding-8B (LoRA)0.88360.96250.9625+0.0789
OpenAI text-embedding-3-large0.8635

The 4B LoRA model, training just 0.29% of its parameters on 1,944 synthetic pairs, beats OpenAI's best embedding API by over 10 points.

Matryoshka: Quality at Every Size

Dime5-large zero-shote5-large fine-tunedRetention
10240.86120.9465100%
5120.84950.941299.4%
2560.78480.942399.6%
1280.72830.927798.0%
640.60090.905895.7%

After fine-tuning, dim=64 retains 95.7% of dim=1024's quality. You can use 64-dimensional embeddings (16× less storage, 16× faster search) with barely any quality loss.

vs. Proprietary SOTA

DimOpenAIQwen3-4B LoRAΔ
10240.86350.9658+0.1023
5120.85730.9526+0.0953
2560.81660.9420+0.1254
1280.75980.9188+0.1590

The gap widens at lower dimensions. Domain-specific fine-tuning with MatryoshkaLoss teaches the model to be useful at every dimensionality.

The Surprising Findings

1. Hard negatives are a great equalizer

ConfigIn-batch negStage 1Stage 2S2 Δ
Batch 870.93270.9492+0.0165
Batch 64630.94360.9465+0.0029
Batch 1281270.94220.9463+0.0041

Pipeline totals converge regardless of Stage 1 batch size. Practical implication: if you're VRAM-constrained, a smaller batch with hard negatives in Stage 2 gets you to the same place.

2. More hard negatives can hurt

Mining 5 raw hard negatives per query caused a regression (0.9398 vs 0.9463 with 1). The fix: filtering with range_min=5, margin=0.1, max_score=0.9. For datasets under ~10K pairs, 1 filtered hard negative per query is the sweet spot.

3. LoRA on 0.29% of parameters beats full fine-tuning

The 4B model, training just 11.8M out of 4,034M parameters, outperformed the fully fine-tuned 560M e5-large and 620M Qwen3-0.6B at every dimension. LoRA's low-rank constraint acts as an implicit regulariser.

Cross-Domain Evaluation: Does It Generalise?

To test generalisation, I chunked and generated queries for the Dutch GDPR (AVG), a completely separate legal document no model saw during training: 377 chunks, 2,262 queries.

ModelGDPR NDCG@10Δ vs zero-shot
multilingual-e5-large (zero-shot)0.6475
Qwen3-0.6B (zero-shot)0.6007
Qwen3-4B (zero-shot)0.7179
OpenAI text-embedding-3-large0.6733
multilingual-e5-large (EU AI Act FT)0.7311+0.0836
Qwen3-0.6B (EU AI Act FT)0.7110+0.1103
Qwen3-4B (EU AI Act FT)0.7900+0.0721
Qwen3-8B (EU AI Act FT)0.8053+0.0705

Every fine-tuned model improved on a document it never saw (+7–11 points). This is not memorisation: they learn transferable Dutch legal retrieval patterns.

Open-source zero-shot beats proprietary. On the harder GDPR benchmark, Qwen3-4B zero-shot outperforms OpenAI by +4.5 pts without any fine-tuning.

Hardware: Training on an RTX 5090

All training ran on a single NVIDIA RTX 5090 (32GB VRAM, Blackwell sm_120).

CachedMNRL (GradCache)

CachedMultipleNegativesRankingLoss decouples the contrastive pool size from VRAM: embed all N samples without gradients, compute the full N×N similarity matrix, then re-embed with gradients. With batch_size=128 and mini_batch_size=4, each query sees 127 negatives while only 4 samples occupy VRAM at any time.

Training the 8B model on 32GB

Base weights consume ~16GB in bf16, leaving ~16GB for everything else. Even with LoRA and flash_attention_2, mini_batch_size had to be 1. The trickiest issue: the script OOM'd during final evaluation. The fix: eval_batch_size=1 and always merge_and_unload() before evaluation.

Final Scoreboard

ModelParamsNDCG@10vs. OpenAI
OpenAI text-embedding-3-largeUnknown0.8635
multilingual-e5-large (fine-tuned)560M0.9492+0.0857
Qwen3-Embedding-0.6B (fine-tuned)620M0.9467+0.0832
Qwen3-Embedding-8B LoRA8B0.9625+0.0990
Qwen3-Embedding-4B LoRA4B0.9658+0.1023

A few hours of training on synthetic data, a single GPU, and €0.75 in electricity. Domain-specific fine-tuning isn't optional for serious RAG. It's the single highest-leverage investment you can make.

Reflections

What I'd Do Differently

  1. Sweep hyperparameters from the start. A minimal sweep (3 LRs × 3 epoch counts = 9 runs) would confirm near-optimal configuration.
  2. Filter hard negatives from the beginning. Adding margin and range-based filtering should have been the default.
  3. Start with CachedMNRL. Skip the batch-size constraints entirely.
  4. Evaluate on a larger corpus sooner. Small eval sets lie.

Next Steps

The natural next step is multi-document fine-tuning on EU AI Act + GDPR + UAVG jointly. Early results show NDCG@10 = 0.9036 on a combined eval set. Further improvements: cross-document multi-hop queries, quality scoring/filtering, lower contrastive temperature, and hyperparameter sweeps.

The Recipe

  1. Chunk structurally. Respect the document's natural boundaries.
  2. Generate synthetic queries with an LLM. 3–5 diverse queries per chunk.
  3. Split by chunk, not by pair. Prevent data leakage.
  4. Stage 1: CachedMNRL + MatryoshkaLoss. Batch size 128, mini-batch 4, 3 epochs.
  5. Stage 2: Mine hard negatives from Stage 1. 1 filtered hard negative per query, lower LR, 2 epochs.
  6. For models > 1B params: use LoRA. Rank 16, alpha 32.
  7. Evaluate on a realistic corpus. At least hundreds of chunks.

Total wall-clock time: under 2 hours on a single GPU for 0.6B, ~3 hours for 4B with LoRA.


All code, training scripts, and model cards are available in the repository. Models on HuggingFace: qwen3-embedding-0.6b-ai-act-nl, qwen3-embedding-4b-ai-act-nl.

Hoe Fine-Tuning van Embeddings OpenAI's Flagship Embedder Verslaat op Nederlandse Juridische Retrieval

Een praktische deep-dive in het fine-tunen van embedding-modellen voor domeinspecifieke RAG, van structurele chunking tot Matryoshka loss, hard negative mining, en lessen geleerd op bleeding-edge GPU hardware.

Daniel Noumon19-04-2026

Context

Introductie

Retrieval-augmented generation (RAG) over Nederlandse juridische documenten vereist een embedding-model dat de vraag van een gebruiker kan matchen aan het exacte artikel of de paragraaf die het antwoord geeft, uit honderden chunks.

Kant-en-klare embedding-modellen zoals OpenAI's text-embedding-3-large zijn indrukwekkend generiek. Ze ondersteunen 100+ talen, scoren bovenaan MTEB-benchmarks en werken direct out of the box. Maar "generiek" is niet "gespecialiseerd". Wanneer je corpus een 144 pagina's tellende Nederlandse verordening is vol kruisverwijzingen, genummerde paragrafen en domeinspecifieke terminologie, laat een generiek model veel retrieval-kwaliteit liggen.

Deze blogpost documenteert mijn experimenten met het fine-tunen van open-source embedding-modellen op de EU AI Act (Nederlandse vertaling), een enkel juridisch document, en hoe enkele uren GPU-tijd generieke modellen transformeerde tot domeinspecialisten die proprietary SOTA met ruime marge overtreffen.

Het hoofdresultaat: mijn best fine-tuned model (Qwen3-Embedding-4B met LoRA) behaalde NDCG@10 = 0.9658, vergeleken met OpenAI's text-embedding-3-large op 0.8635, een verschil van meer dan 10 punten op dezelfde evaluatieset. Zelfs mijn kleinste model (560M parameters) versloeg OpenAI met 8.6 punten na fine-tuning.

Het Document

De EU AI Act (eu_ai_act_NL.pdf) is een 144 pagina's tellende Nederlandse juridische verordening met drie zones:

SectieAandeelInhoud
Overwegingen~42%180 genummerde overwegingen met wetgevende intentie
Artikelen~49%113 artikelen over 13 hoofdstukken, de bindende bepalingen
Bijlagen~8%13 bijlagen met referentielijsten en technische vereisten

Het is een uitdagend retrieval-doel: dichte juridische taal, uitgebreide kruisverwijzingen, en een hiërarchie van hoofdstukken, artikelen, leden en sub-items die semantische betekenis dragen.

Datavoorbereiding

Structurele Chunking

De eerste beslissing was hoe het document op te splitsen in opvraagbare chunks. De naïeve aanpak (vaste grootte van 512 tokens) zou artikelen midden in zinnen doorsnijden. Een structurele chunker respecteert de natuurlijke grenzen van het document.

Mijn aanpak:

  1. Schone extractie: headers/footers verwijderen, kolombreuk-woordsplitsingen herstellen
  2. Parseren op structuur: overwegingen op nummer, artikelen op lid, definities individueel, bijlagen per item
  3. Groottegrenzen: target 50–1.000 tokens. Te grote chunks splitsen op zinsgrenzen met overlap; te kleine chunks samenvoegen met buren
  4. Rijke metadata: elke chunk draagt section_type, chapter, article_number, paragraph_number, hierarchy_path

Resultaat: 573 chunks (223 overwegingen, 329 artikelen, 21 bijlagen). Token-bereik 50–1.015, gemiddeld 283.

Synthetische Trainingsdata

Ik had (query, relevante_chunk)-paren nodig om het embedding-model te trainen. Echte gebruikersvragen bestaan nog niet, dus genereerde ik ze met een LLM.

Per chunk genereerde ik 3–5 diverse Nederlandse queries over typen: feitelijk, definitioneel, procedureel en scenariogebaseerd.

Het resultaat: 2.284 query-chunk paren uit 571 unieke chunks. Ik splitste op chunk-ID (niet op paar) om datalekkage te voorkomen.

SplitParenChunks
Train1.944486 (85%)
Eval34085 (15%)

Evaluatiemethode

Metriek: NDCG@10 (Normalized Discounted Cumulative Gain bij rang 10). Meet hoe goed het model de correcte passage rankt binnen de top 10 resultaten per query.

Evaluatieset: 340 queries gemapt naar 85 unieke corpus-chunks (15% van de data, gesplitst op chunk-ID).

Protocol: Sentence Transformers' InformationRetrievalEvaluator encodeert alle queries en corpus-chunks, rankt op cosine similarity, en berekent NDCG@10, MRR@10, Recall@10 en Accuracy@k. Het beste checkpoint (op NDCG@10 bij dim=1024) wordt bewaard.

Cross-domein evaluatie gebruikt later een veel moeilijkere benchmark: 912 chunks over twee documenten (EU AI Act + Nederlandse AVG), met 5.472 queries.

Training

Baselines: Hoe goed is Zero-Shot?

ModelNDCG@10 (dim=1024)Opmerkingen
multilingual-e5-large0.8612Open-source encoder, 560M params
Qwen3-Embedding-0.6B0.8013Open-source decoder, 620M params
Qwen3-Embedding-4B~0.88Open-source decoder, 4B params
Qwen3-Embedding-8B0.8836Open-source decoder, 8B params
OpenAI text-embedding-3-large0.8635Proprietary, via Azure API

Het proprietary model en de open-source e5-large starten op vrijwel hetzelfde niveau (0.8635 vs 0.8612). Geen enkel model haalt 0.90 zonder fine-tuning.

De Trainingspipeline

Fase 1: In-Batch Negatives (MNRL)

Multiple Negatives Ranking Loss (MNRL) behandelt elke andere passage in de batch als een negatief voorbeeld. Met batch size 128 krijgt elke query 127 negatives "gratis".

Ik wrappte MNRL in MatryoshkaLoss, dat het model traint om bruikbare embeddings te produceren op meerdere afgeknotte dimensionaliteiten (1024, 768, 512, 256, 128, 64) tegelijkertijd.

Matryoshka Representation Learning

Matryoshka-embeddings: zoals Russische matroesjka's, elke prefix van de volledige embedding-vector is getraind om onafhankelijk bruikbaar te zijn. Bron: Hugging Face blog.

Fase 2: Hard Negative Mining

Na Fase 1 kan het model moeite hebben met verwarrende bijna-matches: passages die qua onderwerp vergelijkbaar zijn maar een andere vraag beantwoorden.

Ik gebruikte het Fase 1-model om hard negatives te minen: alle queries en chunks encoderen, ranken op cosine similarity, de correcte positieve uitsluiten, en de meest vergelijkbare foute chunks nemen. Mining vanuit het aangepaste model levert informatievere negatives op dan vanuit het basismodel.

Training loss curves

W&B training loss voor de vier Qwen3-runs. Fase 1 traint 3 epochs vs 2 voor Fase 2. Alle runs convergeren naar ~2.

De Modellen

multilingual-e5-large (560M, encoder)

Een gevestigd meertalig retrieval-model met 1024-dim embeddings en maximaal 512 tokens.

Qwen3-Embedding-0.6B (620M, decoder)

Een decoder-gebaseerd embedding-model van Alibaba. Ondanks de decoder-architectuur presteert het vergelijkbaar met encoder-modellen dankzij massieve pre-training.

Qwen3-Embedding-4B (4B, decoder, LoRA)

Te groot voor volledige fine-tuning op 32GB. Met LoRA (rank 16) trainde ik slechts 11.8M parameters (0.29% van het totaal) — genoeg om elk ander model te overtreffen.

Qwen3-Embedding-8B (8B, decoder, LoRA)

Het grootste model: 7.6B parameters, #1 op het MTEB meertalig leaderboard. Duwde mijn 32GB RTX 5090 tot de absolute limiet: mini_batch_size=1 voor beide fases.

Resultaten

Resultaten: De EU AI Act Benchmark

De Hoofdcijfers

ModelZero-shotFase 1Fase 2Totaal Δ
multilingual-e5-large (Colab, batch 64)0.86120.94360.9465+0.0853
multilingual-e5-large (RTX, batch 8)0.86120.93270.9492+0.0880
Qwen3-Embedding-0.6B0.80130.94190.9467+0.1454
Qwen3-Embedding-4B (LoRA)~0.880.96310.9658~+0.09
Qwen3-Embedding-8B (LoRA)0.88360.96250.9625+0.0789
OpenAI text-embedding-3-large0.8635

Het 4B LoRA-model, dat slechts 0.29% van zijn parameters trainde op 1.944 synthetische paren, verslaat OpenAI's beste embedding API met meer dan 10 punten.

Matryoshka: Kwaliteit op Elke Grootte

Dime5-large zero-shote5-large fine-tunedRetentie
10240.86120.9465100%
5120.84950.941299.4%
2560.78480.942399.6%
1280.72830.927798.0%
640.60090.905895.7%

Na fine-tuning behoudt dim=64 95.7% van de kwaliteit van dim=1024. Je kunt 64-dimensionale embeddings gebruiken (16× minder opslag, 16× sneller zoeken) met nauwelijks kwaliteitsverlies.

vs. Proprietary SOTA

DimOpenAIQwen3-4B LoRAΔ
10240.86350.9658+0.1023
5120.85730.9526+0.0953
2560.81660.9420+0.1254
1280.75980.9188+0.1590

Het verschil groeit bij lagere dimensies. Domeinspecifieke fine-tuning met MatryoshkaLoss leert het model bruikbaar te zijn op elke dimensionaliteit.

De Verrassende Bevindingen

1. Hard negatives zijn een geweldige gelijkmaker

ConfigIn-batch negFase 1Fase 2F2 Δ
Batch 870.93270.9492+0.0165
Batch 64630.94360.9465+0.0029
Batch 1281270.94220.9463+0.0041

Pipeline-totalen convergeren ongeacht de Stage 1 batch size. Praktische implicatie: als je VRAM-beperkt bent, bereik je met een kleinere batch plus hard negatives in Fase 2 dezelfde kwaliteit.

2. Meer hard negatives kunnen schaden

Het minen van 5 ruwe hard negatives per query veroorzaakte een regressie (0.9398 vs 0.9463 met 1). De oplossing: filteren met range_min=5, margin=0.1, max_score=0.9. Voor datasets onder ~10K paren is 1 gefilterde hard negative per query het optimum.

3. LoRA op 0.29% van de parameters verslaat volledige fine-tuning

Het 4B-model, dat slechts 11.8M van 4.034M parameters trainde, overtrof de volledig fine-getunede 560M e5-large en 620M Qwen3-0.6B op elke dimensie. LoRA's low-rank beperking fungeert als impliciete regularisatie.

Cross-Domein Evaluatie: Generaliseert het?

Om generalisatie te testen chunkte ik de Nederlandse AVG (GDPR), een volledig apart juridisch document dat geen model tijdens training heeft gezien: 377 chunks, 2.262 queries.

ModelAVG NDCG@10Δ vs zero-shot
multilingual-e5-large (zero-shot)0.6475
Qwen3-0.6B (zero-shot)0.6007
Qwen3-4B (zero-shot)0.7179
OpenAI text-embedding-3-large0.6733
multilingual-e5-large (EU AI Act FT)0.7311+0.0836
Qwen3-0.6B (EU AI Act FT)0.7110+0.1103
Qwen3-4B (EU AI Act FT)0.7900+0.0721
Qwen3-8B (EU AI Act FT)0.8053+0.0705

Elk fine-tuned model verbeterde op een document dat het nooit heeft gezien (+7–11 punten). Dit is geen memorisatie: ze leren overdraagbare Nederlandse juridische retrieval-patronen.

Open-source zero-shot verslaat proprietary. Op de moeilijkere AVG-benchmark overtreft Qwen3-4B zero-shot OpenAI met +4.5 punten zonder enige fine-tuning.

Hardware: Trainen op een RTX 5090

Alle training draaide op een enkele NVIDIA RTX 5090 (32GB VRAM, Blackwell sm_120).

CachedMNRL (GradCache)

CachedMultipleNegativesRankingLoss ontkoppelt de contrastieve poolgrootte van VRAM: embed alle N samples zonder gradiënten, bereken de volledige N×N similariteitsmatrix, en re-embed met gradiënten. Met batch_size=128 en mini_batch_size=4 ziet elke query 127 negatives terwijl slechts 4 samples VRAM bezetten.

Het 8B-model trainen op 32GB

Basisgewichten nemen ~16GB in bf16 in beslag. Zelfs met LoRA en flash_attention_2 moest mini_batch_size 1 zijn. Het lastigste probleem: het script ging OOM tijdens de laatste evaluatie. De oplossing: eval_batch_size=1 en altijd merge_and_unload() vóór evaluatie.

Eindstand

ModelParamsNDCG@10vs. OpenAI
OpenAI text-embedding-3-largeOnbekend0.8635
multilingual-e5-large (fine-tuned)560M0.9492+0.0857
Qwen3-Embedding-0.6B (fine-tuned)620M0.9467+0.0832
Qwen3-Embedding-8B LoRA8B0.9625+0.0990
Qwen3-Embedding-4B LoRA4B0.9658+0.1023

Enkele uren training op synthetische data, één GPU, en €0.75 aan stroom. Domeinspecifieke fine-tuning is geen optie voor serieuze RAG — het is de investering met de hoogste hefboomwerking die je kunt doen.

Reflecties

Wat ik anders zou doen

  1. Hyperparameters sweepen vanaf het begin. Een minimale sweep (3 LR's × 3 epochs = 9 runs) zou de configuratie bevestigen.
  2. Hard negatives filteren vanaf het begin. Margin- en range-gebaseerd filteren had de standaard moeten zijn.
  3. Beginnen met CachedMNRL. De batch-size beperkingen overslaan.
  4. Eerder evalueren op een groter corpus. Kleine evaluatiesets liegen.

Volgende Stappen

De logische volgende stap is multi-document fine-tuning op EU AI Act + AVG + UAVG gezamenlijk. Vroege resultaten tonen NDCG@10 = 0.9036 op een gecombineerde evaluatieset. Verdere verbeteringen: cross-document multi-hop queries, kwaliteitsscoring/filteren, lagere contrastieve temperatuur, en hyperparameter sweeps.

Het Recept

  1. Chunk structureel. Respecteer de natuurlijke grenzen van het document.
  2. Genereer synthetische queries met een LLM. 3–5 diverse queries per chunk.
  3. Splits op chunk, niet op paar. Voorkom datalekkage.
  4. Fase 1: CachedMNRL + MatryoshkaLoss. Batch size 128, mini-batch 4, 3 epochs.
  5. Fase 2: Mine hard negatives uit Fase 1. 1 gefilterde hard negative per query, lagere LR, 2 epochs.
  6. Voor modellen > 1B params: gebruik LoRA. Rank 16, alpha 32.
  7. Evalueer op een realistisch corpus. Minimaal honderden chunks.

Totale doorlooptijd: minder dan 2 uur op één GPU voor 0.6B, ~3 uur voor 4B met LoRA.


Alle code, trainingsscripts en model cards zijn beschikbaar in de repository. Modellen op HuggingFace: qwen3-embedding-0.6b-ai-act-nl, qwen3-embedding-4b-ai-act-nl.