Lunover Engineering Notes

Vad vi lärde oss när vi byggde vår egen supportbot-widget (chatta med ditt innehåll)

En teknisk genomgång av vad som faktiskt spelar roll när du bygger en supportchatt-widget: retrieval-fel, dokument-som-filer-navigering, caching, access control och guardrails för pålitliga svar.

June 18, 2025By LunoverWork with us

Vad vi lärde oss när vi byggde vår egen supportbot-widget (chatta med ditt innehåll)

Vi byggde vår egen supportbot-widget: en chat-bubbla du bäddar in på en webbplats så att besökare kan ställa frågor och få svar från ditt innehåll (docs, knowledge base, tjänstesidor, policies). Om du någon gång har levererat en sådan vet du att pitchen är enkel och verkligheten är rörig. Problemen du stöter på är sällan “modellproblem”. Det är engineering-problem:
  • retrieval går sönder på förutsägbara sätt
  • latency dödar förtroendet innan första svaret landar
  • access control är lätt att göra fel
  • prompt injection blir “content poisoning”
  • svar måste vara auditerbara annars får du support-risk
Det här inlägget är en detaljerad teardown: arkitektur, datamodell, retrieval-strategi, tool-design, caching, RBAC, UI-mönster och guardrails som gjorde vår widget pålitlig.

Vad vi byggde (krav och constraints)

Vi satte några icke-förhandlingsbara krav från början:
  • Snabb första token: användaren ska se respons snabbt, även om assistenten behöver “söka” i bakgrunden.
  • Grounded answers: svar måste stödjas av innehåll vi faktiskt publicerar.
  • Read-only som default: assistenten får inte mutera innehåll.
  • Multi-locale redo: vår sajt är lokaliserad; assistenten ska inte blanda språk slarvigt.
  • Säker eskalering: när den inte kan svara ska den kunna lotsa vidare utan att hallucinerar.
Utifrån det landade vi i en enkel definition av “lyckas”:
Assistenten ska bete sig som en noggrann ingenjör som läser vår webbplats, inte som en kreativ skribent som improviserar.

En fungerande mental modell: “supportbot = sök + navigation + syntes”

En bra supportassistent gör tre saker i ordning:
  1. Sök: identifiera kandidat-sidor/sektioner.
  2. Navigera: öppna sidor, skanna rubriker, följa länkar, förfina sökningen.
  3. Syntetisera: producera ett kort, korrekt svar med pekare till var det kommer ifrån.
De flesta implementationer gör bara (1) och (3). De hoppar över (2), och det är där kvaliteten dör.

Lärdom 1: Chunk-only RAG fallerar när svaret spänner över flera sidor

Top-K chunk retrieval går sönder på konsekventa sätt:
  • svaret är splittrat över två sidor
  • den “exakta syntaxen” är i ett kodblock som inte rankade
  • rätt svar finns på en sida med svag lexical overlap
  • användarens fråga är underspecificerad så embedding-matchen blir “tillräckligt nära”
När en modell ser bara några få chunks kan den låta säker men vara ofullständig. Användarupplevelsen blir:
  • rätt ton
  • fel detaljer
  • inget sätt att verifiera
Vår takeaway: retrieval räcker inte. Du behöver exploration.

Lärdom 2: Ge assistenten deterministiska primitiver (docs-as-files)

Människor svarar inte på docs-frågor genom att läsa fem slumpmässiga stycken. Vi:
  • find sidan
  • open den
  • skannar rubriker
  • söker efter exakta tokens
  • följer interna länkar
  • upprepar tills vi kan bevisa svaret
Så vi formade assistentens verktygsgränssnitt som ett litet filesystem över vårt innehåll:
  • varje sida är en “fil”
  • kataloger representerar sektioner (eller URL-path segments)
  • assistenten kan ls, find, cat och grep
Det här är inte en gimmick. Det ger modellen primitiver som är:
  • auditerbara (du kan logga tool calls)
  • deterministiska (samma fråga ger samma läsningar)
  • skalbara (den kan utforska utan att du hårdkodar flöden)

