🔎 Rank by words + meaning

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.

Keyword 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 — accepts keyword, vector, or hybrid.
Deliberate scope: there is no /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.
GitHub companion sample: the complete merged source is available at dotnet-ai/hybrid-search-ef-core-pgvector. The merged sample is the technical source of truth for this page.

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-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.

Do not turn “hybrid” into a quality guarantee. Whether it helps depends on your corpus, queries, candidate limits, embedding model, text-search configuration, and relevance judgments. Measure it.

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.

ComponentVerified value
.NET SDK10.0.401
Target frameworknet10.0
EF Core10.0.12
Npgsql EF Core provider10.0.3
Pgvector.EntityFrameworkCore0.3.0
Microsoft.Extensions.AI10.10.0
Microsoft.Extensions.AI.OpenAI10.10.1
Azure.AI.OpenAI2.1.0
OllamaSharp5.5.0
PostgreSQL imagepgvector/pgvector:pg17
Live embedding modelnomic-embed-text
Verified vector dimension768
Deterministic tests13 passed, 0 failed, 0 skipped
Release build0 warnings, 0 errors
global.json
{
  "sdk": {
    "version": "10.0.401",
    "rollForward": "latestPatch",
    "allowPrerelease": false
  }
}
src/HybridSearch.Api/HybridSearch.Api.csproj
<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>
Publication status: the companion was rebuilt on a feature branch, passed 13/13 deterministic tests and GitHub Actions, was merged through PR #28, and was verified again on 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.

Notice 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#.

1

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.

docker-compose.yml
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:

PowerShell / Bash
docker compose up -d

At application startup, the sample creates the extension if necessary and uses EnsureCreatedAsync() to create the clean tutorial schema.

Data/DatabaseInitializer.cs — bootstrap excerpt
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);
}
Tutorial convenience, not production migration policy: 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.
2

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.

Data/DocumentChunk.cs
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:

Data/SearchDbContext.cs — OnModelCreating
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.

Why 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.
3

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.

Options/EmbeddingOptions.cs
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;
}
Services/EmbeddingProviderFactory.cs — provider construction
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();
    }
}
Program.cs — verified runtime registration
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 pathPublication evidence
Ollama / nomic-embed-textLIVE VERIFIED — generated real 768-dimensional embeddings and drove the final API E2E run.
OpenAI adapterCOMPILE / CONSTRUCTION VERIFIED ONLY — no cloud call in this publication run.
Azure OpenAI adapterCOMPILE / CONSTRUCTION VERIFIED ONLY — no cloud call in this publication run.
No cloud key is required for the verified default path. Docker PostgreSQL plus local Ollama is enough to reproduce ingestion and all three search modes.
4

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.

Services/TextChunker.cs — Split
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.

Services/DocumentIngestionService.cs — IngestAsync
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);
}
1
Normalize identity. " hosted-services " becomes "hosted-services" before lookup and storage.
2
Generate before delete. A failed embedding call cannot erase the previously searchable document version.
3
Validate dimensions. Every embedding must match 768 and must not contain NaN/infinite values.
4
Replace atomically. Delete + insert + save are wrapped in a database transaction.

The final E2E verifier explicitly re-ingested a whitespace-padded document ID and confirmed normalized replacement worked.

5

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.

Services/HybridSearchService.cs — KeywordAsync
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 generated tsvector.
  • Rank(...) orders lexical matches by PostgreSQL text-search relevance.
  • The result is converted to a RankedCandidate with rank positions starting at 1.
Keep translation-only helpers inside the LINQ query. During verification, pre-evaluating WebSearchToTsQuery outside the EF expression correctly failed because that method is intended for server translation. The final sample keeps it inline.
6

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.

Services/HybridSearchService.cs — VectorAsync
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.

Index/operator consistency: the query orders by 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.

RRF 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.

Mode-specific score semantics: in 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.
7

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.

Search/ReciprocalRankFusion.cs — Fuse
public 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.

Why keep source ranks? A fused score is easier to debug when you can still see “keyword rank 2, vector rank 5” rather than only one opaque number.
8

Assemble Keyword, Vector, and Hybrid Modes

SearchAsync validates the request, chooses the mode, builds a candidate pool, and uses RRF only for hybrid.

Services/HybridSearchService.cs — SearchAsync
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:

The parser accepts mixed casing and whitespace; missing/empty mode defaults to hybrid.

Search/SearchMode.cs — parser
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))
    };
Candidate depth is a tuning parameter. RRF can only fuse what each leg retrieves. If candidate pools are too shallow, relevant items may never reach the fusion step.
9

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.

PowerShell / Bash
# 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:

API: http://localhost:5087

The endpoints are ordinary Minimal API mappings:

Program.cs — HTTP endpoints
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:

src/HybridSearch.Api/HybridSearch.Api.http
@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
}
Try all three modes. Change "mode" to "keyword", "vector", and "hybrid" while keeping the same query. That makes the differences visible before you start tuning.
10

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.

PowerShell / Bash
dotnet restore HybridSearch.slnx
dotnet build HybridSearch.slnx --configuration Release --no-restore
dotnet test HybridSearch.slnx --configuration Release --no-build
PASS: Release build completed with 0 warnings and 0 errors. The deterministic suite reported 13 passed, 0 failed, 0 skipped. PR #28 passed the repository checks before merge.

The 13 tests cover:

  • RRF agreement across both legs;
  • rank-over-score behavior;
  • invalid rank rejection;
  • Top limiting;
  • supported and unsupported search modes;
  • case/whitespace normalization in the mode parser;
  • deterministic chunking;
  • overlap preservation;
  • empty-text behavior;
  • invalid overlap rejection.
CI evidence is not live-provider evidence. GitHub Actions proves restore/build/test portability. The database and Ollama path is verified separately by 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
powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\scripts\verify.ps1"
CapabilityFinal evidence
Release buildPASS — 0 warnings, 0 errors
Deterministic testsPASS — 13/13
PostgreSQL + pgvector healthLIVE VERIFIED
Ollama nomic-embed-textLIVE VERIFIED
HTTP document ingestionLIVE VERIFIED
Normalized document replacementLIVE VERIFIED
Keyword retrievalLIVE VERIFIED — returned 1 result in the verification scenario
Vector retrievalLIVE VERIFIED — returned 1 result in the verification scenario
Hybrid RRF retrievalLIVE VERIFIED — returned 1 result in the verification scenario
Hybrid top resultCancellationToken in hosted services
GIN indexLIVE VERIFIED
HNSW vector_cosine_opsLIVE VERIFIED
Stored vector dimensionLIVE VERIFIED — 768
OpenAI cloud requestNot run — compile/construction only
Azure OpenAI cloud requestNot run — compile/construction only
Evidence boundary: PostgreSQL/pgvector + Ollama is the live-verified default. OpenAI and Azure OpenAI remain optional adapter examples and are not labeled live-verified on this page.

Changing 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.

1
Check output dimension. A different provider/model may return a different vector length.
2
Update the schema deliberately. A vector(768) column cannot safely accept a different dimension.
3
Re-embed stored content. Even equal-dimensional models do not necessarily share the same vector space.
4
Rebuild/revalidate indexes. Treat the change as a retrieval migration, not a one-line configuration tweak.
Do not copy the old 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.

KnobWhat it changesWhat to measure
Chunk size / overlapHow much context sits in one searchable unit.Missed concepts, duplicated results, overly broad chunks.
PostgreSQL text configTokenization/stemming behavior for the keyword leg.Exact-term precision, language fit, identifier handling.
Embedding modelThe semantic representation used by the vector leg.Recall on paraphrases and domain terminology.
Candidate depthHow many rows each leg gives to RRF.Whether good candidates are missing before fusion.
RRF kHow sharply top ranks dominate the contribution.Ranking stability across judged queries.
Keyword/vector weightsRelative contribution of the two ranked lists.Domain-specific precision/recall tradeoffs.
HNSW search tuningApproximate-neighbor recall/speed tradeoff.Latency and recall against exact search on a test subset.
Start with the defaults, not with random tuning. The sample uses equal RRF weights and 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.

Keep retrieval and generation separable. If you later add RAG, make the retrieved chunk set inspectable before an LLM turns it into an answer. That keeps retrieval quality measurable on its own.

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.

  1. Microsoft Learn: Use the IEmbeddingGenerator interface
  2. Azure SDK for .NET: AzureOpenAIClient.GetEmbeddingClient
  3. Microsoft Learn: Entity Framework Core documentation

PostgreSQL, Npgsql, pgvector & Ollama References

The database and local embedding details come from the primary platform documentation used by the companion sample.

  1. Npgsql: EF Core Full Text Search
  2. PostgreSQL 17: Controlling Text Search
  3. PostgreSQL: Preferred Index Types for Text Search
  4. pgvector-dotnet: .NET and EF Core support
  5. pgvector: vector similarity search for PostgreSQL
  6. Ollama: nomic-embed-text
Primary-source alignment: Npgsql documents generated 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.

Independent educational sample: dotnet-guide.com is an independent educational publisher and is not affiliated with or endorsed by Microsoft, PostgreSQL, pgvector, OpenAI, Azure, or Ollama. Verification labels on this page describe what this companion project actually compiled, tested, or ran; they are not vendor certification claims.