RAGを活用してセキュリティチェックシートの回答作業を自動化した話
はじめに
今回は、RAG(Retrieval-Augmented Generation)を使ってセキュリティチェックシートへの回答を自動化した事例を紹介します。生成AI技術をどう業務に活かせるのか、実際のところどうだったのか、できるだけ具体的にお伝えできればと思います。
そもそもの問題
最近のシステム開発では、何でもかんでも自分たちで作るのではなく、外部のサービスをAPIで使うことが増えてきました。開発スピードが上がって便利なのですが、私の会社は金融系ということもあり、外部サービスを使う前にFISCの安全対策基準に基づいたセキュリティチェックが必須になっています。
このチェックシート、とにかく項目が多いのです。400項目以上あります。
1項目あたりの作業を考えてみると、まず質問の意図を理解して、次にサービス提供会社が出してくるSOC2レポート(だいたい100ページ超えのPDF)から該当しそうな箇所を探して、内容を読んで、要約して、回答欄に書く...という感じです。これが1項目で平均20分くらいかかります。単純計算で8,000分、つまり134時間です。
開発スピードを上げるためにサービスを使おうとしているのに、そのための準備だけで2週間以上取られるって、なんというか本末転倒だなと常々思っていました。
生成AIで何とかできないか
ここ1〜2年で生成AI周りの技術が一気に進化したので、この面倒な作業を何とか自動化できないかと考え始めました。
最終的に採用したのは、Google Apps Script(以下GAS)とGoogle Spreadsheet、それにVertex AI Searchを組み合わせた構成です。やりたいことはシンプルで、「チェックシートの質問を読み取って、RAGでSOC2レポートを検索して、回答を生成してスプレッドシートに書き込む」というだけです。
システムの全体像
全体の流れを図にするとこんな感じになります。
GASがスプレッドシートから質問を読み取って、Vertex AI SearchのSearch APIを叩きます。このレスポンスからセッション名とqueryIdというものを取得して、それを使ってAnswer APIを呼び出すと、コンテキストを保ったまま回答を生成してくれます。最後にその回答をスプレッドシートに書き戻す、という流れです。
実際のチェックシートの構成
元々のチェックシートはExcel形式で、Google Driveにアップロードしてスプレッドシートに変換して使いました。列の構成はこんな感じです。
| 項番 | 大分類 | 中分類 | 小分類 | 統制領域 | ... | M列: 設問 | ... | S列: 回答 | T列: エビデンス指示 |
|---|---|---|---|---|---|---|---|---|---|
| 1 | サイバー | ... | ... | CC6.1 | ... | 貴社ではベンダーサプライヤ管理に関するポリシーを定めていますか? | ... | (ここに回答が入る) | システム構成管理 |
| 2 | サイバー | ... | ... | CC6.2 | ... | すべてのユーザーに一意のアカウントとパスワードを要求していますか? | ... | (ここに回答が入る) | 利用者ID管理 |
M列が質問で、S列が回答の出力先、T列がエビデンス検索のヒントです。データは2行目から始まっています。
SOC2レポートについて
今回対象にしたのは、サービス提供会社から提供してもらうSOC2 Type2レポートです。SOC2というのはAICPAが定めたセキュリティ監査の基準で、Type2レポートだと半年から1年くらいの期間、第三者機関が実際に統制が運用されているかを見てくれます。
レポートの中身は、情報セキュリティポリシー、アクセス制御、システム監視、インシデント対応、データ管理といった項目について、かなり詳しく書かれています。ただ、100ページ以上あるPDFをいちいち読むのは大変です。
生成される回答の例
システムが最終的に生成する回答はこんな感じになります。
HOGE Technology.Incは第三者機関による審査を定期的に受けており、SOC2 Type2レポートにより次のことが査定されています。
HOGE Technology Inc.は、情報セキュリティポリシーを定めており、これには脆弱性管理、資産管理、リスク評価、データ保持に関するポリシーが含まれます。これらのポリシーは、適用される法令および規制に準拠し、情報資産のセキュリティ要件を考慮して策定されています。すべての担当者は、これらのポリシーを読み、同意し、遵守することが求められています。
該当文書名:15-AssetManagementPolicy, 19-DataRententionPolicy, 21-InformationSecurityPolicy, 23-RiskAssessmentPolicy, 25-VulnerabilityPolicy
回答本文だけでなく、引用元の文書名も自動で付けてくれるので、後で確認するときも楽です。
どうやって作ったか
1. SOC2レポートをCloud Storageにアップロード
まずはSOC2レポートのPDFファイルをGoogle Cloud Storageのバケットに格納します。
# バケット作成
gsutil mb gs://your-bucket-name/
# PDFアップロード
gsutil cp *.pdf gs://your-bucket-name/
2. Vertex AI Searchのセットアップ
Google Cloud Consoleからデータストアと検索アプリを作ります。データソースには先ほど作ったCloud Storageのバケットを指定して、データタイプは非構造化データ(PDF)にします。検索アプリでは拡張生成(RAG)を有効にしておきます。
ここで取得したエンジンIDとコレクションIDは後でGASスクリプトで使います。
3. スプレッドシートの準備
ExcelファイルをGoogle Driveにアップロードし、そのファイルをスプレッドシート形式で保存します。
4. GASスクリプトを書く
スプレッドシートの「拡張機能」から「Apps Script」を開いてコードを書きます。
ただ、正直なところ、Apps Scriptのエディタってあまり使いやすくないと感じます。なので、今回はCLASP(Command Line Apps Script Projects)というツールを使いました。これを使うとローカルのVS Codeで開発できて、TypeScriptも使えるし、Gitでバージョン管理もできるし、何よりClaude Codeみたいな開発支援AIと一緒に作業できるのが最高でした。
EZ CLASPというテンプレートを使うと、TypeScript、Jest(テスト)、Biome(リンター)、Huskyによるプレコミットフックまで全部セットアップ済みの環境が手に入ります。めちゃくちゃ便利です。
スクリプトの中身
メインのスクリプトはこんな感じです。設定部分だけ自分の環境に合わせて書き換えます。
メインのスクリプト
// ==============================================================================
// 設定項目
// ==============================================================================
const PROJECT_ID = "YOUR_GCP_PROJECT_ID"; //Google CloudのプロジェクトID
const LOCATION = "global";
const ENGINE_ID = "YOUR_ENGINE_ID"; //Vertex AI Searchで作成したアプリID
const COLLECTION_ID = "default_collection";
const SHEET_NAME = "運用"; //ご自身のスプレッドシートのシート名に合わせてください
// スプレッドシートの列インデックス
const QUESTION_COL_INDEX = 13; // M列(設問)
const INSTRUCTION_COL_INDEX = 20; // T列(エビデンス指示)
const ANSWER_COL_INDEX = 19; // S列(回答出力)
const START_ROW = 2; // データ開始行
// API関連の定数
const API_SLEEP_DURATION_MS = 500; //GASのレートリミット回避のための一時停止時間
const HTTP_STATUS_OK = 200;
// APIエンドポイント
const ANSWER_URL = `https://discoveryengine.googleapis.com/v1alpha/projects/${PROJECT_ID}/locations/${LOCATION}/collections/${COLLECTION_ID}/engines/${ENGINE_ID}/servingConfigs/default_search:answer`;
const SEARCH_URL = `https://discoveryengine.googleapis.com/v1alpha/projects/${PROJECT_ID}/locations/${LOCATION}/collections/${COLLECTION_ID}/engines/${ENGINE_ID}/servingConfigs/default_search:search`;
// ==============================================================================
// メイン処理
// ==============================================================================
function processSecurityQuestions() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_NAME);
const ui = SpreadsheetApp.getUi();
if (!sheet) {
ui.alert(`エラー: シート「${SHEET_NAME}」が見つかりません。`);
return;
}
const lastRow = sheet.getLastRow();
// 1行ずつ処理していく
for (let i = START_ROW; i <= lastRow; i++) {
const question = sheet.getRange(i, QUESTION_COL_INDEX).getValue();
const instruction = sheet.getRange(i, INSTRUCTION_COL_INDEX).getValue();
if (!question) continue;
const finalPrompt = `
ルール
=======
${question}
=======
このルールに関する
=======
${instruction}
=======
を検索して、検索結果に含まれる記載内容を要約して回答してください。
`;
try {
// まずSearch APIでセッションとqueryIdを取得
const searchResponse = queryDiscoveryEngineSearch(finalPrompt);
const queryId = searchResponse.sessionInfo.queryId;
const sessionName = searchResponse.sessionInfo.name ||
`projects/${PROJECT_ID}/locations/${LOCATION}/collections/${COLLECTION_ID}/engines/${ENGINE_ID}/sessions/-`;
if (!queryId) {
throw new Error("queryIdを取得できませんでした。");
}
// 次にAnswer APIで回答を生成
const answerResponse = queryDiscoveryEngineAnswer(finalPrompt, queryId, sessionName);
const { formattedAnswer } = parseDiscoveryAnswerResponse(answerResponse);
// S列に書き込む
sheet.getRange(i, ANSWER_COL_INDEX).setValue(formattedAnswer);
Utilities.sleep(API_SLEEP_DURATION_MS);
} catch (e) {
Logger.log(`Error processing row ${i}: ${e.message}`);
sheet.getRange(i, ANSWER_COL_INDEX).setValue(`ERROR: ${e.message}`);
}
}
ui.alert("全設問の処理が完了しました。");
}
function queryDiscoveryEngineSearch(finalPrompt) {
const payload = {
query: finalPrompt,
pageSize: 10,
queryExpansionSpec: { condition: "AUTO" },
spellCorrectionSpec: { mode: "AUTO" },
languageCode: "ja",
contentSearchSpec: { snippetSpec: { returnSnippet: true } },
userInfo: { timeZone: "Asia/Tokyo" },
session: `projects/${PROJECT_ID}/locations/${LOCATION}/collections/${COLLECTION_ID}/engines/${ENGINE_ID}/sessions/-`
};
const options = {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` },
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(SEARCH_URL, options);
const responseCode = response.getResponseCode();
if (responseCode !== HTTP_STATUS_OK) {
throw new Error(`Search API Error: ${response.getContentText()}`);
}
return JSON.parse(response.getContentText());
}
function queryDiscoveryEngineAnswer(finalPrompt, queryId, sessionName) {
const payload = {
query: { text: finalPrompt, queryId: queryId },
session: sessionName,
answerGenerationSpec: {
ignoreAdversarialQuery: true,
ignoreNonAnswerSeekingQuery: false,
ignoreLowRelevantContent: true,
includeCitations: true,
promptSpec: {
preamble: `Given the conversation between a user and a helpful assistant and some search results, create a final answer for the assistant. The answer should use all relevant information from the search results, not introduce any additional information, and use exactly the same words as the search results when possible. The assistant's answer should be brief, no more than 4 or 5 sentences.
At the top of the summary, always set this sentences, followed by the answer based on the search results:
"HOGE Technology.Incは第三者機関による審査を定期的に受けており、SOC2 Type2レポートにより次のことが査定されています。 ".
After the summary, add a final line exactly in this format: "該当文書名:(エビデンスとなった文書名を全て列挙してください)".`
},
modelSpec: { modelVersion: "stable" }
}
};
const options = {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` },
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(ANSWER_URL, options);
const responseCode = response.getResponseCode();
if (responseCode !== HTTP_STATUS_OK) {
throw new Error(`Answer API Error: ${response.getContentText()}`);
}
return JSON.parse(response.getContentText());
}
function parseDiscoveryAnswerResponse(response) {
let answerText = "回答が生成できませんでした。";
if (response.answer?.answerText) {
answerText = response.answer.answerText.trim();
}
// 大抵の場合、PreambleのおかげでanswerTextに既に文書名が含まれている
if (answerText.includes("該当文書名:")) {
return { formattedAnswer: answerText };
}
// 念のため、含まれていない場合はreferencesから抽出
const citedDocuments = new Set();
if (response.answer?.references) {
for (const ref of response.answer.references) {
if (ref.chunkInfo?.documentMetadata?.title) {
citedDocuments.add(ref.chunkInfo.documentMetadata.title);
}
}
}
const citationsString = Array.from(citedDocuments).join(", ");
if (citationsString) {
answerText += `\n\n該当文書名:${citationsString}`;
} else {
answerText += "\n\n該当文書名:情報なし(SOC2レポート)";
}
return { formattedAnswer: answerText };
}
スクリプト内のpreamble(前文)について
こちらはいわゆるシステムプロンプトで、問い合わせ(今回はスプレッドシート1行)ごとに指示するのではなく、システム共通で指示するための設定です。
preamble:
`Given the conversation between a user and a helpful assistant and some search results,
create a final answer for the assistant.
The answer should use all relevant information from the search results,
not introduce any additional information,
and use exactly the same words as the search results when possible.
The assistant's answer should be brief, no more than 4 or 5 sentences.
At the top of the summary, always set this sentences,
followed by the answer based on the search results:
"HOGE Technology.Incは第三者機関による審査を定期的に受けており、SOC2 Type2レポートにより次のことが査定されています。 ".
After the summary, add a final line exactly in this format: "該当文書名:(エビデンスとなった文書名を全て列挙してください)".`
参照・検索する文書量が多いためRAGによる回答は長くなりがちです。しかし今回の要件ではスプレッドシートの1セルにその回答を収める必要があります。そのため、4〜5文に収まるように要約することを明示的にしています。また、各設問に対する回答には客観性があることと引用元を明記することによりエビデンスの正当性を納得してもらうために、生成された要約結果の前後に定型文をつけるよう、指示しています。
この辺りは要件によるので、ご自身にあったプロンプトに書き換えてください。
マニフェストファイル(appsscript.json)
GASで外部APIを叩くには、適切なOAuthスコープを設定する必要があります。
{
"timeZone": "Asia/Tokyo",
"dependencies": {
"enabledAdvancedServices": [],
"libraries": []
},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/script.external_request",
"https://www.googleapis.com/auth/cloud-platform"
]
}
spreadsheetsはスプレッドシートの読み書き、script.external_requestは外部APIへの接続、cloud-platformはGoogle Cloudのサービスへのアクセスに必要です。
5. 権限の設定
GASを実行するユーザーに、Vertex AI Searchを使う権限を付与します。Google Cloud ConsoleのIAMで、ユーザーのGoogleアカウントに「Discovery Engine Admin」か「Discovery Engine Editor」のロールを付ければOKです。
6. 実行
Apps ScriptエディタでprocessSecurityQuestions関数を選んで「実行」ボタンを押します。初回は権限の承認画面が出るので、指示に従って承認します。あとは待つだけです。
7. 結果確認
スプレッドシートのS列を見ると、質問に対する回答が埋まっているはずです。
他の方法は検討しなかったのか
実は最初からこの構成に決めていたわけではなく、いくつか候補を検討しました。
NotebookLMを使う案
NotebookLMにPDFを読み込ませて質問する方法です。ただ、これだと1項目ずつ質問して、回答を手でExcelに貼り付けることになります。400項目でそれをやるのは現実的じゃないと判断しました。
Gemini Enterpriseを使う案
以前Agentspaceと呼ばれていたサービスです。NotebookLMをエージェント化して、Spreadsheetを操作するカスタムエージェントと組み合わせればいけるかなと思ったんですが、これGoogle Workspaceとは別契約で、しかも最低50アカウントからという縛りがあって断念しました。
自前でアプリを作る案
PythonとかでAIエージェントを作る方法も考えました。ADKみたいなツールキットもありますし。でも、アプリを作るコスト、さらにサーバーや認証基盤を用意する運用コストを考えると、やりたいことに対して大げさすぎると感じました。
Vertex AI Search単独で使う案
Vertex AI Search自体はフルマネージドのRAGシステムなんですが、基本的にはSDKやREST API経由で使うもので、それ単体でアプリケーションとして完結しません。スプレッドシートとも繋げられないので、これも不採用です。
最終的にGAS + Vertex AI Searchにした理由
結局、今回の構成に落ち着いた理由はいくつかあります。
まず、Google Workspace製品との相性がいいこと。スプレッドシートをそのまま使えるし、追加でサーバーを立てる必要もありません。認証もGoogle WorkspaceのSSOをそのまま使えるので、複雑な認証フローを作る必要がないのも楽でした。
それから、CLASPを使えばローカル環境で開発できて、TypeScriptやテストツール、リンターも使えるし、Claude Codeみたいな開発支援AIとも連携できます。Apps Scriptの弱点だった開発環境の問題が一気に解決しました。
コスト面でも、追加の基盤構築が不要で、Vertex AI Searchの従量課金だけで済みます。開発工数も最小限で、保守もGoogleが勝手にやってくれます。
今回の要件に対しては、これが一番シンプルで現実的な解だったと思います。
実際どれくらい楽になったか
数字で見てみます。
従来の手作業だと、400項目 × 20分 = 8,000分で約134時間かかっていました。
自動化した後は、システムの実行に1時間、出力結果の理解とチェックに4時間、自動処理できなかった一部の設問への手動対応に5時間で、合計10時間くらいに収まります。(自動処理できるか否かはインプットになるレポートの記載内容に依存する部分が大きいです)
差し引き124時間、削減率にして92.5%です。
エンジニアの時給を仮に5,000円とすると、年間62万円分の工数削減になります。システム構築に5時間くらいかけたので、2週間程度で元が取れる計算です。Vertex AI Searchの従量課金も月数千円程度なので、コストパフォーマンスはかなり良いと思います。
数字には出ないメリットもあって、人的ミスが減ったこと、回答の品質が安定したこと、引用元が自動で記録されるのでトレーサビリティが向上したこと、あとは単純作業から解放されてエンジニアのモチベーションが上がったことも大きいです。
まとめ
今回、セキュリティチェックシートの回答作業を自動化して、92.5%の作業時間削減を実現できました。
うまくいった理由を振り返ると、既存のインフラ(Google Workspace)を最大限活用したこと、フルマネージドサービス(Vertex AI Search)を選んだこと、過剰なシステム開発を避けてシンプルに作ったこと、あたりがポイントだったと思います。
RAGの活用方法としても、2段階API呼び出し(Search → Answer)でコンテキストを維持したり、カスタムPreambleで出力フォーマットを制御したり、引用元を自動記録して信頼性を担保したりと、いくつか工夫できました。
開発環境についても、CLASPを使ってモダンな開発フローを整えたこと、Claude Codeと連携できたことで、開発効率がかなり上がりました。
今回はセキュリティチェックシートが対象でしたが、似たような業務は他にもたくさんあると思います。契約書レビュー、技術仕様書の適合性チェック、監査対応資料の作成、コンプライアンスチェックなど、Excel/Spreadsheetを使った従来型の業務なら、同じような考え方で自動化できる可能性があります。
生成AI技術は日々進化していますが、大事なのは「何ができるか」よりも「どう使うか」だと思っています。既存の業務フローをちゃんと理解して、適切な技術を選んで組み合わせれば、少ないコストで大きな効果を出せることを実感しました。
参考リンク
Discussion