Fuse.js でトークン長に応じた動的しきい値ファジー検索を作る方法
概要
今回はFuse.js でトークンの長さに応じて動的にしきい値を切り替えるファジー検索の作り方について紹介していきます。
OCR やユーザ入力から拾ったキーワードを辞書に当てるとき、Fuse.js は便利ですが、しきい値を固定にすると短い単語で誤爆・長い単語で取りこぼしが発生します。
「3 文字のカタカナが関係ない単語に引っかかる」「長い専門用語なのに 1 文字ずれで外れる」という、よくあるやつですね(- -;
ここでは、単一しきい値の限界を説明しつつ、トークン長に応じた段階しきい値・曖昧判定・長さ比キャップの 3 段構えで精度を上げる実装を紹介します。
それではやっていきましょう!
目次
Fuse.js のしきい値の基本
Fuse.js の threshold は 0 〜 1 の値で、0 が完全一致、1 が何でもマッチです。
内部では編集距離ベースのスコアが計算されていて、threshold より小さい(= より近い)ものだけがヒットとして返ります。
使い方はこんな感じです。
simpleFuse.ts
1 | import Fuse from "fuse.js"; |
この 0.3 が曲者で、短い単語・長い単語で感度を合わせるのが無理なんですよね。
単一しきい値の限界
実際に試してみるとすぐに壁にぶつかります。
TSのような 2 文字トークンを引くと、別の短い単語にスコア近似でヒットしてしまう- 逆に
ReactServerComponentsのような 20 文字超は、1 文字ずれで threshold=0.3 を超えて取りこぼす - E-number / 型番のようなパターン文字列は、文字列距離では 1 文字違いで意味が全く変わるのに、threshold 0.3 だと誤ヒットする
Fuse.js の threshold は「単語の長さに対する相対的な許容誤差」ではないので、長さが違うトークンを同じしきい値で扱うのが難しいのです。。。
改善 1:トークン長で段階しきい値
改善にはこれが一番効果が見られたように思いました!
トークンを事前に正規化して、長さに応じて別のしきい値で検索します。
searchToken.ts
1 | import Fuse, { type IFuseOptions } from "fuse.js"; |
ポイントはこんな感じです。
- 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 | export function pickConfident<T>( |
ここで言う「曖昧判定」は、どれに寄せるか決められないなら、ユーザに選ばせる or 捨てるという選択をする仕組みです。
誤ヒットを無理に採用するより、取りこぼした方が UX は良いことが多いです。
改善 3:長さ比キャップで部分一致の暴発を防ぐ
もう 1 つよくあるのが、短いトークンが長すぎる候補に当たってしまう問題です。
例えば ルロー という 3 文字で、辞書の カルボキシメチルセルロース を引いてしまうパターン。
部分一致としては正しいんですが、利用者の意図からすると誤りです。
対策は、ヒット先の文字数がトークンの一定倍を超えたら却下する、というシンプルなルールです。
lengthRatio.ts
1 | export function withinLengthRatio(token: string, matchedText: string, ratio = 2.5) { |
この関数を filter の条件に追加します。
2.5 倍くらいが経験的にちょうどよく、3 倍にすると緩すぎ、2 倍にすると厳しすぎます。
3 つ合体版
全部まとめるとこうなります。
fuseSearch.ts
1 | import Fuse from "fuse.js"; |
個々の関数は前のセクションで示したものをそのまま使えば OK です。
運用上のコツ
しきい値や倍率はデータに依存するので、最初から決めようとせず、誤検出サンプルを集めながら調整するのが近道です。
- 誤ヒット・取りこぼしのログを残しておく
- どちらが多いかでしきい値か長さ比のどちらを触るか決める
- 一度に 2 つの定数を同時に動かさない
- 定数を変えたら、既存で正しかったケースに退行がないか確認
地味で本当に面倒ですが、誤検出を 1 件ずつ潰していく方が、式をいじり続けるより早く精度が上がります。
メリット・デメリットまとめ
- 短語・長語どちらも許容誤差が合うようになる
- 辞書が増えても一つの定数をいじるだけで調整できる
- 曖昧判定で誤ヒットを後段に持ち込まなくて済む
- 定数が増えて可読性が落ちる → 1 つの関数に閉じ込める
- チューニングに時間がかかる → 誤検出ログを先に仕組み化
- データ依存で再利用性が低くなる → パッケージ化より、プロジェクト内のユーティリティで留める
締め
Fuse.js のしきい値は「一つで全部済ませる」設計になっていますが、トークン長で段階化し、曖昧判定と長さ比キャップを足すだけで、実データでの使い勝手が一気に上がります。
次回は、Fuse.js に渡す前のトークンそのものを正規表現で補正する、日本語 OCR 誤認識の直し方について書いていきます。
以上となります。
定数の調整がめちゃくちゃ大変でした。。。
それではお疲れさまでした。