Intent-detectie zonder ML: rule-based routing voor AI-agents

Als iemand je HR-chatbot schrijft "wie heeft er Python-skills?", moet je de people-agent aanroepen, niet de knowledge-agent. Als diezelfde persoon schrijft "nee dat bedoel ik niet", moet je helemaal geen retrieval doen en de AI laten erkennen dat hij de vraag verkeerd begreep.
Die routing-beslissing wordt typisch opgelost met een intent-classifier: een ML-model dat de tekst indeelt in categorieën. Wij hebben iets simpelers gebouwd dat in productie net zo goed werkt: een deterministische regelset met phrase-matching, stopwoordfiltering en topic-anchoring.
De intent-typen
Acht intents, compleet dichtgespijkerd met een compile-time exhaustiveness check:
export type Intent =
| "knowledge_lookup" // standaard HR-vraag
| "people_lookup" // vraag over specifieke medewerker
| "meta_question" // "wat weet je?" / "welke documenten heb je?"
| "attribute_filter" // "wie heeft Python-skills?"
| "followup" // korte vervolgvraag op vorig bericht
| "off_topic" // buiten HR-domein
| "correction" // gebruiker wijst vorig antwoord af
| "frustration"; // emotionele uiting, geen inhoudelijke vraag
// TypeScript geeft een error als je een intent vergeet:
const _exhaustiveCheck: Record<Intent, true> = {
knowledge_lookup: true,
people_lookup: true /* ... */,
};
De check dwingt af dat als je een nieuwe intent toevoegt aan het type, je ook de routeringslogica en de UI-labels bijwerkt.
Fase 0: uitzonderingen vóór alles
De detectie-volgorde is essentieel. Drie intents worden als eerste gecheckt omdat ze retrieval volledig overslaan:
Correctie — de gebruiker wijst het vorige antwoord af:
const CORRECTION_INDICATORS = [
"dat vroeg ik niet",
"nee dat bedoel",
"je snapt me niet",
"niet wat ik vroeg",
"mijn vraag was",
"ik vroeg naar",
// 15 varianten in totaal
];
Frustratie — emotionele uiting zonder inhoudelijke vraag:
const FRUSTRATION_INDICATORS = [
"godverdomme",
"dit is belachelijk",
"wtf",
"gvd",
"kut",
"shit",
"hier word ik gek van",
"ziek van", // ook WhatsApp-slang
];
Off-topic — buiten het HR-domein:
const OFF_TOPIC_KEYWORDS = [
"voetbal",
"formule 1",
"crypto",
"bitcoin",
"netflix",
"recept",
"weer",
"storm",
"verkiezingen", // alles wat nooit HR-relevant is
];
Voor correctie en frustratie doet de agent geen retrieval. In plaats daarvan genereert het LLM een empathische reactie op basis van alleen de systeemprompt en het bericht — geen RAG-overhead, sneller en gepaster.
Keyword-extractie met Nederlandse stopwoorden
Voordat we matchen, strippen we stopwoorden. Dit is de meest onderschatte stap in de pipeline: zonder stopwoordfiltering vervuilen woorden als "de", "het", "wat" de FTS-zoekopdracht.
const DUTCH_STOPWORDS = new Set([
"de",
"het",
"een",
"en",
"van",
"in",
"is",
"dat",
"op",
"te",
"zijn",
"met",
"voor",
"niet",
"er",
"maar",
"ze",
"hij",
"aan",
// vragend: 'hoe', 'wat', 'wie', 'waar', 'wanneer', 'waarom', 'welke'
// modaal: 'kan', 'kun', 'weet', 'mag', 'moet', 'wil'
// filler: 'hmm', 'ok', 'ff', 'even', 'gewoon', 'toch', 'nou'
// 70+ woorden in totaal
]);
export function extractKeywords(text: string): string[] {
return text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, " ")
.split(/\s+/)
.filter((w) => w.length >= 3 && !DUTCH_STOPWORDS.has(w))
.filter((w, i, arr) => arr.indexOf(w) === i) // dedupliceer
.slice(0, 6);
}
"Hoe werkt de verlofaanvraag procedure?" → ['werkt', 'verlofaanvraag', 'procedure']. Die drie woorden zijn wat Postgres FTS nodig heeft.
Topic-anchoring voor vervolgvragen
Korte berichten zijn de hardste categorie. "Wat dan?" of "en hoe zit dat?" is zinloos zonder context van het vorige bericht. We lossen dat op met topic-anchoring: zoek terug in de conversatiehistorie naar het meest recente substantiële bericht dat ten minste één keyword deelt met het huidige bericht.
function extractTopicAnchor(history, currentMessage): string | null {
const currentKeywords = new Set(extractKeywords(currentMessage));
const prior = history.filter((m) => m.role === "user");
for (let i = prior.length - 1; i >= 0; i--) {
const content = prior[i].content.trim();
const isSubstantive = content.length >= 10 && /[a-zA-Z]/.test(content);
if (!isSubstantive) continue;
const anchorKeywords = extractKeywords(content);
const hasOverlap = anchorKeywords.some((kw) => currentKeywords.has(kw));
if (hasOverlap) return content;
}
return null;
}
De keyword-overlap-eis is cruciaal. Zonder die check zou een korte "en?" na een volledig ander gespreksonderwerp het verkeerde anker pakken. Met de eis pakken we alleen ankers die daadwerkelijk verwant zijn aan het huidige bericht.
Als een anker gevonden wordt, combineren we het met het huidige bericht voor de zoekopdracht:
retrievalQuery = `${topicAnchor} ${currentMessage}`;
// "verlofaanvraag afwijzen procedure" + "wat dan" → "verlofaanvraag procedure wat dan"
De volledige detectie-volgorde
export function detectIntent(currentMessage, history): IntentResult {
const isShort = currentMessage.split(/\s+/).length <= 7
const hasPriorTurns = history.length > 1
// 0a. Correctie — skip retrieval
if (hasPriorTurns && matchesAny(currentMessage, CORRECTION_INDICATORS))
return { intent: 'correction', retrievalQuery: currentMessage, ... }
// 0b. Frustratie — skip retrieval
if (matchesAny(currentMessage, FRUSTRATION_INDICATORS))
return { intent: 'frustration', ... }
// 0c. Off-topic — skip retrieval
if (matchesAny(currentMessage, OFF_TOPIC_KEYWORDS))
return { intent: 'off_topic', ... }
// 1. Vervolgvraag — anchor op vorige berichten
if (isShort && hasPriorTurns && matchesAny(currentMessage, FOLLOWUP_INDICATORS))
return { intent: 'followup', retrievalQuery: anchoredQuery, ... }
// 2. Meta-vraag — documentindex ophalen
if (matchesAny(currentMessage, META_KEYWORDS))
return { intent: 'meta_question', ... }
// 3. Attribuutfilter — people-agent met filter-query
if (matchesAny(currentMessage, ATTRIBUTE_FILTER_KEYWORDS))
return { intent: 'attribute_filter', ... }
// 4. People-lookup — volledige tekst bewaren voor naam-matching
if (matchesAny(currentMessage, PEOPLE_KEYWORDS))
return { intent: 'people_lookup', retrievalQuery: currentMessage, ... }
// 5. Kort en ambigu zonder indicator → anchor proberen
if (isShort && hasPriorTurns) { /* ... */ }
// 5b. Heel kort, geen context, geen keywords → off_topic
if (words.length <= 2 && !hasPriorTurns && keywords.length === 0)
return { intent: 'off_topic', ... }
// 6. Default: knowledge_lookup met keyword-gebaseerde zoekopdracht
return { intent: 'knowledge_lookup', retrievalQuery: keywordQuery, ... }
}
Van intent naar agent
Na detectie mapt routeToAgent de intent op de bijbehorende agent:
export function routeToAgent(intent: Intent, message: string): AgentId {
if (intent === "people_lookup" || intent === "attribute_filter")
return "people";
if (intent === "meta_question") return "knowledge";
if (matchesAny(message, TEMPLATE_KEYWORDS)) return "template";
return "knowledge"; // followup, knowledge_lookup — default
}
Correctie, frustratie en off-topic bereiken routeToAgent nooit — die worden al eerder afgevangen in de chat-handler.
Waarom geen LLM voor intent-detectie?
De voor de hand liggende vraag: waarom niet gewoon het LLM laten bepalen wat de intent is? Dat kan, en voor complexe domeinen is het soms de betere keuze. Maar voor een HR-chatbot met acht goed gedefinieerde intents zijn de nadelen groter:
- Latentie: een extra LLM-call vóór de eigenlijke aanroep voegt 300–800ms toe
- Kosten: elke intent-detectie kost tokens, ook bij simpele berichten
- Onvoorspelbaarheid: phrase-matching is deterministisch; een LLM kan op maandag "correctie" en op dinsdag "followup" teruggeven voor hetzelfde bericht
- Debuggability: als een bericht verkeerd gerouteerd wordt, laat de regelset exact zien waarom
De regelset is in productie volledig aanpasbaar via de admin-UI: beheerders kunnen zelf indicator-zinnen toevoegen of verwijderen per intent. Dat is iets wat je met een ML-model niet zo makkelijk gedaan hebt. Vertel ons over je routing-uitdaging als je wilt sparren over de aanpak.