In questo articolo

💥 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.

NaiveRagService.cs · C#
// 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

Tutta la pipeline naive: embed → retrieve → stuff → answer. Nessun punto di ritorno.

🤖 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.

Stessa domanda, due architetture

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.

AgentToolContext.cs · estratti C#
[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;
}
Estratti con elisioni esplicite: firme, nomi dei tool e valori restituiti coincidono con il repository.

🔁 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.

InfrastructureServiceExtensions.cs + AgenticRagService.cs · estratti C#
// 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

Il loop agentico è una riga: GetResponseAsync con i tool nelle opzioni.

🪵 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.

Esempio illustrativo di trace Serilog
[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)
Formato illustrativo dal README: search_docs riporta il conteggio, mentre il giudice può riportare il proprio breve verdetto.

📊 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.

Cosa esegue e salva il benchmark
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
Il benchmark produce telemetria confrontabile; la valutazione qualitativa va aggiunta separatamente.

⚖️ 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.

Quickstart
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/run

Repo completo su GitHub · fscamuzzi/rag-vs-agentic-rag

Docker compose, due modelli Ollama, seed: la pipeline gira tutta in locale.

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.