社内向けのナレッジ検索に Gemini で RAG を組み込んだ初日のことを、今でもよく覚えています。デモは完璧でした。「先月の広告収益の推移は?」と聞けば、それらしい数字を引いて答えてくれます。問題は、その質問をアルバイトのアカウントで投げたときに起きました。
経営にしか共有していない収支メモが、しれっと回答の根拠に混ざっていたのです。
本文そのものは表示されません。けれど Gemini は「先月は前月比で増加しています」と、見えてはいけない数字を要約として喋ってしまいました。ベクトル検索は「意味が近いチャンク」を返すだけで、「この人が見てよいか」を一切問わないからです。
権限を尊重する RAG は、後付けの機能ではありません。検索の設計そのものに権限を編み込む必要があります。ここでは、個人開発で複数サービスのナレッジを横断検索する仕組みを作りながら私自身がぶつかった失敗と、その対処を、動くコードとともにお話しします。
なぜ普通の RAG は静かに漏らすのか
典型的な RAG は三段構えです。文書をチャンクに割り、埋め込みベクトルにしてベクトルストアへ。質問が来たら近傍検索で上位 k 件を引き、それを文脈として Gemini に渡す。
この設計には、権限という概念が一文字も登場しません。インデックスは「全員の文書が混ざった一つの巨大な袋」であり、近傍検索はその袋の中から意味の近いものを取り出すだけです。袋の中に役員専用の収支メモが入っていれば、アルバイトの質問にもそれが当たります。
最初に思いつく対処は「回答後フィルタ」です。Gemini に答えさせてから、根拠にした文書をチェックして権限がなければ捨てる。これは二重に間違っています。第一に、捨てる頃にはもう Gemini が秘密を読んで要約してしまっている。第二に、上位 k 件が権限のない文書で埋まると、本来見えるはずの文書が圏外に押し出され、回答品質まで落ちます。
正しい順序は逆です。権限で絞り込んでから検索する 。これを検索前フィルタ(pre-filtering)またはセキュリティトリミングと呼びます。
設計の骨子 — 権限を検索の一級市民にする
考え方はシンプルです。チャンクひとつひとつに「このチャンクを見てよいプリンシパルの集合」を ACL(アクセス制御リスト)として持たせます。プリンシパルとは、ユーザー ID やグループ ID のことです。
検索時には、質問してきたユーザーが属するプリンシパル集合を展開し、「ACL がそのいずれかと交わるチャンクだけ」を候補にします。ベクトル類似度の計算は、その絞り込んだ集合の中だけで行います。
ここで大切なのは、ACL をベクトルストアのメタデータフィルタ として効かせることです。多くのベクトルストア(Pinecone、Qdrant、pgvector、sqlite-vec など)は、近傍検索とメタデータ条件を同時に適用できます。これにより「意味が近く、かつ見てよい」上位 k 件が一度のクエリで取れます。
まずインデックス側です。チャンクを格納するとき、本文の埋め込みと一緒に allowed_principals を必ず付けます。
from google import genai
client = genai.Client( api_key = "YOUR_GEMINI_API_KEY" )
def embed (text: str ) -> list[ float ]:
res = client.models.embed_content(
model = "gemini-embedding-001" ,
contents = text,
)
return res.embeddings[ 0 ].values
def index_chunk (store, doc_id: str , chunk_text: str , allowed_principals: list[ str ]):
"""1チャンクをACL付きで格納する。
allowed_principals には『このチャンクを見てよい』ユーザーID・グループIDを列挙する。
例: ["user:masaki", "group:executives"]
"""
if not allowed_principals:
# 空のACLは『誰でも見える』ではなく『誰も見えない』と解釈する。
# ここを取り違えると、ラベル付けを忘れた文書が全員に漏れる。
raise ValueError ( f "doc= { doc_id } に allowed_principals がありません(誤って全公開を防ぐため拒否します)" )
store.upsert(
id = f " { doc_id } " ,
vector = embed(chunk_text),
metadata = {
"text" : chunk_text,
"doc_id" : doc_id,
"allowed_principals" : allowed_principals,
},
)
allowed_principals が空のときに例外を投げているのが、地味ですが最重要のポイントです。「ACL が空=制限なし=全公開」と暗黙に扱う実装が一番事故ります。**安全側の初期値は『全部見える』ではなく『誰も見えない』(deny by default)**です。ラベル付けを忘れた文書は、漏れるのではなく検索に出てこないのが正しい挙動です。
検索側 — プリンシパルを展開してハード絞り込みする
ユーザーが質問してきたら、まずそのユーザーの所属を**正本(権限の元データ)**から引き直してプリンシパル集合を作ります。ここでインデックスに焼き込んだ古い所属情報を使ってはいけません。理由は後述する「古い ACL 問題」です。
def resolve_principals (user_id: str , directory) -> set[ str ]:
"""ユーザーの所属を権限の正本から取得し、プリンシパル集合に展開する。
本人ID + 所属グループ(ネストしたグループも再帰展開)。
"""
principals = { f "user: { user_id } " }
for group in directory.groups_of(user_id): # 例: ["executives", "marketing"]
principals.add( f "group: { group } " )
for parent in directory.ancestor_groups(group): # ネスト: marketing → all-staff
principals.add( f "group: { parent } " )
return principals
def secure_retrieve (store, query: str , user_id: str , directory, k: int = 6 ):
principals = resolve_principals(user_id, directory)
hits = store.query(
vector = embed(query),
top_k = k,
# ベクトル近傍とメタデータ条件を同時適用する。
# allowed_principals が principals のいずれかを含むチャンクだけが候補になる。
metadata_filter = { "allowed_principals" : { "$in" : list (principals)}},
)
return hits
メタデータフィルタを使う最大の利点は、絞り込みがベクトル検索と同じクエリの中で 起きることです。権限のない文書はそもそも候補ベクトルとして比較されないため、回答後フィルタのように「秘密を読んでから捨てる」事故が原理的に起きません。上位 k 件は最初から「見てよい文書の中での最良 k 件」になります。
古い ACL 問題 — インデックスは権限の正本ではない
ここが運用で最も痛い学びでした。ベクトルストアの allowed_principals は、インデックスした瞬間の権限のスナップショットにすぎません。「マーケ部から異動した人」「共有が解除された文書」を、ベクトルは知りません。
人事異動でアルバイトが退職した直後、まだ再インデックスが走る前の数時間、その人のトークンが生きていれば古い ACL で文書が見えてしまう。これは理論上の話ではなく、私が実際にヒヤリとした場面です。
対策は二段構えにします。第一に、resolve_principals を必ず正本(ディレクトリや IAM)から都度引くこと。インデックス側の所属を信じない。第二に、回答を組む直前に、引いてきたチャンクの権限を正本でもう一度照合する 。多層防御です。
def verify_access (hits, user_id: str , authz) -> list :
"""検索で当たったチャンクを、回答に使う直前に正本で再照合する。
インデックスのACLが古くても、ここで最終的に弾ける。
"""
verified = []
for h in hits:
# authz は『今この瞬間』の権限を判定する正本。
if authz.can_read( user_id = user_id, doc_id = h.metadata[ "doc_id" ]):
verified.append(h)
return verified
二回照合するのは無駄に見えますが、役割が違います。メタデータフィルタは「権限のない文書を Gemini に読ませない」ための検索効率と安全の両立。回答直前の verify_access は「インデックスが陳腐化していても最終的に漏らさない」ための保険です。前者だけだと古い ACL で漏れ、後者だけだと秘密を含む文脈を Gemini に渡してしまいます。
引用と会話履歴からの漏れを塞ぐ
検索を絞っても、まだ二つの抜け穴があります。
ひとつは引用 。回答に「出典: 2026年5月収支メモ」とファイル名やパスを出すと、本文を見せていなくても「そういう文書が存在する」こと自体が情報になります。引用に出すのは verify_access を通ったチャンクの、表示してよいメタデータ(タイトルや公開 URL)だけに限定します。
もうひとつが会話履歴 です。マルチターンの会話で、権限のあった文書の要約が履歴に残り、権限を失った後のターンでもモデルがそれを参照してしまう。会話履歴は「過去に見えた内容」のキャッシュになりがちです。対策として、機密度の高い回答は履歴に生のまま残さず、ターンごとに権限を再評価する設計にしておきます。
def answer (client, store, user_id, directory, authz, query, history = None ):
hits = secure_retrieve(store, query, user_id, directory, k = 6 )
allowed = verify_access(hits, user_id, authz)
if not allowed:
# 候補ゼロは『該当なし』として正直に返す。
# ここで履歴の記憶から答えようとすると過去の権限で漏れる。
return { "text" : "アクセス可能な範囲では該当する情報が見つかりませんでした。" , "citations" : []}
context = " \n\n " .join( f "[ { i } ] { h.metadata[ 'text' ] } " for i, h in enumerate (allowed))
system = (
"あなたは社内ナレッジのアシスタントです。"
"提供された文脈に書かれている内容だけを根拠に答えてください。"
"文脈にない事柄は推測せず『分かりません』と答えてください。"
)
res = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = f " { system }\n\n # 文脈 \n{ context }\n\n # 質問 \n{ query } " ,
)
citations = [{ "doc_id" : h.metadata[ "doc_id" ]} for h in allowed]
return { "text" : res.text, "citations" : citations}
監査ログ — 誰が何を根拠に答えたか
権限を扱う以上、「漏れていないこと」を後から説明できる必要があります。私は質問のたびに、ユーザー、展開後のプリンシパル、検索で当たった doc_id、verify_access で弾いた doc_id、最終的に根拠にした doc_id を構造化ログに残しています。
弾いた件数を残すのが効きます。verify_access で弾く件数が普段より跳ねたら、それは「インデックスが陳腐化している」「再インデックスが詰まっている」のサインです。実運用では、この弾き率を日次で見ることで、古い ACL を放置する前に再インデックスを走らせる判断ができるようになりました。私の環境では弾き率は平常時 1% 未満で、これが数 % に上がった日は決まってバッチの再インデックスが失敗していました。
本番運用の前に確かめること
最後に、私が痛い目を見て学んだチェックを残しておきます。本番運用に入る前に踏みがちな落とし穴は、だいたい次の3つに集約されます。
権限の異なる二アカウントで対称性テストをする
権限の異なる二つのアカウントで同じ質問を投げる 結合テストを必ず用意してください。役員アカウントでは数字が出て、アルバイトアカウントでは「該当なし」になる。この対称性のテストがあるだけで、検索ロジックの改修で権限がすり抜ける事故をほぼ防げます。私はこのテストを CI の必須項目にすることを強く推奨します。
空 ACL を CI で機械的に弾く
allowed_principals を空のまま投入できない仕組みを CI に入れてください。ラベル付け漏れは「漏れる」のではなく「インデックス時に弾かれる」のが正しく、それを人間の注意力ではなくコードで保証します。
メタデータフィルタの性能特性を知る
プリンシパル集合が数百を超えると $in フィルタが重くなるベクトルストアがあります。その場合はグループを階層化して「all-staff」のような広いプリンシパルに畳み込み、$in の要素数を抑える設計を採用する場合に安定します。私自身、Dolice Labs のナレッジを横断検索する個人開発の現場で、ここを後回しにして検索が詰まった苦い経験があります。
権限を尊重する RAG は、華やかな機能ではありません。けれど「見えてはいけないものが見えない」という当たり前を守ることは、社内に AI を根付かせるうえで何よりの信頼になります。私自身まだ運用しながら学んでいる途中ですが、同じように社内検索の安全性に向き合う方の一助になれば幸いです。