---
title: "Full-text search in C#"
url: https://recursiveai.org/oneindex/cookbook/full-text-search-csharp/
description: "Add full-text search to a .NET app in C# with OneIndex: BM25 ranking, phrases, typo tolerance, filters, facets and highlights, in-process with no server and no native code. A complete program and its real output."
version: "0.4.0-preview.2"
---

# Full-text search in C#

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.

```shell
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`:

```csharp
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](https://recursiveai.org/oneindex/docs/getting-started/) · [User text](https://recursiveai.org/oneindex/docs/user-text/) · [Mapping](https://recursiveai.org/oneindex/docs/mapping/) · [All recipes](https://recursiveai.org/oneindex/cookbook/)
