Lunover Engineering Notes

Wat We Leerden bij het Bouwen van Onze Eigen Support Bot Widget (Chat met Je Content)

Een technische teardown van wat er echt toe doet bij het bouwen van een support chat widget: retrieval failure modes, document-als-bestanden navigatie, caching, access control en guardrails voor betrouwbare antwoorden.

June 18, 2025By LunoverWork with us

Wat We Leerden bij het Bouwen van Onze Eigen Support Bot Widget (Chat met Je Content)

We bouwden onze eigen support bot widget: een chat bubble die je in een website embedt zodat bezoekers vragen kunnen stellen en antwoorden krijgen uit je content (docs, knowledge base, servicepagina’s, policies). Als je er ooit eentje hebt shipped, dan weet je: de pitch is makkelijk en de realiteit is rommelig. De problemen waar je tegenaan loopt zijn zelden “modelproblemen”. Het zijn engineering problemen:
  • retrieval breekt op voorspelbare manieren
  • latency breekt vertrouwen voordat het eerste antwoord landt
  • access control is makkelijk om fout te doen
  • prompt injection wordt “content poisoning”
  • antwoorden moeten auditbaar zijn, anders krijg je support-risico
Deze post is een gedetailleerde teardown: architectuur, datamodel, retrieval-strategie, tool design, caching, RBAC, UI patterns en de guardrails die onze widget betrouwbaar maakten.

Wat we bouwden (requirements en constraints)

We hebben upfront een paar niet-onderhandelbare requirements gezet:
  • Fast first token: users moeten snel iets zien, zelfs als de assistant op de achtergrond moet “zoeken”.
  • Grounded answers: antwoorden moeten ondersteund worden door content die we echt shippen.
  • Read-only by default: de assistant mag content niet muteren.
  • Multi-locale ready: onze site is gelokaliseerd; de assistant mag talen niet lukraak mixen.
  • Safe escalation: als hij het niet weet, moet hij kunnen doorverwijzen naar contact/support zonder te hallucineren.
Daaruit haalden we een simpele succesdefinitie:
De assistant moet zich gedragen als een zorgvuldige engineer die onze website leest, niet als een creatieve schrijver die improviseert.

Een werkbaar model: “support bot = search + navigation + synthesis”

Een goede support assistant doet drie dingen op volgorde:
  1. Search: kandidaatpagina’s/secties identificeren.
  2. Navigate: pagina’s openen, headings scannen, links volgen, search verfijnen.
  3. Synthesize: een kort, correct antwoord produceren met aanwijzingen waar het vandaan komt.
De meeste implementaties doen alleen (1) en (3). Ze slaan (2) over, en daar sterft accuracy.

Les 1: Chunk-only RAG faalt zodra het antwoord over meerdere pagina’s verspreid is

Top-K chunk retrieval breekt op consistente manieren:
  • het antwoord is verdeeld over twee pagina’s
  • de “exacte syntax” staat in een code block dat niet hoog rankte
  • het juiste antwoord staat op een pagina met zwakke lexical overlap
  • de vraag van de user is te vaag, dus de embedding match is “ongeveer goed”
Als een model maar een handvol chunks ziet, kan het zelfverzekerd klinken terwijl het incompleet is. De user experience ziet er dan zo uit:
  • juiste toon
  • foute details
  • geen manier om te verifiëren
Onze takeaway: retrieval is niet genoeg. Je hebt exploration nodig.

Les 2: Geef de assistant deterministische primitives (docs-as-files)

Mensen beantwoorden docs-vragen niet door vijf willekeurige paragrafen te lezen. We:
  • find de pagina
  • open hem
  • scannen headings
  • zoeken op exacte tokens
  • volgen interne links
  • herhalen tot we het antwoord kunnen bewijzen
Daarom vormden we de tool interface van de assistant als een klein filesystem over onze content:
  • elke pagina is een “file”
  • directories representeren secties (of URL path segments)
  • de assistant kan ls, find, cat en grep
Dit is geen gimmick. Het geeft het model primitives die:
  • auditbaar zijn (je kunt tool calls loggen)
  • deterministisch zijn (zelfde query geeft dezelfde reads)
  • schaalbaar zijn (het kan exploreren zonder dat jij flows hardcodeert)

