ABEJA Tech Blog

中の人の興味のある情報を発信していきます

外部データをRetrievalしてLLM活用する上での課題と対策案

はじめに

ABEJAでデータサイエンティストをしている服部です。 今回はLLMで外部データを使うケースについてのお話をしたいと思います。

LLMと外部データの利用

大規模言語モデル(Large Language Model, 以下LLM)を利用するにあたって、自社データや最新Web情報などLLM自体が学習していないデータ(以下、外部データ)を用いた応答をしたいニーズが増えているように思えます。 例えば、社内のドキュメントを元にした社内QAボットを作るようなケースですね。 Retrieval Augmented Generation (RAG) とかGroundingとか呼ばれている方法です。

この記事では、こういったLLMと外部データを利用するケースに関する課題と対策案についてご紹介したいと思います。

RetrievalとLLM

LLMにおいて外部データを参照するのは以下のような仕組みで実現することが多いです。

以下、桃太郎の文章を外部データとして、そこからQAをする例にて説明します。

0. (事前準備)参照したいテキストデータをDBに格納

参照したいテキストデータを、一定の文字数以下に分割しながらDBに格納します。 またこのテキストをembeddingとしてベクトル化したものも保存します。

実際はテキストとベクトルの2カラムのテーブルというわけではないですが、こういったデータを保持しておきます。

text vector
むかしむかし、あるところにおじいさんとおばあさんがいました。おじいさんは山に..... [0.3215, 2.234, 0.1834, .....]
すると川上から大きな桃が一つ..... [-0.6282, 1.3852, 0.2341, .....]
おばあさんは桃を洗濯物と一緒にたらいの中に入れて、おうちへ持って帰り、..... [3.1315,-0.1860, -2.1494, ....]
..... .....

1. ユーザの入力文とのテキスト類似度を計算して、関連テキストを抽出する(Retrieval)

ユーザの入力文へ回答する手がかりになりそうなテキストを手順0で作ったデータベースから抽出します。

「手がかりになりそうなテキスト」というのは関連しているテキストのことであり、入力文に類似度が高いテキストデータを取ってくることで実現します。 類似度については、ユーザの入力文をembeddingとしてベクトルに変換し、ベクトル同士の類似度を計算するという方法が一般的です。

類似度計算をすると、下記のように手順0で作ったテキストデータに対して類似度が算出できます。

ユーザのInput: 「桃を拾ったのは誰ですか?」

text 類似度
むかしむかし、あるところにおじいさんとおばあさんがいました。おじいさんは山に..... 0.62
すると川上から大きな桃が一つ..... 0.5
おばあさんは桃を洗濯物と一緒にたらいの中に入れて、おうちへ持って帰り、..... 0.98
..... ...

結果、下記のテキストデータが類似度が高く、関連テキストとして抽出できました。 実際は一つの候補だけでなく、複数の関連テキストを抽出することが多いです。

|おばあさんは桃を洗濯物と一緒にたらいの中に入れて、おうちへ持って帰り、.....|

この関連テキストを抽出する部分のことをRetrievalと呼びます。

2. 関連テキストをLLMのプロンプトに入れ込み、ユーザの入力文に回答する。

例えば、以下のようなプロンプトを用意します。

あなたは対話エージェントです。
[参考]部分の情報を使って[ユーザ入力文]部分のユーザからの会話に回答してください。

[ユーザ入力文]
{user_input}
[参考]
{related_text}

user_input部分にユーザの入力文、related_textの部分に関連テキストが入ります。 今回の例だとこうなります。

あなたは対話エージェントです。
[参考]部分の情報を使って[ユーザ入力文]部分のユーザからの会話に回答してください。

[ユーザ入力文]
「桃を拾ったのは誰ですか?」

[参考]
おばあさんは桃を洗濯物と一緒にたらいの中に入れて、おうちへ持って帰り、.....

そしてこのプロンプトを入力することでLLMは以下のような出力ができます。

おばあさんが桃を拾いました。

こういった形でLLMが学習していない外部データを使った対話などが出来るようになります。

Retrieval時の課題

こうした外部データ接続したLLMですが、関連テキストを抽出するRetrievalがうまくいかないと回答はできません。

そして外部データ次第では、ここが課題になりやすいところだと個人的には思っています。

以下ではポケモンに関するQAを例にしたいと思います。

OpenAIのGPT3.5/4では、学習データとして2021年x月までのものしか含んでいません(2023年5月時点)。 つまりポケモンのソード・シールドまでの情報は答えられるが、スカーレット・バイオレットの内容については答えられません。 結果的にモデルとして学習済みの情報と外部参照データによる回答性能の比較などもしやすかったりします。

ここではポケモンWikiのポケモン毎の紹介ページを外部データとして参照することで、新しいポケモンに関する回答をLLMができることを目指してみます。

※最新の情報を知りたいだけならGoogle Search APIをLangchainのToolsで使う方法がありますが、あくまでは今回は手元のデータから回答を得られるか?の検証のために用いていません。

ちなみにポケモン毎のページはこちらのようなページです。 ただのテキストでの説明というよりはページ内では表も利用されています。

フシギダネ - ポケモンWiki

LangChainでの用意

また、今回はLangChainを使って外部データ参照を実現します。 LangChainは、外部データ参照やChatBot作成などLLMを使ったアプリケーションの構築を支援するライブラリです。

事前にローカルに外部参照したいHTMLを保存しておいた上で下記の手順で進めていきます。 ここではポケモンwikiからポケモン毎のページ、合計1000ページ強のHTMLを事前に用意しています。 LangChainには様々な拡張子のデータを読み込むLoaderやテキスト分割のSplitterがあるのでそれを活用していきます。

from pathlib import Path
from langchain.document_loaders import UnstructuredHTMLLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

html_dir = Path("/path/to/html_directory/")
html_files = list(html_dir.glob("*html"))

all_documents = []
# HTML内のデータを分割する用のSplitterを用意
text_splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=0)

for p in html_files:
    # langchainに用意されたHTMLLoaderでHTMLからdocumentを抽出
    doc = UnstructuredHTMLLoader(p).load()
    # documentをsplitterで分割
    all_documents += text_splitter.split_documents(doc)

次に分割して読み込んだdocumentをembeddingして保存します。 類似度検索を高速にするためにテキストデータをembeddingしたものを事前にDBに保存しておきます。 ここでは、embeddingとして、OpenAIで推奨されているtext-embedding-ada-002を利用します。

from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma

# 下記でOpenAIでembedding用に推奨されている"text-embedding-ada-002"が指定されます
embeddings = OpenAIEmbeddings()
# Langchainでデフォルトで使われる Chroma という VectorStore を利用
db = Chroma.from_documents(all_documents, embeddings, persist_directory="exp_for_blog")

次にretrieverを用意します。

retriever = db.as_retriever()
# OpenAI を使うためのインポート
from langchain.llms import OpenAI

# LLM ラッパーの初期化
llm = OpenAI(model_name="text-davinci-003", temperature=0, max_tokens=500)

# 質問と回答の取得に使用するチェーンをインポート
from langchain.chains import RetrievalQA

# チェーンを作り、それを使って質問に答える
qa = RetrievalQA.from_chain_type(llm=llm, chain_type="stuff", retriever=retriever)

Case1: それぞれの文章がRetrievalしにくい形で保存されている

実際に試してみます。 ここでは「カビゴンってどんなポケモンですか?」と聞いてみます。

query = "カビゴンってどんなポケモンですか?"
qa.run(query)

すると下記のような回答が得られました。

カビゴンは、Pokémon GO、ポケモンマスターズ、New ポケモンスナップ、ピカブイ、アニメなどで登場する幻のポケモンです。くさタイプの御三家のツタージャの前に図鑑番号が振られている唯一のポケモンで、大食いという設定のポケモンです。固定シンボルに話しかけたときの鳴き声は「きゅきゅわわ~んっ!」で、ポケマメはたべる速度こそ高くないものの2口で食べるという性質を持っています。

.....明らかに間違った内容も含んでいて、回答としてはダメそうです。

それでは原因を考えていきます。 最初に、そもそもRetrievalとして、関連している情報を取得できているかを確認します。

retriever.get_relevant_documents(query=query)

出力結果としてはこちらのようなものでした。 たしかにカビゴンに関する情報はあるものの、関係していない情報も含んでいるのが分かります。

['カビゴン (ノブナガ)を参照。Pokémon GOにおけるカビゴンカビゴン '
 '(GO)を参照。ポケモンマスターズにおけるカビゴンレッド、リラとのバディーズやタマゴから生まれるゴンべの進化形として登場する。New '
 'ポケモンスナップにおけるカビゴンNew ポケモンスナップに関連した書きかけです。加筆、訂正して下さる協力者を求めています。(プロジェクト)',
 'カビゴンとメタモンまたは、カビゴンの♀と同じタマゴグループの♂の組み合わせでタマゴが見つかった時、通常はカビゴンのタマゴになり、親のどちらかがまんぷくおこうを持っていた場合はゴンベのタマゴになる。備考',
 '第五世代で登場した幻のポケモンである。通常であれば幻のポケモンはポケモン図鑑の終盤に番号が振られているが、ビクティニはくさタイプの御三家であるツタージャの前(イッシュ図鑑でいえばNo.000)に図鑑番号が振られている唯一のポケモンである。固定シンボルに話しかけたときの鳴き声は「きゅきゅわわ~んっ!」',
 'ピカブイのフジろうじんの発言によると、目が覚めて初めて見たものを食べ物と勘違いして襲ってくることがあるという。ピカブイではポケモンのふえでカビゴンを起こすと、野生ポケモンとしては例外的に戦闘が行われ、その後捕獲が開始する、という流れになる。大食いという設定のポケモンであり、ポケマメはたべる速度こそ高くないものの2口で食べる。アニメにおけるカビゴン無印編第41話で初登場。']

特に

第五世代で登場した幻のポケモンである。通常であれば幻のポケモンはポケモン図鑑の終盤に番号が振られているが、ビクティニはくさタイプの御三家であるツタージャの前(イッシュ図鑑でいえばNo.000)に図鑑番号が振られている唯一のポケモンである。固定シンボルに話しかけたときの鳴き声は「きゅきゅわわ~んっ!」

こちらのテキストがカビゴンではない別のポケモンの説明であるにも関わらず、関連テキストとして取得していることで、カビゴンを幻のポケモンと答えてしまっている可能性があります。

次に本来、関連テキストとして取得したかった文章(カビゴンを説明しているもの)を見てみます。

https://wiki.xn--rckteqa2e.com/wiki/%E3%82%AB%E3%83%93%E3%82%B4%E3%83%B3

カビゴンの説明として使えそうな取得できると良さそうな文章としては以下のようなものが存在しました。

その巨体とでっぷりと肥えた腹部が特徴のポケモン。頭部には尖った耳が生え、下顎の犬歯が口を閉じていても飛び出すほどに発達している。体色は黒とクリーム色の二色で、腹部と顔まわり、3本の爪が生えた足がクリーム色、それ以外の全体が黒。
大食漢なポケモンとして有名で、一日に自分の体重の9割近くになる400kgもの食事を取る。食事を終えると寝転がり、空腹になると起き上がって食料を探すといういわゆる「食っちゃ寝」な生活サイクルを送っている。その胃袋は非常に丈夫であり、カビや腐敗した食べ物はおろか、ベトベトンの毒すらも問題なく消化できるほど。
食事と睡眠以外の事には興味がなく、昼寝の最中にお腹をトランポリン代わりにされても一切気にせずに眠り続ける。その大人しさのためか、カビゴンを遊具代わりにして遊ぶ子供もいるほど。ただ、ポケモンの笛と呼ばれる特殊な笛が奏でる音色には反応するようで、どんなに深い眠りに入っていても笛の音を聞くとすぐさま目を覚ます。  色違いは体の黒色部分が青みがかる。  キョダイマックスのすがた

そして、これらの文章を見てもらうと分かるのですが、文章の中にはカビゴンという言葉が存在せず、知識がない限りカビゴンの説明かどうかが文章から判断できないというのが分かります。 この文章を含むWebページでは、ページタイトルとしてカビゴンと書かれており、ページ本文にはわざわざカビゴンであることを書かなくても読み手は分かる構成になっています。そのページ内テキストを分割することで、分割後の文章単体では何の説明かが分からない文章も多々あります。

結果的に「カビゴンってどんなポケモンか?」という質問と類似度が高い文章として、これらを取得するのは難易度が高いことが分かると思います。

対策案: ページ構造を意識した形で各文章を格納する

