l12a

白ウサギを追え

あいまい検索ライブラリ Fuse.js を試す

Jascript で曖昧検索ができるライブラリ Fuse.js を試します。
不正確な入力でも近いと思われるものを候補に出すのが曖昧検索です。
実装次第でインクリメンタルサーチ的に結果を出すこともできます。

fusejs.io

ライセンス

ライセンスは Apache 2.0 で、商用にも利用できます。
いいですね。

Typescript サポート

Typescript をサポートしており、型定義を利用できます。

動作環境

Fuse.js 自体は特にブラウザで動作することなどを前提にしていないため、node.js でも使えます。

試す

公式ドキュメントに紹介されているサンプルデータを使って、実際に曖昧検索機能を作ってみます。

インストール

例によって。

$ npm install fuse.js

検索対象データの定義

検索に使うデータは以下のように定義しました。

type Book = {
  title: string;
  author: {
    firstName: string;
    lastName: string;
  };
}

キーや階層構造は任意の形をとることができ、またどのキーを検索対象とするかもオプションで設定できます。

実際のデータ

実データは以下のようになります。

items: [
        {
          title: "Old Man's War",
          author: {
            firstName: "John",
            lastName: "Scalzi"
          }
        },
        {
          title: "The Lock Artist",
          author: {
            firstName: "Steve",
            lastName: "Hamilton"
          }
        },
        {
          title: "HTML5",
          author: {
            firstName: "Remy",
            lastName: "Sharp"
          }
        },
        {...}
]

オプション定義と初期化

以下のように初期化します。
使用しているオプションはこれが全てではありません。

import Fuse from 'fuse.js';

var fuse = new Fuse(this.items, {
      shouldSort: true,
      threshold: 0.6,
      location: 0,
      distance: 100,
      includeScore: true,
      minMatchCharLength: 1,
      keys: [
        "title",
        "author.firstName"
      ]
})

各オプションは、

  • shouldSort
    • 検索結果をソートするかの指定
  • threshold
    • 検索結果除外の閾値(0.0 ~ 1.0)
    • 0に近いほど、検索条件と一致した結果のみに絞り込みます
  • location
    • 検索条件が、対象文字列のどこに一致するか
    • スコアに関わります
  • distance
    • location からの距離(文字数)です。
    • 例えば location/distance 共に0であれば、文字列の先頭にのみヒットします。
  • includeScore
    • どれほど検索条件にマッチしているのかを点数化したものが得られます。
    • 0に近いほど、検索条件に一致しています。
    • 検索結果次第で
  • minMatchCharLength
    • 検索条件の文字列が何文字以上含まれているかの条件指定です。
  • keys
    • 対象とするキーの指定です。ドット繋ぎ形式で、多重構造のオブジェクトに対応します。

検索実行

result = fuse.search("Foo")

Vue.js を使って構築したサンプル

Vue.js を使ってUIを作ったサンプルは以下のようになります。 データ部分は長いので省略しました。

<template>
  <div id="app">
    <h1>Study fuse.js</h1>
    <input type="text" placeholder="enter keyword" v-model="searchKeyword">
    <table >
      <thead>
      <tr>
        <th>Title</th><th>Author</th><th>Score</th>
      </tr>
      </thead>
      <tbody v-if="result.length === 0">
      <tr v-for="(v, i) in items" :key="i">
        <td>{{v.title}}</td>
        <td>{{`${v.author.firstName} ${v.author.lastName}`}}</td>
        <td>-</td>
      </tr>
      </tbody>
      <tbody v-else>
      <tr v-for="(v, i) in result" :key="i">
        <td>{{v.item.title}}</td>
        <td>{{`${v.item.author.firstName} ${v.item.author.lastName}`}}</td>
        <td>{{v.score.toFixed(4)}}</td>
      </tr>
      </tbody>
    </table>
  </div>
</template>

<script lang="ts">
import Vue from 'vue';
import Fuse, {FuseResult} from 'fuse.js';

type Book = {
  title: string;
  author: {
    firstName: string;
    lastName: string;
  };
}

let fuse: Fuse<Book, any>

export default Vue.extend({
  name: 'App',
  data (): {
    searchKeyword: string;
    items: Book[];
    result: FuseResult<Book>[];
  } {
    return {
      searchKeyword: '',
      items: [
        {
          title: "Old Man's War",
          author: {
            firstName: "John",
            lastName: "Scalzi"
          }
        },
        {
          title: "Monster 1959",
          author: {
            firstName: "David",
            lastName: "Maine"
          }
        }
      ],
      result: []
    }
  },
  watch: {
    searchKeyword(){
      this.result = fuse.search(this.searchKeyword)
    }
  },
  mounted(): void {
    fuse = new Fuse(this.items, {
      shouldSort: true,
      threshold: 1.0,
      location: 0,
      distance: 100,
      includeScore: true,
      minMatchCharLength: 1,
      keys: [
        "title",
        "author.firstName"
      ]
    })
  }
});
</script>

<style lang="scss">
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 40px;
}
  table {
    margin: 0 auto;
    border-collapse: collapse;
  }

  td, th {
    border: solid 1px;
    padding: 1px 10px;
  }

  th {
    background-color: #ccc;
  }

  input {
    margin: 0 0 20px;
  }
</style>

終わり

依存するものが少なく、設定もしやすいので使いやすいと思いました。 どれほど検索条件をきつく(あるいはゆるく)するかは各オプションを使ってチューニングしていくことになると思います。