← Tutti i progetti
Case Study Live Aprile 2026

Ho costruito il mio NotebookLM
con Python, ChromaDB e DeepSeek

Carico PDF. Faccio domande in italiano. L'AI risponde citando il documento e la pagina esatta. Costo: meno di 1 centesimo a query. I dati non escono mai dal mio server. Ecco come funziona e perché l'ho costruito invece di usare quello di Google.

<1¢ costo per query
~1s tempo risposta
100% dati in locale
~200 righe di codice

Il problema

Lavoro con documenti PDF ogni giorno — circolari normative, manuali tecnici, documentazione. Cercare un'informazione specifica richiede di aprire 5 file, fare Ctrl+F su parole che magari nel documento non ci sono esattamente, leggere pagine intere per trovare un numero.

NotebookLM di Google risolve questo problema benissimo. Ma ha un limite che per me è bloccante: i tuoi documenti vanno su server Google. Per documenti aziendali, fiscali o contrattuali questo non è accettabile.

La domanda che mi sono fatto: quanto ci vuole a costruire la stessa cosa con Python, su server mio, con controllo totale sui dati? Risposta: un pomeriggio.

La soluzione: RAG su PDF

RAG significa Retrieval Augmented Generation. Non addestri un modello — dai i documenti al modello al momento giusto. Il flusso è semplice:

📄
PDF
sorgente
✂️
Chunking
500 token
🧮
Embedding
MiniLM-L6
🗄️
ChromaDB
vettori
🔍
Retrieval
top-5 chunk
💬
DeepSeek
risposta

La parte fondamentale è il retrieval: la domanda dell'utente viene trasformata nello stesso spazio vettoriale dei chunk. ChromaDB trova i 5 frammenti più vicini semanticamente — anche se usano parole diverse. Solo quei 5 frammenti finiscono nel prompt, non l'intero documento.

Confronto con NotebookLM

NotebookLM (Google) Folio (mia versione)
Costo Gratis (Google ci perde) <0,01€ a query (DeepSeek)
Privacy dati PDF vanno su server Google Tutto sul mio VPS
Personalizzazione Zero Totale — prompt, modello, UI
API esterna Pronto subito Richiede setup iniziale (~2h)
Lingua italiana Ottima Ottima (DeepSeek multilingual)
Citazione fonte Sì (file + pagina)
Integrabile in prodotti No Sì — endpoint REST

Il codice core: ingestion PDF

La parte più critica è l'ingestion: estrarre il testo, dividerlo in chunk e vettorizzarlo. Con PyPDFLoader ogni pagina mantiene i metadati (file e numero pagina), che poi finiscono nelle citazioni della risposta.

Python
# Carica il PDF pagina per pagina con metadati
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

loader = PyPDFLoader(pdf_path)
pages = loader.load()  # ogni elemento = 1 pagina

# Aggiungi metadati per le citazioni
for page in pages:
    page.metadata["filename"] = filename
    page.metadata["page"] = page.metadata.get("page", 0) + 1  # 1-indexed

# Chunking: 500 token, overlap 50 per non perdere contesto
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50
)
chunks = splitter.split_documents(pages)

# Vettorizza e salva in ChromaDB
vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2"),
    persist_directory="chroma_pdf_db",
    collection_name="pdf_docs"
)

Il codice core: query con citazioni

Il prompt è costruito per forzare il modello a citare sempre file e pagina. Se l'informazione non c'è nei documenti, DeepSeek lo dice — non inventa.

Python
# Recupera i 5 chunk più rilevanti
retriever = vector_store.as_retriever(search_kwargs={"k": 5})

# Formatta i chunk con riferimento a file e pagina
def format_docs(docs):
    parts = []
    for d in docs:
        fname = d.metadata.get("filename", "documento")
        page  = d.metadata.get("page", "?")
        parts.append(f"[{fname}, pag. {page}]\n{d.page_content}")
    return "\n\n---\n\n".join(parts)

# Prompt che forza le citazioni
prompt = """Rispondi SOLO usando i documenti forniti.
Per ogni informazione cita sempre: [file, pag. X].
Se non è nei documenti, dillo chiaramente.

DOCUMENTI:
{context}

DOMANDA: {input}
RISPOSTA:"""

# Chain LCEL: retriever → format → prompt → LLM → string
chain = (
    {"context": retriever | format_docs, "input": RunnablePassthrough()}
    | PromptTemplate.from_template(prompt)
    | llm
    | StrOutputParser()
)

answer = chain.invoke("Qual è il limite di ricavi per il forfettario?")
Risultato: "Il limite di ricavi per il regime forfettario è 85.000 euro annui [circolare_15_2024.pdf, pag. 7]. Per i primi anni di attività si applicano condizioni specifiche [circolare_15_2024.pdf, pag. 9]."

Perché il chunking è la parte critica

Sbagliare il chunking distrugge l'accuracy. Il problema è che un ragionamento legale o normativo si sviluppa su più paragrafi — se tagli nel punto sbagliato perdi il contesto.

La soluzione è il chunk overlap: ogni frammento condivide 50 token con il precedente e il successivo. Così anche se l'informazione chiave è a cavallo di due chunk, uno dei due la contiene sempre.

Regola pratica: chunk_size=500 + chunk_overlap=50 funziona bene per la maggior parte dei documenti. Per normativa densa (circolari ADE, contratti) puoi scendere a chunk_size=300 per aumentare la granularità.

L'embedding: perché non serve Ctrl+F

Ctrl+F cerca la parola esatta. Il retrieval cerca il significato. Il modello all-MiniLM-L6-v2 è addestrato su miliardi di testi e ha già "capito" che:

Gira completamente in locale (HuggingFace, nessuna API esterna) e ci vuole meno di 1 secondo per vettorizzare una domanda.

Stack completo

Tutto open source, tutto installabile con pip:

requirements.txt
fastapi
uvicorn
pypdf                    # estrazione testo PDF
langchain
langchain-community
langchain-chroma
langchain-huggingface
langchain-openai         # per DeepSeek (API compatibile)
sentence-transformers    # all-MiniLM-L6-v2
chromadb
python-multipart         # upload file via form

Il knowledge graph

La parte più visivamente interessante: dopo l'indicizzazione, i concetti estratti dai documenti vengono visualizzati come un grafo force-directed interattivo. Ogni nodo è un termine rilevante, gli edge rappresentano la co-occorrenza semantica nei documenti.

L'ho costruito con canvas 2D + fisica simulata — nessuna libreria esterna. Un loop requestAnimationFrame calcola repulsione tra nodi, attrazione lungo gli edge, e gravity verso il centro. I nodi sono trascinabili e reagiscono al mouse con un glow animato.

Perché questo è utile: non è solo estetica. Vedere i concetti collegati dà una panoramica immediata dei temi dominanti nel documento — utile prima ancora di fare la prima domanda.

Provalo adesso

Carica un tuo PDF e fai una domanda. Risponde in italiano con citazione della pagina.

Apri Folio →

Cosa ho imparato costruendolo

Sviluppi futuri