Lunover Engineering Notes

Cosa abbiamo imparato costruendo il nostro widget di supporto (chat con i tuoi contenuti)

Un teardown tecnico di cio che conta davvero quando costruisci un widget chat di supporto: failure mode del retrieval, navigazione documenti-come-file, caching, controllo accessi e guardrail per risposte affidabili.

June 18, 2025By LunoverWork with us

Cosa abbiamo imparato costruendo il nostro widget di supporto (chat con i tuoi contenuti)

Abbiamo costruito il nostro widget di supporto: una chat bubble che integri in un sito web cosi i visitatori possono fare domande e ottenere risposte dai tuoi contenuti (docs, knowledge base, pagine servizi, policy). Se ne hai mai rilasciato uno, sai che il pitch e semplice e la realta e complicata. I problemi che incontri raramente sono “problemi di modello”. Sono problemi di ingegneria:
  • il retrieval si rompe in modi prevedibili
  • la latenza distrugge la fiducia prima che arrivi la prima risposta
  • il controllo accessi e facile da sbagliare
  • la prompt injection diventa “avvelenamento dei contenuti”
  • le risposte devono essere verificabili, altrimenti aumenti il rischio di supporto
Questo post e un teardown dettagliato: architettura, modello dati, strategia di retrieval, design degli strumenti, caching, RBAC, pattern UI e i guardrail che hanno reso affidabile il nostro widget.

Cosa stavamo costruendo (requisiti e vincoli)

Abbiamo fissato alcuni requisiti non negoziabili fin dall’inizio:
  • Primo token veloce: gli utenti devono vedere una risposta rapidamente, anche se l’assistente deve “cercare” in background.
  • Risposte grounded: le risposte devono essere supportate dai contenuti che pubblichiamo davvero.
  • Read-only di default: l’assistente non deve modificare i contenuti.
  • Pronto per piu lingue: il sito e localizzato; l’assistente non deve mischiare lingue con leggerezza.
  • Escalation sicura: quando non puo rispondere, deve instradare l’utente verso contatto/supporto senza allucinare.
Da questi abbiamo derivato una definizione semplice di successo:
L’assistente deve comportarsi come un ingegnere prudente che legge il nostro sito, non come uno scrittore creativo che improvvisa.

Un modello mentale utile: “support bot = ricerca + navigazione + sintesi”

Un buon assistente di supporto fa tre cose, in sequenza:
  1. Cerca: identifica pagine/sezioni candidate.
  2. Naviga: apre pagine, scansiona heading, segue link, raffina la ricerca.
  3. Sintetizza: produce una risposta breve e corretta con indicazioni su da dove arriva.
Molte implementazioni fanno solo (1) e (3). Saltano (2), ed e li che muore la precisione.

Lezione 1: un RAG “solo chunk” fallisce quando la risposta e distribuita su piu pagine

Il retrieval top-K di chunk si rompe in modi consistenti:
  • la risposta e divisa tra due pagine
  • la “sintassi esatta” e in un blocco di codice che non e entrato in ranking
  • la risposta giusta e su una pagina con scarsa sovrapposizione lessicale
  • la domanda dell’utente e troppo vaga, quindi il match embedding e “abbastanza vicino”
Quando un modello vede solo pochi chunk puo sembrare sicuro pur essendo incompleto. L’esperienza utente tipica:
  • tono corretto
  • dettagli sbagliati
  • nessun modo per verificare
Il nostro takeaway: il retrieval non basta. Serve esplorazione.

Lezione 2: dare all’assistente primitive deterministiche (docs-come-file)

Gli esseri umani non rispondono alle domande su documentazione leggendo cinque paragrafi casuali. Noi:
  • find la pagina
  • la open
  • scorriamo gli heading
  • cerchiamo token esatti
  • seguiamo i link interni
  • ripetiamo finche possiamo provare la risposta
Quindi abbiamo modellato l’interfaccia strumenti dell’assistente come un piccolo filesystem sopra i nostri contenuti:
  • ogni pagina e un “file”
  • le directory rappresentano sezioni (o segmenti dell’URL)
  • l’assistente puo fare ls, find, cat e grep
Non e un trucco. Fornisce al modello primitive che sono:
  • auditabili (puoi loggare le tool call)
  • deterministiche (la stessa query produce le stesse letture)
  • scalabili (puo esplorare senza che tu debba hardcodare i flussi)

Architettura (la versione piu semplice che funziona)

