Post

Embeddings from Scratch - Understanding Semantic Search with C# and Microsoft’s AI Libraries

Suppose you search for “repair a car,” and a document describes how to “fix an automobile.” The words differ, but the ideas are closely related. A person can recognize that connection immediately. How can software find it?

This is one of the problems embeddings help us solve.

We will begin with simple examples to understand the concepts. Then we will follow the existing embeddings-semantic-search project, using its fictional descriptions, C# implementation, and documented results.

1. Why Searching for Words Can Miss Meaning

A straightforward search system can look for the words entered by the user. For a query such as repair a car, it might reward documents containing “repair” and “car.” A relevant document using “fix” and “automobile” could receive a weaker score. There is also a reverse problem. A document might contain the search words without providing useful information. A page listing prohibited activities could mention car repairs without explaining how to perform them. Shared vocabulary is useful evidence, but it does not fully express the relationship between a question and a useful answer. Embeddings provide another way to compare text.

2. Why Representation Matters

Before software can compare information, it needs a representation of that information. Imagine assigning these numbers:

car → 10 
automobile → 50 
banana → 11

These values identify words. Their numerical relationships are arbitrary. The distance between 10 and 11 does not make a banana more related to a car than an automobile is. The important question is therefore not simply whether we can turn text into numbers. Computers already do that when storing text. The question is whether relationships between the numbers help us perform a useful task.

Embeddings aim to represent information so that useful relationships become measurable.

3. What Is an Embedding?

An embedding is a numerical representation produced by a model. For text, we can picture the process like this:

Text → embedding model → numerical vector
A vector is an ordered list of numbers:
[0.18, -0.42, 0.07, ...]

Those values are illustrative. Actual values depend on the model and input. We can represent a word, sentence, or longer passage. For semantic search, we generate representations of the documents and the query, then compare them. The original text remains important. The vector supports comparison; the text lets us inspect what a result actually says.

4. From Word Counts to Learned Representations

One way to represent a document is to count words from a fixed vocabulary. Suppose our vocabulary is:

[car, automobile, repair, banana]
A simple representation might look like:
"car repair" → [1, 0, 1, 0] 
"automobile" → [0, 1, 0, 0]

Each coordinate has an explicit relationship to a vocabulary term. With a large vocabulary, most coordinates in a short document will be zero. This is a sparse representation. Word-count representations also discard word order. More advanced lexical methods introduce weighting, but the vocabulary remains central to the comparison. You can refer to Stanford’s explanation of term frequency for more information.

Learned embeddings commonly use dense vectors, with information distributed across many coordinates. This gives us a way to capture relationships beyond shared words.

5. Understanding Dimensions and Embedding Spaces

The number of values in a vector is its number of dimensions:

[0.2, 0.5] → two dimensions 
[0.2, 0.5, 0.1] → three dimensions

Embedding models can produce vectors with many more dimensions. These vectors belong to an embedding space: the coordinate system in which we compare their relationships. A drawing with two axes can help us imagine related texts pointing in similar directions. It is a teaching aid; actual vectors usually have more dimensions than we can draw directly. Individual coordinates generally do not have simple labels such as “vehicle knowledge” or “repair experience.” We evaluate the representation through the behavior of the complete vector.

6. Where Do the Numbers Come From?

The model learns its representation during training. One common training approach uses related and unrelated examples. For retrieval, a training example might connect a query with a relevant document and contrast it with competing documents. The training objective encourages stronger similarity for the relevant pair. You can refer to Sentence Transformers loss documentation for more information.

When an application uses a pretrained embedding model, it supplies text and receives vectors. It is using the learned representation, rather than training the model from scratch. Adding a document to a search collection does not, by itself, update that model.

7. Embedding Models and Chat Models Have Different Roles

An embedding model supplies a representation for comparison. A chat model generates messages in response to its input.

A semantic search application can retrieve documents without generating an answer. This distinction matters when designing an application. Searching for useful source material and composing an answer from that material are separate operations. Our project demonstrates the search operation.

8. Measuring Similarity

After generating vectors, we need a comparison rule. A common choice is cosine similarity, which compares vector directions:

