0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ベクトル検索だけのRAGは「肝心なときに思い出さない」— ハイブリッド検索+測定で recall を 0.2→1.0 にした話

0
Last updated at Posted at 2026-05-31

ベクトル検索だけのRAGは「肝心なときに思い出さない」— ハイブリッド検索+測定で recall を 0.2→1.0 にした話

この記事でわかること

  • 「相棒」として設計したRAGが、いざ使うと 思い出してくれない 問題の正体
  • それが 設計の欠陥ではなく retrieval の弱さ であることの見分け方(再設計の罠を踏まないために)
  • 検索を磨く前にやるべき 「取り込めていないデータ」の回収
  • golden set で recall を測りながら 検索を直す進め方(勘でやらない)
  • ハイブリッド検索・多様化・優先度の偏り是正で、recall がどう動いたか
  • 測定して「不採用」を決める ことの価値(試して悪化したら捨てる勇気)
  • 最後に残る「語彙の壁」と、GPUの話、そして 笑い話のオチ
  • 検索の改善が、実は データ取り込み・評価設計・運用アーキテクチャ・インフラ(GPU分散)設計 まで地続きだったこと

対象読者

  • 個人RAG / PKM を 実際に使っていて、いまいち引いてこない と感じている人
  • RAGを「とりあえずベクトル検索」で組んだまま放置している人
  • 検索の改善を 勘でやって沼にハマった 経験のある人
  • RAGの処理分散構想を立てたのに、肝心のハードが動かず足踏みした経験をした人(今回の私です)

はじめに: 相棒のはずが、肝心なときに黙っていた

前回、私は自作の個人RAGを「5年付き合える相棒」にするための設計原則にたどり着きました。生メモを捨てずに溜める、蒸留は置き換えではなく補強、そして毎ターン関連記憶を注入するActivation層。

🔗 前回の記事: AI に5回訂正された夜 — 個人 RAG を5年付き合える相棒にする設計思想

※ こちらの記事はまだ Qiita に投稿していないため、リンク先は私の個人ブログ(外部サイト)へ遷移します。ご了承ください。

設計としては気に入っていました。

ところが、実際に毎日使っていると、ある日こう思ったのです。

「肝心なときに思い出さないな?」

過去に確かに話したはずのこと、確かに記録したはずの教訓。それを今の文脈で引いてきてほしい場面で、相棒はしれっと別のものを出してくる。あるいは、最近の話ばかり出してきて、少し前の重要な結論を忘れている。

設計は良いはずなのに、体験は「物覚えの悪い相棒」でした。

ここで多くの人がやる失敗があります。「設計が悪いんだ」と思って、作り直し始めることです。前回の記事で、私と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 を測る。

この 調査 → 解析 → 改善 のループを止めずに回し続けることが、相棒を本当に育てるということだと思います。今回の語彙の壁も、その途中の一つにすぎません。ひとつ越えれば、また次の壁が見えるはず。そのときも、同じやり方で向き合います。


🔗 個人ブログに同様の記事と関連記事も書いています: ベクトル検索だけのRAGは「肝心なときに思い出さない」— ハイブリッド検索+測定で recall を 0.2→1.0 にした話

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?