.NETC#RAGAI AgentsArchitecture

Agentic RAG in .NET: When One Retrieval Is Not Enough

Classic RAG runs one search and hopes. Agentic RAG lets the model decide what to look up, how often, and when it has enough — here is what that costs and how to build it in C#.

Agentic RAG in .NET: When One Retrieval Is Not Enough

Most retrieval-augmented generation in production looks like this: take the user’s question, embed it, run one vector search, paste the top five passages into the prompt, and send it. That pipeline is a genuinely good default. It is cheap, it is one round trip, and it answers the majority of questions people actually ask a documentation bot.

It also has a failure mode that is easy to miss during a demo and impossible to miss once real users arrive — and the fix has a name now.

What one-shot retrieval quietly assumes

The single-search pipeline carries three assumptions, and none of them are stated anywhere in the code.

That the question is one question. “How do I configure retries, and does that interact with our timeout policy?” is two lookups wearing one sentence. Embed it whole and you get a vector that sits somewhere between the two topics, which is frequently near neither.

That the answer lives in one place. Comparison questions — “what changed between v3 and v4” — need passages from two documents that a single similarity search has no reason to return together. The top five will usually come from whichever document happens to be more verbose.

That you can tell in advance how much context is enough. topK = 5 is a guess made at design time and applied identically to “what is the default port” and “walk me through the migration”.

None of that is an argument against one-shot retrieval. It is an argument for knowing which of your questions violate the assumptions, because that fraction is exactly the fraction that agentic retrieval is worth paying for.

The patterns, cheapest first

“Agentic RAG” covers at least three distinct designs with very different costs. Treat them as a ladder and stop climbing as soon as your evaluation stops improving.

1. Query decomposition

One model call turns the question into two to four focused subqueries, you run those in parallel, merge and deduplicate the results, then answer from the union.

This is the pattern with the best cost-to-benefit ratio by a wide margin. It adds one small model call and the subqueries run concurrently, so the added latency is roughly the slowest search rather than the sum. It directly fixes the compound-question and comparison cases, which between them account for most of what one-shot retrieval gets wrong.

// A small, cheap model is the right choice here - decomposition is a
// structural task, not a reasoning one.
private static readonly ChatOptions DecomposeOptions = new()
{
    ResponseFormat = ChatResponseFormat.ForJsonSchema<SubQueries>(),
    Temperature = 0,
};

public sealed record SubQueries(string[] Queries);

public async Task<string[]> DecomposeAsync(string question, CancellationToken ct)
{
    var response = await _cheapClient.GetResponseAsync(
        [
            new ChatMessage(ChatRole.System,
                """
                Split the user's question into the smallest set of independent
                search queries that together cover it. Return one query if the
                question is already atomic. Never return more than four.
                """),
            new ChatMessage(ChatRole.User, question),
        ],
        DecomposeOptions, ct);

    var parsed = JsonSerializer.Deserialize<SubQueries>(response.Text)!;

    // Trust the cap in code, not in the prompt. A model that ignores
    // "never more than four" should cost you nothing.
    return parsed.Queries.Take(4).ToArray();
}

Then fan out. The merge step matters more than it looks — the same passage will frequently rank highly for two subqueries, and paying for it twice in the context window is pure waste:

var subQueries = await DecomposeAsync(question, ct);

var results = await Task.WhenAll(
    subQueries.Select(q => _store.SearchAsync(q, topK: 4, ct)));

var passages = results
    .SelectMany(r => r)
    .DistinctBy(p => p.ChunkId)          // dedupe before ranking, not after
    .OrderByDescending(p => p.Score)
    .Take(8)
    .ToList();

Note that the final Take(8) is larger than the topK: 5 a one-shot pipeline would use, but smaller than the twelve to sixteen passages the fan-out produced. Decomposition is not a licence to stuff the window — it is a way to make the passages you do include more likely to be relevant.

2. Iterative retrieval

Retrieve, let the model judge whether it can answer, and if not let it issue a follow-up search informed by what it just read. Repeat under a hard cap.

This handles the genuinely multi-hop question — the one where you cannot know the second search until you have read the result of the first. “Which of our services still uses the deprecated auth library, and what is the migration path for that one?” needs the first answer before the second query exists.

It is also substantially more expensive than decomposition, and the expense is the bad kind: the rounds are sequential, so latency adds up rather than overlapping. Two rounds is usually a real improvement, three is occasionally, and beyond that you are almost always watching a model fail to find something that is not in the corpus.

