はじめに
AIエージェントに長期記憶を持たせると、検索で過去のメモを呼び出せるようになります。しかし、検索で似ているメモが見つかったことと、そのメモを今の判断に使ってよいことは別です。
古い方針、却下済みの案、曖昧に似ているだけのメモ、未承認の作業メモをそのままプロンプトへ入れると、AIは自信を持って間違えます。
この記事では、記憶検索の後に置く recall gate の設計をまとめます。想定しているのは、MCPサーバーや社内AIエージェントが、保存済みのメモを検索してプロンプトへ入れる構成です。TypeScriptで実装できる最小スキーマ、落とした理由を残すログ、dry-runで確認するテスト観点まで含めます。
大事なのは、検索を強くすることではなく、使ってはいけない記憶を混ぜないことです。AIパートナーの記憶は増えるほど便利になりますが、同時に「昔は正しかったが今は違う」「別プロジェクトでは正しいが今のユーザーには違う」という事故も増えます。
recall gateとは
recall gateは、検索結果をLLMへ渡す前の検査層です。
type MemoryHit = {
id: string;
title: string;
body: string;
score: number;
updatedAt: string;
status: "approved" | "draft" | "rejected";
visibility: "private" | "shared";
source: "discord" | "docs" | "status" | "wiki";
tags: string[];
};
ベクトル検索は MemoryHit[] を返します。recall gateは、その中から使ってよい記憶だけを選びます。
検索エンジンやRAGの実装では、つい類似度スコアだけを見がちです。しかし、運用ではスコアの前に見るべきものがあります。userId、agentId、memorySpaceId、承認状態、更新日、公開範囲です。これらを見ずに「似ているから入れる」と、別ユーザーの事情や古い決定が現在の回答へ混ざります。
ゲート1: scopeで先に絞る
最初に見るのは類似度ではなくscopeです。誰の、どのAIの、どの用途の記憶かを絞ってから検索します。
type MemoryScope = {
userId: string;
agentId: string;
memorySpaceId: string;
route_id?: string;
};
scope外の記憶は、スコアが高くても使いません。似ている別人の記憶は、もっとも危険な検索結果です。
実装では、ベクトル検索の後にscopeを見るのではなく、可能なら検索条件にscopeを入れます。DB側で絞れるなら、取り出す前に絞る方が安全です。
type SearchQuery = {
text: string;
scope: MemoryScope;
limit: number;
};
async function searchScopedMemory(query: SearchQuery): Promise<MemoryHit[]> {
return vectorStore.search(query.text, {
limit: query.limit,
filter: {
userId: query.scope.userId,
agentId: query.scope.agentId,
memorySpaceId: query.scope.memorySpaceId,
route_id: query.scope.route_id,
},
});
}
route_id を持たせると、投稿アカウントや媒体の混同も止めやすくなります。たとえば公式アカウント向けの投稿ルールと、個人アカウント向けの口調メモが似ていても、routeが違えば候補から外せます。
ゲート2: score threshold
次に、類似度スコアの最低ラインを置きます。
function enoughScore(hit: MemoryHit): boolean {
return hit.score >= 0.78;
}
しきい値は固定で終わりではありません。失敗ログを見ながら調整します。特に短いクエリでは偶然の一致が増えるため、しきい値を高めに置きます。
実務では、同じ0.78でも検索器や埋め込みモデルによって意味が変わります。そのため、設定値はコード直書きではなく、環境やmemory spaceごとに持たせる方が運用しやすいです。
type RecallGateConfig = {
minScore: number;
maxAgeDays: number;
allowDraft: boolean;
maxHits: number;
};
const defaultRecallGateConfig: RecallGateConfig = {
minScore: 0.78,
maxAgeDays: 90,
allowDraft: false,
maxHits: 8,
};
AIエージェントの回答がズレた時に、minScore を変えるだけで改善できることがあります。一方で、スコアを上げすぎると必要な記憶を拾えません。だからこそ、後述するログとテストで「落とした理由」と「使った理由」を残します。
ゲート3: freshness
記憶には鮮度があります。昔は正しかったが、今は違う方針になっていることがあります。
function isFresh(hit: MemoryHit, now = new Date()): boolean {
const ageMs = now.getTime() - new Date(hit.updatedAt).getTime();
const days = ageMs / 1000 / 60 / 60 / 24;
return days <= 90;
}
もちろん、すべての記憶を90日で捨てるわけではありません。長期方針は別タグで保持します。ここで大事なのは、古い運用メモを無条件に現在の正解として扱わないことです。
長期方針と短期運用メモは、同じ鮮度ルールで扱わない方が安全です。たとえば「ブランドの核」や「法務上の禁止事項」は古くても有効なことがあります。一方で、「今週の投稿先」「今日のroute_id」「一時的な回避策」はすぐに古くなります。
function isFresh(hit: MemoryHit, config: RecallGateConfig, now = new Date()): boolean {
if (hit.tags.includes("long_term_policy")) return true;
if (hit.tags.includes("legal_stopline")) return true;
const ageMs = now.getTime() - new Date(hit.updatedAt).getTime();
const days = ageMs / 1000 / 60 / 60 / 24;
return days <= config.maxAgeDays;
}
freshnessは、古い記憶を削除する仕組みではありません。削除ではなく、実行判断に混ぜないためのゲートです。後から調査するためには古い記憶も残しておきたいので、検索対象と実行判断対象を分けます。
ゲート4: approval
作業メモと採用済み方針を分けます。
function isApproved(hit: MemoryHit, config: RecallGateConfig): boolean {
if (config.allowDraft) return hit.status !== "rejected";
return hit.status === "approved";
}
AIエージェントは、途中案や却下案も記録します。それ自体は有用です。ただし、実行判断に使う記憶は承認済みに限定します。
特に危ないのは、Discordの会話や作業ログにある「途中の案」です。会話の途中では一度良さそうに見えた案が、後で却下されることがあります。これをmemoryに残すこと自体は問題ありませんが、draft と approved を同じ重みで扱うと、次のセッションで古い案が復活します。
承認状態は、手作業のラベルでもよいですが、正本ドキュメントへ昇格した時に自動で approved にする設計が便利です。逆に、superseded、旧方針、使用禁止 が入った文書は、検索に出ても実行判断には使わないようにします。
function isNotSuperseded(hit: MemoryHit): boolean {
const text = `${hit.title}\n${hit.body}`.toLowerCase();
return !["superseded", "旧方針", "使用禁止"].some((term) =>
text.includes(term.toLowerCase()),
);
}
recall gateをまとめる
最小実装は次のようになります。
type RejectedRecall = {
id: string;
title: string;
reason:
| "low_score"
| "stale"
| "not_approved"
| "superseded"
| "scope_mismatch";
};
type RecallGateResult = {
accepted: MemoryHit[];
rejected: RejectedRecall[];
};
function recallGate(
hits: MemoryHit[],
config: RecallGateConfig = defaultRecallGateConfig,
): RecallGateResult {
const accepted: MemoryHit[] = [];
const rejected: RejectedRecall[] = [];
for (const hit of hits) {
if (!enoughScore(hit, config)) {
rejected.push({ id: hit.id, title: hit.title, reason: "low_score" });
continue;
}
if (!isFresh(hit, config)) {
rejected.push({ id: hit.id, title: hit.title, reason: "stale" });
continue;
}
if (!isApproved(hit, config)) {
rejected.push({ id: hit.id, title: hit.title, reason: "not_approved" });
continue;
}
if (!isNotSuperseded(hit)) {
rejected.push({ id: hit.id, title: hit.title, reason: "superseded" });
continue;
}
accepted.push(hit);
if (accepted.length >= config.maxHits) break;
}
return { accepted, rejected };
}
filter() をつなげるだけでも動きますが、運用では落とした理由が残らないと困ります。AIが間違えた時に「検索できなかった」のか、「検索できたがゲートで落とした」のかを分けられないからです。
ログとdry-runを先に作る
recall gateは、最初から本番回答に組み込むより、dry-runで挙動を確認できるようにしておくと安全です。
type RecallAuditLog = {
at: string;
query: string;
scope: MemoryScope;
acceptedIds: string[];
rejected: RejectedRecall[];
dryRun: boolean;
};
function buildRecallAuditLog(input: {
query: string;
scope: MemoryScope;
result: RecallGateResult;
dryRun: boolean;
}): RecallAuditLog {
return {
at: new Date().toISOString(),
query: input.query,
scope: input.scope,
acceptedIds: input.result.accepted.map((hit) => hit.id),
rejected: input.result.rejected,
dryRun: input.dryRun,
};
}
dry-runでは、LLMへ実際に渡さず、どの記憶が採用されるかだけを出します。新しいmemory sourceを追加した時、しきい値を変えた時、正本化ルールを変更した時に、いきなり回答品質へ影響を出さず検証できます。
async function previewRecall(query: SearchQuery): Promise<RecallAuditLog> {
const hits = await searchScopedMemory(query);
const result = recallGate(hits);
return buildRecallAuditLog({
query: query.text,
scope: query.scope,
result,
dryRun: true,
});
}
このログは、通常のアプリケーションログとは分けておくと扱いやすいです。ユーザーの個人情報を含む本文をそのまま出さず、id、title、reason、scope だけにする方が安全です。
テスト観点
recall gateは、単体テストを書きやすい層です。検索器やLLMをモックにして、入力された MemoryHit[] に対して、採用/却下の結果だけを検証します。
import { describe, expect, it } from "vitest";
describe("recallGate", () => {
it("rejects stale draft memory even when score is high", () => {
const hits: MemoryHit[] = [
{
id: "old-draft",
title: "旧アカウント運用案",
body: "旧方針として残す。使用禁止。",
score: 0.94,
updatedAt: "2025-01-01T00:00:00.000Z",
status: "draft",
visibility: "private",
source: "docs",
tags: [],
},
];
const result = recallGate(hits, {
minScore: 0.78,
maxAgeDays: 90,
allowDraft: false,
maxHits: 8,
});
expect(result.accepted).toHaveLength(0);
expect(result.rejected[0]?.reason).toBe("stale");
});
});
最低限のテストは次の5つです。
- scope外の記憶が混ざらない
- score不足の記憶を落とす
- 古い短期メモを落とす
-
draftとrejectedを実行判断に使わない -
supersededや使用禁止を含む正本を落とす
さらに、統合テストではdry-runのログを確認します。たとえば「旧方針が上位に返ってきたが、ゲートで落ちている」ことをログで確認できれば、検索器自体を過剰に調整しなくて済みます。
MCPで使う時の注意
MCPサーバーにrecall gateを入れる場合、ツールの戻り値にも注意します。search_memory が生の検索結果を返すのか、recall gate後の結果を返すのかを分けておくと、デバッグしやすくなります。
type SearchMemoryToolResult = {
accepted: Array<Pick<MemoryHit, "id" | "title" | "body" | "updatedAt">>;
audit: {
rejectedCount: number;
reasons: Record<string, number>;
};
};
本番のAIエージェントへ渡すのは accepted だけにします。一方で、運用者向けのログには却下理由を残します。ここを分けないと、LLMが「却下された記憶」まで読んでしまい、ゲートの意味が薄れます。
また、ツール名やroute_idを明示しておくと、投稿先アカウントの混同を防げます。Agent Memoriesのように、公式アカウント、個人発信、MCP研究室など複数の導線を持つ場合は、記憶検索にもrouteの境界が必要です。
まとめ
記憶検索は、似ているメモを返すだけでは足りません。LLMへ渡す前に、使ってよい記憶かを判定する必要があります。
最小のrecall gateは次の4つです。
- scopeで先に絞る
- 類似度しきい値を置く
- 古い運用メモを無条件に使わない
- 未承認メモを実行判断に使わない
さらに実運用では、superseded の検出、dry-run、採用/却下ログ、Vitestなどのテスト観点を入れておくと、あとから原因を追いやすくなります。
AIパートナーが長く育つほど、記憶は増えます。増えた記憶をただ詰め込むのではなく、今の判断に必要なものだけを選ぶ層が必要です。recall gateは、記憶を賢く使うためのフィルターであり、古い約束や別人の文脈を混ぜないための安全装置です。

