日本語用オートコンプリートのためのAnalyzer

Posted by johtani on Tuesday, August 9, 2022

目次

風のうわさで、日本語用のオートコンプリートのためのTokenFilterとAnalyzerがLuceneに取り込まれたと聞きました(LUCENE-10102)。 Elasticsearchでも使えるかなぁ?ということで調べたところ(調べた?聞いた?)、どうやら8.1から利用できるようになっている(GitHub Issue #81858)みたいです(まだ、公式ドキュメントには記載がないのですが)。

8/17追記

作者の打田さんがブログ書いてたの見落としてた(もしくは見たけど忘れてた)ので貼っておきます。マルチテナンシー下での Query Auto Completion 設計・運用戦略 - LegalForce Engineering Blog

ということで、こんな感じで使えるよというのを試してみました。

どういうもの?

日本語入力方法を考慮したオートコンプリート用のトークンを生成してくれるTokenFilterと、 それをLuceneのSuggesterで動くようにしたAnalyzerが用意されています。 Elasticsearchでは、Kuromojiプラグインにそれらを使えるようになっています(バージョン8.1.0以降)。 KuromojiTokenizerと一緒に利用する前提の仕組みとなります。

オートコンプリートとは?

検索窓などでキー入力をしていると、入力した文字で始まる単語の一覧が現れることがあると思います。 あの機能がオートコンプリートと呼ばれるものです。search-as-you-typeとも呼ばれることもあります。 (検索ペンギン本にも書いてあるので興味のある方はぜひ!(ステマ)) 入力された文字列を含むパターンもありますが、今回紹介するものは入力された文字で始まる単語を見つけてくる機能になります。

何がなんで追加されたの?

日本語の入力方法は、かな入力の人もいればローマ字入力の人もいます。 ですので、オートコンプリートでの入力として、「しゃ」という入力が来る場合もあれば、「sha」というローマ字入力途中のものが来る場合もあります。 これらを考慮したトークンの扱いができるように、 JapaneseCompletionFilterというクラスが追加されています。 内部的にはKuromojiTokenizerでトークンに分割された後に、もともとのトークンと同じポジションで読みをローマ字にしたものを出力します。 JapaneseCompletionAnalyzerは上記Filterをすぐに使えるようにしたAnalyzerです。

動かし方

百聞は一見に如かずということで、Elasticsearchでの使い方を見たほうが分かりやすいので、簡単に動かしてみることにします。

インストールとインデックスの用意

Elasticsearch(8.1以上)とKuromojiのプラグインをインストールします(今回試したのは8.3.1)。

サンプルデータ

なにかいいデータはないものか?と思っていたところ夏休みだというのを思い出しました。 夏休みといえば読書感想文だ、ということで、書籍のタイトルがいいかもなと。 青空文庫の著者名と書籍のタイトルの一覧を見つけたのでそちらのデータを使って試してみました。

CSV to JSON

CSVファイルは最後の参考にある青空文庫のページから、「公開中 作家別作品一覧:全て(CSV形式、zip圧縮)」をダウンロードしたものを利用しました。 CSVファイルだったので、jqコマンドで必要な部分だけをNDJSONの形でとりあえずファイルに出力します。

cat list_person_all_utf8.csv | tr -d '"' | jq -c -R 'split(",") | {"auth_id": .[0] | tonumber, "author": .[1], "id": .[2] , "title": .[3], "suggest_ja": [.[3], .[1]]}'

次のようなJSONが1行ずつ出力されます。

{"auth_id":1257,"author":"アーヴィング ワシントン","id":"056078","title":"駅伝馬車","suggest_ja":["駅伝馬車","アーヴィング ワシントン"]}

suggest_jaがオートコンプリートの対象となるデータになります。今回は著作と著者にしてみました。 あとは次に紹介するスキーマでインデックスを作成して、適当なプログラムを使ってBulkでElasticsearchに登録しましょう(私は個人作のツールで入れました)。

インデックス

オートコンプリート用のSuggesterを利用するための特殊なフィールド(completion)を利用したフィールドを用意します。 ちなみに、今回はLUCENE-10102に記載があった設定をそのまま使わせていただきました。 そのほかのフィールドは今回は特に必要はないのでおまけです。

PUT aozora_index
{
  "mappings": {
    "properties": {
      "suggest_ja": {
        "type": "completion",
        "analyzer": "japanese_completion_index",
        "search_analyzer": "japanese_completion_query",
        "preserve_separators": false,
        "preserve_position_increments": true,
        "max_input_length": 50
      },
      
      "auth_id": {
        "type": "long"
      },
      "author": {
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        },
        "analyzer": "kuromoji"
      },
      "author_id": {
        "type": "integer"
      },
      "id": {
        "type": "integer"
      },
      "title": {
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        },
        "analyzer": "kuromoji"
      }
    }
  },
  "settings": {
    "index": {
      "number_of_shards": "1",
      "analysis": {
        "analyzer": {
          "japanese_completion_index": {
            "mode": "index",
            "type": "kuromoji_completion"
          },
          "japanese_completion_query": {
            "mode": "query",
            "type": "kuromoji_completion"
          }
        }
      }
    }
  }
}