// The cap is not a suggestion. Without it, a question whose answer simply
// is not in the index will happily consume your entire token budget
// searching for it.
const int MaxRounds = 3;

var gathered = new List<Passage>();
for (var round = 0; round < MaxRounds; round++)
{
    var hits = await _store.SearchAsync(currentQuery, topK: 5, ct);
    gathered.AddRange(hits.Where(h => gathered.All(g => g.ChunkId != h.ChunkId)));

    var verdict = await AssessAsync(question, gathered, ct);
    if (verdict.CanAnswer) break;

    if (verdict.NextQuery is null) break;   // the model gave up; so should you
    currentQuery = verdict.NextQuery;
}

Log the round count as a metric. A rising average is the clearest early signal you will get that your corpus has drifted away from what users are asking.

3. Retrieval as a tool

Rather than orchestrating the loop yourself, expose search as a function the model can call, and let the agent loop decide when and how often.

This is the least code and the least control, which is exactly the trade. It shines when retrieval is one capability among several — the agent can search the knowledge base, or query the database, or call an API, and the interesting decisions are about which. It is a poor fit when retrieval is the only thing the agent does, because you have handed the model a decision it did not need to make and pay for it on every request.

[Description("Search the internal knowledge base for passages relevant to a question.")]
public async Task<string> SearchKnowledgeBase(
    [Description("A focused search query. Prefer several specific searches over one broad one.")]
    string query,
    CancellationToken ct = default)
{
    var hits = await _store.SearchAsync(query, topK: 5, ct);
    return JsonSerializer.Serialize(hits.Select(h => new { h.Source, h.Text }));
}

The instruction in the parameter description is doing real work there. Models left to their own devices issue one broad query that looks a lot like the user’s original question, which puts you back where you started with extra steps.

Wiring it into Microsoft Agent Framework

Agent Framework has a first-class seam for this. Rather than manually stuffing passages into the prompt, you attach a context provider to the agent through the AIContextProviders option, and the framework injects retrieved context on each turn. TextSearchProvider is the supplied implementation that covers the common case.

var agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
    Instructions = "Answer strictly from the retrieved context. If it is not there, say so.",
    AIContextProviders = [new TextSearchProvider(vectorStore)],
});

The reason to prefer this over hand-rolled prompt assembly is that it keeps retrieval out of your prompt-building code, which means you can change retrieval strategy without touching the prompt and vice versa. Those two things change for different reasons and on different schedules, and coupling them is a decision you will regret about six weeks in.

That instruction line is not decoration either. Retrieval quality problems and hallucination problems look identical from a bug report, and an agent that is explicitly told to say when the context is insufficient tells you which one you have. There is more on that separation in testing and evaluating .NET agents.

Letting the search service do the planning

Azure AI Search added agentic retrieval as a service-side feature: you send the conversation, and it handles decomposition into subqueries, runs them in parallel, and returns a merged result already shaped for a chat model. Parts of it reached general availability in the 2026-04-01 REST API.

The case for it is straightforward. The planner is the part of agentic RAG that looks trivial in a prototype and turns into a maintenance surface in production — subquery quality, dedupe rules, ranking across heterogeneous results, and a steady trickle of “why did it search for that”. If you are on Azure AI Search already, letting the service own that is a real saving.

The case against is equally straightforward: it is a service-side decision you can no longer see into or unit-test locally, and it binds a core behaviour of your application to one vendor’s implementation. If your retrieval logic is a differentiator, keep it in your code where you can evaluate it.

Either way the decision is reversible, which is the useful part. Both sides of it produce the same thing — a set of passages — so keep that boundary clean and the swap stays cheap. Comparing vector databases for .NET covers the store-level side of that choice.

What it actually costs

Be honest about this before you build it, because the numbers are not small.

Decomposition costs one extra model call — use a cheap model, it is a structural task — plus two to four searches instead of one. Vector searches are usually a few milliseconds against a warm index, so the real cost is the model call. Call it 10-20% more per request, mostly latency.

Iteration costs one model call per round to assess, plus the searches, plus a context window that grows with every round. Three rounds against a corpus that does not contain the answer is roughly four times the cost of a one-shot answer that would have said “I do not know” immediately.

