こんにちは、サイオステクノロジーの佐藤陽です。
今回はRAGの構築における「セマンティックチャンキング」の手法についてお話をしたい思います。
セマンティックチャンキング実装してみたいな、と思っている方は是非最後までご覧ください!
はじめに
今回のチャンキングおよびインデクシングの構成としては以下のような形です。
- DocumentIntelligenceのLayoutモデルを活用し、PDFをMarkdown形式で出力
- LangChainのMarkdownHeaderTextSplitterを利用し、
出力されたMarkdownのHeader情報(#, ##)でチャンク化 - チャンク化した文章をベクトルストアに格納
この大きな流れに関しては以前の記事でも紹介したので、こちらを参照ください。
今回実装を行っていたRAGに関しても、この流れでインデクシングを行おうと思い取り組みました。
「上記したような流れですんなり実装できるだろうな」と思い実装の方を進めていたのですが、
いざ実装をすると想定と異なる部分もあり、やや工夫が必要なポイントがあったので、その点を紹介していきたいと思います。
要件
今回扱うPDFデータとしては、以下のようなものです。
(出典:モデル就業規則について – 厚生労働省)
ポイントとしては「第〇章と第〇条という親子関係になっている」
という点です。
就業規則や、会則などにおいてよくあるような形式なのではないでしょうか?
例えば今回であれば以下のような形です。
- 第2章 採用、異動等
- 第4条 採用手続
- 第5条 採用時の提出書類
そして今回は、こういったドキュメントに対して、章の部分で区切ったチャンク化をしたいという要望があるとします。
いわゆるセマンティックチャンキングと呼ばれるチャンク戦略です。
章
で区切ることにより、意味ある塊でのチャンキングが可能となり、Retrievalの精度の向上が見込めます。
ちなみに章
ではなく、条
のほうでチャンク化した方がチャンクサイズとしては小さくなるのですが、
その場合、章
の内容が分断され、章全体に対する質問への回答品質が低下することが懸念されました。
そのため今回においては、チャンクサイズは大きくなりますが章
のレベルでチャンク化するものとします。
なお、このあたりはアプリに対する要件によって実装が異なってくる部分であると思うので 要求に対して適切なチャンク戦略をとってみてください。
ちなみに今回の記事の話はここは重要なポイントではないので、どちらの戦略をとる場合でもお読みいただけます。
課題
今回、このPDFに対して最初にも示したような処理を行いました。
この時、DocumentIntelligenceにおけるMarkdown出力の値を見たところ 以下のような内容で出力が行われました。
# 第1章 総則
## 第1条 目的
## 第2条 適用範囲
## 第3条 規則の尊守
## 第2章 採用、異動等 //本当は'#'であってほしい
## 第4条 採用手続き
## 第5条 採用時の提出書類
## 第6条 ...(略)...
# 第3章 服務規律
注目ポイントとしては、第2章
のヘッダーの大きさが##
で表されている点です。
これではMarkdownHeaderTextSplitterにおいて#
の単位で分割すると、以下のようにチャンク化が行われてしまいます。
chunk[0]: 第1章~~~
chunk[1]: 第3章~~~
##
で判定されてしまうこと自体はDocument IntelligenceのLayoutモデルの性能に依存してくるので、対処しようがないかなとは思います。
PDFを見た限りでは第1章も第2章も同じようなフォーマットで描かれているのですが…。
もしどうしてもDocument Intelligence使うのであれば、カスタムモデルで精度上げる等の手段を取ることになると思います。
推しのDocument Intelligenceでしたが、こうなっては仕方ないので代替案を探しました。
解決策
LangChainに頑張ってもらうことにしました。
以下のような構成に変更しました。
今回はチャンク化したい区切りが第〇章
といった形で、分かりやすく定義されています。
なのでTextSplitterのseparatorのパラメータにこの文字を与えてあげれば区切れそうです。
ただ、章の数値は変動するので、いちいち全部をseparatorへは登録できません。
「どうしたらいいかなぁ」と思って調べていたら、separatorの値には正規表現が使える事が分かりました。
RecursiveCharacterTextSplitterの引数としてis_separator_regex
というものが用意されており、
この値をTrueに設定することでseparator
の値が正規表現の文字列として扱われます。
実際書いてみると以下のような形です。
text_splitter = RecursiveCharacterTextSplitter(
separators=["第[0-9]{1,3}章 ","第[0-9]{1,3}条 "],
chunk_size=1024,
chunk_overlap=0,
length_function=len,
is_separator_regex=True, #正規表現を有効にする
)
result = text_splitter.split_text(content)
今回Separatorの値として、2つの正規表現を定義しました。
この実装の処理としては
- chunk_sizeに収まるように第〇章のところで区切る
- 〇の値としては数値に限定し、3桁まで対応
- 仮に1章と2章の合算がchunk_size(1024)に収まるようであれば、1つのchunkの中に入れられる
- 1.の処理でチャンク化した際に、1つの章のチャンクサイズがchunk_sizeを超えるようであれば、再帰的に第〇条でchunkを行う
といった形となっています。
その結果以下のようにチャンク化されました。
想定した通りのチャンキングが行われていることが確認できます。
懸念点
ここでいくつか懸念点を挙げておきます。
チャンクサイズの肥大化
まず1点目はチャンクサイズの肥大化です。
これはこの記事の最初にも述べていますし、想定内のことではあるのですが
章ごとに区切ることで意味がまとまったチャンクとして扱えるのですが、チャンクサイズが想像以上に大きくなることが懸念されます。
そのため今回は
- 章でチャンク化することをベースとする
- 許容できるchunk_sizeを決めておき、それを上回る場合はチャンクサイズに収まるよう条でも区切るようにする
といった形で対応しましたしました。
ただその分、今回の目的である「章
で区切りたい」という目的からは少し逸れてしまっており、その点が悩ましい所です。
このchunk_sizeの設定に関しては、適切にチューニングを行って行く必要がありそうです。
意図しないチャンキング
タイトルではなく、文章中の第〇条
等に反応してチャンキングしてしまう可能性があることです。
社内規約の中だと例えば「第30条に記載のある~」といった、他の項目を参照するような記載はよくあると思います。
今回は
["第[0-9]{1,3}章 ", "第[0-9]{1,3}条 "]
といったように、正規表現の最後に半角スペースを入れることによって、文中で出てきたワードは外すような工夫をしました。
ただ、これも絶対ではないですし、場当たり的な対応は否めないです。
この点、Markdown出力であれば#
として区切り文字が表れるので、見分けがつきやすいかと思います。(#の個数はおいておき。)
なので、やるとすれば 今回の正規表現を使うケースにおいても、Document IntelligenceとしてはMarkdown出力をしておき
["# 第[0-9]{1,3}章", "# 第[0-9]{1,3}条"]
といったようなSeparatorを用意することで、#
を活用したチャンク化が行えそうですね。
まとめ
今回はDocument IntelligenceのMarkdown出力を使ってセマンティックチャンキングを実装してみたのですが
意外とうまくいかなかったので、その時の代替案をご紹介しました。
あんまりスマートな実装ではないですが、なんとか意図したセマンティックチャンキングは実現できました。
今後のDocument IntelligenceのMarkdown出力の精度向上に期待ですね!
ではまた!