はじめに:ベクトルDBを入れる前に考えたこと
当社(合同会社ジョインクラス)では、Claude Codeとlaunchdのジョブ17本で、記事の生成・状態の更新・営業パイプラインなどを毎日自動で回しています。
「エージェントに記憶を持たせるなら、まずベクトルDB」とよく言われます。僕も最初はそう考えていました。でも、1年近く運用して残ったのは Markdownファイル・TSV・grep の組み合わせでした。ベクトルDBは、ある条件を満たしたときだけ入れる「最後の層」です。
この記事では、実際に動いているスクリプトをもとに次の内容を説明します。
- メモリを 短期・中期・長期 の3層に分けたときの、各層の実装
- 各層でハマった落とし穴と、その確認方法
- ベクトルDB(埋め込み検索)を入れるべきかどうかの判断基準と、最小構成
全体像:3層 + オプション層
| 層 | 寿命 | 当社での実体 | 書き込む主体 | 読み出し方 |
|---|---|---|---|---|
| 短期 | 1セッション | Claude Codeのコンテキスト | 会話そのもの | 自動 |
| 中期 | 日〜週 |
.company/STATE.md、部門ごとの STATE.md
|
cron(launchd) | ファイルパスを渡す |
| 長期 | 月〜年 |
memory/*.md + MEMORY.md(索引)、article-theme-ledger.tsv
|
エージェント/スクリプト | 索引を常時読み込み、本体は必要なときだけ |
| (任意) 意味検索 | 年 | ベクトルDB | バッチ | 類似度検索 |
設計の原則は1つだけです。「毎回読むもの」と「必要なときに引くもの」を分ける。コンテキストに常駐させるのは索引だけにして、本体はパスで参照します。当社のCLAUDE.mdには「コンテキスト使用率を10〜15%に維持する」「ファイルの中身ではなくパスを渡す」というルールがありますが、これはまさにこの原則を書いたものです。
短期メモリ:コンテキストは「作業台」であって倉庫ではない
短期メモリについては、実装よりも 何を入れないか のほうが大事です。
筆者の失敗談があります。CLAUDE.mdにルールを足し続けて1,000行を超えたとき、ルール同士が矛盾し、エージェントが指示と逆の動作を始めました。対策として次の2つを行いました。
- CLAUDE.mdは200行以内にする。詳細は
.claude/agents/*.mdに分ける - サブエージェントには「目的・参照ファイルパス・出力先・権限・完了条件」の5点だけを渡す
短期メモリは毎回ゼロから組み立て直すものだと割り切ります。そのぶん、中期と長期の層をしっかり作ります。
中期メモリ:cronで「今の状態」を1ファイルに要約する
中期メモリは「今この会社はどういう状態か」を表す1枚のスナップショットです。当社では毎朝9時にlaunchdが次のスクリプトを実行し、タスク集計から STATE.md を作り直しています(実ファイル: .company/scripts/auto-state-update.sh)。
#!/bin/bash
# STATE自動更新スクリプト
# タスク集計・パイプライン状態からSTATE.mdを自動更新
source "$(dirname "$0")/common.sh"
LOG_FILE="$LOG_DIR/auto-state-update.log"
main() {
log_info "=== STATE自動更新開始 ===" | tee -a "$LOG_FILE"
ensure_dashboard || exit 1
local result
result=$(api_post "state/auto-update" '{}')
if [ $? -ne 0 ] || [ -z "$result" ]; then
log_error "API呼び出し失敗" | tee -a "$LOG_FILE"
exit 1
fi
if [ "$(echo "$result" | jq -r '.success')" = "true" ]; then
local updated
updated=$(echo "$result" | jq -r '.updated | "日付:\(.date) 完了:\(.completedTasks) 進行中:\(.inProgressTasks) 未着手:\(.pendingTasks) 承認待ち:\(.approvalCount)"')
log_info "STATE更新完了: $updated" | tee -a "$LOG_FILE"
fi
# 中期メモリの変化をgit履歴として残す(=中期→長期への自然な昇格)
git_auto_commit "auto: STATE.md 自動更新 ($updated)"
}
main "$@"
ポイントは3つです。
-
上書き型にする:中期メモリは「最新の状態」だけを持ちます。履歴はgitに任せます。実際、当社のコミットログには
auto: STATE.md 自動更新 (日付:2026-10-26 完了:48 進行中:0 未着手:0 承認待ち:0)のような行が毎日並んでいて、これがそのまま時系列の記録になっています。 - 数値は要約してから書く:生ログをSTATEに入れないことです。エージェントが読むのは「完了48・承認待ち0」程度の粒度で十分です。
-
エージェントにはパスだけを渡す:朝のダイジェストのエージェントには
.company/departments/*/STATE.mdのパスを渡し、中身は向こうで読ませます。
落とし穴:macOSのcronが静かに全滅する
最初はcronで動かしていましたが、フルディスクアクセスの権限が原因で、ジョブがエラーも出さずに全部止まっていました。中期メモリが更新されないと、エージェントは古い状態を前提に判断します。これは見つけにくいバグです。launchdに移行したうえで、次のコマンドで鮮度をチェックしています。
# STATE.mdが24時間以上更新されていなければ警告
find /path/to/.company -name STATE.md -mtime +1 -print | sed 's/^/STALE: /'
長期メモリ①:1ファイル1事実 + 索引
長期メモリは、Claude Codeの auto-memory と同じ形にそろえています。1事実を1ファイルにまとめ、frontmatterに description を書きます。そして MEMORY.md に1行ずつ索引を並べます。
---
name: feedback-zenn-rate-limit
description: Zenn投稿は1日1件まで。書籍CTAを全記事に含める
metadata:
type: feedback
---
Zennは1日に複数コンテンツを公開するとデプロイが失敗する。
**Why:** 記事+書籍を同日公開してレートリミットに複数回引っかかった。
**How to apply:** 公開系ジョブはロックファイルで1日1件に制限する。
- [feedback_zenn_rate_limit.md](feedback_zenn_rate_limit.md) - Zenn投稿は1日1件まで
- [feedback_never_edit_published_books.md](...) — 公開済み書籍の本文を絶対に変更しない
この形にしている理由は次のとおりです。
- 索引の1行 ≒ 検索キー:LLMは索引を読み、関係がありそうなファイルだけを開きます。40件程度なら、これで「意味検索」の代わりになります。
- Why を必ず書く:理由がないルールは、状況が変わったときに守るべきかどうか判断できません。
- 重複を作らず更新する:書き込む前に既存ファイルを確認するルールにしています。この確認をしないと、矛盾した記憶が増えていきます(CLAUDE.mdが1,000行になったときと同じ失敗です)。
長期メモリ②:追記専用の台帳(TSV)
「どのテーマで記事を書いたか」のような イベントの記録 は、Markdownには向きません。当社では記事を生成するたびに、TSVに1行追記しています(auto-replenish-articles.sh の抜粋)。
THEME_LEDGER="$SCRIPT_DIR/article-theme-ledger.tsv" # date\tplatform\tslug\ttheme
# Zenn記事に同一タイトルが既に存在するか(重複投稿を防ぐ)
theme_already_on_zenn() {
local theme=$1
grep -h '^title:' /path/to/zenn-content-company/articles/*.md 2>/dev/null \
| sed 's/^title: *//' | tr -d '"' | grep -Fxq "$theme"
}
generate_article() {
local platform=$1 theme=$2
# ...slug生成...
# sedは使わない(テーマに / や [ が含まれると置換が壊れ、同じテーマが無限に再生成される)
mark_theme_used "$theme"
# 台帳に記録(効果分析でPV/スキと突き合わせる)
printf '%s\t%s\t%s\t%s\n' "$(date +%Y-%m-%d)" "$platform" "$slug" "$theme" >> "$THEME_LEDGER"
# ...
}
台帳の中身は次のような形です。
2026-09-14 zenn mcp-stdio-20260914220022-32061 MCPサーバーのテストとデバッグ — stdioトランスポートの挙動を追う方法
2026-09-14 note ai-1-10-20260914220152-18248 AI経営を1年やって捨てたルール10個 — 増やすより減らす運用改善の記録
落とし穴:sedで「使用済み」にしたら無限ループした
コメントにも書いてあるとおり、最初は sed -i "s/^$theme$/[used] $theme/" でテーマに印を付けていました。ところがテーマに / や [ が入っていると置換が失敗し、同じテーマの記事が毎日生成され続けました。長期メモリへの書き込みは、エスケープの心配がない方法(grep -F や awk で固定文字列として比較する)にしてください。
検証コマンド
# 同じテーマが2回以上記録されていないか(=重複生成の検知)
cut -f4 article-theme-ledger.tsv | sort | uniq -d
# プラットフォーム別の件数
cut -f2 article-theme-ledger.tsv | sort | uniq -c
ベクトルDBはいつ入れるべきか
ここまでの仕組みには明確な弱点があります。grep -Fxq は 完全一致 でしか重複を見つけられません。「MCPサーバーのデバッグ手法」と「MCPサーバーのテストとデバッグ」は、別のテーマとして通ってしまいます。
当社では、ベクトルDBを入れるかどうかを次の基準で判断しています。
| 条件 | ファイル+grepで十分 | ベクトル検索を検討 |
|---|---|---|
| 件数 | 〜数百件 | 数千件以上 |
| 検索キー | タイトル・ID・日付が決まっている | 言い換え・類義語で引きたい |
| 索引をコンテキストに常駐できるか | できる | 入りきらない |
| 誤検出のコスト | 低い | 低い(ただしヒット漏れのコストが高い) |
現在の台帳は約100行、メモリは40件程度です。そのため 本番ではまだベクトルDBを入れていません。ただ、近い意味の重複を検知したくなったときの最小構成は決めてあります。下のコードは、台帳のTSVをそのまま入力にする 検証用のスクリプト です(本番には組み込んでいません)。DBサーバーを立てず、埋め込みをnpyファイルにキャッシュするだけの構成です。
# pip install sentence-transformers numpy
import sys, numpy as np
from pathlib import Path
from sentence_transformers import SentenceTransformer
LEDGER = Path("article-theme-ledger.tsv")
CACHE = Path(".ledger-emb.npy")
model = SentenceTransformer("intfloat/multilingual-e5-small")
themes = [l.split("\t")[3].strip() for l in LEDGER.read_text().splitlines() if l.count("\t") >= 3]
if CACHE.exists() and len(np.load(CACHE)) == len(themes):
emb = np.load(CACHE)
else:
emb = model.encode([f"query: {t}" for t in themes], normalize_embeddings=True)
np.save(CACHE, emb)
candidate = sys.argv[1]
q = model.encode([f"query: {candidate}"], normalize_embeddings=True)[0]
scores = emb @ q
for i in np.argsort(-scores)[:3]:
print(f"{scores[i]:.3f}\t{themes[i]}")
sys.exit(1 if scores.max() >= 0.90 else 0) # 閾値は台帳で要調整
python near_dup_check.py "MCPサーバーのデバッグ手法" && echo "OK: 新規テーマ" || echo "NG: 近似重複"
導入するときの注意点です。
- 閾値は自分の台帳で決める:既知の重複ペアと非重複ペアをいくつか用意し、スコアの分布を見てから決めます。0.90は仮の値です。
- ベクトル検索は「判定」ではなく「候補出し」に使う:上位3件をLLMに渡し、同じテーマかどうかを最終判断させると誤検出が減ります。
- 正本はTSVのまま:ベクトルはいつでも作り直せるキャッシュとして扱います。DBを正本にすると、grepで確認できなくなり、運用が見えにくくなります。
まとめ
- 短期:コンテキストは作業台として使う。入れるのは索引とパスだけ
- 中期:cronで上書きする STATE.md を置き、履歴はgitに任せる。鮮度チェックを忘れない
- 長期:1事実1ファイル + 索引(知識)と、追記専用TSV(イベント)に分ける
- ベクトルDB:件数が多く、言い換えで引きたくなってから入れる。入れても正本はテキストのまま
経営の視点で言えば、メモリ設計は「AIに同じ失敗を二度させないための仕組み」です。当社の長期メモリにある約40件は、ほぼすべてが一度失敗して覚えさせたことです。ベクトルDBより先に、まず「失敗を1ファイルに書いて索引に1行足す」習慣から始めてみてください。今日から始められます。
関連書籍
CLAUDE.mdの肥大化を防ぐ書き方や、索引とサブエージェント用定義ファイルへの分け方など、この記事の「短期・長期メモリ」の土台になる設計は、拙著『CLAUDE.md設計パターン』で詳しく解説しています。