Fuse.js でトークン長に応じた動的しきい値ファジー検索を作る方法

概要

今回はFuse.js でトークンの長さに応じて動的にしきい値を切り替えるファジー検索の作り方について紹介していきます。

OCR やユーザ入力から拾ったキーワードを辞書に当てるとき、Fuse.js は便利ですが、しきい値を固定にすると短い単語で誤爆・長い単語で取りこぼしが発生します。
「3 文字のカタカナが関係ない単語に引っかかる」「長い専門用語なのに 1 文字ずれで外れる」という、よくあるやつですね(- -;

ここでは、単一しきい値の限界を説明しつつ、トークン長に応じた段階しきい値・曖昧判定・長さ比キャップの 3 段構えで精度を上げる実装を紹介します。

それではやっていきましょう!

目次

Fuse.js のしきい値の基本

Fuse.js の threshold は 0 〜 1 の値で、0 が完全一致、1 が何でもマッチです。
内部では編集距離ベースのスコアが計算されていて、threshold より小さい(= より近い)ものだけがヒットとして返ります。

使い方はこんな感じです。

simpleFuse.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import Fuse from "fuse.js";

const dict = [
{ name: "TypeScript", synonyms: ["TS"] },
{ name: "JavaScript", synonyms: ["JS"] },
{ name: "Python", synonyms: ["py"] },
];

const fuse = new Fuse(dict, {
keys: ["name", "synonyms"],
threshold: 0.3,
includeScore: true,
});

console.log(fuse.search("Typscrpt")); // TypeScript がヒット

この 0.3 が曲者で、短い単語・長い単語で感度を合わせるのが無理なんですよね。

単一しきい値の限界

実際に試してみるとすぐに壁にぶつかります。

<ありがちな失敗>
  • TS のような 2 文字トークンを引くと、別の短い単語にスコア近似でヒットしてしまう
  • 逆に ReactServerComponents のような 20 文字超は、1 文字ずれで threshold=0.3 を超えて取りこぼす
  • E-number / 型番のようなパターン文字列は、文字列距離では 1 文字違いで意味が全く変わるのに、threshold 0.3 だと誤ヒットする

Fuse.js の threshold は「単語の長さに対する相対的な許容誤差」ではないので、長さが違うトークンを同じしきい値で扱うのが難しいのです。。。

改善 1:トークン長で段階しきい値

改善にはこれが一番効果が見られたように思いました!
トークンを事前に正規化して、長さに応じて別のしきい値で検索します。

searchToken.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import Fuse, { type IFuseOptions } from "fuse.js";

type Entry = { id: string; name: string; synonyms: string[] };

export function buildIndex(entries: Entry[]) {
const options: IFuseOptions<Entry> = {
keys: ["name", "synonyms"],
includeScore: true,
ignoreLocation: true,
minMatchCharLength: 4,
};
return new Fuse(entries, options);
}

function thresholdFor(token: string): number {
const len = [...token].length; // 絵文字や結合文字対策で配列展開
if (/^[A-Za-z0-9._-]+$/.test(token)) {
// 型番・記号列は厳しく
if (len <= 4) return 0.05;
if (len <= 8) return 0.15;
return 0.2;
}
if (len <= 3) return 0.1;
if (len <= 6) return 0.25;
return 0.3;
}

export function searchToken(fuse: Fuse<Entry>, token: string) {
const threshold = thresholdFor(token);
return fuse.search(token, { limit: 5 }).filter((r) => (r.score ?? 1) <= threshold);
}

ポイントはこんな感じです。

<段階しきい値の設計>
  • 3 文字以下の日本語:0.1(ほぼ完全一致を要求)
  • 6 文字以下の日本語:0.25(1 文字くらいの誤りを許す)
  • それ以上の日本語:0.3(1〜2 文字くらいの誤りを許す)
  • ASCII 系:全体に厳しめ(0.05 〜 0.2)

Fuse.js の threshold はコンストラクタで 1 つしか渡せないので、検索オプションをトークンごとに変える必要がありますが、search(query, options) の第 2 引数で上書き出来る版(@>=7)なら素直にいけます。
旧バージョンなら Fuse インスタンスを複数作って切り替えれば OK です。

改善 2:上位ヒットが似ていたら「曖昧」として捨てる

段階しきい値でもまだ残る問題が、辞書内に似たワードが複数ある時の誤ヒットです。

例えば セルロ と打った時、メチルセルロースカルボキシメチルセルロース が両方そこそこのスコアで返ってくると、どちらに寄せるか機械には決められません。

こういう時は「上位 3 件のスコア差が小さい場合は曖昧扱いで返さない」という保険を入れます。

ambiguity.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function pickConfident<T>(
results: { item: T; score?: number }[],
minGap = 0.02,
minScore = 0.1
) {
if (results.length === 0) return null;
const best = results[0];
if ((best.score ?? 1) < minScore) return best; // ほぼ完全一致なら即採用
const top3 = results.slice(0, 3);
const worst = top3[top3.length - 1];
const gap = (worst.score ?? 1) - (best.score ?? 1);
if (gap < minGap) return null; // 上位がダンゴ状態
return best;
}

ここで言う「曖昧判定」は、どれに寄せるか決められないなら、ユーザに選ばせる or 捨てるという選択をする仕組みです。
誤ヒットを無理に採用するより、取りこぼした方が UX は良いことが多いです。

改善 3:長さ比キャップで部分一致の暴発を防ぐ

もう 1 つよくあるのが、短いトークンが長すぎる候補に当たってしまう問題です。

例えば ルロー という 3 文字で、辞書の カルボキシメチルセルロース を引いてしまうパターン。
部分一致としては正しいんですが、利用者の意図からすると誤りです。

対策は、ヒット先の文字数がトークンの一定倍を超えたら却下する、というシンプルなルールです。

lengthRatio.ts

1
2
3
4
5
6
export function withinLengthRatio(token: string, matchedText: string, ratio = 2.5) {
const a = [...token].length;
const b = [...matchedText].length;
if (a >= 5) return true; // 十分な長さなら気にしない
return b <= a * ratio;
}

この関数を filter の条件に追加します。
2.5 倍くらいが経験的にちょうどよく、3 倍にすると緩すぎ、2 倍にすると厳しすぎます

3 つ合体版

全部まとめるとこうなります。

fuseSearch.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import Fuse from "fuse.js";

type Entry = { id: string; name: string; synonyms: string[] };

export class FuzzyMatcher {
private fuse: Fuse<Entry>;

constructor(entries: Entry[]) {
this.fuse = new Fuse(entries, {
keys: ["name", "synonyms"],
includeScore: true,
ignoreLocation: true,
minMatchCharLength: 4,
});
}

match(token: string) {
const threshold = thresholdFor(token);
const raw = this.fuse
.search(token, { limit: 5 })
.filter((r) => (r.score ?? 1) <= threshold)
.filter((r) => withinLengthRatio(token, r.item.name, 2.5));
return pickConfident(raw, 0.02, 0.1);
}
}

個々の関数は前のセクションで示したものをそのまま使えば OK です。

運用上のコツ

しきい値や倍率はデータに依存するので、最初から決めようとせず、誤検出サンプルを集めながら調整するのが近道です。

<育て方>
  • 誤ヒット・取りこぼしのログを残しておく
  • どちらが多いかでしきい値か長さ比のどちらを触るか決める
  • 一度に 2 つの定数を同時に動かさない
  • 定数を変えたら、既存で正しかったケースに退行がないか確認

地味で本当に面倒ですが、誤検出を 1 件ずつ潰していく方が、式をいじり続けるより早く精度が上がります

メリット・デメリットまとめ

<動的しきい値のメリット>
  • 短語・長語どちらも許容誤差が合うようになる
  • 辞書が増えても一つの定数をいじるだけで調整できる
  • 曖昧判定で誤ヒットを後段に持ち込まなくて済む
<デメリットと対策>
  • 定数が増えて可読性が落ちる → 1 つの関数に閉じ込める
  • チューニングに時間がかかる → 誤検出ログを先に仕組み化
  • データ依存で再利用性が低くなる → パッケージ化より、プロジェクト内のユーティリティで留める

締め

Fuse.js のしきい値は「一つで全部済ませる」設計になっていますが、トークン長で段階化し、曖昧判定と長さ比キャップを足すだけで、実データでの使い勝手が一気に上がります。

次回は、Fuse.js に渡す前のトークンそのものを正規表現で補正する、日本語 OCR 誤認識の直し方について書いていきます。

以上となります。
定数の調整がめちゃくちゃ大変でした。。。
それではお疲れさまでした。