Immagina di poter fare domande in italiano a una cartella piena di PDF — contratti, dispense universitarie, manuali, bilanci — e ricevere risposte precise con la citazione del punto esatto da cui provengono, senza caricare nulla su ChatGPT e senza pagare un abbonamento. Si chiama RAG (Retrieval-Augmented Generation) ed è la stessa tecnica che sta dietro alla maggior parte degli assistenti aziendali basati sui documenti. In questa guida la costruiamo da zero sul tuo computer, gratis e completamente offline: i tuoi file non escono mai dal disco.

A chi serve, cosa otterrai e cosa ti serve prima di iniziare

Questa guida è pensata per chi ha un minimo di dimestichezza con il terminale e vuole un assistente privato sui propri documenti: professionisti che maneggiano contratti riservati, studenti con centinaia di pagine di appunti, ricercatori, sviluppatori. Alla fine avrai uno script Python a cui passi una cartella di PDF e che risponde alle tue domande citando le fonti.

Prerequisiti reali:

  • Sistema operativo: Windows, macOS o Linux.
  • Python 3.10 o superiore installato (verifica con python --version).
  • Hardware: almeno 8 GB di RAM per i modelli piccoli; 16 GB rendono tutto più fluido. Non serve una scheda video dedicata, ma se ce l'hai (NVIDIA con 6+ GB di VRAM) le risposte arriveranno molto più in fretta.
  • Circa 5-6 GB di spazio libero per i modelli.

Quali strumenti usiamo e perché questi

Il RAG ha bisogno di tre mattoni: un motore che esegue i modelli in locale, un modello che trasforma il testo in numeri (embedding) per la ricerca semantica, e un modello linguistico che scrive la risposta. Ecco le scelte e le alternative.

  • Ollama (prima scelta come motore): gratuito e open source, installa e serve i modelli con un solo comando, funziona su tutti i sistemi. In alternativa LM Studio (più grafico) o llama.cpp (più a basso livello). Consiglio Ollama perché espone un'API locale che le librerie Python usano senza configurazioni.
  • nomic-embed-text come modello di embedding: leggero (circa 270 MB), veloce, ottimo rapporto qualità/dimensioni e supporta bene l'italiano. Alternativa: mxbai-embed-large, più preciso ma più pesante.
  • Llama 3.2 (3B) come modello che genera la risposta: gira anche su portatili modesti. Se hai 16 GB di RAM o una GPU, sali a Qwen 2.5 (7B) o Llama 3.1 (8B), che ragionano meglio sui testi complessi.
  • LangChain + Chroma per orchestrare tutto: LangChain collega i pezzi, Chroma è il database vettoriale che conserva gli embedding sul disco. Sono gratuiti e girano in locale. Alternativa: LlamaIndex, equivalente e altrettanto valido.

Costi e limiti: zero. Nessun abbonamento, nessuna chiave API, nessun limite di richieste. Il prezzo lo paghi solo in tempo di calcolo: su un portatile senza GPU una risposta può richiedere da 10 a 40 secondi.

Con Ollama i modelli si scaricano ed eseguono in locale, senza inviare dati a server esterni.

Passo 1: installare Ollama e scaricare i modelli

Su macOS e Windows scarica l'installer dal sito ufficiale ollama.com. Su Linux basta un comando:

curl -fsSL https://ollama.com/install.sh | sh

Verifica che funzioni e poi scarica i due modelli che ci servono (embedding e generazione):

ollama --version
ollama pull nomic-embed-text
ollama pull llama3.2

Il primo pull scarica il modello di embedding, il secondo il modello linguistico. Puoi provare subito che il modello risponda:

ollama run llama3.2 "Rispondi in italiano: cos'è un database vettoriale in una frase?"

Passo 2: preparare l'ambiente Python

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

mkdir rag-pdf && cd rag-pdf
python -m venv .venv
# macOS/Linux:
source .venv/bin/activate
# Windows (PowerShell):
# .venv\Scripts\Activate.ps1

pip install langchain langchain-community langchain-ollama langchain-chroma chromadb pypdf

Crea ora una sottocartella documenti e metti dentro i PDF che vuoi interrogare.

Passo 3: indicizzare i PDF (lo script di ingest)

Questo script legge tutti i PDF nella cartella, li spezza in blocchi (chunk), calcola gli embedding e li salva in Chroma. Va eseguito una sola volta (o quando aggiungi documenti). Salvalo come ingest.py:

import os
from langchain_community.document_loaders import PyPDFDirectoryLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_ollama import OllamaEmbeddings
from langchain_chroma import Chroma

# 1. Carica tutti i PDF dalla cartella "documenti"
loader = PyPDFDirectoryLoader("documenti")
docs = loader.load()
print(f"Pagine caricate: {len(docs)}")

# 2. Spezza il testo in blocchi con un po' di sovrapposizione
splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=150,
)
chunks = splitter.split_documents(docs)
print(f"Blocchi creati: {len(chunks)}")

# 3. Calcola gli embedding con nomic-embed-text e salvali su disco
embeddings = OllamaEmbeddings(model="nomic-embed-text")
Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="chroma_db",
)
print("Indice creato nella cartella chroma_db/")

