MENU

問い合わせ


    【FastAPIで作る社内ナレッジ検索(RAG) 第6回】部署ごとに見てよい文書だけを検索する、アクセス制御を実装する

    社内文書に質問できるAIチャットを作るとき、機能の次に必ず出てくるのが「人事部の給与表まで、誰でも聞き出せてしまわないか」という心配です。第5回では、答えが文書に無いときに「見つかりません」と返す仕組みを作りました。第6回では、質問した人の部署によって、検索できる文書を絞り込む部分を実装します。

    前回の記事:【第5回】答えが社内文書に無いときは「見つかりません」と返す仕組みを作る

    「社内AIに全部の文書を読ませたら、一般社員が給与や人事評価のことまで聞けてしまいませんか? 情報漏えいが心配で、導入に踏み切れません。」

    先に結論をお伝えします。権限の絞り込みは、AIに渡す前の「検索の段階」で行うのが原則です。AIに「この人には見せないでね」と指示するだけでは守れません。指示は文章であり、言い回しを変えた質問で突破される可能性があるからです。この回では、文書ごとに「見てよい部署」を持たせ、検索のSQLに条件を加えるだけで、権限のない文書が検索にも回答にも一切出てこない状態を作ります。あわせて、連載のサンプルが使う「部署の受け渡し方法」が本番では使えない理由と、発注時に確認すべき点も整理します。

    目次

    なぜ「検索の段階」で絞るのか

    RAG(=検索で見つけた文書をAIに渡し、それをもとに答えさせる方式)のシステムでは、情報は次の順で流れます。

    1. 質問を受け取る
    2. 質問に近い文書の断片を検索する
    3. 見つかった断片をAIに渡して回答を作る
    4. 回答と出典を利用者に返す

    権限を守るタイミングは、大きく3つ考えられます。

    方法内容評価
    A. AIへの指示で守る「権限のない情報は答えるな」とプロンプトに書く弱い。文書自体はAIに渡っており、言い換えや誘導で漏れる可能性が残る
    B. 検索後に捨てる検索結果から権限のない断片を取り除いてからAIに渡す漏れは防げるが、上位が権限外の断片で埋まると、本来見られる文書まで結果に残らないことがある
    C. 検索の条件に入れる検索のSQLで「見てよい部署の断片だけ」を対象にする強い。権限外の断片は候補にも入らず、AIにも渡らない。この回で採用する

    筆者の見解として、業務で使うならC案を基本にするのが安全だと考えます。権限外の断片が「AIの手元に一度も渡らない」ことは、後から仕組みを説明するときにも分かりやすい利点です。今回はC案を最小の形で実装します。

    この回で作るもの

    部品ファイル役割
    部署の列を足すdb/migrations/003_department.sql断片に「見てよい部署」を持たせる。既存の断片は全社向け(all)
    取り込みapp/ingest/loader.pyフォルダ名を部署にする。直下のファイルは全社向け
    検索app/db/store.py見てよい部署の断片だけを検索対象にする
    APIapp/main.py質問者の部署を受け取り、検索に渡す
    サンプル文書sample_docs/hr/・sample_docs/sales/人事部限定・営業部限定の架空の文書

    実装1:断片に「見てよい部署」の列を足す(db/migrations/003_department.sql)

    第3回で作ったマイグレーション(=データベースの構造を、番号順の小さなSQLで変えていく仕組み)に、3つ目を足します。

    -- db/migrations/003_department.sql
    -- 第6回:断片に「どの部署に見せてよいか」を持たせる。
    -- 'all' は全社員に見せてよい文書。既存の断片は全社公開として扱う(=今までの挙動を変えない)。
    ALTER TABLE chunks ADD COLUMN IF NOT EXISTS department TEXT NOT NULL DEFAULT 'all';
    
    CREATE INDEX IF NOT EXISTS chunks_department_idx ON chunks (department);

    DEFAULT 'all' としたので、すでに入っている断片は全社向けとして扱われ、今までの動作は変わりません。何度流しても同じ結果になる書き方(IF NOT EXISTS)にしているのは、第3回と同じ方針です。

    ここで設計上の判断が1つあります。「部署を指定し忘れた文書」を、全社向けにするか、誰にも見せないかです。このサンプルは動作確認のしやすさから全社向けを既定にしました。しかし、情報漏えいを重く見る現場では、逆に「未指定は誰にも見せない」を既定にするほうが安全です。この点は発注時の確認事項として、後ほど「発注者向けメモ」に入れます。

    実装2:フォルダ名を部署として取り込む(app/ingest/loader.py)

    文書に部署を付ける方法はいくつもありますが、サンプルではフォルダ名を部署名にします。運用担当者が「人事部の文書はhrフォルダに置く」だけで済み、覚えるルールが少ないからです。

    # app/ingest/loader.py(抜粋)
    DEFAULT_DEPARTMENT = "all"
    
    
    def load_documents(directory: Path) -> list[Document]:
        ...
        for path in sorted(directory.rglob("*")):
            ...
            if text:
                relative = path.relative_to(directory)
                department = relative.parts[0] if len(relative.parts) > 1 else DEFAULT_DEPARTMENT
                documents.append(Document(source=relative.as_posix(), text=text, department=department))

    sample_docs/hr/salary-grade.md なら部署は hr、sample_docs/vacation.md のようにフォルダの外にあるファイルは all です。Document と、そこから作られる断片(Chunk)には department の項目を追加し、断片を保存するとき(app/db/store.py の save_chunks)にその値をデータベースへ書き込みます。

    取り込みの確認コマンドは、部署も表示するようにしました。

    python -m app.ingest sample_docs
    expense.txt (部署: all): 1件
    hr/salary-grade.md (部署: hr): 3件
    sales/discount-policy.md (部署: sales): 2件
    vacation.md (部署: all): 4件

    実装3:検索の条件に部署を入れる(app/db/store.py)

    この回の核心は、検索のSQLにたった1行、条件を足すことです。

    # app/db/store.py(抜粋)
    def search_chunks(
        conn: psycopg.Connection,
        embedder: Embedder,
        question: str,
        departments: list[str],
        limit: int = 3,
    ) -> list[SearchHit]:
        query = to_vector_literal(embedder.embed(question))
        rows = conn.execute(
            "SELECT source, heading, body, embedding <=> %s::vector AS distance"
            " FROM chunks WHERE embedding IS NOT NULL AND department = ANY(%s)"
            " ORDER BY embedding <=> %s::vector LIMIT %s",
            (query, departments, query, limit),
        ).fetchall()
        return [SearchHit(*row) for row in rows]

    department = ANY(%s) は「部署が、渡したリストのどれかに一致する」という意味です。リストの渡し方は、psycopg(=PythonからPostgreSQLに接続する部品)の標準の方法で、値をSQL文に直接埋め込まないため、SQLインジェクション(=入力値でSQLを書き換える攻撃)の心配もありません。

    この条件がORDER BY(近い順に並べる)より前のWHEREに入っているのが重要です。B案(検索後に捨てる)だと、上位3件が全部権限外の断片だったとき、本来見てよい文書が候補に残りません。C案は権限内の断片の中から近い順に取るので、その心配がありません。

    リストが空のときは何も返らないので、引数を渡し忘れても「全部見える」側には倒れません。

    実装4:質問者の部署をAPIで受け取る(app/main.py)

    次に、質問した人の部署を決める部分です。

    # app/main.py(抜粋)
    def get_departments(x_departments: Annotated[str, Header()] = "") -> list[str]:
        """質問者が見てよい部署の一覧。全社向け(all)は誰でも見られるので必ず含める。
    
        【重要】ヘッダーを信じるのは連載の動作確認用。ヘッダーは利用者が自由に書き換えられる。
        本番では、ログイン(SSO等)で確かめ済みの情報(署名つきトークンや社内の人事情報)から
        サーバー側で決めること。
        """
        names = [n.strip() for n in x_departments.split(",") if n.strip()]
        return sorted({"all", *names})

    そして /api/ask と /api/search が、この部署の一覧を検索に渡します。

    # app/main.py(抜粋)
    @app.post("/api/ask")
    def ask(
        body: AskRequest,
        searcher: Annotated[Searcher, Depends(get_searcher)],
        llm: Annotated[LLM, Depends(get_llm)],
        settings: Annotated[Settings, Depends(get_settings)],
        departments: Annotated[list[str], Depends(get_departments)],
    ) -> AskResponse:
        hits = searcher(body.question, departments)
        ...

    第4回・第5回の回答処理(answer_question)は一切変えていません。権限の絞り込みが検索の段階で完結しているため、AIに渡る断片は最初から権限内のものだけです。ここが、C案の設計の良いところです。

    動作確認:同じ質問でも部署で結果が変わる

    サンプル文書を取り込み、同じ質問「一般職の月額給与はいくら」を、部署を変えて検索してみます(PostgreSQL 16 + pgvector 0.6 で実行。連載のサンプル用の簡易な埋め込み方式のため、距離の数値は目安です)。

    python -m app.search "一般職の月額給与はいくら"          # 全社向けだけ
    python -m app.search "一般職の月額給与はいくら" hr       # 人事部の権限つき
    --- 全社向けだけ
    0.835  expense.txt >
    0.928  vacation.md > 休暇規程(サンプル) > 年次有給休暇 > 申請方法
    0.943  vacation.md > 休暇規程(サンプル) > 特別休暇 > 慶弔休暇
    
    --- 人事部(hr)の権限つき
    0.593  hr/salary-grade.md > 給与等級表(サンプル・人事部限定) > 等級ごとの月額給与 > 管理職の給与
    0.684  hr/salary-grade.md > 給与等級表(サンプル・人事部限定) > 等級ごとの月額給与 > 一般職の給与
    0.835  expense.txt >

    権限がない側には、給与表が1件も出てきません。APIも同様で、X-Departments: hr というヘッダーを付けたときだけ、回答の出典に hr/salary-grade.md が含まれました。ヘッダーなしの回答の出典は expense.txt だけでした。

    自動テストでも同じことを確かめています。

    ruff check . && pytest

    今回の実行結果は、pytest 34件成功、ruff check 成功でした。追加したテストは、「権限外の部署の断片は返らない」「部署のリストが空なら何も返らない」「APIでもヘッダーの有無で結果が変わる」「フォルダ名が部署になる」などです。

    なお、動作確認の環境にDockerが無かったため、docker compose での起動や docker build は今回も未確認です。また生成AIの実際の回答内容は、APIキーが必要なため確かめていません(キー無しの仮回答モードでの確認です)。

    つまずきやすい点・セキュリティ上の注意

    1. 部署を名乗るヘッダーは、本番では使えません。このサンプルは X-Departments ヘッダーで部署を受け取りますが、ヘッダーは利用者が自由に書き換えられます。たとえばブラウザの開発者ツールで hr と書き足せば、誰でも人事部の権限になってしまいます。本番では、ログイン(SSOなど)で本人確認した結果や、社内の人事情報にもとづいて、サーバー側で部署を決めます。連載ではログイン機能を扱わないため、この部分は省略しています。

    2. 出典の表示にも権限が及びます。今回の設計では、権限外の断片は検索結果にも出典にも入りません。ファイル名そのものが機密(たとえば「◯◯さん_懲戒処分.md」)のことがあるため、出典に出す名前の付け方も運用で決めておく必要があります。

    3. 文書の移動・削除にも注意が必要です。部署はフォルダ名から決まるため、文書を別のフォルダへ移しても、取り込み直すまでは古い部署のままです。取り込みは、同じ文書を取り込み直すと古い断片を入れ替える作り(第2回)なので、移動後は必ず取り込み直します。文書を削除した場合、このサンプルでは古い断片が残るため、削除の反映の仕組みは実運用では別途必要です。

    4. 「全社向け」の中に機密が混ざらないか。既定が all のため、フォルダの置き間違いがそのまま全社公開になります。取り込み前に、対象フォルダの中身を人が確かめる手順を決めておくと安全です。

    5. 文書の内容から漏れる経路もあります。たとえば全社向けの議事録に給与の話が書かれていれば、それは全社向けとして検索されます。権限の仕組みは「文書単位」の入れ物であり、中身の機密度までは判断しません。

    6. 本番向けには、より強い仕組みもあります。PostgreSQLには、行ごとに見られる人を制限する行レベルセキュリティ(RLS)という機能があります。アプリのバグで条件の付け忘れがあっても、データベース側で止められる利点があります。連載では仕組みを分かりやすくするため、SQLの条件で実装しました。

    発注者向けメモ:社内AI検索の「権限」を頼むときの確認点

    社内文書検索AIの開発を外部に頼む場合、機能の見積もりに目が行きがちですが、権限の設計は費用と安全性の両方を大きく左右します。

    発注者がやること チェックリスト

    • ☐ 検索AIに読ませる文書の一覧を作り、「全社員向け」「部署限定」「役員限定」など公開範囲ごとに分ける
    • ☐ 文書の公開範囲を、誰が最終的に決めるかを決める(現場の担当者か、部門長か)
    • ☐ 公開範囲が決まっていない文書は、最初の対象から外す
    • ☐ 既存の社内ログイン(Microsoft 365、Google Workspace等)と連携するかどうかを決める
    • ☐ 異動・退職・兼務のときに、権限がいつ、誰の操作で変わるかを決める
    • ☐ 「誰が、いつ、何を質問したか」を記録するかどうか、記録の閲覧者を決める
    • ☐ 公開前に、権限のない人として質問し、機密が出ないことを確かめる受け入れテストを用意する

    開発会社への質問例

    • 「権限の絞り込みは、AIへの指示ではなく、検索の段階で行う設計ですか?」
    • 「部署の指定を忘れた文書は、全員に見えますか? それとも誰にも見えませんか?」
    • 「利用者の部署は、社内のログイン情報のどこから取得しますか? 画面やヘッダーの値をそのまま信じていませんか?」
    • 「異動や退職があったとき、権限の反映は何分・何日で行われますか?」
    • 「権限外の情報が出ないことを、どんなテストで確認しますか? その結果を見せてもらえますか?」

    工数やリスクが増える条件

    • 文書の公開範囲が整理されていない(整理から始める必要がある)
    • 個人ごと・案件ごとなど、部署より細かい権限が必要
    • 兼務や異動が多く、権限が頻繁に変わる
    • 既存の文書管理システム(SharePoint等)の権限をそのまま引き継ぎたい
    • 回答の記録(ログ)を、監査に使える形で残したい

    まとめと次回予告

    今回のポイントは次の3つです。

    • 権限の絞り込みは、AIへの指示ではなく、検索の段階(SQLの条件)で行う
    • 権限外の断片は、候補にも出典にもAIへの入力にも入らないので、回答処理は変えなくてよい
    • 部署の受け渡しは、連載では動作確認用のヘッダー。本番ではログイン情報からサーバー側で決める

    次回は第7回として、PDFの取り込みを扱います。PDFから文字を取り出し、ページ番号つきの出典を表示できるようにする予定です(タイトル案:【第7回】PDFを取り込み、ページ番号つきの出典を表示する)。

    この連載の記事一覧

    この記事は連載「FastAPIで作る社内ナレッジ検索(RAG)」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      目次