Tech Blog

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

RAG ChromaDB Ollama ハイブリッド検索 BM25 リランカー 測定駆動 recall Claude Code 個人ナレッジ管理 GPU

🔗 シリーズ目次: 本記事は「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 を測る。

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

気軽にメッセージください

ご感想・ご質問があればメッセージをお願いします。