こんにちは、広野です。 AWS re:Invent 2025 で、Amazon S3 Vectors が GA されました。 Amazon S3 Vectors は、プレビュー時の 40 倍の規模で一般提供されています。 - AWS AWS の新機能についてさらに詳しく知るには、 Amazon S3 Vectors は、プレビュー時の 40 倍の規模で一般提供されています。 aws.amazon.com 以前は RAG 用のベクトルデータベースとして Amazon OpenSearch Service や Amazon Aurora など高額なデータベースサービスを使用しなければならなかったのですが、Amazon S3 ベースで安価に気軽に RAG 環境を作成できるようになったので嬉しいです。 それを受けて、以前作成した RAG チャットボット環境をアレンジしてみました。 内容が多いので、記事を 3つに分けます。本記事は UI 編です。 つくったもの こんな感じの RAG チャットボットです。 画面から問い合わせすると、回答が文字列細切れのストリームで返ってきます。それらをアプリ側で結合して、順次画面に表示しています。 回答の途中で、AI が回答生成の根拠にしたドキュメントの情報が送られてくることがあるので、あればそのドキュメント名を表示します。一般的には親切にドキュメントへのリンクも付いていると思うのですが、今回は簡略化のため省略しました。 このサンプル環境では、AWS が提供している AWS サービス別資料 の PDF を独自ドキュメントとして読み込ませ、回答生成に使用させています。 前回の記事 本記事はアーキテクチャ編、実装編の続編記事です。以下の記事をお読みの上、本記事にお戻りください。 React で Amazon Bedrock Knowledge Bases ベースの簡易 RAG チャットボットをつくる [2026年1月版] アーキテクチャ概要編 AWS re:Invent 2025 で、Amazon S3 Vectors が GA されました。それを受けて、以前作成した RAG チャットボットをアレンジしてみました。 blog.usize-tech.com 2026.01.06 React で Amazon Bedrock Knowledge Bases ベースの簡易 RAG チャットボットをつくる [2026年1月版] 実装編 AWS re:Invent 2025 で、Amazon S3 Vectors が GA されました。それを受けて、以前作成した RAG チャットボット環境をアレンジしてみました。本記事は実装編です。 blog.usize-tech.com 2026.01.06 アーキテクチャ 図の左側半分、アプリ UI 基盤は以下の記事と全く同じです。お手数ですが内容についてはこちらをご覧ください。 React で Amazon Bedrock ベースの簡易生成 AI チャットボットをつくる [2025年7月版] アーキテクチャ概要編 生成 AI 界隈の技術の進化はすさまじく、以前開発したチャットボットのアーキテクチャも陳腐化が見えてきました。この記事を執筆している時点での最新のアーキテクチャで改めて作り直してみたので、いくつかの記事に分けて紹介します。今回 (初回) はアーキテクチャ概要編です。 blog.usize-tech.com 2025.07.30 図の右側半分、Lambda 関数から Bedrock ナレッジベースに問い合わせるところを今回新たに作成しています。 まず、RAG に必要な独自ドキュメントを用意し、ドキュメント用 S3 バケットに保存します。 S3 Vectors を使用して、Vector バケットとインデックスを作成します。 これら S3 リソースを Amazon Bedrock Knowledge Bases でナレッジベースとして関連付けます。 それができると、ドキュメント用 S3 バケットのドキュメント内容をベクトルデータに変換して S3 Vectors に保存してくれます。これを埋め込み (Embedding) と言います。埋め込みに使用する AI モデルは Amazon Titan Text Embeddings V2 を使用します。 Bedrock ナレッジベースが完成すると、それを使用して回答を生成する LLM を指定します。今回は Amazon Nova 2 Lite を使用します。Lambda 関数内でパラメータとして指定して、プロンプトとともに問い合わせることになります。 フロントエンドの UI は React で開発します。 UI について 開発環境 React 19.2.3 vite 7.3.0 ビルドツール @mui/material 7.3.6 UI モジュール @aws-amplify/ui-react 6.13.2 UI モジュール 画面イメージ 画面は一例です。途中、長すぎたのでカットしています。 少し React の画面パーツ的な意味で分類します。以下のような動きをします。 React の State で言うと、以下のようになります。 会話履歴: conversation 直近の回答: streaming 直近の質問: prompt アーキテクチャ概要編で紹介した通り、直近の回答部分 (streaming) が以下のデータ集計・変換処理を経て画面描画されています。 細かい説明は難しいので、次項でコードをまんま紹介しますが、以下の記事と重複する部分は割愛します。 React で Amazon Bedrock ベースの簡易生成 AI チャットボットをつくる [2025年7月版] 実装編3 React 生成 AI 界隈の技術の進化はすさまじく、以前開発したチャットボットのアーキテクチャも陳腐化が見えてきました。この記事を執筆している時点での最新のアーキテクチャで改めて作り直してみたので、いくつかの記事に分けて紹介します。今回 (4回目) は React アプリ開発編です。 blog.usize-tech.com 2025.08.16 React コード import { useState, useEffect, useRef } from "react"; import { Container, Grid, Box, Paper, Typography, TextField, Button, Avatar } from "@mui/material"; import SendIcon from '@mui/icons-material/Send'; import { blue, grey } from '@mui/material/colors'; import { v4 as uuidv4 } from "uuid"; import { events } from "aws-amplify/data"; import { inquireRagSr, Markdown } from "./Functions.jsx"; import Header from "../Header.jsx"; import Menu from "./Menu.jsx"; const RagSr = (props) => { //定数定義 const groups = props.groups; const sub = props.sub; const idtoken = props.idtoken; const imgUrl = import.meta.env.VITE_IMG_URL; //変数定義 const appsyncSessionIdRef = useRef(); //AppSync Events チャンネル用セッションID const bedrockSessionIdRef = useRef(null); //Bedrock Knowledge Bases 用セッションID const channelRef = useRef(); const streamingRefMap = useRef(new Map()); //state定義 const [prompt, setPrompt] = useState(""); const [conversation, setConversation] = useState([]); const [streaming, setStreaming] = useState({ text: "", refs: [] }); //RAGへの問い合わせ送信関数 const putRagSr = () => { if (streaming.text) { setConversation(prev => [ ...prev, { role:"ai", text: streaming.text, ref: streaming.refs }, { role:"user", text: prompt, ref: [] } ]); streamingRefMap.current.clear(); setStreaming({ text:"", refs:[] }); } else { setConversation(prev => [...prev, { role:"user", text: prompt, ref: [] }]); } inquireRagSr(prompt, appsyncSessionIdRef.current, bedrockSessionIdRef.current, idtoken); //プロンプト欄をクリア setPrompt(""); }; //URI整形関数 const normalizeLabel = (uri) => { try { return decodeURIComponent(uri.split("/").pop()); } catch { return uri; } }; //サブスクリプション開始関数 const startSubscription = async () => { const appsyncSessionId = appsyncSessionIdRef.current; if (channelRef.current) await channelRef.current.close(); const channel = await events.connect(`rag-stream-response/${sub}/${appsyncSessionId}`); channel.subscribe({ //ここで、AppSync Events からレスポンスストリームを受け取ったときの挙動を場合分けして定義している //動作を把握するため、各ケースにおいて console.log でログを表示している next: (data) => { //Bedrock Knowledge base の session id 取得 if (data.event.type === "bedrock_session") { console.log("=== Session received ==="); console.log(data.event.bedrock_session_id); bedrockSessionIdRef.current = data.event.bedrock_session_id; return; } //問い合わせに対する回答メッセージ (chunkされている) if (data.event.type === "text") { console.log("=== Message received ==="); setStreaming(s => ({ ...s, text: s.text + data.event.message })); return; } //回答に関する関連ドキュメント (citation) if (data.event.type === "citation") { console.log("=== Citation received ==="); console.log(data.event.citation); //citationに格納されるドキュメント名を取り出し、streamingRefMap に格納する //同じドキュメント名が届いたときは格納しないようチェックしている data.event.citation.forEach(ref => { const uri = ref.location?.s3Location?.uri; if (!uri) return; if (!streamingRefMap.current.has(uri)) { streamingRefMap.current.set(uri, { id: uri, label: normalizeLabel(uri) }); setStreaming(s => ({ ...s, refs: Array.from(streamingRefMap.current.values()) })); } }); } }, error: (err) => console.error("Subscription error:", err), complete: () => console.log("Subscription closed") }); channelRef.current = channel; }; //セッションIDのリセット、サブスクリプション再接続関数 const resetSession = async () => { appsyncSessionIdRef.current = uuidv4(); bedrockSessionIdRef.current = null; setPrompt(""); streamingRefMap.current.clear(); setStreaming({ text:"", refs:[] }); setConversation([]); await startSubscription(); }; //画面表示時 useEffect(() => { //画面表示時に最上部にスクロール window.scrollTo(0, 0); //Bedrockからのレスポンスサブスクライブ関数実行 appsyncSessionIdRef.current = uuidv4(); bedrockSessionIdRef.current = null; startSubscription(); //アンマウント時にチャンネルを閉じる return () => { if (channelRef.current) channelRef.current.close(); }; }, []); //Chatbot UI 会話部分 const renderMessage = (msg, idx) => ( <Box key={idx} sx={{display: "flex", justifyContent: msg.role === "user" ? "flex-end" : "flex-start", mb: 1, width: "100%"}}> {msg.role === "ai" && (<Avatar src={`${imgUrl}/images/ai_chat_icon.svg`} alt="AI" sx={{ mr: 2, mt: 2 }}/>)} <Paper elevation={2} sx={{p:2,my:1,maxWidth:"100%",bgcolor:msg.role === "user" ? blue[100] : grey[100]}}> <Markdown>{msg.text}</Markdown> {msg.ref.length > 0 && ( <> <h4>参考ドキュメント</h4> <ul style={{ paddingLeft: 20, margin: 0 }}> {msg.ref.map(s => ( <li key={s.id}>{s.label}</li> ))} </ul> </> )} </Paper> {msg.role === "user" && (<Avatar src={`${imgUrl}/images/human_chat_icon.svg`} alt="User" sx={{ml:2,mt:2}}/>)} </Box> ); return ( <> {/* Header */} <Header groups={groups} signOut={props.signOut} /> <Container maxWidth="lg" sx={{mt:2}}> <Grid container spacing={4}> {/* Menu Pane */} <Grid size={{xs:12,md:4}} order={{xs:2,md:1}}> {/* Sidebar */} <Menu /> </Grid> {/* Contents Pane IMPORTANT */} <Grid size={{xs:12,md:8}} order={{xs:1,md:2}} my={2}> <main> <Grid container spacing={2}> {/* Heading */} <Grid size={{xs:12}}> <Typography id="bedrocksrtop" variant="h5" component="h1" mb={3} gutterBottom>Amazon Bedrock RAG Stream Response テスト</Typography> </Grid> <Grid size={{xs:12}}> {/* Chatbot */} <Paper sx={{p:2,mb:2,width:"100%"}}> {/* あいさつ文(固定) */} {renderMessage({ role: "ai", text: "こんにちは。何かお困りですか?", ref: []}, -1)} {/* 会話履歴 */} {conversation.map((msg, idx) => renderMessage(msg, idx))} {/* 直近のレスポンス */} {streaming.text && renderMessage({ role:"ai", text: streaming.text, ref: streaming.refs }, "stream")} </Paper> {/* 入力エリア */} <Box sx={{display:"flex",gap:1}}> <TextField fullWidth multiline value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Type message here..." sx={{ flexGrow: 1 }} /> <Button variant="contained" size="small" onClick={putRagSr} disabled={!prompt} startIcon={<SendIcon />} sx={{ whiteSpace: "nowrap", flexShrink: 0 }}>送信</Button> </Box> {/* クリアボタン */} {(streaming.text || conversation.length > 0) && ( <Box sx={{ display: "flex", justifyContent: "flex-end", mt: 2 }}> <Button variant="contained" size="small" onClick={resetSession}>問い合わせをクリアする</Button> </Box> )} </Grid> </Grid> </main> </Grid> </Grid> </Container> </> ); }; export default RagSr; バックエンドの Python コード 実装編の AWS CloudFormation に組み込まれていますが、以下のコードにより AWS AppSync Events チャンネルを介してアプリにストリームレスポンスを返します。 import os import json import boto3 import urllib.request from botocore.auth import SigV4Auth from botocore.awsrequest import AWSRequest # common objects and valiables session = boto3.session.Session() bedrock_agent = boto3.client('bedrock-agent-runtime') endpoint = os.environ['APPSYNC_API_ENDPOINT'] model_arn = os.environ['MODEL_ARN'] knowledge_base_id = os.environ['KNOWLEDGE_BASE_ID'] region = os.environ['REGION'] service = 'appsync' headers = {'Content-Type': 'application/json'} # AppSync publish message function def publish_appsync_message(sub, appsync_session_id, payload, credentials): body = json.dumps({ "channel": f"rag-stream-response/{sub}/{appsync_session_id}", "events": [ json.dumps(payload) ] }).encode("utf-8") aws_request = AWSRequest( method='POST', url=endpoint, data=body, headers=headers ) SigV4Auth(credentials, service, region).add_auth(aws_request) req = urllib.request.Request( url=endpoint, data=aws_request.body, method='POST' ) for k, v in aws_request.headers.items(): req.add_header(k, v) with urllib.request.urlopen(req) as res: return res.read().decode('utf-8') # handler def lambda_handler(event, context): try: credentials = session.get_credentials().get_frozen_credentials() # API Gateway からのインプットを取得 prompt = event['body']['prompt'] appsync_session_id = event['body']['appsyncSessionId'] bedrock_session_id = event['body'].get('bedrockSessionId') sub = event['sub'] # Amazon Bedrock Knowledge Bases への問い合わせパラメータ作成 request = { "input": { "text": prompt }, "retrieveAndGenerateConfiguration": { "type": "KNOWLEDGE_BASE", "knowledgeBaseConfiguration": { "knowledgeBaseId": knowledge_base_id, "modelArn": model_arn, "generationConfiguration": { "inferenceConfig": { "textInferenceConfig": { "maxTokens": 10000, "temperature": 0.5, "topP": 0.9 } }, "performanceConfig": { "latency": "standard" } } } } } # Bedrock sessionId は存在するときのみ渡す (継続会話時のみ) if bedrock_session_id: request["sessionId"] = bedrock_session_id # Bedrock Knowledge Bases への問い合わせ response = bedrock_agent.retrieve_and_generate_stream(**request) # Bedrock sessionId if "sessionId" in response: publish_appsync_message( sub, appsync_session_id, { "type": "bedrock_session", "bedrock_session_id": response["sessionId"] }, credentials ) for chunk in response["stream"]: payload = None # Generated text if "output" in chunk and "text" in chunk["output"]: payload = { "type": "text", "message": chunk["output"]["text"] } print({"t": chunk["output"]["text"]}) # Citation elif "citation" in chunk: payload = { "type": "citation", "citation": chunk['citation']['retrievedReferences'] } print({"c": chunk['citation']['retrievedReferences']}) # Continue if not payload: continue # Publish AppSync publish_appsync_message(sub, appsync_session_id, payload, credentials) except Exception as e: print(str(e)) raise まとめ いかがでしたでしょうか? Amazon Bedrock Knowledge Bases で RAG チャットボットを開発するときの UI 開発例を紹介しました。細かい説明はないので、コードの不明点は生成 AI に聞いてもらえると理解が進むと思います。 本記事が皆様のお役に立てれば幸いです。