Ad alto livello, siamo arrivati a tre livelli:
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)
Il punto chiave: trattiamo “leggere i nostri contenuti” come uno strumento, non come un effetto collaterale del retrieval.

Modello dati: pagine, chunk e un albero di path

Abbiamo mantenuto la rappresentazione dei contenuti intenzionalmente noiosa:
  • page: URL/slug canonico + titolo + locale + visibilita + lastmod + heading
  • chunk: { page, chunk_index, text, embedding, tokens, hash }
  • path_tree: { "services/seo": { isPublic: true, groups: [] }, ... }
Perche un path tree?
  • l’assistente ha bisogno di una mappa di “cosa esiste”
  • il controllo accessi diventa strutturale (pruni i path prima che inizi la sessione)
  • ls/find diventano veloci e cacheabili

Pipeline di ingestion: trasformare un sito in una superficie di conoscenza affidabile

E stato il maggiore sink di tempo. “Indicizza la documentazione” sembra facile finche non vedi com’e fatto il contenuto reale:
  • copy marketing + componenti
  • MDX e heading che non mappano pulitamente su HTML
  • pagine di navigazione che ripetono blocchi di contenuto
  • varianti di lingua che divergono solo in parte
La nostra pipeline fa:
  1. Scoperta pagine: sitemap + lista di route note.
  2. Fetch HTML renderizzato: cio che utenti e crawler vedono davvero.
  3. Estrazione contenuto principale: rimuovi nav, footer, cookie banner, UI ripetuta.
  4. Normalizzazione: collassa spazi, rimuovi query di tracking dai link.
  5. Segmentazione:
    • record a livello pagina per navigazione e citazioni
    • record a livello chunk per retrieval
  6. Annotazione:
    • locale
    • visibilita (pubblico, client-only, interno)
    • outline degli heading
    • link interni in uscita
La lezione principale: indicizza il sito renderizzato, non solo il repo sorgente, oppure perdi cio che l’utente vede davvero e finisci per citare contenuti che in produzione non esistono.

Retrieval: combinare lessicale e vettoriale prima di “chiedere al modello”

Non ci fidiamo di una sola strategia di retrieval. Facciamo:
  • ricerca lessicale per token esatti (ottima per identificatori, acronimi, codici errore)
  • ricerca vettoriale per match semantico (ottima per domande vaghe)
Poi uniamo i candidati e applichiamo sanity check basilari:
  • scarta pagine che non corrispondono al locale dell’utente (a meno che lo chieda esplicitamente)
  • preferisci pagine canoniche rispetto a tag page / listing duplicati
  • preferisci pagine con forte overlap tra heading e termini della query
Solo dopo lasciamo che l’assistente apra pagine e sintetizzi.

Design degli strumenti: cosa puo fare l’assistente (e cosa no)

Abbiamo implementato una superficie strumenti piccola e l’abbiamo resa rigorosa: Consentito:
  • elencare directory: ls /services
  • cercare path: find -name "billing"
  • leggere una pagina completa: cat /services/seo
  • cercare nel contenuto: grep -ri "canonical" /
Non consentito:
  • scrivere o modificare contenuti
  • fetch di URL arbitrari
  • chiamate di rete arbitrarie
Il vantaggio in sicurezza e enorme: rende la “prompt injection” soprattutto un problema di qualita del contenuto, non un problema di compromissione del sistema.

La lezione sulla latenza: filesystem reali sono troppo lenti per una chat interattiva

Se avvii un sandbox/container per sessione per fornire un filesystem reale:
  • il cold start diventa visibile
  • la chat sembra rotta
  • sei tentato di aggiungere complessita (pool caldi, ecc.)
Per un widget di supporto, l’utente sta fissando la UI. Servono strumenti istantanei. Quindi abbiamo virtualizzato le operazioni filesystem sopra il nostro indice:
  • ls e find risolvono dal path tree cacheato
  • cat ricompone la pagina dai chunk salvati (ordinati per chunk_index)
  • i risultati vengono cacheati per sessione (e in parte globalmente, quando e sicuro)
L’assistente ha l’illusione di una shell sopra dei file, ma non esistono file reali.

Cache di cio che si ripete: albero, pagine e “target di grep”

Cacheare “risposte” e fragile perche le domande variano. Cio che si ripete nelle conversazioni reali e:
  • listare le stesse sezioni
  • aprire le stesse 5-10 pagine importanti
  • fare grep sugli stessi token
