Waarom query expansion werkt
Een gebruikersvraag is kort en informatie-arm. "Hoe werkt de verlofregeling?" bevat weinig semantische informatie. Een relevant document bevat woorden als "verlofaanvraag", "vakantiedagen", "HR-portaal" en "goedkeuring leidinggevende" die niet in de vraag staan.
Query expansion vergroot de semantische breedte van de zoekopdracht door de vraag te transformeren naar een representatie die dichter bij de documenten ligt.
Wanneer gebruiken
Query expansion is het meest waardevol als je RAG-systeem relevante documenten mist terwijl ze er wel zijn. Werkt de retrieval al goed, dan voegt het weinig toe en kost het alleen extra latentie.
HyDE: Hypothetical Document Embeddings
async function hydeRetrieval(question: string, vectorDB: VectorDB, k = 5): Promise<Document[]> {
const hypotheticalDoc = await generateHypotheticalDocument(question);
const embedding = await embedText(hypotheticalDoc);
return vectorDB.search(embedding, k);
}
async function generateHypotheticalDocument(question: string): Promise<string> {
const response = await anthropic.messages.create({
model: "claude-haiku-4-5",
max_tokens: 300,
messages: [{
role: "user",
content: `Schrijf een kort, informatief antwoord op deze vraag alsof je een expert bent.
Het antwoord hoeft niet per se correct te zijn, maar moet de juiste terminologie en structuur bevatten.
Vraag: ${question}
Antwoord:`,
}],
});
return response.content[0].type === "text" ? response.content[0].text : question;
}
HyDE werkt goed als de documenten een formele schrijfstijl hebben die verschilt van hoe gebruikers vragen stellen. Het hypothetische antwoord hoeft niet feitelijk juist te zijn: je gebruikt alleen de embedding ervan, niet de inhoud.
Step-back prompting
Genereer een bredere, meer algemene vraag naast de specifieke vraag:
async function stepBackRetrieval(question: string, vectorDB: VectorDB, k = 5): Promise<Document[]> {
const stepBackQuestion = await generateStepBackQuestion(question);
const [specificResults, broadResults] = await Promise.all([
vectorDB.search(await embedText(question), k),
vectorDB.search(await embedText(stepBackQuestion), k),
]);
const seen = new Set<string>();
const combined: Document[] = [];
[...specificResults, ...broadResults].forEach(doc => {
if (!seen.has(doc.id)) {
seen.add(doc.id);
combined.push(doc);
}
});
return combined.slice(0, k);
}
async function generateStepBackQuestion(question: string): Promise<string> {
const response = await anthropic.messages.create({
model: "claude-haiku-4-5",
max_tokens: 100,
messages: [{
role: "user",
content: `Genereer een bredere, meer algemene versie van deze vraag die de achterliggende kennis aanspreekt.
Specifieke vraag: ${question}
Bredere vraag:`,
}],
});
return response.content[0].type === "text" ? response.content[0].text.trim() : question;
}
Query decomposition
Splits complexe vragen in eenvoudigere deelvragen:
async function decomposeAndRetrieve(
question: string,
vectorDB: VectorDB,
k = 3
): Promise<{ subQuestion: string; docs: Document[] }[]> {
const subQuestions = await decomposeQuestion(question);
return Promise.all(
subQuestions.map(async (sq) => ({
subQuestion: sq,
docs: await vectorDB.search(await embedText(sq), k),
}))
);
}
async function decomposeQuestion(question: string): Promise<string[]> {
const response = await anthropic.messages.create({
model: "claude-haiku-4-5",
max_tokens: 300,
messages: [{
role: "user",
content: `Splits deze complexe vraag in 2-4 eenvoudigere deelvragen.
Geef elke deelvraag op een nieuwe regel.
Als de vraag al eenvoudig is, geef hem ongewijzigd terug.
Vraag: ${question}
Deelvragen:`,
}],
});
const text = response.content[0].type === "text" ? response.content[0].text : "";
const parts = text
.split("
")
.map(l => l.replace(/^\d+\.\s*/, "").trim())
.filter(Boolean);
return parts.length > 0 ? parts : [question];
}
Synoniem-expansie
Voeg synoniemen en gerelateerde termen toe voor domeinspecifieke taal:
const DOMAIN_SYNONYMS: Record<string, string[]> = {
"verlof": ["vakantie", "afwezigheid", "vrije dag", "snipperdag"],
"ziek": ["ziekmelding", "verzuim", "arbeidsongeschikt"],
"computer": ["laptop", "werkstation", "pc", "apparaat"],
};
function expandWithSynonyms(query: string): string {
let expanded = query;
for (const [term, synonyms] of Object.entries(DOMAIN_SYNONYMS)) {
if (query.toLowerCase().includes(term)) {
expanded += " " + synonyms.join(" ");
}
}
return expanded;
}
Synoniem-expansie is goedkoop en deterministisch, maar werkt alleen als je de domeintermen vooraf kent. Voor open domeinen levert HyDE of step-back meestal meer op.
Combineren
Combineer meerdere technieken en fuseer de resultaten met Reciprocal Rank Fusion, zodat documenten die in meerdere lijsten hoog scoren naar boven komen:
async function advancedRetrieval(question: string, vectorDB: VectorDB): Promise<Document[]> {
const expandedQuestion = expandWithSynonyms(question);
const [hydeResults, stepBackResults, directResults] = await Promise.all([
hydeRetrieval(question, vectorDB, 5),
stepBackRetrieval(question, vectorDB, 5),
vectorDB.search(await embedText(expandedQuestion), 5),
]);
const allResults = [...hydeResults, ...stepBackResults, ...directResults];
const rankings = [hydeResults, stepBackResults, directResults].map(r => r.map(d => ({ id: d.id })));
const fused = reciprocalRankFusion(rankings);
const docMap = new Map(allResults.map(d => [d.id, d]));
return fused.slice(0, 8).map(r => docMap.get(r.id)!).filter(Boolean);
}
Begin klein en meet
Zet niet meteen alle technieken aan. Begin met HyDE als losse stap, meet de retrieval-kwaliteit met een vaste set testvragen en voeg pas een techniek toe als die meetbaar beter scoort. Anders betaal je latentie en kosten zonder aantoonbare winst.
Kosten-batenafweging
| Techniek | Kwaliteitswinst | Extra LLM-aanroepen | Latentieverhoging |
|---|---|---|---|
| HyDE | Hoog | 1 (klein model) | 300-500ms |
| Step-back | Gemiddeld | 1 (klein model) | 300-500ms |
| Decomposition | Hoog (complexe vragen) | 1 plus k retrievals | 500ms tot 2s |
| Synoniem-expansie | Laag tot gemiddeld | 0 | minder dan 10ms |
Latentie stapelt op
Elke techniek met een extra LLM-aanroep voegt honderden milliseconden toe. Draai onafhankelijke aanroepen parallel (zoals in het combineer-voorbeeld) en gebruik een snel, goedkoop model zoals claude-haiku-4-5 voor de expansiestap. Voor interactieve chat is meer dan een seconde extra al snel merkbaar.
Werkt HyDE goed voor Nederlandse content?
Ja, als je een meertalig model gebruikt voor zowel de HyDE-generatie als de embedding. Controleer of het model vloeiend Nederlands genereert voor de hypothetische documenten, anders verschuift de embedding richting de verkeerde taal.
Wanneer gebruik ik decomposition versus multi-query?
Gebruik decomposition voor vragen die meerdere feitelijke antwoorden vereisen, bijvoorbeeld "Wat zijn de beleidspunten voor X en Y?". Gebruik multi-query voor vragen die je semantisch op meerdere manieren kunt formuleren maar die naar hetzelfde antwoord wijzen.
Kan ik query expansion automatisch uitschakelen voor eenvoudige vragen?
Ja. Classificeer eerst de vraagcomplexiteit: korte feitelijke vragen krijgen geen expansion, complexe analysevragen wel. Een snelle LLM-aanroep of zelfs een eenvoudige heuristiek op lengte en vraagwoorden volstaat vaak voor die classificatie.
Verhoogt query expansion de kosten significant?
Met een snel model zoals claude-haiku-4-5 (rond 1 dollar per miljoen invoertokens en 5 dollar per miljoen uitvoertokens in 2026) blijft de expansiestap doorgaans onder de 10 procent van de totale kosten. Voor de meeste use cases rechtvaardigt de kwaliteitswinst dat.
Welk model gebruik ik het best voor de expansiestap?
Een klein, snel model is hier ideaal omdat de expansie geen perfecte feitelijkheid vereist, alleen de juiste terminologie en structuur. claude-haiku-4-5 is in 2026 het snelste Haiku-model en een logische keuze. Reserveer een groter model voor de uiteindelijke generatie van het antwoord.