How to Build an AI-Powered Semantic Search API in ASP.NET Core 10 Using C#, Ollama and PostgreSQL pgvector – Complete Guide (2026)

How to Build an AI-Powered Semantic Search API in ASP.NET Core 10 Using C#, Ollama and PostgreSQL pgvector – Complete Guide (2026)

A customer searches for “a bag that keeps my laptop dry,” while your product record says “water-resistant computer backpack.” A literal term match may miss that connection. Semantic search gives an API a way to compare the intent of the question with the meaning represented by stored text, while PostgreSQL remains responsible for persistence and query execution.

This guide builds an AI-powered semantic search API in ASP.NET Core 10 using C#, Ollama and PostgreSQL pgvector. It indexes short documentation passages, generates embeddings locally and retrieves the closest stored records. There is no chat completion, agent or generated answer: the response contains the source documents and distance-based scores.

How do you build semantic search with Ollama and pgvector?

Generate document embeddings with Ollama’s current POST /api/embed endpoint, store the vectors in a PostgreSQL vector(768) column, and generate a compatible query vector at search time. Order database rows by cosine distance ascending and limit the result count. Use the same model weights, dimensions and preprocessing contract for indexing and querying.

The write path stores a document vector; the search path compares a query vector with persisted documents. No generative answer is produced.
The write path stores a document vector; the search path compares a query vector with persisted documents. No generative answer is produced. Original SVG diagram.

1. Semantic search versus traditional SQL keyword search

Semantic search ranks records using a learned representation of their contents. An embedding model converts a string into a fixed-length numeric vector. Comparing vectors can help retrieve paraphrases, related concepts and multilingual wording that do not share the same literal tokens. The representation is useful evidence for relevance, not a guarantee that a document is correct or current.

SQL keyword search remains valuable. Equality, full-text search and carefully designed lexical ranking are often the right tools for exact error codes, SKUs and names. A search for “HTTP 500.30” should preserve those characters; a semantic model may consider several hosting errors related without understanding that the distinction is operationally important.

ApproachGood fitLimitation
SQL equality / exact matchingProduct IDs, order numbers and exact error codesDoes not solve paraphrases
PostgreSQL full-text searchToken-aware document search with lexical rankingVocabulary mismatch still needs attention
Dense semantic searchNatural-language descriptions and related questionsNeeds evaluation and compatible model vectors
Hybrid retrievalCombining lexical evidence and vector candidatesRequires an explicit fusion/ranking design

A documentation portal can retrieve a page about app pool configuration for a question about an API failing after IIS deployment. A product catalog can retrieve candidate backpacks for a functional description. In either case, structured filters such as stock, price, language, tenant and permissions still belong in the search design. A relevant document is not automatically an authorized document.

The API in this article indexes small supplied passages. It does not parse PDF files, crawl websites, split arbitrary uploads, generate answers or implement hybrid ranking. Those are separate application responsibilities. Keeping this boundary small makes the persistence and retrieval behavior easier to inspect.

2. Architecture, cosine similarity and cosine distance

The indexing path validates a title and a short passage, adds the model’s document prefix, calls Ollama and commits the text plus embedding to PostgreSQL. The query path validates the question, adds the query prefix, generates one vector and asks PostgreSQL for the nearest stored documents. Previously indexed documents are not embedded again on every search.

The selected model uses different retrieval prefixes for documents and queries. Equal dimensions alone do not establish embedding compatibility.
The selected model uses different retrieval prefixes for documents and queries. Equal dimensions alone do not establish embedding compatibility. Original SVG diagram.

For nonzero vectors a and b, cosine similarity is dot(a,b) / (length(a) × length(b)). Cosine distance is 1 − cosineSimilarity. In pgvector, <=> is the cosine-distance operator. Smaller distance means closer direction; larger similarity means closer direction. A score of 0.8 is not “80% confidence.”

Cosine distance equals one minus cosine similarity. SQL sorts distance ascending; the API reports both measures without calling either a confidence probability.
Cosine distance equals one minus cosine similarity. SQL sorts distance ascending; the API reports both measures without calling either a confidence probability. Original SVG diagram.
pgvector operatorMeasureMatching index operator class
<=>Cosine distancevector_cosine_ops
<->Euclidean / L2 distancevector_l2_ops
<#>Negative inner productvector_ip_ops

The implementation orders by distance ascending inside SQL and computes the display similarity after retrieving the small result set. It does not rewrite SQL ordering as descending 1 - distance, which can make a nearest-neighbor index ineligible. See the official pgvector operator and indexing reference.

All vectors use 768 dimensions in this corpus. Dimension equality is necessary but insufficient: two unrelated 768-dimensional models do not share a useful coordinate space. Changing model weights, prefixes or preprocessing requires a reviewed migration and re-embedding plan. A model tag can move; record the pulled artifact identity in deployment records and do not update it independently of stored vectors.

3. Prerequisites, .NET 10 and current NuGet packages

Install the .NET 10 SDK, a compatible editor or Visual Studio version with .NET 10 support, Ollama, Docker with Linux-container support and Postman. Ensure you have enough disk and RAM for the selected model, PostgreSQL and its indexes. A GPU can change inference latency, but no particular throughput or minimum RAM figure is promised here.

The example runs the API and Ollama on the host, with PostgreSQL in Docker. On Windows, install Ollama from its official installer and follow the Windows installation documentation. The local API normally listens at port 11434. If you later containerize the API, localhost refers to that container rather than the host.

Create the project and install packages
dotnet new web -n SemanticSearch.Api -f net10.0
cd SemanticSearch.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

Use the complete project file below so the design-time dependency remains private. Microsoft EF Core runtime, design tooling and the local dotnet-ef tool use the same 10.0.14 patch. The Npgsql provider has its own version schedule: 10.0.3 accepts EF Core 10 within its documented dependency range. Pgvector.EntityFrameworkCore 0.3.0 explicitly supports EF Core 9 and 10. These are stable selections, not 11.x previews.

SemanticSearch.Api.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UserSecretsId>SemanticSearch.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>

Compatibility was checked against Npgsql’s package dependencies, the pgvector EF integration package and Microsoft’s design package. Restore and commit a lock file in your own repository, and review updates rather than assuming any newer major version is interchangeable.

Keep all listed C# files in the project root with the SemanticSearch.Api namespace. EF tooling generates the Migrations folder.
Keep all listed C# files in the project root with the SemanticSearch.Api namespace. EF tooling generates the Migrations folder. Original SVG diagram.

4. Install Ollama and start PostgreSQL pgvector with Docker

Pull and verify the selected embedding model

Ollama model setup
ollama --version
ollama pull nomic-embed-text-v2-moe
ollama list
ollama show nomic-embed-text-v2-moe

Use nomic-embed-text-v2-moe, not a chat model or an assumed alias for another Nomic release. Its published model information specifies 768-dimensional embeddings, a 512-token sequence limit and retrieval prefixes. This guide keeps full-size output and does not request dimension reduction.

The prefixes are search_document: for indexed content and search_query: for searches. They are applied in C#, so callers send plain text. The token limit includes prefixed input. Local character limits are payload safeguards, not exact tokenizer calculations.

PowerShell: verify the current embedding endpoint
$body = @{
    model = "nomic-embed-text-v2-moe"
    input = "search_query: how do I host an API on IIS?"
    truncate = $false
} | ConvertTo-Json
$result = Invoke-RestMethod -Uri "http://localhost:11434/api/embed" -Method Post -ContentType "application/json" -Body $body
$result.embeddings[0].Count

The expected vector length is 768. Verify it before indexing; this is not an executed result from the authoring environment. The current Ollama embed API accepts input and returns an embeddings array. Older examples using /api/embeddings with prompt describe a different contract. Do not mix those request and response shapes.

Run a local PostgreSQL instance with pgvector installed

compose.yaml
services:
  postgres:
    image: pgvector/pgvector:0.8.7-pg17-bookworm
    container_name: semantic-search-db
    environment:
      POSTGRES_DB: semantic_search
      POSTGRES_USER: search_dev
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD locally}
    ports:
      - "127.0.0.1:5432:5432"
    volumes:
      - search-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U search_dev -d semantic_search"]
      interval: 5s
      timeout: 5s
      retries: 10
volumes:
  search-data:
PowerShell: local database startup
$env:POSTGRES_PASSWORD = [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(24))
docker compose up -d
docker compose ps
docker exec semantic-search-db psql -U search_dev -d semantic_search -c "SELECT version();"

Run these commands in the directory containing compose.yaml and keep the password for the next step. The image installs extension binaries; the EF migration enables vector inside this database. The tag is listed in the official pgvector Docker instructions. Pin an approved image digest in production.

This development container uses its initial administrative role for convenience. Do not reuse that superuser-style role as the production API identity. Initialization variables apply to an empty data directory: restarting with a different environment password does not rotate credentials in an existing volume. The named volume preserves data; rotate credentials deliberately.

5. Configure connection strings and protect credentials

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"
}

Keep connection credentials and both API keys outside committed JSON. Separate reader and writer keys prevent a search-only caller from indexing content. Shared keys are a teaching boundary, not a production identity provider or tenant-authorization system.

PowerShell: development-only secrets
dotnet user-secrets init
$connection = "Host=localhost;Port=5432;Database=semantic_search;Username=search_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

Use these values privately in your local Postman environment; do not share screenshots containing secrets. User Secrets keeps values outside the project tree but does not encrypt them. Read Microsoft’s development-secrets guidance. In deployment, use controlled secret injection. Environment names include ConnectionStrings__SearchDb and Demo__ReadKey.

SSL Mode=Disable is restricted to this loopback-only development database. For a remote database, configure TLS with certificate and hostname validation, for example Npgsql’s VerifyFull mode and the appropriate trusted CA. See Npgsql security and encryption. Never permanently disable certificate checks to hide a configuration error.

The Ollama URL is trusted server configuration, never caller input. This avoids making the API an arbitrary outbound-request proxy. Local inference needs no OpenAI API key, but hardware, maintenance, hosting and the model license still matter.

6. Define the model, DbContext and migrations

Create every C# file shown here inside the SemanticSearch.Api project. Each row represents one short passage and includes source text for displaying a result. The Space field records the application’s embedding contract; it is not an authorization field or a cryptographic verification of downloaded weights.

Contracts.cs
namespace SemanticSearch.Api;

public static class EmbeddingContract
{
    public const string Model = "nomic-embed-text-v2-moe";
    public const int Dimensions = 768;
    // Immutable corpus identity. Change it when weights, prefixes or preprocessing change.
    public const string Space = "nomic-v2-moe-768-prefix-v1";
}
public sealed record IndexRequest(string? Title, string? Content);
public sealed record SearchRequest(string? Query, int TopK = 5);
public sealed record IndexedDocument(Guid Id, string Title, string Space);
public sealed record SearchHit(Guid Id, string Title, string Content,
    double CosineDistance, double CosineSimilarity);
public sealed record SearchResponse(string Space, int RequestedTopK, 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? text, string name, int maxLength)
    {
        if (string.IsNullOrWhiteSpace(text) || text.Length > maxLength)
            throw new ApiFailure(400, $"{name} must contain 1–{maxLength} characters.");
        return text.Trim();
    }
}
SearchDbContext.cs
using Microsoft.EntityFrameworkCore;
using Pgvector;
using Pgvector.EntityFrameworkCore;
namespace SemanticSearch.Api;

public sealed class SearchDocument
{
    public Guid Id { get; set; }
    public string Title { get; set; } = "";
    public string Content { get; set; } = "";
    public string Space { get; set; } = "";
    public DateTimeOffset IndexedAt { get; set; }
    public Vector Embedding { 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.Title).HasMaxLength(120).IsRequired();
        d.Property(x => x.Content).HasMaxLength(600).IsRequired();
        d.Property(x => x.Space).HasMaxLength(100).IsRequired();
        d.Property(x => x.Embedding).HasColumnType("vector(768)").IsRequired();
        d.HasIndex(x => x.Embedding).HasDatabaseName("IX_Documents_Embedding")
            .HasMethod("hnsw").HasOperators("vector_cosine_ops")
            .HasStorageParameter("m", 16).HasStorageParameter("ef_construction", 64);
    }
}

Two configuration steps are required: HasPostgresExtension("vector") tells migrations to enable the database extension, while UseVector() registers provider integration. The property uses Pgvector.Vector and an explicit 768-dimensional column. The official pgvector-dotnet examples document EF mapping and distance translation.

The HNSW index uses cosine operators to match the search query. It is optional for a tiny dataset but included so migrations express the intended index. The construction settings are examples, not benchmark-derived tuning. Change them after measuring your corpus and resources.

Keep design-time tooling independent of API keys

SearchDbFactory.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Pgvector.EntityFrameworkCore;
namespace SemanticSearch.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 factory loads the database connection without requiring the API’s reader/writer keys. Run tooling from the project directory so appsettings.json is found. This creates a DbContext for tooling; it does not migrate the running application automatically.

Create and inspect the migration
dotnet restore --use-lock-file
dotnet build --no-restore
dotnet ef migrations add InitialSearch
dotnet ef migrations script --output initial-search.sql
# Inspect the SQL before applying it.
dotnet ef database update
docker exec semantic-search-db psql -U search_dev -d semantic_search -c "SELECT extname, extversion FROM pg_extension WHERE extname = 'vector';"

The migration should create vector, Documents, its HNSW index and EF migration history. Do not combine EnsureCreated with this workflow. In production, apply reviewed scripts or a migration bundle using a deployment identity rather than granting the API schema-alteration permissions. See EF Core migrations.

