How to Implement Hybrid Search in ASP.NET Core 10 Using C#, PostgreSQL pgvector and Full-Text Search – Complete Guide (2026)

How to Implement Hybrid Search in ASP.NET Core 10 Using C#, PostgreSQL pgvector and Full-Text Search – Complete Guide (2026)

A developer searches for “500.30 after deployment,” while another asks “why does my web service fail to start on Windows?” These questions may point to the same support article, but they provide different retrieval signals. The first contains a precise technical term; the second describes intent. A useful search API should be able to use both signals without pretending their scores mean the same thing.

This tutorial implements hybrid search in ASP.NET Core 10 using C#, PostgreSQL pgvector and full-text search. PostgreSQL retrieves lexical candidates from a generated tsvector column and semantic candidates from stored embeddings. A small, explicit C# Reciprocal Rank Fusion component merges the ranked document IDs. The endpoint also exposes keyword-only and semantic-only modes for comparison.

How does hybrid search combine keywords and vectors?

Run two retrieval methods over the same eligible documents, keep a bounded ranked list from each, deduplicate document IDs and sum their reciprocal-rank contributions. Return the highest fused scores. This avoids adding incompatible raw lexical and vector scores, but it does not guarantee higher relevance: candidate quality, language configuration, filters and evaluation still matter.

Hybrid search merges two separately ranked lists. This implementation runs database queries sequentially on one scoped DbContext, not concurrently.
Hybrid search merges two separately ranked lists. This implementation runs database queries sequentially on one scoped DbContext, not concurrently. Original SVG diagram.

1. Hybrid search versus keyword-only and vector-only retrieval

Dense embeddings represent text as floating-point coordinates learned by a model. They can help retrieve related wording, but may blur distinctions that matter for product codes, version numbers and error messages. A nearest vector is not proof of an exact match or a correct answer.

Lexical retrieval uses terms extracted from documents and queries. PostgreSQL full-text search normalizes text through parsers and dictionaries; it can handle inflection and phrase structure. It is not a raw substring search, nor is ts_rank a sparse neural embedding model. “Sparse lexical retrieval” in this guide refers to term-based evidence from PostgreSQL, not a separately trained sparse-vector encoder.

Keyword retrieval supplies lexical evidence; vector retrieval supplies semantic evidence. Neither guarantees an exact product-code match.
Keyword retrieval supplies lexical evidence; vector retrieval supplies semantic evidence. Neither guarantees an exact product-code match. Original SVG diagram.
MethodUseful signalImportant limitation
Keyword-onlyTechnical terminology, phrase and term matchingVocabulary mismatch; tokenization can alter identifiers
Vector-onlyParaphrases and conceptual similarityCan miss precise distinctions and exact strings
Hybrid with RRFAgreement and complementary candidates across ranked listsCannot rescue a document absent from both lists
Exact code equalityA normalized identifier in a structured columnDoes not answer broader natural-language intent

This implementation is a retrieval API, not a chatbot. It returns source content and ranking evidence. There is no generated answer, agent or claim that a fused score is a probability. The distinct contribution is full-text candidate generation and fusion, rather than another embedding-generation or RAG tutorial.

2. PostgreSQL tsvector, tsquery, websearch_to_tsquery and GIN

A tsvector stores normalized lexemes with positional information; a tsquery expresses matching conditions. The @@ operator checks whether the document representation matches the query. We use an explicit english configuration for both indexing and query parsing, so behavior does not depend on a session’s default text-search configuration.

websearch_to_tsquery accepts web-style query input: ordinary terms, quoted phrases, OR and exclusions. This improves input tolerance compared with requiring callers to write tsquery syntax. It is still a query language: for example, a dash can mean NOT. Parameterization prevents SQL injection, but does not turn those search operators into literal characters.

Inspect lexical normalization in a local SQL session
SELECT to_tsvector('english', 'Deploying APIs with IIS');
SELECT websearch_to_tsquery('english', '"application pool" OR hosting');
SELECT to_tsvector('english', 'Hosting an API with IIS')
       @@ websearch_to_tsquery('english', 'hosting');

These are commands to run, not captured database output. Stop words can disappear, stemming can change terms and punctuation can be interpreted differently from a human’s code-reading expectations. Use a structured Code column and equality filter for literal identifiers; do not promise full-text search preserves every SKU character.

GIN supports matching; it does not directly provide the requested ts_rank ordering. English tokenization is not literal whole-code equality.
GIN supports matching; it does not directly provide the requested ts_rank ordering. English tokenization is not literal whole-code equality. Original SVG diagram.

The generated SearchVector column updates when its source fields change. A GIN index helps identify matching rows. PostgreSQL then calculates ts_rank for those matches and sorts them. GIN does not directly supply the requested relevance order, and ts_rank is not BM25. See PostgreSQL text-search controls, GIN indexing guidance and Npgsql full-text mapping.

English is a deliberate limitation for this example. The embedding model supports multilingual text, but that does not make the english lexical dictionary multilingual. For mixed-language collections, design and evaluate a language/configuration policy instead of silently applying one dictionary to everything.

3. Reciprocal Rank Fusion with a worked numerical example

For a document d, use RRF(d) = Σ 1 / (c + rank_i(d)) over lists that contain it. Ranks start at 1. A missing document contributes zero from that list. This implementation sets c=60 and gives the keyword and vector lists equal weight. The constant is unrelated to TopK or CandidateLimit.

The original RRF paper motivates rank fusion. Its experimental results do not prove an improvement on your dataset. Here, c=60 is a starting configuration, not an automatically optimal setting.

Suppose the keyword list is A, B, C and the vector list is B, D, A. These are constructed rankings, not model outputs:

DocumentKeyword rankVector rankContribution calculationRRF score
A131/61 + 1/630.032266458
B211/62 + 1/610.032522475
C3Absent1/630.015873016
DAbsent21/620.016129032
RRF adds reciprocal ranks, not raw ts_rank and cosine scores. The numbers are a mathematical example, not measured retrieval performance.
RRF adds reciprocal ranks, not raw ts_rank and cosine scores. The numbers are a mathematical example, not measured retrieval performance. Original SVG diagram.

The fused order is B, A, D, C. B benefits from appearing near the top of both lists. Raw ts_rank and cosine distance values never enter this calculation. Floating-point division must be used: integer division such as 1/(60+rank) would produce zero for these ranks.

RRF deliberately discards score magnitude. A document barely ahead of another and a document far ahead both get adjacent ordinal ranks. It can also promote a mediocre item that appears in both lists. Deduplicate once per retriever, define tie handling and evaluate candidate budgets. Two equal-weight lists contribute at most 2/61 for a document ranked first in both, so a value around 0.03 is not “3% relevance.”

