Table of Contents

Introduction

You can find all the C# code samples here: Embeddings GitHub Repository

I encourage you to read these posts first to get the most out of the information provided here:

If you want to explore how vector search works in Azure Cosmos DB for NoSQL, be sure to read this post:

As you know from the previous posts, Azure AI Search is one of the primary services to consider when you need fast and scalable vector search. With that in mind, let’s jump straight into the demo and see how vector search in Azure AI Search works in practice.

Demo

In one of the previous posts, we went through a simple custom implementation of a vector database in C# (with and without the FAISS library). I would like to continue using the same sample dataset here, but instead of a custom local vector DB, we’ll use vector search in Azure AI Search.

In this article, we’re going to use the push approach. Of course, we could also use other techniques (using the pull approach in conjunction with a skillset and the appropriate vectorizer), such as:

  • an integrated Azure OpenAI skill Microsoft.Skills.Text.AzureOpenAIEmbeddingSkill
  • a custom Web API skill Microsoft.Skills.Custom.WebApiSkill
  • a custom AML skill Microsoft.Skills.Custom.AmlSkill
  • an Azure Vision multimodal skill Microsoft.Skills.Vision.VectorizeSkill

All of these alternatives deserve their own dedicated posts, so to keep things focused and practical, we’ll stick with the push approach here.

Creating the vector index

At the beginning, let’s define a C# class that we’ll use to represent each document stored in our index. This model will map directly to the fields we plan to push into Azure AI Search.

namespace DeployedInAzure.EmbeddingsExamples.AiSearchVectorSearch
{
    public record AiSearchVectorSearchDocumentModel
    {
        public required string id { get; init; }
        public required string Phrase { get; init; }
        public required List<string> Tags { get; init; } = [];
        public required float[] Vector { get; init; }
    }
}

Now we can create the index definition (I removed some fields to keep it short).

{
  "@odata.etag": "\"0x8DE4568CDA5118B\"",
  "name": "vector-search-index",
  "purviewEnabled": false,
  "fields": [
    {
      "name": "id",
      "type": "Edm.String",
      "searchable": false,
      "filterable": false,
      "retrievable": true,
      "stored": true,
      "sortable": false,
      "facetable": false,
      "key": true,
      "synonymMaps": []
    },
    {
      "name": "Phrase",
      "type": "Edm.String",
      "searchable": false,
      "filterable": false,
      "retrievable": true,
      "stored": true,
      "sortable": false,
      "facetable": false,
      "key": false,
      "synonymMaps": []
    },
    {
      "name": "Tags",
      "type": "Collection(Edm.String)",
      "searchable": false,
      "filterable": true,
      "retrievable": true,
      "stored": true,
      "sortable": false,
      "facetable": false,
      "key": false,
      "synonymMaps": []
    },
    {
      "name": "Vector",
      "type": "Collection(Edm.Single)",
      "searchable": true,
      "filterable": false,
      "retrievable": false,
      "stored": true,
      "sortable": false,
      "facetable": false,
      "key": false,
      "dimensions": 1536,
      "vectorSearchProfile": "vector-profile-01",
      "synonymMaps": []
    }
  ],
  "vectorSearch": {
    "algorithms": [
      {
        "name": "hnsw-algorithm",
        "kind": "hnsw",
        "hnswParameters": {
          "metric": "cosine",
          "m": 4,
          "efConstruction": 400,
          "efSearch": 500
        }
      }
    ],
    "profiles": [
      {
        "name": "vector-profile-01",
        "algorithm": "hnsw-algorithm"
      }
    ]
  }
}

As you can see, every property in the AiSearchVectorSearchDocumentModel is mapped to a corresponding field within the index. Let’s focus on the Vector field for a moment. First, it has the Collection(Edm.Single) type assigned, which simply means an array of Float32 numbers (you can see all supported data types here). Second, we want to perform a vector search based on that field so we have to mark this field as "searchable": true . Third, we do not want our Vector field to be returned in the results, so retrievable is set to false.

Our embedding model generates vectors with 1536 dimensions, and you can see the same value assigned to the dimensions property. The last property, which is particularly important, is vectorSearchProfile, which points to the vector-profile-01 I created.

This is how it looks in the Azure Portal.

Azure AI Search index with a single vector profile created.

As you can see on the screen, there are three things that can be attached to a single vector profile:

  • Algorithm – defines the algorithm used to perform vector search and allows you to tune it to your needs (with some reasonable defaults).
  • Vectorizer – responsible for automatically converting the user’s text query into an embedding (this will be the topic of the next post).
  • Compression – algorithm used to reduce the size of the vector while still preserving high search accuracy (a dedicated post will be published soon).

I’m going to dedicate separate posts to Vectorizers and Compression, so let’s focus on the Algorithm for now.