Conceptual schema excerpt: compare with generated SQL
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE "Documents" (
    "Id" uuid PRIMARY KEY,
    "Title" varchar(120) NOT NULL,
    "Content" varchar(600) NOT NULL,
    "Space" varchar(100) NOT NULL,
    "IndexedAt" timestamp with time zone NOT NULL,
    "Embedding" vector(768) NOT NULL
);
CREATE INDEX "IX_Documents_Embedding" ON "Documents"
USING hnsw ("Embedding" vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

This excerpt explains the intended objects, not a second installation step after EF creates them. The generated script includes migration metadata and may differ in formatting. Use a fresh tutorial database rather than an unrelated database containing an existing Documents table.

7. Complete Ollama embedding client with validation and cancellation

OllamaEmbeddings.cs
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SemanticSearch.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; }
    }
}
Invalid input stops before inference. Model or vector failures prevent the insert; database failures return a safe error.
Invalid input stops before inference. Model or vector failures prevent the insert; database failures return a safe error. Original SVG diagram.

The typed HttpClient uses a fixed relative route, no redirects and an explicit linked cancellation budget. It never returns an unvalidated vector: count, dimensions, finite coordinates and nonzero length must all match. The API does not assume a successful HTTP status guarantees a usable model result.

Sending truncate=false makes over-limit input fail rather than silently losing the end of a document. The selected model has a short token window, so even a passage within 600 characters can exceed the tokenizer’s limit in some languages or symbol-heavy inputs. The safe 422 response asks the caller to shorten or deliberately chunk it. The sample maps Ollama 400 responses to that general rejection; inspect private service diagnostics to distinguish a token limit from other model-side input problems.

Cancellation is propagated into the HTTP call and database operations. A 60-second application budget is cooperative; it is not a claim that model computation stops instantly. Cold model loading, CPU inference and resource contention may require a different measured budget in your environment. Avoid retrying inference indefinitely when the instance is overloaded.

For larger documents, introduce a separate ingestion process that splits on useful boundaries, preserves source IDs and chunk positions, and records preprocessing versions. Do not increase the database column size and assume the model can accept any text length. The Ollama embedding guide explains the generation boundary.

8. Store document vectors and implement top-K semantic search

SearchService.cs
using Microsoft.EntityFrameworkCore;
using Pgvector;
using Pgvector.EntityFrameworkCore;
namespace SemanticSearch.Api;

public sealed class SearchService(SearchDbContext db, OllamaEmbeddings embeddings,
    ILogger<SearchService> logger)
{
    public async Task<IndexedDocument> IndexAsync(IndexRequest request, CancellationToken ct)
    {
        var title = InputRules.Text(request.Title, "Title", 120);
        var content = InputRules.Text(request.Content, "Content", 600);
        // Do not hold a DB transaction open while the model is running.
        var vector = await embeddings.ForDocumentAsync(title, content, ct);
        var doc = new SearchDocument
        {
            Id = Guid.NewGuid(), Title = title, Content = content,
            Space = EmbeddingContract.Space, IndexedAt = DateTimeOffset.UtcNow,
            Embedding = new Vector(vector)
        };
        db.Documents.Add(doc);
        await db.SaveChangesAsync(ct);
        logger.LogInformation("Indexed document {DocumentId} in {Space}", doc.Id, doc.Space);
        return new IndexedDocument(doc.Id, doc.Title, doc.Space);
    }
    public async Task<SearchResponse> SearchAsync(SearchRequest request, CancellationToken ct)
    {
        var query = InputRules.Text(request.Query, "Query", 300);
        if (request.TopK is < 1 or > 20)
            throw new ApiFailure(400, "TopK must be between 1 and 20.");
        var vector = new Vector(await embeddings.ForQueryAsync(query, ct));
        // Keep the distance operator ascending in ORDER BY so the matching index is eligible.
        var rows = await db.Documents.AsNoTracking()
            .Where(d => d.Space == EmbeddingContract.Space)
            .OrderBy(d => d.Embedding.CosineDistance(vector))
            .Take(request.TopK)
            .Select(d => new { d.Id, d.Title, d.Content,
                Distance = d.Embedding.CosineDistance(vector) })
            .ToListAsync(ct);
        var hits = rows.Select(d => new SearchHit(d.Id, d.Title, d.Content,
            d.Distance, Math.Clamp(1 - d.Distance, -1, 1))).ToArray();
        logger.LogInformation("Search returned {ResultCount} of requested {TopK}", hits.Length, request.TopK);
        return new SearchResponse(EmbeddingContract.Space, request.TopK, hits);
    }
}
The API validates, embeds and then searches. Failures are handled at the correct boundary instead of fabricating empty search results.
The API validates, embeds and then searches. Failures are handled at the correct boundary instead of fabricating empty search results. Original SVG diagram.