4. ASP.NET Core 10 project and compatible packages

Install the .NET 10 SDK, a compatible editor or Visual Studio workload, Docker with Linux containers, Ollama and Postman. The example runs ASP.NET Core and Ollama on the host and PostgreSQL in Docker. Hardware requirements and model latency depend on your environment; no throughput claim is made.

Create the web project and local EF tooling
dotnet new web -n HybridSearch.Api -f net10.0
cd HybridSearch.Api
dotnet add package Microsoft.EntityFrameworkCore --version 10.0.14
dotnet add package Microsoft.EntityFrameworkCore.Design --version 10.0.14
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL --version 10.0.3
dotnet add package Pgvector.EntityFrameworkCore --version 0.3.0
dotnet new tool-manifest
dotnet tool install --local dotnet-ef --version 10.0.14
HybridSearch.Api.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>HybridSearch.Api.Local</UserSecretsId>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.14" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.14">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
    <PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
  </ItemGroup>
</Project>

Microsoft EF runtime/design/tool versions are aligned. Npgsql has its own patch schedule, and its 10.0.3 package declares an EF Core 10-compatible dependency range. Pgvector.EntityFrameworkCore 0.3.0 documents EF Core 9/10 support. These are stable selections; do not substitute 11.x previews merely because NuGet displays them. Verify Npgsql dependencies, the vector adapter and EF design tooling when reproducing the tutorial.

EF tooling generates Migrations beside these files. Rank fusion is a separate pure C# component that can be tested without a model or database.
EF tooling generates Migrations beside these files. Rank fusion is a separate pure C# component that can be tested without a model or database. Original SVG diagram.

5. Start PostgreSQL pgvector, pull Ollama and configure secrets

Use a fresh database for this standalone project. The Docker mapping below uses host port 5433 so it does not collide with another local PostgreSQL instance on 5432. The named volume preserves data; recreating a container does not reset credentials in an initialized database.

compose.yaml
services:
  postgres:
    image: pgvector/pgvector:0.8.7-pg17-bookworm
    container_name: hybrid-search-db
    environment:
      POSTGRES_DB: hybrid_search
      POSTGRES_USER: hybrid_dev
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set a local password}
    ports:
      - "127.0.0.1:5433:5432"
    volumes:
      - hybrid-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U hybrid_dev -d hybrid_search"]
      interval: 5s
      timeout: 5s
      retries: 10
volumes:
  hybrid-data:
PowerShell: start local dependencies
$env:POSTGRES_PASSWORD = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(24))
docker compose up -d
docker compose ps
ollama pull nomic-embed-text-v2-moe
ollama list
ollama show nomic-embed-text-v2-moe

The official image installs the extension binaries; the EF migration enables vector in this database. Ollama must be installed and running before the pull command. The selected Nomic model uses full-size 768-dimensional vectors, a 512-token sequence limit and different document/query prefixes. Record the pulled model identity; do not silently update weights while keeping old stored vectors.

appsettings.json
{
  "ConnectionStrings": { "SearchDb": "" },
  "Ollama": { "BaseUrl": "http://localhost:11434/", "Model": "nomic-embed-text-v2-moe" },
  "Demo": { "ReadKey": "", "WriteKey": "" },
  "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning" } },
  "AllowedHosts": "localhost"
}
PowerShell: local development secrets
dotnet user-secrets init
$connection = "Host=localhost;Port=5433;Database=hybrid_search;Username=hybrid_dev;Password=$($env:POSTGRES_PASSWORD);SSL Mode=Disable;Include Error Detail=false"
dotnet user-secrets set "ConnectionStrings:SearchDb" $connection
$readKey = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
$writeKey = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
dotnet user-secrets set "Demo:ReadKey" $readKey
dotnet user-secrets set "Demo:WriteKey" $writeKey

Keep these values private in your local Postman environment. User Secrets avoids committing credentials but does not encrypt its storage. The local database disables TLS only for this loopback development setup; remote production connections need verified TLS and a least-privilege runtime account. The initial Docker role is administrative convenience, not the recommended production role. Environment overrides include ConnectionStrings__SearchDb and Demo__ReadKey.

6. Document entity, generated full-text column and both indexes

Contracts.cs
namespace HybridSearch.Api;
public static class EmbeddingContract
{
    public const string Model = "nomic-embed-text-v2-moe";
    public const int Dimensions = 768;
    public const string Space = "nomic-v2-moe-768-prefix-v1";
}
public sealed record IndexRequest(string? Code, string? Title, string? Content, string? Category);
public sealed record SearchRequest(string? Query, string Mode = "hybrid", int TopK = 5,
    int CandidateLimit = 50, string? Category = null, string? ExactCode = null);
public sealed record IndexedDocument(Guid Id, string Code, string Title);
public sealed record Candidate(Guid Id, string Code, string Title, string Content, double Value);
public sealed record SearchHit(Guid Id, string Code, string Title, string Content,
    double RrfScore, int? KeywordRank, int? VectorRank,
    double? KeywordScore, double? CosineDistance);
public sealed record SearchResponse(string Mode, int CandidateLimit, int RrfConstant,
    int KeywordCandidates, int VectorCandidates, SearchHit[] Results);
public sealed class ApiFailure(int status, string safeMessage) : Exception(safeMessage)
{ public int Status { get; } = status; }
public static class InputRules
{
    public static string Text(string? value, string name, int max)
    {
        if (string.IsNullOrWhiteSpace(value) || value.Length > max)
            throw new ApiFailure(400, $"{name} must contain 1–{max} characters.");
        return value.Trim();
    }
    public static string Code(string? value)
    {
        var code = Text(value, "Code", 64).ToUpperInvariant();
        if (!System.Text.RegularExpressions.Regex.IsMatch(code, @"\A[A-Z0-9._-]+\z"))
            throw new ApiFailure(400, "Code may contain ASCII letters, digits, dot, underscore or hyphen.");
        return code;
    }
}
SearchDbContext.cs
using Microsoft.EntityFrameworkCore;
using NpgsqlTypes;
using Pgvector;
using Pgvector.EntityFrameworkCore;
namespace HybridSearch.Api;
public sealed class SearchDocument
{
    public Guid Id { get; set; }
    public string Code { get; set; } = "";
    public string Title { get; set; } = "";
    public string Content { get; set; } = "";
    public string Category { get; set; } = "";
    public string Space { get; set; } = "";
    public Vector Embedding { get; set; } = null!;
    public NpgsqlTsVector SearchVector { get; set; } = null!;
}
public sealed class SearchDbContext(DbContextOptions<SearchDbContext> options) : DbContext(options)
{
    public DbSet<SearchDocument> Documents => Set<SearchDocument>();
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasPostgresExtension("vector");
        var d = modelBuilder.Entity<SearchDocument>();
        d.ToTable("Documents"); d.HasKey(x => x.Id);
        d.Property(x => x.Code).HasMaxLength(64).IsRequired();
        d.Property(x => x.Title).HasMaxLength(120).IsRequired();
        d.Property(x => x.Content).HasMaxLength(600).IsRequired();
        d.Property(x => x.Category).HasMaxLength(40).IsRequired();
        d.Property(x => x.Space).HasMaxLength(100).IsRequired();
        d.Property(x => x.Embedding).HasColumnType("vector(768)").IsRequired();
        d.HasGeneratedTsVectorColumn(x => x.SearchVector, "english",
            x => new { x.Title, x.Content, x.Code });
        d.HasIndex(x => x.SearchVector).HasMethod("GIN");
        d.HasIndex(x => x.Code);
        d.HasIndex(x => x.Embedding).HasMethod("hnsw").HasOperators("vector_cosine_ops");
    }
}

Code values are normalized to uppercase ASCII identifiers. The database has a regular index for equality filtering, a GIN index for full-text matching and a cosine HNSW index for vector candidates. SearchVector is generated by PostgreSQL from Title, Content and Code. The application must not assign a manually assembled tsvector on ingestion.

HasGeneratedTsVectorColumn expresses the lexical mapping. HasPostgresExtension enables vector through migrations; UseVector registers the .NET provider integration. These are separate responsibilities. A NuGet package cannot install extension binaries on a PostgreSQL server.

SearchDbFactory.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Pgvector.EntityFrameworkCore;
namespace HybridSearch.Api;

public sealed class SearchDbFactory : IDesignTimeDbContextFactory<SearchDbContext>
{
    public SearchDbContext CreateDbContext(string[] args)
    {
        var config = new ConfigurationBuilder().SetBasePath(Directory.GetCurrentDirectory())
            .AddJsonFile("appsettings.json", optional: false)
            .AddUserSecrets<SearchDbFactory>(optional: true).AddEnvironmentVariables().Build();
        var connection = config.GetConnectionString("SearchDb");
        if (string.IsNullOrWhiteSpace(connection))
            throw new InvalidOperationException("Configure ConnectionStrings:SearchDb.");
        var options = new DbContextOptionsBuilder<SearchDbContext>()
            .UseNpgsql(connection, o => o.UseVector()).Options;
        return new SearchDbContext(options);
    }
}

The design-time factory needs only database configuration, so migration generation does not require the API’s read/write keys or a running embedding model. Execute these commands from the project directory:

Generate, review and apply migrations
dotnet restore --use-lock-file
dotnet build --no-restore
dotnet ef migrations add InitialHybridSearch
dotnet ef migrations script --output initial-hybrid-search.sql
# Review the generated SQL before applying it.
dotnet ef database update
docker exec hybrid-search-db psql -U hybrid_dev -d hybrid_search -c "SELECT extname, extversion FROM pg_extension WHERE extname = 'vector';"
Conceptual generated schema excerpt
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE "Documents" (
  "Id" uuid PRIMARY KEY,
  "Code" varchar(64) NOT NULL,
  "Title" varchar(120) NOT NULL,
  "Content" varchar(600) NOT NULL,
  "Category" varchar(40) NOT NULL,
  "Space" varchar(100) NOT NULL,
  "Embedding" vector(768) NOT NULL,
  "SearchVector" tsvector GENERATED ALWAYS AS (
    to_tsvector('english'::regconfig,
      "Title" || ' ' || "Content" || ' ' || "Code")
  ) STORED
);
CREATE INDEX ON "Documents" USING gin ("SearchVector");
CREATE INDEX ON "Documents" USING hnsw ("Embedding" vector_cosine_ops);
CREATE INDEX ON "Documents" ("Code");

This SQL explains the intended objects; it is not a second creation step after EF migrations. Provider-generated SQL can include coalesce expressions, migration history and different index names. Inspect it rather than replacing it blindly with the explanatory excerpt. Use reviewed migration scripts or bundles in production and avoid mixing EnsureCreated with migrations.

7. Complete local embedding client

OllamaEmbeddings.cs
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace HybridSearch.Api;

public sealed class OllamaEmbeddings(HttpClient http, ILogger<OllamaEmbeddings> logger)
{
    public Task<float[]> ForDocumentAsync(string title, string content, CancellationToken ct)
        => GenerateAsync("search_document: " + title + "\n" + content, ct);
    public Task<float[]> ForQueryAsync(string query, CancellationToken ct)
        => GenerateAsync("search_query: " + query, ct);
    private async Task<float[]> GenerateAsync(string input, CancellationToken ct)
    {
        using var budget = CancellationTokenSource.CreateLinkedTokenSource(ct);
        budget.CancelAfter(TimeSpan.FromSeconds(60));
        var started = System.Diagnostics.Stopwatch.StartNew();
        try
        {
            using var response = await http.PostAsJsonAsync("api/embed", new
            { model = EmbeddingContract.Model, input, truncate = false }, budget.Token);
            if (response.StatusCode == HttpStatusCode.BadRequest)
                throw new ApiFailure(422, "The embedding model rejected this input. Check its token limit.");
            if (response.StatusCode == HttpStatusCode.NotFound)
                throw new ApiFailure(503, "The configured embedding model is unavailable.");
            if ((int)response.StatusCode is 429 or 503)
                throw new ApiFailure(503, "The embedding service is busy.");
            if (!response.IsSuccessStatusCode)
                throw new ApiFailure(502, "The embedding service failed.");
            var result = await response.Content.ReadFromJsonAsync<EmbedResponse>(budget.Token);
            if (result?.Embeddings is not { Length: 1 })
                throw new ApiFailure(502, "The embedding response has an invalid vector count.");
            var vector = result.Embeddings[0];
            if (vector is null || vector.Length != EmbeddingContract.Dimensions)
                throw new ApiFailure(502, "The embedding dimensions do not match this corpus.");
            double norm = 0;
            foreach (var value in vector)
            {
                if (!float.IsFinite(value))
                    throw new ApiFailure(502, "The embedding contains invalid coordinates.");
                norm += (double)value * value;
            }
            if (norm == 0) throw new ApiFailure(502, "A zero vector cannot be searched with cosine distance.");
            logger.LogInformation("Embedding completed: dimensions={Dimensions}, elapsedMs={ElapsedMs}",
                vector.Length, started.ElapsedMilliseconds);
            return vector;
        }
        catch (OperationCanceledException) when (!ct.IsCancellationRequested)
        { throw new ApiFailure(504, "The embedding service exceeded the request budget."); }
        catch (HttpRequestException)
        { throw new ApiFailure(503, "The embedding service cannot be reached."); }
        catch (JsonException)
        { throw new ApiFailure(502, "The embedding service returned an invalid response."); }
    }
    private sealed class EmbedResponse
    {
        [JsonPropertyName("embeddings")]
        public float[][]? Embeddings { get; set; }
    }
}
Inference completes before the write. PostgreSQL maintains the generated SearchVector and both indexes when the row is inserted.
Inference completes before the write. PostgreSQL maintains the generated SearchVector and both indexes when the row is inserted. Original SVG diagram.