動作確認

ここまでで、インデックスとデータの用意ができました。 オートコンプリートを実現するためにはElasticsearchのSuggesterという機能の、Completion Suggesterを利用します。検索の仕方が通常のものとは少し異なります。 ちなみに、速度重視のためにインメモリで動いているとの記載がドキュメントにあります。

Completion Suggesterのクエリ

Suggester用のクエリがあるのでこちらを使います。 例えば、「wagah」という入力がある時に、サジェストする内容を取得するには次のようなリクエストになります。

GET aozora_index/_search
{
  "suggest": {
    "title-suggest": {
      "prefix": "wagah",
      "completion": {
        "field": "suggest_ja"
      }
    }
  },
  "_source": ["title", "author"]
}
  1. 最初の「suggest」がsuggest用のパラメータを意味しています。
  2. 次の「title-suggest」は好きな名前をつけられます。レスポンスにこの名前がついた配列がサジェストの結果になります(同時に3. 複数suggestを呼び出せるので、対応した結果が分かるように名前が付けられます)。
  3. 「prefix」に入力の文字列を渡します。
  4. 「completion」がsuggesterのタイプの指定です。今回はCompletion Suggesterなので「completion」を指定します。その中に「field」でcompletionに利用するフィールド名を指定します。先ほどのスキーマでcompletionタイプを指定したフィールドです。今回は「suggest_ja」になります。
  5. 「_source」は結果を見やすくするために、ヒットしたデータの一部だけ取得するオプションです。

すると、次のような結果が返ってきます。

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 0,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "suggest": {
    "title-suggest": [
      {
        "text": "wagah",
        "offset": 0,
        "length": 5,
        "options": [
          {
            "text": "『吾輩は猫である』中篇自序",
            "_index": "aozora_index",
            "_id": "002671",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "『吾輩は猫である』中篇自序",
                "夏目 漱石"
              ]
            }
          },
          {
            "text": "わが俳諧修業",
            "_index": "aozora_index",
            "_id": "003771",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "わが俳諧修業",
                "芥川 竜之介"
              ]
            }
          },
          {
            "text": "わが母をおもう",
            "_index": "aozora_index",
            "_id": "003997",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "わが母をおもう",
                "宮本 百合子"
              ]
            }
          },
          {
            "text": "わが母を語る",
            "_index": "aozora_index",
            "_id": "049723",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "わが母を語る",
                "上村 松園"
              ]
            }
          },
          {
            "text": "吾輩は猫である",
            "_index": "aozora_index",
            "_id": "000789",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "吾輩は猫である",
                "夏目 漱石"
              ]
            }
          }
        ]
      }
    ]
  }
}
  1. 「suggest」の「title-suggest」がCompletion Suggesterのレスポンスになります。
  2. 最初の「text」は入力の「prefix」の値です。「offset」、「length」もこの文字に関する情報なので特に気にしなくてもいいかと。
  3. 「options」がサジェストされた内容になります。それぞれの「text」がサジェストされた文字列になります。「suggest_ja」には著作と著者名を入れましたが、今回は著作が「text」に帰ってきていることがわかります。

