RAGの精度を改善する:query rewrite・hybrid search・rerank・評価ログ
1. はじめに
前回は、青空文庫の吉川英治『三国志』を使って、最小構成のRAGを実装しました。
前回のRAGは、以下のようなシンプルな構成でした。
文書読み込み ↓ chunk分割 ↓ Embedding ↓ FAISS検索 ↓ 検索結果をLLMへ渡す ↓ 回答生成
この構成でも、RAGの基本的な流れは理解できます。
ただ、実際に作ってみると、回答品質はLLMだけでは決まらないことが分かりました。
特に、以下のような課題がありました。
- ユーザーのqueryが曖昧だと、検索が外れやすい
- 人物名の表記ゆれがあると、関連chunkを取りこぼす可能性がある
- FAISSは必ず近いchunkを返すが、それが正しい根拠とは限らない
- LLMが検索結果以外の一般知識で補ってしまう可能性がある
- 検索対象を増やすと、正解候補だけでなくノイズ候補も増える
今回は、前回の最小RAGをベースにして、検索品質を改善するための仕組みを追加しました
今回試した主な要素は以下です。
- Query Analyzer
- Query Rewrite
- 人物名の別名展開
- Multi-query Retrieval
- BM25
- Dense + BM25 Hybrid Search
- RRFによる検索結果の統合
- LLM Rerank
- Evidence Gateによる拒答制御
- Context Assembly
- Evaluation
- Query Logging
目的は、単にRAGに機能を追加することではありません。
RAGの検索はどこで失敗するのか、どの改善が効いているのかを、比較・評価・観測できる形にすることを目的にします。
2. 今回の改善方針
前回のRAGでは、ユーザーの質問をそのままEmbeddingして、FAISSで類似chunkを検索していました。
original query ↓ Embedding ↓ FAISS ↓ LLM
今回は、この流れを以下のように拡張します。
改善方針は、大きく分けると4つです :
- queryを検索しやすい形に変換する
- Dense SearchとBM25を組み合わせる
- Rerankで候補chunkを再評価する
- EvaluationとLogで改善を確認する
3. プロジェクト構造
D:\LLM\zenn\4
├── .env
├── .env.example
├── .env_openai
├── .gitignore
├── README.md
├── requirements.txt
├── main.py
├── zenn-4-for-gpt.md
├── 说明.txt
│
├── data/
│ ├── raw/
│ │ ├── 01jo.txt
│ │ ├── 02toenno_maki.txt
│ │ ├── 03gunseino_maki.txt
│ │ ├── 04somono_maki.txt
│ │ ├── 05shindono_maki.txt
│ │ ├── 06komeino_maki.txt
│ │ ├── 07sekihekino_maki.txt
│ │ ├── 08boshokuno_maki.txt
│ │ ├── 09tonanno_maki.txt
│ │ ├── 10suishino_maki.txt
│ │ ├── 11gojogenno_maki.txt
│ │ └── 12hengaiyoroku.txt
│ │
│ └── processed/
│ └── chunks_preview.txt
│
├── storage/
│ ├── faiss.index
│ ├── metadata.json
│ └── bm25.pkl
│
├── eval/
│ └── questions.yaml
│
├── outputs/
│ ├── logs/
│ │ └── query_log.jsonl
│ │
│ └── eval_results/
│ ├── eval_summary.md
│ └── eval_details.json
│
├── scripts/
│ ├── build_index.py
│ ├── build_indexes.py
│ ├── query.py
│ ├── query_advanced.py
│ └── evaluate.py
│
└── src/
├── __init__.py
├── advanced_constants.py
├── advanced_rag.py
├── aliases.py
├── bm25_store.py
├── chunking.py
├── clean_aozora.py
├── config.py
├── context_assembler.py
├── embedding_client.py
├── evaluator.py
├── evidence_gate.py
├── hybrid_retriever.py
├── load_docs.py
├── logger.py
├── query_analyzer.py
├── query_rewriter.py
├── rag.py
├── reranker.py
└── vector_store.py
注:
-
storage/
、outputs/
、data/processed/
は実行後に生成・更新される。 -
faiss.index
、metadata.json
、bm25.pkl
はscripts/build_indexes.py
で生成する。 -
outputs/logs/query_log.jsonl
は query 実行ごとに追記される。 -
outputs/eval_results/
はscripts/evaluate.py
で生成される。
4. 検索対象を全12巻に広げる
前回は、実験しやすいように『三国志』のうち、以下の2巻だけを対象にしました。
02toenno_maki.txt 03gunseino_maki.txt
今回は検索対象を全12巻に広げました。
01jo.txt 02toenno_maki.txt 03gunseino_maki.txt 04somono_maki.txt 05shindono_maki.txt 06komeino_maki.txt 07sekihekino_maki.txt 08boshokuno_maki.txt 09tonanno_maki.txt 10suishino_maki.txt 11gojogenno_maki.txt 12hengaiyoroku.txt
index作成は以下のコマンドで行いました
python scripts/build_indexes.py
実行結果は以下です
documents : 12 chunks : 2602 dimension : 1024 faiss : storage/faiss.index metadata : storage/metadata.json bm25 : storage/bm25.pkl
前回では、構成もかなりシンプルでした。
2巻だけ original query FAISS dense search LLM answer
今回は、検索対象を全12巻に広げました。
全12巻 FAISS dense search BM25 search metadata Hybrid Search / RRF / Rerank / Evaluation
検索対象を2巻から12巻に広げると、回答範囲は広がります。
一方で、検索対象が増えると、正解に近いchunkだけでなく、少し似ているだけのchunkも増えます。
つまり、文書量を増やせば単純に精度が上がるわけではありません。
検索空間が広くなるほど、以下のような問題が起きやすくなります。
- 意味は少し近いが、質問の答えではないchunkが混ざる
- 固有名詞や表記ゆれで検索が不安定になる
- top-kに正解chunkは入るが、ノイズも一緒に入る
- LLMに渡すcontextの選び方が難しくなる
- ...
この問題を確認するために、まずは前回に近い構成である baseline_dense を試しました。
5. 実験1: Query Rewrite
最初に、Query Rewrite の効果を確認します。
質問は以下です。
三人が兄弟になる場面を教えて
人間が読むと、この質問は「劉備・関羽・張飛が義兄弟になる場面」を聞いていると分かります。
しかし、検索 query として見ると少し曖昧です。
「三人」が誰を指すのか
「兄弟になる場面」がどの場面なのか
「桃園の誓い」という表現と対応するのか
が明示されていません。
前回の最小 RAG では、このような query をそのまま Embedding して FAISS で検索していました。
今回は、検索前に Query Analyzer / Query Rewrite を入れます。
Query Rewrite の目的は、LLM に回答を作らせることではありません。
ユーザーの質問を、検索しやすい query に変換することです。
今回期待する変換は、たとえば以下のようなものです。
original query: 三人が兄弟になる場面を教えて rewritten query: 劉備・関羽・張飛が桃園で義兄弟の誓いを立てる場面
実行コマンドは以下です。
python scripts/query_advanced.py --mode baseline_dense --query "三人が兄弟になる場面を教えて" python scripts/query_advanced.py --mode rewrite_dense --query "三人が兄弟になる場面を教えて" --show-candidate-text --retrieval-only
baseline_dense: original query のまま検索した場合
結果は以下です。
Dense top3: [1] id=96 score=0.5419 source=02toenno_maki.txt chunk_id=94 [2] id=544 score=0.5121 source=04somono_maki.txt chunk_id=62 [3] id=2228 score=0.4444 source=10suishino_maki.txt chunk_id=233 [1] id=96 chunk_id=94 「実際に当って、徳を積み、身を修め、果たして主君となるの資才がありや否や、それを自身もあなたたちも見届けてから約束しても、遅くないと思わ れますから」 「いや。それはもう、われわれが見届けてあるところです」 「左はいえ、私はなお、憚られます。――ではこうしましょう。君臣の誓いは 、われわれが一国一城を持った上として、ここでは、三人義兄弟の約束を結んでおくことにして下さい。君臣となって後も、なお三人は、末永く義兄弟 であるという約束をむしろ私はしておきたいのですが」 「うむ」 関羽は、長い髯を持って、自分の顔を引っぱるように大きくうなずいた。 「結構だ。張飛、おぬしは」 「異論はない」 改めて三名は、祭壇へ向って牛血と酒をそそぎ、ぬかずいて、天地の神祇に黙祷をささげた。 三 年齢からいえば 、関羽がいちばん年上であり、次が劉備、その次が張飛という順になるのであるが、義約のうえの義兄弟だから年順をふむ必要はないとあって、「長兄 には、どうか、あなたがなって下さい。それでないと、張飛の我ままにも、おさえが利きませんから」と、関羽がいった。 張飛も、ともども、 「それ は是非、そうありたい。いやだといっても、二人して、長兄長兄と崇めてしまうからいい」 劉備は強いて拒まなかった。そこで三名は、鼎座して、将来の理想をのべ、刎頸の誓いをかため、やがて壇をさがって桃下の卓を囲んだ。 「では、永く」 「変るまいぞ」 「変らじ」 と、兄弟の杯を交わし、そ して、三人一体、協力して国家に報じ、下万民の塗炭の苦を救うをもって、大丈夫の生涯とせんと申し合った。 張飛は、すこし酔うてきたとみえて、声を大にし、杯を高 [2] id=544 chunk_id=62 「武士の情けに、その剣で、この頭を刎ね落してくれ。なんの面目あって生きていられようか」 と、慟哭した。 玄徳は、張飛のそばへ歩み寄って、病 人をいたわるような言葉でいった。 「張飛よ。落着くがいい。いつまで返らぬ繰り言をいうのではない」 優しくいわれて、張飛はなおさら苦しげだっ た。むしろ笞で打ッて打ッて打ちすえてほしかった。 玄徳は膝を折って彼の手を握り取り、しかと、手に力をこめて、 「古人のいった言葉に――兄弟ハ 手足ノ如ク、妻子ハ衣服ノ如シ――とある。衣服はほころぶもこれを縫えばまだまとうに足る。けれど、手足はもしこれを断って五体から離したならいつ の時かふたたび満足に一体となることができよう。――忘れたか張飛。われら三人は、桃園に義を結んで、兄弟の杯をかため、同年同日に生るるを求めず 、同年同日に死なんと――誓い合った仲ではなかったか」 「……はっ。……はあ」 張飛は大きく嗚咽しながらうなずいた。 「われら兄弟三名は、各がみな至らない所のある人間だ。その欠点や不足をお互いに補い合ってこそ始めて真の手足であり一体の兄弟といえるのではないか。そちも神ではない。玄徳も 凡夫である。凡夫のわしが、何を以て、そちに神の如き万全を求めようか。――呂布のために、城を奪われたのも是非のないことだ。またいかに呂布でも 、なんの力もない我が母や妻子まで殺すような酷いこともまさか致しはすまい。そう嘆かずと、玄徳と共に、この後とも計をめぐらして、我が力になっ てくれよ。……張飛、得心が参ったか」 「……はい。……はい。……はい」 張飛は、鼻柱から、ぽとぽとと涙を垂らして、いつまでも、大地に両手をついてい た。 [3] id=2228 chunk_id=233 だから戦に負けたとは思わない」と、嘯いた。 「しかし孟獲。剣には負けなくても、策には負けたろう。汝が舟中のざまはどうだ」 「あれは失策った……」と、孟獲もここは正直に肯定して、「――だが、人間だから、暗い所では石にもつまずくよ」と、まだ負けおしみをいった。 孔明はすこし厳を示して 、 「すでに今、三度まで、予は汝を生擒った。この上は約束を履んで、汝の首を斬って放たん。孟獲何か云い置くことはないか」 「待て待て」と、前 の二回とは大いに容子が変ってきて、彼はひどく生命を惜しんで慌てた。 「もう一度放してくれ」 「仏の面も三度という。わが仁義にも程度がある」 「もう一遍でいい」 「その一遍で何をしたいか」 「快く一戦したい」 「重ねて生擒られたら」 「こんどは打ち首になっても悔いない」 「は、は、は、は」 孔明は大笑した。とたんに、自身剣を抜いて、彼の縛めを切り放した。 「孟獲、次の折には、よく軍書を考えて、二度と悔いを残さぬように、 よく陣容を立て直して参れよ。――時に、汝の弟は、どうしたか」 「えっ、弟?」 「骨肉を忘れるとは、如何したものだ。それでも蛮界の王として、土 民を服してゆけるのか」 「火中から助け出したが、途中より別れて生死も分らぬ」 「誰か。――孟優をこれへ連れてこい」と、左右にいいつけると、幕 将たちは、帳の内へ入って、どやどやと一人の蛮将を取り囲んで連れてきた。 「ば、ばか野郎っ。いくら日頃から酒好きだって、敵の毒酒まで飲む馬鹿があるかっ」 孔明は笑って、二人の仲を押しへだてた。 「味方破れに懲りながら、またすぐここで兄弟喧嘩をするなどは、すでに軍書の教えに反いて いるで
top1 の 02toenno_maki.txt / chunk_id=94 は正しい chunk でした。
この chunk には、以下のような本文が含まれています。
「ここでは、三人義兄弟の約束を結んでおくことにして下さい」 「祭壇へ向って牛血と酒をそそぎ」 「兄弟の杯を交わし」
これは、劉備・関羽・張飛が義兄弟の誓いを立てる場面であり、質問に対する直接的な根拠になります。
一方で、top2 と top3 にはノイズも含まれていました。
top2 の 04somono_maki.txt / chunk_id=62 は、後の場面で玄徳が張飛に桃園の誓いを思い出させる場面です。
「われら三人は、桃園に義を結んで」 「兄弟の杯をかため」
この chunk は「桃園」「三人」「兄弟」という語を含んでいるため、意味的には近いです。
しかし、三人が実際に義兄弟になる場面そのものではありません。
つまり、質問の答えとして使うには少し弱い chunk です。
top3 の 10suishino_maki.txt / chunk_id=233 は、孟獲と孔明の場面でした。
「兄弟喧嘩」 「孟獲」 「孔明」
これは「兄弟」という語に反応している可能性がありますが、桃園の誓いとは関係ありません。
この結果から、original query + Dense Search でも正解 chunk は取れました。
しかし、top3 のうち、直接使える根拠は top1 だけでした。
原因は、original query に以下のような検索上重要な語が含まれていないことです。
劉備 関羽 張飛 桃園の誓い 義兄弟
「三人」「兄弟になる」という表現だけでは、意味的に似た別の場面もマッチしやすくなります。
rewrite_dense: query を書き換えて検索した場合
次に、Query Rewrite を入れて検索しました。
rewrite 前の query は以下です。
三人が兄弟になる場面を教えて
rewrite 後は以下のようになりました。
劉備・関羽・張飛が桃園で義兄弟の誓いを立てる場面
rewrite 後の Dense Search 結果は以下です。
Dense top3: [1] id=96 score=0.6915 source=02toenno_maki.txt chunk_id=94 [2] id=544 score=0.6605 source=04somono_maki.txt chunk_id=62 [3] id=92 score=0.6159 source=02toenno_maki.txt chunk_id=90 [1] id=96 source=02toenno_maki.txt chunk_id=94 「実際に当って、徳を積み、身を修め、果たして主君となるの資才がありや否や、それを自身もあなたたちも見届けてから約束しても、遅くないと思わ れますから」 「いや。それはもう、われわれが見届けてあるところです」 「左はいえ、私はなお、憚られます。――ではこうしましょう。君臣の誓いは 、われわれが一国一城を持った上として、ここでは、三人義兄弟の約束を結んでおくことにして下さい。君臣となって後も、なお三人は、末永く義兄弟 であるという約束をむしろ私はしておきたいのですが」 「うむ」 関羽は、長い髯を持って、自分の顔を引っぱるように大きくうなずいた。 「結構だ。張飛、おぬしは」 「異論はない」 改めて三名は、祭壇へ向って牛血と酒をそそぎ、ぬかずいて、天地の神祇に黙祷をささげた。 三 年齢からいえば 、関羽がいちばん年上であり、次が劉備、その次が張飛という順になるのであるが、義約のうえの義兄弟だから年順をふむ必要はないとあって、「長兄 には、どうか、あなたがなって下さい。それでないと、張飛の我ままにも、おさえが利きませんから」と、関羽がいった。 張飛も、ともども、 「それ は是非、そうありたい。いやだといっても、二人して、長兄長兄と崇めてしまうからいい」 劉備は強いて拒まなかった。そこで三名は、鼎座して、将来の理想をのべ、刎頸の誓いをかため、やがて壇をさがって桃下の卓を囲んだ。 「では、永く」 「変るまいぞ」 「変らじ」 と、兄弟の杯を交わし、そ して、三人一体、協力して国家に報じ、下万民の塗炭の苦を救うをもって、大丈夫の生涯とせんと申し合った。 張飛は、すこし酔うてきたとみえて、声を大にし、杯を高 [2] id=544 source=04somono_maki.txt chunk_id=62 「武士の情けに、その剣で、この頭を刎ね落してくれ。なんの面目あって生きていられようか」 と、慟哭した。 玄徳は、張飛のそばへ歩み寄って、病 人をいたわるような言葉でいった。 「張飛よ。落着くがいい。いつまで返らぬ繰り言をいうのではない」 優しくいわれて、張飛はなおさら苦しげだっ た。むしろ笞で打ッて打ッて打ちすえてほしかった。 玄徳は膝を折って彼の手を握り取り、しかと、手に力をこめて、 「古人のいった言葉に――兄弟ハ 手足ノ如ク、妻子ハ衣服ノ如シ――とある。衣服はほころぶもこれを縫えばまだまとうに足る。けれど、手足はもしこれを断って五体から離したならいつ の時かふたたび満足に一体となることができよう。――忘れたか張飛。われら三人は、桃園に義を結んで、兄弟の杯をかため、同年同日に生るるを求めず 、同年同日に死なんと――誓い合った仲ではなかったか」 「……はっ。……はあ」 張飛は大きく嗚咽しながらうなずいた。 「われら兄弟三名は、各がみな至らない所のある人間だ。その欠点や不足をお互いに補い合ってこそ始めて真の手足であり一体の兄弟といえるのではないか。そちも神ではない。玄徳も 凡夫である。凡夫のわしが、何を以て、そちに神の如き万全を求めようか。――呂布のために、城を奪われたのも是非のないことだ。またいかに呂布でも 、なんの力もない我が母や妻子まで殺すような酷いこともまさか致しはすまい。そう嘆かずと、玄徳と共に、この後とも計をめぐらして、我が力になっ てくれよ。……張飛、得心が参ったか」 「……はい。……はい。……はい」 張飛は、鼻柱から、ぽとぽとと涙を垂らして、いつまでも、大地に両手をついてい た。 [3] id=92 source=02toenno_maki.txt chunk_id=90 ――そして明日はいずれまた、お三名して将来の相談もあろうし、大事の門出でもありますし、母が一生一度の馳走をこしらえてあげますからね」 それを聞いて、関羽は、この母親の胸を問うなど愚であることを知った。張飛も共に、頭を下げて、「ありがとうござる」と、心服した。 劉備は、 「では、 お言葉に甘えて、明日はおっ母さんに、一世一代の祝いを奢っていただきましょう。けれどそのご馳走は、吾々ばかりでなく、祭壇を設けて、先祖にも 上げていただきたいものです」 「では、ちょうど今は、桃園の花が真盛りだから、桃園の中に蓆を敷こうかね」 張飛は手を打って、 「それはいい。では吾々も、あしたは朝から桃園を浄めて、せめて祭壇を作る手助けでもしよう」 と、いった。 客の二人に床を与えて、眠りをすすめ、劉備と母のふた りは、暗い厨の片隅で、藁をかぶって寝た。劉が眼をさましてみると、母はもういなかった。夜は明けていたのである。どこかでしきりに、山羊の啼く 声がしていた。 厨の窯の下には、どかどかと薪がくべられていた。こんなに景気よく窯に薪の焚かれた例は、劉備が少年の頃から覚えのないことであった。春は桃園ばかりでなく、貧しい劉家の台所に訪れてきたように思われた。 義盟 一 桃園へ行ってみると、関羽と張飛のふたりは、近所の男を雇 ってきて、園内の中央に、もう祭壇を作っていた。 壇の四方には、笹竹を建て、清縄をめぐらして金紙銀箋の華をつらね、土製の白馬を贄にして天を祭り、烏牛を屠ったことにして、地神を祠った。 「やあ、おはよう」 劉備が声をかけると、 「おお、お目ざめか」 張飛、関羽は、振向いた。 「見事に祭壇ができま
rewrite 後も、top1 は 02toenno_maki.txt / chunk_id=94 でした。
ただし、score は以下のように上がりました。
baseline top1 score : 0.5419 rewrite top1 score : 0.6915
rewrite によって、query に 劉備、関羽、張飛、桃園、義兄弟 という重要な語が入ったため、正解 chunk との距離が近くなったと考えられます。
また、top3 には 02toenno_maki.txt / chunk_id=90 が入りました。
この chunk は、桃園に祭壇を準備する直前の場面です。
「桃園の中に蓆を敷こうかね」 「吾々も、あしたは朝から桃園を浄めて、せめて祭壇を作る手助けでもしよう」
これは誓いそのものの場面ではありませんが、桃園の誓いにつながる前後文脈としては関連があります。
一方で、top2 には baseline と同じ 04somono_maki.txt / chunk_id=62 が残りました。
この chunk は、桃園の誓いを後から回想する場面です。
関連はありますが、質問が求めている「兄弟になる場面」そのものではありません。
比較して分かったこと
baseline と rewrite を比較すると、以下のことが分かりました。
| 比較項目 | baseline_dense | rewrite_dense |
|---|---|---|
| query | 三人が兄弟になる場面を教えて | 劉備・関羽・張飛が桃園で義兄弟の誓いを立てる場面 |
| top1 | 正解 chunk | 正解 chunk |
| top1 score | 0.5419 | 0.6915 |
| top2 | 回想場面 | 回想場面 |
| top3 | unrelated noise | 桃園の誓い前後の関連 chunk |
Query Rewrite によって、top1 の正解 chunk はより強くマッチしました。
また、top3 も完全なノイズではなく、桃園の誓いに近い前後文脈の chunk になりました。
一方で、top2 にはまだ回想場面の chunk が残っています。
つまり、Query Rewrite は検索 query を改善できますが、Dense Search だけでノイズを完全に取り除くことはできません。
今回の結果をまとめると、以下のようになります。
Query Rewrite で改善できたこと:
- 曖昧な「三人」を 劉備・関羽・張飛 に補えた
- 「兄弟になる場面」を 桃園で義兄弟の誓いを立てる場面 に変換できた
- 正解 chunk の score が上がった
- top3 の内容がより関連するものになった
まだ残った問題:
- 回想場面のような関連はあるが直接の根拠ではないchunkが残る
- Dense Search は意味的に近い候補を返すが、答えとして最適かどうかまでは判断しない
そのため、後続の実験では、Alias Expansion、Hybrid Search、Rerank を追加して、候補の品質をさらに改善します。
Query Rewrite の注意点: Query Drift
Query Rewrite は便利ですが、注意点もあります。
それは Query Drift です。
Query Drift とは、rewrite によって元の質問から意図がずれてしまうことです。
たとえば、以下のような rewrite はよくありません。
original query: 玄徳はどんな人物ですか? bad rewrite: 劉備は蜀漢の皇帝としてどのような政治を行いましたか?
この例では、ユーザーは「人物像」を聞いています。
しかし、bad rewrite では「政治」や「皇帝として」という新しい意図が追加されています。
これは元の質問とは違う検索方向になります。
そのため、今回の実装では Query Rewrite に以下の制約を入れました。
- 質問には答えない
- 元の質問にない新しい意図を追加しない
- 本文外の一般知識を検索 query に混ぜない
- search query は最大3件までにする
- 不確かな場合は original query をそのまま返す
- API 呼び出しに失敗した場合も original query に fallback する
Query Rewrite は回答生成ではなく、元の質問の意図を保ったまま、検索しやすい表現に変換するための処理です。
6. 実験2: Alias Expansion
次に、人物名の別名展開を確認します。
質問は以下です。
玄徳はどんな人物ですか?
『三国志』では、同じ人物が複数の名前で呼ばれます。
たとえば、劉備は「玄徳」とも呼ばれます。
また、本文中では「劉備」「玄徳」「劉玄徳」のように、場面によって表記が変わることがあります。
このような表記ゆれがあると、質問では「玄徳」と書かれていても、検索対象の本文では「劉備」や「劉玄徳」と書かれている可能性があります。
そこで、人物名の別名辞書を用意しました。
ALIASES = {
"劉備": ["玄徳", "劉玄徳"],
"玄徳": ["劉備", "劉玄徳"],
"関羽": ["雲長"],
"雲長": ["関羽"],
"張飛": ["翼徳"],
"翼徳": ["張飛"],
"曹操": ["孟徳"],
"孟徳": ["曹操"],
"諸葛亮": ["孔明"],
"孔明": ["諸葛亮"],
"孫権": ["仲謀"],
"仲謀": ["孫権"],
"趙雲": ["子龍"],
"子龍": ["趙雲"],
"司馬懿": ["仲達"],
"仲達": ["司馬懿"],
}
今回の別名辞書は、demo用に手動で作った小さな辞書です。
メリットは、シンプルで制御しやすいことです。
一方で、辞書にない人物名や別表記には対応できません。
実際の業務RAGでは、以下のような流れで alias dictionary を作ることが多いと思います。
業務マスタ / 製品表 / 組織表 / 用語集 ↓ ルール抽出 + LLMによる候補抽出 ↓ 人手で確認 ↓ alias dictionary に登録 ↓ query log を見ながら継続的に更新
つまり、LLMに別名を自由に推測させるのではなく、できるだけ信頼できるデータから作ることが重要です。
たとえば企業文書では、以下のような表記ゆれがよくあります。
- 正式名称 / 略称
- 旧名称 / 新名称
- 製品名 / 型番
- 部署名 / 略称
- 制度番号 / 制度名
- 契約条項番号 / 条項名
そのため、Alias Expansion は『三国志』だけの特殊な処理ではなく、実用的なRAGでも重要な前処理です。
rewrite_dense と rewrite_alias_dense を比較する
最初に、通常の rewrite_dense と rewrite_alias_dense を比較しました。
実行コマンドは以下です。
python scripts/query_advanced.py --mode rewrite_dense --query "玄徳はどんな人物ですか?" --show-candidate-text --retrieval-only python scripts/query_advanced.py --mode rewrite_alias_dense --query "玄徳はどんな人物ですか?" --show-candidate-text --retrieval-only
alias なしの rewrite_dense
alias なしの rewrite_dense では、Query Analysis は以下のようになりました。
type : alias
standalone_query : 劉備はどんな人物ですか?
analysis_queries : ['劉備 人物評価', '劉備 性格 特徴', '劉備 伝記的描写']
retrieval_queries: ['劉備はどんな人物ですか?']
alias_expansion : False
aliases_detected : {'玄徳': ['劉備', '劉玄徳']}
aliases_used : {}
confidence : 0.95
この時点で、Query Analyzer は 玄徳 が 劉備 の別名であることを検出しています。
ただし、alias_expansion は False なので、実際の retrieval query には以下だけが使われました。
劉備はどんな人物ですか?
検索結果は以下です。
Dense candidates: [1] id=69 score=0.5442 source=02toenno_maki.txt chunk_id=67 [2] id=37 score=0.5360 source=02toenno_maki.txt chunk_id=35 [3] id=1196 score=0.5330 source=06komeino_maki.txt chunk_id=231 [1] id=69 source=02toenno_maki.txt chunk_id=67 張飛はもとより折角の名剣を泥池に捨ててしまうのは本意ではないから、止められたのを幸いに、 「何か?」と、わざと身を退いて、劉備の言を待つもののように見まもった。 「まず、お待ちなさい」 劉備は言葉しずかに、張飛の悲壮な顔いろをなだめて、 「真の勇者は慷慨せずといいます。また、大事は蟻の穴より漏るというたとえもある。ゆるゆるはなすとしましょう。しかし、足下が偽ものでないことはよく認めました。偉丈夫の心事を一時でも 疑った罪はゆるして下さい」 「おっ。……では」 「風にも耳、水にも眼、大事は路傍では語れません。けれど自分は何をつつもう、漢の中山靖王劉勝の 後胤で、景帝の玄孫にあたるものです。……なにをか好んで、沓を作り蓆を織って、黄荒の末季を心なしに見ておりましょうや」と、声は小さく語韻はさ さやく如くであったが、凛たるものをうちに潜めていい、そしてにこと笑ってみせた。 「豪傑。これ以上、もう多言は吐く必要はないでしょう。折を見てまた会いましょう。きょうは市へきた出先で、遅くなると母も案じますから――」 張飛は獅子首を突きだして、噛みつきそうな眼をしたまま、いつまでも無言だった。これは感きわまった時にやるかれの癖なのである。それからやがて唸るような息を吐いて、大きな胸をそらしたと思うと、 「そうだったのか! やはりこの張飛の眼には誤りはなかった! いやいつか古塔の上から跳び降りて死んだかの老僧のいったことが、今思いあたる。……ウウム、あな たは景帝の裔孫だったのか。治乱興亡の長い星霜のあいだに、名門名族は泡沫のように消えてゆくが、血は一滴でも残されればどこかに伝わってゆく。 ああ有難い。生き [2] id=37 source=02toenno_maki.txt chunk_id=35 「ただは死なぬ」と思い、石ころをつかむが早いか、近づく者の顔へ投げつけた。 見くびっていた賊の一名は、不意を喰らって、 「あッ」と、鼻ばし らをおさえた。 劉備は、飛びついて、その槍を奪った。そして大音に、 「四民を悩ます害虫ども、もはや免しはおかぬ。県の劉備玄徳が腕のほどを見 よや」 といって、捨身になった。 賊の小方、李朱氾は笑って、 「この百姓めが」と半月槍をふるってきた。 もとより劉備はさして武術の達人ではな い。田舎の楼桑村で、多少の武技の稽古はしたこともあるが、それとて程の知れたものだ。武技を磨いて身を立てることよりも、蓆を織って母を養うこ とのほうが常に彼の急務であった。 でも、必死になって、七人の賊を相手に、ややしばらくは、一命をささえていたが、そのうちに、槍を打落され、よろめいて倒れたところを、李朱氾に馬のりに組み敷かれて、李の大剣は、ついに、彼の胸いたに突きつけられた。 ――おおういっ。 すると、……いやさっ きからその声は遠くでしたのだが、剣戟のひびきで、誰の耳にも入らなかったのである。 遥か彼方の野末から、 「――おおういっ。待ってくれい」 呼ばわる声が近づいてくる。 野彦のように凄い声は、思わず賊の頭を振り向かせた。 両手を振りながら韋駄天と、こなたへ馳けてくる人影が見える。その 迅いことは、まるで疾風に一葉の木の葉が舞ってくるようだった。 だがまたたく間に近づいてきたのを見ると、木の葉どころか身の丈七尺もある巨漢だった。 「やっ、張卒じゃないか」 「そうだ。近頃、卒の中に入った下ッ端の張飛だ」 賊は、不審そうに、顔見合せて云い合った。自分らの部下の中にいる張飛と [3] id=1196 source=06komeino_maki.txt chunk_id=231 「うん」と、一つうなずいたきり、後ろに続く関羽、張飛などの姿へ、棗のような眼をみはっている。 「大儀ながら、廬中へ取次いでもらいたい。自分は、漢の左将軍、宜城亭侯、領は予州の牧、新野皇叔劉備、字は玄徳というもの。先生にまみえんため、みずからこれへ参ったのであるが」 「待っておくれ」 童子は、ふいにさえぎって云った。 「――そんな長い名は、おぼえきれやしない。もう一度いってください」 「なるほど。これはわしが悪かった。ただ、新野の劉備が来ました――と、そう伝えてくれればよい」 「おあいにくさま。先生は今朝早天に出たまま、まだ帰っておりません」 「いずこへ お出でなされたか」 「どこへお出かけやら、ちっとも分りません。――行雲踪蹟不定――で」 「いつ頃、お帰りであろうか」 「さあ。時によると三、五日。あるいは十数日。これもはかり難しですね」 「…………」 玄徳は、落胆して、いかにも力を失ったように、惆悵久しゅうして、なおたたずんでいたが、 そう聞くと、そばから張飛が、 「いないものは仕方がない。早々帰ろうじゃありませんか」といった。 関羽も共に、 「また他日、使いでも立てて、在否を訊かせた上、改めてお越しあってはいかがです」 と、駒を寄せてうながした。 孔明の帰ってくるまでは、そこにたたずんででもいたいような玄徳 であったが、是非なく、童子に言伝てを頼んで悄然、岡の道を降りて行った。 秀雅にして高からぬ山、清澄にして深からぬ水、茂盛した松や竹林には、猿や鶴が遊んでいる。玄徳は、ここの山紫水明にも、うしろ髪を引かれてならなかった。 すると、岡のふもとから身に青衣をまとい、頭に逍遥頭巾をいた
上位には、劉備に関するchunkが取得されています。
たとえば、02toenno_maki.txt / chunk_id=67 には、劉備が自分の出自を静かに語る場面が含まれていました。
「自分は何をつつもう、漢の中山靖王劉勝の後胤で、景帝の玄孫にあたるものです」
また、02toenno_maki.txt / chunk_id=35 には、劉備玄徳が賊と戦う場面が含まれています。
「県の劉備玄徳が腕のほどを見よや」
この結果から、alias なしでも、Query Rewrite によって 玄徳 が 劉備 に変換され、ある程度関連するchunkを取得できていることが分かります。
alias あり: rewrite_alias_dense
次に、alias ありの rewrite_alias_dense を実行しました。
Query Analysis は以下です。
type : alias
standalone_query : 玄徳 人物像
analysis_queries : ['玄徳 人物', '劉備 人物', '劉玄徳 人物']
retrieval_queries: ['玄徳 人物像', '玄徳 劉備 劉玄徳 はどんな人物ですか?']
alias_expansion : True
aliases_detected : {'玄徳': ['劉備', '劉玄徳']}
aliases_used : {'玄徳': ['劉備', '劉玄徳']}
alias_expanded : 玄徳 劉備 劉玄徳 はどんな人物ですか?
confidence : 0.95
alias ありでは、検索queryに以下のような別名が追加されました。
玄徳 劉備 劉玄徳
検索結果は以下です。
Dense candidates: [1] id=1196 score=0.6723 source=06komeino_maki.txt chunk_id=231 [2] id=335 score=0.6527 source=03gunseino_maki.txt chunk_id=89 [3] id=115 score=0.6347 source=02toenno_maki.txt chunk_id=113 [1] id=1196 source=06komeino_maki.txt chunk_id=231 「うん」と、一つうなずいたきり、後ろに続く関羽、張飛などの姿へ、棗のような眼をみはっている。 「大儀ながら、廬中へ取次いでもらいたい。自分は、漢の左将軍、宜城亭侯、領は予州の牧、新野皇叔劉備、字は玄徳というもの。先生にまみえんため、みずからこれへ参ったのであるが」 「待っておくれ」 童子は、ふいにさえぎって云った。 「――そんな長い名は、おぼえきれやしない。もう一度いってください」 「なるほど。これはわしが悪かった。ただ、新野の劉備が来ました――と、そう伝えてくれればよい」 「おあいにくさま。先生は今朝早天に出たまま、まだ帰っておりません」 「いずこへ お出でなされたか」 「どこへお出かけやら、ちっとも分りません。――行雲踪蹟不定――で」 「いつ頃、お帰りであろうか」 「さあ。時によると三、五日。あるいは十数日。これもはかり難しですね」 「…………」 玄徳は、落胆して、いかにも力を失ったように、惆悵久しゅうして、なおたたずんでいたが、 そう聞くと、そばから張飛が、 「いないものは仕方がない。早々帰ろうじゃありませんか」といった。 関羽も共に、 「また他日、使いでも立てて、在否を訊かせた上、改めてお越しあってはいかがです」 と、駒を寄せてうながした。 孔明の帰ってくるまでは、そこにたたずんででもいたいような玄徳 であったが、是非なく、童子に言伝てを頼んで悄然、岡の道を降りて行った。 秀雅にして高からぬ山、清澄にして深からぬ水、茂盛した松や竹林には、猿や鶴が遊んでいる。玄徳は、ここの山紫水明にも、うしろ髪を引かれてならなかった。 すると、岡のふもとから身に青衣をまとい、頭に逍遥頭巾をいた [2] id=335 source=03gunseino_maki.txt chunk_id=89 ご辺とはきっと心も合うだろう」と、趙子龍を迎えにやった。 子龍はすぐ来て、 「何か御用ですか」と、いった。 公孫は、 「この人物です」と、玄 徳へ紹介して、きょうの激戦で目ざましい働きをした子龍の用兵の上手さや、その人がらを、口を極めてたたえた。 子龍は、大いに羞恥って、 「太守 、それがしを召しおいて、知らぬ人の前なのに、そうおからかいになるものではありません。穴でもあらば、隠れたくなります」と、謙遜した。 星眸濶面の見るからに威容堂々たる偉丈夫にも、童心のような羞恥のあるのをながめて、玄徳は思わずほほ笑んだ。 その笑みを見て、趙子龍も、 「やあ」 ニコと、笑った。 玄徳の和やかな眸。 彼の秋霜のような眼光。 それが、初めて相見て、笑みを交わしたのであった。 公孫は、玄徳をさして、 「こちらが、劉備玄徳といって、きょう平原から馳けつけて、自分を扶けてくれた恩人だ。以前から誼みを持って、お互いに扶け合ってきた友人ではあるが」 と、姓名を告げると、趙子龍は、非常に驚いて、 「では、かねがね噂に聞いていた関羽、張飛の二豪傑を義弟に持っておられる劉玄徳と仰せられるのはあなたでありましたか。――これは計らずも、よい折に」 と、機縁をよろこんで、 「それがしは、常山真定の生れで、趙雲、字は子龍ともうす者。仔細あ って公太守の陣中にとどまり、微功を立てましたが、まだ若輩の武骨者にすぎません。どうぞ将来、よろしくご指導ください」 と、辞を低うして、慇懃なあいさつをした。 玄徳も、 「いや、ご丁寧に、恐縮なごあいさつです。自分とてもまだ飄々たる風雲の一槍夫。一片の丹心あるほかは、半国の土地 も持たない [3] id=115 source=02toenno_maki.txt chunk_id=113 「しからば、貴下の手勢のみ率いて、兵糧そのほかの賄、心のままにし給え」 と、武人らしく、あっさりいって別れた。 五 討匪将軍の印綬をおびて、遠く洛陽の王府から、黄河口の広宗の野に下り、五万の官軍を率いて軍務についていた中郎将盧植は、 「なに。劉備玄徳という者がわしを訪ねてきたと? ……はてな、劉、玄徳、誰だろう」 しきりに首をひねっていたが、まだ思い出せない容子だった。 戦地といっても、さすが漢朝の征旗を奉じてきている軍の本営だけに、将軍の室は、大きな寺院の中央を占め、境内から四門の外郭一帯にかけて、駐屯している兵馬の勢威は物々しいものであった。 「はっ。――確かに、劉備玄徳と仰っしゃって、将軍にお目にかかりたいと申して来ました」 外門から取次いできた一人の兵はそういって、盧将軍の前に、直立の姿勢をとっていた。 「一人か」 「いいえ、五百人も連れてであります」 「五百人」 唖然とした顔つきで、 「じゃあ、その玄徳とやらは、そんなにも自分の手勢をつれて来たのか」 「さようです。関羽、張飛、という二名の部将を従えて、お若いようですが、立派な人物です」 「はてなあ?」 なおさら、思い当らない容子であったが、取次ぎの兵が、 「申し残しました。その仁は、県楼桑村の者で、将軍がそこに隠遁されていた時代に、読み書きのお教えをうけたことがあるとかいっておりました」 「ああ! では蓆売りの劉少年かもしれない。いや、そういえば、あれからもう十年以上も経っ ておるから、よい若人になっている年頃だろう」 盧植は、にわかに、なつかしく思ったとみえ、すぐ通せと命令した。もちろん、連れている兵は外門にとめ
alias ありでは、劉備玄徳 や 劉玄徳 を含むchunkが候補に入りました。
たとえば、06komeino_maki.txt / chunk_id=231 には、劉備が孔明を訪ねる場面で、自分の名を説明する箇所があります。
「自分は、漢の左将軍、宜城亭侯、領は予州の牧、新野皇叔劉備、字は玄徳というもの」
また、03gunseino_maki.txt / chunk_id=89 には、趙子龍が劉玄徳に出会う場面が含まれていました。
「劉備玄徳といって」 「劉玄徳と仰せられるのはあなたでありましたか」
この結果から、Alias Expansion によって、玄徳 だけでなく 劉備 や 劉玄徳 を含むchunkも拾いやすくなったことが分かります。
注意: この比較だけでは純粋な alias 効果とは言えない
ここで注意が必要です。
上の比較では、rewrite_dense と rewrite_alias_dense の Query Rewrite 出力自体が変わっています。
alias なしでは、retrieval query は以下でした。
劉備はどんな人物ですか?
alias ありでは、retrieval query は以下になりました。
玄徳 人物像 玄徳 劉備 劉玄徳 はどんな人物ですか?
つまり、この比較では、Alias Expansion だけでなく、Query Rewrite の違いも結果に影響しています。
そのため、純粋に Alias Expansion の効果を見るために、次に --no-rewrite を付けて比較しました。
Query Rewrite を止めて Alias だけを比較する
実行コマンドは以下です。
python scripts/query_advanced.py --mode rewrite_dense --query "玄徳はどんな人物ですか?" --no-rewrite --show-candidate-text --retrieval-only python scripts/query_advanced.py --mode rewrite_alias_dense --query "玄徳はどんな人物ですか?" --no-rewrite --show-candidate-text --retrieval-only
この比較では、Query Rewrite を止めているため、original query はそのままです。
玄徳はどんな人物ですか?
no-rewrite + alias なし
alias なしでは、検索に使われた query は元の質問だけでした。
retrieval_queries:
['玄徳はどんな人物ですか?']
alias_expansion:
False
aliases_detected:
{'玄徳': ['劉備', '劉玄徳']}
aliases_used:
{}
検索結果は以下です。
Dense candidates: [1] id=119 score=0.0164 source=02toenno_maki.txt chunk_id=117 [2] id=1196 score=0.0161 source=06komeino_maki.txt chunk_id=231 [3] id=124 score=0.0159 source=02toenno_maki.txt chunk_id=122 [1] id=119 chunk_id=117 preview=すると彼方から、一彪の軍馬が、燃えさかる草の火を蹴って進んできた 。見れば、全軍みな紅の旗をさし、真っ先に立った一名の英雄も、兜、鎧、剣装、馬鞍、すべて火よりも赤い姿をしていた。 [2] id=1196 chunk_id=231 preview=「うん」と、一つうなずいたきり、後ろに続く関羽、張飛などの姿へ 、棗のような眼をみはっている。 「大儀ながら、廬中へ取次いでもらいたい。自分は、漢の左将軍、宜城亭侯、領は予州の牧、新 [3] id=124 chunk_id=122 preview=「考えれば考えるほど、俺たちの理想は遠い――」 道をながめ、空を仰ぎ、両雄は嘆じ合っていた。 少し前へ立って、馬を進めていた玄徳は、二人の声高なはなしを先刻から後ろ耳で聞いていたが
この結果では、玄徳 を含むchunkは取得できています。
ただし、score は全体的に低く、query の表現としてはやや弱いことが分かります。
no-rewrite + alias あり
alias ありでは、元の query に加えて、alias を展開した query も検索に使われました。
retrieval_queries:
['玄徳はどんな人物ですか?', '玄徳 劉備 劉玄徳 はどんな人物ですか?']
alias_expansion:
True
aliases_detected:
{'玄徳': ['劉備', '劉玄徳']}
aliases_used:
{'玄徳': ['劉備', '劉玄徳']}
alias_expanded:
玄徳 劉備 劉玄徳 はどんな人物ですか?
検索結果は以下です。
Merged dense candidates: [1] id=1196 score=0.0325 source=06komeino_maki.txt chunk_id=231 [2] id=119 score=0.0164 source=02toenno_maki.txt chunk_id=117 [3] id=335 score=0.0161 source=03gunseino_maki.txt chunk_id=89 [4] id=124 score=0.0159 source=02toenno_maki.txt chunk_id=122 [5] id=115 score=0.0159 source=02toenno_maki.txt chunk_id=113 [1] id=1196 chunk_id=231 preview=「うん」と、一つうなずいたきり、後ろに続く関羽、張飛などの姿へ 、棗のような眼をみはっている。 「大儀ながら、廬中へ取次いでもらいたい。自分は、漢の左将軍、宜城亭侯、領は予州の牧、新 [2] id=119 chunk_id=117 preview=すると彼方から、一彪の軍馬が、燃えさかる草の火を蹴って進んできた 。見れば、全軍みな紅の旗をさし、真っ先に立った一名の英雄も、兜、鎧、剣装、馬鞍、すべて火よりも赤い姿をしていた。 [3] id=335 chunk_id=89 preview=ご辺とはきっと心も合うだろう」と、趙子龍を迎えにやった。 子龍は すぐ来て、 「何か御用ですか」と、いった。 公孫は、 「この人物です」と、玄徳へ紹介して、きょうの激戦で目ざましい働 [4] id=124 chunk_id=122 preview=「考えれば考えるほど、俺たちの理想は遠い――」 道をながめ、空を仰ぎ、両雄は嘆じ合っていた。 少し前へ立って、馬を進めていた玄徳は、二人の声高なはなしを先刻から後ろ耳で聞いていたが [5] id=115 chunk_id=113 preview=「しからば、貴下の手勢のみ率いて、兵糧そのほかの賄、心のままにし 給え」 と、武人らしく、あっさりいって別れた。 五 討匪将軍の印綬をおびて、遠く洛陽の王府から、黄河口の広宗の野
alias ありでは、劉備玄徳 や 劉玄徳 を含むchunkが候補に入りやすくなりました。
たとえば、06komeino_maki.txt / chunk_id=231 には、以下のように 劉備 と 玄徳 が同時に出てきます。
「新野皇叔劉備、字は玄徳というもの」
また、03gunseino_maki.txt / chunk_id=89 には、劉備玄徳 や 劉玄徳 が出てきます。
「劉備玄徳といって」 「劉玄徳と仰せられるのはあなたでありましたか」 この実験から分かったこと
Alias Expansion の効果を整理すると、以下のようになります。
| 比較 | alias なし | alias あり |
|---|---|---|
| query | 玄徳はどんな人物ですか? | 玄徳はどんな人物ですか? + 玄徳 劉備 劉玄徳 はどんな人物ですか? |
| aliases_used | なし | 玄徳 → 劉備, 劉玄徳 |
| 取得された候補 | 玄徳を含むchunk中心 | 劉備玄徳・劉玄徳を含むchunkも追加 |
| 目的 | original queryのみで検索 | 別表記による漏れを減らす |
この結果から、Alias Expansion はランキングを直接改善する処理ではないと分かりました。
Alias Expansion の主な役割は、別表記を含むchunkを候補に入れやすくすることです。
つまり、precision を直接上げるというより、recall を改善するための処理です。
今回の例では、alias を追加することで、玄徳 だけでなく、劉備 や 劉玄徳 を含むchunkも検索候補に入りました。
一方で、top1 が常に最適な根拠になるわけではありません。
そのため、Alias Expansion は単独で使うよりも、後段の Hybrid Search や Rerank と組み合わせる方が実用的だと感じました。
Alias Expansion: 別表記を含む候補を拾いやすくする Rerank: 拾った候補の中から、質問の根拠として強いchunkを選ぶ
このように役割を分けて考えると、RAG の検索改善を設計しやすくなります。
7. 実験3: Hybrid Search
次に、Dense Search と BM25 を組み合わせた Hybrid Search を試します。
前の実験では、Query Rewrite と Alias Expansion によって、検索 query を改善しました。
しかし、Dense Search だけでは、意味的に近い chunk を拾える一方で、質問の答えとしては少し弱い chunk や、別場面の chunk も残っていました。
そこで今回は、Dense Search に加えて BM25 Search も使い、両方の検索結果を RRF で統合します。
Hybrid Search = Dense Search + BM25 Search + RRF
今回の実験では、以下の質問を使います。
三人が兄弟になる場面を教えて
Dense Search と BM25 の違い
Dense Search は、query と chunk をそれぞれ Embedding に変換し、意味的に近い chunk を探します。
一方で、BM25 は Embedding を使いません。
query に含まれる語が chunk にどれくらい出てくるかを見ます。
BM25 の直感は以下です。
常见词の価値は低い: 人物、登場、場面 など 稀有词や固有名詞の価値は高い: 孔明、桃園、劉玄徳、赤壁 など 同じ語が何度も出るとスコアは上がるが、無限には上がらない 長すぎる chunk は長さで補正される
Dense Search は意味的な近さを見られる一方で、固有名詞や型番、制度番号、条文番号のような「文字列そのものが重要な情報」を必ずしも強く扱えるとは限りません。
BM25 はその逆で、文字列一致には強いですが、言い換えや曖昧な表現には弱いです。
| 方法 | 得意なこと | 苦手なこと |
|---|---|---|
| Dense Search | 意味的に近い文章を拾う | 固有名詞や厳密な文字列一致が弱い場合がある |
| BM25 | 固有名詞・キーワード一致に強い | 言い換えや意味的な近さは理解しにくい |
たとえば、前の実験で使ったように、query に alias を展開しておくと、BM25 はその語を使って本文中の一致を探しやすくなります。
query: 玄徳はどんな人物ですか? alias expanded query: 玄徳 劉備 劉玄徳 はどんな人物ですか?
この場合、BM25 は 玄徳、劉備、劉玄徳 のような字面の一致を利用できます。
つまり、Alias Expansion と BM25 は相性が良いです。
Alias Expansion: 検索 query に別名を補う BM25: 補われた別名を使って字面一致を拾う
RRF で Dense Search と BM25 を統合する
Dense Search と BM25 は、score の意味が違います。
Dense score: vector similarity BM25 score: keyword relevance score
そのため、Dense score と BM25 score をそのまま足し合わせるのは危険です。
今回は、順位ベースで結果を統合する RRF(Reciprocal Rank Fusion)を使いました。
RRF は以下の式で計算します。
ここで、rank_r(d) は、ある検索器 r における document d の順位です。
今回の実装では、一般的によく使われる値として k=60 を使いました。
k は固定ルールではなく、調整可能なハイパーパラメータです。
たとえば、k が小さい場合は、上位順位の差が大きく反映されます。
k = 10 rank=1 -> 1/11 = 0.0909 rank=10 -> 1/20 = 0.0500
一方で、k が大きい場合は、順位差が少しなだらかになります。
k = 60 rank=1 -> 1/61 = 0.0164 rank=10 -> 1/70 = 0.0143
k=60 では、1つの検索器で極端に上位に来た候補だけでなく、複数の検索器で安定して拾われる候補も評価しやすくなります。
実装イメージは以下です。
def rrf_fuse(dense_results: list[dict], bm25_results: list[dict], rrf_k: int = 60) -> list[dict]:
merged: dict[int, dict] = {}
for rank, item in enumerate(dense_results, start=1):
item_id = int(item["id"])
entry = merged.setdefault(item_id, dict(item))
entry["rrf_score"] = entry.get("rrf_score", 0.0) + 1.0 / (rrf_k + rank)
entry["dense_rank"] = rank
entry["dense_score"] = item.get("score")
for rank, item in enumerate(bm25_results, start=1):
item_id = int(item["id"])
entry = merged.setdefault(item_id, dict(item))
entry["rrf_score"] = entry.get("rrf_score", 0.0) + 1.0 / (rrf_k + rank)
entry["bm25_rank"] = rank
entry["bm25_score"] = item.get("bm25_score")
return sorted(merged.values(), key=lambda item: item["rrf_score"], reverse=True)
RRF 後の結果には、以下の情報を残しています。
- dense_rank
- bm25_rank
- dense_score
- bm25_score
- rrf_score
これにより、その候補が Dense Search 由来なのか、BM25 由来なのか、両方で拾われたのかを確認しやすくなります。
比較1: rewrite_alias_dense
まず、rewrite_alias_dense を実行しました。
これは、複数の検索 query を Dense Search で検索し、その結果を RRF で統合する mode です。
ただし、この時点では BM25 は使っていません。
実行コマンドです。
python scripts/query_advanced.py --mode rewrite_alias_dense --query "三人が兄弟になる場面を教えて" --show-candidate-text --retrieval-only
結果は以下です。
=== Dense Retrieval === Merged dense candidates: [1] id=96 score=0.0328 source=02toenno_maki.txt chunk_id=94 [2] id=544 score=0.0320 source=04somono_maki.txt chunk_id=62 [3] id=92 score=0.0161 source=02toenno_maki.txt chunk_id=90 [4] id=2228 score=0.0159 source=10suishino_maki.txt chunk_id=233
top1 の 02toenno_maki.txt / chunk_id=94 は、正解 chunk です。
ここには、劉備・関羽・張飛が義兄弟の誓いを立てる場面が含まれています。
一方で、top2 の 04somono_maki.txt / chunk_id=62 は、後の場面で桃園の誓いを回想する chunk です。
関連はありますが、「三人が兄弟になる場面」そのものではありません。
また、top4 の 10suishino_maki.txt / chunk_id=233 は、孟獲・孔明の場面であり、今回の質問には直接関係しません。
つまり、Dense Search だけでも正解 chunk は取れていますが、候補の中にはノイズも残っています。
比較2: hybrid
次に、Hybrid Search を実行しました。
実行コマンドです。
python scripts/query_advanced.py --mode hybrid --query "三人が兄弟になる場面を教えて" --show-candidate-text --retrieval-only
Hybrid Search では、Dense Search と BM25 Search の両方を実行し、RRF で統合します。
出力は以下のようになりました。
=== Retrieval Comparison === Dense candidates: [1] id=96 score=0.4355 source=02toenno_maki.txt chunk_id=94 [2] id=544 score=0.4028 source=04somono_maki.txt chunk_id=62 [3] id=92 score=0.4022 source=02toenno_maki.txt chunk_id=90 [4] id=348 score=0.3899 source=03gunseino_maki.txt chunk_id=102 [5] id=1132 score=0.3870 source=06komeino_maki.txt chunk_id=167
BM25 の候補は以下です。
BM25 candidates: [1] id=92 score=7.9587 source=02toenno_maki.txt chunk_id=90 [2] id=51 score=7.5084 source=02toenno_maki.txt chunk_id=49 [3] id=2039 score=5.9346 source=10suishino_maki.txt chunk_id=44 [4] id=2099 score=5.9220 source=10suishino_maki.txt chunk_id=104 [5] id=52 score=5.9020 source=02toenno_maki.txt chunk_id=50
BM25 は、桃園、三名、義 などの字面に反応し、Dense Search とは少し違う候補を拾っています。
たとえば、02toenno_maki.txt / chunk_id=90 は、桃園の祭壇を準備する場面に近い chunk です。
これは、質問の答えそのものではありませんが、桃園の誓いの前後文脈として関連があります。
RRF で統合した結果は以下です。
RRF merged candidates: [1] id=96 score=0.0487 source=02toenno_maki.txt chunk_id=94 [2] id=544 score=0.0479 source=04somono_maki.txt chunk_id=62 [3] id=76 score=0.0376 source=02toenno_maki.txt chunk_id=74 [4] id=97 score=0.0304 source=02toenno_maki.txt chunk_id=95 [5] id=2032 score=0.0277 source=10suishino_maki.txt chunk_id=37
RRF 後も、正解 chunk である 02toenno_maki.txt / chunk_id=94 が top1 に残りました。
また、top4 には 02toenno_maki.txt / chunk_id=95 が入りました。
chunk_id=95 は、誓いの続きに近い chunk です。
そのため、回答に必要な前後文脈としては有用な候補です。
一方で、top2 には依然として 04somono_maki.txt / chunk_id=62 が残っています。
これは桃園の誓いを後から回想する場面であり、関連はありますが、直接の根拠としては弱い chunk です。
また、top5 には 10suishino_maki.txt / chunk_id=37 も入りました。
このように、Hybrid Search にしてもノイズが完全になくなるわけではありません。
この実験から分かったこと
今回の結果を整理すると、以下のようになります。
| mode | 良かった点 | 残った問題 |
|---|---|---|
| rewrite_alias_dense | 正解 chunk_id=94を top1 に取得できた |
回想場面や無関係なchunkも残った |
| hybrid | 正解 chunk_id=94を top1 に維持し、 chunk_id=95のような関連文脈も拾えた |
回想場面や周辺的なchunkはまだ残った |
Hybrid Search によって、Dense Search だけでは見えにくい候補も追加されました。
特に、BM25 は 桃園 や 義 のような文字列一致を利用できるため、前後文脈を拾う助けになります。
一方で、Hybrid Search は「候補を広げる」処理であり、最終的にどの chunk が回答の根拠として最も強いかを判断する処理ではありません。
今回の例でも、RRF 後に正解 chunk は top1 に残りましたが、回想場面や周辺的な chunk も残りました。
つまり、Hybrid Search は recall を改善するためには有効ですが、precision を完全に保証するわけではありません。
そこで次のステップでは、Rerank を使って、候補 chunk をもう一度評価します。
Hybrid Search: 候補を広げる Rerank: 候補の中から、質問の根拠として強い chunk を選ぶ
8. 実験4: Rerank
Hybrid Search では、Dense Search と BM25 Search の両方から候補を集めることができました。
しかし、Hybrid Search はあくまで候補を広く集める処理です。
候補の中には、質問に対して直接の根拠になるchunkもありますが、少し関連しているだけのchunkも含まれます。
たとえば、前の実験では、正解chunkである
02toenno_maki.txt / chunk_id=94は候補に入りました。
一方で、後の場面で桃園の誓いを回想しているchunkや、人物名・兄弟関係だけが近いchunkも残っていました。
そこで、次に Rerank を試しました。
Rerank は、検索候補を増やす処理ではありません。
Hybrid Search で集めた候補の中から、質問に対する根拠として強いchunkを選び直す処理です。
Hybrid Search: 候補を広く集める Rerank: 候補の中から、回答根拠として強いchunkを選ぶ
Reranker は、query と candidate chunk をセットで評価します。
今回の Reranker は、各chunkに対して以下のような情報を出力します。
{
"id": 96,
"relevance_score": 0.95,
"evidence_level": "high",
"reason": "このchunkには劉備・関羽・張飛が義兄弟の誓いを立てる場面が含まれる"
}
単に score だけを見るのではなく、evidence_level と reason も確認できるようにしました。
これにより、なぜそのchunkが上位に来たのかを確認しやすくなります。
reranker
今回は、LLM reranker と local reranker の両方を試しました。
online: - qwen-plus offline: - bge-reranker-v2-gemma - Qwen3-Reranker-0.6B - Qwen3-Reranker-4B - mxbai-rerank-large-v2
local reranker は、以下のように Hugging Face からダウンロードして使いました。
python -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='Qwen/Qwen3-Reranker-0.6B', local_dir=r'your model path')"
実行コマンドは以下です。
$Q="三人が兄弟になる場面を教えて" python scripts/query_advanced.py --mode full --reranker llm --query $Q --show-candidate-text --retrieval-only python scripts/query_advanced.py --mode full --reranker local_qwen --local-rerank-model D:\LLM\models\Qwen3-Reranker-0.6B --query $Q --show-candidate-text --retrieval-only python scripts/query_advanced.py --mode full --reranker local_qwen --local-rerank-model D:\LLM\models\Qwen3-Reranker-4B --query $Q --show-candidate-text --retrieval-only python scripts/query_advanced.py --mode full --reranker local_flag --local-rerank-model D:\LLM\models\bge-reranker-v2-gemma --query $Q --show-candidate-text --retrieval-only python scripts/query_advanced.py --mode full --reranker local_cross_encoder --local-rerank-model D:\LLM\models\mxbai-rerank-large-v2 --query $Q --show-candidate-text --retrieval-only
すべての結果を載せると長くなるため、ここでは代表的な3つを比較します。
- LLM reranker(qwen-plus)
- mxbai-rerank-large-v2
- Qwen3-Reranker-0.6B
LLM reranker(qwen-plus)
まず、LLM reranker の結果です。
[1] id=96 score : 0.95 evidence_level : high source : 02toenno_maki.txt chunk_id : 94 reason : chunk内に、劉備・関羽・張飛が桃園で祭壇を設け、牛血と酒をそそぎ、天地の神祇に黙祷をささげ、義兄弟の約束を結ぶ明確な描写がある。 [2] id=92 score : 0.92 evidence_level : high source : 02toenno_maki.txt chunk_id : 90 reason : chunk内に、桃園で祭壇を作り、三人が共同で桃園を浄め、祭壇設営に従事していることが明記されている。 [3] id=97 score : 0.90 evidence_level : high source : 02toenno_maki.txt chunk_id : 95 reason : chunk内に、張飛が杯を高く挙げて「同年同月同日に死なん」と述べ、兄弟の杯を交わす場面がある。
LLM reranker では、chunk_id=94 が top1 になりました。
このchunkは、三人が義兄弟の約束を結ぶ場面そのものです。
また、chunk_id=90、chunk_id=95 も桃園の誓いの前後文脈として自然です。
特に良かったのは、score だけでなく reason が分かりやすい点です。
たとえば、chunk_id=94 については、以下のような根拠が説明されています。
劉備・関羽・張飛が桃園で祭壇を設ける 牛血と酒をそそぐ 天地の神祇に黙祷する 義兄弟の約束を結ぶ
これは、質問に対する直接の根拠として非常に分かりやすいです。
Evidence Gate も通過しました。
=== Evidence Gate === passed : True reason : evidence passed top_score : 0.95
この結果を見ると、LLM reranker は Hybrid Search よりも、回答根拠として強いchunkを上位に置けていると感じました。
mxbai-rerank-large-v2 の結果
次に、local reranker の中で比較的良かった mxbai-rerank-large-v2 の結果です。
[1] id=96 score : 0.9999558925628662 evidence_level : high source : 02toenno_maki.txt chunk_id : 94 reason : mxbai-rerank-large-v2 local score=1.0000 [2] id=92 score : 0.9999468326568604 evidence_level : high source : 02toenno_maki.txt chunk_id : 90 reason : mxbai-rerank-large-v2 local score=0.9999 [3] id=544 score : 0.9998112320899963 evidence_level : high source : 04somono_maki.txt chunk_id : 62 reason : mxbai-rerank-large-v2 local score=0.9998 [4] id=98 score : 0.999756395816803 evidence_level : high source : 02toenno_maki.txt chunk_id : 96 [5] id=95 score : 0.9993941783905029 evidence_level : high source : 02toenno_maki.txt chunk_id : 93
mxbai-rerank-large-v2 でも、正解chunkである chunk_id=94 が top1 になりました。
この点は良い結果です。
一方で、top3 に 04somono_maki.txt / chunk_id=62 が残っています。
これは後の場面で桃園の誓いを回想するchunkであり、関連はありますが、質問が求める「三人が兄弟になる場面」そのものではありません。
また、local reranker の score は 0.999 付近にかなり密集しています。
0.999955 0.999946 0.999811 0.999756 0.999394
このため、score の差だけを見ると、どの候補がどれくらい強い根拠なのか判断しにくいです。
この結果から、mxbai-rerank-large-v2 は local reranker の中では比較的良い結果でしたが、LLM reranker ほど reason の説明力はありませんでした。
Qwen3-Reranker-0.6B の結果
次に、軽量な Qwen3-Reranker-0.6B の結果です。
[1] id=96 score : 0.9986024498939514 evidence_level : high source : 02toenno_maki.txt chunk_id : 94 [2] id=544 score : 0.9942324757575989 evidence_level : high source : 04somono_maki.txt chunk_id : 62 [3] id=92 score : 0.9842537641525269 evidence_level : high source : 02toenno_maki.txt chunk_id : 90 [4] id=2099 score : 0.9807425737380981 evidence_level : high source : 10suishino_maki.txt chunk_id : 104 [5] id=95 score : 0.9617746472358704 evidence_level : high source : 02toenno_maki.txt chunk_id : 93
この結果でも、top1 は正解chunkである chunk_id=94 でした。
ただし、top2 に後の回想場面である chunk_id=62 が入り、top4 には別の巻のchunkも入りました。
そのため、このqueryに限ると top1 は正しいものの、候補全体の並びとしては LLM reranker より弱いと感じました。
軽量な local reranker は速度や運用面では有利ですが、実際に使う場合は、対象データとqueryで評価してから選ぶ必要があります。
reranker の比較まとめ
今回の実験をまとめると、以下のようになりました。
| reranker | 結果の印象 |
|---|---|
| LLM reranker(qwen-plus) | 直接の根拠chunkを上位に置けた。reason も分かりやすい |
| mxbai-rerank-large-v2 | local reranker の中では比較的良い。top1 は正解だが、回想chunkも上位に残る |
| Qwen3-Reranker-0.6B | 軽量だが、候補全体の並びは LLM reranker より弱い |
| Qwen3-Reranker-4B | Hybrid よりは良いが、ノイズは残る |
| bge-reranker-v2-gemma | top1 は良いが、score の区分がやや分かりにくい |
ここで重要なのは、Rerank という仕組み自体が常に良い結果を保証するわけではないという点です。
同じ候補に対しても、どの reranker を使うかによって並び順は変わります。
そのため、実用的なRAGでは、reranker を入れるだけではなく、reranker 自体を評価して選ぶ必要があります。
実運用を考えた reranker の使い分け
LLM reranker は、今回の実験では最も分かりやすい結果を出しました。
特に、reason を自然言語で出せる点は、debug や説明には便利です。
一方で、LLM reranker には以下の弱点もあります。
- APIコストがかかる
- レイテンシが増える
- 外部APIにデータを送る必要がある
- 大量queryではコストが大きくなる
local reranker には、逆に以下の利点があります。
- APIコストがかからない
- データを外部に送らない
- 推論環境を自分で制御できる
- 大量処理に向いている場合がある
そのため、実運用ではすべてのqueryを LLM reranker に送るのではなく、local reranker と LLM reranker を使い分ける構成が現実的だと思います。
たとえば、以下のような構成です。
この図は、今回の実験そのものの処理フローというより、実運用を考えた reranker の使い分け方です。
通常のqueryは local reranker で処理し、判断が難しいqueryや重要度の高いqueryだけ LLM reranker に回す、という考え方です。
これにより、コストと品質のバランスを取りやすくなります。
この実験から分かったこと
今回の Rerank 実験で分かったことは、以下です。
1つ目は、Hybrid Search は候補取得として有効ですが、最終的な根拠選択としてはまだ不十分だということです。
2つ目は、LLM reranker は、今回のqueryでは直接の根拠chunkを上位に置き、reason も分かりやすかったということです。
3つ目は、local reranker は運用面では魅力がありますが、モデルによって結果がかなり変わるということです。
4つ目は、reranker を入れるだけで安心するのではなく、RAGの評価セットで実際に比較する必要があるということです。
今回の結果だけを見ると、qwen-plus の LLM reranker が最も安定していました。
ただし、APIコストやレイテンシ、データの外部送信を考えると、すべてのqueryで LLM reranker を使うのが常に最適とは限りません。
そのため、実用的には以下のような方針がよさそうです。
通常query: local reranker 判断が難しいquery: LLM reranker 高価値query: LLM reranker 根拠が弱いquery: Evidence Gate で拒答
Rerank は、RAGの回答品質を上げるための重要な処理ですが、どのrerankerを使うか、どのqueryに使うか、どのscoreで拒答するかを含めて設計する必要があります。
9. Evidence Gate による拒答制御
最後に、検索対象に根拠がない質問を試します。
質問は以下です。
織田信長は三国志に登場しますか?
この質問は、今回の『三国志』知識ベースに対しては out-of-domain な質問です。
FAISS や BM25 は、どんな質問に対しても何らかの候補 chunk を返します。
しかし、返ってきた chunk が本当に回答の根拠になるとは限りません。
前回の最小 RAG では、prompt に以下のように書いていました。
根拠がない場合は、不明と答えてください。
ただし、これだけでは最終的に LLM の判断に依存します。
LLM が自分の一般知識で補ってしまう可能性もあります。
そこで今回は、回答生成の前に Evidence Gate を入れました。
Evidence Gate では、以下を確認します。
- Query Analyzer が out-of-domain と判断しているか
- Query Analyzer が direct refusal と判断しているか
- top rerank score が閾値以上か
- evidence_level が none ではないか
- 検索された chunk に本当に回答根拠があるか
根拠が弱い場合は、回答用 LLM に自由回答させず、システム側で拒答します。
検索対象の本文には明確な根拠がありません。
つまり、拒答を prompt 任せにするのではなく、RAG pipeline の制御ロジックとして扱います。
実行コマンド
今回は、前の実験で良かった online LLM reranker の qwen-plus と、軽量 local reranker の Qwen3-Reranker-0.6B を比較しました。
python scripts/query_advanced.py --mode full --reranker llm --query "織田信長は三国志に登場しますか?" --show-log python scripts/query_advanced.py --mode full --reranker local_qwen --local-rerank-model D:\LLM\models\Qwen3-Reranker-0.6B --query "織田信長は三国志に登場しますか?" --show-log
online LLM reranker の結果
まず、online LLM reranker の qwen-plus の結果です。
=== Reranked Chunks === [1] id=0 score : 0.0 evidence_level : none source : 01jo.txt chunk_id : 0 reason : chunkには三国志の概要や登場人物についての記述があるが、織田信長に関する言及は一切ない。信長は戦国時代の日本武将であり、三国志(中国後漢末~三国時代)とは時代・地域・歴史的文脈が全く異なるため、登場する可能性はなく、その否定的根拠もchunk内に存在しない。 reranker_type : llm
Evidence Gate は以下のように拒答しました。
=== Evidence Gate === passed : False reason : query analyzer marked this query as direct refusal top_score : 0.0 === Answer === 検索対象の本文には明確な根拠がありません。 === References === latency_sec: 47.569
qwen-plus は、relevance_score を 0.0 と判断しました。
さらに reason でも、「織田信長に関する記述はない」と明確に説明しています。
この点は、debug や検証の観点では分かりやすいです。
ただし、latency は約 48 秒と長くなりました。
Qwen3-Reranker-0.6B の結果
次に、local reranker の Qwen3-Reranker-0.6B の結果です。
=== Reranked Chunks === [1] id=1 score : 0.033676665276288986 evidence_level : none source : 01jo.txt chunk_id : 1 reason : Qwen3-Reranker-0.6B local score=0.0337 reranker_type : local_qwen
Evidence Gate はこちらも拒答しました。
=== Evidence Gate === passed : False reason : query analyzer marked this query as direct refusal top_score : 0.0 === Answer === 検索対象の本文には明確な根拠がありません。 === References === latency_sec: 10.581
Qwen3-Reranker-0.6B の relevance score は完全な 0 ではなく、0.0337 でした。
しかし、evidence_level は none であり、さらに Query Analyzer が direct refusal と判断していたため、Evidence Gate で拒答できました。
latency は約 11 秒で、qwen-plus よりかなり短くなりました。
比較結果
今回の結果を表にすると、以下のようになります。
| reranker | top_score | evidence_level | passed | latency |
|---|---|---|---|---|
| qwen-plus | 0.0 | none | false | 47.569s |
| Qwen3-Reranker-0.6B | 0.0337 | none | false | 10.581s |
どちらの reranker でも、Evidence Gate による拒答は成功しました。
ただし、拒答の見え方には違いがあります。
qwen-plus は score を 0.0 とし、reason でも「織田信長に関する記述はない」と説明できました。
そのため、判断理由は分かりやすいです。
一方で、Qwen3-Reranker-0.6B は score が 0.0337 と完全な 0 ではありません。
しかし、evidence_level が none であり、Query Analyzer も direct refusal と判断していたため、最終的には正しく拒答できました。
つまり、Evidence Gate は reranker score だけに依存していません。
今回の実装では、以下を組み合わせて判断しています。
Query Analyzer の判断 rerank_score evidence_level direct refusal flag
このように複数の信号を組み合わせることで、reranker が少しスコアを付けてしまっても、システム全体としては安全に拒答できます。
この実験から分かったこと、以下です。
1つ目は、検索システムは out-of-domain な質問に対しても何らかの chunk を返してしまうということです。
2つ目は、prompt だけで拒答を制御するのは不十分だということです。
LLM が一般知識で回答してしまう可能性があるためです。
3つ目は、Evidence Gate を入れることで、回答生成の前にシステム側で拒答できるということです。
4つ目は、online LLM reranker と local reranker にはトレードオフがあるということです。
qwen-plus: 判断理由が分かりやすい ただし latency と API cost が大きい Qwen3-Reranker-0.6B: 速い local で動かせる ただし reason の説明性は弱い
今回の例では、どちらの reranker でも拒答できました。
ただし、qwen-plus の方が理由説明は明確で、Qwen3-Reranker-0.6B の方が処理時間は短い結果になりました。
実運用での考え方
実用的な RAG では、「それっぽい回答」を出すよりも、「根拠がない」と言える方が安全な場面があります。
特に、以下のような用途では拒答制御が重要です。
社内規程 契約書 金融・保険文書 医療・法律関連文書 製品仕様 FAQ / サポート文書
根拠がないのに回答してしまうと、誤案内や業務リスクにつながります。
そのため、RAG では以下のような考え方が必要になります。
検索できた ≠ 回答してよい 検索できた ↓ 根拠として十分か確認する ↓ 十分なら回答する ↓ 不十分なら拒答する
前回は、prompt に「根拠がない場合は不明と答える」と書くだけでした。
今回は、Evidence Gate によって、回答生成の前に拒答判断を行いました。
これにより、拒答を LLM の気分に任せるのではなく、RAG pipeline の安全制御として扱えるようになります。
結論
今回の Evidence Gate 実験では、織田信長は三国志に登場しますか? という out-of-domain query に対して、online LLM reranker と local reranker の両方で拒答できました。
この結果から、Evidence Gate は RAG の安全性を高めるために有効だと分かりました。
ただし、今回の成功は Evidence Gate の条件設計によるものです。
reranker score だけに依存していたら、モデルによって判断が不安定になる可能性があります。
そのため、実運用では以下を組み合わせるのがよいと感じました。
Query Analyzer Rerank score evidence_level out-of-domain 判定 direct refusal flag
RAG の品質改善では、検索精度だけでなく、「いつ答えないか」を設計することも重要です。
検索できた ≠ 回答してよい
10. Evaluationで全体を比較する
最後に、12個の評価queryを用意し、各modeを同じ条件で比較しました。
評価に使ったqueryは以下です。
| id | type | query |
|---|---|---|
| q001 | standard | 桃園の誓いとは何ですか? |
| q002 | colloquial | 三人が兄弟になる場面を教えて |
| q003 | alias | 玄徳はどんな人物ですか? |
| q004 | alias | 孔明はどのように登場しますか? |
| q005 | comparison | 劉備と曹操はどのように違いますか? |
| q006 | multi_intent | 桃園の誓いと赤壁の戦いについて説明してください |
| q007 | standard | 赤壁の戦いについて説明してください |
| q008 | standard | 五丈原では何が起きますか? |
| q009 | context_dependent | 参加した人は? |
| q010 | ambiguous_reference | それはどこで起きましたか? |
| q011 | out_of_domain | 織田信長は三国志に登場しますか? |
| q012 | rhetorical | これって桃園の誓いとは関係ないんじゃないですか? |
評価指標は以下です。
| 指標 | 意味 |
|---|---|
| source_hit@3 | top3に期待sourceが含まれるか |
| source_hit@5 | top5に期待sourceが含まれるか |
| keyword_hit | 候補chunkに期待keywordが含まれるか |
| refusal_accuracy | 拒答すべき質問で正しく拒答できたか |
| mrr | 正解sourceが上位に出るほど高くなる指標 |
| avg_latency | 平均処理時間 |
結果は以下です。
| mode | source_hit@3 | source_hit@5 | keyword_hit | refusal_accuracy | mrr | avg_latency |
|---|---|---|---|---|---|---|
| baseline_dense | 0.75 | 0.75 | 0.75 | 0.75 | 0.62 | 9.58s |
| rewrite_dense | 0.83 | 0.83 | 0.92 | 1.00 | 0.75 | 12.10s |
| rewrite_alias_dense | 0.83 | 0.83 | 0.92 | 0.92 | 0.71 | 13.57s |
| hybrid | 0.83 | 0.83 | 1.00 | 0.83 | 0.67 | 13.47s |
| full | 0.83 | 0.92 | 1.00 | 0.92 | 0.70 | 49.54s |
この結果を見ると、
fullがすべての指標で最良だったわけではありません。
rewrite_denseは、mrr が最も高く、処理時間も比較的短いです。
つまり、Query Rewrite は軽量で効果の大きい改善でした。
一方で、
fullは
source_hit@5と
keyword_hitが最も高くなりました。
必要な根拠chunkを候補内に残しやすい構成だと言えます。
ただし、
fullは平均49.54秒かかっており、かなり重いです。
実運用では、すべてのqueryにfull modeを使うのではなく、queryの種類や重要度によって処理を切り替える必要があると感じました。
11. Query Logで検索過程を観測する
今回の実装では、queryごとに以下の情報をlogとして保存しました。
{
"original_query": "...",
"query_analysis": {},
"alias_expanded_query": "...",
"dense_results": [],
"bm25_results": [],
"rrf_results": [],
"reranked_results": [],
"evidence_gate": {},
"final_context_ids": [],
"answer": "...",
"latency_sec": 1.23
}
RAGでは、最終回答だけを見ても原因が分かりにくいです。
回答が悪い場合でも、原因はさまざまです。
Query Rewrite が悪い Alias Expansion が効いていない Dense Search が外れている BM25 がノイズを拾っている Rerank が誤判定している Evidence Gate が厳しすぎる context の組み立てが悪い
logを残すことで、どの段階で失敗したのかを確認しやすくなります。
12. まとめ
今回は、前回作った最小構成RAGをベースに、検索品質を改善するための仕組みを追加しました。
試した内容は以下です。
Query Rewrite Alias Expansion Hybrid Search RRF Rerank Evidence Gate Evaluation Query Logging
今回の結果から、RAGの品質はLLMだけではなく、検索前後の処理に大きく影響されると分かりました。
特に重要だったのは以下です。
Query Rewrite: 曖昧な質問を検索しやすい形にする Alias Expansion: 別名や表記ゆれによる漏れを減らす Hybrid Search: Dense Search と BM25 で候補を広げる Rerank: 候補の中から根拠として強いchunkを選ぶ Evidence Gate: 根拠が弱い場合は回答しない Evaluation / Logging: 改善結果と失敗原因を確認する
一方で、最も重い full mode が常に最良とは限りませんでした。
rewrite_dense のように、軽量で効果の大きい構成もあります。
そのため、実用的なRAGでは、すべてを一律に重くするのではなく、queryの種類や重要度に応じて処理を切り替えることが重要だと感じました。
前回のRAGは「動くRAG」でした。
今回のRAGは、「どこで失敗しているかを見ながら改善できるRAG」に少し近づけたと思います。
次回は、企業文書PDFを対象に、ページ番号付き引用と親ページ検索を備えたRAGを作ってみたいと思います。