Esegui:

python ingest.py

Vedrai il numero di pagine e di blocchi. La prima volta impiega qualche secondo per documento; il risultato resta salvato in chroma_db/.

Passo 4: fare domande ai documenti (lo script di query)

Ora il cuore del sistema. Recupera i blocchi più pertinenti alla domanda, li passa al modello con istruzioni precise e stampa la risposta con le fonti. Salvalo come chiedi.py:

import sys
from langchain_ollama import OllamaEmbeddings, ChatOllama
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate

# Ricarica l'indice salvato
embeddings = OllamaEmbeddings(model="nomic-embed-text")
db = Chroma(persist_directory="chroma_db", embedding_function=embeddings)

# La domanda arriva dalla riga di comando
domanda = " ".join(sys.argv[1:]) or "Di cosa parlano i documenti?"

# Recupera i 4 blocchi più simili
risultati = db.similarity_search(domanda, k=4)
contesto = "\n\n---\n\n".join(d.page_content for d in risultati)

template = ChatPromptTemplate.from_template(
    "Sei un assistente che risponde SOLO usando il contesto fornito.\n"
    "Se la risposta non è nel contesto, dillo chiaramente.\n"
    "Rispondi in italiano, in modo chiaro e conciso.\n\n"
    "CONTESTO:\n{contesto}\n\n"
    "DOMANDA: {domanda}\n\nRISPOSTA:"
)

modello = ChatOllama(model="llama3.2", temperature=0)
catena = template | modello
risposta = catena.invoke({"contesto": contesto, "domanda": domanda})

print("\n=== RISPOSTA ===\n")
print(risposta.content)
print("\n=== FONTI ===")
for d in risultati:
    fonte = d.metadata.get("source", "?")
    pagina = d.metadata.get("page", "?")
    print(f"- {fonte} (pag. {pagina})")

Interroga i tuoi documenti così:

python chiedi.py "Qual è la durata del contratto e come si rinnova?"
Il flusso del RAG: i PDF vengono spezzati e indicizzati, poi il modello risponde citando i blocchi pertinenti.

Un esempio reale e cosa aspettarsi

Mettiamo che nella cartella ci sia un contratto di locazione. Una domanda come:

python chiedi.py "Quanti giorni di preavviso servono per disdire?"

restituirà qualcosa del genere:

=== RISPOSTA ===
Secondo il contratto, il conduttore può recedere con un preavviso di almeno sei mesi comunicato con lettera raccomandata. Per gravi motivi il termine resta di sei mesi.

=== FONTI ===
- documenti/contratto.pdf (pag. 3)

La parte importante è l'elenco FONTI: puoi sempre andare a verificare la pagina citata. È ciò che distingue un assistente RAG serio da un modello che "inventa" (allucina): la risposta è ancorata al testo reale.

Varianti e casi avanzati

  • Risposte più intelligenti: sostituisci llama3.2 con qwen2.5 o llama3.1 nello script di query, dopo aver fatto ollama pull qwen2.5. Servono più RAM ma migliora molto sui documenti tecnici e legali.
  • Recupero più ampio: aumenta k=4 a k=6 per domande che toccano più parti del documento.
  • Altri formati: per file Word o testo usa UnstructuredFileLoader al posto di PyPDFDirectoryLoader.
  • Interfaccia grafica: con poche righe di Streamlit trasformi chiedi.py in un'app con casella di testo e pulsante, invece del terminale.

Errori comuni e come risolverli

  • Connection refused o "could not connect to ollama": Ollama non è in esecuzione. Aprilo (icona nella barra) oppure lancia ollama serve in un altro terminale.
  • model 'nomic-embed-text' not found: hai dimenticato il pull. Esegui ollama pull nomic-embed-text.
  • Il PDF non viene letto (0 pagine): è probabilmente una scansione (immagini, non testo). Passa prima da un OCR, ad esempio con Tesseract, per estrarre il testo.
  • Risposte lente: normale su CPU. Riduci chunk_size, usa il modello 3B, oppure abilita la GPU.
  • Risposte vaghe o "non lo so": hai indicizzato pochi documenti o i chunk sono troppo piccoli. Aumenta chunk_size o k, e assicurati di aver rilanciato ingest.py dopo aver aggiunto file.

Alternative e quando NON conviene il fai-da-te

Se i documenti non sono riservati e vuoi zero installazioni, NotebookLM di Google o la funzione "carica un file" di ChatGPT e Claude fanno un lavoro simile in pochi clic — ma i file finiscono su un server. Se invece hai centinaia di migliaia di pagine o molti utenti, valuta un database vettoriale gestito (Qdrant, Weaviate) e modelli più grandi via API. Il RAG locale che hai appena costruito è il punto ideale per chi mette la privacy al primo posto e vuole capire davvero come funziona la tecnologia, senza costi ricorrenti.

Da qui puoi proseguire aggiungendo la cronologia della conversazione (memoria), un "reranker" per ordinare meglio i risultati, o esportando l'assistente come piccola API interna. Le basi che hai messo — ingest, indice vettoriale, recupero e generazione con citazioni — sono le stesse dei sistemi in produzione.