Featured image of post LangChainで実用的なRAGアプリケーションを構築するための工夫

LangChainで実用的なRAGアプリケーションを構築するための工夫

昨年、LangChainを利用したRetrievalQA(検索ベースの質問応答システム)の試作を行いました。 その過程で、この技術の有効性を確かめるとともに、このような言語モデルの応用の可能性を実感しました。

最近、ChatGPTを含むLLM(Large Language Model)が注目を集めていますね。 これらのモデルは、人間に近い精度を実現し、自然言語によるコ
LangChainの可能性:LLMを特定の情報ソースと組み合わせて利用するアプリの模索
前回はPDFドキュメントをvector index化して自然言語で検索するアプリを検討してみました。 LangChainの可能性:LLMを特定の
LangChainの可能性2:RetrievalQAを使ったPDF文献について質疑応答するChat Promptの試作

大規模言語モデル(LLM)はその性質上、驚異的なテキスト生成能力を持ちながらも、「ハルシネーション」と呼ばれる、虚偽の情報を生み出すことがあります。 この点は、特に信頼性が求められる研究活動や学習活動においては大きな障害となり得ます。

またハルシネーションのような問題がなくとも、根拠となるソース情報を参照することは、あらゆる知的活動の基本と言えます。 情報を正確に取り扱う上でソースへのアクセスは必須の要素です。

このような背景から、RetrievalQAの手法はRAG(Retrieval-Augmented Generation)という形で引き続き注目を集めています。

RAGは、外部知識源から検索した正確で新しい事実を生成AIのプロンプトに追加することでハルシネーションを防ぐ方法で、LLMの再学習の必要性を減らします
Retrieval-Augmented Generation(RAG)とは? | IBM ソリューション ブログ

しかし、実用的なRAGを構築しようとすると、特に情報源への参照において、難しいポイントがいくつかありました。

この記事では、それらの課題と回避のためにローカルで実施してみたことを書き留めておきます。

RAGの構築

ここで利用するRAGアプリケーションはLangChainをベースとし、内容は以下公式サイトにあるものをそのまま使っているとして進めます。

Retrieval is a common technique chatbots use to augment their responses
Retrieval | 🦜️🔗 LangChain

Text Embeddingのコスト

OpenAIのText Embedding APIのコストは決して高額ではないですが、量や規模が増えてくると気になります。 そもそもインプットできるトークンのQuota制限もありますし、ローカルでできると後々便利です。

また読書に応用する場合、書籍PDFなどを使ってRAGを構築するとなると、Embeddingが学習を共なわないとしても、 何らかの形で全文をpublicにしかねない行為は避けておく方が無難でしょう。

そこでHuggingFaceのモデルを利用して、ローカルでEmbeddingをします。

Hugging Face sentence-transformers is a Python framework for state-of-the-art sentence, text and image embeddings.
Sentence Transformers on Hugging Face | 🦜️🔗 LangChain

LangChainにはHuggingFaceのSentence Transformerを利用するためのラッパーが提供されています。

1
2
3
4
from langchain_community.embeddings.huggingface import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(model_name="intfloat/multilingual-e5-large")
db = FAISS.from_documents(docs, embeddings)

こちらを利用することで、HuggingFace上で利用可能なモデルを指定し、ローカルで利用することができます。

ここで利用している multilingual-e5-large は日本語の性能が高いと評価されているモデルのひとつです。

多言語のテキスト埋め込み用のモデルであるMultilingual-E5-largeの性能を日本語のデータセットで評価してみました。 E5とは E5とはEmbEddings from bidirEctional Encoder rEpresentationsの略で、テキストの埋め込み用のモデルです[1]。Web上から収集した大規模なテキストペアのデータセット(CCPairs)で対照学習したあと、NLIやMS Marcoなどの高品質なデータセットで学習しています。情報検索のベンチマークであるBEIR[2]や埋め込みのベンチマークであるMTEB[3]で評価されており、MTEBではOpenAIのtex…
OpenAIの埋め込みよりも高性能?多言語E5を日本語で評価してみる - Ahogrammer

言語の壁

ソースとして利用したいWebサイトやPDFが日本語とは限りません。

この場合、考えられる対応は2つあります。

  • ベクターDBに入れる文章データ自体を翻訳する
  • ベクターDBに問い合わせるクエリを翻訳する

GPT4などのように、多言語での運用に支障がないモデルをチャット用LLMとして利用するのであれば、2つ目の方法がよいと考えます。

DB自体を翻訳してしまうとソースとして参照している文面がどこにあったのか探しにくくなりますし、改変されているのでソースとしてそのまま引用もできません。 また、うまく検索できていないときに最悪英語でクエリをすればいいので、可用性が高いだろうと思っています。

ベクターDBへのクエリを翻訳する

ベクターDBへのクエリを英語にするには、Quary Transformationを応用します。

To solve this, we can transform the query into a standalone query without any external references an LLM.
Retrieval#Query transformation | 🦜️🔗 LangChain

上記公式サイトの例では、これまでの質問の文脈を考慮してクエリを生成するために利用されています。 これを応用して英語でのクエリを生成させます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
query_transform_prompt = ChatPromptTemplate.from_messages(
    [
        MessagesPlaceholder(variable_name="messages"),
        (
            "user",
            "上記の質問に関連する情報を得るために検索クエリを英語で生成してください。そのクエリのみに反応し、それ以外には反応しないこと。",
        ),
    ]
)

query_transforming_retriever_chain = query_transform_prompt | chat | StrOutputParser() | retriever

このようにクエリの生成を「英語で」お願いします。

GraphQLに関するRAGを作成して、「GraphQLに引数を渡すには?」と質問をしてみます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
[llm/start] [1:chain:RunnableSequence > 2:chain:RunnableAssign<context> > 3:chain:RunnableParallel<context> > 4:chain:chat_retriever_chain > 6:chain:RunnableSequence > 8:llm:ChatOpenAI] Entering LLM run with input:
{
  "prompts": [
    "Human: GraphQLに引数を渡すには?\nHuman: 上記の質問に関連する情報を得るために検索クエリを英語で生成してください。そのクエリのみに反応し、それ以外には反応しないこと。"
  ]
}
[llm/end] [1:chain:RunnableSequence > 2:chain:RunnableAssign<context> > 3:chain:RunnableParallel<context> > 4:chain:chat_retriever_chain > 6:chain:RunnableSequence > 8:llm:ChatOpenAI] [889ms] Exiting LLM run with output:
{
  "generations": [
    [
      {
        "text": "\"How to pass arguments in GraphQL\"",
        "generation_info": {
          "finish_reason": "stop",
          "logprobs": null
        },
        ...
    ]
  ],
  ...
}

「How to pass arguments in GraphQL」というクエリが生成されていることがわかります。

今回は比較的直接的な翻訳のようになっていますが、生成を依頼するプロンプトを工夫することで柔軟にクエリを生成させる応用も考えられると思います。

ベクターDB内の情報の利用効率

Text Embeddingの入力トークンサイズには限度があり、OpenAIでは8191トークンです。

以下のようにRAGの例でよくあるパターンでは、このサイズに収まるようにする意味でも、適当なチャンクにテキストを分割してEmbeddingしています。。

1
2
3
4
5
6
7
8
from langchain_text_splitters import RecursiveCharacterTextSplitter
loader = RecursiveUrlLoader(
    url=url, max_depth=depth, extractor=lambda x: Soup(x, "html.parser").text
)
data = loader.load()

text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=0)
all_splits = text_splitter.split_documents(data)

しかしこの分け方は、必ずしも文章として意味のあるまとまりになるとは限らないため、検索結果がおかしなものになってしまうことがあります。

またWebから取得したドキュメントではHTMLタグなどが残っていたり、コンテンツとは関係のない情報が入っていたりするとノイズになってしまいます。

この対策のために、より意味のある文章のまとまりで分割することを検討します。 ここでは利用しやすいlWebから取得したHTMLドキュメントをスクレイピングして、ベクター化して違いを比較します。

ソースとして、 ayatakeshi さんが公開されているelispの日本語版マニュアルのサイトを利用します。

GNU Emacs Lisp Reference Manual

このサイトは1ページに全ての情報がまとまっており、内容がチャプター、セクション、サブセクションに綺麗に構造化されています。

ベクターDBはFAISS GPU、モデルは intfloat/multilingual-e5-large を利用しました。

TextSplitterを利用した場合

まず先の例にあるようにTextSplitterによるチャンクに「外部プロセスを利用するには?」というクエリを投げてみます。 retrieverの参照数は k=3 とします。

Emacs Lispプログラムは、それら自身のプロセス内で別のプログラムを呼び出すことができます。 これらは、親プロセスであるEmacsプロセスのサブプロセス、または子プロセスと呼ばれます。 非同期プロセスの作成については、セクション40.4で説明されています。 非同期プロセスは、作成後にEmacsと並列して実行され、Emacsはプロセスとコミュニケーションを取ることができます。

この回答のために参照したソースは以下の3つです。

オペレーティングシステムの用語ではプロセス(process)とはプログラムを実行できるスペースのことです。Emacsはプロセス内で実行されます。Emacs Lispプログラムは別のプログラムをそれら自身のプロセス内で呼び出すことができます。これらは親プロセス(parent process)であるEmacsプロセスのサブプロセス(subprocesses)、または子プロセス(child processes)と呼ばれます。

[ << ] [ < ] [ Up ] [ > ] [ >> ]

[Top] [Contents] [Index] [ ? ]

40.4 非同期プロセスの作成

このセクションでは非同期プロセス(asynchronous process)を作成する方法について説明します。非同期プロセスは作成後にEmacsと並列して実行され、Emacsは以降のセクション(プロセスへの入力の送信とプロセスからの出力の受信を参照)で説明する関数を使用してプロセスとコミュニケーションができます。プロセスコミュニケーションは部分的に非同期なだけであることに注意してください。Emacsはこれらの関数を呼び出したときだけプロセスとのデータを送受信できます。

[ << ] [ < ] [ Up ] [ > ] [ >> ]

[Top] [Contents] [Index] [ ? ]

40.9.4 プロセスの出力を受け取る

非同期サブプロセスからの出力は、通常はEmacsが時間の経過や端末入力のような、ある種の外部イベントを待機する間だけ到着します。特定のポイントで出力の到着を明示的に許可したり、あるいはプロセスからの出力が到着するまで待機することでさえ、Lispプログラムでは有用な場合が時折あります。

Function: accept-process-output &optional process seconds millisec just-this-one ¶ この関数はプロセスからの保留中の出力をEmacsが読み取ることを許す。この出力はプロセスのフィルター関数により与えられる。この関数はprocessが非nilならprocessから何らかの出力を受け取るかprocessが接続をcloseするまでリターンしない。

元のページにあるページ操作用のリンクテキストが残っているため、チャンク内の文字数をだいぶ食ってしまっているのがわかります。

また、検索結果としてはいいところを突いているのですが、肝心のプロセス作成の解説などが切れてしまっており、elispとしてどう操作するかまでの情報があまり含まれていないように見えます。

マニュアルでスクレイピングした場合

次にBeautifulSoupを使ってマニュアルでスクレイピングしたソースを使ってみます。

スクレイピングの方針は、チャプター、セクション、サブセクションそれぞれの直下に含まれるテキストコンテンツを単位として抽出することとします。 加えてページ操作のリンクなど不要なものは除いていきます。

参考までにスクレイピング用のコードは以下のような形で実施しました。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
url = "https://ayatakesi.github.io/lispref/29.3/elisp-ja.html"
res = requests.get(url)

soup = BeautifulSoup(res.text, "html.parser")

def extract_content(elems):
    result = []
    for elem in elems:
        elem.find("table").decompose()
        buf = []
        for content in elem.children:
            text = content.get_text(strip=True)
            if not text:
                continue
            buf.append(text)

        pagecontent = "\n".join(buf)
        metadata = {"source": str(url)}
        result.append(Document(page_content=pagecontent, meta_data=metadata))

        elem.decompose() # delete for predecessor
    return result

docs = []
# subsection
subsections = soup.select("div.subsection")
docs = extract_content(subsections)

# section
sections = soup.select("div.section")
docs.extend(extract_content(sections))

# chapter
chapters = soup.select("div.chapter")
docs.extend(extract_content(chapters))

print(len(docs))

この内容をFAISSでインデックス化し、同じく「外部プロセスを利用するには?」というクエリを問い合わせてみます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
外部プロセスを使用するには、Emacs Lispで提供されているいくつかの関数を使用できます。主に以下の関数があります:

1. **make-process**:非同期サブプロセスを開始するための基本的な低レベルなプリミティブです。キーワード引数を受け取り、プロセスフィルターやセンチネルを指定できます。

   例:
   ```emacs-lisp
   (make-process :name "my-process"
                 :buffer "my-process-buffer"
                 :command '("my-command" "arg1" "arg2"))
   ```

2. **start-process**:非同期サブプロセスを作成して、指定されたプログラムの実行をその内部で開始します。これは`make-process`の周辺の高レベルのラッパーです。

   例:
   ```emacs-lisp
   (start-process "my-process" "my-process-buffer" "my-command" "arg1" "arg2")
   ```

3. **start-process-shell-command**:`start-process`と同様ですが、指定されたコマンドの実行にシェルを使用します。これにより、シェルコマンド文字列を直接実行できます。

   例:
   ```emacs-lisp
   (start-process-shell-command "my-process" "my-process-buffer" "my-command arg1 arg2")
   ```

4. **start-file-process**:`start-process`と似ていますが、`default-directory`の値に基づいてファイル名ハンドラーを呼び出す可能性があります。これにより、リモートホスト上でプログラムを実行することができます。

   例:
   ```emacs-lisp
   (start-file-process "my-process" "my-process-buffer" "my-command" "arg1" "arg2")
   ```

これらの関数を使用することで、Emacsから外部プロセスを起動し、そのプロセスとの間でデータの送受信を行うことができます。

先程のTextSplitterの結果と比べると、大分詳細な内容が回答されていることがわかります。

参照しているソースの情報は以下のとおりです。

40.4 非同期プロセスの作成 このセクションでは非同期プロセス(asynchronous process)を作成する方法について説明します。非同期プロセスは作成後にEmacsと並列して実行され、Emacsは以降のセクション(プロセスへの入力の送信とプロセスからの出力の受信を参照)で説明する関数を使用してプロセスとコミュニケーションができます。 …

40 プロセス オペレーティングシステムの用語ではプロセス(process)とはプログラムを実行できるスペースのことです。Emacsはプロセス内で実行されます。Emacs Lispプログラムは別のプログラムをそれら自身のプロセス内で呼び出すことができます。これらは親プロセス(parent process)であるEmacsプロセスのサブプロセス(subprocesses)、または子プロセス(child processes)と呼ばれます。 …

2.5.8 プロセス型 プロセス(process)という単語は、通常は実行中のプログラムを意味します。Emacs自身はこの種のプロセス内で実行されます。しかしEmacs Lispでは、プロセスとはEmacsプロセスにより作成されたサブプロセスを表すLispオブジェクトです。 …

まず、インプットとしてセクションやチャプター、サブセクション単位の情報がまとまって渡っていることがわかります。

一方検索で引かれているチャプターやセクションは、同じようなところになっているようなことが伺えます。

しかし、Chat用のLLMに渡る連なる情報が欠けていたTextSplitterと比較して、引かれたチャンクに含まれる情報がしっかり入力されていることもあって、より精度の高い回答が生成されたようです。

まとめ

この記事では、RAGを実際に利用するにあたって困った点と、それを回避するために実施してみた方法をまとめました。

特にベクターDBへ入れておく情報を整理しておくことは、RAGとしてより有意義な回答を得るために非常に有効だと感じました。

ただ、それなりに手間はかかりますし、SPAのようなサイトだと難しい可能性もあります。 またPDFでは、構造化され、parseの選択肢も多いHTMLに比べるとハードルが高いという面もあります。 しかし、多くの場合一度インデックスを作成してしまえば頻繁に再構築する必要もないでしょうし、HTMLの構造も大きく変わらないと思うので、やる価値はあると思いました。

更に料金面でも、トークンのやりとりが増加するため、価格が膨らみやすいことにも注意が必要です。 上記の例では k=3 で3セクション相当のデータをクエリと共にOpenAI APIに送信していますが、6-7000トークンは送っていると思います。 場合によっては k=2 以下に設定し、トークンを押さえても回答精度が出せるケースもあると思うので、調整する必要があると思います。

しかし、RAG自体は自分のインプット効率を高めるのに非常に有用なツールだと思いますので、しっかりと調整することでコスト以上のリターンを得られるようにしていけるのではないかと思います。

Built with Hugo
テーマ StackJimmy によって設計されています。