Building with the Claude API 第4章 — RAG / Agentic Search
Lesson: Introducing Retrieval Augmented Generation(RAGの導入)
このモジュールでは、retrieval augmented generation(検索拡張生成)、略して RAG と呼ばれる技術について多くのことを議論していく。この最初のビデオでは、RAG とは一体何なのかについて、しっかりとした全体像をつかんでもらうことを目指す。理解を助けるために、非常に手早い例をウォークスルーする。
例で考える課題設定
非常に大きな財務ドキュメント(right-hand side に見えるようなもの)があると想像してほしい。この中には膨大な量のテキストがあるかもしれない。誰にもわからないが、100ページから1000ページくらいあるかもしれない。そして、そのドキュメントのある特定の領域について、Claude にとても具体的な質問をしたいとする。例えば「この会社にはどんなリスク要因があるか」というような質問である。おそらくこのドキュメントの中には、何らかの関連情報が含まれているはずである。ここで、非常に根本的な課題を解決する必要がある。このドキュメントから情報を取り出し、それを Claude に渡して質問に答えてもらうには、どうすればよいか。
この問題を解決する2つの方法を見ていく。
選択肢1: すべてのテキストをプロンプトに詰め込む
選択肢1は、ドキュメントからすべてのテキストを取り出し、right-hand side に見えるようなプロンプトに直接そのまま入れてしまう、というものである。ユーザーの質問に答えるよう Claude に依頼し、そこにユーザーの質問を入れ、さらにドキュメントから取り出したすべてのテキストもプロンプトに入れる。
しかし、これはおそらく最善の解決策ではない。うまくいくこともあるし、うまくいかないこともある。まず知っておくべきこととして、Claude に投入できるテキスト量には明確な上限(ハードリミット)がある。 したがって、このドキュメントが本当に長く、そのすべてのテキストを取り出して Claude に投入してしまうと、その場ですぐにエラーが出てしまう可能性がある。つまり、ドキュメントが本当に長い場合、この解決策は最初からうまくいかない。
このアプローチの2つ目の問題は、プロンプトが長くなるほど Claude の効果が少しずつ落ちていくということである。プロンプトに膨大な量のテキストを入れ始めると、Claude はプロンプトの中に本当に大量の情報があるために、ユーザーが本当に望んでいることを理解し、質問に答えることが単純に難しくなっていく。
そして最後に、より長いプロンプトは処理コストが高くなり、処理時間も長くかかる。 つまり金銭的な負担があり、さらにユーザーは答えが返ってくるまでより長く待たされるという、ユーザー体験上の負担もある。
選択肢1はいくつかのシナリオではうまくいくかもしれないが、他のシナリオでは完全に失敗する可能性がある。そこで選択肢2を見てみよう。
選択肢2: ドキュメントをチャンクに分割して関連部分だけを渡す
選択肢2はもう少し複雑で、2つの別々のステップから成る。
ステップ1: ドキュメントからすべてのテキストを取り出し、小さなチャンクに分割する。
ステップ2: ユーザーが質問をしたら、その質問を先ほどと同じようにプロンプトに入れる。しかし、そこにもう一つ小さなステップを追加する。ユーザーの質問を注意深く調べ、その質問に最も関連していそうなテキストのチャンクを見つける。例えば、ユーザーが「この会社はどんなリスクに直面しているか」と質問し、リスク要因についてのチャンクがあれば、そのチャンクを取り出してプロンプトの中に含める。こうすることで、Claude の注意全体を、財務ドキュメント全体のうちのほんの小さな一部分に集中させることができる。そして、先ほどドキュメント全体をプロンプトに入れていたときよりも、Claude はユーザーの質問にずっとうまく答えられることが期待できる。
選択肢2(RAG)のメリットとデメリット
選択肢2には、明確なメリットとデメリットがある。
メリット:
- Claude は関連するコンテンツだけに集中できる。
- 本当に大きな、ページ数の多いドキュメントにもスケールできる。
- 複数のドキュメントがある場合にも機能する。異なるドキュメントをすべてチャンクに分割し、ユーザーの質問に関連するチャンクだけをプロンプトに含めればよい。
- この技法は一般に、はるかに小さいプロンプトにつながる。これは実行時間が短くなり、コストもずっと安くなることを意味する。
デメリット:
- 単純に複雑さが大きく増す。ドキュメントからテキストを取り出してチャンクに分割する前処理ステップが必要になる。
- これらすべてのチャンクを検索して、ユーザーの質問に最も関連するものを見つける何らかの方法も考え出さなければならない。そもそも「ユーザーの質問に関連する」とはどういうことかを定義する必要すらある。
- 関連するチャンクを見つけてプロンプトに含めたとしても、それが Claude が質問に答えるために必要なすべての文脈を含んでいるという保証は実はない。ユーザーが「この会社はどんなリスクに直面しているか」と質問し、リスク要因セクションだけを含めた場合、ドキュメントの他の重要な領域(例えば戦略概観セクションで、そうしたリスクの一部が何らかの形で言及されている場合)を見落としてしまうかもしれない。
- そして最後に、テキストを分割する方法は数多く存在する。 ドキュメント全体を取り出して均等な分量に分割することもできるし、ドキュメントの中のさまざまなヘッダーを見つけて、ヘッダーごとに新しいチャンクを作る、という方法もある。チャンクの定義の仕方には多くの方法があり、どの技法が自分の特定のアプリケーションに最適かを見極めるために、ある程度の評価作業が必要になる。
もう予想がついているかもしれないが、選択肢2こそが RAG(retrieval augmented generation) である。今見てきたように、RAG には大きなメリットも大きなデメリットもあり、技術的な課題も多い。前処理ステップが必要であり、関連するチャンクを見つけるための何らかの検索メカニズムを考え出す必要があり、ドキュメントをチャンクに分割する必要もある。要するに、選択肢1よりもずっと多くの作業が必要になる。したがって、アプリケーションに RAG を実装することを検討するときは常に、こうしたすべてのステップを分析し、自分の特定のユースケースにとって RAG が本当に適切かどうかを判断する必要がある。
これで RAG が何であるかについての非常に高いレベルの理解ができた。次は、この実際のプロセスの実装を見ていく。
Lesson: Text chunking strategies(テキストのチャンク分割戦略)
このビデオと続く数本のビデオでは、いくつかの notebook の中で、独自のカスタム RAG ワークフローを実装し始める。まずは可能な限り最もシンプルで基本的な RAG のセットアップを作り、その後、時間をかけていくつかの追加ステップを加えていく。
おさらいとして、典型的な RAG パイプラインは、簡略化するとおおよそ次のようになる: ソースドキュメントを取り、テキストのチャンクに分割し、ユーザーが質問をしたら関連するチャンクを見つけてプロンプトに入れる。それがほぼ全体である。
この全体のフローのステップ1は、ソースドキュメントを取り、テキストのチャンクに分割することである。信じられないかもしれないが、ドキュメントを取り出して別々のチャンクに分割するこのプロセスは、RAG パイプライン全体の中でも特に複雑なステップの一つである。単純に、ドキュメントをどうチャンク分割するかが、RAG パイプラインの品質に大きな影響を与えるからである。ここで、その理由を理解してもらうための例をすぐに見てみよう。
なぜチャンク分割が難しいのか: 具体例
ある会社からの何らかのレポートを表しているような、ごく短い数行のソースドキュメントを見てみる。ざっと読んでみると、大きく3つの領域があることがわかる。ヘッダー、医療研究についてのセクション、そしてソフトウェアエンジニアリングについてのセクションである。
このドキュメントを分割する方法は数多くあるが、ここでは一つの方法を提案する。ドキュメントの中の別々の行ごとに、それぞれ別のチャンクを作る、という方法である。そうすると、5つほどの別々のチャンクができあがる。
これらの各チャンクを見てみると、非常に興味深いことに気づく。3番目のチャンクは、完全に医療研究についての内容である。これは医療研究セクションの中にあったものだ。しかし、そこには "bug" という単語が含まれている。つまり、この段落だけをざっと見ると、"bug" という単語が含まれているために、まるでソフトウェアエンジニアリングについての内容であるかのように見えてしまう。そして同様に、下の方にあるソフトウェアエンジニアリングのセクションの中には "infection vectors"(感染経路)という言葉がある。"infection vectors" は医療用語に近い言葉である。つまり、こちらもまた、ソフトウェアエンジニアリングについてのセクションでありながら、その中の言葉遣いがどこか医療研究についてのもののように見えてしまう。
ここで、これらのチャンクを RAG パイプラインに追加したらどうなるかを考えてみてほしい。ユーザーが「今年エンジニアは何個の bug を修正したか」というような質問をしたとする。RAG パイプラインの一部として行うべき仕事は、ユーザーの質問に最も関連するチャンクを見つけることである。ユーザーは "bug" について何か言っている。そこで、一見すると、"bug" という単語を含んでいるこのチャンクが関連していそうに見える。そこでこのチャンクをコンテキストとしてプロンプトに追加してしまうかもしれない。
そして、すぐにわかるように、これは大きな間違いである。ユーザーはレポートからソフトウェアエンジニアリングについての何かを理解したいのであり、当然そちらのセクションが欲しかったはずなのに、誤って医療研究についての何かを取得してしまった。これは、チャンク分割戦略が簡単に大きなエラーと、プロンプトへの非常に悪いコンテキスト挿入を引き起こしてしまう例である。
この問題を解決するために、ソースドキュメントをどう別々のチャンクに分割するかについて、多くの時間をかけて考えていくことになる。
3つのチャンク分割戦略
このビデオでは、ドキュメントを別々のテキストのチャンクに分割するための3つの異なるチャンク分割戦略(あるいは手法)を扱う。それぞれが、先ほど見たような問題に対処するための何らかの特徴や技法を持っている。size-based(サイズベース)、structure-based(構造ベース)、semantic-based(意味ベース) の3つを議論する。
size-based chunking(サイズベースのチャンク分割)
最初に扱うのは size-based chunking である。これは、大きなドキュメント(大きなテキストの塊)を取り、それをほぼ等しい長さの文字列にいくつか分割するというものである。これは実装するのが断然簡単な技法であり、実務での実装でもおそらく最もよく目にする技法である。
サイズベースのチャンク分割では、元のドキュメントを取り、ほぼ等しい長さの文字列にいくつか分割する。今回の場合、約325文字のソースドキュメントがあるとする。これを、まったく恣意的に3つの別々のチャンクに分割することに決めたとする。そうすると、各チャンクはだいたい108文字ほどになる。最初の108文字をチャンク1に、次の108文字をチャンク2に、というように、ドキュメント全体で繰り返す。
これは非常にシンプルな技法だが、すぐに大きな欠点がある。それは、それぞれのチャンクが、途中で切れてしまった単語をいくつか含んでしまう可能性が高いということである。実際、最初のチャンクには "significant" という単語が途中で切れて含まれている。最初のチャンクには "significant" のうち "key" までしか入らず、その続きは次のチャンクで終わる、という具合である。加えて、各チャンクはコンテキストを欠いてしまう。例えば、下にある3番目のチャンクは、残念ながらすぐ上にあったはずのセクションヘッダーを含んでいない。このセクションヘッダーがあれば、このテキストが本当は何について話しているのかについて、多くのコンテキストを与えてくれたはずである。
size-based chunking を使うとすぐに出てくるこの問題を解決するために、overlap(オーバーラップ)戦略を実装できる。overlap 戦略とは、依然としてサイズベースのチャンク分割を行うが、隣接するチャンクから少しだけオーバーラップ(重なり)を含める、というものである。例えば、元のチャンク1があるとして、その次のチャンクから何文字か含めることに決める。この場合、"significant" の残りの部分と、その文全体の終わりまでを含めることになる。そうすると、もう少し意味の通ったチャンクができあがる。そしてチャンク2についても、本体はそのままの領域だが、チャンクの前後から何文字かのオーバーラップを含める。
この戦略では、かなりの量の重複したテキストができることになる。例えば、この場合、"Section 1 Medical Research" が2番目のチャンクの中にも含まれており、それは最初のチャンクにも含まれていたものである。つまりテキストの重複はあるが、その見返りとして、各チャンクが一般により多くのコンテキストを与えてくれるというメリットがある。
structure-based chunking(構造ベースのチャンク分割)
次に見る種類の戦略は structure-based chunking である。これは、ドキュメント全体の構造に基づいてテキストを分割するというものである。ヘッダーや段落、あるいは一般的なセクションを見つけて、それらを各チャンクの区切り線として使う。
このドキュメントで、この戦略を実装するのは非常に簡単だろう。なぜなら、このドキュメントは markdown 構文で書かれているからである。それは、各セクションに小さなポンド記号(二重ハッシュ)があることからわかる。このポンド記号を探し、この記号を見るたびに新しいセクションが始まっていると判断すればよい。ダブルハッシュ文字で分割するコードをプログラム的に書くのは非常に簡単で、右に見えるような、かなりきれいに整形されたセクションが得られるはずである。
これは素晴らしい戦略のように聞こえるかもしれないが、残念ながら現実はそう都合よくいかないことが多い。多くの場合、markdown 構文で全くフォーマットされていないドキュメントを取り込もうとすることになる。プレーンなテキストだけを含む普通の PDF ドキュメントかもしれず、その場合はこうした明確に区切られたセクションは得られない。つまり、これは素晴らしい技法のように見えても、実装するのは実際には非常に難しい場合がある。特に、異なるドキュメントの構造について何の保証もない場合はなおさらである。
semantic-based chunking(意味ベースのチャンク分割)
最後に議論するチャンク分割戦略は semantic-based chunking である。これは、すべてのテキストを取り、文やセクションに分割し、それから何らかの自然言語処理の技法を使って、連続する各文がどれだけ関連しているかを判断する、というものである。そして、こうした何らかの形で関連している文やセクションのグループから、チャンクを組み立てていく。
説明からもわかる通り、これは明らかにずっと高度な技法であり、実際の実装については深くは踏み込まない。これに言及する唯一の理由は、チャンク分割戦略には決まった有限の固定数があるわけではないことを明確にするためである。テキストの分割の仕方には本当に無限の方法があり、どの手法を使うかを決めるのは、結局のところ、自分の特定のユースケースと、取り込もうとしているドキュメントについてどんな保証があるかによる。
notebook でのデモ: 001 chunking
先に進む前に、非常に手早い例を見ていく。3つの異なるチャンク分割戦略を実装した Jupyter notebook が用意されている。001 chunking という名前の notebook を見つけてほしい。また、付属の report.md ファイルをダウンロードして、その notebook と同じディレクトリに置いておく必要がある。この report.md ファイルには、RAG パイプラインの実装を学ぶ際のテスト用に使う、架空の小さなサンプルレポートが入っている。
この中には、いくつかの異なるセルがある。
1つ目のセル: chunk by character(文字によるチャンク分割) — これは size-based 戦略に基づく実装で、テキストをほぼ等しい長さの文字列に分割し、さらにいくらかのオーバーラップも持たせる。引数は、テキスト、各チャンクのサイズ、そしてある程度のチャンクオーバーラップ(チャンクの両側に持たせたい文字数)である。
2つ目のセル: chunk by sentence(文によるチャンク分割) — 似たような考え方だが、今度は正規表現を使ってテキストを個々の文に分割し、各チャンクはいくつかの文(オプションで両側に少しのオーバーラップを付けて)から構成される。
3つ目のセル: chunk by section(セクションによるチャンク分割) — ドキュメントの構造とその正確な内容について強い保証がある場合、structure-based chunking の例として chunk by section を試すことができる。この例では、改行文字、二重ポンド記号、スペースを探し、それを分割の基準とする。そうすると、Executive Summary が最初のチャンクになり(正確にはこれが最初のチャンクで、そこから目次までのすべてが2番目のチャンクの先頭になる)、次のチャンクは Methodology、そして Section 1、と続いていく。これは各チャンクに最良のフォーマットを与える。なぜなら各チャンクはちょうど1つのセクションから構成されるからである。しかし、これはドキュメントの構造について保証があるからこそ機能する。このドキュメントが markdown であり、新しいセクションが始まる場合にのみ改行・ポンド・ポンド・スペースが現れるとわかっているからこそ機能する。
実際に試してみる
chunk by character を試す: まずデフォルト設定(チャンク長150、オーバーラップ20)で実行してみると、デフォルト設定はあまり良いチャンクを生成しないことがすぐにわかる。それぞれのチャンクにあまり意味がなく、「この文が本当に何の役に立つのか、ユーザーの質問に答えるのに使えるのか」というレベルである。そこでデフォルト設定を大きく変更してみる。チャンク長を500、オーバーラップを150にしてみると、少し良くなる。実際のセクションの形成が見え始め、少し情報を与えてくれるようになる。また、オーバーラップもすぐに気づくようになる。"addressing complex challenges" というフレーズが、すぐ上のチャンクにも含まれていることが確認できる。これがオーバーラップの例である。
chunk by sentence を試す: 再びデフォルトの引数を使う。これはかなり良さそうに見える。デフォルトでは各チャンクに5つの文、1文のオーバーラップが含まれ、正規表現を使って各文を分割している。文を正しく分割できないケースもあるかもしれないが、一見したところ、これはかなり良さそうである。各チャンクがしっかりとした量の情報を与えてくれているように見える。
chunk by section を試す: 実行すると、最初のチャンクにはあまり有用な情報が含まれないが、それ以降はすべて非常に強力である。まさに1セクションずつ取得できているからだ。Executive Summary、目次、Section 1、Section 2、Section 3、と続く。
まとめ: どの戦略を使うべきか
どの戦略を使うかは、完全にドキュメントの性質と、その構造についてどんな保証があるかによる。今回の場合、chunk by section が最も素晴らしく見える。しかし、フォーマットについて何の保証もないユーザー提供のドキュメントを受け取ることを想定しているなら、chunk by section はうまくいかないだろう。その場合、chunk by sentence にフォールバックすることになる。しかし、これもうまくいかない場合がある。例えば、ユーザー提供のコードをチャンク分割しようとしている場合を考えてほしい。コードを個々の文に分割しようとすると、コードには予期しない場所にピリオドが含まれることが多いため、おそらく予期しない結果になってしまう。そうなると、古くから信頼できる標準、つまり chunk by character にフォールバックすることになる。chunk by character は最良の結果を保証するものではないが、大部分の場合はうまく機能し、合理的にうまくいく。
Lesson: Text embeddings(テキストエンベディング)
ソースドキュメントからいくつかのテキストチャンクを抽出した後、RAG パイプラインの次のステップは、ユーザーが質問やクエリのようなものを送信してくるのを待つことである。それが起きたら、すべての異なるテキストチャンクを見て、ユーザーの質問に何らかの形で関連していそうなものをいくつか見つけ、それらをコンテキストとしてプロンプトに追加する必要がある。
この「ユーザーの質問に関連するいくつかのテキストチャンクを見つける」というプロセスには、多くの複雑さが隠れている。しかし、よく考えてみると、これは本質的に検索問題である。ユーザーの質問を取り、すべての異なるチャンクを検索して、関連するコンテンツを見つけ、それを何らかの形で表出させたい。
RAG パイプラインの中でこれを実装する最も一般的な方法は、semantic search(セマンティック検索)と呼ばれるシステムを実装することである。Semantic search は、各テキストチャンクが何について話しているかをよりよく理解し、ユーザーの質問に最も関連するチャンクを何らかの形で見つけるために、text embeddings(テキストエンベディング)と呼ばれるものを利用する。
text embedding とは何か
Text embedding とは、あるテキストに含まれる意味の数値表現である。これらのテキストエンベディングは、embedding model(エンベディングモデル)と呼ばれるものによって生成される。"I'm very happy today"(私は今日とても幸せだ)のようなテキストをエンベディングモデルに入力すると、モデルは長い数値のリストを出力する。この長い数値のリストが、実際のエンベディングである。なお、-1 から +1 という範囲は厳密にはエンベディング同士を比較する際のコサイン類似度の値域であり、エンベディングベクトル自体の個々の数値がその範囲に収まると数学的に保証されているわけではない。ただし、Voyage AI のように長さ(L2ノルム)が1になるよう正規化されたベクトルであれば、各成分の絶対値は理論上1を超えない。実際の値は次元数が大きいほど非常に小さくなる(-0.013、0.0198 のような値になる)のが一般的だが、以下では理解しやすくするために、これらの数値を意味のスコアとしてイメージしていく。
では、これらの数値は実際には何を意味しているのだろうか。エンベディングの中の各数値は、入力テキストの何らかの性質のスコアを表している。ここで少しややこしくなる。というのも、この図では2つの相反するアイデアが示されているからである。この点は非常にはっきりさせておきたい。
実際には、エンベディングの中の各数値が実際にどんな性質と結びついているのかはわからない。 最初の数値にラベルを付けて「これはテキストがどれだけ幸福かのスコアです」と言うのは、正確ではない。最初の数値が本当は何を表しているのか、我々は単純にわからない。それでも、これらの数値をそのように考えることは非常に有用である。最初の数値がテキストの幸福度のスコアかもしれない、2番目の数値がテキストがどれだけフルーツについて話しているかのスコアかもしれない、と想像するのは有用である。これらのラベルは完全に講師が作り上げたものであり、各数値が実際に何を表しているのかは我々にはわからないが、この方法でエンベディングについて考えるのは非常に有用である。だから、これが各数値を実際にイメージする方法である。それらは、入力テキストのさまざまな性質の何らかのスコアのようなものである。
エンベディングの生成方法: Voyage AI
最後に理解すべきなのは、これらのエンベディングを実際にどう生成するかである。Claude は現時点ではエンベディング生成を提供していない。 代わりに、推奨されるプロバイダーは Voyage AI である。これは別の会社であり、別のアカウントの登録と別の API key が必要になる。しかし、無料で始められ、使うのも非常に簡単である。このレクチャーには、アカウント作成と API key の取得プロセスを案内する PDF が添付されている。
API key を生成したら、既存の Anthropic API key の隣にある .env ファイルに追加する必要がある。変数名は必ず VOYAGE_API_KEY という名前を割り当てること。そこに、先ほど取得した生成済みキーを入れる。
.env ファイルを更新したら、このレクチャーに添付されている新しい notebook ファイル、002 embeddings もダウンロードすることを勧める。この notebook の一番上に、Voyage AI SDK をインストールするために実行する必要のあるコマンドがある。このライブラリをインストールするために、必ずそのコマンドを実行すること。
この notebook のさらに下に、generate_embedding という関数がすでに用意されている。この関数は、何らかのテキストを受け取り、そのエンベディングを返す、非常にシンプルなものである。この中のすべてのセルを実行し、一番下のセルを実行すると、現在レポートを開き、レポートをチャンク分割し、最初のチャンクを generate_embedding に渡す処理が行われる。そのセルを実行すると、エンベディングのリストが返ってくる。見ての通り、エンベディングの生成は非常に速く、非常に簡単である。したがって、ここでの本当の課題はエンベディングを作成することではなく、それらが RAG パイプライン全体にどう組み込まれるかを理解することである。それが次に検討するトピックになる。
Lesson: The full RAG flow(RAGフロー全体)
このモジュールのここまでで、RAG パイプラインがどのように機能するかについて高いレベルの概要を説明してきた。text chunking について少し話し、text embeddings についても少し触れた。ここで、これら3つの異なるトピック——RAG プロセスの高いレベルの概要、text embeddings、text chunking——をすべて統合して、RAG パイプライン全体を本当に理解する。RAG の完全な例を一つ通しでたどり、多くの詳細に踏み込みながら、すべてをステップバイステップで理解していく。
ステップ1: ドキュメントをチャンクに分割する
これまでと同様に、あるソースドキュメントを取り、別々のテキストのピースにチャンク分割する。この例では、2つのテキストのピースがあると仮定する。Section 1(医療研究)と Section 2(ソフトウェアエンジニアリング)である。
ステップ2: 各チャンクのエンベディングを生成する
ステップ2では、これらの異なるテキストチャンクそれぞれについてエンベディングを生成する。この例では、この架空の完璧なエンベディングモデルがあると仮定する。このエンベディングモデルには2つの非常に重要な特性がある。
- 常に長さ2のエンベディングを返す。つまりたった2つの数値だけである。
- 各数値が入力テキストの何を実際にスコアリングしているのか、正確にわかっているものと仮定する(実際にはそうではないが、このシナリオでは、各数値が実際に何を話しているかを正確に知っていると想像する)。
最初の数値は「テキストがどれだけ医療分野について話しているか」、2番目の数値は「テキストがどれだけソフトウェアエンジニアリングについて話しているか」を表すことにする。
最初のチャンクのテキストをエンベディングすると、これは間違いなく医療研究について話している。なので医療について「0.97」くらいのスコアを与える——そう、絶対にこれは医療分野について話している、と。そして、これは "bug" という用語も使っており、これには少しソフトウェアエンジニアリングの意味合いがある。加えて、medical そのものはソフトウェアエンジニアリングとかなり関係が薄い。なので、ソフトウェアエンジニアリングには「0.344」というスコアを与える。
2番目のテキストのピースについては、これは間違いなくソフトウェアエンジニアリングについて話しているので、そちらには「0.97」を与える。そして "infection vectors" にも言及しており、これには医療の意味合いがある。なので、少し高めの医療スコア「0.3」も与える。
正規化(normalization)
これらのエンベディングを生成した後、normalization(正規化)と呼ばれる、もう一つの数学的なステップを経る。正規化についてそこまで深く理解する必要はない。これはほとんどの場合、使用しているエンベディング API によってすでに自動的に行われる。この正規化ステップは、これらのペアのベクトルそれぞれの大きさ(magnitude)を 1.0 にスケーリングする。この用語がわからなくても全く問題ない。ただ、それぞれの数値の実際の大きさに少し調整が加えられる、ということだけ理解しておけばよい。
これらのエンベディングを生成して正規化すると、このようなプロットで可視化できる。このプロットには単位円が描かれており、それぞれの点は円周上にちょうど乗ることになる。なぜなら長さをちょうど1に正規化したからである。上の方にソフトウェアエンジニアリングのセクション、こちらに医療研究がある、というようになる。
ベクトルデータベースへの格納
このエンベディングを取得したら、次のステップに進む。このステップでは、これらのエンベディングを取り、vector database(ベクトルデータベース)と呼ばれるものに格納する。これは、我々のエンベディングのような、長い数値のリストを保存し、比較し、検索するために最適化されたデータベースである。
この時点で一旦立ち止まる。ここまでの作業はすべて事前に行った前処理の作業だったからである。この時点で、ユーザーが実際にアプリケーションにクエリを送信してくるのを待って、待機する。
ユーザークエリの処理
ある時点で、ユーザーがチャットボットのようなものに自分の質問やクエリを入力するとする。例えば「この会社について興味がある、特にソフトウェアエンジニアリング部門は今年何をしたか」というような質問だとする。
このとき、ユーザーの質問を取り、まったく同じ架空のエンベディングモデルに通す。このシナリオでは、ユーザーの質問がソフトウェアエンジニアリングについて具体的に尋ねているので、「0.89」というスコアを与える。そして、これは会社について、またソフトウェアエンジニアリングについても話しているので、医療分野もソフトウェアエンジニアリングと少し結びついているため、わずかな医療スコア「0.1」も与える。
このエンベディングができたら、また同じ正規化ステップを経る。そして最後に、ベクトルデータベースを利用する。ユーザーのクエリを取り、それをベクトルデータベースに投入して、「あなたの中に保存されているすべてのベクトルを検索して、これに最も性質が近いベクトルを教えてください」と依頼する。今回の場合、Section 2 のソフトウェアエンジニアリングが返ってくることを期待する。なぜなら、それがユーザーが尋ねたことに近い内容だからである。
なぜその結果が返ってくるのか: cosine similarity
ここで、この非常に近い関連性のある結果を返せるようにするために、ベクトルデータベースの内部で実際に何が起きているかを説明する。少し数学の話になるが、心配しなくていい、そこまで難しくはない。
ユーザーのクエリを先ほどのチャートに追加すると、視覚的にすぐにユーザーのクエリがソフトウェアエンジニアリングに非常に近いことがわかる。人間である我々は、このチャートを見て「ああ、明らかにこの2つは近い」と言える。ユーザーのクエリはソフトウェアエンジニアリングと非常に似ている。だから、ベクトルデータベースの中からユーザーのクエリに関連するチャンクを見つけたければ、これが欲しいものになるはずだ、と。しかし、当然コンピュータを使っているので、コンピュータはこういうチャートを作ってそれを見る、というようなことはしない。裏側で何らかの実際の計算が行われている。その計算が正確に何であるかを見ていこう。ベクトルデータベースを使い始めると、この裏側の数学に関連する用語が多く使われるようになるので、これを知っておくことは重要である。ベクトルデータベースとうまくやり取りするには、少なくともこの数学について非常に基本的な理解を持っておく必要がある。
ベクトルデータベースの内部で行われている計算の高レベルな見方は次の通りである。ユーザーのクエリに最も似ているエンベディングを見つけるために、cosine similarity(コサイン類似度)と呼ばれるものを計算したい。これは、ユーザーのクエリと、データベースに格納されている他の各エンベディングとの間の角度のコサインである。角度 A を求めてそのコサインを取り、角度 B を求めてそのコサインを取る、という具合である。
この計算の結果は -1 から 1 の間の数値になる。今回のように 1 に近い結果が得られた場合、それはユーザーのクエリに非常に似たエンベディングを見つけたことを意味する。-1 に近い結果は、ユーザーのクエリに全く似ていないエンベディングを見つけたことを意味する。今回の場合、ユーザークエリとソフトウェアエンジニアリングのチャンクの間のコサイン類似度は 0.983 であり、これら2つのエンベディングは非常に似ていることを意味する。これは、ソフトウェアエンジニアリングのチャンクを取ってユーザーの質問とともにプロンプトに含めるべきだ、というシグナルになる。
補足: cosine distance との違い
先に進む前に、もう一つ、今は少しわかりにくいかもしれないが、後でベクトルデータベースを扱い始めたときに非常に役立つ話をしておく。多くのベクトルデータベースのドキュメントで、cosine distance(コサイン距離)という言葉を目にすることになる。これは cosine similarity とは異なるものである。1 引く cosine similarity として計算される。これは、解釈しやすい数値にするために行われる調整である。cosine distance では、0 に近い値は大きな類似度を意味する。そしてそれより大きな値は、類似度が低いことを意味する。これもまた、ベクトルデータベースのドキュメントで非常によく目にすることになるので、cosine distance と cosine similarity という用語を見たときは注意しておいてほしい。
プロンプトへの組み込みと送信
この数学を高いレベルで理解できたところで、話を戻そう。ユーザーの質問に高い類似度を持つテキストチャンクを見つけたら、ユーザーの質問を取ってプロンプトに追加し、見つかった最も関連性の高いテキストチャンクもプロンプトに追加する。それからそのプロンプトを取り、Claude に送信する。これが、非常に詳細なレベルでの全体のプロセスである。
裏側で起きているすべての技術や、いくつかの数学まで含めて全体を理解できたところで、今度はこれを notebook の中で実装していく。
Lesson: Implementing the RAG flow(RAGフローの実装)
RAG フロー全体を理解したところで、003 VectorDB というもう一つの notebook を使って例をウォークスルーする。この notebook には、ベクトルデータベースのサンプル実装が用意されている。ClaudeVectorIndex という名前で用意した。興味があれば中身を眺めてもよいが、理解すべきことはすべてここで説明する。
この notebook では、RAG フロー全体を5つの異なるステップで実装していく。前のビデオで話したのと同じ5つのステップである。
ステップ1: テキストをセクションでチャンク分割する
すでにファイルを開いてテキストを読み込んである。具体的には、notebook と同じディレクトリにあるはずの report.md ファイルである。ステップ1では、テキストをセクションでチャンク分割する。以前と同じ chunk by section 関数がすでに用意されている。チャンク分割のプロセスを行うために、chunks = chunk_by_section(...) としてすべてのテキストを渡す。うまく動いているか確認するため、chunks のインデックス2を print してみると、目次が表示されるはずである。インデックス3に進むと次のセクション、というように続く。
ステップ2: 各チャンクのエンベディングを作成する
ステップ2では、これらの異なるチャンクそれぞれについてエンベディングを作成する。エンベディング関数は、単一の文字列だけでなく、文字列のリストも渡せるように少し書き直してある。文字列のリストを渡した場合は、それぞれについてエンベディングを作成し、それをエンベディングのリストとして返す。ステップ2を実装するには、generate_embedding を呼び出し、すべての chunks を渡し、その結果を embeddings に代入する。
ステップ3: ベクトルストアのインスタンスを作成する
ステップ3では、ベクトルストアのインスタンスを作成する。ストアを作成したら、チャンクとエンベディングのすべてのペアをループする。zip で両者をまとめ、各ペアをストアに挿入していく。for embedding, chunk in zip(embeddings, chunks): として、各ペアについて store.add(vector=embedding, metadata={"content": chunk}) を実行する。第一引数にエンベディングを、第二引数として content に chunk を持つディクショナリを渡す。
このステップについては少し駆け足で説明したので、なぜこれらすべてをループしているのか、なぜチャンク分割していたのか、なぜこの追加のディクショナリを content として追加しているのか、簡単に補足しておく。すでに議論した通り、いずれかの時点で、ベクトルデータベースにアクセスして、入力に関連するすべての異なるエンベディングのリストを返してもらうことになる。このリストが返ってきたとき、単にエンベディングの数値だけを取得しても、開発者である我々にとってはあまり意味がない。本当に必要なのは、そのエンベディングに紐づいたテキストの方である。だから通常、ベクトルデータベースにこれらの異なるエンベディングを格納するときは、そのエンベディングが生成された元のチャンクのテキスト、あるいは少なくとも元のチャンクテキストに戻れるような何らかの ID を一緒に含める。今回の場合、元のチャンクテキストを各エンベディングと一緒に含める。これにより、後で検索を行い、最も類似したチャンクが返ってきたときに、探していた実際のテキストが手元にある状態になる。
ステップ4: ユーザーの質問のエンベディングを生成する
ステップ4では、将来のある時点でユーザーが質問をしてくることを想定する。その質問を取り、それに対するエンベディングを生成する必要がある。generate_embedding を呼び出して user_embedding を作る。ここでの質問は "what did the software engineering department do last year"(ソフトウェアエンジニアリング部門は昨年何をしたか)とする。
ステップ5: 関連ドキュメントを検索する
最後にステップ5では、関連するドキュメントを見つけようとする。エンベディングでストアを検索し、最も関連性の高い1つだけでなく、最も関連性の高い2つのチャンクを見つけたい。そのために、results = store.search(...) として、user_embedding を渡し、2つの最も関連するチャンクを見つけたいので、もう一つの引数として 2 を渡す。それから for doc, distance in results: として、distance、改行、そしてドキュメントのコンテンツを print する。各チャンクは非常に大きいので、最初の200文字だけを print し、その後にもう一つ改行を入れる。
これを実行すると、結果が得られる。最良の結果として Section 2 が返ってくる。cosine distance も表示される。0.71 である。次に近いチャンクは 0.72 で、それは Methodology セクションだった。これらが、今送信したユーザークエリに対して最も関連性が高いと見なされた2つのチャンクである。
これで RAG ワークフロー全体が完成した。すべて動作するが、期待通りには動かない1つか2つのシナリオがまだある。ワークフローに追加できる改善点がまだいくつかあるので、それについて次から議論していく。
Lesson: BM25 lexical search(BM25 レキシカル検索)
RAG パイプラインの最初のイテレーションが組み上がった。今のところ何もかもうまく見えるが、実は最良の検索結果を得られていないことにすぐに気づくことになる。例を見せよう。
問題の発見: semantic search だけでは不十分な例
report.md ファイルを開いて、少し下にスクロールし、ソフトウェアエンジニアリングのセクションを見てみる。そこには「INC」(Incident の略)2023 Q4-011 という記述がある。この検索語は、この段落の中に3回出現しているように見える。1つ目、2つ目、3つ目、という具合である。さらにドキュメント全体を検索し続けると、下の方の Section 10「Cybersecurity Analysis」の中にも言及されていることがわかる。ヘッダーの中に1回、そして実際の段落の中にも1回言及されている。
この用語、"incident 2023 Q4-011" を検索してみて、何が起きるかを見てみる。つまり、semantic search を使った場合、実際にどんな検索結果が得られるかということである。notebook に戻り、ユーザークエリを "what happened with incident 2023"(インシデント2023で何が起きたか)に更新する。すべてのセルを再実行すると、どんな結果が得られるか見えてくる。
これは少し驚くべき結果である。Section 10 が得られる。これはよい。まさに最初に欲しかった結果である。なぜなら Section 10 はまさにこのインシデントについてのものだからである。しかし驚くべきことに、次の結果は Section 3「Financial Analysis」である。Section 3 を開いてみると、そこにはこのインシデントについての言及はどこにもない。つまり、semantic search からは少し驚くべき出力が返ってきている。本当に欲しかったのは Section 10 とそれに続く Section 2 だったが、実際に得られたのは Section 10 と、残念ながら Section 3 だった。そして Section 3 は、このインシデントを調べる上では全く関係がなさそうに見える。
つまり、我々が組み立てた semantic search 技法は素晴らしく、ほとんどの場合はうまく機能するのだが、こういうコーナーケースでは期待通りにうまく機能しないことがある。検索結果を改善し、望んでいた結果(Section 10 と Section 2)を得るための技法を見ていこう。
解決の方針: lexical search との組み合わせ
ここで使う全体的な戦略は次の通りである。ユーザーが質問をするたびに、その質問を semantic search 側(エンベディング生成とベクトルデータベースを使う側)に投入する。しかし同時に並行して、別の lexical search(レキシカル検索)システムも実装する。Lexical search は、より古典的なテキスト検索に近いもので、ユーザーの質問を個々の単語に分解し、それらの単語を含んでいそうなテキストのチャンクを見つけようとする。
両方のシステムで検索プロセスを通した後、2つの結果セットを得て、それらの結果をマージする。この狙いは、semantic の側面とプレーンテキスト検索の側面の両方を含んだ、もう少しバランスの取れた検索結果を得ることである。うまくいけば、最終的にこのような結果が得られることを期待する。
このレキシカル検索を実装する方法は数多くあるが、今回作っているような RAG パイプラインで非常によく使われる一般的な手法が BM25(Best Match 25 の略)と呼ばれる技法である。
BM25 アルゴリズムの仕組み(高レベルな概要)
これから BM25 アルゴリズムがどう動くかの高レベルな概要を説明し、BM25 を実際に実装した notebook を見て、実際に触って、どんな検索結果が得られるか見ていく。ここでは高レベルな概要にとどめ、話を単純にしてわかりやすくするために、いくつかの小さなステップは省略する。
すべてはユーザーのクエリを受け取ることから始まる。この場合、"a incident 2023Q4011" のような検索文字列を入力したとする。
ステップ1: トークン化。ユーザーのクエリをトークン化する。これは、クエリを別々のチャンクに分解することを意味する。ユーザーのクエリをトークン化する方法は複数あるが、今回は非常にシンプルな方法を使う。句読点を除去し、スペースに基づいてすべての語を分割する、という方法である。今回の場合、別々の検索クエリ語として "a" と "incident 2023" が得られる。
ステップ2: 出現頻度の計測。次に、これらの異なる検索語それぞれが、すべての異なるドキュメント(今回の場合はテキストチャンク)全体でどれくらいの頻度で出現するかを見る。今回は、テキストチャンクが2つだけあるとする。"a" という単語と "incident..." という単語が、それぞれのチャンクの中でどれくらいの頻度で出現するかを見る。最初のチャンクには "a" が2回、2番目のチャンクには "a" が3回出現している。それらをすべて合計すると "a" は合計5回。そして "incident 2023" については、1回しかないようだ。つまり頻度は1になる。
ステップ3: 重要度の割り当て。次に、それぞれの語に、その使用頻度に基づいて相対的な重要度を割り当てる。"a" という単語は5回使われていた。かなり頻繁に使われているため、これはあまり重要な語ではないと判断する。すべての異なるドキュメント(今回の場合はテキストチャンク)全体で使われすぎているためである。しかし "incident 2023" は非常にまれにしか使われていなかった。つまり、これはおそらく検索上の重要度がより高いということになる。
ステップ4: 最良のチャンクを見つける。最後のステップとして、より重み付けの高い語をより多く使っているテキストチャンクを見つける。今回の場合、最初のテキストチャンクには "a" が2回しかないが、2番目のチャンクには3回ある。しかし "a" はすべての異なるチャンク全体で頻繁に使われているため、それほど重要ではない。一方、Text Chunk 1 は "incident 2023" を1回使っており、それは重み付けの高い語であり、非常に重要な語である。この場合、これがおそらく最良のテキストチャンクだと判断し、これをプライムの検索結果として返したいということになる。
notebook でのデモ: 004_BM25
これを実際に動かしてみるために、004_BM25 という新しい notebook を見ていく。今回も一番上に chunk by section 関連のコードがあり、BM25Index というクラスの形で BM25 の基本的な実装が用意されている。このセルを折りたたんで、忘れずに実行しておく。レポートファイルの内容を読み込む。それから3つの別々のステップを進めていく。テキストをセクションでチャンク分割し、BM25 index ストアを作成し、各テキストチャンクをそこに追加する。それからストアを検索してみる。ここでの狙いは、先ほどのような結果に近いもの、正確には同じでなくてもよいが、Incident 2023 を全く含まない結果を見る前に、Incident 2023 を使っているセクションを絶対に見たい、ということである。
ステップ1: テキストをセクションでチャンク分割する。chunks = chunk_by_section(text) とする。
ステップ2: ストアを作成する。すべてのチャンクをループして、ドキュメントとしてストアに追加する。for chunk in chunks: store.add_document({"content": chunk}) のようにする。これを実行する。
ステップ3: ストアを検索する。store.search(...) を呼び出す。前の notebook で良い結果が得られなかったのと同じクエリ、"what happened with incident 2023 Q4011" を使う。そして最初の3件の検索結果を求める。それから for doc, distance in results: として、distance、改行、doc の content、ただし最初の200文字だけを print し、改行と区切り線を2つ入れてわかりやすく表示する。
これを実行すると、はるかに良い検索結果が得られる。今回はまず Software Engineering、その次に Cybersecurity、そして下に Methodology という順で出てくる。今度は、クエリの中で最も重要な検索語、つまり "incident 2023" を使っているセクションを実際に優先できている。"what happened with" のような他の語はそれほど重視されていないことにも気づくだろう。それらはあまり重要な語ではなく、元のレポートの中で何度も使われている可能性があるため、出力の中でそれほど重く重み付けされない。しかし "incident 2023" は、レポートの中では非常にまれな語であるため、間違いなくずっと高い重み付けを持つべきである。そしてそれが検索結果に明確に反映されているのがわかる。
2つの検索システムをそろえたところで
これで、2つの別々の検索システムが手元にある。semantic search を組み立てたものと、この、もう少し古典的なテキスト検索に近い lexical search である。そして、これら2つのストアの実装に注目してみると、講師はそれらをかなり似た API でまとめている。両方とも add_document 関数を持ち、両方とも search 関数を持っている。semantic search を実装するものと lexical search を実装するもの、この2つの別々の検索システムがそろったところで、次のビデオでこの2つの検索システムをマージしていく。ユーザーがクエリを送信するたびに、それをこれら2つの異なる検索システムの両方に転送し、両方から結果セットを取得し、それらの結果をマージする。うまくいけば、semantic search の良いところと、より古典的な lexical search の結果の良いところの両方を得られることになる。
Lesson: A Multi-Index RAG pipeline(マルチインデックスRAGパイプライン)
semantic search の実装と lexical search の実装がそろった。ここで、これらを配線してつなぎ合わせる必要がある。その方法を見ていく。
まず気づくべきことは、この2つの検索機能の実装が、ほぼ全く同じ public API を持っているということである。左手側にある Vector Index クラスは add_document や search のようなメソッドを持ち、BM25 Index の中にもほぼ同一のメソッドがある。この2つを1つの検索パイプラインに接続するために、これらを retriever(リトリーバー)と呼ぶ新しいクラスの中にラップする。この retriever はユーザーの質問を受け取り、それを vector index と BM25 index の両方の search メソッドに転送する。それから retriever は両方から結果を受け取り、それらを何らかの方法でマージする方法を見つける。
マージが難しい理由: reciprocal rank fusion
このマージ操作は実は少しトリッキーである。ここで、これらの異なる検索手法から出てくる結果をどうマージできるかについて、詳しく説明する。結果を統合するために、reciprocal rank fusion(相互ランク融合)と呼ばれる技法を使う。
この技法を理解する最も簡単な方法は、例をたどることである。
vector index で検索を実行し、Section 27、6 という出力が得られたとする。そして BM25 でも全く同じ検索を実行し、6、2、7 という出力が得られたとする。この2つの結果リストを、何らかの方法で統合する必要がある。
そのために、すべての検索結果を1つのテーブルにまとめる。テキストチャンク 27 と 6、そして vector index の出力での順位(rank)と、BM25 index の出力での順位を記録する。ここで rank と言っているのは、単に検索出力の位置のことである。rank 1、2、3、rank 1、2、3、という同じ数値をこのチャート上に記録していく。
すべての rank をそろえたら、次に数式を適用する。数式自体は見た目ほど複雑ではない。各 rank の列(この列とこの列)について、それぞれ別々の項を書き出す。1列目の項は「1 割る(1 プラス そこにある数値)」。つまり 1 / (1 + 1) になる。2列目の項も同様に「1 割る(1 プラス そこにある数値)」。つまり 1 / (1 + 2) になる。
各テキストチャンクのスコアを計算したら、そのテーブルをスコアの大きい順にソートする。そうすると、Section 2 のテキストチャンクが最も関連性の高い検索結果になる。Section 6 が2番目、Section 7 が最も関連性が低い、という結果になる。
これは理にかなっている。それぞれの検索手法での個々の rank の出力を見るだけでも、視覚的にこの出力が理にかなっていることを確認できる。Section 2 は rank 1 と 2 だった。つまり全体的に上位に向かっている傾向がある。Section 6 は 1 と 3。これは中間くらいで、良いスコアと悪いスコアが混在している。そして Section 7 は 2 と 3 で、下位に向かう傾向がある。目視での確認でも、この結果はかなり理にかなっているといえる。
notebook でのデモ: 005_hybrid
結果をどうマージするかを理解したところで、Jupyter notebook に戻り、retriever クラスのサンプル実装と、結果をマージするサンプル実装を見てみる。005_hybrid という次の notebook に進む。ここでもかなり多くのセットアップが上部にある。vector database の実装、BM25 の実装、そして今回新たに retriever クラスの実装も追加されている。
retriever には add_document メソッドがある。add_document を呼び出すと、渡したドキュメントを、retriever の中に含まれる各インデックス(今回の場合は vector index と BM25 index)に渡す。それから retriever には search 関数もある。何らかのクエリテキストを渡すと、そのクエリテキストは retriever の中に含まれる各インデックスに渡される。そして戻ってきたすべての結果を統合する。これが reciprocal rank fusion を実装するマージロジックである。
テストしてみる
ここで少しテストしてみる。この一連の流れに至った経緯を思い出してほしい。それは vector database の実装(先ほどの notebook)に戻ることで、"what happened with incident 2023" のような検索をすると、予期しない結果——Section 10 が最初の結果としては良かったが、2番目の結果が Section 3 になってしまうという、予期しない結果——が得られることがわかった、というものだった。2番目の結果としては Software Engineering が欲しかった。
複数のインデックスを組み合わせるこのハイブリッドアプローチを実行すれば、まず Section 10、そしてソフトウェアエンジニアリングのセクション(おそらく Section 2)が得られることを期待している。これを新しい notebook の中でテストしてみる。
一番下に進み、results = retriever.search("what happened with incident 2023 Q4011") を実行し、最初の3件の検索結果を取得する。そして再び、それらをすべて print する。score、改行、ドキュメントの content(ただし最初の200文字だけ)、そして各チャンクの間に小さな区切りを入れる。これを実行する。
すると、以前よりもずっと良い検索結果が得られる。Section 10、そして Section 2 が得られる——まさに欲しかった通りである。そして Section 5 も得られるが、これはそれほど関連性が高くはない。つまり、この2つの異なる検索技法を組み合わせることで、以前よりずっと良い出力が得られるようになった。
拡張性: 追加の検索インデックスを組み込む
ここで良いのは、これらの各インデックスをそれぞれ独立して実装できたという点である。それぞれが独自の別々のクラスであり、それぞれの実装に search 関数と add_document 関数という全く同じ API を持たせたおかげで、それらをこの大きな retriever クラスに簡単にラップすることができた。もし望むなら、何か他の全く異なる検索機能を実装する追加の検索インデックスをここに加えることも十分にできる。そして、それが search 関数と add_document 関数さえ持っていれば、非常に簡単に追加でき、何らかの結果を生成させ、他の検索手法から来る結果と一緒にその結果をマージできる。
これは良い成功と言えるが、まだ完全に終わったわけではない。RAG パイプラインの精度を向上させるために、まだいくつか検討していく技法が残っている。
章末まとめ: RAG / Agentic Search の要点
- RAG(retrieval augmented generation)は、ドキュメント全体をプロンプトに詰め込む代わりに、ドキュメントをチャンクに分割し、ユーザーの質問に関連するチャンクだけを検索してプロンプトに含める技法である。上限を超える巨大ドキュメントに対応でき、プロンプトを小さく保てるためコストとレイテンシを下げられる一方、chunking・検索・関連性の定義という新たな複雑さを持ち込む。
- chunking(テキストのチャンク分割)には size-based(等サイズ分割+overlap)、structure-based(見出し等の構造に基づく分割)、semantic-based(意味的な関連性に基づく分割)の3種類がある。どれが最適かはドキュメントの構造保証の有無に依存し、保証が強いほど structure-based が有利、保証がないほど size-based(chunk by character)が頼りになる。size-based では overlap によって単語の途中切れとコンテキスト欠落を緩和できる。
- text embeddingはテキストの意味を表す数値のベクトルであり、embedding model(Claude 自体は提供せず Voyage AI を使う)によって生成される。各次元が厳密に何を表すかは不明だが、「何らかの性質のスコア」とイメージすると理解しやすい。
- semantic searchはユーザークエリのエンベディングと、vector database に格納されたチャンクのエンベディングとの cosine similarity(-1〜1、1に近いほど類似)を計算し、最も近いチャンクを検索する。cosine distance は
1 - cosine similarityで、0に近いほど類似度が高い。 - RAG フルフローは「chunking → 各チャンクの embedding 生成 → vector database へ格納 → ユーザークエリの embedding 生成 → 検索 → 関連チャンクをプロンプトに追加して Claude に送信」の5ステップで構成される。
- semantic search だけでは、まれな固有の識別子(インシデント番号など)を含む検索で、意味的には近いが的外れな結果を返してしまうことがある。この弱点を補うのが BM25 に代表される lexical search(古典的なキーワード検索)で、語の出現頻度に基づき、まれな語ほど高く重み付けする。
- semantic search と lexical search はそれぞれ独立した
search/add_documentという共通 API を持つ実装にしておくことで、retriever クラスに統合できる。統合の際の結果マージには reciprocal rank fusion(各インデックスでの順位から1/(1+rank)を計算し合計してソートする手法)を用いる。この設計により、共通 API を持つ第三の検索インデックスを追加することも容易になる(= Multi-Index RAG pipeline)。