全文検索 MySQL FULLTEXTインデックスからElasticsearchへ切り替えた話

はじめに

こんにちは、レバレジーズ フリーランスHub開発チームです。 現在、弊社が運営するフリーランスHub というサービスでは検索エンジンとしてElasticsearchを採用しています。 この記事では、エンジニアがサービスの成長速度と工数を考慮した上で、Elasticsearchを採用するに至った背景や理由、そして、MySQLからElasticsearchへ移行した結果どれくらいパフォーマンスが改善したか、などを紹介していきたいと思います。

サービス紹介

フリーランスHub は全国のフリーランスエージェントの保有案件から、10万件以上の案件をまとめて掲載しているエンジニア・クリエイター向けのフリーランス求人・案件メディアです。

2021年4月にリリースした新規事業で、サイト内でフリーランスエージェントの案件を集約して掲載しており、フリーランスを検討される方が案件に一括で閲覧・応募することができます。

また、サイト内で開発言語や職種別に案件の検索ができることに加え、エージェントの詳細ページから各エージェントの特徴やサービスを受けるメリットなどを比較し自分にあったフリーランス案件・フリーランスエージェントを探すことが可能です。

f:id:r-nakanoue-lvgs:20220224161518j:plain

検索機能の説明

検索パネルは開発スキルや職種、業界、こだわり条件(週3など)や、都道府県、路線、駅での検索機能があります。

f:id:r-nakanoue-lvgs:20220224161624p:plain

f:id:r-nakanoue-lvgs:20220224161640p:plain

サービス設計フェーズ

見通しを立てる

事業をスタートする際は様々な軸で数年の見通しを立てます、今回のテーマに影響するものはこの辺です。

f:id:r-nakanoue-lvgs:20220224171553p:plain

フリーランスHubは機能数が少ないので小さくスタートし、短期間で拡張と機能追加をしてサービスを成長させていく方針にしました。 その他見通しの説明は記事の最後で補足しておきます。

アーキテクトの選定

f:id:r-nakanoue-lvgs:20220224172926p:plain

AWSを利用している他プロジェクトの導入実績をベースに選定、Elasticsearchでの全文検索は幾つかの事業チームでの運用実績があります。 RDSは2つあり、メインはAurora(MySQL)、データ収集と分析を低スペックのMySQLにしています。 集まってくれた開発メンバーの中にElasticsearchを熟知しているメンバーがおらず、安定性と開発速度の両面で懸念が出てきました。

検討

  • リリース前に準備しておきたいことは山積しているため甘い判断は出来ない
  • BigQueryでの全文検索は社内導入実績が無いためElasticsearchよりハードルが高い
  • 最終的にElasticsearchは必要になる
  • 開発期間中に実際のデータが無い状態で、振る舞いを熟知し安定運用レベルに達するのは難しい
  • 5-10万件ならAurora(MySQL)FULLTEXTインデックスでも良さそう
  • RDB利用のほうがLaravelデフォルト機能の親和性が高く開発が楽で初期はメリットが多い
  • AuroraはMroongaの利用が出来ないが、Elasticsearch移行するなら大きく問題は無い

決断

  • Aurora(MySQL)FULLTEXTインデックスで初期開発を行う
  • 機能・拡大フェーズでElasticsearchへ切り替える際、スピードを損なわないシステム設計を行う

システム処理設計

まずはRDSだけでシステムを構築し、切替前にElasticsearchにデータを同期した後、検索機能を切り替える方法を選択しました。

リリース前 (3万件〜)

f:id:r-nakanoue-lvgs:20220224173001p:plain

機能追加・拡大 (9〜12万件)

f:id:r-nakanoue-lvgs:20220224173017p:plain

処理説明

  • 案件データ作成 : HTMLクロール等で収集した元データから表示用のテーブルを作成
  • 全文検索データ作成 : タイトル・内容・スキルなどの文字列を検索用テーブルの1つカラムへ
  • 件数作成 : 表示する事の多い全案件数などはカウントテーブルへ事前に入れておく

サイトからの検索処理

  • 全ての検索処理は検索テーブル、表示は表示テーブルと役割を分ける
  • ヒットした検索テーブルからidを取り、表示テーブルをidで検索し表示する
  • 例えば検索パネルで PHPで検索した場合
    • PHPという文字列で検索テーブルを検索する
    • スキルテーブルからPHPを探しテーブルidで検索というプロセスは行わない
  • 一覧だけでなくサジェスト処理なども同じ

検索データを上手に作成しておく事が必要となる代わりに、検索SQLでテーブルをジョインする必要が無くシンプルになります。 Elasticsearch利用時も検索テーブルだけのシンプルなクエリに出来ます。

インフラ構築

f:id:r-nakanoue-lvgs:20220224173107p:plain

Elasticsearchは最終的なスペックでビルドし利用するまでスペックを落として運用していました。 AWSはCDKで構築したのでサイズとノード数を変更し実行するだけで切り替わる構成です。

Elasticsearchに限らず初期は不要なケースも最初から作成しておきました。例えば、MQはRDSでスタートし後でSQSに切り替えました。 インフラ変更は影響範囲が広く、後で追加を行う時の苦労が多いため、構成は最初から作っておき利用時に起動する等により、サービスの安定性と成長スピードを損なわないメリットがあります。 無駄なコストが発生してしまうので、サービス成長速度を上げ無駄となる期間を短くします。


比較

実行結果

早速結果から、クエリキャッシュOFFで実行しています。 MySQL long_query_time は 0.25s を閾値にして運用しています。

f:id:r-nakanoue-lvgs:20220224173144p:plain

案件数が5万件ほどの時期にソート順の要件が増えてきました。

  • 優先 : 一部の案件を上位に表示
  • 募集終了 : 募集終了した案件も一覧の後ろの方に追加

指定した検索条件で関連順の場合はMySQL FULLTEXTインデックスでも高速ですが、独自にソートを追加したSQLは速度低下が見られました。 ダミーデータで作成した32万件のテーブルにクエリ実行した結果、ヒットした件数が増えるほどソート処理に大きく時間がかかる事がわかりました。 契約数・掲載数も順調に伸びているため、早めにElasticsearchに切り替えることにしました。

1週間ほどエンジニア1名をElasticsearchに貼り付け32万件のダミーデータでクエリを発行し大きく改善する事を確認、他の追加開発を止める事もなく、その後3週間ほどで切り替えが完了。 トークナイザーやデプロイの最適化など、経験と専門性が必要な領域は切り替え後に作り込みました。

クエリ

テーブル、インデックスは item_search です。

f:id:r-nakanoue-lvgs:20220224173452p:plain

CREATE文は補足に記載しておきます。

Aurora(MySQL)

初期

SELECT
    `item_id`, `status`, `created_at`, `base_value`
FROM
    `item_searches`
WHERE
    MATCH(search_string) AGAINST(
    -- キーワードに「東京」が含まれる案件の中で、「Java」もしくは「PHP」の案件の取得
        ' +( ""東京"") +( ""Java PHP"")' IN BOOLEAN MODE
    );

+優先+募集終了

(
    SELECT
        `item_id`, `status`, `created_at`, `base_value`
    FROM
        `item_searches`
    WHERE
    -- `item_id` = 25000,30000,35000,40000,45000は上位表示する優先データ
        `item_id` IN(25000, 30000, 35000, 40000, 45000)
    ORDER BY
        `updated_at` DESC
)
UNION
(
    SELECT
        `item_id`, `status`, `created_at`, `base_value`
    FROM
        `item_searches`
    WHERE
        `status` = 1 AND
        MATCH(search_string) AGAINST(
            ' +( "東京") +( "Java PHP")' IN BOOLEAN MODE
        )
)
UNION ALL
(
    SELECT
        `item_id`, `status`, `created_at`, `base_value`
    FROM
        `item_searches`
    WHERE
        `status` = 2 AND
        MATCH(search_string) AGAINST(
            ' +( "東京") +( "Java PHP")' IN BOOLEAN MODE
        )
);

上位表示する優先データは別のクエリで先に求めています。 上2つのSQLをUNIONしているのは上位表示したデータの重複を防ぐためです。ヒット件数が多い検索条件であるほどソート処理に時間がかかります。なおUNION ALLにしてもヒット件数が多い限りほぼ改善しません。 また、WHERE句でMATCH()を使用すると自動的に関連度順でソートされますが、SELECTステートメントでもMATCH()を使用する事でORDER BY句で関連度順ソートを指定する事ができ、statusと併せてソートする事も可能です。ただ、前者に比べ後者の方がソートのオーバーヘッドがかかるため後者は採用しませんでした。

Elasticsearch

+優先+募集終了

GET /item_search/_search
{
  "track_total_hits": true,
  -- 表示する案件は1ページあたり30件のためsizeを30で指定
  "size": 30, 
  "query": {
    "bool": {
      "must": [
         {   
          "match": {
            "search_string": {
              "query": "Java PHP",
              "operator": "or"
            }   
          } 
        },
        {   
          "match": {
            "search_string": {
              "query": "東京",
              "operator": "and"
            }   
          } 
        }
      ],
      "should": [
        {
          "terms": {
            "item_id": [
              25000
            ],
            "boost": 10000
          }
        },
       {
          "terms": {
            "item_id": [
              30000
            ],
            "boost": 8000
          }
        },
        -- <中略> --
        {
          "terms": {
            "item_id": [
              45000
            ],
            "boost": 2000
          }
        }
      ]
    }
  },
    "sort" : [
    { 
      "status":{
        "order" : "asc"
      }
    },
    {
    "_score":{
        "order" : "desc"
      }
    }
  ]
}

上位表示する優先データはスコアをブーストする方法で実現しています。


切替時のプログラム変更

データ作成

LaravelのScoutを用いて、Auroraの検索テーブルを追加・更新した場合Elasticsearchに自動的に同期される作りにしました。 そのため、モデルをElasticsearchと自動的に同期させるモデルオブザーバを登録するために、検索可能にしたいモデルにLaravel\Scout\Searchableトレイトを追加しました。

use Laravel\Scout\Searchable;

class Post extends Model
{
    use Searchable;
}

検索

以下のクラスを作成し、Elasticsearchで検索できるように設定しました。

  • app/Providers/ElasticsearchServiceProvider
    • Scoutを使ってElasticsearchを使えるようにする設定を記載
  • app/Scout/ElasticsearchEngine
    • ScoutからElasticsearchを操作する内容を記載

また、SQLを呼び出す側だったRepository層では、SQLからElasticsearchのクエリに書き換えました。

形態素解析

php-mecab

  • 形態素解析はphp-mecabを利用
  • スキルテーブルや駅テーブルの名詞をdictに登録
  • 案件データ作成や検索処理以外でも形態素解析の処理がある
  • ES切り替え後もkuromojiだけに出来ない

Elasticsearch kuromoji

  • mecab登録した内容はkuromojiにも登録

検索用データの工夫

f:id:r-nakanoue-lvgs:20220310183456p:plain

1文字は単語として認識しないため、検索データ・検索クエリ共に変換して処理しています。 認識しない記号も同じように変換しています。 駅名は検索データに「渋谷」「渋谷駅」の2つを登録し、検索パネルの場合「渋谷駅」に変換し「渋谷区」の別の駅にヒットしないようにしています。 これらの文字列はmecab、kuromojiに登録しておきます。


設定

Aurora(MySQL)

1単語認識文字数

デフォルトは最小4文字ですが「DB」「東京」などあるため2で設定します。

ft_min_word_len=2
innodb_ft_min_token_size=2

変更する場合はDB再起動が必要なので必ず先にやっておきましょう。

ngram

MySQL FULLTEXT インデックスはWITH PARSER を指定してINDEXを作ります。

スキル(PHP・Linuxなど)での検索の重要性が高いためngram無しでINDEXを作成しました。

ALTER TABLE `item_searches` ADD FULLTEXT KEY `idx` (`search_string`);

例えば「...業務内容はブリッジSEとして...」を解析しても「ブリッジSE」ではヒットしない挙動となり、 Elasticsearch移行前の大きな課題でした。

Elasticsearch

トークナイザー

modeをnomalに設定することで、辞書登録した文字列を1単語として単語認識させることができます。

"tokenizer" : {
  "kuromoji" : {
    "mode" : "normal",
    "type" : "kuromoji_tokenizer"
  }
}

モードがデフォルトのsearchだと、例えば javascriptを形態素解析した場合、javascriptの他に java script としても分解されるため、検索結果にjavaの案件が含まれる事になります。 今回の事例では辞書登録した単語を1単語として単語認識させることで検索結果の精度を上げています。

Character Filter ※2022/03/10 追記

char_filterで検索文字列の大文字を小文字に統一する設定も行なっています。

"char_filter": {
 "lc_normalizer": {
  "type": "mapping",
  "mappings": [
       "A => a",
       "B => b",
       "C => c",
    --- <中略> --- 
       "Z => z"
    ]
  }
},

Character Filterを使い入力文字列を小文字に正規化することで、大文字・小文字に関係なく(※)辞書登録されている文字列に一致させることができます。

※ ユーザ辞書を作成する際にアルファベットは小文字限定で作成している、という前提があります。

エイリアス

  • 必要な理由
    • インデックスはフィールド追加やKuromojiの内容変更などで作り直す事がある
    • プログラムからインデックスを直接参照すると作り直すタイミングでサービスに悪影響が出てしまう
  • 構造
    • エイリアス item_search
    • インデックス item_search_yyyymmdd
    • 変更時はインデックスを作り直しAliasに紐付け検索データを切り替える

リリース手順の変更

artisanコマンドを作成し、以下を実行しています。

  • スキル・駅などのデータを取得しmecabのdictを作成
  • Elasticsearchインデックスの変更がある場合のみ以下も実行
    • 定義に従いインデックスを作成 item_search_yyyymmdd
    • スキル・駅などのデータをKuromojiに登録
    • Aurora item_search から Elasticsearch item_search_yyyymmdd へデータをコピーするプログラムを実行
    • エイリアスを切り替え

終わりに

簡単ではありましたがElasticsearchを採用するに至った背景や理由、MySQLからElasticsearchへ移行した結果どれくらいパフォーマンスが改善したかを紹介しました。

今回の事例のように、レバレジーズでは事業課題を解決するための技術選定やプロダクト改善をエンジニアが主導して行うことができます。 このような主体的に提案ができる環境で是非一緒に働きませんか?

レバレジーズ フリーランスHub開発チームでは一緒にサービスを作ってくれる仲間を募集中です! ご興味を持たれた方は、下記リンクから是非ご応募ください。

leverages.jp


補足

見通し

  • 案件(求人)数
    • 事業モデルが現状だと数年運用しても100万件には達しない
    • スペックアップが簡単など充分コントロール可能なので過度な予測を行う必要がない
  • リリース前期間の見通し
    • 結果的に3ヶ月+12日
    • システムマネージャー2名と類似サービスを見ながら30分ほど打ち合わせ
    • 楽観でチャレンジングな目標を設定
    • 機能・非機能を洗い出しグルーピングして一覧化
    • ストーリーマッピングの要領でリリース前のラインを引く
    • 最初は楽観でラインを引く
    • 実際の開発進捗を見てライン・スケジュールどちらかを調整
    • どうすれば出来るかを常に考える
  • リリース直後が最も忙しくなる
    • 経験則ですが、例えば以下などが良くあります
    • 初めて発生する事案や運用が多い
    • 発見した不具合を即日で潰す
    • 新規の契約や案件(求人)掲載の集中

CREATE

MySQL FULLTEXT インデックス

CREATE TABLE `item_searches` (
  `id` int(11) NOT NULL,
  `item_id` int(11) NOT NULL,
  `base_value` int(11) DEFAULT NULL,
  `status` tinyint(4) NOT NULL,
  `search_string` text NOT NULL,
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

ALTER TABLE `item_searches`
  ADD PRIMARY KEY (`id`),
  ADD KEY `item_searches_item_id_index` (`item_id`) USING BTREE,
  ADD FULLTEXT KEY `idx` (`search_string`),
  MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;

Elasticsearch

PUT /item_search?pretty
{
  "aliases" : {
    "item_search_replace" : { }
  },
  "settings" : {
  "index" : {
    "max_result_window" : "500000",
      "analysis":{
        "char_filter": { -- 2022/03/10追記
          "lc_normalizer": {
            "type": "mapping",
            "mappings": [
                 "A => a",
                 "B => b",
                 "C => c",
             --- <中略> --- 
                 "Z => z"
              ]
          }
        },
        "filter" : {
        "greek_lowercase_filter" : {
          "type" : "lowercase",
            "language" : "greek"
          }
        },
      "tokenizer" : {
        "kuromoji" : {
            "mode" : "normal",
            "type" : "kuromoji_tokenizer",
            "user_dictionary_rules" : [ 
                "Java,Java,Java,スキル", 
                "PHP,PHP,PHP,スキル", 
                -- <中略> --
                "渋谷駅,渋谷駅,渋谷駅,駅名",
                "東京駅,東京駅,東京駅,駅名"
            ]
          }
        },
      "analyzer" : {
          "analyzer" : {
            "type" : "custom",
            "char_filter" : "lc_normalizer", -- 2022/03/10 追記
            "tokenizer" : "kuromoji",
            "filter" : ["kuromoji_baseform", "greek_lowercase_filter", "cjk_width"]
          }
        }
      }
    }
   },
  "mappings" : {
    "properties" : {
      "id" : {
        "type" : "integer"
      },
      "item_id" : {
        "type" : "integer"
      },
      "search_string" : {
        "type" : "text",
        "analyzer": "analyzer",
        "fields" : {
          "keyword" : {
            "type" : "keyword",
            "ignore_above" : 256
          }
        }
      },
      "base_value" : {
        "type" : "integer"
      },
      "status" : {
        "type" : "integer"
      },
      "created_at" : {
        "type" : "date"
      },
      "updated_at" : {
        "type" : "date"
      }
     }
  }
}