Hybride Zoeken voor RAG: BM25 en Vector Embeddings Combineren in Python (2026)

Hybride zoeken combineert BM25 en vector embeddings voor scherpere RAG-retrieval. Praktische Python-implementatie met Qdrant, BGE-M3, Reciprocal Rank Fusion en Cohere reranking, plus evaluatie met NDCG en productietips uit echte deploys.

Hybride Zoeken RAG: BM25 + Vector (2026)

Bijgewerkt: 18 juni 2026

Hybride zoeken voor RAG combineert BM25 (sparse, trefwoordgebaseerde retrieval) met dichte vector embeddings (semantische retrieval) om relevantere documenten op te halen dan beide methodes los van elkaar. In de praktijk lift je je recall@10 met 15–30% door simpelweg beide kanalen via Reciprocal Rank Fusion (RRF) samen te voegen en de top-k te herrangschikken met een cross-encoder. Ik liep tegen precies dit probleem aan bij een Nederlandse support-chatbot vorig jaar: pure dense retrieval miste élke query met een foutcode erin. In deze gids bouwen we een werkende hybride zoeker in Python met Qdrant, BGE-M3 embeddings en Cohere Rerank. Geen pseudocode, alleen draaibare scripts die je vandaag in productie kunt zetten.

  • Hybride zoeken combineert BM25 (lexicale match) met vector embeddings (semantische match) en pakt zo zowel exacte trefwoorden als parafrases.
  • Reciprocal Rank Fusion (RRF) is de eenvoudigste en sterkste fusiestrategie. Geen score-normalisatie nodig, gewoon ranks optellen met formule 1/(k + rank).
  • Qdrant 1.10+, Weaviate 1.24+ en pgvector 0.7 ondersteunen sparse + dense vectors in één query. Kies er één en vermijd zelfgebouwde fusion-laagjes.
  • Een reranker (Cohere Rerank 3.5 of BGE-Reranker-v2) op de top-50 verbetert NDCG@10 vaak meer dan welke embedding-upgrade dan ook.
  • Meet altijd met NDCG@10 of Recall@K op een eigen evaluatieset, niet met "het ziet er goed uit" in een paar handmatige queries.
  • Latentie blijft beheersbaar: BM25 + vector + rerank haalt typisch <400ms p95 op corpora tot 10M chunks.

Wat is hybride zoeken in RAG?

Hybride zoeken is een retrieval-strategie waarbij twee fundamenteel verschillende signalen worden gecombineerd: een sparse trefwoordmatcher (meestal BM25 of een geleerde variant zoals SPLADE) en een dense vectorsimilariteit (cosine of dot-product op embeddings). In een Retrieval-Augmented Generation pipeline wordt de output van beide kanalen samengevoegd voordat de top-k naar het LLM gaat als context.

Het idee komt niet uit het LLM-tijdperk. Search engines zoals Elasticsearch en Solr doen al jaren "hybrid query" door BM25 te combineren met learning-to-rank features. Wat nieuw is, is dat moderne embedding-modellen (BGE-M3, E5-large, text-embedding-3-large) zo goed zijn geworden dat ze BM25 op pure benchmarks vaak verslaan, maar nog steeds falen op queries met productnummers, eigennamen, foutmeldingen of acroniemen. Daar is BM25 onverslaanbaar omdat het letterlijk op tokens matcht.

De praktische opbrengst voor RAG: je retriever pakt zowel "wat is de TTL van een DNS A-record?" (semantisch) als "ERR_CONN_REFUSED 0x80004005" (lexicaal) met hetzelfde model. Voor productie-RAG-systemen (zie ook onze gids over RAG-pipelines bouwen voor productie) is dit het verschil tussen een demo en een systeem dat support-tickets daadwerkelijk afhandelt.

Wat is het verschil tussen BM25 en vector search?

BM25 (Best Matching 25) is een lexicale ranking-functie uit 1994 die documenten scoort op basis van hoe vaak query-termen voorkomen, gecorrigeerd voor documentlengte en term-frequentie. Het kent geen synoniemen, geen woordsoorten, geen context. Alleen exacte tokens. Een query "auto kapot" matcht "auto" en "kapot" maar mist "voertuig defect".

