Lunover Engineering Notes

Was wir beim Bau unseres eigenen Support-Bot-Widgets gelernt haben (Chat mit deinem Content)

Ein technischer Deep-Dive in das, was beim Bau eines Support-Chat-Widgets wirklich zaehlt: Retrieval-Failure-Modes, Docs-als-Files-Navigation, Caching, Access Control und Guardrails fuer verlaessliche Antworten.

June 18, 2025By LunoverWork with us

Was wir beim Bau unseres eigenen Support-Bot-Widgets gelernt haben (Chat mit deinem Content)

Wir haben unser eigenes Support-Bot-Widget gebaut: die Chat-Blase, die du auf einer Website einbettest, damit Besucher Fragen stellen und Antworten aus deinem Content bekommen (Docs, Knowledge Base, Service-Seiten, Policies). Auf dem Papier klingt das simpel. In der Praxis ist es messy. Die Probleme sind selten "Model-Probleme". Es sind Engineering-Probleme:
  • Retrieval bricht vorhersehbar
  • Latenz zerstoert Vertrauen, bevor die erste Antwort landet
  • Access Control ist leicht falsch zu machen
  • Prompt Injection wird zu "Content Poisoning"
  • Antworten muessen auditierbar sein, sonst wird der Bot zum Support-Risiko
Dieser Beitrag ist ein detaillierter Teardown: Architektur, Datenmodell, Retrieval-Strategie, Tool-Design, Caching, RBAC, UI-Patterns und die Guardrails, die unser Widget verlaesslich gemacht haben.

Was wir gebaut haben (Requirements und Constraints)

Wir hatten ein paar nicht verhandelbare Anforderungen:
  • Schnelles First Token: Nutzer sollen schnell eine Reaktion sehen, auch wenn der Assistant im Hintergrund erst "suchen" muss.
  • Grounded Answers: Antworten muessen durch Content gestuetzt sein, den wir wirklich ausliefern.
  • Read-only by default: der Assistant darf Content nicht mutieren.
  • Multi-Locale ready: unsere Site ist lokalisiert; der Assistant soll Locales nicht beliebig mischen.
  • Safe Escalation: wenn er nicht sicher antworten kann, soll er sauber eskalieren (Contact/Support), ohne zu halluzinieren.
Aus diesen Requirements haben wir eine einfache Definition von Erfolg abgeleitet:
Der Assistant soll sich wie ein vorsichtiger Engineer verhalten, der unsere Website liest, nicht wie ein kreativer Writer, der improvisiert.

Ein funktionierendes Modell: "Support Bot = Search + Navigation + Synthesis"

Ein guter Support-Assistant macht drei Dinge in Reihenfolge:
  1. Search: Kandidaten-Seiten/Sections identifizieren.
  2. Navigate: Seiten oeffnen, Headings scannen, Links folgen, Query verfeinern.
  3. Synthesize: eine kurze, korrekte Antwort liefern, mit Pointern, woher sie kommt.
Viele Implementierungen machen nur (1) und (3). Sie ueberspringen (2). Genau dort stirbt Accuracy.

Lesson 1: Chunk-only RAG faellt auseinander, wenn die Antwort ueber mehrere Seiten geht

Top-K Chunk Retrieval bricht in konstanten Mustern:
  • die Antwort ist auf zwei Seiten verteilt
  • die "exakte Syntax" steckt in einem Codeblock, der nicht gerankt wurde
  • die Frage ist underspecified, Embedding Match ist "close enough"
  • die richtige Seite hat wenig lexikalische Ueberschneidung
Wenn ein Model nur ein paar Chunks sieht, kann es sehr sicher klingen und trotzdem unvollstaendig sein. Unser Takeaway: Retrieval reicht nicht. Du brauchst Exploration.

Lesson 2: Gib dem Assistant deterministische Primitives (Docs-as-Files)

Menschen beantworten Docs-Fragen nicht, indem sie fuenf zufaellige Absaetze lesen. Wir:
  • find die Seite
  • open sie
  • scannen Headings
  • suchen nach exakten Tokens
  • folgen internen Links
  • wiederholen, bis wir die Antwort beweisen koennen
Darum haben wir die Tool-Schnittstelle wie ein kleines Filesystem ueber unserem Content gestaltet:
  • jede Seite ist ein "File"
  • Directories repraesentieren Sections (oder URL-Pfadsegmente)
  • der Assistant kann ls, find, cat und grep
