Hai una cartella piena di PDF - manuali, contratti, paper, verbali - e vorresti poterle fare domande in linguaggio naturale ottenendo risposte con le fonti, senza affidare quei documenti a un servizio esterno. La tecnica giusta si chiama RAG (Retrieval-Augmented Generation) e in questa guida la costruiamo da zero in Python, in modo che giri 100% in locale e gratis con Ollama, oppure con le API di OpenAI se vuoi la massima qualita'.
A chi serve e cosa otterrai
E' una guida per sviluppatori e knowledge worker tecnici: chi sa muoversi con Python e il terminale e vuole un assistente sui propri documenti con il controllo su dove finiscono i dati e su quanto si spende. Alla fine avrai uno script che indicizza i tuoi PDF e risponde citando file e pagina. Prerequisiti reali: Python 3.10+, circa 10 GB liberi su disco, 8-16 GB di RAM (di piu' aiuta per i modelli locali piu' grandi), conoscenza base di Python; opzionale una GPU; se scegli l'opzione cloud, un account e una chiave API di OpenAI (o Anthropic).
Come funziona un RAG, in concreto
Mettere un intero PDF dentro il prompt di un modello e' costoso, sbatte contro i limiti di contesto e spesso peggiora le risposte (il modello "si perde nel mezzo"). Il RAG fa diversamente: spezza i documenti in pezzi (chunk), calcola per ciascuno un embedding (un vettore di numeri che ne rappresenta il significato) e li salva in un database vettoriale. Quando arriva una domanda, si calcola l'embedding della domanda, si recuperano i chunk piu' simili e si passano al modello come contesto, chiedendogli di rispondere solo sulla base di quelli. Risultato: meno allucinazioni, risposte verificabili, costi sotto controllo.
Quali strumenti e modelli usare
- Estrazione testo dai PDF:
pypdf(semplice),pdfplumber(buono con le tabelle),PyMuPDFaliasfitz(veloce e affidabile),doclingounstructuredper PDF complessi o scansionati con OCR. Consiglio: PyMuPDF per la maggior parte dei casi. - Embedding: in locale
sentence-transformersconintfloat/multilingual-e5-largeoBAAI/bge-m3(ottimi per l'italiano), oppurenomic-embed-textvia Ollama - gratis e privati. Su cloud,text-embedding-3-smalldi OpenAI (circa 0,02 dollari per milione di token), Voyage AI o Cohere. - Database vettoriale:
Chroma(semplice, locale, salva su disco) e' la scelta consigliata per iniziare;FAISSe' piu' veloce ma in memoria;Qdrant,LanceDBopgvectorper andare in produzione. - Modello per la risposta: in locale via Ollama (
llama3.3,qwen3,gemma3,mistral-small) - gratis e offline, ma serve RAM/VRAM; su cloudgpt-5.5ogpt-5-minidi OpenAI,claude-sonnet-4.6oclaude-opus-4.7di Anthropic - piu' capaci, a consumo. - Framework: si puo' usare
LangChain,LlamaIndexoHaystack, ma qui lo facciamo "a mano": e' poco codice e si capisce cosa succede.
Prima scelta consigliata: PyMuPDF + sentence-transformers (multilingual-e5-large) + Chroma + Ollama (llama3.3) per restare gratis e locale; basta sostituire l'ultimo pezzo con l'API di OpenAI se vuoi la qualita' massima nelle risposte.
Passo 1: preparare l'ambiente
python -m venv .venv
source .venv/bin/activate # su Windows: .venv\Scripts\activate
pip install pymupdf chromadb sentence-transformers ollama
# opzionale, se userai l'opzione cloud:
pip install openaiPoi installa Ollama dal sito ufficiale e scarica un modello:
ollama pull llama3.3
Passo 2: estrarre il testo e spezzarlo in chunk
import fitz # PyMuPDF
from pathlib import Path
def carica_pdf(cartella='documenti'):
docs = []
for pdf in Path(cartella).glob('*.pdf'):
with fitz.open(pdf) as f:
for n, pagina in enumerate(f):
testo = pagina.get_text().strip()
if testo:
docs.append({'fonte': pdf.name, 'pagina': n + 1, 'testo': testo})
return docs
def spezza(testo, dim=900, overlap=150):
parole = testo.split()
pezzi, i = [], 0
while i < len(parole):
pezzi.append(' '.join(parole[i:i + dim]))
i += dim - overlap
return pezziPasso 3: calcolare gli embedding e indicizzare in Chroma
import chromadb
from sentence_transformers import SentenceTransformer
modello_emb = SentenceTransformer('intfloat/multilingual-e5-large')
client = chromadb.PersistentClient(path='./indice')
collezione = client.get_or_create_collection('pdf')
idx = 0
for d in carica_pdf():
for pezzo in spezza(d['testo']):
emb = modello_emb.encode('passage: ' + pezzo).tolist()
collezione.add(
ids=[f'c{idx}'],
embeddings=[emb],
documents=[pezzo],
metadatas=[{'fonte': d['fonte'], 'pagina': d['pagina']}],
)
idx += 1
print(f'Indicizzati {idx} chunk')(Il prefisso passage: e' richiesto dai modelli della famiglia E5; con altri modelli, come bge-m3, si puo' omettere.)
Passo 4: interrogare i documenti
import ollama
def chiedi(domanda, k=5):
q_emb = modello_emb.encode('query: ' + domanda).tolist()
res = collezione.query(query_embeddings=[q_emb], n_results=k)
contesto = ''
for testo, meta in zip(res['documents'][0], res['metadatas'][0]):
contesto += f"[{meta['fonte']} p.{meta['pagina']}]\n{testo}\n\n"
prompt = f"""Rispondi alla domanda usando SOLO il contesto qui sotto. Cita le fonti tra parentesi quadre nella forma [file p.N]. Se la risposta non e' nel contesto, dillo chiaramente.
CONTESTO:
{contesto}
DOMANDA: {domanda}"""
r = ollama.chat(model='llama3.3', messages=[{'role': 'user', 'content': prompt}])
return r['message']['content']
print(chiedi('Quali sono le condizioni di recesso descritte nei contratti?'))Per usare invece l'API di OpenAI, sostituisci le ultime due righe della funzione con:
from openai import OpenAI
client_ai = OpenAI() # legge la variabile d'ambiente OPENAI_API_KEY
r = client_ai.chat.completions.create(
model='gpt-5.5',
messages=[{'role': 'user', 'content': prompt}],
)
return r.choices[0].message.contentTre domande da provare (e cosa aspettarti)
"Riassumi in cinque punti l'argomento principale del documento X, citando le pagine."
"Quali scadenze o date limite sono indicate? Elencale con la fonte."
"C'e' una clausola che parla di responsabilita' in caso di ritardo? Riportala testualmente."
Risultato atteso: risposte di poche righe, ciascuna con riferimenti del tipo [contratto-2026.pdf p.4]; quando l'informazione non c'e', il modello dovrebbe dire che non e' presente, invece di inventarsela.
Varianti e casi avanzati
- Re-ranking: dopo il recupero, riordina i chunk con un cross-encoder (
BAAI/bge-reranker-v2-m3) e tieni solo i migliori: risposte piu' precise. - Ricerca ibrida: combina la ricerca vettoriale con BM25 (parole chiave) - utile per sigle, codici e nomi propri che gli embedding da soli colgono male.
- Interfaccia: avvolgi il tutto in Streamlit o Gradio per avere una chat con i link alle fonti cliccabili.
- PDF scansionati: passa prima da
doclingo da Tesseract OCR per estrarre il testo dalle immagini. - Aggiornamento incrementale: salva un hash di ogni file e re-indicizza solo i documenti nuovi o modificati.
- Chunking migliore: spezza per titoli e sezioni invece che per numero fisso di parole; le risposte ne guadagnano.
Errori comuni e soluzioni
ModuleNotFoundError: No module named 'fitz': il pacchetto si chiamapymupdfma il modulo e'fitz- reinstalla conpip install pymupdf.- Risposte vaghe o continui "non lo so": aumenta
k, riduci la dimensione dei chunk, e verifica che il PDF non sia fatto solo di immagini (in quel caso serve l'OCR). - Ollama:
connection refused: assicurati che l'app sia avviata (o lanciaollama serve) e controllaollama list. - Calcolo degli embedding lentissimo: usa un modello piu' piccolo (
intfloat/multilingual-e5-small), la GPU (SentenceTransformer(..., device='cuda')) oppure passa i testi in batch conencode([...], batch_size=64). out of memorycon llama3.3: scegli un modello piu' piccolo (llama3.2,qwen3:4b) o una versione piu' quantizzata.- Allucinazioni nonostante il contesto: rafforza il prompt ("solo dal contesto"), abbassa la temperatura del modello e mostra sempre le fonti all'utente.
Quando non usare il RAG
Se i documenti sono pochi e corti e ci stanno comodamente nel contesto di un modello da 200K-1M token, passarli direttamente e' piu' semplice e spesso piu' accurato. Se ti serve solo cercare testo esatto, basta una full-text search. E se i dati cambiano di continuo e devono essere sempre aggiornatissimi, e' meglio interrogare direttamente l'API o il database di origine, non un indice statico.
Come proseguire
Quando il prototipo funziona, i passi successivi sono: spostare l'indice su Qdrant o pgvector per la produzione, aggiungere autenticazione, misurare la qualita' con un set di domande e risposte attese, e infine integrare il sistema in un'app. La documentazione utile e' quella di Chroma, sentence-transformers, Ollama e la guida agli embedding di OpenAI.




