Bedrock Knowledge BasesのRAG、「Google検索のほうがマシ」と言われてから9ヶ月で満足度4.4に上げた話

「なんか惜しい」検索精度に悩んだことありませんか?チャンキング設計の見直しとハイブリッド検索・リランキング導入で、ユーザー満足度3.1→4.4まで改善した実務の記録です。

去年の秋に社内ナレッジ検索システムをBedrock Knowledge Basesで構築して、気づいたら本番稼働9ヶ月が経っていた。最初の2〜3ヶ月は「なんか検索精度が惜しい」状態が続いて、チームメンバーから「Google検索のほうがマシ」という言葉を何度か浴びた。正直あれはしんどかった。

ただ、地道にチャンキング戦略を見直して、ハイブリッド検索を有効化して、リランキングを組み合わせたら、最終的にユーザー満足度のアンケートスコアが3.1から4.4(5点満点)まで上がった。この記事は、その過程で踏んだ地雷と気づきをそのまま書いたものだ。

なお、RAGの本番運用に関する精度問題の話はRAG本番2年で「なんか惜しい」を乗り越えた話——2026年の現実的な構成にも詳しく書いてある。Bedrockに限らない一般的なRAG設計の話はそちらも参考にしてほしい。

うちのシステム構成と2026年時点のBedrock Knowledge Basesの状況

先にシステム全体の構成を見せる。社内ドキュメント(Confluence・SharePoint・S3に保存されたPDF)を横断検索するシステムで、月間アクティブユーザーは約300人規模だ。

graph TB
    subgraph User["ユーザー層"]
        WebApp["React SPA\nNext.js 15"]
        SlackBot["Slack Bot"]
    end

    subgraph API["APIレイヤー (ap-northeast-1)"]
        APIGW["API Gateway\nHTTP API"]
        Lambda["Lambda\nQuery Handler\n(Python 3.13)"]
    end

    subgraph Bedrock["Amazon Bedrock"]
        KB["Knowledge Bases\n(OpenSearch Serverless)"]
        Claude["Claude 3.7 Sonnet"]
        Titan["Titan Embeddings V2"]
        Rerank["Reranking Model\nCohere Rerank 3.5"]
    end

    subgraph Ingestion["データ取り込みパイプライン"]
        S3Raw["S3\nraw-documents"]
        S3Processed["S3\nprocessed-chunks"]
        EventBridge["EventBridge\nScheduler"]
        SyncJob["Lambda\nSync Job"]
    end

    subgraph DataSources["データソース"]
        Confluence["Confluence"]
        SharePoint["SharePoint Online"]
        ManualUpload["手動アップロード\n(社内PDF等)"]
    end

    subgraph VPC["VPC (10.0.0.0/16)"]
        subgraph PrivateSubnet["Private Subnet"]
            Lambda
            SyncJob
        end
        VPCEndpoint["VPC Endpoint\nBedrock / S3"]
    end

    WebApp --> APIGW
    SlackBot --> APIGW
    APIGW --> Lambda
    Lambda --> KB
    Lambda --> Claude
    KB --> Titan
    KB --> Rerank
    KB --> OpenSearch[("OpenSearch\nServerless")]
    EventBridge --> SyncJob
    SyncJob --> Confluence
    SyncJob --> SharePoint
    ManualUpload --> S3Raw
    SyncJob --> S3Raw
    S3Raw --> KB
    Lambda --> VPCEndpoint
    SyncJob --> VPCEndpoint

2026年時点でBedrock Knowledge Basesが大きく変わったのは主に3点だ。

アップデート概要うちへの影響
ハイブリッド検索 GAベクトル検索とBM25全文検索の組み合わせ(2025年末GA)日本語コーパスへの効果が特に大きかった
Reranking統合RetrieveAndGenerate API内でCohere Rerank 3.5が使えるように検索後の精度が体感できるレベルで改善
Hierarchical Chunking親チャンク・子チャンクを階層的に管理する戦略が正式サポート「答えが途中で切れる」問題がほぼ解消

これ全部が揃った段階でようやく「惜しい」から「実用的」に変わった感覚がある。

チャンキング設計で半分決まる(これ本当だった)

RAG本番運用1年で痛感したこと|チャンキングで半分決まるという現実でも書かれているが、自分の経験でもチャンキング設計が精度に与える影響は圧倒的に大きかった。