Das ist kein Gimmick. Es gibt dem Model Primitives, die:
  • auditierbar sind (Tool Calls lassen sich loggen)
  • deterministisch sind
  • skalierbar sind (Exploration ohne Hardcoding)

Architektur (die einfachste Version, die funktioniert)

High-level sind wir bei drei Layern gelandet:
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)
Der Kern: "unseren Content lesen" ist ein Tool, nicht nur ein Nebenprodukt von Retrieval.

Datenmodell: pages, chunks und ein path tree

Wir haben die Representation bewusst boring gehalten:
  • page: canonical URL/slug + title + locale + visibility + lastmod + headings
  • chunk: { page, chunk_index, text, embedding, tokens, hash }
  • path_tree: { "services/seo": { isPublic: true, groups: [] }, ... }
Warum ein path tree?
  • der Assistant braucht eine Map: "was existiert"
  • Access Control wird strukturell (Paths vor Sessionstart prunen)
  • ls/find wird schnell und gut cachebar

Ingestion-Pipeline: eine Website zu einer verlaesslichen Knowledge-Surface machen

Das war der groesste Zeitfresser. "Indexiere deine Docs" klingt leicht, bis du realen Content siehst:
  • Marketing Copy + Components
  • MDX und Headings, die nicht sauber auf HTML mappen
  • Listing-Seiten, die Content-Bloecke wiederholen
  • Locale-Varianten, die teilweise divergieren
Unsere Pipeline macht:
  1. Pages discovern: Sitemap + bekannte Route-Liste.
  2. Rendered HTML fetchen: was Nutzer (und Crawler) wirklich sehen.
  3. Main Content extrahieren: Nav, Footer, Cookie Banner, wiederholte UI entfernen.
  4. Normalisieren: Whitespace, Tracking-Query-Strings aus Links entfernen.
  5. Segmentieren:
    • page-level Record fuer Navigation und Zitate
    • chunk-level Records fuer Retrieval
  6. Annotieren:
    • locale
    • visibility (public, client-only, internal)
    • headings outline
    • outbound internal links
Headline-Lesson: Indexiere die gerenderte Site, nicht nur den Source-Repo, sonst zitierst du Dinge, die in Production so nicht existieren.

Retrieval: lexical und vector kombinieren, bevor du "das Model fragst"

Wir vertrauen keiner einzelnen Retrieval-Strategie. Wir kombinieren:
  • lexical search fuer exakte Tokens (Identifier, Akronyme, Error Codes)
  • vector search fuer semantische Naehe (vage Fragen)
Dann mergen wir Kandidaten und machen Sanity Checks:
  • Pages mit falscher Locale ablehnen (ausser explizit angefragt)
  • canonical Pages gegenueber Tag-/Listing-Duplikaten bevorzugen
  • Pages bevorzugen, deren Headings gut zur Query passen
Erst dann darf der Assistant Pages oeffnen und synthesizen.

Tool-Design: was der Assistant darf (und was nicht)

Wir haben eine kleine Tool-Oberflaeche implementiert und sie strikt gemacht: Erlaubt:
  • Directories listen: ls /services
  • Pfade suchen: find -name "billing"
  • eine ganze Seite lesen: cat /services/seo
  • innerhalb von Content suchen: grep -ri "canonical" /
Nicht erlaubt:
  • Content schreiben oder editieren
  • beliebige URLs fetchen
  • beliebige Network Calls
Der Safety-Benefit ist gross: Prompt Injection wird viel eher zu einem Content-Qualitaetsproblem als zu einem System-Compromise.

Latenz: echte Filesystems sind zu langsam fuer interaktiven Chat

Wenn du pro Session einen Sandbox/Container startest, um ein echtes Filesystem zu bieten:
  • Cold Start wird sichtbar
  • Chat fuehlt sich kaputt an
  • du wirst zu Komplexitaet wie Warm Pools verleitet
Fuer ein Widget willst du instant Tools. Darum haben wir Filesystem-Operationen ueber unseren Index virtualisiert:
  • ls und find laufen ueber den gecachten Path Tree
  • cat setzt eine Page aus gespeicherten Chunks zusammen (sortiert nach chunk_index)
  • Ergebnisse werden per Session gecacht (und teilweise global, wenn safe)
Der Assistant bekommt die Illusion von Files, aber ohne echte Files.

Cache, was sich wiederholt: path tree, pages und "grep targets"

Antworten cachen ist schwach, weil Fragen variieren. Was sich in echten Conversations wiederholt:
  • dieselben Sections listen
  • dieselben 5-10 Kernseiten oeffnen
  • dieselben Tokens greppen
