技術 約11分で読めます

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 coffeeUser prefers tea now は文字列としては被らないが、embeddingでは飲み物嗜好という軸で近い。
キー設計を事前にせず、後から「近いのを探して置き換える」をやれる。

ただしベクトル類似度には固有の落とし穴がある。

  1. 対立と類似が区別できない: Python好きPython嫌い はembeddingで近い。共起する語が同じだから。supersession判定を距離だけでやると、肯定と否定の上書きが起きる
  2. 粒度が違う記憶が衝突する: 飲み物が好き紅茶が好き は近いが、後者は前者の特殊化なので置き換えではない
  3. 検索器ごとにスコアの意味が違う: 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.00.85以上で同義に近いモデルによってベースラインが違う
BM25正規化なし、概ね0〜数十クエリ・コーパス次第。相対値で見る絶対値しきい値は移植不可
RRF(reciprocal rank fusion)0〜1付近に正規化されることが多い実装依存元スコアの意味は失われる
LLM rerankerのconfidence0〜10.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

実装で押さえるのはこのあたり。

  1. active recallは必ず WHERE superseded_by IS NULL を通す。ここを忘れると入れた意味がない。インデックスも部分インデックスで貼ると速い
  2. しきい値は検索器ごとに別変数。cosineとBM25とハイブリッドスコアで同じ定数を使わない。テストには「明らかに置換」「明らかに無関係」「グレー」を各5件くらい用意すると校正できる
  3. 置換判定はLLMに通す。距離だけだと「Python好き / Python嫌い」を取り違える。原典は距離だけで判定していたが、自前で組むなら短いプロンプト1回でいい

監査用エンドポイントとして、memory/{id}/historysuperseded_by のチェーンを辿れるようにしておくと、後から「ユーザーの好みが何回変わったか」を出せる。
これは時間減衰やキー上書きでは取れない、supersession chains特有の利点だ。

Heartbeatメモリに入れたらどうなるか

自分のかなチャットのHeartbeatメモリは、現状すべて機械的cutoffで動いている。
today が日付別14日分、task_signals が80件上限、profile が40件上限。
古い順に押し出すだけなので、上書き関係を表現する手段がない。

YourMemory記事の末尾でも書いたが、task_signals で2週間前に呟いた「OGP画像の自動生成を調べたい」が、興味を失った後もずっと提案候補に残るのが地味に効く。
減衰を入れれば自然に弱るが、ユーザーが明示的に「これはもういい」と言ったタイミングで即座に外したい場面もある。

supersession chainsを乗せるならこうなる。

領域現状supersession適用後
today日付別14日分で削除そのまま(日記の性質上、置換ではなく時系列)
task_signals80件上限、古い順に削除INSERT時に既存信号と類似度判定。置換ならsupersedeで現役から外す。撤回発話(「あれもう要らない」)も明示的にsupersede
profile40件上限、古い順に削除「最近Rustに興味ある」→「最近はGoばかり」のような上書きを意味的置換で吸収。古い興味は履歴に残る

today だけは別扱いでいい。
日記は同じ日付の中で積もるのがアイデンティティで、置換するものではない。
むしろ置換しようとすると「朝のことを夜が上書きする」みたいな変な挙動になる。

task_signalsprofile は、現在の機械的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ではむしろ分離されているほうが運用上うまい、という見方もできる。

参考