Recursive AI

Full-text search in C#

All oneindex recipes

Mark a class with [FullText], [Keyword] and [Filterable] attributes, Upsert your objects, Commit, and call Search(text) with whatever the user typed. OneIndex ranks with BM25F, forgives typos, and returns highlights and facet counts with the hits. It runs in your process: no server, no native library.

Terminal
dotnet new console -o FullTextSearch && cd FullTextSearch
dotnet add package RecursiveAI.OneIndex --version 0.4.0-preview.2

Put this in Program.cs and run dotnet run:

C#
using OneIndex;
 
using var index = SearchIndex.Open<Article>("search-data");   // a directory
index.Upsert(
[
    new() { Id = "1", Section = "ops", Year = 2024,
            Title = "Getting started with Kubernetes",
            Body = "Deploy your first container and scale it." },
    new() { Id = "2", Section = "security", Year = 2025,
            Title = "Container security checklist",
            Body = "Scan images, drop root and pin base images." },
    new() { Id = "3", Section = "data", Year = 2023,
            Title = "Scaling PostgreSQL reads",
            Body = "Read replicas and connection pooling for busy apps." },
    new() { Id = "4", Section = "ops", Year = 2025,
            Title = "Kubernetes networking explained",
            Body = "Services, ingress and network policies for containers." },
]);
index.Commit();                    // durable; searches see the last commit
 
// What a user types: words, "phrases", -exclusions; a typo is forgiven.
foreach (var hit in index.Search("kubernets containers -security").Take(5))
    Console.WriteLine($"{hit.Id}  {hit.Score:F2}  {hit.Document.Title}");
 
Console.WriteLine();
var results = index.Search("container")
                   .Where(a => a.Year >= 2024)      // a filter, translated, never run
                   .Facets(a => a.Section)
                   .Execute();
Console.WriteLine($"{results.TotalHits} hits since 2024");
foreach (var hit in results)       // highlights of the fields that matched
    foreach (var (field, fragments) in hit.Highlights)
        Console.WriteLine($"{hit.Id}  {field}: {fragments[0].Marked("[", "]")}");
foreach (var v in results.Facets["Section"].Values)
    Console.WriteLine($"  {v.Value}: {v.Count}");
 
public class Article
{
    [SearchId] public string Id { get; set; } = "";
    [FullText(Boost = 2)] public string Title { get; set; } = "";
    [FullText(Language = "en")] public string Body { get; set; } = "";
    [Keyword, Facet] public string Section { get; set; } = "";
    [Filterable, Sortable] public int Year { get; set; }
}
Text
1  0.43  Getting started with Kubernetes
4  0.41  Kubernetes networking explained
 
3 hits since 2024
2  Title: [Container] security checklist
1  Body: Deploy your first [container] and scale it.
4  Body: Services, ingress and network policies for [containers].
  ops: 2
  security: 1
  • SearchIndex.Open<T>("search-data") creates or opens an index in that directory. A source generator in the package writes the mapping from the attributes at build time, with no reflection, so it works under trimming and NativeAOT. SearchIndex.InMemory<T>() keeps it in memory instead.
  • Search(text) takes user text as typed and never throws on it: words, "quoted phrases", -exclusions and OR. Typo tolerance is on for plain user text, so kubernets matches Kubernetes.
  • [FullText(Boost = 2)] makes a title match count twice a body match; Language = "en" stems English (containers matches container).
  • Where is translated into index filters and never run as C#; a predicate it can’t translate throws and names the part.
  • Commit is atomic: a search sees the last commit, and a commit that returned survives a crash or kill of your process.

Limits

  • A preview (0.4.0-preview.2): APIs and file formats may still change between previews.
  • TotalHits is a lower bound unless you add CountExactly().
  • One writer per index directory: a second writer, even from another process, gets IndexLockedException. Readers in other processes are fine.
  • A field must be declared before you search it; [Dynamic] JSON fields cover open-ended data.
  • Run it once per directory as written: a second run upserts the same ids again, which replaces them, so the output is the same.

Getting started · User text · Mapping · All recipes