tva
← コラム

LLMを活用した求人マッチング:適合度スコアリングパイプラインの構築

キーワードによる絞り込みは速く、安価ですが、肝心な点で判断を誤ります。採用担当者が専門分野、技術の習熟度、コミュニケーションのスタイル、数多くの条件を含む理想の候補者像を定義しても、単語の出現だけを見るバッグオブワーズ方式では、求人がその人物像に合うかを的確に評価できません。「Python経験5+年」と「非同期I/Oを多用する本番のデータエンジニアリング業務でPythonを書く」の意味の違いは、キーワードフィルターには見えません。一方、適切なプロンプトがあれば、言語モデルはその違いを評価できます。

私たちは、固定した候補者プロファイルに対して、キューに入った求人をLLMで採点するマッチングパイプラインを構築しました。マッチングを並行実行し、トークン費用をリアルタイムで追跡し、ネイティブのSwiftUI製macOSアプリから一時停止と再開を操作できます。本記事では構成と、単純な手法と比べてLLMによるマッチングが費用に見合う場面を解説します。


プロンプトアーキテクチャ

システムの中心は、求人ごとに1回呼び出す採点用プロンプトです。各呼び出しには、候補者プロファイルと求人本文の2つを渡します。プロファイルはセッション全体で固定し、スキル、経験レベル、希望分野、譲れない条件、採用担当者が評価に含めたい補足情報を記載します。求人側には、スクレイピングで取得したままの職種名、企業名、仕事内容、応募要件を渡します。

モデルは、0から100の数値スコアと、適合または不適合の主な理由を短く説明する根拠の2フィールドを持つ構造化JSONオブジェクトを返します。出力構造を強制することが重要です。構造化されていない応答は壊れやすい解析処理を必要とし、スコアの表現にもばらつきが生じます。JSONスキーマを指定したツール呼び出しや関数呼び出しのインターフェースを使い、プロバイダーが提供している場合は厳格モードや構造化出力モードを利用すれば、この種の問題を大幅に減らし、後続の集計も簡単にできます。ただし、解析後のオブジェクトの検証は引き続き有用です。

明確にしておきたい設計上の判断は、プロファイルを詳しく書くことです。短いプロファイルでは、70と85を区別する文脈が足りず、スコアにばらつきが生じます。条件の内容だけでなく、それがなぜ重要なのかを明示すると、モデルは有用な差を付けて採点できます。プロファイルは絞り込み用の文字列ではなく、詳細な評価基準として扱います。

ThreadPoolExecutorによる並行実行

50件や100件の求人を処理する場合、APIを順番に呼び出す方法では、対話的に使うには遅すぎます。Pythonのconcurrent.futures.ThreadPoolExecutorなら簡潔に処理できます。ワーカー数はAPI提供者のレート制限に合わせて設定します。通常は5から10件の同時リクエストで、利用枠のエラーを起こさずに十分な処理速度を得られます。

このパターンではas_completed()を使います。投入順ではなく完了順にFutureが返るため、最も早く完了した結果から表示でき、バッチ全体の終了を待たずにUIを順次更新できます。

from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor(max_workers=8) as executor:
    futures = {
        executor.submit(score_listing, listing, profile): listing
        for listing in listings
    }
    for future in as_completed(futures):
        listing = futures[future]
        score, rationale = future.result()
        update_results(listing, score, rationale)

エラー処理には明示的な対応が必要です。個々のAPI呼び出しは、レート制限、一時的なネットワーク障害、コンテンツポリシーによる拒否などで失敗します。各ワーカーは例外を捕捉し、失敗を示す値を返すべきです(上のコード例では簡潔にするため省略しています)。そうしないと、結果処理のループ内でfuture.result()がワーカーの例外を再送出し、残りの結果の処理が止まります。採点に失敗した求人は、黙って除外せず再試行のキューに入れます。理由不明の欠落があるスコア一覧より、失敗が見える方が状況を理解しやすくなります。

トークン使用量のリアルタイム追跡

規模が大きくなると、トークン費用は過小評価しがちです。400トークンのプロンプトを100件の求人に送り、各応答が150トークンになると、モデルの料金区分によっては費用が急速に積み上がります。パイプラインはスレッドセーフなカウンターで累計を追跡します。threading.Lockで保護した単純な整数を使い、API応答のたびにレスポンスのメタデータにあるトークン数を加算します。

SwiftUIのフロントエンドはタイマーでこのカウンターを取得し、入力・出力トークンの累計と、使用中のモデルのトークン単価に基づく推定費用を表示します。採用担当者はセッション費用をリアルタイムで把握でき、プロファイルによって理由の説明が異常に長くなっていることにも気付けます。これは、プロンプトを簡潔にする必要があるか、求人本文に想定以上のノイズが含まれる兆候であることがよくあります。

実用的な改善として、送信後だけでなく送信前にもトークン数を推定します。主要なプロバイダーは、トークン化ライブラリやトークン数を数えるエンドポイントを提供しています(例えば、OpenAIのtiktokenライブラリやAnthropicのトークン計数エンドポイント)。事前に数えれば、求人本文が極端に長くコンテキストを過剰に消費する場合や、プロンプト全体がモデルのコンテキスト上限に近づく場合に警告できます。API呼び出し前に検出することで、途中で切り詰められるか拒否されるリクエストへの無駄な支出を避けられます。

一時停止と再開

採用担当者がセッション途中で結果を確認する際、進行中の処理を失わずに新しい採点リクエストを止めたい場合があります。一時停止と再開には、共有シグナルとしてthreading.Eventを使います。ワーカースレッドは各API呼び出し前にイベントの状態を確認します。一時停止中ならevent.wait()で待機し、イベントが再びセットされると実行を続けます。

実行中のAPI呼び出しはそのまま完了できるため、スレッドを終了させて再起動するよりも扱いやすい方法です。一時停止は「新しい呼び出しを開始しない」という意味であり、「今すぐすべてを中断する」という意味ではありません。ユーザーが結果を確認する前に現在のバッチを完了させたい場合、この違いは重要です。部分的なバッチは、完了したバッチより状況を把握しにくいためです。

一時停止と再開をまたぐ状態の永続化にも明示的に対応します。パイプラインは、採点済みの求人IDをJSONの単純なリストにしたチェックポイントファイルを保持します。再開時には、このファイルに含まれる求人をスキップします。スコアと根拠もチェックポイントとともに永続化しておけば、途中でアプリがクラッシュしても、再起動後に採点済みの求人を採点し直す必要はありません。セッション単位の冪等性は、ごく小さな実装負担で大きな手間を省けます。

SwiftUIによるmacOSフロントエンド

対象端末がmacOSであり、応答性が高く、負荷が小さく、macOSのウィンドウ管理に対応したネイティブな操作感を求めたため、フロントエンドにSwiftUIを選びました。Electronでも実現できますが、個人用の生産性ツールには不要な複雑さが加わります。Pythonのバックエンドはローカルプロセスとして動き、Flaskで最小限のHTTP APIを公開します。SwiftUIアプリはlocalhost経由で通信します。

SwiftUIのObservableObjectと@Publishedのパターンで、データ変更に応じた更新を簡潔に処理できます。ビューモデルがタイマーでバックエンドを取得し、結果一覧とトークンカウンターの表示を駆動する公開配列を更新します。メインウィンドウには、スコアの降順に並ぶ結果一覧、トークン数と推定費用のリアルタイム表示、一時停止・再開と停止ボタンを備えた操作バーの3つのパネルを表示します。

各結果行には、職種名、企業名、色分けしたスコアバッジ、LLMによる1行の理由説明を表示します。行を選択すると詳細シートが開き、理由の全文、モデルが指摘した主要な要素、元の求人への直接リンクを確認できます。採用担当者が確認に費やす時間の大半は、この詳細シートでの検討です。スコアは注目すべき求人を示す目安であり、最終判断ではありません。

LLMによるマッチングがキーワードによる絞り込みより有効な場面

要件が明確な二択条件なら、キーワードによる絞り込みが適切です。特定の資格が必須、特定の都市に居住、一定年数以上の職務経験といった条件です。これらは単純なフィルターでミリ秒単位で処理でき、費用もかかりません。

しかし、実際に重要な条件の多くは文脈に依存します。「読みやすく、十分にテストされたコードを書く人を求める」という希望は、単独のキーワードではなく、求人の書き方に表れます。「技術構成が古いものだけで、近代化の道筋もない職務には関心がない」という条件も、本文を読んで解釈する必要があります。キーワードフィルターではこの情報を拾えません。こうした条件を明確に記したプロファイルを渡せば、LLMは直接評価できます。

キーワードが過剰に盛り込まれる求人市場では、差がさらに広がります。職務の中心となるかどうかに関係なく、どの求人にも同じスキルが列挙されると、キーワードマッチングのスコア分布はほぼ平坦になり、大半の求人が同等に見えます。意味に基づく採点は、単語の有無だけでなく、求人全体がプロファイルとどの程度整合するかを評価するため、このノイズを取り除けます。

LLMを使う必要がない場面

LLMによる採点には、実際に費用と待ち時間が発生します。明確な二択条件で数千件の求人を処理するなど、大量で細かな解釈を要しない選別では、構造化したフィルターの組み合わせが桁違いに速く、安価です。適切な構成は、両方を組み合わせる場合が多くあります。高速な事前フィルターで明らかな不適合を除き、扱える件数に絞ってから、残った求人だけをLLMで採点します。事前フィルターで求人の80%を除外すれば、LLMによる採点費用もおおむね同じ割合だけ減ります。明らかな不適合だけを除外する限り、マッチング品質も維持できます。

もう一つの論点は一貫性です。LLMの出力は非決定的で、同じ求人を2回採点するとスコアが少し変わることがあります。多くの採用用途では、この変動は許容できます。数点の上下で、確認すべき求人が変わるわけではないためです。ただし、モデルのバージョンが違うセッションや、プロンプトを大きく改訂したセッション間でスコアを比較する際には、この点に注意が必要です。

関連記事

関連記事