LLM Frontline
開発・エージェント

RAGのアクセス制御設計|社内文書の閲覧権限をベクトル検索にどう持ち込むか

ミナト開発・API担当
・ 約37分で読めます
RAGのアクセス制御設計|社内文書の閲覧権限をベクトル検索にどう持ち込むか

社内文書を対象にしたRAGを作ると、多くのチームが同じ場所で立ち止まります。ファイルサーバやSharePoint、Google Drive、Confluenceには長年積み上げてきた閲覧権限があるのに、そこから吸い出してベクトルDBに入れたチャンクには、その権限がどこにも残っていない。結果として、人事情報や未公開の案件資料が、権限を持たない社員の質問に対する「参考文書」として出てきてしまいます。しかもRAGは文書をそのまま見せるのではなく要約して答えるので、ファイルを開く権限がなくても中身が読めてしまう。これがRAGにおけるアクセス制御の問題の中心です。この記事では、権限がすり抜ける仕組み、設計パターンの比較、ANNインデックスとフィルタの相性という技術的な制約、実装の勘所、運用・監査までを扱います。

なぜ権限がすり抜けるのか

事故の起き方はほぼ決まっています。原因を4つに分けて見ていきます。

チャンクはACLを持たない

RAGの取り込み処理は、たいてい「ファイルを読む -> テキストを抽出する -> 分割する -> 埋め込みベクトルにする -> ベクトルDBに入れる」という流れです。この過程で残るのは本文とベクトル、せいぜいファイル名やURLくらいで、元のファイルシステムやSaaSが持っていたアクセス制御リスト(ACL)は落ちます。抽出ライブラリの出力に権限情報が入っていないので、意識して取りに行かない限り残りません。検索側から見ると、そのチャンクを誰が読んでよいのかを判断する材料が最初から存在しない状態です。ここに気づかないまま「まず全社の文書を全部入れてみよう」と進めると、検索インデックスが事実上の全社共有フォルダになります。

ベクトル検索はスコア順であって権限順ではない

ベクトル検索が返すのは「質問に意味が近い順」の上位k件です。そこに権限という概念はありません。人事の給与テーブルの説明文が質問と意味的に最も近ければ、それが1位で返ります。検索エンジンの仕事としては正しく、アクセス制御としては完全に間違っている、という状態です。

LLMが「読めない」を成立させない

従来の全文検索であれば、検索結果にタイトルが出てもファイルを開く段階で権限チェックが働き、実害が抑えられることがありました。RAGではそうなりません。取得したチャンクはそのままLLMのコンテキストに入り、要約・言い換えされて回答文になります。ユーザーは一度もファイルを開かずに中身を受け取ります。

OWASPのGen AI Security Projectは、この構造をLLM08:2025(Vector and Embedding Weaknesses)として整理しています。冒頭でRAGを名指しし、リスクの1番目に「不正アクセスとデータ漏えい」を挙げて、アクセス制御が不十分または噛み合っていない場合、機密情報を含む埋め込みへの不正なアクセスが生じ、モデルが個人データや専有情報を取得して開示しうると述べています。2番目には、複数の利用者クラスやアプリケーションが同じベクトルDBを共有するマルチテナント環境で、利用者やクエリをまたいで文脈が染み出すリスク(cross-context leak)が挙がっています。緩和策の筆頭は「きめ細かなアクセス制御と、権限を意識したベクトル/埋め込みストア」、そして「データセットの厳密な論理分割・アクセス分割」です。

なお隣のLLM02:2025(Sensitive Information Disclosure)は、RAG固有の話ではなく、機密情報がモデルの出力経由で開示されるリスク全般を扱う項目です。そちらでも「返してよいデータ型をシステムプロンプトで制限することは緩和策になりうるが、常に守られるとは限らず、プロンプトインジェクション等で回避されうる」と明記されています。次に見るとおり、プロンプトは境界にならない、という点はここでも共通しています。

プロンプトによる指示は制御ではない

「あなたは社内アシスタントです。人事情報や機密資料の内容は開示しないでください」というシステムプロンプトは、書いておく価値はありますが、アクセス制御の代替にはなりません。その指示が効く時点で、すでに機密チャンクはコンテキストに載っています。載っているものを出さないでほしいという「お願い」に成否を委ねている状態であり、言い回しを変えた質問、要約の要求、翻訳の依頼などで容易に引き出せます。

注意

アクセス制御は、モデルに何を指示するかではなく、モデルに何を渡さないかで決まります。プロンプト、出力側のフィルタ、LLM-as-a-judgeによる事後チェックは、いずれも取りこぼしを減らす補助であって、境界そのものにはなりません。境界を作れるのは、検索の前段で候補集合を絞る処理だけです。

あわせて読みたい

RAGとは何か|仕組み・向き不向き・導入判断の考え方

4つの設計パターンを比較する

権限をベクトル検索に持ち込む方法は、大きく4つに整理できます。実務ではこれらを組み合わせますが、まず単体の性質を押さえます。

