Hai centinaia di PDF - manuali, contratti, dispense, normative - e vorresti "parlarci": fare domande in linguaggio naturale e ottenere risposte basate solo su quei documenti, con le citazioni giuste. La tecnica che lo rende possibile si chiama RAG (Retrieval-Augmented Generation): invece di sperare che il modello "sappia" la risposta, gli si forniscono i pezzi di testo pertinenti recuperati dai tuoi file. In questa guida costruiamo un RAG funzionante in Python, scegliendo per ogni pezzo lo strumento giusto e spiegando il perche'.

A chi serve e cosa otterrai

Questa guida e' pensata per chi sa muovere i primi passi con Python (sa installare pacchetti e lanciare uno script) e vuole un sistema che giri sul proprio computer, senza per forza mandare i documenti a un servizio esterno. Al termine avrai uno script che: legge una cartella di PDF, li spezza in frammenti, li trasforma in vettori numerici (embedding), li salva in un database vettoriale locale e risponde alle tue domande citando i passaggi usati. Prerequisiti reali: Python 3.10 o superiore, circa 8-16 GB di RAM, sistema Windows, macOS o Linux. La GPU aiuta ma non e' obbligatoria.

Quali strumenti usare (e quale consiglio come prima scelta)

Le scelte chiave sono tre: come generare gli embedding, dove salvarli e quale LLM usa per rispondere.

  • Embedding: sentence-transformers con un modello come all-MiniLM-L6-v2 (leggero, gira su CPU) oppure nomic-embed-text via Ollama. PRO: gratuiti e locali. CONTRO: qualita' inferiore agli embedding a pagamento di OpenAI o Google su testi molto tecnici.
  • Database vettoriale: Chroma, prima scelta consigliata perche' si installa con un pip, salva su disco e non richiede server. Alternative: FAISS (velocissimo ma piu' grezzo) o Qdrant (ottimo per la produzione).
  • LLM che risponde: Ollama con un modello locale (Llama, Qwen, o un modello "Air" leggero) se vuoi tutto offline e gratis; in alternativa un'API (GPT-5, Claude, Gemini, DeepSeek) se vuoi la massima qualita' e non ti pesa mandare il contesto in cloud.

Prima scelta per iniziare: sentence-transformers + Chroma + Ollama. E' tutto gratuito, locale e sufficiente per imparare; passerai alle API solo se la qualita' delle risposte non basta.

Un RAG recupera dai tuoi documenti solo i frammenti pertinenti e li passa al modello. Foto: Pexels.

Passo 1 - Installa l'ambiente

Crea una cartella di progetto, un ambiente virtuale e installa i pacchetti:

python -m venv .venv
# Windows:  .venv\Scripts\activate
# macOS/Linux:  source .venv/bin/activate

pip install chromadb sentence-transformers pypdf ollama

Se userai Ollama come LLM, installalo dal sito ufficiale e scarica un modello, per esempio:

ollama pull llama3.1:8b

Passo 2 - Estrai e spezza il testo

Il "chunking" e' decisivo: frammenti troppo grandi confondono il modello, troppo piccoli perdono il contesto. Un buon punto di partenza sono blocchi di circa 800-1000 caratteri con una sovrapposizione (overlap) di 150, per non tagliare le frasi a meta'. Metti i tuoi PDF in una cartella documenti/ e usa questo codice:

from pathlib import Path
from pypdf import PdfReader

def estrai_chunk(cartella, dim=900, overlap=150):
    chunks = []
    for pdf in Path(cartella).glob("*.pdf"):
        reader = PdfReader(str(pdf))
        testo = "\n".join((p.extract_text() or "") for p in reader.pages)
        i = 0
        while i < len(testo):
            pezzo = testo[i:i+dim]
            if pezzo.strip():
                chunks.append({"testo": pezzo, "fonte": pdf.name})
            i += dim - overlap
    return chunks

chunks = estrai_chunk("documenti")
print(f"Estratti {len(chunks)} frammenti")

Passo 3 - Genera gli embedding e salvali in Chroma

