GCP を使って名刺管理ボットを本気で作ってみた

2023-08-15

Elasticsearch 実装時のポイント

Elasticsearch は Elastic 社が提供しているオープンソースの全文検索エンジンです。データベースの様にデータを保存し参照することが出来ますが、その主たる用途は大量の文章から目的の単語を含む文章を検索することにあります。

ただ、今回の名刺管理ボットではそれほど大量の文章を扱う訳では無い為、その能力が無駄になってしまいそうですが、今回は Elasticsearch の形態素解析の機能に着目しました。形態素解析とは文章を意味のある最小の単位(トークン)に分解して、それらの意味や品詞など判別することです。これを使うことで OCR 結果の正規化や類似の単語の検索などが可能になります。

Elasticsearch 自体の詳しい説明はここでは省きますが、名刺管理ボット構築にあたってのポイントを説明します。

日本語形態素解析器 Sudachi

Elasticsearch で日本語の全文検索をしようとした場合、日本語用の形態素解析器が必要になります。その形態素解析器として一昔前までは、Kuromoji が使用されていましたが現在は更新が止まっているらしく、今やるなら Sudachi の様です。今回もこの Sudachi を使用しました。

Elasticsearch の形態素解析では以下の順で処理されます。※重要

  1. Character Filter(1文字ずつの変換処理)
  2. Tokenizer(トークン化。単語区切りにする処理)
  3. Token Filter(各トークンに対する調整処理)

この Tokenizer に Sudachi の sudachi_tokenizer を使用します。これにより日本語のトークン化処理が行えるようになります。この Sudachi を利用した、名刺管理ボットのインデックス(データベースのテーブルの様なもの)の定義を紹介します。

{
  "business-card" : {
    "mappings" : {
      "properties" : {
        "add_date" : {
          "type" : "date"
        },
        "add_user" : {
          "type" : "keyword"
        },
        "email" : {
          "type" : "keyword"
        },
        "img_url_original" : {
          "type" : "keyword"
        },
        "img_url_thumbnail" : {
          "type" : "keyword"
        },
        "latest" : {
          "type" : "boolean"
        },
        "old" : {
          "type" : "boolean"
        },
        "origin_text" : {
          "type" : "text",
          "index" : false
        },
        "owner_id" : {
          "type" : "keyword"
        },
        "owner_name" : {
          "type" : "keyword"
        },
        "search_text" : {
          "type" : "text",
          "analyzer" : "sudachi_analyzer"
        },
        "upd_date" : {
          "type" : "date"
        },
        "upd_user" : {
          "type" : "keyword"
        }
      }
    },
    "settings" : {
      "index" : {
        "number_of_shards" : "1",
        "provided_name" : "business-card_ver2",
        "creation_date" : "1587453964811",
        "analysis" : {
          "filter" : {
            "custom_synonym" : {
              "type" : "synonym",
              "synonyms_path" : "/usr/share/elasticsearch/config/sudachi/custom_synonym.txt"
            },
            "my_posfilter" : {
              "type" : "sudachi_part_of_speech",
              "stoptags" : [
                "助詞",
                "助動詞",
                "補助記号,句点",
                "補助記号,読点",
                "接尾辞"
              ]
            }
          },
          "char_filter" : {
            "my_char_filter" : {
              "type" : "mapping",
              "mappings_path" : "/usr/share/elasticsearch/config/sudachi/char_filter_mapping.txt"
            }
          },
          "analyzer" : {
            "sudachi_analyzer" : {
              "filter" : [
                "my_posfilter",
                "custom_synonym"
              ],
              "char_filter" : [
                "my_char_filter"
              ],
              "type" : "custom",
              "tokenizer" : "sudachi_tokenizer"
            }
          },
          "tokenizer" : {
            "sudachi_tokenizer" : {
              "mode" : "search",
              "type" : "sudachi_tokenizer",
              "discard_punctuation" : "true"
            }
          }
        },
        "number_of_replicas" : "1",
        "uuid" : "7aM8sMZWRXyFIi6HjcvoSg",
        "version" : {
          "created" : "7060299"
        }
      }
    }
  }
}

business-card という名前のインデックスの定義で、mappings の中にデータベースで言う属性(列、カラム)が定義されています。origin_text というフィールドには OCR の結果をそのまま格納しています。ただこれは念のため保存しているだけで、検索対象とする文字列は OCR の結果を少し加工して search_text というフィールドに格納しています。search_text には "analyzer" : “sudachi_analyzer" という定義がされています。この部分で Sudachi が使用されています。

厳密には “sudachi_analyzer" というものは Sudachi には存在せず、mappings の並びにある settings で独自に定義しています。この中で Tokenizer として sudachi_tokenizer を使用しています。

ここまでは前置きとして実際に Sudachi を使った形態素解析結果を見ていきます。

Sudachi を使った形態素解析結果

Elasticsearch の操作は Kibana というデータ可視化ツールを使用するのが一般的です。Kibana を使って次のコマンドを発行します。

GET /business-card/_analyze
{
  "tokenizer": "sudachi_tokenizer",
  "text": "株式会社エヌデーデー奥田健一郎",
  "explain": true
}

sudachi_tokenizer を使って 「株式会社エヌデーデー奥田健一郎」 という文字列を形態素解析しています。その結果は次の通りです。

{
  "detail" : {
    "custom_analyzer" : true,
    "tokenizer" : {
      "name" : "sudachi_tokenizer",
      "tokens" : [
        {
          "token" : "株式会社",
          "start_offset" : 0,
          "end_offset" : 4,
          "type" : "word",
          "position" : 0,
          "positionLength" : 2,
          "baseForm" : "株式会社",
          "bytes" : "[e6 a0 aa e5 bc 8f e4 bc 9a e7 a4 be]",
          "normalizedForm" : "株式会社",
          "partOfSpeech" : "名詞,普通名詞,一般,*,*,*",
          "positionLength" : 2,
          "pronunciation" : "カブシキガイシャ",
          "reading" : "カブシキガイシャ",
          "termFrequency" : 1
        },
        {
          "token" : "株式",
          "start_offset" : 0,
          "end_offset" : 2,
          "type" : "word",
          "position" : 0,
          "baseForm" : "株式",
          "bytes" : "[e6 a0 aa e5 bc 8f]",
          "normalizedForm" : "株式",
          "partOfSpeech" : "名詞,普通名詞,一般,*,*,*",
          "positionLength" : 1,
          "pronunciation" : "カブシキ",
          "reading" : "カブシキ",
          "termFrequency" : 1
        },
        {
          "token" : "会社",
          "start_offset" : 2,
          "end_offset" : 4,
          "type" : "word",
          "position" : 1,
          "baseForm" : "会社",
          "bytes" : "[e4 bc 9a e7 a4 be]",
          "normalizedForm" : "会社",
          "partOfSpeech" : "名詞,普通名詞,一般,*,*,*",
          "positionLength" : 1,
          "pronunciation" : "ガイシャ",
          "reading" : "ガイシャ",
          "termFrequency" : 1
        },
        {
          "token" : "エヌデーデー",
          "start_offset" : 4,
          "end_offset" : 10,
          "type" : "word",
          "position" : 2,
          "baseForm" : "エヌデーデー",
          "bytes" : "[e3 82 a8 e3 83 8c e3 83 87 e3 83 bc e3 83 87 e3 83 bc]",
          "normalizedForm" : "エヌデーデー",
          "partOfSpeech" : "名詞,普通名詞,一般,*,*,*",
          "positionLength" : 1,
          "pronunciation" : "",
          "reading" : "",
          "termFrequency" : 1
        },
        {
          "token" : "奥田",
          "start_offset" : 10,
          "end_offset" : 12,
          "type" : "word",
          "position" : 3,
          "baseForm" : "奥田",
          "bytes" : "[e5 a5 a5 e7 94 b0]",
          "normalizedForm" : "奥田",
          "partOfSpeech" : "名詞,固有名詞,人名,姓,*,*",
          "positionLength" : 1,
          "pronunciation" : "オクダ",
          "reading" : "オクダ",
          "termFrequency" : 1
        },
        {
          "token" : "健一郎",
          "start_offset" : 12,
          "end_offset" : 15,
          "type" : "word",
          "position" : 4,
          "baseForm" : "健一郎",
          "bytes" : "[e5 81 a5 e4 b8 80 e9 83 8e]",
          "normalizedForm" : "健一郎",
          "partOfSpeech" : "名詞,固有名詞,人名,名,*,*",
          "positionLength" : 1,
          "pronunciation" : "ケンイチロウ",
          "reading" : "ケンイチロウ",
          "termFrequency" : 1
        }
      ]
    }
  }
}

形態素解析ではこの様に単語に分割して、品詞や読みなどの解析が行えます。では同じように 「髙𣘺渡邊」 という文字列を解析してみます。 「髙(はしごだか)」 であることと、「 𣘺 」「 邊 」が「橋」「辺」ではない点に留意してください。

■コマンド
GET /business-card/_analyze
{
  "tokenizer": "sudachi_tokenizer",
  "text": "髙𣘺渡邊",
  "explain": true
}

■結果
{
  "detail" : {
    "custom_analyzer" : true,
    "tokenizer" : {
      "name" : "sudachi_tokenizer",
      "tokens" : [
        {
          "token" : "髙",
          "start_offset" : 0,
          "end_offset" : 1,
          "type" : "word",
          "position" : 0,
          "baseForm" : "髙",
          "bytes" : "[e9 ab 99]",
          "normalizedForm" : "髙",
          "partOfSpeech" : "名詞,普通名詞,一般,*,*,*",
          "positionLength" : 1,
          "pronunciation" : "",
          "reading" : "",
          "termFrequency" : 1
        },
        {
          "token" : """𣘺""",
          "start_offset" : 1,
          "end_offset" : 3,
          "type" : "word",
          "position" : 1,
          "baseForm" : """𣘺""",
          "bytes" : "[f0 a3 98 ba]",
          "normalizedForm" : """𣘺""",
          "partOfSpeech" : "補助記号,一般,*,*,*,*",
          "positionLength" : 1,
          "pronunciation" : "",
          "reading" : "",
          "termFrequency" : 1
        },
        {
          "token" : "渡邊",
          "start_offset" : 3,
          "end_offset" : 5,
          "type" : "word",
          "position" : 2,
          "baseForm" : "渡邊",
          "bytes" : "[e6 b8 a1 e9 82 8a]",
          "normalizedForm" : "渡邊",
          "partOfSpeech" : "名詞,固有名詞,人名,姓,*,*",
          "positionLength" : 1,
          "pronunciation" : "ワタナベ",
          "reading" : "ワタナベ",
          "termFrequency" : 1
        }
      ]
    }
  }
}

「髙𣘺 」は「タカハシ」という単語に認識されず個別の文字として解析されています。「渡邊」はそのままの漢字で「ワタナベ」として解析されています。 これだと検索時に都合が悪いのでひと手間かける必要があります。

実はこれはまだ形態素解析の3つある機能の内の Tokenizer しか機能していません。名刺管理ボットでは類似文字の変換や類義語の変換を行っています。それが business-card インデックスに定義した sudachi_analyzer です。今度はその sudachi_analyzer を使って形態素解析を行ってみます。

■コマンド
GET /business-card/_analyze
{
  "analyzer": "sudachi_analyzer",
  "text": "髙𣘺渡邊",
  "explain": true
}

■結果
{
  "detail" : {
    "custom_analyzer" : true,
    "charfilters" : [
      {
        "name" : "my_char_filter",
        "filtered_text" : [
          "高橋渡邊"
        ]
      }
    ],
    "tokenizer" : {
      "name" : "sudachi_tokenizer",
      "tokens" : [
        {
          "token" : "高橋",
          "start_offset" : 0,
          "end_offset" : 2,
          "type" : "word",
          "position" : 0,
          "baseForm" : "高橋",
          "bytes" : "[e9 ab 98 e6 a9 8b]",
          "normalizedForm" : "高橋",
          "partOfSpeech" : "名詞,固有名詞,人名,姓,*,*",
          "positionLength" : 1,
          "pronunciation" : "タカハシ",
          "reading" : "タカハシ",
          "termFrequency" : 1
        },
        {
          "token" : "渡邊",
          "start_offset" : 2,
          "end_offset" : 4,
          "type" : "word",
          "position" : 1,
          "baseForm" : "渡邊",
          "bytes" : "[e6 b8 a1 e9 82 8a]",
          "normalizedForm" : "渡邊",
          "partOfSpeech" : "名詞,固有名詞,人名,姓,*,*",
          "positionLength" : 1,
          "pronunciation" : "ワタナベ",
          "reading" : "ワタナベ",
          "termFrequency" : 1
        }
      ]
    },
    "tokenfilters" : [
      {
        "name" : "my_posfilter",
        "tokens" : [
          {
            "token" : "高橋",
            "start_offset" : 0,
            "end_offset" : 2,
            "type" : "word",
            "position" : 0,
            "baseForm" : "高橋",
            "bytes" : "[e9 ab 98 e6 a9 8b]",
            "normalizedForm" : "高橋",
            "partOfSpeech" : "名詞,固有名詞,人名,姓,*,*",
            "positionLength" : 1,
            "pronunciation" : "タカハシ",
            "reading" : "タカハシ",
            "termFrequency" : 1
          },
          {
            "token" : "渡邊",
            "start_offset" : 2,
            "end_offset" : 4,
            "type" : "word",
            "position" : 1,
            "baseForm" : "渡邊",
            "bytes" : "[e6 b8 a1 e9 82 8a]",
            "normalizedForm" : "渡邊",
            "partOfSpeech" : "名詞,固有名詞,人名,姓,*,*",
            "positionLength" : 1,
            "pronunciation" : "ワタナベ",
            "reading" : "ワタナベ",
            "termFrequency" : 1
          }
        ]
      },
      {
        "name" : "custom_synonym",
        "tokens" : [
          {
            "token" : "高橋",
            "start_offset" : 0,
            "end_offset" : 2,
            "type" : "SYNONYM",
            "position" : 0,
            "baseForm" : null,
            "bytes" : "[e9 ab 98 e6 a9 8b]",
            "normalizedForm" : null,
            "partOfSpeech" : "",
            "positionLength" : 1,
            "pronunciation" : null,
            "reading" : null,
            "termFrequency" : 1
          },
          {
            "token" : "渡辺",
            "start_offset" : 2,
            "end_offset" : 4,
            "type" : "SYNONYM",
            "position" : 1,
            "baseForm" : null,
            "bytes" : "[e6 b8 a1 e8 be ba]",
            "normalizedForm" : null,
            "partOfSpeech" : "",
            "positionLength" : 1,
            "pronunciation" : null,
            "reading" : null,
            "termFrequency" : 1
          }
        ]
      }
    ]
  }
}

この結果の簡単な説明は以下の通りです。

  1. 上部の charfilters(Character Filter)で「髙」が「高」に変換され、「高橋渡邊」という文字列に変換されています。
  2. 次に中部の tokenfilters(Tokenizer)で「高橋」と「渡邊」という単語に解析されています。
  3. 最後に下部の tokenfilters(Token Filter)で「渡邊」が「渡辺」に変換され、最終的に「高橋」と「渡辺」という単語に変換されています。

この様に Sudachi と各種フィルターを使った形態素解析によって、格納した文章を検索に都合の良いように解析し整えることが出来ます。この解析処理は検索ワードにも適用されるので、OCR により格納された「高橋」さんの名刺を「高橋」で検索しても「髙橋」で検索してもヒットするようになります。

名刺管理ボットで設定した Character Filter と Token Filter

先に紹介した business-card インデックスの定義では Character Filter と Token Filter は外部ファイルで設定しています。設定ファイルをアップロードしておくので是非参考にしてください。
※ファイルは UTF-8 です

Character Filter

以下の様な人名などで良く間違われて使われそうな漢字を置換しました 。

亞 => 亜
步 => 歩
勳 => 勲
豬 => 猪
兔 => 兎
榮 => 栄
衞 => 衛
銳 => 鋭
薗 => 園
奧 => 奥

この他にも OCR で間違われそうなカタカナの小文字を大文字に変換もしています。

今回の名刺管理ボットの検索結果は登録されたオリジナルの名刺画像のサムネイル画像を返す仕様の為、OCR の結果は検索対象として使用するだけです。従って、こういった変換は特に問題ないと判断しました。これにより「キヤノン」が「キャノン」と読み込まれてしまっても、どちらの単語で検索してもヒットするようになります。

Token Filter

以下の様な、同じ読み方でも色々な漢字がある名前等を定義しました。

髙橋,高槗,髙槗,高𣘺,髙𣘺,高𫞎,髙𫞎 => 高橋
高﨑,高碕,高嵜,髙崎,髙﨑,髙碕,髙嵜 => 高崎