Arkitektur (den enklaste versionen som funkar)

På hög nivå landade vi i tre lager:
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)
Nyckeln: vi behandlar “att läsa vårt innehåll” som ett verktyg, inte som en bieffekt av retrieval.

Datamodell: sidor, chunks och ett path tree

Vi höll representationen medvetet tråkig:
  • page: canonical URL/slug + title + locale + visibility + lastmod + headings
  • chunk: { page, chunk_index, text, embedding, tokens, hash }
  • path_tree: { "services/seo": { isPublic: true, groups: [] }, ... }
Varför ett path tree?
  • assistenten behöver en karta över “vad som finns”
  • access control blir strukturell (prune paths innan sessionen börjar)
  • ls/find blir snabbt och cachebart

Ingestionspipeline: gör en webbplats till en pålitlig knowledge surface

Det här tog mest tid. “Indexera dina docs” låter enkelt tills du ser hur verkligt innehåll ser ut:
  • marknadscopy + komponenter
  • MDX och rubriker som inte mappar rent till HTML
  • navigationssidor som upprepar content-block
  • locale-varianter som divergerar delvis
Vår pipeline gör:
  1. Upptäck sidor: sitemap + känd route-lista.
  2. Hämta renderad HTML: vad användare och crawlers faktiskt ser.
  3. Extrahera main content: ta bort nav, footers, cookie banners, repetitiv UI.
  4. Normalisera: kollapsa whitespace, ta bort tracking query strings från länkar.
  5. Segmentera:
    • page-level record för navigation och citations
    • chunk-level records för retrieval
  6. Annotera:
    • locale
    • visibility (public, client-only, internal)
    • headings outline
    • outbound internal links
Huvudlärdomen: indexera den renderade sajten, inte bara källrepon, annars missar du vad användaren faktiskt ser och riskerar att citera innehåll som inte finns i produktion.

Retrieval: kombinera lexical och vector innan du “frågar modellen”

Vi litar inte på en enda retrieval-strategi. Vi gör:
  • lexical search för exakta tokens (bra för identifiers, akronymer, felkoder)
  • vector search för semantisk match (bra för vaga frågor)
Sedan merge:ar vi kandidater och kör grundläggande sanity checks:
  • avvisa sidor som inte matchar användarens locale (om den inte uttryckligen ber om annat)
  • föredra canonical-sidor framför tag-sidor / dubblettlistningar
  • föredra sidor med stark rubrik-overlap med frågetermer
Först därefter låter vi assistenten öppna sidor och syntetisera.

Tool-design: vad assistenten får göra (och inte får)

Vi implementerade en liten tool-yta och gjorde den strikt. Tillåtet:
  • lista kataloger: ls /services
  • sök paths: find -name "billing"
  • läs en hel sida: cat /services/seo
  • sök i innehåll: grep -ri "canonical" /
Inte tillåtet:
  • skriva eller redigera innehåll
  • hämta godtyckliga URL:er
  • godtyckliga nätverksanrop
Säkerhetsvinsten är stor: prompt injection blir mest en content-kvalitetsfråga, inte en systemkompromissfråga.

Latency-lärdomen: riktiga filesystems är för långsamma för interaktiv chat

Om du startar en sandbox/container per session för att ge ett riktigt filesystem:
  • cold start blir synligt
  • chatten känns trasig
  • du blir frestad att lägga på komplexitet som warm pools
För en supportwidget stirrar användaren på UI:t. Du vill ha instant tools. Så vi virtualiserade filesystem-operationerna över vårt index:
  • ls och find slår i det cachade path tree:t
  • cat sätter ihop sidan från lagrade chunks (sorterade på chunk_index)
  • resultaten cachas per session (och delvis globalt när det är säkert)
Assistenten får illusionen av en shell över filer, men det finns inga riktiga filer.

Cacha det som upprepas: path tree, pages och “grep targets”

Att cacha “svar” är svagt eftersom frågor varierar. Det som upprepas i riktiga konversationer är:
  • lista samma sektioner
  • öppna samma 5-10 viktiga sidor
  • greppa efter samma tokens
