Skip to content
CAMPUX Cloud Bootcamp Lab · AI Platform ← All labs
Hands-On Lab · Advanced
~50 min · Free search tier · Cloud Shell
Python · AI Search · Azure OpenAI · torn down
AI Platform Engineering

Grounded RAG: answers with a citation.

Retrieval-augmented generation is how you make a model answer from your documents instead of its imagination — and "RAG using Azure AI Search, vector databases, and embeddings" is written straight into the job spec. You will build the whole pipeline over a Campux knowledge base, get cited answers, and prove the hardest part: that it refuses to answer what it cannot find.

Fig. 1 · RAG: retrieve, then ground
Resource group · campux-lab-rag-rgindexretrieve → groundAI Searchvector indexEmbedding modelchunks → vectorsChat modelanswers, groundedgrounded, or it refuses
● Screen walkthrough Not yet recorded · ~10 min
Reel · 00:00 / 10:00

Ground a RAG answer on Azure AI Search — same question, cited vs. hallucinated, side by side.

Placeholder — the page below stands alone until the reel lands
Start

Run one step, then check it

Read the step before copying. Bash commands belong in Git Bash on Windows or Azure Cloud Shell set to Bash; PowerShell uses different syntax. A JSON, YAML, Bicep or application-code example belongs in the named file, not directly at the terminal prompt. Follow any shell-specific instructions below.

Keep the same terminal open so earlier variables remain available. Copy one complete command block without the terminal's $ prompt. A backslash continues a command onto the next line. If Bash shows a lone >, it is waiting for the rest of a quote, command or file block. Finish the complete block; if the paste was incomplete, press Ctrl+C and copy it again. Do not paste the next step into an unfinished command.

A line such as RG="training-group" stores a value without printing it. Output captured with $(...) is also stored; a blank screen alone does not prove failure. A sleep deliberately waits, and -o none suppresses normal output. Wait for your normal prompt, then run the step's checkpoint. After an error, stop and keep the exact command and message; later steps cannot repair a missing resource. Never share passwords, access tokens or secret values in a screenshot.

Why

The point of RAG is the refusal

A language model on its own is a confident guesser: ask it your company's return policy and it will invent a plausible one. Retrieval-augmented generation fixes that by putting a retrieval step in front of the model — you convert your documents into vectors (lists of numbers that capture meaning), store them in a search index, and at question time you find the closest chunks and hand only those to the model as context. The model answers from the passage, cites it, and — this is the part that matters in a regulated business — says "I do not have that" when the passage does not contain the answer. Anyone can wire an LLM to a search box; the professional deliverable is a pipeline you can trust, and trust is demonstrated by a clean refusal, not just a good answer.

You will stand up a free-tier Azure AI Search service and an Azure OpenAI resource, embed a small Campux knowledge base into a vector index, and write two short scripts: one to ingest, one to ask. Then you will ask it something it cannot know and watch it decline.

A model that always answers is a liability. RAG's job is to teach it when to say "not in the documents."

Before you begin — one-time setup

You need a free Azure account, the Azure CLI (az) and Python 3, then be signed in with az login. First time? The 15-minute Set up your machine page covers the account, the installs (winget / brew / apt), and sign-in. Prefer zero installs? Run everything in Azure Cloud Shell (Bash), preinstalled and already signed in.

What you need & what it costs

Azure Cloud Shell (Bash) with Python. Source is in github.com/kloudcaptain/campux-labs under lab-rag-azure-ai-search. The Free AI Search tier costs nothing and includes vector search; embedding a handful of short documents and asking a few questions with text-embedding-3-small and gpt-4o-mini comes to a fraction of a cent. Tear down at the end regardless.

Setup

A search service and two models

Create the resource group, a free-tier search service (it includes vector search at no charge), and an Azure OpenAI resource with two deployments — one to turn text into vectors, one to write the grounded answer.

Commands inside $(...) save their output in a variable. Check the printed identifier or the checkpoint before continuing.

# Windows/Git Bash: stop it mangling /subscriptions/... arguments (harmless on macOS/Linux)
export MSYS_NO_PATHCONV=1

RG="campux-lab-rag-rg"
az group create -n "$RG" -l eastus

