LLMに任せてみたけど“なんか違う”と言われたので、地道にルールを作った話

Crowd Agentプロダクトチームでエンジニアとして開発を行っている與猶です。

本記事では、弊社で直面したLLMを使ったプロダクト開発の現場を題材に、Groovesにおけるリードエンジニアとしてコードを通じてプロダクトに責任を持つ仕事とは何か?を実例を交えてお伝えしたいと思います。

技術的な試行錯誤や実装ノウハウに加えて、UX視点からの設計判断やプロダクト体験に焦点を当てた補完的な視点の記事も公開されています。あわせてお読みいただくことで、より多面的に全体像を捉えていただけるかと思います。

👉 人がチェックしやすい体験が肝。ルールベース+LLMで実現する自動転記という記事

※本記事はその技術実装面とリードエンジニアの判断プロセスに焦点を当てています。

これLLMでできるんじゃない?と始まった

近ごろは大規模言語モデル(LLM)ブームの中で、「これLLMでできるんじゃない?」という問いやノウハウについて、あらゆる場面において社内・社外含めて色々な領域で耳することも増えたと思います。

実際、私たちのチームでも、他社媒体から自社フォーマットの求人票への項目変換を、ゼロショット( 事前訓練なしAIにタスクを行わせること) でLLMにやらせてみようとしたことがあります。

結果だけ先に言うと、“なんか違う”とユーザーから言われて終わりました。 今回は、その“違う”をどう捉え直し、どのようにルールベースでプロダクトを作り直し、結果的にユーザに喜んでいただけるプロダクト仕様、業務効率化に繋がったのかについて開発の経緯をまとめていければと思っています。

課題設定:他社媒体→自社フォーマットへの変換自動化

媒体ごとにバラバラだった求人票の項目を今回のプロジェクトでは、多様な形式で提供される求人情報を自社フォーマットへ転記・整備する作業の効率化に取り組みました。

現状の課題

  • 求人データのフォーマットは提供元によって異なる
  • 担当者が内容を読み解き、必要な情報を整形して転記
  • 表記のゆれ、読みやすさ、体裁の調整なども求められ、1件あたり約30分かかっていた

課題

LLMを使ったPoC(概念検証) とその限界

弊社でもLLMを使ったプロダクト機能はすでに複数リリースしており、ある程度の知見や勘所は分かってきていました。

使用する場面としては「要約・情報抽出」といった、比較的入力の構造が安定していて、出力に許容幅のあるユースケースに限定されていました。

一方で今回のケースは、すでにリリース・効果測定の速報まで確認できた現在から振り返ってみると、多様な自然言語表現で記述された求人票を、業務フローで定義された厳密な構造に“意味を保ったままマッピング”する必要があるという点で性質が大きく異なります。

要するに、これは「変換」というよりも、“意味のぶれを許容しない正規化”であり、LLMにとっては文脈解釈と仕様適合性の両立が求められる難しいタスクでした。

上記のような複雑なタスクを当時はまだ当時使っていたモデル Claude 3.5-Haiku/Sonnet では当然難しかったことがモデルごとの特性や性能差への解像度が上がった今としてはわかります。

当時、ユーザからPoCを触った感想としては、以下のような期待値を求められておりこれを満たすことは難易度の高い作業に思えました。

PoCのFB

※モザイク部分は個人情報や特定の業務情報が含まれる部分

定性評価で使ったPoCの例

では、なぜ当時のゼロショットによる変換ではダメだったのでしょうか。一つ一つ掘り下げていきたいと思います。

PoCの詳細

プロンプト

SystemMessage: 
データ抽出のエキスパートとして、あなたのタスクは任意のデータから指示された情報を抽出することです。

UserMessage:
<job></job>タグの内容は求人情報です。

<job>
{{jobData}}
</job>

以下のルールに従って、<job></job>タグの中身のみからデータを抽出してください。
- 抽出できた項目は<job></job>タグ内に記載されている情報を省略せずに正確にコピーしてください。
- 選択肢の場合、選択した根拠となる前後の文章を \`mapSources\` に記載してください。値は<job></job>タグの選択した根拠となる前後の文章です。
- 抽出できた項目はできるだけ多くの情報を値として設定してください。
- 抽出できない項目は値を""で返してください。

以下のTypeScriptの型定義を参考にして、求人情報をJSON形式で記述してください。
\`\`\`typescript
export type JobSchema = {
  description?: string; // 仕事内容
  background?: string; // 募集背景
  (中略)
  relocation?: boolean; // 転勤の有無: true (有り) または false (無し)
  relocationComment?: string; // 必須要件
  recruitmentProcess?: string; // 選考プロセス
  mapSources: Map<string, string>;
};
\`\`\`
`;

入力の求人票

{
  "01_会社名": "Grooves株式会社",
  "02_代表取締役": "Grooves 彗",
  "03_設立": "2023-03-03",
  "04_資本金": "777,777,777円",
  "05_従業員数": "77名(2025年5月現在)",
  "06_職種 / 募集ポジション": "スマートフォトニクス基盤エンジニア(Lead/Sr.)",
  "07_求人タイトル": "【光量子 × Edge AI】分散フォトニックプラットフォームを革新するソフトウェアエンジニア募集",
  "08_雇用形態": "正社員(無期)/業務委託・副業兼務可",
  "09_給与": "年収 6,666,666〜13,131,313円\n月給 555,555円〜1,094,426円\n賞与:年2回(1.25ヶ月/回)\n業績連動トークン:0〜3ヶ月/年\n固定残業45h含む(超過分別途支給)",
  "10_勤務地": "〒987-6543 北海道函館市宇宙町1-23-4\n全国リモート可(UTC+9±2h)\n月1出社(任意)拠点:東京・札幌・福岡\n転勤:原則なし/希望者は北欧研究拠点出向可",
  "11_喫煙環境": "屋内全面禁煙\n屋外デッキに加熱式限定ブースあり",
  "12_勤務時間": "フレックスタイム(コア11:00-15:00)標準8h\n毎月最終金曜日15時以降全社勉強会(勤務扱い)",
  "13_休日・休暇": "完全週休二日制(土日)\nシフト制週休2日(試験運用プロジェクト週)\n祝日/夏季3日/年末年始5日/創立記念日3月3日/メモリアル休暇",
  "14_福利厚生": "社会保険完備/遠隔勤務補助(月8,888円)\n光ブロードバンド補助(上限3,000円)/技術書購入(年33,333円)\n自社開発LED菜園キット支給(偶数月)\n勤続満2年ごとに1ヶ月有給リサーチ休暇",
  "15_募集要件": "【必須】\n・Go, Rust, または Elixir での Server 開発3年以上\n・光学センサまたは FPGA 系 Edge 制御経験\n・Kubernetes 上でのマイクロサービス運用\n・英語技術文書読解 TOEIC 650+\n【歓迎】\n・フォトニックデバイス制御 SDK 開発経験\n・Web3 データインテグレーション\n・OSS メンテナンス経験",
  "16_選考プロセス": "書類 → 一次(COO)→ 二次(CTO 技術課題含む)→ カルチャーフィット面談 → リファレンス → 内定\n全工程オンライン可/最終面談来社歓迎(旅費支給)",
  "17_備考": "独自制度『Aurora Leap』:3年勤続毎に海外カンファレンス参加+旅費10万円補助\n試用期間:6ヶ月(条件変更なし)"
}

※GPTに生成してもらった架空の求人票です

出力

{
  "data": {
    "description": "【光量子 × Edge AI】分散フォトニックプラットフォームを革新するソフトウェアエンジニア募集",
    "background": "",
    "numberOfNewlyHiring": "4~5名",
    "employmentStatuses": [
      {
        "employmentType": "正社員",
        "contractTerm": "期間の定め無し"
      },
      {
        "employmentType": "業務委託"
      }
    ],
    "hasTrial": {
      "hasProbation": true,
      "durationInMonths": 6
    },
    "hasOvertime": {
      "hasOvertime": true,
      "averageMonthlyHours": 45
    },
    "income": {
      "max": 13131313,
      "min": 6666666
    },
    "welfare": "社会保険完備/遠隔勤務補助(月8,888円)\n光ブロードバンド補助(上限3,000円)/技術書購入(年33,333円)\n自社開発LED菜園キット支給(偶数月)\n勤続満2年ごとに1ヶ月有給リサーチ休暇",
    "holiday": "完全週休二日制(土日)\nシフト制週休2日(試験運用プロジェクト週)\n祝日/夏季3日/年末年始5日/創立記念日3月3日/メモリアル休暇",
    "holidayComment": "",
    "prefectures": [
      "全国",
      "北海道"
    ],
    "address": "〒987-6543 北海道函館市宇宙町1-23-4\n全国リモート可(UTC+9±2h)\n月1出社(任意)拠点:東京・札幌・福岡",
    "smokingRegulation": "屋内全面禁煙\n屋外デッキに加熱式限定ブースあり",
    "smokingRegulationForOthers": "",
    "relocation": false,
    "relocationComment": "原則なし/希望者は北欧研究拠点出向可",
    "recruitmentProcess": "書類 → 一次(COO)→ 二次(CTO 技術課題含む)→ カルチャーフィット面談 → リファレンス → 内定\n全工程オンライン可/最終面談来社歓迎(旅費支給)",
    "mapSources": {
      "numberOfNewlyHiring": "【募集ポジション】スマートフォトニクス基盤エンジニア(Lead/Sr.)",
      "employmentStatuses": "【雇用形態】正社員(無期)/業務委託・副業兼務可",
      "hasTrial": "【試用期間】6ヶ月(条件変更なし)",
      "hasOvertime": "【勤務時間】フレックスタイム(コア11:00-15:00)標準8h\n毎月最終金曜日15時以降全社勉強会(勤務扱い)",
      "income": "【給与】年収 6,666,666〜13,131,313円\n月給 555,555円〜1,094,426円\n賞与:年2回(1.25ヶ月/回)\n業績連動トークン:0〜3ヶ月/年\n固定残業45h含む(超過分別途支給)",
      "welfare": "【福利厚生】社会保険完備/遠隔勤務補助(月8,888円)\n光ブロードバンド補助(上限3,000円)/技術書購入(年33,333円)\n自社開発LED菜園キット支給(偶数月)\n勤続満2年ごとに1ヶ月有給リサーチ休暇",
      "holiday": "【休日・休暇】完全週休二日制(土日)\nシフト制週休2日(試験運用プロジェクト週)\n祝日/夏季3日/年末年始5日/創立記念日3月3日/メモリアル休暇",
      "prefectures": "【勤務地】〒987-6543 北海道函館市宇宙町1-23-4\n全国リモート可(UTC+9±2h)\n月1出社(任意)拠点:東京・札幌・福岡",
      "address": "【勤務地】〒987-6543 北海道函館市宇宙町1-23-4\n全国リモート可(UTC+9±2h)\n月1出社(任意)拠点:東京・札幌・福岡",
      "smokingRegulation": "【喫煙環境】屋内全面禁煙\n屋外デッキに加熱式限定ブースあり",
      "relocation": "【転勤】原則なし/希望者は北欧研究拠点出向可",
      "recruitmentProcess": "【選考プロセス】書類 → 一次(COO)→ 二次(CTO 技術課題含む)→ カルチャーフィット面談 → リファレンス → 内定\n全工程オンライン可/最終面談来社歓迎(旅費支給)"
    }
  }
}

“それっぽい” 出力データ

なんかそれっぽいですね。しかし、ユーザのFB通り少しずつ人間が意図しない転記になってしまっていることがわかります。

いくつか例を挙げてみます

  • 募集人数( numberOfNewlyHiring )
    • 求人には、採用人数に記載がないのに勝手に 4〜5名 と選択している
    • mapSource 上も「求人タイトル」っぽいが意図と異なる挙動
  • 雇用形態( employmentStatuses )
    • 定義上は「正社員」「契約社員」「業務委託」から選ぶ(Union型)だが、実際には配列になっている。型定義に対する明確な違反
  • 月平均の時間外労働時間( hasOvertime.averageMonthlyHours )
    • 時間外労働の平均時間に対する違反は記述なし。
    • 45hなのはみなし残業
  • 休日 ( holiday )
    • 定義上は選択式だが、テキストをそのまま載せてしまっている
  • etcetc…

これを改善するために試したのがFew-shotでのプロンプト指示でした。

Few-shotの試行と、プロンプト地獄

Few-shotも試しましたが、パターン数の多さと明確なルール不足により精度・再現性ともに不十分でした。

  • Few-shotを例示すると、例文によりすぎてしまう
    • 求人票に書いてない情報を例文から拾ってしまう
    • 例:求人にはリモート情報がないのに例示の情報から「リモート可」と出力してしまう
  • mapSource が機能しない
    • 正確な文章のコピーができないので、特にテキスト項目以外の場所がどの文章を判断の根拠したのか使えない
  • 指示が多すぎると破綻してくる
    • おそらくこれは、使用しているLLMのコンテキストサイズに依存した挙動だと思うのですが、指示(入力プロンプト)が多すぎると出力も安定しない傾向にありました

このようなプロンプトの微修正をしては評価を繰り返すばかりでした。

改善したプロンプト

<job>
{{jobData}}
</job>

You are given a JSON enclosed in <job> ... </job>. 
Convert it into a single JSON object with exactly **one** top-level key: \`"standardJob"\`. 
The value of \`"standardJob"\` must adhere to the \`StandardJobSchema\` below. 
**Do not add extra keys** besides \`"standardJob"\`.

----------------------------------------------------------------------------------------
[TYPE DEFINITIONS]

type JobSchemaObject<T> = {
  /** Actual data value. */
  value: T;

  /** A confidence score from 0 to 1. */
  confidence: number;

  /** Must always be a Japanese string. */
  prettySentence: string;

  /** Original mappings (non-string or candidate keys, etc.). */
  mapSources?: string[];

  /** If data needs removal or change, define revision; otherwise omit. */
  revision?: {
    sentence: string;
    revised: string;
    type: "delete" | "modify";
    classification: "personal" | "company" | "confidential" | "inappropriate";
  };
};

type StandardJobSchema = {
  description?: JobSchemaObject<string>;
  background?: JobSchemaObject<string>;
(中略)
  relocation?: JobSchemaObject<boolean>;
  relocationComment?: JobSchemaObject<string>;
  recruitmentProcess?: JobSchemaObject<string>;
};

----------------------------------------------------------------------------------------
[PROCESS SPECIFICATION]

1. **Extract Values** 
  - Read the JSON inside <job> ... </job> and parse out each text (ignoring the keys). 
  - Attempt to allocate every piece of data to one of the \`StandardJobSchema\` properties (even if somewhat inaccurate).

2. **Assigning Data** 
  - If a piece of data is numeric, parse into \`number\`. If it suggests boolean, parse into true/false. If multiple items appear relevant, or if the data is an array, consider \`employmentStatuses\`, \`prefectures\`, etc.
  - **If** you cannot classify a particular piece at all, store it in \`commentBySales\`.

3. **JobSchemaObject**
  - For each property you set, create { value, confidence, prettySentence, mapSources?, revision? }.
  - \`prettySentence\` must be **Japanese** text describing that property. 
  - \`mapSources\`? is an array of strings that represent how you interpreted or matched that data to the property.
  - \`revision\`? is only needed if the data is confidential, personal, or needs modification.

4. **Final Output**
  - Return **only**:
    \`\`\`
    {
      "standardJob": StandardJobSchema
    }
    \`\`\`
  - No additional keys. No extra text outside the JSON object.

----------------------------------------------------------------------------------------
[EXAMPLE IN JAPANESE]

\`\`\`
<job>
{
  "01_求人名": "UIデザイナー",
  "02_年収": "500万円-700万円",
  "03_特記事項": "一部リモート勤務可",
  "04_不明な値": "abc123"
}
</job>
\`\`\`

### Possible Output Example (Truncated)
\`\`\`
{
  "standardJob": {
    "jobType": {
      "value": "UIデザイナー",
      "confidence": 0.8,
      "prettySentence": "職種: UIデザイナーです。",
      "mapSources": ["UIデザイナー"]
    },
    "incomeMin": {
      "value": 500,
      "confidence": 1.0,
      "mapSources": ["500万円-700万円"]
    },
    "incomeMax": {
      "value": 700,
      "confidence": 1.0,
      "mapSources": ["500万円-700万円"]
    },
    "commentBySales": {
      "value": "一部リモート勤務可\nabc123",
      "confidence": 0.3,
    }
  }
}
\`\`\`
*Note: Here, "abc123" was unclassifiable, so we placed it in \`commentBySales\`. Also, for demonstration, we used "mapSources": ["02_年収"] but any relevant reasoning is fine.*

----------------------------------------------------------------------------------------
[INSTRUCTIONS TO FOLLOW]
1. Read <job> ... </job> ignoring keys.
2. For each piece of data, try to assign it to a property in \`StandardJobSchema\`. If unclassifiable, use \`commentBySales\`.
3. For each assigned property, fill out \`value\`, \`confidence\`, \`prettySentence\` in Japanese, and optionally \`mapSources\` or \`revision\`.
4. Return one JSON with top-level key \`"standardJob"\`. Nothing more.

地道にルールを書く、という選択

Few-shotでのプロンプトでは上述の通りうまくいかない、ないしは相当の試行錯誤が必要なことが見えてきたところで我々は決断を迫られました。

  1. このままプロンプト改善を進める
    1. LLMのモデル変更・指示変更して精度を上げていくアプローチ
  2. 別の効率化の方法を探る
    1. LLMのような、大きな単位での言語化ではなくルールベースのように小さな単位での言語化をする

プロダクトチームのスキル構成、PoC結果、ユーザFBから判断し、ルールベース実装が最適解だとリードエンジニアとして決断し、開発に踏み切ることにしました。

この判断は、UX/PdMからの視点で“変換体験としての違和感”が明確になったことと、それをどのように構造的に担保するかという視点をすり合わせる中でかたまっていったものです。

チームの意思決定も含めたリードエンジニアとしての判断を行いながら、LLMに何を任せるべきか、どこまで任せられるのかについては、結局のところ自分の手で何度も検証を繰り返しながら判断していったというのが正直なところでした。

「ルールベース」でいくとしてその中身は?

ルールベースでいくと決めたところで以下のような本質的な問題が解決するわけではないため、何かしらのアプローチが必要になります。

  1. 入力の多様さ
    1. 媒体ごとにバラバラな求人票の項目をどのようにまとめるか。よく入力される項目(媒体ごとに25%以上の入力率)のものだけで約200項目ものデータがあります
  2. 出力項目の多様さ
    1. CrowdAgentでは現在、DB上の求人項目として83項目が定義されておりサポートする項目を定義する必要がありました
  3. 項目ごとの業務仕様
    1. 求人項目は、当然全てがフリーフォーマットではなく、一項目ずつ異なるフォーマットやルールが存在します。大枠だけでも、以下の4 タイプが存在します
      1. text (自由入力)
      2. enum (ドロップダウン)
      3. boolean (ラジオボタン、チェックボックスなど)
      4. integer (数値)

ルール自体もコードで生成する

上述の通り、本質的課題は解決しないままなので、それをどう解決するべきでしょうか。

単純に考えても、200種類の入力項目があるデータを83種類にマッピングするバリエーションは単純に考えても1,600以上の組み合わせがあり、これは人間が見切れる量ではありません。

これを解決するために、初期のルールは統計的に抽出された情報をもとにコードで自動生成し、定義の曖昧さや抜け漏れを防ぎつつも一貫したルールで説明可能な出力として生成しました。

以下にそのコードスペニットを例示します。

def main():
    # データ読み込み
    # 媒体ごとに25%以上入力される項目を抽出(BigQuery)
    job_df, job_raw_df = load_job_data(BASE_DIR, PROJECT_ID)
    # CA上で入力率が60%を超えるものを抽出(BigQuery)
    ca_df, ca_items, ca_mappings = load_ca_data(DATA_DIR)

    # 類似度計算
    embedding_matches, df_sim = compute_embedding_matches(job_df, ca_items)
    levenshtein_matches, scores = compute_levenshtein_matches(job_raw_df, ca_items, ca_mappings)

    # マッチング結果統合
    final_matches = merge_matches(ca_items, ca_mappings, embedding_matches, levenshtein_matches)

    # Rubyマッピング生成
    ruby_mapping = generate_ruby_mapping(final_matches, ca_df)

    # 結果の出力
    output_results(df_sim, embedding_matches, DUMP_DIR, ruby_mapping, BASE_DIR, scores)  # 修正

ルールと資料が一致することを重要視する

せっかく上記のように統計情報として生成したルールを「メンテ不能な一度きりの統計による生成ルール」としては私以外がメンテナンス不可能となってボトルネックとなります。

そのため資料上の見た目とコード上の見た目が一致するようにすることにはかなり拘りました。

資料上のルール

rule1

コード上のルール

rule_on_code

このように、資料の構造とコードの定義が一致していることで、レビュー・仕様変更・自動生成が一貫性を保ちやすくなります。

プロダクトは次のフェーズへ

このように、さまざまな試行錯誤や失敗を積み重ねた中で生まれたプロダクトでしたが、初期リリースの効果測定では生産性が40%向上🎉し、1日10件の求人作成が14件まで効率化されたとの計測結果も出ており、成果がで始めたことを嬉しく思っています。

今回の取り組みは、大きな基盤再構築というよりも、日々の業務課題をその場でひとつずつ手探りでほぐしていったような地道な作業の積み重ねでした。

精度と再現性を担保するルールベースによる基盤が整った今、私たちは、より曖昧で柔軟な判断を扱うための“次の問い”に取りかかっています。

この地に足のついた構造を起点に、いよいよ次はLLMという柔らかさをどう設計に組み込むか。 今、プロダクトは新たなバランスを探すフェーズへと移行しています。


最後に:古いタイプのエンジニアから、未来の仲間へ

私は、おそらく古いタイプのエンジニアだと思います。 ルールを書き、整合性を担保し、仕様をつくり、LLMの熱狂が高まる中でも、地道な作業こそが実務において信頼される結果を生み出すと信じて、コードを書いてきました。

今回のプロジェクトでは、最新技術に挑戦しながらも、最終的には素朴で現実的なアプローチに立ち返ることになりました。

人がチェックしやすい体験が肝。ルールベース+LLMで実現する自動転記という記事では、別の視点(ユーザ体験)ベースで書かれた記事です。

同じ対象に対して、異なる視点からの思考や検証が並走できたことは、実務としてとても貴重な経験だったと感じています。

UX側からの設計思想や判断プロセスについても、ぜひ合わせて読んでみてください。 機能や設計において“何を優先すべきか”を考えるうえで、参考になる発見があるはずです。

だからこそ、もっと自然に構造を捉えたり、選択肢を広く持てるようなエンジニアがこれから増えていってほしいと思っています。

「自分だったらどう作るか」そんなふうに考えながら読んでくださった方がいれば、ぜひ一度、あなたのあなたの設計思想や試行錯誤も、ぜひ聞かせてください。