Quindi abbiamo cacheato:
  • il path tree
  • pagine ricostruite complete
  • candidati recenti per grep
Questo ha contato piu di micro-ottimizzare gli embedding, perche ha aumentato la velocita nei follow-up e ridotto i loop “searching…”.

RBAC: il controllo accessi deve essere strutturale, non basato sul prompt

Se alcune docs non sono pubbliche (draft, note interne, docs client-only), non puoi affidarti al prompting. Abbiamo applicato RBAC prima che l’assistente esegua una singola tool call:
  • costruisci un path tree per utente
  • pruni tutto cio a cui l’utente non puo accedere
  • applica lo stesso filtro a ogni query e lettura pagina
Se l’assistente non puo fare ls su un file, non puo fare cat e non puo citarlo. E l’unico modello mentale che regge sotto pressione.

Guardrail: come abbiamo fermato risposte sbagliate ma “sicure”

Abbiamo aggiunto alcune regole che hanno ridotto drasticamente le risposte cattive:

Regola 1: niente evidenza, niente risposta

Se l’assistente non trova contenuto di supporto con gli strumenti, deve:
  • dire che non ha potuto verificare
  • mostrare cosa ha controllato (pagine o sezioni)
  • proporre un next step (contatto/supporto)

Regola 2: preferire citazioni a parafrasi per dettagli critici

Quando la risposta e sensibile alla formulazione esatta (requisiti, limitazioni, copy legale):
  • cita la/le frase/i rilevanti della pagina letta
  • mantieni la sintesi minimale

Regola 3: essere rigorosi su locale e pagine canoniche

Se l’utente e su una route di lingua, l’assistente deve:
  • preferire il contenuto di quel locale
  • evitare di mischiare lingue nella stessa risposta
  • fare fallback sul locale di default solo se la pagina in quel locale non esiste

Il “problema grep”: la feature killer richiede un piano in due fasi

Un grep ricorsivo naive e lento se legge tutto via rete. Abbiamo usato un approccio in due fasi:
  1. filtro grossolano usando l’indice (quali pagine potrebbero contenere il token)
  2. filtro fine in memoria sul testo pagina cacheato per estrarre match esatti + contesto
Questo ha fatto sembrare l’assistente capace di cercare come un developer, non di indovinare come un chatbot.

UX del widget: la UI fa o disfa la fiducia

Abbiamo rilasciato piu iterazioni UI prima che il widget risultasse affidabile. Cosa ha contato di piu:
  • streaming di token con layout stabile (evita reflow “saltellante”)
  • stati chiari:
    • “Cercando…”
    • “Leggendo la pagina…”
    • “Rispondendo…”
  • citazioni brevi inline:
    • “Da: /services/seo”
    • “Da: /legal/privacy”
  • fallback che non sembrano un fallimento:
    • “Ho controllato X e Y ma non ho potuto confermare; ecco come contattarci.”
Questo si combina bene col rendere visibile il “lavoro” dell’assistente. Gli utenti perdonano molto piu un bot che dice “non ho potuto confermare” rispetto a uno che inventa una risposta sicura.

Osservabilita: loggare le letture, non solo i token

Se vuoi migliorare la qualita, devi sapere cosa e successo. Abbiamo loggato:
  • tool call (path listati/letti/cercati)
  • quali pagine sono state usate come evidenza
  • lunghezza della risposta e bucket di latenza
  • tasso “non ho potuto verificare”
  • tasso di escalation (click su contatto)
Questo ci ha permesso di rispondere a domande pratiche:
  • quali pagine mancano di informazioni importanti?
  • quali domande non trovano mai evidenza?
  • quali pagine generano confusione e richiedono ristrutturazione?

Una checklist pratica di build (cosa rifaremmo)

Se stai costruendo un widget di supporto che chatta con i tuoi contenuti, partiremmmo da:
  • indicizzare il sito renderizzato e salvare record a livello pagina
  • combinare retrieval lessicale + vettoriale
  • aggiungere un layer di esplorazione (file + grep)
  • prunare i contenuti per RBAC prima che inizi la sessione
  • mantenere gli strumenti read-only di default
  • aggiungere la regola “niente evidenza, niente risposta”
  • rendere visibile in UI “cercando/leggendo/rispondendo”
  • loggare le tool call per poter debuggare i failure
Se vuoi che ti aiutiamo a implementare questo (widget + indicizzazione + architettura sicura):