目次を表示する

Bedrockで Production Ready な AI 機能を作る ── 設計・運用・現場の知恵

Knowledge Bases で RAG を組み立てる ── ベクトルストア・チャンキング・引用

Knowledge Bases で RAG を組み立てる ── ベクトルストア・チャンキング・引用

ch07 までで足りないもの

ch07 で helpdesk-ai は ストリーミングTool Use を覚えた。get_holiday_balance(employee_id) のような関数を呼んで、人事システムから値を引いてこられる。チャット UI も typing 表示で「動いている感」が出る。

ここで上司から、次の質問が来る。

「経費精算の上限額って、いくらだっけ? 規程に書いてあるはずなんだけど」

これは Tool Use では解けない。「上限額」という単語が規程のどこに、どう書かれているかは社員も知らない。fetch_expense_limit() のような関数を実装しようにも、問い合わせの形は無限で、関数化のしようがない。

社内規程 PDF を 自然言語で検索して、引用付きで答える。これが RAG(Retrieval-Augmented Generation)の出番だ。本章では Bedrock の Knowledge Bases for Bedrock(以下 KB)を helpdesk-ai に組み込み、expense-policy.pdf から「経費精算の上限額は ◯ 万円です(出典:第 3 章 第 2 節)」と答えられるようにする。

ただし、Knowledge Bases は「コンソールでポチポチして接続したら動く」反面、最初の選択を間違えると後戻りできないという罠がある。とくに チャンキング戦略はデータソース接続後に変更不可。本章ではそこを慎重に踏む。


Knowledge Bases for Bedrock の全体像

Knowledge Bases は、ドキュメントを取り込んで埋め込みベクトルに変換し、ベクトル DB に格納して、自然言語クエリで検索できるようにするマネージドサービスだ。RAG のインフラを自前で組まずに済むのが最大の価値で、データソースの取り込み・チャンク分割・埋め込み生成・ベクトル DB の同期・引用付きの応答生成までを 1 つの API でまとめる。

アーキテクチャ

graph LR
    A["データソース<br/>S3 / Confluence /<br/>SharePoint / Web"] --> B[インジェスト<br/>解析→チャンク→埋め込み]
    B --> C["ベクトルストア<br/>(8 種から選択)"]
    D["アプリ<br/>(helpdesk-ai)"] -->|RetrieveAndGenerate| E[Knowledge Base]
    E -->|HYBRID 検索| C
    C --> E
    E -->|生成 + 引用| D

データの流れは 2 系統に分かれる。書き込み側(左半分)はインジェスト処理で、データソースからチャンクを作って埋め込みベクトルを生成し、ベクトル DB に書き込む。読み出し側(右半分)はアプリからの RetrieveAndGenerate 呼び出しで、クエリを埋め込み化してベクトル DB を検索し、上位 K 件を LLM に渡して引用付きの応答を生成する。

データソースの選択肢

データソース主な用途備考
Amazon S3PDF / Word / Markdown / テキスト最も標準。helpdesk-ai はこれを使う
Confluence社内 WikiOAuth で接続
SharePointMicrosoft 365 環境同上
SalesforceCRM ドキュメント同上
Web Crawler公開サイト・ヘルプセンターPreview
Custom自前ストリーミング・非サポート形式プログラマティックに登録
構造化データRDB / DWH(SQL 生成)GenerateQuery API

helpdesk-ai では「社内規程 PDF」を扱うため、S3 を一次データソースにする。s3://company-policies/expense/s3://company-policies/hr/ のようなプレフィックス単位で取り込む設計が無難だ。


8 種のベクトルストア ── 標準スタートはどれか

KB がサポートするベクトルストアは 2026 年 6 月時点で 8 系統 ある。最初の選択は重要なので、特徴を整理しておく。

