【JSの全文検索エンジン】elasticlunr.jsを触ってみた～インストールからパイプライン、日本語対応など～

elasticlunr.jsを使う機会があったのでまとめた。

# Elasticlunr.jsとは

JavaScriptで記述された、軽量な全文検索エンジン
ブラウザ上で動作する。

> 全文検索とは
> 複数の文書（ファイル）から特定の文字列を検索する事

Lunr.jsをベースに開発されている。
Lunr.jsと比べると、インデックスにトークンコーパスとベクターを含まないため、軽量で早い
（トークナイズ用の単語の辞書を持たないから軽量？この辺よくわかりませんでした。）

Elasticsearchやluceneと同じ採点メカニズムを採用している。

デフォルトでは日本語に対応していない。

# サンプルコード

実験用にelasticlunrだけを動かせるコードを以下のリポジトリにおいてます。
とりあえず動かしてみたい方はご活用ください。

[GitHub - elasticlunr-sample](https://github.com/t-kuni/elasticlunr-sample)

```bash:起動コマンド
git clone https://github.com/t-kuni/elasticlunr-sample.git
cd elasticlunr-sample
npm install
node index.js
```

# 基本的な使い方

めちゃシンプルなのでコード見た方が早い
これだけでインデックスの構築と検索ができる。

```js
const elasticlunr = require('elasticlunr');

// インデックス初期化
const index = elasticlunr(function () {
    this.addField('body');
    this.setRef('id');
});

// 文書追加
index.addDoc({
    "id": 1,
    "body": "apple block green",
});
index.addDoc({
    "id": 2,
    "body": "camera diary host"
});

// 検索
const r = index.search("camera", {
    fields: {
        body: {boost: 1},
    },
});

console.log(r);
// 出力： [ { ref: '2', score: 0.5773502691896258 } ]
```


# 主な機能

大まかに7つある。

* search index
* Query-Time boosting
* Boolean Model
* Token Expandation
* Tokenization
* Pipeline
* TF/IDF Model, Vector Space Model



## search index

全文検索に必要なインデックスを構築する。
文書追加(addDoc)時に、文書を解析してインデックスを構築する。

```js
// インデックス初期化
var index = elasticlunr(function () {
    this.addField('body');
    this.setRef('id');
});

// 文書追加
index.addDoc({
    "id": 1,
    "body": "Yestaday Oracle has released its new database Oracle 12g, this would make more money for this company and lead to a nice profit report of annual year."
});
```

## Query-Time boosting

elasticlunrでは、検索単語に対してどの程度マッチしているのかを表すスコアを算出する。
このスコアを算出する際の、フィールド毎の**重み**を設定できる。
`boost`プロパティを使用する。
インデックスの構築時ではなく、検索時に設定出来る。

```js
// 検索実行
const r = index.search("test", {
    fields: {
        title: {boost: 0}, // 重みを0にしたフィールドはスコア算出の対象外となる。
        body: {boost: 1},
    }
});
```




## Boolean Model

**OR検索**、**AND検索**ができる
検索時に`bool`プロパティで設定する。

```js
// 全てのフィールドに対してOR検索
const r = index.search("America Canada", {
    fields: {
        title: {boost: 1},
        body: {boost: 1},
    },
    bool: 'OR' // "America" "Canada"の2単語でOR検索します。（boolオプションを省略した場合もOR検索になります
});

// 全てのフィールドに対してAND検索
const r = index.search("America Canada", {
    fields: {
        title: {boost: 1},
        body: {boost: 1},
    },
    bool: 'AND' // 'America" "Canada"の2単語でAND検索します。
});

// titleフィールドに対してAND検索、それ以外はOR検索
const r = index.search("America Canada", {
    fields: {
        title: {boost: 1, bool: 'AND'},
        body: {boost: 1},
    },
    bool: 'OR'
});
```


## Token Expandation


単語の部分一致を含めるかどうか
例えば、当該機能を有効化して、"micro"で検索した場合、"microwave", "microscope"も一致した単語として扱われる。
この場合、部分一致した文書のスコアは完全一致した場合と比べて低くなる。
デフォルトは無効になっている。
検索時に`expand`プロパティで設定する


```js
const elasticlunr = require('elasticlunr');

// インデックス初期化
const index = elasticlunr(function () {
    this.addField('body');
    this.setRef('id');
});

// 文書追加
index.addDoc({
    "id": 1,
    "body": "microwave"
});
index.addDoc({
    "id": 2,
    "body": "microscope"
});
index.addDoc({
    "id": 3,
    "body": "micro"
});

// 部分一致を含めて検索
const r = index.search("micro", {
    fields: {
        body: {boost: 1},
    },
    expand: true,
});

console.log(r);
// 出力： [ { ref: '3', score: 1.4054651081081644 },
//          { ref: '1', score: 0.1317623538851404 },
//          { ref: '2', score: 0.11712209234234702 } ]

// 部分一致を含めず検索
const r2 = index.search("micro", {
    fields: {
        body: {boost: 1},
    },
    expand: false,
});

console.log(r2);
// 出力： [ { ref: '3', score: 1.4054651081081644 } ]
```

## Tokenization（分かち書き）

文書をトークンに分解すること。
デフォルトのトークナイザーは英語を扱える。

トークナイザを差し替えるには、`elasticlunr.tokenizer`関数を置き換える。

```js
const elasticlunr = require('elasticlunr');

elasticlunr.tokenizer = (field) => {
    /*
     * 引数について
     * field = 文書。フィールド単位で渡される。
     *
     * 返り値について
     * トークンの配列を返す。このトークンが後述のパイプラインで処理される。
     */
    return field.split(' ');
};
```


## Pipeline

**検索対象の文書**と**検索クエリ**に対して処理を実行する関数のスタック
文書追加時、または、検索時に**トークナイズ**→**パイプライン**の順で処理される。
デフォルトでは、英語用の**stop word filter**と**stemmer**が登録される。

* stop word filter
  * Stop wordとは、「は」「が」などの一般的に使われ、ドキュメントの検索に役にたたない単語の事。
  Stop word filterはこれらを除外する仕組み。
* stemmer
  * 語形の変化を取り除き、同一の単語表現に変換する処理のこと
  例えば、'searching', 'searched'は全て'search'となる。
  
主な用途としては、英語以外の言語に対応する場合に、上記２つを置き換えるのに使用する。
インデックス初期化時のクロージャの中で`this.pipeline.reset`、`this.pipeline.add`を使う。

```js
// インデックス初期化
const index = elasticlunr(function () {
    this.addField('body');
    this.setRef('id');

    this.pipeline.reset();
    this.pipeline.add(function (token, tokenIndex, tokens) {
        /*
         * 引数について
         * token      = 処理対象のトークン
         * tokenIndex = 処理対象のトークンの添え字番号
         * tokens     = トークンの配列
         *
         * 返り値について
         * 修正後のトークンを返す。nullを返すと、そのトークンはインデックスされなくなる（後続のパイプラインの処理も行われない）。
         */
        console.log(token, tokenIndex, tokens);
        return token;
    })
});
```

## TF/IDF Model, Vector Space Model

elasticlunrでは、検索単語に対してどの程度マッチしているのかを表すスコアを算出する。
このスコアを算出するための、いくつかの仕組みこと。

* TF/IDF Model（単語の出現頻度と逆文書頻度）
  * 文書中に含まれる単語の重要度を評価する手法
* Vector Space Model（ベクトル空間モデル）
  * 文書をベクトルとして表現し、2文書間の類似度を計算する

このメカニズムはElasticsearch、Luceneと同じ。詳しくは以下を参照してください。
[Luceneのスコアリングについて](https://www.elastic.co/guide/en/elasticsearch/guide/current/practical-scoring-function.html)




# 日本語対応について

デフォルトでは、英語での検索しか出来ない。
多言語対応プラグインを組み込めば、日本語で検索できる様になる。

lunrの多言語対応プラグインをelasticlunr向けにフォークした物を使用する。

[weixsong/lunr-languages](https://github.com/weixsong/lunr-languages/blob/master/lunr.jp.js)

※ lunr-languagesリポジトリは２つあるので注意。
`MihaiValentin/lunr-languages`はlunr用であり、elasticlunrで使用するとエラーが発生する。
`weixsong/lunr-languages`を使用する必要がある。

## インストール方法

`weixsong/lunr-languages`の、以下２つのファイルを自身のプロジェクトにコピーする。

* lunr.stemmer.support.js
* lunr.jp.js

またlunr.jp.jsがTinySegmenterに依存しているため、こちらもダウンロードして自身のプロジェクトにコピーする。

[TinySegmenter](http://chasen.org/~taku/software/TinySegmenter/tiny_segmenter-0.2.js)

完成形は[GitHub - elasticlunr-sample](https://github.com/t-kuni/elasticlunr-sample)にあるので参考にしてください。

#### 修正箇所

TinySegmenterがrequire()に対応していないため、Nodeで使う場合は、以下の通り修正が必要。
require()で呼び出せる様に、末尾に`module.exports`を追加する

```diff:tiny_segmenter-0.2.js:170
    p3 = p;
    word += seg[i];
  }
  result.push(word);

  return result;
}

+ module.exports = TinySegmenter;
```

同様に呼び出し側(`lunr.jp.js`)もrequireに修正する。

```diff:lunr.jp.js:62
            // change the tokenizer for japanese one
            lunr.tokenizer = lunr.jp.tokenizer;
        };
-        var segmenter = new lunr.TinySegmenter();  // インスタンス生成
+        var segmenter = new (require('./tiny_segmenter-0.2.js'))();  // インスタンス生成

        lunr.jp.tokenizer = function (obj) {
```

## サンプルコード

```js:index.js
const elasticlunr = require('elasticlunr');
require('./lunr.stemmer.support.js')(elasticlunr);
require('./lunr.jp.js')(elasticlunr);

// インデックス初期化
const index = elasticlunr(function () {
    this.use(elasticlunr.jp); // 使用言語の指定
    this.addField('body');
    this.setRef('id');
});

// 文書追加
index.addDoc({
    "id": 1,
    "body": "カメラ　犬"
});
index.addDoc({
    "id": 2,
    "body": "本棚　猫"
});
index.addDoc({
    "id": 3,
    "body": "水　刺身"
});

// 日本語で検索
const r = index.search("本", {
    fields: {
        body: {boost: 1},
    },
    expand: true,
});

console.log(r);
// 出力： [ { ref: '2', score: 0.10540988310811232 } ]
```

## まとめ

必要最小限の全文検索エンジン、という感じのライブラリなので
全文検索のコア部分の概要だけ抑えたい～みたいな人の学習用に良いんじゃないかと思った。