Costruire un chatbot aziendale con RAG su documentazione interna: guida pratica
A novembre 2025 un'azienda del settore servizi di manutenzione macchinari industriali - 90 dipendenti interni di cui 14 in assistenza tecnica, fatturato annuo intorno ai 21 milioni di euro, circa 450 clienti PMI italiane con parchi macchinari diversi - mi ha raccontato un problema operativo ricorrente. Il team di assistenza tecnica riceveva in media 180 chiamate settimanali dai clienti: guasti, richieste di supporto sui manuali operativi, verifiche di compatibilità ricambi, procedure di manutenzione preventiva. L'analisi dei ticket degli ultimi 6 mesi rivelava che il 65% delle chiamate rientrava in circa 50 domande ricorrenti, ognuna con risposta standardizzata disponibile nei manuali tecnici, nelle schede prodotto o nelle procedure interne. Il tecnico di secondo livello passava in media 4 minuti a chiamata cercando la risposta nella documentazione (manuali PDF di 300 pagine ciascuno, 1.400 procedure in un Confluence aziendale, un database di FAQ non strutturato) prima di rispondere al cliente. Moltiplicato per 180 chiamate alla settimana, il tempo complessivo di ricerca informazione era di circa 12 ore/settimana - l'equivalente di un terzo di una risorsa full-time, dedicato a fare lookup manuali su documenti che l'azienda aveva già scritto.
In cinque settimane ho costruito un chatbot RAG (Retrieval-Augmented Generation) interno self-hosted, basato su embedding della documentazione tecnica dell'azienda (200 documenti totali: manuali di 28 linee di prodotto, 1.400 procedure di manutenzione, 600 entry di FAQ storiche estratte dai ticket chiusi). Il chatbot è deployato su un VPS Hetzner dedicato con Qdrant come vector database documentato ufficialmente su qdrant.tech e modello di embedding sentence-transformers multilingua per italiano. L'inferenza LLM gira su un'istanza locale di Ollama documentato ufficialmente su ollama.com con modello open source specializzato per task di Q&A tecnico - zero dati dell'azienda o dei clienti vengono inviati ad API esterne. Al termine del rollout: il chatbot risponde autonomamente al 78% delle richieste interne senza escalation al tecnico, i tempi medi di risposta ai clienti sono passati da 8-12 minuti a 2-3 minuti, i 14 tecnici hanno recuperato circa 10 ore/settimana da dedicare a casi complessi invece che a lookup ripetitivi. Questo articolo descrive l'architettura esatta, le scelte di design sui modelli e sui vector store, e il pattern operativo per mantenere aggiornata la knowledge base nel tempo.
RAG in tre righe: cos'è davvero e perché è diverso da un chatbot generico
Il termine RAG è diventato buzzword nel 2024-2025 e viene usato in modo impreciso. La definizione tecnica rigorosa è semplice: RAG è un'architettura dove, di fronte a una domanda dell'utente, il sistema prima retrieva documenti rilevanti da una knowledge base, poi li passa come contesto a un LLM che genera la risposta basandosi su quei documenti specifici.
Il punto chiave che distingue RAG da un chatbot generico è: l'LLM non risponde dalla sua "conoscenza generale" (che sarebbe generic, potenzialmente obsoleta, non specifica dell'azienda), ma dalla documentazione dell'azienda che gli viene iniettata nel prompt. Se chiedi al chatbot RAG "come si cambia il filtro dell'olio sul modello XYZ-500?", il sistema trova i 3-5 chunk di documentazione più rilevanti per quella query (estratti dal manuale del modello XYZ-500), e l'LLM produce la risposta basandosi su quei chunk. Non inventa, non attinge a conoscenza generica, non allucina dettagli - cita letteralmente o parafrasa il manuale. Se la documentazione non contiene l'informazione, il sistema deve dire "non lo so" invece di inventare una risposta plausibile.
Questo pattern è particolarmente importante per casi d'uso aziendali dove l'accuratezza conta più della verbosità. Un generico ChatGPT che inventasse i valori di torque per stringere le viti di un cuscinetto industriale sarebbe catastrofico - il tecnico lo crederebbe, applicherebbe il valore sbagliato, il cuscinetto si romperebbe, il danno sarebbe alla macchina del cliente. Un RAG basato sulle procedure reali dell'azienda risponde con i valori esatti che il team tecnico ha validato anni fa, oppure ammette di non averli.
L'architettura a quattro componenti
Il sistema si articola in quattro componenti distinti. Primo: la pipeline di ingestion che legge i documenti dell'azienda, li spezza in chunk di dimensioni calibrate, calcola gli embedding, li salva nel vector store. Questo stadio gira una volta al setup iniziale, e poi in modo incrementale a ogni update della documentazione (tipicamente settimanale). Secondo: il vector store che conserva gli embedding ed esegue ricerca per similarità semantica. Terzo: il retriever che, ricevuta una domanda, calcola l'embedding della domanda e interroga il vector store per i top-K chunk più simili. Quarto: il generator LLM che riceve la domanda + i chunk rilevanti e produce la risposta finale.
La scelta dei tool per ognuno di questi componenti è stata pragmatica. Per la pipeline di ingestion ho usato Python con LangChain come framework documentato ufficialmente dal progetto open source su python.langchain.com, che offre loader pre-fatti per PDF, Markdown, HTML, Confluence export, e splitter configurabili per chunking. Per il vector store ho scelto Qdrant per tre ragioni: è open source e self-hostable (compatibile con la policy di data sovereignty del cliente), ha performance eccellenti su dataset nell'ordine di centinaia di migliaia di chunk, e ha un'API REST semplice che non richiede SDK specifici. Per l'embedding model ho usato sentence-transformers/paraphrase-multilingual-mpnet-base-v2 che supporta bene l'italiano tecnico ed è disponibile in formato ONNX ottimizzato per inferenza CPU-bound veloce. Per il generator LLM ho scelto un modello open source da 7-8B parameters via Ollama - abbastanza grande da gestire prompt complessi, abbastanza piccolo da girare con buone performance su una GPU consumer-grade o addirittura solo CPU su hardware adeguato.
La pipeline di ingestion: dove si vince o si perde la qualità finale
La qualità del chatbot RAG dipende al 70% dalla qualità dell'ingestion - come i documenti vengono letti, puliti, chunked, embedded. Un documento mal ingestato produce chunk inutili che il retriever non troverà mai, oppure produce rumore che degrada le risposte del generator. Il dettaglio che molti team sottovalutano è che ogni tipo di documento richiede una strategia di ingestion diversa.
Per i manuali PDF tecnici (documenti lunghi, strutturati in capitoli e sezioni, con tabelle e immagini), la strategia è usare un parser che preservi la struttura gerarchica (capitolo → sezione → paragrafo), spezzare in chunk di ~500-800 token con overlap di 100 token per preservare il contesto, includere nel metadata del chunk il riferimento al capitolo e alla sezione di origine. Quando il retriever trova un chunk rilevante, può mostrare all'utente "fonte: Manuale XYZ-500, Capitolo 4.3 Manutenzione Preventiva", un riferimento che l'utente può verificare. Il chunk overlap è importante: senza di esso, un concetto che si estende a cavallo di due chunk verrebbe spezzato e nessuno dei due chunk sarebbe sufficiente per rispondere.
Per le FAQ già strutturate (coppie domanda/risposta), la strategia è preservare l'unità semantica intera della coppia - ogni FAQ diventa un chunk indipendente, con l'embedding calcolato sulla domanda (per matching diretto con domande simili) e la risposta preservata per la generazione.
Per le procedure operative (documenti brevi ma molto specifici, con step sequenziali), la strategia è mantenere la procedura intera in un singolo chunk se sta nel limite di token (~1500 token), altrimenti spezzare per gruppi di step correlati mai per singolo step isolato. Un chunk con "Step 5: avvitare la vite a 45 Nm" senza il contesto degli altri step sarebbe inutile.
Lo script Python semplificato per l'ingestion è questo:
# ingestion.py
from langchain.document_loaders import PyPDFLoader, TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from sentence_transformers import SentenceTransformer
from qdrant_client import QdrantClient, models
import os
import uuid
def ingest_documents(docs_dir: str, collection_name: str):
client = QdrantClient(host="localhost", port=6333)
model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-mpnet-base-v2")
# Crea la collezione se non esiste
try:
client.get_collection(collection_name)
except Exception:
client.create_collection(
collection_name=collection_name,
vectors_config=models.VectorParams(size=768, distance=models.Distance.COSINE)
)
splitter = RecursiveCharacterTextSplitter(
chunk_size=700,
chunk_overlap=100,
separators=["\n\n## ", "\n\n### ", "\n\n", "\n", ". ", " ", ""]
)
points = []
for root, _, files in os.walk(docs_dir):
for filename in files:
filepath = os.path.join(root, filename)
if filename.endswith(".pdf"):
loader = PyPDFLoader(filepath)
elif filename.endswith(".md"):
loader = TextLoader(filepath)
else:
continue
docs = loader.load()
for doc in docs:
chunks = splitter.split_text(doc.page_content)
for chunk in chunks:
embedding = model.encode(chunk).tolist()
points.append(models.PointStruct(
id=str(uuid.uuid4()),
vector=embedding,
payload={
"text": chunk,
"source": filepath,
"page": doc.metadata.get("page", 0),
}
))
# Bulk insert in batches per efficienza
for i in range(0, len(points), 100):
batch = points[i:i+100]
client.upsert(collection_name=collection_name, points=batch)
print(f"Ingestati {len(points)} chunks in {collection_name}")Il vero dettaglio interessante non è il codice in sé (che è relativamente meccanico), è la calibrazione empirica dei parametri. Su documenti diversi, il chunk size ottimale cambia. Sul cliente manutenzione, dopo cinque iterazioni su un dataset di validazione (50 domande reali con le risposte corrette note), la configurazione ottimale è stata chunk 700 token, overlap 100, modello multilingual-mpnet. Altri modelli testati (bge-m3, e5-mistral) hanno prodotto risultati marginalmente diversi ma con penality di performance o costo che non giustificavano il cambio.
Stai cercando un Consulente Informatico esperto per costruire un chatbot RAG self-hosted sulla documentazione interna della tua PMI, con deployment privato senza esposizione di dati ad API esterne? Nel mio profilo professionale trovi l'esperienza concreta su LLM automation, architetture RAG, Ollama e deployment di AI privata per aziende italiane che richiedono sovranità del dato.
Il retriever: semantic search vs hybrid search
Il retriever è il componente che, data una domanda utente, trova i chunk più rilevanti. La strategia naïve è la semantic search pura: calcola l'embedding della domanda, cerca i K chunk con cosine similarity più alta. Questo funziona ragionevolmente bene ma fallisce in due scenari critici.
Primo scenario: domande con codici prodotto specifici (es: "qual è la procedura di manutenzione per il modello XYZ-500?"). La stringa "XYZ-500" è un identificatore univoco che il match semantico tende a ignorare - un documento che parla genericamente di "modelli serie XYZ" avrà alta similarity con la query, ma un documento specifico sul modello "XYZ-500" potrebbe avere similarity più bassa di uno generico. Il risultato: il chatbot recupera documenti approssimati invece di quello esatto.
Secondo scenario: domande con termini tecnici rari. Se l'utente chiede di un termine tecnico specifico che appare solo in un documento del corpus, il semantic search può perdere quel documento se il suo embedding non è abbastanza dominato dal termine raro.
La soluzione è la hybrid search: combinare semantic search con keyword search (BM25 classico) e fondere i risultati. Qdrant supporta hybrid search nativamente; il pattern in codice è:
def hybrid_search(query: str, k: int = 5):
query_embedding = model.encode(query).tolist()
# Semantic search
semantic_results = client.search(
collection_name=collection_name,
query_vector=query_embedding,
limit=k * 2,
)
# Keyword search via full-text index su payload.text
keyword_results = client.scroll(
collection_name=collection_name,
scroll_filter=models.Filter(
should=[
models.FieldCondition(
key="text",
match=models.MatchText(text=term)
) for term in extract_keywords(query)
]
),
limit=k * 2,
)
# Reciprocal Rank Fusion per combinare i ranking
fused = reciprocal_rank_fusion([semantic_results, keyword_results])
return fused[:k]Il pattern Reciprocal Rank Fusion è un metodo standard documentato in letteratura scientifica dell'information retrieval che combina ranking multipli dando peso equilibrato a entrambi. Implementarlo è 20 righe di Python. Sul cliente manutenzione, il passaggio da semantic search pura a hybrid search ha migliorato il recall (percentuale di risposte corrette) dall'72% al 78% - un miglioramento significativo senza cambiamenti strutturali.
Il generator LLM: prompt template e guardrail
L'ultimo componente è il generator LLM. Ricevuti i top-K chunk dal retriever, il sistema li compone in un prompt strutturato inviato al modello. Il prompt template che uso, calibrato dopo diverse iterazioni, è questo:
Sei l'assistente tecnico di [Nome Azienda], specializzato in manutenzione
di macchinari industriali. Rispondi alle domande dei tecnici basandoti
ESCLUSIVAMENTE sui documenti di riferimento forniti sotto.
REGOLE RIGOROSE:
1. Se la risposta non è nei documenti forniti, rispondi:
"Non trovo questa informazione nei documenti disponibili.
Contatta il supporto di secondo livello."
2. NON inventare valori numerici (tolleranze, torque, pressioni, misure).
Se il documento non specifica un valore, dichiara che il valore
non è specificato.
3. Cita SEMPRE il documento di origine in fondo alla risposta nel formato:
"Fonte: [nome_documento], pag. [numero]"
4. Usa italiano tecnico piano, evita gergo non necessario.
5. Se la domanda è ambigua, chiedi chiarimenti invece di indovinare.
DOCUMENTI DI RIFERIMENTO:
{context}
DOMANDA:
{question}
RISPOSTA:Le cinque regole sono ognuna il risultato di un errore osservato nelle versioni precedenti. La regola 1 contro l'allucinazione quando la knowledge base non copre il tema. La regola 2 è specifica del dominio tecnico industriale - un LLM che inventa un torque di stringimento è pericoloso. La regola 3 forza la tracciabilità, permettendo al tecnico di verificare manualmente se la risposta lo lascia in dubbio. La regola 4 evita lo stile "AI-generated verboso" che i tecnici trovavano fastidioso. La regola 5 è la difesa contro ambiguità - se la domanda è "il tempo di sostituzione della cinghia", il chatbot deve chiedere "di quale modello di macchina?" invece di scegliere a caso.
L'implementazione finale del generator con controlli post-risposta:
def generate_response(question: str, chunks: list, model_name: str = "llama3:8b"):
context = "\n\n---\n\n".join([
f"[{c.payload['source']} pag. {c.payload['page']}]\n{c.payload['text']}"
for c in chunks
])
prompt = build_prompt(context, question)
response = ollama.generate(
model=model_name,
prompt=prompt,
options={
"temperature": 0.1, # bassa, per risposte consistenti
"top_p": 0.9,
"num_ctx": 8192,
"stop": ["\n\nDOMANDA:", "\n\nRISPOSTA:"],
}
)
answer = response['response']
# Post-validation: verifica che la risposta citi una fonte
if "Fonte:" not in answer and "Non trovo" not in answer:
answer += "\n\nATTENZIONE: risposta senza citazione fonte, verificare manualmente."
return answerLa temperatura a 0.1 è deliberata: vuole risposte consistenti e conservative, non creative. Per un chatbot tecnico aziendale, creatività è un difetto; consistenza è una feature. La post-validation della citazione fonte è un ulteriore guardrail contro il modello che dimenticasse di citare - se manca, l'utente riceve un warning esplicito di verificare manualmente.
Feedback loop: come il sistema migliora nel tempo
Un sistema RAG statico degrada nel tempo - la documentazione evolve, emergono nuove domande, le risposte che erano buone diventano incomplete. Il pattern di miglioramento continuo che ho implementato sul cliente ha tre meccanismi.
Primo: feedback diretto degli utenti tecnici. A ogni risposta del chatbot, il tecnico può premere thumbs-up o thumbs-down con un campo testuale opzionale. Il feedback viene loggato in un database dedicato con la domanda, la risposta, i chunk usati, il voto e il commento. Settimanalmente rivedo i feedback negativi per identificare pattern ricorrenti di errore.
Secondo: analisi dei "don't know". Le risposte in cui il chatbot dichiara "non trovo l'informazione" sono log specificamente, e rappresentano un gap della knowledge base. Se una stessa domanda genera molti "don't know" da più tecnici, significa che manca documentazione su quel tema - e può essere aggiunta alla knowledge base dopo averla creata. Questo trasforma il chatbot in uno strumento diagnostico per capire dove la documentazione aziendale è debole.
Terzo: aggiornamento incrementale della knowledge base. La pipeline di ingestion è stata resa incrementale - rileva i documenti nuovi o modificati dall'ultimo run, ri-ingesta solo quelli, mantiene consistenza del vector store. Un job settimanale scheduled gira automaticamente la pipeline, il team IT non deve pensarci. Questo pattern è simile a quello che descrivo nel mio articolo sull'automazione della documentazione tecnica con LLM per trasformare codice in wiki aziendale - documentazione che si mantiene automaticamente aggiornata è l'unica che rimane usabile nel tempo.
Costo operativo e benefici misurati
Il cliente manutenzione gestisce il sistema su un VPS Hetzner AX42 dedicato (costo ~75 euro/mese) con una GPU consumer (opzionale - l'inferenza gira anche su CPU ma con latenze 2-3x più alte). Il costo software è zero (tutto open source). Il tempo di lavoro per il mantenimento è ~2 ore/settimana (review feedback + ingestion nuovi documenti). Il costo totale operativo è di circa 120 euro/mese, meno del costo di un'ora di lavoro del tecnico di secondo livello.
Il beneficio misurato a 4 mesi dal rollout: riduzione del 52% del tempo medio speso in ricerca documentazione, riduzione del 34% delle chiamate escalate a tecnici senior (perché le domande base sono gestite dal chatbot), NPS interno del team tecnico migliorato significativamente. Il ROI è chiaramente positivo nel primo trimestre di operatività.
Un beneficio emerso ma non quantificato è la democratizzazione della conoscenza. Prima del chatbot, certi dettagli tecnici complessi erano patrimonio esclusivo dei 2-3 tecnici senior più esperti. Dopo il chatbot, qualunque tecnico junior che chiede può ricevere la stessa risposta. Questo abilita crescita del team senza dipendere dal tempo dei senior per training continuo - un valore strategico a medio termine. Il pattern si integra con la costruzione di MCP server per sviluppatori per integrare Claude Code con strumenti aziendali - RAG è la logica di recupero informazioni, MCP è il canale con cui gli LLM accedono a sistemi esterni in modo strutturato.
Se gestisci un'azienda con documentazione tecnica o procedurale significativa (manuali, procedure, FAQ, contratti, norme interne) e il team passa tempo importante a cercare informazioni che esistono ma non sono facilmente accessibili, oppure vuoi introdurre AI aziendale in modo controllato senza esporre dati a provider esterni, contattami per una valutazione: in tre settimane di lavoro analizzo la struttura della tua documentazione, definisco la strategia di chunking e ingestion calibrata sui tuoi tipi di documento, configuro il vector store e il modello LLM self-hosted, e deployo un chatbot pilota su un sottoinsieme delle domande più frequenti. Dopo il periodo di pilot, insieme al tuo team valutiamo se scalare all'intera base documentale e quali adjustment introdurre per massimizzare il valore operativo.