こんにちは!技術本部 Eight Engineering Unit でサーバーサイドエンジニアをしている常盤です。 今回は、Eight が提供している採用プラットフォームである Eight Career Design (ECD) の候補者検索機能の検索精度を改善した取り組みを紹介したいと思います。 materials.8card.net
ECDの候補者検索機能について
ECD とは、転職を希望する Eight ユーザーと、自社にマッチする人材を採用したい企業の出会いをサポートするサービスです。 ECD の候補者画面では、採用担当者が採用候補者の数十項目以上のプロフィール情報を自由に検索し、スカウトを送信したりタレントプール (お気に入りリストのような機能) に追加することができます。
ECD では2020年に Elasticsearch を利用して候補者検索機能の刷新を行い、その後も継続的に機能の追加・改善を行ってきました (2020年当時の記事はこちら)。 Amazon OpenSearch (旧Elasticsearch) Service を利用している関係上、現在は Elasticsearch 7.10 からフォークしたソフトウェアである OpenSearch を利用しています。
解決したかった課題
候補者検索機能の体験を検証していく中で、以下のような課題が出てきました。
- 検索キーワードに対してあまりマッチしない候補者がヒットしてしまう (= 適合率が低い = 検索ノイズが多い)
- 検索キーワードにマッチするはずの候補者がヒットしないことがある (= 再現率が低い)
- クエリには確かにマッチしているが、スカウトを送信したい候補者が上位に表示されない
採用担当者のスカウト業務は自社にマッチする候補者を検索することから始まるので、上記のどの課題も採用サービスとしての事業成果に直結するものです。私たちがこれらの課題をどのように解決したかを順に説明していきます。
複数のトークナイズ手法の組み合わせによる適合率・再現率の改善
まず 1.
および 2.
の課題に対してどのような取り組みを行ったかを説明していきます。
適合率と再現率
検索システムにおける適合率と再現率とは以下のようなものです。
- 適合率 (Precision): 検索結果のうち実際にユーザーが求めていたものの割合 (例: 10名の検索結果のうち7名が求めていた条件の候補者であれば適合率は70%)
- 再現率 (Recall): ユーザーが求めていたもののうち検索結果に含まれていたものの割合 (例: 求めている候補者が100名おり、そのうち検索結果に80名含まれていれば再現率は80%)
一般的に適合率と再現率は「どちらか一方を上げようとすればもう一方は下がる」傾向がありますが、検索システムにおいてはサービスの特性やユーザーのニーズに合わせて最適なバランスに調整する必要があります。
日本語テキストのトークナイズ手法
Elasticsearch / OpenSearch の全文検索機能では、ドキュメント内のテキストを事前にトークンという細かい単位に分割したものを内部でインデックス化しておくことで、高速で柔軟な検索を実現します。この際、日本語は単語間に空白のない言語のため、形態素解析または n-gram によってトークナイズを行うことがほとんどです。形態素解析については ECD では kuromoji というライブラリを利用しています。形態素解析と n-gram はそれぞれ以下のような特徴があります。
形態素解析 | n-gram | |
---|---|---|
分割方法 | 辞書データを利用したアルゴリズムにより日本語の単語ごとに区切る | 指定したN文字ごとに区切る |
メリット | ・日本語として意味のある分割になるので意図しないヒットが生じづらい ・品詞情報や単語の基本形も同時に取得して利用できる |
・N文字以上の部分文字列に対して確実にヒットする |
デメリット | ・辞書やアルゴリズムは完全ではないため、分割できなかった際などに検索漏れが生じる | ・日本語として意味のないトークンも作られるので、意図しないヒットが生じやすい ・形態素解析と比べてトークンのデータ量が肥大化する |
分割の例1 ("東京都") |
["東京", "都"] (kuromoji の mode: search の場合) |
["東京", "京都"] (N=2) |
分割の例2 ("営業本部長") |
["営業", "本部", "長"] | ["営業", "業本", "本部", "部長"] (N=2) |
上記の例1のケースでは、n-gram では「京都」というキーワードで検索した場合に「東京都」がヒットしてしまう (= 適合率が低い) ことが分かります。
いっぽう例2のケースでは、kuromoji で「部長」と検索した場合に (ヒットさせたいかは別として)「営業本部長」はヒットしない (= 再現率が低い) 結果となります。
これらはあくまで例ですが、一般的には以下のことが言えそうです。
- 形態素解析のみでトークナイズした場合、検索結果の適合率は高く、再現率は低くなりやすい傾向がある
- n-gram のみでトークナイズした場合、検索結果の適合率は低く、再現率は高くなりやすい傾向がある
形態素解析と n-gram の両方でトークナイズして検索する
このようなことから日本語の全文検索においては、形態素解析と n-gram の両方でテキストをトークナイズし、両者を組み合わせた検索クエリを実行する方法がよく取られます。
候補者の肩書きを表す title
フィールドにおける例を示します。
indexのmapping定義 (analyzer の設定は割愛)
{ "properties": { "title": { "type": "text", "analyzer": "kuromoji_analyzer", "fields": { "ngram": { "type": "text", "analyzer": "ngram_analyzer" } } }, # ... その他のフィールド定義 } }
fields
は1つのフィールドを複数の設定でインデックスさせたい場合に利用できるオプションです。
このようにすると、kuromoji でトークナイズされたフィールドは title
という名前で、n-gram でトークナイズされたフィールドは title.ngram
という名前で検索できます。
検索クエリ
{ "query": { "bool": { "should": [ { "match": { "title": { "query": "部長", "boost": 5 } } }, { "match": { "title.ngram": { "query": "部長" } } } ] } } }
複数の条件を組み合わせた検索クエリを作る場合の基本である Boolean query で記述しています。
should
はいわゆる OR 条件に対応するため、この場合は kuromoji か n-gram のいずれかのフィールドでヒットします。
Elasticsearch / OpenSearch では、ヒットしたドキュメントは検索クエリとの関連性の高さを表す _score
という値が高い順に返されますが、Boolean query で複数の条件を組み合わせた場合、最終的な_score
は各条件のスコアを足し合わせたものになります。
上記の例のように boost
というオプションを利用すると、最終的な _score
を計算する際の各条件の重みを変更することができます。
実際のチューニングにおいては、boost
をはじめとした様々なオプションを調整してバランスの良い検索結果にすることが重要です。
このような調整をビジネスサイドやデータ部門の方々と連携して継続した結果、当初よりも「意図したプロフィールの候補者が上位に表示され、検索ノイズも少ない」検索にすることができました。
function_score を利用した表示順位のチューニング
次に、課題の 3. クエリには確かにマッチしているが、スカウトを送信したい候補者が上位に表示されない
についてです。
デフォルトのスコアで順位付けした場合に起きた課題と解決策
先ほどのような改善を行ったとしても、実際は以下のような候補者が上位に表示されてしまうこともありました。
- Eight にしばらくログインしていないため、スカウトを見てもらえない
- プロフィールの情報が少ないため、スカウトできるか判断がつかない
- 転職意向度の設定が高くないため、スカウトしても返信してもらえるか分からない
検索結果は数100件以上になることも多いので、デフォルトで計算されるスコア以外の要素として「その候補者にスカウトしたときに採用成果に繋がりやすいか」も加味して順位付けを行わなければ、採用担当者がマッチする人材を効率的に見つけ出すことは困難になってしまいます。
この解決策として、「その候補者にスカウトしたときに採用成果に繋がりやすいか」の度合いを表す独自のスコアをあらかじめ計算しておき、クエリとの関連度を表す _score
との両方を加味した順位付けを行うことを考えました。
function_score (script_score) の利用
このように検索結果の表示順位を調整したい場合は、function_score 機能を利用することでスコアリングをカスタマイズすることができます。
function_score
の中でも様々なスコアリングの指定方法がありますが、今回は独自のスコアと _score
をかけ合わせた計算を行う必要がある関係上 script_score
を利用しました。
script_score を利用したクエリの例
{ "query": { "function_score": { "functions": [ { "script_score": { "script": { "source": "_score * doc['custom_score'].value;" } } } ], "query": { # ... 検索条件 } } } }
custom_score
は独自のスコアを事前に計算した上で、インデックス内の各候補者のドキュメントのフィールドとして保存しているものとします。
このようにした場合、最終的なスコアはデフォルトの _score
の計算結果と custom_score
の積になるため、検索クエリとの関連度と「採用成果に繋がりやすいか」の両方を加味した表示順位にすることができます。もちろんさらに複雑なスクリプトも指定可能です。(script_score
を使用するとクエリのパフォーマンスが劣化することがあるので注意が必要です)
このような対応により、「採用成果に繋がりやすく、クエリにもマッチしている候補者」を検索結果上位に表示できるようになりました。
検索精度を定量的に検証するためのログ整備
上記のような改善活動は開発チームのみで行ったわけではなく、データ部門の方々とも連携して実施しました。 「ユーザーが求める候補者をどれくらい上位に表示できているか」を定量的に判断・改善するには、実際のユーザーの行動ログを蓄積したうえでそれらを分析する必要があります。 開発チームとしては、候補者検索機能における下記のような行動ログを社内の分析用データベースに蓄積することで、データ部門の方々が精度の分析・改善方針の検討を行うことができるようにしていました。
- ユーザーの検索実行ログ
- 実行された各検索に対して上位にヒットした候補者のログ
- 実行された各検索に対して実際にユーザーの画面に表示された候補者のログ
- 実行された各検索に対してユーザーがスカウト/タレントプールに追加したログ
例えば「ユーザーが求める候補者をどれくらい上位に表示できているか」は 2.
のうち 4.
にあてはまる候補者の割合と考えることができますし、どのような候補者が選ばれやすいかを知ることでスコアリングや機能の改善にも活用することができます。このように、検索機能を継続的に改善するためには当たり前ながらログ情報を整備することが非常に意味を持ちます。
まとめ
以上のように、検索システムの特性を考慮しながら継続的に精度を改善することによって、ECDの採用成果に大きく貢献することができました! Elasticsearch / OpenSearch は技術的な観点からも奥の深いソフトウェアで、他にも様々な活用方法が存在します。ECDでは例えば以下のような取り組みも実施しています。
- ユーザー辞書・同義語辞書の整備による検索精度の改善
- Aggregations (集計) 機能を活用した検索キーワードサジェスト機能
- index alias 等を利用した無停止でのインデックス再構築
今後もさらに検索機能を改善し、候補者と採用企業の出会いに貢献していきたいと思います💪