0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

雑誌PDFを検索するRAGを構築した話

0
Posted at

世の中、月刊誌が隔月刊になったり、不定期刊になったり、Webに移行したり、休刊したり。雑誌受難の時代でしょうか。かの日経ソフトウェアも今年いっぱいで休刊とのこと。ここ数年はかなりの冊数を買ったり年間購読したりしてたので残念ではありますが、時代の流れには逆らえないのでしょう。
さて、IT技術系の雑誌は休刊後とか、あるいは定期刊行の途中でも総集編みたいな形で、バックナンバーをPDF化してDVDに収録したものを販売していたりします。これは非常にありがたい資料ですが、記事を検索するには数MBとかのファイルを一つずつ開いては探す・・・という繰り返しになりがちで、せっかくNASに保存してもあまり日の目を見ないままのファイルもあったりします。せっかく買ったのにもったいないなと思い、検索システムを作ってみることにしました。

ローカルLLMでRAGを構築することも考えましたが、そうするとRAG用のPCを常時起動しておくことになり、それはそれで電気代がかかるなぁと。ちなみにChatGPTに聞いてみたらこんな感じの答えでした。

例えばRTX 5060 Ti搭載PCなら
以前話していたような Core Ultra 7 265K + RTX 5060 Ti 16GB クラスのPCを想定すると、
「電源ONだけどLLMは使っていない」
なら、GPUがほぼアイドルなので、PC全体で例えば 50~80W程度に収まる可能性があります。
→ 月1,500~2,500円くらい
一方、
「LLMを動かしてGPUをかなり使っている」
状態だと、PC全体で150~250Wくらいになることもあります。
→ 月4,000~8,000円くらい
という感じ。

結構高いな~という印象。だったらサーバーレスの方がいいんじゃね?ってことでAWSで構築してみることにしました。

AWS構成図

design.png

AWS構成の概要

  • 検索対象のPDF等はS3に保存。S3へのアップロード機能は含まないためコンソールやCLIで別途アップロードする必要がある。
  • RAGはBedrockで実現。クエリの実行はLambda。
  • CloudFrontでS3に置いた静的コンテンツをホスト。Route53で独自ドメインを管理。
  • Cognitoでユーザー認証。
  • フロントはバニラJS。API Gateway経由でLambda関数を実行し、RAGからの回答をブラウザに返す。

構築の流れ

この記事を見て真似しよう!などという奇特な方がいるかどうか分かりませんが、以下はあくまでも参考情報です。細かい手順とかは書いていません。AWSを多少触ったことがある人ならこれを見ながら同じ感じにできるかも?それにAWSコンソールがしょっちゅう変わるから、数か月後にこれを見た俺が同じようにできるかも謎。
でも一応記事を公開した責任上、コメントをいただいた場合は分かる範囲で回答します。コードの中身に関してはAIに聞く方が良いと思いますが。

1. S3(ナレッジベースのデータソース)

  • 汎用バケットを作成
    • 最初はナレッジベースの動作確認のため、自分しか知らない情報が書かれたTXTやPDFなどを置いておくとテストのときに便利。
    • 適宜フォルダを分割する。ナレッジベースのデータソースにはバケット単位やフォルダ単位で追加可能。
    • データソースに追加した単位でインデックスの再生成ができるので情報更新の有無や頻度などを考慮してフォルダを分けると良い。

2. Bedrock

  • マネージドKBを作成
    • リージョンはS3のリージョンと合わせる。
    • データソースタイプでS3を選択し、データソースの場所は「S3を参照」から作成済みのS3バケットを指定する。
    • S3 prefix filter patternsは、ここで指定したS3のフォルダ、またはオブジェクトの単位でインデックスを生成することができるようになる。
  • ナレッジベースのテスト
    • S3バケットに自分しか知らない情報を置いた場合は、テスト機能でその情報についてプロンプトを投げてみることで動作確認ができる。