Architectuur (de simpelste versie die werkt)

Op hoog niveau kwamen we uit op drie lagen:
Browser widget
  -> /api/support-chat (stream)
      -> Retrieval layer (lexical + vector)
      -> Tool layer ("filesystem" over content)
      -> Answer synthesis (grounded prompt + citations)
      -> Observability (logs + traces + eval hooks)
De kern: we behandelen “onze content lezen” als een tool, niet als een bijeffect van retrieval.

Datamodel: pagina’s, chunks en een path tree

We hielden de contentrepresentatie bewust saai:
  • page: canonical URL/slug + title + locale + visibility + lastmod + headings
  • chunk: { page, chunk_index, text, embedding, tokens, hash }
  • path_tree: { "services/seo": { isPublic: true, groups: [] }, ... }
Waarom een path tree?
  • de assistant heeft een kaart nodig van “wat bestaat”
  • access control wordt structureel (prune paths voordat sessies beginnen)
  • ls/find wordt snel en cacheable

Ingestion pipeline: maak van een website een betrouwbaar knowledge surface

Dit was de grootste tijdslokker. “Index je docs” klinkt makkelijk totdat je ziet hoe echte content eruitziet:
  • marketing copy + componenten
  • MDX en headings die niet netjes mappen naar HTML
  • navigatiepagina’s die contentblokken herhalen
  • locale varianten die deels uiteenlopen
Onze ingestion pipeline doet:
  1. Discover pages: sitemap + bekende route list.
  2. Fetch rendered HTML: wat users en crawlers echt zien.
  3. Extract main content: strip nav, footers, cookie banners, herhaalde UI.
  4. Normalize: whitespace samenvoegen, tracking query strings uit links halen.
  5. Segment:
    • page-level record voor navigatie en citaten
    • chunk-level records voor retrieval
  6. Annotate:
    • locale
    • visibility (public, client-only, internal)
    • headings outline
    • outbound interne links
De headline lesson: index de gerenderde site, niet alleen de source repo, anders mis je wat de user in productie echt ziet en ga je content citeren die niet bestaat.

Retrieval: combineer lexical en vector vóór je “het model vraagt”

We vertrouwen niet op één retrieval-strategie. We doen:
  • lexical search voor exacte tokens (goed voor identifiers, acroniemen, error codes)
  • vector search voor semantische match (goed voor vage vragen)
Daarna mergen we candidates en doen we basic sanity checks:
  • reject pagina’s die niet matchen met de locale van de user (tenzij expliciet gevraagd)
  • prefer canonical pages boven tag pages / duplicate listings
  • prefer pagina’s met sterke heading overlap met query terms
Pas daarna laten we de assistant pagina’s openen en synthetiseren.

Tool design: wat de assistant kan (en niet kan)

We implementeerden een klein tool surface area en maakten het strikt: Allowed:
  • directories lijsten: ls /services
  • paths zoeken: find -name "billing"
  • een volledige pagina lezen: cat /services/seo
  • binnen content zoeken: grep -ri "canonical" /
Not allowed:
  • content schrijven of editen
  • willekeurige URLs ophalen
  • arbitraire network calls
De safety winst is groot: prompt injection wordt vooral een content-quality issue, geen systeemcompromis.

De latency les: echte filesystems zijn te traag voor interactieve chat

Als je per sessie een sandbox/container opzet om een echt filesystem te bieden:
  • cold start wordt zichtbaar
  • chat voelt kapot
  • je krijgt de neiging om complexiteit toe te voegen zoals warm pools
Voor een support widget staart de user naar de UI. Je wilt instant tools. Daarom virtualiseerden we filesystem operations over onze index:
  • ls en find resolven uit de gecachte path tree
  • cat reconstrueert de pagina uit opgeslagen chunks (gesorteerd op chunk_index)
  • resultaten worden per sessie gecached (en deels globaal, waar veilig)
De assistant krijgt de illusie van een shell over files, maar er zijn geen echte files.

Cache wat herhaalt: directory tree, pagina’s en “grep targets”

Caching “answers” is zwak omdat vragen variëren. Wat herhaalt in echte gesprekken:
  • dezelfde secties lijstend
  • dezelfde 5-10 belangrijke pagina’s openen
  • greppen naar dezelfde tokens
