Microsoft Agent Framework Microsoft.Extensions.AI Created: 12 Jul 2026 Updated: 12 Jul 2026

Hybrid Search in .NET with SQL Server: Combining Vector and Keyword Search Using Reciprocal Rank Fusion (RRF)

Modern search experiences demand more than simple keyword matching. Users expect a search box to understand meaning ("wireless audio device" should find "Bluetooth Headphones") while still respecting exact terms ("USB-C 65W" should match precisely). Neither vector search nor keyword search alone solves both problems well. Hybrid search combines them — and Reciprocal Rank Fusion (RRF) is the industry-standard algorithm for merging the two result sets.

In this article we will build a complete, fully working hybrid search endpoint using:

  1. .NET 10 Minimal APIs
  2. Entity Framework Core with the native SQL Server vector type
  3. SQL Server Full-Text Search (CONTAINSTABLE) for keyword ranking
  4. Microsoft.Extensions.AI (IEmbeddingGenerator) for generating embeddings
  5. Reciprocal Rank Fusion to merge both rankings into a single result list

How Hybrid Search Works

  1. Vector search — converts the query into an embedding and ranks products by cosine distance (semantic similarity).
  2. Keyword search — uses SQL Server Full-Text Search to rank products by lexical relevance.
  3. Fusion — merges both ranked lists with the RRF formula:
RRF(d) = Σ 1 / (k + rank(d)) where k = 60

A document appearing near the top of both lists accumulates a high combined score, while a document ranked highly in only one list still gets a fair chance to surface.

Step 1: Enable and Verify SQL Server Full-Text Search

Keyword ranking relies on SQL Server's Full-Text Search feature. Before writing any application code, make sure the feature is installed on your SQL Server instance:

SELECT FULLTEXTSERVICEPROPERTY('IsFullTextInstalled') AS IsFullTextInstalled;

A result of 1 means Full-Text Search is installed. If it returns 0, re-run the SQL Server installer and add the Full-Text and Semantic Extractions for Search feature.

Step 2: Find the Primary Key Index of the Products Table

A full-text index requires a unique, single-column, non-nullable key index — typically the primary key. Find its name first:

SELECT name FROM sys.indexes
WHERE object_id = OBJECT_ID('dbo.Products') AND is_primary_key = 1;

In our example the primary key index is named PK_Products. Note this name — you will need it in Step 4.

Step 3: Create a Full-Text Catalog

A full-text catalog is a logical container for full-text indexes. Create one and mark it as the default:

CREATE FULLTEXT CATALOG ProductCatalog AS DEFAULT;

Step 4: Create the Full-Text Index on the Products Table

Now create the full-text index on the Name column. The LANGUAGE option controls word breaking and stemming — use 1033 for English (or 1055 for Turkish if your product names are in Turkish):

CREATE FULLTEXT INDEX ON dbo.Products
(
Name LANGUAGE 1033 -- 1033 = English, 1055 = Turkish
)
KEY INDEX PK_Products
ON ProductCatalog
WITH CHANGE_TRACKING AUTO;
GO

CHANGE_TRACKING AUTO keeps the index up to date automatically whenever rows are inserted, updated, or deleted.

Step 5: Verify the Index Population Status

Full-text index population runs asynchronously. Check whether it has finished:

SELECT OBJECTPROPERTYEX(OBJECT_ID('dbo.Products'), 'TableFullTextPopulateStatus');
-- 0 = population completed

Step 6: Test Full-Text Search Directly in SQL

Exact word match:

SELECT Id, Name
FROM dbo.Products
WHERE CONTAINS(Name, N'"Bluetooth"');

Prefix (wildcard) match:

SELECT Id, Name
FROM dbo.Products
WHERE CONTAINS(Name, N'"B*"');

Ranked results with CONTAINSTABLE — this is exactly what our application will use, because it returns a relevance RANK we can sort by:

SELECT p.Id, p.Name, ft.RANK
FROM CONTAINSTABLE(dbo.Products, Name, N'"Bluetooth"') ft
JOIN dbo.Products p ON p.Id = ft.[KEY]
ORDER BY ft.RANK DESC;

Step 7: The Product Entity and Seeding Embeddings

Each product stores its name and an embedding vector generated by an AI model. The SqlVector<float> type maps directly to SQL Server's native vector column type. The seed endpoint generates an embedding for every sample product and persists it:

// POST /products/seed — seeds sample products with their embeddings
group.MapPost("/seed", async (
AppDbContext dbContext,
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator) =>
{
if (await dbContext.Products.AnyAsync())
return Results.Ok("Products already seeded.");

var products = new List<Product>();

foreach (var name in SampleProducts)
{
var result = await embeddingGenerator.GenerateAsync([name]);
products.Add(new Product
{
Name = name,
Embedding = new SqlVector<float>(result[0].Vector)
});
}

dbContext.Products.AddRange(products);
await dbContext.SaveChangesAsync();

return Results.Ok($"{products.Count} products seeded.");
})
.WithSummary("Seeds sample products with their embeddings");

