同じ質問をAIに投げても、あるサービスは公式ドキュメントを中心に、別のサービスは個人の検証記事を中心に回答することがあります。
ここで開発者が抱える本当の悩みは、どちらのAIが賢いかではありません。チャットへAI回答を組み込んだときに、利用者が「これは仕様なのか、経験談なのか」「いつ取得した情報なのか」を判断できないことです。
回答文だけ自然に生成できても、運用上は十分ではありません。本稿では、Tencent RTCのSocial Messagingを利用するチャットを想定し、AI回答へ次の情報を添付する仕組みを作ります。
- 公式、コミュニティ、社内情報の区別
- 参照URLと取得時刻
- 回答に使った根拠ID
- 情報が競合した場合の自動送信停止
- AIから人への見える引き継ぎ
AIは根拠を読みやすくまとめる役に限定し、何を正式な根拠として扱うかはアプリケーションと人が決める構成です。
結論
チャットAIの信頼性を上げるには、回答精度を一つのスコアで表すより、次の順序を固定する方が再現しやすくなります。
- ユーザーが質問の用途を選ぶ
- 用途に応じた出典ポリシーを決める
- 検索結果を公式・コミュニティ・社内情報へ分類する
- 出典を確定してからAIに下書きを作らせる
- AIが未知の出典を追加していないか検証する
- 公式情報の不足や根拠の競合があれば、人へ引き継ぐ
- 回答と一緒に出典レシートをチャットへ表示する
重要なのは、AIに「信頼できる情報を探して」とだけ指示しないことです。公式情報が必須かどうかは、質問の用途によって異なります。
前提:仕様質問と経験質問を同じルールで扱わない
たとえば、次の二つは必要な根拠が異なります。
- 「この機能は利用できるか」: 公式情報が必要
- 「実装時にどこで詰まりやすいか」: 個人の検証記事も有用
コミュニティ記事を排除すると実装上の知見を失います。一方、個人記事だけで料金、提供条件、セキュリティ仕様を断定するのも危険です。
そこで、質問時に用途を明示的に選ばせます。
| 用途 | 公式情報 | コミュニティ情報 | 自動送信 |
|---|---|---|---|
| 製品仕様 | 必須 | 補足として利用 | 公式情報がなければ停止 |
| セキュリティ | 必須 | 注意事例として利用 | 根拠が競合したら停止 |
| 料金・契約 | 必須 | 原則として判断根拠にしない | 人の確認を推奨 |
| 実装経験 | 任意 | 主な根拠として利用可能 | 出典種別を明示して送信 |
この分類はLLMに推測させず、UIの選択肢にします。誤分類されたまま流暢な回答を生成するより、利用者に一度選んでもらう方が制御可能だからです。
Tencent RTCのSocial Messagingは、1対1チャット、グループディスカッション、大規模コミュニティ、リッチメディア、ライブ配信ルーム内チャットなどのシナリオを対象としています。今回の出典レシートは、これらのメッセージ本文に付随するアプリケーション状態として実装します。
完成するワークフロー
ユーザーが質問
↓
用途とAI利用への同意を確認
↓
検索コネクターが候補を取得
↓
Source Policyが出典を分類・検査
├─ 条件を満たす → AIが根拠限定の下書きを生成
└─ 条件を満たさない → 人へ引き継ぐ
↓
モデレーション
↓
回答本文 + 出典レシートをチャットへ送信
検索、LLM、チャット配送を一つの関数へ詰め込まないのがポイントです。検索サービスを変更しても、出典ポリシーと表示形式は維持できます。
手順1:検証環境を作る
Node.jsとTypeScriptで最小構成を作ります。
mkdir chat-source-receipt
cd chat-source-receipt
npm init -y
npm install -D typescript tsx vitest @types/node
mkdir src test
package.jsonへスクリプトを追加します。
{
"scripts": {
"demo": "tsx src/demo.ts",
"test": "vitest run"
}
}
実際の検索APIやLLM APIはプロバイダーごとに仕様が異なるため、ここでは固定データを使います。まず判定ロジックを再現可能にし、その後で外部サービスをアダプターとして接続します。
手順2:回答ではなく「出典レシート」をデータモデルの中心にする
src/source-policy.tsを作成します。
import { createHash } from "node:crypto";
export type QuestionPurpose =
| "product_spec"
| "security"
| "billing"
| "implementation_experience";
export type SourceKind = "official" | "community" | "internal";
export type SearchCandidate = {
id: string;
title: string;
url: string;
excerpt: string;
retrievedAt: string;
// 同じclaimKeyで値が異なる場合は競合として扱う
extractedClaim?: {
claimKey: string;
value: string;
};
};
export type Evidence = SearchCandidate & {
kind: SourceKind;
excerptHash: string;
};
export type SourceReceipt = {
questionId: string;
purpose: QuestionPurpose;
evidence: Evidence[];
blockers: string[];
requiresHuman: boolean;
};
const officialHosts = new Set([
"trtc.io"
]);
export function classifySource(url: string): SourceKind {
const host = new URL(url).hostname.toLowerCase();
if (
officialHosts.has(host) ||
[...officialHosts].some((official) => host.endsWith(`.${official}`))
) {
return "official";
}
// 社内ドメインを使う場合は、設定ファイルから注入する
if (host.endsWith(".internal.example")) {
return "internal";
}
return "community";
}
function hashExcerpt(excerpt: string): string {
return createHash("sha256").update(excerpt).digest("hex");
}
function needsOfficialSource(purpose: QuestionPurpose): boolean {
return purpose !== "implementation_experience";
}
function findConflicts(evidence: Evidence[]): string[] {
const valuesByClaim = new Map<string, Set<string>>();
for (const item of evidence) {
const claim = item.extractedClaim;
if (!claim) continue;
const values = valuesByClaim.get(claim.claimKey) ?? new Set<string>();
values.add(claim.value.trim().toLowerCase());
valuesByClaim.set(claim.claimKey, values);
}
return [...valuesByClaim.entries()]
.filter(([, values]) => values.size > 1)
.map(([claimKey]) => `根拠が競合しています: ${claimKey}`);
}
export function buildReceipt(input: {
questionId: string;
purpose: QuestionPurpose;
candidates: SearchCandidate[];
}): SourceReceipt {
const evidence: Evidence[] = input.candidates.map((candidate) => ({
...candidate,
kind: classifySource(candidate.url),
excerptHash: hashExcerpt(candidate.excerpt)
}));
const blockers: string[] = [];
const hasOfficial = evidence.some((item) => item.kind === "official");
if (needsOfficialSource(input.purpose) && !hasOfficial) {
blockers.push("この用途には公式情報が必要です");
}
blockers.push(...findConflicts(evidence));
if (evidence.length === 0) {
blockers.push("回答に利用できる根拠がありません");
}
return {
questionId: input.questionId,
purpose: input.purpose,
evidence,
blockers,
requiresHuman: blockers.length > 0
};
}
excerptHashは、後から検索結果が変わったときに「その時点で何を根拠にしたか」を確認するための値です。ただし、ハッシュだけでは内容を復元できません。監査要件がある場合は、保存期間、著作権、個人情報を確認した上で短い抜粋も保管します。
手順3:LLMへ渡す情報を確定済みの根拠に限定する
AIが便利なのは、複数の根拠を短く整理し、チャット向けの下書きへ変換できる点です。一方で、AIが根拠の正しさや情報の提供条件まで保証するわけではありません。
LLMの出力には、本文だけでなく使用した根拠IDを要求します。
export type DraftAnswer = {
text: string;
usedEvidenceIds: string[];
limitations: string[];
};
export function createDraftPrompt(
question: string,
receipt: SourceReceipt
): string {
const evidenceText = receipt.evidence
.map((item) =>
[
`ID: ${item.id}`,
`種別: ${item.kind}`,
`タイトル: ${item.title}`,
`URL: ${item.url}`,
`抜粋: ${item.excerpt}`
].join("\n")
)
.join("\n\n");
return `
あなたはチャット回答の下書きを作成します。
以下に提示された根拠以外の事実を追加しないでください。
公式情報とコミュニティの経験談を区別してください。
断定できない点はlimitationsへ入れてください。
質問:
${question}
根拠:
${evidenceText}
JSON形式:
{
"text": "回答本文",
"usedEvidenceIds": ["実際に使った根拠ID"],
"limitations": ["未確認事項"]
}
`.trim();
}
export function validateDraft(
draft: DraftAnswer,
receipt: SourceReceipt
): string[] {
const allowedIds = new Set(receipt.evidence.map((item) => item.id));
const errors: string[] = [];
for (const id of draft.usedEvidenceIds) {
if (!allowedIds.has(id)) {
errors.push(`未登録の根拠IDが使われました: ${id}`);
}
}
if (draft.usedEvidenceIds.length === 0) {
errors.push("回答に使った根拠IDがありません");
}
return errors;
}
この検証で防げるのは、主に「検索段階に存在しなかったURLや根拠IDの追加」です。文章中の全主張が本当に抜粋から導けるかは、別途レビューが必要です。
手順4:人への引き継ぎを失敗画面にしない
公式情報が見つからない場合、単にエラーを返すと利用者は同じ質問を繰り返します。チャットには、停止理由と次の操作を表示します。
export type ChatAction =
| {
type: "publish_ai_draft";
text: string;
receipt: SourceReceipt;
}
| {
type: "request_human_review";
message: string;
receipt: SourceReceipt;
};
export function decideChatAction(
draft: DraftAnswer | null,
receipt: SourceReceipt
): ChatAction {
if (receipt.requiresHuman) {
return {
type: "request_human_review",
message: [
"AI回答の送信を保留しました。",
...receipt.blockers.map((reason) => `- ${reason}`),
"担当者へ質問と出典候補を引き継げます。"
].join("\n"),
receipt
};
}
if (!draft) {
return {
type: "request_human_review",
message: "回答の生成に失敗しました。担当者へ引き継げます。",
receipt
};
}
const draftErrors = validateDraft(draft, receipt);
if (draftErrors.length > 0) {
return {
type: "request_human_review",
message: [
"AI下書きの検証に失敗しました。",
...draftErrors.map((error) => `- ${error}`)
].join("\n"),
receipt
};
}
return {
type: "publish_ai_draft",
text: draft.text,
receipt
};
}
Tencent RTC側のチャット配送処理は、このChatActionを受け取るアダプターとして分離します。SDK固有の初期化、認証、メッセージ送信方法は、採用している構成の公式ドキュメントに合わせて実装してください。
画面では少なくとも次を区別します。
AIによる下書き公式情報コミュニティでの経験談担当者が確認中担当者による回答
AI回答と人間の回答を同じ見た目にすると、引き継いでも利用者には責任主体が分かりません。
コード:固定データで二つのケースを再現する
src/demo.tsを作ります。
import {
buildReceipt,
decideChatAction,
type SearchCandidate,
type DraftAnswer
} from "./source-policy.js";
const official: SearchCandidate = {
id: "src-official-1",
title: "Social Messaging solution",
url: "https://trtc.io/solutions/social-messaging",
excerpt: "Social messaging scenarios include chat and community experiences.",
retrievedAt: "2026-08-21T09:00:00Z",
extractedClaim: {
claimKey: "supports_group_discussion",
value: "yes"
}
};
const community: SearchCandidate = {
id: "src-community-1",
title: "グループチャット実装時の設計メモ",
url: "https://dev.example.com/group-chat-note",
excerpt: "実装では回答本文と出典表示を別コンポーネントにした。",
retrievedAt: "2026-08-21T09:00:01Z"
};
function run(label: string, candidates: SearchCandidate[]) {
const receipt = buildReceipt({
questionId: label,
purpose: "product_spec",
candidates
});
const draft: DraftAnswer = {
text: "公式情報ではグループディスカッションが対象に含まれます。実装例は経験談として分けて確認してください。",
usedEvidenceIds: candidates.map((item) => item.id),
limitations: ["個別SDK構成での利用条件は別途確認が必要です"]
};
console.dir(decideChatAction(draft, receipt), { depth: null });
}
run("official-and-community", [official, community]);
run("community-only", [community]);
実行します。
npm run demo
一つ目はAI下書きを公開可能と判定し、二つ目は「製品仕様なのに公式情報がない」ため、人への引き継ぎになります。
確認方法:回答文ではなく判定結果をテストする
test/source-policy.test.tsを作ります。
import { describe, expect, it } from "vitest";
import { buildReceipt, validateDraft } from "../src/source-policy";
describe("source policy", () => {
it("製品仕様でコミュニティ記事しかない場合は人へ回す", () => {
const receipt = buildReceipt({
questionId: "q1",
purpose: "product_spec",
candidates: [
{
id: "community-1",
title: "個人の実装記録",
url: "https://blog.example.com/note",
excerpt: "利用できたと記録されている",
retrievedAt: "2026-08-21T09:00:00Z"
}
]
});
expect(receipt.requiresHuman).toBe(true);
expect(receipt.blockers).toContain("この用途には公式情報が必要です");
});
it("同じ項目について異なる値があれば競合として止める", () => {
const receipt = buildReceipt({
questionId: "q2",
purpose: "security",
candidates: [
{
id: "official-1",
title: "Official page",
url: "https://trtc.io/solutions/social-messaging",
excerpt: "official excerpt",
retrievedAt: "2026-08-21T09:00:00Z",
extractedClaim: { claimKey: "feature_enabled", value: "yes" }
},
{
id: "community-1",
title: "Community note",
url: "https://example.com/note",
excerpt: "community excerpt",
retrievedAt: "2026-08-21T09:00:01Z",
extractedClaim: { claimKey: "feature_enabled", value: "no" }
}
]
});
expect(receipt.requiresHuman).toBe(true);
expect(receipt.blockers).toContain("根拠が競合しています: feature_enabled");
});
it("LLMが未知の根拠IDを追加したら拒否する", () => {
const receipt = buildReceipt({
questionId: "q3",
purpose: "implementation_experience",
candidates: [
{
id: "known-1",
title: "Known source",
url: "https://example.com/known",
excerpt: "known excerpt",
retrievedAt: "2026-08-21T09:00:00Z"
}
]
});
const errors = validateDraft(
{
text: "回答",
usedEvidenceIds: ["unknown-99"],
limitations: []
},
receipt
);
expect(errors).toContain("未登録の根拠IDが使われました: unknown-99");
});
});
npm test
本番接続前には、さらに次を確認します。
- 質問者がAI利用を拒否した場合、検索・LLMへ本文を送らない
- DMの内容をグループチャットの根拠に混ぜない
- 削除済みメッセージをAIコンテキストへ残さない
- 公式ドメインを文字列の部分一致だけで判定しない
- リダイレクト後の最終URLも検査する
- 出典が0件の回答を送信しない
- 競合時に一方をAIが勝手に採用しない
- 人への引き継ぎ後はAIが重ねて回答しない
- AI回答、担当者回答、システム通知を視覚的に区別する
- モデレーションで保留された理由を内部ログへ残す
翻訳を併用する場合
多言語チャットでは、出典レシートの説明文だけを翻訳し、URL、出典種別、取得時刻は変更しない方が追跡しやすくなります。TUIChatにはオンデマンドのテキストメッセージ翻訳に関する公式手順がありますが、対応するコンテンツ種別、言語、エディション上の条件は導入前に公式ページで確認してください。
翻訳結果を新しい原文として検索やモデレーションへ再投入すると、判断根拠がずれる可能性があります。原文と表示用翻訳は別フィールドに保持します。
注意点とトレードオフ
1. 公式情報を必須にすると回答率は下がる
公式ページに直接書かれていない質問は、人へ回る割合が増えます。これは単なる性能低下ではなく、仕様を経験談で埋めないための判断です。よく発生する質問は、承認済みFAQとして追加する方が安全です。
2. 出典数が多くても正確とは限らない
同じ誤情報を複数の記事が参照している可能性があります。件数ではなく、出典種別、一次情報かどうか、更新時刻、競合の有無を表示します。
3. extractedClaim自体も検証対象になる
サンプルでは構造化済みの主張を入力しています。これをLLMに抽出させる場合、抽出結果は事実ではなく未検証データです。セキュリティ、料金、契約に関する競合判定では、人が確認できる元の抜粋とURLを必ず残します。
4. 出典レシートは免責表示ではない
URLを付けるだけでは、誤った回答を送ってよい理由になりません。出典レシートの目的は責任を利用者へ押しつけることではなく、アプリケーションが自動送信を止めるための入力を増やすことです。
5. AIが改善するのは文章化であり、採用判断ではない
LLMは複数の根拠を短い回答へ整理できます。しかし、公式情報と個人の経験談のどちらを採用すべきかは、質問の目的、リスク、更新時刻によって変わります。その判断をプロンプトの雰囲気に任せず、ポリシーと人間の操作として残すのが今回の設計です。
まとめ
検索・AIサービスごとに参照元が変わること自体は、直ちに異常とはいえません。問題は、その違いがチャット画面から見えなくなることです。
Social MessagingへAIを追加するときは、回答生成より先に次を実装すると運用しやすくなります。
- 質問用途の明示
- 出典の分類
- 公式情報の必須条件
- 根拠IDの固定
- 競合時の送信停止
- AI利用への同意
- 人への見える引き継ぎ
「もっと賢いAIを選ぶ」という問題に見えても、実際に必要なのは、どの根拠なら誰が送信を許可できるかをコードにすることです。
関係開示: 筆者はTencent RTCのコンテンツ制作に関与しています。本稿の製品情報と実装上の前提確認には、Tencent RTCの公式ドキュメントを参照しました。