外部脳の検索を、キーワードから意味検索に変えた話

少し前に、ObsidianをClaude Codeの外部脳にした話を書いた。セッションをまたぐと記憶を失うClaude Codeに、Markdownのvaultを読み書きさせて知識を引き継がせる、という仕組みだ。今もちゃんと回っている。

ただ、使い込むうちに弱点が一つ見えてきた。recallがキーワード検索なところだ。

あの記事で書いた読み取り手順の二番目、「質問に関連するキーワードでvaultを検索する」。ここが思ったより取りこぼす。たとえば日次ログは メモ/2026-06-23.md みたいに日付がファイル名なので、いつやったか覚えていないと辿り着けない。「あのとき何やったっけ」が一番引きたいのに、日付を覚えていないと引けない。言葉のズレにも弱い。「権限を渡しすぎないように」で検索しても、ノートに「許可」「射程」と書いてあると当たらない。同じことを言っているのに。

要するに、キーワード検索は精度が高いけど取りこぼしが多い。欲しいのは意味で引く検索、つまりembeddingだった。

きっかけはコメント

ちょうどそのタイミングで、外部脳の記事に「Hermes Agentで TencentDB-Agent-Memory を試してる」というコメントをもらった。エージェントに長期記憶を持たせるツールらしい。実際どうなんだろうと思って調べてみた。

中身はよくできていた。会話を L0、そこから抽出した事実を L1、シナリオを L2、ペルソナを L3、と4層に蒸留していく。保存はローカルのsqlite、埋め込みもローカル。しかも上の層は読めるMarkdownで持っていて、ベクトルの塊にして中身が見えなくなる、ということを避けている。「中身が見える記憶」という思想は、俺がvaultでやりたいことと近い。

ただ、これは Hermes Agent や OpenClaw という別のエージェントランタイムのプラグインだった。俺が使っているのはClaude Codeで、そこには差し込めない。コメントをくれた人はHermes使いだろうから、彼の環境にはきれいにハマる。でも俺の環境には乗らない。

なので、使わないことにした。良さそうでも自分の用途に乗らないなら入れない。評価して「使わない」と決めるのも仕事のうちだ。ただ、置き換えるべき弱点(キーワード検索)と、欲しい機能(意味検索)ははっきりした。

欲しいのは意味検索だけ

だったら、その一点だけ自分で足せばいい。フレームワークを丸ごと持ってくる必要はない。

作ったのは vault-search という小さなCLIだ。やることはこうだ。

  1. vaultの .md を見出し単位でチャンクに割る
  2. 各チャンクを Ollama のローカルモデル(bge-m3)でembeddingにする
  3. 正規化してsqliteに保存する
  4. 検索時はクエリもembeddingにして、cosine類似度で近いチャンクを引く

これだけだと意味検索オンリーになる。でも意味検索は単体だと「それっぽいけど無関係」を拾うことがある。逆にキーワードは正確だけど取りこぼす。なので両方を走らせて、結果を RRF という方法で混ぜる。意味で引いた順位とキーワードで引いた順位を足し合わせて、両方に出てくるものを上に持ってくる。いわゆるhybrid検索だ。

vault-searchのhybrid検索パイプライン: .mdをチャンク化→Ollama(bge-m3)で埋め込み→sqliteに保存。検索時はcosine(意味)とsubstring(keyword)を並走させRRFで融合してtop-kを返す

依存はPythonの標準ライブラリだけにした。手元のPythonが3.14と新しすぎて、torch系のライブラリがまだwheelを出しておらず、入れようとすると依存地獄にハマる。だったら標準ライブラリとOllamaのHTTPだけで完結させたほうが速い。ベクトルの総当たりcosineも、数千チャンク程度なら標準ライブラリのまま75msで返る。埋め込みもローカルなので、ノートの中身がマシンの外に出ることもない。

ripgrepが、居なかった

ここで一番ハマった。せっかくなのでキーワード側を ripgrep で実装した。速いし、普段から使っている。

