Chatbot aziendale con RAG su documentazione interna: guida completa self-hosted per privacy massima

Chatbot aziendale con RAG su documentazione interna: guida completa self-hosted per privacy massima

Ho completato il deploy del mio prototipo di chatbot RAG self-hosted il 28 marzo 2026, su due server distinti per separare i ruoli operativi: un Hetzner GEX44 (RTX 4000 Ada con 20 GB VRAM, Intel Xeon Gold 5412U, 64 GB RAM DDR5, 2x NVMe 1,92 TB, Debian 12) che ospita i modelli di inferenza - Ollama con un Llama 3.1 8B quantizzato 4-bit come generatore, un container text-embeddings-inference di Hugging Face con Nomic Embed Text v1.5 per gli embedding - e un Hetzner CCX33 (8 vCPU AMD EPYC 9454P, 32 GB RAM, 240 GB NVMe) che ospita il resto dello stack: PostgreSQL 16 con pgvector 0.7.4 per il vector store, Laravel 12 su PHP 8.3 con FrankenPHP per l'orchestrazione e il backoffice, un frontend React 19 con Vite per l'interfaccia di chat. Il dominio del test è una base di conoscenza interna sintetica di un'azienda fittizia - 340 documenti fra procedure operative, manuali tecnici, FAQ prodotti, note di rilascio - per un totale di 14.200 chunk indicizzati. Il setup completo mi è costato 220 euro al mese di infrastruttura (i due server sommati), zero di API esterne perché tutto gira localmente, 4 ore di tempo di setup una tantum. Rispetto alla mia versione precedente descritta nell'articolo su chatbot RAG per documentazione interna - dove il generatore era Claude API - questa iterazione è integralmente self-hosted, niente token che lascia il datacenter europeo, niente dipendenza da servizi USA. Il compromesso è qualità del modello: Llama 3.1 8B locale è misurabilmente più modesto di Claude Sonnet 4.6, ma per questo caso d'uso specifico - rispondere a 50-100 domande al giorno su procedure aziendali note - la qualità è sufficiente e il vantaggio di data sovereignty è decisivo.

Perché scegliere self-hosted invece di API cloud per un chatbot RAG aziendale?

La risposta breve è che per certe classi di dati la differenza non è tecnica ma legale. Un'azienda italiana che carica la propria documentazione interna su embedding che vengono processati via API OpenAI o Anthropic di fatto trasferisce quei dati su infrastruttura extraeuropea. Anche se i provider dichiarano di non usarli per training (e lo dichiarano), il flusso fisico dei dati attraversa server USA, e un Garante Privacy scrupoloso può contestare la base giuridica del trattamento. La sentenza Schrems II del 2020 ha creato il precedente legale: il trasferimento di dati personali verso USA richiede supplementary measures che la maggior parte delle PMI italiane non implementa. Self-hosted su server europei (Hetzner Germania/Finlandia, OVH Francia) elimina l'intero problema alla radice.

La seconda ragione è di costo prevedibile. API-based sta bene se il volume è basso e stabile. Se il chatbot diventa popolare e gli utenti lo usano intensivamente, la bolletta API cresce in modo non lineare - caso classico di denial-of-wallet già visto nell'articolo sulla gestione di chiamate LLM asincrone con Laravel Horizon. Self-hosted ha costo fisso mensile dell'infrastruttura, capacità teoricamente illimitata nel volume di query (fino a saturazione hardware, ben oltre quello che una PMI genera). Il break-even fra API e self-hosted per il mio caso specifico è intorno alle 1.000 query/mese: sotto, API è più economico; sopra, self-hosted vince.

La terza ragione è esplicabilità e controllo. Con modelli self-hosted puoi loggare ogni input, ogni embedding, ogni risposta, e rispondere a domande di audit sul ragionamento del sistema. Con API, molti log restano dal lato del provider e l'audit completo richiede richieste formali con tempi di settimane.

Se vuoi vedere come progetto pipeline AI aziendali dove data sovereignty e controllo sono prerequisiti architetturali - non aggiunte opzionali - nel mio hub sull'integrazione AI per aziende trovi articoli su self-hosting, vector database, gateway, con criterio comune di evitare lock-in americano sui dati che le PMI italiane preferiscono tenere in casa.

La topologia dei due server: perché separare inferenza da applicazione

