fuse
Green · 2026-05-10 Node + Browser Search 5–20× expected vs fuse.jsFuzzy text search index over JSON records, with weighted fields. SIMD-accelerated via nucleo-matcher.
- Targets
- Node + Browser
Install
pnpm add @amigo-labs/fuseREADME
@amigo-labs/fuse
Rust-powered fuzzy search index built on
nucleo-matcher. Subset-shape alternative tofuse.js; ranking direction matches (closer = higher), exact scores diverge — seedocs/perf-review/fuse.md.
Install
npm install @amigo-labs/fuse
Usage
import { Fuse } from '@amigo-labs/fuse'
const records = [
JSON.stringify({ title: 'The Hitchhiker’s Guide', author: 'Adams' }),
JSON.stringify({ title: 'Brave New World', author: 'Huxley' }),
]
const fuse = new Fuse(records, {
keys: [{ name: 'title', weight: 2.0 }, { name: 'author' }],
threshold: 0.4,
})
fuse.search('hitchhiker')
// [{ refIndex: 0, score: 0.0… }]
Records are passed as JSON strings to skip the cost of Vec<serde_json::Value> marshalling across the FFI; the index keeps record fields in pre-extracted form.
Install for the browser
The same import works in Angular, React, Vite, esbuild, and webpack ≥ 5 — the bundler picks the WASM build via the browser conditional export:
import { Fuse } from '@amigo-labs/fuse'
nucleo-matcher is small (~70 KB gzipped) — comfortably under the 500 KB browser budget.
Parity
Ranking-direction parity with fuse.js. Bit-identical scores are not a goal — nucleo’s score model differs from fuse.js’s Bitap-based one. See __conformance__/divergences.md for the full delta.
License
MIT
Perf review
Candidate review: fuse.js
Status: 🟢 GO · Predicted: Green · Reviewed: 2026-05-10
Verdict
fuse.js is a near-textbook fit for the portfolio’s strongest
ship-shape: a stateful index built once and queried many times, with
the per-query loop dominated by CPU-bound string scanning (Bitap
algorithm). The bm25 and minisearch precedents have already
proven that this exact shape clears Green when implemented as a
NAPI class with offset-packed output. Pure-JS fuse.js is widely
documented as the slow baseline above ~10k records, and Rust matchers
(nucleo-matcher, fuzzy-matcher) deliver SIMD-accelerated scanning
that pure-JS cannot match. Recommendation: GO.
JS package
- npm:
fuse.js - Downloads (week of 2026-05-02): 9.4M (one of the top-50 most-downloaded search-related packages on npm)
- Exports / API surface:
new Fuse(list, options),fuse.search(query, options?) → result[],fuse.add(doc),fuse.remove(predicate),fuse.setCollection(list),Fuse.createIndex(...)/Fuse.parseIndex(...)for index serialization. Options:keys(weighted),threshold,distance,includeScore,includeMatches,useExtendedSearch,ignoreLocation,findAllMatches,minMatchCharLength. - Typical input: a list of 1k–500k records (objects with one or more searchable text fields) at index time; a short query string (1–60 chars) at query time.
- Typical output:
{ item, refIndex, score, matches? }[], length bounded bylimit(often 10–100). - Realistic median use-case: in-process fuzzy autocomplete or filter over a moderate corpus (commands, contacts, files, product catalogue). The fuse index is built once at app start (or on collection update) and queried per keystroke.
Rust replacement
- Candidate crate(s):
nucleo-matcher(the matcher behind thenucleofuzzy picker, used inhelix-editorandzellij; SIMD, scoring, multi-pattern),fuzzy-matcher(the originalskim/fzf-style matcher),sublime_fuzzy(sublime-style). For Bitap-style ranking with field weights similar to fuse.js,nucleo-matcher+ a small per-record weight layer is the closest fit. - Maintenance / license:
nucleo-matcher0.3.x is actively maintained, MIT/Apache-2.0, production-used.fuzzy-matcheris mature, MIT, slightly less actively maintained. - Known gotchas / divergences:
- Fuse.js scoring is a custom Bitap variant with location bias,
distance weighting, and field weights. Rust matchers use
different scoring (fzf-style).
parity:stricton the ranking is impossible without reimplementing fuse’s exact scoring. Scope must say “parity on the API surface and ranking direction (closer matches rank higher), not on the score numbers”. useExtendedSearch(!negation,^prefix,$suffix,=exact) is fuse-specific syntax. Either reimplement or scope out for v0.1.
- Fuse.js scoring is a custom Bitap variant with location bias,
distance weighting, and field weights. Rust matchers use
different scoring (fzf-style).
BACKLOG check
No entry in BACKLOG.md for fuse.js, fuse, fuzzysort, or any
fuzzy-match variant. Fresh territory.
FFI-overhead prediction
| Factor | Assessment |
|---|---|
| Per-call algorithmic work | High at index build (10k–100k records × multiple fields × tokenization), substantial at query time (Bitap scan over every record’s tokens). Build is one-shot, query is hot-loop. Both dwarf the 109 ns NAPI floor. |
| Input size distribution | Build: 1k–500k records, each with 1–10 short text fields. Query: 1–60 char string. Wide on build side, narrow on query side. |
| Output size distribution | Result list is typically bounded to 10–100 items via limit. With includeMatches, per-result match offsets can be 10s of ranges — pack into Uint32Array to dodge V8 marshalling. |
| Reusable setup (stateful potential) | Extremely high. The fuse index is the entire point of the library. NAPI class with the index living on the Rust side is the obvious shape. This is exactly the bm25 / minisearch Green-recipe. |
| Batch-usage realism | Medium. Per-keystroke query is the dominant call shape (sequential). Batched searchMany(queries) is a real use-case for log analysis / data dedup but not the headline. |
| FFI-share estimate vs. Rust work | <2% on the build path (one big call, 100k records). <5% on the query path (one call per keystroke, ~10–100 result objects marshalled back). Buffer-of-offsets for matches keeps marshalling flat. |
Classification reasoning
The shape is a direct copy of bm25 and minisearch: build a
state-heavy index once, query against it cheaply, return small
result sets. Both bm25 and minisearch were classified Green
once their indices moved into a NAPI class instead of being rebuilt
per call. The same recipe applies here.
Pure-JS fuse.js is well-known as the slow baseline for fuzzy
search at scale — multiple community benchmarks show it 5–20× slower
than fzf / nucleo / fzy on 100k-record corpora. The Rust
matchers operate on byte slices with SIMD where available, while
fuse.js operates on JS strings with per-character JS loops.
The realistic competitor set:
fuse.jsitself (pure-JS, the headline baseline)fuzzysort(pure-JS, faster than fuse but no field-weighting or fuse-style options)match-sorter(pure-JS, simpler scoring)- WASM matchers exist but none are mainstream on npm
No native N-API fuzzy matcher dominates npm — there is no
@mongodb-js/zstd-style native competitor that would trigger the
bcrypt trap. The market for @amigo-labs/fuse is wide open.
Predicted classification: 🟢 Green at all sizes ≥10k records, likely 🟡 Yellow at 100–1k records (FFI overhead + index build dominates the work). The bench scope must report the crossover.
If GO — proposed port
- Recommended crate-name:
@amigo-labs/fuse - Primary API sketch:
type FuseOptions = { keys?: (string | { name: string; weight?: number })[] threshold?: number // 0.0 = exact, 1.0 = any distance?: number includeScore?: boolean includeMatches?: boolean ignoreLocation?: boolean findAllMatches?: boolean minMatchCharLength?: number limit?: number } type FuseResult<T> = { item: T refIndex: number score?: number matches?: { key: string; indices: Uint32Array }[] } export class Fuse<T> { constructor(list: T[], options?: FuseOptions) search(query: string, opts?: { limit?: number }): FuseResult<T>[] add(doc: T): void remove(predicate: (doc: T) => boolean): T[] setCollection(list: T[]): void // Offset-packed fast path — bypass FuseResult marshalling searchOffsets(query: string, limit?: number): { indices: Uint32Array scores: Float32Array matchRanges?: Uint32Array // packed [recordIdx, keyIdx, start, end, ...] } } - Must-have benchmark scenarios:
- Build index for 1k / 10k / 100k records, 3 string fields each.
- Single query against each size, with and without
includeMatches. - 1000 queries against a 100k-record index (the realistic per-keystroke pattern).
- vs
fuse.js(the headline competitor),fuzzysort,match-sorter. - With/without
includeScoreandincludeMatches— quantify the marshalling cost separately.
- Acceptance thresholds (Green gate):
- ≥3× vs
fuse.jsat 10k records (any single query). - ≥5× vs
fuse.jsat 100k records. - ≥1.0× vs
fuse.jsat 100 records (the smallest realistic corpus; if Rust loses here, document as the small-corpus crossover and link to it in the README).
- ≥3× vs
- Risks:
- Scoring parity: fuse.js scoring is a custom Bitap variant. Reimplementing the exact numbers is out of scope; the parity contract is ranking direction, not score values. Document this explicitly in the crate README.
useExtendedSearchsyntax: scope to v0.2.- Index-build amortization: small-corpus benchmark may show Yellow / parity. The Green-classification is conditional on medium-to-large corpora. Document the crossover honestly.
includeMatchesmarshalling: a 100-result query with full match-index objects can return 1000+ small objects. ThesearchOffsetsfast path is the v0.1 hedge against this.
If NO-GO — BACKLOG entry
Not applicable (verdict is GO).
References
- BASELINE:
docs/BASELINE.md(NAPI floor 109 ns, stateful classes amortize build cost) - Portfolio neighbours:
crates/bm25/,crates/minisearch/— the proven stateful-search-index Green pattern - Rust crates: https://crates.io/crates/nucleo-matcher, https://crates.io/crates/fuzzy-matcher
- Upstream JS: https://www.fusejs.io/
- Comparison patterns:
docs/perf-review/bm25.md,docs/perf-review/minisearch.mdif present