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), PyMuPDF alias fitz (veloce e affidabile), docling o unstructured per PDF complessi o scansionati con OCR. Consiglio: PyMuPDF per la maggior parte dei casi.
  • Embedding: in locale sentence-transformers con intfloat/multilingual-e5-large o BAAI/bge-m3 (ottimi per l'italiano), oppure nomic-embed-text via Ollama - gratis e privati. Su cloud, text-embedding-3-small di 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; FAISS e' piu' veloce ma in memoria; Qdrant, LanceDB o pgvector per 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 cloud gpt-5.5 o gpt-5-mini di OpenAI, claude-sonnet-4.6 o claude-opus-4.7 di Anthropic - piu' capaci, a consumo.
  • Framework: si puo' usare LangChain, LlamaIndex o Haystack, 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 openai

Poi installa Ollama dal sito ufficiale e scarica un modello:

ollama pull llama3.3
Scaffali di documenti e archivio, indicizzazione di PDF in un database vettoriale per un sistema RAG
Il RAG indicizza i tuoi PDF in un database vettoriale e recupera solo i pezzi rilevanti per ogni domanda.

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 pezzi

Passo 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.content

Tre 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 docling o 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 chiama pymupdf ma il modulo e' fitz - reinstalla con pip 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 lancia ollama serve) e controlla ollama 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 con encode([...], batch_size=64).
  • out of memory con 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.