In questo articolo
- 💥 La domanda che rompe la RAG classica
- 📏 RAG classica: la pipeline lineare
- 🤖 Agentic RAG: il retrieval lo guida l'LLM
- 🛠️ I tre tool: cerca, valuta, riformula
- 🔁 L'agent loop con Microsoft.Extensions.AI
- 🪵 Come leggere il trace di un run agentico
- 📊 Benchmark: stesse domande, due pipeline
- ⚖️ Quando usare cosa (tabella di decisione onesta)
- 🚀 Il repo: due endpoint, un docker compose
💥 La domanda che rompe la RAG classica
Il caso dimostrativo del repository è una domanda multi-hop: per rispondere servono due documenti diversi — la policy di reso del 2024 e quella del 2025 — e un confronto tra i due. La pipeline classica trasforma la domanda in un embedding e recupera i top-k chunk più simili: se la query «confronta le policy» somiglia più al documento 2025 che al 2024, una parte delle prove può non arrivare al modello.
Il problema non è il vector database, né il modello: è l'architettura. Una pipeline lineare non ha nessun punto in cui accorgersi che il retrieval è venuto male. Nessun retry, nessuna valutazione, nessuna seconda query. Quello che entra al primo colpo è tutto quello che il modello vedrà.
- Query singola: una domanda composita viene schiacciata in un solo embedding.
- Nessuna valutazione: nessuno controlla se i chunk recuperati bastano.
- Nessun retry: se il retrieval è scarso, la risposta è scarsa. Fine.
📏 RAG classica: la pipeline lineare
La pipeline naive è questa, senza fronzoli: embedding della domanda, una ricerca ibrida su Qdrant, chunk infilati nel prompt, risposta. Nel repo è un servizio da una trentina di righe di logica: è il bello — e il limite — di questo approccio.
La ricerca sotto è già ibrida (vettori densi + sparse BM25-style, fusi con Reciprocal Rank Fusion server-side su Qdrant): il retrieval di partenza è buono. Ma per quanto buono sia il singolo colpo, resta un colpo solo.
// 1. Retrieve — one shot, no second chances.
var chunks = await vectorSearchService
.HybridSearchAsync(request.Question, topK, ct);
// 2. Stuff everything into the prompt.
var context = string.Join("\n---\n",
chunks.Select(c => $"[{c.Source}#{c.ChunkIndex}]\n{c.Text}"));
List<ChatMessage> messages =
[
new(ChatRole.System,
"Answer using ONLY the context below. If the context is not " +
"enough to answer, say so explicitly. Cite sources as " +
$"[file#chunk].\n\nContext:\n{context}"),
new(ChatRole.User, request.Question)
];
// 3. Answer. Whatever was retrieved is all the model will ever see.
var response = await chatClient.GetResponseAsync(messages, cancellationToken: ct);Microsoft.Extensions.AI · Microsoft Learn ↗
🤖 Agentic RAG: il retrieval lo guida l'LLM
Nell'Agentic RAG il flusso può diventare iterativo: oltre alla domanda, il modello riceve tre tool e una strategia nel system prompt — spezzare la richiesta in query mirate, cercare, valutare se le prove bastano e, se necessario, riformulare. È il modello a scegliere se e quando invocarli.
La differenza concettuale è importante: la pipeline classica è lineare — input, retrieval, risposta. Quella agentica rende disponibile un loop con una valutazione esplicita. Ma il codice non garantisce che il modello esegua tutti i passaggi né che chiami il giudice prima di rispondere: sono istruzioni e tool, non un workflow deterministico.
RAG classica
- Una query, decisa dal codice
- Un retrieval, poi si risponde comunque
- Nessuna valutazione dei chunk
- Costo basso e latenza prevedibile
Agentic RAG
- Una o più query, proposte dall'LLM
- Retrieval ripetibile tramite tool calling
- Giudizio di sufficienza disponibile come tool
- Costo, latenza e qualità da misurare sul corpus
La naive è una linea retta; l'agentica è un ciclo con valutazione e retry.
🛠️ I tre tool: cerca, valuta, riformula
I tool sono metodi C# con un attributo Description: search_docs fa la ricerca ibrida su Qdrant, rerank_evaluate chiede a una chiamata LLM ausiliaria un verdetto — SUFFICIENT o INSUFFICIENT — sui chunk raccolti, refine_query riscrive una query che ha reso male in una-tre alternative. Nonostante il nome, rerank_evaluate non riordina i chunk: valuta solo la sufficienza; le source finali restano ordinate per score Qdrant.
Lo stato del run — chunk raccolti, trace dei passi, token spesi nelle chiamate ausiliarie — vive in un contesto per-richiesta che i tre tool condividono. Niente framework: solo una classe con tre metodi e la loro descrizione.
[Description("Search the company knowledge base with a focused query. " +
"Returns the most relevant document chunks. Call it multiple " +
"times with different queries for multi-part questions.")]
public async Task<string> SearchDocsAsync(
[Description("A short, focused search query")] string query,
[Description("How many chunks to retrieve")] int topK = 5,
CancellationToken ct = default)
{
var chunks = await vectorSearchService.HybridSearchAsync(query, topK, ct);
// ... dedup, costruzione del risultato e trace come nel repo
return result;
}
[Description("Evaluate whether the chunks retrieved so far are sufficient " +
"to fully answer the user's question. Returns a verdict " +
"(SUFFICIENT or INSUFFICIENT) with a short reason.")]
public async Task<string> RerankEvaluateAsync(
[Description("The original user question")] string question,
CancellationToken ct = default)
{
// ... costruzione dei messaggi e chiamata LLM come nel repo
return response.Text;
}
[Description("Rewrite a query that returned poor or incomplete results " +
"into up to three better search queries.")]
public async Task<string> RefineQueryAsync(
string originalQuery, string reason,
CancellationToken ct = default)
{
// ... chiamata LLM che produce da una a tre query alternative
return response.Text;
}🔁 L'agent loop con Microsoft.Extensions.AI
Il ciclo modello → tool → modello è gestito dal function invocation di Microsoft.Extensions.AI. Registro il client con UseFunctionInvocation e passo i tre tool nelle ChatOptions: il middleware continua mentre il modello produce function call, poi restituisce la risposta finale oppure si ferma al limite configurato.
Qui il limite è cinque iterazioni complessive, mentre il system prompt chiede al modello di ripetere il tentativo al massimo due volte. Il middleware non garantisce però che search_docs, rerank_evaluate e refine_query vengano chiamati, né che lo siano nell'ordine suggerito. Non serve un framework di agenti aggiuntivo, ma il comportamento resta guidato dal modello.
// In InfrastructureServiceExtensions, ramo Ollama:
services.AddChatClient(
serviceProvider => (IChatClient)new OllamaApiClient(
serviceProvider.GetRequiredService<IHttpClientFactory>()
.CreateClient(OllamaHttpClientName),
configuration["AI:Ollama:ChatModel"]!))
.UseFunctionInvocation(configure: c =>
c.MaximumIterationsPerRequest = maxIterations);
// Nel servizio agentico, per ogni richiesta:
var options = new ChatOptions
{
Tools =
[
AIFunctionFactory.Create(context.SearchDocsAsync, "search_docs"),
AIFunctionFactory.Create(context.RerankEvaluateAsync, "rerank_evaluate"),
AIFunctionFactory.Create(context.RefineQueryAsync, "refine_query")
]
};
// UseFunctionInvocation() runs the loop: model -> tool -> model -> ...
var response = await chatClient.GetResponseAsync(messages, options, ct);Function calling con Microsoft.Extensions.AI · Microsoft Learn ↗
🪵 Come leggere il trace di un run agentico
La demo registra ogni tool call su Serilog, la inserisce nel campo trace della risposta e persiste il run completato su MongoDB. Ogni voce contiene step, nome del tool, argomenti, una sintesi del risultato e durata. Per search_docs la sintesi conserva solo il conteggio dei chunk, non i loro testi; rerank_evaluate e refine_query possono invece conservare per intero le rispettive risposte brevi. L'esempio sotto illustra il formato documentato dal repository, non è l'output di un test end-to-end versionato.
Il trace aiuta a capire quale query è stata eseguita e quale verdetto ha prodotto la valutazione. In produzione, però, query, risposte e risultati possono contenere dati sensibili: vanno redatti o esclusi dai log e protetti con livelli, accessi e retention adeguati.
[10:42:01 INF] AGENT step 1 | search_docs(
query="return policy 2024" topK=5) -> 5 chunks (5 new) (38 ms)
[10:42:02 INF] AGENT step 2 | search_docs(
query="return policy 2025" topK=5) -> 5 chunks (4 new) (35 ms)
[10:42:05 INF] AGENT step 3 | rerank_evaluate(
question="Compare..." chunks=9) -> SUFFICIENT: both policies retrieved (2810 ms)📊 Benchmark: stesse domande, due pipeline
Nel repo, POST /benchmark/run legge sei domande — due semplici, due multi-hop, una tricky e una fuori dominio — e le esegue in sequenza prima sulla pipeline naive e poi su quella agentica. Il risultato sono dodici run individuali nella collection runs e un documento riepilogativo nella collection benchmarks, rileggibile con GET /benchmark/latest.
Per ogni esecuzione la demo raccoglie latenza, token in ingresso e uscita, chiamate LLM e tool call. Non include però ground truth, evaluator o accuracy: misura il comportamento operativo, non dimostra da solo che una risposta sia migliore. Per un confronto serio bisogna fissare modello, quantizzazione, hardware e dataset, ripetere i run e valutare anche correttezza e completezza.
6 domande × 2 pipeline = 12 run sequenziali
runs:
mode · question · answer · sources · trace · metrics
benchmarks:
ranAt · chatModel · 12 entries
metrics:
durationMs · inputTokens · outputTokens · llmCalls · toolCalls
qualità della risposta: non valutata automaticamente⚖️ Quando usare cosa (tabella di decisione onesta)
L'Agentic RAG non è un upgrade automatico: può richiedere più chiamate, token e latenza e aggiunge una superficie di errore nuova, perché il modello può saltare o usare male i tool. La domanda giusta non è «qual è il migliore?», è «questa domanda beneficia davvero del loop?».
La mia regola pratica: parto naive, definisco una valutazione di qualità e misuro. Passo all'agentico solo dove i risultati mostrano retrieval incompleto e un beneficio ripetibile. Un router davanti alle due pipeline è una possibile evoluzione, ma non è implementato nella demo: il repository offre due endpoint separati proprio per sperimentare sul proprio corpus.
- Resta naive se: domande puntuali su un solo documento, corpus piccolo e omogeneo, latenza e costo contano più della completezza.
- Valuta l'agentico se: domande multi-hop o comparative, più fonti da incrociare, retrieval incompleto misurato.
- Prerequisito: un modello che sappia davvero fare tool calling — è il motore del loop, non un optional.
- In produzione: router davanti, naive di default, agente a chiamata. E un cap sulle iterazioni, sempre.
🚀 Il repo: due endpoint, un docker compose
Tutto il codice è pubblico: .NET 9 Minimal API, Microsoft.Extensions.AI come astrazione LLM (Ollama in locale di default, OpenAI via configurazione), Qdrant per la ricerca ibrida, MongoDB per run e benchmark, dataset demo incluso. Docker Compose avvia Qdrant e MongoDB; Ollama gira separatamente sull'host. La chiamata manuale a POST /admin/seed ricrea la collection e reingerisce i sette documenti Markdown.
Il modo più istruttivo di usarlo: stessa domanda su tutti e due gli endpoint, poi confrontare trace e sources nelle due risposte. La differenza tra «ha recuperato quello che c'era» e «ha cercato quello che serviva» si vede meglio in un diff che in mille parole.
git clone https://github.com/fscamuzzi/rag-vs-agentic-rag && cd rag-vs-agentic-rag
docker compose up -d # Qdrant + MongoDB
ollama pull llama3.1:8b && ollama pull nomic-embed-text
dotnet run --project src/RagVsAgenticRag.Api
curl -X POST localhost:5210/admin/seed
curl -X POST localhost:5210/rag/naive -H 'Content-Type: application/json' \
-d '{"question":"Compare the 2024 and 2025 return policies"}'
curl -X POST localhost:5210/rag/agentic -H 'Content-Type: application/json' \
-d '{"question":"Compare the 2024 and 2025 return policies"}'
curl -X POST localhost:5210/benchmark/runRepo completo su GitHub · fscamuzzi/rag-vs-agentic-rag ↗
Domande frequenti su Agentic RAG
Che differenza c'è tra RAG e Agentic RAG?
Nella RAG classica il codice decide la query, recupera una volta e risponde. Nell'Agentic RAG è l'LLM a guidare il retrieval tramite tool: decide le query, valuta se i chunk bastano e itera con query riformulate quando non bastano.
L'Agentic RAG è sempre migliore della RAG classica?
No. Può aggiungere chiamate, latenza e token, e il benchmark della demo non misura automaticamente la qualità. Va valutata su domande multi-hop, comparative o multi-fonte con ground truth ed evaluator adatti al proprio corpus.
Serve un framework di agenti per fare Agentic RAG in .NET?
No: nella demo basta il function invocation di Microsoft.Extensions.AI. I tool sono metodi C# con attributo Description e UseFunctionInvocation continua il ciclo quando il modello emette function call, entro un limite configurabile. Non garantisce che tutti i tool vengano usati.
Quali modelli servono per far funzionare il loop agentico?
Serve un modello con tool calling affidabile per i tool e il prompt usati. La demo configura llama3.1:8b come default locale, ma non stabilisce una soglia universale: il comportamento va verificato sul modello, sulla quantizzazione e sulle domande reali.
Parliamone
Se questo tema ti riguarda, scrivimi: confrontarsi su codice e AI è sempre tempo speso bene.



