· 4 min read

Semantic Grep in C#: Search Files by Meaning, Not Keywords

A grep that finds lines by what they mean, in 50 lines of C#. Ask "is this safe to call from several threads" and get the comments that answer it, where grep finds nothing. One NuGet package, offline, no Python.

grep finds the words you typed. Ask a folder of code and docs "is this safe to call from several threads" and grep finds nothing, because nobody wrote that sentence. The answer is there, worded differently:

0.492  Kjarni.Extensions.AI/KjarniEmbeddingGeneratorExtensions.cs:42  /// instance across requests is safe.
0.479  Kjarni.Extensions.AI/KjarniChatClientExtensions.cs:37  /// to re-initialize. The client serializes concurrent calls internally, so sharing one
0.457  Kjarni.Extensions.AI/KjarniChatClientExtensions.cs:38  /// instance across requests is safe — but generation is compute-bound, so concurrent
0.449  Kjarni/ImageIndex.cs:22  /// threads; adding is not.

That is a semantic grep: every line in the folder is turned into a vector by a small embedding model, and the lines whose vectors point the same way as the question come first. This post builds one in about 50 lines of C#. It runs offline, in your own process, from one NuGet package.

Build it

You need the .NET 8 SDK.

dotnet new console -o SemanticGrep
cd SemanticGrep
dotnet add package Kjarni

Replace Program.cs with:

using System.Diagnostics;
using Kjarni;

if (args.Length == 0)
{
    Console.Error.WriteLine("usage: dotnet run -- \"<query>\" [folder] [count]");
    return 1;
}

var query = args[0];
var root = args.Length > 1 ? args[1] : ".";
var count = args.Length > 2 ? int.Parse(args[2]) : 10;

string[] extensions = [".cs", ".md", ".txt", ".py", ".rs", ".go", ".js", ".ts", ".yml", ".yaml"];
string[] skipFolders = ["bin", "obj", ".git", "node_modules", "target"];

var sw = Stopwatch.StartNew();
var lines = new List<(string File, int Number, string Text)>();
foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
{
    var relative = Path.GetRelativePath(root, file);
    if (!extensions.Contains(Path.GetExtension(file))) continue;
    if (relative.Split('/', '\\').Any(skipFolders.Contains)) continue;

    var number = 0;
    foreach (var raw in File.ReadLines(file))
    {
        number++;
        var text = raw.Trim();
        // Braces, `using` lines and one-word calls match everything a little and nothing well.
        if (text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length >= 3)
            lines.Add((relative, number, text));
    }
}

using var embedder = new Embedder("minilm-l6-v2", quiet: true);
var queryVector = embedder.Encode(query);

var scores = new float[lines.Count];
const int batchSize = 256;
for (var start = 0; start < lines.Count; start += batchSize)
{
    var batch = lines.Skip(start).Take(batchSize).Select(l => l.Text).ToArray();
    var vectors = embedder.EncodeBatch(batch);
    for (var i = 0; i < vectors.Length; i++)
        scores[start + i] = Embedder.CosineSimilarity(queryVector, vectors[i]);
}

foreach (var i in Enumerable.Range(0, lines.Count).OrderByDescending(i => scores[i]).Take(count))
{
    var (file, number, text) = lines[i];
    Console.WriteLine($"{scores[i]:F3}  {file}:{number}  {text}");
}
Console.Error.WriteLine($"{lines.Count} lines in {sw.Elapsed.TotalSeconds:F1} s");
return 0;

The first run downloads the model, all-MiniLM-L6-v2 (90 MB), and caches it. After that nothing touches the network.

Try it on a real folder

To get the same results as this post, search Kjarni's own C# bindings, at the 0.4.2 tag:

git clone --depth 1 --branch v0.4.2 https://github.com/olafurjohannsson/kjarni ../kjarni
dotnet run -- "what happens if the model has not been downloaded" ../kjarni/crates/kjarni-ffi/bindings/csharp 5
0.628  Kjarni.Extensions.AI/README.md:26  The model downloads on first use and is cached on disk. After that, nothing touches the network.
0.619  examples/ContentApi/README.md:11  Models download automatically on first run (~850MB total, cached after that).
0.556  Kjarni/Native.cs:22  /// <summary>The model could not be downloaded or loaded.</summary>
0.515  Kjarni/README.md:12  download on first use and everything after that works offline.
0.487  Kjarni/README.md:202  Sizes are what the model occupies on disk after download.
3706 lines in 7.3 s

grep -ri "not been downloaded" over the same folder finds 0 lines. So does grep -ri "graphics card", while the semantic version finds the GPU code:

dotnet run -- "run on the graphics card" ../kjarni/crates/kjarni-ffi/bindings/csharp 5
0.545  Kjarni.Tests/GpuTests.cs:10  /// Skip gracefully if no GPU is present.
0.543  Kjarni/Native.cs:53  Gpu = 1,
0.530  Kjarni.Tests/GpuTests.cs:9  /// GPU inference tests. These require an available GPU.
0.526  Kjarni.Tests/GpuTests.cs:29  Skip.If(true, "No GPU available");
0.526  Kjarni.Tests/GpuTests.cs:150  Skip.If(true, "No GPU available");

Point it at your own notes, a docs folder or a codebase: dotnet run -- "your question" ~/notes.

How it works

  1. Collect lines. Walk the folder, keep text files, skip bin, obj, .git and the like, and keep every line of three words or more with its file and line number.
  2. Embed. EncodeBatch turns 256 lines at a time into 384-number vectors. Lines that mean similar things get vectors that point in similar directions, whatever the wording.
  3. Rank. Embed the query the same way, score each line by cosine similarity, print the best.

The three-word rule matters more than it looks. Before it, using System.Threading; ranked in the top five for the threads question, and _gpu?.Dispose(); led the results for "release unmanaged memory". Short lines have little meaning to compare, so they land close to many queries at once.

What to expect from the scores

The scores are cosine similarities. Good hits here sit between 0.45 and 0.65, and there is no fixed number that means "relevant". Read the order, not the value, and show several results.

Comments and prose match best, because the model was trained on sentences. Code matches when it says something in words: Skip.If(true, "No GPU available") ranks for the graphics card question because of its string, not its logic.

Where it stops being the right tool

  • It embeds every line on every run. 3,706 lines took 7.3 s on an Intel i7-13700, including loading the model. That is fine for a docs folder or a small project, and slow for a large repository. To search a big folder many times, store the vectors: Kjarni's Indexer builds an index on disk once and searches it in milliseconds, shown in Document Search in C#.
  • One line is a small window. An answer that spans a paragraph shows up as its best line. For prose, searching paragraphs or chunks of a few lines works better.
  • English. all-MiniLM-L6-v2 was trained on English. For other languages, use a multilingual model such as bge-m3: new Embedder("bge-m3").
  • It does not replace grep. When you know the exact name, grep is instant and exact. Use this when you know what you mean but not how it was written.

FAQ

Is there a semantic grep for C# or .NET?

This post builds one. The only dependency is the Kjarni NuGet package, which runs the embedding model inside your process, so there is no Python, no ONNX export and no server.

Does it need a GPU or an internet connection?

No GPU: it runs on the CPU. The internet is needed once, to download the model.

Which platforms does it run on?

Windows x64, Linux x64 and arm64, and macOS on Apple Silicon. The NuGet package includes the native library for each.

Getting it

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

Kjarni runs the same engine on every platform: