はじめに
以前、Amazon BedrockとPineconeを使ったAdvanced RAGの開発に携わっていた。
開発メンバーは自分を含めて3人。
そのうち2人は、ほぼ開発未経験のエンジニアだった。
当時はRAGの実装事例も少なく、今のようにAIに設計上の課題や解決策を相談できる環境でもなかったため、試行錯誤の連続だった。
検索精度を上げようと工夫するたびに新たな課題が発生し、それを解決するためにまた別の処理を追加する。
そんなことを繰り返していた。
今振り返ると、もっとシンプルな方法があったのではないかと思う。
今回は、当時のダメダメだった設計を振り返りながら、今ならどう解決するかを整理してみたい。
先に結論を書いておく。
- データの境界はIDで管理する
- 元データと検索用データは分離する
- 会話の状態は構造化して保持する
- 明確な条件はプログラムで処理し、言語理解や推論が必要な部分だけLLMを利用する
LLMに何をさせるかより、LLMに何をさせないか。
これが今回の振り返りのテーマである。
1. 当時のAdvanced RAGの構成
主な技術スタックはAmazon Bedrock(Knowledge Bases)、Pinecone、DynamoDBなど。
システムの処理は、大きく「ナレッジベースへのデータ登録」と「ユーザーからの質問・回答」の2つに分かれていた。
① ナレッジベースへのデータ登録
外部から収集したデータを、LLMで要約してからナレッジベースへ登録していた。
② ユーザーからの質問・回答
検索精度を高めるため、質問解析、クエリ拡張、関連度評価など、複数の工程でLLMを利用していた。
さらに、会話を継続するため、DynamoDBに保存した過去の質問と回答をプロンプトへ追加していた。
この構成で問題になったのが、データ登録時の要約処理と、回答生成までのLLM呼び出し回数の多さ。
精度を改善しようと処理を追加するほど、システム全体が複雑になっていった。
当時の構成と、この記事で整理する今の構成を並べると次のようになる。
赤い箇所がLLMの呼び出しである。
当時の構成
今ならこうする
この構成例では、登録時のLLM呼び出しをなくし、質問1件あたりの呼び出しも4回から2回に減らせる。
ただし、これは設計上の想定である。Rerankerの処理時間などは別途かかるため、実際の効果は7章のPoCで測定したい。
2. 課題と当時の対応。ところが……
振り返ってみると、主な課題は次の4つだった。
| 課題 | 当時の対応 | でも、ここがダメだった |
|---|---|---|
| ① チャンク分割による情報の混在 | LLMで1件ずつ要約して短くする | 混在は防げたが、全件をLLMで要約する必要があり、②の処理時間と情報欠落を招いた |
| ② データ登録に時間がかかる | (①の要約処理が原因。並列化で凌ぐ) | 件数が増えるほど処理時間とコストが増加。情報欠落のリスクも |
| ③ 会話の文脈を維持できない | DynamoDBに会話履歴を保存してLLMへ渡す | 履歴だけでは検索条件や紹介済み情報を確実に管理できない |
| ④ 検索精度が悪い | LLMで質問解析、クエリ拡張、関連度評価 | LLMの呼び出し回数が増え、応答が遅くなる |
②は、①を解決しようとして自分たちで生み出した課題である。
いずれも当時は問題を解決するための工夫だった。
ただ、今振り返ると、LLMに任せなくてもよい仕事までLLMに任せていたという共通点がある。
以下、それぞれ振り返ってみる。
3. 課題① チャンク分割によってデータが混在する
スクレイピングした情報をそのまま登録したかった
複数の公開サイトから情報をスクレイピングし、サイトごとに1つのCSVファイルにまとめてS3に置く。
各CSVには .metadata.json でジャンルを付け、ナレッジベースへ登録していた。
スクレイピング自体は特に問題なかった。
問題は、その後のデータ登録である。
実際に扱っていたのは別の分野の情報だが、ここでは説明のため、架空の公開講座情報を例にする。
講座ID:001
講座名:Python入門
分類:プログラミング
内容:Pythonの基本文法、変数、条件分岐……
対象:初心者
詳細説明:……
講座ID:002
講座名:ネットワーク基礎
分類:インフラ
内容:TCP/IP、ルーティング、DNS……
対象:初心者
詳細説明:……
これらをまとめてテキスト化し、固定長で分割した場合(左)と、講座ごとに分けてから分割した場合(右)を比べると次のようになる。
左のように固定長で分割すると、1つのチャンクに異なる講座の情報が含まれてしまう。
また、1件の情報が複数チャンクに分割されることで、タイトルと詳細情報の関係が分かりにくくなる場合もある。
その結果、Pythonについて質問しているのに、別の講座の情報が回答に混ざるといった問題が起こる。
データとしては別々の情報なのに、検索時には1つのまとまりとして扱われてしまう。
これが当時悩まされた問題だった。
当時の解決策:LLMで事前に要約する
チャンク分割による情報の混在を避けるため、考えたのがLLMによる事前要約だった。
収集したデータを1件ずつLLMに渡し、短い文章にまとめてからS3へ保存し、Knowledge Bases経由でPineconeへ登録する(1章の図の左上の流れ)。
要約は1件ごとに別々のLLM呼び出しで処理し、ほかの講座の情報が入り込まないようにしていた。
そのうえで、1件の情報を1チャンクに収まる長さまで短くし、講座どうしが混ざらないようにした。
実際、情報の混在はこれで防げていた。
しかし、ここには大きな勘違いがあった。
本来、1件の情報を無理やり1チャンクに収める必要はなかった。
必要だったのは、元のデータがどの講座に属しているかを明確にすることだった。
LLMで文章を短くすることではなく、データの分け方と管理方法を工夫することが求められていた。
具体的にどうすればよかったのかは、次の章でまとめて整理する。
4. 課題② 要約処理に時間を取られすぎる
チャンク分割の問題を解決しようとして、新たな問題を作ってしまった。
それがLLMによる要約処理だった。
1024に収めるため、ひたすら要約
当時は「1024」というサイズを意識し、そこに収まるように収集したデータを要約させていた。
例えば、1件の講座情報に大量の説明文が含まれていた場合、LLMに渡して必要な情報を抽出し、短い文章に圧縮する。
元データ
数千文字の講座情報
↓
LLMで要約
↓
数百文字の検索用テキスト
↓
Embedding
↓
Pinecone
この方法の最大の問題は、登録するデータの件数に応じて要約処理が増えること。
例えば、1,000件のデータを収集したとする。
1件の要約に平均3秒かかり、直列で処理した場合、
1,000件 × 3秒 = 3,000秒
約50分
※これは説明用の仮定であり、当時の実測値ではない。
さらに、データが更新されれば、変更された情報について再び要約が必要になる。
並列化すれば処理時間は短縮できるが、APIのレート制限やコストも考慮しなければならない。
せっかくスクレイピングで効率よく情報を収集しても、その後の要約処理がボトルネックになっていた。
しかも、要約には情報欠落のリスクがある。
何が質問されるか分からない段階で、LLMにどの情報を残すべきか判断させること自体が難しい。
検索精度を上げるために要約していたのに、その要約で検索に必要な情報を失っていた可能性もある。
「1024」という数値について
記憶ではEmbeddingモデルはAmazon Titan Text Embeddings V2だった。
仮にそうなら、1024はベクトルの次元数であり、入力できる文章の長さ(最大8,192トークン)とは別物である。
チャンクサイズも、Bedrock Knowledge Bases側で別に設定する値だ。
ただ、数値の正体はあまり重要ではない。
問題は、「1件の情報は1チャンクに収めなければならない」と思い込み、そのために全件をLLMで要約していたことだった。
今ならこうする①:元データと検索用テキストを分離する
今なら、データを無理に要約しようとは考えない。
まず、取得した情報を1件ずつ構造化する。
{
"id": "course_001",
"title": "Python入門",
"category": "プログラミング",
"level": "初心者",
"topics": [
"Python",
"変数",
"条件分岐"
],
"description": "Pythonの基本文法から学ぶ講座"
}
元データはRDBやS3へ保存する。
一方、Embeddingする検索用テキストは、必要な項目をPythonで組み立てるだけ。
Python入門。
プログラミング、初心者向け。
Pythonの基本文法、変数、条件分岐。
これはLLMによる要約ではない。単なる文字列の組み立てである。
構造化されたデータなら、わざわざLLMを呼び出す必要はない。
検索時には、Pineconeから取得したIDを使い、元データを取り出す。
登録時と検索時の流れを1枚にまとめると次のようになる。
こうすれば、検索用テキストが短くても、元の情報は失われない。
ただし、検索用テキストに含めなかった情報は意味検索で見つけにくくなる。
そのため、長い説明文などは講座IDを保持したまま別チャンクとして登録する。
今ならこうする②:長い文章は分割し、IDで紐づける
もちろん、説明文そのものに対する意味検索が必要な場合もある。
その場合は、情報を複数チャンクに分割すればよい。
course_001_profile
course_001_description_001
course_001_description_002
course_001_description_003
それぞれに同じ course_id を付けておく。
検索時にどのチャンクがヒットしても、元の講座情報と紐づけられる。
チャンクを分割してはいけないのではなく、異なるデータを混在させず、元のデータとの関係を失わないことが重要だった。
実は Bedrock Knowledge Bases のままでもできた
ここまで書いて気づいたが、当時の構成を大きく変えなくても、課題①と②はかなり解決できた可能性がある。
当時も .metadata.json は使っていた。
ただし、メタデータを付ける単位は「サイトごとのCSV」だった。
s3://bucket/site_a.csv
s3://bucket/site_a.csv.metadata.json ← ジャンル
.metadata.json はファイル単位で付与される。
そのため、1つのCSVに含まれる全件に同じジャンルが付く。
ジャンルで絞り込むことはできても、チャンクがどの1件の情報なのかは区別できない。
ファイルの単位を1件ずつに変えていれば、
s3://bucket/courses/course_001.txt
s3://bucket/courses/course_001.txt.metadata.json ← course_id、ジャンル、対象など
s3://bucket/courses/course_002.txt
s3://bucket/courses/course_002.txt.metadata.json
異なるデータが1チャンクに混ざることはない。
長い文章が複数のチャンクに分割されても、同じファイルのチャンクには同じメタデータが付くので、どのデータの情報なのかを判別できる。
要約のためのLLM呼び出しも不要になる。
メタデータという仕組みはすでに使っていた。足りなかったのは、1件ごとにIDと属性を持たせ、検索結果から元のデータへたどれるようにするという発想だった。
なお、現在の Bedrock Knowledge Bases では、メタデータによるフィルタリングに加えて、チャンキング戦略も選べる(固定長のほか、チャンキングなし、階層チャンキング、セマンティックチャンキングなど)。
ここでも解決の鍵は新しい技術ではなく、データをどう分けて登録するかだった。
5. 課題③ LLMは前の会話を覚えていない
もう一つの課題が、会話の継続だった。
当時利用していたBedrockの推論呼び出しでは、過去の会話が自動的に次のリクエストへ引き継がれるわけではなかった。
例えば、
ユーザー:
初心者向けのPython講座は?
AI:
Python入門という講座があります。
ユーザー:
ほかには?
2回目の「ほかには?」だけをLLMに渡しても、何について追加情報を求めているのか分からない。
当時の解決策:DynamoDBに会話履歴を保存
そこで、ユーザーの質問とLLMの回答をDynamoDBに保存する仕組みを実装した。
ユーザーから質問
↓
DynamoDBから会話履歴取得
↓
会話履歴と今回の質問をLLMへ渡す
↓
Bedrockで回答生成
↓
質問・回答をDynamoDBへ保存
これにより、LLMが前の会話を参照できるようになる。
実は、この方法自体は間違っていない。
現在でも、会話履歴を外部ストレージに保持する方式は一般的な設計である。
ただし、会話履歴だけに頼る方法には限界がある。
例えば「ほかには?」という質問に対して、すでに紹介した情報を再び回答してしまう可能性がある。
今ならこうする:会話履歴と会話状態を分離する
今なら、質問と回答の履歴だけでなく、現在の会話状態も構造化して保存する。
{
"session_id": "abc123",
"current_topic": "course_search",
"filters": {
"category": "プログラミング",
"level": "初心者"
},
"shown_ids": [
"course_001",
"course_003"
]
}
ここで重要なのは次の2つ。
-
filters:現在の検索条件 -
shown_ids:すでに紹介した情報のID
ユーザーが「ほかには?」と質問した場合は、
「ほかには?」
↓
DynamoDBから会話状態を取得
↓
検索条件を引き継ぐ
↓
追加の情報を検索
↓
紹介済みIDを除外
↓
未紹介の候補を回答
↓
shown_idsを更新
という処理ができる。
つまり、「ほかには?」という質問を解釈するために、毎回LLMへ過去の会話全文を渡す必要がなくなる。
ただし、「ほかには?」が前の検索の続きなのかどうかは、誰かが判定しなければならない。
「ほかには?」「他にある?」のような定型的な表現ならルールで判定できるが、言い回しが多様になればLLMによる判定が必要になる。
その場合でも、LLMに渡すのは会話全文ではなく、現在の会話状態と今回の質問だけで済む。
もちろん、
「さっきの2番目の講座と似たものは?」
といった複雑な質問では、過去の表示順序や会話履歴を参照する必要がある。
そのため、会話履歴を完全になくすわけではない。
会話履歴は記録、会話状態は現在の検索条件や進行状況。
この2つを分けて管理する方が、無駄なLLM処理を減らせる。
状態の引き継ぎが適切か判断できない質問では、ユーザーに確認するのも一つの手である。
6. 課題④ Advanced RAGの処理が多すぎる
当時は、1章の図の左下のように、検索精度を改善するためにLLMの処理をいくつも組み込んでいた。
このうち「質問文解析」では、質問文からジャンルを抽出し、.metadata.json に付けたジャンルで絞り込んでからベクトル検索をしていた。
つまり、メタデータで絞り込むという発想自体は当時からあった。
ただし、メタデータはサイトごとのCSVに付けていたため、絞り込めるのはジャンルまでだった。
ジャンルで絞った後に残るのは、そのジャンルのCSVに含まれる全件のチャンクである。
そこから先は、ベクトル検索の類似度に頼るしかない。
その精度不足を補うために、クエリ拡張や関連度評価といったLLMの処理を重ねていった。
各ステップには意味がある。
しかし、すべての質問に同じ処理を適用する必要があったのだろうか。
例えば、
「初心者向けのPython講座を5件教えて」
という質問なら、
分類 = プログラミング
対象 = 初心者
キーワード = Python
という条件で検索できる。
1件ごとに分類や対象といった属性を持たせていれば、このような質問は属性検索だけで答えられる。
このような質問で、毎回LLMによるクエリ拡張や関連度評価が必要とは限らない。
一方、
「プログラミングが苦手な人でも挫折しにくい講座は?」
のような質問では、説明文の意味を理解する必要がある。
こちらは意味検索が向いている。
今ならこうする:質問に応じて検索方法を切り替える
属性検索の結果は条件で絞り込まれているので、そのまま元データの取得へ進む。
Rerankerについては後述する。
ここで注意したいのは、Query Router自体もLLM呼び出しになり得るという点である。
「条件が明確かどうか」を判定するには、結局質問文を理解する必要があるからだ。
ただ、それでも当時の構成より大幅に減らせる。
LLMの構造化出力を使い、1回の呼び出しで次のようなJSONを返させればよい。
{
"route": "structured",
"filters": {
"category": "プログラミング",
"level": "初心者"
},
"keyword": "Python",
"limit": 5
}
当時は「質問文解析」「クエリ拡張」で2回呼んでいた処理が、1回にまとまる。
関連度評価はLLMでなくてもよい
検索結果の関連度評価も、LLMに1件ずつ判定させる必要はない。
現在は、Bedrock上で Cohere Rerank や Amazon Rerank といったリランキング専用のモデルを利用できる。
リランカーはLLMで回答を生成するよりも軽く、検索結果の並べ替えに特化している。
なお、ハイブリッド検索のためにベクトルDBを替える必要もない。
Pineconeはメタデータによるフィルタリングと、sparse-denseベクトルによるハイブリッド検索に対応している。
すべてをベクトル検索に任せる必要も、すべての質問でLLMを何度も呼び出す必要もない。
条件が明確な質問は、プログラムで処理する。
曖昧な質問や意味理解が必要な場合は、LLMやベクトル検索を利用する。
Advanced RAGの各処理を否定するのではなく、必要なときだけ使う。
それだけでも、応答速度とコストを改善できる可能性がある。
7. 次のPoCではオープンデータで検証したい
ここまで整理した設計が本当に効果があるのか、公開されているオープンデータを使って検証してみたい。
例えば、公開講座や公共施設などのデータを対象にする。
次の3つの構成を比較する。
| 方式 | 概要 |
|---|---|
| Naive RAG | 固定長チャンクによるベクトル検索 |
| Advanced RAG | LLMによるクエリ拡張・関連度評価(当時の構成を再現) |
| Hybrid RAG | 属性検索・全文検索・意味検索の組み合わせ + リランキング |
評価には、正解となる講座IDを付けた質問セット(例:50問)を用意する。
「条件が明確な質問」「意味理解が必要な質問」「会話の続き(ほかには?)」をバランスよく含める。
検証する項目と指標は次のとおり。
| 項目 | 指標 |
|---|---|
| データ登録時間 | 1,000件あたりの登録時間 |
| 情報の混在率 | 複数の講座が含まれるチャンクの割合 |
| 検索精度 | Recall@5、MRR |
| 回答の正確性 | 正解講座を含み、誤った講座を含まない回答の割合 |
| 会話の継続 | 「ほかには?」で紹介済みの講座が再登場した割合 |
| LLM呼び出し回数 | 1質問あたりの平均回数 |
| 応答時間 | 1質問あたりの中央値・p95 |
| 処理コスト | 1質問あたり・1,000件登録あたりの料金 |
当時の課題を再現し、今の設計ならどこまで改善できるのか。
単なる技術紹介ではなく、実測結果を伴うPoCにしたい。
あわせてMCPも試してみたい
なお、PoCではMCP(Model Context Protocol)も試してみたい。
MCPは、LLMアプリケーションから外部データやツールを利用するための共通インターフェースである。
検索精度や応答速度を直接改善する技術ではない。
ただ、今回整理した「属性検索」「意味検索」「IDによる元データ取得」「会話状態の取得」を独立した機能として切り出しておけば、MCPのツールとして公開しやすくなる。
その場合でも、LLMにツール選択を毎回任せれば、呼び出し回数はかえって増える。
MCPを使うかどうかより先に、LLMが不要な処理をプログラム側に寄せておくことが前提になる。
おわりに
当時は、チャンク分割による情報の混在を避けるためにLLMで要約し、検索精度を上げるためにLLMでクエリ拡張し、会話を続けるために履歴をLLMへ渡していた。
目の前の課題を一つずつ解決しようとして、処理を追加していった。
しかし、その解決策が新たな問題を生み、システム全体が複雑になっていた。
特に、スクレイピングしたデータをLLMでひたすら要約していた処理は、今思い返しても非効率だった。
もちろん、当時は参考になる事例も少なく、限られたメンバーで手探りの開発だった。
今ほどAIに気軽に技術相談ができる環境でもなかった。
そんな状況で何とか動くものを作ろうとしていたのだから、当時の判断を一概に否定するつもりはない。
ただ、今ならもっとシンプルにできる。
データの境界はIDで管理する。
元データと検索用データは分離する。
会話の状態は構造化して保持する。
明確な条件はプログラムで処理し、言語理解や推論が必要な部分だけLLMを利用する。
振り返ってみると、RAGの設計で重要なのは、LLMに何をさせるかだけではなかった。
LLMに何をさせないか。
そこをもっと考えるべきだったのだと思う。
次はオープンデータを使って、当時のAdvanced RAGを今の設計で作り直してみたい。