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
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:
| Section | Share | Content |
| 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.
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:
- Clean extraction: strip headers/footers, fix column-break word splits
- Parse by structure: recitals by number, articles by paragraph, definitions individually, annexes by item
- Size guardrails: target 50–1,000 tokens. Oversized chunks split at sentence boundaries with overlap; tiny chunks merged with neighbours
- 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.
| Split | Pairs | Chunks |
| Train | 1,944 | 486 (85%) |
| Eval | 340 | 85 (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.
Baselines: How Good Is Zero-Shot?
| Model | NDCG@10 (dim=1024) | Notes |
| multilingual-e5-large | 0.8612 | Open-source encoder, 560M params |
| Qwen3-Embedding-0.6B | 0.8013 | Open-source decoder, 620M params |
| Qwen3-Embedding-4B | ~0.88 | Open-source decoder, 4B params |
| Qwen3-Embedding-8B | 0.8836 | Open-source decoder, 8B params |
| OpenAI text-embedding-3-large | 0.8635 | Proprietary, 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 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.
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: The EU AI Act Benchmark
The Headline Numbers
| Model | Zero-shot | Stage 1 | Stage 2 | Total Δ |
| multilingual-e5-large (Colab, batch 64) | 0.8612 | 0.9436 | 0.9465 | +0.0853 |
| multilingual-e5-large (RTX, batch 8) | 0.8612 | 0.9327 | 0.9492 | +0.0880 |
| Qwen3-Embedding-0.6B | 0.8013 | 0.9419 | 0.9467 | +0.1454 |
| Qwen3-Embedding-4B (LoRA) | ~0.88 | 0.9631 | 0.9658 | ~+0.09 |
| Qwen3-Embedding-8B (LoRA) | 0.8836 | 0.9625 | 0.9625 | +0.0789 |
| OpenAI text-embedding-3-large | 0.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
| Dim | e5-large zero-shot | e5-large fine-tuned | Retention |
| 1024 | 0.8612 | 0.9465 | 100% |
| 512 | 0.8495 | 0.9412 | 99.4% |
| 256 | 0.7848 | 0.9423 | 99.6% |
| 128 | 0.7283 | 0.9277 | 98.0% |
| 64 | 0.6009 | 0.9058 | 95.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
| Dim | OpenAI | Qwen3-4B LoRA | Δ |
| 1024 | 0.8635 | 0.9658 | +0.1023 |
| 512 | 0.8573 | 0.9526 | +0.0953 |
| 256 | 0.8166 | 0.9420 | +0.1254 |
| 128 | 0.7598 | 0.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
| Config | In-batch neg | Stage 1 | Stage 2 | S2 Δ |
| Batch 8 | 7 | 0.9327 | 0.9492 | +0.0165 |
| Batch 64 | 63 | 0.9436 | 0.9465 | +0.0029 |
| Batch 128 | 127 | 0.9422 | 0.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.
| Model | GDPR 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-large | 0.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
| Model | Params | NDCG@10 | vs. OpenAI |
| OpenAI text-embedding-3-large | Unknown | 0.8635 | — |
| multilingual-e5-large (fine-tuned) | 560M | 0.9492 | +0.0857 |
| Qwen3-Embedding-0.6B (fine-tuned) | 620M | 0.9467 | +0.0832 |
| Qwen3-Embedding-8B LoRA | 8B | 0.9625 | +0.0990 |
| Qwen3-Embedding-4B LoRA | 4B | 0.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.
What I'd Do Differently
- Sweep hyperparameters from the start. A minimal sweep (3 LRs × 3 epoch counts = 9 runs) would confirm near-optimal configuration.
- Filter hard negatives from the beginning. Adding margin and range-based filtering should have been the default.
- Start with CachedMNRL. Skip the batch-size constraints entirely.
- 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
- Chunk structurally. Respect the document's natural boundaries.
- Generate synthetic queries with an LLM. 3–5 diverse queries per chunk.
- Split by chunk, not by pair. Prevent data leakage.
- Stage 1: CachedMNRL + MatryoshkaLoss. Batch size 128, mini-batch 4, 3 epochs.
- Stage 2: Mine hard negatives from Stage 1. 1 filtered hard negative per query, lower LR, 2 epochs.
- For models > 1B params: use LoRA. Rank 16, alpha 32.
- 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
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:
| Sectie | Aandeel | Inhoud |
| 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.
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:
- Schone extractie: headers/footers verwijderen, kolombreuk-woordsplitsingen herstellen
- Parseren op structuur: overwegingen op nummer, artikelen op lid, definities individueel, bijlagen per item
- Groottegrenzen: target 50–1.000 tokens. Te grote chunks splitsen op zinsgrenzen met overlap; te kleine chunks samenvoegen met buren
- 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.
| Split | Paren | Chunks |
| Train | 1.944 | 486 (85%) |
| Eval | 340 | 85 (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.
Baselines: Hoe goed is Zero-Shot?
| Model | NDCG@10 (dim=1024) | Opmerkingen |
| multilingual-e5-large | 0.8612 | Open-source encoder, 560M params |
| Qwen3-Embedding-0.6B | 0.8013 | Open-source decoder, 620M params |
| Qwen3-Embedding-4B | ~0.88 | Open-source decoder, 4B params |
| Qwen3-Embedding-8B | 0.8836 | Open-source decoder, 8B params |
| OpenAI text-embedding-3-large | 0.8635 | Proprietary, 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-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.
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: De EU AI Act Benchmark
De Hoofdcijfers
| Model | Zero-shot | Fase 1 | Fase 2 | Totaal Δ |
| multilingual-e5-large (Colab, batch 64) | 0.8612 | 0.9436 | 0.9465 | +0.0853 |
| multilingual-e5-large (RTX, batch 8) | 0.8612 | 0.9327 | 0.9492 | +0.0880 |
| Qwen3-Embedding-0.6B | 0.8013 | 0.9419 | 0.9467 | +0.1454 |
| Qwen3-Embedding-4B (LoRA) | ~0.88 | 0.9631 | 0.9658 | ~+0.09 |
| Qwen3-Embedding-8B (LoRA) | 0.8836 | 0.9625 | 0.9625 | +0.0789 |
| OpenAI text-embedding-3-large | 0.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
| Dim | e5-large zero-shot | e5-large fine-tuned | Retentie |
| 1024 | 0.8612 | 0.9465 | 100% |
| 512 | 0.8495 | 0.9412 | 99.4% |
| 256 | 0.7848 | 0.9423 | 99.6% |
| 128 | 0.7283 | 0.9277 | 98.0% |
| 64 | 0.6009 | 0.9058 | 95.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
| Dim | OpenAI | Qwen3-4B LoRA | Δ |
| 1024 | 0.8635 | 0.9658 | +0.1023 |
| 512 | 0.8573 | 0.9526 | +0.0953 |
| 256 | 0.8166 | 0.9420 | +0.1254 |
| 128 | 0.7598 | 0.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
| Config | In-batch neg | Fase 1 | Fase 2 | F2 Δ |
| Batch 8 | 7 | 0.9327 | 0.9492 | +0.0165 |
| Batch 64 | 63 | 0.9436 | 0.9465 | +0.0029 |
| Batch 128 | 127 | 0.9422 | 0.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.
| Model | AVG 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-large | 0.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
| Model | Params | NDCG@10 | vs. OpenAI |
| OpenAI text-embedding-3-large | Onbekend | 0.8635 | — |
| multilingual-e5-large (fine-tuned) | 560M | 0.9492 | +0.0857 |
| Qwen3-Embedding-0.6B (fine-tuned) | 620M | 0.9467 | +0.0832 |
| Qwen3-Embedding-8B LoRA | 8B | 0.9625 | +0.0990 |
| Qwen3-Embedding-4B LoRA | 4B | 0.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.
Wat ik anders zou doen
- Hyperparameters sweepen vanaf het begin. Een minimale sweep (3 LR's × 3 epochs = 9 runs) zou de configuratie bevestigen.
- Hard negatives filteren vanaf het begin. Margin- en range-gebaseerd filteren had de standaard moeten zijn.
- Beginnen met CachedMNRL. De batch-size beperkingen overslaan.
- 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
- Chunk structureel. Respecteer de natuurlijke grenzen van het document.
- Genereer synthetische queries met een LLM. 3–5 diverse queries per chunk.
- Splits op chunk, niet op paar. Voorkom datalekkage.
- Fase 1: CachedMNRL + MatryoshkaLoss. Batch size 128, mini-batch 4, 3 epochs.
- Fase 2: Mine hard negatives uit Fase 1. 1 gefilterde hard negative per query, lagere LR, 2 epochs.
- Voor modellen > 1B params: gebruik LoRA. Rank 16, alpha 32.
- 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.