# RAG from Scratch — Singolo PDF Generico Sistema RAG (Retrieval-Augmented Generation) costruito da zero, senza framework di alto livello. Funziona su qualsiasi PDF digitale. Gira interamente in locale, senza GPU, senza cloud. **Stack:** Python · Ollama · nomic-embed-text · Qwen3 8B · ChromaDB **Compatibile con:** Linux · macOS · Windows (WSL2) · CPU Only · ~8 GB RAM libera --- ## Indice - [Panoramica](#panoramica) - [Struttura del progetto](#struttura-del-progetto) - [Gli step](#gli-step) - [Step 0 — Scegli il PDF](#step-0--scegli-il-pdf) - [Step 1 — Ispezione automatica](#step-1--ispezione-automatica) - [Step 2 — Conversione in Markdown grezzo](#step-2--conversione-in-markdown-grezzo) - [Step 3 — Rilevamento struttura](#step-3--rilevamento-struttura) - [Step 4 — Revisione manuale](#step-4--revisione-manuale) - [Step 5 — Chunking adattivo](#step-5--chunking-adattivo) - [Step 6 — Verifica chunk](#step-6--verifica-chunk) - [Step 7 — Installazione ambiente](#step-7--installazione-ambiente) - [Step 8 — Vettorizzazione](#step-8--vettorizzazione) - [Step 9 — Pipeline RAG](#step-9--pipeline-rag) - [Step 10 — Test automatici](#step-10--test-automatici) - [Principi di progettazione](#principi-di-progettazione) --- ## Panoramica ``` PDF └─► STEP 1 Ispezione automatica └─► STEP 2 Conversione in Markdown grezzo └─► STEP 3 Rilevamento struttura └─► STEP 4 Revisione manuale ← step più importante └─► STEP 5 Chunking adattivo └─► STEP 6 Verifica chunk └─► STEP 8 Vettorizzazione └─► STEP 9 Pipeline RAG └─► STEP 10 Test automatici STEP 0 Prerequisito iniziale (PDF adatto) STEP 7 Prerequisito tecnico (ambiente locale) ``` ### Dove si concentra il rischio | Step | Rischio | Motivo | |---|---|---| | Step 0 | 🔴 Alto | Un PDF inadatto invalida tutto il lavoro successivo | | Step 1 | 🟢 Basso | Automatico, solo osservazione | | Step 2 | 🟢 Basso | Automatico, tool maturo | | Step 3 | 🟢 Basso | Automatico, solo analisi | | Step 4 | 🔴 Alto | Manuale — la qualità del MD determina la qualità del RAG | | Step 5 | 🟡 Medio | Logica adattiva, dipende dalla qualità del MD | | Step 6 | 🟢 Basso | Automatico, solo verifica | | Step 7 | 🟢 Basso | Installazione standard | | Step 8 | 🟢 Basso | Meccanico, lento ma affidabile | | Step 9 | 🟡 Medio | Qualità del prompt | | Step 10 | 🟢 Basso | Test automatici | --- ## Struttura del progetto ``` rag-from-scratch/ │ ├── sources/ # PDF originali — non modificare mai │ └── documento.pdf │ ├── processed/ # Output di ogni step per ogni documento │ └── documento/ │ ├── raw.md # MD grezzo da marker (non toccare) │ ├── clean.md # MD revisionato a mano │ ├── structure_profile.json # Profilo struttura da step 3 │ └── chunks.json # Chunk pronti per la vettorizzazione │ ├── scripts/ │ ├── inspect.py # Step 1 — ispezione PDF │ ├── detect_structure.py # Step 3 — analisi struttura MD │ ├── chunker.py # Step 5 — chunking adattivo │ ├── verify_chunks.py # Step 6 — verifica chunk │ ├── ingest.py # Step 8 — vettorizzazione │ ├── rag.py # Step 9 — pipeline RAG │ └── test_pipeline.py # Step 10 — test automatici │ ├── chroma_db/ # Vector store — generato da ingest.py ├── notes/ │ └── revision_log.md # Log delle modifiche manuali al MD ├── requirements.txt ├── .gitignore └── README.md ``` --- ## Gli step --- ### Step 0 — Scegli il PDF **Tipo:** prerequisito manuale **Input:** nessuno **Output:** un PDF adatto al sistema Il PDF deve soddisfare requisiti minimi prima di qualsiasi elaborazione. Un PDF inadatto rende tutto il lavoro successivo inutile. **Criteri obbligatori:** - Il testo è selezionabile nel PDF reader — se non riesci a copiare una parola, pdfplumber non la leggerà - Non è protetto da password - È generato digitalmente, non scansionato — una foto di un libro non è un PDF di testo - Il contenuto importante è nel testo, non nelle immagini **Criteri desiderabili:** - Ha una struttura logica riconoscibile: capitoli, sezioni, paragrafi - Le sezioni hanno titoli espliciti - Non ha layout a colonne multiple - È in una lingua sola o prevalentemente una **Come verificarlo:** Apri il PDF nel tuo reader, seleziona del testo da pagine diverse e copialo. Se il testo copiato è leggibile e nell'ordine giusto, il PDF è adatto. Se ottieni caratteri strani o testo nell'ordine sbagliato, il PDF ha problemi. --- ### Step 1 — Ispezione automatica **Tipo:** automatico **Input:** `sources/documento.pdf` **Output:** report testuale con score e lista problemi **Script:** `scripts/inspect.py` ```bash python scripts/inspect.py sources/documento.pdf --save ``` Lo script analizza il PDF pagina per pagina e produce un report. Serve per capire la qualità del documento e mappare i problemi prima di affrontare la revisione manuale. **Cosa rileva:** - Testo non estraibile (pagine con sole immagini) - Sillabazioni a fine riga - Layout a colonne (righe molto corte e numerose) - Intestazioni e piè di pagina ripetitivi - Caratteri Unicode anomali - Pagine vuote **Output del report:** ``` Score: 87/100 Pagine totali: 243 Pagine con problemi: 12 Pagina 14: sillabazione rilevata (3 occorrenze) Pagina 67: possibile layout a colonne Pagina 201: caratteri Unicode anomali PROSSIMI PASSI: → conversione con marker funzionerà bene → attenzione alle pagine 14 e 67 nella revisione manuale ``` **Decisione:** | Score | Azione | |---|---| | ≥ 70 | Procedi allo step 2 | | 40–70 | Procedi con cautela, revisione estesa necessaria | | < 40 | Valuta una fonte PDF migliore | --- ### Step 2 — Conversione in Markdown grezzo **Tipo:** automatico **Input:** `sources/documento.pdf` **Output:** `processed/documento/raw.md` **Tool:** marker-pdf ```bash marker_single sources/documento.pdf processed/documento/raw.md ``` Converte il PDF in Markdown. Il risultato non è perfetto — è la base su cui lavorerai nello step 4. **Regola fondamentale:** `raw.md` non va mai modificato. È il punto di partenza di riferimento. Se qualcosa va storto nella revisione, puoi sempre ripartire da qui. ```bash # Crea subito la copia su cui lavorare cp processed/documento/raw.md processed/documento/clean.md ``` **Cosa produce marker:** - Titoli riconosciuti e convertiti in `#` `##` `###` - Paragrafi separati da righe vuote - Sillabazione parzialmente risolta **Cosa non produce marker:** - Rimozione intestazioni e piè di pagina - Correzione completa del layout a colonne - Descrizione del contenuto delle immagini --- ### Step 3 — Rilevamento struttura **Tipo:** automatico **Input:** `processed/documento/raw.md` **Output:** `processed/documento/structure_profile.json` **Script:** `scripts/detect_structure.py` ```bash python scripts/detect_structure.py processed/documento/raw.md ``` Analizza la struttura del Markdown grezzo senza modificarlo. Il profilo prodotto guida sia la revisione manuale che il chunker. **I quattro livelli strutturali:** ``` Livello 3 — struttura ricca Il documento ha ### con regolarità. Ogni ### è un'unità semantica chiara. Esempi: opere filosofiche, manuali tecnici, leggi. Strategia chunking: boundary su ### Livello 2 — struttura parziale Il documento ha ## ma pochi o nessun ###. Le sezioni sono i capitoli, non le sottosezioni. Esempi: articoli scientifici, report, saggi. Strategia chunking: boundary su ##, split interno su paragrafi Livello 1 — solo paragrafi Il documento non ha titoli significativi. La struttura è data dalle righe vuote. Esempi: testi narrativi, lettere, trascrizioni. Strategia chunking: boundary su paragrafo Livello 0 — testo piatto Un blocco continuo senza struttura riconoscibile. Esempi: PDF mal convertiti, testi antichi. Strategia chunking: sliding window su frasi ``` **Profilo prodotto:** ```json { "livello_struttura": 3, "n_h1": 1, "n_h2": 9, "n_h3": 296, "n_paragrafi": 312, "boundary_primario": "h3", "lingua_rilevata": "it", "lunghezza_media_sezione": 420, "strategia_chunking": "h3_aware", "avvertenze": [ "14 sezioni sotto i 200 caratteri — verranno accorpate", "8 sezioni sopra i 800 caratteri — verranno divise" ] } ``` --- ### Step 4 — Revisione manuale **Tipo:** manuale **Input:** `processed/documento/raw.md` + profilo struttura **Output:** `processed/documento/clean.md` **Tool:** il tuo editor di testo > Questo è lo step più importante dell'intera pipeline. > La qualità del RAG dipende da questo step più di qualsiasi > parametro tecnico o scelta di modello. Apri `clean.md` nel tuo editor e lavora sezione per sezione, guidato dal report dello step 1 e dal profilo dello step 3. **Struttura target del MD pulito:** ```markdown # Titolo del documento ## Sezione principale ### Sottosezione o unità atomica Testo fluente, frasi complete, nessun artefatto. Ogni paragrafo è semanticamente autonomo. Una riga vuota separa le sezioni. ### Sottosezione successiva Testo della sottosezione successiva... ``` **Cosa rimuovi:** - Numeri di pagina isolati (una riga con solo `42`) - Intestazioni e piè di pagina ripetitivi - Righe vuote multiple — massimo una tra sezioni **Cosa correggi:** - Sillabazioni residue: `estra-\ntto` → `estratto` - Righe spezzate artificialmente: unisci le righe che appartengono alla stessa frase - Parole unite per errore: `ilbene` → `il bene` **Cosa sistemi:** - Ogni sezione ha il suo titolo al livello corretto - Nessuna sezione manca di `###` se ne aveva uno nell'originale - Nessun titolo è duplicato o malformato **Il criterio di qualità:** Leggi ogni sezione ad alta voce. Se suona naturale e fluente è corretta. Se si interrompe c'è una riga spezzata. Se suona ripetitiva c'è un artefatto. **Traccia il lavoro:** ```bash # Aggiorna notes/revision_log.md con ogni correzione rilevante # Commita spesso per poter tornare indietro git add processed/documento/clean.md notes/revision_log.md git commit -m "revisione: sezioni 1-50 corrette" ``` --- ### Step 5 — Chunking adattivo **Tipo:** automatico **Input:** `processed/documento/clean.md` + `structure_profile.json` **Output:** `processed/documento/chunks.json` **Script:** `scripts/chunker.py` ```bash python scripts/chunker.py processed/documento/clean.md --save ``` Divide il Markdown pulito in chunk. Usa il profilo strutturale per scegliere la strategia giusta. Non sa nulla del contenuto — si basa solo sulla struttura. **Regole invarianti per qualsiasi documento:** - Un chunk non attraversa mai il confine tra due sezioni diverse - Un chunk non spezza mai una frase a metà - Ogni chunk porta il suo contesto nel prefisso - L'overlap tra chunk avviene solo su frasi intere, mai tra sezioni diverse **Parametri:** | Parametro | Default | Significato | |---|---|---| | `MIN_CHARS` | 200 | Sotto questa soglia, accorpa al chunk successivo | | `MAX_CHARS` | 800 | Sopra questa soglia, spezza su frasi | | `OVERLAP_S` | 2 | Frasi di overlap tra sotto-chunk dello stesso boundary | **Struttura di ogni chunk:** ```json { "chunk_id": "sezione_principale__sottosezione_3__s0", "text": "[Sezione principale > Sottosezione 3]\nTesto del chunk...", "sezione": "Sezione principale", "titolo": "Sottosezione 3", "sub_index": 0, "n_chars": 412 } ``` Il prefisso `[Sezione > Titolo]` è fondamentale: permette all'embedding di catturare il contesto topico del chunk anche quando il testo da solo sarebbe ambiguo. --- ### Step 6 — Verifica chunk **Tipo:** automatico **Input:** `processed/documento/chunks.json` **Output:** report problemi + statistiche **Script:** `scripts/verify_chunks.py` ```bash python scripts/verify_chunks.py processed/documento/chunks.json ``` Analizza ogni chunk e segnala i problemi. Non corregge nulla. Se ci sono problemi torni allo step 4 o aggiusti i parametri dello step 5. Non si va allo step 8 finché questo step è pulito. **Cosa verifica:** - Ogni chunk ha il prefisso di contesto - Nessun chunk è vuoto - Nessun chunk è sotto `MIN_CHARS` - Nessun chunk è sopra `MAX_CHARS` * 1.5 - Ogni chunk finisce con punteggiatura (frase completa) **Tabella diagnosi:** | Problema | Causa probabile | Soluzione | |---|---|---| | Molti chunk troppo corti | `MIN_CHARS` troppo alto | Abbassa `MIN_CHARS` | | Molti chunk troppo lunghi | `MAX_CHARS` troppo basso | Alza `MAX_CHARS` | | Chunk senza prefisso | Bug nel parsing | Controlla `###` nel MD | | Chunk che finiscono a metà frase | Riga spezzata nel MD | Correggi nello step 4 | **Output se tutto ok:** ``` Totale chunk: 301 ✅ OK: 301 Distribuzione lunghezze: Min: 187 char Max: 923 char Media: 401 char ✅ Nessun problema — procedi con la vettorizzazione. ``` --- ### Step 7 — Installazione ambiente **Tipo:** manuale (una volta sola) **Input:** nessuno **Output:** ambiente locale funzionante ```bash # Installa Ollama curl -fsSL https://ollama.com/install.sh | sh # Scarica i modelli ollama pull qwen3:8b # LLM — ~5 GB ollama pull nomic-embed-text # Embedding — ~270 MB # Installa dipendenze Python pip install -r requirements.txt # Verifica ollama list # deve mostrare entrambi i modelli ``` **Modelli usati:** | Modello | Ruolo | Dimensione | RAM occupata | |---|---|---|---| | `nomic-embed-text` | Converte testo in vettori | 270 MB | ~500 MB | | `qwen3:8b` | Genera le risposte | 5 GB | ~6-7 GB | Questo step si esegue una volta sola. Da questo momento Ollama è sempre disponibile sul sistema. --- ### Step 8 — Vettorizzazione **Tipo:** automatico (lento) **Input:** `processed/documento/chunks.json` **Output:** `chroma_db/` popolato **Script:** `scripts/ingest.py` ```bash python scripts/ingest.py processed/documento/chunks.json ``` Trasforma ogni chunk in un vettore numerico e lo salva in ChromaDB. È il processo più lento — su CPU circa 1-3 secondi per chunk. Per 300 chunk aspetta 5-15 minuti. **Cosa succede per ogni chunk:** ``` testo del chunk │ ▼ Ollama (nomic-embed-text) vettore di 768 numeri [0.23, -0.41, 0.87, 0.12, ...] │ ▼ ChromaDB salva: testo + vettore + metadati ``` **Perché 768 numeri:** Ogni numero rappresenta una dimensione semantica. Testi con significato simile producono vettori simili — i loro numeri sono vicini nello spazio a 768 dimensioni. Questo è ciò che permette il retrieval semantico. **Output durante l'esecuzione:** ``` ✅ Ollama OK — nomic-embed-text disponibile 📦 301 chunk da ingestire [ 1/301] ✓ sezione_1__sotto_1__s0 ETA: 290s [ 2/301] ✓ sezione_1__sotto_2__s0 ETA: 287s ... [301/301] ✓ sezione_9__sotto_42__s0 ETA: 0s ✅ Ingestione completata in 312s — 301/301 chunk salvati ``` `chroma_db/` contiene ora tutti i vettori su disco. Non è necessario ripetere questo step a meno che il documento cambi. --- ### Step 9 — Pipeline RAG **Tipo:** interattivo **Input:** `chroma_db/` + domanda dell'utente **Output:** risposta basata sul documento **Script:** `scripts/rag.py` ```bash python scripts/rag.py ``` Mette insieme retrieval e generation in un loop interattivo. **Flusso per ogni domanda:** ``` La tua domanda in testo │ ▼ embedding della domanda (nomic-embed-text) vettore 768 dimensioni │ ▼ ricerca per similarità coseno in ChromaDB top 3 chunk semanticamente più vicini │ ▼ costruzione del prompt "Rispondi SOLO dal contesto: [chunk 1] [chunk 2] [chunk 3] Domanda: ..." │ ▼ Ollama (Qwen3 8B) risposta generata ``` **Come si usa:** ``` Domanda: La tua domanda qui Domanda: La tua domanda qui -v ← aggiunge i chunk recuperati Domanda: exit ``` **La similarità coseno:** Misura l'angolo tra due vettori — non la distanza ma l'orientamento. Due testi semanticamente simili puntano nella stessa direzione nello spazio a 768 dimensioni, indipendentemente dalla loro lunghezza. **Regola del prompt:** Il LLM risponde SOLO dal contesto fornito. Se la risposta non è nel documento lo dice esplicitamente. Non inventa, non integra con conoscenza esterna. --- ### Step 10 — Test automatici **Tipo:** automatico **Input:** sistema completo **Output:** tutti i test verdi **Script:** `scripts/test_pipeline.py` ```bash python scripts/test_pipeline.py ``` Verifica ogni componente in isolamento e poi nel sistema completo. I test non dipendono dal contenuto del documento — usano dati fittizi creati e distrutti in memoria. **Struttura dei test:** ``` Test unitari — ogni componente isolato ✓ split_sentences non spezza le frasi ✓ parse_markdown rileva la struttura corretta ✓ chunk_sezione rispetta i boundary ✓ il prefisso è sempre presente in ogni chunk Test integrazione — i componenti parlano tra loro ✓ Ollama è raggiungibile ✓ i modelli sono disponibili ✓ l'embedding produce 768 dimensioni ✓ testi diversi producono vettori diversi ✓ ChromaDB scrive e legge correttamente Test qualità — il sistema si comporta bene ✓ il retrieval trova il chunk pertinente ✓ il retrieval non trova il chunk non pertinente ✓ il LLM usa il contesto fornito ✓ il LLM ammette quando la risposta non è nel contesto ``` --- ## Principi di progettazione **Atomico** Ogni step fa una cosa sola. Il chunker non sa niente di Ollama. L'ingestione non sa niente del MD originale. Se un pezzo si rompe, sai esattamente dove. **Verificabile** Ogni step ha un criterio di completamento oggettivo. Non si passa allo step successivo finché il precedente non è verificato. **Reversibile** Puoi tornare indietro senza perdere il lavoro degli altri step. Cambi il MD? Riesegui solo step 5 e 8. Cambi i parametri del chunker? Riesegui solo step 5 e 8. Non si riparte mai da zero. **Senza assunzioni** Il sistema non assume nulla sulla struttura del documento. Rileva il livello strutturale e si adatta. Funziona su libri, manuali, articoli, contratti, dispense. **Tutto locale** Nessuna chiamata a API esterne. Nessun dato trasmesso fuori dalla macchina. Nessun costo di utilizzo.