Technology
Semantic Search in .NET 10 Using PostgreSQL and pgvector
By Darshil Shah · Nov 2026 · 10 min read
- .NET
- PostgreSQL
- pgvector
- Semantic Search
- Ollama
- EF Core
Finding the right information becomes hard when a search system depends entirely on exact words. A user may describe an idea in one way while the stored content uses different terminology, which leads to poor results.
Semantic search overcomes this by converting text into vector embeddings that capture context and meaning. Related content can then be found even when the wording differs. In this article, I build a bookstore API using .NET 10, PostgreSQL, pgvector, EF Core and Ollama to store vectorized book data and perform similarity-based searches.
## Why semantic search helps
- **Context-aware search** retrieves results based on meaning and intent, not only exact keyword matches.
- **Vector embeddings** turn text into high-dimensional vectors so machines can compare content by context.
- **Similarity retrieval** uses metrics such as cosine similarity to rank related content.
- **Native PostgreSQL vector support** stores and queries embeddings with the pgvector extension in the same database as your relational data.
- **Local embeddings with Ollama** generate vectors without paid external AI APIs for this demo.
Semantic search is useful for intent-based queries. Keyword search is often better for exact matches such as titles, ISBNs, IDs, versions and error codes.
| Type | Example |
| -------- | ------------------------------------------------------------- |
| Semantic | “Books to help train my puppy” → *Dog Training for Beginners* |
| Keyword | “ISBN 978-0132350884” → Exact match |
## Implementation overview
The API supports adding books, generating vector embeddings, storing them in PostgreSQL with pgvector and retrieving relevant books through similarity search. Docker runs PostgreSQL, pgvector and Ollama locally.
### Step 1: Install Docker Desktop
Verify Docker is running:
```bash
docker --version
docker compose version
```
### Step 2: Create the solution and API project
```bash
dotnet new sln -n BookStoreSemanticSearch
dotnet new webapi -n BookStore.Api
dotnet sln add BookStore.Api
```
### Step 3: Docker Compose for PostgreSQL and Ollama
Create a `docker-compose.yml` file in the solution root:
```yaml
services:
postgres:
image: pgvector/pgvector:pg16
container_name: bookstore-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: bookstore
ports:
- "5433:5432"
volumes:
- bookstore_pgdata:/var/lib/postgresql/data
ollama:
image: ollama/ollama
container_name: bookstore-ollama
ports:
- "11434:11434"
volumes:
- bookstore_ollama:/root/.ollama
volumes:
bookstore_pgdata:
bookstore_ollama:
```
Start the containers:
```bash
docker compose up -d
docker ps
```
### Step 4: Download the embedding model
Pull `all-minilm`, which generates a 384-dimensional vector for each text input. The PostgreSQL column is configured as `vector(384)` to match.
```bash
docker exec bookstore-ollama ollama pull all-minilm
docker exec -it bookstore-ollama ollama list
```
### Step 5: Enable pgvector
```sql
CREATE EXTENSION IF NOT EXISTS vector;
SELECT extname FROM pg_extension;
```
### Step 6: Install NuGet packages
```bash
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Pgvector.EntityFrameworkCore
dotnet add package Microsoft.Extensions.AI
dotnet add package OllamaSharp
```
### Step 7: Book entity and DbContext
```csharp
using System.ComponentModel.DataAnnotations.Schema;
using Pgvector;
namespace BookStore.Api.Models;
public class Book
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Author { get; set; } = string.Empty;
[Column(TypeName = "vector(384)")]
public Vector? Embedding { get; set; }
public DateTime CreatedAt { get; set; }
}
```
```csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresExtension("vector");
modelBuilder.Entity<Book>(entity =>
{
entity.Property(b => b.Title).HasMaxLength(500).IsRequired();
entity.Property(b => b.Description).HasMaxLength(4000).IsRequired();
entity.Property(b => b.Author).HasMaxLength(300).IsRequired();
entity.HasIndex(b => b.Embedding)
.HasMethod("hnsw")
.HasOperators("vector_cosine_ops");
});
}
```
### Step 8: Configuration and Program.cs
Add the PostgreSQL connection string and Ollama settings to `appsettings.json`. Store secrets outside source control in production.
```json
{
"ConnectionStrings": {
"Default": "Host=localhost;Port=5433;Database=bookstore;Username=postgres;Password=postgres"
},
"Ollama": {
"Endpoint": "http://localhost:11434",
"EmbeddingModel": "all-minilm"
}
}
```
```csharp
builder.Services.AddDbContext<BookStoreDbContext>(options =>
options.UseNpgsql(connectionString, npgsql => npgsql.UseVector()));
builder.Services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(_ =>
(IEmbeddingGenerator<string, Embedding<float>>)
new OllamaApiClient(new Uri(ollamaEndpoint), embeddingModel));
builder.Services.AddScoped<IEmbeddingService, EmbeddingService>();
builder.Services.AddScoped<BookService>();
builder.Services.AddScoped<BookSearchService>();
```
The embedding generator is registered as a singleton because it can be safely reused across requests.
### Step 9: Embedding service
Combine title, description and author so the model gets richer context about each book:
```csharp
public class EmbeddingService(IEmbeddingGenerator<string, Embedding<float>> generator)
: IEmbeddingService
{
public Task<Vector> GenerateBookEmbeddingAsync(
string title, string description, string author,
CancellationToken cancellationToken = default)
{
var text =
quot;{title}. {description} Author: {author}";return GenerateVectorAsync(text, cancellationToken);
}
public Task<Vector> GenerateQueryEmbeddingAsync(
string query, CancellationToken cancellationToken = default)
=> GenerateVectorAsync(query, cancellationToken);
private async Task<Vector> GenerateVectorAsync(
string text, CancellationToken cancellationToken)
{
var embedding = await generator.GenerateVectorAsync(
text, cancellationToken: cancellationToken);
return new Vector(embedding.ToArray());
}
}
```
### Step 10: Create book and search APIs
Creating a book generates an embedding, then stores the book and vector together.
Searching embeds the query and orders books by cosine distance:
```csharp
public async Task<IReadOnlyList<BookSearchResult>> SearchAsync(
string query, int limit, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
return [];
var queryVector = await embeddingService.GenerateQueryEmbeddingAsync(
query, cancellationToken);
return await db.Books
.Where(b => b.Embedding != null)
.OrderBy(b => b.Embedding!.CosineDistance(queryVector))
.Take(limit)
.Select(b => new BookSearchResult
{
Id = b.Id,
Title = b.Title,
Description = b.Description,
Author = b.Author,
Similarity = 1 - b.Embedding!.CosineDistance(queryVector)
})
.ToListAsync(cancellationToken);
}
```
`CosineDistance()` returns distance. Similarity as `1 - distance` helps ranking, but it is not a percentage of relevance.
Apply migrations, seed sample books across categories such as animals, business and coding, then test queries like `teach my dog`, `learn c#` and `raise money for startup` in Swagger.
## Production readiness
Before adopting this approach in production:
- **Embedding model and dimensions.** If you switch models, vector size may change. Update the schema and regenerate embeddings.
- **Request validation.** In .NET 10 Minimal APIs, register `AddValidation()` and enable `.WithParameterValidation()` so invalid requests return 400 before business logic.
- **Error handling.** Configure timeouts and consider retries or circuit breakers for Ollama and PostgreSQL.
- **HNSW tuning.** Adjust `m`, `ef_construction` and `ef_search` with representative data. Higher values can improve recall but may raise memory use or latency.
- **Exact vs approximate search.** Exact search is most accurate. HNSW and IVFFlat trade some recall for speed at scale.
- **Relevance thresholds.** Top-N alone can return weak matches. Choose a similarity threshold through testing.
- **pgvector vs dedicated vector databases.** pgvector is a strong fit when you already use PostgreSQL and have moderate needs. Larger or more complex workloads may need a dedicated vector store such as Pinecone, Qdrant or Weaviate.
## Use cases
Book recommendations, e-commerce product search, knowledge base search, content discovery, document retrieval and the retrieval layer for RAG assistants all fit this pattern.
## What we learned
Semantic search lets applications understand intent instead of relying only on exact keywords. With .NET 10, PostgreSQL, pgvector, EF Core and Ollama, you can build a cost-effective similarity search API without a separate vector database. That foundation supports recommendation systems, intelligent document search and Retrieval-Augmented Generation solutions.