Full-text search platform in pure MoonBit: inverted index, BM25, exact and sloppy phrases, dictionary-free Chinese tokenization, fuzzy search, highlighting, facets, numeric aggregations, HNSW vector search with hybrid fusion, an HTTP service, and a distributed cluster with consistent hashing, scatter-gather on global statistics, write replication, failover, cross-shard aggregations and hybrid retrieval that equal a single node, online rebalancing, replica repair and several coordinators. One snapshot format across native/WASM/JS.

flowchart LR
T[text] --> A[Analyzer<br/>Latin words + CJK bigrams]
A --> I[Inverted index<br/>field → term → postings<br/>doc, freq, positions]
Q[query string] --> P[Lexer → parser<br/>term / phrase / prefix / AND OR NOT]
P --> E[Evaluator<br/>ascending doc arrays<br/>merge-based set ops]
I --> E
E --> K[Top-K selection<br/>score desc, id asc]
K --> H[hits]
I --> S[Snapshot MSK3<br/>CRC-32 frame]
I --> W[WAL<br/>torn-tail recovery]
I --> F[Facets<br/>filter + aggregation]
B[Browser<br/>WasmGC + js-string] -.-> Imoon build server --target native --release
./_build/native/release/build/server/server.exe --addr 127.0.0.1:7700 --assets console
curl -X POST localhost:7700/index/bulk --data-binary @docs.jsonl
curl "localhost:7700/search?q=parser&facets=license"for k in 0 1 2; do
./server.exe --addr 127.0.0.1:$((7801+k)) --shard-port 127.0.0.1:$((7901+k)) &
done
./server.exe --addr 127.0.0.1:7805 --shards "127.0.0.1:7901,127.0.0.1:7902,127.0.0.1:7903"
# ?dfs=true makes the ranking identical to a single node holding the whole corpus
curl "localhost:7805/search?q=parser&dfs=true"tools/demo.sh # starts it and prints the URLs
tools/demo-story.sh # or: the full story as a script (see docs/demo-video.md)./server.exe --addr 127.0.0.1:7805 --shards "…:7920,…:7921,…:7922,…:7923" # after adding the new shard
curl -X POST "localhost:7805/_rebalance?from=3"
curl -X POST "localhost:7805/_repair" # after a member comes back| 3 shards, 3,000 docs | measured |
|---|---|
| mean query p50, 6 shapes | 0.94–1.02× across four runs — the hop costs nothing measurable |
| one forwarded write (1 group, 1 member) | 1.06× |
| sequential writes, 3 groups × 2 members | 1.46×, 530 → 364 docs/s, every write acknowledged by all members |
let index = @moonseek.Index::new([
{ name: "title", weight: 3.0 },
{ name: "body", weight: 1.0 },
])
index.add({
id: "doc1",
fields: [
("title", "全文搜索引擎"),
("body", "倒排索引是搜索引擎的核心,BM25 负责排序。"),
],
})
index.add({
id: "doc2",
fields: [("title", "MoonBit"), ("body", "A fast language for WebAssembly")],
})
let hits = index.search("搜索引擎") // [{ id: "doc1", score: … }]
let hits2 = index.search("title:moonbit") // field-scoped
let hits3 = index.search("\"memory safety\" OR wasm*") // phrase + prefix
// Persist (native)
index.save("index.msk")
let restored = @moonseek.Index::load("index.msk")
// Or portable bytes (works on every backend)
let image : Bytes = index.to_bytes()
let restored2 = @moonseek.Index::from_bytes(image)moon run cmd/main -- index ./docs --index index.msk # index .txt/.md files
moon run cmd/main -- search "搜索引擎" --index index.msk
moon run cmd/main -- search "title:rust" --limit 5
moon run cmd/main -- info --index index.mskmoon build --target wasm-gc
cp _build/wasm-gc/debug/build/web/web.wasm demo/web.wasm
cd demo && python -m http.server 8000
# open http://localhost:8000 (Chrome / Edge with WasmGC + js-string builtins)| Operation | Time |
|---|---|
| Index 1000 docs (with stored fields) | 85.8 ms +/- 6.2 |
| Term query (top-10) | 262 us +/- 12 |
| Phrase query (top-10) | 1.01 ms +/- 0.24 |
| Snapshot save + load | 45.3 ms +/- 2.7 |
| Operation | Time |
|---|---|
| Index build | 2.73 s -> 7,320 docs/s (median of 4) |
| Selective term (df 285) | 0.06 ms |
| Field-scoped term (df 732) | 0.13 ms |
| Saturated term (df 4,021) | 0.36 ms with block-max (see below) |
| Chinese bigram (df 4,113) | 0.37 ms |
| Boolean query | 3.02 ms |
| Exact phrase | 6.25 ms -> 0.29 ms (rarest-term walk, ~24x) |
| Sloppy phrase "a b"~3 | 9.80 ms -> 1.04 ms (~9x) |
| Prefix query seg* | 2.34 ms -> 0.44 ms (one term -> block-max) |
| Facet filter + aggregation (3,055 matches) | 14.0 ms |
| Fuzzy lookup, 23.8k terms | linear 37-46 ms -> BK-tree 0.07-2.7 ms (17-491x) |
| Snapshot 17.6 MB: serialize / load | 0.68 s / 0.75 s |
token.mbt Analyzer: Latin words + CJK bigrams, positions
index.mbt Inverted index, field stats, BM25, stored fields
query.mbt Lexer → recursive-descent parser → evaluator
fuzzy.mbt Levenshtein/Damerau distance, fuzzy search
suggest.mbt Frequency-weighted did-you-mean
highlight.mbt Server-side highlight/spans/snippet (codepoint offsets)
facet.mbt Facet values, filters, aggregations, pagination
bktree.mbt BK-tree for sub-linear fuzzy lookup
explain.mbt BM25 breakdown, sort orders, cursor pagination
segment.mbt SegmentedIndex: immutable segments + tiered merge
wal.mbt CRC-framed write-ahead log (portable byte layer)
wal_native.mbt Durable add/checkpoint/crash recovery (native)
codec.mbt Varint / UTF-8 / double / CRC-32 primitives
snapshot.mbt MSK3 format: versioned payload + checksum + stored fields + facets
store.mbt Native atomic file persistence (async/fs)
web/ Browser exports (#export_name, js-string builtins)
cmd/main/ CLI: index / search / info
examples/ Runnable examples (basic, persistence)
demo/ Single-page browser demo (60 docs, benchmarks, inspector)moon test # 355 tests (native)
moon test --target wasm-gc # 299 tests
moon test --target js # 299 tests
moon bench --release # benchmarks
moon coverage analyze # coverage report
moon run bench/stress --target native --release # 20k-doc stress benchmarkimpl Show for CodecErrorimpl Show for StoreErrorpub(all) struct AggResult {
field : String
kind : String
buckets : Array[(String, Double)]
stats : StatSummary?
percentiles : Array[(Double, Double)]
} derive(Eq, Debug)pub struct BkTree {
// private fields
}impl Default for Bm25Paramspub struct ByteWriter {
// private fields
}pub(all) struct Explanation {
id : String
total_score : Double
details : Array[ScoreDetail]
} derive(Eq, Debug)fn ExternalStats::new(n : Int, df? : Array[((String, String), Int)], avgdl? : Array[(String, Double)]) -> ExternalStatsfn HnswGraph::search(self : HnswGraph, points : HnswPoints, query : Array[Double], k : Int, ef? : Int) -> Array[(Int, Double)]type HnswPointspub struct Index {
schema : Array[FieldSchema]
analyzer : Analyzer
bm25 : Bm25Params
// private fields
}fn Index::and_terms_topk(self : Index, terms : Array[(String, String?)], limit : Int) -> BlockMaxResultfn Index::fuzzy_search(self : Index, term : String, max_distance? : Int, limit? : Int) -> Array[Hit] raise QueryErrorfn Index::fuzzy_terms(self : Index, term : String, max_distance? : Int, field? : String?) -> Array[FuzzyMatch]fn Index::fuzzy_terms_fast(self : Index, term : String, max_distance? : Int, field? : String?) -> Array[FuzzyMatch]fn Index::highlight(self : Index, id : String, field : String, query : String, pre_tag? : String, post_tag? : String) -> String raise QueryErrorfn Index::highlight_spans(self : Index, id : String, field : String, query : String) -> Array[HighlightSpan] raise QueryErrorasync fn Index::load_or_create(path : String, schema : Array[FieldSchema]) -> Index raise StoreErrorfn Index::or_terms_topk(self : Index, terms : Array[(String, String?)], limit : Int) -> BlockMaxResultfn Index::search_after(self : Index, query : String, after_score~ : Double, after_id~ : String, limit? : Int) -> Array[Hit] raise QueryErrorfn Index::search_faceted(self : Index, query : String, options? : SearchOptions) -> FacetedResult raise QueryErrorfn Index::snippet(self : Index, id : String, field : String, query : String, max_chars? : Int, pre_tag? : String, post_tag? : String) -> String raise QueryErrorfn Index::term_topk_blockmax(self : Index, field : String?, term : String, limit : Int) -> BlockMaxResultpub struct Reader {
data : Bytes
pos : Int
}pub(all) struct SearchOptions {
limit : Int
offset : Int
filters : Array[FacetFilter]
facet_fields : Array[String]
} derive(Eq, Debug)impl Default for SearchOptionspub struct SegmentedIndex {
schema : Array[FieldSchema]
analyzer : Analyzer
bm25 : Bm25Params
segments : Array[Index]
active : Index
segment_max_docs : Int
}async fn SegmentedIndex::add_durable(self : SegmentedIndex, dir : String, doc : Document) -> Unit raise StoreErrorasync fn SegmentedIndex::delete_durable(self : SegmentedIndex, dir : String, id : String) -> Bool raise StoreErrorasync fn SegmentedIndex::open_or_create(dir : String, schema : Array[FieldSchema]) -> SegmentedIndex raise StoreErrorfn SegmentedIndex::search(self : SegmentedIndex, query : String, limit? : Int) -> Array[Hit] raise QueryErrorlet BLOCK_SIZE : Intfn compare_numeric(index : Index, field : String, left : String, right : String, forward~ : Bool) -> Intfn damerau_levenshtein(a : String, b : String) -> Intfn damerau_levenshtein_bounded(a : String, b : String, bound : Int) -> Intfn is_missing_number(v : Double) -> Boolfn levenshtein(a : String, b : String) -> Intfn parse_query(input : String, analyzer? : Analyzer, fields? : Array[String]) -> Query raise QueryErrorInstall
Download zipFull-text search platform in pure MoonBit: inverted index, BM25, exact and sloppy phrases, dictionary-free Chinese tokenization, fuzzy search, highlighting, facets, numeric aggregations, HNSW vector search with hybrid fusion, an HTTP service, and a distributed cluster with consistent hashing, scatter-gather on global statistics, write replication, failover, cross-shard aggregations and hybrid retrieval that equal a single node, online rebalancing, replica repair and several coordinators. One snapshot format across native/WASM/JS.