Vector algorithm screen within the Azure AI Search service.

What you can see in the screenshot above is simply a visual representation of what is also defined in the JSON index definition. Let’s analyze it.

First of all, I selected the HNSW algorithm (hnsw). The other option is Exhaustive K-Nearest Neighbors (exhaustiveKnn), which is a brute-force like method similar to what we implemented in the custom vector DB written in C#: DeployedInAzureVectorDb and DeployedInAzureVectorDbFaiss (check the GitHub repo to see the implementation details).

Then we have 4 parameters that allow us to control and tune the algorithm (you can read more here):

  • Bi-directional link count – sets how many neighbors each vector can connect to when building the graph.
  • efConstruction – controls how many candidate nodes are considered when inserting a new vector.
  • efSearch – defines how many candidate nodes are explored during query‑time traversal.
  • Similarity metric – defines how vector distance is calculated. Possible options: cosine , dotProduct , euclidean , hamming.

At the beginning of your journey with Azure AI Search, I recommend leaving the default values as they are and fine-tuning them only if needed (it is very likely you can achieve better accuracy by performing a hybrid search but there will be a dedicated post to explain that issue so stay tuned!).

That’s enough theory so let’s focus on the C# code now. Before we do that, however, we need to assign the appropriate RBAC role (because we don’t want to use API keys after reading this article… right?)

Assigning RBAC role

The RBAC role we need to assign is Search Index Data Contributor. Let’s delve into a few more details to get a full understanding of what’s going on. After reading this post, you should already be familiar with the structure of an RBAC role, so I hope what you see below makes sense to you.

{
    "id": "/subscriptions/cf9e4b91-d6e2-4ae8-a891-541e24bf8e07/providers/Microsoft.Authorization/roleDefinitions/8ebe5a00-799e-43f5-93ac-243d3dce84a7",
    "properties": {
        "roleName": "Search Index Data Contributor",
        "description": "Grants full access to Azure Cognitive Search index data.",
        "assignableScopes": [
            "/"
        ],
        "permissions": [
            {
                "actions": [],
                "notActions": [],
                "dataActions": [
                    "Microsoft.Search/searchServices/indexes/documents/*"
                ],
                "notDataActions": []
            }
        ]
    }
}

In the code samples our tiny app indexes documents and then reads data and therefore that role is most suitable but it’s very likely that in the production app you will have a separate process for data indexing and data reading and then you should consider using Search Index Data Reader RBAC role.

ℹ️ If you have very strict security requirements, you can narrow the scope of the RBAC role assignment to an individual index. By default, you usually assign RBAC permissions at the Azure AI Search service level, which means they apply to all indexes. Please be aware of this alternative. When assigning an RBAC role for a specific index, just use the pattern below when defining the scope:

/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Search/searchServices/<search-service>/indexes/<index-name>

❗❗❗There is one more thing which might be easily overlooked (yeah… I know this from my own experience…). Please make user that you have Role-based access control enabled (or Both) in the Keys section.

Keys section within the Azure AI Search service with Role-based access control option enabled.

Now we can really focus on the C# code!

Data indexing

This is how data indexing process looks like:

private async Task UpsertSampleDocumentsAsync()
{
    var documentsToBeIndexed = new List<AiSearchVectorSearchDocumentModel>();

    foreach (var item in TestData.GetAllTestData().Select((phraseAndTagPair, index) => (phraseAndTagPair, Index: index + 1)))
    {
        // this could be run in parallel if needed too using Task.WhenAll
        var response = await _embeddingClient.GenerateEmbeddingAsync(item.phraseAndTagPair.Phrase);

        var document = new AiSearchVectorSearchDocumentModel
        {
            id = item.Index.ToString(),
            Phrase = item.phraseAndTagPair.Phrase,
            Vector = response.Value.ToFloats().ToArray(),
            Tags = [item.phraseAndTagPair.Tag]
        };

        documentsToBeIndexed.Add(document);
    }

    var batch = IndexDocumentsBatch.Upload(documentsToBeIndexed);

    // if you use Visual Studio Authentication and 401 or 403 is returned even if you have 'Search Index Data Contributor' RBAC role assigned
    // make sure to set the environment variable `AZURE_TENANT_ID` to your Entra tenant ID where the Microsoft Foundry resource is deployed
    var indexDocumentsResult = await _searchClient.IndexDocumentsAsync(batch);

    Console.WriteLine($"`{indexDocumentsResult.Value.Results.Where(x => x.Succeeded).Count()}` documents were uploaded to Azure AI Search successfully!");
}

As you can see, we generate an embedding using the text-embedding-3-small model for each phrase, and then push all of the objects to the index using the IndexDocumentsBatch class. I think this part is straightforward enough, so let’s move straight to the search section.