cosine similarity = dot product / (length of first vector × length of second vector)

The dot product multiplies corresponding coordinates and adds the results. Consider:

[3, 4]
Its length is:
√(3² + 4²) = 5
Dividing each coordinate by 5 gives:
[0.6, 0.8]

This operation, called normalization, gives the vector length one. When both vectors have length one, their dot product directly gives cosine similarity. You can refer to Similarity calculations in Sentence Transformers for more information.

Cosine similarity ranges from −1 to 1. Higher values indicate more similar directions. A score is not a probability that a document answers the question.

9. Similarity Is a Useful Signal, Not a Relevance Guarantee

A document can discuss the right subject while describing the wrong activity. For example, writing about vehicle repairs and performing vehicle repairs involve related vocabulary. They do not establish the same experience. A search result must satisfy the actual requirements of the query. Also, the closest item in a collection can still be unsuitable. If none of the documents answer the question, ranking them does not create a relevant answer.

We will see this limitation in the project’s own data.

10. Enter the Project: Searching Fictional Software Experience

We now move into the embeddings-semantic-search project. From this point, the demonstrations follow its existing implementation. The application searches 12 fictional software project descriptions using two approaches:


The query and result limit appear at the top of Program.cs:

const string query = "experience building containerized APIs"; 
const int topK = 5;

The sample descriptions include deliberately different cases:

The lesson is to compare the rankings and inspect which descriptions actually support the query.

11. Where Microsoft’s AI Libraries Fit

The training repository uses Microsoft Agent Framework for its chat-based lessons. This fifth lesson performs embedding requests through Microsoft.Extensions.AI:

IEmbeddingGenerator<string, Embedding<float>>

The interface accepts text and returns embeddings through GenerateAsync. Microsoft embedding interface documentation. The application uses these responsibilities:

There is no ChatClientAgent or answer-generation step in this experiment. Its contribution to an agent application is the retrieval capability we are learning to understand.

12. Loading and Validating the Inputs

Program.cs begins by loading the copied sample data:

string folder = AppContext.BaseDirectory;

List<ProjectDescription> projects = 
await ProjectDescriptionsLoader.LoadAsync(Path.Combine(folder, "SampleData", "projects.json"));

SearchInputsValidator.Validate(projects, query, topK);

The input checks happen before paid requests. They require a nonempty query of up to 500 characters, a positive top-k, and between 1 and 100 projects. Each project needs a unique ID, nonempty text within the configured limit, and skill names. Next, the application prepares both text batches:

List<string> documentTexts = projects.Select(project => project.Text).ToList();

List<string> queryTexts = [query];

Both searches use the original Text field. The Skills list remains metadata and contributes no search terms. This keeps the comparison controlled: the query, source text, and top-k remain fixed while the ranking method changes.

13. Establishing the Keyword Baseline

The application first runs:

List<SearchResult> lexicalResults = LexicalSearch.Rank(projects, query, topK);

LexicalSearch.cs extracts words, ignores case and punctuation, and counts distinct query words present in each description. For the default query, the words are:

experience building containerized APIs

P06 contains all four and receives a score of 4. Yet its description explains that the work was proofreading and that no software or deployment was implemented.

P01 describes relevant work using “REST endpoints,” “Docker images,” and “containers.” It contains none of the exact query words. This is the vocabulary gap the semantic comparison investigates.

The baseline is deliberately simple. Its behavior should not be treated as a complete evaluation of more advanced lexical search methods.

14. Creating the Embedding Generator

The application selects the embedding settings:

ProviderSettings settings = await SettingsReader.ReadSettingsAsync(folder, useEmbeddingModel: true); 

var factory = new AiClientFactory();

using IEmbeddingGenerator<string, Embedding<float>> generator =     factory.CreateEmbeddingGenerator(settings);

The separate embedding-model setting allows the lesson to use an embedding model while preserving the chat-model configuration used by other projects.

Provider SDK construction and connection details remain inside shared code. The lesson works directly with Microsoft’s embedding interface. The document request is:

GeneratedEmbeddings<Embedding<float>> documentEmbeddings = await ProjectEmbedder.RequestAsync(generator, documentTexts);

