Loading post
Aug 29, 2026

前編:AIに毎回自分を説明するのをやめたでは、なぜChatGPT、Claude、Codex、Claude Codeの間に共通の記憶が必要だったのか、どの粒度で経験を保存したのか、そして実際の相談がどう変わったのかを書いた。後編では、MCPサーバー、OAuth+PKCE、PostgreSQL+pgvector、時系列revision、ハイブリッド検索、過去会話のbackfill、そして実際に踏んだ失敗をまとめる。
Personal Memoryは、AIモデルを内蔵した「もう一つのチャットサービス」ではない。サーバーの役割は、保存、検索、認証、整合性の保証である。何を覚えるか、どの記憶が今回の相談に必要か、会話からどんなcase recordを作るかは、接続しているAIクライアントが判断する。
Firebase Functions v2を使っているが、正本はFirestoreではなくCloud SQL上のPostgreSQLである。既存のポートフォリオbackendと同じrepositoryに置きつつ、Firebase/GCP project、Functions codebase、service account、secrets、Cloud SQL、Hosting、billingは yudai-personal-memory へ分離した。通常のポートフォリオdeployが、個人記憶backendを巻き込まないようにするためだ。
候補にはFirestore、Cloudflare Workers、Google Drive、Graphitiもあった。最終的にPostgreSQL+pgvectorを選んだ理由は、vector検索だけではない。
expectedRevisionによる楽観ロックをtransactionで扱いたかったpgvectorだけで「記憶」が完成するわけではない。vectorは、表現が似ている候補を探す部品である。時間、revision、原文根拠、公開境界、削除後の復活防止は、別のデータモデルとpolicyが必要だった。
中心となるテーブルは次の通り。
| Layer | 保存するもの | AIへ返すタイミング |
|---|---|---|
memories | 現在の日本語case record、日英alias、category、tags、有効期間 | 関係するsummaryだけ |
revisions | 各commit時点のimmutable snapshot | 履歴確認時 |
evidence | 日本語・英語の重要な本人発言の原文 | fullまたは引用確認時のみ |
search_projections | FTS/trigram用テキストと768次元embedding | AIへ直接は返さない |
memory_access | 最後に実際に使った日時、回数、用途 | indexの小さなmetadata |
conversation_ingest_ledger | message keyとSHA-256 hash | 返さない。再取り込み防止用 |
public_projections | 個別承認されたredacted copy | ブログだけ |
tombstones | 削除済みIDとlineage | 返さない。古いjobによる復活防止用 |
memoriesは現在の読みやすい状態、revisionsとevidenceは過去を失わないためのledger、search_projectionsは検索のための派生データである。役割を分けることで、索引を圧縮しても原文根拠を消さずに済む。
実際のpayloadは、短いkey-valueプロフィールより長い。
{
"title": "ポートフォリオ画像のPII処理方針",
"memoryKind": "episode",
"canonicalSummaryJa": "背景、懸念、比較した案、判断理由、結果、未解決点を含む日本語の詳細記録",
"aliases": [
"スクリーンショットの個人情報",
"portfolio screenshot pii",
"why not blur"
],
"evidence": [
{
"language": "ja",
"text": "本人が実際に話した高情報量の原文",
"source": "stable conversation message key"
}
],
"revision": 4,
"validFrom": "2026-08-01T00:00:00.000Z"
}
検索用embeddingには、canonical summary、title、alias、tags、sourceを使う。raw evidenceはembedding providerへ送らない。
ChatGPT Web、Claude Web、ローカルCLIはcallback URIが異なる。共通の静的tokenを各所に貼るより、OAuth Authorization Code+S256 PKCEを実装した。
実装上の境界は次の通り。
/mcpはGoogle tokenやowner secretを直接受け付けない最初はowner secretを認可画面へ貼る方式だった。動作確認には使えたが、「毎回credentialを入れる」体験になった。現在はGoogle sign-inを人間の認可に使い、その後はPersonal Memory専用tokenを発行する構成にした。
scopeも分けた。読む、proposalを作る、privateへ書く、削除する、公開する、は同じ権限ではない。クライアントが要求したscopeは認可画面へ表示する。
初期実装では、load_contextが詳細本文を一括で返した。100件で約535KB、約26万文字になったため、index-firstへ変えた。
日本語は空白で単語が区切られないため、PostgreSQLの標準全文検索だけでは弱い。Intl.Segmenterで日英の検索語を切り出し、FTSとtrigram/substringへ渡す。さらに768次元のembedding検索を行い、最後にReciprocal Rank Fusionで統合する。
実装ではlexical側を少し強くしている。個人記憶では、「意味が近い」ことより、固有名詞、プロジェクト名、過去に使った表現が一致する方が強い根拠になる場面が多かったからだ。一方、英語で保存された話を日本語で聞くような場合にはvectorと日英aliasが効く。
書き込みは、いきなりupsertしない。
標準フローは次の通り。
load_contextまたはsearch_memoryで関連topicを探すpropose_memorytargetMemoryIdと現在のexpectedRevisionを付けるcommit_memory通常のprivateなcreate、evidence、transition、closureは、ownerのstanding approval内でAIが確定できる。correction、merge、delete、publishは別確認が必要である。
expectedRevisionが重要なのは、複数AIが同じ記憶を更新できるからだ。revision 7を読んだAIがproposalを作っている間に、別のAIがrevision 8をcommitしたら、古いproposalはstaleとして拒否する。黙ってlast-write-winsにすると、詳細case recordが短い更新で消える。
ブログの可視化を考えたとき、private DBをフロントエンドからfilterする設計にはしなかった。GET /publicは、個別に承認されたprojectionだけを返す。
{
"items": [
{
"id": "opaque-public-id",
"title": "公開用に編集したタイトル",
"summary": "公開用にredactした要約",
"category": "achievement",
"occurredAt": "2026-08-01T00:00:00.000Z",
"tags": ["personal-memory", "mcp"]
}
]
}
このendpointはprivate sourceを復号しない。公開projectonの作成時に、公開する文章そのものを確認する。private側のvisibility flagだけを見て動的に全文を返すより、コピーを分けた方が事故時のblast radiusが小さい。
新しい会話だけ保存しても、過去の文脈が空では目的を達成できない。そこでClaudeとChatGPTの公式export、ローカルCodex rolloutから、再開可能なbackfillを作った。
処理したexportは小さくなかった。
| Source | 元データ | Prepare後 |
|---|---|---|
| Claude | 908 conversations / 7,759 messages | 1,131 planning batches |
| ChatGPT | 2,274 conversations / 23 JSON files | personal contextを含む1,682 conversations / 16,333 messages / 555 batches |
ChatGPT側では、純粋なコード生成、エラーログ解析、API mechanics、UI微調整、単語の意味だけの会話をローカルprefilterで落とした。ただし技術会話でも、本人の判断、学習の変化、成果、失敗から得たlessonがあれば残した。
通常batchは最大4 conversations、120,000文字。各batchのresult.jsonとrun-state.jsonを保存し、再実行時は有効な完成済みbatchを飛ばす。長時間jobが止まっても、ゼロからやり直さないことを最優先にした。
最終構成だけを見ると素直に見えるが、実装中はかなり壊した。
初期100件ではcontentとcanonicalSummaryJaが全件同じで、load_contextが約535KBを返していた。名前だけsummaryでも、全文でも駄目だった。
修正: indexSummaryをcanonical summaryから分離した。indexは短く、case recordは詳細なまま、evidenceは必要時だけ返す。index更新は意味上のrevisionを作らない派生データとして扱う。
ChatGPT Webの書き込み互換性を試したとき、既存の長いcanonicalSummaryJaを、互換性確認の一段落へ置き換えてしまった。
救済: immutable revisionに過去の詳細が残っていた。以前のrevisionと新しい検証結果を統合し、現在要約を復元できた。revision ledgerは飾りではなく、最初の実事故で役に立った。
巨大な更新groupをchunkへ分けて統合した際、モデルが意味を保ったまま引用をわずかに整えた。人間には同じ意味でも、evidenceは原文ではなくなる。
修正: モデルにはsemanticなcase recordだけを作らせ、永続evidenceは元candidateのsource/textから決定的に選ぶようにした。apply時にsource message内の逐語excerptかを検証する。
Claudeで5並列にしたときはMax planの5時間session上限へ到達した。ChatGPT exportをCodexで8並列処理したときは、Personal Memory APIの300 requests / 600 seconds制限でHTTP 429になった。
修正: 完成済みresult.jsonを保持したまま、次のrate windowを待ち、concurrencyを3、必要なら2へ落として再開した。速さより「完成済みunitを再利用できること」の方が効いた。
Claudeでは通るstructured resultが、ChatGPTでは未宣言structuredContentとして拒否された。
修正: MCP標準のtext contentへ揃え、クライアント固有の暗黙挙動へ依存しないようにした。サーバーが正しくても、実際のクライアントでload → propose → commit → 再検索まで試さないと互換性は証明できない。
最初のowner secret方式は接続できたが、再接続で毎回入力が必要になった。
修正: access tokenを短命にしつつ、rotating refresh tokenとrolling inactivity windowを実装した。認証はセキュリティだけでなく、日常的に使えるかどうかのproduct featureだった。
unit testだけでなく、実クライアント間の往復を確認した。
expectedRevisionが古いproposalを拒否するGET /publicへ混ざらない最終的なユーザーテストは、record IDを知っているかではない。「以前の相談を、推測せず、判断理由と未解決点まで説明できるか」「複数記憶を使い、確認済み事実と推論を分けられるか」で見た。
この構成では、常時費用の中心はCloud SQLである。FunctionsはminInstances=0、個人利用のrequest量なら大部分が小さい。私は月額20ドルを上限として設計したが、Cloud SQLのinstance、storage、backup、region、embedding利用量で変わるため、固定額とは書けない。
個人用途だけなら、最初はSQLite+ローカルMCP、あるいはMarkdown+indexでも十分だと思う。私がCloud SQLを選んだのは、複数マシンとWebクライアントから同じendpointへ接続したかったことと、PostgreSQL、OAuth、migration、observabilityを実際に学びたかったからである。
運用上は次を守っている。
同じものを作り直すなら、順番を変える。
逆に、モデルをCloud Functions内で呼んで「サーバーが自動で全部要約する」構成にはしない。判断promptを変えるたびにdeployが必要になり、呼び出し側のAIと二重にintelligenceを持つからだ。サーバーは事実の錨、クライアントは解釈する層、という分離は維持する。
Personal Memoryを作って分かったのは、長期記憶の中心がvector databaseではないことだった。
本当に難しかったのは、次の境界だった。
MCP、PostgreSQL、pgvector、OAuthはその境界を実装する部品だった。完成形は「何でも覚えるAI」ではなく、必要な過去を根拠付きで取り出し、変化を履歴として戻せる、小さな個人用データシステムである。