Tool-based retrieval costs whatever the model decides, which is the honest answer and the reason to bound it. Cap tool-call iterations in the agent configuration, not in the prompt.

Across all three, the number to watch is not cost per request but cost per answered request. A pipeline that costs 30% more and answers 20% more questions correctly is a good trade; one that costs 30% more and mostly retrieves harder before giving the same answer is not. Cost control for production agents covers the instrumentation that makes that measurable rather than a matter of opinion.

Do the cheap thing first

There is a strong temptation to reach for agentic retrieval when the real problem is upstream, and it is worth resisting because the upstream fixes are cheaper and compound.

Chunking. If your chunks split mid-procedure or bury headings, more queries just return more fragments of the same broken shape. Chunking strategies for .NET RAG is the higher-leverage fix, and it costs nothing at request time.

Hybrid search. Combining vector similarity with keyword matching fixes a large share of the cases people reach for agentic RAG to solve — particularly exact identifiers, error codes and product names, where embeddings are genuinely weak. One search, better results.

Metadata filtering. A great many “the model retrieved the wrong thing” reports are really “it retrieved the right thing from the wrong version of the docs”. A WHERE version = @v clause fixes that permanently, for free.

Work through those three before adding a planner. If retrieval is still failing afterwards, you now have a clean baseline to measure the planner against — and without that baseline you will not be able to tell whether it helped.

When not to build this at all

Your questions are homogeneous. An FAQ bot over 200 support articles gets asked one atomic question at a time. Decomposition on an atomic question returns one subquery and you have paid a model call to learn what you already knew.

Latency is the product. In an interactive assistant where people are watching a cursor, the second round trip is more expensive than a slightly worse answer. Ship one-shot, measure, revisit.

You cannot evaluate yet. Every pattern here is a trade of cost for quality, and if you cannot measure quality you cannot tell whether you made the trade or just spent the money. Build the evaluation set first — a hundred real questions with known-good answers is enough to make every decision on this page empirical instead of aesthetic.

Note: Agent Framework’s context-provider surface — AIContextProviders, TextSearchProvider and the options used to attach them — has moved between previews, and Azure AI Search’s agentic retrieval is versioned by REST API date. Verify the exact names and API version against the Agent Framework RAG documentation and the agentic retrieval overview before wiring anything up. The patterns — decompose, iterate under a cap, or expose retrieval as a tool — have outlived several API renames.

Takeaway

Agentic RAG is not an upgrade you apply to a RAG pipeline. It is a decision to move the “how much retrieval does this need” question from design time to runtime, and it is worth making only when your questions genuinely vary enough to justify the bill.

Start with query decomposition. It is one extra call, it runs in parallel, and it fixes the compound and comparison questions that break one-shot retrieval — which is most of what people mean when they say their RAG bot is unreliable. Add iteration only when you have a measured multi-hop failure it would solve, and cap it in code the first time you write it, not after the incident.


Have a correction or a topic you want covered? Email mani.bc72@gmail.com.

Frequently asked questions

Is agentic RAG just RAG with more steps?

Mechanically, yes — and that is the honest way to think about the cost. The difference is who decides how many steps to take. Classic RAG fixes the number at one, at design time. Agentic RAG moves that decision to runtime and lets the model spend more retrieval on a hard question than on an easy one. That is worth paying for only when your questions genuinely vary in difficulty.

How much slower is it?

Query decomposition with parallel subqueries usually adds one model round trip plus the slowest subquery, so a few hundred milliseconds to a couple of seconds. Iterative retrieval is far worse, because the loops are sequential by definition — budget two to four times the latency of a single-pass answer, and cap the loop count rather than hoping it converges.

Do I need Azure AI Search for this?

No. The patterns are independent of the store, and you can build decomposition and iteration over pgvector, Qdrant or an in-memory index. Azure AI Search agentic retrieval is worth looking at when you would otherwise write and maintain the planner yourself, not because the pattern requires it.

Will the model just retrieve forever?

It will if you let it. Every iterative retriever needs a hard cap on rounds and a total token budget enforced in code, not in the prompt. Treat a loop that hits the cap as a failed answer worth logging, because it usually means the corpus does not contain what was asked.

Does this fix bad chunking?

It hides bad chunking, which is worse. More queries against a badly chunked corpus return more mediocre passages. Fix retrieval quality at the chunk level first — the improvement is cheaper and it makes every downstream pattern work better.