FAQ429件、抽出しただけで満足していた
前回の#9で、Chatwork 8ルームの全メッセージからFAQ429件を自動抽出した。
やった、これでFAQ完成だ。そう思っていた。
でも冷静に考えると、スプレッドシートに429件並んでいるだけだ。問い合わせが来たら、担当者はそのスプレッドシートを開いて、Ctrl+Fで似た質問を探して、回答をコピペして……やるわけがない。誰もやらない。
持っているだけのFAQは、存在しないのと同じだ。
抽出はゴールじゃない。使われて初めて意味がある。じゃあどうするか。問い合わせが来た瞬間に、FAQから回答案を自動で出せばいい。
ということで、Chatwork × Dify × GASで自動提案パイプラインを設計した。この記事では設計と実装方針を書く。まだ本番環境では動かしていない。動かしたら続編を書く。
設計したもの
Chatwork新着メッセージ
↓ Webhook
GAS doPost()
↓ HTTP POST
Dify Chat API(RAG検索: FAQ429件 + 過去回答)
↓ 回答案
Chatworkに「[自動提案]」付きで投稿
構成要素は3つだけ。Chatwork、Dify、GAS。新しいものは何もない。組み合わせるだけ。
ポイントはDifyの使い方。FAQ429件をナレッジとして食わせて、RAG(Retrieval-Augmented Generation)で検索させる。ベクトル検索で類似質問を引っ張り、回答案を生成する。
人間は提案を見て「これでOK」ならそのまま送る。ダメなら自分で書く。全自動ではなく、半自動。ここが設計上のポイントだと思っている。全自動は怖い。「AIが勝手に間違った回答を送った」は取り返しがつかない。
DifyのRAGセットアップ
ナレッジの投入
Difyにはナレッジ(Knowledge)機能がある。ドキュメントを投入すると、自動でチャンク分割→embedding→ベクトルDB格納まで走る。
FAQ429件はAPI経由で一括投入する想定。こう書く。
import requests
import json
import time
DIFY_API_KEY = "app-xxxxxxxxxxxxxxxx" # 実際のキーは環境変数から
DATASET_ID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
BASE_URL = "https://api.dify.ai/v1"
headers = {
"Authorization": f"Bearer {DIFY_API_KEY}",
"Content-Type": "application/json"
}
# スプレッドシートから読み込んだFAQリスト
# faq_list = [{"id": 1, "question": "...", "answer": "..."}, ...]
for faq in faq_list:
payload = {
"name": f"FAQ-{faq['id']:04d}",
"text": f"質問: {faq['question']}\n回答: {faq['answer']}",
"indexing_technique": "high_quality"
}
res = requests.post(
f"{BASE_URL}/datasets/{DATASET_ID}/document/create_by_text",
headers=headers,
json=payload
)
if res.status_code == 200:
print(f"FAQ-{faq['id']:04d} 投入完了")
else:
print(f"FAQ-{faq['id']:04d} 失敗: {res.status_code}")
# レートリミット対策。急ぐ必要はない
time.sleep(0.5)
過去の実回答もCSVアップロードで投入する予定。Difyの管理画面からKnowledge > Add Data Source > Upload FilesでCSVを放り込むだけでいけるはず。
チャンク分割の設計判断
ここは事前に調べて方針を決めた。複数のFAQを1つのドキュメントにまとめると、embeddingの精度が落ちる。「送料はいくらですか」と聞いたのに、同じチャンクに入っていた「返品ポリシー」の回答が混ざって返ってくる——という話はRAGの定番の罠。
1FAQ = 1ドキュメント = 1チャンクで組む。
FAQの質問と回答は意味的に1セット。これを分割したり、別のFAQと混ぜたりしない。429件なら429ドキュメント。数が多くてもDifyは問題なく捌けるはずだ。
Embedding Modelの選択
DifyはデフォルトでOpenAIのtext-embedding-ada-002を使う。日本語のFAQなら、まずこれで十分だろう。
text-embedding-3-smallのほうがコストは下がるので、新規で組むならこっちを試す。Dify側の設定はSettings > Model Providerから。APIキーを入れるだけで切り替わる。
カスタムプロンプト
RAGで検索した結果をそのままLLMに渡すと、当然ながらAI臭い回答が返ってくる。「〜することが可能です」「〜と考えられます」の嵐。事務局の回答としてはありえない。
Difyのアプリ設定で、システムプロンプトをこう組む。
あなたは事務局の担当者です。
以下のルールに従って回答案を作成してください。
## ルール
- 丁寧語を使う(です/ます調)
- 3行以内で簡潔に回答する
- 「〜することが可能です」「〜と考えられます」等のAI定型句は禁止
- 具体的な手順がある場合は箇条書きで示す
- 該当するFAQが見つからない場合は「該当するFAQが見つかりませんでした」とだけ返す
- 推測で回答を補完しない。FAQに書かれていないことは答えない
## コンテキスト
{context}
## 質問
{query}
{context}にRAGの検索結果が入り、{query}にユーザーの質問が入る。Difyの変数展開をそのまま使える。
「推測で補完しない」が一番大事だと思っている。FAQにない質問に対して、LLMが勝手にそれっぽい回答を作る問題。これをプロンプトで潰しておかないと、半自動の意味がなくなる。
GAS側の実装
Webhook受信(doPost)
ChatworkのWebhook設定でGASのURLを登録する。メッセージが投稿されるたびにdoPostが呼ばれる。この仕組み自体は#10でさんざんやった。
const DIFY_API_KEY = PropertiesService.getScriptProperties().getProperty("DIFY_API_KEY");
const CW_API_TOKEN = PropertiesService.getScriptProperties().getProperty("CW_API_TOKEN");
const BOT_ACCOUNT_ID = PropertiesService.getScriptProperties().getProperty("BOT_ACCOUNT_ID");
function doPost(e) {
try {
const data = JSON.parse(e.postData.contents);
const event = data.webhook_event;
// Webhookイベントの種類チェック
if (data.webhook_event_type !== "message_created") return;
// ★ 自分自身の投稿は無視(無限ループ防止)
if (String(event.from_account_id) === BOT_ACCOUNT_ID) return;
// メッセージ本文を取得
const message = event.body;
// 短すぎるメッセージはスキップ(スタンプや「了解」等)
if (message.length < 10) return;
// リプライやTo付きメッセージから本文だけ抽出
const cleanMessage = extractBody(message);
// Difyに問い合わせ
const result = askDify(cleanMessage);
// 信頼度が閾値以上なら回答案を投稿
if (result.confidence >= 0.7) {
const proposal = formatProposal(result);
postToChatwork(event.room_id, proposal);
}
} catch (err) {
console.error("doPost error:", err);
}
}
/**
* Chatworkのメッセージからメンション・引用を除去して本文だけ抽出
*/
function extractBody(message) {
// [To:xxxx]Name を除去
let clean = message.replace(/\[To:\d+\][^\n]*/g, "");
// [引用 aid=xxxx ...]...[/引用] を除去
clean = clean.replace(/\[引用[^\]]*\][\s\S]*?\[\/引用\]/g, "");
// [info]...[/info] を除去
clean = clean.replace(/\[info\][\s\S]*?\[\/info\]/g, "");
// 先頭末尾の空白を除去
return clean.trim();
}
extractBodyが地味に重要。Chatworkのメッセージには[To:xxxx]や[引用]タグが大量に含まれる。これをそのままDifyに渡すと、タグの文字列にembeddingが引きずられて検索精度が落ちる。このへんはChatwork APIを触り続けてきた経験から、最初から織り込んで設計した。
Dify Chat API呼び出し
function askDify(question) {
const url = "https://api.dify.ai/v1/chat-messages";
const options = {
method: "post",
headers: {
"Authorization": "Bearer " + DIFY_API_KEY,
"Content-Type": "application/json"
},
payload: JSON.stringify({
inputs: {},
query: question,
response_mode: "blocking",
user: "chatwork-webhook-bot"
}),
muteHttpExceptions: true
};
const res = UrlFetchApp.fetch(url, options);
const statusCode = res.getResponseCode();
if (statusCode !== 200) {
console.error("Dify API error:", statusCode, res.getContentText());
return { text: "", confidence: 0 };
}
const result = JSON.parse(res.getContentText());
return {
text: result.answer,
confidence: extractConfidence(result),
sources: extractSources(result)
};
}
response_mode: "blocking"にしているのは、GASのdoPostが同期処理だから。Difyにはstreaming modeもあるが、GASでは使えない。レスポンスが返るまで待つ形になる。
FAQ検索なら通常1〜3秒で返ってくるはずなので、タイムアウトの心配はほぼないだろう。
信頼度の抽出
Dify Chat APIのレスポンスには、RAGの検索結果にscoreが含まれる。これを信頼度として使う設計。
function extractConfidence(result) {
// metadata.retriever_resourcesから最も高いscoreを取得
const resources = result.metadata?.retriever_resources;
if (!resources || resources.length === 0) return 0;
// 最上位の検索結果のスコアを信頼度とする
const topScore = Math.max(...resources.map(r => r.score || 0));
return topScore;
}
function extractSources(result) {
const resources = result.metadata?.retriever_resources;
if (!resources || resources.length === 0) return [];
return resources.slice(0, 3).map(r => ({
name: r.document_name,
score: r.score
}));
}
回答案のフォーマット
function formatProposal(result) {
const sources = result.sources
.map(s => `${s.name}(${Math.round(s.score * 100)}%)`)
.join("、");
return [
"[info]",
"[title]自動提案[/title]",
result.text,
"",
`参照元: ${sources}`,
`一致度: ${Math.round(result.confidence * 100)}%`,
"[/info]"
].join("\n");
}
Chatworkの[info]タグで囲むことで、通常のメッセージと視覚的に区別できる。参照元のFAQ番号と一致度を添えて、担当者が「どのFAQに基づいた提案か」を判断できるようにする。
Chatworkへの投稿
function postToChatwork(roomId, message) {
const url = `https://api.chatwork.com/v2/rooms/${roomId}/messages`;
UrlFetchApp.fetch(url, {
method: "post",
headers: {
"X-ChatWorkToken": CW_API_TOKEN
},
payload: {
body: message,
self_unread: 0 // 自分の未読にしない
}
});
}
self_unread: 0を忘れると、ボットの投稿が自分の未読に入り続けて通知地獄になる。#12で書いた既読制御の知識がここで活きる。
設計段階で潰しておくべき罠3つ
1. 無限ループ
Webhook系のボットで一番怖いやつ。ボットが回答案を投稿する→Webhookがそれを拾う→Difyに問い合わせる→また回答案を投稿する→以下無限。
#10のWebhook実装でこの手の問題は身をもって学んでいるので、最初からfrom_account_idのチェックを入れた。
if (String(event.from_account_id) === BOT_ACCOUNT_ID) return;
型が違う罠もある。Chatwork APIはアカウントIDを数値で返すことがあるので、String()で揃えている。ここをサボると「IDは同じなのに一致しない」という地獄が待っている。これも過去にやらかした。
2. AI臭い回答問題
Difyのデフォルトプロンプトのまま動かすと、こういう回答が返ってくる。
お問い合わせいただきありがとうございます。ご質問の件について回答させていただきます。配送料につきましては、全国一律で設定させていただいております。詳細につきましては、以下をご確認いただけますと幸いです。
長い。丁寧すぎる。何も言っていない。事務局の人間はこんな回答は書かない。
カスタムプロンプトで「3行以内」「AI定型句禁止」を指定すれば、こうなるはずだ。
配送料は全国一律XXX円です。
〇〇円以上のご注文で送料無料になります。
これなら使える。プロンプトの調整は地味だが、おそらく一番効果が大きい部分。
3. 信頼度の閾値設定
ここは正直、動かしてみないと分からない。ただ方針は決めている。
閾値は最初から高めに設定する。0.7スタート。低い閾値で精度の怪しい提案を乱発すると、担当者が「またハズレか」と思って提案自体を見なくなる。一度信用を失うと、精度を上げても見てもらえない。
「出さないほうがマシ」なラインを見極めるのが一番難しいはず。 最初から高めにして「提案が出たら信頼できる」という印象を植え付けるほうが戦略的に正しいと踏んでいる。
閾値の最適値は、実データで検証しないと語れない。ここは動かしてから続編で書く。
Chatwork独特のタグ問題
extractBodyのところで書いたが、もう少し掘り下げる。Chatworkのメッセージ形式はかなり独特で、RAGとの相性が悪い。
[To:12345678]太田さん
[引用 aid=87654321 time=1234567890]
前回の件ですが...
[/引用]
送料について教えてください。
本当に聞きたいのは最後の1行だけ。なのにメッセージの大半がタグで占められている。これをそのままembeddingに通すと、「12345678」とか「aid=87654321」みたいな数字列がベクトルに影響する。
extractBodyで全部剥がしてからDifyに渡す。この前処理をサボると、検索精度はまず出ない。17記事分Chatwork APIを触り続けてきた経験が、こういうところで効いてくる。
全体のコード構成
GASのファイル構成はこう設計した。
chatwork-dify-bot/
├── main.gs # doPost + メインロジック
├── dify.gs # Dify API呼び出し
├── chatwork.gs # Chatwork API呼び出し
├── utils.gs # extractBody, formatProposal等
└── config.gs # 定数定義(スクリプトプロパティ経由)
GASでも適度にファイルを分けたほうがいい。1ファイルに全部書くと、Webhook処理のデバッグ中にDify側のコードを延々スクロールする羽目になる。
これから検証すること
設計はできた。コードも書いた。だが、まだ動かしていない。
これから実際にDifyにFAQ429件を投入し、パイプラインを組んで、本番のChatworkルームで動かす。そこで初めて分かることがいくつかある。
1. 閾値の最適値。0.7スタートと決めたが、実際のメッセージでどの程度のカバレッジと精度が出るかは未知数。高すぎれば提案がほとんど出ないし、低すぎればゴミが出る。ここは実データで調整するしかない。
2. カバレッジの拡張。回答案が出なかった問い合わせを蓄積して、人間が書いた回答とセットでDifyに再投入する。学習データが増えれば、カバーできる範囲は自然に広がるはず。
3. カテゴリ別の閾値調整。「送料」「納期」みたいな単純な質問と、「返品対応」「契約変更」みたいな複雑な質問では、必要な閾値が違うだろう。カテゴリ分類をDifyのワークフロー機能で前段に挟めば、動的な閾値設定もできるはず。
4. 担当者の反応。結局、使ってもらえるかどうかが全て。「あ、この提案使える」と思ってもらえるか。それとも邪魔だと思われて無視されるか。ここはツールの精度だけじゃなく、運用の入れ方の問題でもある。
429件のFAQを、スプレッドシートから引っ張り出して「使われるFAQ」にする。設計はできた。次は動かす。
Chatworkシリーズ
- #1 なぜ2026年にまだChatworkを使い倒しているのか
- #2 chatwork-client-gas、ぶっちゃけいるの?
- #3 ルームの参加者データだけで、組織の人間関係マップを作った
- #4 「Chatworkに確定連絡が来たら請求書を送る」をGASで自動化する
- #5 Chatwork MCPを繋いだら、17ルームの未読が10秒で片付いた
- #6 MCP vs GAS — Chatwork自動化の「正解」はどっちか
- #7 コンタクト承認をn8nで自動化しようとしたら、3つの罠にハマった
- #8 ChatworkにAIチームを住まわせたら、勝手に会話が始まった
- #9 Chatwork 8ルームの全メッセージからFAQ429件を自動抽出した
- #10 Webhook署名検証を入れたら全メッセージが消えた
- #11 過去メッセージを全件取得しようとしたら、APIの「100件の壁」にハマった
- #12 Chatwork APIの「既読」は自分で制御できる
- #13 Chatwork APIのファイル機能、使ったことある?
- #14 n8nで全ルーム巡回
- #15 タスク機能をAPIで使い倒す
- #16 MCPを2アカウント同時接続したら、仕事用と事務局用が1画面で回った
- #17 【世界初かもしれない】ChatworkでClaude Code Channelsを実装してみた
- #18 Chatwork × Dify × GASで問い合わせ回答を自動提案する(この記事)
- #19 RelationMapを夜間バッチで毎日自動更新する
- #20 17記事書いて見えた、Chatwork APIエコシステムに足りないもの
Chatworkシリーズ
- #1 なぜ2026年にまだChatworkを使い倒しているのか
- #2 chatwork-client-gas、ぶっちゃけいるの?
- #3 ルームの参加者データだけで、組織の人間関係マップを作った
- #4 「Chatworkに確定連絡が来たら請求書を送る」をGASで自動化する
- #5 Chatwork MCPを繋いだら、17ルームの未読が10秒で片付いた
- #6 MCP vs GAS — Chatwork自動化の「正解」はどっちか
- #7 コンタクト承認をn8nで自動化しようとしたら、3つの罠にハマった
- #8 ChatworkにAIチームを住まわせたら、勝手に会話が始まった
- #9 Chatwork 8ルームの全メッセージからFAQ429件を自動抽出した
- #10 Webhook署名検証を入れたら全メッセージが消えた
- #11 過去メッセージを全件取得しようとしたら、APIの「100件の壁」にハマった
- #12 Chatwork APIの「既読」は自分で制御できる
- #13 Chatwork APIのファイル機能、使ったことある?
- #14 n8nで全ルーム巡回
- #15 タスク機能をAPIで使い倒す
- #16 MCPを2アカウント同時接続したら、仕事用と事務局用が1画面で回った
- #17 【世界初かもしれない】ChatworkでClaude Code Channelsを実装してみた
- #18 Chatwork × Dify × GASで問い合わせ回答を自動提案する(この記事)
- #19 RelationMapを夜間バッチで毎日自動更新する
- #20 17記事書いて見えた、Chatwork APIエコシステムに足りないもの
- #21 Googleフォームの回答をChatworkに自動投稿するGAS
- #22 Chatworkの会話を毎日AIが要約してくれる仕組みをn8nで作った話
- #23 chatwork-cliを入れたら、シェルからChatworkが操作できて世界が変わった
- #24 ChatworkのWebhookをn8nで受けるなら、HMAC署名検証は必ずやれ
- #25 Chatwork × GAS × Claude Codeで会員制講座の運用を自動化した