Step 8: Pure Vector Search (For Comparison)

Before combining approaches, here is the plain semantic search endpoint. It embeds the query and orders products by cosine distance using EF.Functions.VectorDistance:

// GET /products/search?q=... — semantic search
group.MapGet("/search", async (
string q,
AppDbContext dbContext,
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator) =>
{
var queryResult = await embeddingGenerator.GenerateAsync([q]);
var queryVector = new SqlVector<float>(queryResult[0].Vector);

var results = await dbContext.Products
.Where(p => p.Embedding != null)
.OrderBy(p => EF.Functions.VectorDistance("cosine", p.Embedding!.Value, queryVector))
.Take(5)
.Select(p => new
{
p.Id,
p.Name,
Distance = EF.Functions.VectorDistance("cosine", p.Embedding!.Value, queryVector)
})
.ToListAsync();

return Results.Ok(results);
})
.WithSummary("Performs semantic search over product names");

Step 9: The Hybrid Search Endpoint

The hybrid endpoint performs four sub-steps:

  1. Generate the query embedding.
  2. Run vector search — take the top 20 by cosine distance.
  3. Run keyword search — take the top 20 by CONTAINSTABLE rank.
  4. Fuse both ranked lists with RRF and return the top 5.

9.1 Generate the Query Embedding and Run Vector Search

// GET /products/hybrid-search?q=... — hybrid search (vector + keyword, RRF)
group.MapGet("/hybrid-search", async (
string q,
AppDbContext dbContext,
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator) =>
{
// 1. Generate query embedding
var queryResult = await embeddingGenerator.GenerateAsync([q]);
var queryVector = new SqlVector<float>(queryResult[0].Vector);

// 2. Vector search — semantic ranking by cosine distance
var vectorResults = await dbContext.Products
.Where(p => p.Embedding != null)
.OrderBy(p => EF.Functions.VectorDistance("cosine", p.Embedding!.Value, queryVector))
.Take(20)
.Select(p => new { p.Id, p.Name })
.ToListAsync();

9.2 Keyword Search with CONTAINSTABLE

The keyword leg uses a parameterized raw SQL query through SqlQuery<T>. EF Core turns the interpolated {q} into a SQL parameter, keeping the query safe from SQL injection:

// 3. Keyword search — lexical ranking via full-text search, most relevant first
var rankedKeywordResults = await dbContext.Database
.SqlQuery<KeywordSearchResult>($"""
SELECT TOP (20) p.[Id], p.[Name], kt.[RANK] AS [Rank]
FROM [Products] AS p
INNER JOIN CONTAINSTABLE([Products], [Name], {q}) AS kt ON p.[Id] = kt.[KEY]
ORDER BY kt.[RANK] DESC
""")
.ToListAsync();

var keywordResults = rankedKeywordResults
.Select(r => new { r.Id, r.Name })
.ToList();

The result of the raw SQL query is mapped through a small record:

// Maps the result of the CONTAINSTABLE full-text search query used in keyword ranking
private sealed record KeywordSearchResult(int Id, string Name, int Rank);

9.3 Reciprocal Rank Fusion

Both lists are already ordered by relevance, so the loop index i corresponds to the rank (0-based, hence i + 1 to make it 1-based). Products appearing in both lists have their scores summed via GetValueOrDefault:

// 4. Reciprocal Rank Fusion (RRF, k=60)
const double k = 60.0;
var scores = new Dictionary<int, double>();

for (var i = 0; i < vectorResults.Count; i++)
scores[vectorResults[i].Id] = scores.GetValueOrDefault(vectorResults[i].Id) + 1.0 / (k + i + 1);

for (var i = 0; i < keywordResults.Count; i++)
scores[keywordResults[i].Id] = scores.GetValueOrDefault(keywordResults[i].Id) + 1.0 / (k + i + 1);

var nameMap = vectorResults.Concat(keywordResults)
.GroupBy(r => r.Id)
.ToDictionary(g => g.Key, g => g.First().Name);

var results = scores
.Select(kv => new { Id = kv.Key, Name = nameMap[kv.Key], RrfScore = kv.Value })
.OrderByDescending(r => r.RrfScore)
.Take(5)
.ToList();

return Results.Ok(results);
})
.WithSummary("Performs hybrid search combining vector similarity and keyword matching using RRF");

Why k = 60?

