Bedrock Knowledge Bases RAGを本番運用して6ヶ月、「なんか惜しい」を乗り越えた話

「AWSマネージドだし楽勝」と思ってたのに現実は違った。50GB・月5000クエリのRAG本番運用で、チャンキングやハイブリッド検索の試行錯誤を正直に書きます。

うちのチームがBedrock Knowledge BasesでRAGシステムを本番投入してから6ヶ月が経った。最初は「AWSのマネージドだし楽勝でしょ」と思っていたけど、現実はそんなに甘くなかった。

プロジェクトの背景を少し説明すると、社内の技術ドキュメント・仕様書・障害対応ナレッジを横断検索できるQAボットを作ろうというものだった。ドキュメント総量が約50GB(PDF・Markdownが中心)、月間クエリ数は5000件ほどのイメージ。最初の1〜2ヶ月は「なんか回答が惜しい」という状態が続いて、チューニングに相当時間を使った。同じような状況で悩んでいる人、いませんか?

今回は、その試行錯誤の中で「ここを変えたら劇的に変わった」というポイントを、実装コードも交えながら正直に書いていく。

構成全体像——最終的に落ち着いたアーキテクチャ

まず全体の構成図から見てほしい。

graph TB
    subgraph Internet["ユーザー側"]
        User["👤 社内ユーザー"]
    end

    subgraph AWS["AWS Account"]
        subgraph AppLayer["Application Layer"]
            APIGW["API Gateway\n(REST API)"]
            Lambda["Lambda\nOrchestrator"]
        end

        subgraph BedrockLayer["Bedrock Layer"]
            KBs["Knowledge Bases\n(Vector Store)"]
            RetrieveAPI["Retrieve & Generate\nAPI"]
            Claude["Claude Sonnet 4\n(LLM)"]
            Embedding["Titan Embed v3\n(Embedding Model)"]
        end

        subgraph StorageLayer["Storage Layer"]
            S3Raw["S3 Raw Bucket\n(元ドキュメント)"]
            S3Processed["S3 Processed Bucket\n(前処理済み)"]
            OpenSearch["OpenSearch Serverless\n(ベクトルDB)"]
        end

        subgraph MetaLayer["Metadata & Monitoring"]
            DynamoDB["DynamoDB\n(クエリ履歴・評価ログ)"]  
            CloudWatch["CloudWatch\n(メトリクス・ログ)"]
        end

        subgraph IngestLayer["Ingestion Pipeline"]
            StepFn["Step Functions\n(同期ワークフロー)"]
            LambdaPrep["Lambda\n前処理"] 
        end
    end

    User -->|HTTPS| APIGW
    APIGW --> Lambda
    Lambda --> RetrieveAPI
    RetrieveAPI --> KBs
    RetrieveAPI --> Claude
    KBs --> OpenSearch
    KBs --> Embedding
    S3Raw --> StepFn
    StepFn --> LambdaPrep
    LambdaPrep --> S3Processed
    S3Processed --> KBs
    Lambda --> DynamoDB
    Lambda --> CloudWatch

S3に投入されたドキュメントはStep Functionsで前処理パイプラインを通してからKnowledge Basesに同期する、という流れになっている。最初はS3直投入だったんだけど、前処理なしだとドキュメント品質がガタガタで精度が出なかった。ここは後で詳しく話す。

ベクトルDBはOpenSearch Serverlessを選んだ。Aurora PostgreSQL(pgvector)とも比較したけど、ハイブリッド検索(ベクトル+キーワード)がOpenSearchのほうがKnowledge Basesとの統合が自然だったのが決め手になった。ベクトルDBの選定基準についてはベクトルDB比較2026|マルチモーダルEmbedding対応の最適選定ガイドで詳しく書いているので、まだ読んでない人はそちらも参考に。

チャンキング戦略——ここで8割決まる

正直に言うと、最初の2ヶ月の「なんか惜しい」の原因の80%はチャンキング設定だった。デフォルトのFixed-size chunking(300トークン、オーバーラップ20%)で運用し始めたんだけど、これがうちのドキュメント構造と全然合っていなかった。

RAG本番運用1年で痛感したこと|チャンキングで半分決まるという現実でも触れているとおり、チャンキングの設計は本当に重要で、ここを甘く見ると後でつらくなる。

うちが試した3つのチャンキング戦略

3種類を比較した結果がこれ。数字を見ると差は歴然で、Semanticに変えるだけで精度が20ポイント以上跳ね上がった。

戦略設定向いているドキュメント検索精度(内部評価)
Fixed-size300トークン / 20%オーバーラップ均質なテキスト△ 62%
Hierarchical親2048トークン / 子300トークン階層構造のある仕様書○ 78%
Semantic(2025年GA)文境界ベース自動分割技術ブログ・障害レポート◎ 85%

