Caricare i propri PDF in ChatGPT funziona finche' i documenti sono pochi e brevi. Quando il corpus cresce - cento manuali tecnici, anni di delibere, l'intero archivio normativo di un'azienda - il limite si vede: l'LLM dimentica meta' del materiale, le risposte diventano vaghe, e il costo dei token esplode. La soluzione si chiama RAG (Retrieval-Augmented Generation): indici i documenti in un database vettoriale e usi un LLM per rispondere SOLO sui passaggi pertinenti. In questa guida costruiamo un RAG completo, in locale, gratis e privato, usando Ollama, LangChain e ChromaDB. Niente API a pagamento, niente dati che escono dal tuo Mac o PC.

A chi serve questo tutorial e cosa otterrai alla fine

Questa guida e' per: avvocati, ricercatori e professionisti con archivi PDF; aziende che non possono mandare dati sensibili in cloud (sanita', PA, finanza); sviluppatori che vogliono capire il pattern RAG end-to-end prima di portarlo in produzione.

Alla fine avrai uno script Python che indicizza una cartella di PDF, espone una chat che cita la pagina e il documento esatti, gira tutto in locale con Llama 3.3 o DeepSeek R1 e ChromaDB su disco. Tempo necessario: circa 90 minuti.

Prerequisiti reali (hardware, software, conti)

  • Sistema operativo: macOS 14+, Windows 11 con WSL2, o Ubuntu 22.04+.
  • RAM: minimo 16 GB. Con 32 GB potrai usare modelli da 8B/13B confortevolmente.
  • GPU: opzionale ma utile. Una NVIDIA con 8 GB VRAM (RTX 3060, 4060) dimezza i tempi. Senza GPU funziona tutto comunque, su CPU.
  • Spazio disco: 20-30 GB liberi (modelli + database vettoriale).
  • Python: 3.10 o superiore.
  • Ollama: scaricalo da ollama.com/download e verifica con ollama --version in terminale.

Nessun conto cloud richiesto. Nessuna chiave API.

Quale LLM e quale embedding scegliere

Per un RAG locale ti servono due modelli distinti: un LLM che genera la risposta finale e un embedding model che trasforma testo in vettori. Ecco la scelta consigliata per maggio 2026:

  • LLM: Llama 3.3 8B (general purpose, italiano molto buono) o DeepSeek R1 distill 8B (reasoning forte su domande complesse). Entrambi pesano 4-5 GB nel formato Q4_K_M e girano comodamente con 16 GB di RAM.
  • Embedding: nomic-embed-text v1.5 (768 dimensioni, multilingue, ottimo bilanciamento qualita'/velocita') oppure mxbai-embed-large se vuoi qualita' top in inglese.

Per il database vettoriale useremo ChromaDB: e' un database open-source, persistente su disco, con API Python pulita. Le alternative serie sono Qdrant (piu' veloce, ma piu' pesante da configurare) e LanceDB (eccellente con dataset enormi). Per un archivio sotto i 50.000 chunk, Chroma e' la scelta migliore.

Passo 1: installare i modelli con Ollama

Avvia Ollama (su macOS basta aprire l'app, su Linux ollama serve) e in un altro terminale scarica i due modelli:

ollama pull llama3.3:8b
ollama pull nomic-embed-text

Verifica che funzionino con una query veloce:

ollama run llama3.3:8b "Riassumi in due frasi: chi era Leonardo da Vinci?"

Lascia Ollama in esecuzione: il nostro script Python si connettera' alla sua API REST locale (porta 11434).

Passo 2: ambiente Python e dipendenze

Crea una cartella di progetto e un ambiente virtuale:

mkdir rag-pdf-locale && cd rag-pdf-locale
python3 -m venv .venv
source .venv/bin/activate  # su Windows: .venv\Scripts\activate
pip install --upgrade pip
pip install langchain langchain-community langchain-ollama chromadb pypdf sentence-transformers tiktoken

Crea una cartella ./documenti e copiaci dentro tutti i PDF che vuoi indicizzare. Per iniziare, due o tre file vanno bene.

Passo 3: indicizzazione (script ingest.py)

Crea il file ingest.py:

from pathlib import Path
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_ollama import OllamaEmbeddings
from langchain_chroma import Chroma

DOCS_DIR = Path("./documenti")
CHROMA_DIR = "./chroma_db"

def load_pdfs():
    docs = []
    for pdf in DOCS_DIR.glob("*.pdf"):
        loader = PyPDFLoader(str(pdf))
        loaded = loader.load()
        for d in loaded:
            d.metadata["source_file"] = pdf.name
        docs.extend(loaded)
        print(f"  + {pdf.name}: {len(loaded)} pagine")
    return docs

def main():
    print("Carico PDF...")
    docs = load_pdfs()
    print(f"Totale pagine: {len(docs)}")

    print("Spezzo in chunk...")
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=900,
        chunk_overlap=120,
        separators=["\n\n", "\n", ". ", " ", ""]
    )
    chunks = splitter.split_documents(docs)
    print(f"Totale chunk: {len(chunks)}")

    print("Calcolo embedding e salvo su Chroma...")
    embed = OllamaEmbeddings(model="nomic-embed-text")
    Chroma.from_documents(chunks, embed, persist_directory=CHROMA_DIR)
    print("Indice salvato in", CHROMA_DIR)

