既存のASP.NET Core業務アプリにRAG(検索拡張生成、外部データを検索してLLMの回答に反映させる仕組み)を追加しようとしているバックエンドエンジニアに向けた内容です。チャットUIの見た目よりも先に確認すべき、権限制御の設計ミスを整理します。
顧客サポート向けのコパイロット(支援ツール)にRAGを組み込む場合、典型的な構成はSQL Serverにドキュメントをチャンク(文章を一定サイズに分割した単位)として保存し、埋め込みベクトル(文章の意味を数値列に変換したもの)をJSON列として持たせるやり方です。新規にベクトルDBを立てずに既存のデータ層で完結できるため、既存アプリへの後付けとしては選びやすい構成です。
この構成自体は悪くありません。問題は、チャンクを保存・検索する処理のどこかで「今ログインしているユーザーがそのドキュメントを読んでよいか」のチェックが抜け落ちやすい点にあります。
何が起きるか
RAGを導入すると、アシスタントは質問に関連しそうなチャンクをベクトル検索で取得し、それをプロンプトに埋め込んでLLMに渡します。このとき検索対象になるのは、組織やテナントをまたいだ全チャンクであることが珍しくありません。
結果として、Aテナントのユーザーが質問しただけなのに、Bテナントの契約書や顧客対応履歴の断片が回答に混ざるケースが起こり得ます。チャット画面上は自然な回答に見えるため、ログを見ない限り発覚しにくいのが厄介なところです。
さらに厄介なのは、注文ステータスのような「今まさに変化する業務データ」を誤って埋め込み生成の対象にしてしまう設計です。昨日のDBエクスポートから作った埋め込みが、最新の注文状況として回答に出てきてしまうと、情報漏洩とは別の正確性の問題も発生します。
なぜ起きるか
原因は大きく3段階に分解できます。
1段階目は、チャンク保存時にテナントIDやドキュメントの所有者情報を「信頼できない入力」から取ってしまう設計です。アップロードフォームの隠しフィールドやリクエストボディに含まれる組織IDをそのまま使うと、改ざんや取り違えでスコープが崩れます。サーバー側のID(ASP.NET Core Identityのクレームなど)から取得すべき値を、クライアント由来の値で代用してしまうのが典型的なミスです。
2段階目は、検索クエリの発行時にWHERE句でテナントやロールによる絞り込みを入れ忘れることです。ベクトル類似度検索はSQL Serverの通常のSELECTと組み合わせて実装されることが多く、コサイン類似度の計算ロジックに気を取られてWHERE OrganizationId = @currentOrgのような一行を書き忘れる、というヒューマンエラーが起きがちです。
3段階目は、「モデルが知っていること」と「ユーザーが読んでよいこと」を同一視してしまう設計思想そのものです。LLMに渡すコンテキストに対象ドキュメントが含まれていれば、モデルはそれを使って回答を組み立てます。モデル自身に権限判断をさせる(プロンプトで「このユーザーの権限外なら答えないで」と指示する)のは、アプリケーション側のアクセス制御の代わりにはなりません。これはRAG特有の落とし穴というより、認可(authorization)はアプリケーション層の責務であり、モデルへの指示で代替できないという原則の話です。
自分のプロジェクトが該当するか確認する方法
実際に該当するかどうかは、次の観点でコードとスキーマを見れば判断できます。
- チャンクを保存するエンティティ(例:
KnowledgeChunk)にOrganizationIdやTenantIdに相当する列があるか、マイグレーションファイルやOnModelCreatingを確認する - そのIDが、保存処理の引数として渡される際に、どこから来ているかを追う。コントローラーやサービスの引数名が
trustedOrganizationIdのようにサーバー側由来だと明示されているか、それとも単にrequest.OrganizationIdのようにリクエストDTOから来ているかを確認する - 検索クエリ(ベクトル類似度を使うSQLやLINQ)に、テナントIDやユーザーのロールによるフィルタ条件が含まれているかを確認する。検索結果を取得した後にC#側でフィルタしている場合、フィルタ漏れがあると一度取得してしまった時点で情報が外に出るリスクが残る
- 注文ステータスや在庫数など、頻繁に更新される値が埋め込み生成のテキストに含まれていないかを確認する。こうした値は埋め込みではなく、既存の
GetOrderStatusのような通常のサービス呼び出しで都度取得する設計になっているか見る
これらはいずれも、コードレビューやgrepで数分で確認できる項目です。特にベクトル検索を行うSQL文やLINQクエリに対して、OrganizationIdやTenantIdという文字列でgrepをかけ、全ての検索経路に絞り込み条件が付いているか洗い出すのが手早い方法です。
対策の手順
対策は設計変更というより、権限チェックの置き場所を明確にする作業です。
1. チャンク保存時のスコープ値(組織ID・ドキュメントID)は、必ずサーバー側のID(ClaimsPrincipalから取得したユーザー情報やテナント解決ミドルウェアの結果)から取る。アップロードリクエストのボディにある値は検証のみに使い、スコープの決定には使わない
2. 検索クエリには例外なくスコープのWHERE条件を入れる。EF Coreを使っているなら、グローバルクエリフィルタ(HasQueryFilter)でテナントIDを常時適用する設計にすると、個々のクエリでの書き忘れを防げる
3. 検索結果をLLMのプロンプトに渡す直前に、もう一段のチェックを入れる。検索処理自体にバグがあった場合の保険として、取得したチャンクのOrganizationIdが現在のユーザーのテナントと一致するか再検証するフィルタをアプリケーション層に置く
4. 注文ステータスのようなリアルタイム性が求められるデータは埋め込みの対象から外し、ツール呼び出し(tool calling、LLMが外部の関数やAPIを呼び出す仕組み)経由で既存の.NETサービスを呼ぶ設計にする。ドキュメント(ポリシーや手順書)とトランザクションデータ(注文状況など)を明確に分けて扱う
5. 人間のレビューを挟む運用であっても、レビュアーに見せる前の段階で権限チェックを済ませておく。レビューは回答品質の確認であって、アクセス制御の代替ではない
まとめ
RAGをASP.NET Core製の既存業務アプリに追加する際、チャンクの保存・検索経路でテナントや権限のスコープが抜けると、チャット画面には出てこない形で情報漏洩が起こり得ます。
確認の第一歩として、チャンクエンティティのテナントID列がサーバー側由来か、検索クエリに絞り込み条件が入っているかをgrepで洗い出してみてください。
EF Coreのグローバルクエリフィルタでテナント条件を常時適用し、LLMに渡す直前にもう一度スコープを検証する二重のチェックを入れておくと、実装ミスが一箇所で起きても情報漏洩まで到達しにくい構成になります。
モデルへの指示でアクセス制御を代替しようとせず、認可はアプリケーション層の責務として最後まで実装し切ることが、RAG導入時の最初の確認ポイントです。