0
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?

Go + Gemini Embedding + pgvectorでAI Agentに「忘却する脳」を構築する

0
Posted at

前書き:AI Agentの「記憶」について語るとき、多くの人が最初に思いつくのは「過去の対話履歴をすべてPromptに詰め込む」という方法です。Agentの数が少なく、対話がシンプルなうちはこれで凌げますが、複数のAgentが長期運用されるフェーズに入ると一気に破綻します。コンテキストウィンドウは溢れ、Token費用は爆発し、モデルは雑多な履歴の中から真に重要な情報を見つけ出せなくなります。

本記事では、私たちがマルチAgentプロジェクトで実装した記憶システム —— Gemini Embedding + pgvector をベースとした意味検索(セマンティック検索)記憶アーキテクチャ と、その開発過程における重要な技術的決断について解説します。


1. 課題の整理:なぜAgentには「本物の記憶」が必要なのか?

次のようなシナリオを考えてみてください。

Tick 42 の時点で、AgentのA君はAgentのB君と会話をし、B君から「最近、市場で新鮮な魚が売られている」と教えられました。
Tick 200 になり、A君は市場の近くを通りかかります。彼はこの出来事を覚えているでしょうか?そして「魚を買いに行く」という意志決定ができるでしょうか?

特別な記憶システムが存在しない場合、答えは**「不可能」**です。A君が意思決定を行う際、彼に与えられるのは「現在のTickにおける観察入力」だけであり、Tick 42で起きた出来事は彼にとって最初から存在しなかったことになります。

これこそが、マルチAgentシステムにおける「記憶」問題の本質です。Agentには、長い時間軸にわたるインタラクションの中から、過去の重要な情報を選択的に保持し、呼び出す仕組みが必要です。

ここで「選択的」という言葉が極めて重要になります。すべての出来事を記憶する価値があるわけではありません。A君が Tick 100 で「右に1歩進む」と決めたような行動は記憶しても意味がありませんが、Tick 42 でのB君との会話は保持する価値のある「意味のある記憶」です。


2. 全体アーキテクチャ:書き込みと検索の分離(デカップリング)

私たちの記憶システムは、完全な2つの独立したフェーズに分かれています。

各Tickの終了時
     │
     ▼
┌──────────────────────────┐
│   記憶の書き込み(同期)  │
│ 重要度の判定 → DB保存    │
│ (この時点ではベクトル化しない)│
└────────────┬─────────────┘
             │
      バックグラウンドタスク
             ▼
┌──────────────────────────┐
│   ベクトル化(非同期)   │
│   Gemini Embedding       │
│   → pgvector へ書き込み  │
└──────────────────────────┘

次のTickの開始時
     │
     ▼
┌──────────────────────────┐
│     記憶の検索(同期)   │
│ 現在の観察 → Embedding   │
│ → コサイン類似度検索     │
│ → Promptへ結合           │
└──────────────────────────┘

「書き込み」と「検索」の分離こそが、本システムにおいて最も核心的な設計上の決断です。その理由を順を追って説明します。


3. 記憶のデータモデル

まず、記憶データがデータベース内でどのような構造を持っているかを見てみましょう。

type MemoryModel struct {
    NPCID      uint            // どのAgentに属する記憶か
    Type       string          // "observation"(観察)/ "conversation"(会話)
    Content    string          // 記憶のテキスト内容(自然言語)
    Embedding  pgvector.Vector // 1536次元の特徴ベクトル(pgvector形式)
    Importance int             // 重要度スコア(1〜10点)
    Tick       int64           // どのハートビート(Tick)で発生したか
    IsCore     bool            // コア記憶かどうか(将来の拡張用フィールド)
}

いくつかの設計上の工夫について触れておきます。

なぜ Importance(重要度)フィールドが必要なのか?

当初は、LLMを使って各記憶に重要度スコアを採点させる(モデルに「この出来事がどれほど重要か」を判断させる)アプローチを考えました。概念としては正しいのですが、実践においては2つの副作​​用が発生しました。追加のLLM呼び出しによるレイテンシと費用の増加、そしてAPIレートリミット(頻度制限)への圧迫です。

最終的に、私たちはルールベースの静的な重み付けへと変更しました。

  • talk(会話行動):重要度 8
  • move(移動行動):重要度 4
  • stay(待機行動):無視(保存しない)

この「stay 行動をフィルタリングして無視する」という決断は非常に重要でした。もし毎Tickごとに「現在地で待機した」という記憶を保存し続けると、DBは大量の低価値データで埋め尽くされ、検索時のS/N比(シグナル/ノイズ比)が急激に低下します。より多くのデータを保存すること以上に、DBのS/N比を高く保つことの方が遥かに重要なのです。


4. 非同期ベクトル化:メインプロセスのボトルネック化を防ぐ

各Tickが終了した際、Agentの意思決定は自然言語の記憶に変換されてデータベースへ書き込まれます。

func RecordNPCActionMemory(npc *models.NPCModel, decision *llm_ser.NPCDecision, tick int64) {
    if decision.Action == "talk" {
        memory := models.MemoryModel{
            NPCID:      npc.ID,
            Type:       "conversation",
            Content:    fmt.Sprintf("私はみんなに言った:%s", decision.Speak),
            Importance: 8,
            Tick:       tick,
        }
        // 注意:Omit("Embedding") —— この時点ではベクトルを書き込まず、バックグラウンド処理に回す
        global.DB.Omit("Embedding").Create(&memory)
    } else if decision.Action == "move" {
        memory := models.MemoryModel{
            NPCID:      npc.ID,
            Type:       "observation",
            Content:    fmt.Sprintf("私は (%d, %d) へ移動することを決めた", decision.TargetX, decision.TargetY),
            Importance: 4,
            Tick:       tick,
        }
        global.DB.Omit("Embedding").Create(&memory)
    }
    // stay 行動:直接無視し、保存しない
}

コード内の Omit("Embedding") にご注目ください。あえてベクトルの書き込みをスキップし、テキスト内容のみを先に保存しています。

なぜこのような設計にするのか?

Gemini Embedding APIの呼び出しにはネットワークリクエストが伴い、200ms〜500ms程度のレイテンシが発生します。Tick終了時のメインプロセス内で同期的にベクトル化を待つと、エンジン全体の実行テンポが直接的に遅延してしまいます。

そこで、ベクトル化処理をバックグラウンドの定期タスクへと切り出しました。一定間隔ごとにDB内の Embedding IS NULL である記憶をスキャンし、バッチ処理でベクトル化して書き戻します。これにより、メインプロセスとベクトル化処理が完全に分離され、互いをブロックしなくなります。


5. 実装上の技術的工夫

ベクトル化のステップでは gemini-embedding-001 モデルを使用し、OpenAI互換インターフェース経由で呼び出しています。

func GetEmbeddings(texts []string) ([][]float32, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    cfg := global.Config.LLM.GetConfigByName(global.Config.LLM.EmbeddingModel)
    client := llm.GetOpenAIClient(cfg.ApiKey, cfg.BaseURL)

    req := openai.EmbeddingRequest{
        Input: texts,
        Model: openai.EmbeddingModel("gemini-embedding-001"),
    }

    res, err := client.CreateEmbeddings(ctx, req)
    if err != nil {
        return nil, err
    }

    var embeddings [][]float32
    for _, item := range res.Data {
        val := item.Embedding
        // データベースの vector(1536) に合わせるため、1536次元に切り詰める
        if len(val) > 1536 {
            val = val[:1536]
        }
        embeddings = append(embeddings, val)
    }
    return embeddings, nil
}

実務上、注意すべき技術的ポイントがいくつかあります。

1. なぜ1536次元なのか?

gemini-embedding-001 がデフォルトで出力する次元数は1536より大きく設定できます。しかし、DB側で vector(1536) と定義しているため、コード上で強制的にスライス(切り詰め)を行っています。これは実用上のトレードオフです。次元数が高くなるほどストレージや検索のオーバーヘッドが増大するため、本シナリオのセマンティック精度としては1536次元で十分だと判断しました。