Darum haben wir gecacht:
  • den Path Tree
  • rekonstruierte Full Pages
  • recente Grep-Kandidaten
Das hat mehr gebracht als Embeddings zu micro-optimieren, weil es Follow-ups beschleunigt und "searching..."-Loops reduziert.

RBAC: Access Control muss strukturell sein, nicht prompt-basiert

Wenn ein Teil deiner Docs nicht public ist (Drafts, interne Notes, client-only Docs), kannst du nicht auf Prompting vertrauen. Wir enforce RBAC, bevor der Assistant einen einzigen Tool Call macht:
  • user-scoped Path Tree bauen
  • alles prunen, was der User nicht sehen darf
  • denselben Filter auf jede Query und jeden Page Read anwenden
Wenn der Assistant ein File nicht lsen kann, kann er es nicht caten und nicht zitieren.

Guardrails: wie wir "confident wrong" Antworten reduziert haben

Ein paar Regeln haben bad answers drastisch reduziert:

Regel 1: Kein Evidence, keine Antwort

Wenn der Assistant keinen stuetzenden Content findet, soll er:
  • sagen, dass er es nicht verifizieren konnte
  • zeigen, was er geprueft hat (Pages/Sections)
  • einen naechsten Schritt anbieten (Contact/Support)

Regel 2: Bei kritischen Details lieber zitieren als paraphrasieren

Wenn exakte Formulierungen zaehlen (Requirements, Limits, rechtliche Copy):
  • relevante Saetze aus der gelesenen Page zitieren
  • Synthesis minimal halten

Regel 3: Strikt bei Locale und canonical Pages bleiben

Wenn der User in einer Locale ist, soll der Assistant:
  • die Locale bevorzugen
  • Sprachen nicht mischen
  • nur auf Default-Locale fallbacken, wenn die Locale-Page nicht existiert

Das "grep problem": Killer-Feature, aber nur mit Plan

Naives recursive Grep ist langsam, wenn du alles ueber das Netzwerk lesen musst. Wir nutzen einen Two-Phase-Ansatz:
  1. coarse filter ueber den Index (welche Pages koennten das Token enthalten)
  2. fine filter in memory ueber gecachten Page Text, um exakte Treffer + Kontext zu extrahieren
So fuehlt sich der Assistant an wie "Docs durchsuchen wie ein Developer", nicht wie "raten".

Widget-UX: die UI entscheidet ueber Vertrauen

Wir haben mehrere UI-Iterationen gebraucht, bis das Widget sich verlaesslich anfuehlte. Was am meisten zaehlte:
  • Streaming Tokens mit stabilem Layout (keine jumpy Reflows)
  • klare States:
    • "Searching..."
    • "Reading page..."
    • "Answering..."
  • kurze, inline Zitate:
    • "From: /services/seo"
    • "From: /legal/privacy"
  • Fallbacks, die nicht wie ein Fail wirken:
    • "Ich habe X und Y geprueft, konnte es aber nicht bestaetigen; hier ist, wie du uns erreichst."
Das passt gut dazu, die "Arbeit" sichtbar zu machen. Nutzer verzeihen "konnte nicht bestaetigen" viel eher als eine erfundene, selbstsichere Antwort.

Observability: logge Reads, nicht nur Tokens

Wenn du Qualitaet verbessern willst, musst du wissen, was passiert ist. Wir loggen:
  • Tool Calls (welche Pfade gelistet/gelesen/gesucht wurden)
  • welche Pages als Evidence genutzt wurden
  • Answer length und Latenz-Buckets
  • "could not verify" Raten
  • Escalation-Raten (Contact Clicks)
Damit kannst du praktische Fragen beantworten:
  • welche Pages fehlen wichtige Informationen?
  • welche Fragen finden nie Evidence?
  • welche Pages verwirren Nutzer und muessen umstrukturiert werden?

Ein praktischer Build-Checklist (was wir wieder so machen wuerden)

Wenn du ein Support-Widget baust, das mit deinem Content chattet:
  • indexiere die gerenderte Site und speichere page-level Records
  • kombiniere lexical + vector Retrieval
  • addiere eine Exploration-Tool-Layer (files + grep)
  • prune Content fuer RBAC vor Sessionstart
  • halte Tools read-only by default
  • setze die Regel "no evidence, no answer"
  • mache "searching/reading/answering" in der UI sichtbar
  • logge Tool Calls, damit du Failures debuggen kannst
Wenn du willst, dass wir das mit dir umsetzen (Widget + Indexing + sichere Architektur):