2025年末にGA(一般提供)になったSemantic Chunkingが、うちのユースケースには一番刺さった。文章の意味境界で自動的に分割してくれるので、文の途中でぶった切られて文脈が壊れる問題が大幅に改善された。

Semantic Chunkingの設定はCDKでこんな感じに書いている:

# CDK (Python) での Knowledge Base 設定例
from aws_cdk import aws_bedrock as bedrock

knowledge_base = bedrock.CfnKnowledgeBase(
    self, "TechDocsKB",
    name="tech-docs-knowledge-base",
    role_arn=kb_role.role_arn,
    knowledge_base_configuration=bedrock.CfnKnowledgeBase.KnowledgeBaseConfigurationProperty(
        type="VECTOR",
        vector_knowledge_base_configuration=bedrock.CfnKnowledgeBase.VectorKnowledgeBaseConfigurationProperty(
            embedding_model_arn=f"arn:aws:bedrock:{region}::foundation-model/amazon.titan-embed-text-v2:0"
        )
    ),
    storage_configuration=bedrock.CfnKnowledgeBase.StorageConfigurationProperty(
        type="OPENSEARCH_SERVERLESS",
        opensearch_serverless_configuration=bedrock.CfnKnowledgeBase.OpenSearchServerlessConfigurationProperty(
            collection_arn=collection.attr_arn,
            vector_index_name="tech-docs-index",
            field_mapping=bedrock.CfnKnowledgeBase.OpenSearchServerlessFieldMappingProperty(
                metadata_field="metadata",
                text_field="content",
                vector_field="content_vector"
            )
        )
    )
)

data_source = bedrock.CfnDataSource(
    self, "TechDocsDS",
    name="tech-docs-s3",
    knowledge_base_id=knowledge_base.attr_knowledge_base_id,
    data_source_configuration=bedrock.CfnDataSource.DataSourceConfigurationProperty(
        type="S3",
        s3_configuration=bedrock.CfnDataSource.S3DataSourceConfigurationProperty(
            bucket_arn=processed_bucket.bucket_arn
        )
    ),
    vector_ingestion_configuration=bedrock.CfnDataSource.VectorIngestionConfigurationProperty(
        chunking_configuration=bedrock.CfnDataSource.ChunkingConfigurationProperty(
            # Semantic Chunking を使う
            chunking_strategy="SEMANTIC",
            semantic_chunking_configuration=bedrock.CfnDataSource.SemanticChunkingConfigurationProperty(
                max_token=512,
                buffer_size=1,
                breakpoint_percentile_threshold=95
            )
        )
    )
)

breakpoint_percentile_threshold の値は95にしているけど、ここは正直まだ検証中。低くすると細かく分割されすぎるし、高すぎると大きなチャンクになりすぎる。うちのドキュメント構造だと90〜95の間が良さそうという感触はあるものの、ドキュメントの性質によってかなり変わってくるので、自分の環境では必ず手元で試してほしい。

メタデータフィルタリングと前処理——見落としがちな鍵

チャンキングの次に効いたのがメタデータの設計だった。

デフォルトだとS3に置いたファイルをそのまま取り込むだけなんだけど、各チャンクに「このドキュメントはどのカテゴリ?」「いつ作成された?」「誰が管理している?」というメタデータを付与すると、クエリ時にフィルタリングできるようになる。

具体的には、S3のファイルと同名で .metadata.json を並べておくとKnowledge Basesが自動で読み込んでくれる仕様になっている:

// s3://processed-bucket/runbooks/database-failover.md.metadata.json
{
  "metadataAttributes": {
    "category": "runbook",
    "team": "platform",
    "severity_level": "critical",
    "last_reviewed": "2026-05-15",
    "product": "aurora"
  }
}

このメタデータを活用してクエリ時にフィルタリングする:

import boto3
import json

bedrock_agent_runtime = boto3.client("bedrock-agent-runtime", region_name="ap-northeast-1")

def query_knowledge_base(
    query: str,
    category_filter: str = None,
    knowledge_base_id: str = "XXXXXXXX",
    model_id: str = "anthropic.claude-sonnet-4-5"
) -> dict:
    
    retrieval_config = {
        "vectorSearchConfiguration": {
            "numberOfResults": 10,
            "overrideSearchType": "HYBRID",  # ハイブリッド検索
        }
    }
    
    # カテゴリフィルタがある場合は追加
    if category_filter:
        retrieval_config["vectorSearchConfiguration"]["filter"] = {
            "equals": {
                "key": "category",
                "value": category_filter
            }
        }
    
    response = bedrock_agent_runtime.retrieve_and_generate(
        input={"text": query},
        retrieveAndGenerateConfiguration={
            "type": "KNOWLEDGE_BASE",
            "knowledgeBaseConfiguration": {
                "knowledgeBaseId": knowledge_base_id,
                "modelArn": f"arn:aws:bedrock:ap-northeast-1::foundation-model/{model_id}",
                "retrievalConfiguration": retrieval_config,
                "generationConfiguration": {
                    "promptTemplate": {
                        "textPromptTemplate": (
                            "あなたは社内技術ドキュメントのアシスタントです。\n"
                            "以下の参考文書のみに基づいて質問に答えてください。\n"
                            "参考文書に記載がない場合は、その旨を正直に伝えてください。\n\n"
                            "$search_results$\n\n"
                            "質問: $query$"
                        )
                    },
                    "inferenceConfig": {
                        "textInferenceConfig": {
                            "maxTokens": 2048,
                            "temperature": 0.1  # 事実回答なので低め
                        }
                    }
                }
            }
        }
    )
    
    return {
        "answer": response["output"]["text"],
        "citations": response.get("citations", []),
        "session_id": response.get("sessionId")
    }

# 使用例
result = query_knowledge_base(
    query="AuroraのフェイルオーバーRTOはどれくらいですか?",
    category_filter="runbook"
)
print(result["answer"])

overrideSearchType: HYBRID の指定が地味に重要で、ベクトル検索だけだと微妙な固有名詞(AWSサービス名・内部ツール名)の検索精度が低い。ハイブリッドにしてBM25のキーワード検索も組み合わせることで、固有名詞の検索精度が体感でかなり改善した。社内ツール名みたいなニッチな単語は意味ベクトルより文字列マッチのほうが強いんですよね。

前処理パイプラインの設計——これをサボると後で泣く

S3直投入だと問題になるのが、PDFの表・コードブロック・ヘッダー/フッターの混入。特にPDFから変換したMarkdownのコードブロックは、そのままチャンキングされると意味不明な断片になりやすい。

flowchart LR
    A["S3 Raw Bucket\n(PDF/MD/DOCX)"] --> B["Lambda\nファイル種別判定"]
    B -->|PDF| C["Textract\nテキスト抽出"]
    B -->|Markdown| D["直接処理"]
    B -->|DOCX| E["Lambda\nPandoc変換"]
    C --> F["Lambda\n後処理\n(表・コード整形)"]
    D --> F
    E --> F
    F --> G["Lambda\nメタデータ付与"]
    G --> H["S3 Processed Bucket\n(.md + .metadata.json)"] 
    H --> I["Knowledge Bases\n同期トリガー"]

PDFはTextractで抽出しているんだけど、表のセルが行ごとにバラバラに抽出される問題があって、後処理Lambdaで表構造を再構築する処理を入れている。これが地味に大変だった——正直、PDF処理だけで工数の3割くらい使った気がする。

コードブロックについては、チャンキング時に分割されないようにメタデータで is_code_block: true フラグを立てて、Knowledge Basesのカスタムチャンキング設定と組み合わせて分割を抑制している。

前処理の品質が検索精度に直結するという意味では、データ品質管理2026年版:最新ツール&ベストプラクティスで触れているデータ品質の考え方はRAGにもそのまま当てはまる。ゴミを入れるとゴミが返ってくる、という話で。

精度計測と継続的改善——「なんとなく動いてる」状態を脱出する

本番運用で一番後悔しているのが、最初の2ヶ月間「なんとなく動いてる」で精度を定量化していなかったこと。チームから「なんか惜しい回答が多い」という主観フィードバックはあるけど、何をどう改善すべきかが全然わからない状態だった。主観フィードバックって改善のヒントにならないんですよね、「なんか惜しい」じゃどこを直せばいいか皆目わからない。

そこで導入したのが評価ログのDynamoDB保存と、定期的な精度スコアリング:

import boto3
import time
from datetime import datetime, timezone
from typing import Optional

dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table("rag-eval-log")

def log_query_result(
    query: str,
    answer: str,
    citations: list,
    user_id: str,
    session_id: str,
    response_time_ms: int,
    thumbs_up: Optional[bool] = None  # ユーザーフィードバック
):
    item = {
        "query_id": f"{session_id}_{int(time.time())}",
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "query": query,
        "answer": answer,
        "citation_count": len(citations),
        "citations": [
            {
                "source": c.get("retrievedReferences", [{}])[0].get("location", {}).get("s3Location", {}).get("uri", ""),
                "score": c.get("retrievedReferences", [{}])[0].get("metadata", {}).get("score", 0)
            }
            for c in citations if c.get("retrievedReferences")
        ],
        "response_time_ms": response_time_ms,
        "user_id": user_id,
        "session_id": session_id
    }
    
    if thumbs_up is not None:
        item["user_feedback"] = "positive" if thumbs_up else "negative"
    
    table.put_item(Item=item)

このデータを週次でCloudWatchダッシュボードに集約している。特に見ているメトリクスはこの4つ:

メトリクス何を見ているか
引用スコアの平均検索精度の指標
引用なし回答率Knowledge Baseに情報がなく回答できないケース
ユーザーのThumbsUpレート最も直感的な精度指標
レスポンスタイム(P50/P95)体感速度の把握

これらを追いかけていると、どの施策が効いたか・効いていないかが一目でわかるようになった。

xychart-beta
    title "RAG精度改善の推移(Thumbs Up率)"
    x-axis ["Month1", "Month2", "Month3", "Month4", "Month5", "Month6"]
    y-axis "Thumbs Up率 (%)" 0 --> 100
    bar [52, 58, 69, 75, 82, 87]
    line [52, 58, 69, 75, 82, 87]

月1〜2が「なんか惜しい」期で、月3にチャンキング変更・ハイブリッド検索導入、月4にメタデータフィルタリング整備、月5〜6は前処理パイプラインの品質向上が効いた。数値で見ると6ヶ月で52%→87%まで改善できた。正直まだ90%には届いていないけど、月次で少しずつ改善は続いている。

RAG本番2年で「なんか惜しい」を乗り越えた話——2026年の現実的な構成でも言及されているけど、「チャンクが悪い」「プロンプトが悪い」「モデルが悪い」の切り分けができていないと無駄な試行錯誤が増える。先に評価基盤を作るべきだったというのは本当に後悔している。

コストとスケーリングの現実

6ヶ月運用してコスト構造も見えてきたので、参考に共有しておく。

コンポーネント月額コスト(概算)主な変動要因
OpenSearch Serverless約12万円インデックスサイズ・OCU数
Bedrock Retrieve & Generate約8万円クエリ数・回答トークン数
Titan Embed v3(同期時)約3万円ドキュメント更新頻度
Lambda + Step Functions約1万円同期実行回数
Textract(PDF処理)約2万円PDF取り込み量
合計約26万円/月

OpenSearch Serverlessが想定より高かった。OCU(OpenSearch Compute Unit)は最低2OCUから課金される仕様で、スモールスタートには正直向いていない。ドキュメント量が少ない場合はAurora PostgreSQL + pgvectorのほうがコストを抑えられると思う。最初から大規模な構成で始めるんじゃなかったと少し反省している。

クエリ数が増えてきたときのキャッシュ戦略はまだ途中。同一クエリのキャッシュはDynamoDBで実装したけど、類似クエリのキャッシュはまだ検討中。皆さんはどんな方法でやっていますか?

インシデント対応の観点では、Knowledge Basesの同期失敗をCloudWatchアラームで検知してSlack通知する仕組みは早めに入れた。同期が静かに失敗するとドキュメント更新が反映されなくて気づきにくいので、これは初期から入れておくべき。インシデント対応の基本設計はインシデント対応の最新ベストプラクティス2026|DevOps・SRE必読も参考になる。

まとめ

6ヶ月の本番運用で見えてきた「ここが重要」を改めて整理すると、こういう話になる。

  1. チャンキングはSemantic Chunkingが強い——デフォルトのFixed-sizeは手軽だが精度が出にくい。breakpoint_percentile_threshold の値はドキュメント性質によって要チューニング

  2. ハイブリッド検索(HYBRID)を必ず使う——ベクトルのみだと固有名詞・型番・コマンド名の検索精度が低い。overrideSearchType: HYBRID は基本設定に入れるべき

  3. メタデータ設計はS3構造設計と一緒にやる——後付けでメタデータを整備しようとすると全ドキュメント再取り込みが必要になって痛い

  4. 前処理パイプラインをサボらない——PDFの表・コードブロックの混入はそのまま精度劣化につながる。Textract + 後処理Lambdaへの投資は必ず回収できる

  5. 評価基盤は最初から作る——「なんとなく動いてる」状態を早く脱出するには定量指標が必須。DynamoDBへの評価ログ保存とThumbsUpフィードバック収集は初日から仕込んでおくべきだった

次のアクションとして、現在検討中なのはこのあたり:

  • Advanced Parsing(Bedrockネイティブ画像解析):図・グラフが多いアーキテクチャ資料の精度改善
  • ReRankerの導入:検索結果の再ランキングで精度をもう一段上げる
  • Guardrailsとの統合強化:社外情報の混入防止(現在は手動プロンプトで対応中)

「いつかやろう」と思ってた最適化が意外とすぐ効いたりするので、まずは評価基盤を作るところから始めてみてほしい。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事