パターンやること長所短所・向かない場面
事前フィルタ(security trimming)検索クエリにユーザーの主体ID(ユーザーID・グループID)を渡し、メタデータフィルタで候補を絞ってからベクトル検索する上位k件が権限内で埋まる。多くのベクトルDBが標準機能として持つANNインデックスとの相性で再現率が落ちうる。グループ数が多いとフィルタ式が肥大する
事後フィルタベクトル検索で上位k件を取ってから、アプリ側で権限をチェックして落とす実装が最も簡単。既存の権限判定APIをそのまま呼べる上位k件が全部権限外なら結果が空になる。取りこぼしを埋めるためkを大きくするとコストとレイテンシが増える
インデックス分割テナント・部門・機密区分ごとにコレクションやテーブルを分ける分離が構造的に保証される。誤ってフィルタを付け忘れても混ざらない区分の組み合わせで数が爆発する。横断検索が難しく、運用対象が増える
ACL同期(権限メタデータ)取り込み時に、元システムの読み取り可能な主体IDのリストをチャンクのメタデータとして保存し、検索時に突き合わせる元システムの権限をそのまま反映できる。実質的に事前フィルタの土台になる同期の遅延が生じる。グループの入れ子展開、共有リンク、継承の解決が重い

事前フィルタ(security trimming)

もっとも標準的な考え方です。チャンクに「このチャンクを読める主体IDの配列」を持たせ、検索時に呼び出し元ユーザーが属する主体IDのいずれかと一致するものだけを候補にします。Microsoft LearnのAzure AI Searchのドキュメントは、このパターンをsecurity filter patternとして具体的に説明しています。インデックスにCollection(Edm.String)型の主体IDフィールドを作り、filterableをtrue、retrievableをfalse(検索結果として返さない)に設定したうえで、クエリ時に次のようなODataフィルタを付けます。

group_ids/any(g:search.in(g, 'group_id1, group_id2'))

同ドキュメントは、Id eq 'id1' or Id eq 'id2' のような等値式の羅列でも同じことはできるが、値が数百から数千になると応答時間が数秒単位で悪化するため、search.in関数を使うべきだと明記しています。「グループIDが増えるとフィルタ式のコストが跳ねる」という性質は、どのベクトルDBでも形を変えて現れます。

同時に重要な注意も書かれています。security principalによる認証・認可は行われておらず、principalは単なる文字列としてフィルタ式に使われているだけだ、という点です。つまりクエリに渡す主体IDの正しさはアプリ側の責任であり、クライアントから送られてきたグループIDをそのまま信じる実装にすると、フィルタは付いているのに意味をなしません。

事後フィルタ

取得後にチェックする方式です。「まず上位20件取って、権限のないものを落とす」という素朴な実装で、既存の権限判定ロジックを再利用できるのが利点です。

問題は結果が空になることです。ある部門の文書が全体の1%しかない場合、上位20件のほとんどは他部門の文書で埋まり、権限チェック後に残るのは0件か1件になります。Qdrantの解説記事も、低カーディナリティのフィルタを検索後に適用すると結果の大部分を捨てることになり、計算資源を無駄にするうえ、そもそも初回の結果集合に入っていなかった関連文書は取り逃すと指摘しています。

ただし事後フィルタ自体が悪いわけではありません。後述するように、事前フィルタと事後チェックの二段構えは実装ミスに対する保険として機能します。危険なのは「事後フィルタだけ」の構成です。

インデックス分割

テナントや機密区分ごとに別のコレクション・別テーブルに分ける方式です。フィルタの付け忘れがそのまま漏えいになる事前フィルタ方式に比べ、構造的に混ざらないという安心感があります。

ただし数が増えると破綻します。Qdrantの公式ドキュメントは、ほとんどの場合は埋め込みモデルごとに単一コレクションを使い、テナントはペイロードで分割することを推奨しており、強い分離が必要な少数の利用者に限って複数コレクションを使うべきだとしています。理由として、数百から数千のコレクションを作るとリソースのオーバーヘッド、性能劣化、クラスタの不安定化につながるとし、Qdrant Cloudではクラスタあたりのコレクション数を1000に制限していることも明記しています。

一方、pgvectorのREADMEは別の角度から分割を勧めています。近似インデックスをテナント間で共有すると、あるテナントのベクトルが他のテナントの再現率と速度に影響しうるため、テナント分離にはリストパーティショニングか別テーブルを使う、という記述です。Qdrantは運用コスト側の警告、pgvectorは検索品質側の理由と視点が異なるので、自分の構成でどちらの制約が効くかを見極めてください。現実的な落としどころは、分割の軸を1つか2つに絞ることです。「顧客テナント」のように絶対に混ざってはいけない軸だけを物理分割し、部門や機密区分といった多値の軸はメタデータフィルタで扱う配分が扱いやすくなります。

ACL同期(権限メタデータ)

