Zoeken op betekenis in de Clientbox tickethistorie

Klanten van Clientbox stellen supportvragen op hun eigen manier. De één vraagt “hoe koppel ik mijn Google Agenda”, de ander: “hoe zorg ik dat afspraken uit Clientbox in mijn eigen agenda verschijnen”. Twee heel verschillende zinnen, maar inhoudelijk sterk verwante vragen, en de kans is groot dat een vergelijkbare vraag al eerder is beantwoord.

Voor wie dagelijks support draait, telt vooral snelheid: hoe snel vind je een antwoord dat er al is. Elke keer dat een vraag opnieuw wordt uitgezocht terwijl het antwoord al ergens in de tickethistorie staat, kost dat tijd: voor de klant die wacht en voor de collega die het werk dubbel doet.

Een zoekfunctie die hoofdzakelijk op woorden matcht, herkent verwante vragen niet altijd. Bij het doorzoekbaar maken van de Clientbox tickethistorie liep ik tegen precies dit vraagstuk aan: de tickethistorie zit vol bruikbare antwoorden, maar die vind je niet terug als je andere woorden gebruikt dan de vraagsteller destijds deed. Dat bracht me bij vector search: zoeken op betekenis, zonder alleen van exacte woorden afhankelijk te zijn.

Van tekst naar getallen

Een vector is in de kern een lijst met getallen: een punt in een ruimte met meerdere dimensies. Zet je tekst om naar een numerieke representatie waarin semantische relaties zijn vastgelegd, dan spreek je van een embedding. Het idee: inhoud met een vergelijkbare betekenis komt doorgaans dicht bij elkaar te liggen volgens het embeddingmodel en de gekozen afstandsmaat.

Kat   → [0.2, 0.8, 0.1]
Hond  → [0.3, 0.7, 0.2]
Auto  → [0.9, 0.1, 0.8]

Dit voorbeeld is sterk vereenvoudigd. In de praktijk bestaan embeddings uit honderden of duizenden dimensies, maar het principe blijft hetzelfde: teksten die inhoudelijk op elkaar lijken, komen doorgaans dichter bij elkaar te liggen. Voor de Clientbox tickethistorie betekent dit dat tickets over facturatie dichter bij andere facturatietickets kunnen liggen, en vragen over agenda-koppelingen dichter bij andere planningsvragen, zonder dat ze dezelfde woorden hoeven te bevatten.

Wanneer exacte woorden toch nodig zijn

Vector search is sterk in betekenis herkennen, maar doorgaans minder betrouwbaar voor exacte identifiers. Een ticketnummer, foutcode of productnaam wil je letterlijk terugvinden, niet “ongeveer”. Daarom combineerde ik vector search met BM25, een lexicaal rangschikkingsmodel dat onder meer kijkt naar termfrequentie, de zeldzaamheid van termen en documentlengte. Deze combinatie is een vorm van hybrid search:

De verhouding is instelbaar. Omdat gebruikers problemen vaak in eigen woorden beschrijven, test ik 30% BM25 en 70% vector search als eerste hypothese. Bij scorefusie moeten de scores vergelijkbaar zijn; Reciprocal Rank Fusion combineert rangposities in plaats van oorspronkelijke scores.

Snel zoeken zonder alles te vergelijken: HNSW

Zonder index zou een zoekvraag vergeleken moeten worden met elke opgeslagen embedding. Dat wordt langzamer naarmate de dataset groeit. Voor dit project koos ik HNSW (Hierarchical Navigable Small World), een index die embeddings verbindt in meerdere lagen: bovenaan weinig punten voor grote sprongen door de ruimte, onderaan steeds meer punten om nauwkeuriger te zoeken. Een zoekopdracht daalt van boven naar beneden af en hoeft daardoor maar een fractie van alle embeddings te bekijken.

HNSW is een approximate nearest neighbor-index: snel, maar het kan een vector missen die bij een volledige vergelijking wel tot de dichtstbijzijnde resultaten zou behoren. Een paar instellingen sturen die afweging. m bepaalt het maximale aantal verbindingen per laag; ef_construction beïnvloedt de kwaliteit en bouwtijd van de index; ef_search stuurt tijdens het zoeken de afweging tussen recall en snelheid. pgvector levert hiervoor standaardwaarden, maar of die goed werken, moet je op je eigen dataset meten.

Een praktische beperking: dimensies en opslag

Text-embedding-3-large van OpenAI levert zonder aangepaste dimensions-parameter embeddings met 3.072 dimensies. Daar loop je tegen een technische grens aan: een HNSW-index in pgvector op het standaardtype vector ondersteunt maximaal 2.000 dimensies. Een HNSW-index op halfvec ondersteunt maximaal 4.000 dimensies; het type halfvec zelf kan maximaal 16.000 dimensies opslaan. Elke waarde wordt met halve precisie opgeslagen. Dat is geen dimensionality reduction: de embedding blijft 3.072 dimensies groot, maar het is wel een compactere representatie, met ongeveer de helft van de opslag voor de vectorwaarden. Of het mogelijke verlies aan numerieke precisie merkbaar is in de zoekkwaliteit, moet je in de praktijk meten.

Het resultaat

De gevonden tickets of chunks gaan als extra context samen met de oorspronkelijke vraag naar een LLM, dat op basis daarvan een antwoord formuleert. Dit patroon heet Retrieval-Augmented Generation (RAG).

Voor de Clientbox tickethistorie levert dat een zoekfunctie op die niet blijft hangen op exacte woorden. “Hoe koppel ik mijn Google Agenda” en “hoe zorg ik dat afspraken uit Clientbox in mijn eigen agenda verschijnen” kunnen daardoor bij dezelfde relevante tickets uitkomen, terwijl BM25 de kans vergroot dat een ticketnummer of foutcode hoog eindigt, mits zo’n identifier bij het tokeniseren intact blijft. Als een exacte treffer gegarandeerd moet worden, is daarnaast een aparte exacte zoekroute nodig. De precieze verhouding tussen BM25 en vector search test ik nog verder met realistische zoekvragen, maar de basis staat: embeddings voor betekenis, HNSW om dat snel doorzoekbaar te houden en BM25 voor sterke lexicale signalen.

Voor support betekent dat vooral dit: minder vaak hetzelfde uitzoeken en sneller bij het antwoord dat er al lag.

Luc van Westen
Technologie