🔍

3つのプロバイダーで同じRAGを動かしてわかったこと——セキュリティ設計はどこに置くべきか【コード付き】

に公開

はじめに

製造業向けRAGシリーズの第5弾です。

第1弾(設計編)ではACL-aware retrievalの設計原則を、第2弾(実装編)ではChromaDB + Cohere + ClaudeによるACLフィルタリングの実装を、第3弾(運用編)では監査ログとイベント駆動再インデックスを、第4弾(防御比較編)ではPrompt Injection三層防御を扱いました。

今回は少し趣向を変えます。これまでClaude(Anthropic)のAPIをLLMに使ってきましたが、同じシステムをOpenAI(GPT-4o-mini)とCohere(Command R+)でも実装し、3つのプロバイダーを同一条件で比べてみました。

きっかけは単純です。Cohere版を実装したついでに「せっかくなら同じデータ・同じクエリで全部動かしてみよう」と思ったことです。

対象読者は第1〜4弾を読んだエンジニア・アーキテクトで、RAGのプロバイダー選定を検討している方を想定しています。

シリーズ構成:


1. 比較実験の設計

3プロバイダーの構成

まず、3つのシステムがどう違うかを整理します。

役割 Claude版 OpenAI版 Cohere版
Embedding Cohere Embed v3 text-embedding-3-small embed-multilingual-v3.0
Vector Store ChromaDB(ローカル) OpenAI Vector Store(クラウド) ChromaDB(ローカル)
Rerank Cohere Rerank v3 なし(File Search内蔵) rerank-multilingual-v3.0
LLM claude-haiku-4-5 gpt-4o-mini command-r-plus-08-2024
ACL設計 Python側フィルタ(Fail Closed) File Search filtersパラメータ経由(Fail Closed) Python側フィルタ(Fail Closed)

v3系に統一した理由

Embed v4やRerank v4も存在しますが、今回の目的は「同一条件での比較」なので、Claude版・OpenAI版と条件を揃えるためv3系を選びました。「同一条件で比較した」と明記できることが重要でした。

OpenAI版だけVector Storeが異なる

OpenAI版はFile Search APIを使っているため、Vector StoreがOpenAIのクラウドにホストされます。これはアーキテクチャ上の差異として明記しました。後述のレイテンシに直接影響します。

比較条件

  • クエリ: 「冷却システムの点検手順を教えてください」
  • テストユーザー: tanaka(ACL通過)/ yamada(ACL拒否)/ unknown(Fail Closed対象)
  • データ: 3プロバイダー共通の製造業SOP文書

2. 実装上の発見:マルチプロジェクト統合の注意点

設計は決まりました。実装してみると、3つのプロジェクトを一括実行する比較スクリプトで予想外の問題に当たりました。Pythonのimportが落とし穴になったのです。

sys.modulesキャッシュ衝突

Claude版の query.py(ファイル)をインポートした後、OpenAI版の query/(パッケージ)をインポートしようとすると衝突が起きました。

# ❌ 失敗パターン
# Claude版 query.py が sys.modules に 'query' として登録された状態で
# OpenAI版 query/ パッケージを import しようとするとエラー
ModuleNotFoundError: No module named 'query.acl_filter'; 'query' is not a package

解決策は importlib.util.spec_from_file_location() でパスを直指定し、各プロバイダーのimport前に sys.modules のキャッシュをクリアすることでした。

import importlib.util
import sys

def load_provider_module(project_path: str, module_name: str):
    """プロジェクトごとにモジュールをパス直指定でロードする"""
    # 前のプロバイダーのキャッシュをクリア
    for key in list(sys.modules.keys()):
        if key in ("query", "documents", "chunking"):
            sys.modules.pop(key)

    spec = importlib.util.spec_from_file_location(
        module_name,
        f"{project_path}/{module_name}.py"
    )
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module

ChromaDB相対パス問題

各プロジェクトの query.pypath="./chroma_db" と書いていましたが、比較スクリプトを別ディレクトリから実行すると参照先がズレました。

# ❌ 相対パス(実行ディレクトリに依存する)
client = chromadb.PersistentClient(path="./chroma_db")

# ✅ 絶対パス(プロジェクトルートを基準にする)
import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
client = chromadb.PersistentClient(path=os.path.join(BASE_DIR, "chroma_db"))

「動かしてみて初めて気づく」系のミスですが、マルチプロジェクト統合では必ずこの問題が起きると思っていた方がよいです。


3. 実測結果

レイテンシ比較(実測値)

プロバイダー tanaka(ACL通過) yamada(ACL拒否) unknown(Fail Closed)
Claude 3.02秒 2.54秒 0.00秒 ✅
OpenAI 5.81秒 2.49秒 0.00秒 ✅
Cohere 12.80秒※ 0.98秒 0.00秒 ✅

※Cohereのtanakaは2回目計測でレート制限のリトライが発生(初回: 2.66秒)

OpenAIが約2倍遅い理由

Vector StoreがOpenAIのクラウドにあります。ローカルChromaDBと比べてネットワークレイテンシが加算されます。「データ主権 vs レイテンシ」のトレードオフです。クラウドVector Storeはデータ管理が楽になる一方、レイテンシのコントロールが難しくなります。

Fail Closedは全プロバイダーで0.00秒

unknownユーザーは全プロバイダーで即時拒否されます。LLMへのリクエストが一切発生しないためコストもゼロです。これはFail Closed設計の重要な特性です。

コスト比較(tanakaユーザー・ACL通過ケース)

プロバイダー input tokens output tokens コスト
Claude 485 289 $0.001544
OpenAI 1,576 237 $0.000379
Cohere 230 70 $0.001275

OpenAIのinput tokenが最も多い理由

Vector Storeの検索結果をそのままLLMに渡すため、トークン数が多くなりやすい設計です。ただしGPT-4o-miniの単価が低いため、合計コストは最も安くなっています。

Cohereのoutput tokenが最も少ない

Command R+はRAG特化設計のため、回答が簡潔になりやすい傾向があります。これは後述の回答品質比較と連動しています。

回答品質比較(同一クエリの実出力)

「冷却システムの点検手順を教えてください」に対する3つの回答を並べます。

【Claude版】

冷却システム点検手順書(ラインA)に記載された点検手順は以下の通りです:

点検開始前:
保護具(耐熱手袋・安全靴)を着用すること。

手順1: メインバルブが閉じていることを確認する。
手順2: 冷却弁を徐々に開放し、流量計が規定値(毎分50リットル)であることを確認する。
手順3: 温度計が規定値(60℃以下)であることを確認する。
手順4: 異常音・異常振動がないことを目視および触診で確認する。
       異常時は直ちに緊急停止ボタンを押し、保守リーダーおよび工場長に連絡すること。

点検頻度は週1回(毎週月曜日の始業前)。記録は点検台帳に...

【OpenAI版】

冷却システムの点検手順は以下の通りです:

1. メインバルブが閉じていることを確認する。
2. 冷却弁を徐々に開放し、流量計が規定値(毎分50リットル)であることを確認する。
3. 温度計が規定値(60℃以下)であることを確認する。
4. 異常音・異常振動がないことを目視および触診で確認する。
   異常時は直ちに緊急停止ボタンを押し、保守リーダーおよび工場長に連絡すること。

点検頻度は週1回(毎週月曜日の始業前)で、
記録は点検台帳に記入し3年間保管することが求められています。

【Cohere版】

1. 冷却弁の開放確認
2. 冷却水温度を記録(基準値: 15-25℃)
3. ポンプ圧力確認(基準値: 0.3-0.5MPa)

異常が見つかった場合は、直ちに保全リーダーへ報告してください。
プロバイダー 回答フォーマット 情報量 特徴
Claude 文章形式(見出し付き) 多い 保護具・手順・頻度・異常時対応まで網羅
OpenAI 番号付きリスト 最も多い 保管期間(3年)まで言及・最も完全
Cohere 番号付き箇条書き 少ない 簡潔だが保護具・点検頻度が抜ける

temperature=0でもフォーマットは統一できない

3つとも temperature=0 を指定しましたが、出力スタイルはモデルごとに異なりました。temperature=0 は「内容の確定性」を上げる設定であって、「フォーマットの統一」はできません。エンタープライズ導入時は system prompt で明示的に出力フォーマットを指定する必要があります。


4. 気づき:セキュリティ動作が3プロバイダーで同じだった理由

ACL動作比較(全プロバイダー)

プロバイダー tanaka(通過) yamada(拒否) unknown(Fail Closed) 実装方式
Claude PermissionError例外
OpenAI 文字列返却
Cohere PermissionError例外

全プロバイダーでACL・Fail Closedが正常動作しました。これは偶然ではありません。

なぜ全部✅になったのか

実装を見れば理由は明確です。ACLフィルタはこの場所に書かれています。

