Companion repo to the Cloud Perspectives post "Five RAG Architectures in Real Azure Code." Five RAG patterns (Hybrid, GraphRAG, Agentic, Corrective, Multimodal) built and measured against one shared corpus and one shared eval harness, so the comparison in the post is a measured table instead of an argument.
Shared infra, corpus, and eval harness are scaffolded and offline-validated.
None of the five patterns are implemented yet, each patterns/<nn>-<name>/adapter.py
is a stub that documents what it needs to do and raises NotImplementedError
until it does it. python eval/harness.py already runs end to end against all
five stubs and reports not_implemented, which is the point: the harness,
scoring, and reporting shape are locked in before any pattern-specific code
gets written.
The infra has not been deployed. This sandbox has no Azure credentials and no
az/bicep CLI beyond a standalone Bicep binary pulled just for offline
validation, so every .bicep file here has passed bicep build (see below)
but has not been provisioned against a live subscription. Treat resource names,
API versions, and the APIM policy XML as reviewed-but-unverified until a real
azd up confirms them, the same workflow used on the other repos in this
series.
five-rag-patterns/
infra/ # shared Azure resources, one azd environment for all five patterns
corpus/ # the fictional Zorgverzekeraar Meridiaan corpus, see corpus/README.md
eval/ # shared query set, scoring, and the harness that runs both
patterns/
01-hybrid-rag/
02-graphrag/
03-agentic-rag/
04-corrective-rag/
05-multimodal-rag/
Earlier multi-pattern repos in this series (see azure-architecture-patterns)
gave each pattern its own independent Bicep, because those patterns were
genuinely unrelated architectures. Here the whole point is a fair comparison:
all five patterns retrieve from the same Azure AI Search service, generate with
the same Azure OpenAI deployments, and get measured by the same harness. One
azd up provisions all of it, and the comparison table in the post reads
"same substrate, different pattern," not "different substrate, so who knows."
| Resource | Used by | Notes |
|---|---|---|
| Azure AI Search (Standard, semantic ranking on) | Hybrid, Corrective, Multimodal | Hybrid query type does RRF fusion natively |
Azure OpenAI: text-embedding-3-large, gpt-4o-mini, gpt-4o |
All five | gpt-4o is the vision-capable deployment, used by Agentic's planner and Multimodal's captioning step |
| Azure AI Document Intelligence | Multimodal | Structured table and form extraction before anything gets embedded |
| Cosmos DB for NoSQL, local auth disabled | GraphRAG, Multimodal | graph_entities, graph_edges, multimodal_assets containers, data-plane RBAC only |
| Storage account (Blob) | Multimodal, corpus loading | corpus and multimodal containers |
| APIM (Basicv2) fronting Azure OpenAI, managed-identity auth | Agentic, and any pattern that wants the gateway | See the note below on why this matters specifically for Agentic RAG |
| Log Analytics + Application Insights | Eval harness | Optional today, the harness currently logs cost and latency to its own JSON report, not to App Insights yet |
No compute (App Service, Functions, Container Apps) is deployed yet. Each
pattern runs as a local Python script against the shared services above,
authenticated with DefaultAzureCredential, no keys anywhere in the repo. If a
pattern later needs to be exposed as a real API, add it to azure.yaml as a
service and give it a host.
The Citadel platform series found that the Azure AI Foundry Agent Service SDK
bypasses APIM for its LLM calls, which means any gateway-level governance
(rate limits, kill switches, cost attribution) silently stops applying the
moment an agent framework's own SDK makes the call instead of your code.
Pattern 03 is built with the standard OpenAI SDK pointed at
OPENAI_GATEWAY_BASE for exactly that reason. See patterns/03-agentic-rag/adapter.py
for where this matters in the implementation.
Prerequisites: az CLI, azd, and bicep (the az bicep install version is
fine, you do not need the standalone binary this sandbox used for offline
validation).
azd auth login
azd env new <your-environment-name>
azd env set APIM_PUBLISHER_EMAIL you@example.com
azd upazd up provisions everything in the table above into a new resource group
and prints the outputs (SEARCH_ENDPOINT, OPENAI_GATEWAY_BASE,
COSMOS_ENDPOINT, and so on) that the pattern scripts read at runtime.
- Pinned model versions rot. Confirmed on the first real
azd up:InvalidTemplateDeployment/ServiceModelDeprecatedongpt-4o-miniversion2024-07-18(retired 2026-03-31). Fixed the same way rag-risk-routing fixed it in April: theopenaimodule now leavesversionempty by default so Azure resolves the current default instead of a version that will eventually stop accepting new deployments. Default models are nowgpt-5-miniandgpt-5. If a model name itself is not offered in your region, override it rather than guessing:azd env set CHAT_MINI_MODEL_NAME gpt-4.1-mini az cognitiveservices model list -l <region> -o table # see what your region actually has
- Account kind is
AIServices, notOpenAI. Azure now sells OpenAI models through Foundry as "models sold directly by Azure" (aka.ms/foundry-models-sold-by-azure), same deployment mechanism, different account kind and api-version (2024-10-01). Already fixed ininfra/modules/openai.bicep, noted here in case a future model or API version change needs the same kind of update again. - Model availability by region. Even with the kind and version fixed,
gpt-5,gpt-5-mini, andtext-embedding-3-largeare not necessarily available in every region. Check current availability before settingAZURE_LOCATION, and be ready to move theopenaimodule to a different region than the rest of the stack if needed (the pattern used for SQL in azure-architecture-patterns' pattern 01 is a reasonable template for that). - A malformed APIM policy fails at ARM validation, not at bicep build.
Confirmed on the second real
azd up, which got all the way through provisioning Search, Storage, Cosmos DB, Foundry, Document Intelligence, and the APIM service itself, then failed on the APIM policy sub-resource withValidationError: 'openai' is an unexpected token. The expected token is '>'.The cause: the policy'srewrite-uritemplate attribute had a raw"/openai/"string literal inside a double-quoted XML attribute, which terminated the attribute early.bicep buildhas no way to know a Bicep string is meant to be XML, so it compiled clean and the break only showed up against the live ARM API. Fixed by removing the rewrite-uri entirely (it was redundant, APIM's default backend routing already forwards tobackend.url + operation.urlTemplate, which was already correct for every operation) and by fixing the catch-all operation's wildcard syntax from the invalid/*to APIM's actual/{*path}template parameter syntax. Runpython3 infra/scripts/validate_policy_xml.pyafter editinginfra/modules/apim.bicepto catch this class of bug before the nextazd up, not during one. - RBAC role assignments alone do not enable AAD auth on Azure AI Search.
Confirmed by
scripts/smoke_test.pyafter a fully successful deployment:get_service_statistics()returned a flatForbiddendespite both theSearch Index Data ContributorandSearch Service Contributorroles being correctly assigned. The service still required an API key for data-plane calls because nothing had told it to accept AAD tokens at all. Fixed by addingdisableLocalAuth: trueto the search service's properties ininfra/modules/search.bicep, the same approach already used on Cosmos DB. If you see this again on a resource with roles that look correct, check the resource's own auth settings before re-checking the role assignment. - APIM Basicv2 region and quota availability. Basicv2 is newer than the
classic tiers and is not available in every region or every subscription
tier yet. If
apimfails to provision, check both region support and subscription-level quota before assuming the template is wrong. - Cosmos DB RBAC propagation delay.
disableLocalAuth: truemeans thesqlRoleAssignmentsrole assignment is the only way in, and Cosmos DB RBAC assignments can take a few minutes to propagate. A "Forbidden" error immediately afterazd upcompletes is more likely a propagation delay than a broken role assignment, retry before changing anything. - The APIM policy's
rewrite-uriexpression. This strips the/openaipath prefix APIM adds before forwarding to the backend. It has been reviewed by eye, not exercised against a live gateway. If chat completions or embeddings calls 404 through the gateway, check this policy first with APIM's trace tool before checking anything else.
Right after azd up succeeds, before writing any pattern code:
pip install -r requirements.txt
az login # if you have not already
python3 scripts/smoke_test.pyThe script pulls azd env get-values automatically if the outputs aren't
already in your shell environment. It checks, in order: Azure AI Search,
Azure OpenAI directly (embeddings + a one-word chat completion), Azure
OpenAI through the APIM gateway with no client-side auth at all (this is
the important one, it's the exact managed-identity policy that broke twice
during infra provisioning), Cosmos DB (write, read, delete a throwaway
item), Blob Storage (upload, download, delete a throwaway blob), and
Document Intelligence (a reachability call). Every check authenticates as
you, az login's identity, the same principal every RBAC role assignment in
infra/ was granted to. If something fails here, it's the infra, not
pattern code that doesn't exist yet.
pip install -r eval/requirements.txt # none yet, stdlib only so far
python eval/harness.pyThis runs eval/queries.jsonl against every pattern that has an adapter.py
under patterns/, scores retrieval precision/recall and a lexical
groundedness heuristic (see eval/scoring.py for what that heuristic does and
does not catch), estimates cost from eval/pricing.py, and writes a full
JSON report to eval/results/. Run --patterns hybrid,corrective to scope it
to specific patterns while the others are still stubs.
-
Shared infra, corpus, eval harness(this scaffold) -
Pattern 01, Hybrid RAG. Index builder (patterns/01-hybrid-rag/build_index.py) and adapter (patterns/01-hybrid-rag/adapter.py) are implemented, corpus parsing has an offline unit test suite (patterns/01-hybrid-rag/tests/test_corpus_loader.py), and index construction was validated offline (object build + JSON serialization) against the actual installed SDK version before ever touching a live endpoint. Not yet run against the live index or scored with the eval harness for real, that needs your credentials, run:python3 -m unittest patterns/01-hybrid-rag/tests/test_corpus_loader.py -v python3 eval/harness.py --patterns hybrid
The first build's
setup()call creates the index and embeds all 38 corpus chunks (3 embedding batches), which costs a small amount of Azure OpenAI usage every time it reruns,upload_documentsupserts by key so re-running is safe, just not free. -
Patterns 02 and 03, GraphRAG and Agentic RAG, reusing the APIM/OpenAI SDK governance pattern from Citadel.
Pattern 02, GraphRAGis implemented:patterns/02-graphrag/graph_loader.py(pure Python, community detection via networkx Louvain, entity linking, subgraph walking, unit tested inpatterns/02-graphrag/tests/) andpatterns/02-graphrag/build_graph.py(Cosmos DB sync, community summarization via the chat-mini deployment). Community summaries are stored back in thegraph_entitiescontainer as synthetic entities (entityType: "community_summary") rather than a fourth Cosmos container, no infra redeploy needed.setup()'s community summarization is a real, one-time Azure OpenAI cost per run that isn't tracked in any single query'smodel_calls(the harness's cost accounting is per-query by design), watch stdout for that number rather than expecting it in the eval report. Run:python3 -m unittest patterns/02-graphrag/tests/test_graph_loader.py -v python3 eval/harness.py --patterns graphrag
Confirmed live: q06-q08 (GraphRAG's own territory) scored recall=1.00, the other 12 shared queries scored recall=0.21, exactly the "different failure mode, not a worse pattern" story the post is built around. The harness's
eval/harness.pyoutput now prints this own-target-vs-other breakdown automatically for every pattern, added after this result made it obvious the aggregate numbers alone were misleading.Pattern 03, Agentic RAGis implemented:patterns/03-agentic-rag/rules.py(pure Python, the actual dental waiting-period and annual-maximum arithmetic from the policy docs, encoded as code rather than left to an LLM's arithmetic, unit tested including the exact q09 and q10 scenarios) andpatterns/03-agentic-rag/tools.py(four tools: search reuses pattern 01's index, provider lookup reuses pattern 02's graph, two rule-check tools call straight into rules.py).patterns/03-agentic-rag/adapter.pyruns a standard OpenAI function-calling loop throughOPENAI_GATEWAY_BASE(APIM), not the Foundry Agent Service SDK, capped atMAX_ITERATIONS = 5with the cap-hit outcome recorded separately from a clean answer. Run:python3 -m unittest patterns/03-agentic-rag/tests/test_rules.py patterns/03-agentic-rag/tests/test_tools.py -v python3 eval/harness.py --patterns agentic
-
Patterns 04 and 05, Corrective RAG and Multimodal RAG, the newest territory.
Pattern 04, Corrective RAGis implemented:patterns/04-corrective-rag/grading.py(pure Python: grading prompt construction, tolerant JSON response parsing with a safe "ambiguous" fallback on anything unparseable, and chunk filtering, unit tested against the exact q03 stale-FAQ-vs-current-policy scenario) andpatterns/04-corrective-rag/adapter.py(retrieve, grade, then answer with only the grader-preferred chunks / rewrite and retry up toREWRITE_CAP = 2/ decline outright, reusing pattern 01's index).retrieved_doc_idsreflects what the pattern actually used after filtering, not everything it fetched, so a working grader showsdistractor_hits=0on q03-q05 even though the stale FAQ was retrieved. Confirmed live: precision=1.00 and recall=1.00 on q03-q05, versus Hybrid RAG's overall precision of 0.35 with no grading step at all. Run:python3 -m unittest patterns/04-corrective-rag/tests/test_grading.py -v python3 eval/harness.py --patterns corrective
Pattern 05, Multimodal RAGis implemented:patterns/05-multimodal-rag/render_assets.py(renders the table CSV and the claim form directly as PNGs with Pillow, no SVG parsing, no native rendering library dependency, portable to Windows without a WSL/apt fallback) andpatterns/05-multimodal-rag/build_multimodal_index.py(Document Intelligence table extraction for the table, direct vision captioning for the form, both embedded into the same shared index as new documents,doc_typedistinguishes them rather than a new field or a new index).patterns/05-multimodal-rag/validation.pychecks both generated captions againstcorpus/multimodal/captions.json's ground truth at setup() time, printing a warning (not failing the run) if a field goes missing, exactly the check the corpus README promises.run()is identical to Hybrid RAG's retrieve-and-answer once the index has multimodal entries, no separate query-time logic. Run:python3 -m unittest patterns/05-multimodal-rag/tests/test_multimodal.py -v python3 eval/harness.py --patterns multimodal
-
Run the harness across all five for real, write the post from the numbers it produces. All five patterns now have real
setup()/run()implementations, none are stubs anymore,python3 eval/harness.py(no--patternsfilter) runs the full comparison in one pass.