🐢

コンパニオンAIの記憶を、普通のRAGじゃない設計にした話

に公開

コンパニオンを名乗るAIには記憶が要る。何度も会って、前回の文脈を覚えていて、こちらが変わっていくと向こうも少しずつ変わっていく — これが続かないと、毎回「はじめまして」のアシスタントと変わらない。

ところがいざ作りはじめると、いま主流のRAG文献はあまり助けてくれなかった。RAGの議論はだいたい「ドキュメントを検索して prompt に貼る」をうまくやる話で、人と人の継続的なやりとりに必要な部分は別にあった。

Asterel ではここを正面から作り直していて、その輪郭を共有しておきたい。普通のRAGじゃない設計にしたところを、いくつか順に並べる。

なぜ普通のRAGでは足りないのか

ドキュメントRAG的な記憶は、要は「過去のテキスト塊を高速で引ける」。これだけで足りるなら、コンパニオンの記憶も embeddings + 検索だけで終わる。

足りないものはこんなあたり。

  • ある事実が「今も真か」を扱えない(去年付き合っていた相手と今の相手は別)
  • 「忘れる」を実装する道具がない(マスクなのか削除なのか墓石なのか)
  • 「あの人」と前回出てきた「あの人」が同じ人かを判定できない
  • 生ログを「使える記憶」に変換する層がない(積みっぱなしになる)
  • 全文検索とベクトル検索の合議で取り出す、までで止まっている

つまり、時間と忘却と同一性と変換のレイヤーがまるごと欠けている。コンパニオンの記憶を作るというのは、ここを足していく作業だった。

時間軸を持たせる — bitemporal

「あの人と仲が良かった」という記憶は、いつ書いたかと、いつまで真だったかが別。Asterel では graph の edge に二つの時間を持たせている。

ALTER TABLE graph_edges
    ADD COLUMN valid_from TIMESTAMPTZ,
    ADD COLUMN valid_until TIMESTAMPTZ,
    ADD COLUMN confidence DOUBLE PRECISION NOT NULL DEFAULT 0.5;

valid_from / valid_until が「現実でいつからいつまでその関係が成立していたか」、行の created_at が「いつ記録したか」。bitemporal データベースで Snodgrass が定式化した「現実の時間」と「記録の時間」を分ける考え方を、knowledge graph のedgeに持ち込んだ形になる。

ただ、厳密な bitemporal は valid time と transaction time の2軸を持ち、後から訂正された行も潰さずに履歴として残す。Asterelの現状はまず valid time を明示して記録時刻と分けるところから入っていて、transaction time 側の履歴(supersede された行を残す)までは実装できていない。「後付けで足せる土台」を先に作っているステージ。

これがあると、

  • 「過去には true だったが今は違う」事実が、上書きされずに保存される
  • 「最近の関係性で query しろ」が成立する
  • 矛盾検出が時間を含めて意味を持つ

たとえば「Aさんに最近会わない」という事実が入ってきたとき、過去の「Aさんに会っている」を削るのではなく、valid_until = now() を立てて閉じるだけで済む。

忘れることを設計する

forget() は1行で書けるAPIだが、意味論は3種類ある。Asterelでは明示的に分けた。

Mode 意味
Soft 検索の対象から外すだけ。物理データは残ることがある
Hard 物理削除する。ただし「これは消した」という台帳エントリは残す
Tombstone スロットを墓石で置き換え、他は削る

backend ごとに支援する mode は違う。Postgres は3つすべてサポート、Markdown 運用だと tombstone は degraded(=同等のことができない)。これを capability matrix に置いて、コード側は「backend ができないことを要求しない」契約にしている。

ここまで分ける理由は、「忘れる」が状況によって意味が違うから。法的な削除要求や監査が絡む場面では、Hard と Tombstone の使い分けが必要になる。本人の希望で過去の話だけ消したいなら、参照は残っても内容にはたどり着けない Tombstone が向く。一時的に検索から外したいだけなら Soft で足りる。

何を残してよくて何を残してはいけないかは、backend ではなく policy 側で決める領域になる。backend は道具を提供する、policy が使い分ける、という分担。全部 hard delete にしてしまうと、台帳が崩れて再現性も監査もなくなる。

「あの人」を「あの人」だと判定する

会話履歴に「ヒロくん」と「ひろし」と「彼」と「あいつ」が出てきたとき、これが同じ人かどうかを判定するのが entity resolution。コンパニオンの記憶は、ここを誤ると「別人だと思っていたら同じ人だった」「同じ人だと思っていたら別人だった」が両方発生する。

Asterel ではハイブリッドにした。

  1. 埋め込みのコサイン類似度で prefilter(高速、雑だが大量を捌ける)
  2. 残った曖昧ケースを LLM judge に渡す(精度、ただし高コスト)

これは Fellegi-Sunter(1969)の確率的レコードリンケージにある「確実に一致」「確実に不一致」「曖昧域」の分け方を、LLM時代の companion memory に持ち込んだ形に近い。確実に裁けるところはコサインで切って、グレーゾーンだけ別の手段に渡す。

