Caricare i propri PDF in ChatGPT funziona finche' i documenti sono pochi e brevi. Quando il corpus cresce - cento manuali tecnici, anni di delibere, l'intero archivio normativo di un'azienda - il limite si vede: l'LLM dimentica meta' del materiale, le risposte diventano vaghe, e il costo dei token esplode. La soluzione si chiama RAG (Retrieval-Augmented Generation): indici i documenti in un database vettoriale e usi un LLM per rispondere SOLO sui passaggi pertinenti. In questa guida costruiamo un RAG completo, in locale, gratis e privato, usando Ollama, LangChain e ChromaDB. Niente API a pagamento, niente dati che escono dal tuo Mac o PC.
A chi serve questo tutorial e cosa otterrai alla fine
Questa guida e' per: avvocati, ricercatori e professionisti con archivi PDF; aziende che non possono mandare dati sensibili in cloud (sanita', PA, finanza); sviluppatori che vogliono capire il pattern RAG end-to-end prima di portarlo in produzione.
Alla fine avrai uno script Python che indicizza una cartella di PDF, espone una chat che cita la pagina e il documento esatti, gira tutto in locale con Llama 3.3 o DeepSeek R1 e ChromaDB su disco. Tempo necessario: circa 90 minuti.
Prerequisiti reali (hardware, software, conti)
- Sistema operativo: macOS 14+, Windows 11 con WSL2, o Ubuntu 22.04+.
- RAM: minimo 16 GB. Con 32 GB potrai usare modelli da 8B/13B confortevolmente.
- GPU: opzionale ma utile. Una NVIDIA con 8 GB VRAM (RTX 3060, 4060) dimezza i tempi. Senza GPU funziona tutto comunque, su CPU.
- Spazio disco: 20-30 GB liberi (modelli + database vettoriale).
- Python: 3.10 o superiore.
- Ollama: scaricalo da ollama.com/download e verifica con
ollama --versionin terminale.
Nessun conto cloud richiesto. Nessuna chiave API.
Quale LLM e quale embedding scegliere
Per un RAG locale ti servono due modelli distinti: un LLM che genera la risposta finale e un embedding model che trasforma testo in vettori. Ecco la scelta consigliata per maggio 2026:
- LLM: Llama 3.3 8B (general purpose, italiano molto buono) o DeepSeek R1 distill 8B (reasoning forte su domande complesse). Entrambi pesano 4-5 GB nel formato Q4_K_M e girano comodamente con 16 GB di RAM.
- Embedding: nomic-embed-text v1.5 (768 dimensioni, multilingue, ottimo bilanciamento qualita'/velocita') oppure mxbai-embed-large se vuoi qualita' top in inglese.
Per il database vettoriale useremo ChromaDB: e' un database open-source, persistente su disco, con API Python pulita. Le alternative serie sono Qdrant (piu' veloce, ma piu' pesante da configurare) e LanceDB (eccellente con dataset enormi). Per un archivio sotto i 50.000 chunk, Chroma e' la scelta migliore.
Passo 1: installare i modelli con Ollama
Avvia Ollama (su macOS basta aprire l'app, su Linux ollama serve) e in un altro terminale scarica i due modelli:
ollama pull llama3.3:8b
ollama pull nomic-embed-text
Verifica che funzionino con una query veloce:
ollama run llama3.3:8b "Riassumi in due frasi: chi era Leonardo da Vinci?"
Lascia Ollama in esecuzione: il nostro script Python si connettera' alla sua API REST locale (porta 11434).
Passo 2: ambiente Python e dipendenze
Crea una cartella di progetto e un ambiente virtuale:
mkdir rag-pdf-locale && cd rag-pdf-locale
python3 -m venv .venv
source .venv/bin/activate # su Windows: .venv\Scripts\activate
pip install --upgrade pip
pip install langchain langchain-community langchain-ollama chromadb pypdf sentence-transformers tiktoken
Crea una cartella ./documenti e copiaci dentro tutti i PDF che vuoi indicizzare. Per iniziare, due o tre file vanno bene.
Passo 3: indicizzazione (script ingest.py)
Crea il file ingest.py:
from pathlib import Path
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_ollama import OllamaEmbeddings
from langchain_chroma import Chroma
DOCS_DIR = Path("./documenti")
CHROMA_DIR = "./chroma_db"
def load_pdfs():
docs = []
for pdf in DOCS_DIR.glob("*.pdf"):
loader = PyPDFLoader(str(pdf))
loaded = loader.load()
for d in loaded:
d.metadata["source_file"] = pdf.name
docs.extend(loaded)
print(f" + {pdf.name}: {len(loaded)} pagine")
return docs
def main():
print("Carico PDF...")
docs = load_pdfs()
print(f"Totale pagine: {len(docs)}")
print("Spezzo in chunk...")
splitter = RecursiveCharacterTextSplitter(
chunk_size=900,
chunk_overlap=120,
separators=["\n\n", "\n", ". ", " ", ""]
)
chunks = splitter.split_documents(docs)
print(f"Totale chunk: {len(chunks)}")
print("Calcolo embedding e salvo su Chroma...")
embed = OllamaEmbeddings(model="nomic-embed-text")
Chroma.from_documents(chunks, embed, persist_directory=CHROMA_DIR)
print("Indice salvato in", CHROMA_DIR)
if __name__ == "__main__":
main()
Esegui con python ingest.py. Per ogni 100 pagine, il tempo varia da 30 secondi (con GPU) a 3-4 minuti (CPU). Il database viene scritto in ./chroma_db, e' persistente: la prossima volta non dovrai re-indicizzare.
Passo 4: chat con citazioni (script chat.py)
Crea chat.py:
from langchain_ollama import OllamaEmbeddings, ChatOllama
from langchain_chroma import Chroma
from langchain.prompts import ChatPromptTemplate
from langchain.schema import StrOutputParser
from langchain.schema.runnable import RunnablePassthrough
CHROMA_DIR = "./chroma_db"
PROMPT = ChatPromptTemplate.from_template("""
Sei un assistente che risponde SOLO sulla base del contesto fornito.
Se la risposta non e' presente nel contesto, dillo chiaramente.
Cita SEMPRE il documento e la pagina con il formato (file, p. N).
CONTESTO:
{context}
DOMANDA: {question}
RISPOSTA:
""")
def format_docs(docs):
out = []
for d in docs:
src = d.metadata.get("source_file", "ignoto")
page = d.metadata.get("page", "?")
out.append(f"[{src}, p. {page+1 if isinstance(page,int) else page}]\n{d.page_content}")
return "\n\n".join(out)
def main():
embed = OllamaEmbeddings(model="nomic-embed-text")
db = Chroma(persist_directory=CHROMA_DIR, embedding_function=embed)
retriever = db.as_retriever(search_kwargs={"k": 5})
llm = ChatOllama(model="llama3.3:8b", temperature=0.1)
chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| PROMPT
| llm
| StrOutputParser()
)
print("RAG locale pronto. Scrivi 'esci' per uscire.\n")
while True:
q = input("Domanda: ").strip()
if q.lower() in ("esci","exit","quit"): break
if not q: continue
print("\n", chain.invoke(q), "\n")
if __name__ == "__main__":
main()
Avvialo con python chat.py. La prima domanda sara' un po' lenta (Ollama carica il modello in RAM), le successive risponderanno in pochi secondi.
Esempio di output atteso
Domanda: "Quale e' la procedura prevista per il rinnovo del contratto?"
Il rinnovo del contratto si effettua entro 60 giorni dalla scadenza, comunicandolo via PEC come previsto all'articolo 12 comma 3 (manuale_clienti_2025.pdf, p. 14). In caso di mancata comunicazione il contratto si intende risolto automaticamente. (manuale_clienti_2025.pdf, p. 14)
Le citazioni precise sono la differenza fra un giocattolo e uno strumento di lavoro.
Varianti e configurazioni avanzate
- Domande complesse, reasoning forte: sostituisci
llama3.3:8bcondeepseek-r1:8b. Risposte piu' ragionate, latenza maggiore (anche 30 secondi a domanda su CPU). - Documenti scansionati: PyPDFLoader non gestisce PDF immagine. Usa
UnstructuredPDFLoaderconstrategy="ocr_only"o, meglio, fai prima un passaggio con Tesseract. - Multilingua serio: cambia embedding in
BAAI/bge-m3(via sentence-transformers). - Reranking: aggiungi un cross-encoder
bge-reranker-v2per riordinare i top-k. Migliora drasticamente la precisione.
Errori comuni e soluzioni
- "connection refused localhost:11434" → Ollama non sta girando. Apri l'app o lancia
ollama serve. - "context length exceeded" → troppi documenti nei top-k. Riduci
ka 3 o usa un text splitter piu' aggressivo (chunk_size 700). - Risposte vaghe / fuori contesto → chunk troppo piccoli o embedding sbagliato. Aumenta
chunk_sizea 1200 e provabge-m3. - Out of memory durante l'indicizzazione → processa i PDF in batch da 50, salva ogni volta con
Chroma.add_documents.
Alternative e quando NON usare il RAG locale
Se l'archivio supera i 500.000 chunk, considera Qdrant o pgvector su Postgres. Se la latenza sotto al secondo e' un requisito, valuta soluzioni gestite come Azure AI Search o Vertex AI Vector Search. Se serve la massima qualita' sulle risposte e non hai vincoli di privacy, il pattern resta lo stesso ma sostituisci ChatOllama con ChatAnthropic (Claude Sonnet 4.6) o ChatOpenAI (GPT-5.5).
Da qui puoi proseguire con: 1) un'interfaccia web via Streamlit (30 righe di codice), 2) multi-turno con memoria (LangChain ConversationBufferMemory), 3) agenti che oltre a leggere PDF cercano nel web o eseguono SQL. Le basi che hai costruito qui reggono tutto: una pipeline di retrieval pulita e un LLM che non puo' allucinare al di fuori del contesto. Il resto e' polish.