Dus we cacheten:
  • de path tree
  • gereconstrueerde volledige pagina’s
  • recente grep candidates
Dit was belangrijker dan micro-optimalisatie van embeddings, omdat het follow-up snelheid verbeterde en “searching…” loops reduceerde.

RBAC: access control moet structureel zijn, niet prompt-based

Als sommige docs niet publiek zijn (drafts, interne notities, client-only docs), kun je niet op prompting vertrouwen. We enforceen RBAC voordat de assistant één tool call uitvoert:
  • bouw een user-scoped path tree
  • prune alles waar de user geen toegang toe heeft
  • pas dezelfde filter toe op elke query en page read
Als de assistant een file niet kan ls-en, kan hij hem niet cat-en en kan hij hem niet citeren. Dat is het enige mentale model dat standhoudt onder druk.

Guardrails: hoe we zelfverzekerd foute antwoorden stopten

We voegden een paar regels toe die slechte antwoorden drastisch reduceerden:

Regel 1: Geen evidence, geen antwoord

Als de assistant geen ondersteunende content kan vinden met tools, moet hij:
  • zeggen dat hij het niet kon verifiëren
  • laten zien wat hij checkte (pagina’s of secties)
  • een volgende stap bieden (contact/support)

Regel 2: Citeer liever dan parafraseer bij kritieke details

Als het antwoord gevoelig is voor exacte wording (requirements, limitations, legal copy):
  • quote de relevante zin(nen) van de pagina die hij las
  • houd de synthese minimaal

Regel 3: Wees streng op locale en canonical pages

Als de user op een locale route zit, moet de assistant:
  • content van die locale preferen
  • talen niet mixen in één antwoord
  • alleen terugvallen naar de default locale als de locale pagina niet bestaat

Het “grep probleem”: de killer feature heeft een two-phase plan nodig

Naive recursive grep is traag als het alles over het netwerk moet lezen. We gebruikten een two-phase aanpak:
  1. coarse filter met de index (welke pagina’s kunnen het token bevatten)
  2. fine filter in memory over gecachte page text om exacte matches + context te pakken
Dat maakte de assistant voelbaar als iemand die “zoals een developer” zoekt, niet als een chatbot die gokt.

Widget UX: de UI maakt of breekt vertrouwen

We shipten meerdere UI iteraties voordat de widget betrouwbaar voelde. Wat het meest telde:
  • streaming tokens met een stabiele layout (vermijd jumpy reflow)
  • duidelijke states:
    • “Searching…”
    • “Reading page…”
    • “Answering…”
  • korte, inline citaten:
    • “From: /services/seo”
    • “From: /legal/privacy”
  • fallbacks die niet als falen voelen:
    • “I checked X and Y but couldn’t confirm; here’s how to reach us.”
Dit werkt goed samen met het zichtbaar maken van het “werk” van de assistant. Users vergeven een bot die zegt “ik kon het niet bevestigen” veel sneller dan eentje die een zelfverzekerd antwoord verzint.

Observability: log reads, niet alleen tokens

Als je kwaliteit wilt verbeteren, moet je weten wat er gebeurde. We logden:
  • tool calls (paths listed/read/searched)
  • welke pagina’s als evidence gebruikt werden
  • answer length en latency buckets
  • “could not verify” rates
  • escalation rates (contact clicks)
Daarmee konden we praktische vragen beantwoorden:
  • welke pagina’s missen belangrijke informatie?
  • welke vragen vinden nooit evidence?
  • welke pagina’s zorgen voor verwarring en moeten herstructureerd worden?

Een praktische build checklist (wat we opnieuw zouden doen)

Als je een support widget bouwt die met je content chat, zouden we starten met:
  • indexeer de gerenderde site en sla page-level records op
  • combineer lexical + vector retrieval
  • voeg een exploration tool layer toe (files + grep)
  • prune content voor RBAC voordat sessies starten
  • houd tools read-only by default
  • voeg een “no evidence, no answer” regel toe
  • maak “searching/reading/answering” zichtbaar in de UI
  • log tool calls zodat je failures kunt debuggen
Als je wilt dat wij helpen met implementatie (widget + indexing + veilige architectuur):