Fuse.jsを使って あいまい検索(fuzzy search)をする

Node.js環境で[Fuse.js](https://www.npmjs.com/package/fuse.js)を使用して簡単にあいまい検索を実装する方法を紹介します。

Fuse.jsはJavaScript製の軽量な全文検索ライブラリで、特にデータを加工したり、難しい知識を必要とせずにあいまい検索を実現することができます。

https://www.fusejs.io/

## 準備

まず、必要なモジュールをインストールします。Node.jsのプロジェクトを作成し、以下のコマンドを実行して必要なモジュールをインストールします。

```
npm install fuse.js
```

注意点としては `fuse` ではなく `fuse.js` とすることです。[なぜなら `fuse` という名の別名のモジュールが存在している](https://www.npmjs.com/package/fuse)からです。

次に、サンプルコードを以下に示します。今回のコードはJIRAのAPIからデータを取得し、Fuse.jsを使用して検索結果をフィルタリングし、結果をTSVファイルとして保存する、ということをしています。これはどういった目的のものかという説明をすると込み入った話になり長くなるので割愛させていただきます。とにかくあいまい検索に対応していないAPIがいるので対応させたよ、という感じです。

## サンプルコード

```javascript
const https = require('https');
const fs = require('fs');
const Fuse = require('fuse.js');

// JIRA APIの認証情報を設定
// 標準入力させればよかったけどめんどくさくて対応してない
const username = 'your_username';
const password = 'your_password';

if (username === '' || password === '') {
  console.error('お手数ですが、usernameとpasswordを設定してください');
  process.exit(1);
}

const auth = Buffer.from(`${username}:${password}`).toString('base64');

// search APIを利用する
// https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-issue-search/#api-rest-api-2-search-get

// JQLクエリ、8週間以内に更新されたチケットを対象にする
const jqlQuery = 'project = SAMPLE AND issuetype = Task AND updated >= -8w';
const encodedQuery = encodeURIComponent(jqlQuery);

// APIエンドポイントの設定
const options = {
  hostname: 'api.example.com',
  port: 443,
  // JIRAのfieldsの数がとても多いので、あいまい検索に利用したい項目だけに絞り込む。summaryがチケットのタイトル。あと本文になるdescriptionなどを含めても良いかもしれない
  // JIRA APIのmaxResultsを省略した場合のデフォルト値は50件
  path: `/rest/api/2/search?jql=${encodedQuery}&fields=summary&maxResults=200`,
  method: 'GET',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Basic ${auth}`
  }
};

// 検索対象のリスト
const targetList = [
  "Task - Feature A - Implement test cases",
  "Task - Feature B - Investigate logic",
  "Task - Feature C - Execute test plan",
];

// APIリクエストの作成
const req = https.request(options, (res) => {
  let data = '';

  res.on('data', (chunk) => {
    data += chunk;
  });

  res.on('end', () => {
    const response = JSON.parse(data);
    const issues = response.issues || [];
    // JIRAのレスポンスを扱いやすいように [{key: '', summary: ''},...] 形式に加工する
    const organizedIssues = issues.map(item => ({
      key: item.key,
      summary: item.fields.summary
    }));

    // Fuse.jsの設定。第一引数に検索する先になる配列を指定する
    const fuse = new Fuse(organizedIssues, {
      // 検索対象のキーを指定する。descriptionも取得していて対象にしたいならここで指定する
      keys: ['summary'],
      // 検索結果にスコアを含めるかどうか
      includeScore: true,
      // しきい値。デフォルトは0.6。値を0に近づけるとより厳密に、1に近づけるとよりあいまいになる
      threshold: 0.2,
    });

    const tsvLines = targetList
      .map(target => {
        // 検索対象に対しを1レコードずつ検索を実施
        const result = fuse.search(target);
        // マッチするものがない場合はresultが空配列になる
        if (result.length === 0) {
          return null;
        }
        // resultはマッチしたもののスコアが低い順（似ている順）に返されるので1件目を採用する
        const summary = result[0].item.summary;
        const key = result[0].item.key;
        const score = result[0].score;
        const url = `https://api.example.com/browse/${key}`;
        // scoreを結果に含めることでどれだけ似ていたかをあとから判別できるようにする
        return `${target}\t${summary}\t${url}\t${score}`;
      })
      .filter(line => line !== null);

    const tsvContent = `Original Title\tIssue Summary\tURL\tScore\n${tsvLines.join('\n')}`;

    // TSVファイルとして保存
    fs.writeFileSync('output.tsv', tsvContent);
    console.log('TSVファイルが保存されました: output.tsv');
  });
});

req.on('error', (e) => {
  console.error(`問題が発生しました: ${e.message}`);
});

req.end();
```

このコードではこれらの3つのチケットタイトルを含むJIRAチケットを探すということをおこなっています。

```javascript
[
  "Task - Feature A - Implement test cases",
  "Task - Feature B - Investigate logic",
  "Task - Feature C - Execute test plan",
]
```

チケットの名前がそれらと多少異なっていても、いい感じにマッチしてくれます。スコアが 0.1 以下であればほぼマッチしていると言っても差し替えないでしょう。

例えば下記のように末尾の文章が異なっていたりするとスコアが0.8などになったりします。

```javascript
"Task - Feature A - Implement test cases" "Task - Feature A - Execute Test"
```

この場合は検索に引っかかったけども別物である可能性が高い、と見なすことができます。結果セットにスコアを含めて目視確認やチェックスクリプトに利用するなどをすると良いでしょう。

JIRAのチケットタイトルを曖昧にしか覚えていない、そんなときに使うことができます。

## まとめ

今回の例では、APIのデータを取得し、Fuse.jsを使用して検索を行い、その結果をTSVファイルとして保存しました。Fuse.jsは設定がシンプルでありながら、強力な検索機能を提供してくれます。特にありがたいことはMySQLのようなRDBを用意せずとも、データが配列で、キーが振られている（振れる）のであればどんなデータにも対応してあいまい検索ができることです。

何かそういった場面に出くわしたら使ってみてください。