The client calls the current POST /api/embed contract: input in, embeddings array out. It applies search_document: for indexed text and search_query: for query text, sets truncate=false and checks a single finite nonzero vector of length 768. The code does not mix the older /api/embeddings prompt contract with this response shape.

The 60-second linked token is a cooperative operation budget, not a measured optimal timeout. Caller cancellation flows into HTTP and database operations. Model loading or slow CPU inference may need a different budget based on measurement. A timeout does not prove the model instantly stopped all work.

The application limits title/content/query characters, but character count is not token count. Prefixes and the document code are part of the model input. A 512-token rejection maps to a safe 422 response; other model-side 400 rejections use the same general message. In production, use the appropriate tokenizer and deliberate chunking rather than silently discarding input.

Dimension equality alone is not model compatibility. Space is an application-managed contract label, not a digest of the model weights. Re-embed and version the corpus when changing weights, prefixes or preprocessing.

8. Implement Reciprocal Rank Fusion as pure C#

RrfFusion.cs
namespace HybridSearch.Api;
public static class RrfFusion
{
    public const int Constant = 60;
    public static SearchHit[] Merge(IReadOnlyList<Candidate> keyword,
        IReadOnlyList<Candidate> vector, int topK)
    {
        var docs = new Dictionary<Guid, Candidate>();
        var kr = new Dictionary<Guid, int>(); var vr = new Dictionary<Guid, int>();
        var ks = new Dictionary<Guid, double>(); var vd = new Dictionary<Guid, double>();
        void Add(IReadOnlyList<Candidate> list, Dictionary<Guid, int> ranks,
            Dictionary<Guid, double> values)
        {
            var rank = 0;
            foreach (var item in list)
            {
                if (ranks.ContainsKey(item.Id)) continue; // one vote per retriever
                ranks[item.Id] = ++rank; values[item.Id] = item.Value;
                docs.TryAdd(item.Id, item);
            }
        }
        Add(keyword, kr, ks); Add(vector, vr, vd);
        return docs.Values.Select(d =>
        {
            int? k = kr.TryGetValue(d.Id, out var a) ? a : null;
            int? v = vr.TryGetValue(d.Id, out var b) ? b : null;
            var score = (k.HasValue ? 1.0 / (Constant + k.Value) : 0)
                      + (v.HasValue ? 1.0 / (Constant + v.Value) : 0);
            return new SearchHit(d.Id, d.Code, d.Title, d.Content, score, k, v,
                ks.TryGetValue(d.Id, out var s) ? s : null,
                vd.TryGetValue(d.Id, out var dist) ? dist : null);
        }).OrderByDescending(x => x.RrfScore).ThenBy(x => x.Id).Take(topK).ToArray();
    }
}

The helper receives already ordered, bounded candidate lists. It removes duplicate IDs within each list so one retriever cannot cast repeated votes. Rank numbering starts at 1 after deduplication. A document found in both lists receives both contributions; a missing rank stays null and contributes zero.

The final result uses descending RRF score and then ID for equal fused scores. The lexical branch also uses ID to order equal ts_rank values. The vector branch preserves raw distance ordering for index eligibility; tied vector distances can still be returned in a different order by the database, so the overall ranking is not guaranteed reproducible across changing executions.

In keyword-only or semantic-only mode, the same response score is a one-list RRF score. That preserves the branch’s order but is not its native relevance score. KeywordScore and CosineDistance remain separate nullable fields for diagnosis. Do not compare a one-list RRF score directly with a two-list score as if it measured improved relevance.

Optional deterministic fusion check in a separate console project
using HybridSearch.Api;
var a = Guid.Parse("00000000-0000-0000-0000-000000000001");
var b = Guid.Parse("00000000-0000-0000-0000-000000000002");
var c = Guid.Parse("00000000-0000-0000-0000-000000000003");
var d = Guid.Parse("00000000-0000-0000-0000-000000000004");
Candidate Row(Guid id) => new(id, id.ToString(), "toy", "toy", 0);
var result = RrfFusion.Merge([Row(a), Row(b), Row(c)], [Row(b), Row(d), Row(a)], 4);
if (!result.Select(x => x.Id).SequenceEqual(new[] { b, a, d, c }))
    throw new Exception("Unexpected fusion order.");
if (Math.Abs(result[0].RrfScore - (1.0/62 + 1.0/61)) > 1e-12)
    throw new Exception("Unexpected RRF arithmetic.");

For this isolated check, copy Contracts.cs and RrfFusion.cs into a separate .NET 10 console project and use the snippet as its Program.cs. It does not require a database or provider. The check is provided for you to run; it was not executed as C# here. The displayed mathematical totals were independently verified with Python.

9. Keyword retrieval, semantic retrieval and the hybrid endpoint service

