From d50f7f64a9cd5de6c19e3cd221ba3f968dbc6e3b Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 14 Apr 2026 15:57:29 +0200 Subject: [PATCH] step-9: add pipeline RAG interattiva Aggiunge rag.py (loop interattivo retrieval+generation), config.py (tutti i parametri in un unico file), test_ollama.py (verifica Ollama senza ChromaDB) e README.md dedicato. Aggiunge .env.example e aggiorna .gitignore --- .env.example | 1 + .gitignore | 3 + step-9/README.md | 85 +++++++++++++++++ step-9/config.py | 54 +++++++++++ step-9/rag.py | 216 ++++++++++++++++++++++++++++++++++++++++++ step-9/test_ollama.py | 66 +++++++++++++ 6 files changed, 425 insertions(+) create mode 100644 .env.example create mode 100644 step-9/README.md create mode 100644 step-9/config.py create mode 100644 step-9/rag.py create mode 100644 step-9/test_ollama.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c7d369f --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +OPENROUTER_API_KEY=sk-or-... diff --git a/.gitignore b/.gitignore index e1dac65..69458fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Variabili d'ambiente — contiene chiavi API, non committare mai +.env + # Virtual environment .venv/ diff --git a/step-9/README.md b/step-9/README.md new file mode 100644 index 0000000..3f8e38e --- /dev/null +++ b/step-9/README.md @@ -0,0 +1,85 @@ +# Step 9 — Pipeline RAG + +Loop interattivo che risponde a domande in linguaggio naturale sul documento indicato. +Per ogni domanda: vettorizza la query, recupera i chunk più rilevanti da ChromaDB e genera la risposta tramite Ollama. + +--- + +## Prerequisiti + +- Step 8 completato (`chroma_db/` popolata) +- Ollama attivo con il modello di embedding e il modello LLM scaricati + +--- + +## Avvio + +```bash +source .venv/bin/activate +python step-9/rag.py --stem +``` + +`--stem` è l'unico argomento CLI. Tutti gli altri parametri si configurano in `config.py`. + +--- + +## Configurazione (`config.py`) + +| Parametro | Default | Descrizione | +|---|---|---| +| `TOP_K` | `6` | Chunk recuperati per ogni domanda. Valori consigliati: `3`–`10` | +| `TEMPERATURE` | `0.0` | Deterministico a `0.0`, creativo verso `1.0`. Per RAG consigliato `0.0` | +| `NO_THINK` | `True` | Disabilita il chain-of-thought interno dei modelli Qwen3/Qwen3.5. `True` = risposta diretta, più veloce | +| `EMBED_MODEL` | `"nomic-embed-text"` | Deve corrispondere al modello usato in step-8. Se cambiato, rieseguire step-8 con `--force` | +| `OLLAMA_URL` | `"http://localhost:11434"` | Modifica solo se Ollama gira su porta o host diversi | +| `OLLAMA_MODEL` | `"qwen3.5:0.8b"` | Modello LLM. Vedi `step-7/README.md` per la scelta | +| `SYSTEM_PROMPT` | *(vedi file)* | Istruzioni di comportamento inviate al LLM. Modifica per cambiare tono, lingua o condizione di fallback | + +--- + +## Loop interattivo + +``` +── Loop RAG ─────────────────────────────────────── (exit per uscire) + +Domanda: +``` + +| Sintassi | Comportamento | +|---|---| +| `` | Risposta basata sul documento | +| ` -v` | Risposta + chunk recuperati con score di similarità | +| `exit` | Esce dal programma | + +--- + +## Flusso interno + +``` +domanda + │ + ▼ embed (EMBED_MODEL, Ollama) +vettore N-dim + │ + ▼ query ChromaDB — similarità coseno, top-K +chunk rilevanti + │ + ▼ build_prompt (SYSTEM_PROMPT + contesti + domanda) + │ + ▼ generate (OLLAMA_MODEL, Ollama) +risposta +``` + +Il LLM risponde esclusivamente dal contesto fornito. Se il contesto è irrilevante rispetto alla domanda, lo dichiara esplicitamente. + +--- + +## Test senza RAG + +Per verificare che Ollama risponda correttamente prima di interrogare il documento: + +```bash +python step-9/test_ollama.py +``` + +Chat diretta con il modello, senza ChromaDB. Usa gli stessi parametri di `config.py`. diff --git a/step-9/config.py b/step-9/config.py new file mode 100644 index 0000000..8d83cd9 --- /dev/null +++ b/step-9/config.py @@ -0,0 +1,54 @@ +# ─── Step 9 — Configurazione RAG ───────────────────────────────────────────── +# +# Modifica questo file per cambiare i parametri della pipeline. +# +# Uso: +# python step-9/rag.py --stem nietzsche +# ────────────────────────────────────────────────────────────────────────────── + +# ── Retrieval ───────────────────────────────────────────────────────────────── + +# Numero di chunk da recuperare per ogni domanda. +# Valori più alti = più contesto, risposte potenzialmente più complete, +# ma prompt più lunghi e generazione più lenta. +TOP_K = 6 + +# ── Generazione ─────────────────────────────────────────────────────────────── + +# Temperatura del modello LLM. +# 0.0 = completamente deterministico (stessa risposta ad ogni run) +# 0.7 = più creativo e vario +TEMPERATURE = 0.0 + +# Disabilita il "thinking" (ragionamento interno) nei modelli Qwen3/Qwen3.5. +# True = risposta diretta, più veloce +# False = ragionamento interno abilitato (più lento ma potenzialmente più accurato) +NO_THINK = True + +# ── Embedding ───────────────────────────────────────────────────────────────── + +# Modello di embedding usato da Ollama. +# Deve corrispondere al modello usato durante la vettorizzazione (step-8). +# Se cambi questo, devi rieseguire step-8 con --force. +EMBED_MODEL = "nomic-embed-text" + +# ── Ollama ──────────────────────────────────────────────────────────────────── + +# URL del server Ollama (default: locale sulla porta 11434). +OLLAMA_URL = "http://localhost:11434" + +# Modello LLM. Scegli in base alla RAM disponibile (vedi README). +OLLAMA_MODEL = "qwen3.5:0.8b" + +# ── Prompt di sistema ───────────────────────────────────────────────────────── + +# Istruzioni di comportamento inviate al LLM prima del contesto e della domanda. +# Modifica per cambiare il tono, la lingua, il grado di libertà interpretativa +# o le condizioni di fallback ("non so rispondere"). +SYSTEM_PROMPT = ( + "Sei un assistente che risponde usando il contesto fornito. " + "Sintetizza e interpreta liberamente i passaggi del contesto per rispondere alla domanda. " + "Se il contesto contiene informazioni pertinenti, anche indirette, usale per costruire una risposta. " + "Solo se il contesto è completamente irrilevante, rispondi: " + "\"Non trovo questa informazione nel documento.\"" +) diff --git a/step-9/rag.py b/step-9/rag.py new file mode 100644 index 0000000..2c4ca1f --- /dev/null +++ b/step-9/rag.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +Step 9 — Pipeline RAG interattiva + +Riceve una domanda, recupera i chunk più rilevanti da ChromaDB (retrieval) +e genera una risposta tramite Ollama (generation). + +Input: chroma_db/ (collection ChromaDB) +Output: risposta a schermo + +Uso: + python step-9/rag.py --stem + +Nel loop interattivo: + Domanda: → risposta + Domanda: -v → risposta + chunk recuperati + Domanda: exit → uscita +""" + +import argparse +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +import chromadb + +# ─── Configurazione ─────────────────────────────────────────────────────────── + +sys.path.insert(0, str(Path(__file__).parent)) +import config as _cfg + +project_root = Path(__file__).parent.parent +CHROMA_DIR = project_root / "chroma_db" + +OLLAMA_URL = _cfg.OLLAMA_URL +EMBED_MODEL = _cfg.EMBED_MODEL +LLM_MODEL = _cfg.OLLAMA_MODEL +TOP_K = _cfg.TOP_K +TEMPERATURE = _cfg.TEMPERATURE +NO_THINK = _cfg.NO_THINK +SYSTEM_PROMPT = _cfg.SYSTEM_PROMPT + + +# ─── Embedding ──────────────────────────────────────────────────────────────── + +def embed(text: str) -> list[float]: + """Genera il vettore della domanda tramite Ollama.""" + payload = json.dumps({"model": EMBED_MODEL, "prompt": text}).encode() + req = urllib.request.Request( + f"{OLLAMA_URL}/api/embeddings", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read())["embedding"] + + +# ─── Generazione ────────────────────────────────────────────────────────────── + +def call_ollama(prompt: str) -> str: + """Chiama Ollama /api/generate e ritorna la risposta.""" + payload = json.dumps({ + "model": LLM_MODEL, + "prompt": prompt, + "stream": False, + "think": not NO_THINK, + "options": {"temperature": TEMPERATURE}, + }).encode() + req = urllib.request.Request( + f"{OLLAMA_URL}/api/generate", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=300) as resp: + return json.loads(resp.read())["response"].strip() + + +# ─── Retrieval ──────────────────────────────────────────────────────────────── + +def retrieve(collection: chromadb.Collection, question: str) -> list[dict]: + """ + Genera l'embedding della domanda e recupera i TOP_K chunk più simili. + Ritorna lista di dict con chiavi: text, sezione, titolo, distance. + """ + vector = embed(question) + results = collection.query( + query_embeddings=[vector], + n_results=TOP_K, + include=["documents", "metadatas", "distances"], + ) + chunks = [] + for text, meta, dist in zip( + results["documents"][0], + results["metadatas"][0], + results["distances"][0], + ): + chunks.append({ + "text": text, + "sezione": meta.get("sezione", ""), + "titolo": meta.get("titolo", ""), + "distance": dist, + }) + return chunks + + +# ─── Prompt ─────────────────────────────────────────────────────────────────── + +def build_prompt(question: str, chunks: list[dict]) -> str: + context_parts = [] + for i, c in enumerate(chunks, start=1): + header = f"[Contesto {i}" + if c["sezione"]: + header += f" — {c['sezione']}" + if c["titolo"]: + header += f" > {c['titolo']}" + header += "]" + context_parts.append(f"{header}\n{c['text']}") + + context = "\n\n".join(context_parts) + return ( + f"{SYSTEM_PROMPT}\n\n" + f"{context}\n\n" + f"Domanda: {question}" + ) + + +# ─── Loop interattivo ───────────────────────────────────────────────────────── + +def answer(question: str, collection: chromadb.Collection, verbose: bool) -> None: + try: + chunks = retrieve(collection, question) + except (urllib.error.URLError, OSError) as e: + print(f"❌ Errore embedding: {e}") + return + + if verbose: + print("\n── Chunk recuperati ──────────────────────────────────────────") + for i, c in enumerate(chunks, start=1): + loc = c["sezione"] + if c["titolo"]: + loc += f" > {c['titolo']}" + sim = 1 - c["distance"] + print(f" [{i}] {loc} (similarità: {sim:.3f})") + print(f" {c['text'][:120].replace(chr(10), ' ')}...") + print("──────────────────────────────────────────────────────────────\n") + + prompt = build_prompt(question, chunks) + + try: + response = call_ollama(prompt) + except (urllib.error.URLError, OSError) as e: + print(f"❌ Errore generazione: {e}") + return + + print(f"\n{response}\n") + + +def run_loop(collection: chromadb.Collection) -> None: + print("── Loop RAG ─────────────────────────────────────── (exit per uscire)\n") + while True: + try: + raw = input("Domanda: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nUscita.") + break + + if not raw: + continue + if raw.lower() == "exit": + break + + verbose = raw.endswith(" -v") + question = raw[:-3].strip() if verbose else raw + + answer(question, collection, verbose) + + +# ─── Entry point ────────────────────────────────────────────────────────────── + +def main() -> int: + parser = argparse.ArgumentParser(description="Step 9 — Pipeline RAG interattiva") + parser.add_argument("--stem", required=True, + help="Nome della collection ChromaDB (es. nietzsche)") + args = parser.parse_args() + + print("─── Step 9 — Pipeline RAG ────────────────────────────────────────────\n") + print(f" Documento : {args.stem}") + print(f" Modello : {LLM_MODEL}") + print(f" Top-K : {TOP_K}") + print(f" Thinking : {'off' if NO_THINK else 'on'}") + print() + + if not CHROMA_DIR.exists(): + print("❌ chroma_db/ non trovata — esegui prima step-8") + return 1 + + client = chromadb.PersistentClient(path=str(CHROMA_DIR)) + collections = [c.name for c in client.list_collections()] + if args.stem not in collections: + print(f"❌ Collection '{args.stem}' non trovata in chroma_db/") + print(f" → python step-8/ingest.py --stem {args.stem}") + return 1 + + collection = client.get_collection(args.stem) + print(f"✅ Collection '{args.stem}' caricata ({collection.count()} chunk)\n") + + run_loop(collection) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/step-9/test_ollama.py b/step-9/test_ollama.py new file mode 100644 index 0000000..8b683f1 --- /dev/null +++ b/step-9/test_ollama.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +""" +Test chat locale Ollama — senza RAG, senza ChromaDB. +Uso: python step-9/test_ollama.py +""" + +import json +import sys +import urllib.error +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import config as _cfg + +OLLAMA_URL = _cfg.OLLAMA_URL +MODEL = _cfg.OLLAMA_MODEL +TEMPERATURE = _cfg.TEMPERATURE +NO_THINK = _cfg.NO_THINK + + +def chat(prompt: str) -> str: + payload = json.dumps({ + "model": MODEL, + "prompt": prompt, + "stream": False, + "think": not NO_THINK, + "options": {"temperature": TEMPERATURE}, + }).encode() + req = urllib.request.Request( + f"{OLLAMA_URL}/api/generate", + data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=300) as resp: + return json.loads(resp.read())["response"].strip() + + +def main() -> int: + print(f"─── Chat Ollama ──────────────────────────────── (exit per uscire)") + print(f" Modello : {MODEL}") + print(f" Thinking : {'off' if NO_THINK else 'on'}") + print() + + while True: + try: + user = input("Tu: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nUscita.") + break + if not user: + continue + if user.lower() == "exit": + break + try: + reply = chat(user) + print(f"\nAssistente: {reply}\n") + except (urllib.error.URLError, OSError) as e: + print(f"❌ Errore: {e}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main())