
Learn Azure AI Search with C# step by step: create an index, upload documents, run full-text, semantic and vector search with the .NET SDK. Start building today.
If your application still relies on WHERE Name LIKE '%term%' for search, your users are getting a worse experience than they deserve. Azure AI Search with C# (formerly Azure Cognitive Search) gives you a fully managed search engine that handles full-text ranking, typo tolerance, faceting, semantic re-ranking and vector similarity — all accessible from .NET through the Azure.Search.Documents SDK. In this tutorial you will build a working product search from scratch: create an index, upload documents, run keyword queries, add filters and facets, and finish with hybrid vector search that powers modern RAG (Retrieval-Augmented Generation) applications.
Everything here runs on .NET 8/9 with the current stable SDK, and every code sample is complete enough to paste into a console app and run.
What Is Azure AI Search and Why Use It From C#?
Azure AI Search is a platform-as-a-service search engine. You push JSON documents into an index (think of it as a schema-defined table optimised for retrieval) and query it over REST or the SDK. Behind the scenes it runs an inverted index with BM25 ranking, an approximate-nearest-neighbour (HNSW) vector index, and optional semantic re-ranking powered by Microsoft's language models.
Why not just use SQL full-text search or Elasticsearch?
- Zero infrastructure. No cluster to patch, scale or back up. You pick a tier and Azure runs it.
- Relevance out of the box. Stemming, lemmatisation, synonyms and language analyzers for 50+ languages are built in.
- First-class vector search. Store embeddings from Azure OpenAI (or any model) next to your text fields and run hybrid queries that combine both.
- Native .NET SDK.
Azure.Search.Documentsis strongly typed, async-first and integrates withAzure.Identityfor passwordless auth.
Prerequisites
- An Azure subscription with an Azure AI Search resource (the Free tier is enough for this tutorial; Basic or above if you want semantic ranker).
- .NET 8 SDK or later.
- The service endpoint (e.g.
https://my-search.search.windows.net) and an admin API key, or an Entra ID identity with the Search Index Data Contributor role.
dotnet new console -n AzureSearchDemo
cd AzureSearchDemo
dotnet add package Azure.Search.Documents
dotnet add package Azure.Identity
Step 1: Define Your Index Model in C#
The SDK can build an index schema directly from a C# class using attributes. Each attribute maps to an index capability, and choosing them deliberately matters: every IsFilterable or IsFacetable field costs storage and indexing time, so only mark what you actually query.
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(IsSortable = true, AnalyzerName = LexicalAnalyzerName.Values.EnLucene)]
public string Name { get; set; } = default!;
[SearchableField(AnalyzerName = LexicalAnalyzerName.Values.EnLucene)]
public string Description { get; set; } = default!;
[SimpleField(IsFilterable = true, IsFacetable = true)]
public string Category { get; set; } = default!;
[SimpleField(IsFilterable = true, IsSortable = true, IsFacetable = true)]
public double Price { get; set; }
[SimpleField(IsFilterable = true, IsSortable = true)]
public DateTimeOffset LastUpdated { get; set; }
// Vector field – populated later with embeddings (1536 dims for text-embedding-3-small)
[VectorSearchField(VectorSearchDimensions = 1536, VectorSearchProfileName = "default-vector-profile")]
public ReadOnlyMemory<float> DescriptionVector { get; set; }
}
Why SearchableField vs SimpleField? Searchable fields are tokenised and go through the full-text analyzer, which is what you want for names and descriptions. Simple fields are stored verbatim and are ideal for IDs, prices and categories that you filter or sort on but never search with free text.
Step 2: Create the Index with the .NET SDK
SearchIndexClient manages indexes; SearchClient manages documents inside one index. Keep them as singletons — they are thread-safe and hold an HTTP connection pool.
using Azure;
using Azure.Search.Documents;
using Azure.Search.Documents.Indexes;
using Azure.Search.Documents.Indexes.Models;
var endpoint = new Uri(Environment.GetEnvironmentVariable("SEARCH_ENDPOINT")!);
var credential = new AzureKeyCredential(Environment.GetEnvironmentVariable("SEARCH_ADMIN_KEY")!);
// Preferred in production: new DefaultAzureCredential() with Azure.Identity
var indexClient = new SearchIndexClient(endpoint, credential);
const string indexName = "products";
var fieldBuilder = new FieldBuilder();
var index = new SearchIndex(indexName)
{
Fields = fieldBuilder.Build(typeof(Product)),
VectorSearch = new VectorSearch
{
Algorithms = { new HnswAlgorithmConfiguration("hnsw-config") },
Profiles = { new VectorSearchProfile("default-vector-profile", "hnsw-config") }
},
SemanticSearch = new SemanticSearch
{
Configurations =
{
new SemanticConfiguration("default-semantic", new SemanticPrioritizedFields
{
TitleField = new SemanticField("Name"),
ContentFields = { new SemanticField("Description") }
})
}
}
};
await indexClient.CreateOrUpdateIndexAsync(index);
Console.WriteLine($"Index '{indexName}' ready.");
CreateOrUpdateIndexAsync is idempotent, which makes it safe to run at application start-up or from a deployment pipeline. Note one important limitation: you can add fields to an existing index, but you cannot change a field's type or delete it without rebuilding the index.
Step 3: Upload Documents
Documents are uploaded in batches. The SDK exposes IndexDocumentsBatch with four actions: Upload (insert or replace), Merge (partial update), MergeOrUpload and Delete.
var searchClient = indexClient.GetSearchClient(indexName);
var products = new[]
{
new Product { Id = "1", Name = "Wireless Noise-Cancelling Headphones",
Description = "Over-ear Bluetooth headphones with 30-hour battery and active noise cancellation.",
Category = "Audio", Price = 249.99, LastUpdated = DateTimeOffset.UtcNow },
new Product { Id = "2", Name = "Mechanical Keyboard",
Description = "Compact 75% keyboard with hot-swappable switches and RGB backlighting.",
Category = "Peripherals", Price = 129.00, LastUpdated = DateTimeOffset.UtcNow },
new Product { Id = "3", Name = "4K Webcam",
Description = "Ultra HD webcam with autofocus and dual noise-reducing microphones for video calls.",
Category = "Peripherals", Price = 89.50, LastUpdated = DateTimeOffset.UtcNow }
};
var batch = IndexDocumentsBatch.Upload(products);
IndexDocumentsResult result = await searchClient.IndexDocumentsAsync(batch);
foreach (var r in result.Results.Where(r => !r.Succeeded))
Console.WriteLine($"Failed: {r.Key} – {r.ErrorMessage}");
Pitfall: a batch can partially succeed. Always inspect result.Results rather than assuming the whole call worked. For bulk loads of thousands of documents, use SearchIndexingBufferedSender<T>, which batches, retries and throttles automatically.
Step 4: Run Full-Text Search in C#
A basic query is a single line. The SearchOptions object controls paging, selected fields, highlighting and more.
var options = new SearchOptions
{
Size = 10,
IncludeTotalCount = true,
HighlightFields = { "Description" },
HighlightPreTag = "<mark>",
HighlightPostTag = "</mark>"
};
options.Select.Add("Id");
options.Select.Add("Name");
options.Select.Add("Price");
SearchResults<Product> results = await searchClient.SearchAsync<Product>("noise cancelling", options);
Console.WriteLine($"Total matches: {results.TotalCount}");
await foreach (SearchResult<Product> hit in results.GetResultsAsync())
{
Console.WriteLine($"{hit.Score:F2} {hit.Document.Name} ${hit.Document.Price}");
if (hit.Highlights is not null && hit.Highlights.TryGetValue("Description", out var snippets))
Console.WriteLine(" " + string.Join(" … ", snippets));
}
Notice the query "noise cancelling" matched both "noise cancellation" and "noise-reducing" — that is the English analyzer stemming words for you. Try "noize canceling" with options.QueryType = SearchQueryType.Full and a fuzzy suffix (noize~ canceling~) to see typo tolerance in action.
Filters, Sorting and Facets
Filters use OData syntax and are applied before ranking, so they are cheap and precise. Facets return aggregate counts, perfect for the sidebar in an e-commerce UI.
var options = new SearchOptions
{
Filter = "Category eq 'Peripherals' and Price lt 150",
Facets = { "Category", "Price,values:50|100|200" }
};
options.OrderBy.Add("Price asc");
var results = await searchClient.SearchAsync<Product>("*", options);
foreach (var facet in results.Facets["Category"])
Console.WriteLine($"{facet.Value}: {facet.Count}");
Security pitfall: never concatenate raw user input into Filter. Escape single quotes (value.Replace("'", "''")) or, better, use SearchFilter.Create($"Category eq {userCategory}"), which is an interpolated-string handler that escapes parameters for you.
Step 5: Add Vector and Hybrid Search for RAG Scenarios
This is where Azure AI Search with C# moves from "nice search box" to "AI application backbone". Vector search finds documents whose meaning is close to the query, even when no words overlap — "something to block out office chatter" should find the headphones. Combining vector and keyword search (hybrid) consistently beats either alone in Microsoft's published benchmarks, and adding the semantic ranker on top improves it further.
First, generate embeddings. This example uses Azure OpenAI, but any 1536-dimensional model works as long as you use the same one for indexing and querying.
// dotnet add package Azure.AI.OpenAI
using Azure.AI.OpenAI;
using OpenAI.Embeddings;
var openAi = new AzureOpenAIClient(
new Uri(Environment.GetEnvironmentVariable("AOAI_ENDPOINT")!),
new AzureKeyCredential(Environment.GetEnvironmentVariable("AOAI_KEY")!));
EmbeddingClient embedder = openAi.GetEmbeddingClient("text-embedding-3-small");
async Task<ReadOnlyMemory<float>> EmbedAsync(string text)
{
OpenAIEmbedding embedding = await embedder.GenerateEmbeddingAsync(text);
return embedding.ToFloats();
}
// Populate vectors before uploading
foreach (var p in products)
p.DescriptionVector = await EmbedAsync($"{p.Name}. {p.Description}");
await searchClient.IndexDocumentsAsync(IndexDocumentsBatch.MergeOrUpload(products));
Now run a hybrid query: the text goes to the BM25 engine, the vector goes to HNSW, and the results are fused with Reciprocal Rank Fusion before the semantic ranker re-orders the top 50.
string userQuery = "something to block out office chatter";
ReadOnlyMemory<float> queryVector = await EmbedAsync(userQuery);
var options = new SearchOptions
{
VectorSearch = new VectorSearchOptions
{
Queries =
{
new VectorizedQuery(queryVector)
{
KNearestNeighborsCount = 5,
Fields = { "DescriptionVector" }
}
}
},
QueryType = SearchQueryType.Semantic,
SemanticSearch = new SemanticSearchOptions
{
SemanticConfigurationName = "default-semantic",
QueryCaption = new QueryCaption(QueryCaptionType.Extractive)
},
Size = 5
};
var results = await searchClient.SearchAsync<Product>(userQuery, options);
await foreach (var hit in results.GetResultsAsync())
{
Console.WriteLine($"{hit.SemanticSearch?.RerankerScore:F2} {hit.Document.Name}");
foreach (var caption in hit.SemanticSearch?.Captions ?? [])
Console.WriteLine(" " + caption.Text);
}
The headphones come back first with a reranker score near 3.0 even though the query shares no vocabulary with the document. Feed the top hits into a chat completion prompt and you have a working RAG pipeline: retrieval grounded in your data, generation from the LLM.
Best Practices for Azure AI Search in Production .NET Apps
- Register clients as singletons. In ASP.NET Core, use
builder.Services.AddAzureClients(b => b.AddSearchClient(endpoint, indexName, credential))fromMicrosoft.Extensions.Azure. Creating a client per request exhausts sockets. - Use managed identity, not keys.
DefaultAzureCredentialworks locally (via Azure CLI / Visual Studio) and in Azure (via managed identity) with zero code change. Separate query keys from admin keys if you must use keys; never ship an admin key to a browser. - Keep the index lean. Only mark fields filterable/facetable/sortable when a query needs it. Mark large fields
IsHidden = trueif they must be searchable but never returned. - Chunk long documents before embedding. Embedding models have token limits and retrieval quality drops for huge chunks. 300–500 tokens with ~10% overlap is a solid default; store a
ParentIdso you can group chunks back to the source. - Use indexers for data already in Azure. If your source is Blob Storage, Cosmos DB or Azure SQL, an indexer with an integrated vectorization skillset pulls, chunks and embeds data on a schedule with no custom code.
- Handle
RequestFailedException503 with retries. The SDK retries automatically, but tuneSearchClientOptions.Retryfor batch indexing under load and back off when you see throttling. - Measure relevance. Build a small golden set of queries with expected top results and run it in CI whenever you change analyzers, scoring profiles or the embedding model.
Common Pitfalls to Avoid
- Mismatched embedding models. Indexing with one model and querying with another yields garbage rankings without any error. Pin the model name in configuration.
- Forgetting the index is eventually consistent. Documents are searchable a few seconds after upload. Tests that query immediately after indexing can be flaky; poll or wait.
- Semantic ranker on the Free tier. It is not available there and the query fails. Check your tier before enabling
SearchQueryType.Semantic. - Paging with
Skippast 100,000. Deep paging is capped; useOrderByplus a filter on the last seen key for large result sets. - Field name casing. The SDK serialises property names using camelCase by default via
JsonSerializerOptions;FieldBuilderkeeps them consistent, but hand-builtSearchFielddefinitions must match exactly.
Conclusion: Key Takeaways
Building intelligent search with Azure AI Search and C# takes far less code than most developers expect, and the payoff — relevance, typo tolerance, facets and semantic understanding — is immediately visible to users. Here is what to remember:
- Define your schema with
FieldBuilderattributes and create the index idempotently withCreateOrUpdateIndexAsync. - Upload in batches, always check per-document results, and use the buffered sender for bulk loads.
- Start with full-text search plus filters and facets; it covers most use cases on its own.
- Add vector fields and hybrid queries when you need meaning-based retrieval or a RAG pipeline, and enable the semantic ranker for the final quality boost.
- In production, use singleton clients, managed identity, lean indexes and a relevance test suite.
Clone the snippets above into a console app, point them at a Free-tier service, and you will have a working Azure AI Search C# integration in under an hour. From there, the same index can power your website search, an internal knowledge assistant, or a full Copilot-style experience over your own data.
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