if __name__ == "__main__":
    main()

Esegui con python ingest.py. Per ogni 100 pagine, il tempo varia da 30 secondi (con GPU) a 3-4 minuti (CPU). Il database viene scritto in ./chroma_db, e' persistente: la prossima volta non dovrai re-indicizzare.

Passo 4: chat con citazioni (script chat.py)

Crea chat.py:

from langchain_ollama import OllamaEmbeddings, ChatOllama
from langchain_chroma import Chroma
from langchain.prompts import ChatPromptTemplate
from langchain.schema import StrOutputParser
from langchain.schema.runnable import RunnablePassthrough

CHROMA_DIR = "./chroma_db"

PROMPT = ChatPromptTemplate.from_template("""
Sei un assistente che risponde SOLO sulla base del contesto fornito.
Se la risposta non e' presente nel contesto, dillo chiaramente.
Cita SEMPRE il documento e la pagina con il formato (file, p. N).

CONTESTO:
{context}

DOMANDA: {question}

RISPOSTA:
""")

def format_docs(docs):
    out = []
    for d in docs:
        src = d.metadata.get("source_file", "ignoto")
        page = d.metadata.get("page", "?")
        out.append(f"[{src}, p. {page+1 if isinstance(page,int) else page}]\n{d.page_content}")
    return "\n\n".join(out)

def main():
    embed = OllamaEmbeddings(model="nomic-embed-text")
    db = Chroma(persist_directory=CHROMA_DIR, embedding_function=embed)
    retriever = db.as_retriever(search_kwargs={"k": 5})
    llm = ChatOllama(model="llama3.3:8b", temperature=0.1)

    chain = (
        {"context": retriever | format_docs, "question": RunnablePassthrough()}
        | PROMPT
        | llm
        | StrOutputParser()
    )

    print("RAG locale pronto. Scrivi 'esci' per uscire.\n")
    while True:
        q = input("Domanda: ").strip()
        if q.lower() in ("esci","exit","quit"): break
        if not q: continue
        print("\n", chain.invoke(q), "\n")

if __name__ == "__main__":
    main()

Avvialo con python chat.py. La prima domanda sara' un po' lenta (Ollama carica il modello in RAM), le successive risponderanno in pochi secondi.

Esempio di output atteso

Domanda: "Quale e' la procedura prevista per il rinnovo del contratto?"

Il rinnovo del contratto si effettua entro 60 giorni dalla scadenza, comunicandolo via PEC come previsto all'articolo 12 comma 3 (manuale_clienti_2025.pdf, p. 14). In caso di mancata comunicazione il contratto si intende risolto automaticamente. (manuale_clienti_2025.pdf, p. 14)

Le citazioni precise sono la differenza fra un giocattolo e uno strumento di lavoro.

Varianti e configurazioni avanzate

  • Domande complesse, reasoning forte: sostituisci llama3.3:8b con deepseek-r1:8b. Risposte piu' ragionate, latenza maggiore (anche 30 secondi a domanda su CPU).
  • Documenti scansionati: PyPDFLoader non gestisce PDF immagine. Usa UnstructuredPDFLoader con strategy="ocr_only" o, meglio, fai prima un passaggio con Tesseract.
  • Multilingua serio: cambia embedding in BAAI/bge-m3 (via sentence-transformers).
  • Reranking: aggiungi un cross-encoder bge-reranker-v2 per riordinare i top-k. Migliora drasticamente la precisione.

Errori comuni e soluzioni

  • "connection refused localhost:11434" → Ollama non sta girando. Apri l'app o lancia ollama serve.
  • "context length exceeded" → troppi documenti nei top-k. Riduci k a 3 o usa un text splitter piu' aggressivo (chunk_size 700).
  • Risposte vaghe / fuori contesto → chunk troppo piccoli o embedding sbagliato. Aumenta chunk_size a 1200 e prova bge-m3.
  • Out of memory durante l'indicizzazione → processa i PDF in batch da 50, salva ogni volta con Chroma.add_documents.

Alternative e quando NON usare il RAG locale

Se l'archivio supera i 500.000 chunk, considera Qdrant o pgvector su Postgres. Se la latenza sotto al secondo e' un requisito, valuta soluzioni gestite come Azure AI Search o Vertex AI Vector Search. Se serve la massima qualita' sulle risposte e non hai vincoli di privacy, il pattern resta lo stesso ma sostituisci ChatOllama con ChatAnthropic (Claude Sonnet 4.6) o ChatOpenAI (GPT-5.5).

Da qui puoi proseguire con: 1) un'interfaccia web via Streamlit (30 righe di codice), 2) multi-turno con memoria (LangChain ConversationBufferMemory), 3) agenti che oltre a leggere PDF cercano nel web o eseguono SQL. Le basi che hai costruito qui reggono tutto: una pipeline di retrieval pulita e un LLM che non puo' allucinare al di fuori del contesto. Il resto e' polish.