「問い合わせ対応、毎日何時間かかってる?」
担当者に聞かれて、正直に答えられなかった。ある協会の事務局業務を手伝っているのだが、問い合わせ対応にどれだけ時間を使っているか、自分でも把握できていなかった。
やっているのはこういう作業だ。Chatworkの問い合わせルームにメッセージが来る。担当者が確認して、過去に似た質問がないか記憶を頼りに探す。見つからなければ上に聞く。返事を書く。1日にこれを5回、10回。
問題は「過去に似た質問がないか探す」の部分だ。8つのルームに散らばった過去メッセージを人力で漁っている。検索しても引っかからない。記憶頼り。属人的。引き継ぎ不能。
これを何とかしたくて、Chatwork APIで全メッセージを吸い出してFAQを自動生成した。結果、429件のFAQと414件の全文Q&Aが手に入った。そのうち98件(23%)は定型回答で即対応できると判定できた。
やったことの全体像
Chatwork API(8ルーム全メッセージ取得)
↓
テキスト前処理(Chatwork記法除去・正規化)
↓
質問パターン抽出(?/でしょうか/ですか等)
↓
類似質問クラスタリング → FAQ化
↓
回答候補の紐付け
↓
GASで定期吸い上げ → スプレッドシート蓄積
地味なパイプラインだが、一つ一つにハマりポイントがある。
Chatwork APIの100件の壁
最初の壁はメッセージ取得の制限だ。
GET /rooms/{room_id}/messages は1回のリクエストで最大100件しか返さない。force=1を付ければ既読含めて取得できるが、それでも最新100件だ。8ルームあるから800件。全然足りない。
過去に遡るには工夫が必要だった。
// 過去メッセージの遡及取得(疑似コード)
let allMessages = [];
let oldestId = null;
while (true) {
const params = { force: 1 };
// message_idベースで遡る
const batch = fetchMessages(roomId, params);
if (batch.length === 0) break;
allMessages = allMessages.concat(batch);
oldestId = batch[batch.length - 1].message_id;
}
ここで注意。force=1には「5分に1回まで」というレートリミットがある。8ルーム×複数回の取得をやると、すぐにリミットに引っかかる。sleep入れながらじわじわ吸い出した。全部取り終わるまで、コーヒー3杯分くらい待った。
別ルートとして、Tayori(FAQ管理ツール)に蓄積されていたCSVデータも併用した。Chatworkのメッセージと突き合わせることで、429件のFAQ + 414件の全文Q&Aが揃った。
メッセージのカオスを整形する
取得したメッセージをそのまま解析にかけると悲惨なことになる。Chatworkのメッセージには独自記法が山ほど入っている。
[To:99999999]田中さん
[info][title]本日のお問い合わせ[/title]
NO.3847 鈴木様より入金確認のお問い合わせ
[/info]
[rp aid=12345 to=99999-88888]
メンション、引用、装飾タグ、リプライ参照。人によって改行の使い方も全然違う。丁寧に箇条書きする人もいれば、全部1行にまとめる人もいる。絵文字をふんだんに使う人もいる。
#8の記事で書いたクリーニング関数をベースに、FAQ抽出用にチューニングした。
function cleanForFaqExtraction(msg) {
// メンション除去(名前部分は残す)
msg = msg.replace(/\[To:\d+\]/g, '');
// リプライ参照
msg = msg.replace(/\[rp\s+aid=\d+\s+to=[\d\-]+\]/g, '');
// 装飾タグ(中身は残す)
msg = msg.replace(/\[\/?(?:info|title|code|hr)\]/g, '');
// アイコン・プレビュー・DL・日時参照
msg = msg.replace(/\[(?:piconname|preview|download|dtext):[^\]]*\]/g, '');
// 絵文字コード
msg = msg.replace(/\[emojicode:\w+\]/g, '');
// 連続改行を整理
msg = msg.replace(/\n{3,}/g, '\n\n').trim();
return msg;
}
ポイントは「メンション記法は消すが、名前テキストは残す」こと。[To:99999]田中さん を丸ごと消すと、文脈がわからなくなる。[To:99999] だけ消して「田中さん」を残す。
「質問」と「感想」を見分ける
クリーニングした本文から質問を抽出する。最初は単純にパターンマッチでやった。
const questionPatterns = [
/?/,
/\?/,
/でしょうか/,
/ですか/,
/ますか/,
/ませんか/,
/ください/,
/教えて/,
/確認.*お願い/,
];
これで引っかかった件数、約1200件。多すぎる。
原因はすぐわかった。「すごいですね?」「了解ですか?」みたいな、質問じゃないものが大量に混じっている。感嘆符代わりの「?」。確認の相づち。社交辞令。
パターンマッチだけでは無理だと悟った。
2段階フィルタ
結局、2段階のアプローチに落ち着いた。
- パターンマッチで候補抽出(広めに拾う)
- LLMで「本当に質問か」を判定(絞り込む)
2段目でClaude Haikuに投げた。プロンプトはシンプル。
以下のメッセージが「回答を求める質問」か「感想・相づち」か判定してください。
質問なら"Q"、それ以外は"X"と答えてください。
これで1200件が約500件に絞れた。精度は体感で9割くらい。残り1割は微妙なやつだ。「入金したんですけど反映されてないんですが」は質問なのか報告なのか。人間でも迷う。
回答の紐付けが一番難しかった
質問を抽出できた。次は回答の紐付けだ。ここが一番苦労した。
Chatworkにはスレッド機能がない。メッセージは時系列で流れるだけ。質問の直後に来たメッセージが回答とは限らない。別の話題が挟まることもある。複数人が同時に別の質問に答えていることもある。
やったのは3つの手がかりの組み合わせだ。
手がかり1: リプライ参照。[rp]タグが付いているメッセージは、明示的に特定のメッセージへの返信。これが一番確実。
手がかり2: 時間的近接性。質問の後、10分以内に同じルームに投稿された担当者のメッセージを回答候補とする。雑だが、それなりに当たる。
手がかり3: メンション。[To:質問者のID]が付いた返信は、その人への回答である可能性が高い。
function findAnswer(question, allMessages) {
// 1. リプライ参照で紐付け
const directReply = allMessages.find(m =>
m.body.includes(`[rp aid=${question.account_id} to=${question.room_id}-${question.message_id}]`)
);
if (directReply) return directReply;
// 2. 10分以内の担当者メッセージ
const tenMinutes = 10 * 60;
const candidates = allMessages.filter(m =>
m.send_time > question.send_time &&
m.send_time < question.send_time + tenMinutes &&
isStaffAccount(m.account_id)
);
if (candidates.length > 0) return candidates[0];
// 3. メンション付きメッセージ
const mention = allMessages.find(m =>
m.send_time > question.send_time &&
m.body.includes(`[To:${question.account_id}]`)
);
return mention || null;
}
紐付け率は約7割。残り3割は回答が見つからない。質問が放置されていたケースもあるし、別チャネル(メール・電話)で回答したケースもある。完璧な紐付けは構造的に無理だ。Chatworkのメッセージだけでは。
GASで定期吸い上げ
一度きりの分析で終わらせたくなかった。問い合わせは毎日来る。FAQも増える。
GASでスプレッドシートに定期蓄積する仕組みを作った。
function syncMessages() {
const rooms = getTargetRooms(); // 対象ルーム一覧
const sheet = SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName('messages');
for (const roomId of rooms) {
const messages = fetchChatworkMessages(roomId);
const cleaned = messages.map(m => ({
room_id: roomId,
message_id: m.message_id,
account_id: m.account_id,
body: cleanForFaqExtraction(m.body),
send_time: new Date(m.send_time * 1000),
is_question: detectQuestion(m.body),
}));
appendToSheet(sheet, cleaned);
}
}
トリガーを設定して定期実行。新しいメッセージがスプレッドシートに溜まっていく。質問判定フラグも自動で付く。あとはこのデータを元に、月次でFAQを更新すればいい。
結果: 429件のFAQと23%の自動回答カバレッジ
最終的に手に入ったもの。
| 指標 | 数値 |
|---|---|
| 抽出FAQ | 429件 |
| 全文Q&A | 414件 |
| 自動回答可能(定型) | 98件(23%) |
| テンプレで半自動 | 約150件 |
| 判断が必要 | 約180件 |
23%という数字をどう見るか。正直、最初は低いと思った。
でも考えてみると、全問い合わせの約4分の1が「過去に全く同じ質問と回答がある」ということだ。毎日5件の問い合わせがあるとして、そのうち1件は過去のコピペで返せる。年間で365件。1件あたり10分かかるとして、年間60時間の削減になる。
残りの77%も、類似質問のクラスタを見せることで「ゼロから調べる」が「参考にしながら書く」に変わる。これは数字にしにくいが、体感では回答速度が倍になった。
正直な反省
ハマりポイントを振り返ると、技術的に難しいことは何もなかった。APIの制限、テキストの前処理、パターンマッチ。どれも基礎的な技術だ。
一番難しかったのは「質問と感想の区別」と「回答の紐付け」。どちらもChatworkの構造的な限界に起因する。スレッドがない。メッセージの意図を示すメタデータがない。全部が時系列のフラットなストリーム。
この構造の中で質問と回答のペアを作ろうとすること自体が、かなり無理のある作業だった。7割の紐付け率は、まあ妥当な線だと思う。
もう一つ。429件のFAQを作ったはいいが、これを使う仕組みがないと意味がない。FAQがスプレッドシートに眠っているだけでは、属人的な記憶頼りと大差ない。「使える形にする」のが次のステップだ。実際、このFAQデータをRAGのナレッジベースに投入して、問い合わせに対する回答案を自動生成する仕組みも作った。その話はまた別の記事で書く。
Chatworkからの知識抽出、意外とやっている人がいない
Chatworkの月間アクティブID数は792万(2024年時点)。日本のビジネスチャットとしては巨大な規模だ。にもかかわらず、「Chatworkの過去ログからFAQを自動生成した」という記事を、俺は見つけられなかった。
Slackならある。Teamsならある。でもChatworkは、ない。
たぶん、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のファイル機能、使ったことある?