生成AIに未知の情報を読ませる手法と巨大なデータの取り扱い【Okapi BM25によるRAGシステムの検証】
2024-03-06
azblob://2024/03/05/eyecatch/2024-03-05-gpt-bm25-rag.jpg

はじめに

 Large Language Model(LLM)は「知っていること」しか連想できない。「現在時刻」や「朝のニュース」、「会社の文書」といった一定以上新しい情報や、機密性の高い情報はLLMには生成できないのである。

 このような状態でLLMを自社サービスなどに転用するのはなかなかハードルが高い。最近のトレンドも、社内で利用されるような機密情報も生成できない状態のサービスを作るくらいなら、それこそChatGPTで事足りる。

 本ブログは、この「未知の情報」を如何にして扱うかという点に重点を置いて、検証的にOkapi BM25を用いたデータ処理を作成する。

LLMにデータを読ませる手法

 単純な話だがLLMに「未知の情報」を読ませる場合、ユーザメッセージに文書をコピペすればいい。たったそれだけでLLMはコピペした文書を既知の情報として生成してくれるだろう。

 これらの情報の貼り付けの中で主に使われている手法は、GroudingRetrieval-Augmented Generation(RAG)の2種類である。今回主として取り扱うのはRAGだが、Grondingも合わせて説明する。

Grouding

 前述した通りの手法なのだが、GroundingはLLMに情報を付加するという非常にシンプルなものである。

 だが、後述するRAGと同一視されている場合もあるためしっかりと区分すると、Groundingは「下地作り」という意味合いが強く、基礎知識としてデータをLLMに渡すものである。

 Groundingは必ず知っておいて欲しい情報をあらかじめLLMに渡しておくものと考えれば良い。

Retrieval-Augmented Generation(RAG)

 RAGも基本的な部分は前述した通りの手法で、LLMに情報を付加するという点に変わりはない。だが、RAGはGroundingよりも可用性のある手法であり、主にWeb検索データベースなどと併用されがちである。

 RAGは構成する3つの単語を紐解けばおのずと概要が見える。

 Retrieval:

  Googleで調べると「検索」と出てくることだろう。だが、ここで言う「検索」は一般的に連想される単語としての意味合いよりも情報科学的な意味合いが強い。

  いずれにせよ、Searchとは区分されるものであり、情報の「特定」及び「取得」という意味合いで認識していればいい。

 Augmented:

  実は割と有名な技術用語に入っている単語で、それが拡張現実(Augmented Riality)、通称ARである。
  「拡張」という単語を真に受けすぎず、何かしらの「価値」や「品質」を高めるという意味合いで認識していればいい。

 Generation:

  世代、ではなく「生成」という意味合いである。

 つまるところ、Retrieval-Augmented Generationとは「生成」を「検索」によって「拡張」する技術である。

 Groundingとの違いは明白で、RAGはRetrievalという処理があるため、必要なデータを「特定」し「取得」する工程を踏む。

 Web検索と併用する場合は、ほとんどがRAGになるだろう。また社内規定などをあらかじめデータベースに登録しておき、そこからデータを取得して生成を行う場合もRAGとなる。 

文書を如何にして使うか

 文書を如何にして使うか、これはRAGの精度を決める重要なファクターである。

 今回はタイトルにもある通り、「巨大なデータ」の取り扱いに焦点を当てているので巨大なデータを例にするが、基本的に作った文書をそのままRAGに使用するのはあまり良い方法ではない。

 もちろんそれで上手くいく場合もある。だが、文書といっても様々な形態があり、単純に文章量が多かったり、図や表などのLLMが解析できない情報が多分に含まれていたり、複数の文書を見なければ全容を把握できなかったりと、そもそも使うことが難しい場合や、入力として適切でない場合がある。

 そして、巨大なデータの問題点は大きく分けて3つあると考えている。

  1. LLMのトークン制限により、そもそも入力できない

    根本的にデータが入らないのであれば話にならない、だが安易に分割すれば 3 の発生を引き起こす原因となる。

  2. 情報が大きすぎてLLMが必要な情報を忘れてしまう。

    LLMはすべてを記憶しているわけではないため、1 を回避できたとしても巨大なデータをそのまま渡すのは安定性に欠けるといえる。

  3. 情報が切れてしまう

    1 で軽く触れたが、例えば大きすぎる情報を収まるようにバッサリと切れば、生成は可能だが必要だった情報まで欠落してしまう恐れがある。

 3 は 1 になってしまった巨大なデータを何とか使おうとした結果発生する問題だが、うまく必要な情報だけを抜き出す仕組みがなければ 2 も 3 も発生しうる。

 こういった巨大なデータに対するアプローチは、現在チャンク分割Embeddingが主流になっている。

 この手法は、巨大なデータを複数のチャンクに分割し、分割したチャンクをベクトル情報に変換(Embedding)することで、ユーザメッセージとの関連性をスコアリングする手法である。(意図理解能力を備えたセマンティック検索と併用する、ハイブリッド検索も多く見受けられる)

 いずれにせよ、重要なのは「うまく必要な情報だけを抜き出す仕組み」である。

 だが、どのような手法を取ったとしても使うデータとの相性やユーザメッセージの内容は必ず影響してくる。チャンク分割などせず、自身の持っているすべての文書をLLMに乗せて使いやすいように整形するのも一つの手法であるし、チャンク分割しても情報がまとまるように文章を構成するのも良い。

 また、チャンク分割するとしても、1チャンク何文字で切るのか、もしくは何チャンクに分割するのか、区切り文字はどうするのか、オーバーラップは行うのか、スコアリングは何を用いるのか、パラメータはどう設定するのか、閾値はどう設定するか、LLMに渡すチャンク数はいくつにするのか、などなど選択肢は非常に多い。

 なので、今回の実装はあくまで一例であると捉えてほしい。

 この「うまく必要な情報だけを抜き出す仕組み」は現在多くの技術者が心血を注いで研究している分野であり、明確な正解は存在しない。実装の簡単さやコスト面から考えて、最適だと思われるものを使用していただきたい。

生成AI × BM25

 今回はOkapi BM25を用いて巨大なデータを処理する。

 だが、漠然と実装したところでいいものはできない。特にBM25などの探索処理に利用されるようなアルゴリズムに絶対はなく、すべてのデータに対応する万能なものは存在しない。所謂ノーフリーランチ定理というものだが、それはそうとなるべく汎用性が高く性能も高いものを作りたいと思うのが人の常なので、まずはBM25に対する理解度を上げるところからである。 

Okapi BM25とは

 Okapi BM25は、簡単に言ってしまえば「文章単語を一定の指標に基づいてスコアリングすることができる代物」である。

 以下、wikipediaの引用

Okapi BM25は、情報検索における順位付けの手法である。検索エンジンがクエリとの関連性に応じて、文書を順位付けするのに用いられる。1970年代から1980年代にかけて、スティーブン・ロバートソン (コンピュータ科学者)(英語版)やカレン・スパーク・ジョーンズ(英語版)らが確率適合モデル(英語版)に基づいて開発した。BM25の"BM"は、"Best Matching"の略である。

ロンドン大学シティ校が1980年代から1990年代にかけて開発したオカピ情報検索システム (Okapi information retrieval system) に最初に実装されたため、 "Okapi BM25" と呼ばれるが、単に、この手法自体の名称であるBM25とも呼ばれる。

引用元:Wikipedia - Okapi BM25

 

 引用で逆に分かりにくくになってしまったかもしれないが、文章と単語に対して、定量的な判断基準を作ることができるアルゴリズムは、文章のランク付けに決定に大いに役立ってくれることだろう。

BM25のアルゴリズム

 数式なんて入れ始めたらそれは論文か何かなので、なるべく分かりやすく説明する(この文体が論文っぽさを助長している気がするが...)

 BM25スコアは基本的に以下の3つの要素を組み合わせて算出する。

 Inverse Document Frequency(IDF):

  5つの文書があったとき、その「5つの文書に現れる、すべての単語」を変数として、「その単語は5文書中、いくつの文書で現れるか」という情報から導く数値である。

  IDFはレアな単語ほど数値が高く、日本語で「逆文書頻度」と呼ばれる。下の図で例えると、4つの文書に現れる「B」より、1つの文書にしか現れない「E」の方がスコアが高い。