Inside ProjectEmbedder.cs, the actual operation is:

return await generator.GenerateAsync(texts, cancellationToken: deadline.Token);

One call embeds the complete document batch. Twelve input descriptions produce twelve vectors. They do not require twelve separate application requests.

15. Checking the Returned Embeddings

The application validates the response before using it:

EmbeddingResponseValidator.Validate(documentEmbeddings, projects.Count, settings.Model);

The checks require:

  • Exactly one vector per input.
  • The expected model name.
  • Consistent positive dimensions.
  • Finite numerical values.
  • Nonzero vectors.

A zero vector cannot be normalized for this cosine comparison. Nonfinite values would make the numerical results unusable. The shared provider plumbing also checks the returned input indexes and places vectors in input order. That mapping matters. Even a valid vector would produce misleading results if attached to the wrong description.

16. Normalizing the Document Vectors

After validation, each vector is normalized and paired with its original project:

var indexedProjects = new List<IndexedProject>();

for (int index = 0; index < projects.Count; index++) {      indexedProjects.Add(new(projects[index],             VectorNormalizer.Normalize(documentEmbeddings[index].Vector.Span))); 
}

This applies the same operation we explored with [3, 4]: calculate the vector’s length and divide each coordinate by that length. Validation and normalization remain separate stages. The validator checks whether the vector satisfies the requirements. The normalizer transforms an accepted vector to length one. Keeping these responsibilities visible helps a learner follow exactly what happens before a vector becomes searchable.

17. Storing the Collection in SQLite

The application creates a collection containing the provider, model, dimensions, and indexed projects:

var searchIndex = new SearchIndex(settings.Name, settings.Model,     documentEmbeddings[0].Dimensions, indexedProjects);

It then validates, stores, reloads, and validates that collection:

SearchIndexValidator.Validate(searchIndex);

string indexPath = Path.Combine(folder, "projects.sqlite");

SqliteSearchIndex.Replace(indexPath, searchIndex);

SearchIndex storedIndex = SqliteSearchIndex.Read(indexPath);

SearchIndexValidator.Validate(storedIndex);

The database preserves original text, IDs, skills, normalized vectors, and collection metadata. One transaction replaces the complete collection. Changed or removed descriptions cannot remain as stale extra rows. A failed replacement preserves the previous collection.

SQLite supplies persistence here. The vectors are stored as JSON lists, and C# performs the similarity calculations across every document.

18. Embedding the Query

The query now follows its own embedding request:

GeneratedEmbeddings<Embedding<float>> queryEmbeddings = await ProjectEmbedder.RequestAsync(generator, queryTexts);

EmbeddingResponseValidator.Validate(queryEmbeddings, 1, settings.Model);

Embedding<float> queryEmbedding = queryEmbeddings[0];

The application checks compatibility with the stored collection:

SearchIndexValidator.ValidateCompatibility(storedIndex, settings.Name,     queryEmbedding.ModelId!, queryEmbedding.Vector.Span);

This project requires matching provider, model, and dimensions. Equal dimensions alone do not establish compatible representations. Two models can produce vectors of the same length while giving those coordinates different relationships.

Finally, the query vector is normalized:

List<float> normalizedQuery =     VectorNormalizer.Normalize(queryEmbedding.Vector.Span);

The document and query vectors are now ready for comparison.

19. Ranking the Documents

The application calls:

List<SearchResult> semanticResults = SemanticSearch.Rank(storedIndex, normalizedQuery, topK);

The central calculation in SemanticSearch.cs is straightforward:

double score = 0;

for (int dimension = 0; dimension < searchIndex.Dimensions;      dimension++) 
{ 
score += (double)indexedProject.Vector[dimension] * normalizedQuery[dimension]; 
}

Because both vectors have length one, this dot product gives cosine similarity. The method scores every project, sorts by descending score, breaks equal-score ties by document ID, and returns at most top-k results. This is exact search over the collection. There is no minimum relevance threshold. Top-k determines how many results we inspect, rather than certifying that all returned descriptions satisfy the query.

20. Running the Experiment

Install the .NET 10 SDK and create the shared credentials file from shared/shared/.env.example. For OpenRouter, the required settings include:

AI_PROVIDER=openrouter OPENROUTER_API_KEY=your-key OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small

The project also supports direct Gemini access through the selected-provider configuration. Only the selected provider’s embedding model and key are required for this lesson.

Rebuild after changing the settings. From the embeddings-semantic-search application folder, run:

dotnet run

Each successful run makes two embedding model calls:

The application receives 13 vectors through two calls. Normally, this sends two HTTP requests. The shared policy permits up to two retries per call for specified temporary failures, producing at most six HTTP attempts. Each call has one 120-second deadline covering attempts and retry waits. Requests send the fictional text to the selected remote service and may incur charges. The remaining search operations run locally.

21. Reading the Rankings

The console prints the provider, model, query, document count, dimensions, top-k, database path, and both rankings. Each result contains its rank, project ID, score, and a source snippet of up to 90 characters. The project README documents this earlier Gemini run:

The keyword ranking rewards P06’s exact vocabulary. The semantic ranking retrieves relevant implementation and deployment descriptions. It also places P06 second. That is an important observation. However, embeddings can also find useful relationships while still returning misleading matches.

Read the complete descriptions, particularly qualifications such as “without containers” and “no software or deployment was implemented.” Compare the project IDs and supporting text across rankings. Keyword counts and cosine scores use different numerical scales. Other models or model updates can produce different semantic rankings.

22. How This Relates to RAG and Microsoft Agent Framework

Retrieval-augmented generation, or RAG, gives a generative model relevant source material before asking it to answer. Conceptually:

Question    
↓ 
Retrieve relevant source material    
↓ 
Supply that material to a generative model    
↓ 
Generate an answer

The fifth project demonstrates retrieval: representing descriptions, comparing them with a query, and returning ranked source material. In an application built with Microsoft Agent Framework, retrieved descriptions could become context for an agent’s answer. The existing experiment stops before that generation step. This boundary is useful for learning. We can inspect retrieval quality directly, before a generated answer makes the results sound more convincing than the evidence supports.

23. Understanding What the Tests Establish

The project’s deterministic tests check important mechanics:

  • Input validation and rejection before remote work.
  • Embedding response requirements.
  • Vector validation and normalization.
  • Known cosine rankings.
  • Keyword matching rules.
  • Stored-model compatibility.
  • SQLite replacement and rollback behavior.

Fake vectors allow tests to establish a known numerical order.

For example, vectors pointing in the same, perpendicular, and opposite directions can produce expected cosine scores of 1, 0, and −1. Those tests establish that the calculation and ranking behave correctly. They do not establish that a remote embedding model selects the most useful descriptions. From the repository root, deterministic checks run with:

dotnet test AIEngineeringTraining.slnx --filter "Category!=Integration"

The complete fifth-project suite includes an integration case making two real embedding calls. Its README gives the full commands and request counts.

24. Learning by Changing the Experiment

Start by predicting which descriptions support the default query. Then change one variable at a time.

  1. Change the query wording. Try deploying web services as Docker images. Keep the descriptions, model, and top-k fixed. Inspect how both rankings move.
  2. Change top-k. Set it to 3. Check which useful or misleading descriptions remain visible.
  3. Investigate partial matches. Compare P03 and P07. One describes API implementation without containers; the other describes containers without web endpoints.
  4. Edit one description. Change the wording while preserving the work performed. Inspect whether each search method responds to the vocabulary change.
  5. Read beyond the snippets. Check whether the full description changes your judgment of a highly ranked result.

Every normal run regenerates the document and query embeddings. Record the query, model, returned IDs, and your relevance judgments. That makes comparisons easier to interpret.

25. Full Code Reference

The code reference for this article is the AI Engineering Training repository on GitHub, under:

05-embeddings-semantic-search

Start with Program.cs to follow the execution order. Then read ProjectEmbedder.cs, VectorNormalizer.cs, SemanticSearch.cs, and LexicalSearch.cs. Use SampleData/projects.json to judge the evidence behind the rankings. The most useful habit is to inspect every promising result with one question:

Which words in the original description support the work I am searching for?
0 Comments
Leave a Reply

Your e-mail address will not be published. Required fields are marked *