HybridSearchService.cs
using System.Data;
using Microsoft.EntityFrameworkCore;
using Pgvector;
using Pgvector.EntityFrameworkCore;
namespace HybridSearch.Api;
public sealed class HybridSearchService(SearchDbContext db, OllamaEmbeddings embeddings,
    ILogger<HybridSearchService> logger)
{
    public async Task<IndexedDocument> IndexAsync(IndexRequest request, CancellationToken ct)
    {
        var code = InputRules.Code(request.Code);
        var title = InputRules.Text(request.Title, "Title", 120);
        var content = InputRules.Text(request.Content, "Content", 600);
        var category = InputRules.Text(request.Category, "Category", 40);
        var vector = await embeddings.ForDocumentAsync(title + " " + code, content, ct);
        var doc = new SearchDocument { Id = Guid.NewGuid(), Code = code, Title = title,
            Content = content, Category = category, Space = EmbeddingContract.Space,
            Embedding = new Vector(vector) };
        db.Documents.Add(doc);
        await db.SaveChangesAsync(ct); // PostgreSQL generates SearchVector
        return new IndexedDocument(doc.Id, doc.Code, doc.Title);
    }
    public async Task<SearchResponse> SearchAsync(SearchRequest request, CancellationToken ct)
    {
        var query = InputRules.Text(request.Query, "Query", 300);
        var mode = InputRules.Text(request.Mode, "Mode", 10).ToLowerInvariant();
        if (mode is not ("keyword" or "semantic" or "hybrid"))
            throw new ApiFailure(400, "Mode must be keyword, semantic or hybrid.");
        if (request.TopK is < 1 or > 20 || request.CandidateLimit is < 1 or > 100
            || request.CandidateLimit < request.TopK)
            throw new ApiFailure(400, "Use TopK 1–20 and CandidateLimit between TopK and 100.");
        var category = request.Category is null ? null : InputRules.Text(request.Category, "Category", 40);
        var code = request.ExactCode is null ? null : InputRules.Code(request.ExactCode);
        Vector? vector = mode == "keyword" ? null
            : new Vector(await embeddings.ForQueryAsync(query, ct));
        // Inference finishes before the DB transaction starts.
        await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct);
        IQueryable<SearchDocument> eligible = db.Documents.AsNoTracking()
            .Where(d => d.Space == EmbeddingContract.Space);
        if (category is not null) eligible = eligible.Where(d => d.Category == category);
        if (code is not null) eligible = eligible.Where(d => d.Code == code);
        List<Candidate> keyword = []; List<Candidate> semantic = [];
        if (mode is "keyword" or "hybrid")
        {
            // The function remains inside the LINQ expression for server translation.
            keyword = await eligible
                .Where(d => d.SearchVector.Matches(EF.Functions.WebSearchToTsQuery("english", query)))
                .OrderByDescending(d => d.SearchVector.Rank(EF.Functions.WebSearchToTsQuery("english", query)))
                .ThenBy(d => d.Id)
                .Take(request.CandidateLimit)
                .Select(d => new Candidate(d.Id, d.Code, d.Title, d.Content,
                    d.SearchVector.Rank(EF.Functions.WebSearchToTsQuery("english", query))))
                .ToListAsync(ct);
        }
        if (mode is "semantic" or "hybrid")
        {
            semantic = await eligible.OrderBy(d => d.Embedding.CosineDistance(vector!))
                .Take(request.CandidateLimit)
                .Select(d => new Candidate(d.Id, d.Code, d.Title, d.Content,
                    d.Embedding.CosineDistance(vector!))).ToListAsync(ct);
        }
        await tx.CommitAsync(ct);
        var hits = RrfFusion.Merge(keyword, semantic, request.TopK);
        logger.LogInformation("Retrieval mode={Mode}, lexical={Lexical}, vector={Vector}, returned={Returned}",
            mode, keyword.Count, semantic.Count, hits.Length);
        return new SearchResponse(mode, request.CandidateLimit, RrfFusion.Constant,
            keyword.Count, semantic.Count, hits);
    }
}
A dependency failure returns an error in this tutorial. An explicitly requested keyword-only mode remains usable without invoking the model.
A dependency failure returns an error in this tutorial. An explicitly requested keyword-only mode remains usable without invoking the model. Original SVG diagram.

The lexical LINQ expression stays server-side: Matches translates to @@, WebSearchToTsQuery parses input in PostgreSQL, and Rank maps to ts_rank. Do not evaluate WebSearchToTsQuery in an ordinary C# local variable before composing the expression; it is a translation API, not a local query parser.

The vector branch uses cosine distance, sorted ascending with a bounded Take. Cosine similarity is one minus distance, but this API reports distance directly. Do not change the SQL ORDER BY into a descending arithmetic score and assume HNSW remains eligible. See pgvector-dotnet translation examples.

Cosine distance is smaller for closer vectors. Preserve raw ascending distance ordering so vector_cosine_ops is eligible.
Cosine distance is smaller for closer vectors. Preserve raw ascending distance ordering so vector_cosine_ops is eligible. Original SVG diagram.

Both branches share Space, Category and ExactCode filters before their candidate limits. Category is case-sensitive in this example. ExactCode is normalized through the same policy as ingestion. These are relevance filters, not tenant authorization. Production permission predicates must be derived from trusted identity and applied to every branch.

Database queries run sequentially on the same scoped DbContext. EF Core does not support concurrent operations on one context. A repeatable-read transaction gives both queries a consistent database snapshot; inference completes before the transaction begins. HNSW remains approximate even inside a snapshot, and this transaction does not stabilize all future requests.

Indexing creates a new UUID every time. It is intentionally not an idempotent upsert. For durable production ingestion, use a source ID and version policy so retries after uncertain responses do not create duplicate corpus rows. Generated lexical data stays consistent with its source row; source text and dense vectors must also be updated together when implementing edits.

10. Complete Program.cs, access controls and safe errors

ApiErrorHandler.cs
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace HybridSearch.Api;

public sealed class ApiErrorHandler(ILogger<ApiErrorHandler> logger) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext http, Exception error, CancellationToken ct)
    {
        if (http.RequestAborted.IsCancellationRequested) return true;
        var status = error switch
        {
            ApiFailure known => known.Status,
            BadHttpRequestException bad => bad.StatusCode,
            DbUpdateException or NpgsqlException => 503,
            _ => 500
        };
        // Avoid exception messages that might include submitted text or connection details.
        logger.LogError("Request failed: type={ErrorType}, status={Status}, trace={TraceId}",
            error.GetType().Name, status, http.TraceIdentifier);
        http.Response.StatusCode = status;
        http.Response.Headers.CacheControl = "no-store";
        await http.Response.WriteAsJsonAsync(new ProblemDetails
        {
            Status = status, Title = "The request could not be completed.",
            Detail = error is ApiFailure safe ? safe.Message : "Check the server logs using the trace ID.",
            Extensions = { ["traceId"] = http.TraceIdentifier }
        }, contentType: "application/problem+json", cancellationToken: ct);
        return true;
    }
}
Program.cs
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Pgvector.EntityFrameworkCore;
using HybridSearch.Api;

var builder = WebApplication.CreateBuilder(args);
string Required(string key) => !string.IsNullOrWhiteSpace(builder.Configuration[key])
    ? builder.Configuration[key]! : throw new InvalidOperationException($"Configure {key}.");