2. 単一呼び出しではなくバッチインターフェースを使用

GetEmbeddings[]string を受け取り、バッチでのベクトル化に対応しています。バックグラウンドタスクは未処理の記憶をまとめて取得し、1回のAPI呼び出しで複数条のデータを処理します。これはAPIの頻度制限をコントロールし、全体の遅延を抑える上で不可欠です。

3. テキストのチャンク分割(Chunking)

長文のコンテンツに対応するため、オーバーラップ(重複領域)を持たせたテキスト分割アルゴリズムを実装しています。

func ChunkText(text string, chunkSize int, overlap int) []string {
    runes := []rune(text) // マルチバイト文字(日本語・中国語)の文字化けを防ぐため rune で処理
    var chunks []string
    for i := 0; i < len(runes); {
        end := i + chunkSize
        if end > len(runes) {
            end = len(runes)
        }
        chunks = append(chunks, string(runes[i:end]))
        if end == len(runes) {
            break
        }
        i = end - overlap // overlap 分だけ戻し、文脈の断絶を防ぐ
    }
    return chunks
}

オーバーラップ(overlap)を設ける意義は、文章が境界線上でちょうど分割された場合でも、隣接する2つのチャンク双方がその文脈の一部を保持するため、意味が失われない点にあります。また、バイト数(byte)ではなく rune 単位で分割するのは、日本語や中国語などのマルチバイト文字を扱う際の基本操作です(バイト単位で切ると文字化けが発生します)。


6. 記憶検索:コサイン類似度で「最も関連性の高い過去」を見つける

検索は、システム全体の中で最も興味深い部分です。Agentが意思決定を行う直前に、システムは現在の「観察(周囲の状況描写)」をベクトル化し、そのベクトルを用いてDBから最も関連性の高い過去の記憶を検索します。

func RetrieveRelevantMemories(npcID uint, observation string) string {
    // 1. 現在の観察内容をベクトルに変換
    embeddings, err := GetEmbeddings([]string{observation})
    if err != nil || len(embeddings) == 0 {
        return "" // フォールバック:取得失敗時は記憶なしで進行(メインフレームは停止させない)
    }
    vector := pgvector.NewVector(embeddings[0])

    // 2. コサイン距離を用いて、最も関連性の高い5件の記憶を検索
    var memories []models.MemoryModel
    global.DB.Where("npc_id = ? AND embedding IS NOT NULL", npcID).
        Order(clause.OrderBy{
            Expression: clause.Expr{
                SQL:  "embedding <=> ?",
                Vars: []any{vector},
            },
        }).
        Limit(5).
        Find(&memories)

    // 3. 自然言語のテキストに整形し、Promptへ挿入
    result := "\n【あなたの脳裏に以下の関連する記憶が思い浮かんだ】:\n"
    for _, m := range memories {
        result += fmt.Sprintf("- %s\n", m.Content)
    }
    return result
}

<=> 演算子は pgvector におけるコサイン距離演算子であり、値が小さいほど類似度が高いことを示します(コサイン距離 = 1 − コサイン類似度)。

この SQL の ORDER BY 行こそがセマンティック検索の中核であり、PostgreSQL は pgvector の HNSW や IVFFlat インデックスを利用して、膨大なベクトルデータの中からミリ秒単位で最近傍(Nearest Neighbors)を検索します。

また、embedding IS NOT NULL という条件フィルタは必須です。書き込まれたばかりの記憶はまだバックグラウンドでベクトル化されていないため、フィルタリングしないと NULL ベクトルが類似度計算に巻き込まれ、結果が混乱してしまいます。

検索結果は自然言語のテキストに整形され、Promptの末尾に直接結合されます。

【あなたの脳裏に以下の関連する記憶が思い浮かんだ】:
- 私はみんなに言った:市場の近くでお祭りが開かれているらしい
- 私は (15, 8) へ移動することを決めた
- 私はみんなに言った:今日は良い天気だから、川辺を散歩したい

