音声AIに「決済の戻し方を教えて」と尋ねたとき、利用者が求めているのは本番手順なのか、検証環境の説明なのか。それを画面上の検索条件で確認できないまま、RAGがもっともらしい文書を読み上げると、デモでは便利でも運用では判断に困ります。
特に悩ましいのが、Difyのナレッジへ付けたメタデータをLLMに動的選択させる構成です。自然言語から検索条件を推定できる一方、LLMは実在しない値も生成できます。さらに、検索結果が0件だったときに全件検索へ戻すと、音声応答は途切れなくても検索範囲の保証が消えます。
この記事では、LLMをメタデータの提案者に限定し、アプリケーションが検索範囲を確定するゲートをTypeScriptで作ります。
結論:メタデータ抽出と検索許可を別の処理にする
実装の要点は次の4つです。
- LLMは発話から検索候補をJSONで抽出する
- 候補のキーと値を、管理済みカタログで検証する
- 不明・無権限・曖昧なら、検索せず短い追加質問を返す
- 0件でもフィルタを外さず、緩和する条件を利用者に選んでもらう
利用者の音声
↓
音声認識
↓ transcript
メタデータ候補の抽出(Dify / LLM)
↓ untrusted JSON
検索ポリシーゲート(今回実装)
├─ 許可 → DifyのRAG検索
├─ 曖昧 → 音声で追加質問
└─ 拒否 → 権限外として終了
↓
回答生成 → 音声合成 → RTC経由で再生
AIが改善できるのは、発話から製品名や文書種別の候補を拾う部分です。AIだけでは、値が現行の台帳に存在するか、利用者に本番文書の閲覧権限があるか、0件時に条件を外してよいかは決められません。
この構成で人が決めるのは「どのメタデータを正式な検索軸とするか」「どの条件で確認を挟むか」「誰がどの範囲を検索できるか」です。
前提:RTC、Dify、検索ポリシーの責任を分ける
Tencent Conversational AIは、ユーザーとLLMをリアルタイム音声で接続するシナリオを扱います。公式のLLM設定ガイドでは、OpenAI互換モデルやDifyなどのエージェントプラットフォームとの接続、リクエスト識別子を使ったルーティングや観測について説明されています。
ただし、論理的な責任境界は分けておきます。
| 層 | 主な責任 |
|---|---|
| RTC・メディア | 利用者とのリアルタイム音声経路 |
| 音声認識 | 音声をテキストへ変換 |
| Dify / LLM | 検索候補の抽出、RAG、回答文の生成 |
| ポリシーゲート | メタデータ検証、権限確認、追加質問 |
| 音声合成 | 確定した回答文を音声へ変換 |
| アプリケーション | セッション、ターン、同意、停止、監査 |
Difyとの具体的な接続設定は利用中のバージョンに合わせます。今回のコードはSDK固有APIではなく、Difyへ渡す直前の検索条件を確定するアプリケーション層です。
先に決めるメタデータ契約
メタデータを増やすほど検索を細かくできますが、入力漏れや表記揺れも増えます。まずは、音声で区別する意味がある軸に絞ります。
product:
- payments
- accounts
doc_type:
- overview
- runbook
- design
environment:
- development
- staging
- production
lifecycle:
- current
- deprecated
audience:
- support
- developer
- sre
各軸の意図は次のとおりです。
-
product: どの機能・システムについての文書か -
doc_type: 概要、設計、操作手順のどれか -
environment: 開発、検証、本番のどれか -
lifecycle: 現行か廃止済みか -
audience: 想定読者。回答の詳しさを調整する補助情報
ここで重要なのは、メタデータフィルタをアクセス制御にしないことです。フィルタは検索対象を絞る仕組みであり、利用者の認可を保証する境界ではありません。権限判定はDifyへ検索を依頼する前に、アプリケーション側で実施します。
また、文書本文とメタデータの更新責任者を決めます。本文だけ更新され、lifecycle: currentが古いままだと、RAGは期限切れの情報を正しく検索してしまいます。
手順1:再現用プロジェクトを作る
Node.jsが利用できる環境で、次のプロジェクトを用意します。
mkdir voice-rag-policy
cd voice-rag-policy
npm init -y
npm install --save-dev typescript tsx @types/node
mkdir src
package.jsonへ実行コマンドを追加します。
{
"scripts": {
"check": "tsx src/index.ts"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
}
}
手順2:LLMの出力形式を固定する
Dify側のLLMには、回答文ではなく検索計画をJSONで生成させます。
{
"query": "決済サービスのロールバック手順",
"filters": [
{ "key": "product", "value": "payments" },
{ "key": "doc_type", "value": "runbook" },
{ "key": "environment", "value": "production" },
{ "key": "lifecycle", "value": "current" }
]
}
抽出用プロンプトでは、少なくとも次を指示します。
利用者の発話からRAG検索計画をJSONで作成する。
検索に必要な明示情報だけをfiltersへ含める。
値を推測しない。不明な軸は省略する。
説明文やMarkdownを出力しない。
利用可能なキー名は別途提示された一覧に限定する。
ただし、プロンプトによる制約は入力検証の代わりになりません。モデル更新、プロンプトインジェクション、音声認識の誤りによって、一覧外の値が返る前提で実装します。
コード:未知の条件、権限外、0件フォールバックを止める
src/index.tsを作成します。
import assert from "node:assert/strict";
const catalog = {
product: ["payments", "accounts"],
doc_type: ["overview", "runbook", "design"],
environment: ["development", "staging", "production"],
lifecycle: ["current", "deprecated"],
audience: ["support", "developer", "sre"],
} as const;
type MetadataKey = keyof typeof catalog;
type Filter = {
key: MetadataKey;
value: string;
};
type Principal = {
userId: string;
allowedEnvironments: string[];
confirmedProduction: boolean;
};
type SearchPlan = {
query: string;
filters: Filter[];
};
type GateDecision =
| { kind: "search"; plan: SearchPlan }
| { kind: "clarify"; message: string }
| { kind: "denied"; message: string };
type Document = {
id: string;
title: string;
metadata: Record<MetadataKey, string>;
};
const documents: Document[] = [
{
id: "doc-001",
title: "決済サービス 本番ロールバック手順",
metadata: {
product: "payments",
doc_type: "runbook",
environment: "production",
lifecycle: "current",
audience: "sre",
},
},
{
id: "doc-002",
title: "決済サービス 検証環境の概要",
metadata: {
product: "payments",
doc_type: "overview",
environment: "staging",
lifecycle: "current",
audience: "developer",
},
},
];
function isMetadataKey(value: string): value is MetadataKey {
return Object.prototype.hasOwnProperty.call(catalog, value);
}
function validatePlan(raw: unknown, principal: Principal): GateDecision {
if (typeof raw !== "object" || raw === null) {
return { kind: "clarify", message: "質問を検索条件に変換できませんでした。もう一度お願いします。" };
}
const candidate = raw as Record<string, unknown>;
const query = typeof candidate.query === "string"
? candidate.query.trim()
: "";
if (query.length === 0 || query.length > 500) {
return { kind: "clarify", message: "検索したい内容を短く言い直してください。" };
}
if (!Array.isArray(candidate.filters) || candidate.filters.length > 8) {
return { kind: "clarify", message: "検索範囲を特定できませんでした。" };
}
const filters: Filter[] = [];
for (const item of candidate.filters) {
if (typeof item !== "object" || item === null) {
return { kind: "clarify", message: "検索条件の形式が不正です。" };
}
const filter = item as Record<string, unknown>;
if (typeof filter.key !== "string" || typeof filter.value !== "string") {
return { kind: "clarify", message: "検索条件を確認できませんでした。" };
}
if (!isMetadataKey(filter.key)) {
return {
kind: "clarify",
message: `「${filter.key}」は利用できる検索軸ではありません。`,
};
}
const allowedValues = catalog[filter.key] as readonly string[];
if (!allowedValues.includes(filter.value)) {
return {
kind: "clarify",
message: `「${filter.value}」という検索範囲は登録されていません。`,
};
}
filters.push({ key: filter.key, value: filter.value });
}
const environment = filters.find(
(filter) => filter.key === "environment",
)?.value;
if (environment && !principal.allowedEnvironments.includes(environment)) {
return {
kind: "denied",
message: `${environment}環境の文書は検索できません。`,
};
}
const asksForProcedure = /手順|戻し|ロールバック|操作|復旧/.test(query);
if (asksForProcedure && !environment) {
return {
kind: "clarify",
message: "開発、検証、本番のどの環境について知りたいですか?",
};
}
if (environment === "production" && !principal.confirmedProduction) {
return {
kind: "clarify",
message: "本番環境の操作手順を検索します。続けてもよいですか?",
};
}
return {
kind: "search",
plan: { query, filters },
};
}
function retrieve(plan: SearchPlan): Document[] {
return documents.filter((document) =>
plan.filters.every(
(filter) => document.metadata[filter.key] === filter.value,
),
);
}
function execute(
raw: unknown,
principal: Principal,
): GateDecision | { kind: "found"; documents: Document[] } {
const decision = validatePlan(raw, principal);
if (decision.kind !== "search") return decision;
const hits = retrieve(decision.plan);
// 0件でもフィルタを外して再検索しない。
if (hits.length === 0) {
return {
kind: "clarify",
message: "指定した範囲では文書が見つかりませんでした。環境か文書種別を変更しますか?",
};
}
return { kind: "found", documents: hits };
}
const sre: Principal = {
userId: "user-001",
allowedEnvironments: ["staging", "production"],
confirmedProduction: false,
};
const productionPlan = {
query: "決済サービスのロールバック手順",
filters: [
{ key: "product", value: "payments" },
{ key: "doc_type", value: "runbook" },
{ key: "environment", value: "production" },
{ key: "lifecycle", value: "current" },
],
};
const first = execute(productionPlan, sre);
assert.equal(first.kind, "clarify");
const confirmed = execute(productionPlan, {
...sre,
confirmedProduction: true,
});
assert.equal(confirmed.kind, "found");
const inventedMetadata = execute(
{
query: "決済の手順",
filters: [{ key: "region", value: "moon-1" }],
},
sre,
);
assert.equal(inventedMetadata.kind, "clarify");
const zeroHit = execute(
{
query: "アカウントの検証環境設計",
filters: [
{ key: "product", value: "accounts" },
{ key: "doc_type", value: "design" },
{ key: "environment", value: "staging" },
],
},
sre,
);
assert.equal(zeroHit.kind, "clarify");
console.log("all checks passed");
実行します。
npm run check
次の表示になれば、最低限の境界条件を再現できています。
all checks passed
このコードのretrieveは再現用のインメモリ実装です。実運用では、検証済みのplan.filtersだけをDify側のRAG検索へ渡すアダプターに置き換えます。
手順3:音声ターンと検索要求を対応付ける
音声AIでは、検索結果が返る前に利用者が言い直すことがあります。古い検索結果をそのまま読み上げないよう、各処理へアプリケーション管理の識別子を付けます。
type VoiceTurn = {
sessionId: string;
turnId: string;
requestId: string;
transcript: string;
status: "active" | "superseded" | "completed";
};
識別子には次の役割を分けて持たせます。
-
sessionId: 一連の音声会話 -
turnId: 利用者の一回の発話 -
requestId: DifyやLLMへの個別リクエストとの対応
利用者が「いや、検証環境の話」と言い直した場合は、前のturnIdをsupersededにします。前のRAG結果が遅れて返っても、現在のターンと一致しなければ音声合成へ渡しません。
これはメタデータ検証とは別の問題ですが、音声UIでは両方がそろって初めて検索範囲を守れます。正しい条件で検索しても、古いターンの回答を読み上げれば利用者には誤答に聞こえるためです。
確認方法:回答の自然さではなく検索境界を壊す
本番前には、少なくとも次のケースを音声入力から確認します。
1. 存在しないメタデータ値
発話またはLLM出力にenvironment: sandboxを混ぜます。
期待結果は、似た値への自動補正ではなく追加質問です。stagingへ勝手に置き換えてはいけません。
2. 操作手順なのに環境が不明
「決済の戻し方を教えて」と発話します。
期待結果は検索開始ではなく、開発・検証・本番のどれかを尋ねることです。
3. 本番手順を確認なしで要求
本番文書へアクセスできる利用者でも、一度は検索範囲を読み上げて確認します。
この確認は認可の代わりではありません。権限確認を通過した後に、利用者の意図を確定するための操作です。
4. フィルタ後に0件
存在する値だけで、該当文書がない組み合わせを作ります。
期待結果は「全件から見つけました」ではなく、「環境か文書種別を変更しますか?」です。どの条件を緩めるかは利用者に選ばせます。
5. 追加質問中の言い直し
AIが「どの環境ですか?」と尋ねている途中で「検証環境」と発話します。
新しいターンを有効にし、古い検索や音声再生が後から復活しないことを確認します。
6. 権限外の本番指定
allowedEnvironmentsにproductionがない利用者で、本番手順を要求します。
期待結果は追加確認ではなく拒否です。確認への「はい」で権限を昇格できないことを確かめます。
判断基準:動的フィルタを使うべきか
すべてのRAG検索でLLMによる動的フィルタが必要なわけではありません。
| 状況 | 選びやすい方式 |
|---|---|
| 対象製品が画面遷移で確定済み | アプリ側の固定フィルタ |
| 利用者が複数製品を自然言語で横断検索する | LLM抽出+許可リスト検証 |
| 本番操作や障害対応の文書 | 明示確認+権限判定+固定フィルタ |
| メタデータが整備されていない | 先に台帳を整備。動的抽出を入れない |
| 0件が頻発する | 条件の自動緩和ではなく、メタデータ品質を調査 |
固定フィルタは柔軟性が低い代わりに予測しやすく、動的フィルタは会話を自然にできる代わりに検証コストが増えます。検索精度だけでなく、誤った範囲を音声で断定したときの影響から選びます。
注意点
メタデータは本文と同じ頻度でレビューする
文書の更新時にメタデータもレビュー対象へ含めます。特にlifecycleとenvironmentは、誤りが回答内容へ直接影響します。
0件率だけでは品質を判断しない
0件が減っても、フィルタを広くしすぎて無関係な文書が混ざっていれば改善ではありません。次を分けて記録します。
- 不明なキーまたは値で拒否した件数
- 追加質問へ移行した件数
- 認可で拒否した件数
- 有効な条件だが0件だった件数
- 利用者が確認後に条件を変更した件数
各記録には本文や音声そのものを無条件に保存せず、プライバシー方針に沿って必要最小限の識別子と判定理由を残します。
発話からセンシティブ属性を推測しない
役職、健康状態、年齢などをLLMに推定させ、検索条件や応答方針へ使う設計は避けます。必要な権限や役割は認証済みのアプリケーション状態から取得します。
音声確認を長くしすぎない
毎回すべての条件を読み上げると会話が重くなります。通常の概要検索では確認を省略し、本番操作、削除、復旧など影響が大きい条件だけを短く確認する設計が現実的です。
Difyの出力を信頼済みデータとして扱わない
JSON形式で返ってきても、その内容は外部入力です。スキーマ検証、値の許可リスト、認可、件数上限を通してから検索へ使用します。
まとめ
DifyのRAGへメタデータを付けるだけでは、リアルタイム音声AIの検索範囲は保証できません。必要なのは、LLMが提案した条件をそのまま検索へ流さないことです。
- メタデータのキーと値を台帳化する
- LLM出力を未信頼のJSONとして検証する
- 認可とメタデータフィルタを分離する
- 曖昧な操作手順は環境を聞き返す
- 0件時に無条件検索へ戻らない
- ターン識別子で言い直し前の結果を破棄する
AIの役割を「検索条件を決定する存在」から「候補を作る存在」へ置き直すと、自然な音声会話と人間の判断を両立しやすくなります。
関係性の開示: 筆者はTencent RTCのコンテンツ制作に関係しています。本稿の実装上の参照には、Tencent RTCの公式ドキュメントを使用しました。