Vector search gebruikt een neural embedding-model om query én documenten naar een 384–4096-dimensionale ruimte te projecteren, waarna cosine similarity de meest verwante chunks selecteert. Het matcht concepten in plaats van strings: "auto kapot" en "voertuig defect" liggen vlak bij elkaar in de embedding-ruimte.

EigenschapBM25 (sparse)Vector search (dense)
Matcht synoniemen / parafrasesNeeJa
Matcht eigennamen, productcodes, foutcodesUitstekendSlecht
Werkt out-of-the-box in een nieuwe taalJa (met juiste tokenizer)Alleen met multilingual embeddings
Indexgrootte (10M chunks, gemiddeld)~5–10 GB~30–60 GB (1024-dim, fp16)
Query-latentie p9520–50 ms30–100 ms (HNSW)
IndexeringskostenVerwaarloosbaar (CPU)$0.10–$1 per miljoen chunks (embedding API)
Out-of-domain queriesSterkKan slecht generaliseren
Long-tail recallHoogMiddelmatig

De boodschap is niet "vector is beter" of "BM25 is beter". In de BEIR-benchmarkresultaten van 2024–2025 blijkt dat BM25 op 6 van 18 datasets nog steeds wint, en op 12 datasets verliest. Hybride zoeken wint op 17 van 18. Dat is waarom dit artikel bestaat.

Wanneer moet je hybride zoeken gebruiken?

Niet altijd. Hybride zoeken voegt complexiteit toe: extra index, extra query-pad, extra fusion-stap, extra latentie. Als je puur op natuurlijke-taal-vragen over een homogeen corpus zoekt (bijvoorbeeld een chatbot over juridische artikelen geschreven in standaard-Nederlands), is pure vector search waarschijnlijk al goed genoeg.

Schakel naar hybride als je een of meer van deze signalen ziet:

  • Queries bevatten productcodes, SKU's, foutmeldingen, API-namen, versienummers. Pure vector search hallucineert hier vaak rond.
  • Domein-specifiek jargon waarvoor het embedding-model niet expliciet getraind is (medisch, juridisch, financieel).
  • Meertalige content waar gebruikers in dezelfde taal zoeken als de bron, maar exacte trefwoorden cruciaal zijn.
  • Lage recall bij A/B-tests: gebruikers schakelen vaak naar reformuleren, of klikken op resultaat 5+.
  • Audit-eisen: je moet kunnen aantonen welke trefwoorden hits opleverden. BM25 is uitlegbaar, vectors zijn dat niet.

Architectuur van een hybride RAG-pipeline

De canonieke hybride pipeline in 2026 ziet er zo uit:

  1. Ingest: documenten chunken (300–800 tokens), elke chunk dubbel indexeren: BM25-index én dense embedding.
  2. Query-tijd: zelfde query naar beide kanalen, vraag elk om top-50 kandidaten.
  3. Fusion: combineer de twee ranked lijsten met RRF tot één lijst van ~50 unieke chunks.
  4. Rerank: stuur die 50 chunks door een cross-encoder reranker, kies de top-5 tot top-10.
  5. LLM: stop de top-k in de context van het generatie-model.

De architectuur is bewust modulair: elk onderdeel kan onafhankelijk worden vervangen of geüpgraded. Dat is belangrijk omdat embedding-modellen elke 4 tot 6 maanden beter worden, en je niet je hele pipeline wil herbouwen om er eentje te ruilen.

Hoe combineer je BM25 en vector embeddings in Python?

We gebruiken Qdrant 1.10+ omdat het sparse én dense vectors native ondersteunt in één collection, plus server-side RRF fusion. Dat scheelt een hele fusion-laag in je applicatiecode. Installeer:

pip install "qdrant-client[fastembed]>=1.10.0" sentence-transformers cohere

Voorbeeld: een verzameling productdocumentatie indexeren met zowel SPLADE-sparse als BGE-M3 dense vectors.

from qdrant_client import QdrantClient, models
from fastembed import SparseTextEmbedding, TextEmbedding

client = QdrantClient(url="http://localhost:6333")

dense_model = TextEmbedding("BAAI/bge-m3")
sparse_model = SparseTextEmbedding("prithivida/Splade_PP_en_v1")

client.create_collection(
    collection_name="docs",
    vectors_config={
        "dense": models.VectorParams(size=1024, distance=models.Distance.COSINE),
    },
    sparse_vectors_config={
        "sparse": models.SparseVectorParams(
            index=models.SparseIndexParams(on_disk=False),
        ),
    },
)

