GASで社内文書に
答えるAIを作る方法
「就業規則のどこに書いてある?」を毎回人が探す状態は、AIで置き換えられます。 鍵になるのは全文をAIに渡さず、関係のある箇所だけを検索して渡すことです。この仕組みをRAGと呼びます。専用のデータベースを用意せず、GASとスプレッドシートだけで作る手順を5ステップで解説します。
Table of Contents
全文をAIに渡す方法が破綻する理由
社内文書QAを作るとき、最初に思いつくのは「マニュアル全部をプロンプトに貼って質問する」やり方です。 文書が1本なら動きます。ただし本数が増えると、次の3つで確実に行き詰まります。
入力の上限に当たる
1リクエストで送れる長さには上限がある。マニュアル数十本を毎回送ることはできない
料金が跳ね上がる
入力トークン(送った文字数の単位)に応じて課金される。質問1回ごとに全文を送るのは高い
精度が落ちる
関係のない章まで一緒に渡すと、AIが別の規定を持ち出して答えることが増える
そこで「まず探す、次に答えさせる」の2段構えにします。これがRAG(Retrieval-Augmented Generation/検索拡張生成。関連文書を検索してからAIに答えさせる方法)です。 AIを賢くするのではなく、AIに渡す材料を絞るのが仕事の中心になります。
簡易RAGの全体像(5ステップ)
専用のベクトルデータベースは使いません。知識の置き場はスプレッドシート1枚、検索はGASの文字列処理です。 構成はこうなります。
集める
ドライブのGoogleドキュメントを読み、シートに取り込む
分割する
見出しや文字数で300〜800字のチャンク(かたまり)に切る
探す
質問に含まれる語でスコアを付け、上位3〜5チャンクだけ取り出す
答えさせる
そのチャンクだけをClaude APIに渡し、出典付きで回答させる
育てる
質問と検索結果をログに残し、ヒットしない質問から辞書を直す
シートを知識の置き場にする利点は、中身を人が目で見て直せることです。「この回答がおかしい」と言われたとき、どのチャンクが渡ったかをその場で確認できます。 最初から凝った仕組みにせず、まず動く形を作って運用で詰めるほうが早く立ち上がります。
ステップ1-2: 文書をシートに貯めて分割する
「知識」シートを1枚用意し、A列に文書名、B列に見出し、C列に本文チャンクを入れます。 取り込みはDocumentApp(Googleドキュメントをコードで読み書きするサービス)で行います。
// 「知識」シート: A=文書名 / B=見出し / C=本文チャンク
const KNOWLEDGE_SHEET = "知識";
const CHUNK_SIZE = 600; // 日本語なら300〜800文字が扱いやすい
// 指定フォルダのGoogleドキュメントを読み、チャンクに分割して登録する
function importDocsToKnowledge(folderId) {
const sheet = SpreadsheetApp.getActive().getSheetByName(KNOWLEDGE_SHEET);
const files = DriveApp.getFolderById(folderId).getFilesByType(MimeType.GOOGLE_DOCS);
const rows = [];
while (files.hasNext()) {
const file = files.next();
const body = DocumentApp.openById(file.getId()).getBody();
let heading = "(見出しなし)";
let buffer = "";
const total = body.getNumChildren();
for (let i = 0; i < total; i++) {
const child = body.getChild(i);
if (child.getType() !== DocumentApp.ElementType.PARAGRAPH) continue;
const para = child.asParagraph();
const text = para.getText().trim();
if (!text) continue;
// 見出し段落が来たら、そこまでの本文を1チャンクとして確定する
if (para.getHeading() !== DocumentApp.ParagraphHeading.NORMAL) {
if (buffer) rows.push([file.getName(), heading, buffer]);
heading = text;
buffer = "";
continue;
}
buffer += text + "\n";
if (buffer.length >= CHUNK_SIZE) {
rows.push([file.getName(), heading, buffer]);
buffer = "";
}
}
if (buffer) rows.push([file.getName(), heading, buffer]);
}
if (!rows.length) return 0;
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, 3).setValues(rows);
return rows.length;
}文字数で機械的に切るのではなく、見出し段落が来たらそこで区切るのがポイントです。「第5条 有給休暇」の途中で切れてしまうと、検索でヒットしても前提が欠けた文章がAIに渡ります。 見出しをB列に持たせておくと、検索時の重み付けにも回答の出典表示にも使えます。
取り込みは更新のたびに毎回全部やり直す形が単純です。文書が多い場合は、シートを一度クリアしてから再取り込みし、 件数が多ければトリガーで分割実行してください。PDFしか無い文書は、Drive APIでGoogleドキュメントへ変換してから読むか、 AIに直接読ませて本文テキストを取り出す方法もあります。
ステップ3: キーワードスコアで候補を絞る
ここがRAGの心臓部です。とはいえ処理自体は単純で、質問に出てくる語がチャンクに何回出るかを数え、点数の高い順に並べるだけです。
// 質問に関係の近いチャンクを、キーワード一致のスコアで上位N件だけ取り出す
function searchKnowledge(question, topN) {
const limit = topN || 4;
const values = SpreadsheetApp.getActive()
.getSheetByName(KNOWLEDGE_SHEET)
.getDataRange()
.getValues();
const terms = tokenize(question);
if (!terms.length) return [];
const scored = [];
for (let i = 1; i < values.length; i++) {
const doc = String(values[i][0] || "");
const heading = String(values[i][1] || "");
const text = String(values[i][2] || "");
if (!text) continue;
const haystack = normalize(doc + " " + heading + " " + text);
let score = 0;
terms.forEach(function (t) {
// 出現回数を数える。見出しに含まれる語は重みを3倍にする
const hits = haystack.split(t).length - 1;
if (!hits) return;
score += hits;
if (normalize(heading).indexOf(t) >= 0) score += 3;
});
if (score > 0) scored.push({ doc: doc, heading: heading, text: text, score: score });
}
scored.sort(function (a, b) { return b.score - a.score; });
return scored.slice(0, limit);
}
// 全角・半角・大文字小文字の表記ゆれを吸収する
function normalize(s) {
return s.normalize("NFKC").toLowerCase();
}
// 質問文から検索語を作る。助詞や定型句は捨てる
function tokenize(question) {
const stop = ["について", "教えて", "ですか", "とは", "する", "したい", "方法", "場合"];
let s = normalize(question);
stop.forEach(function (w) { s = s.split(w).join(" "); });
return s
.split(/[\s、。,.??!!「」()()・\/]+/)
.filter(function (w) { return w.length >= 2; });
}normalize()で表記ゆれを吸収
NFKCで全角英数字を半角に、小文字化で大文字小文字を統一。「Wi-Fi」と「wi-fi」が一致する
見出し一致は重みを上げる
本文に1回出るより、見出しに出るほうが関連は強い。ここで上位の並びが安定する
1文字の語は捨てる
「の」「は」が全チャンクにヒットしてスコアが無意味になるのを防ぐ
上位3〜5件に絞る
多く渡すほど精度は落ちる。まず4件で試し、答えが足りなければ増やす
日本語は英語と違って単語がスペースで区切られていないため、この分割は厳密ではありません。 それでも社内文書は「有給休暇」「経費精算」「セキュリティ規程」のように固有の言葉が決まっているので、 実務では十分ヒットします。うまく引けない語が出てきたら、対応表(別名→正式名称)をシートに持たせて質問を置換するのが、いちばん費用対効果の高い改善です。
ステップ4: 絞った資料だけをAIに渡す
検索で残った数チャンクだけをプロンプトに入れ、Claude APIに答えさせます。 全文ではなくヒット分だけなので、送る量は数千文字に収まります。
const ENDPOINT = "https://api.anthropic.com/v1/messages";
const SYSTEM_PROMPT = [
"あなたは社内マニュアルの案内役です。",
"渡された【資料】に書かれている内容だけを根拠に答えてください。",
"資料に無いことは推測せず「資料に記載がありません」と答えてください。",
"回答の最後に、使った資料の「文書名 > 見出し」を出典として列挙してください。",
].join("\n");
function askKnowledge(question) {
const hits = searchKnowledge(question, 4);
if (!hits.length) {
return "関連する資料が見つかりませんでした。別の言葉で質問してください。";
}
// ヒットしたチャンクだけを資料として渡す。全文は送らない
const context = hits
.map(function (h, i) {
return "[資料" + (i + 1) + "] " + h.doc + " > " + h.heading + "\n" + h.text;
})
.join("\n\n");
const key = PropertiesService.getScriptProperties().getProperty("ANTHROPIC_API_KEY");
const res = UrlFetchApp.fetch(ENDPOINT, {
method: "post",
contentType: "application/json",
headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
payload: JSON.stringify({
model: "claude-sonnet-5",
max_tokens: 800,
system: SYSTEM_PROMPT,
messages: [
{
role: "user",
content: "【資料】\n" + context + "\n\n【質問】\n" + question,
},
],
}),
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) {
throw new Error("Claude API エラー(" + res.getResponseCode() + "): " + res.getContentText().slice(0, 300));
}
const json = JSON.parse(res.getContentText());
return json.content
.map(function (b) { return b.text || ""; })
.join("")
.trim();
}重要なのはコードよりもsystemの3行です。「資料だけを根拠にする」「無ければ無いと言う」「出典を書く」を明示しないと、AIは一般論で埋めにきます。社内規程の回答で一般論が混ざるのは事故なので、ここは必ず入れてください。
検索が0件のときは、APIを呼ばずに打ち切っているのもポイントです。 資料が無い状態で質問だけ投げると、AIは知識だけで答えてしまいます。「答えられない」を正しく返せることが、社内QAでは正答率と同じくらい重要です。
ステップ5: 質問の言い換えとログで精度を上げる
運用を始めると「聞き方が資料の言葉と違うせいでヒットしない」問題が必ず出ます。 利用者は「有給って何日前に出すの?」と聞き、資料には「有給休暇の申請期限」と書いてある、という食い違いです。 ここは検索の前にAIを1回挟んで、検索語を作らせると改善します。
// 「有給って何日前に出すの?」→「有給休暇 申請 期限 就業規則」のように検索語を作らせる
function buildSearchTerms(question) {
const key = PropertiesService.getScriptProperties().getProperty("ANTHROPIC_API_KEY");
const res = UrlFetchApp.fetch(ENDPOINT, {
method: "post",
contentType: "application/json",
headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
payload: JSON.stringify({
model: "claude-haiku-4-5", // 短い出力なので軽いモデルで十分
max_tokens: 100,
system: "質問を社内文書検索用のキーワードに変換する。半角スペース区切りの単語のみを出力し、説明は書かない。正式名称や言い換えも補う。",
messages: [{ role: "user", content: question }],
}),
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) return question; // 失敗しても元の質問で検索を続ける
const json = JSON.parse(res.getContentText());
const terms = json.content.map(function (b) { return b.text || ""; }).join("").trim();
return question + " " + terms; // 元の質問も残して取りこぼしを防ぐ
}
// 検索前に1回挟むだけ
function askKnowledgeSmart(question) {
const hits = searchKnowledge(buildSearchTerms(question), 4);
// 以降は askKnowledge と同じ
return hits;
}軽量モデルで100トークン程度の出力なので、待ち時間もコストもほとんど増えません。 失敗したら元の質問で検索を続ける作りにしておくと、APIが不調でも仕組み全体は止まりません。
あわせて、質問・回答・使った資料・最高スコアをログに残します。改善のネタは全部ここから出ます。
// 質問と回答、使った資料をシートに残す。改善のネタはここに溜まる
function logQa(question, answer, hits) {
SpreadsheetApp.getActive()
.getSheetByName("QAログ")
.appendRow([
new Date(),
Session.getActiveUser().getEmail(),
question,
answer,
hits.map(function (h) { return h.doc + " > " + h.heading; }).join(" / "),
hits.length ? hits[0].score : 0,
]);
}見るべきはスコア0(ヒットなし)の質問です。文書が足りないのか、言葉が違うだけなのかが分かれます。前者なら文書を足し、後者なら別名の対応表に1行足す。 この地道な往復が、検索方式を高度にするより効きます。
ベクトル検索に進むべきか
RAGの記事では、埋め込み(文章を意味の近さで比べられる数値の並びに変換する処理)とベクトル検索が定番です。 言い換えが多い質問でも意味で拾えるのが強みで、精度の上限は確かに上がります。
ただしGASで組む場合は、前提を確認してから決めてください。 埋め込みの生成には対応した外部APIが要ります。生成した数百次元の数値をシートに保存すると重くなり、 検索のたびに全件と類似度を計算する処理も、件数が増えるほど実行時間を圧迫します。 本格的にやるならベクトル検索に対応したサービスを併用する構成が現実的で、その時点でGAS単体の手軽さは失われます。
判断の目安
文書が数十本・社内用語が安定している・利用者が社内メンバーなら、キーワード検索で始める。 文書が数百本を超える、外部からの問い合わせで言い回しが読めない、という段階になったらベクトル検索を検討する。 まずキーワード版を運用し、ログでヒットしない質問の割合を見てから判断するのが確実です。
まとめ
社内文書QAは、全文をAIに渡すのではなく検索で絞ってから渡すのが基本形です。GASなら、知識の置き場はスプレッドシート、検索はキーワードのスコアリングで足ります。 専用のデータベースを立てなくても、集める・分割する・探す・答えさせる・育てるの5ステップで動くものができます。
精度を決めるのは検索方式より、チャンクの切り方とsystemプロンプトの3行です。 まず見出し単位で分割し、「資料だけを根拠に、無ければ無いと答え、出典を書く」を徹底してください。 そのうえでヒットしない質問をログから拾い、対応表を足していけば、運用しながら賢くなっていきます。
よくある質問
RAG(Retrieval-Augmented Generation/検索拡張生成)は、質問に関係のありそうな文書をまず検索し、その文章を一緒に渡してAIに答えさせる方法です。AIそのものは変えず、渡す材料を変えます。ファインチューニング(モデル自体を追加学習させる方法)と違い、マニュアルを更新したらシートを直すだけで反映されます。社内文書QAの用途では、まずRAGで十分なことがほとんどです。
文書が数十〜数百ページ程度で、社内用語がある程度そろっているなら、キーワード一致のスコアリングでも実用になります。むしろ精度を落とす原因は検索方式より「1チャンクが大きすぎる」「表記ゆれを吸収していない」ことのほうが多いです。ベクトル検索は語彙が違っても意味で拾える点が強みなので、言い換えが多い問い合わせ対応まで広げる段階で検討してください。
送る範囲を自分で決められる点がRAGの利点です。全文ではなく、ヒットした数チャンクだけが送信されます。そのうえで、送信先の利用規約(APIで送ったデータが学習に使われないか)を必ず確認し、給与や個人情報を含むシートは対象フォルダから外してください。個人情報が混ざりうる場合は、送信前にマスキング処理を挟む設計にします。
systemプロンプトで「渡した資料に書かれていることだけで答える」「無ければ『資料に記載がありません』と答える」と明示し、出典(見出しや行番号)も一緒に出させてください。出典を書かせると、根拠のない回答が目に見えて減ります。それでも起きる場合は、そもそも検索がヒットしていない可能性が高いので、まず検索結果を目視で確認します。
日本語なら300〜800文字が扱いやすい目安です。短すぎると前提が切れて意味が通らず、長すぎると関係のない話まで一緒にヒットして検索がぼやけます。見出しで区切れる文書なら、文字数より見出し単位で分けるほうが精度が出ます。分割時に見出しを各チャンクの先頭へコピーしておくと、検索にも回答の出典表示にも効きます。
検索はシートを読んで文字列を照合するだけなので、数千チャンク程度なら数秒で終わります。重いのは分割・登録のほうです。ドライブの文書を一括で取り込む処理は、時間ベースのトリガーで分割実行し、処理済みフラグを立てて再開できるようにしてください。GASの実行時間は1回あたり6分(無料アカウント)が上限です。