BM25 relevance.
On the same write.
Every posting lands inside the same atomic write as the row it indexes — no separate search cluster to keep in sync. BM25, boolean and phrase modes, Lucene-default scoring, and stemming + lemmatization across 18 languages.
Text in. Tokens. Ranked hits out.
Three stages between a raw field and a scored result. Each stage is configured per field, so a product title and a support transcript can run different analyzers on the same table.
UAX #29 word boundaries — correct across Latin, Cyrillic, CJK, Arabic and Hindi. An ICU + geo tokenizer handles mixed-script and location-aware text in the same pass.
Snowball stemming in 18 languages, or dictionary lemmatization in 9 for the canonical lemma rather than a stem. Diacritics fold and stopword removal are per-field opt-ins.
BM25 with the Lucene defaults — k1=1.2 for term-frequency saturation, b=0.75 for length normalization. Hits come back best-first, scored.
One line each. Four different answers.
Same field, same index — the mode parameter
decides whether you get relevance, set membership, or exact sequence.
?q=carbon+marathon&mode=bm25&k=10
→ [{doc_id, score}] — ranked best-first Relevance-ranked retrieval. Lucene-default k1=1.2, b=0.75.
carbon AND plate NOT trail
→ every doc satisfying the expression AND / OR / NOT set matching. Exact membership, no scoring bias.
“carbon plate”
→ docs containing the exact ordered sequence Position-list intersection — adjacent terms, in order, or no match.
title + description, independently
→ one combined result set Index and query any field on its own analyzer, combine in one request.
When to reach for BM25 versus vector or hybrid retrieval — and how the ranking formula actually works — is covered in the architecture guide.
Word boundaries are not universal. The tokenizer knows.
CJK text has no spaces. Arabic joins letters. Hindi stacks conjuncts. A whitespace splitter gets all three wrong — so the tokenizer implements UAX #29, the Unicode segmentation standard, and boundaries come out right per script.
Above the tokenizer: Snowball stemming in 18 languages, and for 9 of them a dictionary step that resolves each word to its canonical lemma instead of a truncated stem. Both are per-field choices, not instance-wide settings.
The posting commits with the row. Zero lag, by construction.
In a bolted-on search stack, the row lands in the database and a worker ships it to the search cluster later. Between those two events, search lies: the row exists but no query can find it, or the doc is findable but already deleted.
Here the postings land in the same atomic write as the row. Searchable the instant the write commits — 0 index-vs-row lag, no reindex jobs, no sidecar search engine, nothing to reconcile.
> GET /v1/tenants/:t/fts/shop.products/description
?q=carbon+marathon&mode=bm25&k=10
# best-first, one row per hit
[
{ "doc_id": "sku-8842", "score": 7.41 },
{ "doc_id": "sku-1207", "score": 5.96 }
] Point your Elasticsearch client at it. Nothing to rewrite.
OriginChain answers the Elasticsearch REST API and Query DSL. The official @elastic client connects, clears its product check, and runs the same index → search → aggregate → bulk → delete lifecycle it runs against a real cluster. Point it at a new endpoint and existing app code and dashboards keep working — you change the URL, not the queries.
No search cluster to stand up, secure, or keep in sync — and because the postings commit with the row, there is no refresh interval to wait on. Row-level security and column masking apply to the search itself, not just to the documents it returns.
// same client, same DSL — just a new endpoint
const es = new Client({ node: 'https://your-instance' })
await es.search({
index: 'shop.products',
query: { match: { description: 'carbon marathon' } },
aggs: { by_brand: { terms: { field: 'brand' } } }
})