最初は固定サイズチャンキング(512トークン、オーバーラップ50トークン)でスタートした。これが失敗の始まりだった。

# 最初の失敗構成(固定サイズ)
# ドキュメントの論理的な区切りを無視してぶった切るので
# 文脈が途切れたチャンクが大量に生成された

chunking_config = {
    "chunkingStrategy": "FIXED_SIZE",
    "fixedSizeChunkingConfiguration": {
        "maxTokens": 512,
        "overlapPercentage": 10
    }
}

たとえば社内の設計書PDFが「背景」「要件定義」「実装方針」「テスト計画」という構成になっているとき、固定サイズだと「要件定義の後半+実装方針の前半」みたいな意味不明なチャンクが大量発生する。これを検索しても当然まともな回答は返ってこない。ドキュメントの論理構造を完全に無視してぶった切るので、当たり前といえば当たり前なんだけど、最初は地味に気づくのが遅れた。

試行錯誤の末に行き着いたのがHierarchical Chunking + カスタムメタデータの組み合わせだ。

import boto3
import json

bedrock_agent = boto3.client('bedrock-agent', region_name='ap-northeast-1')

# Hierarchical Chunkingの設定
def create_knowledge_base_with_hierarchical_chunking():
    response = bedrock_agent.create_knowledge_base(
        name='internal-knowledge-base',
        description='社内ナレッジ検索システム',
        roleArn='arn:aws:iam::123456789012:role/BedrockKBRole',
        knowledgeBaseConfiguration={
            'type': 'VECTOR',
            'vectorKnowledgeBaseConfiguration': {
                'embeddingModelArn': 'arn:aws:bedrock:ap-northeast-1::foundation-model/amazon.titan-embed-text-v2:0',
                'embeddingModelConfiguration': {
                    'bedrockEmbeddingModelConfiguration': {
                        'dimensions': 1024,  # Titan V2は256/512/1024を選べる
                    }
                }
            }
        },
        storageConfiguration={
            'type': 'OPENSEARCH_SERVERLESS',
            'opensearchServerlessConfiguration': {
                'collectionArn': 'arn:aws:aoss:ap-northeast-1:123456789012:collection/kb-collection',
                'vectorIndexName': 'bedrock-kb-index',
                'fieldMapping': {
                    'vectorField': 'embedding',
                    'textField': 'text',
                    'metadataField': 'metadata'
                }
            }
        }
    )
    return response

# データソース設定 - Hierarchical Chunking
def create_data_source_hierarchical(kb_id: str, bucket_name: str):
    response = bedrock_agent.create_data_source(
        knowledgeBaseId=kb_id,
        name='s3-documents',
        dataSourceConfiguration={
            'type': 'S3',
            's3Configuration': {
                'bucketArn': f'arn:aws:s3:::{bucket_name}',
                'inclusionPrefixes': ['processed/']
            }
        },
        vectorIngestionConfiguration={
            'chunkingConfiguration': {
                'chunkingStrategy': 'HIERARCHICAL',
                'hierarchicalChunkingConfiguration': {
                    'levelConfigurations': [
                        {
                            'maxTokens': 1500  # 親チャンク: 文書のセクション単位
                        },
                        {
                            'maxTokens': 300   # 子チャンク: 段落単位
                        }
                    ],
                    'overlapTokens': 60
                }
            },
            'parsingConfiguration': {
                'parsingStrategy': 'BEDROCK_FOUNDATION_MODEL',
                'bedrockFoundationModelConfiguration': {
                    'modelArn': 'arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0',
                    'parsingPrompt': {
                        'parsingPromptText': """
文書の内容を正確に抽出してください。
表はMarkdownテーブル形式で保持し、図表のキャプションも含めてください。
見出し構造(H1, H2, H3)を維持し、文書の階層構造がわかるようにしてください。
                        """
                    }
                }
            }
        }
    )
    return response

親チャンク(1500トークン)はセクション全体のコンテキストを保持し、子チャンク(300トークン)で具体的な回答を引っ張ってくる設計だ。検索時は子チャンクで類似検索してヒットしたら、親チャンクを回答生成に使う。この設計に変えてから「答えが途中で切れる」問題がほぼ解消された。

ハイブリッド検索 + Rerankingの実装

