React で Amazon Bedrock Knowledge Bases ベースの簡易 RAG チャットボットをつくる [2026年1月版] UI編

こんにちは、広野です。

AWS re:Invent 2025 で、Amazon S3 Vectors が GA されました。

以前は RAG 用のベクトルデータベースとして Amazon OpenSearch Service や Amazon Aurora など高額なデータベースサービスを使用しなければならなかったのですが、Amazon S3 ベースで安価に気軽に RAG 環境を作成できるようになったので嬉しいです。

それを受けて、以前作成した RAG チャットボット環境をアレンジしてみました。

内容が多いので、記事を 3つに分けます。本記事は UI 編です。

つくったもの

こんな感じの RAG チャットボットです。

  • 画面から問い合わせすると、回答が文字列細切れのストリームで返ってきます。それらをアプリ側で結合して、順次画面に表示しています。
  • 回答の途中で、AI が回答生成の根拠にしたドキュメントの情報が送られてくることがあるので、あればそのドキュメント名を表示します。一般的には親切にドキュメントへのリンクも付いていると思うのですが、今回は簡略化のため省略しました。
  • このサンプル環境では、AWS が提供している AWS サービス別資料 の PDF を独自ドキュメントとして読み込ませ、回答生成に使用させています。

前回の記事

本記事はアーキテクチャ編、実装編の続編記事です。以下の記事をお読みの上、本記事にお戻りください。

 

アーキテクチャ

  • 図の左側半分、アプリ UI 基盤は以下の記事と全く同じです。お手数ですが内容についてはこちらをご覧ください。
  • 図の右側半分、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 コード

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 に聞いてもらえると理解が進むと思います。

本記事が皆様のお役に立てれば幸いです。

タイトルとURLをコピーしました