3. S3(静的コンテンツを配置してCloudFrontのオリジンにする)

  • 汎用バケットを作成
    • 末尾に記載の「index.html」「config.js」「app.js」「style.css」をバケットのルートにアップロードする。(各コードはChatGPTが作ったものなのであまり分かってません。)
    • config.jsの内容は適宜修正する。
    • 他のファイルも好みに応じて適宜修正・変更する。
    • favicon.icoも置くと良いかも。

4. Route53

  • 独自ドメインを設定
    • このシステム用に(というよりこのシステムのCloudFront用に)独自ドメインを取得するか、取得済みのドメインのサブドメインをRoute53のホストゾーンに委譲する。
    • 独自ドメインを使わない場合はちょっと良く分からない。独自ドメイン持ってなければこの機会にとりましょう。

5. CloudFront

  • ディストリビューションを作成
    • 独自ドメインを「Domain」に指定する。
    • オリジンタイプでS3を選択し、S3 オリジンは「Browse S3」から作成済みのS3バケットを指定する。

6. Certification Manager

  • CloudFrontのディストリビューション作成時に自動作成されるので特に意識しない。
    • S3などを東京リージョンなど北米以外に作ったとしても、ACMはバージニア北部リージョンに作られる。

7. Cognito(旧Hosted UIを利用したログイン認証)

  • ユーザープールを作成
    • アプリケーションタイプは「シングルページアプリケーション」(Javaアプリで使うとかならそれなりに。)
    • サインイン識別子とサインアップのための必須属性は適宜
    • 自己登録を有効化はOFF(OFFにしてHosted UIを使うと初回登録時にエラーになる。エラーにしたくなければ、何か制御しないといけない。)
      • 自己登録を許可すると誰でもログインできてしまうようになり、そのまま購入したPDFの検索をさせると著作権的に問題になるはず。そもそも自分しか使わなくても著作権的に問題がないかは検討が必要。
  • アプリケーションクライアントを作成
    • アプリケーションタイプは「シングルページアプリケーション」

8. Lambda

  • 関数を作成
    • 関数を1から作成する。
    • ランタイムは「Node.js 26.x(Preview)」(24.xの方が良いかも。Preview版だとSDKが古いことがあるらしい。)
      • Preview版のSDKが古くて最新版のSDKを使いたい場合、Lambdaにレイヤーを追加すれば良いみたい。その手順は末尾に記載。
  • コードを入力
    • 末尾に記載の「index.mjs」を入力
    • 機能を変更したい場合は適宜修正。
  • 設定タブ
    • 環境変数を定義
キー
ALLOW_ORIGIN https://[独自ドメイン]
KNOWLEDGE_BASE_ID BedrockのナレッジベースID
REGION Bedrockのリージョン
  • 設定タブ(続き)
    • 一般設定→タイムアウトを30秒など適宜修正。
    • 画面に表示する「出典」用にS3の署名付きURLを発行するための権限を追加する。アクセス権限→実行ロール名のリンクを開き、許可を追加→インラインポリシーを作成から以下を追加。
json
{
	"Version": "2012-10-17",
	"Statement": [
		{
      "Effect": "Allow",
      "Action": [
          "s3:GetObject"
      ],
      "Resource": "arn:aws:s3:::[ナレッジベースのS3のバケット名]/*"
		}
	]
}

9. API Gateway

  • APIの作成
    • HTTP API、自動デプロイ を選択
    • ルートの作成で ルート=/rag、メソッド=POST
  • 作成したルートに対するオーソライザーの作成
  • 作成したルートに対する統合の設定
    • Lambdaを選択

index.html
<!DOCTYPE html>
<html lang="ja">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>RAG検索</title>

  <link rel="stylesheet" href="style.css">
</head>

<body>
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.6/dist/purify.min.js"></script>


  <div class="header">
    <h1>RAG検索</h1>
    <button id="logoutButton">ログアウト</button>
  </div>

  <main>
    <div class="query-area">
      <textarea id="query" placeholder="質問を入力してください"></textarea>

      <button id="askButton">問い合わせ</button>
    </div>

    <div class="answer-area">
      <h2>回答</h2>
      <div id="answer"></div>
      <div id="sources"></div>
    </div>
  </main>

  <script src="config.js"></script>
  <script src="app.js"></script>