事前フィルタを成立させるには、そもそもチャンクに主体IDのリストが載っていなければなりません。それを元システムから取ってきて同期し続けるのがこのパターンです。

各クラウドのマネージドサービスも、この方向に機能を伸ばしています。Azure AI Searchのドキュメントは、文書レベルのアクセス制御として、文字列比較のsecurity filtersに加えて、POSIX風のACL/RBACスコープ、Microsoft Purviewの秘密度ラベル、SharePointのACL取り込みという4つのアプローチを整理しています(後者3つはプレビュー機能として案内されています)。クエリ時にはx-ms-query-source-authorizationヘッダーで利用者のトークンを渡し、トークンのクレームとインデックスに保存された権限メタデータを突き合わせて、許可されない文書を除外する仕組みです。

Google Cloudのエンタープライズ検索基盤(公式ドキュメントではAgent Searchと表記)も、データにacl_infoフィールドを持たせ、readersとしてユーザーIDやグループIDを列挙します。同ドキュメントには、1文書あたり3000 readersまで、ロケーションあたりのIDプロバイダは1つ、そしてアクセス制御はデータストア作成時に選択する必要があり後から変更できない、という制約が明記されています。最後の点は設計順序に直結するので、採用を検討するなら最初に読んでおくべき箇所です。

Amazon Bedrock Knowledge Basesではメタデータフィルタが基本の道具になります。公式ドキュメントによれば、equals / notEquals / greaterThan / lessThan / in / notIn / stringContains / listContains / startsWithといった演算子があり、andAll(すべての条件を満たす)とorAll(いずれかの条件を満たす)で最大5つのフィルタをまとめ、さらに最大5つのフィルタグループを組み合わせられます。グループIDのリストで絞る場合、equalsをorAllで並べる素朴な書き方は1グループあたり5個までという上限に当たります。ここで注意したいのは、これらの演算子には「文書側の主体IDの配列」と「利用者側の所属IDのリスト」の重なりを1つの式で判定するものがない点です。公式の演算子表を読むと、inは渡したリストに文書側の属性値が含まれるかを見る演算子、listContainsは文書側の文字列リストが渡した単一の値を含むかを見る演算子で、いずれも配列同士の突き合わせではありません。したがってBedrockでは、文書側を単一の主体ID(あるいは部門や機密区分といった単一のラベル)に正規化して利用者のIDリストをinで受ける、主体IDの数を上限に収まる範囲へ抑える、といった設計側の割り切りが必要になります。なおinやlistContainsは公式ドキュメント上「現時点ではAmazon OpenSearch Serverlessなどで最もよくサポートされる」と案内されているので、自分の構成で使えるかは事前に確認してください。

メモ

Bedrockには、利用者の質問とメタデータのスキーマからモデルがフィルタを自動生成するimplicit metadata filteringもあります(公式ドキュメントではAnthropic Claudeモデルでサポートと案内)。検索精度の改善には有用ですが、アクセス制御には使えません。生成されたフィルタが常に正しい保証はなく、権限の境界をモデル出力に委ねることになるためです。権限フィルタは必ずアプリ側で決定的に組み立ててください。

あわせて読みたい

ベクトルデータベースの選び方|pgvector・Qdrant・Milvus・Weaviate・Chromaを用途と規模と運用体制で決める

事前フィルタが再現率に効く理由

「事前フィルタが原則」と書きましたが、そこには技術的な代償があります。ここを知らないと、権限は守れているのに検索結果がおかしい、という状態にはまります。

近似インデックスは「フィルタしてから探す」わけではない

HNSWのような近似最近傍(ANN)インデックスは、ベクトル空間のグラフをたどって近いものを探します。ここに「特定のグループIDを持つものだけ」という条件を持ち込むと、たどる先の多くが条件外になり、探索が空振りします。pgvectorのREADMEはこの挙動を具体的な数字で説明しています。近似インデックスではフィルタはインデックス走査の後に適用されるため、条件が全行の10%に一致する場合、HNSWでhnsw.ef_searchが既定の40なら、平均して4行しかマッチしない、という記述です。上位10件を返したいのに4件しか残らない、という状態が普通に起こります。

対策として、pgvectorは0.8.0から反復インデックススキャン(iterative index scans)を提供しています。十分な件数が見つかるまでインデックスの走査を自動的に広げる仕組みで、次のように有効化します。

-- 距離順を厳密に保つ場合
SET hnsw.iterative_scan = strict_order;

-- 多少順序が前後してもよいので再現率を優先する場合
SET hnsw.iterative_scan = relaxed_order;

走査の打ち切り条件も設定できます。READMEによればhnsw.max_scan_tuplesの既定値は20000、hnsw.scan_mem_multiplierはwork_memの倍数で既定値は1、IVFFlatではivfflat.max_probesが使えます。加えて、条件に合う行が少ない場合には、近似インデックスではなくフィルタ列側のB-treeインデックスによる厳密検索のほうが速く正確なことがある、とも書かれています。権限で大きく絞り込む構成では、まずフィルタ列にインデックスを張るのが出発点になります。

Qdrantはフィルタ可能HNSWとカーディナリティ推定で対処する

Qdrantは別のアプローチを取ります。公式ドキュメントによれば、インデックス化されたペイロードの値にもとづいてHNSWグラフに追加の辺を張り、フィルタ適用下でもグラフの連結性を保つ「フィルタ可能HNSW」を構築します。加えてペイロードインデックスからフィルタのカーディナリティ(合致する点の数)を推定し、クエリプランナが探索戦略を選びます。合致数が少ないと見積もられた場合は、HNSWをたどらずペイロードインデックス経由の探索に切り替えます。その閾値がfull_scan_thresholdで、既定値は10000キロバイト(ベクトルの合計サイズで測る)と説明されています。

運用上の注意として、フィルタ可能HNSWの恩恵を受けられるのは、ペイロードインデックスを作成した後にHNSWインデックスが生成された場合だけです。データ投入後にペイロードインデックスを追加したならHNSWの再構築が必要になります。権限フィールドは後から足したくなりがちなので、この順序は覚えておいてください。

マルチテナント向けには、ペイロードインデックス作成時にis_tenant=trueを指定できます(v1.11.0以降、keywordとuuid型で利用可能)。テナントごとにデータをディスク上で近い位置にまとめ、ランダムシークを減らして読み出しを効率化する最適化です。時刻など主要な絞り込み条件になるフィールドにはis_principalもあります(integer / float / datetime型)。

再現率の劣化は、権限フィルタを入れた瞬間には気づきにくい種類の劣化です。「検索結果が減った」ではなく「答えの質がなんとなく落ちた」という形で現れます。評価セットは権限フィルタなしの結果とありの結果を並べ、権限内の正解文書が上位に残っているかを見てください。フィルタありで正解が消えているなら、それは権限の問題ではなく検索の問題です。

あわせて読みたい

ハイブリッド検索とは|キーワード検索(BM25)とベクトル検索を組み合わせてRAGの取りこぼしを減らす

推奨構成: 事前フィルタを軸にした三重の防御

ここまでを踏まえた実務的な構成をまとめます。

  1. 1

    主体IDを正規化する

    ユーザーID、グループID、ロール、テナントIDを、システム横断で一意な文字列に正規化します。SharePointのグループ、社内ADのグループ、SaaS側のロールが混在する環境では、これをやらないと突き合わせが成立しません。IDは推測されにくい不透明な値にし、メールアドレスのような可変の識別子を主キーにしないでください。
  2. 2

    取り込み時に許可主体のリストを載せる

    チャンクのメタデータに、そのチャンクを読める主体IDの配列(allowed_principals)を保存します。継承やフォルダ単位の権限は取り込み時に展開して実体化させます。文書単位ではなくチャンク単位で持つのが原則です。
  3. 3

    認証済みのセッションから主体IDを解決する

    検索時のグループIDはクライアントから受け取らず、サーバ側でセッションのユーザーIDから解決します。入れ子グループはここで展開してキャッシュします。キャッシュのTTLは、権限変更の反映遅延として許容できる時間そのものになります。
  4. 4

    ベクトル検索に事前フィルタとして渡す

    allowed_principalsと解決済み主体IDリストの積集合が空でないものだけを候補にします。フィルタ式の組み立てはアプリの1箇所に集約し、検索を呼ぶすべての経路がそこを通るようにします。
  5. 5

    回答生成前にもう一度検証する

    取得したチャンクIDについて権限判定を再度行います。事前フィルタが効いていれば全件通過するので、通過しないものが出たら実装バグかインデックスの不整合です。弾くと同時に必ずアラートを上げてください。
  6. 6

    引用元の表示も同じ判定を通す

    回答に付ける出典(ファイル名・URL・抜粋)は、本文と同じ権限判定を通した結果だけを表示します。本文はフィルタしたのに引用リストは別経路、という実装は珍しくありません。

検索時の事前フィルタが第一線、回答生成前の再検証が第二線、監査ログが第三線です。第二線は通常なにもしませんが、インデックス更新の取りこぼしや、新しく追加した検索経路のフィルタ漏れを検知する装置として働きます。

実装スケッチ

公式ドキュメントの構文にもとづく最小例です。実運用ではエラー処理や監査ログが加わります。

pgvectorの場合

チャンクテーブルに許可主体の配列を持たせ、重なりがあるかを配列演算子で判定します。

CREATE TABLE chunks (
    id            bigserial PRIMARY KEY,
    doc_id        text NOT NULL,
    tenant_id     text NOT NULL,
    body          text NOT NULL,
    allowed       text[] NOT NULL,   -- 読み取り可能な主体IDの配列
    embedding     vector(1536)
);

-- 配列の重なり判定を速くする
CREATE INDEX ON chunks USING gin (allowed);
-- テナント絞り込み用
CREATE INDEX ON chunks (tenant_id);
-- ベクトル索引
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);

