Lokale re-ranking toevoegen aan je RAG-zoekpipeline
In de vaste ontwikkelroute van lokale AI — kiezen → installeren → gebruiken → koppelen → beheren/serveren — bevinden we ons bij de stap koppelen. Wie documenten heeft klaargezet in een vectoropslag en merkt dat zoekresultaten net niet relevant genoeg zijn, heeft een kwalitatieve tussenstap nodig. Voordat we de opzet uitbreiden, is het verstandig om te controleren of de basisinfrastructuur stevig staat; raadpleeg welke hardware geschikt is voor lokale taalmodellen om te verifiëren of je machine voldoende geheugenbandbreedte heeft voor meervoudige verwerkingsstappen.
Veel lokale RAG-systemen (Retrieval-Augmented Generation) lopen tegen dezelfde frustratie aan: de vectordatabase haalt net de verkeerde tekstblokken op. Een semantische vectorzoekopdracht (bi-encoder) is razendsnel in staat om uit tienduizenden paragrafen de twintig meest waarschijnlijke kandidaten te selecteren, maar mist vaak de fijnmazige precisie om te bepalen welke drie fragmenten écht het antwoord bevatten. Door een lokale re-ranker (cross-encoder) tussen de zoekstap en het generatieve taalmodel te plaatsen, stijgt de contextkwaliteit aanzienlijk zonder dat er zwaardere generatiemodellen nodig zijn.
Waarom bi-encoders tekortschieten in RAG
Een standaard zoekstap in een vectordatabase gebruikt een zogenoemde bi-encoder. Hierbij worden documentchunks en zoekvragen onafhankelijk van elkaar omgezet in compacte vectoren. Het voordeel hiervan is rekensnelheid: de vectoren van alle documenten worden eenmalig berekend en opgeslagen. Tijdens een zoekactie hoeft de database enkel de cosinusgelijkenis te berekenen tussen de vector van de gebruikersvraag en de vooraf opgeslagen documentvectoren.
Deze scheiding heeft echter een fundamentele beperking. Omdat de zoekvraag en het documentfragment elkaar tijdens de wiskundige transformatie nooit "ontmoeten", kunnen complexe nuances zoals ontkenningen, specifieke productcodes of voorwaardelijke zinnen verloren gaan in de samengeperste vectorruimte. Om het overzicht helder te houden over hoe deze vectorbegrippen zich verhouden tot andere concepten, biedt de complete lijst met AI- en machine learning-begrippen een beknopt referentiekader voor onderliggende terminologie.
Een cross-encoder pakt dit anders aan. Deze analyseert de zoekvraag en het kandidaat-tekstblok gelijktijdig via een volledig aandachtsmechanisme (full cross-attention). Elk woord in de vraag heeft interactie met elk woord in het document. Hierdoor kan het model bepalen of een paragraaf werkelijk antwoord geeft op de specifieke vraag, in plaats van enkel vast te stellen dat beide teksten over hetzelfde algemene thema gaan.
De architectuur van een tweetraps-zoekpipeline
Cross-encoders zijn veel te rekenintensief om rechtstreeks op een complete collectie van tienduizenden documenten los te laten. Daarom combineren we het beste van twee werelden in een klassieke tweetraps-architectuur:
In stap één verzorgt de vectordatabase de grove filtering. We halen niet de gebruikelijke top 3 of top 5 fragmenten op, maar verruimen het zoekvenster naar bijvoorbeeld 25 tot 50 kandidaatchunks (hoge recall). In stap twee beoordeelt de lokale re-ranker deze 25 fragmenten één voor één in interactie met de zoekvraag. De re-ranker kent elk fragment een relevantiescore toe tussen 0 en 1. Vervolgens selecteren we uitsluitend de beste 3 tot 5 fragmenten met de hoogste score (hoge precision) en sturen we die door naar de lokale LLM.
Om deze tweetraps-pipeline te voeden heb je een betrouwbare index nodig; in het artikel over een lokale vectordatabase inrichten met Qdrant of Chroma zie je hoe je dergelijke opslagsystemen configureert en klaarmaakt voor grotere ophaalvolumes.
| Eigenschap | Bi-Encoder (Vector Search) | Cross-Encoder (Re-Ranker) |
|---|---|---|
| Verwerking | Gescheiden (vraag en chunk apart) | Gezamenlijk (vraag + chunk gelijktijdig) |
| Snelheid | Extreem snel (< 5 ms over 100k items) | Gemiddeld (20–150 ms voor 25 chunks) |
| Geheugengebruik | Laag (afhankelijk van indexopslag) | 50 MB tot 1.2 GB VRAM/RAM |
| Precisie op top-1 | Matig tot redelijk | Zeer hoog |
| Rol in pipeline | Fase 1: Ophalen (Recall) | Fase 2: Selectie & Ordening (Precision) |
Lokale modellen en bibliotheken kiezen
Voor lokale re-ranking zijn er twee dominante benaderingen: lichtgewicht ONNX-modellen via geoptimaliseerde CPU-runtimes of volwaardige transformer-modellen via PyTorch of C++ backends.
FlashRank is de meest laagdrempelige oplossing. Het draait volledig op CPU via ONNX Runtime, vereist geen zware PyTorch-dependencies en heeft standaard modellen van slechts 40 tot 150 MB (zoals ms-marco-TinyBERT-L-2-v2 en bge-reranker-large in ONNX-formaat). De latentie ligt doorgaans tussen de 15 en 40 milliseconden voor 20 tekstfragmenten op een standaard consumenten-CPU.
BGE-Reranker (BAAI), zoals bge-reranker-v2-m3 of bge-reranker-large, is de gouden standaard wanneer meertaligheid en maximale precisie vereist zijn. De M3-variant ondersteunt meer dan 100 talen en verwerkt contexten tot 8192 tokens. Dit model draait uitstekend op een lokale GPU via HuggingFace Sentence Transformers of via ONNX met DirectML/CUDA acceleratie.
Wie zijn pipeline draait op een Linux-omgeving kan voor maximale prestaties de handleiding voor lokale taalmodellen opzetten onder Linux raadplegen om direct CUDA- of ROCm-stuurprogramma's goed in te stellen.
Praktijkvoorbeeld met Python en FlashRank
Onderstaand script demonstreert hoe we een lijst ruwe zoekresultaten uit een willekeurige databron herordenen met FlashRank. Installeer eerst de minimale runtime via pip install flashrank.
from flashrank import Ranker, RerankRequest
# Initialiseer de lichtgewicht ranker (laadt ONNX-model lokaal)
ranker = Ranker(model_name="ms-marco-TinyBERT-L-2-v2", cache_dir="./opt/models")
query = "Wat is het maximale vermogen van de laadpaal bij piekbelasting?"
# Gesimuleerde ruwe opbrengst uit de eerste zoekfase (top-k = 5)
ruwe_chunks = [
{
"id": 1,
"text": "De laadpaal kan worden aangesloten op een standaard 3-fase "
"aansluiting van 3x25A in de meterkast."
},
{
"id": 2,
"text": "Tijdens dynamische pieklastbeperking schaalt de laadpaal het "
"maximale laadvermogen automatisch terug naar 11 kW."
},
{
"id": 3,
"text": "Onderhoud aan het laadstation dient jaarlijks te worden "
"uitgevoerd door een gecertificeerd installateur."
},
{
"id": 4,
"text": "Het nominale piekvermogen bedraagt 22 kW onder ideale "
"omstandigheden zonder netbeperking."
}
]
# Stel de re-ranking vraag samen
rerank_request = RerankRequest(query=query, passages=ruwe_chunks)
resultaten = ranker.rerank(rerank_request)
# Toon de gerangschikte uitkomst
for rang, item in enumerate(resultaten, start=1):
score = item["score"]
tekst = item["text"]
chunk_id = item["id"]
print(f"Rang {rang} (Score: {score:.4f}) [ID {chunk_id}]: {tekst}")
In deze test ziet de vectorzoeker vaak ID 4 als meest relevant vanwege het woord "piekvermogen". De cross-encoder herkent echter direct dat vraagsteller specifiek zoekt naar het gedrag bij piekbelasting, waardoor ID 2 naar de hoogste positie stijgt.
Integratie met SentenceTransformers en PyTorch
Voor zwaardere opstellingen met een dedicated GPU biedt de sentence-transformers bibliotheek directe toegang tot state-of-the-art modellen zoals BAAI/bge-reranker-large.
import torch
from sentence_transformers import CrossEncoder
device = "cuda" if torch.cuda.is_available() else "cpu"
model_naam = "BAAI/bge-reranker-v2-m3"
# Laad de cross-encoder
cross_encoder = CrossEncoder(model_naam, max_length=512, device=device)
zoekvraag = "Welke opzegtermijn geldt voor een contract van onbepaalde duur?"
document_kandidaten = [
"Contracten voor bepaalde tijd eindigen van rechtswege na de termijn.",
"Bij een overeenkomst voor onbepaalde tijd geldt een wettelijke "
"opzegtermijn van minimaal een kalendermaand.",
"Facturen dienen binnen veertien dagen na dagtekening te worden voldaan."
]
# Bouw paren van (vraag, document)
paren = [[zoekvraag, doc] for doc in document_kandidaten]
# Bereken scores
scores = cross_encoder.predict(paren)
# Sorteer op basis van score in aflopende volgorde
geordend = sorted(
zip(scores, document_kandidaten),
key=lambda x: x[0],
reverse=True
)
for score, doc in geordend:
print(f"Score: {score:+.4f} -> {doc}")
De cross-encoder levert een logit-score op. Hoe hoger het getal, des te sterker de correlatie tussen de vraag en de specifieke paragraaf.
Impact op latency, geheugen en kwantisatie
Het toevoegen van een re-ranker introduceert een extra berekening tussen de database en de antwoordgeneratie. We moeten deze overhead zorgvuldig afwegen tegen de kwaliteitswinst.
Wanneer we 25 tekstblokken re-ranken met een model van 300 miljoen parameters op een CPU, duurt dit gemiddeld 40 tot 100 milliseconden. Op een moderne Apple Silicon Mac (M1/M2/M3) of een systeem met een discrete Nvidia-kaart is deze latentie verwaarloosbaar (< 20 ms). Dit weegt ruimschoots op tegen de tijdswinst bij de LLM zelf: doordat we minder contextblokken naar het generatiemodel hoeven te sturen, bespaart het taalmodel tientallen tot honderden milliseconden aan prompt processing tijd.
Wie op compacte machines met beperkt werkgeheugen draait, kan het modelformaat verder beperken; lees hoe kwantisatie helpt om AI-modellen efficiënt op lokale hardware te draaien zonder noemenswaardig verlies van nauwkeurigheid.
Testresultaten met Nederlandse context
Nederlandse zakelijke en juridische teksten bevatten vaak specifieke samengestelde woorden en formele formuleringen. Om te testen hoe lokale re-ranking presteert op Nederlandstalige data, hebben we een vergelijking uitgevoerd met 50 complexe zoekvragen over beleidsdocumenten.
Zonder re-ranking (uitsluitend vectorzoekopdracht met een algemeen meertalig embeddingmodel) bevatte de top-1 positie in 62% van de gevallen het exacte antwoordfragment. Door een eerste selectie van 20 fragmenten te herordenen met bge-reranker-v2-m3 steeg de top-1 precisie naar 88%.
De kwaliteit van de eerste zoekfase blijft echter bepalend voor de uiteindelijke uitkomst; lees daarom het overzicht over het juiste embeddingmodel kiezen voor Nederlandse teksten om te voorkomen dat relevante documenten al vóór de re-rankingfase buiten de selectie vallen. Een re-ranker kan immers alleen fragmenten beoordelen die door de eerste selectie zijn binnengehaald. Daarnaast helpt de handleiding over taalmodellen beter laten presteren in het Nederlands om ook de uiteindelijke antwoordformulering strak en foutloos te houden.
Privacy en gegevensbescherming
Een cruciaal voordeel van een volledig lokale re-rankingoplossing is dat documentfragmenten en gebruikersvragen het eigen apparaat of het lokale netwerk op geen enkel moment verlaten. Veel cloudgebaseerde re-ranking API's vereisen dat alle ruwe tekstblokken via externe servers worden verwerkt. Door zowel de vectordatabase, de cross-encoder als het generatieve model lokaal te hosten, blijft alle gevoelige bedrijfsinformatie binnen de eigen infrastructuur; zie de richtlijnen over privacyvriendelijk werken met AI-modellen voor aanvullende maatregelen rondom dataopslag en logs.
Veelgemaakte fouten en faalmechanismen
Hoewel re-ranking de betrouwbaarheid van een RAG-pipeline sterk verbetert, zijn er specifieke valkuilen waar rekening mee gehouden moet worden:
- Te krappe initiële recall: Als de vectordatabase in de eerste fase slechts 5 kandidaten ophaalt, kan de re-ranker een gemist relevant fragment niet meer terughalen. Stel de initiële zoekfase altijd in op minimaal 20 tot 40 documenten.
- Tokenlimieten van de cross-encoder overschrijden: Veel re-rankers hebben een contextlimiet van 512 tokens voor de gecombineerde lengte van vraag en passage. Zorg dat je documentchunks niet langer zijn dan 350 à 400 tokens, zodat de zoekvraag er altijd volledig bij past.
- Taal-incompatibiliteit: Modellen die uitsluitend getraind zijn op Engelstalige datasets (zoals pure MS-MARCO checkpoints) presteren matig op Nederlandse samengestelde termen. Kies altijd een meertalige variant zoals BGE-v2-M3 of Qwen2-Rerank.
- Blind vertrouwen op ruwe score-afkappunten: Re-ranking scores zijn relatieve relevantie-indicatoren, geen absolute waarheidswaarden. Hanteer bij voorkeur een top-k selectie (bijvoorbeeld de beste 3 resultaten) in plaats van een harde minimale grensscore.
Integratie in een complete lokale RAG-stack
Wanneer we alle componenten samenbrengen, ontstaat een robuuste zoekketen die consistenter presteert dan traditionele vectorzoekers. De flow verloopt lineair:
- De gebruiker stelt een vraag via de interface of API.
- De vectorindex haalt de 30 meest gelijkende chunks op (Recall-fase).
- Optioneel: een BM25-zoekopdracht haalt 30 trefwoord-matches op (Hybride zoekactie).
- De re-ranker beoordeelt alle unieke kandidaten en kent scores toe.
- De beste 3 tot 4 tekstfragmenten worden als context samengevoegd in de systeemprompt.
- Het lokale taalmodel formuleert een feitelijk en beknopt antwoord.
Met deze structuur wordt het risico op hallucinaties aanzienlijk verlaagd, omdat het generatiemodel uitsluitend wordt gevoed met fragmenten waarvan de relevantie op woordniveau is geverifieerd.


