diff --git a/step-9/README.md b/step-9/README.md index 3f8e38e..48fd47d 100644 --- a/step-9/README.md +++ b/step-9/README.md @@ -1,43 +1,30 @@ -# Step 9 — Pipeline RAG +# Step 9 — Interrogazione del documento -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. +Due modalità di interrogazione, entrambe con loop interattivo: + +| Script | Modalità | Quando usarlo | +|---|---|---| +| `rag.py` | Retrieval + generazione LLM | Risposta in linguaggio naturale | +| `retrieve.py` | Solo retrieval (no LLM) | Debug, verifica chunk, ricerca semantica | --- ## Prerequisiti - Step 8 completato (`chroma_db/` popolata) -- Ollama attivo con il modello di embedding e il modello LLM scaricati +- Ollama attivo con il modello di embedding scaricato +- Per `rag.py`: anche il modello LLM scaricato --- -## Avvio +## rag.py — Risposta in linguaggio naturale ```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 +Per ogni domanda: vettorizza la query, recupera i chunk più rilevanti da ChromaDB e genera la risposta tramite Ollama. ``` ── Loop RAG ─────────────────────────────────────── (exit per uscire) @@ -51,9 +38,7 @@ Domanda: | ` -v` | Risposta + chunk recuperati con score di similarità | | `exit` | Esce dal programma | ---- - -## Flusso interno +Flusso interno: ``` domanda @@ -74,6 +59,45 @@ Il LLM risponde esclusivamente dal contesto fornito. Se il contesto è irrilevan --- +## retrieve.py — Retrieval puro (senza LLM) + +```bash +source .venv/bin/activate +python step-9/retrieve.py --stem +``` + +Vettorizza la query e restituisce i chunk più simili con score di similarità — senza chiamare Ollama per la generation. Utile per verificare la qualità del retrieval e diagnosticare risposte sbagliate. + +``` +── Loop retrieval ──────────────────────── (exit per uscire, -f per testo completo) + +Query: +``` + +| Sintassi | Comportamento | +|---|---| +| `` | Chunk più simili con score di similarità (testo troncato a 200 car.) | +| ` -f` | Chunk più simili con testo completo | +| `exit` | Esce dal programma | + +Accetta `--top-k N` per sovrascrivere il valore di `config.py` per quella sessione. + +--- + +## 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 | + +--- + ## Test senza RAG Per verificare che Ollama risponda correttamente prima di interrogare il documento: diff --git a/step-9/retrieve.py b/step-9/retrieve.py new file mode 100644 index 0000000..aad0d63 --- /dev/null +++ b/step-9/retrieve.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +Step 9 — Retrieval puro (senza generazione LLM) + +Loop interattivo: inserisci una query, ottieni i chunk più simili dalla +collection ChromaDB tramite embedding semantico — senza chiamare Ollama +per la generation. + +Utile per: + - verificare la qualità del retrieval prima di diagnosticare risposte sbagliate + - controllare che i chunk giusti vengano recuperati per una query + - usare la pipeline come motore di ricerca semantica + +Input: chroma_db/ (collection ChromaDB) +Output: lista chunk con score di similarità + +Uso: + python step-9/retrieve.py --stem + +Nel loop interattivo: + Query: → chunk più simili con score + Query: -f → testo completo dei chunk + Query: 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 +TOP_K = _cfg.TOP_K + + +# ─── Embedding ──────────────────────────────────────────────────────────────── + +def embed(text: str) -> list[float]: + """Genera il vettore della query 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"] + + +# ─── Retrieval ──────────────────────────────────────────────────────────────── + +def retrieve(collection: chromadb.Collection, query: str, top_k: int) -> list[dict]: + """ + Genera l'embedding della query e recupera i top_k chunk più simili. + Ritorna lista di dict con chiavi: rank, similarity, sezione, titolo, text. + """ + vector = embed(query) + results = collection.query( + query_embeddings=[vector], + n_results=top_k, + include=["documents", "metadatas", "distances"], + ) + chunks = [] + for rank, (text, meta, dist) in enumerate( + zip( + results["documents"][0], + results["metadatas"][0], + results["distances"][0], + ), + start=1, + ): + chunks.append({ + "rank": rank, + "similarity": round(1 - dist, 4), + "sezione": meta.get("sezione", ""), + "titolo": meta.get("titolo", ""), + "text": text, + }) + return chunks + + +# ─── Output ─────────────────────────────────────────────────────────────────── + +def print_results(chunks: list[dict], full: bool = False) -> None: + print(f"── {len(chunks)} chunk recuperati ─────────────────────────────────\n") + for c in chunks: + loc = c["sezione"] + if c["titolo"]: + loc += f" > {c['titolo']}" + print(f" [{c['rank']}] similarità: {c['similarity']:.4f} | {loc}") + if full: + print() + print(c["text"]) + else: + print(f" {c['text'][:200].replace(chr(10), ' ')}") + if len(c["text"]) > 200: + print(f" … ({len(c['text'])} caratteri totali)") + print() + + +# ─── Loop interattivo ───────────────────────────────────────────────────────── + +def run_loop(collection: chromadb.Collection, top_k: int) -> None: + print("── Loop retrieval ──────────────────────── (exit per uscire, -f per testo completo)\n") + while True: + try: + raw = input("Query: ").strip() + except (EOFError, KeyboardInterrupt): + print("\nUscita.") + break + + if not raw: + continue + if raw.lower() == "exit": + break + + full = raw.endswith(" -f") + query = raw[:-3].strip() if full else raw + + try: + chunks = retrieve(collection, query, top_k) + except (urllib.error.URLError, OSError) as e: + print(f"❌ Errore embedding (Ollama raggiungibile?): {e}\n") + continue + + print() + print_results(chunks, full=full) + + +# ─── Entry point ────────────────────────────────────────────────────────────── + +def _build_epilog() -> str: + lines = [ + "Uso:", + " python step-9/retrieve.py --stem ", + "", + "Nel loop interattivo:", + " chunk più simili con score (testo troncato)", + " -f testo completo dei chunk", + " exit termina", + ] + if CHROMA_DIR.exists(): + try: + client = chromadb.PersistentClient(path=str(CHROMA_DIR)) + names = [c.name for c in client.list_collections()] + if names: + lines += ["", f"Collection disponibili: {', '.join(names)}"] + else: + lines += ["", "Nessuna collection trovata — eseguire prima: python step-8/ingest.py"] + except Exception: + pass + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Step 9 — Retrieval puro (senza LLM)\n\n" + "Loop interattivo: inserisci una query e ottieni i chunk più simili\n" + "tramite embedding semantico, senza generazione LLM." + ), + epilog=_build_epilog(), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--stem", + required=True, + help="Nome della collection ChromaDB da interrogare.", + ) + parser.add_argument( + "--top-k", + type=int, + default=TOP_K, + metavar="N", + help=f"Numero di chunk da restituire per query (default: {TOP_K} da config.py).", + ) + args = parser.parse_args() + + print("─── Step 9 — Retrieval puro ──────────────────────────────────────────\n") + print(f" Documento : {args.stem}") + print(f" Embed model : {EMBED_MODEL}") + print(f" Top-K : {args.top_k}") + print() + + if not CHROMA_DIR.exists(): + print("❌ chroma_db/ non trovata — esegui prima step-8", file=sys.stderr) + 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/", file=sys.stderr) + print(f" → python step-8/ingest.py --stem {args.stem}", file=sys.stderr) + return 1 + + collection = client.get_collection(args.stem) + print(f"✅ Collection '{args.stem}' caricata ({collection.count()} chunk)\n") + + run_loop(collection, args.top_k) + return 0 + + +if __name__ == "__main__": + sys.exit(main())