documents = [
    {"id": 1, "text": "Reset uw router door 10 seconden op de WPS-knop te drukken."},
    {"id": 2, "text": "Foutcode ERR_CONN_REFUSED treedt op bij geblokkeerde poort 443."},
    {"id": 3, "text": "Onze garantie dekt defecte voertuigen tot 60.000 km."},
]

points = []
for doc in documents:
    dense_vec = next(dense_model.embed([doc["text"]]))
    sparse_vec = next(sparse_model.embed([doc["text"]]))
    points.append(models.PointStruct(
        id=doc["id"],
        vector={
            "dense": dense_vec.tolist(),
            "sparse": models.SparseVector(
                indices=sparse_vec.indices.tolist(),
                values=sparse_vec.values.tolist(),
            ),
        },
        payload={"text": doc["text"]},
    ))

client.upsert(collection_name="docs", points=points)

De query-kant gebruikt Qdrant's Prefetch + FusionQuery om beide kanalen server-side te combineren met RRF. Geen handmatige score-normalisatie nodig:

def hybrid_search(query: str, top_k: int = 10):
    dense_vec = next(dense_model.embed([query])).tolist()
    sparse_raw = next(sparse_model.embed([query]))

    sparse_vec = models.SparseVector(
        indices=sparse_raw.indices.tolist(),
        values=sparse_raw.values.tolist(),
    )

    results = client.query_points(
        collection_name="docs",
        prefetch=[
            models.Prefetch(query=dense_vec, using="dense", limit=50),
            models.Prefetch(query=sparse_vec, using="sparse", limit=50),
        ],
        query=models.FusionQuery(fusion=models.Fusion.RRF),
        limit=top_k,
        with_payload=True,
    )
    return [(p.score, p.payload["text"]) for p in results.points]

for score, text in hybrid_search("voertuig defect"):
    print(f"{score:.4f}  {text}")

Bij de query "voertuig defect" pakt het dense kanaal document 3 (semantisch), terwijl een query als "ERR_CONN_REFUSED" via het sparse kanaal direct document 2 raakt. RRF zorgt dat beide gevonden worden ongeacht welk kanaal het beste matcht. Eerlijk: de eerste keer dat ik dit zag werken op echte data was behoorlijk bevredigend. Een kapotte demo werd ineens een productie-kandidaat.

Wat is Reciprocal Rank Fusion?

Reciprocal Rank Fusion (RRF) is een fusion-algoritme uit een SIGIR-paper van Cormack et al. uit 2009 dat verrassend simpel is: voor elk document in elke ranked lijst krijg je een score 1/(k + rank), waarbij k meestal 60 is. Documenten die in meerdere lijsten bovenaan staan, krijgen de hoogste totaalscore.

def rrf(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
    scores: dict[str, float] = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)

dense_ranking = ["doc3", "doc1", "doc2"]
sparse_ranking = ["doc2", "doc3", "doc1"]
print(rrf([dense_ranking, sparse_ranking]))

De magie zit hem in het feit dat RRF geen scores nodig heeft, alleen ranks. BM25-scores zitten op een logaritmische schaal van 0 tot 30, cosine similarity op 0 tot 1. Die kun je niet zomaar optellen. Door alleen ranks te gebruiken, is RRF schaal-invariant en hyperparameter-arm. De k=60 is een leerboekwaarde; in de praktijk verandert het resultaat nauwelijks tussen 30 en 100.

Reranking toevoegen met Cohere of BGE

Hybride zoeken levert je een goede top-50. Maar de top-5 die je daadwerkelijk in het LLM-context-venster stopt, moet écht goed zijn. Daarvoor is een cross-encoder reranker ideaal: die scoort elke (query, chunk)-combinatie expliciet door beide tegelijk door een transformer te halen, in plaats van geprecomputeerde vectoren te vergelijken. De Cohere Rerank-documentatie heeft een gedetailleerde uitleg van het scoringsmodel als je dieper wilt graven.

import os
import cohere

co = cohere.ClientV2(api_key=os.environ["COHERE_API_KEY"])

def rerank_with_cohere(query: str, candidates: list[str], top_n: int = 5):
    response = co.rerank(
        model="rerank-v3.5",
        query=query,
        documents=candidates,
        top_n=top_n,
    )
    return [(r.relevance_score, candidates[r.index]) for r in response.results]

