Recursive AI

Hybrid search with reranking in C#

All oneindex recipes

Give a class one [Vector(From = ...)] property and an embedder, and Search(text) becomes hybrid: a BM25 keyword list and a vector list, fused by reciprocal rank fusion. Add .Rerank() and a cross-encoder reads the query beside each of the best hits and orders them again. Both models are bundled and run in your process.

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

RecursiveAI.OneIndex.Models carries the embedder and reranker weights inside the package (a 134 MB download from NuGet, once).

Put this in Program.cs and run dotnet run:

C#
using OneIndex;
using OneIndex.Models;
 
BgeSmallEmbedder.UseAsDefault();                   // embeds documents and queries
MiniLmReranker.UseAsDefault();                     // cross-encoder for .Rerank()
 
using var index = SearchIndex.Open<Doc>("kb-index");
index.Upsert(
[
    new() { Id = "1",
            Text = "error CS0246: the type or namespace name could not be found" },
    new() { Id = "2",
            Text = "Add a package reference so the compiler can resolve a type." },
    new() { Id = "3",
            Text = "Namespaces group related types and avoid name clashes." },
    new() { Id = "4",
            Text = "A missing using directive leaves a class name unresolved." },
    new() { Id = "5",
            Text = "The project file lists the NuGet packages an app uses." },
    new() { Id = "6",
            Text = "Fix a build that cannot find a type: check usings and packages." },
]);
index.Commit();
 
string q = "compiler says type not found";
Show("Keywords only (BM25)", index.Search(q).Hybrid(0));
Show("Hybrid (default)", index.Search(q));
Show("Hybrid, reranked", index.Search(q).Rerank(window: 20));
 
void Show(string title, SearchQuery<Doc> search)
{
    Console.WriteLine(title);
    var hits = search.Take(3).ToList();
    if (hits.Count == 0) Console.WriteLine("  (no hits)");
    foreach (var hit in hits)
        Console.WriteLine($"  {hit.Id}  {hit.Score,7:F3}  {hit.Document.Text}");
}
 
public class Doc
{
    [SearchId] public string Id { get; set; } = "";
    [FullText(Language = "en")] public string Text { get; set; } = "";
    [Vector(From = nameof(Text))] public float[]? Embedding { get; set; }
}
Text
Keywords only (BM25)
  (no hits)
Hybrid (default)
  6    0.136  Fix a build that cannot find a type: check usings and packages.
  1    0.125  error CS0246: the type or namespace name could not be found
  2    0.115  Add a package reference so the compiler can resolve a type.
Hybrid, reranked
  1    0.652  error CS0246: the type or namespace name could not be found
  2   -2.409  Add a package reference so the compiler can resolve a type.
  6   -3.335  Fix a build that cannot find a type: check usings and packages.
  • The keyword list is empty: in user text every word not joined by OR must match, and no document contains “says”. The vector list still finds the documents that mean the same thing, so the hybrid search answers.
  • Search(text) is hybrid automatically when the index has an embedder and the class has exactly one [Vector(From = ...)] property. Hybrid(0) turns it off for one query; Hybrid(0.5) weighs the arms equally.
  • The defaults are reciprocal rank fusion with SemanticRatio = 0.75 (the vector list leads), RankConstant = 10 and a window of 100 hits per list. Filters from Where(...) apply inside both lists, and a -word in the user’s text removes documents from both.
  • Scores are on different scales: BM25, then the fused rank score, then the cross-encoder’s score, which has no fixed range. Compare positions, not numbers, across the three.
  • Rerank(window: 20) rescores the best 20 hits of the first stage with the cross-encoder (cross-encoder/ms-marco-MiniLM-L6-v2) and orders them by its score. Each hit keeps the first-stage score in FirstStageScore.

How much it helps

From the project’s own relevance measurements (nDCG@10 on BEIR test sets, the bundled int8 bge-small-en-v1.5): hybrid at its defaults averages 0.5168 over SciFact, ArguAna, SCIDOCS and NQ, against 0.5058 for the vector arm alone and 0.4049 for BM25, and is above both arms on every set. The defaults were tuned on SciFact, ArguAna and SCIDOCS, and NQ was held out. Your data may prefer another ratio: set it per query.

Limits

  • A preview (0.4.0-preview.2): APIs and file formats may still change between previews.
  • The reranker costs one model pass per hit in the window, on the CPU: keep the window small (tens, not thousands).
  • TotalHits of a hybrid search counts the documents in either list (at most twice the window); a page deeper than that is empty unless you raise the window.
  • The bundled models are English. Licenses: bge-small-en-v1.5 is MIT; cross-encoder/ms-marco-MiniLM-L6-v2 is Apache-2.0 and was trained on MS MARCO, whose terms are the data owner’s and apply to that data, which is not included.

Hybrid search · Reranking · Models · All recipes