前回の記事では、セキュリティニュースの記事を都道府県に分類する処理を、文字列一致からJevに置き換えることを決めるまでを書きました。
今回は、本番に導入する前に分類の精度を上げるために行った工夫の話です。
先に結論を書くと、プロンプトを工夫するより、質問を分けて、自信のない答えはコード側で捨てるほうがうまくいきました。
前回のおさらい
直近200件の記事で、旧方式(文字列一致)とJevを比較した結果です。
| 判定 | 件数 |
|---|---|
| 両者「該当なし」 | 163 |
| 回答した都道府県が両者一致 | 26 |
| Jevのみ都道府県を回答 | 9 |
| 旧のみ都道府県を回答 | 1 |
| 旧が複数回答(Jevの回答含む) | 1 |
Jevは、記事に都道府県が書かれていなくても、被害組織の本社所在地を推測して分類できることがわかりました。
「該当なし」の163件は本当に正しいのか
差分の11件はJevの全勝でしたが、両者とも「該当なし」と答えた163件は差分に出てこないため、まだ確かめていません。そこで、Claude Codeの提案で163件から20件を無作為に抜き出して確認しました。
- 該当なしで正しい: 12件(海外製品の脆弱性、JPCERTのWeekly Report、警察庁の全国向け注意喚起など)
- 本社所在地から推定できたのに、両方とも該当なし: 7件
- サンポー食品(佐賀県)、ベネフィット・ワン(東京都)、タイムズモビリティ(東京都)、らしんばん(東京都)、ワシントンホテル(愛知県)、ApplyNow(東京都)、PhotoGoods(大阪府)
- 判断が分かれる: 1件
- 日本郵便(本社は東京都だが、全国向けのサービス)
20件中7件を取りこぼしていたので、単純に比率を当てはめると、163件中50件以上が取りこぼしの可能性があります。
気になったのは、Jevの本社推定に一貫性がないことです。京王電鉄や西武鉄道は本社所在地を答えたのに、タイムズモビリティやベネフィット・ワンは答えませんでした。前回のプロンプトは「どの都道府県の地名か」を問う書き方だったので、記事に地名がないと「該当なし」に寄っていたのだと考えられます。
試行1: 1つの質問に優先順位を書き込む
まず、プロンプトに判断の優先順位を書き込んでみました。
- 本文に書かれた被害拠点(支店・店舗・郵便局など)の所在地
- 書かれていなければ、被害組織の本社所在地
- どちらもわからなければ「該当なし」
| 判定 | 件数 |
|---|---|
| 両者「該当なし」 | 141 |
| Jevのみ都道府県を回答 | 31 |
| 回答した都道府県が両者一致 | 26 |
| 旧のみ都道府県を回答 | 1 |
| 旧が複数回答(Jevの回答含む) | 1 |
「該当なし」から県付きに変わったのは22件でした。日本郵便のように、郵便局名が書かれていない記事は本社の東京都になるなど、狙いどおりのケースが増えました。
一方で、本社推定の誤りも出てきました。
- 原子力機構 → 福島県(正しくは茨城県)
- モンベル → 兵庫県(正しくは大阪府)
- ヤマト運輸 ×2 → 神奈川県(正しくは東京都)
しかも、抜き取りで見つけたタイムズモビリティやベネフィット・ワンは、相変わらず「該当なし」のままでした。知らない会社でもそれらしい県を答え、知っている会社でも答えないことがあるわけです。プロンプトの調整だけで直すのは限界だと感じました。
試行2: 質問を2つに分け、優先順位はコード側で決める(採用)
ここで、Claude CodeにTypeSafe公式のAgent Skill(Claude Codeプラグイン)を入れて、改善案を出してもらいました。スキルのガイドには、次のような考え方が書かれていました。
- 1つの質問では、1つの狭い判断だけを聞く
- 優先順位のようなルール(ポリシー)は、プロンプトではなくコード側に持たせる
- 答えに付いてくる確率やconfidenceを使って、しきい値で扱いを変える
これに沿って、次のように作り直しました。
- 1回のリクエストで、2つのChoiceを同時に聞く(同じリクエスト内の質問は並列に評価されます)
-
site: 本文に書かれた地名から、被害拠点の都道府県を答える(知識による推測は禁止) -
hq: 被害組織の本社所在地の都道府県を答える(知識から答えてよい。インシデントの記事でないものや、海外製品の脆弱性情報は「該当なし」)
-
- 優先順位はコードで決める:
site→hq(confidenceが0.5以上のときだけ)→ 該当なし - 記事は文字列ではなく、
{"article": {"title": ..., "description": ...}}という構造化したJSONで渡す(公式ドキュメントのおすすめ)
from typesafe_sdk import Choice, TypeSafeClient
# 質問1: 本文に書かれた地名だけで判断する(知識による推測はさせない)
SITE_INSTRUCTIONS = (
"`article.title` と `article.description` に、被害が発生した拠点(支店・店舗・郵便局・病院・学校・自治体など)の"
"所在地を示す地名(都道府県名・市区町村名・地域名)が書かれていますか。書かれていればその都道府県を選んでください。"
"本文に地名が書かれていない場合は「該当なし」を選び、組織の所在地を知識から推測してはいけません。"
"地名と同じ文字を含む人名・チーム名・製品名は地名として扱ってはいけません。"
)
# 質問2: 被害組織の本社所在地(記事に地名がなくても知識から答えてよい)
HQ_INSTRUCTIONS = (
"`article` が伝えるセキュリティインシデントで被害を受けた組織の本社(本部)は、どの都道府県にありますか。"
"被害組織が特定できない記事、海外製品の脆弱性情報など国内組織の被害を伝えていない記事、"
"インシデント(被害)を伝える記事でない場合は「該当なし」を選んでください。"
)
HQ_THRESHOLD = 0.5
# CRITERIA は前回と同じ、47都道府県+「該当なし」の選択肢
def classify(client, title, description):
response = client.system_one(
state={"article": {"title": title, "description": description}},
questions={
"site": Choice(instructions=SITE_INSTRUCTIONS, criteria=CRITERIA),
"hq": Choice(instructions=HQ_INSTRUCTIONS, criteria=CRITERIA),
},
)
site = response.answers["site"]
hq = response.answers["hq"]
# 優先順位はコードで決める
if site.choice != "該当なし":
return site.choice # 本文に地名があれば、それを最優先
if hq.choice != "該当なし" and hq.confidence >= HQ_THRESHOLD:
return hq.choice # 本社の推測は、自信があるときだけ採用
return "該当なし"
siteとhqの生の答えとconfidenceを保存しておけば、しきい値を変えて評価し直すときに、Jevを呼び直す必要もありません。
結果
| 決め手 | 件数 |
|---|---|
| 拠点(本文の地名) | 27 |
| 本社(confidence 0.5以上) | 31 |
| 該当なし | 142 |
拠点で決まった27件は、目で見た範囲では全件正しく分類できていました。宝塚市立病院は、拠点として兵庫県に分類されました。山口東京理科大の訓練の記事は、拠点が山口県、本社は「インシデントの記事ではない」ので該当なしと、質問を分けた効果が出ていました。
本社で決まった31件も、30件が正しく分類できていました。誤りと思われるのはGyazo → 東京都(confidence 0.69、公表したHelpfeelの本社は京都府)の1件だけです。
面白かったのは、confidenceが0.5未満で捨てた23件です。試行1で出ていた誤りが、ほとんどここに入っていました。23件のうち13件は、Jevの答えが誤りでした。
| 記事 | Jevの答え | confidence | 実際の本社 |
|---|---|---|---|
| 原子力機構 | 福島県 | 0.24 | 茨城県 |
| モンベル(2件) | 北海道 | 0.32〜0.36 | 大阪府 |
| ベルカディア(モンベルのサイト運営会社) | 北海道 | 0.18 | 東京都 |
| ヤマト運輸(2件) | 神奈川県 | 0.29〜0.39 | 東京都 |
| 佐川急便 | 大阪府 | 0.20 | 京都府 |
| 旭化成 | 大阪府 | 0.21 | 東京都 |
| カルビー | 埼玉県 | 0.14 | 東京都 |
| ピーシーデポ | 東京都 | 0.26 | 神奈川県 |
| スマレジ | 東京都 | 0.42 | 大阪府 |
| 日本トレクス | 東京都 | 0.42 | 愛知県 |
| ワシントンホテル | 東京都 | 0.44 | 愛知県 |
一方で、タイムズカー(記事4件分)、らしんばん、ロート製薬、ニチレイ、スコア・ジャパンなど、正しいのに捨ててしまったものも10件ありました(confidence 0.23〜0.49)。
つまり、しきい値0.5で正しい答えを10件あきらめて、誤りを13件防いだことになります。
捨てた23件をconfidenceで分けると、さらに傾向がはっきりします。
| confidence | 正しい | 誤り |
|---|---|---|
| 0.4以上0.5未満 | 8件 | 3件 |
| 0.4未満 | 2件 | 10件 |
0.4未満はほとんどが誤りなので、捨てて正解でした。一方、0.4〜0.5は正しい答えのほうが多く、しきい値を0.4に下げれば、誤りを3件増やすかわりに正しい答えを8件拾えます。
間違えるくらいなら答えないほうがよいと考えて、今はしきい値0.5で運用しています。
しきい値をコード側に持たせたことには、もう1つメリットがあります。プロンプトを変えるとJevの答えそのものが変わり、試行1のように、どこがどう変わるかは試してみないとわかりません。一方、しきい値はコードの数字を1つ変えるだけで、Jevの答えは変わりません。本番ではJevの生の答えとconfidenceをDBに保存しているので、0.4に下げたらどの記事に県が付くかも、Jevを呼び直さずに事前に計算できます。
※しきい値未満で捨てた23件は、公式情報で本社所在地を確認しました。それ以外の本社所在地の正誤は、一部を除き筆者とClaudeの知識で判断したものです。サプライチェーン(委託先・提携先)での事件は、記事の中心になっている企業の本社を正解としました。
同じ会社でも、記事によってconfidenceが揺れる
タイムズカーの記事は5件あり、Jevの答えはすべて東京都でしたが、confidenceは記事ごとに違い、しきい値を超えたのは1件だけでした。
| confidence | 本文での会社の書かれ方 |
|---|---|
| 0.51 | 「東証プライム上場企業のパーク24株式会社は…連結子会社であるタイムズモビリティ…」 |
| 0.48 | 「タイムズカー」のみ(会社名なし) |
| 0.44 | 「タイムズモビリティが運営する…」 |
| 0.42 | 「タイムズモビリティ株式会社は…」 |
| 0.31 | 「パーク24グループのタイムズモビリティが運営する…」 |
confidenceは、会社そのものではなく記事の書き方で変わるようです。しきい値付近の会社は、同じ事件でも記事によって地図に載ったり載らなかったりします。これも「誤った県に載せるより、取りこぼすほうがまし」という考え方で、許容することにしました。
記事をまとめて投げれば安くなる?
本番に入れる前に、コストも検証しました。TypeSafeの公式cookbook(Parallel questions)には、「質問を1回の呼び出しにまとめると12.2倍安い」という例が載っています。それなら、複数の記事を1回のリクエストにまとめれば安くなるのでは、と考えました。
Claudeの予想は「ほとんど安くならない」
ところが、Claudeに相談したところ、予想は逆でした。理由は次のとおりです。
- cookbookで安くなるのは、「1つの文書(state)に複数の質問をするので、文書のトークンを1回分しか払わずに済むから」
- 記事を束ねる場合は、記事ごとに別のことを聞くので、質問は記事の数だけ必要になる(
site_0,hq_0,site_1,hq_1, …)。文書も質問も合計は変わらないので、ほとんど安くならない - さらに、隣の記事に引っぱられて精度が落ちたり、1回の失敗で複数の記事が分類できなくなったりする心配もある
筆者の考えとClaudeの予想、どちらが正しいのか、実際に検証してみました。
検証方法
直近20件の記事を、次の方式で分類して比べました。質問文は試行2と同じです。
| 方式 | 内容 |
|---|---|
| single | 1記事1リクエスト(site / hqの2つの質問)。比較の基準 |
| single-2 | singleをもう一度実行。Jevの実行ごとの揺れの目安 |
| batch-5 / batch-10 | 5件 / 10件の記事をstateのarticles配列にまとめ、記事ごとにsite_i / hq_iを1リクエストで聞く |
結果
| 方式 | リクエスト | input | output | input/記事 | 合計トークン | site一致 | hq一致 | hqのconfidenceの平均差 |
|---|---|---|---|---|---|---|---|---|
| single | 20 | 30,364 | 18,329 | 1,518 | 48,693 | 20/20 | 20/20 | 0 |
| single-2 | 20 | 30,364 | 18,329 | 1,518 | 48,693 | 20/20 | 20/20 | 0.015 |
| batch-5 | 4 | 26,360 | 18,354 | 1,318 | 44,714(−8%) | 19/20 | 16/20 | 0.083 |
| batch-10 | 2 | 25,832 | 18,348 | 1,292 | 44,180(−9%) | 19/20 | 16/20 | 0.106 |
コスト
- inputは13〜15%減りました(1記事あたり約200トークン)。リクエストごとにかかる固定分を、束ねた記事で分け合えたのだと思います。outputは変わりませんでした。
- outputは1記事あたり約920トークンと大きめです。47都道府県+該当なしの確率を、2つの質問の分だけ返すためだと思われます。
- 公式cookbookのコードでは、outputの単価が0として設定されていました。outputが無料なら、課金されるのはinputだけなので、束ねたときの節約は inputの13〜15% です。
- そもそも1記事あたりの費用はとても小さく、新着が1日50件あっても月$0.1程度の見込みです。
精度
- singleとsingle-2は答えが完全に一致し、confidenceの差も平均0.015でした。Jevは、同じ入力ならほぼ同じ答えを返します。
- 一方、束ねた場合は、本社の答えが20件中4件変わり、confidenceの差も平均0.08〜0.11と、実行ごとの揺れの5〜7倍になりました。束ねたこと自体が、答えを変えています。
- 原子力機構: 茨城県(0.25、正しい)→ 福島県(0.10〜0.18、誤り)
- ベネフィット・ワン: 該当なし(0.65)→ 東京都(0.46〜0.47)
- confidenceも全体的に下がりました(セイコーマート 0.98 → 0.64、イープラス 0.77 → 0.54)。しきい値0.5で見ると、タイムズカー(0.50 → 0.32)やらしんばん(0.51 → 0.31〜0.41)が採用されなくなりました。
結論: 1記事1リクエストのままにする
結果は、ほぼClaudeの予想どおりでした。束ねても課金対象のinputは13〜15%しか減らず、そのかわりに隣の記事の影響で答えやconfidenceが変わってしまいます。1回の失敗で複数の記事が分類できなくなるリスクもあります。1リクエストあたりの時間(0.23〜0.34秒)もほぼ同じでした。
コストを減らすなら、束ねるよりも次の2つのほうが効果的です。
- 新着の記事だけを分類する(保存済みの記事を毎回分類し直さない)
- 都道府県と関係ないソースを対象から外す(脆弱性情報が中心のJVNとJPCERT)
本番への導入
ここまでの方式で、収集処理(AWS Lambda、TypeScript)に組み込みました。
- TypeSafeのJavaScript SDKで、試行2と同じ2つの質問を1記事1リクエストで聞く
- Jevの呼び出しに失敗したときは、旧方式の文字列一致に切り替えて、収集自体は止めない
- 判定の根拠(どちらの質問で決まったか、答え、confidence、しきい値)をDBに保存する
- 新着の記事だけを分類し、JVNとJPCERTは分類しない
導入後に最初に入ってきた新着記事12件のうち、県が付いた4件(池上通信機×2→東京都、あいの風とやま鉄道→富山県、日本大学→東京都)はすべて正しく、どれも旧方式では県が付かない記事でした。
まとめ
- プロンプトに優先順位を書き込むだけでは、「知らない会社でもそれらしく答える」「知っている会社でも答えない」が直りませんでした
- 質問を1つの判断ずつに分けて、優先順位とconfidenceのしきい値をコード側で持たせると、誤りの多くを捨てられました
- しきい値は、正しい答えもいくらか捨てる取引です。どちらの間違いがより困るかを、用途から決めるのが大事でした
- しきい値が妥当かどうかは、捨てた答えの正誤を公式情報で確かめて、はじめて正確にわかりました(推測だけで判断していたときは、0.4〜0.5に正しい答えがこれほど多いことに気づけませんでした)
- 記事をまとめて1回で聞いても、ほとんど安くならず、答えが変わってしまいました
AIの答えをそのまま使うのではなく、「自信がないときは答えない」仕組みをコード側で作れるのが、Jevの面白いところだと感じました。
長くなりましたが最後まで読んでいただいてありがとうございました。