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.
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:
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; } }
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",-exclusionsandOR. Typo tolerance is on for plain user text, sokubernetsmatchesKubernetes.[FullText(Boost = 2)]makes a title match count twice a body match;Language = "en"stems English (containersmatchescontainer).Whereis translated into index filters and never run as C#; a predicate it can’t translate throws and names the part.Commitis 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.
TotalHitsis a lower bound unless you addCountExactly().- 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.