Elasticsearchを導入したら、過去15年分のメール(150万通越え)の本文から、会社名や担当者名、電話番号などで、瞬時に検索できるようになった。

改元アニバーサリーの10連休中に、いつか勉強してみたいと思っていた、検索専用DBについて調べて、いろいろ遊んでみました。

僕が独立する前に勤めていた職場では、僕が15年前に開発したメールシステムを現在も使っています。ネットショップを運営しているのですが、注文が集中すると、とても一人ではメールを捌くことが出来ないため、一つのメールアカウントを、同時に複数のスタッフが並行してやりとりできるシステムを自作したです。

中央にデータベース(MySQL3系だったかな)を置き、Rubyスクリプトを使ってメールサーバからPOP受信してこのDBに格納し、各スタッフのパソコンにインストールした専用アプリケーションソフト(見た目は普通のメールソフトだけど、商品の発注や納期管理、伝票類の印刷、発送業務まで全部盛りの便利なヤツ。これも自分で開発)を使って、中央のDBにアクセスすることにより実現していました。

Windows2000時代に開発したのですが、現代の環境に合わせるバージョンアップは、日常業務をこなしながらでは時間的に難しいため、仮想環境を利用して今もWindows2000上で稼働しているシーラカンス的なシステムです。

その会社を退職し独立した今、そのシステムの刷新を仕事として受注しております。

前置きが長くなってしまいましたが、現在、このシステムをウェブアプリとしてリニューアル開発しています。

現在抱えている問題として、メール本文の検索が遅いという問題があります。MySQL3系にメールのテキスト150万件ですから、どうにもこうにも検索に時間がかかります。検索条件によっては、数分待たされることも珍しくない状況です。

今回もMySQLを使うのですが、現代のMySQLはフルテキストインデックスを使えるように進化しており、こうした検索にも対応できるようになっています。

実際に150万件のデータを食わせて、いろいろテストを行いました。フルテキストインデックスには、mecabによる形態素解析を利用したものと、N-gramを使ったものが一般的なようで、どちらも試してみたものの、ちょっと使いにくいなというのが率直な感想でした。

検索結果が返ってくるのも格段に早くなっており、決して使えないわけではないのですが、返ってくる検索結果がちょっと微妙だし、もう少しこうなんとかしたいなとモヤモヤしていたところに、検索に特化したDBがあるのを知り、この連休中に遊んでみたわけです。

Elasticsearchを試してみた

検索に特化したDBにもいくつか種類があるようですが、Elasticsearchというものを試してみました。データの出し入れは、RESTfulに行えるようで、なかなか使いやすそうな予感がします。

例によって、DockerでElasticsearchコンテナを立ち上げてみました。また、データを可視化するためのツールであるKibanaコンテナも一緒に立ち上げました。

Elasticsearchは、MySQLのようなリレーショナルなデータベースではなく、キーバーリュー型のデータベースのようです。

仕組みをざっくり説明すると、こんな感じです。

文字データを登録する際に、様々なフィルタや解析プログラムを使用して単語に分解し、その単語のリストが検索用データとして使用されます。例えば、「Yahoo」という単語の含まれるメールを探したいとき、単純な検索方法では、「yahoo」や「YAHOO」など、全角半角や大文字小文字の違いによってうまく検索できない可能性があります。そういったことに対処するために、入力された文字データの表記の揺れを統一してやる必要があります。Elasticsearchは、アルファベットや数字を半角に変換し、さらに小文字に変換してから検索用の情報として使用する、なんてことが出来るようになっています。変換するフィルタの種類や組み合わせは、目的に合わせて自由に指定できるようになっています。

今回の案件では、検索キーワードは100%名詞の単語です。
会社名や担当者名、電話番号やメールアドレスなどが主な検索キーワードになります。この前提条件の元、以下のように設定してみました。

単語に分解する方法として、形態素解析を使用しました。

形態素解析とは、 自然言語処理の手法の一つで、ある文章・フレーズを「意味を持つ最小限の単位(=単語)」に分解して、文章やフレーズの内容を判断するために用いられる解析法です。

例えば、以下の文章を形態素解析にかけると、以下のように分解されます。

「 仕組みをざっくり説明すると、こんな感じかな。 」
  ↓
「 仕組み / を / ざっくり / 説明 / する / と / 、 /こんな/ 感じ/ かな / 。」

形態素解析では、分割したワードの品詞なども取得できますので、名詞のみを抽出するようカスタマイズしました。

検索用のフィールドを複数定義することも出来まして、例えば本文の中から電話番号と思われる部分だけを正規表現を用いて抽出したり、メールアドレスと思われる部分を抽出して、それぞれ検索用フィールドとして個別に定義することも出来ます。

さらっと書きましたが、この複数の検索フィールドを設定できるという点は非常に便利でして、一つのメール本文を投入すれば、フレーズ一致検索用に設計したアナライザを通してフレーズ一致用の検索フィールドを作り、それに加えて、部分一致検索用の検索フィールド、電話番号用の検索フィールド、メールアドレス用の検索フィールドというように、それぞれの検索用途に合わせたアナライザを設定して、複数の検索フィールドを一度に作ることができます。また、検索時に、どの検索フィールドを使うのか指定することも可能です。

今回は、PHPからElasticsearchを呼び出しますので、Composerで「 Elasticsearch\ClientBuilder 」をインストールして使用しています。

以下が今回のマッピングデータになります。

$params = [
    'index' => 'mails',
    'body' => [
        'settings' => [
            'index' => [
                'analysis' => [
                    'char_filter' => [
                        'digit_only' => [
                            'type' => 'pattern_replace',
                            'pattern' => '[^\\d\\s]+',
                            'replacement' => '',
                        ],
                    ],
                    'filter' => [
                       'ja_neologd_pos_meishi_filter' => [
                            'type' => 'kuromoji_neologd_part_of_speech',
                            'stoptags' => [
                                '名詞-代名詞',
                                '名詞-代名詞-一般',
                                '名詞-代名詞-縮約',
                                '名詞-数',
                                '名詞-非自立',
                                '名詞-非自立-一般',
                                '名詞-非自立-副詞可能',
                                '名詞-非自立-助動詞語幹',
                                '名詞-非自立-形容動詞語幹',
                                '名詞-特殊-助動詞語幹',
                                '名詞-接尾',
                                '名詞-接尾-一般',
                                '名詞-接尾-人名',
                                '名詞-接尾-地域',
                                '名詞-接尾-サ変接続',
                                '名詞-接尾-助動詞語幹',
                                '名詞-接尾-形容動詞語幹',
                                '名詞-接尾-副詞可能',
                                '名詞-接尾-助数詞',
                                '名詞-接尾-特殊',
                                '名詞-接続詞的',
                                '名詞-動詞非自立的',
                                '名詞-引用文字列',
                                '名詞-ナイ形容詞語幹',
                                '接頭詞',
                                '接頭詞-名詞接続',
                                '接頭詞-動詞接続',
                                '接頭詞-形容詞接続',
                                '接頭詞-数接続',
                                '動詞',
                                '動詞-自立',
                                '動詞-非自立',
                                '動詞-接尾',
                                '形容詞',
                                '形容詞-自立',
                                '形容詞-非自立',
                                '形容詞-接尾',
                                '副詞',
                                '副詞-一般',
                                '副詞-助詞類接続',
                                '連体詞',
                                '接続詞',
                                '助詞',
                                '助詞-格助詞',
                                '助詞-格助詞-一般',
                                '助詞-格助詞-引用',
                                '助詞-格助詞-連語',
                                '助詞-接続助詞',
                                '助詞-係助詞',
                                '助詞-副助詞',
                                '助詞-間投助詞',
                                '助詞-並立助詞',
                                '助詞-終助詞',
                                '助詞-副助詞/並立助詞/終助詞',
                                '助詞-連体化',
                                '助詞-副詞化',
                                '助詞-特殊',
                                '助動詞',
                                '感動詞',
                                '記号',
                                '記号-一般',
                                '記号-読点',
                                '記号-句点',
                                '記号-空白',
                                '記号-括弧開',
                                '記号-括弧閉',
                                '記号-アルファベット',
                                'その他',
                                'その他-間投',
                                'フィラー',
                                '非言語音',
                                '語断片',
                                '未知語'
                            ],
                        ],
                    ],
                    'tokenizer' => [
                        'ja_neologd_tokenizer' => [
                            'type' => 'kuromoji_neologd_tokenizer',
                            'mode' => 'search'
                        ],
                    ],
                    'analyzer' => [
                        'ja_neologd_analyzer' => [
                            'type' => 'custom',
                            'char_filter' => [
                                'icu_normalizer',
                            ],
                            'tokenizer' => 'ja_neologd_tokenizer',
                            'filter' => [
                                'kuromoji_neologd_stemmer',
                            ],
                        ],
                        'ja_neologd_meishi_analyzer' => [
                            'type' => 'custom',
                            'char_filter' => [
                                'icu_normalizer',
                            ],
                            'tokenizer' => 'ja_neologd_tokenizer',
                            'filter' => [
                                'ja_neologd_pos_meishi_filter',
                                'kuromoji_neologd_stemmer',
                            ],
                        ],
                        'email_analyzer' => [
                            'type' => 'custom',
                            'char_filter' => [
                                'icu_normalizer',
                            ],
                            'tokenizer' => 'uax_url_email',
                            'filter' => [
                                'stop',
                            ],
                        ],
                        'phone_number_analyzer' => [
                            'type' => 'custom',
                            'char_filter' => [
                                'icu_normalizer',
                                'digit_only',
                            ],
                            'tokenizer' => 'whitespace',
                            'filter' => [
                                'trim',
                            ],
                        ],
                    ],
                ],
            ],
        ],
        'mappings' => [
            'mail' => [
                'properties' => [
                    'body' => [
                        'type' => 'text',
                        'analyzer' => 'ja_neologd_meishi_analyzer',
                        'search_analyzer' => 'ja_neologd_meishi_analyzer',
                    ],
                    'tel' => [
                        'type' => 'text',
                        'analyzer' => 'phone_number_analyzer',
                        'search_analyzer' => 'phone_number_analyzer',
                    ],
                    'email' => [
                        'type' => 'text',
                        'analyzer' => 'email_analyzer',
                        'search_analyzer' => 'email_analyzer',
                    ],
                    'mail_date' => [
                        'type' => 'date',
                        'format' => 'yyyy-MM-dd HH:mm:ss',
                    ],
                ],
            ],
        ],
    ],
];

先程は、メール本文から電話番号やメールアドレスを抽出する方法を紹介しましたが、今回は電話番号やメールアドレスは、初めから別のフィールドに分かれていますので、本文から取り出すアナライザは用意していません。
(本文に関しては、複数フィールドで設計していたのですが、結果的に不要でしたので削除しました)

上記の定義ファイルでやっていることを、ざっと説明しますと、以下のようになります。

■body(本文)フィールド

  1. アルファベット、数字、記号の半角小文字に変換
  2. 形態素解析して名刺のみを抽出
  3. 一般的なカタカナのスペルから長音記号を削除

最後の長音記号削除は、「サーバー」を「サーバ」という表記に統一する処理です。これも表記ゆれの影響を無くすための処理です。

■telフィールド

  1. 半角数字に統一
  2. 数字のみを抽出(カッコやハイフンなどの除去)
  3. 前後の無駄な余白の除去

携帯電話番号のハイフンの位置や、市外局番のカッコの使い方の揺れに対処するため、純粋に数字部分だけを抽出しています。

■emailフィールド

  1. 半角英数に統一&小文字化
  2. emailの形式になっているかチェック

表記揺れ対策とメールアドレスのパターンチェックのみです。

それぞれのフィールドに、登録時のアナライザと検索時のアナライザを登録しています。検索する際にも、同じアナライザを通すことによって、表記の揺れが修正された状態で検索が行われるようになります。
(登録用と検索用で異なるアナライザを設定することもあります)

現時点では、このように設計していますが、実際の運用が始まってから、部分一致なども必要になれば、さらにN-gramを利用したアナライザを作って追加する可能性もあります。

上半分でアナライザの定義をしているのですが、注意すべきはフィルタやトークナイザが適用される順番は、以下に示した順だということです。これは変更できないようですので、ここを押さえておかないと、意図通りの結果が得られないため注意が必要です。

char_filter(文字単位の処理)

tokenizer(単語に分割)

filter(単語単位の処理)

データを追加すると、それぞれのフィールドのデータが、それぞれのアナライザによって単語分割され、その結果が索引(検索用フィールド)に登録されます。例えば、分割された単語に「山田」という単語があれば、それが索引に登録され、同時にデータ番号がその索引に紐づけられます。検索時には、この索引のデータから検索が行われ、マッチするものが見つかれば、その索引に紐づけられたデータ番号から、該当するデータが判明します。

実験してみた。

さて、実際に15年分150万件のメールデータを読み込ませて(3時間以上かかった)、得意先名で検索してみましょう!

カタカタ、ターーーーン!

まじかよ…

腰が抜けた…

これまでの老朽化したシステムでは、うっかり期間を区切らず、つまり対象となるデータ数を絞らず150万件全部を頭からケツまでなめるような全文検索をかけてしまうと、数分待たされていたものが、なんとElasticsearchでは、

わずか0.2秒で返ってきたw

なんだよこれ、まじかよ。

Elasticsearch、神かよ。

しかも、全角半角、大文字小文字、なんでもOK。

別の案件で、大量の文章をDBで扱うサービスの制作も並行しているのですが、急遽方針転換してElasticsearchの導入をしようと思います。

Elasticsearch、神かよ。

シェアする

  • このエントリーをはてなブックマークに追加

フォローする