ObsidianへMarkdownノートをためていくと、内容は覚えていても、どの言葉で書いたかを思い出せないことがあります。検索に使う語が分かる場合と、内容しか思い出せない場合では、必要な探し方も違います。
そこで僕は、ObsidianのVaultを探すデスクトップアプリを作りました。文字列の一致を順位付けするBM25全文検索と、意味の近さで探すベクトル検索を組み合わせ、その結果をRAGチャットへ渡します。
RAGは、検索したノートを根拠として言語モデルに回答させる仕組みです。Vaultは、ObsidianがMarkdownノートを保存するフォルダです。
この記事では、3つをどう役割分担させたかと、RAGが回答に使ったノートを画面へ残した理由をまとめます。対象はローカルで動かす検索とRAGです。アプリ全体の完成や配布を扱う記事ではありません。
まず全体像
アプリはVault内のMarkdownファイルを読み、検索しやすい長さの断片に分けます。分けた断片から、全文検索用の索引と意味検索用のベクトルを作ります。
検索時は、BM25とベクトル検索がそれぞれ候補を出します。2つの点数を直接比べるのではなく、RRF(Reciprocal Rank Fusion)で順位を統合します。同じノートから複数の断片が見つかっても、一覧には1件のノートとして表示します。
RAGチャットでも、質問文を使って同じ検索を行います。見つかった断片とその前後をローカルの言語モデルへ渡します。回答には、参照したノートのパスと見出しを添えます。
3つの機能は、探す段階が違う
僕は全文検索、意味検索、RAGを、同じ機能の強弱としては扱いませんでした。それぞれが担当する段階を分けています。
| 機能 | 向いている場面 | このアプリでの役割 |
|---|---|---|
| BM25全文検索 | 固有名詞、型番、エラーコードなど、探す語が分かる | 文字として一致する候補を順位付けする |
| ベクトル検索 | 書いたときと探すときで表現が違う | 内容の近い候補を順位付けする |
| RAG | 複数の候補を読み合わせて質問へ答えてほしい | 検索した断片を言語モデルへ渡し、回答と参照元を返す |
たとえば、エラーコードを控えたノートなら、同じ文字列をBM25で探す方が直接的です。一方、「外へ送らずにノートを探す仕組み」のように、元のノートと違う言葉で探す場合は、意味検索が候補を補えます。
RAGは、検索の代わりではありません。先に検索で読む範囲を絞り、その範囲を使って質問へ答える役割です。検索結果がなければ、ノートを根拠にした回答も作れないため、先に検索を整える必要があります。
全文検索と意味検索を1つの結果へまとめた
全文検索だけにすると、言葉を思い出せない場面で候補を拾いにくくなります。意味検索だけでは、固有の文字列をそのまま探したいときに、似た話題が上へ来ることがあります。
そこで、BM25とベクトル検索を並行して行い、双方の順位をRRFでまとめました。RRFは検索方式ごとの点数を直接足すのではなく、それぞれで何位だったかを使って新しい順位を作ります。尺度の違う点数をそろえる調整を増やさずに、両方で上位に出た断片を評価するための選択です。
検索結果は断片のまま並べず、ノート単位へまとめます。1つのノートから複数の断片が見つかっても、一覧ではそのノートを1件として見せ、選ぶと中央のプレビューで本文を読める形です。
ベクトルがまだ読み込まれていない場合や、質問文のベクトル化に失敗した場合は、検索全体を止めずBM25だけで続けます。意味検索が使えなくても、キーワード検索は残すためです。
短い追質問では、検索に使う語が足りなかった
検索とRAGをつないだあと、会話が続いたときの検索にも手を入れました。最初の質問が「全文検索と意味検索の違いは?」でも、次の質問は「それはどう使い分ける?」のように短くなります。この追質問だけでVaultを検索すると、「それ」が何を指すのか分かりません。
そこで、短い質問や「それ」「続き」などを含む質問では、直近の利用者の発言と、直近の回答の冒頭部分を検索語へ補います。回答文に含まれる記号をそのまま検索へ渡すと、検索用の記号として解釈され、検索に失敗する可能性がありました。検索用の補助文へ変換するときに記号を取り除き、会話用の文章と検索用の文字列を分けています。
この修正では、RAGが回答を作る部分だけではなく、会話から次の検索語を組み立てる部分も設計の対象になりました。
架空Vaultで利用場面を作った
公開用の例には、実際のVaultやファイルパスを使っていません。次のような架空ノートだけを用意しました。
架空Vault/
├─ 設計/
│ ├─ 検索方式の役割分担.md
│ └─ ローカル処理の境界.md
├─ 会議/
│ └─ 検索改善メモ.md
└─ 運用/
├─ 障害調査の手順.md
└─ RAG回答の確認.md
説明用のシナリオでは、「言葉が一致しないメモも探したい」と検索します。架空ノートには「書いたときと探すときで表現が変わりそうな内容」「言い換えた質問でも関係するメモへたどり着く」と書いてあります。検索語がそのまま入っていないノートを、意味検索が補う場面を示すための例です。
続けて「検索方法はどう使い分ける?」と質問し、RAGが検索で選んだ断片を回答へ渡す流れを描きました。画面には回答だけでなく、設計/検索方式の役割分担.md などの参照元も残します。
この図は現行画面の構成をもとにした説明用イメージであり、検索やRAGの実行結果を写したものではありません。図に描いた検索順位や回答内容は検証結果ではなく、検索から質問までの操作の流れを示す架空の例です。
RAGの回答から元のノートへ戻れるようにした
RAGへ渡すのは、検索で見つかった断片だけではありません。ヒットした断片の前後も結合し、途中で文脈が切れにくい形へ整えます。ただし、1回の質問で際限なくノートを渡すのではなく、候補数と1件あたりの長さに上限を置いています。
回答が返ったあと、画面下部へ参照ソースを表示します。表示するのは、検索で候補になったノートのパスと見出しです。見出しがない場合はノートのタイトルを使います。
ここで大切なのは、参照ソースが回答の正しさを保証するものではないことです。表示から分かるのは、どのノートを回答生成へ渡したかです。重要な内容は、参照元を開いて原文を確認します。
ノートを扱う処理はローカルへ閉じた
この記事で扱う構成では、Markdownの読み取り、断片への分割、全文検索用の索引、ベクトルの生成と検索を端末内で行います。
- 全文検索の索引は、Rust製の全文検索ライブラリTantivyで作る
- ノートの断片とベクトルは、組み込みデータベースSQLiteへ保存する
- 埋め込みモデルは、機械学習モデルを実行するONNX Runtimeで動かす
- チャット用の言語モデルは、同じ端末で起動したllama-serverを通して使う
画面はWeb UIを作るSvelte、デスクトップアプリの枠組みはTauriとRustで作りました。画面から検索やチャットを呼び出し、Rust側がVault、検索索引、モデルをつなぎます。
なお、元のアプリには外部APIを使う選択肢もありますが、この記事の対象には含めていません。「ローカルRAG」と書く範囲を、端末内のモデルへ接続する構成に限定しています。
完成扱いにした範囲
統合テストでは、次の範囲を確認しました。
- Vault内のMarkdownを断片へ分け、全文検索用の索引とベクトルを作る
- BM25とベクトル検索の順位をRRFで統合し、ノート単位で結果を返す
- ベクトル検索を使えない場合に、BM25だけで検索を続ける
- 検索したノート断片をローカルの言語モデルへ渡し、回答を生成する
- 回答と一緒に参照ノートのパスと見出しを表示する
このテストは、メモリ上のデータベース、Tantivyの索引、テスト用の埋め込みモデルと言語モデルを使った統合確認です。実際の埋め込みモデルによる検索品質や、架空Vaultの図に描いた順位を測ったものではありません。
クラスタリング、重複ノートの整理、外部API、CLIなど、アプリに含まれる別機能はこの記事では扱いません。
作って分かったこと
僕が作った構成では、検索にベクトル検索とRAGを組み合わせても、すべてを回答生成へ寄せる必要はありませんでした。正確な語が分かるときは全文検索、言い換えで探すときは意味検索、見つけた内容を読み合わせるときはRAGと役割を分けています。
もう1つは、成功時だけで設計を終わらせないことです。ベクトル検索に失敗しても使えるBM25を残し、RAGの回答には参照元を添えました。使えないときの動きと、回答後に元のノートを確かめる手段まで実装しています。
まとめ
僕が作ったObsidianノート検索アプリでは、BM25全文検索とベクトル検索をRRFで1つの順位へまとめ、その結果をローカルRAGへ渡しました。キーワード、意味、質問への回答を、別々の段階としてつないだ構成です。
ベクトル検索を使えない場合はBM25だけで続け、RAGの回答には参照したノートを表示します。検索方式を増やしただけでなく、ベクトル検索が使えないときにも検索を続け、回答後に原文を確認できるところまでつなぎました。