var connection = Required("ConnectionStrings:SearchDb");
var readKey = Required("Demo:ReadKey");
var writeKey = Required("Demo:WriteKey");
if (readKey.Length is < 32 or > 256 || writeKey.Length is < 32 or > 256 || readKey == writeKey)
    throw new InvalidOperationException("Use distinct random read/write keys of 32–256 characters.");
var baseUri = new Uri(Required("Ollama:BaseUrl"), UriKind.Absolute);
if (baseUri.Scheme is not ("http" or "https") || !baseUri.AbsoluteUri.EndsWith('/'))
    throw new InvalidOperationException("Configure a trusted HTTP(S) Ollama base URL ending in /.");
if (Required("Ollama:Model") != EmbeddingContract.Model)
    throw new InvalidOperationException("This corpus requires the tutorial's fixed embedding model.");
builder.WebHost.ConfigureKestrel(o => o.Limits.MaxRequestBodySize = 16 * 1024);
builder.Services.AddDbContext<SearchDbContext>(o =>
    o.UseNpgsql(connection, p => { p.UseVector(); p.CommandTimeout(15); }));
builder.Services.AddHttpClient<OllamaEmbeddings>(http =>
{
    http.BaseAddress = baseUri;
    // The typed client owns a linked 60-second budget for each embedding operation.
    http.Timeout = Timeout.InfiniteTimeSpan;
    http.MaxResponseContentBufferSize = 1024 * 1024;
}).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
builder.Services.AddScoped<HybridSearchService>();
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<ApiErrorHandler>();
builder.Services.ConfigureHttpJsonOptions(o =>
    o.SerializerOptions.UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow);
builder.Services.AddRateLimiter(o =>
{
    o.RejectionStatusCode = 429;
    o.AddConcurrencyLimiter("search-work", p => { p.PermitLimit = 4; p.QueueLimit = 0; });
});
var readHash = SHA256.HashData(Encoding.UTF8.GetBytes(readKey));
var writeHash = SHA256.HashData(Encoding.UTF8.GetBytes(writeKey));
var app = builder.Build();
app.UseExceptionHandler();
app.UseRouting();
app.Use(async (http, next) =>
{
    http.Response.Headers.CacheControl = "no-store";
    if (!http.Request.Headers.TryGetValue("X-Api-Key", out var values)
        || values.Count != 1 || values.ToString().Length > 256)
    { http.Response.StatusCode = 401; return; }
    var suppliedHash = SHA256.HashData(Encoding.UTF8.GetBytes(values.ToString()));
    var canWrite = CryptographicOperations.FixedTimeEquals(writeHash, suppliedHash);
    var canRead = CryptographicOperations.FixedTimeEquals(readHash, suppliedHash);
    if (!canWrite && !canRead) { http.Response.StatusCode = 401; return; }
    if (HttpMethods.IsPost(http.Request.Method)
        && !string.Equals(http.Request.Path.Value, "/api/search", StringComparison.OrdinalIgnoreCase)
        && !canWrite)
    { http.Response.StatusCode = 403; return; }
    await next(http);
});
app.UseRateLimiter();
var api = app.MapGroup("/api").RequireRateLimiting("search-work");
api.MapPost("/documents", async (IndexRequest request, HybridSearchService service, CancellationToken ct) =>
    Results.Ok(await service.IndexAsync(request, ct)));
api.MapPost("/search", async (SearchRequest request, HybridSearchService service, CancellationToken ct) =>
    Results.Ok(await service.SearchAsync(request, ct)));
app.Run();

The reader key can call POST /api/search; the writer key can ingest and search. The route check normalizes case for its search allowlist. This is a small local demonstration, not a production authentication framework. Configure identity-provider validation, endpoint authorization policies, revocation and auditing before exposing a multi-user service.

The global handler returns safe ProblemDetails rather than database connection text or model response bodies. It logs type, status and trace ID. In .NET 10, handled exceptions can suppress framework diagnostics; this explicit event keeps failures visible without logging submitted queries or secrets.

The concurrency limiter permits four active endpoint requests per process, with no queue. It does not implement distributed, per-user or token-based quotas. The HttpClient disables redirects and uses a trusted configuration URL, so a caller cannot choose an arbitrary inference host. Keep model and database ports private.

Related foundations on ASP.NET PB: JWT authentication, IExceptionHandler error handling and IIS deployment. These explain surrounding concerns; they do not automatically provide document-level authorization for this search corpus.

11. Postman tests and a representative comparison dataset

PowerShell: build and run after applying migrations
$env:ASPNETCORE_ENVIRONMENT = "Development"
dotnet build
dotnet run --no-launch-profile -- --urls "http://localhost:5094"

Create private Postman variables baseUrl=http://localhost:5094, readKey and writeKey. Add Content-Type: application/json. Do not export environments containing secrets. Local HTTP is for the developer machine; production ingress must use HTTPS.

Ingest a small corpus

POST each object separately to {{baseUrl}}/api/documents with X-Api-Key: {{writeKey}}. The endpoint does not accept an array. Each successful request returns HTTP 200 with id, normalized code and title; UUIDs are generated at runtime.

Document A: a precise technical error
{
  "code": "IIS-500.30",
  "title": "ASP.NET Core startup failure",
  "content": "HTTP Error 500.30 means an in-process ASP.NET Core application failed to start. Inspect startup configuration and safe server diagnostics.",
  "category": "hosting"
}
Document B: deployment wording
{
  "code": "IIS-DEPLOY",
  "title": "Publish an API to Windows Server",
  "content": "Publish the web service, install the ASP.NET Core Hosting Bundle and configure an IIS website, application pool and HTTPS binding.",
  "category": "hosting"
}
Document C: another precise error
{
  "code": "IIS-500.31",
  "title": "Missing ASP.NET Core runtime dependencies",
  "content": "HTTP Error 500.31 indicates that ASP.NET Core Module could not find required native dependencies. Check the installed runtime and hosting setup.",
  "category": "hosting"
}
Document D: unrelated content
{
  "code": "GARDEN-01",
  "title": "Container gardening",
  "content": "Grow tomatoes in a sunny location with drainage and consistent watering.",
  "category": "gardening"
}

Run the same query in three modes

POST {{baseUrl}}/api/search using X-Api-Key: {{readKey}}. Change only mode between keyword, semantic and hybrid:

Search comparison request
{
  "query": "How do I publish a web service to Windows?",
  "mode": "hybrid",
  "topK": 3,
  "candidateLimit": 20,
  "category": "hosting"
}