ここが2026年のBedrock Knowledge Basesで一番変わった部分だ。実際のクエリ処理コードを見てもらうのが早い。

import boto3
from typing import Optional

bedrock_runtime = boto3.client('bedrock-agent-runtime', region_name='ap-northeast-1')

def retrieve_and_generate(
    query: str,
    kb_id: str,
    session_id: Optional[str] = None,
    num_results: int = 10
) -> dict:
    """
    ハイブリッド検索 + Reranking + 回答生成
    """
    request_params = {
        'input': {'text': query},
        'retrieveAndGenerateConfiguration': {
            'type': 'KNOWLEDGE_BASE',
            'knowledgeBaseConfiguration': {
                'knowledgeBaseId': kb_id,
                'modelArn': 'arn:aws:bedrock:ap-northeast-1::foundation-model/anthropic.claude-3-7-sonnet-20250219-v1:0',
                'retrievalConfiguration': {
                    'vectorSearchConfiguration': {
                        'numberOfResults': num_results,
                        # ハイブリッド検索: ベクトル検索 + BM25
                        'searchType': 'HYBRID',
                        # Rerankingの設定
                        'rerankingConfiguration': {
                            'type': 'BEDROCK_RERANKING_MODEL',
                            'bedrockRerankingConfiguration': {
                                'modelConfiguration': {
                                    'modelArn': 'arn:aws:bedrock:ap-northeast-1::foundation-model/cohere.rerank-v3-5:0'
                                },
                                'numberOfRerankedResults': 5  # Rerank後に上位5件を使用
                            }
                        },
                        # メタデータフィルタリング(部署・ドキュメント種別で絞り込み可能)
                        # 'filter': {
                        #     'equals': {
                        #         'key': 'department',
                        #         'value': 'engineering'
                        #     }
                        # }
                    }
                },
                'generationConfiguration': {
                    'promptTemplate': {
                        'textPromptTemplate': """
以下の社内ドキュメントの情報を元に、質問に回答してください。

<documents>
$search_results$
</documents>

<question>
$query$
</question>

回答の際は以下のルールを守ってください:
- 提供されたドキュメントの情報のみを使用すること
- 情報が見つからない場合は「該当するドキュメントが見つかりませんでした」と答えること
- 引用元のドキュメント名・セクションを明記すること
- 専門用語は社内の表記に統一すること
                        """
                    },
                    'inferenceConfig': {
                        'textInferenceConfig': {
                            'maxTokens': 2048,
                            'temperature': 0.0,  # RAGは温度0が安定する
                            'topP': 0.9
                        }
                    }
                }
            }
        }
    }
    
    if session_id:
        request_params['sessionId'] = session_id
    
    response = bedrock_runtime.retrieve_and_generate(**request_params)
    
    return {
        'answer': response['output']['text'],
        'session_id': response.get('sessionId'),
        'citations': [
            {
                'text': citation['generatedResponsePart']['textResponsePart']['text'],
                'references': [
                    {
                        'source': ref['retrievedReference']['location']['s3Location']['uri'],
                        'content': ref['retrievedReference']['content']['text'][:200]
                    }
                    for ref in citation['retrievedReferences']
                ]
            }
            for citation in response.get('citations', [])
        ]
    }


# 使用例
if __name__ == '__main__':
    result = retrieve_and_generate(
        query="オンプレからAWSへの移行手順はどこにまとまっていますか?",
        kb_id="XXXXXXXXXX"
    )
    print(result['answer'])
    for citation in result['citations']:
        print(f"引用元: {citation['references'][0]['source']}")

ハイブリッド検索を有効にして体感で一番効いたのは、専門用語や略語のクエリだ。たとえば社内で「AWS移行」を「クラウドリフト」と呼んでいるプロジェクトがあって、ユーザーが「クラウドリフト 手順」で検索するとベクトル検索だけだとヒットしにくかった。BM25の全文検索を組み合わせることで、このケースが劇的に改善した。社内固有の用語が多い環境ほど、ハイブリッドの恩恵が大きいと思う。

Rerankingについては正直最初は懐疑的だった。「上位N件取ってきてまた並び替えるって意味あるの?」と思っていたけど、実際に計測するとはっきり効果が出た。特に「複数のドキュメントに分散している情報をまとめる」系のクエリへの効果が目に見えて違う。

手法ごとの回答品質を社内評価で計測したのが以下だ。