Indexing calls the model before opening a database transaction, then SaveChangesAsync commits the record. If inference fails, no row is added. If the database write fails after inference, the request fails safely; the generated vector is not durable. This POST intentionally creates a new ID each time. Retrying after an uncertain response can create duplicate documents, so production ingestion needs a source identifier and an idempotent upsert/version policy.

Search uses AsNoTracking and projects only the fields returned to the caller. The vector itself is not returned. The database performs distance ordering and LIMIT; there is no ToList before ranking, and no application-side scan of the entire corpus. The score conversion happens after the bounded result set is materialized.

The Space filter prevents accidental mixing of application-defined corpora, but it cannot detect a silent model-tag update. Do not serve new query vectors against old weights without rebuilding or versioning the corpus. This sample holds one model and schema contract fixed; it is not a dynamic model selector.

TopK is a requested maximum, not a promise of exactly K matches. An empty corpus returns an empty array. Approximate indexing, filters and available documents can also reduce results. Ties need not have stable order. Add an evaluated secondary ordering strategy only after checking its effect on query plans.

9. Centralized errors, dependency injection and complete Program.cs

ApiErrorHandler.cs
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace SemanticSearch.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 SemanticSearch.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<SearchService>();
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, SearchService service, CancellationToken ct) =>
    Results.Ok(await service.IndexAsync(request, ct)));
api.MapPost("/search", async (SearchRequest request, SearchService service, CancellationToken ct) =>
    Results.Ok(await service.SearchAsync(request, ct)));
app.Run();

The startup code verifies mandatory configuration, registers EF with UseVector, configures the typed client and attaches a concurrency limiter to API endpoints. UseExceptionHandler wraps the pipeline. In .NET 10, handled exceptions can suppress framework diagnostics by default; this handler writes its own safe structured event with an error type, status and trace ID.

The reader key can call POST /api/search; the writer key can index and search. The check uses a case-insensitive search-path allowlist, so differently cased document URLs cannot bypass writer checks. This remains a deliberately small demonstration: production should attach identity and scope policies to endpoints, derive tenant access from trusted claims and implement revocation and audit requirements.

The concurrency limit admits four in-flight requests per application process and queues none. It is neither a distributed rate limit nor a per-user quota. Invalid or unauthorized requests should not consume inference capacity. Review Microsoft’s rate-limiting documentation before choosing production limits.

For the broader authentication design, see the existing JWT authentication in ASP.NET Core 10 guide. For centralized error-handling concepts, see global exception handling with IExceptionHandler. Those posts provide context; copying their configuration alone does not add tenant-aware search authorization.

Example safe error contract for a missing local model (the trace ID is illustrative):

Illustrative 503 ProblemDetails
{
  "title": "The request could not be completed.",
  "status": 503,
  "detail": "The configured embedding model is unavailable.",
  "traceId": "example-trace-id"
}

10. Test document indexing and search with Postman

Build and start the local API

PowerShell: start after migrations succeed
$env:ASPNETCORE_ENVIRONMENT = "Development"
dotnet build
dotnet run --no-launch-profile -- --urls "http://localhost:5093"

Local HTTP is used only on the developer machine. Configure HTTPS at the production ingress and restrict access to model and database ports. Create a Postman environment with baseUrl set to http://localhost:5093 and private readKey/writeKey values from setup. Set Content-Type: application/json on requests.

Index three short passages

Send POST {{baseUrl}}/api/documents with X-Api-Key: {{writeKey}}. Submit each JSON object below as a separate request; this endpoint does not accept a JSON array.

First document
{
  "title": "Host an API with IIS",
  "content": "Install the ASP.NET Core Hosting Bundle, publish the API and configure an IIS website and application pool. Verify the deployed endpoint over HTTPS."
}
Second document
{
  "title": "Protect Web API endpoints",
  "content": "Validate access tokens with the configured issuer and audience. Require authorization on protected endpoints and apply least-privilege scopes."
}
Third document
{
  "title": "Grow tomatoes in containers",
  "content": "Choose a sunny position, use a container with drainage and water the tomato plant consistently."
}

A successful insertion returns HTTP 200 and an IndexedDocument object. This API uses 200 for the completed operation because it does not define a separate GET resource URL. A 201 response with Location would be appropriate if a resolvable resource endpoint were added.

Illustrative indexing response contract
{
  "id": "11111111-1111-1111-1111-111111111111",
  "title": "Host an API with IIS",
  "space": "nomic-v2-moe-768-prefix-v1"
}

