Marz
A search engine for documentation sites that need CJK to work, and can't ship a dictionary to get it.
Build the index once — in Python, at build time — and search it in the browser from WebAssembly. No server, no network calls at query time, no segmentation model to download.
import marz
builder = marz.IndexBuilder("ja", ref_field="location")
builder.field("title", 10.0)
builder.field("text")
builder.add({"location": "guide/", "title": "検索エンジン", "text": "..."})
Path("search.bin").write_bytes(builder.build().to_bytes())
import { initialize, load } from "marz-search";
await initialize();
const index = await load(await fetch("/search.bin").then((r) => r.arrayBuffer()));
for (const hit of index.search("検索エンジン")) {
console.log(hit.ref, hit.score, hit.matches);
}
Why
The usual way to search Chinese or Japanese text is a segmentation dictionary — jieba is 4.9 MB uncompressed, and TinySegmenter is smaller but still a model that has to be right about Japanese. Either way a browser downloads it before the first search.
Marz tokenizes CJK into overlapping character bigrams instead. 検索エンジン
becomes 検索, エン, ンジ, ジン — no bigram crosses the Han/Katakana
boundary (索エ would span two words), so a query matches whatever a document
contains without either side knowing where words begin. Nothing to download,
nothing to be wrong about.
Bigrams alone over-match: a document containing 検索 and エンジン separately matches a query for 検索エンジン. So Marz records term positions and verifies adjacency at query time, which boosts a real phrase match rather than filtering the partial one. A document with the phrase scores roughly two orders of magnitude above one with the pieces scattered — it sorts to the top, and the partial hit stays available instead of vanishing. Filtering would make a CJK query behave as AND while the same query in English behaves as OR, which is a worse surprise than a low-ranked extra result.
Korean is written with spaces, so it is tokenized on whitespace. Forcing bigrams on a language that does not need them would only cost index size.
Numbers
Measured on the reference machine, on a synthetic corpus of 5,000 documents
with a title and a body. Reproduce with just size and cargo bench. Numbers
are illustrative (toolchain and corpus dependent) and not pinned by CI — see
crates/marz-core/benches/.
| English | Japanese | |
|---|---|---|
| JSON index (what lunr ships) | 3.5 MB | 7.4 MB |
| Binary index | 716 KB (20%) | 1.2 MB (17%) |
| Binary, positions dropped | 493 KB (14%) | 782 KB (11%) |
The binary format is read in place. BinaryIndex::open validates the header and
computes section bounds in ~330 ns regardless of corpus size — postings are
decoded from the buffer as queries ask for them. Parsing the equivalent JSON
takes ~112 ms at 5,000 documents, and materializing the whole binary index into
owned structures takes ~52 ms. Dropping positions costs highlighting and phrase
verification.
Search cost tracks how many documents match, not how many exist. A selective
query over the 5,000-document English index takes ~3 ms; a query for a term
that appears in every document takes ~24 ms, because scoring and sorting the
entire corpus is the actual work being asked for. Japanese is ~24 ms for a
single bigram and ~40 ms for a five-character phrase, where the extra time is
adjacency verification over positions. Reproduce with cargo bench -- search.
WebAssembly, after wasm-opt: measure with just size (CI enforces a ceiling).
The original four-language build was 175 KB raw, 84 KB gzipped; the current
full build ships 40+ languages (37 Snowball stemmers at ~2.9 KB gzipped each,
plus Thai/Lao/Khmer/Burmese/Tibetan cluster bigrammers). A single-language
build trims back to ~87 KB gzipped (scripts/build-wasm.sh --no-default-features --features de). The optional client-side index builder adds ~39 KB and is
behind a feature flag, because a site that builds its index in Python does not
need it in the browser.
Dependencies
marz-core depends on serde and serde_json, and only for the legacy JSON
index format — the binary format, the tokenizers, the Porter stemmer and the
query parser are all hand-written with no dependencies. The stemmer is not
regex-based, which is what keeps it out of the WASM bundle.
Languages
| Code | Tokenization | Stemming |
|---|---|---|
en |
whitespace + punctuation | Porter, matching lunr.js |
zh |
Han bigrams | none |
ja |
Han/Kana bigrams | none |
ko |
whitespace | none |
th, lo, km, my, bo |
cluster bigrams (Thai, Lao, Khmer, Myanmar, Tibetan) | none |
37× Snowball (de, fr, ru, …) |
whitespace + punctuation | Snowball |
anything else (vi, he, uk, …) |
whitespace + punctuation (generic fallback, warns) | none |
Call marz.languages() / languages() for the exact list this build ships;
trimmed WASM builds advertise fewer codes by design.
A single index can serve several languages at once via MultiLanguage, which
dispatches per script — one index for a site with translated pages, rather than
one per locale. Text is NFC-normalized and width-folded first, so fullwidth
RUST is found by typing rust and halfwidth ガイド by typing ガイド.
Relationship to lunr
Marz began as a lunr reimplementation and no longer is one. lunr scores through
a precomputed field-vector cosine similarity; Marz sums BM25 weights at query
time, which is what makes the index small enough to read in place. The query
language — +required, -prohibited, field:scoped, term^boost, fuzzy~1,
wild* — is lunr's, and English ranking is checked against lunr.js on every
commit.
Absolute scores deliberately differ. lunr divides by the query vector's magnitude, which is constant across documents for a given query and so cannot change their order; ranking is the observable behaviour, and pinning the magnitude would freeze a design decision this rewrite reversed.
The oracle is lunr.js, not lunr.py. The two stem terminal y differently —
fly becomes fli under lunr.js and stays fly under lunr.py — so a query for
fly finds a document containing only flies under one and nothing under the
other. Both cannot be matched. Marz follows lunr.js, because a browser consuming
a Marz index sits beside lunr.js. See crates/marz-core/tests/golden.rs.
Layout
crates/marz-core— the engine: tokenizers, stemmer, query parser, BM25 scorer, binary formatcrates/marz-wasm— wasm-bindgen bindings, search-only by defaultjs/— TypeScript wrapper around the WASM modulepython/— PyO3 bindings for the build steptests/fixtures— the lunr.js ranking oracle, and the hand-written CJK expectations that have no oracle
Install
Nothing is published yet — the first release has not been cut. When it is:
pip install marz-search # build indexes (and search them, natively)
npm install marz-search # search them in the browser
marz-core will be on crates.io for Rust callers. RELEASING.md covers the
publish setup: all three registries go out from one tag via trusted publishing,
with no tokens stored in this repository.
Development
just test-all # Rust, Python and JS suites
just lint # clippy, including the wasm-only builder feature
just size # shipped WASM size, raw and gzipped
just golden # regenerate the lunr.js ranking oracle
cargo bench # build, search, load and serialize
License
Apache-2.0
Metadata
Release files for marz-search 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| marz_search-0.1.1.tar.gz | 222.7 kB | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| marz_search-0.1.1-cp310-abi3-win_amd64.whl | CPython 3.10 | abi3 | Windows x86-64 | Details |
| marz_search-0.1.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl | CPython 3.10 | abi3 | Linux glibc 2.17+ x86-64 | Details |
| marz_search-0.1.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl | CPython 3.10 | abi3 | Linux glibc 2.17+ ARM64 | Details |
| marz_search-0.1.1-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl | CPython 3.10 | abi3 | macOS 11.0+ ARM64, macOS 10.12+ universal2 (ARM64, x86-64), macOS 10.12+ x86-64 | Details |
Total release size: 3.4 MB
Release files / marz_search-0.1.1.tar.gz
| Download URL | marz_search-0.1.1.tar.gz |
|---|---|
| Size | 222.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
dc1d71b9975e334da20aba5bb0a7aee56a3b3b5dd8036c08d08a719d2952903e
|
|
BLAKE2b-256 checksum How to use checksums |
7f8469a3099554cfbcbef9b4812b730911e2aa2031118bdc8abf70777c6ccf40
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.
Transparency logRelease files / marz_search-0.1.1-cp310-abi3-win_amd64.whl
| Download URL | marz_search-0.1.1-cp310-abi3-win_amd64.whl |
|---|---|
| Size | 587.5 kB |
| Tags | CPython 3.10 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
c84a59521efedb06afb8fdb40e227d35163f9bca74e86d7dd3aeed2ec8450ba8
|
|
BLAKE2b-256 checksum How to use checksums |
76e17ecee431127ecd1ffdf50f53ab29429bf199c7df170907f90a2c0fa56211
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.
Transparency logRelease files / marz_search-0.1.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
| Download URL | marz_search-0.1.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl |
|---|---|
| Size | 695.8 kB |
| Tags | CPython 3.10 Linux glibc 2.17+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
ba6f2536b477e1e75996cb1e52da0ad404bc44ee548cfe4f659276370f8704fd
|
|
BLAKE2b-256 checksum How to use checksums |
29235b19c75c31902f7059b3664e1328c87384e972dd6d27b57cd247de8bc74d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.
Transparency logRelease files / marz_search-0.1.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
| Download URL | marz_search-0.1.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl |
|---|---|
| Size | 678.0 kB |
| Tags | CPython 3.10 Linux glibc 2.17+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
bd1df7732a61913a86f426e58c2ad60c72c18d1b15cf7c4a450a0f58001f0eb2
|
|
BLAKE2b-256 checksum How to use checksums |
1161fc08d775c07507a674f210f1ba8fc7be0cf1f329855a5eeeb8489d6d1724
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.
Transparency logRelease files / marz_search-0.1.1-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl
| Download URL | marz_search-0.1.1-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl |
|---|---|
| Size | 1.3 MB |
| Tags | CPython 3.10 abi3 macOS 10.12+ universal2 (ARM64, x86-64) macOS 10.12+ x86-64 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
a5a431705dd14e17ade1fefa56adef269978b60870bca949dbba3ffe25cfd4d3
|
|
BLAKE2b-256 checksum How to use checksums |
e04992a0b234819ef2be9899480dac6caad10f2af326bfdba613cb8d67701e6b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 7, 2026.
Transparency log