Expect HTTP 200 with mode, candidateLimit, rrfConstant, keywordCandidates, vectorCandidates and results. Each hit includes native branch scores, nullable branch ranks and a fused score. No exact model rank or score is asserted here. Ordinary full-text terms are generally ANDed, so the lexical branch can be empty when the wording has no joint term match. That is useful evidence to record rather than hide.

Valid empty-corpus response contract
{
  "mode": "hybrid",
  "candidateLimit": 20,
  "rrfConstant": 60,
  "keywordCandidates": 0,
  "vectorCandidates": 0,
  "results": []
}

The empty example describes an empty eligible corpus, not the measured result of the four-document test. A dependency failure does not return this response; it returns an error.

Test exact codes and search syntax separately

Code-constrained retrieval request
{
  "query": "startup failure",
  "mode": "hybrid",
  "topK": 3,
  "candidateLimit": 20,
  "exactCode": "iis-500.30",
  "category": "hosting"
}

The equality predicate restricts both branches to IIS-500.30. It is not a guaranteed code-lookup endpoint: keyword-only mode still requires its text match, and approximate vector retrieval can return fewer candidates. If a business operation must fetch a specific code regardless of query relevance, implement a dedicated authorized equality lookup. Do not infer exact equality from a free-form query containing hyphens.

Query / variationWhat to inspect
500.30 startup failureTechnical-token behavior and whether the correct error article is retrieved
How can I run my web service on a Windows server?Paraphrase retrieval across modes
"application pool"Phrase parsing in the lexical branch
hosting OR runtimeExplicit OR query behavior
the and orStop-word/query-language effects; no assumed lexical matches
Nonexistent categoryNo cross-category result leakage
ExactCode=IIS-500.31Both branches remain within the structured equality filter

Negative tests

CaseExpected contract
Missing/invalid key401 before inference
Read key on document ingestion403; no insert
Invalid mode, blank query or TopK outside 1–20400
CandidateLimit below TopK or above 100400
Unknown JSON property or malformed body400
Over-token input rejected by model422; no vector stored
Model missing/unreachable503 in semantic or hybrid mode
Wrong vector dimensions or malformed model response502
Embedding budget exhausted504 unless the caller has cancelled
Database unavailable503; no silent keyword fallback
Concurrency capacity exceeded429

Keyword-only mode does not invoke Ollama and is an explicit diagnostic option, not an automatic degraded response. Hybrid mode fails if its required model branch fails. If you introduce fallback in production, declare the returned mode and log the degraded operation rather than labeling keyword-only results “hybrid.”

12. Evaluate retrieval with Recall@K and MRR

Create relevance judgments before tuning the fusion constant. For each representative query, record the document IDs a reviewer considers relevant. Include exact codes, paraphrases, ambiguous queries, no-answer cases and permission-filtered queries. Keep the same corpus snapshot and judgments when comparing keyword, semantic and hybrid runs.

Recall@K is the number of judged relevant documents in the first K results divided by the total number of judged relevant documents for that query. Reciprocal rank is 1 divided by the rank of the first relevant result, or zero if none is retrieved. MRR averages reciprocal rank across the chosen query set. If you cap the scan at K, call it MRR@K and apply that rule consistently.

Evaluation helpers for a separate test project
static double RecallAtK(IReadOnlyList<Guid> ranked, ISet<Guid> relevant, int k)
{
    if (k < 1 || relevant.Count == 0) throw new ArgumentException("Define K and relevance labels.");
    return ranked.Take(k).Distinct().Count(relevant.Contains) / (double)relevant.Count;
}
static double ReciprocalRankAtK(IReadOnlyList<Guid> ranked, ISet<Guid> relevant, int k)
{
    if (k < 1 || relevant.Count == 0) throw new ArgumentException("Define K and relevance labels.");
    var ids = ranked.Distinct().Take(k).ToArray();
    for (var i = 0; i < ids.Length; i++)
        if (relevant.Contains(ids[i])) return 1.0 / (i + 1);
    return 0;
}

For example, if two documents are judged relevant and the first three unique results contain one of them at position 2, Recall@3=1/2 and reciprocal rank@3=1/2. This is a constructed arithmetic example, not an observed performance improvement. The API returns unique IDs; the helper assumes ranked results follow that contract.

For queries with no judged relevant documents, recall has a zero denominator. Define a separate no-answer/false-positive evaluation and state how those queries are excluded or treated in aggregate metrics. Do not quietly assign perfect recall. Record how judgments were made and inspect disagreements.

Evaluate candidate recall before fusion quality: a document missing from both candidate lists cannot appear in final results. Tune candidate depth, lexical configuration and model/chunking choices before concluding RRF failed. Use a held-out query set for final comparison; repeatedly tuning and reporting the same few toy queries overstates evidence. No improvement percentage is claimed here.

13. Filtering, pagination, query plans and production tradeoffs

CandidateLimit is not TopK

CandidateLimit is the maximum retrieved from each branch; TopK is the final response size. With a limit of 50 per branch, fusion sees at most 100 unique documents before truncation. Increasing only TopK cannot recover documents excluded from both lists. Wider candidates increase database ranking work, transfer size and fusion memory; evaluate the tradeoff rather than choosing an arbitrary huge limit.

Pagination requires a stable candidate universe

This tutorial intentionally returns a bounded top-K response without offset pagination. Re-running approximate retrieval for each page can change candidates and create gaps or duplicates. For deeper browsing, materialize a permission-scoped ranked result set with an expiry and a search-session token, then page that stable set. Bind tokens to identity, filters, query, model/corpus version and ranking configuration.

Do not apply Skip independently to lexical and vector branches and fuse only those “pages.” RRF ranks are defined relative to each original candidate list; dropping earlier ranks changes the calculation. A database snapshot inside one request is not a cross-request pagination snapshot.

Inspect indexes at the matching boundary

The lexical GIN index supports @@ candidate matching, after which ts_rank sorting still costs work. HNSW uses vector_cosine_ops to match <=> ascending with LIMIT. The planner may reasonably prefer a sequential scan on a four-row table. Use EXPLAIN and appropriate development diagnostics rather than forcing an index merely to obtain a desired screenshot.

Conceptual parameterized SQL shapes
-- Lexical branch: $1=query, $2=space, $3=candidate limit
SELECT "Id", ts_rank("SearchVector", websearch_to_tsquery('english', $1)) AS score
FROM "Documents"
WHERE "Space" = $2
  AND "SearchVector" @@ websearch_to_tsquery('english', $1)
