3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

差分記憶システムの実装: MastraとS3 Vectorsでキャラクター記憶を管理する

Posted at

これはマニアックな話です。

差分記憶とは何か?

ここで言う差分記憶とは、ストーリーの章ごとキャラクターごとに、何を覚えているべきかを管理する概念です。

例えば、RPGの主人公やサブキャラクターは、ストーリーの進行によって覚えている内容に差分が生じます。また、キャラクターごとに自分だけが知っている秘密も存在します。
このような記憶の差分を管理するのが、差分記憶の役割です。

これは、従来のAIエージェント用の短期記憶や長期記憶、ワーキングメモリとは異なります。GraphRAGやTemporal Knowledge Graphといった技術と併用することは可能ですが、それらとも別の概念です。

既存のAIエージェントにおけるメモリの概念は、タスク指向、かつシステム利用するユーザー中心の設計となっています。そのため、ストーリー内の各キャラクターの記憶として利用するには不向きです。
0B5A2B28-6FB4-4386-B7F7-3F8FA8669B90.jpeg

何のために必要?

この差分記憶機能が必要な理由は、長寿IPコンテンツにおいて、作者自身でさえ見落としがちな各キャラクター固有の細かな伏線や、特定の時点をブレイクポイントとして新たなストーリーを展開するといったユースケースが存在するためです。

原作者の不在や制作体制の変更があった場合、新企画を立ち上げる際には、より一層原作へのリスペクトが求められます。差分記憶があれば、コアなファンから見ても設定の破綻を防ぐことができ、原作の整合性を保ちながら新しいストーリーを紡ぐことが可能になります。

例えば、ドラゴンボールでは、Z戦士編から始まるifストーリーが何度も制作されています。

MastraとS3 Vectorsで差分記憶システムの実装

今回の差分記憶管理システムには、いくつかの機能要件があります。

複数のストーリーを管理でき、各エピソードは特定のストーリーに属します。
エピソード内では、登場人物ごとにその人物だけが知る情報を差分記憶として管理します。同時に、全登場人物が共有する共通情報も存在します。これらの情報はメタデータとしてフィールド化する必要があり、エピソード間の時系列も管理する必要があります。

例えば、下記のストーリーでは、求めるレコード形式は以下の通りです。

  ┌─────────────────────────────────────────────────┐
  │ Index: character-memory-default-story           │  ← ストーリーで分離
  ├─────────────────────────────────────────────────┤
  │                                                 │
  │  Vector 1: vec:episode-1:v1:world:world:0       │
  │  ├─ embedding: [0.123, 0.456, ...]              │
  │  └─ metadata:                                   │
  │     ├─ episodeId: "episode-1"                   │  ← どのエピソード
  │     ├─ episodeNo: 1                             │  ← エピソード順番
  │     ├─ scope: "world"                           │  ← 記憶タイプ: 公開 or 秘密
  │     ├─ characterId: "world"                     │  ← 誰の記憶
  │     ├─ text: "翼はカフェの店長である"               │
  │     └─ importance: 3                            │
  │                                                 │
  │  Vector 2: vec:episode-1:v1:character:himuro-nigo:0 │
  │  ├─ embedding: [0.789, 0.234, ...]              │
  │  └─ metadata:                                   │
  │     ├─ episodeId: "episode-1"                   │
  │     ├─ episodeNo: 1                             │
  │     ├─ scope: "character"                       │  ← 秘密
  │     ├─ characterId: "himuro-nigo"               │  ← 二郷だけが知る
  │     ├─ text: "二郷は時間停止能力を持っている"        │
  │     └─ importance: 5                            │
  │                                                 │
  │  Vector 3: vec:episode-2:v1:world:world:0       │
  │  └─ metadata:                                   │
  │     ├─ episodeNo: 2                             │
  │     └─ ...                                      │
  │                                                 │
  └─────────────────────────────────────────────────┘

  ┌─────────────────────────────────────────────────┐
  │ Index: character-memory-story-b                 │  ← 別のストーリー
  ├─────────────────────────────────────────────────┤
  │  Vector 1: vec:episode-1:v1:world:world:0       │
  │  └─ metadata: ...                               │
  └─────────────────────────────────────────────────┘

エピソードが作成・修正・削除されたタイミングで、登場人物の記憶(ベクトルデータ)を更新します。
大量の同期リクエストが発生することを想定し、Amazon S3 Vectorsを採用しました。

Amazon S3 Vectorsは、Amazon S3にネイティブでベクトル検索機能を追加した、低コストでスケーラブルなベクトルストレージサービスです。

2025年12月GAしまして、プレビューの時と比べて性能もかなり強化されてます。

  • 1秒あたりの書き込みリクエスト数 200倍(5 → 1000)
  • メタデータキーの合計数 5倍(10 → 50)

2025-s3-vector-1-vector-overview.png

同期ロジックと取得ロジックの分離

同期ロジックの流れ、エピソード更新する際に、API Gatewayのエンドポイントを呼び出し、更新用の情報をSQSに送信します。その後、Lambda内でAI SDKを使用して記憶の分割処理を行い、その後チャンクデータをS3 Vectorsに同期します。

/**
 * エピソードテキストからメモリを抽出
 */
export async function extractMemoryFromEpisode(
  episodeText: string,
  episodeId: string,
  episodeNo: number
): Promise<EpisodeDelta> {
  // キャラクター情報を含むプロンプト
  const characterInfo = characterIds
    .map(
      (id) => `- ${id}: ${CHARACTERS[id].name} - ${CHARACTERS[id].description}`
    )
    .join("\n");

  const systemPrompt = `あなたはストーリーから重要な事実を抽出するアシスタントです。

## キャラクター一覧
${characterInfo}

## タスク
以下のエピソードテキストから、キャラクターAIが「記憶」として持つべき重要な事実を抽出してくだい。
...その他指示
`;
  const { object } = await generateObject({
    model: extractModel,
    schema: extractedMemorySchema,
    system: systemPrompt,
    prompt: `以下のエピソード${episodeNo}から重要な事実を抽出してください:\n\n${episodeText}`,
  });
  const facts: MemoryFact[] = [];

  // ワールド共通の事実
  for (const fact of object.worldFacts) {
    facts.push({
      text: fact.text,
      scope: "world",
      importance: fact.importance,
    });
  }

  // キャラクター固有の事実
  for (const [charId, charFacts] of Object.entries(object.characterFacts)) {
    for (const fact of charFacts) {
      facts.push({
        text: fact.text,
        scope: "character",
        characterId: charId as CharacterId,
        importance: fact.importance,
      });
    }
  }

  return {
    episodeId,
    episodeNo,
    facts,
    extractedAt: new Date(),
  };
}

名称未設定ファイル.drawio (7).png

差分記憶の取得は、Mastraのダイナミックエージェントと@mastra/s3vectorsライブラリで実現します。
Request ContextにAIエージェントが呼び出されたエピソード番号キャラクターIDを渡します。
フィルタリングの際は、一般情報(world)characterIdに属する情報 に絞り込み、対象エピソードの順番までのチャンクのみを取得します。

/**
 * 「未来を知らない」+「他キャラの秘密を知らない」フィルタを生成
 *
 * @param characterId キャラクターID
 * @param currentEpisodeNo 現在のエピソード番号
 * @returns S3Vectors用のフィルタオブジェクト
 */
export function createMemoryFilter(
  characterId: string,
  currentEpisodeNo: number
) {
  const cutoff = currentEpisodeNo - 1;

  if (cutoff < 1) {
    return null;
  }
  return {
    $and: [
      { episodeNo: { $lte: cutoff } },
      {
        $or: [
          { scope: 'world' },
          {
            $and: [
              { scope: 'character' },
              { characterId },
            ],
          },
        ],
      },
    ],
  };
}
...
    const results = await store.query({
      indexName,
      queryVector: embeddings[0],
      topK: context.topK ?? 10,
      filter,
    });

実際に実行してみます。差分記憶の抽出対象は、下記のストーリーの1章と2章です。

主人公の氷室二郷に対して、カフェの先輩について尋ねてみます。

🤖 Character Agent Test

Character: himuro-nigo (氷室二郷)
Current Episode: 3
Message: "カフェの先輩、翼さんってどんな人?"

📝 RuntimeContext:
   - characterId: himuro-nigo
   - currentEpisodeNo: 3
   - storyId: default-story

💭 Generating response...

results: [
  {
    id: 'vec:episode-1:world:world:5',
    score: 0.6865002408878158,
    metadata: {
      text: '二郷はカフェ「ブルームーン」でアルバイトをしており、店長は翼である',
      episodeId: 'episode-1',
      episodeNo: 1,
      vectorId: 'vec:episode-1:world:world:5',
      scope: 'world',
      importance: 4,
      characterId: 'world',
      factIndex: 5
    }
  },
  {
    id: 'vec:episode-2:world:world:4',
    score: 0.5963619156816176,
    metadata: {
      importance: 3,
      episodeNo: 2,
      scope: 'world',
      factIndex: 4,
      vectorId: 'vec:episode-2:world:world:4',
      characterId: 'world',
      text: '翼は大学生の先輩として二郷の勉強を気にかけ、受験のアドバイスをしている',
      episodeId: 'episode-2'
    }
  },
...
]
════════════════════════════════════════════════════════════
💬 Response:

ああ、翼さんね。カフェ「ブルームーン」の店長で、俺の先輩なんだ。

大学生で、すごく面倒見がいい人だよ。俺が高校生だから、よく勉強のことを気にかけてくれて、受験のアドバイスとかもしてくれる。バイト先の上司っていうだけじゃなくて、本当に良い先輩って感じかな。

カフェでの仕事も、翼さんがいるから居心地がいいんだと思う。気分転換にもなるし、普通の日常って感じで…俺にとっては大切な場所なんだ。
════════════════════════════════════════════════════════════

実際、このカフェの先輩は監視者でもありますが、氷室二郷はその情報を知らないため、この出力は妥当です。

最後

DBだけでは不十分な理由

従来のデータベース検索LIKE句、タグ、全文検索でも近いことは実現できますが、記憶は正規化が難しいという特徴があります。
記憶には外見、職業、関係性、内心、伏線、感情、出来事の解釈など多様な要素が含まれ、作品の進行に伴い属性が増え続けるため、固定スキーマでの管理は困難です。

一方、RAGはテキストのまま記憶を保持・検索できるため、スキーマ進化のコストが小さく、新しい属性や概念にもデータ構造の変更なしで柔軟に対応できます:point_up_tone1:

なぜAmazon Bedrock ナレッジベースではなく自前構成なのか

S3へのデータ同期が非効率、ストーリー関連情報はすべてDBに保存されているため、一度S3に移動する手間が増えます。

メタデータファイルの制限.metadata.json自体に制限があり、同期のたびに作成してS3に配置するのはメンテナンスコストが高い。また、別情報によると、.metadata.jsonに設定できるメタデータは10個まで、かつ予約キーが3つ存在するため、今回のユースケースとの親和性が低い。

S3 Vectorsのメタデータは50個まで作られます。

チャンク作成の柔軟性が低い、Bedrock ナレッジベースはLambda経由でチャンクを作成することも可能だが、そこまでやるなら自前でLambdaを実装するのと大差がない。

参考資料

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?