Il disegno che ho adottato ha due host ben separati. Il primo, inference host (GEX44 con GPU), ospita i modelli e nient'altro. Niente database di business, niente logica applicativa, niente frontend. Espone via HTTP localhost sui porti 11434 (Ollama) e 8080 (text-embeddings-inference). Il secondo, application host (CCX33), ospita PostgreSQL+pgvector, Laravel, e il frontend React. Parla all'inference host via private network Hetzner (la rete interna gratuita che connette due server nello stesso datacenter) con firewall che permette solo le porte 11434 e 8080.

La separazione ha tre motivi. Primo: isolamento di fallimento. Se il GPU host ha un'OOM o un driver NVIDIA va in panic (succede, capita), l'applicazione host resta su e può degradare graziosamente (es. mostrando all'utente "AI temporaneamente non disponibile, inserisci direttamente il ticket di supporto"). Secondo: scaling indipendente. Il giorno in cui il volume di query sale, posso aggiungere un secondo GPU host dietro un load balancer senza toccare l'application host. Terzo: sicurezza. Il GPU host è esposto in rete privata, non ha SSH pubblico, non ha frontend web - superficie di attacco minimizzata. Il principio è lo stesso che applico nella containerizzazione LLM self-hosted su VPS con GPU: isolare il componente più pesante dal resto dello stack protegge tutto lo stack.

L'ingestione documentale: dal file alla riga pgvector

Il primo stadio operativo della pipeline è portare i documenti in pgvector. Il supporto formati che ho implementato copre: Markdown, PDF (via smalot/pdfparser di Laravel), DOCX (via phpoffice/phpword), HTML (via Symfony/DomCrawler), e plain text. Per ogni documento, il processo è: estrazione testo → normalizzazione (rimozione header/footer ripetuti, merge di paragrafi spezzati dal layout) → chunking con overlap (500 token per chunk, 50 token di overlap) → embedding via text-embeddings-inference → insert in pgvector con metadata.

<?php
declare(strict_types=1);

namespace App\Rag\Ingestion;

use Illuminate\Support\Facades\Http;
use Pgvector\Laravel\Vector;

final class ChunkIngestionService
{
    public function ingestDocument(int $documentId, string $text, array $metadata): int
    {
        $chunks = $this->chunker->split($text, maxTokens: 500, overlapTokens: 50);
        $inserted = 0;

        foreach (array_chunk($chunks, 32) as $batch) {
            $response = Http::post('http://inference-host:8080/embed', [
                'inputs' => array_column($batch, 'text'),
            ])->throw()->json();

            $rows = [];
            foreach ($batch as $i => $chunk) {
                $rows[] = [
                    'document_id' => $documentId,
                    'chunk_index' => $chunk['index'],
                    'content' => $chunk['text'],
                    'embedding' => new Vector($response['embeddings'][$i]),
                    'metadata' => json_encode([...$metadata, 'token_count' => $chunk['tokens']]),
                    'created_at' => now(),
                ];
            }
            \DB::table('document_chunks')->insert($rows);
            $inserted += count($rows);
        }
        return $inserted;
    }
}

Il batch di 32 chunk per chiamata al servizio di embedding è il punto di ottimo empirico sulla RTX 4000 Ada: sopra 32, la VRAM si satura e le chiamate diventano lente; sotto, l'overhead HTTP per chiamata domina. Il throughput complessivo è di circa 1.400 chunk embedded al minuto - sufficiente per elaborare 340 documenti tipici in 12 minuti, una volta tanto al primo deploy e in delta successivi.

L'indice pgvector è HNSW con parametri m=24, ef_construction=128 come ho descritto nell'articolo dedicato su pgvector in produzione con indici HNSW - la configurazione è identica perché il caso d'uso è identico: knowledge base di medie dimensioni, aggiornamenti incrementali, query P95 sotto 50 ms.

L'orchestrazione Laravel: retrieval, prompt composition, chiamata al generatore

Il cuore applicativo è un controller Laravel che riceve la domanda utente, esegue retrieval, compone il prompt, chiama il generatore Ollama, restituisce la risposta al frontend. La sequenza è semplice ma ogni passo ha decisioni importanti.

<?php
declare(strict_types=1);

namespace App\Http\Controllers\Chat;

final class ChatController extends Controller
{
    public function __invoke(ChatRequest $request, RagService $rag, OllamaClient $ollama): JsonResponse
    {
        $query = $request->validated('message');

        $queryEmbedding = $rag->embed($query);
        $topChunks = $rag->retrieveTopK($queryEmbedding, k: 6, minSimilarity: 0.35);

        if (empty($topChunks)) {
            return response()->json([
                'answer' => "Non ho trovato informazioni pertinenti nei documenti interni. Riprova riformulando la domanda oppure apri un ticket al supporto.",
                'sources' => [],
            ]);
        }

        $prompt = $this->promptBuilder->build($query, $topChunks);
        $response = $ollama->generate(model: 'llama3.1:8b-instruct-q4_K_M', prompt: $prompt);

        return response()->json([
            'answer' => $response['text'],
            'sources' => array_map(fn($c) => [
                'document_id' => $c['document_id'],
                'snippet' => mb_substr($c['content'], 0, 200) . '...',
                'similarity' => round($c['similarity'], 3),
            ], $topChunks),
        ]);
    }
}

La soglia minSimilarity: 0.35 è il filtro che trasforma il sistema da chatbot che risponde sempre a chatbot che sa quando non sa. Se la domanda dell'utente è così fuori contesto dal knowledge base che anche il documento più simile ha similarity sotto il 0,35, il sistema risponde onestamente "non ho informazioni pertinenti" invece di allucinare una risposta. Il valore 0,35 è stato calibrato su 200 domande di test: sopra il 0,4 perdevo risposte utili, sotto il 0,25 cominciavano a uscire risposte non ancorate.

Il ritorno include sempre le sources con snippet e similarity. Il frontend le mostra come citazioni cliccabili che aprono il documento originale - principio di provenance visibile che ho descritto nell'articolo sul knowledge management AI-assisted con memoria persistente: l'utente vede da dove viene la risposta e può verificarla.

Il prompt builder: istruzioni strette per evitare hallucination

Il system prompt passato a Llama 3.1 è rigoroso su tre punti. Primo: rispondi usando SOLO le informazioni contenute nei documenti forniti. Secondo: se la risposta non è nei documenti, ammettilo esplicitamente. Terzo: cita il numero del documento di riferimento per ogni affermazione. Nella mia esperienza, un prompt permissivo verso un modello 8B produce allucinazioni nel 15-20% delle risposte; un prompt stretto le porta sotto il 3%.

private function buildPrompt(string $query, array $chunks): string
{
    $context = "";
    foreach ($chunks as $i => $chunk) {
        $context .= "[Documento " . ($i + 1) . "]\n" . $chunk['content'] . "\n\n";
    }
    return <<<PROMPT
Sei un assistente aziendale che risponde a domande degli utenti USANDO SOLAMENTE le informazioni presenti nei documenti forniti.

REGOLE OBBLIGATORIE:
1. Se l'informazione non è nei documenti, rispondi: "Non ho questa informazione nei documenti interni."
2. Cita sempre il numero del documento di riferimento (es. "Come indicato nel [Documento 2]...").
3. NON inventare. NON riempire con dettagli plausibili ma non presenti.
4. Mantieni la risposta concisa (massimo 200 parole).

DOCUMENTI FORNITI:

{$context}

DOMANDA UTENTE: {$query}

RISPOSTA:
PROMPT;
}

Il punto 4 sulla concisione è importante: Llama 3.1 8B tende a essere verbose, e una risposta da 500 parole su una domanda semplice frustra l'utente. Il vincolo di 200 parole nel prompt tiene le risposte focalizzate. Per domande che genuinamente richiedono più spazio (es. "spiegami il processo di onboarding clienti"), il modello ignora il vincolo - è un soft limit che guida ma non forza.

Il frontend React e l'UX delle citations

Il frontend React mostra la chat come interfaccia tipica messenger - bubble utente a destra, bubble assistant a sinistra, typing indicator mentre il modello genera. La differenza rispetto a un chatbot generico è la sezione sources sotto ogni risposta: un riepilogo cliccabile con titolo documento + snippet + similarity score.

function AssistantMessage({ message }: { message: ChatMessage }) {
    return (
        <div className="bubble bubble-assistant">
            <div className="answer">{message.answer}</div>
            {message.sources.length > 0 && (
                <div className="sources">
                    <div className="sources-header">Fonti consultate:</div>
                    {message.sources.map((s, i) => (
                        <a key={i} href={`/docs/${s.document_id}`} className="source-card" target="_blank" rel="noopener">
                            <span className="source-index">[{i + 1}]</span>
                            <span className="source-snippet">{s.snippet}</span>
                            <span className="source-score">({(s.similarity * 100).toFixed(0)}% match)</span>
                        </a>
                    ))}
                </div>
            )}
        </div>
    );
}

Questo pattern di trust through transparency è quello che fa la differenza tra un chatbot aziendale adottato dal team e uno ignorato dopo una settimana. Se il modello dice "la procedura di onboarding richiede 5 giorni lavorativi" e il documento citato lo conferma, l'utente si fida. Se invece il modello dice lo stesso senza fonti, e l'utente che conosce l'azienda sa che non è proprio così, il sistema perde credibilità istantaneamente.

I numeri di quattro settimane di prova sulla sandbox

Dopo quattro settimane di uso nella sandbox con il mio script di simulazione di utenti (circa 400 query totali nel periodo) - mix di domande realistiche su procedure, manuali, FAQ - i numeri sono questi. Accuracy complessiva misurata confrontando risposte del chatbot con risposte corrette note: 78,4%. Falsi negativi (chatbot ha risposto "non ho l'informazione" ma l'informazione c'era): 9,1%. Falsi positivi (chatbot ha inventato o confuso l'informazione): 2,8%. Rimanenti 9,7% sono risposte parzialmente corrette - concetto giusto ma dettagli imprecisi. Il tempo medio di risposta P95 è di 3,8 secondi (1,2 per retrieval pgvector, 2,6 per generazione Llama 3.1 su RTX 4000 Ada).

Per confronto, lo stesso setup con Claude Sonnet 4.6 al posto di Llama 3.1 (test condotto in una config parallela con API) darebbe accuracy stimata intorno al 92% e latenza P95 di 2,1 secondi. La scelta self-hosted quindi costa circa 14 punti percentuali di accuracy e 1,7 secondi di latenza aggiuntiva - in cambio di zero dati che lasciano il perimetro aziendale, costi fissi prevedibili, controllo totale. Per molti casi PMI italiane che ho sentito in conversation iniziale, quel trade-off è accettabile perché il valore del sistema non sta nelle ultime 10 percentuali di accuracy, sta nel togliere all'utente la necessità di mandare email al supporto per una domanda che la wiki risponderebbe - e questo lo fa al 78%.

Quando il chatbot RAG self-hosted non è la scelta giusta

Se il tuo caso d'uso è esterno-rivolto - chatbot che risponde a clienti sul sito, customer care conversazionale - il modello 8B self-hosted probabilmente non basta. Gli utenti esterni fanno domande fuori scenario, hanno aspettative di qualità conversazionale alta, e la differenza tra Llama 3.1 8B e Claude Sonnet 4.6 si sente. Se il volume di query è inferiore a 500 al mese, spendere 220 euro mensili per due server self-hosted è sproporzionato - l'API-based costa 10-30 dollari/mese per quel volume. Se il tuo team non ha nessuna competenza di operazioni Linux e infrastructure (provisioning, backup, monitoraggio), il self-hosted diventa un secondo job che distrae dal lavoro vero.

Il pattern self-hosted si giustifica quando hai contemporaneamente: dati interni che non puoi mandare su API esterne (settore sanitario, bancario, PA, contratti con clausole di data residency), volume sopra le 1.000 query/mese che rende il costo API significativo, team di sviluppo con competenze infrastructure Linux/Docker/PostgreSQL, e orizzonte di utilizzo di almeno 18 mesi che ammortizza l'investimento iniziale. In quel punto di ottimo, il self-hosted non è una scelta ideologica ma economica: è strettamente più conveniente e più difendibile legalmente.

La differenza vera fra un'azienda che adotta un chatbot RAG self-hosted e una che tiene tutto in cloud USA non è di sofisticazione tecnica - è di consapevolezza del rischio. Nel 2026, con il Garante Privacy italiano che ha emesso ripetuti provvedimenti su servizi AI che trasferiscono dati personali fuori dall'UE, la scelta di mantenere il perimetro dati su infrastruttura europea non è più un plus opzionale - è diventato il default responsabile per chi tratta dati di cittadini italiani. Il consulente IT che spiega questo al cliente PMI sta facendo un servizio di anni avanti rispetto a chi gli propone "la soluzione AI di OpenAI integrata nel gestionale". Non perché OpenAI sia male - perché il percorso di dati che quella integrazione implica non è compatibile con un rigoroso rispetto GDPR senza misure supplementari che costano di più del self-hosting stesso.

Se stai valutando l'introduzione di un chatbot RAG per la tua documentazione aziendale interna e vuoi capire se il pattern self-hosted è economicamente e operativamente adatto al tuo contesto, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.

Ultima modifica: