Keyword search finds what users type. Semantic search finds what users mean. The difference matters when your documents use different terminology than your users, when queries are phrased as natural questions, or when the intent is conceptual rather than lexical. This workshop builds a semantic search API from the ground up using Azure AI Search and Azure OpenAI embeddings.
You will create a search index with vector fields, generate embeddings, index documents, and expose three search modes through a .NET 9 Minimal API: pure vector search, hybrid search (BM25 + vector), and hybrid search with semantic ranking. The project is fully runnable.
Prerequisites
- .NET 9 SDK installed
- An Azure AI Search resource (Basic tier or higher for vector search)
- An Azure OpenAI resource with a deployed
text-embedding-3-smallmodel - Your Azure AI Search endpoint and admin key
- Your Azure OpenAI endpoint, API key, and embedding deployment name
To understand how embeddings power retrieval-augmented generation, the RAG Explained for .NET Developers guide provides essential context.
Step 1 — Scaffold the Project
dotnet new webapi -n SemanticSearchApi --use-minimal-apis
cd SemanticSearchApi
dotnet add package Azure.Search.Documents --version 11.7.0
dotnet add package Azure.AI.OpenAI --version 2.1.0
The Azure.Search.Documents SDK provides the search client. Azure.AI.OpenAI handles embedding generation.
Step 2 — Application Configuration
appsettings.json
{
"AzureSearch": {
"Endpoint": "https://<your-search-service>.search.windows.net",
"AdminKey": "<your-admin-key>",
"IndexName": "articles"
},
"AzureOpenAI": {
"Endpoint": "https://<your-resource>.openai.azure.com/",
"ApiKey": "<your-api-key>",
"EmbeddingDeployment": "text-embedding-3-small"
},
"Logging": {
"LogLevel": {
"Default": "Information"
}
}
}
Step 3 — Define Configuration and Document Models
Models/AzureSearchSettings.cs
namespace SemanticSearchApi.Models;
public sealed class AzureSearchSettings
{
public const string SectionName = "AzureSearch";
public required string Endpoint { get; init; }
public required string AdminKey { get; init; }
public string IndexName { get; init; } = "articles";
}
Models/AzureOpenAISettings.cs
namespace SemanticSearchApi.Models;
public sealed class AzureOpenAISettings
{
public const string SectionName = "AzureOpenAI";
public required string Endpoint { get; init; }
public required string ApiKey { get; init; }
public required string EmbeddingDeployment { get; init; }
}
Models/ArticleDocument.cs
using Azure.Search.Documents.Indexes;
using Azure.Search.Documents.Indexes.Models;
namespace SemanticSearchApi.Models;
public sealed class ArticleDocument
{
[SimpleField(IsKey = true, IsFilterable = true)]
public required string Id { get; init; }
[SearchableField(AnalyzerName = "en.microsoft")]
public required string Title { get; init; }
[SearchableField(AnalyzerName = "en.microsoft")]
public required string Content { get; init; }
[SimpleField(IsFilterable = true, IsFacetable = true)]
public required string Category { get; init; }
[VectorSearchField(
VectorSearchDimensions = 1536,
VectorSearchProfileName = "default-vector-profile")]
public ReadOnlyMemory<float>? ContentVector { get; init; }
}
Models/SearchRequest.cs
namespace SemanticSearchApi.Models;
public sealed class SearchRequest
{
public required string Query { get; init; }
public int Top { get; init; } = 5;
public string? CategoryFilter { get; init; }
}
Models/SearchResult.cs
namespace SemanticSearchApi.Models;
public sealed class SearchResultItem
{
public required string Id { get; init; }
public required string Title { get; init; }
public required string Content { get; init; }
public required string Category { get; init; }
public double? Score { get; init; }
public double? RerankerScore { get; init; }
}
The VectorSearchField attribute on ContentVector tells Azure AI Search this field stores 1536-dimensional embeddings and uses the default-vector-profile for search. The dimension count matches text-embedding-3-small output.
Step 4 — Build the Index Management Service
This service creates the search index with both keyword and vector search capabilities, plus a semantic configuration for re-ranking.
Services/IndexService.cs
using Azure;
using Azure.Search.Documents.Indexes;
using Azure.Search.Documents.Indexes.Models;
using Microsoft.Extensions.Options;
using SemanticSearchApi.Models;
namespace SemanticSearchApi.Services;
public sealed class IndexService
{
private readonly SearchIndexClient _indexClient;
private readonly AzureSearchSettings _settings;
private readonly ILogger<IndexService> _logger;
public IndexService(
SearchIndexClient indexClient,
IOptions<AzureSearchSettings> settings,
ILogger<IndexService> logger)
{
_indexClient = indexClient;
_settings = settings.Value;
_logger = logger;
}
public async Task CreateOrUpdateIndexAsync()
{
var vectorSearch = new VectorSearch();
vectorSearch.Algorithms.Add(
new HnswAlgorithmConfiguration("default-hnsw")
{
Parameters = new HnswParameters
{
Metric = VectorSearchAlgorithmMetric.Cosine,
M = 4,
EfConstruction = 400,
EfSearch = 500
}
});
vectorSearch.Profiles.Add(
new VectorSearchProfile("default-vector-profile", "default-hnsw"));
var semanticConfig = new SemanticConfiguration(
"default-semantic-config",
new SemanticPrioritizedFields
{
TitleField = new SemanticField("Title"),
ContentFields = { new SemanticField("Content") }
});
var semanticSearch = new SemanticSearch();
semanticSearch.Configurations.Add(semanticConfig);
var index = new SearchIndex(_settings.IndexName)
{
VectorSearch = vectorSearch,
SemanticSearch = semanticSearch,
Fields = new FieldBuilder().Build(typeof(ArticleDocument))
};
await _indexClient.CreateOrUpdateIndexAsync(index);
_logger.LogInformation(
"Index '{IndexName}' created or updated", _settings.IndexName);
}
}
The HNSW (Hierarchical Navigable Small World) algorithm is the industry standard for approximate nearest-neighbor search. The parameters control the trade-off between search quality and speed. EfSearch = 500 favors accuracy; reduce it for faster but less precise results.
Step 5 — Build the Embedding Service
Services/EmbeddingService.cs
using Azure.AI.OpenAI;
using Microsoft.Extensions.Options;
using OpenAI.Embeddings;
using SemanticSearchApi.Models;
namespace SemanticSearchApi.Services;
public sealed class EmbeddingService
{
private readonly AzureOpenAIClient _aiClient;
private readonly AzureOpenAISettings _settings;
public EmbeddingService(
AzureOpenAIClient aiClient,
IOptions<AzureOpenAISettings> settings)
{
_aiClient = aiClient;
_settings = settings.Value;
}
public async Task<ReadOnlyMemory<float>> GenerateEmbeddingAsync(string text)
{
var embeddingClient = _aiClient.GetEmbeddingClient(
_settings.EmbeddingDeployment);
OpenAIEmbedding embedding = await embeddingClient.GenerateEmbeddingAsync(text);
return embedding.ToFloats();
}
public async Task<IReadOnlyList<ReadOnlyMemory<float>>> GenerateEmbeddingsAsync(
IEnumerable<string> texts)
{
var embeddingClient = _aiClient.GetEmbeddingClient(
_settings.EmbeddingDeployment);
var textList = texts.ToList();
OpenAIEmbeddingCollection embeddings =
await embeddingClient.GenerateEmbeddingsAsync(textList);
return embeddings.Select(e => e.ToFloats()).ToList();
}
}
The batch method GenerateEmbeddingsAsync is critical for indexing. Sending multiple texts in one API call is faster and cheaper than individual calls. The Azure OpenAI embedding endpoint supports up to 2048 inputs per batch.
Step 6 — Build the Search Service
Services/SearchService.cs
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Microsoft.Extensions.Options;
using SemanticSearchApi.Models;
namespace SemanticSearchApi.Services;
public sealed class SearchService
{
private readonly SearchClient _searchClient;
private readonly EmbeddingService _embeddingService;
public SearchService(
SearchClient searchClient,
EmbeddingService embeddingService)
{
_searchClient = searchClient;
_embeddingService = embeddingService;
}
public async Task<List<SearchResultItem>> VectorSearchAsync(SearchRequest request)
{
var queryVector = await _embeddingService.GenerateEmbeddingAsync(request.Query);
var searchOptions = new SearchOptions
{
Size = request.Top,
Select = { "Id", "Title", "Content", "Category" },
VectorSearch = new()
{
Queries =
{
new VectorizedQuery(queryVector)
{
KNearestNeighborsCount = request.Top,
Fields = { "ContentVector" }
}
}
}
};
if (!string.IsNullOrEmpty(request.CategoryFilter))
searchOptions.Filter = $"Category eq '{request.CategoryFilter}'";
return await ExecuteSearchAsync(searchOptions);
}
public async Task<List<SearchResultItem>> HybridSearchAsync(SearchRequest request)
{
var queryVector = await _embeddingService.GenerateEmbeddingAsync(request.Query);
var searchOptions = new SearchOptions
{
Size = request.Top,
Select = { "Id", "Title", "Content", "Category" },
VectorSearch = new()
{
Queries =
{
new VectorizedQuery(queryVector)
{
KNearestNeighborsCount = request.Top,
Fields = { "ContentVector" }
}
}
}
};
if (!string.IsNullOrEmpty(request.CategoryFilter))
searchOptions.Filter = $"Category eq '{request.CategoryFilter}'";
// Passing the query text enables BM25 keyword search alongside vector
return await ExecuteSearchAsync(searchOptions, request.Query);
}
public async Task<List<SearchResultItem>> SemanticHybridSearchAsync(
SearchRequest request)
{
var queryVector = await _embeddingService.GenerateEmbeddingAsync(request.Query);
var searchOptions = new SearchOptions
{
Size = request.Top,
Select = { "Id", "Title", "Content", "Category" },
QueryType = SearchQueryType.Semantic,
SemanticSearch = new()
{
SemanticConfigurationName = "default-semantic-config",
QueryCaption = new(QueryCaptionType.Extractive),
QueryAnswer = new(QueryAnswerType.Extractive)
},
VectorSearch = new()
{
Queries =
{
new VectorizedQuery(queryVector)
{
KNearestNeighborsCount = request.Top,
Fields = { "ContentVector" }
}
}
}
};
if (!string.IsNullOrEmpty(request.CategoryFilter))
searchOptions.Filter = $"Category eq '{request.CategoryFilter}'";
return await ExecuteSearchAsync(searchOptions, request.Query);
}
private async Task<List<SearchResultItem>> ExecuteSearchAsync(
SearchOptions options, string? searchText = null)
{
var response = await _searchClient.SearchAsync<ArticleDocument>(
searchText, options);
var results = new List<SearchResultItem>();
await foreach (var result in response.Value.GetResultsAsync())
{
results.Add(new SearchResultItem
{
Id = result.Document.Id,
Title = result.Document.Title,
Content = result.Document.Content,
Category = result.Document.Category,
Score = result.Score,
RerankerScore = result.SemanticSearch?.RerankerScore
});
}
return results;
}
}
Three search modes offer progressive sophistication. VectorSearchAsync finds semantically similar documents based purely on embedding distance. HybridSearchAsync combines that with BM25 keyword scoring via Reciprocal Rank Fusion — the fusion algorithm merges two ranked lists without requiring manual weight tuning. SemanticHybridSearchAsync adds a semantic ranker that uses a transformer model to re-rank the hybrid results for maximum relevance.
Step 7 — Wire Up Program.cs
using System.ClientModel.Primitives;
using Azure;
using Azure.AI.OpenAI;
using Azure.Search.Documents;
using Azure.Search.Documents.Indexes;
using Microsoft.Extensions.Options;
using SemanticSearchApi.Models;
using SemanticSearchApi.Services;
var builder = WebApplication.CreateBuilder(args);
// Bind configuration
builder.Services.Configure<AzureSearchSettings>(
builder.Configuration.GetSection(AzureSearchSettings.SectionName));
builder.Services.Configure<AzureOpenAISettings>(
builder.Configuration.GetSection(AzureOpenAISettings.SectionName));
// Register Azure AI Search clients
builder.Services.AddSingleton(sp =>
{
var settings = builder.Configuration
.GetSection(AzureSearchSettings.SectionName)
.Get<AzureSearchSettings>()!;
return new SearchIndexClient(
new Uri(settings.Endpoint),
new AzureKeyCredential(settings.AdminKey));
});
builder.Services.AddSingleton(sp =>
{
var settings = builder.Configuration
.GetSection(AzureSearchSettings.SectionName)
.Get<AzureSearchSettings>()!;
return new SearchClient(
new Uri(settings.Endpoint),
settings.IndexName,
new AzureKeyCredential(settings.AdminKey));
});
// Register Azure OpenAI client
builder.Services.AddSingleton(sp =>
{
var settings = builder.Configuration
.GetSection(AzureOpenAISettings.SectionName)
.Get<AzureOpenAISettings>()!;
return new AzureOpenAIClient(
new Uri(settings.Endpoint),
new AzureKeyCredential(settings.ApiKey));
});
// Register services
builder.Services.AddSingleton<IndexService>();
builder.Services.AddSingleton<EmbeddingService>();
builder.Services.AddScoped<SearchService>();
var app = builder.Build();
// Initialize index on startup
app.MapPost("/api/admin/create-index", async (IndexService indexService) =>
{
await indexService.CreateOrUpdateIndexAsync();
return Results.Ok("Index created or updated.");
});
// Index sample documents
app.MapPost("/api/admin/index-documents", async (
SearchClient searchClient,
EmbeddingService embeddingService) =>
{
var documents = GetSampleDocuments();
var contents = documents.Select(d => d.Content).ToList();
var embeddings = await embeddingService.GenerateEmbeddingsAsync(contents);
var indexDocuments = documents.Zip(embeddings, (doc, vector) =>
new ArticleDocument
{
Id = doc.Id,
Title = doc.Title,
Content = doc.Content,
Category = doc.Category,
ContentVector = vector
}).ToList();
var batch = IndexDocumentsBatch.Upload(indexDocuments);
await searchClient.IndexDocumentsAsync(batch);
return Results.Ok($"{indexDocuments.Count} documents indexed.");
});
// Pure vector search
app.MapPost("/api/search/vector", async (
SearchRequest request, SearchService search) =>
{
var results = await search.VectorSearchAsync(request);
return Results.Ok(results);
});
// Hybrid search (BM25 + vector)
app.MapPost("/api/search/hybrid", async (
SearchRequest request, SearchService search) =>
{
var results = await search.HybridSearchAsync(request);
return Results.Ok(results);
});
// Semantic hybrid search (BM25 + vector + semantic ranker)
app.MapPost("/api/search/semantic", async (
SearchRequest request, SearchService search) =>
{
var results = await search.SemanticHybridSearchAsync(request);
return Results.Ok(results);
});
app.Run();
// Sample data for indexing
static List<(string Id, string Title, string Content, string Category)> GetSampleDocuments() =>
[
("1", "Introduction to Dependency Injection",
"Dependency injection is a design pattern that implements inversion of control for resolving dependencies. In .NET, the built-in DI container manages service lifetimes including transient, scoped, and singleton. Constructor injection is the most common approach, where dependencies are provided through class constructors.",
"Architecture"),
("2", "Understanding Async/Await in C#",
"Asynchronous programming in C# uses the async and await keywords to write non-blocking code. The Task-based Asynchronous Pattern (TAP) is the recommended approach. When you await a task, the current method is suspended until the awaited task completes, freeing the thread to handle other work.",
"CSharp"),
("3", "Getting Started with Entity Framework Core",
"Entity Framework Core is an object-relational mapper for .NET that eliminates the need for most data-access code. It supports LINQ queries, change tracking, updates, and schema migrations. EF Core works with SQL Server, SQLite, PostgreSQL, and other databases through provider plugins.",
"Data"),
("4", "Building REST APIs with ASP.NET Core",
"ASP.NET Core provides a powerful framework for building RESTful web APIs. Minimal APIs offer a lightweight approach with less ceremony, while controllers provide more structure. Both support model binding, validation, content negotiation, and OpenAPI documentation generation.",
"WebDev"),
("5", "Vector Databases and Semantic Search",
"Vector databases store high-dimensional embeddings and enable similarity search. Unlike traditional keyword search, vector search finds semantically similar content regardless of exact word matches. Common algorithms include HNSW and IVF for approximate nearest neighbor search.",
"AI"),
("6", "Prompt Engineering for .NET Developers",
"Effective prompt engineering involves crafting clear instructions for large language models. Techniques include few-shot examples, chain-of-thought reasoning, and system message design. .NET developers can use the Azure OpenAI SDK to implement sophisticated prompt strategies in their applications.",
"AI"),
("7", "Microservices Communication Patterns",
"Microservices communicate through synchronous protocols like HTTP/gRPC or asynchronous messaging with queues and event buses. Service discovery, circuit breakers, and retry policies ensure resilient communication. In .NET, libraries like Polly and MassTransit simplify these patterns.",
"Architecture"),
("8", "Securing .NET Applications with OAuth 2.0",
"OAuth 2.0 and OpenID Connect provide industry-standard protocols for authentication and authorization. ASP.NET Core Identity and Microsoft.Identity.Web simplify integration with identity providers like Microsoft Entra ID. Token validation, refresh token flows, and scope-based authorization protect API endpoints.",
"Security")
];
Step 8 — Test the API
Start the application and run the setup calls first:
dotnet run
Create the index:
curl -X POST http://localhost:5000/api/admin/create-index
Index sample documents:
curl -X POST http://localhost:5000/api/admin/index-documents
Wait a few seconds for Azure AI Search to make the documents searchable, then try each search mode.
Pure vector search:
curl -X POST http://localhost:5000/api/search/vector \
-H "Content-Type: application/json" \
-d '{"query": "how do I make my code more testable", "top": 3}'
This should return the dependency injection article first, even though the query does not contain the words “dependency” or “injection.” That is semantic search working.
Hybrid search:
curl -X POST http://localhost:5000/api/search/hybrid \
-H "Content-Type: application/json" \
-d '{"query": "async await C# tasks", "top": 3}'
Hybrid search combines keyword matches (exact hits on “async”, “await”, “C#”) with semantic understanding, producing more robust results.
Semantic hybrid search:
curl -X POST http://localhost:5000/api/search/semantic \
-H "Content-Type: application/json" \
-d '{"query": "What is the best way to call a database without blocking the thread?", "top": 3}'
The semantic ranker re-scores results using a cross-encoder model, pushing the most contextually relevant document to the top. Expect the async/await and EF Core articles to surface.
Filtered search:
curl -X POST http://localhost:5000/api/search/vector \
-H "Content-Type: application/json" \
-d '{"query": "best practices for AI", "top": 3, "categoryFilter": "AI"}'
Complete Project Structure
SemanticSearchApi/
Program.cs
appsettings.json
Models/
AzureSearchSettings.cs
AzureOpenAISettings.cs
ArticleDocument.cs
SearchRequest.cs
SearchResult.cs
Services/
IndexService.cs
EmbeddingService.cs
SearchService.cs
What You Learned
You built a semantic search API that goes beyond keyword matching. The index stores both text (for BM25) and vector embeddings (for semantic similarity) in a single Azure AI Search resource. Three search strategies offer different trade-offs: pure vector for maximum semantic coverage, hybrid for the best of both worlds, and semantic hybrid with re-ranking for peak relevance.
This search layer is the retrieval half of retrieval-augmented generation. To combine it with a chat model, continue with the RAG Chatbot with Semantic Kernel and Cosmos DB workshop. The Azure.Search.Documents SDK on NuGet and the Azure AI Search vector search documentation cover the full API surface. For the latest SDK changes, see the Azure.Search.Documents 11.7.0 release notes.