Optimizing Retrieval Quality
Retrieval quality is the single most important determinant of RAG system performance. No amount of prompt engineering or LLM capability compensates for poor retrieval β if the right context never reaches the model, the answer will be wrong.
This guide codifies our team's standards for building high-quality retrieval pipelines on Azure AI Search. Every pattern here is tested, opinionated, and reflects what we've learned from running production RAG systems.
Why Retrieval Quality Mattersβ
A RAG system is only as good as its retrieval. The chain of failure looks like this:
Bad chunking β Wrong context retrieved β LLM hallucinates or refuses to answer β User loses trust
Conversely, well-tuned retrieval produces:
- Higher factual accuracy β the LLM grounds answers in relevant, precise context.
- Lower latency β fewer irrelevant chunks means smaller context windows and faster LLM calls.
- Reduced cost β better precision at lower
kvalues means fewer embedding and LLM tokens consumed. - Better user experience β cited sources are actually relevant to the question asked.
Our goal is precision at the top-k: the first 3β5 retrieved chunks should contain the answer. Everything in this guide serves that goal.
Chunking Strategiesβ
Chunking is the foundation of retrieval quality. How you split documents determines what the embedding model sees, what the vector index stores, and ultimately what gets retrieved. A bad chunking strategy cannot be fixed by a better embedding model or a higher k value.
Fixed-Size vs. Semantic Chunkingβ
Fixed-size chunking splits text at regular intervals (e.g., every 512 tokens). It is simple and predictable but breaks semantic units β a chunk might start mid-sentence or split a paragraph in half.
Semantic chunking splits at natural boundaries β paragraph ends, section breaks, or points where the topic shifts. It preserves meaning but requires more sophisticated logic.
For our team, the default is recursive character text splitting with document-aware fallbacks. Here is why:
| Strategy | Pros | Cons |
|---|---|---|
| Fixed-size | Simple, fast, predictable token counts | Breaks semantic boundaries, poor quality on structured docs |
| Recursive character split | Respects natural boundaries, configurable, our default | Slightly more complex setup |
| Semantic (LLM-driven) | Highest quality boundary detection | Expensive, slow, impractical for large-scale ingestion |
| Document-aware (section/paragraph) | Best for structured content (Markdown, HTML) | Requires format-specific parsing |
Our Default: Recursive Character Text Splittingβ
The RecursiveCharacterTextSplitter from LangChain is our default because it recursively tries multiple separators (paragraphs, newlines, sentences, spaces) until it finds a boundary that produces chunks within the target size.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
separators=[
"\n\n", # Paragraph breaks
"\n", # Line breaks
" . ", # Sentences (space-period-space)
" ", # Words
"", # Characters (last resort)
],
)
chunks = splitter.split_documents(documents)
The separator order matters. The splitter tries each separator in order, so paragraphs are split first, then lines, then sentences, then words. This hierarchy preserves the most semantically meaningful boundaries.
Overlap Strategiesβ
Overlap prevents information from being lost at chunk boundaries. Without overlap, a key phrase split across two chunks might not appear in either chunk's embedding.
Recommended overlap by chunk size:
| Chunk Size (tokens) | Overlap (tokens) | Overlap Ratio |
|---|---|---|
| 256 | 20β30 | ~10% |
| 512 | 50β60 | ~10β12% |
| 1024 | 100β150 | ~10β15% |
Excessive overlap (>20%) creates redundant chunks that waste index storage and embedding costs without improving retrieval. Keep overlap between 10β15%.
Document-Aware Chunking for Structured Contentβ
When your source documents have structure (Markdown headings, HTML sections, PDF page breaks), leverage it. Document-aware chunking produces higher-quality results because chunks align with semantic units.
- Markdown Headings
- HTML Sections
from langchain.text_splitter import MarkdownHeaderTextSplitter
headers_to_split_on = [
("#", "Header 1"),
("##", "Header 2"),
("###", "Header 3"),
]
markdown_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
)
# Split by headings first
header_splits = markdown_splitter.split_text(markdown_content)
# Then apply recursive splitting within each section
recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
)
chunks = []
for section in header_splits:
section_chunks = recursive_splitter.split_documents([section])
# Propagate heading metadata to all sub-chunks
for chunk in section_chunks:
chunk.metadata.update(section.metadata)
chunks.extend(section_chunks)
from langchain.text_splitter import HTMLHeaderTextSplitter
headers_to_split_on = [
("h1", "Header 1"),
("h2", "Header 2"),
("h3", "Header 3"),
]
html_splitter = HTMLHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
)
header_splits = html_splitter.split_text(html_content)
recursive_splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=50,
)
chunks = []
for section in header_splits:
section_chunks = recursive_splitter.split_documents([section])
for chunk in section_chunks:
chunk.metadata.update(section.metadata)
chunks.extend(section_chunks)
Chunk Size Recommendations by Document Typeβ
Different document types require different chunk sizes. Use these as starting points and tune based on retrieval metrics:
| Document Type | Chunk Size | Overlap | Rationale |
|---|---|---|---|
| Technical docs / API references | 256β512 tokens | 20β50 | Dense information, short answers needed |
| Policy / legal documents | 512β768 tokens | 50β75 | Context-heavy, need surrounding clauses |
| Blog posts / articles | 512β768 tokens | 50β75 | Narrative flow, moderate density |
| Research papers | 768β1024 tokens | 75β100 | Long arguments, need broader context |
| Chat transcripts / Q&A | 256β384 tokens | 20β30 | Short turns, self-contained meaning |
| Code documentation | 256β384 tokens | 20β30 | Code snippets are short and precise |
Start with 512 tokens / 50 overlap as your baseline. Adjust only if you measure a retrieval quality gap for a specific document type.
Azure AI Search Integrated Chunkingβ
Azure AI Search now supports integrated chunking and vectorization in the indexer pipeline. If your data lives in a supported source (Azure Blob Storage, Cosmos DB, OneLake), you can let Azure handle chunking:
from azure.search.documents.indexes.models import (
ChunkingSkill,
SplitSkill,
)
# Use Azure's built-in chunking in the skillset
chunking_skill = SplitSkill(
text_split_mode="pages",
maximum_page_length=800,
page_overlap_length=100,
)
Supported split modes:
pagesβ splits by token count (configurable max length and overlap).recursiveβ mirrorsRecursiveCharacterTextSplitterbehavior.fixedβ splits at exact token boundaries.
When using integrated chunking, the maximum_page_length and page_overlap_length values are in tokens, not characters. A maximum_page_length of 800 with page_overlap_length of 100 roughly corresponds to our 512/50 recommendation when accounting for tokenization differences.
Rerankingβ
Reranking is the process of re-scoring initially retrieved results using a more sophisticated model. It sits between retrieval and context assembly, and it is the most effective single lever for improving retrieval quality.
What Is Reranking and When to Use Itβ
The initial retrieval step (vector or hybrid search) uses approximate similarity scoring β fast but imprecise. Reranking takes the top-N candidates and re-scores them using a model that jointly considers the query and each document, producing a more accurate relevance ranking.
Use reranking when:
- Your RAG application serves end users (not internal tools).
- You retrieve more than 5 candidates and need to narrow to the best 3β5.
- Your domain has nuanced relevance criteria (e.g., legal, medical, financial).
Skip reranking when:
- Your initial retrieval is already highly precise (small, well-curated corpus).
- Latency budgets are extremely tight (< 200ms end-to-end).
- You are in early prototyping and need to validate chunking and indexing first.
Azure AI Search Semantic Rankerβ
Azure AI Search's built-in semantic ranker is the simplest reranking option. It is a deep learning model fine-tuned for query-document relevance, running entirely within the search service.
To enable it, configure a semantic configuration on your index:
from azure.search.documents.indexes.models import (
SemanticSearch,
SemanticConfiguration,
SemanticPrioritizedFields,
SemanticField,
)
semantic_config = SemanticSearch(
configurations=[
SemanticConfiguration(
name="rag-semantic-config",
prioritized_fields=SemanticPrioritizedFields(
title_field=SemanticField(name="title"),
content_fields=[SemanticField(name="content")],
keywords_fields=[SemanticField(name="category")],
),
)
]
)
# Attach to your index definition
index.semantic_settings = semantic_config
Then use it at query time:
from azure.search.documents.models import QueryType
results = search_client.search(
search_text="How does hybrid search work?",
vector_queries=[
VectorQuery(
kind="vector",
fields=["contentVector"],
k_nearest_neighbors=50, # Microsoft recommends k=50 for semantic ranker
)
],
query_type=QueryType.SEMANTIC,
semantic_configuration_name="rag-semantic-config",
top=5,
)
Microsoft recommends setting k_nearest_neighbors=50 when using semantic ranker. The ranker needs enough candidates to work with β too few and it cannot meaningfully re-score.
Cost note: Semantic ranking requires the Basic tier or higher. It is included in the tier price with no per-query charge.
Cross-Encoder Rerankersβ
For maximum quality, use a dedicated cross-encoder reranker. These models score each (query, document) pair independently and produce more accurate rankings than the built-in semantic ranker.
- Cohere Rerank
- BGE Cross-Encoder (Local)
import cohere
co = cohere.Client(api_key=os.environ["COHERE_API_KEY"])
# Initial retrieval
initial_results = search_client.search(
search_text=query,
vector_queries=[VectorQuery(kind="vector", fields=["contentVector"], k=50)],
top=50,
)
# Rerank with Cohere
documents_to_rerank = [
hit["content"] for hit in initial_results
]
reranked = co.rerank(
model="rerank-english-v3.0",
query=query,
documents=documents_to_rerank,
top_n=5,
)
# Extract top results with rerank scores
final_results = [
{"content": documents_to_rerank(r.result_index), "score": r.relevance_score}
for r in reranked.results
]
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-large")
# Initial retrieval
initial_results = search_client.search(
search_text=query,
vector_queries=[VectorQuery(kind="vector", fields=["contentVector"], k=50)],
top=50,
)
# Rerank locally
pairs = [(query, hit["content"]) for hit in initial_results]
scores = reranker.predict(pairs)
# Combine and sort by reranker score
scored_results = sorted(
zip(initial_results, scores),
key=lambda x: x[1],
reverse=True,
)[:5]
Performance vs. Quality Tradeoffsβ
| Reranking Method | Added Latency | Quality Gain | Cost |
|---|---|---|---|
| None (raw vector) | 0 ms | Baseline | Free |
| Azure Semantic Ranker | 50β150 ms | ModerateβHigh | Included in tier |
| Cohere Rerank | 100β300 ms | High | ~$0.0001 per document |
| BGE Cross-Encoder (local) | 50β200 ms | High | Your compute cost |
| BGE Cross-Encoder (GPU) | 20β80 ms | High | GPU infrastructure |
For most production RAG systems, Azure Semantic Ranker gives the best quality-to-latency ratio. Add a cross-encoder reranker only if you measure a meaningful quality gap on your evaluation set.
Metadata Filteringβ
Metadata filtering narrows the search space before vector similarity scoring. It is the difference between searching your entire knowledge base and searching only the relevant subset.
Designing Metadata Schemasβ
Every document chunk should carry structured metadata that enables filtering. Design your schema before ingestion:
# Recommended metadata fields for every chunk
CHUNK_METADATA_SCHEMA = {
"id": "string", # Unique chunk identifier
"source_document_id": "string", # Parent document ID
"source_file_name": "string", # Original filename
"document_type": "string", # "policy", "technical-doc", "blog", etc.
"department": "string", # "engineering", "legal", "hr", etc.
"language": "string", # "en", "de", etc.
"created_at": "datetime", # Document creation date
"updated_at": "datetime", # Last modification date
"version": "string", # Document version
"section_heading": "string", # Heading context from document-aware chunking
}
Every metadata field you want to filter on must be marked filterable=True in the index schema. Non-filterable fields cannot be used in OData filter expressions.
Common Metadata Patternsβ
- By Document Type
- By Department
- By Date Range
- Combined Filters
Filter to a specific document category:
# Only retrieve from technical documentation
results = vector_store.similarity_search(
"how to configure vector search",
k=5,
filter="document_type eq 'technical-doc'",
)
Scope results to a department's content:
# Only engineering department docs
results = vector_store.similarity_search(
"CI/CD pipeline setup",
k=5,
filter="department eq 'engineering'",
)
Retrieve only recent content:
# Documents updated in the last 6 months
from datetime import datetime, timedelta
six_months_ago = (datetime.utcnow() - timedelta(days=180)).isoformat() + "Z"
results = vector_store.similarity_search(
"security compliance requirements",
k=5,
filter=f"updated_at ge {six_months_ago}",
)
Stack multiple conditions:
# Engineering technical docs, updated recently, in English
filter_expr = (
"department eq 'engineering' "
"and document_type eq 'technical-doc' "
"and language eq 'en' "
"and updated_at ge 2025-01-01T00:00:00Z"
)
results = vector_store.similarity_search(
"Kubernetes deployment strategies",
k=5,
filter=filter_expr,
)
Query-Time Filtering vs. Index-Time Organizationβ
You have two architectural choices for how to handle content segmentation:
| Approach | When to Use | Tradeoff |
|---|---|---|
| Query-time filtering (one index, filter at query time) | Fewer than 5 content domains, overlapping metadata | Simpler, single index to manage |
| Index-time organization (multiple indexes, one per domain) | Distinct domains with different schemas, strict isolation | More indexes to maintain, but cleaner separation |
Our recommendation: Use a single index with query-time filtering unless you have genuinely different schemas or strict data isolation requirements. Multiple indexes multiply operational complexity.
Filter Timing: Pre-Filter vs. Post-Filterβ
Azure AI Search supports two filter application modes for vector queries:
- Pre-filter β the filter is applied before vector search, narrowing the candidate set. Faster, but may exclude relevant results if the filter is too restrictive.
- Post-filter β vector search runs on the full index, then results are filtered. More inclusive, but slower.
from azure.search.documents.models import VectorFilterMode
# Pre-filter: filter first, then search (faster, more restrictive)
results = search_client.search(
search_text=query,
vector_queries=[VectorQuery(kind="vector", fields=["contentVector"], k=50)],
filter="department eq 'engineering'",
vector_filter_mode=VectorFilterMode.PRE_FILTER,
)
# Post-filter: search first, then filter (more inclusive)
results = search_client.search(
search_text=query,
vector_queries=[VectorQuery(kind="vector", fields=["contentVector"], k=50)],
filter="department eq 'engineering'",
vector_filter_mode=VectorFilterMode.POST_FILTER,
)
Use pre-filter for large indexes with narrow filter scopes. Use post-filter when you want maximum recall and your filter is broad.
Hybrid Search Tuningβ
Hybrid search runs vector and keyword queries in parallel, then merges results using Reciprocal Rank Fusion (RRF). It is our default search mode for production RAG systems because it consistently outperforms pure vector or pure keyword search.
How RRF Worksβ
RRF combines ranked lists from different search modalities without requiring score normalization. For each document, it computes:
RRF score = Ξ£ (1 / (k + rank_i))
Where k is the RRF constant (default 61 in Azure AI Search) and rank_i is the document's rank in each modality's result list. Documents that rank highly in both vector and keyword results receive the highest combined score.
Weighting Vector vs. Keyword Signalsβ
You control the relative importance of vector versus keyword results through the weight parameter on vector queries:
# Vector-heavy: weight=2 means vector signal counts double
results = search_client.search(
search_text="Azure AI Search vector search",
vector_queries=[
VectorQuery(
kind="vector",
fields=["contentVector"],
k_nearest_neighbors=50,
weight=2.0, # Vector signal weighted 2x
)
],
top=5,
)
# Keyword-heavy: weight=0.5 means keyword signal dominates
results = search_client.search(
search_text="HNSW algorithm configuration",
vector_queries=[
VectorQuery(
kind="vector",
fields=["contentVector"],
k_nearest_neighbors=50,
weight=0.5, # Keyword signal weighted 2x
)
],
top=5,
)
When to Lean Vector-Heavy vs. Keyword-Heavyβ
| Query Type | Recommended Weight | Why |
|---|---|---|
| Conceptual questions ("how does X work?") | Vector-heavy (1.5β2.0) | Semantic understanding matters more than exact terms |
| Proper nouns, product codes, IDs | Keyword-heavy (0.3β0.5) | Exact matching is critical |
| Mixed queries (concept + specific terms) | Balanced (0.8β1.2) | Both signals contribute |
| Technical documentation lookup | Keyword-heavy (0.5β0.8) | Precise term matching often wins |
| General knowledge / FAQ | Vector-heavy (1.5β2.0) | Paraphrasing is common in questions |
Semantic Configuration and Its Impactβ
The semantic configuration tells Azure AI Search which fields to prioritize for semantic ranking. A well-tuned configuration can improve relevance by 10β30%:
from azure.search.documents.indexes.models import (
SemanticSearch,
SemanticConfiguration,
SemanticPrioritizedFields,
SemanticField,
)
semantic_config = SemanticSearch(
configurations=[
SemanticConfiguration(
name="rag-semantic-config",
prioritized_fields=SemanticPrioritizedFields(
title_field=SemanticField(name="title"),
content_fields=[
SemanticField(name="content"),
SemanticField(name="summary"),
],
keywords_fields=[
SemanticField(name="tags"),
SemanticField(name="category"),
],
),
)
]
)
Field roles in semantic configuration:
| Role | Purpose | Field Example |
|---|---|---|
title_field | Primary title/heading for the document | title, heading |
content_fields | Main text content for relevance scoring | content, body, summary |
keywords_fields | Categorical metadata for intent matching | tags, category, topic |
Testing and Tuning Hybrid Weightsβ
Do not guess at weights. Test empirically on a representative query set:
import json
from dataclasses import dataclass, asdict
@dataclass
class HybridWeightTest:
weight: float
mean_reciprocal_rank: float
precision_at_5: float
avg_latency_ms: float
def evaluate_hybrid_weights(
queries: list[str],
ground_truth: dict[str, list[str]], # query -> expected doc IDs
search_client,
weights: list[float] = None,
) -> list[HybridWeightTest]:
"""Test different vector weights and measure retrieval quality."""
if weights is None:
weights = [0.3, 0.5, 0.8, 1.0, 1.5, 2.0]
results = []
for weight in weights:
mrr_scores = []
precision_scores = []
latencies = []
for query in queries:
import time
start = time.perf_counter()
search_results = search_client.search(
search_text=query,
vector_queries=[
VectorQuery(
kind="vector",
fields=["contentVector"],
k_nearest_neighbors=50,
weight=weight,
)
],
top=5,
)
latency = (time.perf_counter() - start) * 1000
latencies.append(latency)
retrieved_ids = [doc["id"] for doc in search_results]
expected_ids = ground_truth.get(query, [])
# Mean Reciprocal Rank
mrr = 0.0
for rank, doc_id in enumerate(retrieved_ids, 1):
if doc_id in expected_ids:
mrr = 1.0 / rank
break
mrr_scores.append(mrr)
# Precision@5
hits = sum(1 for doc_id in retrieved_ids if doc_id in expected_ids)
precision_scores.append(hits / len(retrieved_ids))
results.append(HybridWeightTest(
weight=weight,
mean_reciprocal_rank=sum(mrr_scores) / len(mrr_scores),
precision_at_5=sum(precision_scores) / len(precision_scores),
avg_latency_ms=sum(latencies) / len(latencies),
))
return results
Run the weight evaluation monthly or whenever you add significant new content. Retrieval quality drifts as your corpus evolves.
Index Design Best Practicesβ
Field Type Selectionβ
Every field in your index needs a deliberate role. Over-indexing (marking every field as searchable) wastes resources and can degrade query performance.
| Field Attribute | When to Use | When NOT to Use |
|---|---|---|
searchable=True | Fields you want full-text search on (content, title) | IDs, timestamps, numeric metadata |
filterable=True | Fields used in OData filters (department, date, type) | Large text fields, content bodies |
facetable=True | Fields for grouping/aggregation (tags, categories) | High-cardinality fields (UUIDs, URLs) |
sortable=True | Fields for ordering (timestamps, scores) | Text content fields |
retrievable=True | Fields you want in query results | Internal fields never returned |
Recommended index schema for RAG:
from azure.search.documents.indexes.models import (
SearchIndex,
SearchField,
SearchFieldDataType,
SimpleVectorIndexer,
HnswAlgorithmConfiguration,
VectorSearch,
VectorSearchProfile,
VectorSearchAlgorithmKind,
VectorSearchAlgorithmMetric,
SemanticSearch,
SemanticConfiguration,
SemanticPrioritizedFields,
SemanticField,
)
index = SearchIndex(
name="rag-production-index",
fields=[
# Key field β unique identifier
SearchField(
name="id",
type=SearchFieldDataType.String,
key=True,
retrievable=True,
),
# Content β searchable for keyword search, retrievable for results
SearchField(
name="content",
type=SearchFieldDataType.String,
searchable=True,
retrievable=True,
),
# Title β searchable and used in semantic config
SearchField(
name="title",
type=SearchFieldDataType.String,
searchable=True,
retrievable=True,
),
# Vector field β stores embeddings
SearchField(
name="contentVector",
type=SearchFieldDataType.Collection(SearchFieldDataType.Single),
searchable=True,
vector_search_dimensions=512,
vector_search_profile_name="default-profile",
),
# Metadata β filterable but NOT searchable
SearchField(
name="document_type",
type=SearchFieldDataType.String,
filterable=True,
facetable=True,
retrievable=True,
),
SearchField(
name="department",
type=SearchFieldDataType.String,
filterable=True,
facetable=True,
retrievable=True,
),
SearchField(
name="source_file_name",
type=SearchFieldDataType.String,
filterable=True,
retrievable=True,
),
SearchField(
name="created_at",
type=SearchFieldDataType.DateTimeOffset,
filterable=True,
sortable=True,
retrievable=True,
),
SearchField(
name="updated_at",
type=SearchFieldDataType.DateTimeOffset,
filterable=True,
sortable=True,
retrievable=True,
),
SearchField(
name="language",
type=SearchFieldDataType.String,
filterable=True,
facetable=True,
retrievable=True,
),
SearchField(
name="section_heading",
type=SearchFieldDataType.String,
filterable=True,
retrievable=True,
),
],
vector_search=VectorSearch(
profiles=[
VectorSearchProfile(
name="default-profile",
algorithm_configuration_name="default-algorithm",
)
],
algorithms=[
HnswAlgorithmConfiguration(
name="default-algorithm",
kind=VectorSearchAlgorithmKind.HNSW,
parameters={
"m": 4,
"efConstruction": 400,
"efSearch": 500,
"metric": VectorSearchAlgorithmMetric.COSINE,
},
)
],
),
semantic_settings=SemanticSearch(
configurations=[
SemanticConfiguration(
name="default",
prioritized_fields=SemanticPrioritizedFields(
title_field=SemanticField(name="title"),
content_fields=[SemanticField(name="content")],
),
)
]
),
)
HNSW Parametersβ
The HNSW algorithm configuration controls the tradeoff between index quality, build speed, and search speed:
| Parameter | Recommended | Effect |
|---|---|---|
m | 4 (default) | Max connections per layer. Higher = better recall, more memory. |
efConstruction | 400 | Build-time exploration. Higher = better index quality, slower build. |
efSearch | 500 | Query-time exploration. Higher = better recall, slower queries. |
metric | COSINE | Distance metric. Use cosine for normalized embeddings. |
For most RAG workloads, the defaults (m=4, efConstruction=400, efSearch=500) are well-tuned. Only increase efSearch if you measure recall degradation.
When to Use Multiple Indexesβ
Use multiple indexes when:
- Content domains have fundamentally different schemas.
- You need strict data isolation (e.g., tenant separation in multi-tenant apps).
- Different content types require different embedding dimensions or models.
Use a single index when:
- Content shares a common schema.
- You want unified search across domains.
- Operational simplicity is a priority.
Partitioning and Replicationβ
For production scale:
| Scale | Partitions | Replicas | Notes |
|---|---|---|---|
| Development (< 100K docs) | 1 | 1 | Basic tier sufficient |
| Production (100Kβ10M docs) | 1β2 | 1β2 | Standard tier, scale replicas for read throughput |
| High-scale (10M+ docs) | 2β4 | 2+ | Standard 2/3, monitor query latency |
- Partitions scale write throughput and index size.
- Replicas scale read throughput and provide redundancy.
Index Lifecycle Managementβ
# Check index status
index_stats = index_client.get_index_stats("rag-production-index")
print(f"Document count: {index_stats.document_count}")
print(f"Storage (MB): {index_stats.storage_size / 1024 / 1024}")
# Soft delete pattern β mark documents as deleted instead of removing
def soft_delete_documents(search_client, doc_ids: list[str]):
"""Mark documents as deleted by updating a status field."""
updates = [
{"id": doc_id, "@search.action": "merge", "status": "deleted"}
for doc_id in doc_ids
]
search_client.merge_or_upload_documents(updates)
# Filter out soft-deleted documents at query time
results = search_client.search(
search_text=query,
filter="status ne 'deleted'",
# ... rest of query
)
Avoid frequent index deletion and recreation. It causes downtime and requires full re-indexing. Use soft deletes and incremental updates instead.
Cost Optimizationβ
Understanding Azure AI Search Costsβ
Azure AI Search uses a tiered pricing model. Vector search itself is free across all tiers β you pay for the service capacity, not per-query.
| Tier | Monthly Cost (approx.) | Max Documents | Vector Search | Semantic Ranker |
|---|---|---|---|---|
| Free | $0 | 10,000 | No | No |
| Basic | ~$50 | 1,000,000 | Yes | Yes |
| Standard | ~$150 | 10,000,000 | Yes | Yes |
| Standard 2 | ~$300 | 10,000,000 | Yes | Yes |
| Standard 3 | ~$450 | 100,000,000 | Yes | Yes |
The real costs are in the embedding model calls (Azure OpenAI), not the search service:
| Cost Center | What You Pay For | Optimization Lever |
|---|---|---|
| Azure AI Search | Tier price (fixed) | Right-size the tier |
| Azure OpenAI Embeddings | Per-token (ingestion + queries) | Reduce chunk count, cache queries |
| Azure OpenAI Chat (LLM) | Per-token (context + response) | Better retrieval = smaller context |
| Cross-encoder reranker | Per-document (if using Cohere/etc.) | Lower initial k, use Azure ranker |
Caching Strategiesβ
Reduce embedding costs by caching query embeddings:
import hashlib
import json
import os
from pathlib import Path
CACHE_DIR = Path("query_embedding_cache")
CACHE_DIR.mkdir(exist_ok=True)
def cache_key(text: str) -> str:
"""Generate a deterministic cache key from query text."""
return hashlib.sha256(text.encode()).hexdigest()
def get_cached_embedding(query: str) -> list[float] | None:
"""Check cache before calling the embedding API."""
key = cache_key(query)
cache_file = CACHE_DIR / f"{key}.json"
if cache_file.exists():
return json.loads(cache_file.read_text())
return None
def embed_query_with_cache(query: str, embedding_model) -> list[float]:
"""Embed a query, using cache when available."""
cached = get_cached_embedding(query)
if cached:
return cached
# Cache miss β generate embedding
response = embedding_model.embed_query(query)
cache_file = CACHE_DIR / f"{cache_key(query)}.json"
cache_file.write_text(json.dumps(response))
return response
Query embedding caching is most effective for FAQ-style applications where users ask similar questions. For open-domain QA, cache hit rates are lower.
Right-Sizing the Pricing Tierβ
Start with Basic for development and small production workloads (< 1M documents). Upgrade when you hit document limits or need cognitive skillsets.
Monitor your usage:
from azure.search.documents.indexes import SearchIndexClient
from azure.core.credentials import AzureKeyCredential
index_client = SearchIndexClient(
endpoint=os.environ["AZURE_SEARCH_ENDPOINT"],
credential=AzureKeyCredential(os.environ["AZURE_SEARCH_API_KEY"]),
)
stats = index_client.get_index_stats("rag-production-index")
print(f"Documents: {stats.document_count:,}")
print(f"Storage: {stats.storage_size / (1024**2):.1f} MB")
# Alert if approaching tier limits
BASIC_LIMIT = 1_000_000
if stats.document_count > BASIC_LIMIT * 0.8:
print("WARNING: Approaching Basic tier document limit. Consider upgrading.")
Monitoring and Alerting on Costsβ
Set up Azure Monitor alerts for your search service:
# Example: Azure CLI to create a cost alert
# az monitor metrics alert create \
# --name "SearchDocumentCountAlert" \
# --resource-group rg-ai-engineering \
# --scopes "/subscriptions/{id}/resourceGroups/rg-ai-engineering/providers/Microsoft.Search/searchServices/ai-eng-search-prod" \
# --condition "GreaterThan DocumentCount 800000" \
# --action-group "/subscriptions/{id}/resourceGroups/rg-ai-engineering/providers/Microsoft.Insights/actionGroups/alert-group"
Monitoring Retrieval Qualityβ
You cannot improve what you do not measure. Track retrieval quality continuously.
Key Metricsβ
| Metric | Definition | Target |
|---|---|---|
| Recall@K | Fraction of relevant documents found in top-K results | > 0.8 at K=10 |
| Precision@K | Fraction of top-K results that are relevant | > 0.7 at K=5 |
| MRR (Mean Reciprocal Rank) | Average of 1/rank for first relevant result | > 0.6 |
| NDCG@K | Normalized Discounted Cumulative Gain | > 0.7 |
| Retrieval Latency (p95) | 95th percentile query time | < 500 ms |
Evaluation Frameworkβ
from dataclasses import dataclass
from typing import Callable
@dataclass
class RetrievalMetrics:
recall_at_k: float
precision_at_k: float
mrr: float
ndcg_at_k: float
avg_latency_ms: float
def compute_retrieval_metrics(
queries: list[str],
ground_truth: dict[str, set[str]],
retriever: Callable[[str], list[dict]],
k: int = 5,
) -> RetrievalMetrics:
"""Compute retrieval quality metrics against a labeled test set."""
recalls = []
precisions = []
reciprocal_ranks = []
ndcgs = []
latencies = []
import time
for query in queries:
relevant_ids = ground_truth.get(query, set())
if not relevant_ids:
continue
start = time.perf_counter()
results = retriever(query)
latency = (time.perf_counter() - start) * 1000
latencies.append(latency)
retrieved_ids = [doc["id"] for doc in results[:k]]
# Recall@K
hits = len(set(retrieved_ids) & relevant_ids)
recalls.append(hits / len(relevant_ids) if relevant_ids else 0)
# Precision@K
precisions.append(hits / k if k > 0 else 0)
# MRR
rr = 0.0
for rank, doc_id in enumerate(retrieved_ids, 1):
if doc_id in relevant_ids:
rr = 1.0 / rank
break
reciprocal_ranks.append(rr)
# NDCG@K (simplified binary relevance)
dcg = sum(
(1.0 / (2**i - 1)) if doc_id in relevant_ids else 0
for i, doc_id in enumerate(retrieved_ids, 1)
)
ideal_hits = min(len(relevant_ids), k)
idcg = sum(1.0 / (2**i - 1) for i in range(1, ideal_hits + 1))
ndcgs.append(dcg / idcg if idcg > 0 else 0)
return RetrievalMetrics(
recall_at_k=sum(recalls) / len(recalls) if recalls else 0,
precision_at_k=sum(precisions) / len(precisions) if precisions else 0,
mrr=sum(reciprocal_ranks) / len(reciprocal_ranks) if reciprocal_ranks else 0,
ndcg_at_k=sum(ndcgs) / len(ndcgs) if ndcgs else 0,
avg_latency_ms=sum(latencies) / len(latencies) if latencies else 0,
)
Using Langfuse to Monitor Retrieval Qualityβ
Langfuse provides observability for LLM applications, including retrieval tracking. Instrument your RAG chain:
from langfuse import Langfuse
langfuse = Langfuse()
def trace_retrieval(
trace_name: str,
query: str,
retrieved_docs: list[dict],
metrics: RetrievalMetrics | None = None,
):
"""Log retrieval trace to Langfuse."""
trace = langfuse.trace(
name=trace_name,
input={"query": query},
output={
"doc_count": len(retrieved_docs),
"doc_ids": [doc["id"] for doc in retrieved_docs[:10]],
},
)
# Attach retrieval metrics as scores
if metrics:
trace.score(name="recall_at_5", value=metrics.recall_at_k)
trace.score(name="precision_at_5", value=metrics.precision_at_k)
trace.score(name="mrr", value=metrics.mrr)
trace.score(name="retrieval_latency_ms", value=metrics.avg_latency_ms)
A/B Testing Retrieval Configurationsβ
Test retrieval changes before deploying:
import random
class RetrievalABTest:
"""A/B test different retrieval configurations."""
def __init__(self, experiment_name: str, variants: dict[str, dict]):
self.experiment_name = experiment_name
self.variants = variants # {"control": {...}, "treatment": {...}}
self.results = {"control": [], "treatment": []}
def get_variant(self) -> str:
return random.choice(list(self.variants.keys()))
def record_result(self, variant: str, metrics: RetrievalMetrics):
self.results[variant].append({
"recall": metrics.recall_at_k,
"precision": metrics.precision_at_k,
"mrr": metrics.mrr,
"latency_ms": metrics.avg_latency_ms,
})
def compare(self) -> dict:
"""Compare variant performance."""
summary = {}
for variant, results in self.results.items():
if not results:
continue
summary[variant] = {
"avg_recall": sum(r["recall"] for r in results) / len(results),
"avg_precision": sum(r["precision"] for r in results) / len(results),
"avg_mrr": sum(r["mrr"] for r in results) / len(results),
"avg_latency_ms": sum(r["latency_ms"] for r in results) / len(results),
"sample_size": len(results),
}
return summary
Run A/B tests on a representative sample of production queries for at least one week before making changes permanent. Retrieval quality varies by query type and content distribution.
Quick Reference: Our Team Defaultsβ
| Setting | Our Default |
|---|---|
| Chunk size | 512 tokens |
| Chunk overlap | 50 tokens |
| Text splitter | RecursiveCharacterTextSplitter |
| Embedding model | text-embedding-3-small, 512 dimensions |
| Distance metric | Cosine |
HNSW efSearch | 500 |
| Hybrid search | Always (vector weight 1.0, tune per domain) |
| Semantic ranker | Enabled (k=50) |
| Cross-encoder reranker | Only if Azure ranker is insufficient |
| Index tier | Basic (dev), Standard (prod) |
Retrieval k | 5 (final), 50 (for reranking) |
| Filter mode | Pre-filter (large indexes), Post-filter (small indexes) |
| Monitoring | Langfuse + monthly retrieval evaluation |