Q&Aチャットボット高品質化への道〜テキストの埋め込みベクトル変換における適切なチャンクデータ長を探る
そういえば先日のLangChainもくもく会でこんな質問があったのを思い出しました。
以前に紹介していた記事ではチャンク化をUnstructuredライブラリに任せていたので「このぐらいが良いよ」とハッキリとは言えなかったのですが、今日はこの問題について検証を交えながら考えてみたいと思います。
埋め込みベクトル化するデータ長の限界値
そもそもで埋め込みベクトル化できるデータ長の限界値はどの程度なのでしょうか。OpenAIのドキュメントによると、OpenAIのtext-embedding-ada-002を利用して埋め込みベクトルを求める際の最大入力トークンは8,191トークンと書かれています。
トークン単位は日本語の文字数と一致しないので、イメージがつきやすいように以下のコードで日本語の文字数とトークン数の概算をとってみました。
import tiktoken
def count_cl100kbase_token(example_string: str) -> None:
encoding_name = "cl100k_base"
encoding = tiktoken.get_encoding(encoding_name)
token_integers = encoding.encode(example_string)
print(f"char_length: {len(example_string)} characters")
print(f"{encoding_name}: {len(token_integers)} tokens")
hiragana = 'あいうえお' * 2000
count_cl100kbase_token(hiragana)
kanji = "忍" * 4000
count_cl100kbase_token(kanji)
ひらがなと漢字とでだいぶ開きがありますが、一般的な日本語文章だとひらがなと漢字がまぜこぜになるということで、5,000〜6,000文字ぐらいがtext-embedding-ada-002を利用して一度に埋め込みベクトルを作成できる限界値になるのではと推測します。
ただ、実際の用途ではこの文字列をプロンプト内に注入する訳なので、最大文字数は生成モデルの最大入力トークン長に左右されます。例えばGPT-3.5の最大入力トークン長は4,096トークンなので、5,000文字の埋め込みベクトルを作成しても実際に利用することはできません。プロンプトのトークン長との兼ね合いでコンテキストとして使う情報の文字数を決定する必要があります。あまりにキツキツに設計してしまうと、クエリに利用できる文字数が少なくなってしまうので、ユーザー体験が損なわれる可能性があります。
コンテキスト設計の検討
所定の文字列を埋め込みベクトルと共にベクトルDBに格納する目的は、ユーザーからのクエリに対して適切な補足情報をベクトルDBから抽出し、その情報をプロンプトに埋め込むことで、ユーザーからのクエリに答えるための正確なコンテキストを生成モデルに渡すことにあります。
というわけで、その埋め込まれる情報そのものの質も、生成モデルの応答の質に関係すると考えられます。
例えば、ただ機械的に文字列をチャンクに区切ったものをベクトルDBに格納したとしたらどうなるでしょうか。埋め込みベクトルとして近い意味を持つ文字列群を引っ張ってきたときに、たまたま区切られている箇所の文章の意味は確かに近いけれども、その文の前半部は全く違うことを話している、という可能性がありそうです。
分かりやすいイメージだと、マスコミの切り取り報道みたいな感じですね。
そう考えると、ベクトルDBに格納するデータは一つ一つが完結している、もしくは少なくともタイトルやサブタイトルがついており文脈が分かるようなものになっている方が望ましいと考えられます。
簡単な比較検証
ここまでの検討を踏まえて、精緻な軸ではありませんが以下の観点で簡易的に比較検証を試みてみました。
検証にはLangChainライブラリのドキュメントを利用しました。LangChainライブラリのドキュメントとして有用な部分のほとんどはipynb形式のファイルとなっているため、取込対象はipynb形式のファイルに絞りました。
また、ベクトルDBにはローカルで動作するChromaDBを用いています。
(補足)チャンクへのタイトル&サブタイトルの付加方法
AやCについてはシンプルに埋め込みベクトル化するだけですが、Bについては以下のようにデータを作成しました。
例えば上記のドキュメントをUnstructuredライブラリでチャンク化すると以下のようなデータが得られます。
import tempfile
import json
from unstructured.partition.md import partition_md
def partition_ipynb(file_path):
with open(file_path, "r") as f:
data = json.load(f)
elements = []
for cell in data["cells"]:
if cell["cell_type"] == "code":
elements.append("```python\n" + "".join(cell["source"]) + "\n```\n")
elif cell["cell_type"] == "markdown":
elements.append("".join(cell["source"]) + "\n")
text = "\n".join(elements)
try:
with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=True) as f:
f.write(text)
f.flush()
partition_result = partition_md(filename=f.name)
return partition_result
except Exception as e:
print(f"{file_path}: {e}")
return []
docs = partition_ipynb("../tmp/data/langchain/summarize.ipynb")
print(docs[:5])
print([str(doc) for doc in docs[:5]])
unstructured.documents.elements.XXXXのようなクラスのインスタンスがリストで作成されるので、以下のような関数でタイトル、サブタイトル、本文の組み合わせのチャンクになるように文字列を加工します。
def partition_to_chunk(elements):
if len(elements) == 0:
return []
for i, element in enumerate(elements):
if isinstance(element, Title):
title = str(element)
title_index = i
break
elements.pop(title_index)
result = []
sub_title = ""
for i, element in enumerate(elements):
if isinstance(element, Title):
sub_title = str(element)
continue
chunk_text = "\n".join([title, sub_title, str(element)])
result.append(chunk_text)
return result
els = partition_to_chunk(docs)
print(els[:5])
共通の仕様
プロンプトには以下のものを利用しました。contextにベクトルDBから抽出した値が設定されるようになっています。生成モデルはGPT-3.5です。
from langchain.chat_models import ChatOpenAI
from langchain import PromptTemplate, LLMChain
template = """
命令文:
以下の情報を活用して、与えられた質問に対し、プログラマーの役に立つように答えてください。情報そのものをそのまま提供するのではなく、プログラマーにとって理解がしやすいようにアレンジして答えて下さい。
情報:
{context}
質問:
{question}
私の答え:
"""
prompt = PromptTemplate(template=template, input_variables=["context", "question"])
llm = ChatOpenAI(temperature=0)
chain = LLMChain(llm=llm, prompt=prompt)
クエリの実行は以下の関数で行います。
def run_qa(question):
result = collection.query(query_texts=[question], n_results=20)
inputs = [{"context": doc, "question": question} for doc in result["documents"]]
return chain.apply(inputs)[0]['text']
run_qa関数内のn_resultsはベクトルDBから抽出するチャンク数を意味しており、Cのパターンだけ抽出文字数が大きくなるため値を変更しています。
というわけでそれぞれのパターンで共通の問いを投げかけてみましたので、見ていきましょう。
A)Unstructuredライブラリ任せで機械的にチャンク化
run_qa("LangChainで使えるChainの種類について教えて下さい")
run_qa("LangChainで扱えるAgentを列挙して下さい")
run_qa("文章の要約を行うときに利用するmap_reduceオプションについて、具体的に何をしているのかを教えて下さい")
run_qa("LangChainで会話の長期記憶を実装するためにどんな方法が用意されていますか?")
B)Unstructuredでチャンク化したものにタイトルとサブタイトルを付加
run_qa("LangChainで使えるChainの種類について教えて下さい")
run_qa("LangChainで扱えるAgentを列挙して下さい")
run_qa("文章の要約を行うときに利用するmap_reduceオプションについて、具体的に何をしているのかを教えて下さい")
run_qa("LangChainで会話の長期記憶を実装するためにどんな方法が用意されていますか?")
C)ドキュメントまるごと埋め込みベクトル化
このパターンのみ、n_resultを1にしています(複数抽出するとプロンプトのトークン長制限を超えてしまうため)。
run_qa("LangChainで使えるChainの種類について教えて下さい")
run_qa("LangChainで扱えるAgentを列挙して下さい")
run_qa("文章の要約を行うときに利用するmap_reduceオプションについて、具体的に何をしているのかを教えて下さい")
run_qa("LangChainで会話の長期記憶を実装するためにどんな方法が用意されていますか?")
検証から分かったこと
機械的に文字列を区切ったとしても何となくそれっぽい内容を抽出できているのか、Aパターンでもそれなりの応答を返せている印象です。しかし、タイトル&サブタイトルだけだとしても常にチャンクに文脈を持たせているBパターンの方が、Aパターンよりも精緻な情報が返っている印象です。チャンク化している内容はほんのちょっとの違いですが、応答結果の差分は大きいのではないでしょうか。
Cパターンは与えられているコンテキストは正しそうなものの、複数のドキュメントにまたがるような内容をフォローできていないため、AパターンやBパターンと比較すると近眼視的な内容が応答されています。元ソースが1ドキュメントで完結するものであれば問題なさそうですが、複数ドキュメントに情報がまたがっている場合は、複数ドキュメントを参照できるようなチャンク長にした方が良さそうです。
従って、ここまでで得られた情報を総合すると、
チャンク毎の情報が一つ一つが完結していること
複数ドキュメントの内容を拾えるようなチャンク長であること
という要件を満たすデータ長が、チャンク毎にベクトルDBに登録する上での適切なデータ長であると考えられます。
現場からは以上です。