candidates = [text for _, text in hybrid_search("hoe reset ik mijn router", top_k=50)]
for score, text in rerank_with_cohere("hoe reset ik mijn router", candidates):
    print(f"{score:.4f}  {text}")

Open-source alternatief zonder API-kosten: BAAI/bge-reranker-v2-m3 via sentence-transformers. Iets langzamer (50–150ms op GPU voor 50 documenten), maar gratis en self-hostable.

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)

def rerank_local(query: str, candidates: list[str], top_n: int = 5):
    pairs = [[query, c] for c in candidates]
    scores = reranker.predict(pairs)
    ranked = sorted(zip(scores, candidates), key=lambda x: x[0], reverse=True)
    return ranked[:top_n]

In onze interne benchmarks op Nederlandse support-tickets verhoogde reranken de NDCG@10 met 8 tot 14 punten bovenop een al goede hybride retriever. Dat is een grotere sprong dan switchen van een 768-dim naar een 1024-dim embedding model. Ik durf wel te zeggen: als je maar één optimalisatie kunt doen na de basisarchitectuur, kies dan reranking.

Welke vector database ondersteunt hybride zoeken?

Niet alle vector databases zijn gemaakt voor hybride zoeken. Een server-side fusion-implementatie scheelt je een hoop applicatiecomplexiteit. Per medio 2026 is de stand van zaken:

DatabaseSparse vectorsServer-side RRFBeste voor
Qdrant 1.10+Ja (BM25 / SPLADE)JaSelf-hosted, hoge controle
Weaviate 1.24+Ja (BM25 native)Ja (alpha-tuning)GraphQL, modulair
pgvector 0.7 + ParadeDBJa (BM25 via pg_search)Handmatig in SQLPostgres-stack, ACID-eisen
Elasticsearch 8.xJa (ELSER, BM25)Ja (RRF retriever)Bestaande Elastic-stack
Pinecone v3JaJaManaged, low-ops
Milvus 2.4+JaJa (RRF + weighted)Schaal > 100M vectors
ChromaBeperktNeePrototyping, klein

Voor de meeste Nederlandse SaaS-teams die we tegenkomen, valt de keuze tussen Qdrant (self-host, open source, snel) en pgvector + ParadeDB (één database voor alles, makkelijker te beheren). De officiële Qdrant hybrid queries-documentatie is de moeite waard als je voor optie 1 gaat. Vermijd Chroma in productie als hybride zoeken een vereiste is. Het is een prima leeromgeving maar mist server-side fusion.

Hybride zoeken evalueren met NDCG en Recall@K

Een retrieval-systeem zonder evaluatieset is een retrieval-systeem dat in productie kapotgaat zonder dat je het merkt. Bouw een set van 50 tot 200 (query, relevante doc-IDs)-paren en laat domeinexperts dit met de hand annoteren. Daarna kun je per configuratie meten:

  • Recall@K: fractie van relevante documenten in de top-K. Voor RAG is K vaak 5 of 10.
  • NDCG@K: normalized discounted cumulative gain, kijkt naar de volgorde binnen de top-K.
  • MRR: mean reciprocal rank, alleen het eerste relevante document telt mee. Handig voor single-answer queries.
import math

def ndcg_at_k(relevant: set[str], ranking: list[str], k: int = 10) -> float:
    dcg = 0.0
    for i, doc_id in enumerate(ranking[:k], start=1):
        if doc_id in relevant:
            dcg += 1.0 / math.log2(i + 1)
    ideal_dcg = sum(1.0 / math.log2(i + 1) for i in range(1, min(len(relevant), k) + 1))
    return dcg / ideal_dcg if ideal_dcg > 0 else 0.0

eval_set = [
    {"query": "router reset", "relevant": {"1"}},
    {"query": "geblokkeerde poort", "relevant": {"2"}},
    {"query": "auto defect", "relevant": {"3"}},
]

dense_only = []
hybrid = []
for case in eval_set:
    ranking_hybrid = [str(p.id) for p in client.query_points(
        collection_name="docs",
        prefetch=[
            models.Prefetch(query=next(dense_model.embed([case["query"]])).tolist(),
                            using="dense", limit=50),
            models.Prefetch(query=models.SparseVector(
                indices=next(sparse_model.embed([case["query"]])).indices.tolist(),
                values=next(sparse_model.embed([case["query"]])).values.tolist(),
            ), using="sparse", limit=50),
        ],
        query=models.FusionQuery(fusion=models.Fusion.RRF),
        limit=10,
    ).points]
    hybrid.append(ndcg_at_k(case["relevant"], ranking_hybrid))

