Recursive AI

Migrate from Lucene.NET to OneIndex

Considering a Lucene.NET alternative? Map the APIs, run both C# examples, rebuild from source, and check query behavior before switching to OneIndex.

If you want a Lucene.NET alternative that stays inside your .NET application, OneIndex gives you a typed document model, full-text search, and vector and hybrid search in one embedded library. To migrate from Lucene.NET, map your fields, rebuild the index from the original records, and change your search calls. OneIndex cannot open a Lucene index and has no compatibility layer; this is an application migration, not a package rename.

This guide runs on .NET 10 with Lucene.NET 4.8.0-beta00017, a prerelease, and OneIndex 0.4.0-preview.2, a preview. The Lucene.NET version is a fixed test target, not a claim that it is the latest release. OneIndex’s APIs and file formats may change between previews, as its release README states. Both complete programs below were run against these NuGet packages.

When OneIndex fits the move

Consider OneIndex when you want to keep search in-process while replacing field-by-field document construction with an attributed C# class. Its typed API puts writes, commits, searches, filters, facets, and highlights behind SearchIndex<T>. Vector and hybrid search are available in the same library; see the released architecture guide.

Start with an inventory if your application depends on custom Lucene analyzers, query classes, or collectors. Similar names do not promise the same behavior. OneIndex’s migration reference lists mappings and gaps, including no direct wildcard query, span-query family, block joins, or payloads in this preview. If any of those are central to your search, resolve that dependency before choosing a cutover date.

This article is about leaving Lucene.NET. If you only need to update an older Lucene.NET application to the 4.8 API, use Apache’s 4.8 migration guide instead.

Map the API, not the index files

These mappings apply to the tested Lucene.NET beta and OneIndex preview. They describe corresponding jobs, not interchangeable types.

JobLucene.NET 4.8.0-beta00017OneIndex 0.4.0-preview.2
Open and search an indexFSDirectory, writer, reader, searcher, and parser (example)SearchIndex.Open<T>(path) (example)
Declare fieldsDocument with stored or unstored fields (example)Attributes such as [SearchId], [FullText], and [Keyword]; stored source by default (mapping)
Replace by stable IDUpdateDocument(new Term(...), document) (writer API)Upsert(document) using [SearchId] (mapping)
Delete by IDDeleteDocuments(new Term(...)) (writer API)Delete(id), then commit (delete behavior)
Parse query textQueryParser.Parse with Lucene syntax (parser API)Search for forgiving user text, or SearchStrict for field syntax and parse errors (query mapping)

Use your database or files as the source for the new index. Do not point OneIndex at the Lucene directory. Keep separate directories so you can build and validate the replacement without changing the running index.

Before: the Lucene.NET program

This example indexes two products, replaces product p1, deletes p2, commits, and closes the writer. It then opens a reader and searches the committed index. The ID is a whole-value StringField; the title is an analyzed TextField. Both are stored so the result can return the original values.

Create a console project named LuceneMigrationExample, then install all three Lucene.NET packages at the same version:

Shell.NET 10
dotnet new console -n LuceneMigrationExample -f net10.0
cd LuceneMigrationExample
dotnet add package Lucene.Net --version 4.8.0-beta00017
dotnet add package Lucene.Net.Analysis.Common --version 4.8.0-beta00017
dotnet add package Lucene.Net.QueryParser --version 4.8.0-beta00017

Replace Program.cs with this complete program, then run dotnet run. The structure follows Apache’s indexing and searching example; the replace and delete calls are documented on IndexWriter.

Program.cs · Lucene.NET.NET 10
using System;
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Documents;
using Lucene.Net.Index;
using Lucene.Net.QueryParsers.Classic;
using Lucene.Net.Search;
using Lucene.Net.Store;
using Lucene.Net.Util;
 
using var directory = FSDirectory.Open("lucene-data");
using var analyzer = new StandardAnalyzer(LuceneVersion.LUCENE_48);
var config = new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer);
using (var writer = new IndexWriter(directory, config))
{
    void Upsert(string id, string title)
    {
        var document = new Document
        {
            new StringField("id", id, Field.Store.YES),
            new TextField("title", title, Field.Store.YES)
        };
        writer.UpdateDocument(new Term("id", id), document);
    }
 
    Upsert("p1", "Wired headphones");
    Upsert("p2", "Wireless speaker");
    Upsert("p1", "Wireless headphones");
    writer.DeleteDocuments(new Term("id", "p2"));
    writer.Commit();
}
 
using var reader = DirectoryReader.Open(directory);
var searcher = new IndexSearcher(reader);
var parser = new QueryParser(LuceneVersion.LUCENE_48, "title", analyzer);
var results = searcher.Search(parser.Parse("wireless"), 10);
foreach (var hit in results.ScoreDocs)
{
    var document = searcher.Doc(hit.Doc);
    Console.WriteLine($"{document.Get("id")}: {document.Get("title")}");
}

