Tech Blog

個人ノートを LLM (Ollama) で5カテゴリに自動分類する — distill パイプラインの設計と落とし穴

Ollama LLM RAG ChromaDB Python 自動分類 AIアシスタント

🔗 シリーズ目次: 本記事は AIアシスタント運用ノート — Copilot / Claude Code を相棒として育てるための実践記録 シリーズの 実装編 (3) です。

この記事でわかること

  • 個人 RAG の運用で 「note は書くけど、教訓に昇格させるのを忘れる」 問題の構造
  • LLM (Ollama / Llama 3 日本語特化) で 5カテゴリ自動分類 する仕組み
  • 雑に実装すると 「申し訳ありません」が教訓に分類される 等の事故が起きる、その対処
  • JSON mode の使い方 と、それでも失敗する時のフォールバック設計
  • 再処理防止 state ファイル + mtime での差分検出
  • 運用 1か月で見えた効果と注意点

対象読者

  • 個人 RAG を運用していて 「書きっぱなしの note」 が増えてきた方
  • ローカル LLM (Ollama) を 実用的なバッチ処理 に組み込みたい方
  • LLM 分類タスクで 「予期しない誤分類」を抑える ノウハウを知りたい方

動作環境

項目バージョン
Python3.13 (venv)
Ollamaサーバー起動済み (localhost:11434)
分類用 LLMmicroai/suzume-llama3 (日本語特化 Llama 3)
Embeddingnomic-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つ:

  1. none を選ぶ例を3〜4個明示 — 謝罪・指示・短文を具体例として LLM に見せる
  2. 思考プロセスを言語化させる — 「次回に活かせる洞察があるか?」を最初に問う
  3. 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つの工夫:

  1. 元の note とは別ファイルに昇格 — 元 note は触らずに残す(後で人間が見直せるよう)
  2. 要約 + 原文抜粋 を両方記録 — LLM が要約を間違えても、原文を見れば判断できる
  3. 昇格と同時に 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 の質も検索の質も両方上がる。書き手の意識まで変わる のが副作用としてうれしい

関連記事:

書きっぱなしの note が、明日の自分を救う教訓に化ける。それが distill パイプラインの本当の価値 だと感じています。

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。