refactor: sposta rag.py, retrieve.py, config.py in cartella rag/
- rag/config.py, rag/rag.py, rag/retrieve.py (spostati da radice) - rag/__init__.py aggiunto per import come package - path aggiornati: project_root = Path(__file__).parent.parent - ollama/check_env.py: import da ingestion.config e rag.config - ollama/test_ollama.py: import da rag.config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# ─── Configurazione RAG ───────────────────────────────────────────────────────
|
||||
|
||||
# ── 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.2
|
||||
|
||||
# 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
|
||||
|
||||
# ── 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:4b"
|
||||
|
||||
# ── Prompt di sistema ─────────────────────────────────────────────────────────
|
||||
|
||||
# Istruzioni inviate al LLM prima del contesto e della domanda.
|
||||
# Mantieni le risposte ancorate al contesto per evitare allucinazioni.
|
||||
SYSTEM_PROMPT = (
|
||||
"Sei un assistente specializzato nell'analisi di documenti accademici. "
|
||||
"Rispondi sempre in italiano. "
|
||||
"Basati esclusivamente sui contesti numerati forniti: non aggiungere nozioni esterne, "
|
||||
"non inventare informazioni non presenti nel testo. "
|
||||
"Quando citi un risultato indica la sezione tra parentesi quadre usando il percorso "
|
||||
"fornito (es. [Sezione > Sottosezione]). "
|
||||
"Per spiegazioni strutturate usa passaggi numerati. "
|
||||
"Se il contesto non contiene informazioni sufficienti rispondi esattamente: "
|
||||
"\"Non trovo questa informazione nei documenti forniti.\""
|
||||
)
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pipeline RAG interattiva
|
||||
|
||||
Riceve una domanda, recupera i chunk più rilevanti da ChromaDB (retrieval)
|
||||
e genera una risposta tramite Ollama (generation).
|
||||
|
||||
Input: chroma_db/<stem> (collection ChromaDB)
|
||||
Output: risposta a schermo
|
||||
|
||||
Uso:
|
||||
python rag.py --stem <nome>
|
||||
|
||||
Nel loop interattivo:
|
||||
Domanda: <testo> → risposta
|
||||
Domanda: <testo> -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
_here = Path(__file__).parent
|
||||
project_root = _here.parent
|
||||
sys.path.insert(0, str(_here))
|
||||
sys.path.insert(0, str(project_root))
|
||||
import config as _cfg
|
||||
from ingestion.config import EMBED_MODEL
|
||||
|
||||
CHROMA_DIR = project_root / "chroma_db"
|
||||
|
||||
OLLAMA_URL = _cfg.OLLAMA_URL
|
||||
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, system: str = "") -> str:
|
||||
"""Chiama Ollama /api/generate e ritorna la risposta."""
|
||||
payload = json.dumps({
|
||||
"model": LLM_MODEL,
|
||||
"system": system,
|
||||
"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, source, header_path, 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,
|
||||
"source": meta.get("source", ""),
|
||||
"header_path": meta.get("header_path", ""),
|
||||
"distance": dist,
|
||||
})
|
||||
return chunks
|
||||
|
||||
|
||||
# ─── Prompt ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def build_prompt(question: str, chunks: list[dict]) -> str:
|
||||
"""Ritorna (system, user_prompt) separati per l'API Ollama."""
|
||||
context_parts = []
|
||||
for i, c in enumerate(chunks, start=1):
|
||||
header = f"[Contesto {i}"
|
||||
if c["header_path"]:
|
||||
header += f" — {c['header_path']}"
|
||||
header += "]"
|
||||
context_parts.append(f"{header}\n{c['text']}")
|
||||
|
||||
context = "\n\n".join(context_parts)
|
||||
user_prompt = f"{context}\n\nDomanda: {question}"
|
||||
return SYSTEM_PROMPT, user_prompt
|
||||
|
||||
|
||||
# ─── 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):
|
||||
sim = 1 - c["distance"]
|
||||
src = f"[{c['source']}] " if c.get("source") else ""
|
||||
loc = c["header_path"] or ""
|
||||
print(f" [{i}] {src}{loc} (similarità: {sim:.3f})")
|
||||
print(f" {c['text'][:120].replace(chr(10), ' ')}...")
|
||||
print("──────────────────────────────────────────────────────────────\n")
|
||||
|
||||
system, prompt = build_prompt(question, chunks)
|
||||
|
||||
try:
|
||||
response = call_ollama(prompt, system=system)
|
||||
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 _build_epilog() -> str:
|
||||
lines = [
|
||||
"Uso:",
|
||||
" python rag.py --stem <nome>",
|
||||
"",
|
||||
"Loop interattivo:",
|
||||
" <domanda> risposta basata sul documento",
|
||||
" <domanda> -v risposta + chunk recuperati con score di similarità",
|
||||
" 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 ingestion/ingest.py"]
|
||||
except Exception:
|
||||
pass
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Pipeline RAG interattiva\n\n"
|
||||
"Risponde a domande in linguaggio naturale su un documento\n"
|
||||
"indicizzato in ChromaDB da ingestion/ingest.py."
|
||||
),
|
||||
epilog=_build_epilog(),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stem",
|
||||
help="Collection di un singolo documento (retrocompatibile)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--collection",
|
||||
help="Collection multi-documento creata con: ingest.py --collection <nome> --stems ...",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
collection_name = args.collection or args.stem
|
||||
if not collection_name:
|
||||
parser.error("specifica --stem <nome> oppure --collection <nome>")
|
||||
|
||||
print("─── Pipeline RAG ────────────────────────────────────────────\n")
|
||||
print(f" Collection : {collection_name}")
|
||||
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 ingestion")
|
||||
return 1
|
||||
|
||||
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
||||
collections = [c.name for c in client.list_collections()]
|
||||
if collection_name not in collections:
|
||||
print(f"❌ Collection '{collection_name}' non trovata in chroma_db/")
|
||||
if args.stem:
|
||||
print(f" → python ingestion/ingest.py --stem {collection_name}")
|
||||
else:
|
||||
print(f" → python ingestion/ingest.py --collection {collection_name} --stems doc1 doc2 ...")
|
||||
return 1
|
||||
|
||||
collection = client.get_collection(collection_name)
|
||||
print(f"✅ Collection '{collection_name}' caricata ({collection.count()} chunk)\n")
|
||||
|
||||
run_loop(collection)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
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/<stem> (collection ChromaDB)
|
||||
Output: lista chunk con score di similarità
|
||||
|
||||
Uso:
|
||||
python retrieve.py --stem <nome>
|
||||
|
||||
Nel loop interattivo:
|
||||
Query: <testo> → chunk più simili con score
|
||||
Query: <testo> -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
_here = Path(__file__).parent
|
||||
project_root = _here.parent
|
||||
sys.path.insert(0, str(_here))
|
||||
sys.path.insert(0, str(project_root))
|
||||
import config as _cfg
|
||||
from ingestion.config import EMBED_MODEL
|
||||
|
||||
CHROMA_DIR = project_root / "chroma_db"
|
||||
|
||||
OLLAMA_URL = _cfg.OLLAMA_URL
|
||||
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, source, header_path, 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),
|
||||
"source": meta.get("source", ""),
|
||||
"header_path": meta.get("header_path", ""),
|
||||
"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:
|
||||
src = f"[{c['source']}] " if c.get("source") else ""
|
||||
loc = c["header_path"] or ""
|
||||
print(f" [{c['rank']}] similarità: {c['similarity']:.4f} | {src}{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 retrieve.py --stem <nome>",
|
||||
"",
|
||||
"Nel loop interattivo:",
|
||||
" <query> chunk più simili con score (testo troncato)",
|
||||
" <query> -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 ingestion/ingest.py"]
|
||||
except Exception:
|
||||
pass
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"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",
|
||||
help="Collection di un singolo documento (retrocompatibile)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--collection",
|
||||
help="Collection multi-documento creata con: ingest.py --collection <nome> --stems ...",
|
||||
)
|
||||
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()
|
||||
|
||||
collection_name = args.collection or args.stem
|
||||
if not collection_name:
|
||||
parser.error("specifica --stem <nome> oppure --collection <nome>")
|
||||
|
||||
print("─── Retrieval puro ──────────────────────────────────────────\n")
|
||||
print(f" Collection : {collection_name}")
|
||||
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 ingestion", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
client = chromadb.PersistentClient(path=str(CHROMA_DIR))
|
||||
collections = [c.name for c in client.list_collections()]
|
||||
if collection_name not in collections:
|
||||
print(f"❌ Collection '{collection_name}' non trovata in chroma_db/", file=sys.stderr)
|
||||
if args.stem:
|
||||
print(f" → python ingestion/ingest.py --stem {collection_name}", file=sys.stderr)
|
||||
else:
|
||||
print(f" → python ingestion/ingest.py --collection {collection_name} --stems doc1 doc2 ...", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
collection = client.get_collection(collection_name)
|
||||
print(f"✅ Collection '{collection_name}' caricata ({collection.count()} chunk)\n")
|
||||
|
||||
run_loop(collection, args.top_k)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user