AI Chatbot

なぜRAGパイプラインは漏らしてはいけないデータを漏らし続けるのか:検索層(Retrieval Layer)でのアクセス制御

御社のアプリにはロールベースの権限がある。文書管理システムにはフォルダ単位のACLがある。しかしRAGチャットボットにはそのどちらもない — あるのは一つの共有ベクトルインデックスだけで、正しい質問さえすれば給与明細でも喜んで差し出してしまう。

これは仮定の話ではない。エンタープライズRAG導入において最も頻繁に起きるアーキテクチャ上のミスであり、デモではほぼ発見されない。デモは一つの文書セットと一人のユーザーで動く。しかし本番環境には人事記録、法務契約、役員会資料、そして200人の従業員がいる — 同じフォルダに対して権限が異なる社員が二人存在した瞬間、ほとんどのRAGパイプラインは何の警告もなく権限の強制を止めてしまう。

ギャップ:権限を意識するUI、権限を意識しないretrieval

繰り返し現れるパターンはこうだ。

  1. 企業が社内文書ストア(Google Drive、SharePoint、Confluence、DMS)上にRAGチャットボットを構築する。
  2. チャットのインターフェースはユーザーの本人確認を正しく行い、権限に応じた文書一覧を綺麗に表示する。
  3. しかしチャットボットの背後にあるベクトルインデックスは、人事・経理・法務・エンジニアリングを含むあらゆるデータから一度きりで構築されており、どのチャンクを誰が見てよいかというレコードがチャンク単位で存在しない。
  4. ユーザーが質問する。retrievalのステップはインデックス全体から意味的に最も近いチャンクを検索し、ユーザーが一度もアクセス権を与えられていない文書のチャンクを見つけ、そのままLLMのコンテキストとして渡す。
  5. LLMは本来の役割を正確にこなす — そのチャンクを使って質問に答える。ユーザーが決して見るべきではなかった詳細も含めて。

エラーは出ない。監査フラグも立たない。インターフェースはそもそも出典文書すら表示しないため、retrievalが権限の境界を越えたことに誰も気づかない — セキュリティレビューでの発見事項、あるいはさらに悪い場合は規制当局からの照会として表面化するまでは。

だからこそ「うちにはすでにSSOとアプリのロールベースアクセス制御がある」は「うちのRAGパイプラインは実際にアクセス制御を強制している」と同じ主張ではない。SSOは誰がログインできるかを制御する。アプリ上のRBACはどの画面が見えるかを制御する。しかしどちらもretrievalのステップには触れていない。意味的類似度検索は、自分が誰の文書を読んでいるかを知らないし気にもしない。

APPI(個人情報保護法)、J-SOX、そして経済安全保障推進法の観点から見れば、このような漏洩は単なる社内の恥では済まない。特に漏洩した情報が従業員や顧客の個人データ、あるいは特定重要物資・重要技術に関連する情報であった場合、報告義務が発生しうるインシデントとなる。NISCのガイドラインが求める「サプライチェーン全体でのセキュリティ管理」の観点からも、AIパイプラインの内部で発生するこの種の権限漏れは看過できない。

なぜテストでは発覚しないのか

RAGにおけるアクセス制御の欠陥が本番環境まで生き残る理由は三つある。

  • シングルテナントのテストデータ — ほとんどのパイロットは機密レベルの区別がないデモ用コーパスで実施されるため、そもそも漏れるものがない。
  • シングルユーザーでのテスト — 開発者一人、管理者アカウント一つでハッピーパスだけをテストする。問題が現れるのは、より狭い権限を持つ二人目のユーザーが質問を始めてからだ。
  • LLMはどちらの場合でも自信満々に答える — 権限漏洩はエラーのようには見えない。システムがうまく動作しているように見える。チャットボットは正確に答えた。それこそが問題なのだ。

retrieval層で実際に権限を強制する三つのパターン

1. クエリ時点でのメタデータフィルタリング

最低限の対策:すべてのチャンクに、元となる文書から継承したアクセス制御メタデータを付与し、それを類似度検索の内部で強制フィルタとして適用する — 検索後ではなく。

弊社のpgvectorチュートリアルにあるdocument_chunksスキーマを拡張し、アクセステーブルを追加する:

-- 各文書を閲覧できるグループ/ロール
CREATE TABLE document_acl (
    document_id  BIGINT REFERENCES documents(id) ON DELETE CASCADE,
    principal    TEXT NOT NULL,   -- 例: 'role:finance', 'user:alice@company.com', 'group:leadership'
    PRIMARY KEY (document_id, principal)
);

CREATE INDEX ON document_acl (principal);
def semantic_search_with_acl(
    query: str,
    user_principals: list[str],   # 例: ["user:alice@company.com", "role:finance", "group:apac"]
    top_k: int = 5,
) -> list[dict]:
    query_embedding = get_embedding(query)
    conn = psycopg2.connect(DB_URL)
    cur = conn.cursor()
    try:
        cur.execute(
            """
            SELECT dc.id, dc.content, d.title, d.source_url,
                   1 - (dc.embedding <=> %s::vector) AS similarity
            FROM document_chunks dc
            JOIN documents d ON d.id = dc.document_id
            WHERE EXISTS (
                SELECT 1 FROM document_acl a
                WHERE a.document_id = d.id
                AND a.principal = ANY(%s)
            )
            ORDER BY dc.embedding <=> %s::vector
            LIMIT %s
            """,
            [query_embedding, user_principals, query_embedding, top_k],
        )
        return cur.fetchall()
    finally:
        cur.close()
        conn.close()

このフィルタはクエリの内部で実行され、取得後の結果を後付けでチェックするものではない。この違いは重要だ。retrieval後にフィルタするということは、禁止されたコンテンツを破棄すると決める前に、すでにアプリケーションのメモリに取り込んでしまっているということであり、漏洩はネットワーク層ですでに発生している。post-filterのステップにバグがあれば、そのコンテンツはそのままLLMに届いてしまう。

2. バックストップとしての行レベルセキュリティ(RLS)

アプリケーション層のフィルタリングは、そのテーブルをクエリするすべてのコードパスと同程度にしか信頼できない。WHERE EXISTS句を書き忘れたスクリプト、管理ツール、あるいは将来の連携が一つでもあれば、その穴は再び開いてしまう。PostgresのRow-Level Security(RLS)は同じルールをデータベース層で強制するため、アプリ開発者が想定していなかったクエリにも適用される:

ALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY;

CREATE POLICY chunk_access_policy ON document_chunks
USING (
    EXISTS (
        SELECT 1 FROM document_acl a
        WHERE a.document_id = document_chunks.document_id
        AND a.principal = ANY(current_setting('app.user_principals')::text[])
    )
);
# このコネクション上でクエリを実行する前にセッションのprincipalsを設定する
cur.execute("SET app.user_principals = %s", [user_principals])

これは多層防御であり、アプリケーション層のフィルタの代替ではない。一つのエンドポイントの権限バグが、システム全体のデータ侵害へと拡大するのを止めるためのものだ。

3. 権限変更と古くなったembeddingへの対処

チャンクとembeddingは書き込まれた時点で静的なデータになる。しかし権限は静的ではない — 社員が経理部から異動する、契約が再分類される、契約終了した業務委託者のアクセスは即座に消えるべきだ。必要なことは二つある。

  • コンテンツの再embeddingではなく、ACLの同期を行う — 権限が変わったらdocument_aclを直接更新する。文書を再embeddingする必要はない。アクセス制御テーブルはベクトルデータとは別に管理されているため、アクセス取り消しはメタデータの書き込みであり、パイプラインの再実行ではない。
  • 信頼できる情報源からの同期ジョブ — 文書がGoogle DriveやSharePointにある場合、現在のフォルダ/ファイル権限を取得しdocument_aclと突き合わせるスケジュールジョブを実行する。ACLは、特にネストしたフォルダの継承がある場合、ほとんどのチームが想定するより速く元システムからずれていく。

アーキテクチャ概要

flowchart TD
    A["ユーザーの質問 + IDトークン"] --> B["ユーザーのprincipalsを解決\nロール グループ 直接付与"]
    B --> C["質問をembed"]
    C --> D["フィルタ付き類似度検索\nクエリ内でACLチェック"]
    E["document_aclテーブル"] --> D
    F["元システムからの権限同期\nDrive SharePoint DMS"] --> E
    D --> G["許可されたチャンクのみ"]
    G --> H["LLMが回答を生成"]
    H --> I["retrieval監査ログ\n誰が 何を いつ 一致したprincipal"]

監査可能性:コンプライアンス部門が実際に求めるもの

権限を意識したretrieval層は、権限を意識しない層には持てないものを提供する — 説明責任を果たせるログだ。すべてのretrievalイベントは、問い合わせを行ったIDと、そのIDに対して解決されたprincipal、そしてどの文書が返却され得たか(実際に最上位マッチだったかどうかに関わらず)を記録すべきだ。セキュリティレビューや規制当局が「御社のAIシステムが制限されたデータを露出させなかったとどうやって証明できるのか」と尋ねたとき、答えとなるのはこのログであり、単なる約束ではない。

よくある質問

これはフロントエンドの権限チェックがすでに処理しているのでは?

いいえ。フロントエンドのチェックはユーザーが何をクリックできるかを制御するだけで、retrievalのステップが内部でLLMのコンテキストウィンドウに何を取り込んでいるかは見えていない。これらは別々の二つの強制ポイントであり、通常構築されているのはそのうち一つだけだ。

これによりretrievalは遅くなるのか?

document_acl.principalにインデックスが張られていれば、インデックス化されたEXISTSフィルタが追加するオーバーヘッドはミリ秒の一桁台程度であり、embedding生成やLLM推論の時間と比べれば無視できる水準だ。

フラットなロールより複雑な権限体系 — ネストしたフォルダ、継承されたグループ、一時的なアクセス許可 — の場合はどうすればよいか?

各継承パスを、クエリ時ではなく同期時にdocument_aclの一行としてモデル化する。階層構造は同期ジョブの段階でフラット化し、クエリ自体は単純なメンバーシップチェックのままにしておく。

文書がそれほど機密ではない場合でも必要か?

コーパスが人事・法務・財務・顧客の個人情報など何らかの機密層と一般的なコンテンツを混在させているなら必要だ。この失敗モードは「たまに気まずい」程度では済まない。システム内の何もそれを異常として検知していないため、後から説明するのが難しい情報開示になる。


実際にアクセス制御を強制するRAGパイプラインの構築や監査をお考えですか — 権限のないインデックスの前に権限付きUIを置くだけではなく? hello@simplico.net までお問い合わせください。弊社はタイ、日本、東南アジアのエンタープライズクライアント向けに本番環境のRAGアーキテクチャを設計・導入しています。