The UUID above is a placeholder. Your API generates a fresh UUID. It is not an observed database record.

Search with a paraphrase

Send POST {{baseUrl}}/api/search with X-Api-Key: {{readKey}}:

Search request
{
  "query": "How can I run my web service on a Windows web server?",
  "topK": 2
}

Expect HTTP 200 with space, requestedTopK and a results array. Each match contains id, title, content, cosineDistance and cosineSimilarity. Results are ordered by ascending distance. The IIS passage is a plausible relevance candidate, but no exact rank or score is guaranteed without running and evaluating the selected model. Do not hardcode invented embeddings or confidence scores into an expected-output screenshot.

Valid response shape for an empty corpus
{
  "space": "nomic-v2-moe-768-prefix-v1",
  "requestedTopK": 2,
  "results": []
}

An empty result is a normal contract only when no eligible rows are retrieved. The implementation does not disguise model or database failures as an empty array.

Negative tests worth performing

TestExpected behavior
No key or incorrect key401; no inference
Reader key sent to POST /api/documents403; no insert
Blank query, TopK=0 or TopK=21400 before model work
Title above 120 characters or content above 600400
Unknown JSON property or malformed JSON400 binding/JSON failure
Model rejects over-token input with truncate=false422; no row inserted
Ollama unreachable or model not installed503 with safe detail
Wrong vector dimension or malformed model JSON502; no database write
Embedding budget exceeded504 unless caller already cancelled
Database unavailable503; failure is not an empty successful search
More admitted work than concurrency permits429; no queued inference

For a controlled failure test, stop only your disposable development dependency and restart it afterward. Keep credentials out of captured output. Test client cancellation as well as timeout; a disconnected client may receive no JSON response at all. Use mocks for vector validation/unit tests and an actual PostgreSQL+pgvector integration environment for query translation and ordering. EF’s in-memory provider cannot validate pgvector SQL or HNSW behavior.

Useful integration assertions include: indexing persists one row, unauthorized indexing persists none, document/query prefixes differ correctly, an empty table returns no matches, model failure prevents insertion and database errors never leak credentials. For a deterministic database ordering test, seed known nonzero toy vectors and query directly, independently of the model’s semantic quality.

11. HNSW indexes, distance ordering and performance tradeoffs

HNSW is approximate and can miss neighbors. Compare it with an exact baseline on your data; filtering can reduce the result count.
HNSW is approximate and can miss neighbors. Compare it with an exact baseline on your data; filtering can reduce the result count. Original SVG diagram.

pgvector can perform exact nearest-neighbor search without an approximate index. HNSW adds a graph-based approximate access path: it explores candidates rather than promising every true nearest neighbor. It can be useful as a corpus grows, but it consumes memory, increases write/index-maintenance work and requires retrieval-quality evaluation.

Setting / choiceEffect to evaluate
mGraph connectivity; affects index size and construction work
ef_constructionCandidate list during index construction; affects build cost and graph quality
hnsw.ef_searchSearch candidate breadth; trades search work against recall
Exact baselineReference ranking for measuring approximate recall
FiltersCan reduce returned candidates and affect plans/recall
Model and chunkingOften change relevance more than index tuning alone

Use the pgvector HNSW reference for supported types, dimensions and options. The sample’s vector(768) fits the documented vector index limit. Do not apply a high-dimensional model to an existing column or index without checking the type’s limits.

Inspect SQL and plans before tuning

Conceptual SQL shape: parameter binding is required
SELECT "Id", "Title", "Content",
       "Embedding" <=> $1 AS distance
FROM "Documents"
WHERE "Space" = $2
ORDER BY "Embedding" <=> $1
LIMIT $3;

Here $1 is a bound Pgvector.Vector parameter, $2 is the corpus identity and $3 is bounded TopK. This is an explanation of EF’s intended SQL shape, not a psql command with unset parameters. Inspect generated SQL using ToQueryString on the composed EF query in a safe development environment, then use EXPLAIN on an appropriately bound query. Never concatenate raw user text or vector strings into SQL.

Read PostgreSQL’s EXPLAIN documentation. EXPLAIN ANALYZE actually executes the statement. A sequential scan on three rows is reasonable and is not evidence that pgvector is broken. Do not force an index globally just to make a tutorial screenshot look impressive.

Optional per-transaction HNSW tuning in a SQL session
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SET LOCAL hnsw.iterative_scan = strict_order;
-- Execute the parameterized nearest-neighbor query in this same transaction.
COMMIT;