Ora trasformiamo ogni frammento in un vettore e lo salviamo in un database persistente su disco:

import chromadb
from sentence_transformers import SentenceTransformer

modello = SentenceTransformer("all-MiniLM-L6-v2")
client = chromadb.PersistentClient(path="./vectordb")
coll = client.get_or_create_collection("pdf")

testi = [c["testo"] for c in chunks]
vettori = modello.encode(testi, show_progress_bar=True).tolist()

coll.add(
    ids=[f"id-{i}" for i in range(len(chunks))],
    documents=testi,
    embeddings=vettori,
    metadatas=[{"fonte": c["fonte"]} for c in chunks],
)
print("Indicizzazione completata")

Questo passaggio va eseguito una sola volta (o quando aggiungi documenti): il database resta salvato nella cartella vectordb.

Passo 4 - Interroga: retrieval + risposta del modello

Adesso il cuore del RAG: data una domanda, recuperiamo i frammenti piu' simili e li passiamo all'LLM con un prompt che gli impone di rispondere solo in base al contesto.

import ollama

def chiedi(domanda, k=4):
    q = modello.encode([domanda]).tolist()
    res = coll.query(query_embeddings=q, n_results=k)
    contesto = "\n\n".join(res["documents"][0])
    fonti = {m["fonte"] for m in res["metadatas"][0]}

    prompt = f"""Rispondi alla domanda usando SOLO il contesto qui sotto.
Se la risposta non e' nel contesto, scrivi: "Non e' nei documenti".

CONTESTO:
{contesto}

DOMANDA: {domanda}"""

    out = ollama.chat(model="llama3.1:8b",
                      messages=[{"role": "user", "content": prompt}])
    return out["message"]["content"], fonti

risposta, fonti = chiedi("Qual e' la durata del periodo di recesso?")
print(risposta)
print("Fonti:", ", ".join(fonti))

Risultato atteso: una risposta sintetica tratta dai tuoi PDF, seguita dall'elenco dei file da cui proviene. Se l'informazione non c'e', il modello dichiara di non averla trovata invece di inventarla - esattamente cio' che vogliamo.

Prompt utili da copiare

"Rispondi in italiano, in massimo 5 frasi, citando tra parentesi il nome del file da cui proviene ogni affermazione."
"Elenca in forma di tabella tutte le scadenze e gli importi presenti nel contesto. Se un dato manca, scrivi 'non indicato'."

Errori comuni e come risolverli

  • "Non e' nei documenti" anche quando l'informazione c'e': i chunk sono troppo piccoli o pochi. Aumenta k (per esempio a 6-8) o la dimensione dei frammenti.
  • Risposte generiche o inventate: il modello sta ignorando il contesto. Rafforza il prompt ("usa SOLO il contesto") e abbassa la temperatura, oppure passa a un modello piu' capace via API.
  • PDF scansionati che restituiscono testo vuoto: sono immagini, non testo. Serve un passaggio di OCR (per esempio con strumenti come Tesseract o un servizio di OCR) prima del chunking.
  • Risposte lente: un modello da 8B su CPU e' lento. Usa una GPU, scegli un modello piu' piccolo o sposta la generazione su un'API.

Varianti e come proseguire

Una volta che la base funziona, i miglioramenti piu' efficaci sono due. Il primo e' il reranking: dopo aver recuperato 20 frammenti, un modello "cross-encoder" li riordina per pertinenza e ne tieni solo i migliori 4, alzando la precisione. Il secondo e' la ricerca ibrida, che combina la similarita' vettoriale con la ricerca per parole chiave (BM25), utile quando contano termini esatti come codici e numeri d'articolo. Se preferisci non scrivere tutta l'impalcatura a mano, framework come LangChain e LlamaIndex offrono gli stessi mattoni gia' pronti. E quando vorrai portare il sistema in produzione - piu' utenti, piu' documenti - valuta un database vettoriale come Qdrant e un LLM via API per garantire qualita' e stabilita'. Ma il principio resta quello che hai appena costruito: recuperare i pezzi giusti e lasciare che il modello ragioni solo su quelli.