Waarom hybrid search?
Vectorzoekopdrachten zijn geweldig voor semantische overeenkomst maar missen de exacte treffer als iemand zoekt op een productnummer, naam of technische code. BM25-trefwoordzoekopdrachten zijn precies daarvoor sterk, maar missen juist semantische varianten. Hybrid search combineert beide zodat je het beste van twee werelden krijgt.
Een concreet voorbeeld:
- Vraag:
CVE-2024-12345 impact - Vectorzoekopdracht: vindt documenten over kwetsbaarheden in het algemeen (semantisch goed)
- BM25: vindt de exacte match van het CVE-nummer (letterlijk goed)
- Hybrid: combineert beide voor het beste resultaat
Vuistregel
Hybrid search verbetert recall bijna altijd en precision in de meeste gevallen. Het is in 2026 de standaardaanpak voor productie-RAG-systemen.
BM25 implementeren
BM25 is een verfijning van TF-IDF met document-lengtenormalisatie. De parameters k1 (termfrequentie-verzadiging) en b (lengtenormalisatie) hebben gangbare standaardwaarden van 1.5 en 0.75.
class BM25 {
private idf = new Map<string, number>();
private docVectors: Map<string, number>[] = [];
private avgDocLength = 0;
constructor(
private documents: string[],
private k1 = 1.5,
private b = 0.75
) {
this.buildIndex();
}
private tokenize(text: string): string[] {
return text.toLowerCase()
.replace(/[^\w\s]/g, " ")
.split(/\s+/)
.filter(t => t.length > 1);
}
private buildIndex(): void {
const df = new Map<string, number>();
const docLengths: number[] = [];
this.docVectors = this.documents.map(doc => {
const tokens = this.tokenize(doc);
docLengths.push(tokens.length);
const tf = new Map<string, number>();
tokens.forEach(t => tf.set(t, (tf.get(t) ?? 0) + 1));
tf.forEach((_, term) => df.set(term, (df.get(term) ?? 0) + 1));
return tf;
});
this.avgDocLength = docLengths.reduce((a, b) => a + b, 0) / docLengths.length;
const N = this.documents.length;
df.forEach((freq, term) => {
this.idf.set(term, Math.log((N - freq + 0.5) / (freq + 0.5) + 1));
});
}
search(query: string, topK: number): { index: number; score: number }[] {
const queryTokens = this.tokenize(query);
const scores = this.documents.map((_, docIdx) => {
const docVector = this.docVectors[docIdx];
const docLen = Array.from(docVector.values()).reduce((a, b) => a + b, 0);
const score = queryTokens.reduce((sum, term) => {
const tf = docVector.get(term) ?? 0;
const idf = this.idf.get(term) ?? 0;
const numerator = tf * (this.k1 + 1);
const denominator = tf + this.k1 * (1 - this.b + this.b * docLen / this.avgDocLength);
return sum + idf * numerator / denominator;
}, 0);
return { index: docIdx, score };
});
return scores.sort((a, b) => b.score - a.score).slice(0, topK);
}
}
Reciprocal Rank Fusion (RRF)
RRF combineert rankings van meerdere systemen zonder dat de scores onderling vergelijkbaar hoeven te zijn. Dat is precies de uitdaging bij hybrid search: een BM25-score en een cosine-similarity zitten op totaal andere schalen. RRF kijkt alleen naar de positie in elke lijst.
function reciprocalRankFusion(
rankings: { id: string }[][],
k = 60
): { id: string; score: number }[] {
const scores = new Map<string, number>();
for (const ranking of rankings) {
ranking.forEach((item, rank) => {
const current = scores.get(item.id) ?? 0;
scores.set(item.id, current + 1 / (k + rank + 1));
});
}
return Array.from(scores.entries())
.map(([id, score]) => ({ id, score }))
.sort((a, b) => b.score - a.score);
}
De constante k (gangbaar 60) dempt de invloed van de allerhoogste posities, zodat een document dat in beide lijsten redelijk hoog staat het wint van een document dat alleen in een lijst op nummer 1 staat.
Complete hybrid search pipeline
Hieronder draaien beide retrievers parallel en fuseren we de rankings. Let op dat je elke retriever ruimer ophaalt dan de uiteindelijke topK, zodat de fusie genoeg materiaal heeft om te combineren.
async function hybridSearch(
query: string,
documents: { id: string; content: string }[],
topK = 10
): Promise<{ id: string; content: string; score: number }[]> {
const queryEmbedding = await embedText(query);
const vectorResults = await vectorDB.search(queryEmbedding, topK * 2);
const bm25 = new BM25(documents.map(d => d.content));
const bm25Results = bm25.search(query, topK * 2);
const bm25WithIds = bm25Results.map(r => ({ id: documents[r.index].id }));
const vectorWithIds = vectorResults.map(r => ({ id: r.id }));
const fusedRankings = reciprocalRankFusion([vectorWithIds, bm25WithIds]);
const topResults = fusedRankings.slice(0, topK);
const docMap = new Map(documents.map(d => [d.id, d]));
return topResults.map(r => ({
...r,
content: docMap.get(r.id)?.content ?? "",
}));
}
Bouw de BM25-index niet per query op
In het voorbeeld hierboven wordt de BM25-index bij elke aanroep opnieuw opgebouwd, puur voor de leesbaarheid. In productie indexeer je je documenten een keer en hergebruik je de index, of je laat de zoekmachine (zoals Qdrant of een Elasticsearch/OpenSearch-cluster) de sparse index beheren.
Qdrant native hybrid search
In plaats van zelf te fuseren kun je de fusie aan de zoekmachine overlaten. Qdrant ondersteunt hybrid search natief via de Query API: je geeft een dense en een sparse vector als aparte prefetch-subquery's mee en laat Qdrant ze samenvoegen met RRF.
Let op: de oude client.search(...)-aanroep met een ingebouwd fusion-veld is vervangen. Gebruik sinds de Query API client.query(...) met prefetch en een top-level query: { fusion: "rrf" }.
const searchResult = await qdrantClient.query("documents", {
prefetch: [
{
query: denseEmbedding,
using: "dense",
limit: 20,
},
{
query: {
indices: sparseIndices,
values: sparseValues,
},
using: "sparse",
limit: 20,
},
],
query: { fusion: "rrf" },
limit: 10,
});
Hierbij verwijzen using: "dense" en using: "sparse" naar named vectors die je bij het aanmaken van de collectie hebt geconfigureerd. Wil je in plaats van RRF op scoreverdeling fuseren, dan kun je fusion: "dbsf" (Distribution-Based Score Fusion) gebruiken.
Wanneer voegt hybrid search het meest toe?
Hybrid search levert het grootste voordeel op bij content met veel exacte, niet-semantische tokens: productcodes, foutmeldingen, API-namen, juridische artikelnummers, medicijnnamen. Bij puur conceptuele, verhalende content is de winst van BM25 kleiner. Meet daarom altijd op je eigen evaluatieset in plaats van te vertrouwen op algemene benchmarks.
Welke gewichten gebruik ik voor BM25 versus vectorscores?
RRF heeft geen expliciete gewichten nodig, het rangschikt op basis van de positie in elke lijst. Wil je toch wegen, gebruik dan alpha-hybridisatie: score = alpha * vector_score + (1 - alpha) * bm25_score, met genormaliseerde scores. Een typisch startpunt is alpha rond 0.5 en daarna afstemmen op je evaluatieset.
Helpt hybrid search bij meertalige content?
Ja. BM25 matcht exacte termen ongeacht de taal, wat handig is voor eigennamen en codes. Vectorzoekopdrachten kunnen taalbarrieres overbruggen als je een meertalig embeddingmodel gebruikt. De combinatie dekt zowel letterlijke als semantische treffers.
Is hybrid search altijd beter dan alleen vectorzoekopdrachten?
Op de meeste retrieval-benchmarks presteert hybrid beter. De uitzondering is content die vrijwel volledig conceptueel is zonder exacte technische termen, daar voegt BM25 weinig toe. Meet het altijd op je eigen data.
Kan ik ook TF-IDF gebruiken in plaats van BM25?
Dat kan, maar BM25 is voor information retrieval vrijwel altijd beter. Het verschil zit in de document-lengtenormalisatie en de termfrequentie-verzadiging die BM25 toepast en TF-IDF niet.
Wat is een goede waarde voor de RRF-constante k?
De gangbare standaard is 60. Een hogere k maakt de fusie vlakker en geeft documenten die in meerdere lijsten redelijk scoren meer kans, een lagere k legt meer nadruk op de toppositie in een afzonderlijke lijst. Stem af op je eigen evaluatieset.
Moet ik de fusie zelf bouwen of aan de database overlaten?
Als je vectordatabase native hybrid search ondersteunt (zoals Qdrant met de Query API), laat je de fusie het beste daar gebeuren: minder rondreizen, minder code en de sparse index wordt beheerd. Bouw zelf alleen als je meerdere los staande bronnen moet combineren.