焼肉が食べたい

ただの日記です。技術的に学んだことも書こうと思っていますが、あくまで自分用メモです。 プロフィールはこちら。https://chie8842.github.io/aboutme/

Elasticsearch7.2でkuromoji_ipadic_neologd_tokenizerのsearchモードとsynonym_token_filterを一緒に使うとエラーが出る

TL;DR

Elasticsearch7.2でKuromoji IPADic Neologd TokenizerのsearchモードとSynonym Token Filterを使うとエラーが出る。
エラーを回避するには、Synonym Token Filterを利用するanalyzerではkuromoji_tokenizerのnormalモードを使う

発生した問題

ElasticsearchでKuromoji IPADic Neologd TokenizerのsearchモードとSynonym Token Filterを一緒に使ったところ、インデックス作成時にエラーが出た。

/var/log/elasticsearch/[cluster name].logのエラー

エラーメッセージ(クリックして表示)

[2019-09-19T05:57:48,919][DEBUG][o.e.a.a.i.t.p.TransportPutIndexTemplateAction] [n01] failed to put template [shops_template]
java.lang.IllegalArgumentException: failed to build synonyms
        at org.elasticsearch.analysis.common.SynonymTokenFilterFactory.buildSynonyms(SynonymTokenFilterFactory.java:138) ~[?:?]
        at org.elasticsearch.analysis.common.SynonymTokenFilterFactory.getChainAwareTokenFilterFactory(SynonymTokenFilterFactory.java:90) ~[?:?]
        at org.elasticsearch.index.analysis.AnalyzerComponents.createComponents(AnalyzerComponents.java:84) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.index.analysis.CustomAnalyzerProvider.create(CustomAnalyzerProvider.java:63) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.index.analysis.CustomAnalyzerProvider.build(CustomAnalyzerProvider.java:50) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.index.analysis.AnalysisRegistry.produceAnalyzer(AnalysisRegistry.java:584) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.index.analysis.AnalysisRegistry.build(AnalysisRegistry.java:534) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.index.analysis.AnalysisRegistry.build(AnalysisRegistry.java:216) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.index.IndexService.<init>(IndexService.java:180) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.index.IndexModule.newIndexService(IndexModule.java:411) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.indices.IndicesService.createIndexService(IndicesService.java:563) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.indices.IndicesService.createIndex(IndicesService.java:512) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.cluster.metadata.MetaDataIndexTemplateService.validateAndAddTemplate(MetaDataIndexTemplateService.java:235) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.cluster.metadata.MetaDataIndexTemplateService.access$300(MetaDataIndexTemplateService.java:65) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.cluster.metadata.MetaDataIndexTemplateService$2.execute(MetaDataIndexTemplateService.java:176) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.cluster.ClusterStateUpdateTask.execute(ClusterStateUpdateTask.java:47) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.cluster.service.MasterService.executeTasks(MasterService.java:687) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.cluster.service.MasterService.calculateTaskOutputs(MasterService.java:310) ~[elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.cluster.service.MasterService.runTasks(MasterService.java:210) [elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.cluster.service.MasterService$Batcher.run(MasterService.java:142) [elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.cluster.service.TaskBatcher.runIfNotProcessed(TaskBatcher.java:150) [elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.cluster.service.TaskBatcher$BatchedTask.run(TaskBatcher.java:188) [elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.common.util.concurrent.ThreadContext$ContextPreservingRunnable.run(ThreadContext.java:688) [elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.common.util.concurrent.PrioritizedEsThreadPoolExecutor$TieBreakingPrioritizedRunnable.runAndClean(PrioritizedEsThreadPoolExecutor.java:252) [elasticsearch-7.3.2.jar:7.3.2]
        at org.elasticsearch.common.util.concurrent.PrioritizedEsThreadPoolExecutor$TieBreakingPrioritizedRunnable.run(PrioritizedEsThreadPoolExecutor.java:215) [elasticsearch-7.3.2.jar:7.3.2]
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128) [?:?]
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628) [?:?]
        at java.lang.Thread.run(Thread.java:835) [?:?]
Caused by: java.text.ParseException: Invalid synonym rule at line 10
        at org.apache.lucene.analysis.synonym.SolrSynonymParser.parse(SolrSynonymParser.java:72) ~[lucene-analyzers-common-8.1.0.jar:8.1.0 dbe5ed0b2f17677ca6c904ebae919363f2d36a0a - ishan - 2019-05-09 19:35:41]
        at org.elasticsearch.analysis.common.SynonymTokenFilterFactory.buildSynonyms(SynonymTokenFilterFactory.java:134) ~[?:?]
        ... 27 more
Caused by: java.lang.IllegalArgumentException: term: 焼餃子 analyzed to a token (焼餃子) with position increment != 1 (got: 0)
        at org.apache.lucene.analysis.synonym.SynonymMap$Parser.analyze(SynonymMap.java:325) ~[lucene-analyzers-common-8.1.0.jar:8.1.0 dbe5ed0b2f17677ca6c904ebae919363f2d36a0a - ishan - 2019-05-09 19:35:41]
        at org.elasticsearch.analysis.common.ESSolrSynonymParser.analyze(ESSolrSynonymParser.java:57) ~[?:?]
        at org.apache.lucene.analysis.synonym.SolrSynonymParser.addInternal(SolrSynonymParser.java:114) ~[lucene-analyzers-common-8.1.0.jar:8.1.0 dbe5ed0b2f17677ca6c904ebae919363f2d36a0a - ishan - 2019-05-09 19:35:41]
        at org.apache.lucene.analysis.synonym.SolrSynonymParser.parse(SolrSynonymParser.java:70) ~[lucene-analyzers-common-8.1.0.jar:8.1.0 dbe5ed0b2f17677ca6c904ebae919363f2d36a0a - ishan - 2019-05-09 19:35:41]
        at org.elasticsearch.analysis.common.SynonymTokenFilterFactory.buildSynonyms(SynonymTokenFilterFactory.java:134) ~[?:?]
        ... 27 more
[2019-09-19T05:58:00,832][INFO ][o.e.c.m.MetaDataCreateIndexService] [n01] [shops] creating index, cause [auto(bulk api)], templates [], shards [1]/[1], mappings []
[2019-09-19T05:58:00,983][INFO ][o.e.c.m.MetaDataMappingService] [n01] [shops/2DRfIkXNS66azf7EEvGuZw] create_mapping [_doc]

ちなみにエラーが出たときのAnalyzerとmapの設定はこれ。

  "settings": {
    (省略)
    "analysis": {
      "filter": {
        "my_synonym": {
          "type": "synonym",
          "synonyms_path": "synonym.txt"
        }
      },
      "tokenizer": {
        "kuromoji_search": {
          "type": "kuromoji_ipadic_neologd_tokenizer",
          "mode": "search"
        }
      },
      "analyzer": {
        "synonym_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_search",
          ...
          "filter": ["my_synonym", ...]
        },

原因調査してわかったこと

ログに以下のエラーがあることから、synonym.txtの「焼餃子」を含む行の構文に問題があるらしい。

Caused by: java.lang.IllegalArgumentException: term: 焼餃子 analyzed to a token (焼餃子) with position increment != 1 (got: 0)

ちなみにsynonym.txtの中身はこんなかんじ

...省略...
焼き餃子, 焼きぎょうざ, 焼餃子

ここで、「焼餃子」をsynonym_analyzerでtokenizeした結果を見てみる。

  "settings": {
    (省略)
    "analysis": {
      "tokenizer": {
        "kuromoji_search": {
          "type": "kuromoji_ipadic_neologd_tokenizer",
          "mode": "search"
        }
      },
      "analyzer": {
        "kuromoji_search_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_search",
          
        },

Analyzeした結果

$ curl -XPOST 'http://localhost:9200/hoge_index/_analyze?pretty' -H "Content-type: application/json" -d '{"analyzer": "kuromoji_search_analyzer", "text": "焼餃子"}'
{
  "tokens" : [
    {
      "token" : "焼",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "焼餃子",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0,
      "positionLength" : 2
    },
    {
      "token" : "餃子",
      "start_offset" : 1,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    }
  ]
}

エラーメッセージにあった、position increment != 1 (got: 0)positionっぽいものがある。 Analyzerのtokenizerにkuromoji_ipadic_neologd_tokenizersearchモードを利用すると、synonymのanalyzeの結果として、複合語を考慮した出力がされるが、「焼」と「焼餃子」でpositionの値が一緒であるためにpositionのincrementのエラーが出ているっぽい。 ソースコードを追っていってもその様子はけっこう容易に理解できた。 ただ、positionのcheckはlucene側に実装があったが、なんのためのチェックかはコメントなどもなくわからなかった。

次に過去のIssueを調べた結果、以下のIssueコメントを見つけた。

Ensure TokenFilters only produce single tokens when parsing synonyms by romseygeek · Pull Request #34331 · elastic/elasticsearch · GitHub

synonymを使いたいときは、normalモードを使えとのこと。 そのとおりにしたらうまくいった。

一応試しにnormalモードのAnalyzerを使って「焼餃子」Analyzeしてみる。

Analyzer

    "analysis": {
      "tokenizer": {
        "kuromoji_normal": {
          "type": "kuromoji_ipadic_neologd_tokenizer",
          "mode": "normal"
        }
      },
      "analyzer": {
        "kuromoji_normal_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_normal"
        }
       ...

Analyzeした結果

$ curl -XPOST 'http://localhost:9200/hoge_index/_analyze?pretty' -H "Content-type: application/json" -d '{"analyzer": "kuromoji_normal_analyzer", "text": "焼餃子"}'
{
  "tokens" : [
    {
      "token" : "焼",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "餃子",
      "start_offset" : 1,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    }
  ]
}

normalモードだと複合語である「焼餃子」が消えて同じpositionをとるtokenがなくなっていた。

最終的な構成

normalモードで動くとはいえ、複合語も考慮したインデキシングはしてほしいので、最終的には以下のようにした。

Analyzerの構成

    "analysis": {
      "filter": {
        "my_synonym": {
          "type": "synonym",
          "synonyms_path": "synonym.txt"
        }
      },
      "tokenizer": {
        "kuromoji_normal": {
          "type": "kuromoji_ipadic_neologd_tokenizer",
          "mode": "normal",
          "discard_punctuation": "true",
          "user_dictionary": "userdict.txt"
        },
        "kuromoji_search": {
          "type": "kuromoji_ipadic_neologd_tokenizer",
          "mode": "normal",
          "discard_punctuation": "true",
          "user_dictionary": "userdict.txt"
        }
      },
      "analyzer": {
        "kuromoji_search_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_search",
          "char_filter": ["kuromoji_iteration_mark"],
          "filter": [
            "lowercase",
            "cjk_width",
            "kuromoji_baseform",
            "kuromoji_part_of_speech"
          ]
        }
        "synonym_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_normal"
          "char_filter": ["kuromoji_iteration_mark"],
          "filter": [
            "lowercase",
            "cjk_width",
            "kuromoji_baseform",
            "kuromoji_part_of_speech",
            "my_synonym"
          ]
        },
       ...
      }
    }

mapping

...
    "properties": {
      "hogehoge": {
        "type": "text",
        "fields": {
          "kuromoji_field": {
            "type": "text",
            "analyzer": "kuromoji_search_analyzer"
          },
          "synonym_field": {
            "type": "text",
            "analyzer": "synonym_analyzer"
          },
          ....
        }
      },
...

query

curl -XGET 'http://localhost:9200/hoge_index/_search?pretty' -H 'Content-Type: application/json' -d'{
  "query": {
    "multi_match" : {
      "type" : "cross_fields",
      "query" : "焼餃子",
      "fields" : ["hogehoge.kuromoji_field^5", "hogehoge.synonym_field"],
      "operator" : "and"
    }
  }
}'

その他試したこと1

www.elastic.co

上記を見ると、The tokenizer parameter controls the tokenizers that will be used to tokenize the synonymとあるので、以下のようにsynonym filterのパラメータにtokenizerを渡せばsynonymのTokenizerだけnormalモードを使ってその他の単語のTokenizeはsearchモードを使うことができるのかと思ったけど、最初と同様のエラーがでてできなかった。

    "analysis": {
      "filter": {
        "ja_synonym": {
          "type": "synonym",
          "synonyms_path": "synonym.txt",
          "tokenizer": "kuromoji_normal"
        }
      },
      "tokenizer": {
        "kuromoji_normal": {
          "type": "kuromoji_ipadic_neologd_tokenizer",
          "mode": "normal"
        },
        "kuromoji_search": {
          "type": "kuromoji_ipadic_neologd_tokenizer",
          "mode": "search"
        }
      },
      "analyzer": {
        "kuromoji_search_analyzer": {
          "type": "custom",
          "tokenizer": "kuromoji_normal",
          "filter": ["ja_synonym"]
        }

その他試したこと2

ちなみに、このブログを見ると、

Synonym Token Filter を日本語と一緒に使用する場合は、辞書の内容もトークナイザーの仕様にあわせて単語を分割して登録しておく必要があります。

とあるが、このやり方だとインデックス作成時はエラーが出ないが、一つの単語として認識してくれないのでシノニムとしては使えなかった。 Elasticsearch5.3あたりのドキュメントを見ると、The tokenizer parameter controls the tokenizers that will be used to tokenize the synonym, and defaults to the whitespace tokenizer. とあるので、こちらのバージョンでの話なのかもしれない。