Ground a RAG answer on Azure AI Search — same question, cited vs. hallucinated, side by side.
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.
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."
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.
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.
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
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.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
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.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?"
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.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?"
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.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
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.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.
- 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
swedencentraloreastus, or check the current model-availability table in the Azure docs and adjust the--model-versionto a version offered there. The pipeline itself is unchanged; only the deployment target moves. - 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.