These optional settings are not enabled by the C# sample. SET LOCAL applies only inside its current transaction, not to an unrelated pooled API connection. Iterative scans are available in pgvector 0.8.0 and later; the selected image includes them. Even strict ordering does not mean exact recall: it orders the approximate candidates that are found.

With approximate indexes, filters can leave fewer than TopK eligible results. Increasing search breadth or using iterative scans may help, but does not remove every limit. For strong tenant isolation, consider partitioning or separate corpora/indexes and test the actual access pattern. See iterative index scan guidance.

Measure the system at the right boundaries

Record inference time, model cold-load behavior, database latency, failure rate and retrieval relevance separately. The sample logs vector dimensions and elapsed inference time without source text. Precompute document vectors once, bound TopK, project only required columns and use AsNoTracking for read-only results. Avoid running concurrent operations on one scoped DbContext.

An embedding cache must include model identity, dimensions, prefix/preprocessing version and permitted data scope. For a production catalog, use a durable indexing queue and a versioned source ID instead of repeatedly creating rows. Re-embed only changed content and ensure deleted or unauthorized documents are removed from retrieval. No measured latency, recall or cost numbers are claimed here.

12. Security, local inference and production operations

Local inference controls where this application sends text, but it is not a complete privacy policy. Model downloads, logs, backups, monitoring agents and misconfigured network bindings can still move or expose data. Review the actual deployment and the Ollama operational FAQ; do not advertise unconditional offline privacy merely because the provider URL contains localhost.

Protect both ingestion and retrieval

  • Replace shared demo keys with authenticated identities and explicit read/write scopes.
  • Derive tenant IDs and document permissions from trusted identity/context, not a caller-supplied “tenant” string.
  • Apply authorization in the database retrieval boundary before returning text; never retrieve globally and hide forbidden records afterward.
  • Restrict writer access to prevent corpus poisoning and misleading support content.
  • Validate payload size, token limits and request concurrency. A four-request per-process limit is not fleet-wide abuse protection.
  • Do not log submitted documents, queries, vectors, API keys or database connection strings by default.
  • Use a least-privilege database identity for runtime; reserve migrations and extension installation for deployment roles.
  • Protect backups, retention and deletion workflows for source text and derived vectors.

A malicious passage cannot make this search-only API execute arbitrary tools because there is no agent or chat model in the path. It can still influence search results. If you later add RAG, treat retrieved text as untrusted data and keep tool permissions and system instructions separate.

Deploy without exposing internal services

Keep Ollama on a private interface and PostgreSQL behind network controls. Do not expose unauthenticated inference ports to the public internet. If the API moves into a container, configure a permitted service/host address deliberately rather than broadening bindings as a shortcut. Use outbound allowlists, authenticated TLS gateways where appropriate and no caller-controlled model URLs.

For IIS hosting, publish the .NET API normally and ensure its process identity can access the private model/database services. An interactive Windows tray app is not automatically a reliable server service. Review restart behavior, model loading, readiness checks and dependency health. The existing ASP.NET Core 10 IIS deployment guide covers the web-hosting foundation.

Operational readiness means more than a successful single query. Test model absence, disk exhaustion, database restarts, slow inference, cancellation, malformed model output, unauthorized ingestion and recovery after partial failures. Define how model artifacts and corpus versions roll forward and backward together.

13. Common errors and troubleshooting

ProblemLikely cause and correction
404 from OllamaConfirm the model is installed and the route is /api/embed. This sample maps a missing configured model to 503. Do not switch to a legacy endpoint without changing its contract.
No embeddings property in the responseCheck that you called the current embed endpoint rather than generate, chat or the older singular-response API.
Expected 768 dimensions but received another lengthVerify model identity and its output settings. Reject the vector; do not pad or truncate coordinates to satisfy the database.
Token-limit failure despite short-looking textCharacters and tokens differ. Include the retrieval prefix in your budget and chunk deliberately; truncate=false prevents silent loss.
type vector does not existInstall extension binaries in the PostgreSQL server and enable vector in this database. A NuGet package alone cannot install a database extension.
UseVector or CosineDistance cannot be foundRestore Pgvector.EntityFrameworkCore and import its namespace. Keep the provider/package majors compatible with EF10.
EF migration tries to build HTTP configurationUse the supplied IDesignTimeDbContextFactory and run commands from the project directory.
Database password authentication fails after restartAn initialized named volume keeps its original password. New container environment values do not rotate existing database roles.
HNSW index is not chosenCheck vector_cosine_ops, ascending raw distance ordering and LIMIT; inspect the plan and dataset size before changing planner settings.
Too few matches after adding HNSWEvaluate candidate breadth, filters, dead tuples and iterative scans. TopK is a maximum, not a guarantee.
Scores look meaningful but results are poorCheck prefixes, model/corpus compatibility and passage quality. Build labeled relevance tests; there is no universal score threshold.
Repeated POST produces duplicate documentsThis teaching endpoint creates a new UUID. Add an idempotent source-ID/version design for production ingestion.
API cannot reach localhost model inside DockerLocalhost is the API container itself. Configure trusted container networking or a supported host address.
503 on database operationsCheck PostgreSQL health, connection settings, privileges and migration completion. Inspect trace-linked private telemetry.
One user can see another user’s documentsThe demo is a single authorized corpus. Implement trusted tenant/document permissions in retrieval before using it for multiple tenants.

