全文検索＋GPT-3で検索結果の情報をもとにした簡単な質問回答システムを作ってみた

# はじめに

ChatGPTとっても便利！
ただ、誤った内容を回答することがあるのが弱点。
たとえばマルテンサイトという金属組織の用語について「マルテンサイトとは？」と聞くと以下のように、架空の生物の話を回答してきた。
>マルテンサイトは、約3.5億年前から約2億年前まで存在した古生物です。～略～　その中に複数の穴が開いていることが特徴です。

一方でBingが搭載したプロメテウスでは、検索ワードに関連するWebページを拾ってきて、その情報をもとにGPTが回答を生成することで精度を上げているみたい。

https://www.itmedia.co.jp/news/articles/2302/24/news115.html




# やってみたこと
ということで、その真似事にも及ばないが、クローズドな環境でもBingのプロメテウスのようなことができることを目指して、**全文検索システムであるFessを使い、取得した情報をGPT-3に与えた上で質問をするシステムを作ってみた**。

ただ、今回はWebページを検索するのではなく、ローカルに予め参考となる情報をテキストてま格納しておき、それらのファイルを検索している（Web検索もできるが、多様なHTMLファイルからの情報抽出が大変そうだったので（汗））。

また、GPT-3へ参考情報を与える方法として、質問文の後に、検索でヒットしたテキスト内容を追加するシンプルな方法をとっている。（プロメテウスはもっと高度なことをしていると思う）

以下、作成したシステム画面。検索用のキーワードと質問を入力すると、検索結果と回答（画像グリーン箇所）、コスト（画像ブルー箇所）が出力される。
先のマルテンサイトに関して正しく回答できている。よかった！これで、安心して他のマルテンサイトに関する質問もできる。
![スクリーンショット (74).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1108240/2852963b-933e-52d1-afd2-354261fdd666.png)


 


以降、作成方法について記載しておく。全文検索にはFess、アプリケーションにはStreamlitを利用。Fessの構築は初めてだったがDockerイメージも用意されており、少ない手順でできるようになっている（私は一部躓きましたが）




# Fessのインストール＆起動
以下のマニュアルに従って構築が可能だが、手順を簡単にまとめておく。

https://fess.codelibs.org/ja/setup.html

https://github.com/codelibs/docker-fess/tree/v14.3.0/compose

### 事前設定
以下のコマンドを実行
~~~
wsl -d docker-desktop
sysctl -w vm.max_map_count=262144
~~~
（以下を参考）

https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#_windows_with_docker_desktop_wsl_2_backend

### Dockerから起動
[git](https://github.com/codelibs/docker-fess/tree/v14.3.0/compose)から以下のファイルをダウンロード
+ `compose.yaml`
+ `compose-elasticsearch8.yaml`

次に以下のように`compose.yaml`を編集。
今回はローカルフォルダ内の検索を行いたいため、`volumes`を追加し、ローカルのフォルダ（以下のファイルでは予め作成しておいた`doc`フォルダを指定）を/homeにマウントする処理を追記している。

~~~compose.yaml
services:
  fess01:
    image: ghcr.io/codelibs/fess:14.3.0
    container_name: fess01
    environment:
      - "ES_HTTP_URL=http://es01:9200"
      - "FESS_DICTIONARY_PATH=${FESS_DICTIONARY_PATH:-/usr/share/elasticsearch/config/dictionary/}"
    volumes:
      - doc:/home
    ports:
      - "8080:8080"
    networks:
      - esnet
    depends_on:
      - es01
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "5"
    restart: unless-stopped

networks:
  esnet:
    driver: bridge
~~~
展開先のフォルダに移動してから以下のコマンドを実行
~~~
docker compose -f compose.yaml -f compose-elasticsearch8.yaml up -d
~~~
`http://localhost:8080/`にアクセスすると検索画面が開く。

以上でうまくいくはずだが、自分はうまく行かなかった。以下にはその内容と解決方法をメモしておく。
### 自環境でうまくいかなかった話と解決方法

ターミナル上ではエラーなくdocker composeの処理が完了したが、コンテナが停止と再起動を繰り返すし、`http://localhost:8080/`にアクセスしても404エラーが返ってくる。
docker-desktopのログをよく見ると以下のエラーをはいていた。事前設定で増やしたはずだが。。
~~~
max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]
~~~
以下のサイトを見ると、`.wslconfig`ファイルに`kernelCommandLine = "sysctl.vm.max_map_count=262144"`を追加することでも、設定可能でこの方法で無事解決！
こちらの方法だとdocker-desktopの起動のたびに設定する必要がなくなるみたいで、こちらの方が無難かも。なぜ事前設定で記載した方法ではだめだったのか原因はわからず。

https://tech.note.wlaboratory.com/entry/category/memo/docker/elastic-stack-tuning-vm-max_map_count-wsl-docker-desktop

最終的な`.wslconfig`の内容は以下の通り。
(`.wslconfig`ファイルがない場合は`C:\Users\<yourUserName>\`に作成する必要あり)
~~~`.wslconfig`
[wsl2]
memory=3GB
processors=2
swap=0
kernelCommandLine = "sysctl.vm.max_map_count=262144"
~~~
編集後は以下のコマンドでWLS2を再起動
~~~
wsl --shutdown
~~~

# Fessのクローラ設定と実行
`http://localhost:8080/`にアクセスし、右上の「ログイン」ボタンを押してIDとパスワードを入力（初期は両方ともadmin）
![スクリーンショット (69).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1108240/7aff9476-3cea-5d54-b4cb-060be701f903.png)

### クローラの設定
検索対象のフォルダをクローリングするよう設定します。
「クローラ」->「ファイルシステム」->「新規作成」を押すと以下の画面が出てくるので入力。
今回の場合、検索対象のファイルは`/home`に格納するので`file:///home`とした（ローカルフォルダの`doc`をマウントしている）。またクローリングする深さを1とした。
![スクリーンショット (71).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1108240/9455a98f-f010-f957-9f4e-4bd15448fa0d.png)


### クローリングを実行
※この処理を実行する前にローカルフォルダ`doc`に検索対象のテキストファイル（後にGPTへ渡す参考情報）を格納しておく。

「システム」->「スケジューラ」と進んだ画面に表示される「Default Crawler」をクリックし、「今すぐ開始」をクリック。
「システム情報」->「ジョブログ」の状態が実行中からOKになればクローリング完了。

# Streamlitで検索＆GPT-3への質問画面を作成
大まかな処理の流れは以下の通り。
1. 全文検索での検索ワード及びGPT-3への質問内容が入力され、実行ボタンが押される
1. 検索ワードにヒットした上位3件のテキストファイルを取得
1. 質問のプロンプトを作成。プロンプトの内容は「`{質問内容}＋"以下の文章を参考にして答えてください。" + {前ステップで取得したテキストの内容}`」
1. GPT-3へプロンプトを送信し、返ってきた結果を表示

コードは以下の通り。

~~~python:app.py
import httpx
import streamlit as st
import sys
import openai

def view_response(res):
    '''検索クエリ結果の表示''' 
    # 検索ヒット件数
    record_count = res["response"]["record_count"]
 
    # 検索ワード
    q = res["response"]["q"]
 
    if record_count == 0:
        # 検索結果がない場合
        st.error(f'{q}に一致する情報は見つかりませんでした。')
         
    else:
        # 検索件数など
        page_number = res["response"]["page_number"]
        startRange = res["response"]["start_record_number"]
        endRange = res["response"]["end_record_number"]
        exec_time = res["response"]["exec_time"]
        # "q" の検索結果 232 件中 2 件表示 (0.2 秒)
        st.write(
            f" **{q}** の検索結果 **{record_count}** 件中 **{startRange} ** 件表示 ({exec_time}秒)")
 
        # 検索結果表示処理
        results = res["response"]["result"]
        for i, result in enumerate(results):
            # 検索結果
            title = result["title"]
            url_link = result["url_link"].replace("file://home/","")
            digest = result["digest"]
            st.markdown(f"###### {i+1}. [{title}]({url_link})",
                        unsafe_allow_html=True)
            st.markdown(digest)
 
    return int(record_count)
 

def make_prompt(res):
    '''GPTのプロンプト文作成'''
    prompt = "{question} \n" 
    prompt += "以下の文章を参考にして答えてください。\n "

    results = res["response"]["result"]
    for i, result in enumerate(results):
        #リンク先のドキュメント取得
        url_link = result["url_link"].replace("file://home/","")#コンテナ側のパスを返すので、ローカルの場所に変更
        doc = open(url_link,encoding="utf-8").read()
        prompt += "文章ID:"+ str(i+1) +"\n"
        prompt +=  doc + " \n\n "
    return prompt

 
 
def main():
    '''MainUI'''
    # アプリ名
    st.markdown('## 全文検索 + 質問 by GPT-3')
    # 検索ワード入力
    input_word = st.text_input(label='キーワード入力', value='')
    st.write(f'検索ワード: {input_word}')
 
    # 検索＆GPTのプロンプトで取り込むドキュメント数上限
    page_size = st.sidebar.number_input(label='ドキュメント取り込み件数上限', value=3, step=1)
    #st.sidebar.write(f'件数: {page_size}')
    # GPTのパラメータ
    max_tokens = st.sidebar.number_input(label='max_tokens', value=500, step=50)
    temperature = st.sidebar.slider(label="temperature",value=0.7,min_value=0.0,max_value=1.0,step=0.1)
    
    #質問内容入力
    qestion_word = st.text_input(label='質問入力', value='')
    # 検索
    if st.button("検索&質問"):
        # GETリクエスト
        response = httpx.get(
            f'http://localhost:8080/json/?q={input_word}&num={page_size}')
      
        # 検索結果をJSONで受け取る
        res = response.json()
        # 検索結果詳細
        with st.expander("検索結果"):
            # 検索結果の表示処理
            record_count = view_response(res)
    
        if record_count >  0:
            #GPT実行
            prompt = make_prompt(res)
            prompt = prompt.format(question=qestion_word)

            openai.api_key = "xxxxxx"#取得したキーと入れ替える

            response = openai.Completion.create(
                model="text-davinci-003",
                prompt=prompt,
                temperature=temperature,
                max_tokens=max_tokens,
                top_p=1,
                frequency_penalty=0,
                presence_penalty=0
                )        
            comp = response["choices"][0]["text"]
            token = response["usage"]["total_tokens"]
            cost = round(token * 0.02 /1000 * 130,2)  
            st.success(comp,icon="🤖")
            st.info("トークン数：{token}　コスト（130円/ドル）：{cost}円".format(token=token,cost=cost),icon="🪙")

            # GPT質問内容文
            with st.expander("GPT質問内容 for debug"):
                for m in prompt.split('\n'):
                    st.markdown(m)
                    
            # GPT結果詳細
            with st.expander("GPT結果詳細 for debug"):
                st.json(response)
            
 
if __name__ == '__main__':
    main()
~~~
あとは`streamlit run app.py`でstreamlitを起動し、`http://localhost:8501/`にアクセス。

# さいごに
社内のローカルネットにこのシステムがあると、機密情報や業務Q&Aのチャットボットとして使えそう。（私自身、社内独自の経費処理フローや設備スペックについてよく忘れるので。。）
改善余地としては、

+ ドキュメント検索用のキーワードを入れる必要があり、質問内容からドキュメントを検索するようにする。
+ 社内情報もイントラとしてWeb上に公開されているものもあるので、テキストだけでなくWebページも扱えるようにする。
+ GPT-3のプロンプトのMaxトークン数が4000のため、与えられる文字数に制限がある。そのためより多くの情報を与えるには、質問に関連する部分だけ抽出する処理をいれ、無駄をできるだけ省く必要がある。