Build Hybrid Search in .NET with EF Core 10 and pgvector
Build one .NET 10 search API with three modes: PostgreSQL full-text keyword retrieval, pgvector cosine retrieval, and a hybrid mode that combines both ranked lists with Reciprocal Rank Fusion. The verified default path uses IEmbeddingGenerator, Ollama nomic-embed-text, vector(768), GIN, and HNSW.
What You Will Build
You will build a small document-search API where keyword retrieval and semantic retrieval stay independently understandable, then meet at a rank-fusion layer instead of being forced onto one invented score scale.
DOCUMENT INGESTION
text
│
├──► deterministic chunking
│
├──► IEmbeddingGenerator
│ └── Ollama / nomic-embed-text
│
└──► PostgreSQL
├── generated tsvector ──► GIN
└── vector(768) ────────► HNSW / vector_cosine_ops
QUERY
│
├──► keyword leg ──► PostgreSQL FTS ranking
│
├──► vector leg ───► pgvector cosine ranking
│
└──► hybrid mode ──► Reciprocal Rank Fusion
│
▼
one ranked result listKeyword precision
PostgreSQL full-text search keeps exact terms, operators, stemming, and lexical matching in the retrieval path.
Semantic recall
Ollama generates query and document embeddings, while pgvector orders candidates by cosine distance.
Rank fusion
RRF rewards candidates that rank well in either or both legs without pretending that their raw scores share a unit.
The public tutorial surface stays deliberately small:
GET /health— confirms database connectivity and reports the configured embedding provider/model/dimension.POST /api/documents— normalizes, chunks, embeds, and atomically replaces one logical document.POST /api/search— acceptskeyword,vector, orhybrid.
/api/ask endpoint in the verified companion. This tutorial is about retrieval. Answer generation, RAG prompting, reranking, authentication, multi-tenancy, and deployment are intentionally separate concerns.Why Hybrid Search Instead of One Retriever?
Keyword search and vector search fail in different ways. Keyword search is strong when the query contains important literal terms, identifiers, API names, product codes, error text, or phrases that should match lexically. Vector search can recover conceptually related text even when the words differ.
Keyword leg Vector leg
─────────── ──────────
"Cancelling hosted service" "gracefully stop a background worker"
│ │
▼ ▼
PostgreSQL FTS embedding + cosine distance
│ │
└──────────────────┬───────────────────────┘
▼
Reciprocal Rank Fusion
│
▼
candidate agreement mattersKeyword-only risk
A relevant document can use different wording and never enter the lexical candidate set.
Vector-only risk
Semantic similarity can be broad; exact identifiers and literal terms may deserve stronger lexical treatment.
Hybrid boundary
Fusion can improve ordering only among candidates retrieved by the component legs. It cannot recover a document both legs missed.
Prerequisites and Verified Baseline
This rewrite replaces the old floating package placeholders with the exact versions restored, built, tested, live-verified, published to GitHub, and passed by repository CI.
| Component | Verified value |
|---|---|
| .NET SDK | 10.0.401 |
| Target framework | net10.0 |
| EF Core | 10.0.12 |
| Npgsql EF Core provider | 10.0.3 |
| Pgvector.EntityFrameworkCore | 0.3.0 |
| Microsoft.Extensions.AI | 10.10.0 |
| Microsoft.Extensions.AI.OpenAI | 10.10.1 |
| Azure.AI.OpenAI | 2.1.0 |
| OllamaSharp | 5.5.0 |
| PostgreSQL image | pgvector/pgvector:pg17 |
| Live embedding model | nomic-embed-text |
| Verified vector dimension | 768 |
| Deterministic tests | 13 passed, 0 failed, 0 skipped |
| Release build | 0 warnings, 0 errors |
{
"sdk": {
"version": "10.0.401",
"rollForward": "latestPatch",
"allowPrerelease": false
}
}
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" Version="2.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.AI" Version="10.10.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.10.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageReference Include="OllamaSharp" Version="5.5.0" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
</ItemGroup>
</Project>
main. The final local E2E run was completed on October 1, 2026.Project Architecture
The project keeps ingestion, embeddings, relational mapping, retrieval, and fusion separate enough that each boundary can be tested or replaced without hiding what the other layers do.
HybridSearch.Api
│
├── Program.cs
│ ├── EF Core / Npgsql / UseVector()
│ ├── IEmbeddingGenerator registration
│ └── HTTP endpoints
│
├── Data/
│ ├── DocumentChunk
│ ├── SearchDbContext
│ └── DatabaseInitializer
│
├── Services/
│ ├── TextChunker
│ ├── EmbeddingProviderFactory
│ ├── DocumentIngestionService
│ └── HybridSearchService
│
└── Search/
├── SearchModeParser
└── ReciprocalRankFusion
HybridSearch.Api.Tests
├── TextChunkerTests
├── SearchModeParserTests
└── ReciprocalRankFusionTestsNotice what is not hidden behind an all-in-one “search framework”: PostgreSQL FTS is visible, pgvector distance is visible, and RRF is ordinary deterministic C#.
Start PostgreSQL + pgvector
The live path uses PostgreSQL 17 with the pgvector extension in Docker. The database is deliberately local and disposable so the retrieval code can be verified without provisioning a cloud search service.
services:
postgres:
image: pgvector/pgvector:pg17
container_name: dotnet-guide-hybrid-search-postgres
environment:
POSTGRES_DB: hybridsearch
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "54329:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d hybridsearch"]
interval: 2s
timeout: 3s
retries: 30
volumes:
- hybridsearch-data:/var/lib/postgresql/data
volumes:
hybridsearch-data:
Start the database:
docker compose up -d
At application startup, the sample creates the extension if necessary and uses EnsureCreatedAsync() to create the clean tutorial schema.
public static async Task InitializeAsync(
IServiceProvider services,
string connectionString,
CancellationToken cancellationToken = default)
{
await using (NpgsqlConnection connection = new(connectionString))
{
await connection.OpenAsync(cancellationToken);
await using NpgsqlCommand command = new(
"CREATE EXTENSION IF NOT EXISTS vector;",
connection);
await command.ExecuteNonQueryAsync(cancellationToken);
}
using IServiceScope scope = services.CreateScope();
SearchDbContext db =
scope.ServiceProvider.GetRequiredService<SearchDbContext>();
await db.Database.EnsureCreatedAsync(cancellationToken);
}
EnsureCreatedAsync() is used so a clean local container is easy to reproduce. For production, manage schema evolution with reviewed EF Core migrations or another deliberate deployment migration process.Model Searchable Chunks and Both Indexes
Each row is one chunk. The row stores ordinary text, the model name used to embed it, a generated PostgreSQL tsvector, and a pgvector embedding.
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(768)")]
public Vector? Embedding { get; set; }
public NpgsqlTsVector SearchVector { get; private set; } = null!;
}
The database mapping creates both retrieval structures:
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);
});
}
Generated tsvector
Npgsql builds the searchable vector from Title + Text using the english text-search configuration.
GIN
The generated SearchVector is indexed with PostgreSQL GIN for the lexical retrieval leg.
HNSW
The embedding column uses HNSW with vector_cosine_ops, m=16, and ef_construction=64.
vector(768)? It is not a generic pgvector requirement. It matches the live-verified default embedding model, nomic-embed-text. Model and schema dimensions must agree.Configure IEmbeddingGenerator
The application-level embedding boundary is IEmbeddingGenerator<string, Embedding<float>>. The verified runtime registers Ollama, while the source also keeps optional OpenAI and Azure OpenAI construction examples.
public sealed class EmbeddingOptions
{
public const string SectionName = "Embedding";
public string Provider { get; init; } = "Ollama";
public string Endpoint { get; init; } = "http://localhost:11434";
public string Model { get; init; } = "nomic-embed-text";
}
public static class EmbeddingDimensions
{
public const int Value = 768;
}
public static class EmbeddingProviderFactory
{
public static IEmbeddingGenerator<string, Embedding<float>> CreateOllama(
string endpoint,
string model) =>
new OllamaApiClient(new Uri(endpoint), model);
// Compile/construction verified only in this publication run.
public static IEmbeddingGenerator<string, Embedding<float>> CreateOpenAI(
string apiKey,
string model) =>
new EmbeddingClient(model, apiKey).AsIEmbeddingGenerator();
// Compile/construction verified only in this publication run.
public static IEmbeddingGenerator<string, Embedding<float>> CreateAzureOpenAI(
string endpoint,
string apiKey,
string deployment)
{
AzureOpenAIClient client = new(
new Uri(endpoint),
new AzureKeyCredential(apiKey));
return client
.GetEmbeddingClient(deployment)
.AsIEmbeddingGenerator();
}
}
builder.Services.AddDbContext<SearchDbContext>(options =>
options.UseNpgsql(
connectionString,
npgsql => npgsql.UseVector()));
builder.Services.AddSingleton<
IEmbeddingGenerator<string, Embedding<float>>>(services =>
{
EmbeddingOptions options = services
.GetRequiredService<IOptions<EmbeddingOptions>>()
.Value;
if (!options.Provider.Equals(
"Ollama",
StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"The live-verified runtime provider for this sample is Ollama. " +
"See docs/provider-options.md for compile-verified cloud adapter examples.");
}
return EmbeddingProviderFactory.CreateOllama(
options.Endpoint,
options.Model);
});
| Provider path | Publication evidence |
|---|---|
Ollama / nomic-embed-text | LIVE VERIFIED — generated real 768-dimensional embeddings and drove the final API E2E run. |
| OpenAI adapter | COMPILE / CONSTRUCTION VERIFIED ONLY — no cloud call in this publication run. |
| Azure OpenAI adapter | COMPILE / CONSTRUCTION VERIFIED ONLY — no cloud call in this publication run. |
Chunk, Embed, and Ingest Documents Safely
The chunker is intentionally deterministic: 120 words per chunk with a 20-word overlap by default. That keeps the tutorial focused on retrieval rather than introducing a second semantic chunking system.
public IReadOnlyList<string> Split(
string text,
int maxWords = 120,
int overlapWords = 20)
{
if (string.IsNullOrWhiteSpace(text)) return [];
if (maxWords <= 0)
throw new ArgumentOutOfRangeException(nameof(maxWords));
if (overlapWords < 0 || overlapWords >= maxWords)
throw new ArgumentOutOfRangeException(nameof(overlapWords));
string[] words = text.Split(
(char[]?)null,
StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries);
var chunks = new List<string>();
int start = 0;
while (start < words.Length)
{
int end = Math.Min(start + maxWords, words.Length);
chunks.Add(string.Join(" ", words[start..end]));
if (end == words.Length) break;
start = end - overlapWords;
}
return chunks;
}
The final hardened ingestion path normalizes documentId, generates and validates every replacement embedding first, and only then replaces the existing rows inside a transaction.
public async Task<IngestDocumentResponse> IngestAsync(
IngestDocumentRequest request,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(request.DocumentId))
throw new ArgumentException("DocumentId is required.", nameof(request));
if (string.IsNullOrWhiteSpace(request.Title))
throw new ArgumentException("Title is required.", nameof(request));
if (string.IsNullOrWhiteSpace(request.Text))
throw new ArgumentException("Text is required.", nameof(request));
string documentId = request.DocumentId.Trim();
string title = request.Title.Trim();
IReadOnlyList<string> chunks = chunker.Split(request.Text);
if (chunks.Count == 0)
throw new ArgumentException(
"The document did not produce any chunks.",
nameof(request));
var replacement = new List<DocumentChunk>(chunks.Count);
for (int index = 0; index < chunks.Count; index++)
{
ReadOnlyMemory<float> vector =
await embeddings.GenerateVectorAsync(chunks[index]);
ValidateVector(vector);
replacement.Add(new DocumentChunk
{
Id = Guid.NewGuid(),
DocumentId = documentId,
ChunkNumber = index + 1,
Title = title,
Text = chunks[index],
EmbeddingModel = embeddingOptions.Value.Model,
Embedding = new Vector(vector.ToArray())
});
}
await using var transaction =
await db.Database.BeginTransactionAsync(cancellationToken);
await db.DocumentChunks
.Where(x => x.DocumentId == documentId)
.ExecuteDeleteAsync(cancellationToken);
db.DocumentChunks.AddRange(replacement);
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return new IngestDocumentResponse(
documentId,
chunks.Count,
embeddingOptions.Value.Model,
EmbeddingDimensions.Value);
}
" hosted-services " becomes "hosted-services" before lookup and storage.768 and must not contain NaN/infinite values.The final E2E verifier explicitly re-ingested a whitespace-padded document ID and confirmed normalized replacement worked.
Build the PostgreSQL Keyword Leg
The keyword leg stays inside EF-translatable LINQ. WebSearchToTsQuery is used directly in the query expression, allowing Npgsql to translate it to PostgreSQL websearch_to_tsquery.
private async Task<IReadOnlyList<RankedCandidate>> KeywordAsync(
string query,
int limit,
CancellationToken cancellationToken)
{
var rows = await db.DocumentChunks
.AsNoTracking()
.Where(x => x.SearchVector.Matches(
EF.Functions.WebSearchToTsQuery("english", query)))
.OrderByDescending(x => x.SearchVector.Rank(
EF.Functions.WebSearchToTsQuery("english", query)))
.Take(limit)
.Select(x => new
{
x.Id,
x.DocumentId,
x.ChunkNumber,
x.Title,
x.Text,
Score = x.SearchVector.Rank(
EF.Functions.WebSearchToTsQuery("english", query))
})
.ToListAsync(cancellationToken);
return rows.Select((x, index) => new RankedCandidate(
x.Id,
x.DocumentId,
x.ChunkNumber,
x.Title,
x.Text,
index + 1,
x.Score))
.ToList();
}
Three details matter:
Matches(...)filters chunks using the generatedtsvector.Rank(...)orders lexical matches by PostgreSQL text-search relevance.- The result is converted to a
RankedCandidatewith rank positions starting at 1.
WebSearchToTsQuery outside the EF expression correctly failed because that method is intended for server translation. The final sample keeps it inline.Build the pgvector Cosine Leg
The vector leg embeds the query using the same model family as the stored chunks, validates the dimension, creates a pgvector Vector, and orders rows by cosine distance.
private async Task<IReadOnlyList<RankedCandidate>> VectorAsync(
string query,
int limit,
CancellationToken cancellationToken)
{
ReadOnlyMemory<float> embedding =
await embeddings.GenerateVectorAsync(query);
if (embedding.Length != EmbeddingDimensions.Value)
{
throw new InvalidOperationException(
$"Embedding dimension mismatch. Expected {EmbeddingDimensions.Value}; " +
$"received {embedding.Length}.");
}
Vector queryVector = new(embedding.ToArray());
var rows = await db.DocumentChunks
.AsNoTracking()
.Where(x => x.Embedding != null)
.OrderBy(x => x.Embedding!.CosineDistance(queryVector))
.Take(limit)
.Select(x => new
{
x.Id,
x.DocumentId,
x.ChunkNumber,
x.Title,
x.Text,
Distance = x.Embedding!.CosineDistance(queryVector)
})
.ToListAsync(cancellationToken);
return rows.Select((x, index) => new RankedCandidate(
x.Id,
x.DocumentId,
x.ChunkNumber,
x.Title,
x.Text,
index + 1,
1.0 - x.Distance))
.ToList();
}
The sample exposes 1 - cosine distance as the vector-mode Score. That number is useful inside the vector result mode, but it is still not the same scoring system as PostgreSQL's lexical rank.
CosineDistance, and the HNSW index uses vector_cosine_ops. The final live database inspection confirmed that exact operator class.Why You Should Not Add Keyword and Vector Scores
The tempting implementation is keywordScore + vectorScore. The problem is not arithmetic; it is meaning. PostgreSQL text rank and cosine similarity are produced by unrelated scoring systems. A larger value in one system does not have a stable conversion into the other.
PostgreSQL rank vector similarity
│ │
│ different scale │ different scale
└─────────────── X ─────────────┘
do not add raw scores
keyword rank position vector rank position
│ │
└───────────────┬───────────────┘
▼
Reciprocal Rank FusionRRF solves a narrower problem: it asks how highly a candidate ranked in each list. That makes fusion less dependent on the numerical calibration of either source score.
keyword mode the response score is the PostgreSQL source score; in vector mode it is 1 - cosine distance; in hybrid mode it is the RRF score. Do not compare these values across modes as if they were one metric.Fuse the Rankings with Reciprocal Rank Fusion
The sample implements RRF as deterministic C#. Defaults are k=60 and equal keyword/vector weights of 1.0.
For each candidate in each ranked list:
contribution = weight / (k + rank)
Defaults:
k = 60
keywordWeight = 1.0
vectorWeight = 1.0
final RRF score =
keyword contribution
+ vector contributionpublic static IReadOnlyList<SearchHit> Fuse(
IReadOnlyList<RankedCandidate> keyword,
IReadOnlyList<RankedCandidate> vector,
int top,
int k = 60,
double keywordWeight = 1.0,
double vectorWeight = 1.0)
{
if (top <= 0) throw new ArgumentOutOfRangeException(nameof(top));
if (k < 0) throw new ArgumentOutOfRangeException(nameof(k));
if (keywordWeight < 0)
throw new ArgumentOutOfRangeException(nameof(keywordWeight));
if (vectorWeight < 0)
throw new ArgumentOutOfRangeException(nameof(vectorWeight));
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 SearchHit(
x.Id,
x.DocumentId,
x.ChunkNumber,
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 (candidate.Rank <= 0)
throw new ArgumentOutOfRangeException(
nameof(source),
"Ranks must start at 1.");
if (!accumulator.TryGetValue(
candidate.Id,
out MutableResult? item))
{
item = new MutableResult(candidate);
accumulator.Add(candidate.Id, item);
}
item.Score += weight / (k + candidate.Rank);
if (isKeyword) item.KeywordRank = candidate.Rank;
else item.VectorRank = candidate.Rank;
}
}
}
A candidate that appears in both legs receives both contributions. The response also keeps KeywordRank and VectorRank, so you can see which retrievers contributed.
Assemble Keyword, Vector, and Hybrid Modes
SearchAsync validates the request, chooses the mode, builds a candidate pool, and uses RRF only for hybrid.
public async Task<IReadOnlyList<SearchHit>> SearchAsync(
SearchRequest request,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(request.Query))
throw new ArgumentException("Query is required.", nameof(request));
if (request.Top is < 1 or > 20)
throw new ArgumentOutOfRangeException(
nameof(request),
"Top must be between 1 and 20.");
SearchMode mode = SearchModeParser.Parse(request.Mode);
int candidateLimit =
Math.Min(Math.Max(request.Top * 4, 20), 100);
if (mode == SearchMode.Keyword)
{
IReadOnlyList<RankedCandidate> keyword =
await KeywordAsync(
request.Query.Trim(),
candidateLimit,
cancellationToken);
return keyword.Take(request.Top)
.Select(x => new SearchHit(
x.Id, x.DocumentId, x.ChunkNumber,
x.Title, x.Text,
x.SourceScore, x.Rank, null))
.ToList();
}
if (mode == SearchMode.Vector)
{
IReadOnlyList<RankedCandidate> vector =
await VectorAsync(
request.Query.Trim(),
candidateLimit,
cancellationToken);
return vector.Take(request.Top)
.Select(x => new SearchHit(
x.Id, x.DocumentId, x.ChunkNumber,
x.Title, x.Text,
x.SourceScore, null, x.Rank))
.ToList();
}
IReadOnlyList<RankedCandidate> keywordCandidates =
await KeywordAsync(
request.Query.Trim(),
candidateLimit,
cancellationToken);
IReadOnlyList<RankedCandidate> vectorCandidates =
await VectorAsync(
request.Query.Trim(),
candidateLimit,
cancellationToken);
return ReciprocalRankFusion.Fuse(
keywordCandidates,
vectorCandidates,
request.Top);
}
The candidate pool is intentionally wider than the requested result count:
requested top = N
candidateLimit =
min(
max(N × 4, 20),
100
)
request Top must be 1..20The parser accepts mixed casing and whitespace; missing/empty mode defaults to hybrid.
public static SearchMode Parse(string? value) =>
value?.Trim().ToLowerInvariant() switch
{
"keyword" => SearchMode.Keyword,
"vector" => SearchMode.Vector,
"hybrid" or null or "" => SearchMode.Hybrid,
_ => throw new ArgumentException(
"Search mode must be 'keyword', 'vector', or 'hybrid'.",
nameof(value))
};
Run the API and Try Hybrid Search
First start PostgreSQL + pgvector, make sure Ollama is running, install the verified embedding model once, then run the API.
# Start PostgreSQL + pgvector
docker compose up -d
# Install the verified embedding model once
ollama pull nomic-embed-text
# Start the API
dotnet run --project src/HybridSearch.Api
The verified local launch URL is:
http://localhost:5087The endpoints are ordinary Minimal API mappings:
app.MapPost("/api/documents", async (
IngestDocumentRequest request,
DocumentIngestionService ingestion,
CancellationToken cancellationToken) =>
{
try
{
IngestDocumentResponse result =
await ingestion.IngestAsync(request, cancellationToken);
return Results.Ok(result);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
});
app.MapPost("/api/search", async (
SearchRequest request,
HybridSearchService search,
CancellationToken cancellationToken) =>
{
try
{
IReadOnlyList<SearchHit> result =
await search.SearchAsync(request, cancellationToken);
return Results.Ok(result);
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
});
The repository includes an HTTP file with a minimal ingestion + hybrid-search flow:
@HybridSearchApi = http://localhost:5087
POST {{HybridSearchApi}}/api/documents
Content-Type: application/json
{
"documentId": "hosted-services",
"title": "CancellationToken in hosted services",
"text": "Use CancellationToken to request graceful shutdown of a background worker. Hosted services should observe cancellation and finish cleanup before the process exits."
}
###
POST {{HybridSearchApi}}/api/search
Content-Type: application/json
{
"query": "CancellationToken shutdown",
"mode": "hybrid",
"top": 5
}
"mode" to "keyword", "vector", and "hybrid" while keeping the same query. That makes the differences visible before you start tuning.Build and Test Without Docker or an AI Key
The deterministic layer is intentionally CI-friendly. Normal GitHub Actions does not start PostgreSQL, install Ollama, download a model, or use a cloud credential.
dotnet restore HybridSearch.slnx
dotnet build HybridSearch.slnx --configuration Release --no-restore
dotnet test HybridSearch.slnx --configuration Release --no-build
The 13 tests cover:
- RRF agreement across both legs;
- rank-over-score behavior;
- invalid rank rejection;
Toplimiting;- supported and unsupported search modes;
- case/whitespace normalization in the mode parser;
- deterministic chunking;
- overlap preservation;
- empty-text behavior;
- invalid overlap rejection.
scripts/verify.ps1.What the Final Verified Run Proved
The final publication source was re-run after the last ingestion-hardening changes. The verifier started a clean PostgreSQL + pgvector container, launched the real API, generated real Ollama embeddings, called the HTTP endpoints, and inspected the live database.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\verify.ps1"
| Capability | Final evidence |
|---|---|
| Release build | PASS — 0 warnings, 0 errors |
| Deterministic tests | PASS — 13/13 |
| PostgreSQL + pgvector health | LIVE VERIFIED |
Ollama nomic-embed-text | LIVE VERIFIED |
| HTTP document ingestion | LIVE VERIFIED |
| Normalized document replacement | LIVE VERIFIED |
| Keyword retrieval | LIVE VERIFIED — returned 1 result in the verification scenario |
| Vector retrieval | LIVE VERIFIED — returned 1 result in the verification scenario |
| Hybrid RRF retrieval | LIVE VERIFIED — returned 1 result in the verification scenario |
| Hybrid top result | CancellationToken in hosted services |
| GIN index | LIVE VERIFIED |
HNSW vector_cosine_ops | LIVE VERIFIED |
| Stored vector dimension | LIVE VERIFIED — 768 |
| OpenAI cloud request | Not run — compile/construction only |
| Azure OpenAI cloud request | Not run — compile/construction only |
IX_DocumentChunks_SearchVector
USING gin ("SearchVector")
IX_DocumentChunks_Embedding
USING hnsw ("Embedding" vector_cosine_ops)
WITH (ef_construction='64', m='16')
stored vector_dims("Embedding")
= 768Changing the Embedding Model Is a Data Migration
The embedding dimension is part of the storage contract. In this sample, nomic-embed-text produced 768 values, so both the code guard and PostgreSQL column use 768.
vector(768) column cannot safely accept a different dimension.vector(1536) assumption. That value was tied to a different model expectation. The current verified sample uses 768 because that is what the actual live model returned.Tune and Measure Relevance
Once the plumbing works, the difficult work is no longer compilation—it is relevance. Build a small judged query set that represents what users actually search for, then compare the modes rather than assuming hybrid wins.
| Knob | What it changes | What to measure |
|---|---|---|
| Chunk size / overlap | How much context sits in one searchable unit. | Missed concepts, duplicated results, overly broad chunks. |
| PostgreSQL text config | Tokenization/stemming behavior for the keyword leg. | Exact-term precision, language fit, identifier handling. |
| Embedding model | The semantic representation used by the vector leg. | Recall on paraphrases and domain terminology. |
| Candidate depth | How many rows each leg gives to RRF. | Whether good candidates are missing before fusion. |
RRF k | How sharply top ranks dominate the contribution. | Ranking stability across judged queries. |
| Keyword/vector weights | Relative contribution of the two ranked lists. | Domain-specific precision/recall tradeoffs. |
| HNSW search tuning | Approximate-neighbor recall/speed tradeoff. | Latency and recall against exact search on a test subset. |
k=60 so the first experiment is understandable. Change one variable at a time and keep a relevance set.Production Considerations
The sample is intentionally small and locally reproducible. A production search system needs more controls around data lifecycle, observability, security, and retrieval evaluation.
Schema lifecycle
Replace tutorial EnsureCreatedAsync() bootstrap with deliberate, reviewed migrations and deployment sequencing.
Re-embedding jobs
Model changes require versioned embeddings, backfills, cutover planning, and clear dimension compatibility.
Access filtering
Search must not return a chunk merely because it ranks well. Apply tenant/user/document authorization in the retrieval design.
Observability
Record mode, latency, candidate counts, model/version, index settings, and relevance signals without logging sensitive document text unnecessarily.
Failure policy
Decide what happens when embeddings are unavailable. The hardened ingestion path already avoids deleting an old document before replacements are ready.
Evaluation
Track retrieval metrics on representative judged queries and re-run them after chunking, model, index, or fusion changes.
Common Problems and What They Mean
relation "documentchunks" does not exist
EF Core created a quoted mixed-case table name: "DocumentChunks". A manual PostgreSQL query that omits the quotes is folded to lowercase. Quote the identifier when inspecting it directly.
WebSearchToTsQuery says the query switched to client evaluation
Keep EF.Functions.WebSearchToTsQuery(...) inside the EF LINQ expression. It is a translation helper for PostgreSQL SQL, not a normal client-side parser.
Embedding dimension mismatch
The configured model returned a vector length different from 768. Do not suppress the guard. Decide whether you changed models intentionally, then migrate the schema and regenerate stored embeddings.
Hybrid results look identical to keyword results
That can be legitimate on a tiny corpus or a strongly lexical query. Inspect KeywordRank, VectorRank, and candidate depth. Use a larger judged query set before changing weights.
Docker starts but the API cannot connect
Check that the container is healthy and that local port 54329 is available. The checked-in connection string targets localhost:54329.
Ollama is running but ingestion fails
Confirm nomic-embed-text is installed and reachable from http://localhost:11434. The model is embedding-only; that is exactly what this tutorial needs.
CI does not run the live database test
That is intentional. Repository CI proves restore/build/13 deterministic tests. The publication's live PostgreSQL/pgvector/Ollama evidence was produced separately and recorded in the companion.
Frequently Asked Questions
Why not add keyword and vector scores directly?
They come from unrelated scoring systems. The verified sample fuses rank positions with RRF so one raw score scale cannot silently dominate the other.
Why does the sample use vector(768)?
The live-verified default model is Ollama nomic-embed-text, which returned 768-dimensional embeddings. The database column and runtime dimension guard intentionally match that model.
Can I use OpenAI or Azure OpenAI embeddings?
Yes. The companion includes compile/construction-verified adapter examples. Those cloud paths were not live-tested for this publication, and changing models can require a dimension/schema change plus re-embedding.
Does hybrid search always beat keyword-only or vector-only retrieval?
No. It can only fuse candidates produced by its input legs. Evaluate all modes against representative judged queries instead of assuming one architecture always wins.
Does GitHub Actions need Docker, Ollama, or an API key?
No. CI restores, builds, and runs 13 deterministic tests only. The live database and embedding path is kept separate from normal CI.
Why use EnsureCreatedAsync() in the sample?
It keeps a clean disposable Docker tutorial database easy to reproduce. Production applications should use reviewed migrations or another deliberate deployment migration process.
Do I need to regenerate embeddings when the model changes?
Treat an embedding-model change as a re-indexing migration. Check dimension compatibility and regenerate stored document embeddings so query and document vectors remain compatible.
Official Microsoft References
The .NET embedding abstractions and Azure OpenAI construction discussed here are grounded in Microsoft's current .NET documentation.
PostgreSQL, Npgsql, pgvector & Ollama References
The database and local embedding details come from the primary platform documentation used by the companion sample.
- Npgsql: EF Core Full Text Search
- PostgreSQL 17: Controlling Text Search
- PostgreSQL: Preferred Index Types for Text Search
- pgvector-dotnet: .NET and EF Core support
- pgvector: vector similarity search for PostgreSQL
- Ollama:
nomic-embed-text
tsvector columns, GIN, WebSearchToTsQuery, and rank translation. pgvector documents HNSW and vector_cosine_ops, while pgvector-dotnet documents EF Core distance/index APIs. The companion sample was then verified against those APIs rather than relying on documentation alone.Verified dotnet-guide.com Companion Sample
The merged GitHub sample is the authoritative implementation behind this tutorial. It contains the .NET 10 API, deterministic tests, Docker Compose setup, the end-to-end verification script, package graph, provider notes, and machine-readable verification evidence.