Skip to main content

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. Each boosted phrase term scores double, so a document with the phrase sorts well above one with the pieces scattered — while 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 mixes Hangul syllables with spaces and occasional Hanja, so it is bigrammed like Japanese (Hangul and Han runs); whitespace alone would leave unspaced Hangul compounds unfindable.

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. Materializing the whole binary index into owned structures takes ~52 ms. (The browser build loads that way: MarzIndex.load materializes rather than memory-mapping, so budget one load per page.) 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 has no dependencies: the binary format, the tokenizers, the Porter stemmer and the query parser are all hand-written. The stemmer is not regex-based, which is what keeps it out of the WASM bundle. (serde and serde_json appear only in dev-dependencies, for reading test fixtures.)

Languages

Code Tokenization Stemming
en whitespace + punctuation Porter, matching lunr.js
zh Han bigrams none
ja Han/Kana bigrams none
ko Hangul/Hanja bigrams 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 width-folded first (full-width to ASCII, half-width katakana to full-width — compatibility folding, not full NFC), 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.

Index compatibility

An index is only valid for the marz that built it. Loading rejects a version or language mismatch outright, but tokenizer and stemmer changes between releases can shift ranking without tripping either check — so rebuild every index on every marz upgrade, and pin the Python (marz-search) and npm (marz-search) versions together. Cached or deployed .marz bytes from an older build must be regenerated, not reused.

Layout

  • crates/marz-core — the engine: tokenizers, stemmer, query parser, BM25 scorer, binary format
  • crates/marz-wasm — wasm-bindgen bindings, search-only by default
  • js/ — TypeScript wrapper around the WASM module
  • python/ — PyO3 bindings for the build step
  • tests/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.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for marz-search 0.2.1
File Size Uploaded
marz_search-0.2.1.tar.gz 239.0 kB Details

Built distributions (wheels)

Table of built distributions (wheels) for marz-search 0.2.1
File
marz_search-0.2.1-cp310-abi3-win_amd64.whl CPython 3.10 abi3 Windows x86-64 Details
marz_search-0.2.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.2.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl CPython 3.10 abi3 Linux glibc 2.17+ ARM64 Details
marz_search-0.2.1-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl CPython 3.10 abi3 macOS 10.12+ universal2 (ARM64, x86-64), macOS 11.0+ ARM64, macOS 10.12+ x86-64 Details

Total release size: 3.2 MB

Release files / marz_search-0.2.1.tar.gz

Download URL marz_search-0.2.1.tar.gz
Size 239.0 kB
Tags Source
SHA-256 checksum
How to use checksums
15b621a185eaa94fe9584ffbc9eacd7d663f0bbb94f233051e60308c4aa3fa23
BLAKE2b-256 checksum
How to use checksums
b0624bae980e15aee1477fe2eaafc4d5194790d488e0304d552f61f1f5c4fabc
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 12, 2026.

Transparency log

Release files / marz_search-0.2.1-cp310-abi3-win_amd64.whl

Download URL marz_search-0.2.1-cp310-abi3-win_amd64.whl
Size 541.8 kB
Tags CPython 3.10 Windows x86-64 abi3
SHA-256 checksum
How to use checksums
f4e78271ac8305af9ae3b76094c657d0a8a598e93d15241fa04b4eac4785731b
BLAKE2b-256 checksum
How to use checksums
ba08228ef9e7c4c5c45f2aed8880970b3f703ae5ab62a733a8337f01cefec98d
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 12, 2026.

Transparency log

Release files / marz_search-0.2.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL marz_search-0.2.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 649.6 kB
Tags CPython 3.10 Linux glibc 2.17+ x86-64 abi3
SHA-256 checksum
How to use checksums
2576127c103533dc4c0dac3169a7ec9670b5b64b7073a51e51acf2c34e91d792
BLAKE2b-256 checksum
How to use checksums
f9bf565855f55aadfe0a63ae4e7fcdec3d54e41f510c62ea76331335d91e1869
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 12, 2026.

Transparency log

Release files / marz_search-0.2.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Download URL marz_search-0.2.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Size 634.5 kB
Tags CPython 3.10 Linux glibc 2.17+ ARM64 abi3
SHA-256 checksum
How to use checksums
9606562691d96ecce45af483e08ca44462ad5f9d4b48b7a51553d67bd99098a0
BLAKE2b-256 checksum
How to use checksums
fcb5b1062ce8f915a5839c5bb68d6796be77ec8e96b4753721de91f08141d44d
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 12, 2026.

Transparency log

Release files / marz_search-0.2.1-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl

Download URL marz_search-0.2.1-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl
Size 1.2 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
69e3b0f36be4d4a9d549a7a678ff86281b639e06a5947f3a12588b35db03bee1
BLAKE2b-256 checksum
How to use checksums
b7d207036b210daea54a8cbcf7673b03fb28d9c5d67f49e897e6a462b1738485
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 12, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.0

5 release files

0.2.2

5 release files

This release

0.2.1 This release

5 release files

0.1.3

5 release files

0.1.2

5 release files

0.1.1

5 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page