ベクトルストア立ち位置強み弱み・制約
OpenSearch Serverless第一候補(汎用)コンソールでクイック作成、VPC エンドポイント対応、バイナリベクトル対応アイドル時もコストが発生
Amazon S3 Vectors第一候補(コスト最優先)プロビジョニング不要、サブセカンド、低頻度クエリで安価メタデータ上限(1KB / vector、35 keys)あり、バイナリ非対応
Aurora PostgreSQL(pgvector)既存 RDB と統合jsonb + GIN index でメタデータフィルタが強い同一アカウント内に KB が必要、RDS PostgreSQL(非 Aurora)は非対応
OpenSearch Managed Cluster既存 OpenSearch 資産を活用k-NN プラグイン、バージョン 2.16+ でバイナリ対応Public access のみ(VPC 内非サポート)
Neptune AnalyticsGraphRAGエンティティ抽出 → グラフ構築、Vector + Graph 検索ユースケース選ぶ
Pinecone既に Pinecone 利用中サードパーティ SaaSSecrets Manager 経由で API key 管理
Redis Enterprise Cloud既に Redis 利用中サードパーティTLS 必須
MongoDB Atlas既に MongoDB 利用中サードパーティ、Vector Search Indexフィルタは Vector Index 側で別途設定が必要

迷ったら OpenSearch Serverless か S3 Vectors の二択 で考えるのが現実的だ。Production で頻繁にクエリが走るなら OpenSearch Serverless、PoC や月数百クエリの社内ツールなら S3 Vectors。

helpdesk-ai は社内向けで「営業時間内に数百〜数千クエリ」を想定するので、OpenSearch Serverless をデフォルトに置く。KB のコンソールで「クイック作成」を選ぶと、OpenSearch Serverless の collection と index、IAM ロール、暗号化ポリシーまでが自動で組み立てられる。

注意:RDS PostgreSQL(非 Aurora)と、VPC 内に閉じた Managed OpenSearch は サポート外。「既存の RDS PostgreSQL に pgvector を入れて KB と繋げよう」とすると詰むので、KB から見たい場合は Aurora PostgreSQL に移行する必要がある。


4 種のチャンキング戦略 ── ここが落とし穴

ドキュメントは長すぎると埋め込みベクトルの表現力が落ちるし、短すぎると文脈が切れる。だから チャンク(断片) に分割してから埋め込む。KB がサポートするチャンキング戦略は 4 種類だ。

graph TB
    subgraph Standard["Standard"]
        S1[Fixed-size<br/>固定トークン + overlap]
        S2[Default<br/>約 300 トークン・文境界尊重]
        S3[No chunking<br/>1 ファイル = 1 チャンク]
    end
    subgraph Hierarchical["Hierarchical"]
        H1[親チャンク<br/>大きめ]
        H2[子チャンク<br/>小さめ]
        H2 -.検索は子で照合.-> H1
        H1 -.応答は親を LLM へ.-> LLM[LLM]
    end
    subgraph Semantic["Semantic"]
        SE1[文の意味的距離で分割<br/>追加 FM コスト発生]
    end
    subgraph Multimodal["Multimodal"]
        M1[Nova Multimodal Embeddings<br/>音声/動画 1-30 秒]
    end

それぞれの使いどころを掘り下げる。

Standard chunking

最もシンプルで、3 つのバリアントがある。

  • Fixed-size:トークン数(最大)と overlap 率を指定。ページ・セクションといった論理境界は 跨がない
  • Default:約 300 トークン、文境界を尊重。設定省略時のデフォルト
  • No chunking:ドキュメント 1 つを 1 チャンクとして扱う。事前に自前で分割しておく前提

「とりあえず Default」がもっとも多い選択だが、これがアンチパターン #3「Default チャンキング放置」になる。詳細は後述。

Hierarchical chunking

親(大)/ 子(小)の 2 階層 を作って、検索は子で照合し、応答時には親に置換して LLM に渡すという二重構造。「ピンポイントで当てたいが、文脈は広く渡したい」という RAG の根本的な葛藤に対する答えだ。

API リファレンスや手順書のように「行単位で意味が変わる」文書では、検索精度を上げるために子チャンクを 200 トークン程度まで小さくし、応答品質を保つために親チャンクで前後の見出しや段落を一緒に渡す、といった設計ができる。

S3 Vectors と組み合わせるときの注意:親子関係をメタデータに格納するため、合計トークン数が高い(> 8000)と S3 Vectors のメタデータサイズ上限(1KB / vector、35 keys / vector)に抵触し得る。Hierarchical を本気で使うなら OpenSearch Serverless を選ぶ。