次に著者名でもやってみましょう。 「太宰」という入力が来たものとしてリクエストを投げます。

GET aozora_index/_search
{
  "explain": true, 
  "suggest": {
    "title-suggest": {
      "prefix": "太宰",
      "completion": {
        "field": "suggest_ja"
      }
    }
  }
}

レスポンスは次のようになります。

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 0,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "suggest": {
    "title-suggest": [
      {
        "text": "太宰",
        "offset": 0,
        "length": 2,
        "options": [
          {
            "text": "太宰 治",
            "_index": "aozora_index",
            "_id": "000236",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "ア、秋",
                "太宰 治"
              ]
            }
          },
          {
            "text": "太宰 治",
            "_index": "aozora_index",
            "_id": "001572",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "I can speak",
                "太宰 治"
              ]
            }
          },
          {
            "text": "太宰 治",
            "_index": "aozora_index",
            "_id": "001578",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "愛と美について",
                "太宰 治"
              ]
            }
          },
          {
            "text": "太宰 治",
            "_index": "aozora_index",
            "_id": "046597",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "青森",
                "太宰 治"
              ]
            }
          },
          {
            "text": "太宰 治",
            "_index": "aozora_index",
            "_id": "004357",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "青森",
                "太宰 治"
              ]
            }
          }
        ]
      }
    ]
  }
}

先ほどとは異なり、「options」の中の個々の「text」はすべて「太宰 治」になってしまいました。 登録したデータは著作の一覧ですが、「suggest_ja」には著作と著者名を入れたためです。 これでは、実際に検索窓に実装したときに同じものが並んでしまいます。 こんな時のためのオプション「skip_duplicates」が用意されています。 先ほどのリクエストに「“skip_duplicates”: true」を追加します。

GET aozora_index/_search
{
  "explain": true, 
  "suggest": {
    "title-suggest": {
      "prefix": "太宰",
      "completion": {
        "field": "suggest_ja",
        "skip_duplicates": true
      }
    }
  },
  "_source": ["suggest_ja"]
}

するとレスポンスは次のように変化します。

{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 0,
      "relation": "eq"
    },
    "max_score": null,
    "hits": []
  },
  "suggest": {
    "title-suggest": [
      {
        "text": "太宰",
        "offset": 0,
        "length": 2,
        "options": [
          {
            "text": "太宰 治",
            "_index": "aozora_index",
            "_id": "000236",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "ア、秋",
                "太宰 治"
              ]
            }
          },
          {
            "text": "太宰治との一日",
            "_index": "aozora_index",
            "_id": "042582",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "太宰治との一日",
                "豊島 与志雄"
              ]
            }
          },
          {
            "text": "太宰治情死考",
            "_index": "aozora_index",
            "_id": "043137",
            "_score": 1,
            "_source": {
              "suggest_ja": [
                "太宰治情死考",
                "坂口 安吾"
              ]
            }
          }
        ]
      }
    ]
  }
}

「options」は3つに減り、重複していないことがわかります。 「text」が同じ場合は最初に出てきたものを返すようです(注意:結果から観測しているだけで、実装はまだ見ていません)。 (なお、今回は説明を省きますが、入力データでスコアを指定することも可能になっています)。 そのほかにもCompletion Suggesterにはいくつか仕組みが用意されているのですが、それはまた今度にでも。

まとめ

ということで、Luceneコミッターの打田さんに感謝です。 便利な仕組みが簡単に使えるようになるのはとてもありがたいですね。

まずは簡単にどういうものかとどうやって使うのかを記事にしてみました。 中の動きや、注意点、これまでとの違いなどは次の記事にしようと思います。 そういえば、公式ドキュメントにはまだ出てきてないな、それを書こうと思ってついでに動かしてみたんだけど、追加するのはまた後日かな。。。

参考資料


comments powered by Disqus