Semantic search in .NET / C#
What you’re building
Section titled “What you’re building”A search over eight handwritten notes. You type “where did I put my keys?” and it finds:
The spare set for the front door is in the kitchen drawer.
Read that note again. It doesn’t contain the word “keys”. It doesn’t contain “key”. A LIKE '%key%' query would skip it entirely and hand you the note about a 4mm hex key instead. The wrong answer, confidently.
Making the right note win is what this guide is about.
Before you start
Section titled “Before you start”You need the .NET SDK. Check whether you already have it:
dotnet --versionIf that prints a version number of 8.0 or higher, you’re set. If it says the command isn’t found, install it from dotnet.microsoft.com/download, and pick the SDK rather than the Runtime. The Runtime only runs .NET apps; the SDK is what builds them.
No other background is required. If you can write a foreach loop, you can follow this.
Part 1: the mechanics, with no API key
Section titled “Part 1: the mechanics, with no API key”Step 1: Make the project
Section titled “Step 1: Make the project”-
Create it and move into the folder:
Terminal window dotnet new console -n NotesSearchcd NotesSearch -
Check the empty project runs:
Terminal window dotnet runYou should see
Hello, World!. If you do, your toolchain is fine and anything that breaks later is our code, not your setup. That’s worth knowing.
Step 2: Understand the one idea
Section titled “Step 2: Understand the one idea”Before the code, the concept, because it’s smaller than people expect.
An embedding is a list of numbers that stands in for a piece of text. Text that means similar things gets similar numbers. That’s it.
Once meaning is numbers, “find me something similar” becomes arithmetic, and computers are exceptionally good at arithmetic.
For Part 1 we’ll write those numbers by hand, using five made-up categories:
[ where things tools food health admin ] [ are kept & DIY & money ]
"spare set … kitchen drawer" [ 0.95, 0.20, 0.05, 0.00, 0.10 ]"standing desk … 4mm hex key" [ 0.15, 0.95, 0.00, 0.05, 0.00 ]"pizza dough: 500g flour…" [ 0.05, 0.05, 0.95, 0.10, 0.00 ]The first note scores high on where things are kept. The hex key note scores high on tools. Search for keys, and the first one wins, because you were asking where something is, not about DIY.
Step 3: Write the code
Section titled “Step 3: Write the code”Open Program.cs, delete what’s there, and paste this in:
// Each note is paired with five numbers standing in for its meaning.// The categories are: where things are kept, tools & DIY, food,// health, admin & money. (The Note type itself is at the bottom.)Note[] notes =[ new("The spare set for the front door is in the kitchen drawer.", [0.95f, 0.20f, 0.05f, 0.00f, 0.10f]), new("The dog needs his flea treatment on the 3rd.", [0.05f, 0.00f, 0.10f, 0.70f, 0.35f]), new("Mum's birthday is in April, she likes tulips.", [0.05f, 0.00f, 0.15f, 0.00f, 0.60f]), new("Renew the car insurance before it lapses.", [0.00f, 0.05f, 0.00f, 0.00f, 0.95f]), new("The standing desk needs a 4mm hex key to assemble.", [0.15f, 0.95f, 0.00f, 0.05f, 0.00f]), new("Pizza dough: 500g flour, 300ml water, rest overnight.", [0.05f, 0.05f, 0.95f, 0.10f, 0.00f]), new("The wifi password is taped under the router.", [0.65f, 0.10f, 0.00f, 0.00f, 0.20f]), new("Physio said ice the knee for twenty minutes.", [0.00f, 0.00f, 0.00f, 0.95f, 0.05f]),];
// The questions, and what we reckon each one is "about".(string Question, float[] Vector)[] searches =[ ("where did I put my keys?", [0.90f, 0.30f, 0.00f, 0.00f, 0.05f]), ("what do I cook on Friday?", [0.05f, 0.05f, 0.95f, 0.05f, 0.00f]), ("my knee is hurting", [0.00f, 0.00f, 0.00f, 0.95f, 0.05f]),];
foreach (var (question, queryVector) in searches){ Console.WriteLine($"\nQ: {question}");
var best = notes .Select(note => new { note.Text, Score = CosineSimilarity(note.Vector, queryVector) }) .OrderByDescending(result => result.Score) .Take(3);
foreach (var result in best) Console.WriteLine($" {result.Score:F3} {result.Text}");}
// How similar are two vectors? Returns roughly -1 (opposite) to 1 (identical).// It compares the *direction* the two vectors point, ignoring their length.static float CosineSimilarity(float[] a, float[] b){ float dot = 0f, lengthA = 0f, lengthB = 0f;
for (int i = 0; i < a.Length; i++) { dot += a[i] * b[i]; // multiply matching positions, add them up lengthA += a[i] * a[i]; // Pythagoras, part one lengthB += b[i] * b[i]; // Pythagoras, part two }
return dot / (MathF.Sqrt(lengthA) * MathF.Sqrt(lengthB));}
// A note, and the numbers that stand in for its meaning.// In a file using top-level statements, type declarations have to come// after the statements — the compiler is strict about it (error CS8803).record Note(string Text, float[] Vector);Step 4: Run it
Section titled “Step 4: Run it”dotnet runQ: where did I put my keys? 0.991 The spare set for the front door is in the kitchen drawer. 0.957 The wifi password is taped under the router. 0.459 The standing desk needs a 4mm hex key to assemble.
Q: what do I cook on Friday? 0.999 Pizza dough: 500g flour, 300ml water, rest overnight. 0.245 Mum's birthday is in April, she likes tulips. 0.176 The dog needs his flea treatment on the 3rd.
Q: my knee is hurting 1.000 Physio said ice the knee for twenty minutes. 0.907 The dog needs his flea treatment on the 3rd. 0.104 Pizza dough: 500g flour, 300ml water, rest overnight.That output is real: this sample is compiled and run before publishing, so what you see above is what the program actually printed.
You just built a semantic search. No API, no database, no machine learning library. About forty lines of C# and one loop of arithmetic.
Look at the first result. The front-door note scores 0.991. The hex key note — the only one that literally contains “key” — scores 0.459 and lands third. Keyword search would have ranked those in exactly the opposite order.
Notice the third search too: “my knee is hurting” finds the physio note, then the dog’s flea treatment. Both are health-ish, so they sit near each other. That’s a genuine wrinkle of this technique, not a bug — “related” isn’t always “relevant”, and it’s the reason reranking exists.
Part 2: real embeddings
Section titled “Part 2: real embeddings”Everything above works, but we wrote the numbers ourselves. That doesn’t scale past a demo. Nobody is hand-scoring ten thousand documents across five made-up axes.
A real model does it for you. Same code, one method swapped.
Step 1: Install the SDK
Section titled “Step 1: Install the SDK”dotnet add package OpenAIThat’s the official OpenAI library for .NET, published by OpenAI.
Step 2: Get an API key
Section titled “Step 2: Get an API key”Create one at platform.openai.com/api-keys and copy it straight away, since it’s only shown once.
New accounts need a payment method before the API responds. Embeddings are remarkably cheap (fractions of a penny for everything in this guide), but the card has to be on file.
Then put the key in your environment:
export OPENAI_API_KEY="sk-..." # macOS / Linuxsetx OPENAI_API_KEY "sk-..." # Windows — then open a NEW terminalStep 3: Replace Program.cs
Section titled “Step 3: Replace Program.cs”using OpenAI.Embeddings;
string[] notes =[ "The spare set for the front door is in the kitchen drawer.", "The dog needs his flea treatment on the 3rd.", "Mum's birthday is in April, she likes tulips.", "Renew the car insurance before it lapses.", "The standing desk needs a 4mm hex key to assemble.", "Pizza dough: 500g flour, 300ml water, rest overnight.", "The wifi password is taped under the router.", "Physio said ice the knee for twenty minutes.",];
string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException( "OPENAI_API_KEY is not set. See step 2 of the guide.");
EmbeddingClient client = new("text-embedding-3-small", apiKey);
// Embed all eight notes in ONE request, before any searching happens.float[][] noteVectors = await EmbedAsync(notes);
foreach (string question in new[] { "where did I put my keys?", "what do I cook on Friday?", "my knee is hurting", }){ Console.WriteLine($"\nQ: {question}");
// Only the question gets embedded at search time. float[] queryVector = (await EmbedAsync([question]))[0];
var best = noteVectors .Select((vector, index) => new { Text = notes[index], Score = Dot(vector, queryVector) }) .OrderByDescending(result => result.Score) .Take(3);
foreach (var result in best) Console.WriteLine($" {result.Score:F3} {result.Text}");}
// The only method that changed between Part 1 and Part 2.// Sends every string in one request and returns unit-length vectors.async Task<float[][]> EmbedAsync(string[] texts){ OpenAIEmbeddingCollection response = await client.GenerateEmbeddingsAsync(texts); return response.Select(item => Normalize(item.ToFloats().ToArray())).ToArray();}
// Scale a vector to length 1. Once both vectors are length 1, cosine// similarity is just the dot product — no division needed later.static float[] Normalize(float[] vector){ float length = MathF.Sqrt(vector.Sum(value => value * value)); return vector.Select(value => value / length).ToArray();}
static float Dot(float[] a, float[] b){ float total = 0f; for (int i = 0; i < a.Length; i++) total += a[i] * b[i]; return total;}Step 4: Run it
Section titled “Step 4: Run it”dotnet runQ: where did I put my keys? 0.412 The spare set for the front door is in the kitchen drawer. 0.287 The standing desk needs a 4mm hex key to assemble. 0.169 The wifi password is taped under the router.Same winner, real model, and nobody hand-scored anything.
Those numbers are illustrative, not captured from a run — exact scores move between model versions, and any tutorial quoting them to three decimal places is showing you one moment in time. The ranking is the part that matters.
Understanding the code
Section titled “Understanding the code”-
EmbeddingClient client = new("text-embedding-3-small", apiKey)One client, locked to one model when you create it. That’s a quiet kindness from the SDK: it makes it structurally awkward to embed half your data with one model and half with another. That’s a real bug, and it produces no error message, just silently worse results.
-
EmbedAsync(notes)runs once, before the loopThis is the single most important line in the file. Embedding your documents is the expensive part, so it happens once, up front. Only the query is embedded per search.
The classic beginner mistake is moving this inside the loop — or inside a web request handler — which re-embeds every document on every single search. It works fine with eight notes and collapses the moment you have real data.
-
One call for eight notes
GenerateEmbeddingsAsynctakes a whole collection. Results come back in the order you sent them, so index 3 of the response belongs to note 3. Don’t loop and call it once per string; that’s eight round trips where one would do. -
item.ToFloats()The SDK returns
ReadOnlyMemory<float>rather than a plain array, which avoids copying. We call.ToArray()here for readability. If you’re embedding a lot and watching allocations, work with theReadOnlyMemory<float>and its.Spandirectly. -
Normalize— and why Part 2 usesDotinstead ofCosineSimilarityLook back at Part 1’s
CosineSimilarity: it divides the dot product by both vectors’ lengths. If both vectors already have length 1, that division is by1 × 1: pointless.So we normalise once when embedding, and every later comparison becomes a plain multiply-and-add. Same answer, less work, and the work moved from search time (every query) to storage time (once). The full explanation.
-
OrderByDescending(...).Take(3)Highest score first, keep three. Fine for eight notes. At eighty thousand you want a vector database doing an approximate search instead of sorting everything on every query. See chunking and vector databases.
When it goes wrong
Section titled “When it goes wrong”Everyone hits at least one of these. None of them mean you’ve misunderstood embeddings.
InvalidOperationException: OPENAI_API_KEY is not set
The variable isn’t reaching the process. export only applies to the terminal you typed it in: new tab, new export. On Windows, setx needs a brand new terminal. And Visual Studio or Rider caches the environment from when it launched, so setting a variable in a terminal won’t reach an already-running IDE; restart it, or set the variable in launchSettings.json.
ClientResultException: HTTP 401
The key arrived but was rejected. Usually that means revoked, mistyped, or with a stray quote captured by setx. Print the first six characters (never the whole key) to see what’s actually arriving.
HTTP 429 on your very first call
Reads like rate limiting; almost always means no billing set up. Check your billing settings.
Every result scores about the same Usually one of two things. Either your query is far shorter or vaguer than your documents, or — the silent one — you embedded your notes with one model and your query with another. Vectors from different models live in unrelated coordinate spaces and comparing them produces confident nonsense with no error.
The compiler complains about notes inside the local function
Top-level statements have real scoping rules that catch people out. If you move this into a proper class Program, both EmbedAsync and notes need to become static members. The compiler will be explicit about it.
Where to go from here
Section titled “Where to go from here”If you’re on Azure OpenAI
Section titled “If you’re on Azure OpenAI”Same code, different client construction:
dotnet add package Azure.AI.OpenAIusing Azure.AI.OpenAI;using System.ClientModel;
AzureOpenAIClient azure = new( new Uri("https://YOUR-RESOURCE.openai.azure.com/"), new ApiKeyCredential(apiKey));
EmbeddingClient client = azure.GetEmbeddingClient("your-deployment-name");Azure wants your deployment name, not the model name. Teams usually set them to the same string, which hides the distinction right up until someone names a deployment differently and everything breaks. Every line below that stays identical, because Azure.AI.OpenAI builds on the same EmbeddingClient type. Azure embeddings docs.
If you’d rather not commit to one provider
Section titled “If you’d rather not commit to one provider”Microsoft ships an abstraction for exactly this:
dotnet add package Microsoft.Extensions.AIYou code against IEmbeddingGenerator<string, Embedding<float>>, which OpenAI, Azure, Ollama and others all implement. Switching provider becomes a dependency-injection change rather than a rewrite. Worth adopting early if you think you might switch. Docs.
References
Section titled “References”- openai-dotnet on GitHub — official SDK source and current API
- OpenAI package on NuGet — version history
- OpenAI — Embeddings guide — models, dimensions, token limits
- Download .NET — the SDK, if you need it
- TensorPrimitives.CosineSimilarity — the fast path
- Microsoft.Extensions.AI — provider-agnostic embeddings
- Azure OpenAI — How to generate embeddings
- Safe storage of app secrets in development — keeping keys out of source