増えたObsidianノートを探すため、全文検索・意味検索・RAGを1つのアプリにつないだ

ObsidianへMarkdownノートをためていくと、内容は覚えていても、どの言葉で書いたかを思い出せないことがあります。検索に使う語が分かる場合と、内容しか思い出せない場合では、必要な探し方も違います。

そこで僕は、ObsidianのVaultを探すデスクトップアプリを作りました。文字列の一致を順位付けするBM25全文検索と、意味の近さで探すベクトル検索を組み合わせ、その結果をRAGチャットへ渡します。

RAGは、検索したノートを根拠として言語モデルに回答させる仕組みです。Vaultは、ObsidianがMarkdownノートを保存するフォルダです。

この記事では、3つをどう役割分担させたかと、RAGが回答に使ったノートを画面へ残した理由をまとめます。対象はローカルで動かす検索とRAGです。アプリ全体の完成や配布を扱う記事ではありません。

まず全体像

Obsidian VaultのMarkdownノートを分割し、BM25全文検索とベクトル検索の順位を統合して検索結果を作り、その結果をローカル言語モデルへ渡して回答と参照ノートを表示する構成図
BM25とベクトル検索の結果を1つの順位へまとめ、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 などの参照元も残します。

架空Vaultを使った説明用画面。左に検索語と3件の検索結果、中央に検索方式の役割分担ノート、右にRAGの質問、回答、参照ソースを表示している
実ノートを写した画面ではなく、現行画面の構成へ架空データを当てた説明用イメージです。

この図は現行画面の構成をもとにした説明用イメージであり、検索やRAGの実行結果を写したものではありません。図に描いた検索順位や回答内容は検証結果ではなく、検索から質問までの操作の流れを示す架空の例です。

RAGの回答から元のノートへ戻れるようにした

RAGへ渡すのは、検索で見つかった断片だけではありません。ヒットした断片の前後も結合し、途中で文脈が切れにくい形へ整えます。ただし、1回の質問で際限なくノートを渡すのではなく、候補数と1件あたりの長さに上限を置いています。

回答が返ったあと、画面下部へ参照ソースを表示します。表示するのは、検索で候補になったノートのパスと見出しです。見出しがない場合はノートのタイトルを使います。

ここで大切なのは、参照ソースが回答の正しさを保証するものではないことです。表示から分かるのは、どのノートを回答生成へ渡したかです。重要な内容は、参照元を開いて原文を確認します。

ノートを扱う処理はローカルへ閉じた

この記事で扱う構成では、Markdownの読み取り、断片への分割、全文検索用の索引、ベクトルの生成と検索を端末内で行います。

  • 全文検索の索引は、Rust製の全文検索ライブラリTantivyで作る
  • ノートの断片とベクトルは、組み込みデータベースSQLiteへ保存する
  • 埋め込みモデルは、機械学習モデルを実行するONNX Runtimeで動かす
  • チャット用の言語モデルは、同じ端末で起動したllama-serverを通して使う

画面はWeb UIを作るSvelte、デスクトップアプリの枠組みはTauriとRustで作りました。画面から検索やチャットを呼び出し、Rust側がVault、検索索引、モデルをつなぎます。

なお、元のアプリには外部APIを使う選択肢もありますが、この記事の対象には含めていません。「ローカルRAG」と書く範囲を、端末内のモデルへ接続する構成に限定しています。

完成扱いにした範囲

統合テストでは、次の範囲を確認しました。

  1. Vault内のMarkdownを断片へ分け、全文検索用の索引とベクトルを作る
  2. BM25とベクトル検索の順位をRRFで統合し、ノート単位で結果を返す
  3. ベクトル検索を使えない場合に、BM25だけで検索を続ける
  4. 検索したノート断片をローカルの言語モデルへ渡し、回答を生成する
  5. 回答と一緒に参照ノートのパスと見出しを表示する

このテストは、メモリ上のデータベース、Tantivyの索引、テスト用の埋め込みモデルと言語モデルを使った統合確認です。実際の埋め込みモデルによる検索品質や、架空Vaultの図に描いた順位を測ったものではありません。

クラスタリング、重複ノートの整理、外部API、CLIなど、アプリに含まれる別機能はこの記事では扱いません。

作って分かったこと

僕が作った構成では、検索にベクトル検索とRAGを組み合わせても、すべてを回答生成へ寄せる必要はありませんでした。正確な語が分かるときは全文検索、言い換えで探すときは意味検索、見つけた内容を読み合わせるときはRAGと役割を分けています。

もう1つは、成功時だけで設計を終わらせないことです。ベクトル検索に失敗しても使えるBM25を残し、RAGの回答には参照元を添えました。使えないときの動きと、回答後に元のノートを確かめる手段まで実装しています。

まとめ

僕が作ったObsidianノート検索アプリでは、BM25全文検索とベクトル検索をRRFで1つの順位へまとめ、その結果をローカルRAGへ渡しました。キーワード、意味、質問への回答を、別々の段階としてつないだ構成です。

ベクトル検索を使えない場合はBM25だけで続け、RAGの回答には参照したノートを表示します。検索方式を増やしただけでなく、ベクトル検索が使えないときにも検索を続け、回答後に原文を確認できるところまでつなぎました。