Hai una libreria di PDF — manuali tecnici, contratti, normative, paper accademici, slide aziendali — e vuoi chiedere al computer "cosa dice il documento sui requisiti di backup nel capitolo 7?" senza spedire i file a OpenAI o Google. È esattamente quello che fa il RAG (Retrieval-Augmented Generation): combina un motore di ricerca semantica sui tuoi documenti con un LLM locale che risponde citando le fonti. Questo tutorial costruisce un RAG completo, offline, in dieci minuti di lavoro, usando Ollama, LangChain e Chroma. Niente costi per API, nessun dato esce dal computer.
A chi serve, cosa otterrai
La guida è pensata per consulenti, avvocati, ricercatori, tecnici IT, studenti di magistrale, sviluppatori che vogliono integrare una funzione "chiedi ai documenti" in un'app interna. Alla fine avrai uno script Python che indicizza una cartella di PDF in un database vettoriale Chroma, e una piccola CLI per fare domande con risposte e citazioni alla pagina. Tutto gira su CPU o su qualsiasi GPU consumer.
Quali strumenti usare e perché
- Ollama: server locale per LLM open source. Gratuito, multipiattaforma, scarica modelli con un comando. Pro: zero configurazione. Contro: meno controllo fine rispetto a llama.cpp o vLLM.
- LangChain (v0.3+): framework di orchestrazione per pipeline LLM. Gestisce loader PDF, splitter, retriever, prompt. Pro: comunità grande, integrazioni pronte. Contro: API che cambiano spesso fra versioni.
- Chroma: database vettoriale leggero, embeddato in Python. Pro: zero infrastruttura, basta una cartella su disco. Contro: non scala oltre il milione di chunk; sopra serve Qdrant o PostgreSQL+pgvector.
- nomic-embed-text: modello di embedding via Ollama. 137M parametri, ottimo rapporto qualità/velocità per l'inglese e buono per l'italiano.
- llama3.1:8b oppure qwen3.6:7b: LLM generativi locali. Prima scelta: llama3.1:8b se hai 8 GB+ di VRAM, altrimenti llama3.2:3b.
Costi e alternative
L'intero stack è gratuito e open source. Le alternative commerciali (OpenAI Assistants con File Search, Claude Projects, Gemini grounding) costano meno della tua ora di lavoro se i PDF sono pochi e non sensibili, ma il vantaggio del RAG locale è la privacy totale e l'indipendenza da vincoli di quota. Per documentazione legale o sanitaria, è la soluzione più sicura.
Prerequisiti
- Computer con almeno 16 GB di RAM (24 GB raccomandati). Windows 10/11, macOS 12+ o Linux moderno.
- Python 3.11 o 3.12 installato (python.org).
- 40 GB di spazio libero (Ollama + modelli + indice).
- Una manciata di PDF di test (basta una cartella).
Passo 1 — Installare Ollama e scaricare i modelli
Su Windows o macOS scaricare l'installer da ollama.com/download e lanciarlo. Su Linux:
curl -fsSL https://ollama.com/install.sh | shVerifica con: ollama --version. Poi scarica i modelli che ti servono:
ollama pull nomic-embed-text
ollama pull llama3.1:8b
# in alternativa, se hai poca VRAM:
ollama pull llama3.2:3bIl primo pesa ~270 MB, il secondo circa 4,7 GB (llama3.1:8b), il terzo 2 GB.
Passo 2 — Ambiente Python e dipendenze
python -m venv .venv
source .venv/bin/activate # su Windows: .venv\Scripts\activate
pip install -U langchain langchain-community langchain-ollama \
langchain-chroma pypdf chromadb tiktokenPasso 3 — Indicizzare i PDF
Crea una cartella docs/ e copia dentro tutti i PDF da interrogare. Quindi salva il file ingest.py:
from pathlib import Path
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_ollama import OllamaEmbeddings
from langchain_chroma import Chroma
DOCS_DIR = Path("docs")
DB_DIR = "chroma_db"
all_docs = []
for pdf in DOCS_DIR.glob("**/*.pdf"):
print(f"Carico: {pdf.name}")
all_docs.extend(PyPDFLoader(str(pdf)).load())
splitter = RecursiveCharacterTextSplitter(
chunk_size=900,
chunk_overlap=180,
separators=["\n\n", "\n", ". ", " "]
)
chunks = splitter.split_documents(all_docs)
print(f"Chunk totali: {len(chunks)}")
embeddings = OllamaEmbeddings(model="nomic-embed-text")
Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory=DB_DIR
)
print("Indice salvato in", DB_DIR)
Lancialo: python ingest.py. Il primo passaggio è il più lento perché Ollama deve caricare il modello embedding e processare ogni chunk. Indicativamente: 100 pagine di PDF ~ 1 minuto su una CPU recente.
Passo 4 — Lo script di domanda e risposta
Salva ask.py:
import sys
from langchain_ollama import OllamaEmbeddings, ChatOllama
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
DB_DIR = "chroma_db"
LLM = "llama3.1:8b" # o llama3.2:3b se hai poca RAM
TOP_K = 5
embeddings = OllamaEmbeddings(model="nomic-embed-text")
vectordb = Chroma(persist_directory=DB_DIR, embedding_function=embeddings)
llm = ChatOllama(model=LLM, temperature=0.1)
prompt = ChatPromptTemplate.from_messages([
("system",
"Sei un assistente che risponde SOLO usando il CONTESTO fornito. "
"Se la risposta non è nel contesto, rispondi 'Non lo trovo nei documenti'. "
"Cita sempre la fonte (nome file e numero di pagina) tra parentesi quadre."),
("human", "CONTESTO:\n{context}\n\nDOMANDA: {question}")
])
def format_ctx(docs):
return "\n\n".join(
f"[{d.metadata.get('source','?')} p.{d.metadata.get('page','?')}]\n{d.page_content}"
for d in docs
)
q = " ".join(sys.argv[1:]) or input("Domanda: ")
docs = vectordb.similarity_search(q, k=TOP_K)
chain = prompt | llm
resp = chain.invoke({"context": format_ctx(docs), "question": q})
print("\nRisposta:\n", resp.content)
print("\nFonti usate:")
for d in docs:
print(" -", d.metadata.get('source'), "p.", d.metadata.get('page'))
Uso:
python ask.py "Cosa dice il documento sui requisiti minimi di backup?"Risposta tipica: 2-4 frasi con citazioni in formato [manuale.pdf p.27], seguite dall'elenco delle fonti.
Prompt aggiuntivi da provare
1. "Riassumi in cinque punti le novità introdotte rispetto alla versione precedente, citando le pagine."
2. "Esiste una clausola di rescissione automatica? Riporta il testo esatto e la posizione."
3. "Genera una tabella confrontativa fra le opzioni A, B, C menzionate nel documento."
4. "Quali sono le sanzioni previste in caso di mancato adempimento dell'articolo 12?"
Tuning: chunk size, top-k, modelli
- chunk_size: 800-1200 caratteri funziona per documentazione tecnica. Per testi legali densi salire a 1500-2000; per FAQ scendere a 500.
- chunk_overlap: 15-20% del chunk_size. Troppa sovrapposizione gonfia l'indice senza migliorare la qualità.
- TOP_K: con 5 risultati copri quasi sempre il contesto necessario. Con domande complesse, salire a 8 o 10.
- Modello generativo:
llama3.1:8bbilancia bene qualità e velocità. Per risposte più sofisticate in italiano, provaqwen3.6:7b; per il massimo della qualità (lento),llama3.3:70b-q4richiede 48 GB di VRAM o uso CPU pesante. - Embedding:
nomic-embed-textva bene; per qualità superiore in italiano, valutaremxbai-embed-large.
Errori comuni e soluzioni
- "connection refused on 11434": Ollama non è in esecuzione. Su Windows/macOS rilancia l'app; su Linux
systemctl --user start ollamaoollama serve. - Risposte vuote o "Non lo trovo": il retrieval non trova chunk pertinenti. Cause: documento scannerizzato senza OCR (PyPDFLoader non legge le immagini), chunk troppo piccoli, modello embedding sbagliato per la lingua. Soluzioni: pre-processare i PDF con
ocrmypdf, alzare chunk_size, cambiare embedding. - OutOfMemory su CPU: usa
llama3.2:3bal posto di 8b, o aggiungi swap. - Risposte allucinate fuori dal contesto: il modello "sa" cose e le aggiunge. Stringere il prompt di sistema ("rispondi ESCLUSIVAMENTE dal contesto, anche se conosci la risposta") e abbassare
temperaturea 0. - Indice non ritrova nuovi PDF: dopo aver aggiunto file, rilancia
ingest.py. Per evitare di reindicizzare tutto, usaChroma.add_documents()incrementale e una funzione di hash sui file.
Varianti avanzate
- Web UI con Streamlit: avvolgere lo script in un'interfaccia chat con
streamlit run app.py. Cento righe di codice. - Multi-query retrieval: chiedere al LLM di riformulare la domanda in tre varianti e fare retrieval su ciascuna. Pacchetto
langchain.retrievers.MultiQueryRetriever. - Re-ranker: dopo il retrieval, riordinare i chunk con un cross-encoder (
BAAI/bge-reranker-v2-m3) per migliorare la precisione. - Hybrid search: combinare ricerca semantica (Chroma) e BM25 lessicale per non perdere nomi propri, codici alfanumerici e termini rari.
- Citazioni esatte: estrarre la frase precisa dal chunk usando l'highlight di Chroma o un secondo passaggio di estrazione.
Quando non usarlo
Il RAG locale ha senso con qualche centinaio o migliaio di PDF; oltre, conviene un vector DB dedicato (Qdrant, Weaviate, pgvector) e un orchestratore (LangGraph, LlamaIndex). Per usi consumer occasionali, una sola domanda a settimana, conviene caricare direttamente i PDF su ChatGPT, Claude Projects, NotebookLM. Per dati sanitari italiani sotto GDPR/Garante, il RAG locale resta la scelta più sicura.
Come proseguire
Tre risorse: la documentazione ufficiale RAG di LangChain, il manuale di Ollama e i template di Hugging Face Spaces per esempi di interfacce. Il prossimo passo naturale è trasformare lo script in un'app desktop (Tauri/Electron) o in un servizio REST con FastAPI, da deployare su un mini PC in ufficio.