AgentのLLMはこの記憶を参照することで、過去の出来事を考慮した自然な意思決定を行えるようになります。


7. フォールバック処理(Graceful Degradation):記憶システムをSPOFにしない

これは見落とされがちですが、非常に重要な設計上のディテールです。

記憶システムの各ステップには失敗の可能性が潜んでいます(Embedding APIのタイムアウト、DBクエリの遅延、ベクトル化タスクの滞留など)。どのステップで障害が発生しても、Agentが現在のTickでの意思決定を行えなくなる事態は避けなければなりません

私たちの原則は**「記憶は錦上添花(あったら嬉しいもの)であり、不可欠な前提条件ではない」**というものです。

コード上では以下のように表現されています。

  • GetEmbeddings が失敗した場合、RetrieveRelevantMemories は error を投げずに空文字列 "" を返す。
  • 空文字列がPromptに結合された場合、記憶がない状態としてAgentは通常通り動作を継続する。
  • バックグラウンドのベクトル化タスクが失敗した場合はログを記録し、次回の定期タスクでリトライを行う(メインプロセスには影響を与えない)。
embeddings, err := GetEmbeddings([]string{observation})
if err != nil || len(embeddings) == 0 {
    global.Log.Sugar().Errorf("観察ベクトルの取得に失敗: %v", err)
    return "" // フォールバック:記憶なしで継続し、メインプロセスを保護
}

このような「失敗時にエレガントに機能を縮小させる(Graceful Degradation)」設計思考は、外部APIに依存するあらゆるシステムにおいて遵守すべきパターンです。


8. 成果と振り返り(考察)

この記憶システムを実際に運用したところ、思いがけない創発的効果(Emergent Effect)が観察されました。Agent間における情報の自然な伝播です。

Tick 50 でA君が「市場で新鮮な魚が売られている」と発言し、その記憶がDBに保存・ベクトル化されます。Tick 200 になり、B君が市場の近くを通った際、B君の現在の観察文脈に「市場」という言葉が含まれているため、検索時に(過去にA君と対話した履歴があれば)A君の発言記憶や関連記憶が引き合わされます。

これは意図してロジックを作り込んだわけではなく、セマンティック検索によって自然にもたらされた効果です。「関連する記憶が、関連する文脈において呼び起こされる」——これは人間の記憶のメカニズムと非常によく似ています。

もちろん、現在のシステムには明確な限界も存在します。

  1. 記憶の減衰メカニズムが存在しない:現実世界では古い記憶ほど曖昧になりますが、現状は時間経過による減衰を考慮せず、類似度のみでソートしています。Tick フィールドに生成時刻が記録されているため、将来的にスコアリング式へ時間重みを導入することが可能です。
  2. 「リフレクション(反省・抽象化)」層の不在:スタン福の「Generative Agents」論文で提唱された記憶アーキテクチャには3つの層(観察・反省・計画)がありますが、現在は「観察」層のみを実装しており、複数記憶のハイレベルな要約を行う「反省」層は未導入です。
  3. ベクトルインデックスのチューニング:記憶数の増大に伴い、pgvector の検索パフォーマンスを維持するため CREATE INDEX USING hnswivfflat によるインデックス最適化を定期的に行う必要があります。

9. 結語

AI Agentに記憶システムを構築することの本質は、情報のフィルタリングと再現(リコール)に関するエンジニアリング課題を解決することに他なりません。

Embedding ベクトル化 + pgvector コサイン検索 は、強力な意味関連性ツールを提供してくれました。また、書き込みと検索の分離バックグラウンド非同期ベクトル化フォールバック耐性 は、多数のAgentが高頻度で動作する環境下でのシステム安定性を担保し、ルールベースの重要度フィルタ は最小限のコストで記憶ライブラリのS/N比を高く維持してくれました。

技術選型に絶対的な正解はなく、「現在の制約において適切かどうか」が存在するのみです。もしみなさんも同様のAgent記憶システムを開発されているようでしたら、この記事が少しでも参考になれば幸いです。

0
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
0
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?