あきろぐ

いろいろめもするよ🐈🐈🐈

ElasticsearchのAnalyze APIとExplain APIを使いこなそう

Elasticsearchを使って日本語の全文検索機能を実装することはよくあるケースですね。

大量のドキュメントをインデックスに登録し運用していると、想定したドキュメントがヒットしない、関連性が低いドキュメントのスコアが高くなっている等、意図していない挙動になっている場合があると思います。

その時にデバッグする時に便利なAPIや使い方をまとめてみます。

Analyze API

実際に検索クエリがどのようにトークン分割されているか確認したい場合に使います。

アナライザを指定することで、アナライザごとにどのようにトークン分割されているか比較することが可能です。

www.elastic.co

standard analyzer

例えば、ビルドインのデフォルトアナライザであるStandard Analyzerを指定してみます。このアナライザはUnicode Text Segmentation algorithmに基づいてトークン分割されますが、テキストが日本語だと1文字ごとに分割されてしまいます。ひらがなや表示文字であることは認識しているようですね。

GET /_analyze
{"analyzer": "standard","text": ["いちご大福"], "explain": false}

{
  "tokens" : [
    {
      "token" : "い",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "<HIRAGANA>",
      "position" : 0
    },
    {
      "token" : "ち",
      "start_offset" : 1,
      "end_offset" : 2,
      "type" : "<HIRAGANA>",
      "position" : 1
    },
    {
      "token" : "ご",
      "start_offset" : 2,
      "end_offset" : 3,
      "type" : "<HIRAGANA>",
      "position" : 2
    },
    {
      "token" : "大",
      "start_offset" : 3,
      "end_offset" : 4,
      "type" : "<IDEOGRAPHIC>",
      "position" : 3
    },
    {
      "token" : "福",
      "start_offset" : 4,
      "end_offset" : 5,
      "type" : "<IDEOGRAPHIC>",
      "position" : 4
    }
  ]
}

simple analyzer

文字以外の文字(記号やスペースなど)をテキストの区切りと判断するsimpleアナライザを指定すると、以下のように認識されます。記号?を途中に挟むとその前後でトークン分割されていますね。

# いちご大福
GET /_analyze
{"analyzer": "simple","text": ["いちご大福"], "explain": false}

{
  "tokens" : [
    {
      "token" : "いちご大福",
      "start_offset" : 0,
      "end_offset" : 5,
      "type" : "word",
      "position" : 0
    }
  ]
}

# いちご?大福
GET /_analyze
{"analyzer": "simple","text": ["いちご?大福"], "explain": false}

{
  "tokens" : [
    {
      "token" : "いちご",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "大福",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "word",
      "position" : 1
    }
  ]
}

ビルドインプラグイン一覧はこちらに記載されてあります。

www.elastic.co

kuromoji analyzer

日本語のテキスト分析を行う場合は、kuromojiなどのプラグインをインストールします。

www.elastic.co

アナライザにkuromojiを指定すると、日本語の単語ごとにトークン分割されるようになります。

GET /_analyze
{"analyzer": "kuromoji","text": ["いちご大福"], "explain": false}

{
  "tokens" : [
    {
      "token" : "いちご",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "大福",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 1
    }
  ]
}

explainをtrueにすると、トークン属性や詳細情報も表示されます。

GET /_analyze
{"analyzer": "kuromoji","text": ["いちご大福"], "explain": true}

{
  "detail" : {
    "custom_analyzer" : false,
    "analyzer" : {
      "name" : "kuromoji",
      "tokens" : [
        {
          "token" : "いちご",
          "start_offset" : 0,
          "end_offset" : 3,
          "type" : "word",
          "position" : 0,
          "baseForm" : null,
          "bytes" : "[e3 81 84 e3 81 a1 e3 81 94]",
          "inflectionForm" : null,
          "inflectionForm (en)" : null,
          "inflectionType" : null,
          "inflectionType (en)" : null,
          "keyword" : false,
          "partOfSpeech" : "名詞-一般",
          "partOfSpeech (en)" : "noun-common",
          "positionLength" : 1,
          "pronunciation" : "イチゴ",
          "pronunciation (en)" : "ichigo",
          "reading" : "イチゴ",
          "reading (en)" : "ichigo",
          "termFrequency" : 1
        },
        {
          "token" : "大福",
          "start_offset" : 3,
          "end_offset" : 5,
          "type" : "word",
          "position" : 1,
          "baseForm" : null,
          "bytes" : "[e5 a4 a7 e7 a6 8f]",
          "inflectionForm" : null,
          "inflectionForm (en)" : null,
          "inflectionType" : null,
          "inflectionType (en)" : null,
          "keyword" : false,
          "partOfSpeech" : "名詞-一般",
          "partOfSpeech (en)" : "noun-common",
          "positionLength" : 1,
          "pronunciation" : "ダイフク",
          "pronunciation (en)" : "daifuku",
          "reading" : "ダイフク",
          "reading (en)" : "daifuku",
          "termFrequency" : 1
        }
      ]
    }
  }
}

表示する属性はattributesで絞り込むことも可能です。

GET /_analyze
{"analyzer": "kuromoji","text": ["いちご大福"], "explain": true, "attributes" : ["keyword","pronunciation"]}

{
  "detail" : {
    "custom_analyzer" : false,
    "analyzer" : {
      "name" : "kuromoji",
      "tokens" : [
        {
          "token" : "いちご",
          "start_offset" : 0,
          "end_offset" : 3,
          "type" : "word",
          "position" : 0,
          "keyword" : false,
          "pronunciation" : "イチゴ"
        },
        {
          "token" : "大福",
          "start_offset" : 3,
          "end_offset" : 5,
          "type" : "word",
          "position" : 1,
          "keyword" : false,
          "pronunciation" : "ダイフク"
        }
      ]
    }
  }
}

kuromoji_tokenizer

また、アナライザはカスタムすることが可能で、トークナイザやトークンフィルターなど要件に応じて指定することができます。

ここでは、kuromoji_tokenizerを使った例を示します。kuromoji_tokenizerのsearchモードを指定した場合、以下のようにテキストが分割されます。

kuromoji_tokenizerのモードはnormal, search, extendedの3種類があり、未知な単語や複合語をトークナイザがどのように扱うのかを決定するものです。

詳細は、こちらに記載されています。

www.elastic.co

GET /_analyze
{
  "tokenizer": {
    "mode": "search",
    "type": "kuromoji_tokenizer"
  },
  "text": ["いちご大福が美味しい"]
}

{
  "tokens" : [
    {
      "token" : "いちご",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "大福",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "が",
      "start_offset" : 5,
      "end_offset" : 6,
      "type" : "word",
      "position" : 2
    },
    {
      "token" : "美味しい",
      "start_offset" : 6,
      "end_offset" : 10,
      "type" : "word",
      "position" : 3
    }
  ]
}

トークンフィルター

次にトークンフィルターを使ってみます。kuromoji_part_of_speechフィルターは、話し言葉で使われるような特定の品詞をストップタグとして除外してくれます。

デフォルトでどのような品詞が除外されるのかは、stoptags.txtに記載されています。

例えば、「いちご大福が美味しい」というテキストの場合、助詞の「が」が除外されているのがわかります。

# デフォルト
GET /_analyze
{
  "tokenizer": {
    "mode": "search",
    "type": "kuromoji_tokenizer"
  },
  "filter" : ["kuromoji_part_of_speech"],
  "text": ["いちご大福が美味しい"]
}

{
  "tokens" : [
    {
      "token" : "いちご",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "大福",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "美味しい",
      "start_offset" : 6,
      "end_offset" : 10,
      "type" : "word",
      "position" : 3
    }
  ]
}

デフォルトのストップタグを変更することも可能ですが、デフォルトで定義されていたストップタグを上書きしてしまい、元々除外されていたストップタグが出力されてしまうので、注意が必要です。

GET /_analyze
{
  "tokenizer": {
    "mode": "search",
    "type": "kuromoji_tokenizer"
  },
  "filter" : [
      {
      "type": "kuromoji_part_of_speech",
      "stoptags": [
        "接頭詞-名詞接続"
      ]
    }
  ],
  "text": ["いちご大福が美味しい"]
}

フィルターは、複数指定することも可能です。

以下では、漢数字を半角のアラビア数字に正規化するkuromoji_numberと動詞と形容詞を終止形に置き換えるkuromoji_baseformを追加してみた例です。

一〇〇〇1000となり、食べたい食べるに変換され出力されていますね。

GET /_analyze
{
  "tokenizer": {
    "mode": "search",
    "type": "kuromoji_tokenizer"
  },
  "filter" : ["kuromoji_part_of_speech", "kuromoji_number", "kuromoji_baseform"],
  "text": ["いちご大福が一〇〇〇個食べたい"]
}

{
  "tokens" : [
    {
      "token" : "いちご",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "大福",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "1000",
      "start_offset" : 6,
      "end_offset" : 10,
      "type" : "word",
      "position" : 2
    },
    {
      "token" : "個",
      "start_offset" : 10,
      "end_offset" : 11,
      "type" : "word",
      "position" : 3
    },
    {
      "token" : "食べる",
      "start_offset" : 11,
      "end_offset" : 13,
      "type" : "word",
      "position" : 4
    }
  ]
}

辞書、シノニム

お団子食べたいというテキストを渡した時、「お、団子、食べる」の3トークンに分割されます。お団子は一単語として認識させたい場合、辞書登録する必要があります。

GET /_analyze
{
  "tokenizer": {
    "mode": "search",
    "type": "kuromoji_tokenizer"
  },
  "filter" : [
    "kuromoji_part_of_speech",
    "kuromoji_number",
    "kuromoji_baseform",
    "kuromoji_stemmer"
  ],
  "text": ["お団子食べたい"]
}

{
  "tokens" : [
    {
      "token" : "お",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "団子",
      "start_offset" : 1,
      "end_offset" : 3,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "食べる",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 2
    }
  ]
}

kuromojiはデフォルトでMeCab-IPADIC辞書を用いています。この辞書を拡張したい場合、user_dictionary_rulesCSVフォーマットで記載する必要があります。

お団子を辞書登録すると、無事「お団子、食べる」の2つのトークンに分割されるようになりました。

GET /_analyze
{
  "tokenizer": {
    "mode": "search",
    "type": "kuromoji_tokenizer",
    "user_dictionary_rules": ["お団子,お団子,オダンゴ,カスタム名詞"]
  },
  "filter" : [
    "kuromoji_part_of_speech",
    "kuromoji_number",
    "kuromoji_baseform",
    "kuromoji_stemmer"
  ],
  "text": ["お団子食べたい"]
}

{
  "tokens" : [
    {
      "token" : "お団子",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "食べる",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 1
    }
  ]
}

お団子は一単語として認識させることができましたが、お団子、団子、おだんごの3パターンで検索しても同意語として扱いたい場合は、synonymトークンフィルターを使用します。synonymを定義するための記法は、SolrとWordNetの2種類があります。今回は、Solr記法を用いました。

「おだんご食べたい」というテキストを渡すとおだんごが団子とお団子の同意語として認識されているのが分かります。

GET /_analyze
{
  "tokenizer": {
    "mode": "search",
    "type": "kuromoji_tokenizer",
    "user_dictionary_rules": [
      "お団子,お団子,オダンゴ,名詞",
      "おだんご,おだんご,オダンゴ,名詞"
    ]
  },
  "filter" : [
    "kuromoji_part_of_speech",
    "kuromoji_number",
    "kuromoji_baseform",
    "kuromoji_stemmer",
    {
      "type": "synonym",
      "lenient": false,
      "synonyms": [
        "おだんご,お団子,団子"
      ]
      
    }
  ],
  "text": ["おだんご食べたい"]
}

{
  "tokens" : [
    {
      "token" : "おだんご",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "お団子",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "団子",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "食べる",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "word",
      "position" : 1
    }
  ]
}

展開方向の抑制というのは、以下のように⇒を使って、おたんこまたはたんこというテキストが渡ってきた場合、右側に記載されたおだんご、団子、お団子の3つに展開されるように抑制することです。つまり、お団子と検索した際はおたんこやたんこは同意語として認識させないようにできます。もし、表記揺れワードに対して展開方向を抑制しなければ、意図しないドキュメントがヒットしまう可能性があるため注意が必要です。

このように、表記揺れワードに対してもお団子として認識させたい場合は、シノニムを定義する際に展開方向の抑制をすることをおすすめします。

GET /_analyze
{
  "tokenizer": {
    "mode": "search",
    "type": "kuromoji_tokenizer",
    "user_dictionary_rules": [
      "お団子,お団子,オダンゴ,名詞",
      "おだんご,おだんご,オダンゴ,名詞"
    ]
  },
  "filter" : [
    "kuromoji_part_of_speech",
    "kuromoji_number",
    "kuromoji_baseform",
    "kuromoji_stemmer",
    {
      "type": "synonym",
      "lenient": false,
      "synonyms": [
        "おだんご,お団子,団子",
        "おたんこ,たんこ=>おだんご,お団子,団子"
      ]
      
    }
  ],
  "text": ["おたんこ食べたい"]
}

{
  "tokens" : [
    {
      "token" : "おだんご",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "お団子",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "団子",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "食べる",
      "start_offset" : 4,
      "end_offset" : 6,
      "type" : "word",
      "position" : 1
    }
  ]
}

synonymトークンフィルター、synonym graphトークンフィルター

上記の例では、synonymトークンフィルターを使用しましたが、同様のフィルターとしてsynonym graphトークンフィルターが存在します。

synonym graphトークンフィルターは、テキスト解析の過程で複数の単語で構成されたシノニムを含めて、synonymトークンフィルターと比較してシノニムをより簡単に扱うことが可能です。

これだけだと、従来のsynonymトークンフィルターと何が異なるのか分かりづらいと思うので、トークナイザの仕組みをみてみましょう。

トークナイザは、テキストをトークンのストリームに変換し、ストリーム内の各トークンの位置情報(position)、いくつのトークンにまたがっているのかを表す数(positionLength)を記録します。トークンのストリームのことをトークングラフ(Directed acyclic graph:有向非巡回グラフ)と言います。

Analyze APIでexplain=trueにすると各トークンのpositionとpositionLengthを確認することができます。

GET /_analyze
{
  "tokenizer": {
    "mode": "search",
    "type": "kuromoji_tokenizer"
  },
  "text": ["いちご大福が美味しい"],
  "explain": true,
  "attributes": ["position","positionLength"]
}

{
  "detail" : {
    "custom_analyzer" : true,
    "charfilters" : [ ],
    "tokenizer" : {
      "name" : "__anonymous__kuromoji_tokenizer",
      "tokens" : [
        {
          "token" : "いちご",
          "start_offset" : 0,
          "end_offset" : 3,
          "type" : "word",
          "position" : 0,
          "positionLength" : 1
        },
        {
          "token" : "大福",
          "start_offset" : 3,
          "end_offset" : 5,
          "type" : "word",
          "position" : 1,
          "positionLength" : 1
        },
        {
          "token" : "が",
          "start_offset" : 5,
          "end_offset" : 6,
          "type" : "word",
          "position" : 2,
          "positionLength" : 1
        },
        {
          "token" : "美味しい",
          "start_offset" : 6,
          "end_offset" : 10,
          "type" : "word",
          "position" : 3,
          "positionLength" : 1
        }
      ]
    },
    "tokenfilters" : [ ]
  }
}

トークンの中には、複数のトークンにまたがるものも存在します。例えば、DNSのシノニムとしてDomain Name Systemを持つ場合などです。この場合、DNSのpositionLengthは3になります。(position 0 のDomainからposition 3のSystemにまでトークンが広がっているため)

synonym graphトークンフィルターは、このように複数トークンにまたがる場合のpositionLengthを正確に記録してくれるそうです。(synonymトークンフィルターだとpositionLengthは1になってしまう)

ただし、Indexingする場合にはpositionLengthが無視されるので、synonym graphトークンフィルターはサポートされていないため注意が必要です。

詳しくはこちら:

www.elastic.co

Explain API

Elasticsearchで検索機能を実現していると、関連性の低いドキュメントがヒットしていたり、そのドキュメントが高いスコアになっているなど発生しうると思います。 そういった時に便利なAPIがExplain APIです。このAPIを使うと何故そのドキュメントがインプットされた検索クエリに対してヒットしたのか詳細を教えてくれます。Explain APIはElasticsearchが自動で算出するスコアをfunctionやweightを使って調整している場合に活躍すると思います。

www.elastic.co

今回は、ドキュメントのtitleフィールドのweightを2倍、introductionのweightを0.5倍にし、title基本簡単というワードのどちらかが含まれていれば100点を加算するように指定しました。

GET <index_name>/_explain/<doc_id>
{
    "query": {
      "function_score": {
        "score_mode": "sum",
        "boost_mode": "multiply",
        "query": {
          "bool": {
            "must": [
              {
                "simple_query_string": {
                  "query": "いちご大福",
                  "fields": [
                   "title^2.0",
                   "introduction^0.5"
                   ],
                  "analyzer": "kuromoji",
                  "default_operator": "and",
                  "boost": 1
                }
              }
            ]
          }
        },
        "functions": [
          {"filter": {"match": {"title": {"query": "基本 簡単", "minimum_should_match": 1 }}}, "weight": 100}
        ]
      }
    }
}

レスポンスは以下のようにどのような計算式でスコアが算出されているのか細かく教えてくれます。「いちご大福」は「いちご」と「大福」2つのトークンに分割されるのでそれぞれに対してスコアが計算されます。

ざっくり説明すると、以下のような計算がされたことになります。

queryスコア

  • titileにいちご、大福が含まれる・・・30.19222点
  • introductionににいちご、大福が含まれる・・・7.147559点

functionスコア

  • 簡単が含まれている・・・100点

score_modeがsumでboost_modeがmultiplyなので、結果約3734点のスコアがついたことになります。

(30.19222 + 7.147559) * 100 = 3733.9778

queryスコアは、Elasticsearch側でtf-idfというドキュメントに含まれる単語の重要度を評価する手法を用いて計算されているそうです。

{
  "_index" : "<index_name>",
  "_type" : "_doc",
  "_id" : "<doc_id>",
  "matched" : true,
  "explanation" : {
    "value" : 3733.9778,
    "description" : "function score, product of:",
    "details" : [
      {
        "value" : 37.33978,
        "description" : "sum of:",
        "details" : [
          {
            "value" : 30.19222,
            "description" : "sum of:",
            "details" : [
              {
                "value" : 12.783758,
                "description" : "weight(title:いちご in 1956) [PerFieldSimilarity], result of:",
                "details" : [
                  {
                    "value" : 12.783758,
                    "description" : "score(freq=1.0), computed as boost * idf * tf from:",
                    "details" : [
                      {
                        "value" : 4.4,
                        "description" : "boost",
                        "details" : [ ]
                      },
                      {
                        "value" : 4.7500243,
                        "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
                        "details" : [
                          {
                            "value" : 387,
                            "description" : "n, number of documents containing term",
                            "details" : [ ]
                          },
                          {
                            "value" : 44789,
                            "description" : "N, total number of documents with field",
                            "details" : [ ]
                          }
                        ]
                      },
                      {
                        "value" : 0.6116599,
                        "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:",
                        "details" : [
                          {
                            "value" : 1.0,
                            "description" : "freq, occurrences of term within document",
                            "details" : [ ]
                          },
                          {
                            "value" : 1.2,
                            "description" : "k1, term saturation parameter",
                            "details" : [ ]
                          },
                          {
                            "value" : 0.75,
                            "description" : "b, length normalization parameter",
                            "details" : [ ]
                          },
                          {
                            "value" : 5.0,
                            "description" : "dl, length of field",
                            "details" : [ ]
                          },
                          {
                            "value" : 13.437027,
                            "description" : "avgdl, average length of field",
                            "details" : [ ]
                          }
                        ]
                      }
                    ]
                  }
                ]
              },
              {
                "value" : 17.408463,
                "description" : "weight(title:大福 in 1956) [PerFieldSimilarity], result of:",
                "details" : [
                  {
                    "value" : 17.408463,
                    "description" : "score(freq=1.0), computed as boost * idf * tf from:",
                    "details" : [
                      {
                        "value" : 4.4,
                        "description" : "boost",
                        "details" : [ ]
                      },
                      {
                        "value" : 6.4684134,
                        "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
                        "details" : [
                          {
                            "value" : 69,
                            "description" : "n, number of documents containing term",
                            "details" : [ ]
                          },
                          {
                            "value" : 44789,
                            "description" : "N, total number of documents with field",
                            "details" : [ ]
                          }
                        ]
                      },
                      {
                        "value" : 0.6116599,
                        "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:",
                        "details" : [
                          {
                            "value" : 1.0,
                            "description" : "freq, occurrences of term within document",
                            "details" : [ ]
                          },
                          {
                            "value" : 1.2,
                            "description" : "k1, term saturation parameter",
                            "details" : [ ]
                          },
                          {
                            "value" : 0.75,
                            "description" : "b, length normalization parameter",
                            "details" : [ ]
                          },
                          {
                            "value" : 5.0,
                            "description" : "dl, length of field",
                            "details" : [ ]
                          },
                          {
                            "value" : 13.437027,
                            "description" : "avgdl, average length of field",
                            "details" : [ ]
                          }
                        ]
                      }
                    ]
                  }
                ]
              }
            ]
          },
          {
            "value" : 7.147559,
            "description" : "sum of:",
            "details" : [
              {
                "value" : 3.3820996,
                "description" : "weight(introduction:いちご in 1956) [PerFieldSimilarity], result of:",
                "details" : [
                  {
                    "value" : 3.3820996,
                    "description" : "score(freq=2.0), computed as boost * idf * tf from:",
                    "details" : [
                      {
                        "value" : 1.1,
                        "description" : "boost",
                        "details" : [ ]
                      },
                      {
                        "value" : 4.5432725,
                        "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
                        "details" : [
                          {
                            "value" : 476,
                            "description" : "n, number of documents containing term",
                            "details" : [ ]
                          },
                          {
                            "value" : 44789,
                            "description" : "N, total number of documents with field",
                            "details" : [ ]
                          }
                        ]
                      },
                      {
                        "value" : 0.67674476,
                        "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:",
                        "details" : [
                          {
                            "value" : 2.0,
                            "description" : "freq, occurrences of term within document",
                            "details" : [ ]
                          },
                          {
                            "value" : 1.2,
                            "description" : "k1, term saturation parameter",
                            "details" : [ ]
                          },
                          {
                            "value" : 0.75,
                            "description" : "b, length normalization parameter",
                            "details" : [ ]
                          },
                          {
                            "value" : 50.0,
                            "description" : "dl, length of field (approximate)",
                            "details" : [ ]
                          },
                          {
                            "value" : 68.668335,
                            "description" : "avgdl, average length of field",
                            "details" : [ ]
                          }
                        ]
                      }
                    ]
                  }
                ]
              },
              {
                "value" : 3.7654593,
                "description" : "weight(introduction:大福 in 1956) [PerFieldSimilarity], result of:",
                "details" : [
                  {
                    "value" : 3.7654593,
                    "description" : "score(freq=1.0), computed as boost * idf * tf from:",
                    "details" : [
                      {
                        "value" : 1.1,
                        "description" : "boost",
                        "details" : [ ]
                      },
                      {
                        "value" : 6.693357,
                        "description" : "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
                        "details" : [
                          {
                            "value" : 55,
                            "description" : "n, number of documents containing term",
                            "details" : [ ]
                          },
                          {
                            "value" : 44789,
                            "description" : "N, total number of documents with field",
                            "details" : [ ]
                          }
                        ]
                      },
                      {
                        "value" : 0.5114242,
                        "description" : "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:",
                        "details" : [
                          {
                            "value" : 1.0,
                            "description" : "freq, occurrences of term within document",
                            "details" : [ ]
                          },
                          {
                            "value" : 1.2,
                            "description" : "k1, term saturation parameter",
                            "details" : [ ]
                          },
                          {
                            "value" : 0.75,
                            "description" : "b, length normalization parameter",
                            "details" : [ ]
                          },
                          {
                            "value" : 50.0,
                            "description" : "dl, length of field (approximate)",
                            "details" : [ ]
                          },
                          {
                            "value" : 68.668335,
                            "description" : "avgdl, average length of field",
                            "details" : [ ]
                          }
                        ]
                      }
                    ]
                  }
                ]
              }
            ]
          }
        ]
      },
      {
        "value" : 100.0,
        "description" : "min of:",
        "details" : [
          {
            "value" : 100.0,
            "description" : "function score, score mode [sum]",
            "details" : [
              {
                "value" : 100.0,
                "description" : "function score, product of:",
                "details" : [
                  {
                    "value" : 1.0,
                    "description" : "match filter: (title:基本 Synonym(title:かんたん title:カンタン title:簡単))~1",
                    "details" : [ ]
                  },
                  {
                    "value" : 100.0,
                    "description" : "product of:",
                    "details" : [
                      {
                        "value" : 1.0,
                        "description" : "constant score 1.0 - no function provided",
                        "details" : [ ]
                      },
                      {
                        "value" : 100.0,
                        "description" : "weight",
                        "details" : [ ]
                      }
                    ]
                  }
                ]
              }
            ]
          },
          {
            "value" : 3.4028235E38,
            "description" : "maxBoost",
            "details" : [ ]
          }
        ]
      }
    ]
  }
}

まだ便利な使い方はたくさんあるかと思いますが、一旦以上です。

Google Cloud Vision APIとOpenAIのFunction Callingを使ってみた

今回はGoogleのCloud Vision APIを使って画像からテキストを検出し、そのテキストとOpenAIのFunction Callingを用いて構造化データを抽出していきたいと思います。

Cloud Vision API

Googleが提供している画像を分析し情報を抽出できるサービスです。OCRによって画像からテキストを検出したり、ランドマークやロゴなども検出することが可能となっています。

cloud.google.com

テキスト検出の場合、最初の1000ユニット(画像)までは無料で使えます。

cloud.google.com

OpenAI Function Calling

OpenAIが提供しているChat APIに追加された機能です。

APIを呼び出すときに関数を定義することで構造化されたデータ、つまりJSONオブジェクトとしてレスポンスを受け取ることができます。

自分達のアプリケーションコードに組み込む時にJSONオブジェクトとしてレスポンスを受け取ることができれば、何かと便利ですよね。

https://platform.openai.com/docs/guides/function-callingplatform.openai.com

コストは使用するモデルによって異なりますが、InputとOutputの両方でコストがかかります。(1000トークンあたり)

openai.com

トークンはOpenAIの自然言語モデルでテキストを処理するときの単位のようなもので、以下のサイトからトークン数を確認することができます。

platform.openai.com

日本語の場合、ひらがなやカタカナは1文字=1トークンですが、漢字は2-3トークン、絵文字は3トークンほど消費するようです。

Cloud Vision APIOCR処理を実装する

では、実際にOCRでテキスト検出するためのコードをPython3.9で書いていきます。

まずCloud Visionクライアントライブラリをインストールし、Google Cloud上でサービスアカウントを発行し、APIキーをJSONファイルとしてローカルにダウンロードします。

サービスアカウントの発行手順等はこちらの記事は分かりやすかったです。

dev.classmethod.jp

$ pip install --upgrade google-cloud-vision

クレデンシャルファイルのパスを環境変数として設定します。

# .zshrc
export GOOGLE_APPLICATION_CREDENTIALS='<json_file_path>'

今回テキスト検出する対象は、私の好きな長谷川あかりさんのこちらのレシピ画像を使ってみました。(このレシピお気に入りの1つです)

実際に書いたコードはこちら。

from google.cloud import vision

client = vision.ImageAnnotatorClient()
file_paths = [
    '/Users/user_a/Downloads/recipe01.jpeg',
    '/Users/user_a/Downloads/recipe02.jpeg',
    '/Users/user_a/Downloads/recipe03.jpeg',
    '/Users/user_a/Downloads/recipe04.jpeg'
]

output_text = ''

for file_path in file_paths:
    with open(file_path, 'rb') as image_file:
        content = image_file.read()

    image = vision.Image(content=content)

    response = client.document_text_detection(
        image=image,
        image_context={'language_hints': ['ja']}
    )
    output_text += response.full_text_annotation.text + '\n'

print(output_text)

出力結果はこちら。

パッケージの細かい文字まで検出してくれたようです。画像にない文字も検出されていることもありますが、これだけの精度で検出されれば問題ないですね。

ごはんに合う!
鶏むね肉とごぼうの柚子胡椒クリームシチュー
※2人分
鶏むね肉150g (1cmくらいの角切り)
ごぼう150g (四つ割りにして端から1cmに切る)
小麦粉大さじ2
バター10g
料理酒大さじ2
牛乳200ml
柚子胡椒小さじ1
塩適量
BAR
elna
000
100
日清
クッキング
粒 フラワー
薄力小麦粉
タイプの
M
Oxx
NET150g
柚子こしょう
24.10/8/201
S&B
牛乳
明治
ナチュラルテイスト
生乳100%使用
おいしい牛乳
新鮮な生乳のおいしさ、そのまま
要冷蔵(10℃以下)
450ml
450ml
鶏むね肉に
塩ひとつまみ (1g)を
ふって馴染ませたら、
小麦粉を満遍なくまぶす
1
水150mlと料理酒を加え、
煮立ったら鶏肉を加えて
よく混ぜる。
蓋をして弱めの中火で
5分煮込む。
フライパンに
バターを加えて
中火で溶かし
ごぼうを入れる。
塩ひとつまみをふって、
ごぼうのいい香りが
してくるまで炒める
2
4
牛乳と柚子胡椒を加えて
とろみがつくまで
強めの中火でおよそ
1分30秒~2分ほど煮詰めたら、
火を止める。
塩少々で味をつけたら完成。
※味を見て足りなければ
塩で調える。
ごぼうの香りとしっとり鶏むね肉、
文句なしのおいしさ!
柚子胡椒の塩気でごはんが進みます。

Function Callingで構造化データを抽出する

まず、必要なOpenAIのPythonライブラリをインストールします。

$ pip install --upgrade openai

APIキーを発行し、環境変数として設定します。

# .zshrc
export OPENAI_API_KEY='your-api-key-here'

OCRで得られたテキストデータを用いて、以下のようにFunctionを定義して構造化データを出力しています。

Functionの定義はこちらの記事を参考にしました。

gihyo.jp

import json
from openai import OpenAI

client = OpenAI()

# テキストデータからどのような構造化データを返してほしいか定義
functions = [
    {
        "name": "recipe_ingredients",
        "description": """これはテキストデータからレシピの情報を抽出する処理です。
        レシピ名、レシピに必要な材料名、材料の種類、材料の量を抽出します。
        """,
        "parameters": {
            "type": "object",
            "properties": {
                "recipe_name": {
                    "type": "string",
                    "description": "レシピ名"
                },
                "ingredients": {
                    "type": "array",
                    "description": "レシピに必要な材料一覧",
                    "items": {
                        "type": "object",
                        "properties": {
                            "name": {
                                "type": "string",
                                "description": "材料名"
                            },
                            "type": {
                                "type": "string",
                                "description": "材料の種類"
                            },
                            "amount": {
                                "type": "string",
                                "description": "材料の量"
                            }
                        }
                    }
                }
            },
            "required": ["recipe_name"]
        }
    }
]

# ここで渡すテキストデータを指定
# 先ほど出力したテキストデータを読み込む
messages = [
    {
      "role": "user",
      "content": output_text
    }
]

# openaiにモデル、function、messagesを指定しリクエスト送る
response = client.chat.completions.create(
  model="gpt-3.5-turbo-1106",
  messages=messages,
  functions=functions,
  temperature=1,
  max_tokens=2024,
  function_call={"name": "recipe_ingredients"}
)

# 構造化データ表示
str = response.choices[0].message.function_call.arguments
json_str = json.dumps(str)
print(json.loads(json_str))

結果はこちら。正確にレシピ名と材料を認識し、JSONオブジェクトを返してくれました! これはいいですね!

{
  "recipe_name": "鶏むね肉とごぼうの柚子胡椒クリームシチュー",
  "ingredients": [
    {
      "name": "鶏むね肉",
      "amount": "150g",
      "type": "肉"
    },
    {
      "name": "ごぼう",
      "amount": "150g",
      "type": "野菜"
    },
    {
      "name": "小麦粉",
      "amount": "大さじ2",
      "type": "調味料"
    },
    {
      "name": "バター",
      "amount": "10g",
      "type": "調味料"
    },
    {
      "name": "料理酒",
      "amount": "大さじ2",
      "type": "調味料"
    },
    {
      "name": "牛乳",
      "amount": "200ml",
      "type": "乳製品"
    },
    {
      "name": "柚子胡椒",
      "amount": "小さじ1",
      "type": "調味料"
    },
    {
      "name": "塩",
      "amount": "適量",
      "type": "調味料"
    }
  ]
}

期待以上の結果が得られたので、とても満足しました。様々な場面で活用できそうです。

/etc/environmentで環境変数を読み込む場合の制約について

1024bytesを超える環境変数は値が切り捨てられる

/etc/environment環境変数を設定する場合、値に入れる文字数が多いと正しく読み込まれないことがあります。具体的にいうと1024bytesを超える値を設定しようとすると切り捨てられ、ログファイルには以下のようなエラーが吐かれるようになります。

$ sudo tail -f /var/log/secure | grep pam_
Jan 17 07:23:04 ip-xxx sudo: pam_unix(sudo:session): session opened for user root by (uid=0)
Jan 17 07:23:04 ip-xxx su: pam_unix(su-l:session): session opened for user ec2-user by (uid=0)
Jan 17 07:23:16 ip-xxx CROND[30553]: pam_env(crond:setcred): non-alphanumeric key 'YwdvO1GIK7uTSudiYcUn' in /etc/environment', ignoring

sourceコマンドを用いて手動で/etc/environmentを読み込む場合は1024bytesを超える環境変数でも問題なく設定でき呼び出すことができるため、特に制約がないように見えます。

しかし、ユーザーログイン時にPAMによってユーザー認証が行われる際、pam_env.soというPAMモジュールが環境変数の初期設定のために呼びだされ、環境変数を読み込んでいますが、古いバージョンのPAMモジュールだと環境変数の長さの制約(1024bytes)があるため、この値を超える環境変数は切り捨てられてしまいます。

切り捨てられると上記のようにnon-alphanumeric keyとしてログに書き込まれるようです。

この問題についてはlinux-pamのレポジトリにIssueが上がっていました。

BUF_SIZEという変数で/etc/environmentに設定できる環境変数の長さが制限されているようです。

github.com

BUF_SIZEは以下のコミットで8192まで上限が上げられているため、PAMのバージョンが1.5.3以降であれば問題なさそうです。

github.com

PAMのバージョンは、以下のコマンドを使って確認できます。

$ rpm -qa | grep pam

回避策

v1.5.3未満のPAMが使われているLinuxの場合の回避策としては、以下2つが挙げられるかなと思います。

  1. BUF_SIZEの値を変更してPAMをリビルドする
  2. /etc/environment以外のファイルに環境変数を設定する

1に関しては、リビルドする方法がこちらに記載されています。Ubuntuを使用しているのであればこちらのリンクのスクリプトを用いることができそうです。

unix.stackexchange.com

2に関しては、私が実際に試した方法となります。

例えば、.bash_profileに長い環境変数を保存しておき、アプリケーション等で.bash_profileに記載された環境変数が読み込まれるようにしておけば、上記の問題は回避することが可能です。

AWS LambdaでExifToolを使う Ruby編

何をしようとしたか

Lambda上で動画や画像のメタデータ取得するためにExifToolのRubyラッパーツールを導入しました。 しかし、ExifToolをLambda上で実行するには一筋縄ではいかなかったので、その解決方法をまとめてみます。

環境

  • AWS Lambda
  • Ruby3.2
  • serverless framework

ExifToolをRubyで扱うために以下のgemを使用しました。

github.com

発生した問題:Exiftoolの実行ファイルが存在しないと怒られる

READMEを参考にGemfile内にexiftool_vendoredを追加した後、

gem 'exiftool_vendored'

以下のコマンドで必要なgemを指定パスにインストールしました。

bundle install --path

Exiftoolを使用して動画や画像のメタデータを取得するコードを記述します。

# sample code
require 'exiftool_vendored`

exiftool = Exiftool.new("tmp/test.jpg").to_hash
# 画像の縦横の長さを取得
width = exiftool[:width]
height = exiftool[:height]

serverless frameworkを用いてLambdaにデプロイし、実際にLambdaを実行すると、以下のようなExiftool::ExiftoolNotInstalledエラーが発生します。

Exiftool::ExiftoolNotInstalled
"/var/task/vendor/bundle/ruby/3.2.0/gems/exiftool-1.2.4/lib/exiftool.rb:60:in `initialize'"
...
...
...

該当するソースコードを参照するとcmdの中身が空である場合ExiftoolNotInstalledエラーになっているようです。

    # I'd like to use -dateformat, but it doesn't support timezone offsets properly,
    # nor sub-second timestamps.
    cmd = "#{self.class.command} #{exiftool_opts} -j -coordFormat \"%.8f\" #{escaped_filenames} 2> /dev/null"
    json = `#{cmd}`.chomp
    raise ExiftoolNotInstalled if json == ''

https://github.com/exiftool-rb/exiftool.rb/blob/011d12b4f51e0e86a0d73c43ba2efe5138b6d698/lib/exiftool.rb#L60

Exiftool.commandでExiftoolの実行ファイルパスを出力してみましたが、以下のように実行ファイルパスが返ってきますし、実行ファイルはLambdaにアップロードしたファイル群にも含まれています。

"/var/task/vendor/bundle/ruby/3.2.0/gems/exiftool_vendored-12.68.0/bin/exiftool"

しかし、Exiftool.exiftool_installed?を実行するとfalseが返ってくる謎の状態となっていました。

原因

ExifToolは、Perlライブラリであり実行環境にPerlがインストールされていないといけません。すなわち、Lambdaの実行環境(ランタイムがRubyの場合)にはデフォルトでPerlが入っていかなったため上記のエラーが発生していました。

ExifTool is a platform-independent Perl library plus a command-line application for reading, writing and editing meta information in a wide variety of files.

exiftool.org

そのため、ExifToolを実行するにはLambda上にPerlをインストールする必要があります。

解決方法

今回は、Lambda Layerを使ってPerlとExiftoolのレイヤーを作成しました。レイヤーの作成方法は以下のブログを参考にしました。

dev.to

上記のブログと異なるのは、デプロイするリージョンはTokyoリージョンなので、Tokyoリージョンのパブリックレイヤー(arn:aws:lambda:ap-northeast-1:445285296882:layer:perl-5-38-runtime-al2-x86_64:2)を使用しました。

shogo82148.github.io

serveless.ymlには対象のhandlerにlayersを追加すればOKです。

    handler: handler.main
    layers:
      - arn:aws:lambda:ap-northeast-1:445285296882:layer:perl-5-38-runtime-al2-x86_64:2 # public layer
      - arn:aws:lambda:ap-northeast-1:<account_id>:layer:exiftool:1 # custom layer

そして、ExifToolを実行するために参照するパスをExiftool.commandで指定しておきます。

# sample code
require 'exiftool_vendored`

#exiftoolはperlが必要なためlambda layer上のexiftoolを指定
Exiftool.command = '/opt/bin/perl /opt/bin/exiftool' 

exiftool = Exiftool.new("tmp/test.jpg").to_hash
# 画像の縦横の長さを取得
width = exiftool[:width]
height = exiftool[:height]

この状態でLambdaをデプロイ&実行すれば、問題なく画像のメタデータを取得することができました。

参考文献