見出し画像

Q&Aチャットボット高品質化への道〜テキストの埋め込みベクトル変換における適切なチャンクデータ長を探る

そういえば先日のLangChainもくもく会でこんな質問があったのを思い出しました。

Q&Aの元ネタにしたい文字列をチャンクで区切ってembeddingと一緒にベクトルDBに保存する際の、チャンクで区切る適切なデータ長ってどのぐらいなのでしょうか?

以前に紹介していた記事ではチャンク化を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)

char_length: 10000 characters
cl100k_base: 8000 tokens

kanji = "忍" * 4000
count_cl100kbase_token(kanji)

char_length: 4000 characters
cl100k_base: 8000 tokens

ひらがなと漢字とでだいぶ開きがありますが、一般的な日本語文章だとひらがなと漢字がまぜこぜになるということで、5,000〜6,000文字ぐらいがtext-embedding-ada-002を利用して一度に埋め込みベクトルを作成できる限界値になるのではと推測します。

ただ、実際の用途ではこの文字列をプロンプト内に注入する訳なので、最大文字数は生成モデルの最大入力トークン長に左右されます。例えばGPT-3.5の最大入力トークン長は4,096トークンなので、5,000文字の埋め込みベクトルを作成しても実際に利用することはできません。プロンプトのトークン長との兼ね合いでコンテキストとして使う情報の文字数を決定する必要があります。あまりにキツキツに設計してしまうと、クエリに利用できる文字数が少なくなってしまうので、ユーザー体験が損なわれる可能性があります。

コンテキスト設計の検討

所定の文字列を埋め込みベクトルと共にベクトルDBに格納する目的は、ユーザーからのクエリに対して適切な補足情報をベクトルDBから抽出し、その情報をプロンプトに埋め込むことで、ユーザーからのクエリに答えるための正確なコンテキストを生成モデルに渡すことにあります。

というわけで、その埋め込まれる情報そのものの質も、生成モデルの応答の質に関係すると考えられます。

例えば、ただ機械的に文字列をチャンクに区切ったものをベクトルDBに格納したとしたらどうなるでしょうか。埋め込みベクトルとして近い意味を持つ文字列群を引っ張ってきたときに、たまたま区切られている箇所の文章の意味は確かに近いけれども、その文の前半部は全く違うことを話している、という可能性がありそうです。

分かりやすいイメージだと、マスコミの切り取り報道みたいな感じですね。

そう考えると、ベクトルDBに格納するデータは一つ一つが完結している、もしくは少なくともタイトルやサブタイトルがついており文脈が分かるようなものになっている方が望ましいと考えられます。

簡単な比較検証

ここまでの検討を踏まえて、精緻な軸ではありませんが以下の観点で簡易的に比較検証を試みてみました。

A)Unstructuredライブラリ任せで機械的にチャンク化
B)Unstructuredでチャンク化したものにタイトルとサブタイトルを付加
C)ドキュメントまるごと埋め込みベクトル化

検証には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ライブラリによって分割された文字列

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の種類について教えて下さい")

LangChainでは、専用に作成された複数のChainタイプが提供されています。例えば、ConversationChainを使用して、2種類のメモリを持つアプリケーションを開発することができます。また、LangChainはPromptsやMapReduceのようなChainコンポーネントを提供しており、それらを組み合わせることで手軽に複雑なアプリケーションを構築することができます。詳細については、LangChainのドキュメンテーションを参照してください。

run_qa("LangChainで扱えるAgentを列挙して下さい")

LangChainには以下のようなAgentがあります。

- API Chain
- LangChain Example
- Langchain-serve

AgentsはLLMを使用してどのアクションを取るか、何の順序で取るかを決定します。アクションはツールを使用して出力を観察することもできますし、ユーザーに対して応答を返すこともできます。また、LangChainには複数のAgentが用意されており、エンドツーエンドの例も提供されています。

run_qa("文章の要約を行うときに利用するmap_reduceオプションについて、具体的に何をしているのかを教えて下さい")

map_reduceオプションは、複数のドキュメントから要約を作成する際に使用するChainの1つです。このオプションを使用すると、まずMapステップで各ドキュメントを分割して処理します。次にReduceステップで、分割された情報を統合して、ドキュメントの要約を作成します。Mapステップでは、バッチサイズを設定することができ、Reduceステップでは、複数のドキュメントから要約を作成することができます。ただし、バッチサイズが大きすぎると、レート制限エラーが発生する可能性があるため、注意が必要です。

run_qa("LangChainで会話の長期記憶を実装するためにどんな方法が用意されていますか?")

LangChainは、短期的な記憶と長期的な記憶の両方を実装するために、異なる種類のメモリコンポーネントを提供しています。簡単な例として、チャットボットを設計する際には、以前のメッセージを記憶して、それをコンテキストとして使用することができます。より複雑な例では、鍵となる情報を時間の経過とともに記憶し、それを長期的な記憶として使用することができます。また、LangChainでは、ChatGPTのようなチェーンを作成することもできます。

B)Unstructuredでチャンク化したものにタイトルとサブタイトルを付加

run_qa("LangChainで使えるChainの種類について教えて下さい")

LangChainでは、Question Answering、Summarization、LLM Chain、Retrieval Question/Answering、Conversation Knowledge Graph Memory、Entity Memory、Transformation Chain、Loading from LangChainHub、Aleph Alpha、DeepInfra LLM Example、Retrieval Question Answering with Sourcesなど、様々な種類のChainを使用することができます。それぞれに適したChainタイプを選択することで、プログラマーはより効率的にプロジェクトを進めることができます。詳細については、それぞれのChainの説明を参照してください。

run_qa("LangChainで扱えるAgentを列挙して下さい")

Custom MRKL Agent、JSON Agent、ZeroShotAgent、Aleph AlphaなどのAgentを使用できます。

run_qa("文章の要約を行うときに利用するmap_reduceオプションについて、具体的に何をしているのかを教えて下さい")

map_reduceオプションは、文章の要約を行うために使用されるLangChainの一つです。このオプションは、複数の文書から構成される大きな文章を、小さな文書に分割して、それらを要約するための作業を並列化します。具体的には、まずmapステップで各文書を要約に適した形式に変換し、その後reduceステップでそれらを結合して最終的な要約文書を作成します。ただし、注意点として、mapステップで使用するバッチサイズが大きすぎるとレート制限のエラーが発生する可能性があるため、注意する必要があります。

run_qa("LangChainで会話の長期記憶を実装するためにどんな方法が用意されていますか?")

LangChainでは、Conversation Knowledge Graph MemoryおよびConversationSummaryMemoryという2つのタイプの長期記憶が用意されています。Conversation Knowledge Graph Memoryは、知識グラフを使用して会話の履歴を再現するためのものであり、ConversationSummaryMemoryは、会話の要約を生成するためのものです。これらの記憶を使用することで、LangChainを介して長期的な対話の流れを保持し、より人間らしい自然な対話を実現することができます。

C)ドキュメントまるごと埋め込みベクトル化

このパターンのみ、n_resultを1にしています(複数抽出するとプロンプトのトークン長制限を超えてしまうため)。

run_qa("LangChainで使えるChainの種類について教えて下さい")

LangChainで使えるChainの種類には、Modal Chain、CSQA Chain、LAMA Chain、Text Classification Chainなどがあります。それぞれのChainは、対話型AIやバーチャルアシスタント、自然言語推論などの応用に使用されます。具体的な使用方法については、公式ドキュメントを参照してください。

run_qa("LangChainで扱えるAgentを列挙して下さい")

情報から得られる情報に基づき、LangChainを使用して扱えるAgentには、Aleph Alphaが含まれることが示唆されています。よって、「LangChainで扱えるAgentは、Aleph Alphaが含まれます。」と答えることができます。

run_qa("文章の要約を行うときに利用するmap_reduceオプションについて、具体的に何をしているのかを教えて下さい")

文章の要約を行う際に、map_reduceオプションは、複数のドキュメントを一つにまとめ、言語チェーンを用いて要約を生成する手法です。具体的には、まず言語チェーンによって各文書をテキストに変換し、それらを一つにまとめます。そして、そのまとめたテキストに対して、再度言語チェーンを適用して要約を生成することで、複数の文書をまとめた上でより正確な要約を生成することができます。また、この手法では中間ステップを返すこともでき、これにより要約生成の過程を理解することができます。

run_qa("LangChainで会話の長期記憶を実装するためにどんな方法が用意されていますか?")

LangChainでは、会話の長期記憶を実現するために、Conversation Knowledge Graph Memoryが用意されています。これは、知識グラフを使用してメモリを再現する方式で、会話過程で発生する情報を記録し、それを次回の会話で利用可能にします。ConversationKGMemoryを使用することで、保存した情報を読み込むことができます。また、Contextを保存することもでき、過去の会話からエンティティや知識のトリプレットを取得することも可能です。ConversationChainを使用することで、このConversationKGMemoryを長期記憶として使用する会話チェーンを作成することができます。

検証から分かったこと

機械的に文字列を区切ったとしても何となくそれっぽい内容を抽出できているのか、Aパターンでもそれなりの応答を返せている印象です。しかし、タイトル&サブタイトルだけだとしても常にチャンクに文脈を持たせているBパターンの方が、Aパターンよりも精緻な情報が返っている印象です。チャンク化している内容はほんのちょっとの違いですが、応答結果の差分は大きいのではないでしょうか。

Cパターンは与えられているコンテキストは正しそうなものの、複数のドキュメントにまたがるような内容をフォローできていないため、AパターンやBパターンと比較すると近眼視的な内容が応答されています。元ソースが1ドキュメントで完結するものであれば問題なさそうですが、複数ドキュメントに情報がまたがっている場合は、複数ドキュメントを参照できるようなチャンク長にした方が良さそうです。

従って、ここまでで得られた情報を総合すると、

  • チャンク毎の情報が一つ一つが完結していること

  • 複数ドキュメントの内容を拾えるようなチャンク長であること

という要件を満たすデータ長が、チャンク毎にベクトルDBに登録する上での適切なデータ長であると考えられます。

※もちろん扱うベクトルDBの検索方法によって適切なデータ設計は変わると思いますので、実際に扱うベクトルDBの特性にあわせてデータ設計を検討されて下さい。

現場からは以上です。

この記事が気に入ったらサポートをしてみませんか?