社内文書を対象にした を作ると、必ず権限の問題にぶつかります。人事の文書、経営の資料、他部門の案件情報。全員が全部を見てよいわけではありません。
この回は、見せてよい文書だけを引く設計を扱います。結論を先に言うと、検索の前に絞るのが原則です。
1. 後から除外では足りない
最も素朴な実装は、検索してから権限で除外するというものです。しかしこれには問題があります。
| 方式 | 問題 |
|---|---|
| 検索後に除外 | 候補に入った時点で漏れる経路が残る |
| 同上 | 上位が全部除外され、結果が空になる |
| 同上 | 除外された件数から存在が推測できる |
| 事前に絞る | 問題が起きにくい |
3行目は見落とされやすい漏洩経路です。「該当10件のうち8件は表示できません」と出すと、その人が見られない文書が8件存在すると分かります。件数だけでも情報になります。
2行目も実務的な問題です。関連度の高い上位が全部除外されると、結果が空になる。利用者からは「何も見つからない」としか見えず、権限の問題だと分かりません。
候補に入った文書は、除外される前に何らかの処理を通ります。ログに残る、キャッシュに載る、再ランクの計算に使われる。除外の位置が後ろになるほど、経路が増えます。
2. 事前に絞る
── 検索する前に、対象を見てよい範囲へ絞る ── が原則です。多くの検索基盤が、条件付きの検索を支援しています。
def search_with_permission(client, query: str, user, top_k: int = 5): """利用者の権限で絞ってから検索する。""" # 1. その人が見てよい範囲を決める(少数のグループにまとめておく) allowed_groups = user.access_groups # 例: ["all", "sales", "kanto"]
if not allowed_groups: # 何も見られない場合は、検索自体を実行しない return {"hits": [], "reason": "no_access"}
# 2. 検索の条件に含める(後から除外しない) results = client.search( query=query, filter={"access_group": {"$in": allowed_groups}}, top_k=top_k, )
return {"hits": results, "reason": None}
# 検索後に除外する実装との違い:# 後から除外 → 候補に入る → ログ・キャッシュ・再ランクを通る# 事前に絞る → そもそも候補に入らない何も見られない場合に、検索自体を実行しないのも要点です。実行してから0件になるより、そもそも走らせないほうが経路が短く、応答も速い。
3. 権限をどう表現するか
文書1つずつに個別の権限を持たせると、絞り込みが複雑になり、遅くなります。少数のグループにまとめるのが現実的です。
| 表現 | 柔軟さ | 速さ | 管理 |
|---|---|---|---|
| 全体公開のみ | 低い | 最も速い | 最も簡単 |
| 少数のグループ | 中 | 速い | 簡単 |
| 部門 × 機密度 | 高い | 中 | 中 |
| 文書ごとに個別 | 最も高い | 遅い | 破綻しやすい |
2行目か3行目が実務的な落としどころです。最下行は柔軟ですが、条件が長大になり、検索が遅くなります。また権限の変更が追いつかなくなります。
この判断は 壊れないデータ基盤 EP.15 で扱った層の設計と同じ構造です。柔軟さと把握しやすさは交換関係にあります。
新しい権限体系を作らないのが最も安全です。文書が置かれている場所の権限をそのまま使えば、二重管理になりません。「RAG では見えるが、元の場所では見えない」という食い違いも防げます。
4. 索引に埋め込まない
重要な設計判断です。権限の情報を、索引を作るときに固定してはいけません。
| 方式 | 権限が変わったとき |
|---|---|
| 索引に埋め込む | 索引の作り直しが必要 |
| 検索時に参照する | すぐ反映される |
人事異動や退職は日常的に起きます。そのたびに索引を作り直すのは現実的ではありません。索引には「この文書はどのグループのものか」だけを持ち、「その人がどのグループか」は検索時に参照します。
こうすると、権限を外した瞬間に反映されます。エンジニアの引き継ぎ術 EP.01 で扱った離任処理でも、確実に効くようになります。
5. 落とし穴
設計が正しくても、周辺で漏れる経路があります。
- キャッシュ — 誰かの検索結果が、別の人に返っていないか
- ログ — 検索結果の内容を記録していないか
- 引用の表示 — 見られない文書の題名が出ていないか
- 評価用のデータ — 権限を無視して集めていないか
- 開発環境 — 本番の文書をそのまま入れていないか
1番目が最も危険です。利用者を鍵に含めないキャッシュを使うと、Aさんの結果がBさんに返ります。パフォーマンスチューニング実践 EP.06 で「鍵の漏れは事故になる」と書いたのは、まさにこの形です。
3番目も見落とされます。回答に出典を表示するとき、その文書名が見てよいものかを確認していますか。事前に絞っていれば問題ありませんが、出典だけ別経路で取得している実装では漏れます。
6. 確認の方法
実装したら、確認します。権限の誤りは、見えすぎる方向はエラーにならないので、テストでしか検出できません。
import pytest
# 役割と、その人が見てよい文書のIDEXPECTED = { "general": {"doc-001", "doc-002"}, "sales": {"doc-001", "doc-002", "doc-010", "doc-011"}, "hr": {"doc-001", "doc-002", "doc-050"},}
# 権限に関係なく、必ず試す質問PROBE_QUERIES = [ "給与", "評価制度", "来期の方針", "顧客リスト", "退職",]
@pytest.mark.parametrize("role", sorted(EXPECTED))@pytest.mark.parametrize("query", PROBE_QUERIES)def test_no_unauthorized_document(client, role, query): """どの質問でも、見てよい範囲を超えた文書が出ないこと。""" user = make_user(role) result = search_with_permission(client, query, user, top_k=20)
returned = {h["doc_id"] for h in result["hits"]} leaked = returned - EXPECTED[role]
assert not leaked, ( f"role={role} query={query!r} で見えてはいけない文書: {sorted(leaked)}" )役割 × 質問の組み合わせを網羅するのが要点です。特定の質問でだけ漏れる、ということがあります。機微な語を意図的に含めた質問を用意しておくと、検出力が上がります。
そして、権限の設定を変えるたびにこのテストを流す。LLM時代のテスト戦略 EP.05 で扱ったリグレッションとして、最も守るべき対象です。
検索後の除外では足りない(候補に入る時点で経路が生まれ、件数からも推測できる)。原則は 事前絞り込み。権限は少数のグループにまとめ、元の文書管理の権限に合わせる(新しい体系を作らない)。索引に埋め込まず、検索時に参照すれば、権限の変更が即座に効く。そしてキャッシュの鍵に利用者を含めることを忘れないでください。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。