Semantic chunking

文の意味的距離 に基づいて分割する。NLP ベースで、内部で foundation model を呼ぶため 追加 FM コストが発生 する。

パラメータは Maximum tokens(チャンクの最大トークン数)/ Buffer size(前後何文を埋め込み生成に使うか)/ Breakpoint percentile threshold(分割閾値)。理屈は美しいが、1 ファイルあたり 1 MB の制限 があり、技術文書では失敗するケースが多い。「絶対に Semantic」と決め打ちすべき場面は意外と少ない。

Multimodal chunking

Nova Multimodal Embeddings を使う場合、音声・動画は 1〜30 秒(既定 5 秒)でチャンク化される。テキストチャンキング設定は音声・動画・画像には適用されない。Bedrock Data Automation(BDA)パーサーを通せば、マルチモーダル → テキスト変換後に通常のテキストチャンキングに乗せられる。

ここが最大の制約 ── 後から変えられない

❌ よくある失敗
  1. PoC で「Default」のまま動かす
  2. 本番投入後に「Top-K に最重要文書が出てこない」とクレーム
  3. チャンキング戦略を変えようとする
     → 「データソース接続後は変更不可」エラー
  4. データソースをまるごと作り直し(インジェスト時間 + コスト)

✅ あるべき選択
  1. PoC でも FIXED_SIZE(512 トークン、overlap 20 %)から始める
  2. LLM-as-a-Judge で retrieval quality を測る基盤を先に作る
  3. 計測しながら 256 / 512 / 1024 を比較する

「Default チャンキング放置」は本シリーズの ch17 アンチパターン #3 として再登場する。Production Ready に到達するには、最初の 1 時間でこの罠を避ける必要がある。


ベストプラクティス ── 「チャンキングよりまず評価」

本番運用しているチームに「チャンキングをどう選んでいる?」と聞くと、判で押したように同じ答えが返ってくる。

FIXED_SIZE(max_tokens=512、overlap=20 %)から始めて、評価しながら調整する

ベンチマーク記事の実測でもこの組み合わせが本番推奨スタート地点として出てくる。チャンクサイズの目安は、文書の性質で変えると良い。

文書タイプ推奨チャンクサイズ
一般ドキュメント(規程・FAQ)400–800 トークン
API リファレンス・精密性が要る情報200–500 トークン
ストーリー性のある文書(マニュアル全体)700–1,200 トークン

そして、最も重要な原則がこれだ。

「平凡なチャンキング戦略 + 良い評価」は、「最良のチャンキング + 評価なし」より遠くまで行ける

チャンキングは局所最適化、評価は全体最適化だ。先に評価基盤を作って計測できる状態にしてから、チャンキングを動かす。評価の話は ch12 で詳しく扱う

加えて日本語固有の事情として、sudachi のような日本語トークナイザを組み込むケース がある。セゾンテクノロジーが HULFT サポートチャットボットで「ハイブリッド検索(ベクトル + sudachi)+ 階層的チャンク + クエリ拡張」を組み合わせ、Knowledge Bases 単体比で +22 % の精度向上 を達成した事例が公開されている。「日本語 PDF が中心」のチームは、ハイブリッド検索でキーワード側のトークナイザを意識する価値がある。


実装:RetrieveAndGenerate を helpdesk-ai に組み込む

ここからコードに入る。spec.md §2 の規約に従い、src/handlers/kb.ts に Knowledge Base ハンドラを実装する。クライアントは src/client.ts から共通 bedrockAgent を使う。

共通クライアント(再掲)

// src/client.ts
import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime";
import { BedrockAgentRuntimeClient } from "@aws-sdk/client-bedrock-agent-runtime";

const region = process.env.AWS_REGION ?? "us-east-1";

export const bedrock = new BedrockRuntimeClient({
  region,
  maxAttempts: 5,
  retryMode: "adaptive",
});

export const bedrockAgent = new BedrockAgentRuntimeClient({
  region,
  maxAttempts: 5,
  retryMode: "adaptive",
});

export const MODEL_ID =
  process.env.BEDROCK_MODEL_ID ?? "us.anthropic.claude-sonnet-4-5";
export const KB_ID = process.env.KB_ID ?? "";

askKnowledgeBase:引用付き応答を返す

// src/handlers/kb.ts
import {
  RetrieveAndGenerateCommand,
  type Citation,
} from "@aws-sdk/client-bedrock-agent-runtime";
import { bedrockAgent, KB_ID, MODEL_ID } from "../client.js";

const region = process.env.AWS_REGION ?? "us-east-1";
const accountId = process.env.AWS_ACCOUNT_ID ?? "";
const modelArn = `arn:aws:bedrock:${region}:${accountId}:inference-profile/${MODEL_ID}`;

export type KBAnswer = {
  text: string;
  sessionId: string;
  sources: { uri?: string; page?: number; snippet?: string }[];
};

export async function askKnowledgeBase(
  question: string,
  opts?: { sessionId?: string; department?: string; yearFrom?: number },
): Promise<KBAnswer> {
  // メタデータフィルタを動的に組み立てる
  const filters: any[] = [];
  if (opts?.department) {
    filters.push({ equals: { key: "department", value: opts.department } });
  }
  if (opts?.yearFrom !== undefined) {
    filters.push({ greaterThan: { key: "year", value: opts.yearFrom } });
  }
  const filter = filters.length === 0 ? undefined : { andAll: filters };

  const res = await bedrockAgent.send(
    new RetrieveAndGenerateCommand({
      input: { text: question },
      sessionId: opts?.sessionId,
      retrieveAndGenerateConfiguration: {
        type: "KNOWLEDGE_BASE",
        knowledgeBaseConfiguration: {
          knowledgeBaseId: KB_ID,
          modelArn,
          retrievalConfiguration: {
            vectorSearchConfiguration: {
              numberOfResults: 5,
              overrideSearchType: "HYBRID", // ベクトル + キーワードの併用
              ...(filter ? { filter } : {}),
            },
          },
          generationConfiguration: {
            inferenceConfig: {
              textInferenceConfig: {
                maxTokens: 1024, // ch06 から徹底:必ず明示
                temperature: 0.0, // RAG は事実重視で低温に
              },
            },
            // Guardrails 連携の枠だけ用意(ch11 で有効化)
            ...(process.env.GUARDRAIL_ID
              ? {
                  guardrailConfiguration: {
                    guardrailId: process.env.GUARDRAIL_ID,
                    guardrailVersion: process.env.GUARDRAIL_VERSION ?? "DRAFT",
                  },
                }
              : {}),
          },
        },
      },
    }),
  );

  return {
    text: res.output?.text ?? "",
    sessionId: res.sessionId ?? "",
    sources: extractSources(res.citations ?? []),
  };
}

function extractSources(citations: Citation[]) {
  const out: KBAnswer["sources"] = [];
  for (const c of citations) {
    for (const r of c.retrievedReferences ?? []) {
      // ページ番号は組み込みメタデータから引く(型ガードで number だけ通す)
      const rawPage = r.metadata?.["x-amz-bedrock-kb-document-page-number"];
      const page = typeof rawPage === "number" ? rawPage : undefined;
      out.push({
        uri: r.location?.s3Location?.uri,
        page,
        snippet: r.content?.text?.slice(0, 200),
      });
    }
  }
  return out;
}

ここで踏んでいるポイントを整理する。

  • numberOfResults: 5:上位 K 件。多すぎると LLM のコンテキストを圧迫し、コストが上がる。helpdesk-ai のような社内 QA は 3〜8 が目安
  • overrideSearchType: "HYBRID":ベクトル検索 + キーワード検索の併用。日本語の固有名詞(「e-Gov」「就業規則」など)で取りこぼしを減らす
  • maxTokens: 1024必ず明示 する。未設定だと Sonnet の最大値 64K が TPM クォータに予約され、スロットリングの原因になる(ch06 で扱った原則)
  • temperature: 0.0:RAG は創造性ではなく忠実性が欲しいので低温
  • guardrailConfigurationch11 への伏線 として、環境変数があれば付与する形だけ用意。Guardrails の設計と段階導入は ch11 で

サーバから呼び出す

// 例:Fastify ハンドラ
app.post("/api/ask", async (req, reply) => {
  const { question, sessionId } = req.body as { question: string; sessionId?: string };

  const answer = await askKnowledgeBase(question, {
    sessionId,
    department: "finance",
    yearFrom: 2024,
  });

  return reply.send({
    text: answer.text,
    sessionId: answer.sessionId,
    citations: answer.sources.map((s, i) => ({
      index: i + 1,
      uri: s.uri,
      page: s.page,
      snippet: s.snippet,
    })),
  });
});

UI 側は citations[] を本文末尾に脚注として表示すれば、「経費精算の上限は 5 万円です¹」のように出力できる。RAG の 信頼性は引用の存在で担保される ので、citations は UI から外さない方が良い。

sessionId の扱いRetrieveAndGenerate は内部で会話履歴を持っており、sessionId を引き継ぐと続きの質問(フォローアップ)が文脈付きで動く。クライアントから初回 sessionId を指定はできない。初回応答で発行されたものを以降使い回す。


Retrieve API ── 検索だけする使い道

RetrieveAndGenerate は「検索 + 生成」を 1 リクエストでこなすが、用途によっては 生成は別 LLM に任せたい ことがある。

  • 別モデルへの文脈注入:Bedrock 外の自社 LLM や Anthropic 直叩きに文脈だけ渡したい
  • 自前オーケストレーション:プロンプトテンプレートを完全に自前で組みたい
  • 検索品質のデバッグ:「そもそも上位 K に関連文書が来ているか」を確認したい
// src/handlers/kb.ts に追加
import { RetrieveCommand } from "@aws-sdk/client-bedrock-agent-runtime";

export async function retrieveOnly(query: string, k = 10) {
  const res = await bedrockAgent.send(
    new RetrieveCommand({
      knowledgeBaseId: KB_ID,
      retrievalQuery: { text: query },
      retrievalConfiguration: {
        vectorSearchConfiguration: {
          numberOfResults: k,
          overrideSearchType: "HYBRID",
        },
      },
    }),
  );

  return (res.retrievalResults ?? []).map((r) => ({
    score: r.score,
    uri: r.location?.s3Location?.uri,
    text: r.content?.text ?? "",
  }));
}

検索品質を疑ったときは、まず retrieveOnly("経費精算 上限") を叩いてスコアと内容を眺める。これが Knowledge Base デバッグの基本動作になる。「答えがズレている」のがチャンキング起因なのか、プロンプト起因なのか、Retrieve 単体で切り分ける。


メタデータフィルタリング ── 「権限」と「鮮度」を担保する

社内規程は 部門別・年度別 で版が変わる。expense-policy-2024.pdfexpense-policy-2025.pdf が両方インデックスにあれば、古い方が引かれる可能性がある。これを制御するのが メタデータフィルタ だ。

<filename>.metadata.json の構造

S3 にドキュメントを置くとき、同じプレフィックスに同名のメタデータファイルを置く。

s3://company-policies/expense/
├── expense-policy-2025.pdf
├── expense-policy-2025.pdf.metadata.json
├── expense-policy-2024.pdf
└── expense-policy-2024.pdf.metadata.json

中身はこういう構造。

{
  "metadataAttributes": {
    "department": "finance",
    "year": 2025,
    "doc_type": "policy",
    "access_level": "all_employees"
  }
}

演算子と複合条件

サポートされる演算子は次のとおり。

  • 比較:equals / notEquals / greaterThan / greaterThanOrEquals / lessThan / lessThanOrEquals
  • 集合:in / notIn / listContains
  • 文字列:startsWith / stringContains
  • 論理:andAll / orAll

「経理部門かつ 2024 年以降」のような複合条件は次のように書く。

const filter = {
  andAll: [
    { equals: { key: "department", value: "finance" } },
    { greaterThan: { key: "year", value: 2024 } },
  ],
};

「経理 or 人事のうち、access_level=all_employees のもの」ならこう。

const filter = {
  andAll: [
    { in: { key: "department", value: ["finance", "hr"] } },
    { equals: { key: "access_level", value: "all_employees" } },
  ],
};

組み込みメタデータも便利だ。x-amz-bedrock-kb-source-uristartsWith で絞れば「特定フォルダ配下のみ検索」が実現できる。x-amz-bedrock-kb-document-page-number を引いて、引用 UI に「第 12 ページ」と表示するのも先ほどの extractSources でやった通り。

OpenSearch Managed の落とし穴:カスタムメタデータは type: "text" + fields.keyword: { "type": "keyword" } の構造で定義しないと “Rewrite first” エラー が出る。Managed クラスタを選んだ場合は注意。


日本語 RAG の現実的な落とし穴

ここまでは英語ドキュメントでも日本語ドキュメントでも同じ話だが、日本語 RAG にはもうひとつ伝えておくべき制約がある。

Bedrock Guardrails には Contextual grounding という機能がある。「LLM の応答が、与えられたコンテキスト(取得結果)に対して忠実か」を自動で判定する、RAG ハルシネーション対策の中核機能だ。本来なら RetrieveAndGenerate の出力をそのまま Contextual grounding に通せば、回答がドキュメントから逸脱したときに自動でブロック・警告できる。

ところが ── Contextual grounding は 2026 年 6 月時点で日本語非対応 だ。Content filters / Denied topics / PII filters は 2025-04-30 から日本語 Standard tier に対応したものの、Contextual grounding だけは英語などに限定されたまま動いている。

つまり、日本語 RAG では 「ハルシネーションを Guardrails で機械的に止める」道が現状塞がっている。代替策は次の 2 つに集約される。

  1. 引用の存在でユーザーに判断材料を渡す:UI で出典 URI とページ番号を必ず併記する。「機械が止められないなら、人間が判断できるようにする」
  2. LLM-as-a-Judge で代替評価する:別 LLM を審判役にして「応答がコンテキストに忠実か」をオフラインまたは抜き取りでスコアリングする

LLM-as-a-Judge による RAG 評価の具体的な組み立ては ch12 で詳述する。本章では「日本語 RAG は Contextual grounding が使えない」という事実だけ押さえておく。Guardrails 全般の話と、英語ドキュメント時の Contextual grounding の活用は ch11 で扱う。


次章への接続

ここまでで helpdesk-ai は「社内規程 PDF を引用付きで答える」ところまで来た。

「経費精算の上限額は?」
  → askKnowledgeBase()
  → 「上限は 5 万円です(出典:expense-policy-2025.pdf, p.12)」

ただ、ユーザーは次にこう言う。

「じゃあ、それで申請しといて」

これは RAG では解けない。「申請する」は 行動 であって、社内規程の検索ではない。submit_expense_request(...) のような関数を呼ぶ必要があり、しかも「上限確認 → 残額確認 → 申請」のような 複数ステップの計画と実行 が必要になる。

これを Tool Use だけで組もうとすると、stopReason === "tool_use" のループを自前で延々と書くハメになる。マネージドな ReAct ループ が欲しい ── というのが、次章 ch09 で Bedrock Agents に進む動機だ。Agents は Knowledge Bases と Action Groups(関数ツール)を 1 つのループで束ね、「規程を引きながら申請まで実行する」までを面倒見てくれる。


章末まとめ

  • Knowledge Bases for Bedrock は データソース取り込み・チャンク・埋め込み・ベクトル DB 同期・引用付き応答 をまとめるマネージド RAG
  • ベクトルストアは OpenSearch Serverless(汎用)か S3 Vectors(コスト最優先) の二択で考えるのが現実的
  • チャンキング戦略は データソース接続後に変更不可。Default 放置は本シリーズ ch17 アンチパターン #3
  • 本番推奨スタート地点は FIXED_SIZE(512 トークン、overlap 20 %)+ HYBRID 検索 + 評価基盤。「チャンキングよりまず評価」
  • RetrieveAndGenerate で引用付き応答、Retrieve で検索品質デバッグ。メタデータフィルタで権限・鮮度を担保
  • 日本語 RAG では Guardrails の Contextual grounding が使えない(ch11 で詳述、ch12 で LLM-as-a-Judge による代替評価)
  • 次章では「複数ステップの実行」を担う Bedrock Agents へ進む