Loading post
Jul 21, 2026

技術記事を読み終えて、全部の段落にうなずいて、2週間後には中身をほとんど説明できない。僕は長いことこれをやっていました。「だいたい忘れる」ではなくて、読んだこと自体が他人事みたいに、きれいに消えるんです。
これは僕個人の欠陥というより、エビングハウスが1885年に記録したとおりに忘却曲線が仕事をしているだけです。新しい情報の記憶は指数関数的に減衰して、受動的な再読はその減衰にほとんど効きません。効くものは昔から分かっています。忘れかけたまさにそのタイミングで、間隔を広げながら、自分の頭から素材を引き出すこと。科学としては何十年も前に決着していて、問題はロジスティクスです——自分の脳との何百件もの小さな復習アポイントを手作業で管理し続ける規律を、誰が持っているのか?
僕は持っていません。ADHD的な特性があって、決済系の本業と夜の学習時間が取り合いになっていて、維持コストが意志力を超えて放置されたAnkiデッキがいくつもあります。なので、意志力が負けたときにいつもやることをやりました。システムを作ったわけです。僕の個人学習プラットフォーム(Next.js + TypeScriptのFirebase Functions + Firestore + LLM API)はいま、学習ループを一周まわしています。学んだことを記録すると、LLMがそれをフラッシュカードと用語集に変換し、SM-2スケジューラが各カードを次にいつ見せるか決め、そして定期的に、数週間分のノートから合成された問題でクイズ生成器がテストを仕掛けてきます。
この記事はそのエンジニアリング解説です。実際のスケジューリングアルゴリズム、Firestoreのデータモデル、カード生成のプロンプト設計、それから正直な「うまくいかなかったこと」の節。
┌────────────────────────────────────────────┐
│ 学習エントリ │
│ (本 / 動画 / 記事 / 仕事のインシデント │
│ からのノート、markdown) │
└───────┬──────────────┬─────────────────────┘
│ LLM │ LLM
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ フラッシュカード │ │ 用語集 │
│ front / back / │ │ term / definition│
│ hint / tags │ │ / examples │
└────────┬────────┘ └────────┬─────────┘
│ │
▼ ▼
┌─────────────────────────────────────┐
│ 復習キュー (SM-2) │
│ nextReviewDate <= now → 期限到来 │
│ [Again] [Hard] [Good] [Easy] │
└────────────────┬────────────────────┘
│ 評価が easeFactor, interval,
│ nextReviewDate を更新
▼
┌─────────────────────────────────────┐
│ クイズ (蓄積されたエントリから生成) │
│ 多肢選択 / 短答 / コード穴埋め │
└────────────────┬────────────────────┘
│ 弱点
└──────▶ 新しいカード/エントリへ還流
すべては学習エントリとして入ってきます。読んだもの、観たもの、デバッグしたものについての僕のノートです。エントリはLLMでフラッシュカードと用語に展開されます。カードと用語は、間隔反復で管理される復習キューに流れ込みます。クイズはエントリを横断して、単なる想起ではなく統合を試します。クイズで落とした問題が「どのエントリに新しいカードが必要か」を教えてくれたところで、ループが閉じます。
効果は3つ。どれも地味で、そして再現性がものすごく高いです。
設計への落とし込みは素直です。システムは項目ごとのスケジュール状態を保存しないといけない(間隔)。復習UIは答えより先に問題を見せないといけない(想起)。そして要約ではなくテストを生成すべき(テスト効果)。学習科学はエンジニアに珍しく優しくて、理論がそのままスキーマと2つのエンドポイントに翻訳できます。
各フラッシュカードは、トップレベルのflashcardsコレクションのFirestoreドキュメントで、userIdフィールドで所有者に紐づきます。面白いのは、SM-2のスケジューリング状態がカード自体に非正規化されて載っている点です。
interface FlashcardDoc {
userId: string;
deckId?: string;
front: string; // 問題 / プロンプト
back: string; // 答え
frontType: 'text' | 'code';
backType: 'text' | 'code';
codeLanguage?: string;
hint?: string;
explanation?: string;
tags: string[];
// 出自 — すべてのカードは自分がどこから来たかを知っている
sourceEntryId?: string; // このカードを生んだ学習エントリ
dictionaryTermId?: string; // 用語集の項目から生成された場合
articleId?: string; // 記事から生成された場合
// 間隔反復の状態 (SM-2)
easeFactor: number; // 2.5 から始まる
interval: number; // 次の復習までの日数
repetitions: number; // 連続成功回数
nextReviewDate: Timestamp;
reviewHistory: { date: Timestamp; difficulty: string; timeTaken: number }[];
createdAt: Timestamp;
updatedAt: Timestamp;
}
意図的な選択が3つあります。
sourceEntryId、articleId、dictionaryTermId)。 復習中にカードで混乱したら、ワンタップで元のノートに文脈ごと戻れます。生成時の重複排除にも効きます(後述)。reviewHistoryを配列で埋め込む。 復習のたびに{date, difficulty, timeTaken}をarrayUnionで追記します。個人ツールなら配列は小さいままですし(1枚のカードを200回復習しても大したことはない)、履歴がドキュメントに載っているとスケジューラのデバッグが楽です。どのカードを見ても、なぜその間隔になっているのかを再生できます。デッキは薄い整理レイヤー(name、flashcardIds、キャッシュされたcardsDueカウント)で、用語集は同じスケジューリングフィールドを持つ並行コレクションです。なので、用語もカードも1本の復習パイプラインを流れます。
「今、何を勉強すべきか?」はこうなります。
const now = Timestamp.now();
let query = db.collection('flashcards')
.where('userId', '==', uid)
.where('nextReviewDate', '<=', now)
.limit(20);
if (deckId) query = query.where('deckId', '==', deckId);
if (categoryId) query = query.where('categoryId', '==', categoryId);
エンドポイントは同じ形のクエリを用語集コレクションにも投げて両方返すので、「ミックス」セッションでは概念カードと語彙が交互に出てきます。新しいカードはinterval: 0、nextReviewDate: nowで生まれます。つまり「新規」に特別扱いはなくて、生まれたてのカードは単に最初から期限が来ているカードです。ここはかなり気に入っています。クエリ1本、コードパス1本、整合性を保つべきisNewフラグなし。
limit(20)は地味に効いています。単なるパフォーマンスガードではなく、セッションサイズについてのUX判断です。失敗談の節で詳しく書きます。
カードをめくって自己採点すると、クライアントは4つの評価——Again、Hard、Good、Easy——のどれかと所要時間を添えてsubmitReviewを呼びます。サーバーは評価をSM-2の0〜5の品質スケールに写像して、スケジュールを更新します。
const qualityMap = { again: 0, hard: 2, good: 4, easy: 5 };
function calculateSM2(
easeFactor: number, // このカードが歴史的にどれだけ「簡単」だったか
interval: number, // 現在の間隔(日数)
repetitions: number, // 連続成功回数
difficulty: 'again' | 'hard' | 'good' | 'easy',
) {
const quality = qualityMap[difficulty];
// 難しかった復習でeaseは下がり、簡単だった復習でわずかに上がる。
// 下限1.3は、カードが縮み続ける死のスパイラルに入るのを防ぐ。
let newEaseFactor =
easeFactor + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
newEaseFactor = Math.max(1.3, newEaseFactor);
if (quality < 3) {
// 失敗:はしごをリセット。カードは今日また戻ってくる。
return { newEaseFactor, newInterval: 0, newRepetitions: 0 };
}
const newRepetitions = repetitions + 1;
const newInterval =
newRepetitions === 1 ? 1 : // 初回成功:明日
newRepetitions === 2 ? 6 : // 2回目:6日後
Math.round(interval * newEaseFactor); // 以降は掛け算で伸ばす
return { newEaseFactor, newInterval, newRepetitions };
}
エンドポイントはその後、帳簿づけをします。
const { newEaseFactor, newInterval, newRepetitions } =
calculateSM2(card.easeFactor ?? 2.5, card.interval ?? 1,
card.repetitions ?? 0, body.difficulty);
const nextReviewDate = new Date();
nextReviewDate.setDate(nextReviewDate.getDate() + newInterval);
await docRef.update({
easeFactor: newEaseFactor,
interval: newInterval,
repetitions: newRepetitions,
nextReviewDate: Timestamp.fromDate(nextReviewDate),
lastReviewedAt: now,
reviewHistory: FieldValue.arrayUnion({ date: now, difficulty, timeTaken }),
});
アルゴリズムの手触りについていくつか。数字は見た目より面白いです。
quality < 3はAgain(0)とHard(2)の両方をカバーします。 つまり僕のマッピングでは、Hardは「ソフトな失敗」で、はしごをリセットします。AnkiはHardを小さめの乗数付きの合格として扱いますが、SM-2原理主義では3が合格ラインです。いまのところ厳格版のままにしていて、おかげでHardはちゃんと意味のあるボタンになっています——押すことは「このカードはまだ定着していない」と認めることなので。SM-2は最先端かというと、違います。FSRSあたりは個人化された忘却曲線をフィッティングして、復習効率で上回ります。ただSM-2は30行で、訓練データが要らなくて、挙動が完全に追えて、「システムなし」に対する利得の9割は取れます。個人ツールでは、デバッグできることが勝ちます。
良いフラッシュカードを書くのはスキルで、まさにその地味な熟練労働で僕のAnki習慣は死にました。なので、カード執筆は委譲しました。学習エントリを貼るかリンクすると、生成エンドポイントが厳格な契約に沿ってClaudeにカードを要求します。
システムプロンプトは、本来なら手作業で守るはずのカード執筆ルールをエンコードしています。
You are an expert at creating effective flashcards for learning
technical concepts, following spaced repetition best practices:
PRINCIPLES:
1. Each card tests ONE atomic concept
2. Questions are specific and unambiguous
3. Answers are concise but complete
4. For code-related cards, use actual code examples
5. Vary the question types — and avoid yes/no questions entirely:
a card you can pass by coin flip teaches nothing
CARD TYPES:
- Definition: "What is X?" → definition
- Reverse: "What term describes …?" → the term
- Code: "What does this code do?" → explanation
- Comparison: "Difference between X and Y?" → key differences
- Application: "When would you use X?" → use cases
Respond with valid JSON only:
{ "flashcards": [ { "front": "...", "back": "...",
"frontType": "text|code", "backType": "text|code",
"codeLanguage": "...", "hint": "...", "explanation": "...",
"tags": ["..."] } ] }
ユーザープロンプトは3つのパラメータを足します。コンテンツ本体、ガイダンス文に写像される難易度(「beginner: 基本的な定義と基礎」から「expert: アーキテクチャ判断と微妙なトレードオフ」まで)、そして——ここが重要な——重複を避けるべき既存用語のリストです。
const userPrompt = `Generate ${count} flashcards from the following content.
DIFFICULTY: ${difficulty}
${difficultyGuidance[difficulty]}
EXISTING TERMS (avoid duplicating these):
${existingTerms.join(', ')}
CONTENT:
${content}`;
このexistingTermsの行が、LLM生成の一番実務的な問題への主な防御です。その問題とは、生成は冪等ではないということ。同じエントリに対して生成を2回走らせると、重なるけど言い回しの違う2組のカードができます。両方を復習に入れると、勉強時間の半分をほぼ重複したカードに使うことになる。コレクションに既にある用語をプロンプトに渡すことで、「カードを生成せよ」が「まだ持っていないカードを生成せよ」に変わります。意味レベルの重複排除を、システムの中で意味を理解できる唯一のコンポーネントにやらせているわけです。(もっと厳格にやるならsourceEntryId+コンテンツをハッシュ化して2回目の生成自体を拒否するんでしょうが、僕はソフト版で運用して、ときどき手で剪定しています。)
レスポンスは、僕のバックエンドのLLM絡み全部と同じ防御的パースを通ります——うっかり付いたマークダウンフェンスを剥がして、JSON.parseして、flashcards配列の存在を検証して、各カードをフォールバック付きデフォルト(frontType: 'text'、tags: [])で正規化する。カードはクライアントに提案として届きます。僕がざっと目を通して、駄作を消して、そこで初めて新品のSM-2状態(easeFactor: 2.5, interval: 0, nextReviewDate: now)と、元エントリを指すsourceEntryId付きで保存されます。生成は安いですが、復習キューの汚染は高くつく。人間の出番は、その2つのステップの間です。
フラッシュカードは原子をテストします。ただ、原子化された知識の失敗モードは、すべてのカードに合格しながらそれらを組み合わせられないことです——ease factorが何かは知っていて、インデックスが何かも知っているのに、その2つを組み合わせたクエリ設計になると固まる。なのでループには第2の評価レイヤーがあります。複数の学習エントリから一度に生成されるクイズです。
クイズのエンドポイントはエントリIDの集合を受け取り、タイトル・本文・キーテイクアウェイを1つのコンテキストに連結して、混合形式のクイズを要求します。
Create questions that test understanding, not just memorization.
QUESTION TYPES: multiple_choice, true_false, short_answer, code_completion
GUIDELINES:
1. Test understanding, not just facts
2. Include scenario-based questions
3. Make incorrect options plausible but clearly wrong
4. Explain why each answer is correct/incorrect
各問題は選択肢ごとの解説付きで返ってきます——間違いの選択肢も教材になるので、テスト効果の二重取りです。地味にハマった実装メモが2つ。
lastIndexOfで見つけた最後のフェンスの間のJSONを取り出します。code_completion問題はフィールド内に``` を正当に含みうるので、非貪欲マッチだとそこでドキュメントが切れてしまいます。プロンプトの中で一番効いている行は「plausible but clearly wrong(もっともらしいが明確に間違い)」でした。これがないと、「データベースが削除される」みたいな、何もテストしない選択肢が返ってきます。あると、現実の誤解に近い誤答を作ってくるので、その中から選ぶこと自体が本物の想起作業になります。
うまくいった:生成が習慣キラーのステップを消した。 僕のAnki時代はカード執筆の負債で死にました——書かれていないカードが罪悪感として積み上がっていく。いまは「これはカードにする価値がある」の限界コストがボタン1つです。コレクションは、空いている夜の数ではなく、実際に学んだ量に比例して育ちます。
うまくいった:出自リンク。 混乱させられるカードからソースエントリにタップで飛べば混乱は数秒で解けますし、クイズの失敗は、補強が必要なまさにそのノートに誘導してくれます。グラフ構造(エントリ ↔ カード ↔ 用語 ↔ 記事)がスキーマの地味なMVPです。
いかなかった:カード品質の分散。 LLMのカードは「まあまあ良い」のあたりに集まりますが、悪いやつの尻尾がしぶとく残ります。答えが表に漏れているカード。1枚に3つの事実を詰め込んだ裏面(原子性違反——3分の2だけ正解したカードは採点できないので、これが一番困る)。誰も覚える必要のない枝葉の雑学カード。保存前レビューでほとんど捕まえられますが、裏を返せば、生成は「執筆」ではなく「下書き」です。僕の採用率は8割くらい。使い続けるには十分で、目視を省くには足りません。
いかなかった:復習負債の山。 間隔反復には残酷な性質があります——数日サボってもシステムは一時停止せず、積み上がっていく。仕事が忙しくてしばらく空けたあと、戻ってみたら期限到来のカードがどっさり、というのを実際にやりました。<!-- TODO(Yudai): 実際の期間と件数を覚えていればここに入れる(下書きの「2週間の修羅場で数百件」は要事実確認) --> 恐怖を感じるキューは避けるキューになって、そうやってSRS習慣は死にます。dueクエリのlimit(20)が僕の構造的な答えになりました。1セッションは20項目、以上。バックログは、1回の地獄セッションなしに数日かけて捌けていきます。実際に学んだのは、期限超過は緊急性ではないということです——期限切れのカードはすでに忘れているか、まだ覚えているかのどちらかで、いずれにせよ、期限超過が何百件あろうと、0枚やるより今日20枚やるほうが勝ちます。
半分うまくいった:4ボタン採点。 Again/Hard/Good/EasyはAnki由来の標準ですが、正直に自己観察すると、僕のHardとGoodの境界は気分で揺れます。それがease factorにノイズを入れる。作り直すなら、Again/Good(合否)の2択に圧縮するのを検討すると思います。現代的なスケジューラにも、そちらを好むものがあります。シグナルは少なく、きれいに。
reviewHistoryに実データが溜まってきたので、SM-2の固定式の代わりにカード単位の保持曲線をフィッティングできます——履歴が全カードに載っているので、データモデルはすでに対応済みです。nextReviewDateを±1日ずらすのは、小さな変更のわりにキューの士気に大きく効きます。nextReviewDateフィールドに、能動的想起は「表を見せてから裏」のUIに、テスト効果はクイズ生成器になります。難しかったのは理論ではなくロジスティクスで、ロジスティクスこそソフトウェアの仕事です。