· 5 min read

Semantic Search in C# — Without a Vector Database

Add semantic search to your C# app in 5 lines. Match text by meaning, not keywords. No Python, no external services, no vector database. Runs locally on CPU.

Semantic Search in C#

Add semantic search to your C# app in 5 lines of code. No API key. No Python. No vector database.

Search for "how to change my login credentials" — semantic search returns "How do I reset my password?" because the meaning matches, even though the words don't.

dotnet add package Kjarni

NuGet

using Kjarni;

using var embedder = new Embedder("minilm-l6-v2");
Console.WriteLine(embedder.Similarity("doctor", "physician")); // 0.8598
Console.WriteLine(embedder.Similarity("doctor", "banana"));    // 0.3379

The model runs locally on CPU. No external services, no API costs, no data leaving your machine.

How Semantic Search Works

The core idea: convert text into numbers that capture meaning.

A sentence embedding model reads text and outputs a vector — an array of floating-point numbers, typically 384 or 768 dimensions. Texts with similar meaning produce vectors that are close together. Texts with different meaning produce vectors that are far apart.

"doctor"    -> [0.12, -0.34, 0.56, 0.78, ...]  (384 numbers)
"physician" -> [0.11, -0.33, 0.55, 0.79, ...]  (384 numbers)  <- close
"banana"    -> [-0.45, 0.23, -0.12, 0.01, ...]  (384 numbers)  <- far

You measure the distance between vectors using cosine similarity. The score ranges from -1 (opposite) to 1 (identical).

Encoding Text

using var embedder = new Embedder("minilm-l6-v2");
float[] vector = embedder.Encode("Hello world");
Console.WriteLine(vector.Length);                          // 384
Console.WriteLine(string.Join(", ", vector[..5]));
// -0.034477282, 0.03102318, 0.006734989, 0.02610899, -0.03936202

The model downloads on first use (~90MB) and caches locally. Every call to Encode() runs the full transformer: tokenization, attention layers, mean pooling, normalization. The output is a unit-length vector ready for cosine similarity.

These are the same vectors you'd get from Python's sentence-transformers:

from sentence_transformers import SentenceTransformer
model = SentenceTransformer('all-MiniLM-L6-v2')
vector = model.encode("Hello world", normalize_embeddings=True)
# [-0.03447726  0.03102319  0.00673499  0.02610895 -0.03936201]

Same model, same weights, same output — without the 6.8GB Python dependency.

Measuring Similarity

Cosine similarity tells you how close two vectors are:

using var embedder = new Embedder("minilm-l6-v2");

var pairs = new[] {
    ("doctor", "physician"),
    ("doctor", "hospital"),
    ("doctor", "banana"),
    ("cat", "dog"),
    ("cat", "car"),
    ("machine learning", "artificial intelligence"),
    ("machine learning", "potato soup"),
};

foreach (var (a, b) in pairs)
    Console.WriteLine($"  {embedder.Similarity(a, b):F4}  \"{a}\" / \"{b}\"");
  0.8598  "doctor" / "physician"
  0.5971  "doctor" / "hospital"
  0.3379  "doctor" / "banana"
  0.6606  "cat" / "dog"
  0.4633  "cat" / "car"
  0.7035  "machine learning" / "artificial intelligence"
  0.1848  "machine learning" / "potato soup"

The scores match intuition. "Doctor" and "physician" are near-synonyms (0.86). "Cat" and "dog" are related but different (0.66). "Machine learning" and "potato soup" have almost nothing in common (0.18).

Under the hood, Similarity() calls Encode() on both inputs and then computes the dot product of the normalized vectors. If you're comparing one query against many documents, it's more efficient to encode them separately:

float similarity = Embedder.CosineSimilarity(vectorA, vectorB);

Encode your documents once. Encode the query at search time. Rank by cosine similarity.

using var embedder = new Embedder("minilm-l6-v2");

var docs = new[] {
    "How do I reset my password?",
    "What is your refund policy?",
    "Do you ship internationally?",
    "How do I update my billing address?",
    "Where can I track my order?",
};

// Encode all documents (do this once, store the vectors)
var vectors = embedder.EncodeBatch(docs);

// Search
var query = embedder.Encode("I need to change my login credentials");

var results = docs
    .Select((doc, i) => (doc, score: Embedder.CosineSimilarity(query, vectors[i])))
    .OrderByDescending(x => x.score);

foreach (var (doc, score) in results)
    Console.WriteLine($"  {score:F4}: {doc}");
  0.5981: How do I reset my password?
  0.4067: How do I update my billing address?
  0.0767: Where can I track my order?
 -0.0027: What is your refund policy?
 -0.0451: Do you ship internationally?

"Change my login credentials" matches "reset my password" at 0.60 despite sharing zero keywords. That's the entire value of semantic search.

"Update my billing address" comes second. The model understands that changing account information is related even though the specific fields differ.

Batch Encoding

EncodeBatch() processes multiple texts in a single call. This is faster than encoding them one by one because the model can process multiple inputs in parallel:

