Full-Text Search
Full-text search finds documents by relevance, not exact substring — tokenized, ranked matching over one or more string properties. The library exposes a single FullTextSearch API that runs on each provider’s native full-text engine: FTS5 on SQLite, a generated tsvector + GIN index on PostgreSQL, a FULLTEXT index on MySQL, Oracle Text (CTXSYS.CONTEXT), a Full-Text Index on SQL Server, the fts extension on DuckDB, full-text search on Cosmos DB, and a $text index on MongoDB. LiteDB and IndexedDB have no native engine, so they fall back to an in-memory TF-IDF scan — the API works everywhere.
The shape mirrors vector search: register the searchable property/properties with MapFullTextProperty<T>(...), then query with store.FullTextSearch<T>("some text"). Results come back ordered by relevance descending, each with a Score.
Declaring a full-text property
Section titled “Declaring a full-text property”Full-text is declarative and up-front: the index is created for you when the store initializes the type, so a type must be mapped before it can be searched — there is no ad-hoc full-text over an arbitrary property (unlike .Where(x => x.Body.Contains(...)), which works on any field). This is what lets the library create the underlying FTS index, generated/computed column, or $text index on your behalf — you never write the DDL.
services.AddDocumentStore(opts =>{ opts.DatabaseProvider = new SqliteDatabaseProvider("Data Source=app.db");
// single property opts.MapFullTextProperty<Article>(a => a.Body);
// …or several fields combined into one index opts.MapFullTextProperty<Article>([a => a.Title, a => a.Body]);});It indexes the text value extracted at the property’s JSON path, not the raw JSON document. For a collection of strings or a custom projection, use the AOT-safe overload:
opts.MapFullTextProperty<Article>( propertyNames: ["tags"], textSelector: a => a.Tags, // IEnumerable<string> language: FullTextLanguage.English);FullTextLanguage
Section titled “FullTextLanguage”Controls stemming / stop-words where the backend supports it (PostgreSQL regconfig, Oracle lexer, DuckDB stemmer, the in-memory fallback). Backends without per-language support ignore everything but the distinction between Simple (no stemming) and a stemmed language.
public enum FullTextLanguage{ Simple, English, Spanish, French, German, Italian, Portuguese, Dutch, Russian}Searching
Section titled “Searching”FullTextSearch is a terminal operation that returns up to maxResults documents ordered by relevance descending. Terms are OR-combined (a document matches if it contains any term); the exact operator grammar is provider-specific.
var hits = await store.FullTextSearch<Article>("orleans persistence", maxResults: 20);
foreach (var hit in hits) Console.WriteLine($"{hit.Score:F3} {hit.Document.Title}");FullTextResult
Section titled “FullTextResult”public class FullTextResult<T> where T : class{ public required T Document { get; init; } public double Score { get; init; } // higher = more relevant}Score is normalized so higher always means a better match, but its absolute scale is provider-specific (BM25 on SQLite/DuckDB, ts_rank on PostgreSQL, MATCH … AGAINST on MySQL, textScore on MongoDB, the in-memory TF-IDF score, and a positional score on Cosmos, whose FullTextScore cannot be projected). Compare scores only within one result set, never across providers.
Pre-filtering
Section titled “Pre-filtering”Pass a predicate to narrow results alongside the full-text match — for tenant or category scoping. It is pushed into the query where the provider supports it.
var techHits = await store.FullTextSearch<Article>( "orleans", filter: a => a.Category == "tech");Fluent form
Section titled “Fluent form”FullTextMatch on the query builder bridges to the same engine and folds the query’s current Where predicates into the pre-filter. OrderBy, GroupBy, and Paginate are ignored — maxResults controls the count and results come back ranked.
var hits = await store.Query<Article>() .Where(a => a.Category == "tech") .FullTextMatch("orleans", maxResults: 10);Composable queries with Lucene syntax
Section titled “Composable queries with Lucene syntax”FullTextSearch/FullTextMatch are terminal ranked calls. When you instead want full-text as a composable predicate — AND/OR’d with ordinary filters, paged, and sorted alongside everything else — use DocumentFunctions.LuceneMatch(field, luceneQuery) in a Where and DocumentFunctions.LuceneScore(field, luceneQuery) in an OrderBy/projection. They translate a Lucene query string to the provider’s native full-text engine over the same MapFullTextProperty index (so the type must still be mapped). The field argument identifies the mapping — pass a mapped property; the search spans the type’s whole combined index.
// filter + relevance sort + paging in one server-side queryvar hits = await store.Query<Article>() .Where(a => a.Category == "tech" && DocumentFunctions.LuceneMatch(a.Body, "orleans AND grain NOT deprecated")) .OrderByDescending(a => DocumentFunctions.LuceneScore(a.Body, "orleans grain")) .Skip(0).Take(20) .ToList();The same functions are available in the string-expression grammar (AOT-safe), lowering to the identical query:
store.Query<Article>().Where("lucenematch(body, 'title:quick AND brown~')");store.Query<Article>().Where($"lucenematch(body, {userQuery})") .OrderBy("lucenescore(body, 'orleans') desc");Lucene grammar
Section titled “Lucene grammar”| Feature | Example |
|---|---|
| term | orleans |
| phrase | "virtual actor" |
| boolean | orleans AND grain, a OR b, a NOT b, +must -mustnot (also && || !) |
| grouping | (quick OR slow) AND fox |
| prefix | distrib* |
| fuzzy | orlean~ / orlean~1 |
| proximity | "quick fox"~5 |
| boost | orleans^2 (affects ranking only) |
Range queries ([a TO b]) and non-trailing wildcards (te*t) are rejected with a clear error.
Provider support for composable queries
Section titled “Provider support for composable queries”Composable queries reuse the same per-type index as FullTextSearch, but each engine expresses a different subset of the grammar. Operators a backend can’t express throw NotSupportedException — they never silently degrade to something else.
| Provider | LuceneMatch | LuceneScore | Advanced operators |
|---|---|---|---|
| SQLite (FTS5) | ✅ | ✅ (bm25) | prefix, proximity (NEAR) |
PostgreSQL (tsquery) | ✅ | ✅ (ts_rank) | prefix |
| MySQL (BOOLEAN MODE) | ✅ | ✅ | prefix, proximity |
SQL Server (CONTAINS) | ✅ | ✅ (CONTAINSTABLE) | prefix, proximity (NEAR) |
Oracle Text (CONTAINS) | ✅ | ❌ — use FullTextSearch | prefix, fuzzy, proximity |
| LiteDB / IndexedDB (in-memory) | ✅ | ✅ | all (incl. fuzzy) |
| DuckDB / Cosmos DB / MongoDB | ❌ — use FullTextSearch | ❌ | — |
Baseline (terms, phrases, AND/OR/NOT, grouping) works on every provider in the ✅ rows. Notes:
- Oracle —
SCORE(n)requires a co-locatedCONTAINSlabel in the same statement, so a standaloneLuceneScorecan’t be produced; usestore.FullTextSearch<Article>(...)for ranking on Oracle. - DuckDB / Cosmos / MongoDB — the engine can’t express the boolean Lucene grammar inline (DuckDB’s index is a rebuild-on-query snapshot; Cosmos/MongoDB full-text is bag-of-words / single top-level clause). Use the terminal
store.FullTextSearch(...)/FullTextMatch(...)on those providers. - Field-scoped terms (
title:foo) are not supported in v1 on any provider — the index is a single combined field — and throw. Pass the query without a field prefix; it searches all mapped properties.
Provider support
Section titled “Provider support”The index is maintained automatically by the engine — no write-path bookkeeping — so once a type is mapped, Insert/Update/Remove/Clear keep it in sync for free.
| Provider | Engine | Ranking | Notes |
|---|---|---|---|
| SQLite | FTS5 virtual table + triggers | BM25 | Always available. |
| PostgreSQL | generated tsvector column + GIN | ts_rank | Per-language stemming. |
| MySQL | generated column + FULLTEXT (InnoDB) | MATCH … AGAINST | Natural-language mode. |
| Oracle | CTXSYS.CONTEXT (sync on commit) | SCORE() | Requires the Oracle Text option. |
| SQL Server | Full-Text Index + FREETEXTTABLE | RANK | Requires the Full-Text Search feature. |
| DuckDB | fts extension (BM25) | BM25 | Snapshot index — rebuilt before each query. |
| Cosmos DB | full-text policy + FullTextScore | rank order | Newer Cosmos service feature. |
| MongoDB | $text index | textScore | One text index per collection. |
| LiteDB | in-memory TF-IDF | TF-IDF | No native engine — scans the collection. |
| IndexedDB | in-memory TF-IDF | TF-IDF | No native engine — scans the store. |
Limitations
Section titled “Limitations”- Map before you query. A type that hasn’t been mapped with
MapFullTextPropertycannot be full-text searched —FullTextSearch/FullTextMatchthrow. This is by design (it’s how the index gets created). - One mapped type per table/collection is the supported shape on engines that allow only a single full-text index per table (SQL Server, MongoDB).
- Optional features must be installed. Oracle Text and SQL Server Full-Text Search are optional server components; if absent,
FullTextSearchfails at query time. Cosmos full-text requires a service/SDK that supports it. - In-memory fallback (LiteDB, IndexedDB) loads and scans the collection, so it suits modest data sizes — exactly the client-tier scenarios those providers target.