After: the OneIndex program

The stable ID and original title stay the same. The field declarations move onto a class: [SearchId] identifies the replacement key, and [FullText] makes the title searchable. Upsert replaces by that key; Delete removes by it. Closing and reopening verifies the committed index rather than a writer’s uncommitted view.

Create a separate console project. Use an application name such as OneIndexMigrationExample, not OneIndex, to avoid an assembly-name collision with the library.

Shell.NET 10
dotnet new console -n OneIndexMigrationExample -f net10.0
cd OneIndexMigrationExample
dotnet add package RecursiveAI.OneIndex --version 0.4.0-preview.2

Replace Program.cs with this program and run dotnet run:

Program.cs · OneIndex preview.NET 10
using System;
using OneIndex;
 
using (var index = SearchIndex.Open<Product>("oneindex-data"))
{
    index.Upsert(new Product { Id = "p1", Title = "Wired headphones" });
    index.Upsert(new Product { Id = "p2", Title = "Wireless speaker" });
    index.Upsert(new Product { Id = "p1", Title = "Wireless headphones" });
    index.Delete("p2");
    index.Commit();
}
 
using var reopened = SearchIndex.Open<Product>("oneindex-data");
var results = reopened.Search("wireless").Take(10).Execute();
foreach (var hit in results)
    Console.WriteLine($"{hit.Id}: {hit.Document.Title}");
 
public class Product
{
    [SearchId] public string Id { get; set; } = "";
    [FullText] public string Title { get; set; } = "";
}

Both programs print exactly:

Output · both programs.NET 10
p1: Wireless headphones

The test checks replacement, deletion, stored result text, and reopening after a commit. It does not establish matching token streams, scores, or rankings for a real corpus. For an introduction to the typed API, see getting started with OneIndex.

Check queries and ranking before switching

Choose a query entry point deliberately

Do not pass every existing Lucene query string straight into Search. Apache’s QueryParser documentation defines its query language. OneIndex’s user-text parser is forgiving: malformed input is treated as text rather than rejected. Its strict parser supports a documented field syntax with positioned errors. Neither choice is a promise of Lucene query-string compatibility.

Classify callers first: a search box usually wants forgiving text; a saved advanced query needs a deliberate syntax translation. Field names also change if you move from lowercase Lucene names to C# property names. Review saved filters, field-qualified queries, and any API that accepts raw query strings.

Test the analyzer, not just its name

The sample uses Lucene.NET’s StandardAnalyzer and OneIndex’s default standard analyzer. Lucene.NET’s analyzer includes a stop-word filter, documented on StandardAnalyzer. OneIndex’s analysis guide describes its standard analyzer without stop-word removal. Test stop words, punctuation, accents, identifiers, and language-specific stemming from your own data. Set a language or register a custom analyzer where your schema needs one.

Retune score rules instead of copying numbers

Lucene.NET 4.8’s DefaultSimilarity uses the classic TF-IDF model. OneIndex uses BM25F. If your application already selects a different Lucene similarity, record that too. Do not carry over score thresholds or assume equal boost values mean equal ranking. Compare useful results by stable document ID, and judge them against expected answers.

Also check totals: OneIndex documents TotalHits as a lower bound unless you request CountExactly(). A page count or export job that needs an exact number should ask for it explicitly; see the API notes.

Rebuild and cut over without losing changes

  1. Record the current contract. List indexed fields, stored fields, analyzers, sort rules, filters, query features, and update paths. Save representative queries and their expected IDs, including empty results and deleted records.
  2. Build a new index from a known source snapshot. Map each record to the new type with the same stable ID. Batch records through Upsert and commit. Keep a source checkpoint so you know which later changes still need applying.
  3. Catch up writes and deletes. Replay changes after the checkpoint, in order, including deletions. A change log or a short write pause can close the gap. A bulk copy alone is not a complete migration while the source is still changing.
  4. Compare behavior. Check live record counts, stored values, expected result IDs, filters, sort order, paging, and malformed input. Compare ranked results against your acceptance cases, not raw scores from the two engines.
  5. Switch reads, then keep a rollback path. Choose a final checkpoint, apply remaining changes, commit, and route searches to OneIndex. Keep the Lucene index and its update path available during your rollback window. Otherwise, switching back can serve stale data.

If only the Lucene index remains, export with Lucene.NET first. Recover original values from stored fields, not analyzed tokens. Use stable application IDs and skip deleted documents; do not treat internal Lucene document numbers as your migration keys. Fields that were never stored need another source. OneIndex’s existing-index guidance explains the rebuild requirement.

The goal is a new search implementation with a tested contract, not identical files or identical scores. Start with the two programs above, map your real schema with the OneIndex migration reference, and keep the first rebuild separate from the index serving your users.