
Learn Azure AI Search with C# — index data, run vector and hybrid queries, and add RAG to your .NET app. Start building intelligent search today.
If your application's search box still runs a LIKE '%term%' query against SQL Server, your users are quietly suffering. They type "cheap laptop for uni" and get zero results because your catalogue says "affordable notebook for students." Azure AI Search with C# fixes exactly this problem: it combines classic keyword search, vector embeddings, and semantic reranking into a single managed service that you can drive from .NET with a few dozen lines of code. In this tutorial you'll build a working search index from scratch, run keyword, vector, and hybrid queries, and finish with a Retrieval Augmented Generation (RAG) pattern that grounds an LLM in your own data.
This guide targets .NET 9 and the Azure.Search.Documents v11 SDK. Every snippet is runnable. We'll explain why each design choice matters, not just which method to call — because the difference between a search feature that delights users and one that gets abandoned is almost entirely in the details of schema design and query strategy.
What Is Azure AI Search and Why Use It From C#?
Azure AI Search (formerly Azure Cognitive Search) is a search-as-a-service platform. You push documents into an index; the service tokenises, analyses, and stores them in an inverted index plus an optional vector index. You then query it over REST or, far more pleasantly, through the official .NET SDK.
The obvious question: why not just use Elasticsearch, or PostgreSQL full-text search, or pgvector? Three reasons matter for most teams:
- You get three retrieval modes in one index. BM25 keyword scoring, approximate nearest neighbour (HNSW) vector search, and a semantic reranker built on Microsoft's language models. Running all three against a self-hosted stack means operating three systems.
- Integrated vectorization removes a whole pipeline. The service can call Azure OpenAI to generate embeddings at index time and query time, so you never write embedding orchestration code.
- Security and compliance are inherited. Managed identity, private endpoints, customer-managed keys, and document-level security trimming are built in — a genuine consideration for teams in the UK, EU, and Australia dealing with data residency requirements.
The trade-off is cost and lock-in. A Basic tier instance runs roughly $75/month and Standard S1 around $250/month, and the index definition is Azure-specific. For a hobby project, pgvector is cheaper. For a production application where search quality directly affects revenue, the managed service almost always wins on total cost of ownership.
Setting Up: Packages, Clients, and Authentication
Install the SDK and the identity library. Skip the API-key approach if you can — managed identity is the correct production pattern.
// dotnet add package Azure.Search.Documents
// dotnet add package Azure.Identity
// dotnet add package Azure.AI.OpenAI
using Azure;
using Azure.Identity;
using Azure.Search.Documents;
using Azure.Search.Documents.Indexes;
var endpoint = new Uri("https://my-search-service.search.windows.net");
// Production: managed identity / developer credentials, no secrets in config
var credential = new DefaultAzureCredential();
var indexClient = new SearchIndexClient(endpoint, credential);
var searchClient = new SearchClient(endpoint, "products-index", credential);
In ASP.NET Core, register these as singletons. SearchClient is thread-safe and holds a pooled HttpClient; creating one per request is a classic source of socket exhaustion.
builder.Services.AddAzureClients(clients =>
{
clients.AddSearchClient(new Uri(endpoint), "products-index");
clients.AddSearchIndexClient(new Uri(endpoint));
clients.UseCredential(new DefaultAzureCredential());
});
Assigning the right RBAC roles
Managed identity fails silently for a lot of developers because the role assignment is missing. You need Search Index Data Reader for queries and Search Index Data Contributor for uploads. The Search Service Contributor role manages the service itself but grants no data plane access — a distinction that costs people hours.
Defining Your Index: The Decision That Matters Most
Your index schema determines search quality more than any query-time tuning. In the v11 SDK you define it with attributes on a POCO and let FieldBuilder generate the schema.
using System.Text.Json.Serialization;
using Azure.Search.Documents.Indexes;
using Azure.Search.Documents.Indexes.Models;
public class Product
{
[SimpleField(IsKey = true, IsFilterable = true)]
public string Id { get; set; } = default!;
[SearchableField(AnalyzerName = LexicalAnalyzerName.Values.EnMicrosoft)]
public string Name { get; set; } = default!;
[SearchableField(AnalyzerName = LexicalAnalyzerName.Values.EnMicrosoft)]
public string Description { get; set; } = default!;
[SimpleField(IsFilterable = true, IsFacetable = true)]
public string Category { get; set; } = default!;
[SimpleField(IsFilterable = true, IsSortable = true)]
public double Price { get; set; }
[SimpleField(IsFilterable = true)]
public bool InStock { get; set; }
// 1536 dimensions matches text-embedding-3-small
[VectorSearchField(VectorSearchDimensions = 1536,
VectorSearchProfileName = "default-vector-profile")]
public IReadOnlyList<float>? DescriptionVector { get; set; }
}
Three things deserve explanation here.
Attribute choice is not cosmetic. SearchableField builds an inverted index and enables full-text matching. SimpleField stores the value for filtering and retrieval but never tokenises it. Marking every string as searchable bloats your index and degrades relevance, because noise fields dilute BM25 scoring. Mark a field searchable only if a user would plausibly type words from it.
Analyzers drive recall. EnMicrosoft applies English lemmatisation, so "running shoes" matches "run shoe". The default standard.lucene analyzer only does basic stemming. For a multi-market application serving the USA, UK, Canada, and Australia, English analysers behave consistently — but if you're serving India with Hindi or Tamil content, add per-language fields rather than trying to make one field handle everything.
Vector dimensions must match your embedding model exactly. 1536 for text-embedding-3-small and ada-002; 3072 for text-embedding-3-large. Get this wrong and uploads fail with an unhelpful error.
Creating the index with vector and semantic configuration
var searchFields = new FieldBuilder().Build(typeof(Product));
var definition = new SearchIndex("products-index", searchFields)
{
VectorSearch = new VectorSearch
{
Profiles =
{
new VectorSearchProfile("default-vector-profile", "hnsw-config")
},
Algorithms =
{
new HnswAlgorithmConfiguration("hnsw-config")
{
Parameters = new HnswParameters
{
M = 4, // connections per node
EfConstruction = 400, // build-time accuracy
EfSearch = 500, // query-time accuracy
Metric = VectorSearchAlgorithmMetric.Cosine
}
}
}
},
SemanticSearch = new SemanticSearch
{
Configurations =
{
new SemanticConfiguration("default-semantic", new SemanticPrioritizedFields
{
TitleField = new SemanticField("Name"),
ContentFields = { new SemanticField("Description") },
KeywordsFields = { new SemanticField("Category") }
})
}
}
};
await indexClient.CreateOrUpdateIndexAsync(definition);
Cosine is the right metric for OpenAI embeddings, which are normalised. Higher EfSearch improves recall at the cost of latency — 500 is a sensible default; drop toward 100 if p99 latency matters more than perfect recall.
Indexing Documents Efficiently
Upload in batches, never one document at a time. The service accepts up to 1,000 documents or 16 MB per request, and per-document round trips will make a 100,000-item catalogue take hours instead of minutes.
public async Task IndexProductsAsync(IEnumerable<Product> products)
{
const int batchSize = 500;
foreach (var chunk in products.Chunk(batchSize))
{
var batch = IndexDocumentsBatch.Upload(chunk);
try
{
IndexDocumentsResult result =
await searchClient.IndexDocumentsAsync(batch);
// Partial failure is normal — inspect per-document results
foreach (var item in result.Results.Where(r => !r.Succeeded))
{
logger.LogWarning("Failed {Key}: {Message}",
item.Key, item.ErrorMessage);
}
}
catch (RequestFailedException ex) when (ex.Status == 207)
{
logger.LogError(ex, "Batch partially failed");
}
}
}
The HTTP 207 status is the single biggest gotcha in the SDK. A batch can partially succeed, and if you only wrap the call in a try/catch you will silently lose documents. Always iterate result.Results.
For throttling (HTTP 503 or 429), the SDK retries automatically with exponential backoff, but if you're saturating a Basic tier you should reduce batch size rather than fight the service.
Querying: Keyword, Vector, and Hybrid Search in C#
Now the payoff. A plain keyword search with filters and facets:
var options = new SearchOptions
{
Filter = "InStock eq true and Price lt 1500",
OrderBy = { "Price asc" },
Facets = { "Category,count:10" },
Size = 20,
IncludeTotalCount = true,
HighlightFields = { "Description" }
};
SearchResults<Product> response =
await searchClient.SearchAsync<Product>("laptop", options);
Console.WriteLine($"Total: {response.TotalCount}");
await foreach (SearchResult<Product> hit in response.GetResultsAsync())
{
Console.WriteLine($"{hit.Score:F3} {hit.Document.Name}");
}
Note that Filter uses OData syntax and is applied before scoring — it's a hard boolean gate, not a ranking signal. This is a genuine performance win: filtering by tenant ID or category narrows the candidate set before the expensive scoring pass.
Hybrid search: keyword plus vector plus semantic reranking
Hybrid search is where Azure AI Search earns its price. You issue a keyword query and a vector query simultaneously; the service fuses the two result sets with Reciprocal Rank Fusion, then optionally reranks the top candidates with a semantic model.
using Azure.Search.Documents.Models;
public async Task<List<Product>> HybridSearchAsync(string query)
{
var options = new SearchOptions
{
VectorSearch = new VectorSearchOptions
{
Queries =
{
// Integrated vectorization: the service embeds the text for us
new VectorizableTextQuery(query)
{
KNearestNeighborsCount = 50,
Fields = { "DescriptionVector" }
}
}
},
QueryType = SearchQueryType.Semantic,
SemanticSearch = new SemanticSearchOptions
{
SemanticConfigurationName = "default-semantic",
QueryCaption = new(QueryCaptionType.Extractive),
QueryAnswer = new(QueryAnswerType.Extractive)
},
Size = 10
};
var response = await searchClient.SearchAsync<Product>(query, options);
var results = new List<Product>();
await foreach (var hit in response.Value.GetResultsAsync())
{
// Semantic reranker score: 0–4 scale, NOT the BM25 score
var rerankScore = hit.SemanticSearch?.RerankerScore ?? 0;
if (rerankScore >= 1.5) results.Add(hit.Document);
}
return results;
}
Two critical details. First, KNearestNeighborsCount = 50 while Size = 10 is deliberate — you want a wide candidate pool for the reranker to work with, then return the best ten. Setting k equal to your page size starves the reranker.
Second, RerankerScore is on a 0–4 scale and is genuinely comparable across queries, unlike the BM25 Score which is only meaningful within one result set. If you need a "no good results" threshold, use the reranker score. A cutoff around 1.5–2.0 filters most irrelevant matches.
If you're generating embeddings yourself
Integrated vectorization is preferable, but if you need control over the embedding model, generate vectors in C# and pass them directly:
var embeddingClient = new AzureOpenAIClient(openAiEndpoint, credential)
.GetEmbeddingClient("text-embedding-3-small");
var embedding = await embeddingClient.GenerateEmbeddingAsync(query);
var vector = embedding.Value.ToFloats();
var vectorQuery = new VectorizedQuery(vector)
{
KNearestNeighborsCount = 50,
Fields = { "DescriptionVector" }
};
Adding RAG: Grounding an LLM in Your Search Index
Retrieval Augmented Generation is the most common reason teams adopt Azure AI Search in 2026. The pattern is simple: retrieve relevant documents, stuff them into a prompt, and ask the model to answer only from those documents.
public async Task<string> AskAsync(string question)
{
var docs = await HybridSearchAsync(question);
var context = string.Join("\n\n", docs.Select((d, i) =>
$"[{i + 1}] {d.Name}: {d.Description} (${d.Price})"));
var chat = new AzureOpenAIClient(openAiEndpoint, credential)
.GetChatClient("gpt-4o-mini");
var messages = new ChatMessage[]
{
new SystemChatMessage(
"Answer using ONLY the numbered sources below. " +
"Cite sources as [1], [2]. If the sources do not contain " +
"the answer, say you don't know.\n\n" + context),
new UserChatMessage(question)
};
var completion = await chat.CompleteChatAsync(messages,
new ChatCompletionOptions { Temperature = 0.1f });
return completion.Value.Content[0].Text;
}
The quality of a RAG system is bounded almost entirely by retrieval quality, not by the language model. If your answers are wrong, the fix is nearly always better chunking and hybrid search — not a bigger model. Chunk long documents into 300–500 token segments with 10–15% overlap, and store the parent document ID so you can return full context on demand.
Best Practices and Common Pitfalls
- Never put an unbounded user string into a filter. Build OData filters with parameterised helpers or escape single quotes by doubling them. Filter injection is a real vulnerability.
- Reuse clients as singletons. Per-request
SearchClientinstantiation exhausts sockets under load. - Use
Selectto retrieve only the fields you render. Pulling back 1536-dimension vectors on every hit wastes enormous bandwidth — vectors should almost never be returned to the client. - Don't paginate deeply with
Skip. It's capped at 100,000 and degrades badly. Use a filter on a sortable key for cursor-style paging instead. - Index updates are eventually consistent. A document uploaded a moment ago may not appear in the very next query. Don't write integration tests that assume immediate visibility.
- Most schema changes require a rebuild. You can add fields, but you cannot change a field's type or analyzer. Plan for an index-aliasing strategy so you can rebuild into a new index and swap the alias with zero downtime.
- Monitor your quota. Semantic reranking is billed separately and free-tier quota (1,000 queries/month) disappears quickly in testing.
Conclusion: Key Takeaways
Building intelligent search with Azure AI Search and C# is far less work than most developers expect — the SDK is well designed, and the managed service removes the operational burden of running a search cluster. The real skill lies in the decisions around the code:
- Schema design dominates search quality. Be selective about
SearchableField, pick the right analyzer, and match vector dimensions to your embedding model. - Hybrid search — BM25 plus vectors plus semantic reranking — consistently outperforms any single retrieval mode. Use a wide k with a narrow page size.
- Use
RerankerScore, not the raw BM25 score, for relevance thresholds. It's the only score comparable across queries. - Batch your uploads and always inspect per-document results — HTTP 207 partial failures are silent data loss.
- For RAG, invest in retrieval quality and chunking before reaching for a larger model.
- Use managed identity with the correct data-plane RBAC role, and never interpolate user input into OData filters.
Start with a small index and a Basic tier service, get hybrid search working end to end, and measure relevance with real user queries before you tune anything. Search is one of the few features where a weekend of work can measurably change how people feel about your entire application.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment