個人ノートを LLM (Ollama) で5カテゴリに自動分類する — distill パイプラインの設計と落とし穴
🔗 シリーズ目次: 本記事は AIアシスタント運用ノート — Copilot / Claude Code を相棒として育てるための実践記録 シリーズの 実装編 (3) です。
この記事でわかること
- 個人 RAG の運用で 「note は書くけど、教訓に昇格させるのを忘れる」 問題の構造
- LLM (Ollama / Llama 3 日本語特化) で 5カテゴリ自動分類 する仕組み
- 雑に実装すると 「申し訳ありません」が教訓に分類される 等の事故が起きる、その対処
- JSON mode の使い方 と、それでも失敗する時のフォールバック設計
- 再処理防止 state ファイル + mtime での差分検出
- 運用 1か月で見えた効果と注意点
対象読者
- 個人 RAG を運用していて 「書きっぱなしの note」 が増えてきた方
- ローカル LLM (Ollama) を 実用的なバッチ処理 に組み込みたい方
- LLM 分類タスクで 「予期しない誤分類」を抑える ノウハウを知りたい方
動作環境
| 項目 | バージョン |
|---|---|
| Python | 3.13 (venv) |
| Ollama | サーバー起動済み (localhost:11434) |
| 分類用 LLM | microai/suzume-llama3 (日本語特化 Llama 3) |
| Embedding | nomic-embed-text (ChromaDB 投入用) |
| ChromaDB | 永続化モード |
1. はじめに — 「書きっぱなし note」問題
個人 RAG を運用していると、まず ChromaDB に投入する「素材」を増やすところから始まります。私は rag note "..." というコマンドで、思いついた瞬間に短い note を残せる仕組みを作っていました。
数週間後、data/diary/ には 数百個の note エントリ が積み上がりました。
# 2026-04-30
## 04:09
Qiita 429対応の教訓: Rate-Resetヘッダーの値が唯一の正解。
GeminiやLLMの推測(24時間待て等)に従ってはいけない。
## 12:30
2026-04-30 my-rag-brain distill機能を実装した。
背景・経緯: もともとnote(diary/)にすべてのメモを書いていたが、
lesson/idea/knowledge/profileとして昇格させる手動操作が...
## 15:51
distill誤分類対策: AIの謝罪文・操作手順がlessonに誤分類される問題は2段階で対処する...
ベクトル検索で rag search "Qiita Rate-Reset" を引けば note の中身は出てきます。でも何かが足りない。
足りなかったのは、「これは教訓 (lesson) として再発防止に使うべき」「これはアイデア (idea) として将来のために積む」 という カテゴリ分け でした。note の状態だと、検索結果はフラットに混ざる。「あのとき書いた教訓」「あのとき書いたアイデア」を区別したい。
最初は 手動分類 をやろうとしました。data/lessons/ data/ideas/ を作って、note を読みながら格上げする。
3日で挫折しました。 手動操作は必ず忘れる。書いた直後ですら忘れる。
そこで思いました。
これ、LLM に分類させればいいんじゃないか?
ローカルに Ollama が動いている。Llama 3 系列なら日本語の note も判別できそう。1か月分の note を一気に読ませて、5カテゴリに自動で振り分ける バッチを作ってみることにしました。
本記事はその実装と、雑に作ると当然のように起きた事故 の話です。
2. 5カテゴリの定義
まず、どんなカテゴリに分けたいかを決めました。
| カテゴリ | 何を入れるか | 例 |
|---|---|---|
lesson | 次回からこうすべき、という具体的な再発防止ルール | 「Rate-Reset ヘッダーが唯一の正解」 |
idea | 将来実装・実現したいアイデア・構想 | 「個人 RAG を MCP 化して Copilot と繋ぐ」 |
knowledge | 技術仕様・API 仕様・客観的事実 | 「nomic-embed-text は 768次元」 |
profile | 価値観・信念・動機・こだわり | 「機械最適より人間可読性を優先する」 |
conclusion | 議論・検討を通じて導き出した判断 | 「Qiita = 要約版、Astro = 完全版」 |
none | 上記に該当しない(作業記録・謝罪・手順書) | 「申し訳ありません、修正します」 |
none を独立させたのが、後で書く 「謝罪文 lesson 事件」 の対策です。
3. ナイーブな実装と、すぐ起きた事故
最初は雑に LLM に投げました。
prompt = f"""
以下のテキストを lesson / idea / knowledge / profile / conclusion のどれかに分類してください。
テキスト:
{text}
カテゴリ:
"""
response = ollama.chat(model="llama3.2:3b", messages=[...])
これで動きました。30件くらいテストして、それっぽい分類 が返ってきました。喜んで全件流しました。
結果を確認したら、lessons/ ディレクトリに大量の謝罪文 が入っていました。
## 22:43 [auto-distilled]
申し訳ありません。同じミスを繰り返さないよう、次から気を付けます。
**原文(抜粋)**:
申し訳ありません。先ほどの実行は誤りでした。
---
LLM はこう推論したのでしょう:
「同じミスを繰り返さないよう」「次から気を付けます」 = 教訓っぽい言葉 = lesson だ!
確かにそう読めますが、これは AI が事故った直後の 謝罪文 であって、教訓そのものではない。教訓は別途、私が手で書いた「Rate-Reset ヘッダーが唯一の正解」のような文章にあります。
他にもこんな誤分類が頻発しました:
- 「以下をそのまま Gemini Ultra に貼り付けてください」 → idea に分類(実際は操作指示)
- 「その通りです」 → conclusion に分類(実際は同意の相づち)
- 3行しかない短文 → 強引に lesson に分類(実際は判断材料なし)
何でもないテキストを、無理やりカテゴリに収めようとする。LLM 分類の罠 1号 です。
4. 対策 — LLM に「none を選んでいい」と教える
問題を分解すると、こうでした。
4.1 LLM は「該当なし」を選びたがらない
選択肢にない情報を「該当なし」と判断する訓練を、LLM はあまり受けていません。与えられた選択肢の中から無理やり1つを選ぶ 傾向があります。
対策: none を選択肢に明示 + 「none を選んでいい例」をプロンプトに書く。
CLASSIFY_PROMPT = """\
以下のノートエントリを読んで、最も適切なカテゴリに分類してください。
カテゴリと選ぶ基準:
- lesson : 「次回からこうすべき」という具体的な再発防止ルールが書かれている
- idea : 将来実装・実現したいアイデア・構想が書かれている
- knowledge : 技術仕様・API仕様・ツールの客観的な事実が書かれている
- profile : 作者の価値観・信念・動機・こだわりが書かれている
- conclusion : 議論・検討を通じて導き出した導出・合意・設計判断が書かれている
- none : 上記に当てはまらない(作業記録・謝罪文・操作手順・会話のやり取りなど)
noneを選ぶ例:
- 「申し訳ありません」「その通りです」などの謝罪・同意の文
- 「以下を実行してください」などの操作指示・手順書
- 単なる作業経緯の説明(何をしたか の記録)
- 短すぎてルールや洞察が読み取れない断片
ノートエントリ:
---
{text}
---
思考プロセス:
1. このエントリに「次回に活かせる洞察・ルール・知見」が含まれているか?
2. 含まれている場合: 上記カテゴリのどれが最も近いか
3. 含まれていない場合: none を選ぶ
出力(JSONのみ。コードブロック・余分なテキスト不要):
{{"reasoning": "なぜそのカテゴリを選んだか1文", "type": "lesson|idea|knowledge|profile|conclusion|none", "summary": "要点を1文で(none の場合は空文字)"}}
"""
ポイントは3つ:
noneを選ぶ例を3〜4個明示 — 謝罪・指示・短文を具体例として LLM に見せる- 思考プロセスを言語化させる — 「次回に活かせる洞察があるか?」を最初に問う
reasoningを JSON に含める — LLM 自身に判断理由を書かせることで、安直な分類を抑える
これだけで誤分類が 8割以上減りました。
4.2 LLM に投げる前に事前フィルタ
それでも残るパターンには、Python 側で事前フィルタを入れました。
def classify_entry(text: str) -> dict:
stripped = text.strip()
# フィルタ1: 短すぎる断片は問答無用で none
if len(stripped) < 50:
return {"type": "none", "summary": ""}
# フィルタ2: 典型的な謝罪・同意・手順導入の冒頭パターン
apology_patterns = ("申し訳", "その通りです", "おっしゃる通り", "ご指摘の通り", "以下をそのまま")
if any(stripped.startswith(p) for p in apology_patterns):
return {"type": "none", "summary": ""}
# ここまで来たら LLM に投げる
prompt = CLASSIFY_PROMPT.format(text=text[:600])
# ...
50文字未満 と 謝罪パターン冒頭は LLM に問い合わせる前に弾く。Ollama を呼ぶコストも下がるし、誤分類の主要な発生源を遮断できました。
4.3 JSON mode を使う + フォールバック regex
Ollama (とその裏の Llama 系モデル) は、プロンプトで「JSON だけ出して」と頼んでも、冒頭に「はい、わかりました」と前置きを付けたり、コードブロックで囲んだり する個体差がありました。
対策: Ollama の format="json" オプションで JSON mode を強制。
resp = ollama.chat(
model=LLM_MODEL,
messages=[{"role": "user", "content": prompt}],
options={"temperature": 0.1}, # 揺らぎを抑える
format="json", # JSON mode: 必ず有効な JSON を出力
)
それでも稀に失敗するので、フォールバックを入れます。
raw = resp["message"]["content"].strip()
try:
result = json.loads(raw)
except json.JSONDecodeError:
# フォールバック: format="json" でも稀に失敗する場合の安全弁
m = re.search(r"\{[^{}]+\}", raw, re.DOTALL)
if not m:
return {"type": "none", "summary": ""}
result = json.loads(m.group())
完璧を期待しない。失敗しうる前提で、フォールバックで救う。これは LLM を実用バッチで使う時の鉄則だと感じました。
5. 昇格処理 — カテゴリ別ファイルに追記 + ChromaDB 投入
分類結果が確定したら、対応する data/<カテゴリ>/YYYY-MM-DD.md に追記します。
TYPE_DIRS = {
"idea": DATA_DIR / "ideas",
"lesson": DATA_DIR / "lessons",
"knowledge": DATA_DIR / "knowledge",
"profile": DATA_DIR / "profile",
"conclusion": DATA_DIR / "conclusions",
}
def promote_entry(entry_type, time_str, summary, original, source_date, dry_run):
target_dir = TYPE_DIRS.get(entry_type)
if not target_dir:
return # none はファイル作らない
target_dir.mkdir(parents=True, exist_ok=True)
target_file = target_dir / f"{source_date}.md"
if not target_file.exists():
target_file.write_text(
f"# {source_date} {LABEL_BY_TYPE[entry_type]}(自動抽出)\n\n",
encoding="utf-8"
)
block = f"""
## {time_str} [auto-distilled]
{summary}
**原文(抜粋)**:
{original[:400]}
---
"""
with target_file.open("a", encoding="utf-8") as f:
f.write(block)
# 同時に ChromaDB にも投入
ingest_file(str(target_file), entry_type, tags=f"{source_date},auto-distilled")
3つの工夫:
- 元の note とは別ファイルに昇格 — 元 note は触らずに残す(後で人間が見直せるよう)
- 要約 + 原文抜粋 を両方記録 — LLM が要約を間違えても、原文を見れば判断できる
- 昇格と同時に ChromaDB に投入 — タグに
auto-distilledを入れて、後で「自動分類した分」を絞れる
6. 再処理防止 — state ファイル + mtime チェック
distill は 重い処理 です。Ollama に1エントリずつ問い合わせるので、note 100件で数十秒〜数分かかります。
毎回全件処理し直すのは無駄なので、処理済みファイルを state に記録 する仕組みを入れました。
STATE_FILE = DATA_DIR / ".distill_state.json"
def load_state() -> dict:
if STATE_FILE.exists():
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
return {"processed": {}}
def save_state(state: dict):
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
processed には {ファイル名: 処理時の mtime} を記録します。次回実行時はこう判定:
for md_path in diary_files:
file_key = md_path.name
mtime = datetime.fromtimestamp(md_path.stat().st_mtime).isoformat()
# 処理済み かつ ファイルが更新されていない → スキップ
if not all_mode and file_key in processed and processed[file_key] >= mtime:
continue
# 新規 or 更新あり → 処理する
...
ファイル名だけでなく mtime まで見る のがポイント。同じファイルに新しい note エントリが追記されたら、mtime が更新されるので再処理対象になります。
--all フラグで強制全件再処理もできる:
rag distill # 未処理だけ
rag distill --all # 全件再処理(state 無視)
rag distill --dry-run # 分類結果だけ表示、保存しない
7. 運用してみて — 何が変わったか
1か月運用した実感です。
7.1 「書きっぱなし note」がほぼ消えた
以前は「書いたまま放置」だった note が、自動で意味のあるカテゴリに振り分けられて検索可能になる 状態が普通になりました。
data/lessons/ の累積は徐々に増え、教訓だけを横断検索することもできるようになりました。
7.2 RAG 検索の質が上がった
検索時に type=lesson で絞ると、「過去の自分の失敗から学んだルール」だけ が返ります。Copilot/Claude Code に渡すコンテキストとして、これが効果的でした。雑に全件検索するより、精度の高い文脈を AI に渡せる。
7.3 「あ、これは記録すべきだ」と思える瞬間が増えた
副作用ですが、note を書く時に「これは自動分類されたら lesson 行きだな」とか「これは knowledge かな」と意識するようになりました。書く側にとっても、カテゴリ意識 が芽生える。記録の質が上がる連鎖が起きました。
8. 注意点・限界
8.1 LLM の判断は完璧ではない
--dry-run でこまめにチェックしないと、たまに変な分類が混じります。半月に1回くらい、data/lessons/ を眺めて「これは違うな」と思ったエントリを手動で none 相当に外す運用が必要です。
8.2 同じ note が複数カテゴリに該当することがある
「Qiita 429 の Rate-Reset 教訓」は lesson でも knowledge でもあります。今の実装は 1エントリ1カテゴリ にしてしまっていますが、本来はマルチラベル分類のほうが適切かもしれません。今後の改善ポイント。
8.3 LLM モデル変更で結果が変わる
microai/suzume-llama3 を使っていますが、別モデルに切り替えると 分類傾向が変わる ことを観測しました。プロンプトはモデルごとにチューニングが必要。乗り換える時はサンプルで品質を見てからにすべき。
8.4 ローカル LLM のリソース消費
Ollama が起動している前提なので、ノート PC で他作業しながらだと メモリ・CPU がじわじわ取られる 感覚があります。バッチで一気に流すなら寝る前に走らせる、みたいな運用も合理的です。
9. まとめ
- 個人 RAG の運用で 「note は書くけど分類が追いつかない」 問題は誰でも遭遇する。LLM 自動分類 で解決可能
- ナイーブな実装だと 「申し訳ありません」が lesson に分類 されるような事故が起きる。
noneカテゴリの明示 + 思考プロセスのプロンプト埋め込み + 事前フィルタ で抑える - LLM 出力は JSON mode + フォールバック regex で安全に受け取る。完璧を期待せず、失敗しうる前提で設計
- state ファイル + mtime チェック で再処理防止。重い LLM バッチを賢く運用する基本
- 1か月運用すると、note の質も検索の質も両方上がる。書き手の意識まで変わる のが副作用としてうれしい
関連記事:
- Copilot Chat と Claude Code の会話履歴を ChromaDB に投入して『過去の自分』を検索する — distill の前段、note や会話履歴を RAG に積み上げる仕組み
- Copilot に同じ間違いを2度させない仕組み — RAG + MCP で『議論の記憶』を持たせる設計 — distill で抽出された lesson を Copilot がリアルタイムで参照する MCP サーバー実装
書きっぱなしの note が、明日の自分を救う教訓に化ける。それが distill パイプラインの本当の価値 だと感じています。