家庭内に組んだ分散LLM推論基盤「Coordinator」の開発記、第23回です。前回(第22回)でRAGのチャンク分割を見出し境界尊重型に作り直し、検索品質を上げました。その時に積み残した「検索品質の続き=ハイブリッド検索・リランク」に、今回ようやく着手します。
結論から書きます。ハイブリッド検索を実装して2段階のA/Bで計測した結果、採用を見送りました。そして本当に効いたのは、取り込み時にノイズ節を1つ除外するだけの修正でした。手の込んだ仕組みが、地味な1行に負けた——その一部始終の記録です。
動機:denseは「完全一致の固有名詞」が苦手
うちのRAGは bge-m3 で埋め込んだベクトル検索(dense)です。意味の近さは強いのですが、task_tier() や pg_hba.conf、OLLAMA_NUM_PARALLEL、v6.4.1 といった希少な識別子の完全一致が弱い。家庭内インフラのドキュメントはこの手の固有名詞だらけなので、ここを語彙検索(BM25)で補い、両者を融合すれば的中率が上がるはず——というのが出発点でした。
融合には RRF(Reciprocal Rank Fusion)を使う定番の構成です。Qdrant の全点を起動時に読み込んでインメモリBM25索引を作り、denseの順位とBM25の順位を 1/(k+rank) で足し合わせて上位を取り直す。専用VM(2vCPU)に重い形態素解析器を載せたくないので、トークナイザは「ASCII識別子トークン+日本語は文字bigram」という軽量なものにしました。
第1の計測:賢いはずのRRFが、良い検索を壊した
実装してA/B(dense単体 vs ハイブリッド)を回した結果は、期待と真逆でした。
- 「moonをWake-on-LANで起こす仕組み」——dense単体はWoL関連の節をきれいに上位独占(top1=0.62)。ところがハイブリッドはその最上位を弾き出して、無関係な「設計思想」節を押し込んできた。denseが完璧に答えられていたクエリを劣化させたのです。
- 狙っていた識別子クエリでも、語彙側が新しい本命チャンクを注入できた形跡はほぼなし。要するに「dense上位を少し並べ替えただけ」で、その並べ替えが害になっていました。
原因はトークナイザの日本語bigramでした。「設計思想」や「記事執筆状況」のようなあらゆるキーワードを含むメタ節が、bigram一致でBM25の上位を占拠してしまう。RRFは順位しか見ないので、このノイズが融合結果を引っ張る。賢い融合アルゴリズムも、入力がノイズだと賢く間違えるわけです。
精緻化:識別子のみ+「救済専用」フュージョン
負の結果を受けて2点直しました。
- 語彙を識別子トークンだけに(日本語bigramを廃止)。意味マッチはdenseの仕事と割り切る。これで本物の技術用語が語彙の上位に来て、識別子を含まない自然文クエリでは語彙がほぼ沈黙し、denseを乱さなくなる。
- 「救済専用」フュージョン。全並べ替えのRRFをやめ、denseの順位を正本として、語彙は「denseが取りこぼした強い識別子一致だけを追加する」役に徹する。denseの上位は絶対に降格させない。
これでdense良好クエリの劣化(回帰)は消えました。設計目標は満たした。問題は——肝心の効果が出たかです。
第2の計測:効果ゼロ、しかも本命はdenseが既に取れていた
本番コレクション(100チャンク)で測り直すと、救済が発火したのは12クエリ中3つだけ。しかもその中身は軽微なノイズばかりで、明確に役立った救済はゼロでした。
さらに決定的だったのは、当初「ハイブリッドで救いたい」と思っていた本命——動的ティアフォールバックの説明節——を、dense単体が既に#1と#2で取れていたことです。
本番では、解こうとしていた検索の問題がそもそも存在しなかった。
前回のセッションで「動的フォールバックを質問すると小型モデルが古いルールを答える」という誤答に遭遇していたのですが、それは検索(retrieval)の失敗ではなく、合成(synthesis)の問題だったのです。正しいチャンクが先頭で渡っていても、本文が「静的ルール先頭・例外は後半&コード内」だと、3B程度の合成モデルは冒頭の目立つルールをそのまま答えてしまう。これは検索をいくら強化しても直りません。対処は「機能節の冒頭に結論を1文置く」という書き方のルールです。
本当の犯人:検索を汚していたメタ表
A/Bを眺めているうちに、別の発見がありました。「記事執筆状況」という節——ブログ全記事のタイトル・URL・状態を並べた索引表——が、dense検索そのものの最大のノイズ源になっていたのです。
各記事タイトルが技術用語を満載した日本語文なので、ほとんどどんな質問にも意味的に近いと判定されてしまう。実際、「新しいWorkerを追加するときの落とし穴」という質問のdense#1が、本来のチェックリストではなく、この索引表になっていました。運用の知識はゼロなのに、です。
対処は単純でした。取り込み時に、パンくずに「記事執筆状況」を含むチャンクを除外する——いわばノイズ節のdenylistです。コードとしては取り込みスクリプトに数行。再取り込み後の効果は劇的でした。
- 「記事執筆状況」が全dense結果から消滅
- 「新Worker追加の落とし穴」——dense#1が索引表から、本来のチェックリスト節(スコア0.75)へ繰り上がり
- 「task_tier / GPUモデルをCPU」——本命の動的ティアフォールバック節が上位を独占
手の込んだハイブリッド検索(高コスト・効果なし)ではなく、ノイズ節を1つ外すdenylist(低コスト・効果大)が、RAG品質の本筋だったわけです。
学んだこと
今回いちばんの収穫は、技術そのものより姿勢の方でした。
- 計測してから信じる。「dense + BM25のハイブリッドは検索の定石」という一般論は正しい。でも自分のコーパスで効くかは別問題で、測ったら逆効果だった。一般論を自分の現場に持ち込むときは、必ず手元のデータで確かめる。
- 負の結果も結果である。「作ったが採用しない」と決められたのは、A/Bという物差しがあったから。コードは消さずに棚上げ(worker未接続)として残し、「いつ・なぜ効かなかったか」を引き継ぎ資料に記録しました。次に同じ誘惑が来たとき、過去の自分が止めてくれる。
- 問題の切り分けを間違えない。誤答の原因を「検索が悪い」と決めつけてハイブリッドに突っ走りかけたが、実際は「合成モデルが本文の書き方に引っ張られる」authoringの問題だった。症状の出る層と原因の層は、しばしば違う。
- 地味な掃除がいちばん効く。そもそもナレッジに入れるべきでないメタ表を1つ外す——この当たり前の整理が、どんな再ランクよりも効いた。
というわけで、ハイブリッド検索の章は「やってみて、測って、やめた」で幕を閉じました。RAGの検索品質は、チャンク分割の改善(前回)+ノイズ節の除外(今回)+結論先頭の書き方ルールで、当面これで十分という手応えです。次は会話履歴メモリあたりに手をつける予定です。
※本記事は家庭内・自宅サーバー環境での個人的な実験の記録です。構成や数値はこの環境固有のものです。