Client-Side RAG con Transformers.js: AI nel browser senza server


Client-Side RAG con Transformers.js: AI nel browser senza server

La maggior parte delle applicazioni AI dipende da server remoti: le query dell’utente vengono inviate a un’API, processate su GPU cloud, e le risposte tornano indietro. Questo approccio funziona, ma ha costi nascosti: latenza, costi di infrastruttura, e soprattutto il dato dell’utente esce dal browser.

Con WebRAG AI ho costruito un assistente conversazionale che gira interamente nel browser: embeddings, ricerca semantica e risposte contestuali, tutto senza un singolo byte inviato a server esterni.

Architettura: cosa serve per un RAG client-side

Un sistema RAG ha tre componenti fondamentali:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    BROWSER                       β”‚
β”‚                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚  Knowledge    β”‚    β”‚  Embedding Model     β”‚   β”‚
β”‚  β”‚  Base (JSON)  β”‚    β”‚  (Transformers.js)   β”‚   β”‚
β”‚  β”‚  ~112 chunks  β”‚    β”‚  e5-base / e5-small  β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚         β”‚                       β”‚                β”‚
β”‚         β–Ό                       β–Ό                β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚           Vector Search                   β”‚   β”‚
β”‚  β”‚     (cosine similarity, top-k)            β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                     β”‚                            β”‚
β”‚                     β–Ό                            β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   β”‚
β”‚  β”‚         Risultati con Source              β”‚   β”‚
β”‚  β”‚    (citazioni cliccabili ai contenuti)    β”‚   β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚
β”‚                                                  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         Zero connessioni a server esterni

1. Knowledge Base: chunking e pre-computazione

Il primo passo e costruire una base di conoscenza. I contenuti del sito (blog post, pagine progetto, poesie) vengono:

  1. Estratti a build time da Markdown/HTML
  2. Suddivisi in chunk di ~1000 token con overlap di 200 token
  3. Vettorializzati con il modello di embedding
  4. Salvati come JSON statico (rag-embeddings.json)
// Configurazione chunking
const CHUNK_CONFIG = {
  maxTokens: 1000,     // Dimensione chunk
  overlapTokens: 200,  // Overlap tra chunk consecutivi
  minTokens: 50        // Chunk minimo (evita frammenti inutili)
};

Perche pre-computare? Calcolare embeddings runtime per 112 chunk richiederebbe 30-60 secondi su CPU. Pre-computandoli a build time, la ricerca e istantanea (<100ms).

2. Modello di Embedding: Transformers.js + ONNX

Transformers.js di Hugging Face permette di eseguire modelli ONNX direttamente nel browser:

import { pipeline } from '@xenova/transformers';

// Carica modello di embedding multilingue
const embedder = await pipeline(
  'feature-extraction',
  'Xenova/multilingual-e5-base',  // ~220MB, supporta italiano
  {
    device: 'webgpu',             // GPU se disponibile
    dtype: 'q8'                   // Quantizzazione 8-bit
  }
);

// Genera embedding per la query utente
const queryEmbedding = await embedder(
  'passage: come funziona il monitoraggio sismico?',
  { pooling: 'mean', normalize: true }
);

Scelta del modello: multilingual-e5-base supporta 100+ lingue (cruciale per un sito in italiano) e ha un buon rapporto qualita/dimensione.

3. Ricerca Semantica: cosine similarity

Con gli embeddings pre-computati e l’embedding della query, la ricerca e un semplice calcolo di similarita:

function cosineSimilarity(a, b) {
  let dot = 0, normA = 0, normB = 0;
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];
    normA += a[i] * a[i];
    normB += b[i] * b[i];
  }
  return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}

// Cerca i chunk piu rilevanti
function search(queryEmbedding, knowledgeBase, topK = 5) {
  return knowledgeBase
    .map(chunk => ({
      ...chunk,
      score: cosineSimilarity(queryEmbedding, chunk.embedding)
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, topK);
}

Con 112 chunk, questa operazione richiede <5ms. Anche con migliaia di chunk, resterebbe sotto i 50ms.

Le sfide pratiche (e come risolverle)

ONNX/WASM: la configurazione che funziona davvero

La documentazione di Transformers.js non copre tutti i problemi reali. Dopo settimane di debugging, questa e la configurazione stabile:

import { env } from '@xenova/transformers';

// Disabilita proxy worker (causa crash su alcuni browser)
env.backends.onnx.wasm.proxy = false;

// Disabilita caching browser (privacy-first)
env.useBrowserCache = false;

// Quantizzazione stabile per WASM
const targetDtype = 'q8';  // NON fp16 su WASM

// Nella generazione (se usi LLM)
const generateConfig = {
  use_cache: false,   // Previene buffer overflow
  num_beams: 1        // No beam search (instabile su WASM)
};

Lezione appresa: wasm.proxy = true (il default) lancia un Web Worker separato per ONNX. Questo funziona nel 90% dei casi, ma causa crash silenziosi su Safari e browser mobile. Disabilitandolo, il modello gira nel thread principale β€” leggermente piu lento ma stabile al 100%.

WebGPU vs WASM: fallback graceful

async function detectBestBackend() {
  // Prova WebGPU (10x piu veloce)
  if (navigator.gpu) {
    try {
      const adapter = await navigator.gpu.requestAdapter();
      if (adapter) return 'webgpu';
    } catch (e) {
      console.warn('[RAG] WebGPU non disponibile:', e);
    }
  }
  // Fallback a WASM (funziona ovunque)
  return 'wasm';
}

Risultati reali: | Backend | Load time | Search latency | Supporto browser | |β€”β€”β€”|———–|β€”β€”β€”β€”β€”|β€”β€”β€”β€”β€”β€”| | WebGPU | ~3s | <20ms | Chrome 121+, Edge 121+ | | WASM | ~8s | <100ms | Tutti i browser moderni |

Privacy: sessionStorage only

Per un sito GDPR-compliant, nessun dato deve persistere tra sessioni:

// NO IndexedDB, NO localStorage, NO Browser Cache
// Solo sessionStorage (si cancella alla chiusura del tab)

env.useBrowserCache = false;     // Transformers.js non cachera modelli
env.cacheDir = undefined;        // Nessuna directory cache

// Consenso utente per sessione
if (!sessionStorage.getItem('ai-consent')) {
  showConsentDialog();  // L'utente accetta per questa sessione
}

Trade-off: il modello (~120-220 MB) viene riscaricato ad ogni sessione. E un costo accettabile per la privacy totale.

Performance reali

Testato su hardware consumer (laptop 2023, no GPU dedicata):

Metrica Valore
Download modello ~5-15s (dipende dalla rete)
Prima query <200ms
Query successive <100ms
Memoria ~300-500MB
Knowledge base 112 chunk, ~40 documenti

Quando ha senso un RAG client-side?

Si, se:

  • Il corpus e piccolo/medio (< 10.000 chunk)
  • La privacy e un requisito non negoziabile
  • Non vuoi costi di infrastruttura server
  • Il target e desktop/laptop (non mobile)

No, se:

  • Hai milioni di documenti
  • Serve real-time su mobile
  • Il modello di embedding non supporta la tua lingua
  • Vuoi risposte generative complesse (serve un LLM completo)

Risorse