Skip to main content

Azure AI Search with C#: Build Smart Search (2026)

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 SearchClient instantiation exhausts sockets under load.
  • Use Select to 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.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...