</body>

</html>
config.js
const config = {
    clientId: "CognitoのクライアントID",
    cognitoDomain: "Cognitoドメイン",
    redirectUri: "リダイレクトURL",
    apiUrl: "APIのURL",
};
app.js
// 起動処理
main();


async function main() {

    const params = new URLSearchParams(window.location.search);
    const code = params.get("code");

    if (code) {
        // Cognitoから戻ってきた場合
        await exchangeToken(code);

        // URLからcodeを消す
        window.history.replaceState(
            {},
            document.title,
            "/"
        );

    } else {

        // すでにログイン済みか確認
        const accessToken =
            sessionStorage.getItem("access_token");

        if (!accessToken) {
            await login();
            return;
        }
    }

    // ここからアプリ処理
    document.getElementById("message").innerText = "ログイン済み";
    document.getElementById("btnApi").style.display = "block";

}


// Cognito Hosted UIへ移動
async function login() {

    const verifier =
        generateVerifier();

    sessionStorage.setItem(
        "code_verifier",
        verifier
    );

    const challenge =
        await generateChallenge(verifier);

    const url =
        config.cognitoDomain +
        "/oauth2/authorize" +
        "?response_type=code" +
        "&client_id=" +
        encodeURIComponent(config.clientId) +
        "&redirect_uri=" +
        encodeURIComponent(config.redirectUri) +
        "&scope=openid+email" +
        "&code_challenge=" +
        encodeURIComponent(challenge) +
        "&code_challenge_method=S256";

    location.href = url;
}


// code → token交換
async function exchangeToken(code) {

    const verifier =
        sessionStorage.getItem(
            "code_verifier"
        );
    const response = await fetch(
        config.cognitoDomain + "/oauth2/token",
        {
            method: "POST",

            headers: {
                "Content-Type":
                    "application/x-www-form-urlencoded"
            },

            body: new URLSearchParams({
                grant_type:
                    "authorization_code",

                client_id:
                    config.clientId,

                code:
                    code,

                redirect_uri:
                    config.redirectUri,

                code_verifier:
                    verifier
            })
        }
    );

    if (!response.ok) {
        console.error(
            await response.text()
        );
        throw new Error(
            "Token取得失敗"
        );
    }

    const token = await response.json();

    sessionStorage.setItem(
        "access_token",
        token.access_token
    );

    sessionStorage.setItem(
        "id_token",
        token.id_token
    );

    if (token.refresh_token) {

        sessionStorage.setItem(
            "refresh_token",
            token.refresh_token
        );
    }
}


// PKCE verifier生成
function generateVerifier() {

    const array = new Uint8Array(32);

    crypto.getRandomValues(array);


    return base64urlEncode(array);
}


// PKCE challenge生成
async function generateChallenge(verifier) {

    const data = new TextEncoder().encode(verifier);


    const digest =
        await crypto.subtle.digest(
            "SHA-256",
            data
        );


    return base64urlEncode(
        new Uint8Array(digest)
    );
}


// Base64 URL形式へ変換
function base64urlEncode(buffer) {

    return btoa(
        String.fromCharCode(...buffer)
    )
        .replace(/\+/g, "-")
        .replace(/\//g, "_")
        .replace(/=/g, "");
}


// API呼び出し
async function callRag() {

    const queryElement = document.getElementById("query");
    const answerElement = document.getElementById("answer");
    const sourcesElement = document.getElementById("sources");

    const query = queryElement.value.trim();

    if (!query) {
        alert("質問を入力してください。");
        return;
    }

    const token = sessionStorage.getItem("access_token");

    if (!token) {
        alert("ログインしてください。");
        return;
    }

    answerElement.textContent = "回答を生成しています...";
    sourcesElement.innerText = "";

    try {

        const response = await fetch(config.apiUrl, {
            method: "POST",

            headers: {
                "Content-Type": "application/json",
                "Authorization": "Bearer " + token
            },

            body: JSON.stringify({
                query: query
            })
        });

        const data = await response.json();

        if (!response.ok) {
            throw new Error(data.error || data.message || "APIエラー");
        }

        const answerElement = document.getElementById("answer");
        const sourcesElement = document.getElementById("sources");

        answerElement.innerHTML = DOMPurify.sanitize(marked.parse(data.answer));
        sourcesElement.innerHTML = "";

        if (data.sources && data.sources.length > 0) {

            const titleElement = document.createElement("h3");
            titleElement.textContent = "出典";

            sourcesElement.appendChild(titleElement);

            data.sources.forEach(source => {

                const link = document.createElement("a");

                link.textContent = source.title;
                link.href = source.uri;
                link.target = "_blank";
                link.rel = "noopener noreferrer";

                sourcesElement.appendChild(link);
                sourcesElement.appendChild(document.createElement("br"));
            });
        }

    } catch (error) {

        console.error("RAG error:", error);

        answerElement.textContent =
            "エラーが発生しました。\n" + error.message;
    }
}

// ログアウト
function logout() {

    // ブラウザに保存しているアクセストークンを削除
    sessionStorage.removeItem("access_token");

    // Cognitoからログアウト
    const logoutUrl =
        config.cognitoDomain +
        "/logout" +
        "?client_id=" +
        encodeURIComponent(config.clientId) +
        "&logout_uri=" +
        encodeURIComponent(config.redirectUri);

    window.location.href = logoutUrl;
}

// ログアウトボタン
document
    .getElementById("logoutButton")
    .addEventListener("click", logout);

document
    .getElementById("askButton")
    .addEventListener("click", callRag);
style.css
body {
  font-family: sans-serif;
  max-width: 900px;
  margin: 40px auto;
  padding: 0 20px;
}

.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 30px;
}

button {
  padding: 10px 20px;
  font-size: 15px;
  cursor: pointer;
}

#logoutButton {
  padding: 7px 15px;
}

.query-area {
  margin-bottom: 30px;
}

#query {
  width: 100%;
  min-height: 120px;
  box-sizing: border-box;
  padding: 12px;
  font-size: 16px;
  font-family: inherit;
  resize: vertical;
}

#askButton {
  margin-top: 10px;
}

.answer-area {
  margin-top: 30px;
}

#answer {
  min-height: 100px;
  padding: 15px;
  background: #f5f5f5;
  white-space: pre-wrap;
}
index.mjs
import {
  BedrockAgentRuntimeClient,
  AgenticRetrieveStreamCommand
} from "@aws-sdk/client-bedrock-agent-runtime";

import {
  S3Client,
  GetObjectCommand
} from "@aws-sdk/client-s3";

import {
  getSignedUrl
} from "@aws-sdk/s3-request-presigner";

const client = new BedrockAgentRuntimeClient({
  region: process.env.REGION
});

const s3Client = new S3Client({
  region: process.env.REGION
});

// Presigned URLを作る関数
async function createPresignedUrl(uri) {
  const url = new URL(uri);

  const bucket = url.hostname.split(".")[0];

  const key = decodeURIComponent(
    url.pathname.substring(1)
  );

  const command = new GetObjectCommand({
    Bucket: bucket,
    Key: key,
    ResponseContentDisposition: "inline"
  });

  return await getSignedUrl(
    s3Client,
    command,
    { expiresIn: 300 }
  );
}

export const handler = async (event) => {
  try {
    const body = JSON.parse(event.body);

    const query = body.query;

    console.log("Query:", query);

    // AgenticRetrieveStream
    const agenticResponse = await client.send(
      new AgenticRetrieveStreamCommand({
        messages: [
          {
            content: {
              text: query
            },
            role: "user"
          }
        ],

        retrievers: [
          {
            configuration: {
              knowledgeBase: {
                knowledgeBaseId: process.env.KNOWLEDGE_BASE_ID
              }
            }
          }
        ],

        agenticRetrieveConfiguration: {
          foundationModelType: "MANAGED",
          rerankingModelType: "MANAGED",
          maxAgentIteration: 5
        },

        generateResponse: true
      })
    );

    let fullAnswer = "";

    // 検索結果を格納
    const retrievalResults = [];

    if (agenticResponse.stream) {

      for await (const streamEvent of agenticResponse.stream) {

        // --------------------------------
        // 検索結果
        // --------------------------------
        if (streamEvent.result) {

          for (const item of streamEvent.result.results ?? []) {

            console.log(
              "Result:",
              item.content?.text
            );
            console.log(
              "RESULT ITEM =",
              JSON.stringify(item, null, 2)
            );

            retrievalResults.push(item);
          }
        }

        // --------------------------------
        // 回答
        // --------------------------------
        else if (streamEvent.responseEvent) {

          fullAnswer +=
            streamEvent.responseEvent.text ?? "";
        }

        // --------------------------------
        // トレース
        // --------------------------------
        else if (streamEvent.traceEvent) {

          const {
            step,
            status,
            message
          } = streamEvent.traceEvent.attributes ?? {};

          console.log(
            `Trace [${step}/${status}]: ${message}`
          );
        }
      }
    }

    console.log(
      "回答(完全):",
      fullAnswer
    );

    console.log(
      "retrievalResults.length =",
      retrievalResults.length
    );

    // --------------------------------
    // 出典を作成
    // --------------------------------
    const sourcesMap = new Map();

    for (const result of retrievalResults) {

      const title =
        result.metadata?._document_title ||
        "不明";

      const uri =
        result.metadata?._source_uri ||
        result.location?.s3Location?.uri ||
        null;

      if (!uri) {
        continue;
      }

      if (!sourcesMap.has(uri)) {

        const presignedUrl =
          await createPresignedUrl(uri);

        sourcesMap.set(uri, {
          title,
          uri: presignedUrl
        });
      }
    }

    const sources = [...sourcesMap.values()];

    console.log(
      "sources =",
      JSON.stringify(sources)
    );


    for (const source of sources) {

      if (source.uri) {

        try {

          source.signedUrl =
            await createPresignedUrl(source.uri);

        } catch (error) {

          console.error(
            "署名付きURL作成エラー:",
            source.uri,
            error
          );

          source.signedUrl = null;
        }
      }
    }


    console.log(
      "sources with signedUrl =",
      JSON.stringify(sources)
    );

    // --------------------------------
    // ブラウザへ返す
    // --------------------------------
    return {
      statusCode: 200,

      headers: {
        "Access-Control-Allow-Origin": process.env.ALLOW_ORIGIN,
        "Access-Control-Allow-Headers": "Authorization,Content-Type"
      },

      body: JSON.stringify({
        answer: fullAnswer,
        sources: sources
      })
    };

  } catch (error) {

    console.error("Error:", error);

    return {
      statusCode: 500,

      headers: {
        "Access-Control-Allow-Origin": process.env.ALLOW_ORIGIN
      },

      body: JSON.stringify({
        error: error.message
      })
    };
  }
};
NodeのSDKの最新版をLamndaのレイヤーに追加するには?
# Amazon Qに聞くのが良いと思う。
# たぶんこんな感じ

# Cloud Shellを起動して、
mkdir -p layer/nodejs
cd layer/nodejs
npm install @aws-sdk/client-bedrock-agent-runtime
cd ..
zip -r bedrock-agent-runtime-sdk.zip nodejs/

aws lambda publish-layer-version \
  --layer-name bedrock-agent-runtime-sdk \
  --zip-file fileb://bedrock-agent-runtime-sdk.zip \
  --compatible-runtimes nodejs18.x nodejs20.x \
  --region ap-northeast-1
#           ↑のリージョンは適宜

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?