こんにちは、ネットワールドの海野です。
前編では、社内 PDF を対象にした RAG の設計思想 — 「データ正本はオンプレ、計算はクラウド」という割り切りについて書きました。後編では、その設計を実際にどうコードに落としたかをお伝えします。
ソースコードは GitHub で公開しています。
インフラエンジニアが書いたコードなので粗い部分もありますが、「動くものを作った記録」として読んでいただければと思います。
前編の振り返り
前編で決めた4つのポイントです。
- データ主権 — PDF の原本、チャンク、エンベディング、インデックスはすべてオンプレに置く
- 最小開示 — クラウド(AWS Bedrock)に送るのはユーザーの質問と Top-K 根拠の抜粋だけ
- 統制と説明可能性 — 誰が何を聞いて何が返ったかを追跡できるようにする
- 可搬性 — 将来 Bedrock から別の推論基盤に乗り換えても、検索レイヤーは変えなくて済む構造にする
このポイントがコードのどこに表れているかを追っていきます。
フィルタ検索と API
最初のベクトル検索はインデックス内の全チャンクが候補でした。社内文書を対象にすると、就業規程の質問をしているのに出張精算規程のチャンクが混ざる、という問題がすぐに出ます。
doc_id(文書)、dept(部署)、confidentiality(機密度)などでフィルタできる仕組みを入れました。フィルタを何も指定しない検索はデフォルトで拒否します(fail-closed)。
# フィルタ指定あり → 就業規程だけを対象に検索
python rag_vector_cli.py --question "試用期間は何ヶ月ですか" \
--filters-json '{"doc_id": ["就業規程"]}'
# フィルタなし → エラーで停止
python rag_vector_cli.py --question "試用期間は何ヶ月ですか"
# => Error: No retrieval scope resolved. Use --allow-unscoped to permit.
質問文からスコープを自動推定する仕組みも入れていますが、推定に失敗した場合はやはり停止します。
CLI だけだと外部ツールとの連携口がないので、HTTP の API サーバーも立てました。
# 起動
python rag_api_server.py --index-dir rag_data/index
# 検索
curl -s http://localhost:8000/search \
-H "Content-Type: application/json" \
-d '{"question": "試用期間は何ヶ月ですか", "top_k": 5}'
この2つで「質問を投げたら根拠付きの回答が返る」という最小限の動線ができました。
PDF からチャンクを作る
PDF をテキストに変換し、検索しやすいサイズに分割し、メタデータを付与するところまでがこのパイプラインの範囲です。
pypdf だけだと社内文書の表組みが崩れるケースがあったので、PyMuPDF も足してページ単位で品質の高い方を採用するようにしました。同じ PDF でもページによって得意なエンジンが変わります。
チャンキングは 900文字 / 150文字オーバーラップ です。正直なところ試行錯誤の結果で、500文字だと文脈が切れすぎる、1500文字だと Top-K の多様性が落ちる、という感触から 900 に落ち着きました。日本語は空白で段落が分かれないので、句点やセクション見出しを認識して段落単位で分割しています。
メタデータは後から必要になって足しました。フィルタ検索を実装する段階でチャンクごとに doc_id, dept, labels, updated_at, confidentiality が要ることがわかり、この5項目が揃わないとインジェストを停止する品質ゲートを設けています。
FAISS でベクトル検索
テキストをベクトルに変換して類似検索するための基盤には FAISS を使っています。
OpenSearch Service も検討しましたが、PoC 段階では見送りました。常時稼働コストがかかることと、前編の「ベクトルインデックスもオンプレに置く」という設計に矛盾すること、の2点です。FAISS ならローカルに .faiss ファイルとして保存するだけなので、コストゼロでデータの所在も明確です。
Embedding モデルは intfloat/multilingual-e5-small です。日本語対応、軽量で CPU で回せる、Apache 2.0、そしてオンプレ実行できる。前編で書いた「なぜ Embedding はローカルなのか」の答えが、そのまま選定理由です。
Retriever Contract
検索処理は共通のインターフェースを通して呼び出しています。現在の実装はローカル FAISS ですが、将来 VAST Data や NetApp に差し替えるときにインターフェースの入出力だけ合わせれば済む構造にしました。
差分更新の仕組みも入れており、文書ごとのフィンガープリントで変更があったものだけ再インデックスします。ただし、ファイル追加を検知して自動実行するトリガーはまだありません。(手動です。)
Bedrock への問い合わせ
ベクトル検索で Top-K の根拠チャンクが得られたら、ユーザーの質問と一緒に AWS Bedrock へ送って回答を生成します。ここが「最小開示」の最重要ポイントです。
Bedrock の呼び出しには aws bedrock-runtime converse コマンドを subprocess で実行しています。
cmd = [
"aws", "bedrock-runtime", "converse",
"--region", self.region,
"--profile", self.profile,
"--model-id", model_id,
"--cli-input-json", f"file://{payload_file}",
"--query", "output.message.content[0].text",
"--output", "text",
]
boto3 を使うのが一般的だということは後から知りました。最初に CLI でテスト通信したスクリプトがそのまま発展した経緯です。検証段階ではプロファイル切り替えや --debug が便利でしたが、本番なら boto3 に移行すべきだと思っています。
プロンプト構成
Bedrock に送るプロンプトは、システムプロンプト + Evidence ブロック(Top-K チャンクの番号付きリスト) + ユーザーの質問の3パートです。「Evidence だけに基づいて回答する」「根拠がなければ回答しない」といったルールでハルシネーションを抑えています。送信前にメールアドレスや電話番号もマスクしています。
Rerank と回答プロファイル
ベクトル検索の Top-K をそのまま使うと根拠として微妙なチャンクが混ざることがあるので、Bedrock の Rerank API で再スコアリングしています。
回答モデルは用途に応じて使い分けられるようにしました。
| プロファイル | モデル | 用途 |
|---|---|---|
cost |
Gemma 3 4B | コスト重視。簡単な質問の確認用 |
high |
Gemma 3 27B | 品質重視。複雑な質問や検証用 |
リファクタリング
開発が進むにつれてコードの重複や役割の集中が出てきたので、8ステップに分けてモジュール分離を進めました。
| 内容 | 効果 |
|---|---|
| CLI/API 共通の QA フローを集約 | 検索→回答の実行パスが1箇所に |
| CLI の引数パースを分離 | CLI 本体 608行 → 165行 |
| 設定バリデーションを共通化 | 設定の不整合を防止 |
| 未接続時のエラー分類を明示化 | silent fallback を防止 |
| 例外捕捉を業務例外に限定 | 実装バグの握り潰しを防止 |
| Bedrock 呼び出しを抽象化 | テスト時にモックが差せる |
| API 契約テストを追加 | リファクタ時の仕様崩れを検出 |
| PDF 取り込みパイプラインを分割 | 抽出品質改善を安全に進められる構造に |
ユニットテストは最終的に 55件。GitHub Actions で PR ごとに回帰テスト(Recall >= 0.95, MRR >= 0.95)を走らせています。
Terraform でコスト管理
IaCオジサンとしてはここがいちばん慣れた作業でした。
AWS Budgets で月額 $90 の上限を設定し、3段階の閾値でメール通知を出しています。Bedrock は従量課金なので、テストスクリプトの無限ループなどでうっかり使いすぎるリスクがあります。試算では月 10,000 リクエストでも $8〜25 程度なので $90 あれば余裕ですが、安全マージン込みです。
IAM の最小権限
Bedrock を呼び出すための IAM ポリシーは、許可するモデルを ARN 単位で制限しています。
# 生成モデル(Gemma 3 4B / 27B)のみ許可 statement { sid = "InvokeBedrockModels" actions = ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"] resources = [for model_id in var.allowed_model_ids : "arn:aws:bedrock:${var.region}::foundation-model/${model_id}"] } # Rerank モデルのみ許可 statement { sid = "InvokeRerankerModel" actions = ["bedrock:InvokeModel"] resources = [local.rerank_model_arn] } # Rerank API statement { sid = "RerankDocuments" actions = ["bedrock:Rerank"] resources = ["*"] }
resources = ["*"] は Rerank API の仕様上避けられない箇所で、生成モデルの呼び出しは ARN 単位で制限しています。
ここで正直に書いておきますと、現在の構成は IAM ユーザー + 長期アクセスキー方式です。2022年に IAM Roles Anywhere が出て、オンプレからでも一時クレデンシャルが取れるようになっていますが、いったん未対応です。本番に持っていくなら移行が必要だと思っています。 このままマネするのはおすすめしません。
やれていないこと (GitHub Issue に飛びます)
- VAST/NetApp 実接続 (#7) — Retriever Contract のアダプタ枠は用意してありますが、実接続はまだです。VAST 側のバージョンアップ待ちもあります。
- OpenWebUI / Dify 連携 (#17) — エンドポイントは用意しましたが、実機での E2E テストはまだです。
- イベント駆動インデクシング (#5) — 差分更新は実装済みですが、自動トリガーはまだありません。
- 監査ログ (#6) — request_id による追跡はできていますが、保持期間や PII マスク拡張は未整備です。
まとめ
| 設計ポイント | 実装での対応 |
|---|---|
| データ主権 | PDF 原本・チャンク・FAISS インデックスはすべてローカルに保持 |
| 最小開示 | Bedrock に送るのは質問 + Top-K 根拠のみ。送信前に PII をマスク |
| 統制と説明可能性 | request_id 貫通の監査ログ、fail-closed スコープ、IAM 最小権限 |
| 可搬性 | Retriever Contract による検索バックエンドの抽象化、回答プロファイルによるモデル切り替え |
MVP として動くところまでは来ました。ユニットテスト 55件、Recall@K = 1.0、MRR = 1.0 という数字は出ていますが、評価用の小さなデータセットでの結果です。完成ではなく、「ここまでは動いた」という報告です。
次回予告 — VAST DATA 連携に向けて
補足コラムでは、VAST Data および NetApp との連携検証について書く予定です。Retriever Contract を入れた理由がここで活きてくるはずです。ローカル FAISS からの差し替えがアダプタの実装だけで済むかどうか、やってみた結果をお伝えします。
リポジトリ: https://github.com/unnowataru/vpnless-rag-mvp
(だいぶ先になりそうですが。)
あとがき
インフラエンジニアが Python で RAG を組む、というのは自分でも最初は違和感がありました。ただ、やってみると「PDF を取り込んで、検索して、API を叩いて、結果を返す」という流れは、インフラ構築の手順設計と似ている部分があります。
粗い部分は隠さずに書きました。PoC は完璧である必要はなくて、「動くものを作って、課題を洗い出す」ことが目的です。この記事がその材料になればいいなと思います。あと、Codex に丸投げしてる部分もけっこうありますが、スモークテストとか契約とかなんなんだ…?ってなりました。みなさんもそういうとこありませんか?
まあでも、バイブコーディングでもなんでも一度作ったことあるのとないのとでは見える世界が違うなぁ…という感想です。