Vector Search

private async Task<IReadOnlyCollection<AiSearchVectorSearchResult>> FindSimilarItemsAsync(string keyword, int topK)
{
    var queryVector = (await _embeddingClient.GenerateEmbeddingAsync(keyword)).Value.ToFloats();

    var searchOptions = new SearchOptions
    {
        VectorSearch = new VectorSearchOptions
        {
            Queries =
            {
                new VectorizedQuery(queryVector)
                {
                    KNearestNeighborsCount = topK,
                    Fields = { nameof(AiSearchVectorSearchDocumentModel.Vector) }
                }
            }
        },
        Select =
        {
            nameof(AiSearchVectorSearchDocumentModel.id),
            nameof(AiSearchVectorSearchDocumentModel.Phrase),
            nameof(AiSearchVectorSearchDocumentModel.Tags)
        },

    };

    var response = await _searchClient.SearchAsync<AiSearchVectorSearchDocumentModel>(searchText: null, searchOptions);

    var results = new List<AiSearchVectorSearchResult>();
    await foreach (var searchResult in response.Value.GetResultsAsync())
    {
        var result = new AiSearchVectorSearchResult
        {
            id = searchResult.Document.id,
            Phrase = searchResult.Document.Phrase,
            Tags = searchResult.Document.Tags,
            SimilarityScore = searchResult.Score.GetValueOrDefault()
        };

        results.Add(result);
    }

    return results;
}

As you can see, the first thing we do is convert the keyword value into an embedding using the EmbeddingClient. Then we create a SearchOptions object and define a single query of type VectorizedQuery. We pass the embedding to the constructor and configure two things:

  • KNearestNeighborsCount – specifies how many elements we want to be returned
  • Fields – indicates which vector field the vector search should be invoked against. Since our index contains only one vector field (Vector), this property has just a single value.

You can also see that this is a pure vector search because we didn’t pass any value to the SearchAsync function (searchText: null). Of course, we could provide one to unleash the full power of hybrid search but we’ll cover that in a separate post.

Another interesting aspect of search functionality in Azure AI Search is the possibility to:

  • use a single embedding to run a query against multiple vector fields, for example: Fields = { "SomeVectorA", "AnotherVectorB" }. The important point here is that all vector fields must store values from the same embedding space.
  • run independent vector queries, for example: Queries = { new VectorizedQuery(...), new VectorizedQuery(...) }, which are then combined using the RRF function. This can be particularly useful for multi‑modal queries (such as TEXT + IMAGE).

This topic also deserves a separate post to fully explore the concept, so let’s not dive deeper into it here.

Below are the results for our 4 known keywords (Mars, Apollo 11, Neil Armstrong, Curiosity Rover):

Top 5 similar items to "Mars":
- [Tag:Mars] Mars exploration: 0.80
- [Tag:Mars] Mars atmosphere: 0.79
- [Tag:Curiosity Rover] Mars rover: 0.78
- [Tag:Curiosity Rover] Mars Science Laboratory: 0.77
- [Tag:Mars] Martian surface: 0.76

Top 5 similar items to "Apollo 11":
- [Tag:Apollo 11] Moon landing mission: 0.71
- [Tag:Neil Armstrong] First man on the Moon: 0.69
- [Tag:Neil Armstrong] Buzz Aldrin: 0.68
- [Tag:Apollo 11] NASA 1969: 0.65
- [Tag:Curiosity Rover] Rover landing: 0.65

Top 5 similar items to "Neil Armstrong":
- [Tag:Neil Armstrong] Buzz Aldrin: 0.75
- [Tag:Neil Armstrong] First man on the Moon: 0.73
- [Tag:Apollo 11] Moon landing mission: 0.70
- [Tag:Neil Armstrong] Astronaut: 0.69
- [Tag:Neil Armstrong] NASA astronaut corps: 0.65

Top 5 similar items to "Curiosity Rover":
- [Tag:Curiosity Rover] Mars rover: 0.80
- [Tag:Curiosity Rover] Mars Science Laboratory: 0.72
- [Tag:Curiosity Rover] Rover landing: 0.71
- [Tag:Mars] Mars exploration: 0.71
- [Tag:Mars] Red Planet: 0.66

Vector Search with Role‑Based Filtering

Now, let’s apply one tiny change to our search function to show one more interesting aspect which you may find helpful in your projects. As you can see based on the above results the most similar phrases sometimes contains a different tag which is fine but… what if we wanted to return just items with a given tag?

if (!string.IsNullOrWhiteSpace(tag))
{
    searchOptions.Filter = $"Tags/any(t: t eq '{tag}')";
}

