Puntata 246
Puntata 246 — TEST: agente RAG sui tuoi PDF
Livello: 🧙 Maestro Yoda · Capitolo 22 · Agenti, tool use, RAG
Hai letto 11 puntate di teoria su agenti, RAG, embedding, chunking, vector DB, tool use. Adesso le metti insieme in un mini-progetto end-to-end: un agente che risponde a domande sui PDF che gli dai, con citazioni. 200 righe di Python, una sera, e hai capito davvero come si combinano le parti.
Cosa costruirai
Un agente RAG conversazionale che:
- Indicizza una cartella di PDF tuoi.
- Risponde a domande in italiano basandosi solo su quei PDF.
- Cita la pagina e il documento di ogni affermazione.
- Si rifiuta di rispondere se l’informazione non c’è.
- Supporta follow-up question (memoria di conversazione).
Stack: Python + LlamaIndex + Qdrant locale + bge-m3 (embedding open) + Claude/GPT o Ollama locale.
Tempo stimato: 60-90 minuti se hai le dipendenze già pronte; 2-3 ore from scratch.
Costo: 0-2 € (a seconda del modello).
Setup
Requisiti
- Python 3.11+
- Una cartella con 5-20 PDF (manuali, contratti, paper — la dimensione su cui vuoi fare RAG).
- API key di Anthropic o OpenAI (a scelta), OPPURE Ollama installato localmente.
- Docker (per Qdrant locale).
Installa dipendenze
pip install llama-index llama-index-vector-stores-qdrant \
llama-index-embeddings-huggingface llama-index-llms-anthropic \
llama-index-readers-file qdrant-client pypdf sentence-transformers
Avvia Qdrant
docker run -p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant
Dashboard: http://localhost:6333/dashboard.
Step 1 — Ingestion (indexing pipeline)
indexing.py:
import os
from llama_index.core import SimpleDirectoryReader, Settings, StorageContext, VectorStoreIndex
from llama_index.core.node_parser import MarkdownNodeParser, SentenceSplitter
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.vector_stores.qdrant import QdrantVectorStore
from qdrant_client import QdrantClient
PDF_DIR = "./pdfs"
COLLECTION = "mio-rag"
# Embedding: bge-m3 open, multilingua, ottimo italiano
Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-m3")
# Splitter: 500 token con overlap 50
Settings.node_parser = SentenceSplitter(chunk_size=500, chunk_overlap=50)
# Load PDFs
docs = SimpleDirectoryReader(
input_dir=PDF_DIR,
recursive=True,
file_metadata=lambda fp: {"file_name": os.path.basename(fp)}
).load_data()
print(f"Caricati {len(docs)} document chunks")
# Qdrant store
client = QdrantClient(host="localhost", port=6333)
vector_store = QdrantVectorStore(
client=client,
collection_name=COLLECTION,
)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
# Build index (embed + insert)
index = VectorStoreIndex.from_documents(
docs,
storage_context=storage_context,
show_progress=True,
)
print(f"Indicizzato. Collection '{COLLECTION}' su Qdrant.")
Lancia: python indexing.py.
Tempo: 1-5 min per 20 PDF (~500 pagine totali). Più volte: skippa indexing (i vettori restano in Qdrant).
Step 2 — Retrieval + LLM (query engine)
query.py:
from llama_index.core import VectorStoreIndex, StorageContext, Settings
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.embeddings.huggingface import HuggingFaceEmbedding
from llama_index.llms.anthropic import Anthropic
from llama_index.vector_stores.qdrant import QdrantVectorStore
from qdrant_client import QdrantClient
COLLECTION = "mio-rag"
# Stesso embedding model dell'indexing
Settings.embed_model = HuggingFaceEmbedding(model_name="BAAI/bge-m3")
# LLM: Claude (alternativa: OpenAI / Ollama)
Settings.llm = Anthropic(model="claude-sonnet-4", max_tokens=2048)
# Riconnetti al vector store
client = QdrantClient(host="localhost", port=6333)
vector_store = QdrantVectorStore(client=client, collection_name=COLLECTION)
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex.from_vector_store(vector_store, storage_context=storage_context)
# Chat engine con memory + citation
chat_engine = index.as_chat_engine(
chat_mode="condense_plus_context",
memory=ChatMemoryBuffer.from_defaults(token_limit=4000),
similarity_top_k=5,
system_prompt=(
"Sei un assistente che risponde SOLO basandosi sui documenti forniti. "
"Cita SEMPRE [file_name:page] per ogni affermazione. "
"Se l'informazione NON è nei documenti, rispondi: "
"'Questa informazione non è presente nei documenti forniti.' "
"Rispondi in italiano. Sii conciso (max 200 parole)."
),
)
print("Chat pronta. Scrivi una domanda (Ctrl+C per uscire).\n")
while True:
try:
q = input("> ")
if not q.strip():
continue
response = chat_engine.chat(q)
print(f"\n{response}\n")
# Mostra source nodes (per debug)
for i, sn in enumerate(response.source_nodes, 1):
file = sn.metadata.get("file_name", "?")
page = sn.metadata.get("page_label", "?")
score = sn.score or 0
print(f" [{i}] {file} p.{page} (sim={score:.3f})")
print()
except KeyboardInterrupt:
print("\nCiao.")
break
Lancia: python query.py.
Esempi di domanda:
- “Cosa dice il manuale sulla manutenzione del filtro X?”
- “Quali sono le clausole di recesso del contratto Y?”
- “Spiegami in 3 punti il metodo descritto nel paper Z.”
- Follow-up: “E le complicazioni?”
Step 3 — Migliorie graduali
Una volta che funziona basic, prova questi upgrade in ordine.
3.1 — Hybrid search (dense + sparse)
Qdrant supporta sparse vectors. Aggiungi BM25 lexical:
vector_store = QdrantVectorStore(
client=client,
collection_name=COLLECTION,
enable_hybrid=True,
fastembed_sparse_model="Qdrant/bm25",
)
Re-indicizza. Atteso: +5-15% recall.
3.2 — Reranker
Aggiungi un reranker open (bge-reranker):
from llama_index.core.postprocessor import SentenceTransformerRerank
reranker = SentenceTransformerRerank(
model="BAAI/bge-reranker-v2-m3",
top_n=3, # top-3 dopo rerank
)
chat_engine = index.as_chat_engine(
chat_mode="condense_plus_context",
similarity_top_k=20, # retrieva 20, rerank a 3
node_postprocessors=[reranker],
# ...
)
Atteso: +5-10% answer quality, +200-500 ms latency.
3.3 — Better PDF parsing
Se i tuoi PDF hanno tabelle, footnote, formule complesse:
pip install docling
# usa Docling come parser
from llama_index.readers.docling import DoclingReader
docs = DoclingReader().load_data(file=Path("path/to/doc.pdf"))
Atteso: chunk migliori per PDF strutturati, recall significativamente migliore su tabelle.
3.4 — Modello LLM alternativo
Ollama locale (gratis, no API):
from llama_index.llms.ollama import Ollama
Settings.llm = Ollama(model="qwen2.5:7b-instruct", request_timeout=120)
OpenAI:
from llama_index.llms.openai import OpenAI
Settings.llm = OpenAI(model="gpt-5", api_key=os.environ["OPENAI_API_KEY"])
Confronta quality + cost + latency sui tuoi 10 query.
3.5 — Conversational memory tuning
Default token_limit=4000 può essere troppo poco per chat lunghe. Aumenta a 16k+ se hai budget context.
3.6 — Agentic RAG
Trasforma il query engine in agente con tool:
from llama_index.core.agent import ReActAgent
from llama_index.core.tools import QueryEngineTool
retrieval_tool = QueryEngineTool.from_defaults(
query_engine=index.as_query_engine(similarity_top_k=5),
name="documenti",
description="Strumento per cercare informazioni nei documenti aziendali. "
"Usa quando la domanda riguarda manuali, contratti, paper indicizzati."
)
agent = ReActAgent.from_tools([retrieval_tool], llm=Settings.llm, verbose=True)
response = agent.chat("Confronta cosa dicono i due manuali X e Y sulla procedura Z")
L’agente decide quante volte chiamare il retrieval e con che query. Migliore per domande complesse multi-hop.
Step 4 — Eval
Costruisci un eval set di 20-30 domande con risposte gold:
eval.py:
import json
from llama_index.core.evaluation import (
FaithfulnessEvaluator, RelevancyEvaluator, CorrectnessEvaluator
)
eval_set = json.load(open("eval_set.json"))
# Format: [{"question": "...", "expected": "..."}, ...]
faithfulness = FaithfulnessEvaluator(llm=Settings.llm)
relevancy = RelevancyEvaluator(llm=Settings.llm)
correctness = CorrectnessEvaluator(llm=Settings.llm)
results = []
for case in eval_set:
response = chat_engine.chat(case["question"])
f = faithfulness.evaluate_response(response=response)
r = relevancy.evaluate_response(query=case["question"], response=response)
c = correctness.evaluate(
query=case["question"],
response=str(response),
reference=case["expected"]
)
results.append({
"q": case["question"],
"faithful": f.passing,
"relevant": r.passing,
"correctness": c.score
})
passed_f = sum(r["faithful"] for r in results) / len(results)
passed_r = sum(r["relevant"] for r in results) / len(results)
avg_c = sum(r["correctness"] for r in results) / len(results)
print(f"Faithfulness: {passed_f:.1%}")
print(f"Relevancy: {passed_r:.1%}")
print(f"Correctness avg: {avg_c:.2f}/5")
Target ragionevoli per un primo MVP:
- Faithfulness >85% (la risposta è supportata dai chunk).
- Relevancy >85% (i chunk sono rilevanti alla query).
- Correctness >3.5/5 (la risposta è corretta vs gold).
Sotto questi → debug: chunking? embedding? retrieval? prompt?
Step 5 — UI (opzionale)
Aggiungi Gradio per chat UI in 20 righe:
import gradio as gr
def chat_fn(message, history):
response = chat_engine.chat(message)
sources = "\n\nFonti:\n" + "\n".join(
f"- {sn.metadata.get('file_name','?')} p.{sn.metadata.get('page_label','?')}"
for sn in response.source_nodes[:3]
)
return str(response) + sources
gr.ChatInterface(chat_fn, title="Mio RAG").launch()
Apri http://localhost:7860 → chat web pronta.
Errori comuni (e fix)
a) “No relevant context found” su domande ovvie
- Probabilmente chunking sbagliato (chunk troppo grossi). Riprova con
chunk_size=300. - Embedding model sbagliato per la lingua. Verifica che sia multilingua.
b) Cita pagina sbagliata
- Parser PDF non estrae bene il page number. Prova
pypdfvspymupdfvsdocling.
c) Risposta inventata (no faithfulness)
- System prompt non abbastanza strict. Aggiungi: “Se l’informazione non è ESATTAMENTE nei chunk forniti, dì che non lo sai.”
- Modello small o vecchio. Prova Claude/GPT.
d) Risposta troppo lunga / verbose
- Aggiungi al system prompt: “Massimo 150 parole. Concise, no preamble.”
e) Re-indexing ogni volta
- I vettori restano in Qdrant. Salta
from_documents, usafrom_vector_store.
f) Out of memory durante indexing
- Riduci batch size dell’embedding model:
embed_batch_size=4inHuggingFaceEmbedding.
Cosa hai dimostrato
Completando questo TEST hai:
- Costruito una pipeline RAG completa end-to-end (puntate 238-241).
- Usato un vector DB (puntata 239) — Qdrant locale.
- Scelto e usato un embedding model (puntata 240) — bge-m3.
- Implementato chunking ragionato (puntata 241).
- Aggiunto memory (puntata 235) — conversazione multi-turn.
- Citato sources — pattern audit-grade.
- Trasformato in agente (puntate 235-236) — ReAct + tool.
- Valutato il sistema (puntata 245) — faithfulness, relevancy, correctness.
Costo totale: <€2 di API o €0 con Ollama. Tempo: una serata.
Sostituibile con: 100% open stack (Ollama llama3.2 + bge-m3 + Qdrant locale).
Cosa puoi farci da qui
Estensioni naturali per progetti reali:
- Multi-tenant: collection separate per cliente, con auth.
- Doc updates: cron job che monitora cartella PDF, re-indicizza i nuovi.
- Audit log: salva ogni query + risposta + sources in DB per compliance.
- API REST: wrappa con FastAPI per esporre come servizio.
- MCP server (puntata 242): esponi
query_docscome tool standardizzato. - Multi-agent (puntata 243): retrieval agent + reasoning agent + citation checker.
Diploma del Capitolo 22
Se hai completato tutte le 12 puntate (235-246):
- Capisci cos’è un agente AI (235).
- Padroneggi tool use / function calling (236).
- Sai usare ReAct e i suoi successori (237).
- Sai progettare RAG (238).
- Hai scelto consapevolmente un vector DB (239).
- Sai scegliere embedding (240) e chunking (241).
- Capisci MCP e quando usarlo (242).
- Sai quando multi-agent funziona davvero (243).
- Hai una posizione informata su browser/desktop agents (244).
- Sai valutare e monitorare agenti (245).
- Hai costruito un mini-progetto end-to-end (246).
Questo set ti permette di costruire agenti AI production-grade, scegliere tecnologie con criterio, e diffidare di marketing che sopravvende l’autonomia degli agenti nel 2026.
Glossario lampo
- Chat engine (LlamaIndex) — wrapper con memory + retrieval + LLM, gestisce condense question e follow-up.
- Condense plus context — pattern in cui le follow-up question vengono “condensate” in una standalone question prima del retrieval.
- Source nodes — chunk recuperati e usati per la risposta, esposti per citazioni e debug.
- Faithfulness — metrica di eval RAG: la risposta è supportata dai chunk forniti?
- Relevancy — metrica di eval RAG: i chunk recuperati sono rilevanti alla query?
Take-away
In una serata hai costruito un agente RAG production-grade: indicizza i tuoi PDF, risponde con citazioni, fa fallback su “non lo so”, supporta chat conversazionale, e si può valutare con metriche standard. Senza nessuna magia: solo composizione disciplinata di componenti open. Questo è lo skill che separa “uso ChatGPT” da “costruisco AI per la mia azienda”. Il prossimo capitolo (23) entra in sicurezza e adversarial: la parte oscura degli agenti che tu, ora, sei in grado di costruire.
➡️ Prossima puntata: prompt injection — l’SQL injection dell’AI (apre Capitolo 23).