LLM 一本だとコストが膨らみ、コサイン一本だと精度が出ない。組み合わせると、コストは現実的なまま、グレーだけは丁寧に判定できる。

思い出し方を多段にする

検索はベクトル一発、ではない。Asterelの recall パイプラインはだいたいこういう構成。

  1. ベクトル検索とキーワード検索を独立に走らせる
  2. RRF (Reciprocal Rank Fusion, k=60) で2つのランクを merge
  3. MMR で diversity を入れる(関連性 vs 多様性、λ=0.7)
  4. 上位5件を seed に Personalized PageRank を knowledge graph 上で走らせる
  5. PPR の活性スコアを retrieval ランキングに blend back
  6. 必要なら最後に LLM rerank

なぜ RRF かというと、ベクトル類似度とキーワードスコアはスケールが違うから。生スコアを足し合わせると片方に支配される。RRF はランク順位だけ見るので、スケール非依存で合議できる。k=60 は Cormack らの原著以来よく使われるデフォルト値で、上位差を残しつつ、片方のランクだけが支配的にならないバランス。

graph を retrieval に使う発想自体は GraphRAG(Edge et al., 2024)の系譜だが、retrieval seed から PPR で活性を広げる具体の動かし方は HippoRAG / HippoRAG-2(Jiménez Gutiérrez et al., 2025)のほうに近い。top-K の retrieval を起点に、graph を伝って関連 entity に活性が広がる。「直接ヒットしなかったが、ヒットした entity と強く繋がっている」ものを救えるのが効く。

寝かせて整理する — 生ログを記憶に変える

ここがコンパニオン記憶の中核に近い。普通のRAGとの差は「検索が賢い」だけじゃなくて、生ログをそのまま積むのではなく、belief / event / relation に変換して持っておくところにある。

Asterelには sleep-phase consolidation という、人間が寝ている間に記憶を整理するのと同じノリのバックグラウンドジョブがある。スコープ内の retrieval unit を (entity_id, topic_prefix) でグルーピングして、

  • content は concat、importance は max、reliability は平均、visibility は最も厳しいやつを採る
  • 密なクラスタは Note tier の graph entity に昇格させる
  • temporal_decay_score を更新(30日 half-life の指数減衰)
fn aggregate_group(candidates: &[ConsolidationCandidate]) -> GroupAggregate {
    // 上のルールに従って各 candidate を畳み込む
}

struct GroupAggregate {
    combined_content: String,  // concat
    signal_tier:      String,  // 最も高い tier
    importance:       f64,     // max
    reliability_avg:  f64,     // 平均
    visibility:       String,  // 最も restrictive
}

時間が経つほど weight が落ち(decay)、似たものは集約され(consolidation)、強いクラスタは昇格する(promotion)。会話を生ログのまま積んでおくと検索が重くなるだけでなく、関係性として再構成されない。寝かせて整える層が独立に要る。

やらないと決めたこと

ここに記憶設計の個性が出る。

  • 会話を生ログのまま長期保存しない。durable layer に入るのは consolidate された belief / event / relation で、生発話そのものではない。
  • 削除を副作用なく終わらせないforget() は必ず台帳に痕跡を残す。「消したかどうか」が後から分かる状態を保つため。
  • 同一エンティティ判定を LLM 一本に委ねない。コストと再現性の両面で、coarse はルールで切る。
  • personality / affect を memory に直接混ぜない。emotional context は別ラインで持って、検索とblendとは分離する。
  • memory backend を抽象化しすぎない。capability matrix で「Postgres ならできる、Markdown ならできない」を露出させる。

「全部できる抽象 API」にしないことが、運用で壊さないコツになる。

まとめると

コンパニオンAIの記憶で難しいのは、検索精度そのものじゃない。難しいのは、過去の発話を「今の関係に使ってよい形」に変換しつづけることだ。

そのためには、vector store ひとつでは足りない。時間、忘却、同一性、統合、監査 — このあたりを普通のRAGの外側に置いて、独立に動かす必要がある。Asterel の memory はそうやって組んでいる。

まだ pre-1.0 で、運用しながら調整は続いている。骨格としてはだいたいこの形に落ち着いてきた、というところ。

リポジトリと研究パケットはこちら。

https://github.com/asterel-rs/asterel

参考にしたもの

  • Snodgrass (1999) Developing Time-Oriented Database Applications in SQL — bitemporal の古典
  • Jensen & Snodgrass (1996) Semantics of Time-Varying Information — 時間的意味論
  • Fellegi & Sunter (1969) A Theory for Record Linkage — 確率的レコードリンケージ、entity resolution の発想元
  • Edge et al. (2024) Graph RAG — テキストを graph index として扱う方向性
  • Jiménez Gutiérrez et al. (2025) From RAG to Memory: Non-Parametric Continual Learning for LLMs (HippoRAG-2, ICML 2025) — PPR で連想・出典つきの graph 検索
  • Cormack et al. (2009) Reciprocal Rank Fusion — RRF の原典

Discussion