関連記事、元にするソース: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'refine: {end}')
print('--map_rerank--')
# load_qa_chainを準備
chain = load_qa_chain(ChatOpenAI(temperature=0), chain_type="map_rerank")
# 質問応答の実行
s = time.time()
print(chain({"input_documents": docs, "question": query}, return_only_outputs=True))
end = time.time() - s
print(f'map_rerank: {end}')
基本的には、load_qa_chain に与える chain_typeを変更するだけです。しかし、 refine に関してだけは、ドキュメントと異なり existing_answer をパラメーターとしてセットする必要がありました(インタープリタのエラーから対応)。
refine の動作原理から考えて、「前段階までで出した回答」のことで間違いないと思いますので、初回呼び出しである場合は空欄で問題ないと思います。あるいは、仮説として既存の回答を出したり、別のChainの出した解を入れて再検討のプロセスを回すことも考えられます。
上記はバグだったようで、 Langchain 0.0.116 にて修正されました。
実行結果
--map_reduce--
{'output_text': '文中には明確な最大の経営課題についての言及はありません。'}
map_reduce: 34.84847950935364
--refine--
{'output_text': 'Based on the context provided, the biggest management challenge for A company would be to adapt to changing market trends and consumer preferences. The company has already faced a decline in sales for their high-end strawberries and had to shift their focus to vegetable cultivation. As the market continues to evolve, the company will need to stay ahead of the curve and be willing to make changes to their products and strategies to remain competitive. Additionally, managing the increasing workload and hiring and training new employees will also be a challenge for the company.'}
refine: 24.453298568725586
--map_rerank--
{'output_text': 'The biggest management challenge was the complexity of the business, including unclear role assignments among employees and issues with supply and demand. Additionally, there was pressure from major clients to maintain stable quality and shipping. '}
map_rerank: 14.920390129089355
何故か、 map_reduce のみが日本語での回答となり、他の回答は英語となりました。
さて、回答内容について検討してみると、map_reduce の「明確な最大の経営課題についての言及はない」、というのは非常に素直な回答と言えます。textsplitをせずに stuffing しても大体同様の回答が得られます。
map_rerank については、文中の言葉でそれっぽいところを抜き書きしてきたという感じで、アルゴリズム通りの動作といえます。
さて、refine についてですが、こちらは興味深いのでDeepL翻訳の結果も乗せて起きます。
正解かどうかはともかく、文章を読み進めながら検討を繰り返していることが分かります。stuffingよりも、途中で内容を検討することで、「読みました」だけでは得られない回答が出ています。
とはいえ、「最大の」という最初の prompt に忠実かと言われるとそうではないと言えるでしょう。と言うのも、「さらに、」と余計なことを付け加えてしまっているためです。そこがなければ、論点がかなり絞れていて優秀だなぁと思うのですが……。
後、気になる点と言えば、 map_reduce の実行速度が最も遅い点ですね。rerank が一番早いのは納得出来るのですが、 reduce が遅いのは、統合するための API 呼び出しが1回で終わらないのか、単にAPIが重いだけだったのか……。
まとめ
chain_type
- stuffは大きなデータでは使えないが早い。とりあえずテストとかで使える
- map_reduceはchunk数がそんなに多くないときはrefineよりも遅い?場合がある。情報も欠損しがちだが、promptには忠実な傾向にある
- refine はchunk数がそんなに多くなければ他と速度的に大きな違いが出ない。深く検討した結果が出てくるが、その分 prompt の指示にあわない結果になる傾向がある
- map_rerank は一番それっぽい答えを出すので、prompt には忠実だが、実は深く検討されていない解。ある意味、ChatGPTが平然と嘘をつくという結果に近い。
CharacterTextSplitter
- separator をちゃんとセットしないと、期待通りに動いてくれない
- overlapは日本語だとほぼ機能しない?
- chunk_sizeはデータやchain_typeに応じて決める