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弾(設計編)
- 第2弾(実装編)
- 第3弾(運用編)
- 第4弾(防御比較編)
- 第5弾(本記事・プロバイダー比較編)
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.py に path="./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設計を通じて、「動くものを作る」から「安定して動かし続ける」フェーズに進みます。
参考
- 第1弾(設計編)
- 第2弾(実装編)
- 第3弾(運用編)
- 第4弾(防御比較編)
- GitHub: ku-kyoto-lab
注: 実測値はM5 MacBook Air(24GB/1TB)・自宅回線環境での計測値。ネットワーク状態・APIの混雑状況により変動します。
Discussion