Immagina di poter fare domande in linguaggio naturale ai tuoi documenti — manuali, contratti, appunti, paper — e ricevere risposte precise, con tanto di riferimento al punto esatto in cui l'informazione si trova. E' esattamente cio' che permette il RAG (Retrieval-Augmented Generation), la tecnica che combina un motore di ricerca semantico con un modello linguistico. In questa guida costruiremo, passo dopo passo, un sistema RAG in Python che risponde a domande sui tuoi PDF, funzionante anche completamente in locale e gratis.

A chi serve e cosa otterrai

Questa guida e' pensata per chi ha gia' un minimo di dimestichezza con Python (sai installare pacchetti ed eseguire uno script) e vuole andare oltre il semplice "incollare un PDF in ChatGPT". 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 rilevanti. Nessun dato lascera' il tuo computer, se scegli la via locale.

Prerequisiti reali: un computer con Windows, macOS o Linux; Python 3.10 o superiore; circa 8 GB di RAM (16 consigliati se usi un modello locale); una connessione a internet solo per il primo download dei modelli. Per la variante locale useremo Ollama; per la variante cloud basta una chiave API di OpenAI.

Costruiremo un sistema RAG in Python che risponde a domande sui tuoi PDF citando i passaggi rilevanti.

Quali strumenti useremo e perche'

Le scelte tecnologiche contano. Ecco cosa impiegheremo e le alternative:

  • pypdf per estrarre il testo dai PDF. Semplice e affidabile per documenti testuali; per PDF scansionati (immagini) servirebbe invece un OCR come Tesseract.
  • ChromaDB come database vettoriale. E' leggero, gira in locale senza server e si installa con un comando. Alternative: FAISS (piu' veloce ma piu' spartano), o soluzioni gestite come Pinecone e Qdrant per grandi volumi.
  • Ollama con il modello nomic-embed-text per gli embedding e un modello come llama3.1 o qwen2.5 per generare le risposte. Vantaggio: gratis, privato, offline. Svantaggio: qualita' e velocita' dipendono dal tuo hardware.
  • In alternativa, le API di OpenAI (text-embedding-3-small per gli embedding e gpt-4o-mini per le risposte): qualita' elevata e nessun requisito hardware, ma i dati transitano sul cloud e c'e' un costo per token, per quanto ridotto.

La nostra prima scelta per imparare e' la via locale con Ollama: azzera i costi, protegge i dati riservati ed e' perfetta per capire ogni pezzo del meccanismo. Passeremo a OpenAI solo se serve piu' qualita' su documenti complessi.

Passo 1 — Preparare l'ambiente

Crea una cartella per il progetto e un ambiente virtuale, poi installa le dipendenze:

mkdir rag-pdf && cd rag-pdf
python -m venv venv
# su macOS/Linux:
source venv/bin/activate
# su Windows:
venv\Scripts\activate

pip install pypdf chromadb ollama openai

Per la via locale, installa Ollama dal sito ufficiale ollama.com, poi scarica i due modelli che ci servono:

ollama pull nomic-embed-text
ollama pull llama3.1

Verifica che Ollama sia attivo con ollama list: dovresti vedere entrambi i modelli nell'elenco. Metti infine i tuoi file PDF in una sottocartella chiamata documenti.

Passo 2 — Leggere e spezzare i PDF (chunking)

Un modello non puo' "ingoiare" un intero manuale in una volta: dobbiamo dividere il testo in frammenti (chunk) di dimensione gestibile, con una piccola sovrapposizione per non perdere il filo tra un pezzo e l'altro. Crea un file rag.py:

import os
from pypdf import PdfReader

def carica_pdf(cartella="documenti"):
    testi = []
    for nome in os.listdir(cartella):
        if not nome.lower().endswith(".pdf"):
            continue
        reader = PdfReader(os.path.join(cartella, nome))
        for i, pagina in enumerate(reader.pages):
            testo = pagina.extract_text() or ""
            if testo.strip():
                testi.append({"fonte": f"{nome} - pag.{i+1}", "testo": testo})
    return testi

def spezza(testo, dim=800, overlap=150):
    parti, inizio = [], 0
    while inizio < len(testo):
        fine = inizio + dim
        parti.append(testo[inizio:fine])
        inizio = fine - overlap
    return parti

Qui ogni pagina viene divisa in blocchi da circa 800 caratteri con 150 di sovrapposizione. Sono valori di partenza ragionevoli: frammenti troppo grandi diluiscono la ricerca, troppo piccoli perdono il contesto.

Il chunking spezza i documenti in frammenti gestibili: la dimensione giusta e' un compromesso tra precisione e contesto.

Passo 3 — Creare gli embedding e l'indice

Ora trasformiamo ogni frammento in un vettore numerico e lo salviamo in ChromaDB. Aggiungi a rag.py:

import chromadb
import ollama

client = chromadb.PersistentClient(path="./indice")
collezione = client.get_or_create_collection("pdf")

def embedding(testo):
    r = ollama.embeddings(model="nomic-embed-text", prompt=testo)
    return r["embedding"]

def indicizza():
    documenti = carica_pdf()
    idx = 0
    for doc in documenti:
        for pezzo in spezza(doc["testo"]):
            collezione.add(
                ids=[str(idx)],
                embeddings=[embedding(pezzo)],
                documents=[pezzo],
                metadatas=[{"fonte": doc["fonte"]}],
            )
            idx += 1
    print(f"Indicizzati {idx} frammenti.")

Esegui l'indicizzazione una sola volta (o quando aggiungi documenti). Grazie a PersistentClient, l'indice resta salvato su disco nella cartella indice e non va ricostruito a ogni avvio.

Passo 4 — Recuperare e generare la risposta

Il cuore del RAG: data una domanda, cerchiamo i frammenti piu' simili e li passiamo al modello come contesto, chiedendogli di rispondere solo in base a quelli. Completa lo script:

def chiedi(domanda, k=4):
    q_emb = embedding(domanda)
    res = collezione.query(query_embeddings=[q_emb], n_results=k)
    frammenti = res["documents"][0]
    fonti = [m["fonte"] for m in res["metadatas"][0]]
    contesto = "\n\n".join(frammenti)

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

CONTESTO:
{contesto}

DOMANDA: {domanda}"""

    r = ollama.chat(model="llama3.1",
                    messages=[{"role": "user", "content": prompt}])
    print(r["message"]["content"])
    print("\nFonti:", ", ".join(set(fonti)))

if __name__ == "__main__":
    # indicizza()  # scommenta al primo avvio
    chiedi("Qual e' la procedura descritta nel documento?")

Al primo avvio scommenta indicizza(), lancia python rag.py e attendi la costruzione dell'indice; dalle esecuzioni successive puoi ricommentarla. Il parametro k=4 indica quanti frammenti recuperare: alzalo per domande ampie, abbassalo per risposte piu' mirate.

Passo 5 — Provare con una domanda vera

Supponiamo di aver caricato il manuale di un elettrodomestico. Una richiesta tipo:

"Ogni quanto va pulito il filtro e come si smonta?"

Il sistema recuperera' i frammenti pertinenti dal manuale e il modello formulera' una risposta discorsiva, chiudendo con l'indicazione della fonte (es. "manuale.pdf - pag.12"). Se l'informazione non c'e', dira' esplicitamente che non e' presente nei documenti: e' il comportamento che vogliamo, per evitare che il modello inventi.

Passare a OpenAI per piu' qualita'

Se i tuoi documenti sono complessi e vuoi risposte piu' rifinite, puoi sostituire Ollama con le API di OpenAI. Cambiano solo due funzioni:

from openai import OpenAI
oai = OpenAI(api_key="LA_TUA_CHIAVE")

def embedding(testo):
    r = oai.embeddings.create(model="text-embedding-3-small", input=testo)
    return r.data[0].embedding

# e nella funzione chiedi(), al posto di ollama.chat:
# r = oai.chat.completions.create(model="gpt-4o-mini",
#     messages=[{"role":"user","content":prompt}])
# print(r.choices[0].message.content)

Attenzione a non mescolare gli embedding: se reindicizzi con OpenAI, devi rigenerare tutto l'indice, perche' i vettori dei due modelli non sono confrontabili tra loro.

Errori comuni e come risolverli

  • "Connection refused" da Ollama: il servizio non e' avviato. Apri l'app Ollama o lancia ollama serve in un terminale separato.
  • Il PDF restituisce testo vuoto: probabilmente e' una scansione (immagini). Serve un OCR: converti prima le pagine con Tesseract, oppure usa una libreria come pdfplumber.
  • Risposte generiche o fuori tema: aumenta la sovrapposizione tra i frammenti, riduci la loro dimensione o alza k. Spesso il problema e' nel chunking, non nel modello.
  • Indicizzazione lentissima: gli embedding locali sono pesanti su CPU. Riduci il numero di documenti di prova, oppure passa agli embedding di OpenAI, molto piu' rapidi.

Varianti e come proseguire

Da questa base puoi crescere in molte direzioni: aggiungere un'interfaccia web con Streamlit per interrogare i documenti dal browser; introdurre il re-ranking dei risultati per migliorare la precisione; gestire la cronologia della conversazione per domande di follow-up; o usare framework piu' completi come LangChain e LlamaIndex, che astraggono molti di questi passaggi. Ma aver costruito il meccanismo "a mano", come abbiamo fatto qui, ti da' un vantaggio enorme: quando qualcosa non funziona, sai esattamente dove guardare. Il RAG non e' magia, e' recupero piu' generazione — e ora sai come metterli insieme.