RAGの精度が73%から100%に向上した話 ─ チャンキング戦略の比較検証
はじめに
RAG(Retrieval-Augmented Generation)システムを構築していると、「なぜか精度が上がらない」という壁にぶつかることがあります。
本記事では、社内規程文書を対象としたRAGシステムで、回答精度を73.3%から100%に改善した過程を紹介します。検証した複数のチャンキング戦略の中で、意外にも最もシンプルな解決策が最も効果的だったという結果になりました。
また、「Re-rankingを導入すれば精度が上がる」と思っていたのですが、逆に精度が下がるという予想外の結果も得られました。その理由についても考察します。
プロジェクト構成
技術スタック
| レイヤー | 技術 |
|---|---|
| フロントエンド | Next.js 16 + React 19 + TypeScript |
| バックエンド | FastAPI + Python 3.12 |
| 埋め込みモデル | intfloat/multilingual-e5-large |
| ベクトルDB | Chroma |
| LLM | Google Gemini 2.0 Flash |
| デプロイ | Vercel(Frontend)+ Hugging Face Spaces(Backend) |
システムアーキテクチャ
ユーザーの質問
│
▼
┌─────────────┐
│ Next.js │ フロントエンド(SSEストリーミング対応)
└─────────────┘
│
▼
┌─────────────┐
│ FastAPI │ バックエンド
└─────────────┘
│
├─── Embedding ─── multilingual-e5-large
│
├─── Vector Search ─── Chroma DB
│
└─── LLM ─── Gemini 2.0 Flash
│
▼
ストリーミング回答
ユーザーの質問はまず埋め込みモデルでベクトル化され、Chroma DBから類似度の高いチャンクを検索します。取得したチャンクをコンテキストとしてLLMに渡し、回答を生成します。
RAGが失敗する典型的なパターン
RAGシステムの精度が上がらない原因は、多くの場合LLMの性能ではなく、検索(Retrieval)の問題です。今回のプロジェクトで特に問題となったパターンを紹介します。
1. 暗黙的参照問題(Implicit Reference)
ドキュメント内で使われている用語と、ユーザーが使う用語が一致しない問題です。
例:
- ドキュメント:「第2条の2に定める者については、上限額を月額20,000円とする」
- ユーザーの質問:「アルバイトの通勤手当の上限は?」
「第2条の2に定める者」が「アルバイト」を指していることは、文書全体を読まないとわかりません。ベクトル検索では「アルバイト」というキーワードを含むチャンクが優先的に取得されるため、この重要な例外規定を見逃してしまいます。
2. マルチホップ推論(Multi-hop Reasoning)
回答に複数の情報源が必要なケースです。
例:
- ユーザーの質問:「正社員とアルバイトの結婚祝金の差額は?」
この質問に答えるには、正社員の結婚祝金(50,000円)とアルバイトの結婚祝金(10,000円)の両方を取得する必要があります。しかし、これらが文書内の離れた場所に記載されている場合、片方しか取得できず、差額を計算できません。
3. 例外規定の埋没
日本の社内規程では、一般的に以下の構造になっています:
第1条〜第10条: 一般規則(全従業員向け、キーワード密度高)
↓
第11条〜: 特例(特定の従業員タイプ向け、キーワード密度低)
↓
附則: 補足規定(例外ケース、さらにキーワード密度低)
ベクトル検索はキーワードの出現頻度に影響されるため、一般規則ばかりが取得され、例外規定が埋没してしまいます。
4. 否定・除外クエリ
「〜できない」「〜の対象外」といった否定形のクエリは、ベクトル検索が苦手とする領域です。
例:
- 「アルバイトが受けられない福利厚生は?」
ベクトル検索は論理演算(NOT、AND、OR)を直接扱えないため、このようなクエリでは適切な情報を取得しにくい傾向があります。
意図的に失敗するデータセットの設計
RAG改善の効果を検証するため、上記の問題を意図的に含むデータセットを作成しました。
設計原則
-
日本企業の実際の規程構造を模倣
- 一般規則 → 特例 → 附則のパターン
- 全6文書(通勤手当、休暇、経費精算、リモートワーク、服務、福利厚生)
-
同一概念に複数の呼称(エイリアス問題)
ユーザーが使う言葉 文書内の表現 アルバイト 短期雇用者、第2条の2に定める者 パート パートタイム従業員、週所定労働日数4日以上の者 -
例外規定を意図的に離れた場所に配置
具体例:通勤手当規程
# 通勤手当規程
## 第3条(通勤手当の支給)
通勤手当の上限額は月額50,000円とする。
← 一般規則(「通勤手当」が頻出)
...(約50行後)...
## 第12条(支給額の特例)
第2条の2に定める者については、上限額を月額20,000円とし、
定期券の支給は行わず、実際の出勤日数に基づき日割りで支給する。
← 例外規定(「アルバイト」というキーワードなし)
ユーザーが「アルバイトの通勤手当の上限は?」と質問した場合:
- ベクトル検索は「通勤手当」を含む第3条付近を取得
- 第12条の例外規定(実際の答え:20,000円)は取得されない
- LLMは「50,000円」と誤った回答をしてしまう
評価用クエリ
15問の評価クエリを作成し、それぞれに「必須キーワード」と「禁止キーワード」を設定しました。
{
"question": "アルバイトの通勤手当の上限額はいくらですか?",
"required_keywords": ["20,000円", "2万円"],
"prohibited_keywords": ["50,000円"]
}
「50,000円」(正社員の上限)が回答に含まれていたら不正解、「20,000円」(アルバイトの上限)が含まれていたら正解、という自動評価が可能になります。
検証したチャンキング戦略
1. Standard Chunking(ベースライン)
最も基本的なチャンキング戦略です。
# 設定
chunk_size = 1000 # 文字
overlap = 200 # 文字
結果:73.3%(11/15問正解)
1000文字のチャンクでは、一般規則と例外規定が別々のチャンクに分離されてしまい、例外規定に関する質問で失敗しました。
2. Large Chunking
チャンクサイズを大きくしたシンプルな改善です。
# 設定
chunk_size = 2000 # 文字
overlap = 500 # 文字
結果:100%(15/15問正解)
2000文字のチャンクにすることで、一般規則と例外規定が同一チャンクに収まるケースが増え、全問正解を達成しました。
3. Parent-Child Chunking
検索用の小さなチャンク(子)と、LLMに渡す大きなチャンク(親)を分離する戦略です。
# 設定
child_chunk_size = 400 # 検索用
parent_chunk_size = 2000 # LLMへのコンテキスト用
仕組み:
- 小さな子チャンク(400文字)でベクトル検索を実行
- ヒットした子チャンクに紐づく親チャンク(2000文字)をLLMに渡す
結果:93.3%(14/15問正解)
より詳細で網羅的な回答が可能になりましたが、「正社員とアルバイトの差額」のような比較クエリで1問失敗しました。これは、2つの金額が異なる親チャンクに分かれていたためです。
4. Hypothetical Questions
各チャンクに対して、LLMで想定される質問を事前生成する戦略です。
# インデックス作成時
for chunk in chunks:
questions = llm.generate(f"以下の内容について、ユーザーが質問しそうな内容を3つ生成してください:\n{chunk}")
# 生成された質問をベクトル化してインデックス
例:
- 元のチャンク:「第2条の2に定める者については、上限額を月額20,000円とする」
- 生成された質問:「アルバイトの通勤手当の上限はいくらですか?」
結果:93.3%(14/15問正解)
エイリアス問題(「第2条の2に定める者」≒「アルバイト」)の解決に効果的でしたが、LLMのAPI呼び出しコストが増加する点がデメリットです。
5. Re-ranking(意外な結果)
初回検索の結果を、Cross-Encoderで再ランキングする戦略です。
# 設定
initial_k = 10 # 初回検索で10件取得
final_k = 4 # Cross-Encoderで上位4件に絞る
model = "cross-encoder/ms-marco-MiniLM-L-6-v2"
結果:60.0%(9/15問正解)← Standard単体より悪化
これは予想外の結果でした。Re-rankingを入れることで精度が下がったのです。
結果の考察
結果サマリー
| 戦略 | 精度 | 実装複雑度 |
|---|---|---|
| Standard (1000/200) | 73.3% | 低 |
| Large (2000/500) | 100% | 低 |
| Parent-Child | 93.3% | 中 |
| Hypothetical Questions | 93.3% | 高 |
| Standard + Reranking | 60.0% | 中 |
なぜ Large Chunk が最強だったか
今回のデータセットでは、例外規定が一般規則から300〜500文字以内に配置されていました。2000文字のチャンクであれば、多くのケースで両方を含むことができます。
これは「シンプルな解決策が最も効果的だった」という結果ですが、注意点があります:
- データセットに依存する結果です
- 例外規定が1000文字以上離れている場合は、Large Chunkでも失敗します
- チャンクが大きすぎると、検索精度が下がる可能性もあります
Re-ranking が失敗した理由
Re-rankingが精度を下げた理由は、Precision(精度)とRecall(再現率)の違いにあります。
Re-ranking の役割:
取得した10件の中から、最も関連性の高い4件を選ぶ
→ Precision(精度)を上げるツール
今回の問題:
そもそも例外規定が10件の中に含まれていない
→ Recall(再現率)の問題
結論:
初回検索で取得できなかった情報は、
Re-rankingでも救えない
Re-rankingは「ノイズを除去する」ツールであり、「見逃した情報を拾う」ツールではありません。今回の問題の本質は**検索漏れ(Recall不足)**だったため、Re-rankingは効果がありませんでした。
むしろ、初回検索で辛うじて下位に入っていた関連チャンクが、Re-rankingによって除外されてしまい、精度が悪化したと考えられます。
今後の改善案
実装したい改善
1. ヘッダーベースのチャンキング
固定長ではなく、Markdownの見出し(##、###)で分割する方法です。
## 第3条(通勤手当の支給)
...本文...
## 第4条(申請方法) ← ここで分割
...本文...
これにより、意味的なまとまりを保ったチャンクを作成できます。
2. クエリ拡張
ユーザーのクエリを、文書内の用語に変換してから検索します。
ユーザー: 「アルバイトの通勤手当は?」
↓ LLMで拡張
拡張後: 「アルバイト 短期雇用者 第2条の2に定める者 通勤手当 交通費」
3. 検索件数の増加
単純に取得件数(k)を増やすことで、例外規定が含まれる確率を上げます。
# Before
k = 4
# After
k = 10
ただし、kを増やしすぎるとコンテキストが長くなり、LLMの処理コストが増加します。
データ品質の改善
チャンキング戦略の改善には限界があります。より根本的な解決策として、文書自体を前処理するアプローチがあります。
Before: 通勤手当規程.md(全従業員向け、例外規定が散在)
After:
- 通勤手当規程_正社員.md
- 通勤手当規程_パート.md
- 通勤手当規程_アルバイト.md
従業員タイプごとに文書を分割し、それぞれの視点から書き直すことで、例外規定の問題を根本的に解消できます。この方法で93.3%の精度を達成しました。
今後検証したいこと
- セマンティックチャンキング: 意味の区切りを自動検出してチャンキング
- GraphRAG: 知識グラフを活用したRAG
- ファインチューニング済み埋め込みモデル: ドメイン特化の埋め込みモデル
まとめ
本記事では、RAGシステムの精度改善について、実際のデータを用いた検証結果を紹介しました。
主な発見
-
シンプルな解決策が最も効果的だった
- Large Chunk(2000文字)で100%を達成
- 複雑なParent-ChildやHypothetical Questionsより効果的
-
Re-rankingは万能ではない
- Recall(再現率)の問題には効果がない
- むしろ精度が下がるケースもある
-
データ品質が最も重要
- チャンキング戦略の改善には限界がある
- 文書の前処理が根本的な解決策になりうる
RAG精度改善のポイント
「RAG精度改善の鍵は、LLMの最適化ではなく、データ品質と検索精度にある」
まずは自分のデータセットで、どのような検索失敗が起きているかを分析することが重要です。その上で、最もシンプルな解決策から試してみることをお勧めします。
Discussion