現在いろいろなエンジニアの方と Microsoft Agent Framework Workshop をやっているのですが、そのなかで記憶に関するディスカッションがかなり活発に行われています。その中でのフィードバックを受けて、今回の様な実装案を考えました。
Microsoft Agent Framework には組み込みの MemoryContextProvider や Mem0ContextProvider が用意されていますが、実際に使ってみるとあれがない、これがないとなることが多いです。そこで Azure AI Search を組み合わせ、エージェント専用の人間の記憶に近づけたカスタムメモリーレイヤーを設計・実装してみました。
その中で、今回記憶の検索に特化した新しいランキング方針 RankingProfile.IMPORTANT や RankingProfile.RECENT を実装しました。
なぜカスタムメモリーが必要か
AI エージェントに「前回の会話で伝えた好み」を覚えさせたい場合、Mem0 のような汎用メモリーライブラリを使う選択肢があります。しかし、実運用で以下の要件が出てくると、汎用ライブラリでは制御しきれなくなります。
- 記憶の重要度や鮮度で 検索順位を変えたい
- 古い記憶を自動的にフェードアウトさせ、エージェントに渡す候補から外したい(忘却)
- スコアリングプロファイル、日付フィルター、カテゴリ絞り込み、セマンティックランカーなど Azure AI Search の便利機能をフルに使いたい
- 記憶の追加・更新・削除の判断ロジックを透明化したい
本記事では、Microsoft Agent Framework v1.4.0 の ContextProvider を継承し、Azure AI Search のインデックスを記憶ストアとして直接制御するアーキテクチャを紹介します。
アーキテクチャの全体像
シーケンス図
設計のポイントは 3 つです。
-
記憶のライフサイクルをトップレベルフィールドで管理する
importance、retention_score、access_count、updated_atを JSON ペイロードに閉じ込めず、Azure AI Search のフィルター・ソート・スコアリングプロファイルでそのまま使えるようにします。 -
検索方式とランキング方針を分ける
SearchModeは keyword / vector / hybrid / semantic の候補取得方式だけを表し、記憶の検索に最適化したRankingProfileがIMPORTANTやRECENTのランキング方針を表します。 -
LLM が使えなくてもフォールバックで動く
記憶抽出と統合判断にルールベースのフォールバックを持たせ、LLM が未設定でも構造を確認できます。
クラス構成
| クラス | 責務 |
|---|---|
AzureAISearchMemoryContextProvider |
ContextProvider を継承。before_run() で記憶検索・注入、after_run() で記憶保存を実行 |
AzureAISearchMemoryStore |
Azure AI Search インデックスの作成、ドキュメント Upsert、検索、状態更新、忘却処理 |
AzureOpenAIMemoryIntelligence |
Azure OpenAI の JSON Completion と Embedding を呼び出す薄いヘルパー |
MemoryExtractor |
会話メッセージから記憶候補 (MemoryCandidate) を抽出 |
MemoryConsolidator |
新規候補と既存記憶を比較し、ADD / UPDATE / DELETE / NONE を決定 |
HumanMemoryPolicy |
時間減衰、想起強化、アーカイブ判定のポリシー |
データモデル
MemoryRecord: Azure AI Search に保存する 1 件の記憶
@dataclass
class MemoryRecord:
id: str # UUID
user_id: str # ユーザー単位の分離
agent_id: str # エージェント単位の分離
memory: str # 記憶本文
category: str = "general" # preference / profile / plan など
importance: float = 0.5 # 0.0–1.0(忘却しにくさ)
confidence: float = 0.7 # 抽出時の確信度
status: MemoryStatus = "active" # active / archived / deleted
retention_score: float = 1.0 # 保持スコア(減衰で下がる)
access_count: int = 0 # 想起回数
created_at: datetime # 作成日時
updated_at: datetime # 更新日時
last_accessed_at: datetime | None # 最後に想起された日時
expires_at: datetime | None # 期限(一時的な記憶用)
embedding: list[float] # ベクトル検索用
importance や retention_score をトップレベルフィールドにすることで、Azure AI Search のスコアリングプロファイルから直接参照できます。Mem0 が使うような payload JSON に閉じ込めると、フィルターやソートで活用できなくなります。
MemoryStatus: 記憶のライフサイクル状態
| 状態 | 意味 | 検索対象 |
|---|---|---|
active |
通常の検索対象 | 常に含む |
archived |
古い / 低保持スコア |
include_archived=True で含む |
deleted |
明示削除または矛盾により無効化 | 含まない |
検索方式とランキング方針: SearchMode / RankingProfile
利用シナリオに応じて検索手法を変えられる設計にしました。IMPORTANT や RECENT が今回新規で開発したものです。
class SearchMode(StrEnum):
KEYWORD = "keyword" # テキスト一致
VECTOR = "vector" # 意味的類似
HYBRID = "hybrid" # キーワード + ベクトル
SEMANTIC = "semantic" # Hybrid + セマンティックランカー
class RankingProfile(StrEnum):
NONE = "none" # スコアリングプロファイルなし
RECENT = "recent" # 最近更新を優先
IMPORTANT = "important" # 重要度・保持スコア・参照回数を重視
スコアリングプロファイルとの対応
| RankingProfile | 内部で使うプロファイル | ブーストする値 |
|---|---|---|
IMPORTANT |
memory-priority |
importance (boost=8)、retention_score (boost=4)、access_count (boost=4, logarithmic) |
RECENT |
memory-recency |
updated_at (boost=8, Freshness 90 日) |
NONE |
なし | Azure AI Search の Relevance Score のみ |
呼び出し側のコードはシンプルです。
options = MemorySearchOptions(
mode=SearchMode.HYBRID,
ranking_profile=RankingProfile.IMPORTANT,
top=5,
min_retention_score=0.1,
)
results = provider.search_memories("大阪旅行の好み", options=options)
参考:RAGOps Studio におけるスコアリングプロファイルの管理
ちなみに、RAGOps Studio を使うとスコアリングプロファイルが UI で管理できます👍
記憶抽出: MemoryExtractor
会話メッセージから「永続的に保存すべき記憶」を抽出します。記憶の抽出については以前も取り上げましたが、今回も同様にプロンプトで指定します。
LLM 抽出モード
Azure OpenAI の JSON モードで、以下のような構造を返却させます。
{
"memories": [
{
"memory": "大阪旅行では駅近ホテルを最優先する",
"category": "preference",
"importance": 0.85,
"confidence": 0.9
}
]
}
抽出対象はユーザーの嗜好、制約、プロフィール、定期的なニーズです。一時的な事実やアシスタントだけが主張した内容は対象外とします。
ルールフォールバック
LLM が使えない場合は、日本語・英語のパターンマッチで永続的な発話を検出します。適当です。
durable_patterns = [
(r"(好き|好み|優先|苦手|嫌い|必要|いつも|毎回)", "preference"),
(r"(prefer|like|dislike|always|usually)", "preference"),
]
フォールバック時の既定値は importance=0.65、confidence=0.55、source="rule-fallback" です。
記憶統合: MemoryConsolidator
記憶の統合については、Foundry Agent Service においてもフルマネージドで機能実装していましたが、今回ここのロジックについても独自で実装します。LLM を使用して類似・重複するトピックをマージし、冗長な情報の保存を防ぎます。
LLM が新しい記憶候補と既存記憶を比較し、4 つのイベントから 1 つを選択します。具体的には意味的な類似度比較、矛盾検出、マージ文の自然言語生成を行います。
| イベント | 意味 | 処理 |
|---|---|---|
ADD |
新規記憶として保存 | 新しい MemoryRecord を Upsert |
UPDATE |
既存記憶に統合 | 本文マージ後に既存レコードを Upsert |
DELETE |
既存記憶を無効化 |
status を deleted に変更 |
NONE |
既存記憶でカバー済み | 何もしない |
判断フロー(ルールフォールバック)
近傍なし → ADD
正規化テキスト完全一致 or スコア >= 0.92 → NONE
忘却・削除指示に見える → DELETE
スコア >= 0.78 かつカテゴリ一致 → UPDATE
その他 → ADD
UPDATE 時は importance と confidence に max(existing, candidate) を使い、重要度を不用意に下げない設計にしています。
人間らしい記憶: HumanMemoryPolicy
エージェントの記憶が増え続けると、古い予定や弱い好みまで毎回参照してしまい応答品質を下げます。HumanMemoryPolicy は、思い出された記憶を残しやすくし、使われない記憶を徐々に見えにくくする仕組みです。
保持スコアの計算式(簡易版)
認知科学的にはもっと究めることができるようですが、今回デモ用途として簡易的に実装しています。保持スコアは「この記憶をまだエージェントに見せる価値があるか」を 0.0〜1.0 で表す数値です。スコアが下がると検索フィルターで除外され、閾値を下回ると archived 状態に遷移します。
式は 3 つの独立した要素の組み合わせです。
$$
\text{retention_score} = \text{decay} \times \text{importance_anchor} + \text{access_rehearsal}
$$
前半の積が「時間経過と重要度で決まる基本的な残り具合」を表し、後半の加算項が「実際に使われた実績による底上げ」を表します。
各要素の定義:
$$
\text{decay} = 0.5^{\frac{\text{age_days}}{\text{half_life_days}}}
$$
$$
\text{importance_anchor} = 0.35 + 0.65 \times \text{importance}
$$
$$
\text{access_rehearsal} = \min(\text{access_count} \times 0.04,\ 0.35)
$$
式の意味としては、
- 記憶が作られてからの経過日数で基本的な「新鮮さ」が決まります(decay)
- その新鮮さに重要度の重みを掛けて、「時間と重要度を踏まえた基本値」が出ます(decay × anchor)
- 最後に、実際に使われた実績分を足して最終スコアにします(+ rehearsal)
想起による強化
before_run() の検索で使われた記憶は touch_results() により以下が更新されます。
record.last_accessed_at = now
record.access_count += 1
record.retention_score = policy.retention_score(record)
record.status = policy.status_for(record)
よく使う記憶は残りやすく、使わない記憶は自然にフェードアウトする。Ebbinghaus の忘却曲線を簡略化したモデルです。この辺、カスタムしがいがあって面白いですね。
ContextProvider への接続
Agent Framework の ContextProvider を継承し、before_run() と after_run() をオーバーライドします。
from agent_framework import Agent
from agent_framework._sessions import ContextProvider
class AzureAISearchMemoryContextProvider(ContextProvider):
async def before_run(self, *, agent, session, context, state):
# 1. ユーザー入力からクエリ構築
# 2. Embedding 生成
# 3. Azure AI Search で関連記憶を検索
# 4. touch_results() で想起強化
# 5. context.extend_messages() で記憶をコンテキストに注入
async def after_run(self, *, agent, session, context, state):
# 1. 入力 + 応答メッセージを収集
# 2. MemoryExtractor で記憶候補を抽出
# 3. MemoryConsolidator で ADD/UPDATE/DELETE/NONE 判断
# 4. AzureAISearchMemoryStore で反映
# 5. (オプション) 忘却 dry-run を state に保存
エージェントへの接続は context_providers に渡すだけです。
agent = Agent(
client=create_azure_openai_chat_client(),
name="TravelAgent",
instructions="関連する記憶がある場合だけ利用して回答してください。",
context_providers=[custom_memory_provider],
)
result = await agent.run("大阪旅行の計画を立てて", session=session)
コンテキスト注入フォーマット
検索結果はシステムメッセージとしてエージェントに渡されます。
## Relevant memories
Use these memories only when they help answer the user.
1. [preference; importance=0.85; retention=0.91] 大阪旅行では駅近ホテルを最優先する
2. [preference; importance=0.70; retention=0.88] ベジタリアン対応の店を優先して探す
Azure AI Search インデックス設計
フィールド設計
| フィールド | 型 | 属性 | 用途 |
|---|---|---|---|
id |
String | Key | UUID |
user_id / agent_id
|
String | Filterable, Sortable | データ分離 |
memory / summary
|
String | Searchable | テキスト検索対象 |
category |
String | Searchable, Filterable, Facetable | カテゴリ別絞り込み |
importance |
Double | Filterable, Sortable, Facetable | スコアリングプロファイルで利用 |
retention_score |
Double | Filterable, Sortable | 保持スコアフィルター |
access_count |
Int32 | Filterable, Sortable | 想起回数 |
created_at / updated_at
|
DateTimeOffset | Filterable, Sortable | Freshness スコアリング |
status |
String | Filterable, Facetable | 検索可視性制御 |
embedding |
Collection(Edm.Single) | Vector | HNSW ベクトル検索 |
Semantic Configuration
content_fields: memory, summary
keyword_fields: category
データ分離
検索フィルターには常に以下が含まれます。記憶はユーザーごとに混ぜずに管理する必要がありますのでこのようなフィルタが必要になります。
user_id eq '<user_id>' and agent_id eq '<agent_id>'
これはアプリケーションレベルの分離です。マルチテナント本番環境では、テナント ID の追加、インデックス分離、RBAC、監査ログの検討が必要です。
RankingProfile.IMPORTANT vs RankingProfile.RECENT
同じクエリでも、検索モードによって上位結果が変わります😎
# 古いが重要度が高く、何度も参照された基本方針
record_a = MemoryRecord(
memory="大阪旅行のホテルの基本方針はたこ焼き屋が徒歩圏内であること。",
importance=1.0, access_count=20, days_old=75,
)
# 新しいが重要度の低い一時候補
record_b = MemoryRecord(
memory="大阪旅行のホテルの基本方針はたこ焼き屋が徒歩圏内であること。",
importance=0.05, access_count=0, days_old=0,
)
| モード | 上位に来る記憶 | 理由 |
|---|---|---|
IMPORTANT |
record_a |
importance=1.0、access_count=20 のブーストが大きい |
RECENT |
record_b |
updated_at が直近で Freshness スコアが最大 |
使い分け:
- 長期的な好みや強い制約を優先したい →
ranking_profile=RankingProfile.IMPORTANT - 直近の予定や最近追加された条件を優先したい →
ranking_profile=RankingProfile.RECENT - 通常の候補取得方式 →
mode=SearchMode.HYBRID
忘却: dry-run とメンテナンス
apply_forgetting() は retention_score asc で弱い記憶を取得し、新しいスコアと状態を計算します。
# 候補の確認だけ(既定)
preview = store.apply_forgetting(policy, dry_run=True, limit=25)
# 実際にインデックスを更新(定期メンテナンスジョブで実行)
store.apply_forgetting(policy, dry_run=False, limit=100)
物理削除はしません。deleted 状態のドキュメントを一定期間後にパージする場合は、別途バッチ処理として設計します。
汎用記憶との比較
| 観点 | 汎用記憶 | この実装 |
|---|---|---|
| スキーマ制御 | ライブラリが管理 | 自分で設計(フィルター・ソート・プロファイル自由) |
| 検索順位の調整 | ライブラリ内部のロジック | Azure AI Search のスコアリングプロファイル |
| 記憶の状態管理 | 限定的 |
active / archived / deleted を明示的に遷移 |
| 忘却 | 手動削除 | 時間減衰 + 想起強化 + アーカイブ |
| 抽出・統合の透明性 | ブラックボックス |
MemoryExtractor / MemoryConsolidator として分離 |
| フォールバック | なし | LLM 未設定時のルールベース処理 |
| セマンティックランカー | 非対応 |
SearchMode.SEMANTIC で利用可能 |
現行の制約
以下も今後実装してもいいかも。
| 制約 | 改善案 |
|---|---|
| LLM 判断は非決定的 | JSON Schema バリデーション、Few-Shot、評価データセットの追加 |
| フォールバック Embedding は簡易的 | 本番では Azure OpenAI Embedding を必須にする |
| グラフ構造はない | エンティティ / Fact インデックス追加、または Graph DB 連携 |
| 評価ハーネスがない | LoCoMo / LongMemEval 風の小型 Eval ノートブックを追加 |
まとめ
今回はこんな感じで記憶を Azure AI Search で実装してみました。
- 記憶抽出 — どの発話を記憶として残すか
- 統合判断 — 追加、更新、削除、スキップをどう分けるか
- 検索と順位付け — Azure AI Search のスコアリングプロファイルに記憶の優先度を伝える
- 忘却と保持 — 使われた記憶を強め、古い記憶を弱める
Azure AI Search の強みは、キーワード、ベクトル、ハイブリッド、セマンティック検索等を用途に応じて組み合わせられる点にあります。正確な語句一致を重視したい場面ではキーワード検索、言い換えや曖昧な表現を拾いたい場面ではベクトル検索、両方を活かしたい場面ではハイブリッド検索が使えます。また、これらは Agentic に組み合わせることもできます。
さらにスコアリングプロファイルを使うことで、本文の一致度だけでなく、重要度、保持スコア、更新日時、アクセス回数のような非ベクトルフィールドも順位付けに反映できます。これはエージェントの記憶検索と相性がよく、「意味的に近い」だけでなく、「今このユーザーに渡す価値が高い」記憶を選びやすくなります。
そしてスコアリングプロファイルはキーワード検索だけでなく、ベクトルクエリやハイブリッドクエリでも利用できます。ただ、ブースト対象は非ベクトルフィールドですので、ベクトル検索で意味的に近い候補を見つけ、スコアリングプロファイルで重要度や鮮度を反映して並べ替える、という役割分担ができます。
この柔軟性により、Azure AI Search は単なる類似検索ではなく、記憶ストアとしても使えるマルチロールな検索エンジンになります。エージェントのメモリーレイヤーでは、記憶本文、重要度、保持スコア、アクセス回数、更新日時をトップレベルフィールドとして持たせることで、説明可能で制御しやすい記憶検索を実現できます。
GitHub
↑ はMicrosoft Agent Framework Workshop シリーズとして作成しています。今後以下に統合予定です。
