LangchainでChunk分割とChainTypeをチャンとやって精度と安定性を高める 基本

FacebooktwitterredditpinterestlinkedinmailFacebooktwitterredditpinterestlinkedinmail

関連記事、元にするソース:https://ict-worker.com/ai/gpt35-with-smeca.html

この記事の目的

API の上限回避

GPT-3.5-turbo や GPT-4 の OpenAI の APIをはじめ、各種 LLM の API には 1回の API 呼び出しで処理できる token の上限が存在しています。それを越えると、例外として、

openai.error.InvalidRequestError: This model's maximum context length is 4097 tokens.

こんなようなエラーが返ってきます。

回避するには、ある程度の大きさの chunk (かたまり) に分割して API に投げる必要があります。適切な大きさの chunk を設定してやれば、基本的にはこのエラーは回避できるはずです。

要約とQ&Aの精度を向上させる Langchain の chain_type

chunk 単位に分割して処理することで、API の上限回避が可能になりました。しかし、ただ機械的に分割したテキストを投げるだけでは、個別の回答が返ってくるだけになってしまいます。

そこで、Langchain では chain_type 引数に、どのように API に対して(分割された)chunk を処理させるか、4つの方法を与えることができます。

これらの方法により、APIの呼び出し回数を抑えたり、より望むものに近い答えを出す方法を選択できます。

Langchain で選べる形式

参考:https://note.com/mega_gorilla/n/n6f46fc1985ca

※公式ドキュメントからは発見できず。DL 界隈では当たり前すぎる知識なんでしょうね。

chain_type=stuff, 詰め込み方式

サンプルでよく用いられている方式。promptの内容を全て一度に API に詰め込むため、大きなデータは使えない。

Q&A ではテスト用にくらいしか使えなさそうです(embeddingを用いる場合)。要約の場合には、blog記事くらいであれば収まりそうな気がします……、がその程度ですね。

先に挙げたエラーがすぐに返ってくるようになると思います。

chain_type=map_reduce, それぞれのchunkに実行する

chunkごとにpromptの要求を実行して、最終的にそれを結合する。要求は、QAであれば質問、要約であれば要約。大きいデータを扱うのに適していますが、chunkの数が多ければ多いだけ、APIの呼び出し回数が増えます。

APIの呼び出しが独立しているため、並列処理が可能です。ただし、情報の結合時に情報が失われ(reduce?)ます。

chain_type=refine, 純度を高めていく

最初のchunk に promptが実行され、その出力と次のかたまりを用いて以前の結果を洗練させていく(refine)方式。

前のchunk の API 呼び出しの完了を待たないといけないため、chunk の数が多ければその分、回答が得られるまでの時間が長くなってしまう。ただ、stuffを除くその他の方式よりは全てのドキュメントの内容を反映させたものになりやすい、とされる。

chain_type=map_rerank

各chunkに対してpromptを実行し、かつ、どれだけ確からしいかスコア付けをさせる。

そのスコアを元に、もっとも確からしい回答を返答します。

map?

Python のように、それぞれに独立して API 呼び出しを行う(関数呼び出しを行う)から、mapとついているんだと思います。

もっとも簡単なドキュメントのchunk分割

from langchain.text_splitter import CharacterTextSplitter
text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0, separator="\n")

もっとも簡単な分割方法は、前回Q&A記事でも登場したCharacterTextSplitter クラスを使う方法です。

このクラスはその名の通り、とある「文字(Character)」を用いて chunk_size に収まるようにテキストを分割します。ただ、分割するための文字が充分に出現しないと、chunksizeをいくら小さくしてもテキストは分割されないようで、その点には注意が必要です。

デフォルトでは’\n\n’と、改行2個が割り当てられています。これだと、自然な日本語だと少し都合が悪い場合もあると思います。変更するには、

text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=0, separator="\n")

このように、separator引数に値を渡すと変更されます(この場合は改行1つで分割される)。

chunk_overlap を試してみる

chunkで分割された API 呼び出し方式の中には、前後の文脈が失われるものもありました。そこで、AIが完全に文脈を見失わないように、chunkにまたがる文章を overlap する文字数を指定するのが chunk_overlap だそうです。

果たして、どの程度効果があるのかは不明ですが、ひとまず、設定を色々いじってみましたが、 CharacterTextSplitterでは chunk_size や chunk_overlapの数値を大小させてみても変化は見られませんでした。

text_splitter = CharacterTextSplitter(chunk_size=400, chunk_overlap=10, separator="\n")
docs = text_splitter.split_documents(documents)

from pprint import pprint
pprint(docs)

出力:

Created a chunk of size 526, which is longer than the specified 400
[Document(page_content='A 社は、サツマイモ、レタス、トマト、 苺、トウモロコシなどを栽培・販売する農業法人(株式会社)である。資本金は 1,000 万円(現経営者とその弟が折半出資)、従業員数は 40 名(パート従業員 10 名を含む)である。A 社 の所在地は、水稲農家や転作農家が多い地域である。', lookup_str='', metadata={'source': 'reiwa4-1.txt'}, lookup_index=0),
 Document(page_content='A 社は、戦前より代々、家族経営で水稲農家を営んできた。69 歳になる現経営者は、幼い頃から農作業に触れてきた体験を通じて農業の面白さを自覚し、父親からは農業のイロハを叩き込まれた。当初、現経営者は水稲農業を引き継いだが、普通の農家と違うことがしたいと決心し、先代経営者から資金面のサポートを受け、1970 年代初頭に施設園芸用ハウスを建設して苺の栽培と販売を始める。同社の苺は、糖度が高いことに加え、大粒で形状や色合いが良く人気を博した。県外からの需要に対応するため、1970 年代後半にはハウス 1 棟、1980 年代初頭にはハウス 2 棟を増設した。\nその頃から贈答用果物として地元の百貨店を中心に販売され始めた。1980 年代後半にかけて、順調に売上高を拡大することができた。', lookup_str='', metadata={'source': 'reiwa4-1.txt'}, lookup_index=0),
 Document(page_content='他方、バブル経済崩壊後、贈答用の高級苺の売上高は陰りを見せ始める。現経営者は、次の一手として 1990 年代後半に作り方にこだわった野菜の栽培を始めた。当時限られた人員であったが、現経営者を含め農業経験が豊富な従業 員が互いにうまく連携し、サツマイモを皮切りに、レタス、トマト、トウモロコシなど栽培する品種を徐々に広げていった。この頃から業務量の増加に伴い、パート従業員を雇用するようになった。', lookup_str='', metadata={'source': 'reiwa4-1.txt'}, lookup_index=0),
 Document(page_content='A 社は、バブル経済崩壊後の収益の減少を乗り越え、順調に事業を展開していたが、1990 年代後半以降、価格競争の影響を受けるようになった。その頃、首都圏の大手流通業に勤めていた現経営者の弟が入社した。現経営者が生産を担い、弟は常務取締役として販売やその他の経営管理を担い、二人三脚で経営を行うようになる。現経営者と常務は、新しい収益の柱を模索する。そこで、打ち出したのが、「人にやさしく、環境にやさしい農業」というコンセプトであった。常務は、販売先 の開拓に苦労したが、有機野菜の販売業者を見つけることができた。A 社は、この販売業者のアドバイスを受けながら、最終消費者が求める野菜作りを行い、2000 年代前半に有機 JAS と JGAP(農業生産工程管理)の認証を受けた。', lookup_str='', metadata={'source': 'reiwa4-1.txt'}, lookup_index=0),

なお、chunk_size  を小さくすることで、分割される行数は多くなっています(それでも、500以上のchunkができていると警告がでていますね)。

一つの段落が長くなりがちな日本の文章では、句読点での分割なども考慮にいれていいのかもしれません。また、overlapについては別のSpliteメソッドで活用した方が有益かもしれません。

実際に出力結果を比較してみる

※importやDBからの読み込みなどは前回の記事を参照

各chain_typeで実行してみる

chain_type=”stuff’については、token最大値を上回ってしまったため(分割が細かすぎた?)、掲載していません。

また、各API の実行時間も計測しています。ただ、ネットワークの状態やOpenAI APIの混雑度などにもよるため、あまり参考にはならないかもしれません。

import time # 各chain_type の実行時間比較

print('--map_reduce--')
# load_qa_chainを準備
chain = load_qa_chain(ChatOpenAI(temperature=0), chain_type="map_reduce")
# 質問応答の実行
s = time.time()
print(chain({"input_documents": docs, "question": query}, return_only_outputs=True))
end = time.time() - s
print(f'map_reduce: {end}')

print('--refine--')
# load_qa_chainを準備
chain = load_qa_chain(ChatOpenAI(temperature=0), chain_type="refine")
# 質問応答の実行
s = time.time()
print(chain({"input_documents": docs, "question": query, "existing_answer": ""}, return_only_outputs=True))
# サンプルだとexisting_answer は指定しなくても動作していたが、実際にはインタープリタには怒られたので空文字列
# langchain 0.0.116 では修正されているので、"existing_answer": "" は不要
end = time.time() - s
print(f'refi