Azure Cognitive Search で日本語全文検索をするためのアナライザー実装メモ

# はじめに
[Azure Cognitive Search](https://www.youtube.com/watch?v=9GcjFofXdd0) は オープンソースの全文検索エンジンライブラリである Apache Lucene をベースに開発されており、多くの有用なモジュールを Cognitive Search に取り入れています。その中には日本語アナライザーも含まれており、Cognitive Search では日本語アナライザーとして、Lucene の日本語アナライザーと Microsoft Bing をベースとしたアナライザーの 2 種類から選ぶことができます。

パートナー様からよく質問されるのが、Lucene と Microsoft のどちらのアナライザーを利用したほうがいいのかというものです。Microsoft のアナライザーは詳細な仕様が公開されていないということもあり、詳細を説明することが難しいので、仕様が公開されている Lucene の日本語アナライザーの構造をベースに違いやメリットを解説していきたいと思います。

# 目次
1. [アナライザーとは](#アナライザーとは)
1. [Lucene 日本語アナライザー](#lucene-日本語アナライザー)
    1. [JapaneseBaseFormFilter(JBFF)](#1-japanesebaseformfilterjbff)
    1. [JapanesePartOfSpeechStopFilter(JPOSSF)](#2-japanesepartofspeechstopfilterjpossf)
    1. [CJKWidthFilter(CJKWF)](#3-cjkwidthfiltercjkwf)
    1. [StopFilter(SF)](#4-stopfiltersf)
    1. [JapaneseKatakanaStemFilter(JKSF)](#5-japanesekatakanastemfilterjksf)
    1. [LowerCaseFilter(LCF)](#6-lowercasefilterlcf)
    1. [Lucene 日本語アナライザーまとめ](#lucene-日本語アナライザーまとめ)
1. [Microsoft 日本語アナライザー](#microsoft-日本語アナライザー)
1. [カタカナに関する差異](#カタカナに関する差異)
1. [数値に関する差異](#数値に関する差異)
1. [カスタムアナライザー](#カスタムアナライザー)
1. [カスタムアナライザーの実装](#カスタムアナライザーの実装)
1. [使えそうなフィルター](#使えそうなフィルター)
    1. [stopwords](#1-stopwords)
    1. [asciifolding](#2-asciifolding)
    1. [mapping](#3-mapping)

# アナライザーとは
アナライザーについてのわかりやすい解説は、畠山さんの [Azure Search 大全](https://www.slideshare.net/dahatake/azure-search-114345418)の P.80～を見ていただければよいと思います。Azure Cognitive Search における**アナライザーとは、文字フィルター、トークナイザー、トークンフィルターをまとめたもの**です。インデックスの各フィールドに対して、アナライザーを 1 つセットすることもできますし、要件に合わせて文字フィルターやトークナイザー、トークンフィルターを別々にセットすることもできます。

# Lucene 日本語アナライザー
Lucene 日本語アナライザーは Apache Lucene に含まれており、処理内容は `managed-schema` ファイルに記述されています。文字列の処理は、1 つのトークナイザーと、そのあとに複数のトークナイズフィルターの処理が順番に行われます。Azure Cognitive Search では、以下の処理内容が `ja.lucene` として**そのまま実装**されています。

Apache Lucene ベースの全文検索エンジンである、Apache Solr の analysis 機能を使うと、各フィルターごとにどのように文字列が処理されていくかを可視化することができます。Solr で `"text_ja"` の設定でトークナイズとフィルターを掛けると、最初に `JapaneseTokenizer(JT)` が文章を単語ごとに分割した後、6 つのフィルターが上から順番に単語を変換したり削除したりしていきます。

```
私はﾏｲｸﾛｿﾌﾄのＡｚｕｒｅコンテナーを使おうと思っていた。
```

上のテスト用文章を、Solr の analysis に掛けると以下のように字句解析されます。

| Filter |1|2|3|4|5|6|7|8|9|10|11|12|13|14|
|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--
|JT|私 |は|ﾏｲｸﾛｿﾌﾄ|の|Ａｚｕｒｅ|コンテナー|を|使お|う|と|思っ|て|い|た
|JBFF|私 |は|ﾏｲｸﾛｿﾌﾄ|の|Ａｚｕｒｅ|コンテナー|を|使う|う|と|思う|て|いる|た
|JPOSSF|私 ||ﾏｲｸﾛｿﾌﾄ||Ａｚｕｒｅ|コンテナー||使う|||思う||いる|
|CJKWF|私 ||マイクロソフト||Azure|コンテナー||使う|||思う||いる|
|SF|私 ||マイクロソフト||Azure|コンテナー||使う|||思う|||
|JKSF|私 ||マイクロソフト||Azure|コンテナ||使う|||思う|||
|LCF|私 ||マイクロソフト||azure|コンテナ||使う|||思う|||

最終的にインデックスに登録される単語は、上表の一番下に残った `6` つの単語となります。
それでは、各フィルターの効果について解説していきます。

| Filter | Tokenizer/Filter | 説明 | 例
|:--|:--|:--|:--
|JT|JapaneseTokenizer|単語ごとに分かち書きをする日本語トークナイザー|私は→私 は
|JBFF|JapaneseBaseFormFilter|動詞や形容詞を基本形に変換|思っ→思う
|JPOSSF|JapanesePartOfSpeechStopFilter|特定の品詞タグを持つトークンを削除|は→×
|CJKWF|CJKWidthFilter|全角ローマ字を半角に、半角カナを全角に変換|ＡＢＣ→ABC
|SF|StopFilter|ストップワードを削除|いる→×
|JKSF|JapaneseKatakanaStemFilter|最後の長音文字（U+30FC）を削除|コンテナー→コンテナ
|LCF|LowerCaseFilter|英字の大文字を小文字に変換|ABC→abc

\* managed-schema より。

## 1, JapaneseBaseFormFilter(JBFF)
JapaneseBaseFormFilter は日本語の動詞や形容詞を基本形に変換するフィルターです。
ここでは、`使お → 使う`　や、`思っ → 思う`　が変換されています。

## 2, JapanesePartOfSpeechStopFilter(JPOSSF)
特定の品詞タグを持つトークンを削除するフィルターです。
削除する品詞は、Solr ディレクトリの `example/solr/conf/lang/stoptags_ja.txt` で定義されています。（Azure Cognitive Search にはこのファイルを編集する機能はありません）
以下の定義に従って、接続詞、助詞、助動詞などが削除されることが分かります。

```text
接続詞
助詞
助詞-格助詞
助詞-格助詞-一般
助詞-格助詞-引用
助詞-格助詞-連語
助詞-接続助詞
助詞-係助詞
助詞-副助詞
助詞-間投助詞
助詞-並立助詞
助詞-終助詞
助詞-副助詞／並立助詞／終助詞
助詞-連体化
助詞-副詞化
助詞-特殊
助動詞
記号
記号-一般
記号-読点
記号-句点
記号-空白
記号-括弧開
記号-括弧閉
その他-間投
フィラー
非言語音
```

ここでは、`は（助詞-係助詞）`、`を（助詞-格助詞-一般）`、`た（助動詞）`などが削除されています。

## 3, CJKWidthFilter(CJKWF)
全角ローマ字を半角に、半角カナを全角に正規化します。
ここでは、`Ａｚｕｒｅ → Azure`　や、`ﾏｲｸﾛｿﾌﾄ → マイクロソフト`　が変換されています。

## 4, StopFilter(SF)
一般的に検索には役に立たないが、ランキングには悪影響を与えるような単語「ストップワード」を削除します。Solr では日本語の Wikipedia から頻出する用語を厳選していることが、[LUCENE-3745](https://issues.apache.org/jira/browse/LUCENE-3745) の投稿から分かります。削除するワードは、Solr ディレクトリの`example/solr/conf/lang/stopwords_ja.txt` で定義されています。（Azure Cognitive Search にはこのファイルを編集する機能はありません）

```
# 定義の一部
の
に
は
を
た
が
で
て
と
し
れ
さ
ある
いる
も
する
から
な
こと
...
```

ここでは、定義にもあるように、`いる`が削除されています。

## 5, JapaneseKatakanaStemFilter(JKSF)
最後の長音文字（U+30FC）を削除することで、一般的なカタカナ表記のバリエーションを正規化します。デフォルトで 4 文字以上のカタカナの長音を除去します。

ここでは、`コンテナー → コンテナ`　が変換されています。

## 6, LowerCaseFilter(LCF)
英字の大文字を小文字に変換します。
ここでは、`Azure → azure`　が変換されています。

## Lucene 日本語アナライザーまとめ
以上の文字処理によって、最終的に以下のようになります。

```
私はﾏｲｸﾛｿﾌﾄのＡｚｕｒｅコンテナーを使おうと思っていた。
私 マイクロソフト azure コンテナ 使う 思う 
```

Lucene 日本語アナライザーの特徴として、**各単語の語幹（と活用語尾を含めた基本形）処理や、品詞やストップワードによる削除が積極的に適用される**アナライザーであることが分かりました。今回の例文では最終的にトークン化される単語は 6 つに絞られます。

また、Azure Cognitive Search のトークン化を可視化するための REST API である [Analyze API](https://docs.microsoft.com/rest/api/searchservice/test-analyzer) を使って上記の結果と全く同じ結果を得ることができます。（残念ながら Solr のようにフィルターごとに字句解析の過程を示してくれるわけではありませんが）
以下の JSON を Analyze API へ POST してください。

```json
{
  "text":"私はﾏｲｸﾛｿﾌﾄのＡｚｕｒｅコンテナーを使おうと思っていた。",
  "analyzer": "ja.lucene"
}
```

Azure Cognitive Search では、Lucene 日本語アナライザーに含まれるフィルターを編集することはできません。現状の実装渡しとなります。ですので、**アナライザーをカスタマイズしたい場合は、後述のカスタムアナライザーの利用を検討してください。**

# Microsoft 日本語アナライザー
Microsoft 日本語アナライザーは、Office および Bing で使用される Microsoft 独自の自然言語処理技術によって提供されるアナライザーです。[公式の Docs](https://docs.microsoft.com/azure/search/index-add-language-analyzers#comparing-lucene-and-microsoft-analyzers) では、一応書かれている特徴としては、以下のようなものがあります。

- Lucene よりは低速
- ステミングではなくレンマ化
- エンティティ認識 (URL、電子メール、日付、数値) 

実際のところ日本語でどのような処理を行っているのかは不明ですので、これまで Lucene で行ったような分析から逆に推定してみようと思います。Microsoft アナライザーでのトークナイズの結果は、[Analyze API](https://docs.microsoft.com/rest/api/searchservice/test-analyzer) から得ることができます。

以下の JSON を [Analyze API](https://docs.microsoft.com/rest/api/searchservice/test-analyzer) へ POST してください。

```json
{
  "text":"私はﾏｲｸﾛｿﾌﾄのＡｚｕｒｅコンテナーを使おうと思っていた。",
  "analyzer": "ja.microsoft"
}
```

そうすると、以下のような結果が得られます。比較のため、下段に Lucene 日本語アナライザーの結果を入れています。

|analyzer|1|2|3|4|5|6|7|8|9|10|11|12|
|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|
|ja.micosoft|私 |ﾏｲｸﾛｿﾌﾄ|マイクロソフト|ａｚｕｒｅ|azure|コンテナー|コンテナ|使おう|思っ|て|い|た|
|ja.lucene|私 ||マイクロソフト||azure||コンテナ|使う|思う|||

ここから以下が分かります。

 - 動詞は**基本形に変換しない**。`使おう`、`思っ`
 - **動詞、助詞、助動詞の削除のしかたに違い**がある。`い（動詞-非自立）`、`て（助詞-接続助詞）`、`た（助動詞）`を残す。
 - 全角ローマ字を半角に、半角カナを全角に変換し、**元を残す**。
 - 最後の長音文字（U+30FC）を削除し、**元を残す**。
 - 英字の大文字を小文字に変換

`ja.micosoft` は `ja.lucene` と比べて元の単語を結構残すことが分かりました。これは、ワイルドカードやあいまい検索時のヒット数を上げる効果がありますがその反面、検索ノイズが増えてしまう可能性もあります。

Azure Cognitive Search の Full Lucene クエリパーサーの仕様上、ワイルドカードを使って部分一致検索を行った場合は**[字句解析されません](https://docs.microsoft.com/azure/search/query-lucene-syntax#impact-of-an-analyzer-on-wildcard-queries)**。したがって上記の例では、クエリーを `search=ﾏｲｸﾛ*` とした場合、`ja.lucene` ではヒットしませんが、`ja.microsoft` ではヒットするという差異が出ます。部分一致検索を多用するようなシナリオでは、`ja.microsoft` をおすすめします。

# カタカナに関する差異
カタカナの処理のしかたに違いがあることが分かります。

```
アナライザー、インデクサー、トークナイザー、トークンフィルター
```

|analyzer|1|2|3|4|5|6|7|8|9|
|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|
|ja.micosoft|アナライザー|アナライザ|インデクサー|インデキサ|トークナイザー|トクナイザ|トークン|フィルター|フイルタ|
|ja.lucene||アナライザ||インデクサ||トークナイザ|トーク|トークンフィルタ|ンフィルタ|

`ja.micosoft` は語尾の長音記号を削除するだけでなく、`トクナイザ` のように語中の長音記号も削除してしまっていることが分かります。特徴的なのが、`インデクサ` ではなく、`インデキサ` になっていたり、`フィルタ` が `フイルタ` に変換されていることが分かります。フイルムとかキヤノン的なやつでしょうか。

`ja.lucene` は必ず語尾の長音記号のみを削除します。`トークンフィルター` の部分の分かち書きが上手くいっていないようです。`トーク（名詞-一般）`、`トークンフィルター（名詞-固有名詞-組織）`、`ンフィルター（名詞-一般）` に分割されてしまいます。これは Lucene のベースとなっている [Kuromoji](https://www.atilika.com/ja/kuromoji/) 側の問題です。未知語の処理の仕方から、`SEARCH MODE` が実装されていることが分かります。

# 数値に関する差異
数値の処理のしかたに違いがあることが分かります。

```
2019年4月25日、史上3社目となる時価総額1兆ドル突破。
```

|analyzer|1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|
|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|
|ja.micosoft|2019年||4月||25日||史上|3社目|||なる|時価|総額|1兆ドル|||突破
|ja.lucene|2019|年|4|月|25|日|史上|3|社|目||時価|総額|1|兆|ドル|突破

`ja.micosoft` は公式 Docs に記載されている通り、エンティティ認識（日付、数値）が行われていることが分かります。ちなみにメールアドレスは一つながりと分割のセットで、URL は分割されてしまいました。

`ja.lucene` は数値とそれ以外をはっきり分割します。`ja.micosoft` のようなエンティティ認識は行われません。

# カスタムアナライザー
Azure Cognitive Search のカスタム アナライザーを使用すると、呼び出すトークナイザーまたはフィルター処理の種類と、実行順序を選択できるので、トークンに変換するまでのプロセスを自分で制御できます。

どこまでカスタムが可能なのでしょうか。カスタムアナライザーの全リストは[こちら](https://docs.microsoft.com/azure/search/index-add-custom-analyzers)にありますが、よく使われるものとして以下が考えられます。

1. [文字フィルター](https://docs.microsoft.com/azure/search/index-add-custom-analyzers#character-filters)
    1. html_strip
    1. mapping
    1. pattern_replace
1. [トークナイザー](https://docs.microsoft.com/azure/search/index-add-custom-analyzers#tokenizers)
    1. microsoft_language_tokenizer
    1. nGram
    1. keyword_v2
1. [トークンフィルター](https://docs.microsoft.com/azure/search/index-add-custom-analyzers#token-filters)
    1. asciifolding
    1. cjk_width
    1. stopwords
    1. synonym
    1. lowercase
    1. ...etc

**日本語でも使えるトークナイザーとして、Microsoft の言語アナライザーに含まれる言語トークナイザー（microsoft_language_tokenizer）が用意されています。**これは形態素解析を用いたトークナイザーですが、形態素解析だけでは検索の要件を満たせない場合（新語や辞書に無い単語が多い等）は nGram トークナイザーの利用を検討してください。また、keyword_v2 は分かち書きを一切しないフィールド（構造化テーブルのIDなどを部分一致検索する場合などに）に使います。フィルターの多くは Apache Lucene のライブラリを取り込んでいます。

## microsoft_language_tokenizer 

ベースとなる microsoft_language_tokenizer がどのようにトークナイズするかを以下に示します。

|1|2|3|4|5|6|7|8|9|10|11|12|12|12|12|12|
|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|:--|
|私|は|ﾏｲｸﾛｿﾌﾄ|マイクロソフト|の|Ａｚｕｒｅ|Azure|コンテナー|コンテナ|を|使おう|と|思っ|て|い|た|

トークナイズの時点で全角ローマ字を半角に、半角カナを全角に変換し、最後の長音文字（U+30FC）を削除して元を残す処理を行っていることが分かります。ここに、独自のストップワードの指定や文字の変換フィルターなどを組み合わせていく流れになります。

ここでも気を付けていただきたいのが、Full Lucene クエリパーサーを使用して部分一致や正規表現検索を行う場合で、仕様上クエリパーサーは字句解析を行いませんが、**自動的に英大文字を小文字に変換**します。上記トークナイザーのみで実装してしまうと、例えば `Azure` の部分が検索で引っかからなくなってしまいますから、必ず最後に `lowercase` フィルターをかませて小文字に変換したインデックスを作成してください。これは `keyword` アナライザーを使用して英数の ID を部分一致検索するようなケースにも当てはまります。その場合は `keyword_v2` トークナイザーを実装したカスタムアナライザーを作成する必要があります。

# カスタムアナライザーの実装

カスタムアナライザーをインデックスに登録する際の JSON 定義の例を以下に示します。`analyzers[]` の中で カスタムアナライザーを定義し、その中で、`charFilters[]`、`tokenizer`、`tokenFilters[]` を順番に定義していきます。オプション値を必要とするものに関しては、`analyzers[]` と同じレイヤーで定義してから呼ぶ必要があります。`lowercase` のようにオプション値が不要なものは直接記述することができます。

```json
    "analyzers": [
        {
            "@odata.type": "#Microsoft.Azure.Search.CustomAnalyzer",
            "name": "CustomJapaneseAnalyzer",
            "charFilters": [],
            "tokenizer": "my_japanese_tokenizer",
            "tokenFilters": ["my_japanese_stopwords","lowercase"]
        }
    ],
    "charFilters": [],
    "tokenFilters": [
        {
            "name": "my_japanese_stopwords",
            "@odata.type": "#Microsoft.Azure.Search.StopwordsTokenFilter",
            "stopwords": ["の","に","は","を","た"]
        }
    ],
    "tokenizers": [
        {
            "name": "my_japanese_tokenizer",
            "@odata.type": "#Microsoft.Azure.Search.MicrosoftLanguageTokenizer",
            "language": "japanese"
        }
    ],
```

上記の定義ができたら、フィールド定義の `analyzer` にセットするだけです。

```json
   {
      "name": "content",
      "type": "Edm.String",
      "facetable": false,
      "filterable": false,
      "key": false,
      "retrievable": true,
      "searchable": true,
      "sortable": false,
      "analyzer": "CustomJapaneseAnalyzer",
      "indexAnalyzer": null,
      "searchAnalyzer": null,
      "synonymMaps": [],
      "fields": []
    },
```

REST API 経由でインデックスを登録する方法は[こちらの記事](https://qiita.com/nohanaga/items/633b4f3d4ac77154d286#2-%E3%82%A4%E3%83%B3%E3%83%87%E3%83%83%E3%82%AF%E3%82%B9%E3%81%AE%E4%BD%9C%E6%88%90)を参照してください。

# 日本語でも使えるフィルター
以下に日本語でも使えるフィルターをいくつか紹介します。

## 1, stopwords
ストップワードを削除するトークンフィルター。既定では、フィルターに英語の定義済みストップワードリストが指定されている（日本語の定義済みリストはありません）ため、`stopwords[]` パラメータに文字列の配列として、日本語ストップワードのリストを直接記述します。

Solr のストップワードリスト `example/solr/conf/lang/stopwords_ja.txt` から抽出してくれば Lucene 日本語アナライザーと似たような結果が得られるかもしれません。

```json
    "tokenFilters": [
        {
            "name": "my_japanese_stopwords",
            "@odata.type": "#Microsoft.Azure.Search.StopwordsTokenFilter",
            "stopwords": ["の","に","は","を","た"]
        }
    ],
```

## 2, asciifolding
ASCII の最初の 127 文字 ("基本ラテン" Unicode ブロック) に含まれないアルファベット、数字、記号の Unicode 文字が、同等の ASCII に変換されます (ある場合)。
例えば、`①②③ → 123`、`ᴁ → AE`、`ａｂｃ → abc`
`preserveOriginal` が `true` の場合、元のトークンが保持されます。 既定値は `false` です。

```json
   "tokenFilters": [
        {
            "@odata.type": "#Microsoft.Azure.Search.AsciiFoldingTokenFilter",
            "name": "my_japanese_asciifolding",
            "preserveOriginal": false
        }
    ],
```

## 3, mapping
マッピング オプションで定義されたマッピングに基づき、文字単位で置き換える文字フィルター。
**文字フィルターはトークナイズ処理の前**に実行されます。
例えば、`齊 → 斉`、`ゑ → え`、`壱 → 一` など。

```json
  "charFilters": [
         {
           "name":"my_japanese_mapping",
           "@odata.type":"#Microsoft.Azure.Search.MappingCharFilter",
           "mappings":["齊=>斉", "壱=>一"]
        }
  ],
```

ここまで搭載されているのに、Lucene の JapaneseTokenizer が実装されていないのが悲しい。結局のところ、Lucene に対応するトークナイザーやフィルターが用意されているのかというと、以下の対応表となります。

|Apache Lucene(Solr)| Azure Cognitive Search| 日本語対応
|:--|:--|:--
|JapaneseTokenizer|microsoft_language_tokenizer|〇
|JapaneseBaseFormFilter|なし stemmer?|×
|JapanesePartOfSpeechStopFilter|なし|×
|CJKWidthFilter|cjk_width|〇
|StopFilter|stopwords|〇
|JapaneseKatakanaStemFilter|microsoft_language_tokenizer|〇
|LowerCaseFilter|lowercase|〇

現状このような差異があるので、利用ニーズに応じて選択すればよいと思います。Azure Cognitive Search では、インデクサーの出力フィールドマッピングで同じデータを複数フィールドに振り分けられるので、"ja.lucene" や "ja.microsoft" アナライザーの併用やフィールドの重み付けによるチューニングも検討できます。

ニーズがあれば、この辺もう少し詳しく書こうかな…