経費精細DBのAI仕分けで、「その他」が37%に達しました。精度を上げるために選択肢を7個から20個へ細分化した夜間バッチが、翌朝には分類不能の山を残していた事故です。原因は選択肢の増加そのものではなく、境界が曖昧になった選択肢と、「迷ったらその他」という逃げ道を公式化したプロンプトの組み合わせでした。この記事では、その他率を1行で測る週次検算と、7個再設計+description+3行プロンプトの分類完全形(Python・コピペで動く)まで書きます。
何が起きたか: 選択肢を増やした夜に「その他」が4.6倍になった
| 日時 | 起きたこと |
|---|---|
| 10/31 22:00 | 経費精細の仕分け選択肢を7個→20個へ拡張してリリース(「宿泊費(国内)」「飲食費(昼食)」等へ細分化) |
| 11/3 09:40 | 週次レポートで「その他」が37%を占めていることを発見(拡張前は8%) |
| 11/4 15:00 | 「LLMの精度不足」と誤認しプロンプトの長文化を2回試すが、その他率は34%→31%で横ばい |
| 11/5 21:10 | その他行を読んだら「会議の飲食代」「タクシー+電車の混合」などどの選択肢にも当てはまる行が大半と判明 |
| 11/6 09:20 | 7個再設計+3行プロンプト適用の翌朝、その他率4%で着地 |
失敗の本体はLLMの能力ではなく設計でした。20個の選択肢のうち10個は「交通費(電車)/交通費(タクシー)」「飲食費(昼食)/飲食費(接待)/会議費」のように互いに境界が曖昧なペアで、境界に落ちた行はLLMが判断を放棄して「その他」へ流れました。
原因の切り分け: その他が増える3つの穴
- 選択肢の細分化で境界が曖昧になった: 人間の運用なら「迷ったら近い方へ」で処理できますが、LLMは選択肢同士の違いが言葉で定義されていないとき、判断材料がないため最も安全な「その他」を選ぶ
- descriptionが不在: Notionのselect型プロパティは選択肢名しか持てず、プロンプトには20個の名前の文字列だけが並んだ。「宿泊費(国内)」と「宿泊費(海外)」の違いを説明する1行がどこにもなかった
- プロンプトの逃げ道: 冒頭に足した「判断に迷う場合はその他」という1行が、境界曖昧な行をその他へ送る公式なルートとして機能した。「その他」は分類の失敗を受け止める窓口であって、判断の代替先ではない
1行検算: その他率を週次で測る
まず現状を数字で見ます。Notion APIでDBを全件走査し、分類プロパティの比率を数えるだけの検算です。この1行があれば、11/3の朝ではなく10/31の夜(初回バッチ直後)に検知できていました。
from collections import Counter
import os, requests
TOKEN = os.environ["NOTION_TOKEN"]
DB_ID = os.environ["EXPENSE_DB_ID"]
API = "https://api.notion.com/v1"
H = {"Authorization": "Bearer " + TOKEN, "Notion-Version": "2022-06-28"}
labels, cursor = [], None
while True: # ページネーションを完走する(100件で止まる事故の予防線)
q = {"page_size": 100, "start_cursor": cursor} if cursor else {"page_size": 100}
r = requests.post(f"{API}/databases/{DB_ID}/query", headers=H, json=q, timeout=30)
r.raise_for_status()
data = r.json()
for page in data["results"]:
sel = page["properties"]["分類"].get("select")
labels.append(sel["name"] if sel else "(未設定)")
if not data.get("has_more"): break
cursor = data["next_cursor"]
c = Counter(labels)
other = c["その他"] / sum(c.values()) * 100
print(f"その他率 {other:.1f}% ({c['その他']}/{sum(c.values())}行)")
print("OK" if other < 10 else "NG: 週次棚卸しを実行して選択肢境界を見直す")
修正: 7個再設計+description+3行プロンプトの分類完全形
修正は3点セットです。①選択肢をMECEな7個へ戻す ②各選択肢に「何を含み、何を含まないか」の1行を与える ③プロンプトから逃げ道を消し、根拠の1語を必須にする。
"""Notion AI仕分けの完全形: 7個選択肢+description+3行プロンプト"""
import os, requests
LLM_URL = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1") + "/chat/completions"
LLM_KEY = os.environ["LLM_API_KEY"]
OPTIONS = {
"交通費": "移動に使った費用全般。電車・バス・タクシー・駐車を含み、複数手段の混合も1行にまとめて交通費とする。",
"宿泊費": "泊まりを伴う宿の費用。交通費は含めない。",
"飲食費": "食事の費用。会議や接待の食事もここに入れ、会議費とは分けない。",
"会議・研修費": "会場費・受講費・セミナー費。飲食は含めない。",
"備品・消耗品": "文具・機材・事務用品。",
"書籍・情報料": "書籍購入・SaaS・ソフトの利用料。",
"通信費": "携帯・回線・郵送の費用。",
}
def build_prompt(item: str) -> str:
catalog = "\n".join(f"- {k}: {v}" for k, v in OPTIONS.items())
return (
f"経費の内容から最も近い選択肢を1個だけ選ぶ。\n"
f"判断に迷ったら、選択肢ごとの説明と突き合わせて根拠1語で決める。\n"
f"「その他」は、どの説明にも当てはまらないときだけ選ぶ。\n\n"
f"選択肢:\n{catalog}\n\n内容: {item}"
)
def classify(item: str) -> tuple[str, str]:
body = {"model": os.environ.get("LLM_MODEL", "gpt-4o-mini"), "temperature": 0,
"messages": [{"role": "user", "content": build_prompt(item)}]}
r = requests.post(LLM_URL, headers={"Authorization": "Bearer " + LLM_KEY},
json=body, timeout=30)
r.raise_for_status()
answer = r.json()["choices"][0]["message"]["content"].strip()
label = next((k for k in OPTIONS if k in answer), "その他")
reason = answer.replace(label, "").strip("。:: ")[:12] or "境界"
return label, reason # reasonは仕分け根拠の1語。Notionの「根拠」プロパティへ残す
ポイントは3つです。①根拠1語の必須化: 「根拠を書け」と指示するだけで、LLMは選択肢の説明と突き合わせて判断し、「その他」への逃避が構造的に起きなくなります。②温度0: 仕分けは分類問題なので出力の揺らぎは害しかない。③根拠の記録: 根拠プロパティに1語残すことで、週次棚卸しで「その他」だけでなく「境界の疑わしい分類」も人間の目で検証できます。
実行するとこうなります:
$ python3 sort_audit.py
その他率 4.1% (9/219行)
OK
比較表: 選択肢設計の3方式
| 方式 | その他率(実測) | 境界曖昧行の扱い | 検算との相性 | 判定 |
|---|---|---|---|---|
| 20個・descriptionなし(事故版) | 37% | その他へ逃避 | 比率は出るが原因が読めない | ✗ やめる |
| 7個・descriptionつき+3行プロンプト | 4% | 根拠1語で近い方へ決定 | その他率+根拠で週次検証可能 | ✓ 採用 |
| フリーテキスト分類 | —(集計不能) | 人間の後仕分け次第 | 集計の前提が壊れる | △ 小規模のみ |
失敗した設計
- 失敗1: 「選択肢を増やせば精度が上がる」という前提。人間の運用では細分化は精度を上げますが、LLMは選択肢間の違いが言葉で定義されていないとき判断を放棄します。細分化は「説明できる範囲」でしか効かない
- 失敗2: 「迷ったらその他」の1行。親切のつもりで足した指示が、境界曖昧な行を全てその他へ吸い込む公式ルートになった。「その他」は分類の失敗を受け止める窓口であって、判断の代替先ではない
- 失敗3: その他率を誰も測っていない運用。バッチログに行数は出ていましたが、比率を出す行がなかった。検算がなければ、選択肢設計の良し悪しは1週間後の不満としてしか現れません
まとめ: 次にやること
- 選択肢をMECEな7個前後へ戻し、それぞれに「何を含み、何を含まないか」の1行説明を与える
- プロンプトは3行に固定する: 近い1個を選ぶ/迷いは根拠1語で解決/その他は当てはまらないときだけ
- その他率の検算(10%しきい値)をバッチ末尾に入れる。外だしの週次運用は忘れる
- そのとき注意: 既に「その他」へ分類された行は、再設計後に棚卸しバッチで全件再分類してください。放置すると新しい選択肢体系と実データがずれ、検算の分母が汚れます
読者特典(無料)
本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。
関連記事(同テーマ)
- MCPサーバーにツールを11個渡したらAIが4割使い間違えた — 道具が選ばれるdescription設計の5つの型
- Notionのプロパティ名を1つ変えたら夜間バッチが400で全滅した — スキーマ変更に強い3つの保険と完全実装
📗 Notion完全活用ガイド — 情報を「仕組み」に変える実践書(Kindle・読み放題対象)