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?

AI記憶検索にrecall gateを入れる:古い記憶・曖昧な一致・未承認メモをそのまま使わない

0
Posted at

AI記憶検索のrecall gate - 古い記憶と曖昧な一致をそのまま使わない

記憶検索recall gate - scope filter、score threshold、freshness、approval

はじめに

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の実装では、つい類似度スコアだけを見がちです。しかし、運用ではスコアの前に見るべきものがあります。userIdagentIdmemorySpaceId、承認状態、更新日、公開範囲です。これらを見ずに「似ているから入れる」と、別ユーザーの事情や古い決定が現在の回答へ混ざります。

ゲート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に残すこと自体は問題ありませんが、draftapproved を同じ重みで扱うと、次のセッションで古い案が復活します。

承認状態は、手作業のラベルでもよいですが、正本ドキュメントへ昇格した時に自動で 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,
  });
}

このログは、通常のアプリケーションログとは分けておくと扱いやすいです。ユーザーの個人情報を含む本文をそのまま出さず、idtitlereasonscope だけにする方が安全です。

テスト観点

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つです。

  1. scope外の記憶が混ざらない
  2. score不足の記憶を落とす
  3. 古い短期メモを落とす
  4. draftrejected を実行判断に使わない
  5. 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つです。

  1. scopeで先に絞る
  2. 類似度しきい値を置く
  3. 古い運用メモを無条件に使わない
  4. 未承認メモを実行判断に使わない

さらに実運用では、superseded の検出、dry-run、採用/却下ログ、Vitestなどのテスト観点を入れておくと、あとから原因を追いやすくなります。

AIパートナーが長く育つほど、記憶は増えます。増えた記憶をただ詰め込むのではなく、今の判断に必要なものだけを選ぶ層が必要です。recall gateは、記憶を賢く使うためのフィルターであり、古い約束や別人の文脈を混ぜないための安全装置です。

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?