OpenAI API＋Langchain＋FaissによるRAG環境構築

## はじめに

n番煎じですが，OpenAI API＋Langchain＋Faissを使用するRAG環境の構築をします．RAG用のテキストファイルのサンプルは用意していないため，適当にテキストファイルを作成して使用してください．

## ディレクトリ構成
ややこしいですが，ディレクトリ構成は以下のようになります．
```
llm_test
    |- code
    |   |- main.py # mainの関数
    |
    |- workspace
    |   |- make_index.py # RAG用のindexを作成
    |   |- convert_prompt_json.py # LLMへ入れるプロンプトのテンプレートを作成
    |
    |- database 
    |   |- source # RAG用のindexの元となるテキストファイル(自前で用意してください)
    |   |   |- *.txt
    |   |   |- *.txt
    |   |   |- *.txt
    |   |
    |   |- index # RAG用のindexを保管 (make_index.pyで作成)
    |   |   |- index.faiss
    |   |   |- index.pkl
    |   |
    |   |- question_prompt_templete.txt # プロンプト文のテンプレートテキスト(自前で用意してください)
    |   |- question_prompt_templete.json # テンプレートテキストをlangchain用に変換したjson(convert_prompt_json.pyで作成)
    |
    |-Dockerfile
    |-requirements.txt
    
.env # 環境変数を管理
docker-compose.yaml
```

以下でそれぞれの中身を説明します．

### Dockerfile

Dockerファイルの中身は以下の通りです．中身はシンプルで，単にPython環境にpipで必用なモジュールをインストールしているだけです．今回はprod(本番環境用)しか作成していませんが，必用があればbaseを継承してdev環境を作成することもできます．

``` Dockerfile: Dockerfile
FROM python:3.11 AS base

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

RUN \
  --mount=type=cache,target=/var/lib/apt/lists \
  --mount=type=cache,target=/var/cache/apt/archives \
  apt-get update \
  && apt-get install -y --no-install-recommends build-essential

WORKDIR /code
COPY ./requirements.txt /code/requirements.txt
RUN pip3 install --no-cache-dir -U pip && \
pip3 install --no-cache-dir -r /code/requirements.txt

FROM base AS prod
WORKDIR /code
COPY /code .
COPY workspace/ /workspace
```

### requirements.txt

雑なのでバージョンは指定していません．今回必用なモジュールだけ入っています（langchain-communityはいらないかも）．

``` requirements.txt
langchain
langchain-core
langchain-community
langchain-openai
faiss-cpu
unstructured
openai
```

### .env

OpenAIから取得したAPIキーを書くだけです．

```.env
OPENAI_API_KEY =  "{ここにAPI KEYを書く}"
```

### docker-compose.yaml

コンテナ1つだけだからdocker-composeを使う必要があるかは微妙ですが，実行するのが楽なので使います．databaseを分離する必要があるときには使えると思います．今回はbindマウントしてしまいます．

```docker-compose.yaml
services:
  llm_test:
    image: llm_test
    container_name: llm_test
    env_file: ./.env
    build:
      context: ./llm_test
      dockerfile: Dockerfile
      target: prod
    volumes:
      - type: bind
        source: ./llm_test/database
        target: /database
```

### workspaceの中身
#### convert_prompt_json.py

question_prompt_templete.txtに書かれたLLMへ入力する用の文をlangchainですぐに読み込めるようにjsonに変換しておくプログラムです．

question_prompt_templete.txtには以下のような文章が入っている想定です．
```question_prompt_templete.txt
あなたは質問を受け付け、専門知識を基に回答するアドバイザーです。
ユーザからの質問と、その回答で利用できるドキュメントの情報があります。
[回答生成の手順]の通りに、ユーザの質問に対する回答を生成してください。

<回答生成の手順>
- [制約] [ドキュメントの内容]をすべて理解してください。
 - [制約]は、回答を生成する際に守らなければならないことです。必ず従ってください。
 - [ドキュメントの内容]には、参考にするべきドキュメントが含まれています。ドキュメントはデータベースに保存されており、質問の内容をもとに必用な情報が与えられます。
- このあとユーザからの質問が続きますので、ユーザの質問を理解してください。
 - ユーザからの質問が、ユーザの最も知りたいことなので、最重視してください。
- [ドキュメントの内容]の中から、ユーザの質問に関連する情報を見つけ、その情報をもとに回答を行ってください。
 - その際、[制約]に必ず従ってください。
</回答生成の手順>

<制約>
- 質問に関係のない[ドキュメントの内容]は無視し、質問に関係のあるドキュメントにのみ基づいて回答してください。
- 回答は平易な日本語で答えてください。
- 回答はユーザの読みやすさを考慮して必要に応じて箇条書きや構造的な文章にしてください。
</制約>

<ドキュメントの内容>
{context}
</ドキュメントの内容>

<質問>
{question}
</質問> 

回答:
```

上記の文章をlangchain用のテンプレートへ変換するプログラムは以下の通りです．
```convert_prompt_json.py
from langchain_core.prompts import PromptTemplate

def main(prompt_path, output_path):
    with open(prompt_path, "r") as f:
        prompt = f.read()
        
        prompt_template = PromptTemplate(
            templete=prompt,
            input_variables=['context', 'question']
        )
        prompt_templete.save(output_path)


if __name__ == '__main__':
    prompt_path = "/database/question_prompt_templete.txt"
    output_path = "/database/question_prompt_templete.json"
    main(prompt_path, output_path)
```

#### make_index.py

今回は日本語の文章を対象にシンプルに"。"でチャンキングするようにします．Faissで
OpenAIの埋め込みモデルを使用してベクトル化後，indexにします．

```make_index.py
import os

from langchain_community.document_loaders import DirectoryLoader
from langchain.text_splitter import CharacterTextSplitter
from langchain_community.vectorstores.faiss import FAISS
from langchain_openai import OpenAIEmbeddings

def make_text_document(dir):
    loader = DirectoryLoader(dir)
    document_list_simple = loader.load_and_split(
        text_splitter=CharacterTextSplitter.from_tiktoken_encoder(
            chunk_size=256,
            chunk_overlap=50,
            separator="。",
        )
    )
    return document_list

def main():
    text_dir = '/database/source'
    output_dir = '/database/index'
    
    OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY")
    
    document_list = make_text_document(text_dir)
    
    embeddings = OpenAIEmbeddings(model='text-embedding-3-small', api_key=OPENAI_API_KEY)
    
    os.makedirs(output_dir, exist_ok=True)
    index = FAISS.from_documents(
        documents=document_list,
        embedding=embeddings,
    )
    
    index.save_local(
        folder_path=output_dir,
        index_name='index'
    )
```

### codeディレクトリの中身

main関数が入っています．質問を受け取って，langchainのRetrievalQAに突っ込むだけでRAGが実装できます．

```main.py
import argparse
import os

from langchain_community.vectorstores.faiss import FAISS
from langchain_openai import OpenAIEmbeddings
from langchain_openai import ChatOpenAI
from langchain.prompts import load_prompt
from langchain.chains import RetrievalQA

def get_args():
    
    parser = argparse.ArgumentParser()
    parser.add_argument('--index_dir', type=str, default='/database/index')
    parser.add_argument('--index_name', type=str, default='index')
    parser.add_argument('--retrieve_search_num', type=int, default=6)
    parser.add_argument('--prompt_templete_path', type=str, default="/database/question_prompt_templete.json")
    parser.add_argument('-q', '--question', type=str)
    
    # OpenAI
    parser.add_argument('--openai_model', type=str, default='gpt-4o-mini')
    parser.add_argument('--openai_embeddings', type=str, default='text-embedding-3-small')
    parser.add_argument('--openai_key', type=str)
    
    return parser.parse_args()

def main():
    
    args = get_args()
    
    if not args.openai_key:
        try:
            args.openai_key = os.environ['OPENAI_API_KEY']
        except KeyError:
            raise ValueError("Please provide the OpenAI API key. You can provide it as an argument or set it as .env file.")
    
    embeddings = OpenAIEmbeddings(model=args.openai_embeddings, api_key=args.openai_key)
    llm = ChatOpenAI(model=args.openai_model, api_key=args.openai_key)
    
    index = FAISS.load_local(
        folder_path=args.index_dir,
        index_name=args.index_name,
        embeddings=embeddings,
        allow_dangerous_deserialization=True
    )
    
    question_prompt = load_prompt(os.path.join(args.prompt_templete_path))

    chain = RetrievalQA.from_chain_type(
        llm=llm,
        retriever=index.as_retriever( 
            search_kwargs={'k': args.retrieve_search_num} # indexから上位いくつの検索結果を取得するか
        ), 
        chain_type_kwargs={"prompt": question_prompt}, # プロンプトをセット
        chain_type="stuff", # 検索した文章の処理方法
        return_source_documents=True # indexの検索結果を確認する場合はTrue
    )
    
    if args.question:
        question = args.question
        
        response = chain.invoke(question)
        
        for i, source in enumerate(response["source_documents"]):
            print(f"\nindex: {i+1}----------------------------------------------------")
            print(f"{source.page_content}")
            print("---------------------------------------------------------------")

        response_result = response["result"]
        print(f"\nAnswer: {response_result}")
```

## 動かし方

1 Dockerファイルをビルド
```
docker-compose build

# うまくいかないときは
docker-compose build --no_cache
```
2 コンテナを起動
```
docker-compose run -it llm_test bash
```
3 コンテナ内で以下を実行
```
python3 main.py -q "質問したい内容"
```

## 終わりに

LLM+RAGの環境を雑に作ってみました．ライブラリが整理されてるおかげでめっちゃくちゃ簡単に作れるのでぜひお試しください．