Falqobit.Fahras
0.1.1
dotnet add package Falqobit.Fahras --version 0.1.1
NuGet\Install-Package Falqobit.Fahras -Version 0.1.1
<PackageReference Include="Falqobit.Fahras" Version="0.1.1" />
<PackageVersion Include="Falqobit.Fahras" Version="0.1.1" />
<PackageReference Include="Falqobit.Fahras" />
paket add Falqobit.Fahras --version 0.1.1
#r "nuget: Falqobit.Fahras, 0.1.1"
#:package Falqobit.Fahras@0.1.1
#addin nuget:?package=Falqobit.Fahras&version=0.1.1
#tool nuget:?package=Falqobit.Fahras&version=0.1.1
Falqobit.Fahras
Arabic full-text search for .NET. A persistent, on-disk index tuned for the ways Arabic text actually gets typed: with or without diacritics, with hamza and alef spelled inconsistently, with spaces in the wrong places or missing entirely.
Fahras (فهرس — "index") is lexical, not semantic. There are no embeddings and no vector search. Recall comes from indexing every text field five different ways and OR-ing boosted queries across all of them.
index.Add(users);
index.Search("محمد إبراهيم"); // also matches مُحَمَّد إِبْرَاهِيم, محمد ابراهيم, محمدابراهيم
Install
dotnet add package Falqobit.Fahras
Targets net10.0.
Getting started
With dependency injection
services.AddFahras<AdUser>("ad_users_index", opt => opt
.WithPlainField(u => u.Username)
.WithFields(u => u.FullName, u => u.Department)
.WithIndexDir(@"E:\indexes"));
Then inject IFahras<AdUser> anywhere. AddFahras lives in the Microsoft.Extensions.DependencyInjection namespace, so no extra using is needed. One index per type T — registering a second for the same T throws at startup.
IFahras<T> carries the operations only — searching, reading and writing. Field registration is not on it, because an index is configured once at construction; nor is IDisposable, because the container owns the singleton's lifetime. Depend on it rather than on Fahras<T> so your own services can be tested against a substitute — though note that a substitute drops the matching behaviour that is the point of the index, so tests worth writing about recall still want a real index over a temp directory.
Standalone
using var index = Fahras<AdUser>.Create("ad_users_index")
.WithPlainField(u => u.Username)
.WithFields(u => u.FullName, u => u.Department)
.WithIndexDir(@"E:\indexes");
Fields
| Method | Behaviour |
|---|---|
WithField / WithFields |
Analyzed Arabic text. Indexed five ways: raw, normalized, stemmed, space-shuffled, space-stripped. |
WithPlainField |
Exact-match only. Only these fields are legal Update keys. |
WithIndexDir(dir) resolves to dir/indexes/{name}. Omit it and the index lands in {CurrentDirectory}/indexes/{name}, on either construction path.
T is stored as JSON and read back with System.Text.Json, so T must round-trip through STJ — no constructor-only types without matching JSON support.
Searching
List<SearchResult<AdUser>> hits = index.Search("محمد");
foreach (var hit in hits)
{
Console.WriteLine($"{hit.Id} {hit.Item.FullName} {hit.Score}");
}
With no options, Search returns at most 50 results using the Basic and Normalized strategies. To choose your own:
var options = SearchOptions.Create()
.WithMaxResults(20)
.WithMinScore(0.4f)
.WithNormalizedQuery()
.WithNormalizedStemmedQuery()
.WithNormalizedNoSpacesQuery();
var hits = index.Search("محمدابراهيم", options);
Each strategy is a separate query, OR-ed into one boolean query with its own boost:
| Strategy | Default boost | Matches |
|---|---|---|
Basic |
1.5 | The text as indexed. |
Normalized |
2.0 | Diacritics and punctuation stripped; hamza, alef, teh marbuta and yeh folded. |
NormalizedStemmed |
1.5 | Normalized, then stemmed. |
NormalizedSpaces |
0.5 | Long unspaced runs split into chunks. |
NormalizedNoSpaces |
0.5 | All spaces removed. |
Fuzzy |
0.3 | Edit-distance match on the raw text. |
NormalizedFuzzy |
0.4 | Edit-distance match on the normalized text. |
Scores are normalized against the top hit, so Score is relative within one result set and not comparable across searches. Results scoring at or below MinScore (default 0) are dropped.
Normalization is also available directly, via the static ArabicText class: NormalizeArabicText, RemoveNoise, Stem, InsertSpaces.
Writing
index.Add(users); // append; each item gets a fresh id
index.Update(u => u.Username, users); // upsert keyed on a WithPlainField field
index.Delete(docIds);
index.DeleteAll(); // empty the index, keep the directory
index.DeleteIndexDir(); // delete the directory outright
Update derives a stable document id from the key field, so SearchResult.Id for a given item survives re-indexing. Add mints a new id every time.
Concurrency
Fahras is built for many reader processes and an occasional writer sharing one directory on disk — for example a web API searching an index that a background service rewrites on a schedule.
- Reads take no lock. Any number of threads or processes may search the same directory at the same time. Searching a directory that has no index yet returns an empty result rather than throwing, and
Countis0. - Each write call is one locked, committed batch. Exactly one writer is permitted per directory, enforced machine-wide by a
write.lockfile. Every write opens a writer, runs the batch, and commits before returning. - A write colliding with one already in progress throws
LockObtainFailedExceptionafter the write-lock timeout (1 second by default, seeWithWriteLockTimeout).
Two consequences worth designing around:
- Pass whole batches. Every call is a separate lock acquisition and commit, so
Updatein a loop is dramatically slower than handing it the whole sequence at once. - Readers only observe committed state. There is no read-your-own-uncommitted-writes, and a batch that throws partway rolls back entirely — no partial batch is ever visible.
Building
dotnet build Falqobit.Fahras.slnx
dotnet test Falqobit.Fahras.slnx
Documentation
Full documentation is at https://fahras.falqobit.sa/docs/.
Licence
Elastic License 2.0 — source-available, not open source.
Free to use, in production, in commercial and closed-source products, whatever the size of your company. No thresholds, no registration, no licence key, no telemetry.
The one restriction: you may not offer Fahras to third parties as a hosted or managed search service.
If you are not sure whether your use case is covered, ask — https://fahras.falqobit.sa. Hosted Fahras, support and integration are also available.
Copyright © Falqobit.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Lucene.Net (>= 4.8.0-beta00017)
- Lucene.Net.Analysis.Common (>= 4.8.0-beta00017)
- Lucene.Net.QueryParser (>= 4.8.0-beta00017)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Options (>= 10.0.10)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.1 | 91 | 7/30/2026 |
| 0.1.0 | 97 | 7/30/2026 |
| 0.1.0-alpha.1 | 53 | 7/29/2026 |