IDF

  



 Term Frequency(TF):

  5つの文書があったとき、「5つの文書ごとの、すべての単語の出現回数」を変数として、「各文書において、その単語は何回現れるか」という情報から導く数値である。

  IDFがレアなほど数値が高かったのに対し、こちらはたくさん現れるほど数値が高い

  IDFと違い各文書に対してスコアリングを行うため、4つの文書で現れている「B」でも、2回出現するⅣの文書の方が他の文書の「B」よりスコアは高い。また文書内で現れない単語(文書ⅠでいうところのD, E、文書Ⅴで言うところのB, D, E)のスコアは0となる。

TF

 いったんブレイクするが、実はこのIDFとTFでTF-IDFというアルゴリズムができる。「全文書中、単語はどれほどレアか(IDF)」と「各文書中、単語はどれほど頻出か(TF)」を組み合わせることで、「特定の文書にしか現れないが、頻出する単語」のスコアが高くなるアルゴリズムとなる。

 例えば「これ」という単語は「あらゆる文書に現れ、頻出する単語」となる。しかし単純に「頻出する単語」をスコアにしていては、こういった「あらゆる文書に現れ、頻出する単語」がすさまじいノイズとなってしまい、本来文書で焦点が当てられている単語を特定できない。そのため「特定の文書にしか現れない」という条件を加えるわけである。

 このブログで言えば、「文書」や「巨大」、「データ」はそこそこ使っているがこれは「あらゆる文書に現れ、頻出する単語」と言えそうなので比較的スコアは低いだろう。おそらくスコアが高いのは「RAG」や「BM25」などではないだろうか。

 Document Length(DL):

  読んで字のごとくだが、DLは文章の長さを表している。そして、BM25自体も簡単な説明で済ませることができる。

  実はBM25は先ほど説明したTF-IDFで、文書の長さによる影響を考慮したものである。TF-IDFはその特性上、極端に短い/長い文書のスコアリングを定量的に行うことを苦手としていた。そこで、比較的長い文書にはペナルティが与えられるようにするためにこのパラメータ用意するわけである。

 ここまでを総括するとBM25とは「全文書中、単語はどれほどレアか(IDF)」と「各文書中、単語はどれほど頻出か(TF)」と「各文書の長さ(DL)」を組み合わせて、

 「特定の文書にしか現れないが、頻出する単語」を「文書の長さ」による影響を減らして定量的にスコアリングするアルゴリズムとなる。

コード

 ここからコードを書いていく。

 まず、今回の手法だが巨大なデータをチャンク分割して利用する形を取るので、チャンク分割の処理を書く。

C#/// <summary>
/// チャンク分割
/// </summary>
/// <param name="content">データ本文</param>
/// <param name="chunkSize">1チャンクの最低文字数</param>
/// <returns>チャンク本文のリスト</returns>
private List<string> SplitChunks(string content, int chunkSize)
{
    var chunks = new List<string>();
    while (true)
    {
        if(content.Length < chunkSize) 
        {
            chunks.Add(content);
            break;
        }
        
        var sentenceEnd = content.IndexOfAny(new char[] { '.', '。' }, chunkSize);
        if (sentenceEnd == -1 )
        {
            chunks.Add(content.Substring(0, chunkSize));
            content = content.Substring(chunkSize);
        }
        else
        {
            sentenceEnd += 1;
            chunks.Add(content.Substring(0, sentenceEnd));
            content = content.Substring (sentenceEnd);
        }
    }
    return chunks;
}

 

 チャンクサイズを超えたところで「.」もしくは「。」が出現するまでを1チャンクとする。

 オーバーラップなどはせず、最後のチャンクの精査もしないシンプルなものだが、そのあたりはお好みで変えて頂きたい。

 続いて文書内のすべての単語(名詞)を抽出する処理を書く。

