Ground a RAG answer on Azure AI Search — same question, cited vs. hallucinated, side by side.
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 don't 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.
# 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.