検索は次の形になります。&&は配列の重なり演算子で、呼び出し元の主体IDのいずれかが許可リストに含まれる行だけを残します。

-- $1: 主体IDの配列(サーバ側で解決した値)、$2: クエリベクトル、$3: テナントID
SET hnsw.iterative_scan = relaxed_order;

SELECT id, doc_id, body, embedding <=> $2 AS distance
FROM chunks
WHERE tenant_id = $3
  AND allowed && $1
ORDER BY distance
LIMIT 10;

前述のとおり、近似インデックスではフィルタが索引走査の後に効くため、hnsw.iterative_scanを設定しないと該当件数が少ないときに取りこぼします。relaxed_orderは順序が多少前後する代わりに再現率がよく、厳密な距離順が必要なら、pgvectorのREADMEが示すようにマテリアライズドCTEで包んで外側で並べ替える方法があります。

さらに堅くしたい場合は、PostgreSQLの行レベルセキュリティ(RLS)を併用できます。公式ドキュメントによれば、ALTER TABLE ... ENABLE ROW LEVEL SECURITYを行うとポリシーで許可された行以外は見えなくなり、ポリシーが1つもなければ既定で全拒否になります。

ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;

CREATE POLICY chunks_read ON chunks
  FOR SELECT
  USING (allowed && string_to_array(current_setting('app.principals', true), ','));

注意

RLSには見落としやすい落とし穴があります。PostgreSQL公式ドキュメントは、スーパーユーザーとBYPASSRLS属性を持つロールは常に行セキュリティを迂回し、テーブルの所有者も通常は迂回すると明記しています。所有者にも適用するにはALTER TABLE ... FORCE ROW LEVEL SECURITYが必要です。アプリケーションがテーブル所有者やスーパーユーザーで接続していると、ポリシーを書いたのに一切効いていない、という状態になります。RLSを入れたら、必ず権限のないロールで実際に検索して0件になることを確認してください。

Qdrantの場合

Qdrantではペイロードに許可主体を持たせ、query_pointsquery_filterで絞ります。公式ドキュメントのフィルタ構文にもとづくと、次の形です。

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")

# 権限フィールドは投入前にペイロードインデックスを作っておく
client.create_payload_index(
    collection_name="docs",
    field_name="allowed",
    field_schema=models.PayloadSchemaType.KEYWORD,
)
client.create_payload_index(
    collection_name="docs",
    field_name="tenant_id",
    field_schema=models.KeywordIndexParams(
        type=models.KeywordIndexType.KEYWORD,
        is_tenant=True,
    ),
)

# principals はサーバ側でセッションから解決した主体IDのリスト
client.query_points(
    collection_name="docs",
    query=query_vector,
    query_filter=models.Filter(
        must=[
            models.FieldCondition(
                key="tenant_id",
                match=models.MatchValue(value=tenant_id),
            ),
            models.FieldCondition(
                key="allowed",
                match=models.MatchAny(any=principals),
            ),
        ]
    ),
    limit=10,
)

MatchAnyは公式ドキュメントでv1.1.0以降のMatch Any条件として説明されており、SQLのIN演算子に相当します。同じフィールドに対する複数のshould条件を並べるより速い、とも記されています。保存値が配列の場合は、そのうち1つでも一致すれば条件を満たします。allowedを配列で持つ設計と噛み合う挙動です。

公式ドキュメントのとおり、mustに条件を並べればAND、shouldはOR、must_notは否定です。

ヒント

否定条件(must_not)で「機密ラベルが付いていないもの」を除外する設計は避けたほうが安全です。ラベルの付け忘れがそのまま漏えいになるためです。許可リスト方式(そのチャンクを読める主体を明示的に列挙し、書かれていないものは見せない)にしておけば、メタデータの欠落は「見えない」側に倒れます。安全側に倒れる既定値を選んでください。

あわせて読みたい

RAGのチャンク分割(chunking)とは|検索品質を左右する文書の区切り方

実務で詰まるところ

設計そのものより、周辺の細部で事故が起きます。よく踏むものを挙げます。

権限の継承と共有リンク

フォルダに設定された権限を子が継承する構造は一般的ですが、取り込み時にそれを解決していないと、ファイル単体のACLだけを見て「誰も読めない」あるいは「全員読める」と誤解釈します。共有リンクも厄介で、「リンクを知っている人全員」という状態は特定の主体IDに展開できません。この種の権限は、専用の擬似主体ID(たとえばlink-shared)を割り当てて明示的に扱うか、RAGの対象から外すかを決めておく必要があります。

権限変更の反映遅延(stale ACL)

異動、退職、プロジェクト終了に伴う権限剥奪が検索インデックスに反映されるまでには、必ず時間差があります。Azure AI Searchのドキュメントも、クエリ時の権限判定はインデックスに保存済みの権限メタデータに対して行われ、元システム側の権限変更は同期後にしか反映されないと明記しています。SharePointのACLについては、固有の権限を持つアイテムの変更はインデクサ実行ごとに増分で取り込まれる一方、親スコープから継承した権限の変更は明示的な再同期が必要だとも説明されています。

設計上は、この遅延を「許容できる時間」として明示的に決めてください。退職者のアカウント無効化のように即時性が要るものは、インデックスの再同期を待つのではなく認証層で断ち切る(セッションを無効化する、主体ID解決が失敗したら検索させない)ほうが確実です。

1ファイル内で機密度が違う

議事録の一部だけが人事案件、提案書の一部だけが価格情報、というケースはよくあります。権限メタデータをチャンク単位で持つ設計にしておけば、章ごとに異なる主体IDを割り当てられます。ただし、どの章が機密かを自動判定するのは難しく、誤ればそのまま漏えいです。現実的には「機密が混在する文書は取り込み対象から外す」「機密部分を除いた版を別途用意する」といった運用側の割り切りが要る場面が多く、技術で解けない部分を無理に技術で解こうとしないほうが安全です。

メタデータ経由の推測

本文を出さなくても、ファイル名やタイトルだけで情報が漏れます。「2026年度_A社買収_DD報告書.pdf」というファイル名は、それ自体が機微情報です。件数表示も同様で、「該当0件」と「該当3件だが表示できません」の違いから存在の有無が推測できます。対策は、権限外の文書は「存在しない」ものとして扱い件数にも含めないこと、そしてファイル名やタイトルにも本文と同じ判定を適用することです。Azure AI Searchのドキュメントが主体IDフィールドのretrievableをfalseにするよう案内しているのも、判定に使うメタデータ自体を外に出さないという同じ発想です。

引用元表示とキャッシュ・ログの残留

RAGの回答には出典を付けるのが基本ですが、ここが別経路になっていて権限判定を通っていない実装をときどき見かけます。本文と引用は同じ候補集合から作ってください。

さらに見落としやすいのが、権限フィルタの外側に情報が漏れる場所です。

  • 検索結果のキャッシュ: ユーザー横断のキャッシュキーにすると、Aさんの権限で取得した結果がBさんに返ります。キャッシュキーに主体IDの集合を含めるか、そもそもユーザー横断でキャッシュしないかを決めてください。
  • 会話履歴: 一度コンテキストに入った機密は後続ターンの履歴に残ります。権限が変わっても履歴は変わりません。
  • ログ・トレースと評価データ: プロンプト全文をログに残す構成では、機密チャンクがログ基盤に複製されます。ログ基盤のアクセス権が元文書より緩ければ、そこが新しい漏えい経路です。本番ログから評価セットを作れば、機密が評価環境やスプレッドシートにも流出します。どちらも元文書と同じ機密区分で管理してください。
  • 埋め込みベクトルそのもの: OWASPのLLM08:2025は、埋め込みから元の情報を復元しようとする埋め込み反転(embedding inversion)攻撃をリスクとして挙げています。ベクトルデータのバックアップや開発環境へのコピーも、本文と同じ扱いにするのが安全側です。

あわせて読みたい

LLMアプリのオブザーバビリティ|トレーシングとログ設計で「なぜこの出力になったか」を後から追えるようにする

運用と監査

作った後のほうが長く続きます。監査の目的は「誰が・いつ・どの文書を根拠に・何を答えたか」を後から再現できることです。OWASPのLLM08:2025も、緩和策の1つとして検索活動の詳細かつ改ざん不能なログの保持を挙げています。最低限、次の項目があれば調査に耐えます。

記録項目なぜ必要か
リクエストID・タイムスタンプ回答と検索を突き合わせる主キー
認証済みユーザーID誰の権限で実行されたか。クライアント申告値ではなくサーバ解決値
適用した主体IDの集合(またはそのハッシュ)どの権限で絞ったか。事故時の再現に必須
検索クエリの識別子生のクエリ文は機微を含みうるので、必要に応じてハッシュ化や保持期間の短縮を検討する
取得したチャンクID・文書IDの一覧何を根拠にしたか。本文は残さずIDだけでも追跡できる
事後検証で弾いた件数0でない場合は事前フィルタの不具合のサイン
インデックスのバージョン・同期時刻権限メタデータがいつ時点のものだったか

チャンクの本文そのものをログに残すかどうかは、慎重に決めてください。調査には便利ですが、前述のとおりログ基盤が新しい漏えい経路になります。IDだけを残し、必要になったらそのIDで元文書を引く運用のほうが、多くの組織では安全です。

権限は「動いているように見えて実は効いていない」ことが起こりやすい領域です。自動テストで継続的に確認します。