def query_with_acl(user_id: str, question: str) -> str:
    # ① ユーザーのグループを取得
    groups = get_user_groups(user_id)

    # ② Fail Closed: unknownユーザーは即時拒否(LLMに届かない)
    if not groups:
        raise PermissionError(f"ユーザー '{user_id}' は未登録のため拒否")

    # ③ Embeddingで候補文書を取得
    results = collection.query(query_embeddings=[query_embedding], n_results=10)

    # ④ Python側でACLフィルタ(LLMに届く前にフィルタする)
    filtered_docs = [
        doc for doc, meta in zip(docs, metadatas)
        if any(g in meta.get("allowed_groups", []) for g in groups)
    ]

    # ⑤ Rerankして上位を選ぶ
    # ⑥ LLMに回答させる
    ...

①〜④はLLMが介在しないアプリケーション層の処理です。どのLLMを使うかは⑥の一行だけが関係します。

OpenAI版はACLフィルタをFile Search APIの filters パラメータとして渡す実装になっており、フィルタリング処理自体はOpenAI側で行われます。ただしFail Closed(未登録ユーザーの即時拒否)はPython側のStep1で処理するため、LLMに届く前にアクセスを制御する設計は3版共通です。

セキュリティ境界はLLMの手前に置く

ACLフィルタをLLM側に任せると、プロンプトで「このユーザーに見せていい文書だけを回答せよ」と指示することになります。これはPrompt Injectionによって無効化されるリスクがあります(第4弾参照)。

Python側に実装すれば、LLMがどんな出力をしようとも、そもそも不正な文書がLLMに届きません。プロバイダーを替えてもセキュリティロジックが壊れないのはこの設計があるからです。


5. 各プロバイダーの選定観点(実測ベース)

レイテンシ・コスト・回答スタイルの整理

観点 Claude OpenAI Cohere
レイテンシ(ACL通過) 3.02秒 5.81秒 2.66秒※
コスト/クエリ $0.001544 $0.000379 $0.001275
回答情報量 多い 最も多い 少ない
回答フォーマット 文章形式 リスト形式 箇条書き(構造化)
Vector Store ローカル クラウド(OpenAI) ローカル
レート制限 安定 安定 リトライ発生あり※

選定の観点

コストを最優先するなら: OpenAI(GPT-4o-mini)。クエリあたり $0.000379 は最安です。ただしVector Storeのクラウド依存とレイテンシを許容できる場合に限ります。

データをローカルに置きたいなら: Claude版またはCohere版。製造業のSOP・機密図面など「クラウドに出したくない文書」を扱うなら、ローカルChromaDBが前提になります。

構造化出力が欲しいなら: Cohere(Command R+)。番号付き箇条書きがデフォルトなので、手順書の回答として相性が良い面もあります。ただし情報量が少なくなりやすい点は要注意です。

※Cohereのレイテンシは安定時2.66秒ですが、レート制限発生時はリトライロジック(3回・3秒待機)が動作し12.80秒まで増加します。本番環境では指数バックオフを含む適切なリトライ設計が必要です。

セキュリティ要件の観点では、どのプロバイダーを選んでも設計は同じです。 ACLとFail ClosedをPython側に実装する限り、LLMの選択と無関係に動作します。差はビジネス要件(コスト・データ主権・レイテンシ許容度)で選ぶことになります。


まとめと次回予告

今回の実験で確認できたことを3点に絞ります。

1. セキュリティ境界をLLMの手前に置くと、プロバイダーを替えても壊れない

ACLとFail ClosedをPython側(アプリケーション層)に実装したことで、3つの異なるLLMを使っても全9ケースで同一の動作を確認できました。「ベンダーロックインしないセキュリティ設計」とはこういうことだと、実装を通じて理解しました。

2. レイテンシ・コスト・回答スタイルにはプロバイダーごとに明確な差がある

Vector Storeのアーキテクチャ(ローカル vs クラウド)がレイテンシに直接影響しました。コストはGPT-4o-miniが最安です。回答スタイルはtemperature=0でも統一できず、モデルの学習データに依存します。

3. マルチプロジェクト統合はPythonのimport設計に注意が必要

sys.modulesキャッシュ衝突とChromaDBの相対パス問題は、実際に動かしてみて初めて気づきました。importlib.util.spec_from_file_location と絶対パス指定が解決策でした。

次回は本番運用設計を扱う予定です。Evals・Observability・SLA/SLO設計を通じて、「動くものを作る」から「安定して動かし続ける」フェーズに進みます。


参考


注: 実測値はM5 MacBook Air(24GB/1TB)・自宅回線環境での計測値。ネットワーク状態・APIの混雑状況により変動します。

Discussion