ベクトル検索だけのRAGは「肝心なときに思い出さない」— ハイブリッド検索+測定で recall を 0.2→1.0 にした話
🔗 シリーズ目次: 本記事は「AIアシスタント運用ノート」シリーズの 測定駆動チューニング編 です。前提となる設計思想編を先に読むと、なぜこの構造なのかが分かります。
この記事でわかること
- 「相棒」として設計したRAGが、いざ使うと 思い出してくれない 問題の正体
- それが 設計の欠陥ではなく retrieval の弱さ であることの見分け方(再設計の罠を踏まないために)
- 検索を磨く前にやるべき 「取り込めていないデータ」の回収
- golden set で recall を測りながら 検索を直す進め方(勘でやらない)
- ハイブリッド検索・多様化・優先度の偏り是正で、recall がどう動いたか
- 測定して「不採用」を決める ことの価値(試して悪化したら捨てる勇気)
- 最後に残る「語彙の壁」と、GPUの話、そして 笑い話のオチ
- 検索の改善が、実は データ取り込み・評価設計・運用アーキテクチャ・インフラ(GPU分散)設計 まで地続きだったこと
対象読者
- 個人RAG / PKM を 実際に使っていて、いまいち引いてこない と感じている人
- RAGを「とりあえずベクトル検索」で組んだまま放置している人
- 検索の改善を 勘でやって沼にハマった 経験のある人
- RAGの処理分散構想を立てたのに、肝心のハードが動かず足踏みした経験をした人(今回の私です)
はじめに: 相棒のはずが、肝心なときに黙っていた
前回、私は自作の個人RAGを「5年付き合える相棒」にするための設計原則にたどり着きました。生メモを捨てずに溜める、蒸留は置き換えではなく補強、そして毎ターン関連記憶を注入するActivation層。
🔗 前回の記事: AI に5回訂正された夜 — 個人 RAG を5年付き合える相棒にする設計思想
設計としては気に入っていました。
ところが、実際に毎日使っていると、ある日こう思ったのです。
「肝心なときに思い出さないな?」
過去に確かに話したはずのこと、確かに記録したはずの教訓。それを今の文脈で引いてきてほしい場面で、相棒はしれっと別のものを出してくる。あるいは、最近の話ばかり出してきて、少し前の重要な結論を忘れている。
設計は良いはずなのに、体験は「物覚えの悪い相棒」でした。
ここで多くの人がやる失敗があります。「設計が悪いんだ」と思って、作り直し始めることです。前回の記事で、私とAIは「再設計の罠」を散々踏みました。だから今回は、まずそれを疑いました。
これは アーキテクチャの欠陥ではなく、retrieval(検索)の弱さ ではないか?
二層構造も、Capture-firstも、Activationも、骨格は前回詰めたとおり妥当。直すのは検索の質であって、設計の作り直しではない。この切り分けが、今回の出発点でした。
**不調が出たら、作り直す前に「どの層の問題か」を見極める。**個人のRAGでも、業務で預かる大きなシステムでも、まず切り分けてから動く——この順番は変わりません。
第1幕: 検索を磨く前に — 「そもそも入っていない」を疑う
検索を改善しようと意気込んだ矢先、嫌な可能性に気づきました。
引けないのは、検索が悪いからではなく、そもそもデータが入っていないからではないか?
調べると、当たりでした。会話を取り込むパイプラインが、長い発言の後半を静かに切り捨てていたのです。さらに、AIがツールを挟みながら複数回に分けて答えると、2回目以降の応答が丸ごと落ちていました。
つまり、過去の濃い議論ほど、肝心な部分がインデックスに存在しなかった。「検索しても出てこない」のは当然で、そこには初めから何も無かったわけです。
取り込みで起きていたこと(イメージ)
長い発言: ┌──────────────────────────────────┐
│ ●●●●●●●● 設計の核心は、実はこの後半 │
└──────────────────────────────────┘
修正前: [●●●●●●●●] ✂ ← ここで切り捨て。後半は消える
修正後: [●●●●][●●●●] ← 分割して、全部インデックスへ
AI は「返答 → ツール実行 → 続きの返答」と小分けに答えることがある:
本物のユーザー発言
├ AI 返答(1)
├ ツール実行(検索やコード)→ その結果
├ AI 返答(2) ← 結果を受けた続き
└ AI 返答(3) ← さらに続き
次の本物のユーザー発言 ← ここで「1 つの答え」が終わる
修正前: 返答(1) だけ取り込み、(2)(3) は落としていた
(ツール結果を「次のユーザー発言」と勘違いして打ち切っていた)
修正後: 次の本物のユーザー発言までの (1)+(2)+(3) を全部つなげて取り込む
これは地味ですが、決定的な教訓でした。
検索をいくら磨いても、元データに無いものは引けない。retrieval の前に capture を疑え。
取り込みの境界判定を直し、切り捨てをやめ、全会話を入れ直しました。失われていた過去のやり取りが、目に見えて戻ってきました。検索の話は、ここからが本番です。
第2幕: 勘で直さない — golden set で「測りながら」直す
ここが今回いちばん伝えたいところです。
検索の改善は、勘でやると必ず沼にハマります。「なんとなく良くなった気がする」は、たいてい気のせいか、別のものを壊しています。
そこで、小さな golden set(「この質問には、この記録が出てほしい」という正解ペアの集合)を自分で作りました。そして、変更のたびに recall(上位N件に正解が入った割合) を測る。Before/Afterを数字で見る。これだけで世界が変わります。
最初に測った素のベクトル検索のスコアは、正直ひどいものでした。意味の近いものは引けても、言い回しが少し違うだけで取りこぼす。recall@10 が 0.2、つまり10件出して2割しか正解にかすらない。
打ち手はRAGの定石どおりでしたが、一つずつ入れて、一つずつ測りました。
検索パイプライン(最終形のイメージ)
質問
│
├─▶ ベクトル検索(意味が近い)──┐
│ ├─▶ 順位を融合(RRF) ─▶ 多様化 ─▶ 優先度の調整 ─▶ 結果
└─▶ 語彙検索 BM25(字面が近い)─┘
▲
└ 日本語の語形ゆれは、文字 1〜2 文字単位に砕いて拾う
- ハイブリッド検索: 意味ベースのベクトル検索に、字面ベースの語彙一致(BM25系)を併走させ、両者の順位を融合する。日本語の語形ゆれ(「遅い」と「遅すぎ」のような揺れ)を、字面側で拾えるように工夫しました。これが一番効きました。
- 多様化: 上位が「ほぼ同じ内容」で埋まらないよう、少し離れた候補も一定枠で混ぜる。前回の記事で書いた「富士山の麓付近を教えてくれる設計」を、検索側でも守るためです。
- 優先度の偏り是正: 「絶対に忘れたくない」と印をつけた記録を最上位に固定していたのですが、これが増えると、今の質問に本当に近い記録を押しのけてしまう。固定をやめ、順位を少し持ち上げる程度に均衡させました。
日本語の「字面で拾う」側を少しだけ具体化すると、語をそのまま見るのではなく、文字を1〜2文字の並び(n-gram)に砕いてから一致を取ります。こうすると、語形が変わっても部分的に字面が重なり、BM25側で拾えるようになります。
# 日本語の語形ゆれを字面側で拾う:文字を 1〜2gram に砕いて BM25 へ
def char_ngrams(text, n_min=1, n_max=2):
grams = []
for n in range(n_min, n_max + 1):
grams += [text[i:i + n] for i in range(len(text) - n + 1)]
return grams
# 「遅い」 → 遅, い, 遅い
# 「遅すぎ」→ 遅, す, ぎ, 遅す, すぎ
# 共有する「遅」で字面がかすり、語形が違ってもヒットを拾える
結果、golden set の recall@10 は 0.2 から 1.0 まで上がりました。crowding(重要印の記録が上位を占拠する現象)も、実測で大きく改善しました。
中身を少しだけ。融合の肝は、スコアの絶対値ではなく「順位」で混ぜることです(距離とBM25スコアは尺度が違って足せないが、順位なら足せる)。
# RRF: 2つのランキングを「順位」で融合する(簡略版・値は例)
def rrf(rankings, k=60): # k は安定化の定数
score = {}
for ranking in rankings: # ranking = 上位順に並んだ id のリスト
for rank, doc_id in enumerate(ranking):
score[doc_id] = score.get(doc_id, 0) + 1 / (k + rank + 1)
return score # id -> スコア(大きいほど上位)
そして「勘でやらない」の核が、これです。変更のたびに同じ golden を流して、数字の差だけを見る。
# golden set で recall@N を測る(簡略版)
def recall_at_n(golden, search, n=10):
hit = 0
for query, expected_id in golden: # (質問, 出てほしい記録)
top = [doc.id for doc in search(query)][:n]
hit += 1 if expected_id in top else 0
return hit / len(golden)
print(recall_at_n(golden, baseline_search)) # 例: 0.2 (素のベクトル検索)
print(recall_at_n(golden, hybrid_search)) # 例: 1.0 (ハイブリッド + 多様化)
※ 掲載コードは概念を示す簡略版で、実際のパラメータや構成はもう少し込み入っています。要点は「順位で融合」「数字で比較」の2つです。
気づき: 検索改善の価値は「やった手」ではなく「測った差」にある。recall を見ながら直すと、効く手と効かない手が一目で分かる。
数字があると、議論が「好み」から「事実」になります。これは設計レビューでも同じでした。
第3幕: 測定して「不採用」を決める勇気
測定駆動のいちばんおいしいところは、ダメな手をダメだと確定できることです。
期待していた手の一つに「クエリ拡張」がありました。検索前に、LLMで質問を別の言い回しに言い換えて、複数バリエーションで検索する。語彙のズレに強くなるはず——という目論見です。
試して、測りました。悪化しました。 言い換えが意味を微妙にずらし(drift)、かえって正解を取りこぼす。しかも遅い。ローカルの軽量LLMでは、品質も速度も割に合いませんでした。
もう一つ、「もっと強い埋め込みモデルに替える」は、実際に組んで動かしてみました。ところが手元のCPUでは、5件中4件が処理に失敗したうえ、通った分も1件あたり数秒かかる。毎回の検索にそのコストが乗るのでは、リアルタイムに使う相棒としては、現実的ではありませんでした。
両方とも、測ったうえで不採用にしました。コードは「測定して net-negative・本番未接続」と明記して残してあります(こういう失敗の過程こそ、後で見返す価値がある)。
ここで一つ、運用上の原則が固まりました。機能の良し悪しではなく、速度と体験を守るための判断——いわゆる非機能要件の設計です。
気づき: リアルタイムに走る検索(読み)の経路に、重い処理を置かない。重いものは裏のバッチへ。これを破ると、品質が上がっても体験が死ぬ。
重い処理を、どちらの経路に置くか
┌─ ライブ経路(読み・リアルタイム)─────────────────┐
│ 質問 → 軽い検索 → すぐ返す ← ここに重い処理を置かない │
└────────────────────────────────────────────────────┘
┌─ バッチ経路(書き・裏方)────────────────────────┐
│ 取り込み / 蒸留 / 重い前処理 ← 時間がかかってOK │
└────────────────────────────────────────────────────┘
クエリ拡張(LLM言い換え) も 重い埋め込み も、ライブ経路に乗らなかった → 不採用
「試した → 悪化した → だから捨てた」を堂々と言えるのは、測っているからです。勘だと「なんか入れたけど効いてる気がする」が永遠に残ります。
第4幕: 同じ意味でも、言葉が違うと引けない — 語彙の壁
通常の質問では recall は満点近くまで来ました。ところが、わざと難しくした golden set——質問と、引きたい記録とが、同じ意味なのに言葉をまったく共有しないケースだけは、依然として 0.2 のまま残りました。これが、この記事で「語彙の壁」と呼んでいるものです。
たとえば、
- 「やる気が続く」と聞いて、「モチベーション維持」の記録を引きたい
- 「仲間と力を合わせる」と聞いて、「チームワーク」の記録を引きたい
人間なら「同じことを言っている」と分かる。でも、字面は一文字も重ならず、軽量な埋め込みでも届かない。ここが、ローカル環境で組んだRAGの最後の壁でした。
語彙の壁:意味は同じなのに、言葉が一文字も重ならない
質問: 「やる気が続く」
│ 字面の重なり = ゼロ
▼
記録: 「モチベーション維持」
ベクトル検索 … 意味は近いはずだが、軽量な埋め込みでは届かない ✗
BM25(字面) … 共有する文字が無いので、そもそも拾えない ✗
──────────────────────────────────────────────
→ cross-encoder リランカー / 多言語に強い埋め込み で初めて橋が架かる
対処法はわかっています。cross-encoder のリランカーで上位を並べ替えるか、多言語に強い埋め込みに替えるか。どちらも、効くと分かっている。
ただし——どちらも、実用的な速度で動作させるには高性能なGPUが必要。CPUでは、第3幕で実際に測ったとおり1件あたり数秒もかかってしまう。毎回の検索が数秒待ちでは、リアルタイムに返す相棒の経路には乗せられません。
そして、ここから話が少しおかしな方向に転がります。
第5幕: RAGの処理分散構想と、肝心のデスクトップが動かない現実
高性能なGPUが要る。でも、それを一台に全部背負わせるのは重い。だったら、家にある複数のGPUマシンに役割を分ければいい——そう考えた私は、嬉々として その分散構成 を描き始めました。
埋め込みやリランカーのような重い処理を、家にある複数のGPUマシンに役割分担させ、ラップトップは操作役の端末に徹する。LANを流れるのはクエリと結果の数KBだけだから、ボトルネックにならない——図まで描いて、悦に入っていました。
意気揚々とデスクトップの電源を入れました。
動きませんでした。
もう一台も、結果は同じでした。長期間まったく触っていなかったデスクトップは二台とも、電源を入れても起動せず、ビープ音が5回鳴るだけ。CPUの熱まわりだろうと当たりをつけて、簡易水冷と電源を新品に替えてみるつもりですが、確定診断はまだです。いずれにせよ、RAGの処理分散構想は 肝心のマシンが二台とも起動しない という、しょうもない現実の前で止まりました。
ここで一瞬、手元のラップトップに望みをかけました。
確かに、このラップトップにも RTX 2070 は載っています。これで試せばいいじゃないか——一瞬、そう思いました。
でも、すぐに思い直しました。このラップトップは、私が毎日 VSCode と Claude Code を動かしている作業機そのものです。本来の分散構成でラップトップに与えた役割は、クエリを投げて結果を受け取る側——つまり操作役でした。 重い処理はデスクトップ側のGPUに預け、手元の端末は軽いまま保つ。これは妥協ではなく、意図してそう置いた配置です。
普段の仕事道具に、埋め込みサーバーやリランカーを常時背負わせれば、肝心の作業そのものが重くなる。だから、この壁に本気で挑むのは、やはりデスクトップを直して本来の分散を組んでからです。ラップトップでも試せる範囲はありますが、その実装——リランカーの組み込みなどは、まだこれからの話です。
頭の中の処理分散構想(家にある複数マシンに役割を分散)
[マシンA:Ollama(埋め込み + LLM)担当]
┌──────────────────────────────────┐
│ Ryzen 7 2700X + RTX 2060 │
└──────────────────────────────────┘
※ 埋め込みも LLM もモデルが軽く 6GB で十分。VRAM より速さで選び、Pascal の GTX ではなく Turing の RTX 2060 にした
│ LAN
▼
[マシンB:本体・ChromaDB(CPU 中心)]
┌──────────────────────────────────┐
│ Core i7 9900K + GTX 1080Ti │
└──────────────────────────────────┘
※ ChromaDB の処理は CPU 中心。空く GPU には、検索結果を並べ直して精度を上げるリランカーを任せる
│ LAN
▼
[ラップトップ:クライアント]
┌──────────────────────────────────┐
│ Core i7-9750H + RTX 2070 │
└──────────────────────────────────┘
✗ デスクトップ 2 台とも同じ症状で起動せず
│
▼
分散はおあずけ。当面はラップトップ(作業機)で試せる範囲から。
本来の分散は、デスクトップを直してから(実装はこれから)
念のため言い添えると、この配置は思いつきの寄せ集めではありません。埋め込みもLLMも軽いモデルで足りるので、6GBでもTuringで速いRTX 2060へ。ChromaDB本体の処理はCPU中心なので、余るGPUにリランカーを載せられる1080Ti機へ。ラップトップは操作役に徹し、LANを流れるのはクエリと結果の数KBだけ——それぞれのハードの得手・不得手に役割を合わせた、れっきとした設計判断です。RAGの検索を直すつもりが、いつのまにかネットワークとハードの配置を考えるインフラ設計になっていました。
そして、この「重い役割を分けて、得意なハードに載せる」考え方は、**AIに求める性能が上がるほど効いてきます。**規模が変わればスケールやオーケストレーションの道具立ては変わっても、設計の考え方はそのまま持ち上がる——机の上で踏んだ判断は、大きなシステムの処理分散と地続きでした。
その第一歩——埋め込みの接続先を環境変数で抽象化することだけは、ハードの復旧を待たずに設計できます。接続先をハードコードしなければ、1台でも分散でも“同じコード”のまま、向け先を変えるだけで済むからです(実装はこれから)。
# 埋め込みの接続先を環境変数で抽象化する(設計・これから実装)
# 1台でも分散でも“同じコード”で、向け先だけ差し替える
import os
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
# ラップトップ単体 → localhost
# 分散時 → http://<RTX 2060 機>:11434 を指すだけ
数世代前のPCを持ち寄り、役割を分けて効率よくRAGを回す——その処理分散構想は、いったん封印です。デスクトップPCは部品を替えれば動くはずなので、直したら本来の分散構成を組む予定です。
余談: なぜ私はGPUを持っていたのか
最後に、少しだけ環境の話を。
ローカルでGPUを使ったRAGチューニングは、強力です。クラウドに送らず、手元で何度でも測って回せる。第2幕〜第4幕は、ローカルにそこそこのGPUがあったから踏み込めました。
このGPUは、RAGのために買ったものではありません。車が好きで本格的なレーシングシミュレーターを組み、新しいもの好きが高じてPC接続型のVR(Oculus Rift 2)にまで手を出す——そんな趣味のために積んでいたものが、たまたま今回のチューニングに効いた、というだけの話です。どちらも、それなりのGPUがないと気持ちよく動きませんから。
もし手元に眠っているゲーミングPCがあるなら、それは立派な実験環境です。GPUがあると、ローカルLLM/RAGは気軽に、何度でも試せます。実際、第2幕〜第4幕のチューニングは、そのラップトップ1台で回しました。
おわりに: 測って直す、という地味な強さ
派手な話は一つもありませんでした。やったことを並べると、こうです。
- 検索の前に、取り込みを疑った(元データに無いものは引けない)
- golden set を作って、recall を測りながら直した(勘でやらない)
- 効く手を入れ、効かない手を測って捨てた(不採用を確定する勇気)
- 重い処理はリアルタイム経路に置かなかった(品質より体験を殺さない)
- 最後はGPUの壁に当たり、本来必要な分散構成を描いたが、肝心のデスクトップが起動せず足踏みした(笑い話つき)
これらは、特定のツールが何であっても古びない原則だと思います。ChromaDBが別のベクトルDBに変わっても、Ollamaが別のランタイムに変わっても、「測りながら直す」「重いものはライブに置かない」「重い処理は適材適所のハードへ割り当て、接続先を抽象化して1台でも分散でも同じコードで動かす」「できるところまでは手元で、足りなければ素直に増強する」は、たぶんずっと効きます。
相棒は、最初から賢いわけではありません。測って、直して、また測る。その地味な往復だけが、物覚えの悪い相棒を、少しずつ「思い出してくれる相棒」に変えていきます。
最後に残った「語彙の壁」は、まだ越えていません。これを越えるには、強い埋め込みとリランカーをきちんと動かす分散構成が要る。それはデスクトップを直して組んでからです。当面はラップトップで試せる範囲から手をつけますが、本番はそのあと——というのが、いまの正直な現在地です。
ロードマップ — いまの現在地
✅ 完了
├ 取り込み欠落の修正(D1/D5)→ 全会話を再投入
├ ハイブリッド検索(ベクトル + BM25 + RRF)
├ 多様化(MMR)/優先度の偏り是正
├ golden set で recall を測定(0.2 → 1.0)
└ クエリ拡張・強い埋め込み(CPU) を実装 → 測定 → 棄却
⬜ 未完了(語彙の壁を越えるための残り)
├ 埋め込み接続先の環境変数化(GPU へ向ける準備)
├ リランカー(cross-encoder)の組み込み
├ 強い埋め込み + リランカーを GPU で再測定 → 語彙の壁を越える
└ 本来の分散構成(デスクトップ修理 → 役割分散)
作って終わり、ではない
RAGは、一度チューニングすれば完成、というものではありません。うまく引いてこない場面に出くわしたら、まず何が起きているかを調べ、原因を解析する。そのうえで、直すべき場所を——ときには設計まで踏み込んで——手当てし、また recall を測る。
この 調査 → 解析 → 改善 のループを止めずに回し続けることが、相棒を本当に育てるということだと思います。今回の語彙の壁も、その途中の一つにすぎません。ひとつ越えれば、また次の壁が見えるはず。そのときも、同じやり方で向き合います。