🧙 Maestro Yoda Cap. 22 · Agenti, tool use, RAG

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.

una scrivania con un pila di PDF (manuali, contratti, paper) e un piccolo robot che li sfoglia, evidenzia righe con un highlighter, e produce una risposta. In sovrimpressione: “INPUT: 50 PDF · OUTPUT: risposta con cite [pdf:3, p.12] in 4 sec”

Cosa costruirai

Un agente RAG conversazionale che:

  1. Indicizza una cartella di PDF tuoi.
  2. Risponde a domande in italiano basandosi solo su quei PDF.
  3. Cita la pagina e il documento di ogni affermazione.
  4. Si rifiuta di rispondere se l’informazione non c’è.
  5. 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 pypdf vs pymupdf vs docling.

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, usa from_vector_store.

f) Out of memory durante indexing

  • Riduci batch size dell’embedding model: embed_batch_size=4 in HuggingFaceEmbedding.

Cosa hai dimostrato

Completando questo TEST hai:

  1. Costruito una pipeline RAG completa end-to-end (puntate 238-241).
  2. Usato un vector DB (puntata 239) — Qdrant locale.
  3. Scelto e usato un embedding model (puntata 240) — bge-m3.
  4. Implementato chunking ragionato (puntata 241).
  5. Aggiunto memory (puntata 235) — conversazione multi-turn.
  6. Citato sources — pattern audit-grade.
  7. Trasformato in agente (puntate 235-236) — ReAct + tool.
  8. 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:

  1. Multi-tenant: collection separate per cliente, con auth.
  2. Doc updates: cron job che monitora cartella PDF, re-indicizza i nuovi.
  3. Audit log: salva ogni query + risposta + sources in DB per compliance.
  4. API REST: wrappa con FastAPI per esporre come servizio.
  5. MCP server (puntata 242): esponi query_docs come tool standardizzato.
  6. 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).