xychart-beta
    title "チャンキング・検索手法別の回答品質スコア(社内評価、5点満点)"
    x-axis ["固定サイズ\nベクトル検索", "階層チャンク\nベクトル検索", "階層チャンク\nハイブリッド検索", "階層チャンク\nハイブリッド+Rerank"]
    y-axis "品質スコア(5点満点)" 0 --> 5
    bar [3.1, 3.7, 4.1, 4.4]

スコアが段階的に上がっているのがわかる。どれか1つだけ入れるなら「階層チャンク → ハイブリッド検索」の順に優先度が高い、というのが9ヶ月やってみた個人的な結論だ。

取り込みパイプラインの設計と運用上の落とし穴

RAGの精度はクエリ処理だけじゃなくて、データ取り込みの品質にも大きく依存する。これが盲点になりがちなんだよなあ。

うちでハマったのは主に2つ。

1. PDFのパース品質問題

Bedrock Knowledge BasesのデフォルトパーサーはPDFをそれなりに読めるが、複雑なレイアウト(段組み・表が多いドキュメント)はボロボロだった。Claude 3.5 Sonnetをパーサーとして使うオプション(上のコードにある BEDROCK_FOUNDATION_MODEL パーシング)に切り替えてからかなりマシになった。ただしコストが跳ね上がるので、PDFの種類によって使い分けている。シンプルなテキスト主体のドキュメントはデフォルトパーサーで十分だ。

# ドキュメントの前処理スクリプト(S3にアップロード前に実行)
import boto3
import json
from pathlib import Path

def add_metadata_to_document(s3_client, bucket: str, key: str, metadata: dict):
    """
    Bedrock KB用のメタデータファイルを生成する
    {document_key}.metadata.json として保存
    """
    metadata_key = f"{key}.metadata.json"
    
    # Bedrock Knowledge Basesのメタデータ形式
    kb_metadata = {
        "metadataAttributes": {
            "department": {"value": {"stringValue": metadata.get('department', 'general')}},
            "document_type": {"value": {"stringValue": metadata.get('type', 'unknown')}},
            "created_date": {"value": {"stringValue": metadata.get('created_date', '')}},
            "author": {"value": {"stringValue": metadata.get('author', '')}},
            "confidentiality": {"value": {"stringValue": metadata.get('confidentiality', 'internal')}}
        }
    }
    
    s3_client.put_object(
        Bucket=bucket,
        Key=metadata_key,
        Body=json.dumps(kb_metadata, ensure_ascii=False),
        ContentType='application/json'
    )
    
    print(f"メタデータ保存: {metadata_key}")


# 取り込み同期の実行(定期バッチ)
def start_ingestion_job(bedrock_agent_client, kb_id: str, data_source_id: str):
    response = bedrock_agent_client.start_ingestion_job(
        knowledgeBaseId=kb_id,
        dataSourceId=data_source_id,
        description=f"定期同期ジョブ"
    )
    
    job_id = response['ingestionJob']['ingestionJobId']
    print(f"取り込みジョブ開始: {job_id}")
    return job_id


def wait_for_ingestion_completion(bedrock_agent_client, kb_id: str, data_source_id: str, job_id: str, max_wait: int = 600):
    """取り込み完了を待機(タイムアウト付き)"""
    import time
    
    elapsed = 0
    while elapsed < max_wait:
        response = bedrock_agent_client.get_ingestion_job(
            knowledgeBaseId=kb_id,
            dataSourceId=data_source_id,
            ingestionJobId=job_id
        )
        
        status = response['ingestionJob']['status']
        stats = response['ingestionJob'].get('statistics', {})
        
        print(f"ステータス: {status} | "
              f"処理済み: {stats.get('numberOfDocumentsScanned', 0)} | "
              f"失敗: {stats.get('numberOfDocumentsFailed', 0)}")
        
        if status in ['COMPLETE', 'FAILED']:
            if status == 'FAILED':
                failures = response['ingestionJob'].get('failureReasons', [])
                raise Exception(f"取り込み失敗: {failures}")
            return response['ingestionJob']
        
        time.sleep(30)
        elapsed += 30
    
    raise TimeoutError(f"取り込みジョブがタイムアウトしました: {job_id}")

2. 取り込み失敗の検知漏れ

取り込みジョブが部分的に失敗しても全体としてはCOMPLETEステータスになることがある。numberOfDocumentsFailed を必ずモニタリングしないと、気づかないうちにドキュメントが欠落する。「なんでこの情報が返ってこないんだろう」と数週間悩んでから原因に気づいたときは脱力した。

CloudWatch Alarmで numberOfDocumentsFailed > 0 を監視するようにしてからは見落としがなくなった。SLI/SLO設計2026完全ガイドの考え方を参考に、知識ベースの鮮度・完全性をSLIとして定義したのも地味に効いた。

コスト設計と実測値

Bedrock Knowledge Basesのコストは構造的にわかりにくい。整理するとこうなる。

コスト項目課金単位うちの月次実績
Titan Embeddings V2$0.00002/1K tokens約$180
OpenSearch ServerlessOCU時間・インデックスストレージ約$320
Claude 3.7 Sonnet(生成)$3.00/1M input, $15.00/1M output約$240
Cohere Rerank 3.5$0.001/1K tokens約$45
S3ストレージ$0.025/GB/month約$15
Lambda(クエリ処理)従量課金約$20
月次合計約$820(約12万円)

300ユーザーで月12万円なので、ユーザー1人あたり400円/月だ。感覚的にこれが高いか安いかはユースケース次第だが、社内のナレッジ検索工数削減効果を考えると十分ペイしている、という判断になっている。

個人的に見落としがちだと思うのがOpenSearch Serverlessの固定費だ。最低2 OCU(約$350/月)が必須で、これがどんなに利用量が少なくてもかかる。小規模なユースケースならベクトルDB比較2026でも紹介されているように、別のベクトルDBをバックエンドにする選択肢も検討する価値がある。ある程度のユーザー規模になれば相対的に許容範囲になってくるけど、PoC段階では正直重い。

コスト削減で効いたのはクエリキャッシュだ。同じ質問が繰り返される傾向のあるFAQ系クエリはElastiCache(Redis)でキャッシュして、Bedrockを呼ばないようにした。これで全クエリの約30%をキャッシュヒットさせられて、月$180ほど削減できた。地味に大きい。

最初の数ヶ月はチューニング中の試行錯誤クエリとOpenSearch Serverlessのキャパシティ過剰確保で高かったが、徐々に落ち着いてきた。

xychart-beta
    title "月次コスト推移(導入後〜9ヶ月)"
    x-axis ["Month1", "Month2", "Month3", "Month4", "Month5", "Month6", "Month7", "Month8", "Month9"]
    y-axis "USD" 0 --> 1400
    line [1320, 1180, 1050, 980, 920, 870, 840, 830, 820]

まとめ

9ヶ月運用して見えてきた、Bedrock Knowledge BasesでRAGを本番稼働させるための重要ポイントをまとめる。

1. チャンキングは必ずHierarchical Chunkingを使う
固定サイズは楽だが精度が出ない。親チャンク1500トークン・子チャンク300トークンを基準に、ドキュメント種別ごとに調整する価値がある。

2. ハイブリッド検索は日本語コーパスで特に効果的
専門用語・略語・社内固有の表現がある環境ではBM25との組み合わせが刺さる。単純なベクトル検索よりも安定する。

3. Reranking(Cohere Rerank 3.5)は地味に効く
コストは微増するが、検索結果の上位精度が体感でわかるレベルで改善する。特に「複数のドキュメントに分散している情報をまとめる」系のクエリへの効果が大きい。

4. 取り込みの失敗監視を初日から設定する
numberOfDocumentsFailed のアラートは後回しにしがちだが、後から気づくと知識ベースが穴だらけになっている。これは本当に早めにやっておいてほしい。

5. OpenSearch Serverlessの固定費を意識した規模設計を
最低$350/月が固定でかかるので、ユーザー数が少ない段階では代替バックエンドも検討する。

まず小規模なPoCでHierarchical Chunkingとハイブリッド検索の効果を計測してみてほしい。searchType: 'HYBRID' を有効にするのはAPIの設定変更だけで済むので、既存のKnowledge Basesがあれば今日からでも試せる。精度の変化は体感できるはずだ。

皆さんのチームはBedrock Knowledge Basesでどんな工夫をしてますか?特にメタデータフィルタリングをうまく使っているケースがあれば聞いてみたいところだ。

U

Untanbaby

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

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

関連記事