0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLM の出力は信用するな — Claude API で PDF→Anki 自動生成 CLI を作って学んだ 6 つの防御策

0
Posted at

はじめに

前回の記事で、Everything Claude Code (ECC) 環境で初めて本格的な開発を始めた 10 日間のことを書きました。git も知らなかった初心者が、PDCAサイクルを何度も回しながら開発を覚えていく話でした。

今回は、その 10 日間の後半で作った pdf2anki というツールの話をします。

アプリではなくコンテンツが問題だった

G検定の勉強のために、忘却曲線に基づく出題アプリを作りました。Python/Streamlit で Web 版、Swift/SwiftUI で iOS 版。ECC 環境の TDD で実装し、ADR で意思決定を記録しながら、2 週間で動くものができました。

できてから気づきました。自分が作っていたのは Anki の再発明だったのです。

20 年以上の歴史を持つ Anki と同じものを 2 週間で作り直す意味はありません。しかし開発の過程で、本当のボトルネックが見えました。アプリではなくコンテンツです。

iOS 版では PDF からテキスト化したデータを正規表現と LLM で 410 問の問答ペアに抽出するアルゴリズムを実装しました。しかし、表記揺れや章構造の不一致で精度が安定しませんでした。「第 1 章」と「第1章」(スペースの有無)でマッチングが壊れるような世界です。

「どんなテキストからでも高品質な Anki カードを自動生成する」ツールがあれば、アプリ本体は Anki に任せられます。

そうして作り始めたのが pdf2anki です。

$ pdf2anki convert textbook.pdf -o cards.tsv

PDF、テキスト、Markdown を入力すると、Claude API でフラッシュカード(TSV/JSON)を出力する CLI ツールです。

この記事では、pdf2anki を作る過程で踏んだ 6 つの落とし穴 と、その防御策を書きます。Claude API をこれから使う人が同じ穴を踏まずに済むように。

前回の記事で書いた「しつこく質問する」姿勢は、今回も変わりません。Claude Code に「なぜこうするのか?」「他の方法はないのか?」と聞き続けました。その質問と回答の中から見えてきた設計判断を、できるだけ正直に書きます。


1. LLM の出力 JSON は壊れる前提で設計する

起きたこと

Claude API にカード生成を依頼すると、返ってくる JSON がそのままパースできないことがあります。

プロンプトに「JSON だけを返せ」と書いても、マークダウンの ```json ``` で囲んでくることがあります。配列の途中に説明テキストが混じることもあります。カンマが余分だったり、閉じ括弧が足りなかったりします。

LLM の出力は確率的です。「こう返せ」と指示しても、100% の保証はありません。

失敗した対応

最初は json.loads() で一括パースし、失敗したら全体をリトライしていました。10 枚のカードのうち 1 枚だけフォーマットが壊れていても、10 枚全部を再生成します。API コストが無駄に膨らみました。

防御策: カード単位でバリデーションし、部分的な成功を許容する

for i, item in enumerate(data):
    try:
        card = AnkiCard.model_validate(item)
        cards.append(card)
    except (ValidationError, TypeError) as e:
        logger.warning("Skipping invalid card at index %d: %s", i, e)

10 枚中 1 枚が壊れていても、残り 9 枚は救います。

「全か無か」ではなく「部分的な成功を許容する」設計。 これは LLM を組み込むツール全般に通用する原則だと思います。

JSON のラッピング(```json ```)は正規表現で事前に剥がしてからパースに回します。泥臭い前処理ですが、こういう小さな防御の積み重ねが LLM 連携の安定性を支えています。

持ち帰り: LLM の出力パースは「壊れる前提」で書く。全体リトライではなく、個別スキップ+ログで回復力を持たせる。


2. LLM が生成したカードの品質はバラバラ

起きたこと

JSON が正しくパースできても、中身の品質は保証されません。

実際に出てきた問題カードの例です:

  • 表面が「説明してください」だけ。何を説明するのかわからない
  • 裏面が 500 文字超。カードではなくレポート
  • 1 枚に 3 つの概念が詰め込まれている。Anki のカードは 1 枚 1 概念が原則
  • リスト問題なのに QA 形式で出力されている。穴埋めにすべき

「LLM に任せたから大丈夫」は危険な前提でした。

失敗した対応

全カードを LLM に再評価させる方式を最初に試しました。生成にも評価にも API を叩くので、コストが単純に倍になります。100 枚のカードを作るたびに 200 回の API コール。現実的ではありません。

防御策: 3 層パイプラインで品質を担保する

第 1 層(コード判定・LLM 不要): ヒューリスティックスコアリング

6 つの軸で 0〜1 のスコアを付け、重み付き合計が 0.90 以上なら合格。LLM は呼びません。

重み チェック内容
表面の質 25% 10〜200 文字、疑問符の有無
裏面の質 25% 5〜200 文字、簡潔さ
カード種別の適合 15% リスト内容なのに QA になっていないか
Bloom レベル 10% 認知レベルが内容に合っているか
タグの質 10% 階層タグが付いているか
原子性 15% 1 枚 1 概念になっているか

原子性の判定が面白かったです。裏面を句点で分割し、文数が多ければ「複数概念が混在している」と判定します。

# 裏面が 3 文以上 → 複数概念の疑い
sentences = [s for s in _SENTENCE_SPLIT_RE.split(back) if s.strip()]
if len(sentences) >= 3:
    score = max(0.3, 1.0 - len(sentences) * 0.15)

# 「また、」「さらに」→ 追加情報の接続詞 → さらに減点
if _MULTI_CONCEPT_RE.search(back):
    score = max(0.2, score - 0.15)

「また、」「さらに」が出てきたら、1 枚のカードに情報を詰め込みすぎている可能性が高いです。人間が見ても「これは分割したほうがいいな」と思うカードを、コードで検出できます。

第 2 層(LLM 使用): 0.90 未満のカードだけを Claude に批評させる

批評結果は 3 択: improve(書き直し)、split(分割)、remove(削除)。最大 2 ラウンドです。

ポイントは 全カードに LLM を使わない こと。スコア 0.90 以上のカードは第 1 層で素通りさせます。実測では生成カードの 60〜70% が第 1 層を通過しました。LLM 批評が必要なのは 30〜40% です。

第 3 層(コード判定・LLM 不要): 重複検出

文字バイグラムの Jaccard 類似度で重複を検出します。表面の類似度が 0.7 を超えたら重複フラグを立てます。

前回の記事で書いた ECC の TDD と同じ発想です。テストを先に書くように、品質の基準を先に定義する。基準がなければ「良くなった気がする」で終わります。

持ち帰り: 「LLM で判定できること」と「コードで判定できること」を分ける。コードで済む判定に LLM を使うのは、精度ではなくコストの問題。


3. API コストは「見えない」と怖い

起きたこと

Claude API は従量制です。Sonnet で入力 $3/M tokens、出力 $15/M tokens。

100 ページの PDF を処理したらいくらかかるか。実行してみるまでわかりません。

初期テスト中、うっかり長い PDF を処理して $2 近く使ったことがあります。学習用の個人ツールでこの出費は痛いです。

防御策は 3 つ

① 実行前のコスト見積もり

preview コマンドで、API を叩かずに見積もりを出します。

$ pdf2anki preview textbook.pdf
Estimated cost: $0.42 (Sonnet) / $0.11 (Haiku)
Sections: 12 | Chunks: 8 | Tokens: ~45,000

ユーザーが「これなら OK」と判断してから convert を実行する流れにしました。

② budget_limit で暴走防止

CostTracker に予算上限(デフォルト $1.00)を設定し、API コールのたびに累積コストをチェックします。超えたらそこで止まります。

@dataclass(frozen=True, slots=True)
class CostTracker:
    budget_limit: float = 1.00
    records: tuple[CostRecord, ...] = ()

frozen=True で不変にしています。前回の記事で触れた「不変性の原則」がここで効きました。API コールのたびに新しい CostTracker インスタンスを返します。途中で値が書き換わる心配がなく、「いまいくら使ったか」を常に正確に追跡できます。

③ テキスト量に応じたモデル自動選択

短いテキスト(10,000 文字未満、30 枚未満)ならコストが約 1/4 の Haiku を使い、多ければ Sonnet にルーティングします。

_SONNET_TEXT_THRESHOLD = 10_000   # 文字数
_SONNET_CARD_THRESHOLD = 30      # カード枚数

単純な閾値分岐ですが、短いテキストの処理コストが大幅に下がります。

持ち帰り: 従量制 API を使うツールには「実行前の見積もり」「実行中の上限」「モデルの使い分け」の 3 点セットを入れる。ユーザーの財布を守るのは開発者の責任。


4. 長文 PDF の分割を間違えると、カードの文脈が壊れる

起きたこと

Claude の入力トークン上限は約 200K ですが、一度に大量のテキストを投げるとカードの品質が落ちます。分割が必要です。

失敗した対応

最初はテキストを文字数で均等に切っていました。当然、章の途中で切れます。

「第 3 章の後半」と「第 4 章の前半」が混ざったチャンクから生成されたカードは、文脈がおかしくなりました。ある章の用語定義が別の章の概念と混同されます。

防御策: セクション分割 + breadcrumb で文脈を保持する

Markdown の見出し(######)で論理的に分割する方式に変えました。

各セクションに breadcrumb(パンくずリスト) を付与します。Web サイトの「ホーム > 製品 > iPhone」のような、いま全体のどの階層にいるかを示すナビゲーションです。

breadcrumb: "本論 > 第1章 論書名の意味 > 1.1 語源"

Claude はこの breadcrumb を見て「いま第 1 章の語源の話をしている」と理解できます。チャンクの先頭にこのコンテキストがあるだけで、生成されるカードの文脈が格段に正確になりました。

見出し階層は heading_stack という辞書で追跡します。H2 が出現したら、それ以下の H3 情報をクリアします。

heading_stack: dict[int, str] = {}
# H2 出現 → H3 をクリア
keys_to_remove = [k for k in heading_stack if k >= level]

1 セクションが 30,000 文字を超えたら段落単位でサブ分割し、見出しに (cont.) を付けて連続セクションだと示します。

日本語テキスト特有の罠

2 つあります。

1 つ目。Markdown 見出しがないテキストへの対応です。日本語の書籍なら「第一章」「(1)」「一、」といったパターンで見出しを検出するフォールバックを入れました。

2 つ目。トークン推定の誤差です。CHARS_PER_TOKEN = 4 という定数は英語基準です。日本語は 1 トークンあたり 2〜3 文字なので、コスト推定が実際の半分程度になります。これは既知の未修正問題です。日本語を扱うなら係数を 2.5 程度に修正する必要があります。

持ち帰り: 長文を LLM に渡すときは「意味の境界」で切る。機械的な文字数分割は文脈を壊す。分割後のチャンクに「いまどこの話か」のメタ情報を付与すると、出力品質が大きく改善する。


5. 画像対応はコストとの闘い

起きたこと

PDF にはテキストだけでなく図やチャートもあります。

Claude API にはテキストだけでなく 画像も入力できる機能(Vision) があります。写真や図を送ると、Claude が「この画像に何が写っているか」を理解して回答してくれます。これを使えば、教科書の図表からもカードを生成できます。

「全ページを画像として Vision に投げれば完璧では?」と思いました。

コストを計算して目が覚めました。

画像のトークンコストは (幅 × 高さ) ÷ 750 です。100 ページを全ページ画像化すると画像だけで約 15 万トークン。テキストと合わせて 30 万トークン、約 $1。テキストだけなら $0.15 程度です。7 倍のコスト差です。

防御策: 3 つの閾値で画像処理を制御する

① 画像カバレッジ閾値: 20%

pymupdf でページ内の画像面積を計算し、ページ面積の 20% 以上を画像が占めるページだけ Vision に回します。テキスト主体のページはテキスト抽出で十分です。

② DPI: 150 に固定

Vision の制約は長辺 1568px、最大 1.15 メガピクセルです。

  • 300 DPI: きれいだがトークンが倍。1 画像 3,000 トークン超
  • 72 DPI: 安いが図中の文字が潰れる
  • 150 DPI: 文字が読める最低限の解像度。1 画像 1,500 トークン程度

③ 1 ページ最大 5 画像

5 枚 × 1,500 トークン = 7,500 トークン。budget_limit と組み合わせれば暴走しません。

この 3 つの妥協で、Vision ありの処理コストをテキストのみの 1.5〜2 倍に抑えました。7 倍にはなりません。

持ち帰り: Vision は強力だが高い。「全部画像で投げれば楽」という誘惑に負けず、テキスト抽出で済む部分はテキストで処理する。画像は本当に必要なページだけに絞る。


6. プロンプト変更の効果が測れないと、改善が博打になる

起きたこと

プロンプトを書き換えたとき、カードの品質が上がったのか下がったのかわかりません。「良くなった気がする」では心許ないです。LLM の出力は非決定的で、同じプロンプトでも毎回違う結果が出ます。

防御策: 期待カードとのキーワードマッチで品質を自動テストする

LLM 開発では、出力品質の自動評価を Eval(エバル) と呼びます。

pdf2anki では「このテキストからはこういうカードが生成されるべき」という期待値を YAML で定義し、実際の出力と照合する方式を採りました。

- id: "dl-001"
  text: |
    過学習(オーバーフィッティング)とは、訓練データに対して
    過度に適合し、未知のデータに対する汎化性能が低下する現象である。
    対策としてドロップアウト、早期終了、データ拡張などがある。
  expected_cards:
    - front_keywords: ["過学習", "何"]
      back_keywords: ["訓練データ", "汎化性能", "低下"]
      card_type: qa
    - front_keywords: ["過学習", "対策"]
      back_keywords: ["ドロップアウト", "早期終了", "データ拡張"]
      card_type: qa

生成されたカードにキーワードが含まれているかで一致度を計算し、Recall(網羅率)・Precision(正確率)・F1(両者のバランス指標)を出力します。

$ pdf2anki eval --dataset evals/dataset.yaml
Recall: 0.78 | Precision: 0.85 | F1: 0.81

キーワードマッチは完璧ではありません。「ニューラルネットワーク」と「神経回路網」を同一視できません。

しかし重要なのは「プロンプト変更で回帰(=前より悪くなること)が起きていないか」の検知です。完璧な意味理解ではありません。プロンプトを変えるたびに pdf2anki eval を回して F1 が下がっていないか確認する。これだけで改善が博打ではなくなります。

ECC の TDD で学んだことと同じです。テストがあれば、リファクタリングしても安心できます。Eval があれば、プロンプトを書き換えても安心できます。

持ち帰り: LLM の出力品質は「テストケース」で管理する。完璧な自動評価でなくていい。「回帰を検知できる」だけで、プロンプト改善の生産性が大きく変わる。


おわりに

pdf2anki を作る過程は、妥協の連続でした。

やりたかったこと 現実 妥協点
LLM に完璧な JSON を返させたい 出力は壊れる 個別スキップで部分的成功を許容
全カードを LLM で品質評価したい コストが倍になる ヒューリスティックで 60〜70% をフィルタ
コストを気にせず使いたい 従量制は怖い 見積もり + 上限 + モデル選択
テキストを均等に分割したい 文脈が壊れる セクション分割 + breadcrumb
全ページ画像で投げたい 7 倍のコスト カバレッジ閾値 + DPI + 枚数制限
プロンプト改善を感覚で判断 回帰に気づけない キーワードマッチ Eval

どれも「理想の設計」ではなく「現実との折り合い」の産物です。

開発全体を通して効いた判断基準が一つあります。「これをやらない場合、同じ時間で何ができるか」 を常に問うことです。

途中で「OpenAI にも対応すべきでは」と考えました。$10 クレジットが残っていたからです。実装を見積もると 1,000 行、10 時間。$10 回収のために時給 $1 で働く計算になります。同じ 10 時間で画像カード生成、TUI、Eval フレームワークが作れました。

前回の記事で書いた ECC の PDCA サイクルが、ここでも回っています。

  • Plan: Claude Code に「なぜこうするのか?」をしつこく質問し、設計の背景を理解する
  • Do: TDD でテストを先に書き、実装する
  • Check: Eval でプロンプトの品質を測定し、ヒューリスティックでカードの品質を検証する
  • Act: ADR で「なぜ OpenAI 対応を捨てたか」「なぜ画像閾値を 20% にしたか」を記録する

Claude API は強力なツールです。しかし「API を叩けば解決する」と思った瞬間に、コストが膨らみ、品質が不安定になり、デバッグが困難になります。API の力を引き出すのは、その周辺に積み上げる防御的な設計です。

この記事が、同じ道を歩く人の参考になれば幸いです。

pdf2anki は GitHub で公開 しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?