用意しておく権限テスト

  • 一般社員ロールで、人事専用文書に含まれる固有の文字列を狙い撃ちした質問をして、結果が0件かつ回答に該当情報が含まれないこと
  • 部門Aのユーザーが部門B専用の文書を検索して0件になること(逆方向も両方)
  • テナントAのユーザーの検索結果に、テナントBの文書IDが1件も含まれないこと
  • 権限を剥奪したユーザーが、同期完了後に該当文書を取得できなくなること(許容遅延の測定も兼ねる)
  • 引用元リスト・出典URL・件数表示にも、本文と同じフィルタが効いていること
  • 会話の2ターン目以降、フォローアップ質問でも同じフィルタが適用されること
  • 権限フィルタを付け忘れた検索経路が存在しないこと(検索呼び出しの静的チェック)
  • 権限フィルタあり・なしで再現率を比較し、権限内の正解文書が上位から消えていないこと

このうち特に効くのは、最初の1つです。「権限のない機密文書にしか出てこない固有の文字列」をカナリアとして仕込み、一般ユーザーの回答にその文字列が現れたら即座に検知する。CIで回せますし、本番でも定期的に実行できます。

定期的な見直し対象としては、主体ID解決キャッシュのTTLが許容遅延に収まっているか、新しく追加された検索経路(管理画面の検索、バッチ処理、エージェントのツール)がフィルタを通っているか、ベクトルDBのバックアップのアクセス権が元文書と同等以上か、あたりを押さえておきます。

特にエージェント構成では、LLMがツールとして検索を呼ぶ場合に注意が要ります。ツールの実装へ主体IDを渡す経路がないと、サービスアカウントの全権限で検索してしまいます。主体IDはツールの引数として渡すのではなく(モデルが書き換えられるため)、実行コンテキストからサーバ側で解決する設計にしてください。

あわせて読みたい

LLMガードレールの設計|入力・出力・挙動の3層で安全境界をつくる実務ガイド

やってはいけないこと

最後に、避けるべき実装をまとめます。

注意

次の設計は、いずれも「権限を守っているつもり」になりやすく、実際には境界になっていません。1. システムプロンプトで「機密は開示しないで」と指示するだけ。2. 出力側のフィルタやLLM-as-a-judgeだけで漏えいを止めようとする。3. クライアントから送られてきたグループIDやテナントIDをそのままフィルタに使う。4. 権限メタデータの欠落を「全員に見せる」側の既定値にする。5. 事後フィルタのみで、上位k件を大きくして取りこぼしをごまかす。6. モデルに検索フィルタを生成させ、その中に権限条件を含める。7. 本文だけをフィルタし、引用元・件数・タイトルは素通しにする。

特に6番目は、検索精度の改善策として自然に導入されがちです。質問の意図からメタデータフィルタを生成する仕組み自体は有用ですが、そこで生成されるのは「絞り込みの提案」であって「権限の判定」ではありません。権限条件はモデルの出力とは独立にアプリ側で付加し、生成されたフィルタとANDで結合してください。

本記事の仕様に関する記述は、Microsoft Learn(Azure AI Search)、pgvector公式リポジトリ、PostgreSQL公式ドキュメント、Qdrant公式ドキュメント、AWS公式ドキュメント(Amazon Bedrock Knowledge Bases)、Google Cloud公式ドキュメント、OWASP Gen AI Security Projectの公開情報(2026年8月時点)に基づきます。各サービスの機能名・既定値・プレビュー状況は変更されることがあるため、実装時は必ず最新の一次情報を確認してください。また、本記事は一般的な設計方針の整理であり、個別の法令・契約上の要件への適合を保証するものではありません。

すでに動いているRAGに後から権限を入れる場合は、まず対象データを棚卸しして「そもそも入れるべきでなかった文書」を削り、監査ログを整え、絶対に混ざってはいけない軸で粗く分離し、それから権限メタデータの同期と事前フィルタへ進むのが安全です。取り込みパイプラインに手が入る順序なので、後回しにするほど作り直しの範囲が広がります。

「最初は全社の共有ドライブをまるごと入れて、精度を上げることばかり考えていました。権限の話が出たのは、法務レビューの直前です。後から入れると、取り込みパイプラインをほぼ作り直すことになりました。順番を逆にすべきでした。」
社内文書RAGを運用する開発者

よくある質問

