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.
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.
RAG significa Retrieval Augmented Generation. Non addestri un modello — dai i documenti al modello al momento giusto. Il flusso è semplice:
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.
| 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ì | Sì (file + pagina) |
| Integrabile in prodotti | No | Sì — endpoint REST |
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.
# 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 prompt è costruito per forzare il modello a citare sempre file e pagina. Se l'informazione non c'è nei documenti, DeepSeek lo dice — non inventa.
# 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?")
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.
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.
Tutto open source, tutto installabile con pip:
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
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.
Carica un tuo PDF e fai una domanda. Risponde in italiano con citazione della pagina.
Apri Folio →