ところが検索結果を見ると、全部が意味検索のタグ(emb)ばかりで、キーワード(kw)が一つも付かない。hybridのつもりが意味検索オンリーで動いていた。

調べていくと、原因はripgrepが見つかっていないことだった。Pythonからripgrepを呼ぶと FileNotFoundError になる。でもシェルでは普通に rg が動く。なんで、と思って which rg を叩いたら、出てきたのは実行ファイルのパスじゃなくてシェル関数の定義だった。

Claude Codeの環境では、rg は実体のバイナリじゃない。Claude Code本体へリダイレクトするシェル関数として定義されていた。だからシェルからは動くのに、Pythonの subprocess からは見えない。そして俺のコードは、ripgrepが見つからないとキーワード側を黙ってスキップする作りになっていた。エラーを握りつぶしていたわけだ。

直そうとして、絶対パスでripgrepを探すか、と書きかけて、手が止まった。

そもそもチャンクのテキストは、もうsqliteに全部入っている。検索のためにembeddingと一緒に保存してあるんだから。外部のripgrepにファイルを舐めさせる必要なんて、最初から無かった。キーワード検索は、sqliteの中のテキストに対してPythonで部分一致を取ればいいだけだ。

ripgrepを呼ぶのをやめて、DBの中で完結させた。依存が一つ減って、しかもripgrepの出力(ファイルと行番号)をチャンクに対応づける処理も丸ごと要らなくなった。コードはむしろ短くなった。

足した依存が、実は要らなかった。必要なデータはもう手元にあった。これが今回の一番の学びだ。便利そうな道具に手を伸ばす前に、自分がもう持っているものを見たほうがいい。

効果

vaultは1055ファイル、チャンクにすると2927個。これに対して意味検索が効くようになった。

「gitleaks入れたときどうやった」で検索すると、日付を一つも指定していないのに メモ/2026-06-23.md、つまりその作業をした日の日次ログが一位で出る。日付を覚えていなくても、やったことの内容で引ける。これが欲しかった。

「AIに権限を渡しすぎないようにする」で検索すると、許可の射程の記事の元ネタが出てくる。ノート側には「権限」とも「渡しすぎ」とも書いていない。「許可」「射程」と書いてある。語が一致していないのに、意味で繋がって出てくる。キーワード検索では絶対に出なかったやつだ。

これで外部脳の手順2が、キーワード検索からhybrid検索に変わった。次のセッションからは、Claude Codeが意味で過去を掘ってから答えるようになる。

公開した

作ってみて、これObsidian使いなら割と欲しいんじゃないかと思った。なのでMITで公開した。

github.com/nobu666/obsidian-vault-search

意味検索のプラグインは既にいくつかある。アプリ内のGUIで使う Smart Connections や、もっと多機能な khoj とか。それらと比べたときのこいつの立ち位置は、「標準ライブラリとOllamaだけの軽量CLIで、エージェントのrecallに食わせる」ところだ。GUIで人が眺めるためじゃなく、Claude Codeみたいなエージェントがセッション開始時に叩いて、過去のノートを文脈に引き込むための道具。300行ない一枚スクリプトなので、中身も一読で追える。

公開するついでに、テストとCIも付けて、mainブランチは直pushを禁止してPR経由しか通らないようにした。自分一人のリポジトリでも、公開した以上は最低限の体裁を整えておく。

振り返り

重いフレームワークを評価して、結局250行の自作に落ち着いた。コメントで教えてもらったツール自体は良いものだったし、それを調べたから「自分に必要なのは意味検索の一点だけ」とはっきりした。良いものでも乗らないなら入れない、という判断は、調べてみないとできない。無駄足ではなかった。

外部脳の記事で「最初から作り込みすぎないのがコツ」と書いた。今回もそれだった。まずキーワード検索でつないで、足りないと感じてから意味検索を足す。最初から全部embeddingで作っていたら、たぶんripgrepの件にも気づかず、要らない依存を抱えたまま満足していたと思う。育てながら気づくことのほうが多い。