Hybride zoekopdrachten: vector embeddings + full-text search in productie

Als je een RAG-systeem bouwt, krijg je al snel de vraag: vector search of full-text search? Het antwoord dat werkt in productie is: beide, in volgorde, met een degelijke merge-stap ertussen.
Vector-only retrieval is fragiel. Een gebruiker die "Jan de Vries" typt verwacht niet dat het embedding-model snapt dat "Jan" semantisch verwant is aan de medewerker in je database — dat is gewoon een naam, en names worden weggevlakt in een embedding. Full-text search is even fragiel: als de gebruiker vraagt "hoe werkt de onboarding-procedure?", maar het document gebruikt het woord "inwerktraject", pakt BM25 niks op.
Hybride aanpak lost beide gevallen op. Hier is hoe we dat in productie hebben gebouwd.
De drie retrieval-lagen
De retrieval werkt in twee parallelle fases gevolgd door een merge.
Fase 1 — alles tegelijk
Zodra een zoekopdracht binnenkomt, starten we vier dingen parallel:
- Laad project-instellingen (welke kenniscategorieën zijn actief, is people-context aan?)
- Haal de toon-van-stem op uit de database
- Embed de zoekquery via Azure OpenAI (
text-embedding-3-small) - Zoek op titelniveau met een fuzzy similarity-drempel
const [settingsResult, toneResult, queryEmbedding, titleResult] =
await Promise.all([
settingsQuery,
toneQuery,
embed(searchQuery).catch(() => [] as number[]),
titleQuery,
]);
De title-query gebruikt een aparte Postgres-functie (search_knowledge_by_title) die vergelijkt op documenttitels met een lage drempel (0.1). Dat vangt gevallen op waarbij de gebruiker bijna letterlijk de naam van een document typt, zonder dat hij de exacte bewoording kent.
Fase 1b — semantisch + FTS parallel
Nu we een embedding hebben, starten we semantic search (match_knowledge via pgvector) en full-text search (search_knowledge_fts via Postgres tsvector) tegelijkertijd:
const [semanticResults, ftsResult] = await Promise.all([
sb.rpc("match_knowledge", {
query_embedding: toEmbeddingParam(queryEmbedding),
match_count: 4,
p_org_id: orgId,
}),
sb.rpc("search_knowledge_fts", {
query_text: searchQuery,
match_count: 4,
p_org_id: orgId,
}),
]);
Beide queries zijn scoped op org_id zodat multi-tenant isolatie gegarandeerd is, ook als we de service-role client gebruiken voor het omzeilen van RLS.
Fase 2 — volledige documenten voor titeltreffers
Als de title-query raak heeft, halen we alle chunks van die documenten op — niet alleen de top-K die de vector-search teruggeeft. Dat is bewust: als een gebruiker duidelijk naar een specifiek document vraagt, wil je het hele document beschikbaar hebben in de context, niet alleen het meest "relevante" stukje.
const fullDocChunks = await Promise.all(
matchedTitles.map((title) =>
sb
.from("knowledge_documents")
.select("id, category, title, chunk")
.eq("title", title)
.order("created_at", { ascending: true }),
),
);
De merge-stap: volgorde bepaalt alles
De merge combineert drie sets chunks:
- Volledige documenten van titeltreffers (hoogste prioriteit)
- Semantische resultaten
- FTS-resultaten
const seen = new Set<string>();
const merged: KRow[] = [];
for (const r of [...fullDocChunks, ...semanticResult.data, ...ftsResult.data]) {
if (!seen.has(r.id)) {
seen.add(r.id);
merged.push(r);
}
}
De volgorde is niet willekeurig. Chunks die als "volledig document bij een titeltreffger" zijn opgehaald, hebben hogere relevantie-garanties dan een chunk die toevallig een hoge cosine-similarity heeft. Door te dedupliceren op id terwijl we in volgorde lopen, behouden we de eerste (meest betrouwbare) bron per chunk.
Na de merge filteren we de tone_of_voice-categorie weg — die wordt apart geïnjecteerd als systeem-instructie, niet als kenniscontext.
Diagnostics: weten wat er mis gaat
Elke retrieval-aanroep retourneert een RetrievalDiagnostics-object:
export type RetrievalDiagnostics = {
totalHits: number;
hitsBySource: { semantic: number; fts: number; title_match: number };
noHit: boolean;
};
noHit: true triggert een debug-log met de zoekquery. Dat klinkt triviaal, maar in productie is het goud waard: je ziet precies welke vragen de kennisbank leeg laten en kunt gericht content toevoegen.
In de AI-call-log wordt per beantwoord bericht bijgehouden hoeveel hits er waren per bron, inclusief similarity-scores voor semantische resultaten. Dat maakt het eenvoudig om achteraf te analyseren of een fout antwoord te wijten was aan slechte retrieval of aan het model zelf.
De rerank-hook
De code heeft een rerank-functie die momenteel een no-op is:
export function rerank(docs: RetrievedDoc[], _query: string): RetrievedDoc[] {
return docs;
}
Dat is bewust. De signatuur is stabiel zodat je later een cross-encoder of een hosted reranking-API (Cohere, Jina) kunt inpluggen zonder de callers te hoeven aanpassen. We hebben nog geen harde noodzaak voor reranking gevonden — de merge-volgorde is goed genoeg voor onze use case. Maar de hook zit er klaar als de precision-metrieken later achterblijven.
Gekoppelde agents en context-stacking
Custom agents kunnen gelinkt zijn aan andere built-in agents (people, conversations, knowledge). Als een agent zulke links heeft, draaien we die sub-retrievals parallel met de hoofdretrieval:
const [
linkedPeopleContext,
linkedConversationsContext,
linkedKnowledgeContext,
] = await runLinkedAgents(linkedAgents ?? [], searchQuery, orgId);
Het eindresultaat dat naar de LLM gaat, is een opgestapeld context-blok: toon-van-stem → persoonsprofiel → entiteitsrelaties → kennisdocumenten → gelinkte context. De volgorde bepaalt de impliciete prioriteit in de context-window.
Wat dit oplevert in de praktijk
De meerwaarde van hybride retrieval wordt het duidelijkst in edge cases:
- Exacte namen en codes (medewerker-ID's, product-SKU's, regelcodes): FTS pakt ze op, vector-search niet.
- Conceptuele vragen zonder exacte keywords ("hoe werkt de aanmeldprocedure?"): semantische search wint.
- Directe documentverwijzingen ("zie de bijlage over verlofbeleid"): de title-query pakt het document in zijn geheel op.
In onze productiedatabase met honderden kennisdocumenten verdeeld over twintig categorieën, levert de hybride aanpak significant minder no-hit queries op dan pure vector-search of pure FTS. Beide benaderingen solo kennen blinde vlekken. Samen vullen ze die van elkaar op.
Als je hybride retrieval wilt bouwen op Postgres, kijk dan naar de combinatie van pgvector voor de semantische kant en Postgres' ingebouwde tsvector/tsquery voor FTS. De overhead van een aparte zoekinfrastructuur (Elasticsearch, Typesense) is zelden gerechtvaardigd tenzij je schaal dat afdwingt. Vertel ons over je zoek-use case als je wilt sparren over de aanpak.