C#/// <summary>
/// 名詞の抽出
/// </summary>
/// <param name="content">データ本文</param>
/// <returns>名詞のリスト(重複あり)</returns>
private List<string> ExtractNouns(string content)
{
    var tagger = MeCabIpaDicTagger.Create();
    var nodes = tagger.Parse(content);
    var nouns = nodes.Where(node => node.PartsOfSpeech == "名詞");
    return nouns.Select(noun => noun.Surface).ToList();

}

 

 使用したトークナイザーはNMeCab。今回は名詞以外不要と判断したため名詞のみを抽出している。

 

 また、メインメソッドでこれを呼び出し名詞の一覧を作る場合は必ずHashSetにパースする必要があるので注意。(このまま使うと名詞の重複がある状態になる)

 続いて、各チャンクに各単語が何回出現するかをカウントする処理を書く。

C#/// <summary>
/// Bag of Wordsの作成
/// </summary>
/// <param name="chunks">チャンク本文</param>
/// <param name="nouns">名詞の一覧</param>
/// <returns>各チャンクのBag of Words</returns>
private List<Dictionary<string, int>> CreateBagOfWords(List<string> chunks, HashSet<string> nouns)
{
    var bow = new List<Dictionary<string, int>>();
    foreach (var chunk in chunks)
    {
        var nounsCount = nouns.ToDictionary(item => item, item => 0);
        var chunkNouns = ExtractNouns(chunk);
        chunkNouns.ForEach(noun => nounsCount[noun]++);
        bow.Add(nounsConut);
    }
    return bow;
}

 

 突然新しい言葉を出してしまい申し訳ないが、Bag of Words(BoW)は各文書にその単語が何回現れるかカウントしたものという認識で構わない。ただしその文書中に単語が現れなくとも、それは「0回出現」という評価の指標になるため、BoWを正しく表するなら、「共有の単語帳の中にある単語が、その文書で何回現れたか」である。単語帳は nouns で、文書は chunk に当たる。

 

 最後にBM25の計算用のメソッドを一気に書く。本ブログではBM25の細かい説明を一切していないため、このコードを試そうと思っている方は一度しっかり計算式まで調べたほうが良いだろう。

/// <summary>
/// TFスコアの算出
/// </summary>
/// <param name="bow">Bag of Words</param>
/// <returns>単一のBoWに対するTFスコア</returns>
private List<double> CalculateTF(Dictionary<string, int> bow)
{
    var nouns = bow.Keys;
    var nNouns = nouns.Select(noun => bow[noun]);
    var totalNouns = bow.Select(e => e.Value).Sum();

    var tfScores = nNouns.Select(value => ((double)value / (double)totalNouns)).ToList();
    return tfScores;
}

/// <summary>
/// IDFスコアの算出
/// </summary>
/// <param name="bows">全チャンクのBag of Words</param>
/// <returns>IDFスコア</returns>
private List<double> CalculateIDF(List<Dictionary<string, int>> bows)
{
    var idfScore = new List<double>();
    var nChunk = bows.Count;
    var nouns = bows[0].Keys;
    foreach (var noun in nouns)
    {
        var dfti= bows.Where(e => e[noun] > 0).Count();
        var idfti = Math.Log((nChunk - dfti + 0.5) / (dfti + 0.5));
        if (idfti < 0) { idfti = 0.0; }
        idfScore.Add(idfti);
    }
    return idfScore;
}

/// <summary>
/// 平均DLの算出
/// </summary>
/// <param name="bows">全チャンクのBag of Words</param>
/// <returns>DLの平均値</returns>
private double CalculateAvgDL(List<Dictionary<string, int>> bows)
{
    var nChunk = bows.Count;
    var totalNouns = bows.Select(e => e.Select(e => e.Value).Sum()).ToList();
    var avgDl = totalNouns.Sum() / Math.Abs(nChunk);
    return avgDl;
}

/// <summary>
/// BM25の算出
/// </summary>
/// <param name="bows">全チャンクのBag of Words</param>
/// <param name="k">0以上の実数</param>
/// <param name="b">0から1の実数</param>
/// <returns>全てのチャンクのBM25スコア</returns>
private async Task<List<List<double>>> CalculateBM25Async(List<Dictionary<string, int>> bows, double k, double b)
{
    // TFスコアの算出
    var tfTasks = new List<Task<List<double>>>();
    tfTasks.AddRange(bows.Select(bow => Task.Run(() => CalculateTF(bow))));
    var tfTaskResult  = await Task.WhenAll(tfTasks);
    var tfScores = tfTaskResult.ToList();

    // IDFスコアの算出
    var idfScore = CalculateIDF(bows);

    // 平均DLの算出
    var avgDl = CalculateAvgDL(bows);

    var bm25Scores = new List<List<double>>();
    var nChunk = bows.Count();
    var nouns = bows[0].Keys;
    var totalNouns = bows.Select(e => e.Select(e => e.Value).Sum()).ToList();
    for (var i = 0; i < nChunk; i++)
    {
        bm25Scores.Add(tfScores[i].Select(e => (idfScore[i]) * (e * (k + 1)) / ((e + k) * (1 - b + b * totalNouns[i] / avgDl))).ToList());
    }
    return bm25Scores;
}

 後はこれらのメソッドをまとめ上げる。本来であれば文章の整形など様々な対応を取ることで精度を担保するのだが今回はここに記述したものをまとめ上げるだけにする。

/// <summary>
/// BM25スコアでランキングを行う
/// </summary>
/// <param name="userRequest"></param>
/// <param name="content"></param>
/// <returns>ランキングしたチャンクのリスト</returns>
public async Task<List<string>> RankingChankAsync(string userRequest, string content)
{
    var chunks = SplitChunks(content, 3000);
    var nouns = ExtractNouns(content).ToHashSet();

    // Bag of Wordsの作成
    var bowTasks = new List<Task<Dictionary<string, int>>>();
    bowTasks.AddRange(chunks.Select(chunk => Task.Run(() => CreateBagOfWords(chunk, nouns))));
    var bowTaskResult = await Task.WhenAll(bowTasks);
    var bows = bowTaskResult.ToList();

    // BM25スコアの算出
    var bm25Scores = await CalculateBM25(bows, 2.0, 0.75);

    // ユーザリクエストに含まれる名詞を取得
    var userRequestNouns = ExtractNouns(FormatSentence(UserRequest));
    var nounList = nouns.ToList();
    var indices = new HashSet<int>();

    // ユーザリクエストに含まれ、かつBoW内に存在する名詞のインデックス番号を取得
    foreach (var userRequestNoun in userRequestNouns)
    {
        var nounIndex = nounList.IndexOf(userRequestNoun);
        if (nounIndex == -1) { continue; }
        indices.Add(nounIndex);
    }

    // 上記のインデックス番号でBM25スコアを取り、単純な合計値でランクとする
    var bm25ScoreList = bm25Scores.Select(e => indices.Select(index => e[index]).Sum()).ToList();
    var pairs = bm25ScoreList.Select((value, index) => new { Value = value, Index = index }).ToList();
    pairs.Sort((x, y) => y.Value.CompareTo(x.Value));
    var rankedChunks = pairs.Select(pair => chunks[pair.Index]).ToList();
    return rankedChunks;
}

検証

 コードができたので実際に検証をしてみる。

 LLMはGPT-4 1106previewを使用する。

 使用するデータはJAXAの安心信頼性推進部というサイトにある。「宇宙用高圧ガス機器技術基準」というPDFをテキストにしたものを使用する。

 ひとまず小手調べに適当な内容を聞いてみる。

Diff質問 : 宇宙用高圧ガス機器の製造工程で特に注意すべき点は何ですか?​

解答 : 

1. **参考文書**
 参考となる文書には、圧力容器の設計、溶接施工方法の確認試験、品質保証プログラム標準、宇宙用高圧ガス機器技術基準解説書などがあります。これらの文書は、宇宙用高圧ガス機器の製造における基本的なガイドラインを提供しています。
 
2. **適用範囲**
 宇宙用高圧ガス機器の適用範囲には、ロケット推進薬タンク、気蓄器、フランジ部、配管などが含まれます。これらの機器は、特定の技術基準に従って製造される必要があります。
 
3. **用語の定義**
 技術基準における用語の定義を理解することが重要です。例えば、「アウトガス」は物質が高真空や高温に曝されたときに放出される気体の現象を指し、「圧力容器」は一定の圧力以上の圧縮ガスや液化ガスを貯蔵する容器を意味します。
 
4. **損傷予防管理**
 損傷予防管理計画に従い、損傷予防管理を実施し、記録することが求められます。不具合対応などの計画外作業は、損傷予防管理計画に基づいて検討し、実施する必要があります。
 
5. **損傷許容設計**
 損傷許容設計を実施する場合は、その実証が必要です。これには、解析や試験による実証が含まれます。

6. **損傷管理に対する配慮**
 防護層の設置や設計標定部の強度余裕の確保など、損傷管理に対する配慮が必要です。

7. **製造工程**
 高圧ガス機器の製造工程は、信頼性、作業性、コストのトレードオフを考慮して適切に行われるべきです。機械加工、板金加工、溶接、熱処理などの工程が含まれます。

8. **溶接施工管理**
 溶接施工管理は、溶接施工要領書に基づいて行われ、溶接条件の記録や開先部の清掃状態の確認などが重要です。

9. **熱処理**
 焼なまし、溶接後熱処理、溶体化、時効などの熱処理が適切に行われるべきです。

10. **非金属ライナの成形**
 非金属ライナの成形には、原材料の有効寿命管理や接着剤の配合成分比などの管理が必要です。

11. **フィラメントワインディング**
 フィラメントワインディングは、試作試験で設定した基準に従って適切に管理されるべきです。


宇宙用高圧ガス機器の製造工程では、参考文書に基づいた適用範囲の理解、用語の定義の把握、損傷予防管理計画の遵守、損傷許容設計の実証、損傷管理への配慮、製造工程の適切な実施、溶接施工管理、熱処理、非金属ライナの成形、フィラメントワインディングの管理など、多岐にわたる注意点があります。これらの点を適切に管理することが、高品質な宇宙用高圧ガス機器を製造するためには不可欠です。

 

 項目1~3は何とも言えないが、それ以降は良い感じではないだろうか。

 しかし、最もスコアの高いチャンクとして参考文書のチャンクを取得している。参考文献の欄に「宇宙用高圧ガス」という単語が密集しているので、それでスコアが上がってしまったのだろう。

 これは、本や文書のタイトルは簡潔で分かりやすいため発生してしまう現象だと考えらる。

 BM25のランキングに置いては、参考文献をまとめて一か所に置くスタイルはあまりよくないということなのだろう。

 精度に関して言えばまだ改善の余地があるBM25ランキングだが、最も特筆すべきはやはり実行速度である。

 今回使用したJAXAのテキストは文字数にして9万文字を超えるが、チャンク分割からランキングの完了までの時間は0.3秒程度である。

 LLMを用いたプロダクトはやはり生成速度が重要になるが、最初の一文字が生成されるまでのタイムラグをほとんど感じさせないこの処理速度はかなり魅力的と言える。


おわりに

 検証を行ったBM25による巨大なデータの処理は、改善の余地ありという結果にまとまると考える。

 重ねて言うが、現状RAGのシステムやデータを抜き出す仕組みに正解は存在しない。精度、速度、コストなど様々な観点からその時々で最適なものを使う、もしくは作るしかない。

 BM25は、基本的にお金を掛けずに試すことのできるデータ処理であるため、とっつきやすい部類だとは思う。パラメータの調整を行う、他の手法と組み合わせる、チャンク分割手法を変える、など様々なアプローチを行い。より良いものができれば、どこかで使う機会があるかもしれない。