# free-tier AI Search - one per subscription, includes vector search
SEARCH="campuxsearch$RANDOM"
az search service create -n "$SEARCH" -g "$RG" --sku free -l eastus
SEARCH_ENDPOINT="https://$SEARCH.search.windows.net"
SEARCH_KEY=$(az search admin-key show --service-name "$SEARCH" -g "$RG" --query primaryKey -o tsv)

# Azure OpenAI + an embeddings model and a small chat model
AOAI="campuxaoai$RANDOM"
az cognitiveservices account create -n "$AOAI" -g "$RG" -l eastus2 \
  --kind OpenAI --sku S0 --custom-domain "$AOAI" --yes
az cognitiveservices account deployment create -g "$RG" -n "$AOAI" \
  --deployment-name text-embedding-3-small \
  --model-name text-embedding-3-small --model-version 1 --model-format OpenAI \
  --sku-name Standard --sku-capacity 1
az cognitiveservices account deployment create -g "$RG" -n "$AOAI" \
  --deployment-name gpt-4o-mini \
  --model-name gpt-4o-mini --model-version 2024-07-18 --model-format OpenAI \
  --sku-name Standard --sku-capacity 1
AOAI_ENDPOINT=$(az cognitiveservices account show -n "$AOAI" -g "$RG" --query properties.endpoint -o tsv)
AOAI_KEY=$(az cognitiveservices account keys list -n "$AOAI" -g "$RG" --query key1 -o tsv)

export SEARCH_ENDPOINT SEARCH_KEY AOAI_ENDPOINT AOAI_KEY
pip install --quiet azure-search-documents openai
Checkpoint Both services exist and four values are exported. If a model deployment is refused in eastus2, that region is out of capacity for it — try swedencentral or eastus (footnote 1). The free search service is the one-per-subscription freebie; if you already have one, reuse it.
Step 1

Ingest: chunk, embed, index

This script creates a vector index and uploads the Campux knowledge base. Each document is embedded into a 1536-number vector; the index stores the text and its vector side by side.

cat > ingest.py <<'EOF'
import os
from azure.core.credentials import AzureKeyCredential
from azure.search.documents import SearchClient
from azure.search.documents.indexes import SearchIndexClient
from azure.search.documents.indexes.models import (
    SearchIndex, SimpleField, SearchableField, SearchField,
    SearchFieldDataType, VectorSearch, HnswAlgorithmConfiguration, VectorSearchProfile)
from openai import AzureOpenAI

SE, SK = os.environ["SEARCH_ENDPOINT"], os.environ["SEARCH_KEY"]
oai = AzureOpenAI(api_key=os.environ["AOAI_KEY"], api_version="2024-10-21",
                  azure_endpoint=os.environ["AOAI_ENDPOINT"])
INDEX = "campux-kb"

DOCS = [
  ("returns", "Campux Retail accepts returns within 30 days with a receipt. Perishable goods such as oat milk are non-returnable."),
  ("hours",   "The Camden store opens at 07:00 and closes at 20:00 on weekdays. Shoreditch opens at 08:00 and closes at 22:00."),
  ("loyalty", "The Campux loyalty card earns one point per pound spent. 100 points can be redeemed for a free coffee."),
]

def embed(text):
    return oai.embeddings.create(model="text-embedding-3-small", input=text).data[0].embedding

# create (or update) the vector index
fields = [
  SimpleField(name="id", type=SearchFieldDataType.String, key=True),
  SearchableField(name="content", type=SearchFieldDataType.String),
  SearchField(name="vector", type=SearchFieldDataType.Collection(SearchFieldDataType.Single),
              searchable=True, vector_search_dimensions=1536,
              vector_search_profile_name="hnsw-profile"),
]
vs = VectorSearch(
  algorithms=[HnswAlgorithmConfiguration(name="hnsw")],
  profiles=[VectorSearchProfile(name="hnsw-profile", algorithm_configuration_name="hnsw")])
SearchIndexClient(SE, AzureKeyCredential(SK)).create_or_update_index(
  SearchIndex(name=INDEX, fields=fields, vector_search=vs))

# embed and upload
docs = [{"id": i, "content": t, "vector": embed(t)} for i, t in DOCS]
SearchClient(SE, INDEX, AzureKeyCredential(SK)).upload_documents(docs)
print(f"indexed {len(docs)} documents into {INDEX}")
EOF