We can simply combine our vector search query with a standard filter query applied to the Tags field, which is marked as "filterable": true. I wanted to show this example because it addresses a common challenge: returning data based on user roles. If you know the roles of the user invoking the query, and you have a Roles string collection defined as a filterable field in Azure AI Search, you can easily cross-match these values to ensure that only the appropriate items are returned for that user.

Below are the results:

Top 5 similar items to "Mars":
- [Tag:Mars] Mars exploration: 0.80
- [Tag:Mars] Mars atmosphere: 0.79
- [Tag:Mars] Martian surface: 0.76
- [Tag:Mars] Red Planet: 0.76
- [Tag:Mars] Olympus Mons: 0.64

Top 5 similar items to "Apollo 11":
- [Tag:Apollo 11] Moon landing mission: 0.71
- [Tag:Apollo 11] NASA 1969: 0.65
- [Tag:Apollo 11] Lunar module: 0.64
- [Tag:Apollo 11] Saturn V rocket: 0.63
- [Tag:Apollo 11] Sea of Tranquility: 0.57

Top 5 similar items to "Neil Armstrong":
- [Tag:Neil Armstrong] Buzz Aldrin: 0.75
- [Tag:Neil Armstrong] First man on the Moon: 0.73
- [Tag:Neil Armstrong] Astronaut: 0.69
- [Tag:Neil Armstrong] NASA astronaut corps: 0.65
- [Tag:Neil Armstrong] Space suit: 0.59

Top 5 similar items to "Curiosity Rover":
- [Tag:Curiosity Rover] Mars rover: 0.80
- [Tag:Curiosity Rover] Mars Science Laboratory: 0.72
- [Tag:Curiosity Rover] Rover landing: 0.71
- [Tag:Curiosity Rover] Martian soil analysis: 0.65
- [Tag:Curiosity Rover] Gale Crater: 0.63

ℹ️ Of course, with such a setup, you need to ensure that whenever the roles for a given document (or any other data source) change, you also update all the corresponding chunks stored in Azure AI Search.

Filter mode in Azure AI Search

Above, I mentioned one use case that you may find helpful, but I also need to highlight one more imortant thing which is the FilterMode.

It allows you to control WHEN given filter is applied. Before the vector search, or after it?

var searchOptions = new SearchOptions
{
    VectorSearch = new VectorSearchOptions
    {
        Queries =
        {
            new VectorizedQuery(queryVector)
            {
                KNearestNeighborsCount = topK,
                Fields = { nameof(AiSearchVectorSearchDocumentModel.Vector) }
            }
        },
        FilterMode = VectorFilterMode.PostFilter
    },
    Select =
    {
        nameof(AiSearchVectorSearchDocumentModel.id),
        nameof(AiSearchVectorSearchDocumentModel.Phrase),
        nameof(AiSearchVectorSearchDocumentModel.Tags)
    },
    Size = topK
};

You can control it using FilterMode (line 13) when specifying SearchOptions. The default value is PreFilter .

Now look for a moment at the results returned when no tag filter was applied. We can see a mix of various tags. I deliberately kept topK at 5 to show you one implication of the PostFilter mode.

Top 5 similar items to "Mars":
- [Tag:Mars] Mars exploration: 0.80
- [Tag:Mars] Mars atmosphere: 0.79
- [Tag:Mars] Martian surface: 0.76

Top 5 similar items to "Apollo 11":
- [Tag:Apollo 11] Moon landing mission: 0.71
- [Tag:Apollo 11] NASA 1969: 0.65

Top 5 similar items to "Neil Armstrong":
- [Tag:Neil Armstrong] Buzz Aldrin: 0.75
- [Tag:Neil Armstrong] First man on the Moon: 0.73
- [Tag:Neil Armstrong] Astronaut: 0.69
- [Tag:Neil Armstrong] NASA astronaut corps: 0.65

Top 5 similar items to "Curiosity Rover":
- [Tag:Curiosity Rover] Mars rover: 0.80
- [Tag:Curiosity Rover] Mars Science Laboratory: 0.72
- [Tag:Curiosity Rover] Rover landing: 0.71

In this setup, the search may return fewer results than requested, because the filter is applied only after the vector search has already selected its top candidates.

To fully understand the pros and cons of each approach (preFilter , postFilter and strictPostFilter which is still in preview) I encourage you to get familiar with that comparison table.

❗❗❗ Please be aware that applying filters when invoking vector search comes with a cost, so I encourage you once again to read the documentation carefully to understand all the consequences. Get familiar with the exhaustive=true property when using preFilter).

Summary

I hope that after reading this guide you feel confident and ready to start using vector search in Azure AI Search in your own projects. There’s a lot of power packed into this service, and once you get comfortable with vector search, filters, and the different configuration options, you’ll be able to build fast, intelligent search experiences with ease. I’m excited to see what you create next.

Thanks for your time and see you in the next post!

Categorized in:

AI Services,