社内RAGで「他部署の資料が見えてしまう」
——本番直前に必ず起きる事故と、その防ぎ方
社内向けのRAGは、だいたい本番直前で同じ壁にぶつかります。「検索したら、他部署の資料が出てきた」。デモの段階では誰も気づきません。少人数のテストデータでは、そもそも見てはいけない文書が入っていないからです。全社のデータを入れた瞬間に露見します。
これは実装のミスではなく、ベクトル検索という仕組みそのものの性質です。放置できる種類のバグではないので、設計の段階で織り込む必要があります。この記事では、何が起きるのか、なぜ普通の感覚では防げないのか、そしてPostgreSQL+pgvectorでどう実装するかを順に書きます。
何が起きるのか
たとえば社員が「今期の評価基準を教えて」と聞いたとします。RAGは質問を意味のベクトルに変換し、社内文書の中から意味の近いものを上位5件取ってきて、それを根拠にAIが回答を作ります。
このとき上位に来るのは、人事部が持っている個人別の査定シートかもしれません。「評価基準」という質問と、意味的にはこれ以上ないほど近いからです。
検索は、正しく動いています。
意味は完全に近い。それが問題なのです。
ベクトル検索がやっているのは「質問のベクトルと文書のベクトルの距離を測って、近い順に並べる」ことだけです。この計算のどこにも、閲覧権限という概念は存在しません。
なぜ、普通のWeb開発の感覚だと防げないのか
通常のデータベース検索と、ベクトル検索では処理の順番が違います。
| 通常のDB検索 | ベクトル検索 | |
|---|---|---|
| 処理の順番 | 条件で絞る → 並べる | 全件との距離を測る → 近い順にk件 |
| 権限の扱い | WHERE句に自然に書ける | 書く場所が用意されていない |
| 該当なしのとき | 0件が返る | 0件にならない。遠くても必ずk件返る |
最後の行が厄介です。通常の検索なら「条件に合うものがない」は0件で表現されますが、ベクトル検索はどんなに的外れでも必ずk件返してきます。「近いものがなかった」という状態が存在しないので、無関係な文書が堂々と根拠として使われます。
filter() があるので「後から絞ればいい」と思ってしまう。ところが後から絞ると、次の節で書くとおり別の問題が起きます。防ぎ方は3つ。ただし2つは条件次第で破綻する
① 後フィルタ:検索してから、権限のないものを落とす
いちばん実装が楽な方法です。5件取ってきて、そのうち見ていいものだけ残す。多くのチュートリアルがこの形になっています。
ですが、これは本番で確実に壊れます。5件すべてが他部署の文書だった場合、残るのは0件。AIは根拠なしで答えることになります。
さらに悪い副作用があります。「権限のある人には答えが出て、権限のない人には無言が返る」という挙動は、その文書が存在すること自体を教えてしまいます。人事の資料について聞いたときだけ回答が急に薄くなる、という形で外から推測できてしまう。情報を隠したつもりが、存在を漏らしています。
件数を稼ごうとして k を 5 から 50 に増やす対処もよく見ますが、これは問題を先送りしているだけです。データが増えれば50件も全部落ちます。しかも毎回50件ぶんの距離計算をすることになり、遅くなります。
② インデックス分離:部署ごとにテーブルを分ける
安全性はいちばん高い方法です。人事は人事のインデックス、営業は営業のインデックス。物理的に混ざりません。
ただし、横断検索ができなくなります。社内RAGを入れる動機はたいてい「部署をまたいで探せるようにしたい」なので、目的と正面から衝突します。加えて、
- 兼務している社員をどう扱うか(両方のインデックスを検索して結果をマージする=スコアが比較できない)
- 全社共有の就業規則をどこに置くか(全インデックスに複製する=更新漏れが起きる)
という運用の問題が必ず出てきます。部署数が少なく、かつ横断の必要がない場合に限って有効です。
③ 事前フィルタ:検索の条件そのものに権限を入れる(本命)
文書の側に「この文書を見ていいグループ」を持たせ、距離を測る前に候補を絞ります。絞った母集団の中で近い順にk件取るので、件数が減りません。存在推定も起きません。
後から落とすのではなく、
最初から見えている集合を変える。
実装は少し面倒ですが、本番に出すつもりなら選択肢はこれだけです。以下、具体的に書きます。
PostgreSQL + pgvector での実装
マネージドのベクトルDBを使わず、すでに使っているPostgreSQLに pgvector を入れる構成を前提にします。社内RAGではデータを外に出さないことが要件になりがちなので、この構成が選ばれる場面は多いはずです。
文書に「見ていいグループ」を配列で持たせる
権限をユーザー単位で持つと組み合わせが爆発するので、グループ(部署・役職・プロジェクト)のID配列で持ちます。
# models.py from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.indexes import GinIndex from pgvector.django import VectorField, HnswIndex class DocumentChunk(models.Model): document = models.ForeignKey(Document, on_delete=models.CASCADE) body = models.TextField() embedding = VectorField(dimensions=384) # e5-small # 見ることを許可されたグループID。ここが権限の実体 allowed_groups = ArrayField(models.IntegerField(), default=list) class Meta: indexes = [ GinIndex(fields=["allowed_groups"]), HnswIndex( name="chunk_emb_hnsw", fields=["embedding"], m=16, ef_construction=64, opclasses=["vector_cosine_ops"], ), ]
配列の重なりを判定する && 演算子は GINインデックスが効きます。ここを張り忘れると全件走査になり、絞り込みのたびに遅くなります。
検索は「絞ってから測る」
# services/search.py from pgvector.django import CosineDistance def search(query_vec, user, k=5): group_ids = list(user.groups.values_list("id", flat=True)) return ( DocumentChunk.objects .filter(allowed_groups__overlap=group_ids) # ← 先に絞る .annotate(dist=CosineDistance("embedding", query_vec)) .order_by("dist")[:k] )
要点は filter() を annotate() より前に書くことです。順番を入れ替えても同じSQLになるように見えますが、意図が読めなくなるので必ずこの順で書きます。
権限の取得をこの関数の中に閉じ込めるのが重要です。呼び出し側が group_ids を渡す設計にすると、いずれ「管理画面用だから全部見せたい」といって空配列や特権フラグを渡すコードが生まれ、そこから漏れます。検索関数は必ず user を受け取り、権限は内部で解決する。
ここで一度ハマります:絞り込むと、件数が足りなくなる
事前フィルタを入れると、今度は検索結果が k 件に届かないという現象が起きます。原因はHNSWインデックスの仕組みです。
HNSWは全件を調べる代わりに、グラフを辿って「近そうな候補」を ef_search 件ぶんだけ集めてきます。権限による絞り込みは、その候補が集まった後に適用されます。集めた候補のほとんどが権限外だった場合、残るのはごくわずかです。
対処は2つあります。
- pgvector 0.8.0 以降の反復スキャンを使う(推奨)。候補が足りなければ自動的に探索を続けてくれます。
SET hnsw.iterative_scan = relaxed_order; hnsw.ef_searchを上げる。単純ですが、絞り込みの強さに応じて必要な値が変わるので、上げ幅の根拠が持てません。
EXPLAIN ANALYZE で Index Scan が消えても慌てないでください。実装しても、まだ3つの経路から漏れます
検索に権限を入れれば終わり、ではありません。ここから先が、実際に事故になる部分です。
① チャンクの切り方で権限が混ざる
長い文書は分割してベクトル化します。このとき、1つの文書の中に権限の異なる情報が同居していると守れません。
典型は議事録です。前半は全社共有でよい決定事項、後半は特定メンバーだけの人事の話。文書単位で権限を付けていると、どちらかに倒すしかなくなります。
権限は、文書ではなくチャンクに付ける。
そして「混在する文書は分割する」という運用を先に決める。
② 会話履歴に残り、次の質問で再利用される
マルチターンの対話にすると、過去のやりとりを文脈として渡すのが普通です。ところが過去の回答には、そのとき参照した文書の内容が含まれています。
権限が変わったあと(異動した、プロジェクトを抜けた)に古い履歴を読み込むと、いま見てはいけない情報が、履歴経由で現在の回答に入り込みます。履歴にも参照時点の権限スコープを記録し、変わっていたら読み込まない、という判定が要ります。
③ 「引用しないで」とプロンプトに書いても意味がない
これがいちばん誤解されている点です。「機密文書は引用しないでください」とシステムプロンプトに書けば安全になる、という発想がありますが、渡した時点で、すでに漏れています。
LLMは渡された文書を読んで回答を作ります。直接引用しなくても、内容は要約や言い換えの形で出てきます。プロンプトによる禁止は対策ではなく、お願いです。権限のない文書は、そもそもLLMに渡さない。ここは実装で担保するしかありません。
「何を渡したか」の記録を残す
社内で運用する以上、事故は前提です。問題は起きたときに「何が誰に漏れたか」に答えられるかどうかで、ここが答えられないシステムは、たいてい一度の事故で止められます。
最低限、質問ごとに次を残します。
| 記録する項目 | なぜ必要か |
|---|---|
| 質問した人 / 日時 | 影響範囲の特定 |
| そのとき解決した権限グループ | 権限設定の誤りだったのかを切り分ける |
| LLMに渡したチャンクのID | これが本体。回答文だけ残しても何が漏れたかは分からない |
| 回答文 | 実際に出力された内容の確認 |
チャンクのIDさえ残っていれば、後から「この文書を参照した質問」を逆引きできます。回答テキストだけを保存しているログは、事故のときにほぼ役に立ちません。
権限は、後から足せない
ここまで書いたことの結論はひとつです。
権限モデル → チャンク設計 → インデックス設計 → 検索
この順番でしか作れない。
逆順に作ると、つまり「まず動くRAGを作ってから権限を足そう」とすると、チャンクの切り直しと再ベクトル化が発生します。文書量が数万件を超えていれば、それだけで数日かかる作業です。しかもその間、システムは止まります。
デモを作るだけなら権限は要りません。ですが社内に出すつもりがあるなら、最初のスプリントで権限モデルを決めてください。これが、この記事でいちばん伝えたいことです。
まとめ
- ベクトル検索は意味の近さしか見ていない。権限という概念が計算に存在しない
- 検索してから落とす「後フィルタ」は、件数が消えるうえに文書の存在を漏らす
- 正解は事前フィルタ。グループID配列+GINインデックスで、距離を測る前に絞る
- 絞ると件数が足りなくなるので、pgvectorの反復スキャンを有効にする
- チャンクの混在・会話履歴・プロンプト任せ、の3経路が残る
- LLMに渡したチャンクIDのログがないと、事故のときに何も説明できない
- 権限は後付けできない。最初に決める
POSIIでは、社内データを外に出さない構成でのRAG構築と、権限設計を含む要件整理をお手伝いしています。「PoCまでは動いたが、本番に出す段になって権限で止まっている」という段階のご相談が最も多い領域です。