The constant k = 60 comes from the original RRF research paper and is used by most search engines (including Azure AI Search and Elasticsearch). It dampens the influence of very high ranks so that a document ranked #1 in one list does not completely dominate documents that are consistently ranked #3–#5 across both lists.

Step 10: Try It Out

  1. Seed the database:
POST /products/seed
  1. Run a pure semantic search:
GET /products/search?q=wireless audio device
  1. Run the hybrid search:
GET /products/hybrid-search?q=bluetooth headphones

Example hybrid search response:

[
{ "id": 1, "name": "Wireless Bluetooth Headphones", "rrfScore": 0.03252 },
{ "id": 5, "name": "Bluetooth Neckband Earphones", "rrfScore": 0.03175 },
{ "id": 2, "name": "Noise Cancelling Earbuds", "rrfScore": 0.01587 },
{ "id": 3, "name": "Over-Ear Studio Headphones", "rrfScore": 0.01562 },
{ "id": 14, "name": "Mini Bluetooth Keyboard", "rrfScore": 0.01538 }
]

Notice how "Wireless Bluetooth Headphones" ranks first: it scores highly in both the semantic list (it means "bluetooth headphones") and the keyword list (it literally contains both words), so its RRF contributions add up.

Summary

  1. Enable and verify SQL Server Full-Text Search, then create a catalog and a full-text index on the Name column.
  2. Store product embeddings in SQL Server's native vector column using SqlVector<float>.
  3. Run vector search with EF.Functions.VectorDistance and keyword search with CONTAINSTABLE, each returning a top-20 ranked list.
  4. Merge both lists with Reciprocal Rank Fusion (k = 60) and return the top results.

Hybrid search gives you the best of both worlds: semantic understanding for natural-language queries and lexical precision for exact terms — all inside SQL Server, without any external search engine.

Appendix: Complete Hybrid Search Endpoint Code

Here is the finished, fully working hybrid search endpoint in one piece, ready to copy into your project:

using Microsoft.Data.SqlTypes;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
using WebApplication.API.Data;

namespace WebApplication.API.Endpoints;

public static class ProductsEndpoints
{
public static void MapProductEndpoints(this WebApplication app)
{
var group = app.MapGroup("/products").WithTags("Products");

// GET /products/hybrid-search?q=... — hybrid search (vector + keyword, RRF)
group.MapGet("/hybrid-search", async (
string q,
AppDbContext dbContext,
IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator) =>
{
// 1. Generate query embedding
var queryResult = await embeddingGenerator.GenerateAsync([q]);
var queryVector = new SqlVector<float>(queryResult[0].Vector);

// 2. Vector search — semantic ranking by cosine distance
var vectorResults = await dbContext.Products
.Where(p => p.Embedding != null)
.OrderBy(p => EF.Functions.VectorDistance("cosine", p.Embedding!.Value, queryVector))
.Take(20)
.Select(p => new { p.Id, p.Name })
.ToListAsync();

// 3. Keyword search — lexical ranking using SQL Server full-text search
// (CONTAINSTABLE), most relevant first
var rankedKeywordResults = await dbContext.Database
.SqlQuery<KeywordSearchResult>($"""
SELECT TOP (20) p.[Id], p.[Name], kt.[RANK] AS [Rank]
FROM [Products] AS p
INNER JOIN CONTAINSTABLE([Products], [Name], {q}) AS kt ON p.[Id] = kt.[KEY]
ORDER BY kt.[RANK] DESC
""")
.ToListAsync();

var keywordResults = rankedKeywordResults
.Select(r => new { r.Id, r.Name })
.ToList();

// 4. Reciprocal Rank Fusion (RRF, k=60)
const double k = 60.0;
var scores = new Dictionary<int, double>();

for (var i = 0; i < vectorResults.Count; i++)
scores[vectorResults[i].Id] = scores.GetValueOrDefault(vectorResults[i].Id) + 1.0 / (k + i + 1);

for (var i = 0; i < keywordResults.Count; i++)
scores[keywordResults[i].Id] = scores.GetValueOrDefault(keywordResults[i].Id) + 1.0 / (k + i + 1);

var nameMap = vectorResults.Concat(keywordResults)
.GroupBy(r => r.Id)
.ToDictionary(g => g.Key, g => g.First().Name);

var results = scores
.Select(kv => new { Id = kv.Key, Name = nameMap[kv.Key], RrfScore = kv.Value })
.OrderByDescending(r => r.RrfScore)
.Take(5)
.ToList();

return Results.Ok(results);
})
.WithSummary("Performs hybrid search combining vector similarity and keyword matching using RRF");
}

// Maps the result of the CONTAINSTABLE full-text search query used in keyword ranking
private sealed record KeywordSearchResult(int Id, string Name, int Rank);
}


Share this lesson: