πŸ”Ž Keywords Miss Context, Vectors Miss Precision

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 search
  • POST /api/documents β€” chunk, embed, and store content
  • POST /api/ask β€” optional grounded answer using retrieved chunks
  • GET /health β€” database and application health
                         User query
                             β”‚
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                β–Ό                         β–Ό
       PostgreSQL full-text         pgvector cosine
       ranked keyword list          ranked semantic list
                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                             β–Ό
                  Reciprocal Rank Fusion
                             β–Ό
                    One explainable ranking
βœ…
Verified target: .NET 10 LTS, EF Core 10, Microsoft.Extensions.AI 10.x, PostgreSQL, and pgvector. Last reviewed: July 22, 2026. Package versions remain centralized in Directory.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.

Hybrid search is not magic. If both retrievers miss a document, fusion cannot bring it back. The quality of the final result still depends on chunking, embeddings, full-text configuration, and candidate depth.

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.
No search background required: each retrieval method and the fusion formula are introduced before the implementation.

Create the Solution and PostgreSQL + pgvector

bash
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
xml
<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>
yaml
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: 10

Start PostgreSQL:

bash
docker compose up -d postgres

Model the Document Chunk

csharp
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!;
}
csharp
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);
        });
    }
}
csharp
builder.Services.AddDbContext<SearchDbContext>(options =>
    options.UseNpgsql(
        builder.Configuration.GetConnectionString("Search"),
        npgsql => npgsql.UseVector()));
Dimension must match: a 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

csharp
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();
});
csharp
using Microsoft.Extensions.AI;
using OllamaSharp;

IEmbeddingGenerator<string, Embedding<float>> generator =
    new OllamaApiClient(
        new Uri("http://localhost:11434/"),
        "nomic-embed-text");
csharp
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);
        }
    }
}
Batch ingestion: sending several chunks per embedding request reduces overhead. Handle partial failures and provider rate limits so one failed batch does not silently leave half-indexed documents.

The Vector Leg: pgvector and HNSW

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

High-dimensional models: regular pgvector 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

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

Need typo tolerance? PostgreSQL full-text search handles linguistic normalization, not every misspelling. Consider 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 positions
RRF works with positions, not raw scores. This avoids score normalization and lets independent retrieval systems vote through their rankings.

Implement Reciprocal Rank Fusion

csharp
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

csharp
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);
    }
}
csharp
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 });
});
Return both source ranks. Seeing that a result was keyword rank 2 and vector rank 1 makes the fused ordering understandable and much easier to debug.

Tune and Measure Relevance

csharp
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;
}
json
{
  "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.
Do not tune on one impressive query. A change that improves a demo query can make the broader search experience worse.

Optional: Turn Retrieved Chunks into a Grounded Answer

csharp
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 })
    });
});
Retrieval is not proof. Retrieved text can be incomplete, incorrect, or contain prompt-injection instructions. Keep the RAG step separate, preserve source attribution, and treat retrieved content as dataβ€”not trusted commands.

Test Without a Live Embedding Model

csharp
[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);
}
csharp
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

SituationPostgreSQL + pgvectorDedicated vector store
Vectors beside relational recordsExcellent fitExtra synchronization
Small to medium corpusHNSW is often sufficientOptional
Vector-first, very large scaleRequires careful tuningOften stronger
Minimal infrastructureReuse the existing databaseAdds another service
Advanced vector-specific operationsMore limitedPurpose-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.

Keep an exit path: a vector-store abstraction can reduce application coupling if you expect the storage engine to change later. Do not add the abstraction merely because a migration is theoretically possible.

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.

Frequently Asked Questions

Why not add keyword and vector scores?

They use unrelated scales. RRF works with rank positions, so neither scoring system dominates simply because its numbers are larger.

Does hybrid search always beat both individual methods?

No. It often improves robustness, but poor chunking, a weak embedding model, shallow candidate retrieval, or bad full-text configuration can still produce weak results.

Can I change the embedding model without rebuilding the index?

No. Treat a model or dimensionality change as a data migration and regenerate all stored vectors.

Should I use exact search before HNSW?

For a small corpus, exact vector search is simpler and can provide a quality baseline. Add HNSW when measured latency or scale justifies approximate search.

Primary References

  1. Microsoft: Use the IEmbeddingGenerator interface
  2. pgvector-dotnet: Npgsql and EF Core integration
  3. pgvector: vector types, HNSW, operators, and limits
  4. PostgreSQL full-text search
  5. Azure AI Search: Reciprocal Rank Fusion
  6. Microsoft.Extensions.VectorData overview
Back to Tutorials