Tuning Chunking, Indexing, and Retrieval in Azure AI Foundry RAG Pipelines
If you've already shipped a first RAG pipeline in Azure AI Foundry, you've probably hit the point where "it works on the demo doc" stops being good enough. This post is about the tuning work that happens after that: chunking strategy, index configuration, hybrid retrieval, and — the part most teams skip — actually measuring retrieval quality instead of eyeballing generation quality.
The retrieval pipeline, end to end
Embedding model choice matters more than teams assume
Chunking and hybrid search get most of the attention, but the embedding model underneath is doing the heavy lifting for the vector half of retrieval, and swapping it isn't free. A few things worth knowing before you pick one and move on:
- Dimension count trades off storage/latency against recall. A 1536-dimension embedding model captures more nuance than a 384-dimension one, but every additional dimension increases index size and query latency linearly. For most enterprise document sets, the jump from 384 to 1536 dimensions shows a real recall improvement; the jump from 1536 to 3072 usually doesn't justify the cost.
- Domain-specific fine-tuned embeddings beat general-purpose ones on jargon-heavy content. If your documents are full of internal product names, acronyms, or industry-specific terminology, a general embedding model will cluster those terms poorly in vector space — they weren't well-represented in its training data. Fine-tuning an embedding model on your own document corpus (even a lightweight contrastive fine-tune) often produces a bigger recall gain than any amount of chunking optimization.
- Re-embedding is expensive and easy to forget. If you change embedding models after your index is already built, every existing chunk needs to be re-embedded and re-indexed — old vectors are meaningless against a new model's vector space. Teams sometimes discover this the hard way after upgrading a model version and getting degraded results because half the index is old vectors and half is new.
def validate_embedding_consistency(index_client, sample_size=20):
"""Sanity check: are all vectors in the index from the same embedding model/version?"""
sample = index_client.search("*", top=sample_size, select=["id", "embedding_model_version"])
versions = set(doc["embedding_model_version"] for doc in sample)
if len(versions) > 1:
raise ValueError(f"Index contains mixed embedding versions: {versions} — re-index required")
return versionsTag every indexed chunk with the embedding model version that produced it, and run a check like this before trusting retrieval quality metrics — a silently mixed index is one of the more confusing failure modes to debug after the fact, because retrieval looks "randomly" worse rather than obviously broken.
Why generation quality is the wrong thing to optimize first
When a RAG answer is wrong, the instinct is to blame the model or the prompt. In practice, a large fraction of "bad answers" are retrieval failures wearing a generation costume: the right chunk never made it into context, so the model either hallucinates or answers from a semantically-adjacent-but-wrong passage. Before touching the prompt, isolate retrieval:
from azure.ai.projects import AIProjectClient
from azure.identity import DefaultAzureCredential
client = AIProjectClient(
endpoint="https://your-foundry-resource.services.ai.azure.com/api/projects/your-project",
credential=DefaultAzureCredential(),
)
def retrieve_only(query, index_name, top_k=5):
search_client = client.inference.get_search_client(index_name)
results = search_client.search(query, top=top_k)
return [{"content": r.content, "score": r.score, "source": r.source} for r in results]Run this against a labeled set of (query, expected_source) pairs before you ever look at a generated answer. If the expected chunk isn't in the top-k, no amount of prompt engineering fixes it.
Chunking strategy: fixed-size is a starting point, not a destination
The default fixed-token chunking (e.g., 512 tokens with 50-token overlap) is fine for homogeneous prose but breaks down on:
- Tables and structured data — a table split mid-row loses meaning entirely. Detect table boundaries and keep them atomic, even if that means a larger-than-average chunk.
- Hierarchical documents (policy docs, contracts) — a clause referencing "Section 4.2" is meaningless without that section's heading. Prepend heading context to each chunk rather than relying on overlap alone.
- Code and config files — chunk on function/class boundaries using an AST-aware splitter, not token count.
A pattern that works well in practice: chunk semantically first (by section/heading/function), then apply a token-count ceiling within each semantic unit, splitting only if it exceeds the limit.
| Content type | Naive fixed-token chunking | Recommended strategy |
|---|---|---|
| Tables | Splits mid-row, loses meaning | Keep atomic, allow larger chunk size |
| Hierarchical docs (policies, contracts) | Loses section context on split | Prepend heading path to each chunk |
| Code / config | Breaks functions/blocks arbitrarily | Split on AST/function boundaries |
| Plain prose | Works reasonably well | Fixed-token with modest overlap |
def semantic_chunk(document, max_tokens=512):
sections = split_by_headings(document)
chunks = []
for section in sections:
if token_count(section.text) <= max_tokens:
chunks.append({"text": section.text, "heading": section.heading})
else:
for sub in split_by_tokens(section.text, max_tokens, overlap=50):
chunks.append({"text": f"{section.heading}\n{sub}", "heading": section.heading})
return chunksHybrid retrieval: don't choose between vector and keyword search
Pure vector search misses exact-match cases (product SKUs, error codes, proper nouns that weren't well-represented in training). Pure keyword search misses paraphrase and conceptual queries. Azure AI Search's hybrid mode — combining BM25 keyword scoring with vector similarity via Reciprocal Rank Fusion — outperforms either alone on mixed query workloads, and the config cost is low:
{
"search": "*",
"vectorQueries": [
{ "vector": [0.02, -0.13, ...], "k": 20, "fields": "content_vector" }
],
"queryType": "semantic",
"semanticConfiguration": "default"
}Turn on semantic ranking too — it re-ranks the fused result set using a cross-encoder, which is where most of the precision gain in the top-3 results actually comes from.
Measuring retrieval quality, not vibes
Build a small eval set — 50-100 real or realistic queries with known correct source chunks — and track:
- Recall@k: did the correct chunk appear in the top k results?
- MRR (Mean Reciprocal Rank): how high did it rank when it did appear?
def recall_at_k(eval_set, retrieve_fn, k=5):
hits = 0
for query, expected_source in eval_set:
results = retrieve_fn(query, top_k=k)
if any(r["source"] == expected_source for r in results):
hits += 1
return hits / len(eval_set)Re-run this every time you change chunking strategy, embedding model, or index config. It's the only way to know whether a change actually helped or just felt like it should.
Common failure mode: overlap masking a chunking problem
If you find yourself increasing overlap to "fix" retrieval misses, stop — that's usually a signal your chunk boundaries are wrong, not that you need more redundancy. Overlap is a band-aid for boundary noise; it doesn't fix chunks that split coherent ideas in half. Fix the boundaries first.
What's next
Once retrieval is solid, the next failure mode shows up in the orchestration layer itself — Prompt Flow DAGs that work in testing but degrade under production traffic in ways that are hard to trace. That's Part 2.
References
- Azure AI Search hybrid search and semantic ranking: https://learn.microsoft.com/en-us/azure/search/hybrid-search-overview
- Azure AI Search chunking strategies: https://learn.microsoft.com/en-us/azure/search/vector-search-how-to-chunk-documents
- Azure AI Foundry documentation: https://learn.microsoft.com/en-us/azure/ai-foundry/
- Retrieval evaluation metrics (Recall@k, MRR): https://learn.microsoft.com/en-us/azure/search/search-relevance-overview