はじめに
仕事で社内ナレッジ検索の PoC(会議の記録から QA集を作り、FAQ チャットで横断検索する RAG アプリ)を作る機会がありました。PoC なので検証が止まっている夜間・休日にコストが流れるのは避けたい。そこでベクトルストアに Amazon S3 Vectors を採用し、アイドルコストほぼゼロで RAG が動く構成にしました。検索品質の本格的な評価はこれからですが、構成としては狙いどおりに動いています。
一方で、S3 Vectors はまだ事例が少なく、特に CDK での構築と Bedrock Knowledge Bases と組み合わせるときの必須設定は、情報がまとまっていないと感じました。本記事は、構築してみて分かった「最初に押さえておけば一発で通るポイント」を整理したものです。同じ構成を試す方の参考になれば幸いです。
S3 Vectors とは
S3 の一部として提供されるベクトルストレージです。専用の「ベクトルバケット」を作り、その中に「ベクトルインデックス」を定義して、ベクトルの格納と類似検索を行います。
一言で言うと「検索エンジンを常時起動しないベクトルストア」。ここが本記事の主題です。
比較対象は OpenSearch Serverless にしました。Bedrock Knowledge Bases のベクトルストアとして最も一般的で、RAG の構成例ではデファクトと言っていい選択肢だからです。採用検討にあたって両者のコストと特性を実査・比較し、その結果 S3 Vectors を選びました。
| 観点 | S3 Vectors | OpenSearch Serverless(比較) |
|---|---|---|
| コスト構造 | ストレージ + クエリの従量課金のみ | OCU の常時課金(最小 1 OCU ≈ 月 $244・東京、2026-07 時点の実査) |
| アイドルコスト | ほぼゼロ | 使わなくても発生 |
| 検索方式 | ベクトル検索のみ | ベクトル + BM25(ハイブリッド)可 |
| クエリレイテンシ | 100ms 級 | 数〜数十 ms 級 |
| 向き | 低〜中頻度アクセス・コスト重視 | 高 QPS(秒間クエリ数)・検索品質重視 |
※ BM25 = キーワードの一致度でスコアリングする全文検索アルゴリズム(OpenSearch のキーワード検索の標準)。意味の近さで探すベクトル検索が苦手とする固有名詞・型番・略語の完全一致に強く、両者の併用が「ハイブリッド検索」です。
採用判断: デメリットを許容できるか
PoC の要件は「利用は日中の低頻度・データは高々数百ドキュメント・コスト最小化」。コストを実査した上で、以下のデメリットを分かった上で採用しました。
- ハイブリッド検索ができない(ベクトル検索のみ・BM25 併用不可)。固有名詞・型番・略語の取りこぼしリスクがあります。ここは実データで品質評価し、問題が出たら OpenSearch Serverless に切り替える方針にしました
-
メタデータフィルタに制約がある。S3 Vectors は各ベクトルにメタデータを付けて検索時の絞り込み(例:
department = "営業"のみを対象に類似検索)ができますが、検索条件に使える「フィルタ可能メタデータ」は 1 ベクトルあたり約 2KB・キー数にも上限があります。属性ベースのアクセス制御(閲覧可能ユーザーのリストをメタデータに持たせる等)のような凝ったフィルタ設計はこの上限で破綻しうるので要注意です(今回は「公開 = 全員に見える」モデルでフィルタ不使用のため影響なし) - クエリレイテンシは 100ms 級(開発中の体感では対話 UI で気になりませんでした。後述)
- 新しいサービスで事例が薄い(この記事を書いている理由でもあります)
ポイントは「切り替え可能な作りにしておく」こと。IaC はベクトルストア部分を差し替えられる構造にし、アプリ側の検索インターフェースも抽象化しておきました。この抽象化は後に、別のベクトル DB(Milvus 系の RAG 基盤)との比較検証にもそのまま流用でき、思わぬところで効きました。
全体構成
- ナレッジ(Markdown 文書)を S3 の
kb-source/プレフィックスに配置 - 「公開」操作のたびに Ingestion Job を起動して差分をベクトル化(数百ドキュメント規模で反映まで 1 分程度)
- チャット側は
bedrock-agent-runtimeの Retrieve API で検索し、LLM の回答生成は自前で制御(検索と生成を分離)
ここで押さえておきたいのは、S3 が 2 役で登場することです。原稿を置く汎用 S3 バケット(データソース)と、ベクトルを格納する S3 Vectors のベクトルバケット(ストレージ)はまったくの別物で、Knowledge Base がその橋渡しをします。私は最初、コンソールでデータソース側の S3 だけを見て「あれ、これただの S3 では?」と混乱しました。ベクトルバケットは通常の S3 バケット一覧には表示されず、S3 コンソール左ナビの「ベクトルバケット」という別セクションにいます(ARN も arn:aws:s3vectors:… と別体系)。
つまずきやすいポイント集(CDK 実装)
① CFN リソース型に L1/L2 コンストラクトがない → raw CfnResource で書く
AWS::S3Vectors::VectorBucket / AWS::S3Vectors::Index は新しい CloudFormation 型で、執筆時点の aws-cdk-lib にはコンストラクトがありません。CDK のバージョンに依存しない CfnResource で直接定義します(その型が使えるリージョンかどうかは aws cloudformation describe-type で事前確認できます)。
vector_bucket = CfnResource(
self, "VectorBucket",
type="AWS::S3Vectors::VectorBucket",
properties={"VectorBucketName": vector_bucket_name}, # 小文字・ハイフンのみ
)
vector_index = CfnResource(
self, "VectorIndex",
type="AWS::S3Vectors::Index",
properties={
"VectorBucketName": vector_bucket_name,
"IndexName": "qa-index",
"DataType": "float32",
"Dimension": 1024, # 埋め込みモデルに合わせる(titan-embed-text-v2 の既定 = 1024)
"DistanceMetric": "cosine",
"MetadataConfiguration": {
"NonFilterableMetadataKeys": ["AMAZON_BEDROCK_TEXT"],
},
},
)
vector_index.add_dependency(vector_bucket)
② NonFilterableMetadataKeys: ["AMAZON_BEDROCK_TEXT"] を宣言しないと Ingestion が失敗する
Bedrock Knowledge Bases は、検索結果として返す原文チャンクを AMAZON_BEDROCK_TEXT というメタデータキーでベクトルと一緒に格納します。S3 Vectors のメタデータには「フィルタ可能」と「フィルタ不可」の区別があり、フィルタ可能側には容量制限(約 2KB/ベクトル)がある。原文チャンクは当然 2KB を超えうるので、AMAZON_BEDROCK_TEXT をフィルタ不可メタデータとしてインデックス作成時に宣言しておく必要があります。
忘れると Knowledge Base の Ingestion が失敗します。しかもインデックスのメタデータ設定は作成後に変更できないため、作り直しです。KB と S3 Vectors の両方のドキュメントを行き来しないと見つけにくい要件なので、先に知っておく価値があります。
③ Dimension と埋め込みモデルの次元がズレていると失敗する
amazon.titan-embed-text-v2:0 の既定は 1024 次元(512 / 256 も指定可)。インデックスの Dimension と一致していないと、これも Ingestion で失敗します。そしてこれも作成後に変更できません。②とあわせて、「インデックスは作る前に設定を確定させる」が鉄則です。
④ Knowledge Base のロールは「作成と同時に」権限を確定させる
AWS::Bedrock::KnowledgeBase は作成時にロールの権限を検証します。CDK でポリシーを後付け(add_to_policy)すると、CFN の作成順序によっては KB 作成時点でポリシーが間に合わず失敗することがあるため、inline_policies でロール作成と同時に確定させるのが安全です。必要な権限は 4 つ。
kb_role = iam.Role(
self, "KbRole",
assumed_by=iam.ServicePrincipal("bedrock.amazonaws.com"),
inline_policies={
"kb-access": iam.PolicyDocument(statements=[
# ①埋め込みモデルの呼び出し
iam.PolicyStatement(actions=["bedrock:InvokeModel"],
resources=[embedding_model_arn]),
# ②S3 Vectors の読み書き
iam.PolicyStatement(actions=["s3vectors:*"],
resources=[bucket_arn, index_arn]),
# ③データソース S3 の読み取り
iam.PolicyStatement(actions=["s3:GetObject", "s3:ListBucket"],
resources=[source_bucket_arn, f"{source_bucket_arn}/*"]),
# ④データソースが KMS 暗号化されている場合は復号も
iam.PolicyStatement(actions=["kms:Decrypt", "kms:DescribeKey"],
resources=[data_key_arn]),
])
},
)
Knowledge Base 本体はストレージに S3_VECTORS を指定するだけです。
kb = CfnResource(
self, "KnowledgeBase",
type="AWS::Bedrock::KnowledgeBase",
properties={
"Name": "qa-kb",
"RoleArn": kb_role.role_arn,
"KnowledgeBaseConfiguration": {
"Type": "VECTOR",
"VectorKnowledgeBaseConfiguration": {
"EmbeddingModelArn": embedding_model_arn,
},
},
"StorageConfiguration": {
"Type": "S3_VECTORS",
"S3VectorsConfiguration": {"IndexArn": index_arn},
},
},
)
ベクトルバケットとインデックスには RemovalPolicy.RETAIN を付けておくのがおすすめです。ベクトルは再 Ingestion で復元できるとはいえ、スタック更新の事故で消えると再構築の時間が痛いです。
アプリからの使い方
検索(Retrieve API)
Knowledge Base 経由なので、アプリは S3 Vectors を直接触りません。普通の Retrieve API だけで完結します。
client = boto3.client("bedrock-agent-runtime")
res = client.retrieve(
knowledgeBaseId=KB_ID,
retrievalQuery={"text": question},
retrievalConfiguration={"vectorSearchConfiguration": {"numberOfResults": 6}},
)
for r in res["retrievalResults"]:
text = r["content"]["text"] # 原文チャンク(AMAZON_BEDROCK_TEXT 由来)
uri = r["location"]["s3Location"]["uri"] # 元ドキュメントの S3 URI
score = r["score"] # 類似度スコア
スコアの実測感覚
titan-embed-text-v2(cosine)で日本語 FAQ 文書を検索したときの肌感です。
- 関連が強い結果: 0.6 前後
- 「関連なし」として捨ててよいライン: 0.4 未満(この閾値未満は LLM のプロンプトにも引用にも使わない設計にしました)
このスコア感、ベクトルストアと埋め込みモデルの組み合わせで変わります。同じアプリで別のベクトル DB(Milvus 系)と比較したとき、同じ「関連が強い」でもスコアの出方が違っていて、閾値は使い回せないことが実測で分かりました。閾値はストアごとに実データでチューニングする前提にしておくのが安全です。
同期(Ingestion Job)
ドキュメント更新時に Ingestion Job を起動します。差分同期なので、数百ドキュメント規模なら 1 分程度で検索に反映されました。
boto3.client("bedrock-agent").start_ingestion_job(
knowledgeBaseId=KB_ID, dataSourceId=DS_ID,
)
構築してみて分かったこと(本格評価は PoC でこれから)
- 検証を中断してもコストが流れないのは PoC と相性が良いと感じます。「まず S3 Vectors で始めて、品質かレイテンシで困ったら OpenSearch に切り替える」は合理的な進め方ではないでしょうか
- レイテンシは対話 UI では気になりませんでした(開発中の体感)。FAQ チャットは LLM の生成時間が支配的で、検索の 100ms 級はその中に埋もれます
- ハイブリッド検索が無い影響はこれから評価。固有名詞・略語の多い社内文書では BM25 の不在が効いてくる可能性があり、切り替え判断のポイントはここになりそうです
- チャンク戦略は QA 1 件 = 1 チャンクに近い粒度が扱いやすく、引用(どの文書のどの QA か)の組み立ても素直になりました
まとめ
| こういう場合は | 選択 |
|---|---|
| PoC・社内ツール・低頻度アクセス・コスト最小化 | S3 Vectors |
| 高 QPS・ミリ秒級レイテンシ・ハイブリッド検索が必要 | OpenSearch Serverless 等 |
RAG の構成例では OpenSearch がデファクトですが、実際に S3 Vectors で組んでみると、S3 にファイルを置いて Ingestion するだけで検索側が完成する手軽さと、従量課金のみのコスト構造は PoC 用途に合っていると感じました。検索品質の評価はこれからなので、ハイブリッド検索の不在がどう効くかは PoC の結果を見て判断する予定です。
CDK で組む場合のポイントは 4 つ — ① raw CfnResource で定義 ②AMAZON_BEDROCK_TEXT の NonFilterable 宣言 ③ Dimension とモデル次元の一致 ④ KB ロールは inline_policies で同時確定。ここさえ押さえれば一発で通るはずです。