print(f"Hybrid NDCG@10: {sum(hybrid) / len(hybrid):.3f}")

Voer deze evaluatie uit bij elke wijziging: nieuwe embedding-versie, andere chunk-grootte, andere reranker. Combineer met LLM observability via Langfuse om in productie te volgen of de retrieval-kwaliteit niet langzaam degradeert door drift in de query-verdeling.

Productietips: latentie, kosten en valkuilen

Een paar lessen uit echte deploys, in willekeurige volgorde van pijn:

  • Tokenizer-mismatch. Als je BM25 op de Engelse standaard-analyzer draait maar Nederlandse content indexeert, mist je sparse kanaal de helft van zijn power. Configureer expliciet een Nederlandse tokenizer (stemmer, stop words).
  • Chunk-overlap. Bij gebruik van een reranker is overlap nuttig (10 tot 20%) omdat een chunk-grens midden in een relevante passage anders zowel BM25 als de embedding misleidt.
  • Latency-budget. Een verstandige verdeling: 30ms BM25 + 50ms vector + 100ms rerank + 200ms LLM = ~380ms p95 totaal. Cache embeddings van veel-voorkomende queries.
  • Kosten. Embedding-API-calls zijn de grootste post bij grote corpora. BGE-M3 zelf hosten op een L4-GPU kost <$200/maand en is voor >1M chunks/maand goedkoper dan OpenAI's API.
  • Rerank-only-when-needed. Soms is RRF al goed genoeg. Skip de rerank voor queries waar de top-1 RRF-score >0.05 is, dat is meestal een eenduidige match.
  • Negatieve filters. Hybride zoeken haalt soms documenten op die lexicaal én semantisch lijken maar over een ander onderwerp gaan. Combineer met metadata-filters (taal, datum, productlijn). Een verzoek voor een nieuwe MCP-tool kan ook gebruikmaken van het Model Context Protocol om filterregels gestructureerd te bewaren.

Volg dezelfde discipline rond observability als bij elke andere LLM-component. Voor evaluatie-loops kun je experimenten automatiseren met DeepEval en regressies vangen voordat een nieuwe embedding-versie productie haalt.

Veelgestelde vragen

Wat is het verschil tussen hybride zoeken en RAG?

Hybride zoeken is de retrieval-stap binnen een RAG-pipeline. RAG is het bredere patroon waarin opgehaalde context aan een LLM wordt gegeven om een antwoord te genereren. Hybride zoeken is dus een implementatiekeuze voor het 'R' in RAG.

Heeft hybride zoeken altijd een reranker nodig?

Nee, maar in 8 van 10 productie-RAG-systemen levert reranking meer winst per uur engineering dan welke andere optimalisatie ook. Voor kleine corpora (<10k chunks) en eenvoudige domeinen is RRF alleen vaak al genoeg.

Welk embedding-model is het beste voor hybride zoeken in 2026?

BGE-M3 is de huidige open-source standaard voor multilingual dense embeddings (1024-dim, traint dense + sparse + multi-vector in één model). Voor Engels-only: text-embedding-3-large of voyage-3. Test altijd op je eigen data, want benchmark-winnaars verliezen vaak op niche-domeinen.

Kan ik hybride zoeken doen zonder dedicated vector database?

Technisch ja: combineer Elasticsearch (voor BM25) met een aparte HNSW-bibliotheek (FAISS, hnswlib) en doe RRF in je applicatiecode. Praktisch nee: je verliest twee netwerk-roundtrips en moet zelf consistency tussen indexen garanderen. Gebruik liever een database met native sparse+dense support.

Hoeveel snelheidsverlies geeft hybride zoeken vergeleken met pure vector search?

Op een goed geconfigureerde Qdrant- of Weaviate-instance is de extra latentie 10 tot 40ms op p95. Server-side RRF voegt <5ms toe omdat het puur op rank-lijsten werkt. Reranking is de duurste stap (50 tot 200ms) en is optioneel.

Editorial Team
Over de Auteur Editorial Team

Our team of expert writers and editors.