AIが同じ好みを聞き返すならVektor Memoryのsupersession chainsを見る
目次
AIに「コーヒーが好き」と覚えさせたあと、3か月後に「紅茶に変えた」と言う。
翌週また好みを聞いたとき、AIがコーヒーの話を持ち出してくる。
これは検索精度の問題ではなく、古い記憶を現役から外す処理が入っていないだけだ。
DEV CommunityのVektor Memoryの記事は、そのバグをNode.jsの5層を追って直した話だった。
v1.5.4で入ったsupersession chainsは、古い記憶を消さずに「新しい記憶に置き換えられた」と印を付け、通常のrecallから外す作りになっている。
忘れていないから間違える
この手の失敗は「AIが忘れた」ではない。
むしろ逆で、古い情報と新しい情報をどちらも覚えている。
「User prefers dark mode」と「User switched to light mode」が同じDBに残り、どちらも検索に出る。
モデルは片方を選べず、確認質問を増やすか、たまたま古いほうを採用する。
記憶が増えるほど賢くなるはずなのに、実際には古い正しさがノイズになる。
実運用で詰まるのは「コーヒーから紅茶」のような例題よりむしろ生活側の更新だ。
- 引っ越しで住所・最寄り駅・通勤時間が変わる
- 転職で所属・役職・使う技術スタックが変わる
- ツールを乗り換える(Notion→Obsidian、VSCode→Cursor)
- 設定を変える(dark mode→light mode、フォント、shell)
- 方針を撤回する(「やっぱりあのアーキにはしない」)
- 単純な訂正(誕生日の年を間違えて教えた)
どれも「古い情報も残しておいてほしいが、現在の回答には混ぜないでほしい」というケースだ。
削除してしまうと監査が効かないし、減衰だけだと数日〜数週間は古い情報が顔を出し続ける。
ここがsupersession chainsの効きどころになる。
過去のメモリ系記事との位置づけ
最近書いてきたエージェントメモリ系の記事は、それぞれ別の問題を解いていた。
Vektor Memoryのsupersession chainsはその中で「自由文の意味的な上書き」という別レイヤを足す位置にいる。
| 手法 | 古い記憶への対処 | 強い場面 | 弱い場面 |
|---|---|---|---|
| 時間減衰(YourMemory) | スコアを時間で弱める | 「いつか不要になる」雑多な記憶 | 半年に一度だが重要な記憶を弱らせる |
| キー上書き(Cloudflare Agent Memory) | 同じキーの記憶で明示的に置換 | 構造化された属性(住所、好み) | 自由文の記憶には事前にキー設計が要る |
| 取り出し分け(CTX) | 系統別にソースを変える | git・コード・履歴ごとに最適化 | 「古い・新しい」の判定は別レイヤ |
| 画像化(OCR-Memory) | 該当箇所を画像から戻す | 長い実行履歴の圧縮 | 矛盾の整合性管理は別途必要 |
| メモ哲学(Contextual Agentic Memory論文) | 検索棚として割り切る | 議論の前提整理 | それ自体は実装ではない |
| 意味的置換(Vektor Memoryのsupersession chains) | embeddingで近傍を取り superseded_by で現役から外す | 自由文の好み・設定変更・撤回 | スコアの種類としきい値の理解が要る |
要するに、YourMemoryは「時間で弱らせる」、Cloudflareは「キーで上書く」、Vektorは「意味で置き換える」。
キーで上書けるなら一番堅いが、自由文の記憶(「最近Rustに興味ある」「昼はラーメン控える」)には毎回キーを設計するのが面倒だ。
時間で弱らせるのは雑にやれる代わりに、「3日前の撤回」のような短期の上書きには間に合わない。
意味的置換はその中間で、自由文に対して「近い意味の古い記憶があるか」を毎回判定して退役させる。
superseded_by IS NULL が効く場所
原典の実装は素直だ。
新しいmemoryが入ると、既存memoryから意味的に近いものを探す。
置き換え対象が見つかったら、古い行の superseded_by に新しいmemoryのIDを入れ、置き換え時刻も記録する。
通常のactive recallでは WHERE superseded_by IS NULL で古いものを落とす。
履歴は消えないので、エージェントが過去に何を信じていたかは後から追える。
でも現在の回答には、外した記憶を混ぜない。
flowchart TD
A[1月<br/>コーヒーが好き] --> B[3月<br/>紅茶に変えた]
B --> C[古いmemoryへ<br/>superseded_byを設定]
C --> D[active recallでは<br/>新しいmemoryだけ返す]
C --> E[監査では<br/>古いmemoryも追跡可能]
ポイントは「現役から外す」と「削除する」が別だという点だ。
削除してしまうと、後から「ユーザーは半年でコーヒーから紅茶に切り替えた」のような時系列の事実が取り出せなくなる。
supersession chainsはchainなので、A → B → C と好みが変遷したときに、現在値はCで、C.superseded を辿るとB、Aと過去が読める。
なぜkey一致では足りないのか
「住所」「好きな飲み物」「使っているエディタ」のように属性化できるなら、Cloudflare型のキー上書きが一番堅い。
ただしエージェントが受け取る発話は属性化されていないことのほうが多い。
- 「やっぱりコーヒーやめて紅茶にしてる」
- 「最近はコーヒー控えてるんだよね」
- 「Tea is better in the morning」
これを key=preferred_drink に正規化するのは、結局LLMで一段抽出する処理が要る。
そしてLLMが毎回「これは飲み物の好みのキーだ」と当てられるなら苦労はしない。
英語と日本語が混ざる、表現が婉曲、属性名が新規、というだけで簡単に外す。
ベクトルDBに自由文のまま入れて、意味的近傍で衝突検出するのは、その正規化を諦める代わりにembeddingの距離で雑に拾う方針だ。
User likes coffee と User prefers tea now は文字列としては被らないが、embeddingでは飲み物嗜好という軸で近い。
キー設計を事前にせず、後から「近いのを探して置き換える」をやれる。
ただしベクトル類似度には固有の落とし穴がある。
- 対立と類似が区別できない:
Python好きとPython嫌いはembeddingで近い。共起する語が同じだから。supersession判定を距離だけでやると、肯定と否定の上書きが起きる - 粒度が違う記憶が衝突する:
飲み物が好きと紅茶が好きは近いが、後者は前者の特殊化なので置き換えではない - 検索器ごとにスコアの意味が違う: cosineの0.9とBM25の0.9はまったく別の事象(後述)
実務上は、近傍を取るのは類似度でやるが、最後の置換判定はLLMに「これは置き換えか、追加か、無関係か」を聞くのが安全だ。
原典は類似度しきい値だけで判定しているが、自前で組むなら一段LLM判定を挟みたい。
正しいコードが違う入口で死んでいた
バグの本体は、supersessionの考え方ではなく配線だった。
dedup処理は vektor-dedup.js に実装され、memory objectをProxyで包み、remember() 呼び出しを横取りする作りだった。
スキーマ移行もあり、しきい値もあり、module exportもあった。
ただしClaude Desktopから動くMCPサーバは、修正していた vektor.mjs ではなく、別のDXT entry pointから起動していた。
そちらのboot sequenceにはdedup wrapperが入っていなかった。
機能は正しい場所に存在したが、実行経路には乗っていなかったわけだ。
ここはMCPまわりの実装でかなり見覚えがある。
CLIから動かしたときの入口、Claude Desktopから起動したときの入口、パッケージ化されたDXTの入口が違う。
「このファイルを直したのに挙動が変わらない」は、だいたい実行されているentry pointを見間違えている。
さらに、optional moduleの読み込みエラーが空の catch に飲まれていた。
Claude Desktop配下のMCP subprocessではstderrが見えにくく、PowerShell側からは何も起きていないように見える。
ログをファイルへ出して初めて、template literal内に壊れたエスケープが混ざってSyntaxErrorになっていたことが分かった。
この話はメモリ機能の記事でありつつ、実装側にはMCPサーバのデバッグ記事として読める。
AIが同じことを繰り返す原因を見ているはずが、実際に見るべきはwrap chain、entry point、optional moduleのエラーハンドリングだった。
BM25の0.12を低すぎると見ない
最後に残った詰まりはスコアの意味だった。
dedupは既存memoryをrecallして近い候補を探す。
しきい値は0.95に設定されていた。
0.95はembeddingのcosine similarityなら分かる。
かなり近い文同士なら0.85から1.0近辺に出る。
でもこの経路で返ってきていたのはBM25のscoreだった。
BM25はキーワード一致に基づくランキングスコアで、cosine similarityのように0から1の直感的な類似度ではない。
| スコアの種類 | 典型レンジ | 「近い」と言える目安 | 注意 |
|---|---|---|---|
| Cosine similarity(embedding) | 0.0〜1.0 | 0.85以上で同義に近い | モデルによってベースラインが違う |
| BM25 | 正規化なし、概ね0〜数十 | クエリ・コーパス次第。相対値で見る | 絶対値しきい値は移植不可 |
| RRF(reciprocal rank fusion) | 0〜1付近に正規化されることが多い | 実装依存 | 元スコアの意味は失われる |
| LLM rerankerのconfidence | 0〜1 | 0.7以上で関連と見なすことが多い | 校正されていないことが多い |
原典では候補のスコアが0.10から0.12程度に並び、0.95のしきい値では全部捨てられていた。
しきい値を0.09に落として、ようやく「User prefers Node.js for coding」と既存の「User likes Node.js to code」がsupersessionされた。
ここが実務で一番効く。
RAGやメモリ検索の結果に score という名前が付いていても、意味は検索器ごとに違う。
embeddingのcosine、BM25、RRF後のrank fusion、LLM rerankerのconfidenceを同じしきい値で扱うと壊れる。
切り替えるたびにしきい値を再校正するか、内部で正規化(min-maxやrank-based)してから比較する。
CTXでClaude Codeに動くメモリを足す記事でも、CTXはgit log、コード、チャット履歴で取り出し方を変えていた。
メモリを全部ベクトルDBに寄せないのは、保存先の好みではなく、情報の種類ごとにスコアの意味が違うからでもある。
自前エージェントに入れる最小構成
supersession chainsを自前のエージェントメモリに入れるなら、最小構成は次の5フィールドで足りる。
CREATE TABLE memory (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
embedding VECTOR(768), -- pgvector / DuckDB VSS / sqlite-vss どれでも
created_at TIMESTAMPTZ NOT NULL,
superseded_by TEXT REFERENCES memory(id),
superseded_at TIMESTAMPTZ
);
CREATE INDEX active_recall_idx ON memory (id) WHERE superseded_by IS NULL;
書き込み時のフローはこうなる。
flowchart TD
A[新しい発話を抽出] --> B[embedding化]
B --> C[active recallから<br/>類似度上位N件を取得]
C --> D{近傍ありか}
D -- なし --> E[新規INSERT]
D -- あり --> F[LLMに置換/追加/無関係を判定]
F -- 置換 --> G[古いmemoryへ<br/>superseded_byを設定]
F -- 追加 --> E
F -- 無関係 --> E
G --> E
実装で押さえるのはこのあたり。
- active recallは必ず
WHERE superseded_by IS NULLを通す。ここを忘れると入れた意味がない。インデックスも部分インデックスで貼ると速い - しきい値は検索器ごとに別変数。cosineとBM25とハイブリッドスコアで同じ定数を使わない。テストには「明らかに置換」「明らかに無関係」「グレー」を各5件くらい用意すると校正できる
- 置換判定はLLMに通す。距離だけだと「Python好き / Python嫌い」を取り違える。原典は距離だけで判定していたが、自前で組むなら短いプロンプト1回でいい
監査用エンドポイントとして、memory/{id}/history で superseded_by のチェーンを辿れるようにしておくと、後から「ユーザーの好みが何回変わったか」を出せる。
これは時間減衰やキー上書きでは取れない、supersession chains特有の利点だ。
Heartbeatメモリに入れたらどうなるか
自分のかなチャットのHeartbeatメモリは、現状すべて機械的cutoffで動いている。
today が日付別14日分、task_signals が80件上限、profile が40件上限。
古い順に押し出すだけなので、上書き関係を表現する手段がない。
YourMemory記事の末尾でも書いたが、task_signals で2週間前に呟いた「OGP画像の自動生成を調べたい」が、興味を失った後もずっと提案候補に残るのが地味に効く。
減衰を入れれば自然に弱るが、ユーザーが明示的に「これはもういい」と言ったタイミングで即座に外したい場面もある。
supersession chainsを乗せるならこうなる。
| 領域 | 現状 | supersession適用後 |
|---|---|---|
today | 日付別14日分で削除 | そのまま(日記の性質上、置換ではなく時系列) |
task_signals | 80件上限、古い順に削除 | INSERT時に既存信号と類似度判定。置換ならsupersedeで現役から外す。撤回発話(「あれもう要らない」)も明示的にsupersede |
profile | 40件上限、古い順に削除 | 「最近Rustに興味ある」→「最近はGoばかり」のような上書きを意味的置換で吸収。古い興味は履歴に残る |
today だけは別扱いでいい。
日記は同じ日付の中で積もるのがアイデンティティで、置換するものではない。
むしろ置換しようとすると「朝のことを夜が上書きする」みたいな変な挙動になる。
task_signals と profile は、現在の機械的cutoffを残したまま supersession を上に重ねるのが安全だ。
supersedeされた信号はactive recallから外れるので、80件上限の中でも「生きている信号」だけが提案候補に並ぶ。
さらに recall_count 的な参照頻度を加味すれば、YourMemoryの強化と組み合わさって「繰り返し効く知識」と「一度の思いつき」が分離する。
実装の山は、置換判定をどこに置くかだ。
Heartbeatは現状ジョブキューで動いているので、task_signals のINSERT前に短いLLM呼び出しを1回挟む形が現実的になる。
プロンプトは「次の新規信号は、以下の既存信号のどれかを置き換えるか、無関係な追加か」を聞くだけでいい。
コストはINSERTごとに小さなclassification call 1回ぶんで、提案品質の改善とは釣り合うはずだ。
ここまで書いて思うのは、結局これ全部「思い出せる場所に書いた」という話で、「覚えた」ではないということだ。
人間の記憶は学習と一体だが、AIのメモリは重みに触らずcontextをいじっているだけで、reflexion系で自己批評を回しても、根本のθは動いていない。
なぜ勝手にファインチューニングしないか、という疑問は当然出てくる。
ただ、catastrophic forgettingで他能力が壊れる、ユーザーごとの重みが要る、unlearningが原理的に難しい、監査が効かない、推論より桁で高い、と現実側の事情がそこそこある。
夜間バッチで個人LoRAを焼いて、その上にメモ(supersession含む)を重ねる二層構造、くらいが当面の落としどころに見える。
「記憶=学習」を統合しないのは設計の怠慢ではなく、安全と可逆性のためにわざと分けている側面もある。
人間の記憶が学習と不可分なのは生物学的制約の結果で、AIではむしろ分離されているほうが運用上うまい、という見方もできる。