var texts = new[] {
    "First document",
    "Second document",
    "Third document",
};

float[][] vectors = embedder.EncodeBatch(texts);
// vectors[0] = embedding for "First document"   (384 floats)
// vectors[1] = embedding for "Second document"   (384 floats)
// vectors[2] = embedding for "Third document"    (384 floats)

For a production FAQ system, you'd encode all your documents at startup, store the vectors in memory (or serialize them to disk), and only encode the user's query at request time.

Choosing a Model

Kjarni ships with two embedding models:

ModelDimensionsSpeedQuality
minilm-l6-v2384FastGood
mpnet-base-v2768SlowerBetter

Start with minilm-l6-v2. It's the most widely used embedding model in production and handles most use cases well. Switch to mpnet-base-v2 if you need higher quality and can afford the extra latency and memory.

Both models have a 512 token input limit (~300-400 words). Longer text gets truncated. If your documents are long, split them into chunks first.

Cosine Similarity Explained

Two vectors are similar when they point in the same direction. Cosine similarity measures the angle between them:

similarity = (A · B) / (|A| × |B|)
  • 1.0 — identical direction (same meaning)
  • 0.0 — perpendicular (unrelated)
  • -1.0 — opposite direction (opposite meaning, rare in practice)

Since Kjarni normalizes all vectors to unit length by default, cosine similarity simplifies to the dot product:

// These are equivalent for normalized vectors:
float sim1 = Embedder.CosineSimilarity(a, b);
float sim2 = a.Zip(b, (x, y) => x * y).Sum();  // dot product

In practice, most semantic similarity scores fall between 0.0 and 0.8. A score above 0.5 usually means strong relevance. A score above 0.7 means near-paraphrase. The exact threshold depends on your use case.

Practical Patterns

FAQ Matching

Route support tickets to the most relevant FAQ:

using var embedder = new Embedder("minilm-l6-v2");

var faqs = new[] {
    "How do I cancel my subscription?",
    "How do I get a refund?",
    "How do I change my email address?",
    "What payment methods do you accept?",
    "How do I contact support?",
};
var faqVectors = embedder.EncodeBatch(faqs);

string MatchFaq(string userQuestion)
{
    var queryVec = embedder.Encode(userQuestion);
    var best = faqs
        .Select((faq, i) => (faq, score: Embedder.CosineSimilarity(queryVec, faqVectors[i])))
        .OrderByDescending(x => x.score)
        .First();

    return best.score > 0.4 ? best.faq : "No matching FAQ found.";
}

Console.WriteLine(MatchFaq("I want to stop paying"));
// How do I cancel my subscription?

Console.WriteLine(MatchFaq("Can I pay with crypto?"));
// What payment methods do you accept?

Deduplication

Find near-duplicate content in a dataset:

var texts = GetAllDocuments();
var vectors = embedder.EncodeBatch(texts);

var duplicates = new List<(string, string, float)>();

for (int i = 0; i < texts.Length; i++)
    for (int j = i + 1; j < texts.Length; j++)
    {
        var sim = Embedder.CosineSimilarity(vectors[i], vectors[j]);
        if (sim > 0.85)
            duplicates.Add((texts[i], texts[j], sim));
    }

A threshold of 0.85 catches rephrased content while ignoring merely related documents.

Combining with Sentiment

Find relevant reviews about a topic, then check their sentiment. See Sentiment Analysis in C# for more on the classification side.

using var embedder = new Embedder("minilm-l6-v2");
using var classifier = new Classifier("roberta-sentiment");

var query = embedder.Encode("battery life");
var relevant = reviews
    .Select(r => (review: r, score: Embedder.CosineSimilarity(query, embedder.Encode(r))))
    .Where(x => x.score > 0.3)
    .OrderByDescending(x => x.score);

foreach (var (review, score) in relevant)
    Console.WriteLine($"{classifier.Classify(review)}  \"{review}\"");

Semantic search is not always better than keyword search.

Semantic search works best when:

  • Users don't know the exact terminology
  • You're matching intent, not words ("change login" → "reset password")
  • Documents are short (FAQs, product descriptions, support tickets)

Keyword search works best when:

  • Users search for exact terms (error codes, product IDs, proper nouns)
  • You need exact phrase matching
  • Documents are long and repetitive keywords matter

The best approach is usually both. See Build a Document Search Engine in C# for a hybrid search implementation that combines BM25 keyword search with semantic vectors and reranking.

How This Works Under the Hood

Kjarni loads HuggingFace sentence-transformer models directly from safetensors. The inference engine is written in Rust with SIMD-optimized kernels (AVX2/FMA on x86, NEON on ARM). The C# package wraps a single native library — no Python, no ONNX Runtime, no external dependencies.

The outputs match Python's sentence-transformers to four decimal places.

For the full story on why Kjarni exists and how it compares to Python and ONNX Runtime, see Why I Built a Native ML Inference Engine in Rust.

Install:  dotnet add package Kjarni
NuGet:    https://www.nuget.org/packages/Kjarni
GitHub:   https://github.com/olafurjohannsson/kjarni

Next Steps