今回の例でいうと、各文章にページのタイトル、すなわち「カビゴン」と書かれていたらRetrieval出来た可能性があります。

そこで、そのテキストがページの中で何を意味している文章かが分かるようにデータの構造を利用します。 今回はHTMLであるため、まずはシンプルにH1, H2, H3のタグを利用します。

ここではBeautifulsoupを使ってそれぞれの文章が、

[H1タグ] > [H2タグ] > [H3タグ]: 本文

となるように、LangchainのBSHTMLLoaderを少し変えたStructuredHTMLLoaderというものを作って対策してみます。

注意しないといけないのが、HTMLLoaderで上記の形で返したとしても、TextSplitterで分割する際に、Hタグの情報が最初の文章にしかつかなくなってしまいます。そのためDocumentクラスのmetadataにHタグの情報を一時的に入れて、分割後に後でHタグの情報を文章に付与するという方法をとっています。(もっといい方法はあるかもしれません)

html_files = list(html_dir.glob("*html"))
all_documents = []
text_splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=0)
for p in tqdm(html_files, total=len(html_files)):
    # StructuredHTMLLoaderでHTMLを読み込み
    doc = StructuredHTMLLoader(p).load()
    # text splitterで一定文字数以内に分割
    all_documents += text_splitter.split_documents(doc)

def add_topic_path_to_text(doc):
    doc.page_content = doc.metadata["topic_path"] + " : " + doc.page_content
    return doc

# metadataに格納していたHタグの情報を各テキストに付与
all_documtents = [add_topic_path_to_text(doc) for doc in all_documents]

こちらでやった場合、カビゴンに関連する説明文としては以下のような文章になります。

カビゴン > 概要 : その巨体とでっぷりと肥えた腹部が特徴のポケモン。頭部には尖った耳が生え、下顎の犬歯が口を閉じていても飛び出すほどに発達している。体色は黒とクリーム色の二色で、腹部と顔まわり、3本の爪が生えた足がクリーム色、それ以外の全体が黒。

これによりこの文章がカビゴンの説明文であることが分かります。

こちらのデータセットでインデックスを作り再び試してみます。

query = "カビゴンってどんなポケモンですか?"
qa.run(query)

回答

カビゴンはレッド、リラとのバディーズやタマゴから生まれるゴンべの進化形として登場するポケモンです。カードゲームやマンガなどで使用可能なポケモンとして登場します。

回答時に参照した文章はこちらです。

カビゴン > ポケモンカードにおけるカビゴン : カビゴン (カードゲーム)を参照。
カビゴン > ポケモンずかんの説明文 : レッド、リラとのバディーズやタマゴから生まれるゴンべの進化形として登場する。 使用可能なポケモンとして登場する。カビゴン (UNITE)を参照。 カビゴン/対戦を参照のこと。
カビゴン > 進化 : レッド、リラとのバディーズやタマゴから生まれるゴンべの進化形として登場する。 使用可能なポケモンとして登場する。カビゴン (UNITE)を参照。 カビゴン/対戦を参照のこと。
カビゴン > マンガにおけるカビゴン : ポケットモンスターSPECIALにおけるカビゴン

前回は違うポケモンの情報を取得してそれをカビゴンのものとして説明していましたが、それは解消されてそうです。 ちゃんとカビゴンの説明文をRetrievalして、回答しています。

ただ、説明文はポケモンUNITEの情報が中心ですし、取得したかったものとは少し異なるようです。

これは「カビゴンってどんなポケモンですか?」という文章に対して、類似度が高いものを引っ張ってきているだけなので、最も適切な文章を取ってこれていない可能性があります。

他の対策案

ここでは代表して上記の対策だけ実施しましたが、他にも以下のような対応策も考えられます。 (ここに書いてあるのも恐らく一部であり、今後新たな良い方法もでてくると思います)

聞き方を明確にする

「どんなポケモンですか?」という聞き方が曖昧ですし、もう少し求めている回答を得やすい聞き方をするという考え方です。例えば「カビゴンというポケモンの特徴を教えて下さい。」とするだけでも、カビゴンの特徴に関する文章を取ってきやすくなるため、もっと特徴を説明している文章をRetrievalできるようになったりします。 ただし、これがChatBotのようなものの場合、聞き方はユーザーに委ねられているので、聞き方を改善するというのは限界があるかもしれません。

類似度を測るクエリ文章を置き換える

今回は質問文をそのままembeddingして類似度を測りましたが、質問文自体で類似度を計算するのが適切かというとそうとも限りません。 欲しい情報を取得しやすくなるように文章を置き換えたほうが有効となることもあります。検索エンジンで「カビゴンの特徴を教えて」とは言わず、「カビゴン 特徴」とかで調べますよね。 そしてこの置き換え自体をLLMに担ってもらうことも出来ます。

「HyDE(Hypothetical Document Embeddings)」と呼ばれるLLMに回答させた結果をembeddingして、Retrievalに使う方法などが有名かもしれません。

arxiv.org

HyDE含め、Llama Index上にもいくつか実装されています。

gpt-index.readthedocs.io

不要そうな文章をデータから削除する

例えば、 「カビゴン > ポケモンカードにおけるカビゴン : カビゴン (カードゲーム)を参照。」 という文章は何も情報がなく、これを参照しても回答に使えない可能性が高いです。こういった参照できないデータを最初から除くことも有効と考えられます。

データ自体をLLMで整形し直す

今回は、Hタグの部分だけ修正しましたが、そもそもLLMで元となるデータを引用しやすい形に要約したり、整形・構造化してもらうという方法もあります。 個人的には、このあたりの工夫が今後もっとメジャーになってくるのではないかと思っています。

Case2: 未知の単語を含む

もう一つポケモンを使って違う例です。

上記のHタグを使ったIndexで、今度は新しいポケモンについて同じように聞いてみます。

query = "ニャオハってどんなポケモンですか?"
qa.run(query)

回答

ニャオハは黒と赤色の毛をした猫のポケモンです。腹から脚にかけては赤色ベースで、脚に黒色の縞模様が2本ずつ入っています。額の「キ」の字模様は真ん中の棒の部分が伸び、耳の間に毛として続いています。背中にも同様の「キ」の字模様が入り、尻尾にも赤色の縞模様が入っています。頬のたてがみは少し大きくなっており、敵の気配や周囲の様子を感じ取る感覚器官としての役割も果たしています。3本の爪が目立ちます。

...どうやら私の知っているニャオハとは違うようです。 猫であることはあってますが、色合いが明らかに違います。

Retrievalの結果を見てみます。

'ニャオニクス > 備考 : 隠れ特性は♂はいたずらごころ、♀はかちきになり、異なるフォルムではあるが隠れ特性を複数持つのはこのポケモンが初めて。',
'ニャビー > 特徴 : 黒と赤色の毛をした猫のポケモン。全体は黒毛で、4本の脚にそれぞれ2本の縞模様、目から下、額の「キ」の字の模様が赤色。毛には油が含まれており、生え変わりの時期には古い毛を全て燃やしてしまう。大きな黄色い目が特徴的。鼻の色は黒。両頬から髭のような毛が伸びている。',
'ニャヒート > 特徴 : 黒と赤色の毛をした猫ポケモン。ニャビーの頃とは異なり、腹から脚にかけては赤色ベースで、脚に黒色の縞模様が2本ずつ入っている。額の「キ」の字模様は真ん中の棒の部分が伸び、耳の間に毛として続いている。背中にも同様の「キ」の字模様が入るほか、尻尾にも赤色の縞模様が入る。ニャビーの頃にも生えていた頬のたてがみは少し大きくなっており、敵の気配や周囲の様子を感じ取る感覚器官としての役割も果たす。3本の爪が目立',
'ニャヒート > 特徴 : 黒と赤色の毛をした猫ポケモン。ニャビーの頃とは異なり、腹から脚にかけては赤色ベースで、脚に黒色の縞模様が2本ずつ入っている。額の「キ」の字模様は真ん中の棒の部分が伸び、耳の間に毛として続いている。背中にも同様の「キ」の字模様が入るほか、尻尾にも赤色の縞模様が入る。ニャビーの頃にも生えていた頬のたてがみは少し大きくなっており、敵の気配や周囲の様子を感じ取る感覚器官としての役割も果たす。3本の爪が目立'

どうやら、ニャオハではなく、ニャオニクス・ニャビー・ニャヒートという別のポケモンの文章をとってきてしまったみたいです。 全部猫ポケモンではあるのですが。。

ここで一つの仮説が立てられます。

仮説: ニャオハという言葉を知らず、適切なembeddingを得られていない

類似度計算のために使われているembeddingは、text-embedding-ada-002というモデルによって作られたものですが、このモデルも学習データの期間までの情報しか知らず、ニャオハという言葉を知りません。ニャオハがポケモンであることもわかりません。 つまり未知の単語をembeddingしている故に、適切なベクトル表現が得られず、Retrievalがうまくいっていないという可能性があります。 そして「ニャオハ」を知らないために、「ニャ」の部分が共通的な他のポケモンを類似したものとして取ってきているのではないかと個人的には思います。

特に、今回のembeddingで使われているモデル含め最近のTransformerのモデルでは事前に文章をtokenと呼ばれるパーツに分解するのですが、単語単位ではなく文字やサブワードなどより細かい単位で分割されることが多く、ニャがつくポケモンは部分的に共通な文章と見えている可能性があります。 tiktoken というライブラリを使うことで、実際にどう分割するかが見えます。

github.com

import tiktoken
word = "ニャオハ"

enc = tiktoken.get_encoding("cl100k_base")  # cl100k_baseは今回使ったtext-embedding-ada-002を指す
tokens = enc.encode(word)
print(tokens)

#  Output: [78683, 68581, 90962, 2845, 237]

他のポケモンも比較してみると、78683, 68581 が共通していることが分かります。

ニャオハ: [78683, 68581, 90962, 2845, 237]
ニャヒート:  [78683, 68581, 2845, 240, 84477]
ニャオニクス: [78683, 68581, 90962, 78683, 29220, 22398]
ホゲータ: [2845, 249, 3484, 110, 38248, 123]

質問文と未知の単語

質問文に未知の単語がないものであれば、embeddingも正しく出来るのでニャオハに関するQAもできるのではないか?という仮説も立てられます。 そこで下記のような質問を投げてみます。

質問

緑色の草タイプの猫ポケモンを教えて下さい。

回答

 ニャオハです。

関連テキストとしても

ニャオハ > 概要 : 緑色の子猫ポケモン。顔中央に葉のような模様がある。首元も葉のような毛の房で覆われている。瞳は赤色。

といった欲しい文章をRetrievalが出来ていました。 「緑色」や「草タイプ」、「猫」、「ポケモン」といった言葉はモデルも学習しているものなので、とってこれたのではないでしょうか。

単純に未知の単語というより、Retrievalをする元となる質問文に未知の単語を含むかどうかというのも大事な観点に感じます。

上記はあくまで仮説です。 embeddingがどうなっているかについての検証は人間には非常に難しいものになるので、これ以上の深掘りは辞めておきます。。

対策案: 単語の共起ベースで類似度を計算する

一つのシンプルな対策としては、古典的な手法ではありますが、単語の共起をベースにRetrievalする方法があります。 これまではDeepLearningモデルのベクトルの類似度を使っていましたが、同じ単語がどれくらい使われているか?で類似度を測ります。 GPTやDeepLearing以前から情報検索分野、自然言語処理で使われてるもので、BM25やTF-IDF、Bag-of-wordsなどの手法があります。

「ニャオハ」という単語を知らなくても、ニャオハを単語として分割さえ出来てしまえば、ニャオハという単語が入った文章同士が類似度で近いものと計算されることを期待します。 逆に「ニャヒート」は全く別の単語になるので、「ニャオハ」と「ニャヒート」は完全別物として扱われることも期待できます。

ここではLangChainでTF-IDFを使って見たいと思います。

LangChainでのTF-IDF Retriever

LangChainにはTF-IDFでのRetrieverするクラスも用意されていて、簡単に実現できます。

ただし注意しないといけないのは、デフォルトのクラスだと英語前提になっているため、パラメータで日本語のtokenizerを渡して上げる必要があります。 下記のように書くことが出来ます。 ここではMeCabで分かち書きしたものをベースにTFIDFで類似度をはかってRetrievalします。

from langchain.retrievers import TFIDFRetriever
import MeCab

def mecab_tokenizer(text):
    mecab = MeCab.Tagger("-Owakati")
    return mecab.parse(text).split()


tfidf_retriever = TFIDFRetriever.from_texts(
    [d.page_content for d in all_documents], tfidf_params={"tokenizer": mecab_tokenizer}
)

ちなみに日本語でも出来るようにパラメータを渡すのは当初できなかったので、PRあげて4月にリリース版にもマージされています。

github.com

TFIDFでの結果

ここで前と同じニャオハに関する質問を投げてみます。

> ニャオハってどんなポケモンですか?


気まぐれで甘えん坊な性格をしており、他のポケモンにかまけていると拗ねることもあるポケモンです。

こちらの説明はどうやらポケモン公式サイトの情報的にもあってそうです!

www.pokemon.co.jp

また、このときのRetrievalの結果(類似の文章)を見てみます。

 'ニャオハ > 進化 : ニャオハ (カードゲーム)を参照。',
 'ニャオハ > おぼえるわざ : ニャオハ (カードゲーム)を参照。',
 'ニャオハ > 備考 : ニャオハ (カードゲーム)を参照。',
 'ニャオハ > ダメージ倍率 : ニャオハ (カードゲーム)を参照。',
 'ニャオハ > 種族値 : ニャオハ (カードゲーム)を参照。',
 'ニャオハ > 入手方法 : ニャオハ (カードゲーム)を参照。',
 'ニャオハ > ポケモンずかんの説明文 : ニャオハ (カードゲーム)を参照。',
 'ニャオハ > 概要 : 気まぐれで甘えん坊な性格をしており、他のポケモンにかまけていると拗ねることもある。  ニャオハ (カードゲーム)を参照。',
 'ニャオハ > ポケモンカードにおけるニャオハ : ニャオハ (カードゲーム)を参照。',
 'ニャローテ > 概要 : ニャオハの進化形。  ニャローテ (カードゲーム)を参照。'

情報がない文章も取ってきてしまっていますが、ニャオハに関する情報を引っ張ってこれているのが分かります。

今回はTFIDFをRetrievalに使う対策を紹介しましたが、当然この方法も万能ではなく、むしろデフォルトのembeddingを使ったほうがよいケースのほうが多いかもしれません。 ただし、データやLLMを使うユースケースによっては、こういった方法も有用なときがあると思っています。

他の対策案

以下のような対策案が考えられます。 (他にも、ユースケースやデータ次第で様々な方法はあると思います)

未知の単語を含んだテキストでファインチューニングする

未知の単語だったものを学習さえしてしまえば、embeddingでのretrievalも改善される可能性があります。embeddingで使われるモデルは、GPT3.5, GPT4.0などのLLMに比べて小さいため、学習自体のコストもそこまで大きくないと思われます。 ただし学習データセットの用意は必要になります。

ファインチューニングの方法としてはOpenAIのAPIで、text-embedding-ada-002 は出来るようです。

platform.openai.com

またOpenAIがembeddingの学習方法のnotebookも公開してくれています。

github.com

その単語を学習している別のモデルを利用する

OpenAIのtext-embedding-ada-002 以外にも類似度計算に使えるembeddingを出力できるモデルは色々あります。

Chat部分を担うLLMに比べると、選択肢は多いです。 その中で、その未知の単語を学習しているモデルがあればそれを使うという手もあります。

例えばこちらのブログでは、OpenAIのEmbedding以外に、 JMedRoBERTa という医療ドメインのデータを学習したモデルのファインチューニングのほうが性能がよかった結果が出ています。

fintan.jp

まとめ

今回はLLMで外部データをRetrievalする際に起きがちな課題について、ポケモンの例を使いながら紹介しました。

実際にはデータやユースケースによって課題も解決策も様々であり、今回書いたものはあくまでその一部だと思っています。 単純にデータを読み込むだけで精度高い回答が得られるわけではなく、Retrieval周りの難しさや工夫の余地が伝われば嬉しいです。

そして、このあたりの分野は日々のアップデートが大きくここに書いたことも数カ月後には古くなっているかもしれませんが、それはそれで新たな解決策などが出るのであればいいことかなとも思います。 一方、LLMでもこういったRetrievalの部分は従来の自然言語処理・情報検索の要素としての工夫点はまだまだ残っているとも今回の記事を書きながら感じました。

引き続き、キャッチアップ・シェアしていけたらと思います。