事後フィルタだけではだめですか。実装が一番簡単なのですが
アクセス制御としては機能しますが、検索結果が空になりやすいという実用上の問題があります。ベクトル検索は権限を考慮せずスコア順で上位k件を返すため、権限内の文書が全体のごく一部しかない場合、上位k件がほぼ権限外で埋まり、フィルタ後に0件になります。Qdrantの解説記事も、低カーディナリティのフィルタを検索後に適用すると結果の大部分を捨てることになり、そもそも初回の結果集合に入らなかった関連文書は取り逃すと指摘しています。kを大きくすればある程度は緩和できますが、コストとレイテンシが増え、根本的には解決しません。事前フィルタを主にして、事後チェックは実装ミス検知の保険として併用する構成をおすすめします。
権限フィルタを入れたら検索結果の質が落ちました。原因は何ですか
近似最近傍インデックスとフィルタの相性が原因である可能性が高いです。pgvectorの公式ドキュメントは、近似インデックスではフィルタがインデックス走査の後に適用されるため、条件が全行の10パーセントに一致する場合、HNSWでhnsw.ef_searchが既定の40なら平均4行しかマッチしないと説明しています。対策としてpgvectorは0.8.0から反復インデックススキャンを提供しており、hnsw.iterative_scanをstrict_orderまたはrelaxed_orderに設定すると、必要な件数が見つかるまで走査を広げます。Qdrantの場合はペイロードインデックスにもとづくフィルタ可能HNSWとカーディナリティ推定で対応しており、ペイロードインデックスを作成した後にHNSWが構築されている必要があります。データ投入後にインデックスを追加した場合はHNSWの再構築が必要です。
人事異動や退職の反映はどれくらい遅れますか
構成によりますが、遅延はゼロにはなりません。Azure AI Searchの公式ドキュメントは、クエリ時の権限判定はインデックスに保存済みの権限メタデータに対して行われ、元システム側の権限変更はインデクサ実行やプッシュAPI更新などの同期後にしか反映されないと明記しています。SharePointのACLについては、固有の権限を持つアイテムの変更は各インデクサ実行で増分取り込みされるが、親スコープから継承した権限の変更は明示的な再同期が必要だとも説明されています。設計としては、許容できる遅延時間を明示的に決めたうえで、退職者のアカウント無効化のように即時性が必要なものは、インデックスの同期を待たずに認証層で遮断してください。
システムプロンプトで「権限のない情報は答えないで」と指示するのでは不十分ですか
不十分です。その指示が効く時点で、機密チャンクはすでにモデルのコンテキストに入っています。出力を抑える指示は、言い回しを変えた質問、要約や翻訳の依頼、プロンプトインジェクションによって回避されうるもので、境界としては成立しません。OWASPのGen AI Security ProjectもLLM08:2025の緩和策として、きめ細かなアクセス制御と厳密な論理分割を挙げています。プロンプトによる指示や出力側のフィルタは、取りこぼしを減らす補助として価値がありますが、検索の前段で候補集合を絞る処理の代わりにはなりません。
モデルに検索フィルタを自動生成させる機能は、権限にも使えますか
使わないでください。Amazon Bedrock Knowledge Basesには、利用者の質問とメタデータスキーマからモデルがフィルタを生成するimplicit metadata filteringがあり、検索精度の改善には有用です。しかし生成されたフィルタが常に正しい保証はなく、権限の境界をモデル出力に委ねることになります。権限条件はアプリ側で決定的に組み立て、モデルが生成したフィルタとはANDで結合する形にしてください。同様に、クライアントから送られてきたグループIDやテナントIDをそのまま信じるのも避けます。Azure AI Searchの公式ドキュメントも、security filterのprincipalは認証・認可を行うものではなく単なる文字列比較だと明記しています。渡す値の正しさはアプリ側の責任です。

まとめ

RAGのアクセス制御は、あとから足すのが最も難しい部類の設計です。権限が検索の外側ではなく、取り込みパイプラインの内側に必要だからです。チャンクを作る時点で許可主体のリストを載せていなければ、検索時にどれだけ工夫しても絞り込めません。

原則は3つです。第一に、事前フィルタを主軸にすること。事後フィルタ単体では結果が空になり、実用に耐えません。第二に、権限は同期されたコピーであると認め、遅延を設計に織り込むこと。即時性が要るものは認証層で断ち切ります。第三に、メタデータが欠けているときは「見せない」側に倒れる既定値を選ぶこと。許可リスト方式にしておけば、取り込みの取りこぼしが漏えいになりません。

技術的な代償として、事前フィルタは近似インデックスの再現率に影響します。pgvectorの反復インデックススキャンやQdrantのフィルタ可能HNSWのように、各実装が用意している対処を理解し、権限フィルタあり・なしで再現率を比較しながら設定を詰めてください。権限は守れているのに答えの質が落ちた、という状態は、利用者から見れば「使えないRAG」です。

そして、プロンプトやガードレールはアクセス制御の代わりにはならない、という点は繰り返し確認する価値があります。モデルに何を指示するかではなく、モデルに何を渡さないか。境界はそこにしか引けません。

あわせて読みたい

AI利用時の情報管理|入力してよいデータの線引きと社内での運用

出典・参考

この記事をシェア

関連する記事

開発・エージェント

LLMガードレールの設計|入力・出力・挙動の3層で安全境界をつくる実務ガイド

LLMを組み込むアプリ・エージェントに欠かせないガードレール設計を、実装アーキテクチャの目線で整理します。システムプロンプトだけでは安全境界にならない理由、入力・出力・挙動の3層構成、分類器やスキーマ検証・OSSフレームワークといった実装手段の類型、過剰検知と過小防御のトレードオフ、評価とレッドチーミングの必要性までを扱います。