python ingest.py
Checkpoint It prints indexed 3 documents into campux-kb. Your documents now live in Azure AI Search as vectors — the same shape whether it is three coffee-shop policies or thirty thousand pages of a bank's procedures.
Step 2

Ask: retrieve, then ground

The ask script embeds the question, runs a vector search for the nearest chunks, and hands only those to the chat model with a strict instruction: answer from the context, cite the source id, and refuse otherwise.

cat > ask.py <<'EOF'
import os, sys
from azure.core.credentials import AzureKeyCredential
from azure.search.documents import SearchClient
from azure.search.documents.models import VectorizedQuery
from openai import AzureOpenAI

SE, SK = os.environ["SEARCH_ENDPOINT"], os.environ["SEARCH_KEY"]
oai = AzureOpenAI(api_key=os.environ["AOAI_KEY"], api_version="2024-10-21",
                  azure_endpoint=os.environ["AOAI_ENDPOINT"])
q = sys.argv[1]

qvec = oai.embeddings.create(model="text-embedding-3-small", input=q).data[0].embedding
sc = SearchClient(SE, "campux-kb", AzureKeyCredential(SK))
hits = sc.search(search_text=None, select=["id", "content"],
                 vector_queries=[VectorizedQuery(vector=qvec, k_nearest_neighbors=3, fields="vector")])
context = "\n".join(f"[{h['id']}] {h['content']}" for h in hits)

prompt = (
  "Answer the question using ONLY the context below. "
  "Cite the source id in square brackets. "
  "If the answer is not in the context, reply exactly: "
  "'Not found in the Campux documents.'\n\n"
  f"Context:\n{context}\n\nQuestion: {q}")
ans = oai.chat.completions.create(model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}], temperature=0)
print(ans.choices[0].message.content)
EOF

Ask it something the documents do cover:

python ask.py "Can I return oat milk I bought yesterday?"
Checkpoint You get a grounded answer with a citation — something like No. Perishable goods such as oat milk are non-returnable. [returns]. The model did not know your return policy; it read it from the retrieved chunk and told you where it came from.
Step 3

The real test: make it refuse

Now ask something that is nowhere in the knowledge base. A naive chatbot invents an answer; a grounded pipeline declines.

python ask.py "What is the Wi-Fi password at the Camden store?"
Checkpoint It replies exactly Not found in the Campux documents. — no invented password, no confident guess. That refusal is the deliverable: it is what lets a regulated business put this in front of customers or staff. You have just demonstrated the difference between a demo and something an auditor would sign off.
Down

Tear it down

Delete the resource group; it removes the search service and the Azure OpenAI resource together.

az group delete -n campux-lab-rag-rg --yes
az group exists -n campux-lab-rag-rg      # -> false
Checkpoint az group exists returns false. The free search service is released (so your one-per-subscription freebie is available again) and the model deployments are gone. Total spend: a fraction of a cent for the embeddings and answers.
End

What you can now honestly claim

You built a retrieval-augmented generation pipeline on Azure AI Search: you embedded documents with Azure OpenAI, stored and searched them as vectors, grounded a chat model in the retrieved passages with citations, and — the part most people skip — proved that it refuses to answer outside its sources. That is "implement RAG patterns using Azure AI Search, vector stores, and embeddings" from the job description, built rather than name-dropped. Together with the MCP lab you can now speak to both sides of AI platform engineering: grounding a model in enterprise data, and governing how it is reached. Very few candidates have done either; you have done both.

Footnotes
  1. Model availability varies by region and shifts over time. If a deployment is refused, the resource's region is out of capacity for that model — recreate the Azure OpenAI resource in swedencentral or eastus, or check the current model-availability table in the Azure docs and adjust the --model-version to a version offered there. The pipeline itself is unchanged; only the deployment target moves.
  2. This lab embeds whole short documents for clarity. Real corpora are split into overlapping chunks of a few hundred tokens before embedding, because retrieval quality depends on chunk size — too large and the relevant sentence is diluted, too small and it loses context. Chunking strategy is a genuine part of the RAG engineering job, not a detail.