【第8回】ノートを3万件貯めたら、AIに使わせたくなった
Obsidianのノート3万件をナレッジベース化。Claude Codeに8並列で探索させたら1回20秒——0.6秒にするまでの記録
← 前の記事: 【第7回】「あの動画どこだっけ」から始まった——Obsidian で知識が育つまで知識を貯めたら、次は使いたくなった
前回、「ツールは手段、目的は知識を育てること」と書きました。
育てました。ノートは増え続けて、vault は6つ。動画メモ、読書ノート、実験記録、料理レシピ、仕事のメモ——Obsidian の中にかなりの知識が詰まっています。
でも、この知識を使っているのは自分だけです。自分が思い出して、自分で検索して、自分で読み返す。
やりたかったのは、AIに使わせることでした。
このサイト(yatmita.com)の記事を書くとき、Claude Code に「記事のネタになりそうなものを探して」と頼みたい。6つの vault を横断的に探索して、関連する情報をかき集めてきてほしい。しかも切り口を変えて8並列くらいで。
そのためには、CLIコマンド1つでナレッジベースを検索できる仕組みが必要でした。Claude Code はシェルコマンドを実行できるので、CLI ツールさえあれば勝手に使ってくれる。
ほしかったのはRAGか、司書か
ここで少し立ち止まって考えました。自分がほしいものは何なのか。
RAG(Retrieval-Augmented Generation)は、検索で拾ったチャンクをLLMに渡して、それを元に回答を生成する仕組みです。「こういうテーマについて教えて」と聞くと、関連するドキュメントの断片を参照しながら、AIがまとめた文章が返ってくる。
でも自分がほしかったのは、AIがまとめた回答ではなくて、ノートそのものでした。
図書館の司書に「こういうテーマで調べたいんですけど」と相談したとき、司書が自分の言葉で要約を語り始めたら困ります。そうじゃなくて、「この棚の3冊目が近いですよ」「あと雑誌コーナーにも関連記事がありました」と、原典を持ってきてほしい。
記事のネタ探しも同じです。AIに要約された文章ではなく、自分が過去に書いたノートの原文がほしい。そのノートを読んで、記事に使えるかどうかは自分で判断する。AIにはネタの在処を探す役割——司書の役割を求めていました。
だから kbsearch は、検索結果をLLMに食わせるRAGパイプラインではなく、ノートのチャンクをそのまま返すCLIツールとして作りました。KB は Knowledge Base の略。Obsidian の vault をKB単位で管理して、ハイブリッド検索で関連ノートを探し出す。Claude Code はこの CLI を道具として使って、司書のように振る舞います。
「意味で探す」と「言葉で探す」
検索の方式を考えるとき、2つのアプローチがあります。
キーワード検索(BM25) は、検索ワードがドキュメントにそのまま含まれているかを調べます。古典的ですが確実。「鍋の素材」で検索すれば「鍋」と「素材」が出てくるノートが見つかる。ただし言い換えには弱い。「フライパン」で検索しても「スキレット」はヒットしません。
セマンティック検索(ベクトル検索) は、テキストをAIモデルで数値ベクトルに変換(embedding)して、意味の近さで検索します。「フライパン」で検索すれば「スキレット」も拾える。一方で、固有名詞や型番のように「意味はないが正確に一致してほしい」ものは苦手です。
| キーワード (BM25) | セマンティック | |
|---|---|---|
| 得意 | 完全一致、固有名詞、型番 | 言い換え、類義語、概念的な検索 |
| 苦手 | 表記ゆれ、言い換え | 正確な一致、未知の専門用語 |
| 必要なもの | 転置インデックス | embeddingモデル + ベクトルDB |
どちらも一長一短。だから両方やって結果を混ぜる。これがハイブリッド検索です。
結果の混ぜ方
2つの検索結果をどう統合するかにもいくつか方式があります。
スコア正規化 + 重み付け平均。各検索のスコアを0〜1に正規化して、重みをつけて足す。シンプルですが、BM25のスコアとベクトル類似度はスケールが全然違うので、正規化の仕方で結果が大きく変わります。
RRF(Reciprocal Rank Fusion)。スコアではなく順位で統合します。「キーワード検索で3位、セマンティック検索で1位」なら、順位の逆数を足し合わせる。スコアのスケール差を気にしなくていいので、異なる検索方式の組み合わせに向いています。
RRFスコア = Σ 1 / (k + rank_i)
kbsearch ではRRFを採用しました。チューニング不要で、検索方式を追加・変更しても壊れにくいのが決め手です。
kbsearch の構成
- Qdrant(ベクトルDB)— セマンティック検索
- SQLite FTS5 trigram — 日本語キーワード検索(BM25)
- sentence-transformers —
multilingual-e5-baseでembedding - RRF — 両方の結果をマージ
キーワード検索側は少し工夫しています。Obsidian のノートにはフロントマター(YAML ヘッダ)があって、タイトル、タグ、カテゴリなどのメタ情報が書かれています。これをそのまま本文と一緒に放り込むのではなく、フロントマターの項目ごとに FTS5 のカラムを分けました。
CREATE VIRTUAL TABLE chunks USING fts5(
title, tags, category, source, body,
tokenize='trigram'
);こうすると BM25 の重み付けをカラム単位で制御できます。タイトルに含まれるキーワードは本文中の出現よりスコアが高くなる、タグの一致はさらに強い——といった調整が、SQLite の bm25() 関数に重みを渡すだけでできます。
SELECT *, bm25(chunks, 10.0, 8.0, 5.0, 2.0, 1.0) AS rank
FROM chunks WHERE chunks MATCH ?
ORDER BY rankObsidian のノートはフロントマターが充実しているので、この構造がそのまま検索の精度に効いてきます。せっかくメタ情報を書いてきたんだから、検索にも使わないともったいない。
動きました。Obsidian 6 vault のノート約31,000チャンクを横断検索できるようになった。検索精度も悪くない。
Claude Code にスキルとして登録して、「このテーマでネタになりそうな情報を探して」と頼んでみました。Claude Code は切り口を変えて8つの検索を並列実行しようとします。
ただ1つ、致命的な問題がありました。
1回20秒 × 8並列 = 使い物にならない
$ kbsearch search "検索ワード"
(20秒間、何も起きない)
1回の検索に20秒。Claude Code が8並列で探索すると、全部終わるまで20秒以上ただ待つことになります。人間が手動で検索するなら「ちょっと遅いな」で済むかもしれませんが、AIエージェントに道具として使わせるなら、この速度は致命的です。
ボトルネックは「検索」ではなかった
「検索が遅い」と思っていましたが、計測してみると違いました。
import sentence_transformers ~8秒 ← PyTorch + CUDA の依存ツリー
モデルロード (e5-base → GPU) ~10秒 ← 毎回プロセス起動で再ロード
embed_query (実際の推論) ~0.03秒
Qdrant 検索 + SQLite FTS5 ~0.2秒
20秒のうち18秒がモデルの準備。検索自体は0.2秒で終わっています。
CLIツールは毎回プロセスが起動します。Webサーバーのようにモデルを常駐させられない。起動するたびにPyTorchをimportして、500MBのモデルをGPUに載せて、それからようやく検索が始まる。
問題は検索ではなく、起動コストでした。
Phase 1: sentence-transformers をやめる
sentence-transformersはPyTorchベースで、importだけで8秒かかります。代わりにFastEmbedを選びました。ONNX Runtimeベースの軽量なembeddingライブラリで、PyTorchが不要になります。
ここでClaude Codeが1つ判断を入れました。モデルを e5-base(768次元)から e5-large(1024次元)に変更するという。理由は、qdrant-clientのFastEmbed統合がe5-baseをサポートしていないから。e5-largeにすれば、Qdrant側でembeddingを処理できる——という話でした。
モデルが変わるので、全チャンクを再embedding。CPUで約15分。
結果は 20秒 → 8秒。半分以下になったけど、まだ遅い。
Claude Codeが手段と目的を取り違えた
ここで違和感に気づきました。「モデルを変えたのは、qdrant-client内でembeddingを完結させるためだったよね?」と聞いてみたんです。
e5-largeに変更した目的は:
- qdrant-clientのFastEmbed統合を使いたい
- そのためにFastEmbed対応モデルが必要
- e5-baseは非対応 → e5-largeに変更
なのに肝心のゴール——qdrant-clientにembeddingを任せる——を実装していなかった。embedder.pyで手動FastEmbedする実装にしてしまい、「ライブラリを入れ替える」という作業に集中するあまり、なぜ入れ替えるのかを忘れていました。
Claude Codeは優秀ですが、タスクを渡すと目の前の実装に集中して、元の目的を見失うことがあります。人間が「それ、何のためにやってるんだっけ?」と軌道修正する場面は、この開発中に何度かありました。
修正してみたが……
qdrant-clientのmodels.Document APIに書き換えました。見た目上は「Qdrantに任せている」コードになったし、検索結果も正しい。しかし速度は変わらず 8秒のまま。
なぜか。models.Documentは裏でクライアントプロセス内のFastEmbedを動かしていただけでした。Self-hosted Qdrantにはサーバーサイドembedding機能がない(Cloud版のみ)。
目的を思い出して実装し直したのに、そもそもその方式では解決しなかった。
Phase 2: embedding を別サーバーにする
CLIプロセスの中でembeddingをやる限り、モデルのロード時間は消えません。解決策はシンプルで、embeddingを別プロセスに出す。
HuggingFaceの**TEI(Text Embeddings Inference)**を使いました。embedding専用のHTTPサーバーで、Dockerコンテナとして常駐させておく。CLIからはHTTPリクエストを1つ投げるだけです。
def embed_query(text: str) -> list[float] | None:
try:
resp = httpx.post(f"{TEI_URL}/embed",
json={"inputs": "query: " + text}, timeout=30)
resp.raise_for_status()
return resp.json()[0]
except (httpx.ConnectError, httpx.TimeoutException):
return NoneTEIが落ちていたらNoneを返して、セマンティック検索はスキップ。キーワード検索だけで結果を返します。
結果は 8秒 → 3.5秒。大きく改善したけど、まだ目標の1秒には届かない。
Phase 3: SDK が重すぎる
残りのボトルネックをプロファイリングしました。
CLI import: 0.1秒
qdrant-client import: ~2.5秒 ← これ
TEI embed (HTTP): ~0.5秒
Qdrant クエリ: ~0.5秒
qdrant-clientのimportだけで2.5秒。grpc, protobuf, numpy, pydanticなど重い依存ツリーが全部読み込まれます。
「qdrant-clientは実際に何をしているのか」を調べると、使っているAPIは全部で7つ。全てQdrantのREST APIに1:1で対応していました。SDKのモデルクラスは、最終的にJSONのdictに変換されるだけの型安全ラッパー。curlで直叩きしても問題なく動く。
qdrant-clientを捨てて、httpxで薄いHTTPラッパーを書きました。全7関数で約100行。uv syncでqdrant-clientと依存パッケージ10個が一括削除されました。
結果
import 時間: 0.16秒
横断検索 (hybrid): 0.63秒
単一KB検索: 0.59秒
キーワードのみ: 0.26秒
20秒 → 0.6秒。約33倍の高速化。
| フェーズ | 検索時間 | 主なボトルネック |
|---|---|---|
| 初期 (sentence-transformers) | ~20秒 | PyTorch import + モデルロード |
| FastEmbed移行後 | ~8秒 | ONNX Runtime import + モデルロード |
| TEI移行後 | ~3.5秒 | qdrant-client import |
| qdrant-client排除後 | ~0.6秒 | TEI embed + Qdrant query |
最終的な依存はhttpxだけ。起動して結果が返るまで、体感で「一瞬」になりました。
知識の最後はRAG化だった
このシリーズを振り返ると、知識との関わり方が段階的に変わってきたことがわかります。
保存 → 整理 → 検索 → 記録 → 比較 → 実験 → まとめ
ここまでが前回までの7段階。そして今回が8段階目です。
RAG化。育てた知識をチャンク分割してembeddingし、ベクトルDBに入れて、AIエージェントの道具にした。
Obsidian の中で完結していた知識が、AIから引き出せるようになりました。Claude Code に「このテーマで記事を書きたい、ネタになりそうなものを探して」と頼むと、8並列でナレッジベースを探索して、関連するノートの断片を集めてきてくれます。0.6秒で。
自分が書いたノート、見た動画のメモ、実験の記録——それらが、自分の記事を書くための素材として、AIの手を通じて戻ってくる。知識を「自分が使う」段階から「AIに使わせる」段階に進んだとき、ノートを貯め続けてきたことの意味が変わりました。
1本の動画ノートから始まった流れが、RAG(Retrieval-Augmented Generation)の基盤にたどり着いた。最初からここを目指していたわけではありません。ノートが増えて、vault が増えて、AIに探させたくなって、作ってみたら遅くて、速くして——その繰り返しの先に、気づいたらここにいました。
今では Obsidian ノートだけでなく、OCR した蔵書専用の KB や、86GB ほどあるまとまった資料の KB も動かしています。KB ごとにカラム構成と BM25 の重み付けを変えてある。蔵書なら書名やページ番号を重視、資料系ならセクション見出しやファイル名を重視。データの性質に合わせてチューニングできるのが、KB 単位で分離管理している利点です。
やってみて思ったこと
計測しないと見えない。「検索が遅い」と感じていたけど、検索は0.2秒で終わっていた。遅いのはimportとモデルロード。体感と実態がずれているとき、プロファイリングが唯一の手がかりになります。
SDKは便利だが、重さの代償がある。qdrant-clientは型安全で使いやすい。でもimportに2.5秒かかる。使っているAPIがREST直叩きで済むなら、100行のラッパーのほうがCLIツールには向いている。常駐するWebサーバーなら問題にならないけど、CLIは毎回起動します。
AIは目の前のタスクに集中しすぎる。Claude Codeにモデル変更とライブラリ移行を任せたら、手段の実装に没頭して目的を見失った。人間が「それ、何のためにやってるんだっけ?」と問いかける役割は、AIとの協業で思った以上に大事でした。
知識は、使い手が変わると価値が変わる。同じObsidianのノートでも、自分が手動で検索するのと、AIに8並列で探索させるのでは、引き出せるものが違います。自分では思いつかない切り口で、自分の知識の中から関連情報を拾ってくる。保存した時点では想像もしなかった使い方が、後から生まれてくる。だから、まずは保存しておくことに意味がある。第1回で「まず1本だけでいい」と書いたのは、そういうことだったんだと今ならわかります。