A useful troubleshooting sequence is: verify the model endpoint directly, verify returned dimensions, verify extension/schema state, then inspect generated SQL and the database plan. Changing all layers at once makes it difficult to identify whether the problem is inference, mapping, connectivity or retrieval quality.

14. Frequently asked questions

Do I need an OpenAI key?

No. This example calls a local Ollama embedding model. It still requires local compute and an appropriate model license; “no external API key” is not the same as “no operating cost.”

Is this a RAG chatbot?

No. It retrieves documents and returns their text and scores. RAG adds a separate generation stage that uses retrieved context to draft an answer. This guide deliberately stops at retrieval.

Why not use Microsoft.Extensions.AI here?

The raw typed HttpClient makes Ollama’s current JSON contract, truncate behavior and failure handling explicit. Microsoft.Extensions.AI can be a useful abstraction when you want interchangeable embedding providers, but it is not necessary to call this local REST endpoint.

Can I use a different Ollama embedding model?

Yes, with a reviewed new contract: model identity, prefixes, token limit, output dimensions and storage/index compatibility must all be checked. Rebuild or version the corpus; simply changing the model string is not safe.

Why store vector(768)?

The selected Nomic v2 MoE model produces 768-dimensional full-size embeddings. The application validates that length and the database enforces the column shape. Different models or reduced outputs require matching schema and data changes.

Is cosine similarity a confidence percentage?

No. It measures vector direction similarity. A high value does not prove factual correctness, authorization or answer quality. Choose thresholds only after evaluating representative queries and labeled documents.

Does HNSW guarantee the true nearest documents?

No. It is approximate. Evaluate recall against an exact baseline and inspect latency/resource use before choosing search parameters.

Should every document be one database row?

For short passages, that is a clear learning model. Longer documents usually need chunks with source IDs, positions and a deletion/update policy. Chunking should respect the model’s token window and preserve useful context.

Can the same database support keyword search?

Yes. PostgreSQL can store source text and support lexical search as well as vectors. Combining candidate lists needs an explicit hybrid-ranking design; this tutorial does not pretend cosine distance implements lexical matching.

Why can a successful search return fewer than TopK?

There may be fewer eligible documents, or approximate candidate scanning and filtering may reduce results. Return the actual count without inventing placeholder matches.

Was the implementation tested?

The code was reviewed against official APIs and package dependencies, but it was not compiled or executed. There was no local .NET, Docker, PostgreSQL or Ollama runtime available. Run the build, migration and positive/negative API checks before using it. All response examples are explicitly illustrative.

15. Conclusion and useful next steps

This API keeps the semantic-search boundary visible: validate input, generate a compatible local vector, store document embeddings durably and let PostgreSQL rank candidates with a matching distance operator. The practical details—prefixes, dimensions, failure handling, authorization and query shape—matter as much as adding a vector column.

Start with a small corpus you can judge manually. Verify the data path, add labeled relevance tests, then decide whether approximate indexing and more elaborate ingestion are justified. Preserve exact identifiers with lexical techniques where needed and treat model changes as data migrations.

Possible supporting tutorials

  • Combining document freshness and semantic relevance for versioned API documentation.
  • Testing document-level access rules in a PostgreSQL semantic-search endpoint.
  • Designing restart-safe ingestion checkpoints for local embedding workloads.

These are future ideas only; no additional drafts were created.

16. References and further reading

Research date: 4 November 2026. Package versions, model tags and operational defaults can change. Check the linked release/dependency pages when reproducing the project.

Developer questions about cosine indexes and descending score ordering were checked against the operator-class issue discussion and ordering discussion; the current project documentation is the implementation authority. A public .NET retrieval/RAG example was reviewed for scope comparison, not used as proof that this implementation was tested. This tutorial focuses on model contracts, failure boundaries and persistent retrieval. No search-volume, competition, benchmark or traffic figures are invented.

Comments