Hybrid Search in .NET with EF Core 10 and pgvector: Fuse Keyword + Vector Retrieval
Combine exact keyword matching with semantic vector search, then use Reciprocal Rank Fusion to produce one clear and explainable ranking.
What You Will Build
Build a document-search API that runs keyword and vector retrieval side by side, then merges both rankings with Reciprocal Rank Fusion.
POST /api/searchβ keyword, vector, or hybrid searchPOST /api/documentsβ chunk, embed, and store contentPOST /api/askβ optional grounded answer using retrieved chunksGET /healthβ database and application health
User query
β
ββββββββββββββ΄βββββββββββββ
βΌ βΌ
PostgreSQL full-text pgvector cosine
ranked keyword list ranked semantic list
ββββββββββββββ¬βββββββββββββ
βΌ
Reciprocal Rank Fusion
βΌ
One explainable rankingDirectory.Packages.props.Why One Search Method Is Not Enough
Keyword search is excellent when the query contains an exact product name, error code, identifier, or phrase. It becomes weaker when the reader uses a synonym or describes the same idea with different words.
Vector search works in the opposite direction. It can connect βcancel a jobβ with βstop a hosted service,β but it may rank a broadly related paragraph above a document containing the exact code or API name.
Keyword strength
Exact terms, names, codes, titles, and precise phrases.
Vector strength
Meaning, paraphrases, synonyms, and user intent.
Hybrid strength
Agreement between independent retrievers raises useful documents.
Prerequisites
- .NET 10 SDK and an IDE.
- Docker Desktop or Docker Engine.
- Basic EF Core migration experience.
- An embedding provider: OpenAI/Azure OpenAI or Ollama locally.
- Basic ASP.NET Core Minimal API and dependency-injection knowledge.
Create the Solution and PostgreSQL + pgvector
mkdir HybridSearch
cd HybridSearch
dotnet new sln -n HybridSearch
dotnet new webapi -n HybridSearch.Api -o src/HybridSearch.Api
dotnet new xunit -n HybridSearch.Tests -o tests/HybridSearch.Tests
dotnet sln add src/HybridSearch.Api
dotnet sln add tests/HybridSearch.Tests
dotnet add tests/HybridSearch.Tests reference src/HybridSearch.Api<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Replace placeholders with versions restored and tested before publishing. -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.x.y" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.x.y" />
<PackageVersion Include="Pgvector.EntityFrameworkCore" Version="0.x.y" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.x.y" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.x.y" />
<PackageVersion Include="OpenAI" Version="2.x.y" />
<PackageVersion Include="OllamaSharp" Version="x.y.z" />
</ItemGroup>
</Project>services:
postgres:
image: pgvector/pgvector:pg17
environment:
POSTGRES_DB: hybridsearch
POSTGRES_USER: app
POSTGRES_PASSWORD: dev-password
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d hybridsearch"]
interval: 5s
timeout: 5s
retries: 10Start PostgreSQL:
docker compose up -d postgresModel the Document Chunk
using System.ComponentModel.DataAnnotations.Schema;
using NpgsqlTypes;
using Pgvector;
public sealed class DocumentChunk
{
public Guid Id { get; init; }
public string DocumentId { get; init; } = string.Empty;
public int ChunkNumber { get; init; }
public string Title { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
public string EmbeddingModel { get; set; } = string.Empty;
[Column(TypeName = "vector(1536)")]
public Vector? Embedding { get; set; }
public NpgsqlTsVector SearchVector { get; private set; } = null!;
}using Microsoft.EntityFrameworkCore;
using Pgvector.EntityFrameworkCore;
public sealed class SearchDbContext(DbContextOptions<SearchDbContext> options)
: DbContext(options)
{
public DbSet<DocumentChunk> DocumentChunks => Set<DocumentChunk>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresExtension("vector");
modelBuilder.Entity<DocumentChunk>(entity =>
{
entity.HasKey(x => x.Id);
entity.HasIndex(x => new { x.DocumentId, x.ChunkNumber }).IsUnique();
entity.HasGeneratedTsVectorColumn(
x => x.SearchVector,
"english",
x => new { x.Title, x.Text })
.HasIndex(x => x.SearchVector)
.HasMethod("GIN");
entity.HasIndex(x => x.Embedding)
.HasMethod("hnsw")
.HasOperators("vector_cosine_ops")
.HasStorageParameter("m", 16)
.HasStorageParameter("ef_construction", 64);
});
}
}builder.Services.AddDbContext<SearchDbContext>(options =>
options.UseNpgsql(
builder.Configuration.GetConnectionString("Search"),
npgsql => npgsql.UseVector()));vector(1536) column accepts only 1,536-element vectors. Record the model name and dimension so a model change becomes an explicit re-indexing operation.Generate Embeddings with IEmbeddingGenerator
using Microsoft.Extensions.AI;
using OpenAI;
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(sp =>
{
string apiKey = builder.Configuration["OpenAI:ApiKey"]
?? throw new InvalidOperationException("OpenAI:ApiKey is missing.");
string model = builder.Configuration["Search:EmbeddingModel"]
?? "text-embedding-3-small";
return new OpenAIClient(apiKey)
.GetEmbeddingClient(model)
.AsIEmbeddingGenerator();
});using Microsoft.Extensions.AI;
using OllamaSharp;
IEmbeddingGenerator<string, Embedding<float>> generator =
new OllamaApiClient(
new Uri("http://localhost:11434/"),
"nomic-embed-text");public sealed class EmbeddingIndexer(
SearchDbContext db,
IEmbeddingGenerator<string, Embedding<float>> generator,
IOptions<SearchOptions> options)
{
public async Task IndexAsync(
IReadOnlyList<DocumentChunk> chunks,
CancellationToken cancellationToken)
{
const int batchSize = 32;
foreach (DocumentChunk[] batch in chunks.Chunk(batchSize))
{
GeneratedEmbeddings<Embedding<float>> generated =
await generator.GenerateAsync(
batch.Select(x => x.Text),
cancellationToken: cancellationToken);
for (int i = 0; i < batch.Length; i++)
{
ReadOnlyMemory<float> vector = generated[i].Vector;
if (vector.Length != options.Value.EmbeddingDimensions)
{
throw new InvalidOperationException(
$"Expected {options.Value.EmbeddingDimensions} dimensions, " +
$"but received {vector.Length}.");
}
batch[i].Embedding = new Pgvector.Vector(vector.ToArray());
batch[i].EmbeddingModel = options.Value.EmbeddingModel;
}
db.DocumentChunks.AddRange(batch);
await db.SaveChangesAsync(cancellationToken);
}
}
}The Vector Leg: pgvector and HNSW
public sealed record RankedCandidate(
Guid Id,
string Title,
string Text,
int Rank,
double SourceScore);
public sealed class VectorSearch(
SearchDbContext db,
IEmbeddingGenerator<string, Embedding<float>> generator)
{
public async Task<IReadOnlyList<RankedCandidate>> SearchAsync(
string query,
int take,
CancellationToken cancellationToken)
{
ReadOnlyMemory<float> queryMemory =
await generator.GenerateVectorAsync(query, cancellationToken: cancellationToken);
var queryVector = new Pgvector.Vector(queryMemory.ToArray());
var rows = await db.DocumentChunks
.AsNoTracking()
.Where(x => x.Embedding != null)
.OrderBy(x => x.Embedding!.CosineDistance(queryVector))
.Take(take)
.Select(x => new
{
x.Id,
x.Title,
x.Text,
Distance = x.Embedding!.CosineDistance(queryVector)
})
.ToListAsync(cancellationToken);
return rows.Select((x, index) => new RankedCandidate(
x.Id, x.Title, x.Text, index + 1, 1.0 - x.Distance)).ToList();
}
}Cosine distance is smaller for closer vectors, so the query orders ascending. The response can expose 1 - distance as an intuitive diagnostic similarity, but fusion will use the rank rather than this raw number.
vector indexes are constrained by vector width. A 3,072-dimensional model does not fit the ordinary 2,000-dimension indexed path. Request fewer dimensions or deliberately choose an appropriate half-precision strategy after testing quality.The Keyword Leg: PostgreSQL Full-Text Search
public sealed class KeywordSearch(SearchDbContext db)
{
public async Task<IReadOnlyList<RankedCandidate>> SearchAsync(
string query,
int take,
CancellationToken cancellationToken)
{
NpgsqlTsQuery tsQuery = EF.Functions.WebSearchToTsQuery("english", query);
var rows = await db.DocumentChunks
.AsNoTracking()
.Where(x => x.SearchVector.Matches(tsQuery))
.OrderByDescending(x => x.SearchVector.Rank(tsQuery))
.Take(take)
.Select(x => new
{
x.Id,
x.Title,
x.Text,
Score = x.SearchVector.Rank(tsQuery)
})
.ToListAsync(cancellationToken);
return rows.Select((x, index) => new RankedCandidate(
x.Id, x.Title, x.Text, index + 1, x.Score)).ToList();
}
}websearch_to_tsquery accepts user-friendly search syntax and avoids requiring callers to write PostgreSQL query operators. The generated tsvector and GIN index keep document parsing out of the request path.
pg_trgm as a separate fuzzy candidate leg rather than quietly replacing the full-text leg.Why You Cannot Just Add the Scores
A keyword rank and cosine similarity are not calibrated against each other. One could produce values around 0.02, while the other produces values around 0.85. Adding them makes the larger numerical scale dominate even when its ranking is less useful.
Keyword list Vector list
1. CancellationToken 1. Stopping hosted services
2. Hosted service shutdown 2. CancellationToken
3. Worker lifecycle 3. Graceful shutdown
Raw scores: unrelated scales Ranks: directly comparable
ββββββββββββββββ¬βββββββββββββββ
βΌ
Fuse rank positionsImplement Reciprocal Rank Fusion
public sealed record FusedResult(
Guid Id,
string Title,
string Text,
double Score,
int? KeywordRank,
int? VectorRank);
public static class ReciprocalRankFusion
{
public static IReadOnlyList<FusedResult> Fuse(
IReadOnlyList<RankedCandidate> keyword,
IReadOnlyList<RankedCandidate> vector,
int top,
int k = 60,
double keywordWeight = 1.0,
double vectorWeight = 1.0)
{
var accumulator = new Dictionary<Guid, MutableResult>();
Add(keyword, keywordWeight, isKeyword: true);
Add(vector, vectorWeight, isKeyword: false);
return accumulator.Values
.OrderByDescending(x => x.Score)
.ThenBy(x => x.Id)
.Take(top)
.Select(x => new FusedResult(
x.Id, x.Title, x.Text, x.Score,
x.KeywordRank, x.VectorRank))
.ToList();
void Add(
IReadOnlyList<RankedCandidate> source,
double weight,
bool isKeyword)
{
foreach (RankedCandidate candidate in source)
{
if (!accumulator.TryGetValue(candidate.Id, out MutableResult? item))
{
item = new MutableResult(candidate.Id, candidate.Title, candidate.Text);
accumulator.Add(candidate.Id, item);
}
item.Score += weight / (k + candidate.Rank);
if (isKeyword) item.KeywordRank = candidate.Rank;
else item.VectorRank = candidate.Rank;
}
}
}
private sealed class MutableResult(Guid id, string title, string text)
{
public Guid Id { get; } = id;
public string Title { get; } = title;
public string Text { get; } = text;
public double Score { get; set; }
public int? KeywordRank { get; set; }
public int? VectorRank { get; set; }
}
}The common default k = 60 reduces the difference between adjacent ranks. Lower values make the very top positions more influential. Treat it as a tuning parameter, not a universal truth.
Assemble the Hybrid Pipeline
public sealed class HybridSearchService(
KeywordSearch keywordSearch,
VectorSearch vectorSearch,
IOptions<SearchOptions> options)
{
public async Task<IReadOnlyList<FusedResult>> SearchAsync(
string query,
int? requestedTop,
CancellationToken cancellationToken)
{
string normalized = query.Trim();
if (normalized.Length < 2)
return [];
SearchOptions settings = options.Value;
int top = Math.Clamp(requestedTop ?? settings.DefaultTop, 1, 50);
Task<IReadOnlyList<RankedCandidate>> keywordTask =
keywordSearch.SearchAsync(
normalized, settings.CandidatesPerLeg, cancellationToken);
Task<IReadOnlyList<RankedCandidate>> vectorTask =
vectorSearch.SearchAsync(
normalized, settings.CandidatesPerLeg, cancellationToken);
await Task.WhenAll(keywordTask, vectorTask);
return ReciprocalRankFusion.Fuse(
await keywordTask,
await vectorTask,
top,
settings.RrfK,
settings.KeywordWeight,
settings.VectorWeight);
}
}public sealed record SearchRequest(
string Query,
string Mode = "hybrid",
int? Top = null);
app.MapPost("/api/search", async (
SearchRequest request,
HybridSearchService hybrid,
KeywordSearch keyword,
VectorSearch vector,
CancellationToken cancellationToken) =>
{
if (string.IsNullOrWhiteSpace(request.Query))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[nameof(request.Query)] = ["Query is required."]
});
object results = request.Mode.ToLowerInvariant() switch
{
"keyword" => await keyword.SearchAsync(request.Query, request.Top ?? 10, cancellationToken),
"vector" => await vector.SearchAsync(request.Query, request.Top ?? 10, cancellationToken),
"hybrid" => await hybrid.SearchAsync(request.Query, request.Top, cancellationToken),
_ => throw new BadHttpRequestException("Mode must be keyword, vector, or hybrid.")
};
return Results.Ok(new { request.Query, request.Mode, Results = results });
});Tune and Measure Relevance
public sealed class SearchOptions
{
public const string SectionName = "Search";
public string EmbeddingModel { get; init; } = "text-embedding-3-small";
public int EmbeddingDimensions { get; init; } = 1536;
public int CandidatesPerLeg { get; init; } = 50;
public int RrfK { get; init; } = 60;
public double KeywordWeight { get; init; } = 1.0;
public double VectorWeight { get; init; } = 1.0;
public int DefaultTop { get; init; } = 10;
}{
"Search": {
"EmbeddingModel": "text-embedding-3-small",
"EmbeddingDimensions": 1536,
"CandidatesPerLeg": 50,
"RrfK": 60,
"KeywordWeight": 1.0,
"VectorWeight": 1.0,
"DefaultTop": 10
},
"ConnectionStrings": {
"Search": "Host=localhost;Database=hybridsearch;Username=app;Password=dev-password"
}
}Create a small labeled query set with expected relevant chunk IDs. Compare keyword-only, vector-only, and hybrid rankings using the same queries. Tune one lever at a time:
- Candidate depth: more recall, more work.
- RRF k: how strongly top positions dominate.
- Weights: useful only after measurement.
- Chunk size: affects both lexical and semantic matching.
Optional: Turn Retrieved Chunks into a Grounded Answer
app.MapPost("/api/ask", async (
AskRequest request,
HybridSearchService search,
IChatClient chatClient,
CancellationToken cancellationToken) =>
{
IReadOnlyList<FusedResult> sources =
await search.SearchAsync(request.Question, 5, cancellationToken);
string context = string.Join("
", sources.Select((x, index) =>
$"[Source {index + 1}: {x.Title}]
{x.Text}"));
var messages = new[]
{
new ChatMessage(ChatRole.System,
"Answer only from the supplied sources. Say when the sources are insufficient."),
new ChatMessage(ChatRole.User,
$"Question: {request.Question}
Sources:
{context}")
};
ChatResponse response = await chatClient.GetResponseAsync(
messages,
cancellationToken: cancellationToken);
return Results.Ok(new
{
answer = response.Text,
citations = sources.Select(x => new { x.Id, x.Title })
});
});Test Without a Live Embedding Model
[Fact]
public void Documents_ranked_by_both_legs_rise_in_the_fused_list()
{
Guid agreed = Guid.NewGuid();
Guid keywordOnly = Guid.NewGuid();
Guid vectorOnly = Guid.NewGuid();
RankedCandidate[] keyword =
[
new(keywordOnly, "Exact", "...", 1, 8.2),
new(agreed, "Agreed", "...", 2, 6.1)
];
RankedCandidate[] vector =
[
new(vectorOnly, "Semantic", "...", 1, 0.91),
new(agreed, "Agreed", "...", 2, 0.88)
];
IReadOnlyList<FusedResult> result =
ReciprocalRankFusion.Fuse(keyword, vector, top: 3, k: 60);
Assert.Equal(agreed, result[0].Id);
Assert.Equal(2, result[0].KeywordRank);
Assert.Equal(2, result[0].VectorRank);
}public sealed class FakeEmbeddingGenerator(
IReadOnlyDictionary<string, float[]> vectors)
: IEmbeddingGenerator<string, Embedding<float>>
{
public EmbeddingGeneratorMetadata Metadata { get; } =
new("fake-embeddings", dimensions: vectors.First().Value.Length);
public Task<GeneratedEmbeddings<Embedding<float>>> GenerateAsync(
IEnumerable<string> values,
EmbeddingGenerationOptions? options = null,
CancellationToken cancellationToken = default)
{
var generated = values
.Select(value => new Embedding<float>(vectors[value]))
.ToList();
return Task.FromResult(
new GeneratedEmbeddings<Embedding<float>>(generated));
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose() { }
}The fake generator keeps vectors stable, fast, and free. Database integration tests should still run against real PostgreSQL with the vector extension enabled because an in-memory provider cannot reproduce pgvector or full-text SQL behavior.
When PostgreSQL + pgvector Is Enough
| Situation | PostgreSQL + pgvector | Dedicated vector store |
|---|---|---|
| Vectors beside relational records | Excellent fit | Extra synchronization |
| Small to medium corpus | HNSW is often sufficient | Optional |
| Vector-first, very large scale | Requires careful tuning | Often stronger |
| Minimal infrastructure | Reuse the existing database | Adds another service |
| Advanced vector-specific operations | More limited | Purpose-built capabilities |
Choose the simplest store that meets measured needs. PostgreSQL is especially attractive when metadata filters, permissions, relational joins, and vectors must remain close together.
Production Readiness Checklist
- Embedding model name and dimensions are stored and validated.
- Model changes trigger a deliberate full re-index.
- Document ingestion is batched, retryable, and observable.
- Full-text and HNSW indexes exist in production.
- Candidate depth and RRF settings were tested on labeled queries.
- Empty and very short queries are handled explicitly.
- Queries and document text are not logged by default.
- Tenant and authorization filters are applied before returning chunks.
- Index age, ingestion failures, latency, and result quality are monitored.
- Optional model answers encode output and preserve citations.
What to Build Next
Evaluation harness
Measure Recall@K, MRR, and nDCG on a labeled query set.
Cross-encoder reranking
Apply a stronger second-stage ranker to the fused candidates.
Permission-aware retrieval
Filter candidates by tenant and user access before fusion.
The natural next tutorial is an evaluation harness. Search relevance should become a tested product behavior, not an opinion formed from a few manual searches.