Så vi cachade:
  • path tree:t
  • rekonstruerade full pages
  • senaste grep-kandidater
Det spelade större roll än att mikro-optimera embeddings, eftersom det förbättrade uppföljningshastighet och minskade “searching…”-loopar.

RBAC: access control måste vara strukturell, inte prompt-baserad

Om vissa docs inte är publika (utkast, interna anteckningar, client-only docs) kan du inte lita på prompting. Vi enforced RBAC innan assistenten gör ett enda tool call:
  • bygg ett user-scoped path tree
  • prune:a allt användaren inte får se
  • applicera samma filter på varje query och page read
Om assistenten inte kan ls en fil kan den inte cat den och den kan inte citera den. Det är den enda mental modellen som håller under press.

Guardrails: hur vi stoppade självsäkra fel-svar

Vi lade till några regler som kraftigt minskade dåliga svar.

Regel 1: Inget evidens, inget svar

Om assistenten inte kan hitta stödjande innehåll via tools ska den:
  • säga att den inte kunde verifiera
  • visa vad den kollade (sidor eller sektioner)
  • föreslå nästa steg (contact/support)

Regel 2: Föredra att citera framför att parafrasera för kritiska detaljer

När svaret är känsligt för exakta formuleringar (krav, begränsningar, juridisk copy):
  • citera relevant(a) mening(ar) från sidan den läste
  • håll syntesen minimal

Regel 3: Var strikt med locale och canonical-sidor

Om användaren är på en locale-route ska assistenten:
  • föredra den locale:ns innehåll
  • undvika att blanda språk i ett svar
  • falla tillbaka till default locale bara om locale-sidan inte finns

“Grep-problemet”: killer feature kräver en tvåfasplan

Naiv rekursiv grep är långsam om den läser allt över nätet. Vi använde en tvåfas-approach:
  1. coarse filter med indexet (vilka sidor kan innehålla token)
  2. fine filter i minne över cachad sidtext för att plocka exakta träffar + kontext
Det gjorde att assistenten kändes som att den kunde söka som en utvecklare, inte gissa som en chatbot.

Widget-UX: UI:t bygger eller förstör förtroende

Vi levererade flera UI-iterationer innan widgeten kändes pålitlig. Det som spelade störst roll:
  • streaming tokens med stabil layout (undvik jumpy reflow)
  • tydliga states:
    • “Searching…”
    • “Reading page…”
    • “Answering…”
  • korta, in-line citations:
    • “From: /services/seo”
    • “From: /legal/privacy”
  • fallbacks som inte känns som failure:
    • “Jag kollade X och Y men kunde inte bekräfta; här är hur du når oss.”
Det här funkar bra ihop med att göra assistentens “arbete” synligt. Användare förlåter en bot som säger “jag kunde inte bekräfta” långt mer än en som hittar på ett säkert svar.

Observability: logga läsningarna, inte bara tokens

Om du vill förbättra kvalitet måste du veta vad som hände. Vi loggade:
  • tool calls (paths listade/lästa/sökta)
  • vilka sidor som användes som evidens
  • svarslängd och latency buckets
  • “kunde inte verifiera”-andel
  • eskaleringsgrad (contact clicks)
Det gjorde att vi kunde svara på praktiska frågor:
  • vilka sidor saknar viktig information?
  • vilka frågor hittar aldrig evidens?
  • vilka sidor skapar förvirring och behöver omstrukturering?

En praktisk bygg-checklista (vad vi skulle göra igen)

Om du bygger en supportwidget som chattar med ditt innehåll börjar vi med:
  • indexera den renderade sajten och lagra page-level records
  • kombinera lexical + vector retrieval
  • lägg till ett exploration tool layer (filer + grep)
  • prune:a innehåll för RBAC innan sessioner startar
  • håll tools read-only som default
  • lägg till en “inget evidens, inget svar”-regel
  • gör “searching/reading/answering” synligt i UI:t
  • logga tool calls så du kan debugga failure
Om du vill att vi hjälper till att implementera detta (widget + indexing + säker arkitektur):