ORDER BY score DESC, "Id"
LIMIT $3;
-- Vector branch: $1=bound vector, $2=space, $3=candidate limit
SELECT "Id", "Embedding" <=> $1 AS distance
FROM "Documents"
WHERE "Space" = $2
ORDER BY "Embedding" <=> $1
LIMIT $3;

These illustrate the query shape, not a second hand-written implementation. The C# service composes parameterized LINQ and adds Category/Code predicates to both branches. Do not paste unresolved $1 values into psql or concatenate user input into SQL. Inspect generated SQL with ToQueryString in a private development environment.

HNSW is approximate; filtering can reduce candidate counts. Consider iterative scans and measured search-breadth settings when needed, but preserve authorization and evaluate recall. For selective structured filters, ordinary indexes, partitions or distinct corpora may be appropriate. The official iterative-scan guidance explains limitations.

Secure and operate the complete system

  • Use real identities and document-level authorization before supporting multiple users or tenants.
  • Keep API keys, connection strings and raw document/query text out of normal logs.
  • Use verified TLS for remote database connections, HTTPS for the public API and private inference networking.
  • Separate schema-deployment privileges from runtime read/write permissions.
  • Bound payloads, token budgets, candidates, concurrency and result sizes.
  • Restrict writer access to prevent corpus poisoning and implement source/version-based idempotency.
  • Version model weights, preprocessing and lexical configuration; coordinate corpus rebuilds with releases.
  • Track model latency, database latency, branch candidate counts, empty-list frequency and dependency failures separately.
  • Evaluate deletion, backup and retention for both source text and derived embeddings.

Local Ollama inference does not make the whole system automatically private or free. Hardware, model licensing, logs and backups still require review. Retrieved text remains untrusted data if you later pass it into a generative model. Search relevance must never grant tool permissions.

For parallel retrieval in a higher-throughput version, use separate DbContext instances or a carefully designed single SQL statement. Separate contexts also raise consistency questions. The Microsoft DbContext guidance explicitly warns against sharing one context across concurrent operations.

14. Troubleshooting and frequently asked questions

ProblemWhat to check
SearchVector does not exist or remains nullApply the generated-column migration to the correct database; do not create an ordinary nullable tsvector property by hand.
WebSearchToTsQuery throws on the clientKeep the call inside the LINQ expression so Npgsql translates it to SQL.
Lexical candidates are emptyInspect English normalization, stop words, AND/OR behavior and the actual query. Empty lexical evidence is not a database outage.
Exact product code retrieves a related but wrong codeUse normalized structured equality or a dedicated lookup. FTS parsing and embeddings do not guarantee literal identity.
HNSW is not selectedCheck operator class, raw ascending distance ORDER BY, LIMIT, filters and table size.
Fewer than CandidateLimit resultsAvailable rows, filters or approximate candidate scanning can reduce the count.
RRF scores are all zeroUse floating-point division and 1-based ranks; do not use integer division.
One document receives multiple votes from one branchDeduplicate branch IDs before assigning ranks and summing contributions.
A second operation started on this contextDo not run both EF queries concurrently on a scoped DbContext.
Embedding dimensions mismatchVerify model and output configuration; reject the result rather than padding or slicing it.
Credentials stop working after recreating a containerThe named data volume preserves original database roles/passwords. Environment initialization does not rotate them.
Hybrid fails while keyword mode worksCheck Ollama model availability, input token limits and the linked HTTP budget. No automatic fallback is configured.
Results leak across categories or tenantsApply the same trusted predicates to each candidate branch; Category alone is not authorization.

Is ts_rank the same as BM25?

No. It is PostgreSQL’s native ranking function with its own inputs and behavior. This guide does not install a BM25 extension or claim BM25 scoring.

Should I add ts_rank to cosine similarity?

Not without a calibrated score-combination design. Their scales and distributions differ. RRF avoids that direct addition by combining ordinal ranks.

Does RRF guarantee better results?

No. It can combine complementary evidence, but bad candidates, unsuitable language processing and irrelevant agreement can hurt results. Measure with representative judgments.

Why is the RRF constant 60?

It is a widely used starting value from the original method. It smooths rank contributions. It is not the number of neighbors and is not universally optimal.

Can a document appear without a lexical match?

Yes, through the semantic branch. A document in only one list gets one contribution. In hybrid mode, lexical exclusion syntax is not automatically a global vector filter; enforce business exclusions as explicit predicates on both branches.

Does ExactCode force a result?

No. It constrains eligible documents. Branch matching and retrieval still apply. Use an authorized equality endpoint for guaranteed identifier lookup behavior.

What happens if Ollama is unavailable?

Semantic and hybrid modes fail safely. An explicitly requested keyword-only search avoids inference. If you add fallback, mark the degraded mode in the response.

Can this work for multilingual documents?

The vector model can represent multiple languages, but the lexical branch here uses English dictionaries. Choose a language strategy and evaluate each supported language before describing the whole API as multilingual search.

Was the project tested?

It was reviewed against official package/API documentation, but not compiled or executed. The four RRF totals were independently checked in Python; the isolated C# check and Postman scenarios are provided for local verification. No runtime screenshots or benchmark gains are claimed.

15. Conclusion: make both retrieval branches observable

A reliable hybrid search implementation makes its assumptions visible: compatible dense vectors, a declared lexical configuration, bounded candidates, shared filters and an explicit fusion formula. This guide keeps native scores and branch ranks in the response so you can diagnose why a document moved rather than treating the final score as unexplained AI confidence.

Run the implementation locally, verify migrations and negative cases, then build relevance judgments for your own query mix. Compare keyword, semantic and hybrid modes before tuning candidate depth or adding rerankers. Exact identifiers, authorization and durable ingestion remain application responsibilities.

Future supporting tutorials

  • Diagnosing missing hybrid candidates with branch-level recall traces.
  • Migrating PostgreSQL text-search dictionaries without losing evaluation reproducibility.
  • Comparing equal-weight and weighted RRF with a held-out query set.

These are suggestions only; no additional posts were created.

16. References and research notes

Research checked on 4 November 2026. The implementation follows PostgreSQL/Npgsql/pgvector contracts; Azure’s RRF explanation is conceptual background, not a claim that this PostgreSQL API has Azure’s scoring ranges or parallel execution behavior.

Search-intent review focused on .NET developers combining pgvector and PostgreSQL full-text search, diagnosing mismatched score scales, missing exact codes and index/query errors. The DigitalOcean hybrid-query walkthrough and official .NET sample were reviewed as comparison material. This tutorial adds explicit mode comparisons, filter consistency, code-equality boundaries, failure contracts and an isolated fusion check. No keyword volumes, difficulty scores or measured relevance gains are invented.

Comments