「Qiitaに聞いた！！」をAmazon Bedrockで作った！（Claude 3でRAG）

出オチです。（タイトル先行で始める技術ブログがあってもいいじゃない）

先にデモを提示します。
自由に使っていただいて構いません。（びっくりする課金が来たら、止めますw）

https://huggingface.co/spaces/moritalous/qiita_ni_kiita

---

ここのところ、簡単に構築できる生成AIアプリづくりが個人的ブームになってます。Qiitaをナレッジの情報源としたRAGを作ってみましたので、作り方を解説します。

:::note

[【未経験者大歓迎】RAG超入門：AWSが推奨するRAGを体験するハンズオン](https://qiita.com/moritalous/items/61f91039c13aeb9a51eb)を見ていただいた方へ

上記投稿では、生成AI感がゼロでしたが、同様のことを、プロンプトを使って実現する内容となっています。
生成AIに興味を持っていただいた方は、本投稿も見ていただき、違いを感じていただければと思います。

:::

## 使用するもの

* 生成AI：Amazon Bedrock (Claude 3 Haiku)
* ドキュメント取得：Google検索
* 画面UI：Streamlit

## 処理の流れ

[先日投稿した記事](https://qiita.com/moritalous/items/61f91039c13aeb9a51eb)と同様、以下の流れを行います。

1. 検索クエリ生成
1. 検索
1. 回答生成

プロンプトはClaudeの開発元のAnthripicが公開しているクックブックを参考にしました。

https://github.com/anthropics/anthropic-cookbook/blob/main/third_party/Brave/web_search_using_brave.ipynb

## 解説

### 1. 検索クエリ生成

ユーザーの質問文をもとに検索クエリを生成します。Amazon BedrockのClaude 3 Haikuを使用します。

プロンプトは上記クックブックを参考に、このようなものを用意しました。

> 📝システムプロンプト
You are an expert at generating search queries for the Google search engine. Generate two search queries that are relevant to this question in Japanese. Output only valid JSON.

* Google検索の検索クエリを作ってね
* 2つ作ってね
* 日本語で回答してね
* 出力は正しいJSON形式にしてね

> 📝ユーザープロンプト
> User question: {{question}}
> 
> Format: {"queries": ["query_1", "query_2", "query_3"]}

`{{question}}`の部分に、ユーザーの質問文を入れてClaudeに送信します。

--- 
<details><summary>検索クエリを生成する関数のソースコード</summary>

```python
# 検索クエリを生成する関数
def generate_search_queries(question: str) -> List[str]:
    """
    Google 検索エンジン用の検索クエリを生成する
    """
    GENERATE_QUERIES = """
        User question: {{question}}
        Format: {"queries": ["query_1", "query_2", "query_3"]}
        """

    response = client.invoke_model(
        modelId="anthropic.claude-3-haiku-20240307-v1:0",
        body=json.dumps(
            {
                "anthropic_version": "bedrock-2023-05-31",
                "max_tokens": 1024,
                "system": "You are an expert at generating search queries for the Google search engine. Generate two search queries that are relevant to this question in Japanese. Output only valid JSON.",
                "messages": [
                    {
                        "role": "user",
                        "content": [
                            {
                                "type": "text",
                                "text": GENERATE_QUERIES.replace(
                                    "{{question}}", question
                                ),
                            }
                        ],
                    },
                ],
                "temperature": 0,
            }
        ),
    )

    result = json.loads(response.get("body").read())
    search_queries = result["content"][0]["text"]
    search_queries = json.loads(search_queries)

    return search_queries
```

</details>

--- 

実行するとこのような動作になります。

```text:ユーザーの質問文
AWS上でのRAGの作り方を教えて下さい。できるだけ初心者向きのがいいです
```
```text:生成された検索クエリ
* AWS上でRAGを作る方法
* AWS初心者向けRAG作成ガイド
```

検索結果にバリエーションがあったほうが幅広い情報が検索できるのではないかと思い、検索クエリを2つ生成させています。（参考にしたクックブックでは3つ作成させてました）

### 2. 検索

検索クエリができたので、ドキュメントを取得します。

ドキュメント取得は2ステップです。

1. Google検索　->　ドキュメントの概要とURLが取得できる
1. Qiita投稿のMarkdown全文を取得

#### 2.1. Google検索

まずGoogle検索を行います。検索対象サイトを`qiita.com`に絞ることで、情報源をQiitaに限定させます。

:::note
Google検索は、`site:`というキーワードを付けることで、検索対象サイトを制限できます。

例：「bedrock site:qiita.com」 -> qiita.comドメインに限定してbedrockを検索する
:::

Google検索をAPIで行うには`APIキー`と`検索エンジン ID`が必要です。

* 取得方法

    1. https://console.cloud.google.com/apis/library/customsearch.googleapis.com にアクセスし、 「Custom Search API」を有効化　（要Google Cloudアカウント）
    1. APIとサービスにアクセス（ https://console.cloud.google.com/apis/dashboard ） 、認証情報メニューをクリック
    1. 「認証情報を作成」の「API キー」をクリック、APIキーを発行
    1. プログラム可能な検索エンジンにアクセス（ https://programmablesearchengine.google.com/ ）
    1. 「使ってみる」をクリック
    1. 入力フォームに情報入力、次の画面で「カスタマイズ」をクリック
    1. 「検索エンジン ID」を入手

:::note
無料で使い始められるのですが、無料上限を超過後は有料になります。超過しないように制限する方法はこちらです。

1. APIとサービスにアクセス（ https://console.cloud.google.com/apis/dashboard ）
1. 一覧の「Custom Search API」をクリック
1. 「割り当てアラートとシステム上限」タブをクリック
1. フィルター一覧の「Queries per day」にチェックを入れ、「割り当てを編集」をクリック
1. 新しい値を「99」に変更　※デフォルトは10,000だったと思う
:::

APIの呼び出しには`Google API Python client library`を使用します。

https://github.com/googleapis/google-api-python-client

```python:（Google検索部分のみ抜粋）
from googleapiclient.discovery import build

service = build("customsearch", "v1", developerKey=os.environ.get("GOOGLE_API_KEY"))
cse = service.cse()
res = cse.list(
    q=f"{search_query} site:qiita.com",
    cx=os.environ.get("GOOGLE_CSE_ID"),
    num=3,
).execute()

res["items"] # 検索結果が格納される
```

簡単ですね。今回は3件取得することにしました。

結果を受けたあと、必要な情報（title、link、snippet）だけをピックアップしたリストにして返却します。

--- 
<details><summary>Qiitaを検索する関数のソースコード</summary>

```python
# Qiitaを検索する関数
def search_qiita(search_query: str) -> list:
    """
    指定された検索クエリでQiitaを検索する
    """
    service = build("customsearch", "v1", developerKey=os.environ.get("GOOGLE_API_KEY"))
    cse = service.cse()
    res = cse.list(
        q=f"{search_query} site:qiita.com",
        cx=os.environ.get("GOOGLE_CSE_ID"),
        num=3,
    ).execute()

    documents = list(
        map(
            lambda x: {
                "title": x["title"],
                "link": x["link"],
                "snippet": x["snippet"],
            },
            res["items"],
        )
    )

    return documents
```

</details>

--- 


:::note
他の検索エンジンも色々あります。

以前調査した情報
https://qiita.com/moritalous/items/99a7cdf1620e79d77290
:::

:::note
Google検索で試す前は、[Qiita API](https://qiita.com/api/v2/docs)を使って進めていたのですが、生成された検索クエリでの検索が期待通りいかなかったので、Google検索に切り替えました。
:::

#### 2.2. Qiita投稿のMarkdown全文を取得

Qiitaの投稿は、URLの末尾に`.md`をつけると、Markdown形式で取得できます。

例：https://qiita.com/moritalous/items/61f91039c13aeb9a51eb.md

Google検索で取得したURLにアクセスし、Markdown形式の投稿全部を取得します。

--- 

<details><summary>検索結果にマークダウンを追加する非同期関数のソースコード</summary>

```python
# 検索結果にマークダウンを追加する非同期関数
async def add_markdown(search_result: dict) -> dict:
    """
    検索結果にマークダウンを追加する
    """
    url = search_result["link"]
    response = requests.get(f"{url}.md")
    markdown = response.text
    search_result["markdown"] = html.escape(markdown)

    return search_result
```

</details>

---

### 3. 回答生成

ドキュメントが取得できたので、Claude 3 Haikuで回答を生成させます。

今回は、2つの検索クエリそれぞれに3件ずつのGoogle検索があるので、計6件のQiita投稿があります。

かなりの文章量になりますが、 **Claude 3は200kトークンまで入力できるのでへっちゃらです。** 全部まるごとぶち込みます。

Claudeのドキュメントには、長文を渡す際のプロンプトエンジニアリングテクニックが載ってます。（[こちら](https://docs.anthropic.com/claude/docs/long-context-window-tips)）

* XMLで構造化しようね
* 質問はプロンプトの最後にね

上記を踏まえ、このようなプロンプトとしました。（ほぼクックブックのままですが）

> 📝ユーザープロンプト
> I have provided you with the following search results:
> {xml_docs}
> 
> Please answer the user's question using only information from the search results. 
> Keep your answer concise.
> Answer is olways in Japanese!
> 
> User's question: {question}

ドキュメントは、XML形式に変換して`{xml_docs}`の部分に挿入します。XMLの形式は以下のようにしました。

```xml
<documents>
 <doc title="{タイトル}">
  <link>{リンク}</link>
  <markdown>{Markdownの投稿全部}</markdown>
 </doc>
 <doc title="{タイトル}">
  <link>{リンク}</link>
  <markdown>{Markdownの投稿全部}</markdown>
 </doc>
 ...
<documents>
```

準備が整ったので、これをBedrockに送信します。

--- 

<details><summary>質問に対する回答を生成する関数のソースコード</summary>

```python
# 質問に対する回答を生成する関数
def generate_answer(question: str, documents: dict):
    """
    検索結果から質問に対する回答を生成する
    """
    xml_docs = create_xml_documents(documents=documents)

    ANSWER_QUESTION = f"""I have provided you with the following search results:
    {xml_docs}
    Please answer the user's question using only information from the search results. 
    Keep your answer concise.
    Answer is olways in Japanese!
    User's question: {question}
    """

    response = client.invoke_model(
        modelId="anthropic.claude-3-haiku-20240307-v1:0",
        body=json.dumps(
            {
                "anthropic_version": "bedrock-2023-05-31",
                "max_tokens": 1024,
                "messages": [
                    {
                        "role": "user",
                        "content": [
                            {
                                "type": "text",
                                "text": ANSWER_QUESTION,
                            }
                        ],
                    },
                ],
                "temperature": 0.1,
            }
        ),
    )

    result = json.loads(response.get("body").read())
    return result["content"][0]["text"]
```

</details>

---

これで質問に対する回答を得ることができました。

```text:ユーザーの質問文
AWS上でのRAGの作り方を教えて下さい。できるだけ初心者向きのがいいです
```
```text:生成された回答
AWS上でRAGを構築する方法について、初心者向けに以下のように説明します:

1. Amazon Kendraを使って、ドキュメントのインデックスを作成します。Kendraは機械学習を使った高度な検索サービスで、ドキュメントの内容を理解して検索結果を返してくれます。
1. Amazon Bedrockを使って、生成AIモデルを活用します。Bedrockは複数の大手AI企業のモデルを使えるサービスで、質問に対する回答を生成することができます。
1. Kendraの検索結果とBedrockの回答生成を組み合わせることで、RAGのシステムを構築できます。ユーザーの質問に対して、Kendraが関連ドキュメントを検索し、Bedrockがそれらを参考に回答を生成するというアーキテクチャです。

この方法は、KendraとBedrockの機能を活用することで、プログラミングの知識が少なくても比較的簡単にRAGシステムを構築できます。詳しい手順は記事を参照してください。
```

### 画面を作成

Streamlitで画面を作成します。

1. フォームを作成
    ```python
    with st.form("Form"):
    ```
    `with`句の中が、フォームの中に相当します。

1. 質問入力欄を作成
    
    ```python
    question = st.text_input("質問")
    ```
    入力した文字列は`question`変数に格納されます。

1. フォーム送信ボタンを作成

    ```python
    if st.form_submit_button("質問する"):
    ```
    ボタンクリック時の処理をifの中に記述します。

1. 結果を出力
    生成した回答を表示します。

    ```python
    st.markdown(answer)
    ```

--- 

<details><summary>画面表示部分のソースコード</summary>

```python
# メイン関数
async def main():
    with st.form("Form"):
        question = st.text_input("質問")

        if st.form_submit_button("質問する"):
            with st.status("処理中...", expanded=True) as status:
                search_queries = generate_search_queries(question=question)
                st.write("検索クエリ: " + str(search_queries["queries"]))

                documents = []
                for search_query in search_queries["queries"]:
                    search_results = search_qiita(search_query=search_query)
                    result = await asyncio.gather(
                        *[add_markdown(x) for x in search_results]
                    )
                    documents.extend(result)
                st.write("検索完了")

                st.write("回答生成中...")
                answer = generate_answer(question=question, documents=documents)

                status.update(label="complete!", state="complete", expanded=False)

            st.markdown(answer)

            st.divider()

            st.markdown("参照ドキュメント")

            for document in documents:
                st.markdown(
                    f'[{document["title"]}]({document["link"]}) by {document["link"].split("/")[3]}'
                )
```

</details>

---

## まとめ

Claudeを使って、Qiitaを情報源としたRAGを構築しました。
作ってみた感想は、「簡単に結構いいものができた」と思っています。

情報源を変えるだけで、いろいろな使い方に応用がききそうだなと思いました。

---

最後に宣伝

Bedrockの書籍を出版することになりました。興味を持っていただいた方は、どうぞお手に取ってください。

[Amazon Bedrock 生成AIアプリ開発入門 [AWS深掘りガイド]](https://www.sbcr.jp/product/4815626440/)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/41574/8c912dbc-0d13-4d75-2993-53c4a589149a.png)