16,894のObsidianファイルに対応するハイブリッドリトリーバーの構築
16,894のMarkdownファイルに対するgrepは、検索語によって11〜66秒かかり、関連性の低いマッチが数百件返されます。ベクトル検索はセマンティックに関連するコンテンツを返しますが、入力した正確な関数名を見逃します。両方の手法を融合するハイブリッドリトリーバーは、API呼び出しゼロで、83 MBの単一SQLiteファイルから23ミリ秒(クエリ埋め込みを含むエンドツーエンド)で正しい答えを返します。1
メモ魔の問題は収集ではありません。問題は検索です。Obsidianはキャプチャを摩擦なく行えるようにします。ファイルが十分蓄積されると、Vaultはライトオンリーデータベースになります。追加は簡単でも、クエリは不可能です。ファイル名での検索はファイル名が無意味になるまでは機能します。全文検索は同じキーワードが400のドキュメントに出現するまでは機能します。タグはタグ付けを忘れるまでは機能します。
HNのコメント投稿者が、私がObsidian Vault用に構築した検索システムの完全なアーキテクチャを求めました。2 ここにそのすべてがあります。チャンキング戦略、埋め込みモデル、デュアルインデックスSQLiteスキーマ、実数値を使った融合の計算、そして何百回もシステムにクエリを投げた後に発見した障害モードです。
TL;DR
このリトリーバーは、FTS5 BM25キーワード検索とModel2Vecベクトル類似検索をReciprocal Rank Fusion(RRF)で融合し、単一のランク付きリストにまとめます。すべてが1つのSQLiteデータベース内でローカルに動作します。16,894ファイルから49,746チャンク、83 MB。フルリインデックスは4分。インクリメンタル更新は10秒以内。このシステムはフックを通じてClaude Codeと統合し、エージェントがファイルをコンテキストにロードせずにVaultの知識にアクセスできます。BM25は正確な識別子や関数名をキャッチします。ベクトル検索は異なる用語間のセマンティックマッチをキャッチします。RRFはスコアキャリブレーションなしで両者をマージします。正直なトレードオフとして、タグが充実した浅いコンテンツが構造化の不十分な深いコンテンツより上位にランクされることがあります。BM25はキーワード密度を報酬とし、深さは報酬としないためです。
重要ポイント
大規模Vaultを持つメモユーザーへ。 私の経験では、全文検索だけでは数千ファイルを超えると使い物にならなくなりました。また、既存のObsidian検索プラグイン(Smart Connections、Omnisearch)はアプリ内でインデックスするため、他のツールがクエリできる外部ライブラリとしては機能しません。1 BM25の上にベクトル検索を追加すると、概念は覚えているがキーワードを思い出せないクエリをキャッチできます。リトリーバーは完全にSQLite上で動作し、外部サービス、GPU、APIコストは一切不要です。Model2Vecはトランスフォーマーではなく30 MBの静的ワードベクトルであるため、CPUの速度で埋め込みを行います。3
検索システムを構築する開発者へ。 RRFはチューニングが最も少なくて済む融合手法です。この数式はランク位置のみを使用し、生スコアは使用しないため、BM25スコアとコサイン距離のキャリブレーションは不要です。k=60と等しい重みから始めてください。自分のデータで失敗ケースを測定した後にのみチューニングします。sqlite-vec拡張は、別途ベクトルデータベースを用意せずにSQLite内でベクトルKNN検索を実現します。4
Claude Codeユーザーへ。 リトリーバーはフックが呼び出せるライブラリとして動作します。PreToolUseフックはエージェントが作業を開始する前にVaultにクエリを投げます。エージェントはファイル全体をロードする代わりに、ファイルパスの帰属情報付きの2〜3 KBの焦点を絞った結果を確認できます。この統合によりコンテキストウィンドウを小さく保ちつつ、エージェントに16,894ファイル分の知識へのアクセスを提供します。
最小限の実行可能バージョン。 最もシンプルな出発点は、MarkdownファイルにFTS5仮想テーブルを作成することです(BM25のみ、埋め込みなし)。キーワード検索がセマンティックマッチを見逃し始めたらsqlite-vecとModel2Vecを追加します。RRF融合は最後に追加します。各レイヤーは独立して動作します。フルスタックにはPython 3、1つの30 MBモデルのダウンロード、pip install model2vec sqlite-vecが必要です。GPUなし、Dockerなし、外部サービスなし。16,894ファイルの合計ディスク使用量:83 MB。
完全な運用ガイドが必要ですか? Obsidian AIインフラストラクチャリファレンスでは、Vaultアーキテクチャ、プラグイン設定、MCPサーバーセットアップ、インクリメンタルインデックスのレシピ、トラブルシューティングを網羅しています。この記事のアーキテクチャ深掘りに対するステップバイステップのコンパニオンです。
キーワード検索だけでは大規模で失敗する理由
全文検索はVaultの規模において予測可能な形で破綻します。FTS5とBM25ランキングは正確なマッチに優れています。requestAnimationFrameを検索すれば、その正確なトークンを含むすべてのファイルが、用語頻度とドキュメント長でランク付けされて表示されます。5 RobertsonとZaragozaの確率的関連性モデルのサーベイは、BM25の強みを裏付けています。このアルゴリズムはキーワード中心のクエリに対してパラメータチューニングが最小限でも良好に動作します。14 障害モードは同義語と概念マッチングです。「認証失敗の処理方法」を検索すると、BM25は「認証」や「失敗」を個別に含むすべてのファイルを返し、間接的に関連するコンテンツで結果が薄まります。
ベクトル検索は同義語の問題を解決します。クエリを埋め込み、ベクトル空間で近い位置にあるチャンクを見つけます。「認証失敗の処理方法」は、「ログインエラーの復旧」や「セッション期限切れの処理」に関するコンテンツとマッチします。埋め込みが異なる用語間のセマンティックな類似性を捉えるためです。6 Karpukhinらは、Dense Passage Retrieval(DPR)を用いて、密な埋め込みがオープンドメイン質問応答においてBM25をtop-20精度で9〜19%上回ることを実証しました。密な表現が語彙的重複を超えた意味を捉えるためです。15 障害モードは逆で、ベクトル検索は正確な識別子を見逃します。関数名_rrf_fuseを検索すると、ベクトル検索は融合やランキングアルゴリズムに関するコンテンツを返しますが、実際の関数定義が概念的な説明より下位にランクされる場合があります。
どちらの手法も単独では両方の障害モードをカバーできません。単一のクエリがその違いを示します(優位性の証明ではなく、総合的な評価にはシステムがまだ持っていないゴールデンセットが必要です)。クエリ「PostToolUse hook for context compression」は各手法から異なるtop-3結果を返します:
| Rank | BM25 Only | Vector Only | Hybrid (RRF) |
|---|---|---|---|
| 1 | hook-stdlib.sh “PostToolUse Handler” | context-is-the-new-memory.md “Compression Layers” | context-is-the-new-memory.md “Compression Layers” |
| 2 | settings.json “PostToolUse Events” | token-budget-analysis.md “Context Engineering” | hook-stdlib.sh “PostToolUse Handler” |
| 3 | compress-output.sh “Tool Output Filter” | agent-memory-patterns.md “Retrieval Integration” | compress-output.sh “Tool Output Filter” |
BM25は正確なフックファイルと設定リファレンスを見つけました(「PostToolUse」のキーワードマッチ)が、概念的なコンテキストエンジニアリングのノートを見逃しました。ベクトル検索は圧縮戦略のノートを見つけました(「context compression」のセマンティックマッチ)が、特定のフック実装を見逃しました。RRFは概念と実装の両方に重要なノートを昇格させ、戦略ノートとフックファイルを1位と2位に配置しました。13
MS MARCOパッセージランキングの研究は、ウェブ検索ベンチマークにおけるこのパターンを裏付けています。ハイブリッド検索はBM25単独または密検索単独を一貫して上回り、特定の用語と抽象的な概念の両方を含むクエリで最大の改善が見られます。716
アーキテクチャ:相乗効果を生む3つのレイヤー
システムには3つの独立したレイヤーがあります。各レイヤーは他がなくても動作しますが、組み合わせると相乗効果を発揮します。
レイヤー1:インテーク。 733行のPythonスコアリングパイプラインが、すべての受信シグナルを4つの次元(関連性、実行可能性、深さ、権威性)で評価します。スコア0.55以上のシグナルは12のドメインフォルダのいずれかに自動ルーティングされます。0.40〜0.55のシグナルは手動レビューのキューに入ります。0.40未満ではパイプラインがシグナルを破棄します。パイプラインは14ヶ月にわたり7,771のシグナルを手動タグ付けなしで処理してきました。1 インテークレイヤーはVaultに何が入るかを決定します。検索レイヤーはそれを見つけられるようにします。
レイヤー2:検索。 以下で詳述するハイブリッド検索エンジンです。エンジンはすべてのファイルを見出し境界でチャンク分割し、Model2Vecでチャンクを埋め込み、ベクトルKNN用のvec0テーブルとBM25用のFTS5仮想テーブルの両方を持つSQLiteにインデックスします。クエリは両方のインデックスに対して同時に実行され、RRFが結果を単一のランク付きリストに融合します。
レイヤー3:統合。 リトリーバーをエージェントのワークフローに組み込むClaude Codeフック。フックはプロンプト送信時に発火し、Vaultに関連コンテキストをクエリし、上位結果を会話に注入します。エージェントは生のファイル内容ではなく、ソース帰属情報付きの焦点を絞ったチャンクを確認できます:
# Illustrative output (format matches production, content simplified)
## Relevant Memory Context
### OAuth Token Rotation (security-patterns)
Rotate tokens on 401 response. Store refresh token in keychain,
not environment variable. Implement retry with backoff...
### Session Expiration Handling (auth-architecture)
Three expiration modes: absolute (24h), sliding (30min idle),
refresh (7d with rotation). Hook into 401 interceptor...
各結果にはセクション見出しとソースプロジェクトが付随し、コンテキストの膨張を防ぐため500トークンの予算で制限されます。
リトリーバーは2つ目の統合ポイントも実現します。PostToolUseフックがツール出力を会話に入る前に圧縮します。生のツール出力にはタイムスタンプ、順序のアーティファクト、実行ごとに異なる冗長なフォーマットが含まれます。リトリーバーは生のダンプを安定した焦点を絞ったサブセットに置き換えます。エージェントはノイズを見ることなく、関連する抽出物のみを確認します。副次的な利点として、リトリーバーの出力は同じクエリに対して決定論的であるため(同じインデックス状態は同じランク付き結果を生成する)、圧縮出力はプロンプトキャッシングに寄与します。変更のないデータに対する繰り返しクエリは同一のコンテキストブロックを生成し、CLIの自動プロンプトキャッシングがキャッシュされたプレフィックスを再利用します。
より広範なインフラストラクチャの話では、フック、スキル、エージェントがモデル周辺のプログラマブルレイヤーとしてどのように構成されるかを説明しています。
レイヤーは設計上分離されています。インテークのスコアリングは埋め込みについて何も知りません。リトリーバーはシグナルルーティングルールについて何も知りません。しかし、インテークはVaultに高品質なコンテンツが含まれることを保証し、検索は任意のクエリに対して適切なサブセットを表面化し、統合はそのサブセットをコンテキストの膨張なしにエージェントに届けます。コンテキストを重要リソースとする理論的フレーミングについて私は記事を書きました。リトリーバーはその実践的な実装です。
チャンキング:検索品質はここから始まる
チャンキングは検索結果の粒度を決定します。チャンクが大きすぎると、ベクトル検索はファイル全体を返しますが、関連するのは1つの段落だけです。チャンクが小さすぎると、埋め込みがセマンティックマッチングに必要なコンテキストを失います。RAGパイプラインに関する研究では、ほとんどのユースケースにおいてチャンクサイズがモデル選択よりも検索品質に大きな影響を与えることが確認されており、段落レベルの検索タスクでは200〜500トークンのチャンクが最も良好に機能します。18
チャンカーはH2(##)見出し境界で分割し、Markdownの構造を保持します。8 OAuthトークンローテーションに関するノートが3つのH2セクションを持つ場合、3つのチャンクになり、それぞれが埋め込みが意味を捉えるのに十分な自己完結性を持ちます。インデクサーは見出しテキストと親ノートのタイトルを各チャンクのメタデータとして保存し、チャンクテキスト自体がスパースな場合でもBM25マッチングのコンテキストを提供します。
# chunker.py: H2 splitting with heading context
MIN_CHUNK_CHARS = 30
MAX_CHUNK_CHARS = 2000
def _split_at_headings(body):
sections = []
current_heading = ""
current_lines = []
for line in body.split("\n"):
if line.startswith("## "):
if current_lines:
text = "\n".join(current_lines).strip()
if text:
sections.append((current_heading, text))
current_heading = line[3:].strip()
current_lines = []
else:
current_lines.append(line)
if current_lines:
text = "\n".join(current_lines).strip()
if text:
sections.append((current_heading, text))
return sections
チャンカーは2,000文字を超えるセクションをさらに分割します。まずH3境界で、次に段落区切りで分割します。30文字未満のセクションは破棄します。また、Related、See Also、Links、Referencesセクションもスキップします。これらは通常、検索可能なコンテンツではなくウィキリンクのリストです。
検索品質に影響する2つの設計上の選択があります。1つ目は、インデクサーが見出しコンテキスト文字列("OAuth Token Rotation | note | security, authentication")を別カラムに保存し、チャンクテキスト(1.0)よりも低い重み(0.3)でFTS5にインデックスすることです。チャンク本文に検索語が含まれない場合でもBM25は見出しでマッチしますが、見出しマッチは本文マッチよりも低いスコアになります。2つ目は、チャンカーがフロントマターのタグとノートタイプを抽出して見出しコンテキストに含めることで、「security」の検索が本文テキストに異なる用語を使用していてもsecurityタグ付きのノートにマッチすることです。
埋め込み:30 MBモデル、API呼び出しゼロ
埋め込みモデルはModel2Vecのpotion-base-8Mです。760万パラメータで256次元ベクトルを生成する静的ワード埋め込みモデルです。3 MTEBベンチマークスイートにおいて、potion-base-8Mはall-MiniLM-L6-v2の89%の性能(平均50.03対56.09)を最大500倍の推論速度で達成しており、コンシューマーハードウェアでの大規模コーパスのインデックス作成に実用的です。917 1つ注意点として、このモデルのMTEB Retrievalサブスコアは、Classification(64.44)やSTS(73.24)のスコアと比較して著しく低くなっています(31.71)。MTEBの検索ベンチマークはウェブコーパスでのドキュメントレベルのランキングをテストしており、均質なMarkdownチャンクでの段落レベルのマッチングではありません。チャンクが短く、トピックに焦点を当てており、一貫した語彙で書かれている場合、このギャップの影響は小さくなります。トランスフォーマーベースの埋め込みモデルとは異なり、Model2Vecは入力に対してアテンションレイヤーを実行しません。このモデルはセンテンストランスフォーマーの知識を静的トークン埋め込みに蒸留し、逐次的な計算ではなく加重平均によってベクトルを生成します。9
なぜ静的埋め込みがこのユースケースで機能するのか? 短いMarkdownチャンク(平均200〜400語)は単一トピックについての集中した語彙を含みます。それらのトークンベクトルの加重平均は、オフトピックの希薄化がほとんどないため、埋め込み空間の意味のある領域に着地します。実際には、3つの異なるテーマをカバーする2,000語のドキュメントは、トピッククラスターの中ではなくクラスター間にある曖昧な重心を生成する傾向があります。一方、OAuthトークンローテーションに関するチャンクは、他の認証コンテンツと密にクラスタリングするベクトルを生成します。静的埋め込みは文脈的な曖昧性解消(「bank」が「river bank」と「bank account」で異なる意味を持つこと)をトレードオフとして犠牲にし、その代わりに生の速度を得ています。各チャンクが1つの概念をカバーする個人ナレッジベースでは、曖昧性のペナルティは小さく、論文では最大500倍の推論速度向上が報告されています。9
# embedder.py: lazy-loading Model2Vec in a dedicated venv
DEFAULT_MODEL = "minishlab/potion-base-8M"
EMBEDDING_DIM = 256
class Model2VecEmbedder:
def __init__(self, model_name=DEFAULT_MODEL):
self._model_name = model_name
self._model = None
def _ensure_model(self):
if self._model is not None:
return
_activate_venv() # Add memory venv to sys.path
from model2vec import StaticModel
self._model = StaticModel.from_pretrained(self._model_name)
def embed_batch(self, texts):
self._ensure_model()
vecs = self._model.encode(texts)
return [v.tolist() for v in vecs]
実用上の結果として、16,894ファイルのフルリインデックスはApple M3 Proで4分で完了します。インクリメンタルインデックス(mtime比較で検出された変更ファイルのみ)は、通常の1日分の編集で10秒以内に実行されます。1
モデルはツールチェーンの他の部分との依存関係の衝突を避けるため、~/.claude/venvs/memory/の隔離された仮想環境で実行されます。エンベッダーはインポート時ではなく初回使用時にモデルをレイジーロードするため、リトリーバーがBM25のみモードにフォールバックする際にモジュールのインポートコストはゼロです。
なぜより大きなモデルを使わないのか? 2つの理由があります。1つ目は、256次元ベクトルによりSQLiteデータベースが49,746チャンクで83 MBに収まることです。より高次元のベクトル(768や1,024)は、短いMarkdownチャンクでの品質向上がわずかであるにもかかわらず、データベースサイズを3〜4倍にします。10 2つ目は、APIベースの埋め込み(例えばOpenAIのtext-embedding-3-smallは100万トークンあたり$0.02)がレイテンシ、コスト、オフラインで動作すべきシステムへのネットワーク依存性をもたらすことです。11 Vault全体の再埋め込みはAPI価格で約$0.30と、単体では些細な金額ですが、実際のコストは49,746チャンクにわたるラウンドトリップレイテンシと個人ノートを外部APIに送信するプライバシーへの影響です。
モデルハッシュメカニズムが埋め込みの互換性を追跡します。インデクサーはモデル名と語彙サイズから導出されたハッシュを保存します。モデルが変更された場合、インクリメンタルインデックスが不一致を検出し、自動的にフルリインデックスをトリガーします。
SQLiteスキーマ:3つのテーブル、1つのファイル
インデックス全体がWAL modeの1つのSQLiteファイル(vectors.db、83 MB)に格納されており、同時読み取りの安全性を確保しています。12 3つのテーブルがそれぞれ異なる目的を果たします:
-- Chunk content and metadata
CREATE TABLE chunks (
id INTEGER PRIMARY KEY,
file_path TEXT NOT NULL,
section TEXT NOT NULL,
chunk_text TEXT NOT NULL,
heading_context TEXT DEFAULT '',
mtime_ns INTEGER NOT NULL,
embedded_at REAL NOT NULL
);
-- FTS5 for BM25 search (content-synced to chunks)
CREATE VIRTUAL TABLE chunks_fts USING fts5(
chunk_text, section, heading_context,
content=chunks, content_rowid=id
);
-- sqlite-vec for vector KNN search
CREATE VIRTUAL TABLE chunk_vecs USING vec0(
id INTEGER PRIMARY KEY,
embedding float[256]
);
FTS5テーブルはcontent-syncパターンを使用しています。テキストの重複コピーを保存する代わりにchunksテーブルを直接参照します。5 1つ注意点として、content-syncテーブルは削除を自動的に伝播しません。インデクサーはchunksテーブルから行を削除する前に明示的にINSERT INTO chunks_fts(chunks_fts, rowid) VALUES('delete', ?)コマンドを発行する必要があります。そうしないとFTS5インデックスが暗黙的に不整合になります。BM25クエリのカラム重みは、チャンクテキストに1.0、セクション見出しに0.5、見出しコンテキストに0.3を割り当てます:
# vector_index.py: BM25 search with column weights
bm25(chunks_fts, 1.0, 0.5, 0.3) as score
sqlite-vec拡張は256次元のfloatベクトルをパックされたバイナリデータとして保存し、コサイン距離によるKNNクエリをサポートします。4 Pythonのstruct.packがベクトルをシリアライズします:
def _serialize_vector(vec):
return struct.pack(f"{len(vec)}f", *vec)
スキーマは設計上グレースフルデグラデーションを処理します。sqlite-vecのロードに失敗した場合(拡張がない、プラットフォーム非互換)、リトリーバーはBM25のみの検索にフォールバックします。vec_availableプロパティがベクトル検索が動作可能かどうかを追跡します。
Reciprocal Rank Fusion:これを機能させる数学
RRFはスコアキャリブレーションなしで2つのランク付きリストをマージします。7 なぜ生スコアを直接組み合わせないのか? BM25は負の関連性スコアを返し(SQLiteのFTS5実装ではより負=より関連性が高い)、コサイン距離は0から2の間の値を返します。これらのスケールを比較するにはクエリ分布に敏感な正規化が必要です。RRFはスコアではなくランク位置のみを使用することで、この問題を完全に回避します。この数式は各ドキュメントに、各リストで出現した位置に基づいてスコアを割り当てます:
score(d) = Σ (weight_i / (k + rank_i))
ここでkは定数(実装では60、オリジナルのCormackらの論文に従っています7)、rank_iは結果リストiにおけるドキュメントのランク、weight_iはオプションのリストごとの乗数(両方ともデフォルトは1.0)です。
実際のランクを使った計算例を示します。クエリ:「how does the review aggregator handle disagreements」。5つのチャンクが結合結果に現れます:
| Chunk | BM25 Rank | Vec Rank | BM25 RRF | Vec RRF | Fused Score |
|---|---|---|---|---|---|
| review-aggregator.py “Disagreement Resolution” | 3 | 1 | 1/63 = 0.0159 | 1/61 = 0.0164 | 0.0323 |
| deliberation-config.json “Review Weights” | 1 | 8 | 1/61 = 0.0164 | 1/68 = 0.0147 | 0.0311 |
| code-review MOC “Multi-Agent Review” | 7 | 2 | 1/67 = 0.0149 | 1/62 = 0.0161 | 0.0310 |
| jiro-artisan.sh “Review State Machine” | 2 | 12 | 1/62 = 0.0161 | 1/72 = 0.0139 | 0.0300 |
| quality-loop.md “Evidence Gate” | - | 3 | 0 | 1/63 = 0.0159 | 0.0159 |
1番目のチャンクは両方のリストで上位にランクされているため勝利しています。BM25はテキスト内の「review」「aggregator」「disagreements」にマッチしました。ベクトル検索はコードレビューにおける紛争解決のセマンティック概念にマッチしました。2番目のチャンクはBM25で1位(設定ファイル内の「review」への正確なキーワードマッチ)でしたが、ベクトル検索では8位(設定のJSONはセマンティックにスパース)でした。RRFは適切にスコアを下げました。最後のチャンクはベクトル結果にのみ出現したため、1つのソースからのみRRFスコアを受け取りました。
# retriever.py: RRF fusion core
RRF_K = 60
def _rrf_fuse(self, bm25_results, vec_results,
bm25_weight=1.0, vec_weight=1.0):
scores = {}
for rank, r in enumerate(bm25_results, start=1):
cid = r["id"]
if cid not in scores:
scores[cid] = {"rrf_score": 0.0, ...}
scores[cid]["rrf_score"] += bm25_weight / (self._rrf_k + rank)
scores[cid]["bm25_rank"] = rank
for rank, r in enumerate(vec_results, start=1):
cid = r["id"]
if cid not in scores:
scores[cid] = {"rrf_score": 0.0, ...}
scores[cid]["rrf_score"] += vec_weight / (self._rrf_k + rank)
scores[cid]["vec_rank"] = rank
return [SearchResult(chunk_id=cid, **data)
for cid, data in scores.items()]
デフォルトの候補プールは融合前に各ソースから30件の結果で、最大60件の候補が生成されます。リトリーバーは融合後のtop 10結果を返します。オプションのmax_tokensパラメータがトークン予算に合わせて結果を切り詰め、1トークンあたり4文字で推定します。
インデックス作成:フルとインクリメンタル
インデクサーは2つのモードをサポートします。フルリインデックスはデータベースをクリアしてゼロから再構築します。インクリメンタルインデックスはファイルの変更時刻(mtime_ns)を保存されたタイムスタンプと比較し、変更されたファイルのみを再処理します。1
# index_vault.py: incremental detection
stale = index.get_stale_files(vault_mtimes) # mtime changed or new
deleted = index.get_deleted_files(vault_paths) # no longer in vault
埋め込みはModel2Vecのオーバーヘッドを分散するため64テキストのバッチで実行されます。8 フルリインデックス中は500ファイルごとにプログレスカウンターが表示されます。SIGINTハンドラーがグレースフルシャットダウンを有効にし、停止前に現在のファイルの処理を完了します。
設定ファイルはフォルダインデックスを制御するためにアローリストモデルを使用しています。Vaultには22の許可フォルダと5つの永続的に除外されたフォルダ(個人の健康ノート、キャリアドキュメント、Obsidian内部ディレクトリ)があります。20 インデクサーは許可フォルダ内のファイルのみを処理し、それ以外はすべてスキップします。
1つの重要な設計上の選択として、インデクサーは保存前にすべてのチャンクに対してクレデンシャルフィルターを実行します。個人ノートにはデバッグセッション中にペーストされたAPIキー、ベアラートークン、データベース接続文字列、秘密鍵が含まれています。クレデンシャルフィルターは21のベンダー固有パターン(OpenAIキー、GitHub PAT、AWSアクセスキー、Stripeトークン、その他17種類)と11の汎用検出器(データベースURL、JWT、ベアラートークン、パスワード割り当て、高エントロピーbase64文字列)をマッチングします。20 フィルターはマッチしたコンテンツを[REDACTED:pattern-name]トークンに置換し、どのパターンが発火したかをログに記録しますが、シークレット自体はログに記録しません。
# chunker.py: credential filtering before storage
cleaned_text, scan_result = clean_content(sub_text)
if not scan_result.is_clean:
logger.info("Scrubbed %d credential(s) from %s [%s]",
scan_result.match_count, file_path, sub_heading)
クレデンシャルフィルターなしで個人ノートをインデックスすると、シークレットの検索可能なデータベースが作成されてしまいます。フィルターは埋め込みの前に実行されるため、ベクトル表現はクレデンシャルパターンをエンコードしません。「API key」のクエリは、実際のキーを含むノートではなく、APIキー管理について議論しているノートを返します。
うまくいかないこと:正直な障害モード
本番インデックスに対する何百ものクエリの後、4つの障害パターンが明確になりました。
キーワード密度の高い浅いコンテンツが深いコンテンツより上位にランクされる。 security, authentication, oauthとタグ付けされた3文の要約を持つ短いノートは、OAuth実装についての2,000語の深掘り記事よりもBM25で高スコアを獲得します。深掘り記事は導入部で一度だけ用語を使用し、その後は具体的なプロトコルの詳細に移行するためです。BM25はドキュメント長に対する用語頻度を報酬とします。RobertsonとZaragozaがアルゴリズムの「用語頻度飽和」コンポーネントとして文書化した特性です。514 浅いノートの方がキーワード密度が高いのです。RRFはベクトル検索が深いコンテンツをより高くランク付けするため(埋め込みがセマンティックな深さを捉える)、この問題を部分的に修正しますが、浅いノートは本来表示されるべきでない場合でも融合結果に残ります。
構造化データのインデックス作成が不十分。 JSON設定ファイル、YAMLフロントマターブロック、変数名を含むコードスニペットは低品質のBM25マッチを生成します。「review configuration」の検索はreviewキーを持つすべてのJSONファイルにマッチします。ベクトル検索はキーバリューの関係を埋め込みが捉えるため、構造化データをやや良好に処理しますが、構造化コンテンツは散文よりも本質的にチャンク分割が困難です。埋め込み前にJSONをキーパス:値のペアにフラット化すれば、設定が多いノートの検索品質が向上するでしょう。
チャンク境界がコンテキストを分断する。 チャンカーは2つのH2セクションの境界にまたがる段落を2つのチャンクに分割します。各チャンクには説明の半分しか含まれません。どちらのチャンクも完全なコンテキストを欠いているため、うまく埋め込めません。チャンカーは見出しコンテキスト(親見出しをメタデータに引き継ぐ)でこの問題を緩和していますが、本文テキストは境界で連続性を失います。オーバーラッピングウィンドウは改善しますが、チャンク数とデータベースサイズが増加します。
時間的な関連性が見えない。 リトリーバーには最新性の概念がありません。14ヶ月前の初期アーキテクチャ決定に関するノートは、昨日の現在の実装に関するノートと同等にランク付けされます。進化するナレッジベースでは、新しいノートが古いノートを置き換えることがよくあります。リトリーバーはそれを認識しません。
次に来るもの:拡張ロードマップ
5つの追加機能が障害モードに対処し、システムの能力を拡張します。
Learning-to-Rankリランキングレイヤー。 RRF融合の後、軽量なリランカーがメタデータシグナルに基づいてスコアを調整できます。ノートの最新性、クエリドメインへのタグの関連性、リンク密度(多くリンクされているノートはより権威的な場合が多い)などです。リランカーはフルコーパスではなく融合後のtop-30結果に対して実行され、レイテンシを23msのベースライン以下に保ちます。
クエリ意図の分類。 異なるクエリには異なる検索戦略が必要です。正確な識別子の検索(_rrf_fuse)ではBM25に重みを置くべきです。概念的な質問(「how does review handle disagreements」)ではベクトル検索に重みを置くべきです。クエリごとにbm25_weightとvec_weightを調整する軽量な分類器が、融合アーキテクチャを変更せずに精度を向上させます。
時間的減衰。 現在の状態に関するクエリでは、最近のノートをわずかに高く重み付けします。融合後に適用される減衰関数が、最終更新がNヶ月以上前のファイルからのチャンクのスコアを低減します。mtime_nsタイムスタンプはスキーマに既に存在しています。リトリーバーの重み付け関数だけで十分です。
ゴールデンクエリによる評価ハーネス。 このシステムには現在、自動品質測定がありません。50〜100件の精選されたクエリ-回答ペアのセットがあれば、検索品質のリグレッションテストが可能になります。チャンキング、埋め込み、融合パラメータの変更後にテストスイートを実行し、recall@10が劣化しないことを検証できます。BEIRベンチマークは、検索システムが異なるクエリ分布間でnDCG@10が20ポイント以上変動しうることを実証しており、ドメイン固有の評価が不可欠であることを示しています。19 ゴールデンセットがなければ、改善は逸話的なものにとどまります。
ノート間関係インデックス。 Obsidianのウィキリンク([[note-name]])はノート間の明示的な関係をエンコードしています。現在のシステムはリンク構造を完全に無視しています。リンク先をメタデータとしてインデックスすれば、リトリーバーが多くの高スコアノートからリンクされているノートのチャンクをブーストできるようになります。Vault版のPageRankのようなものです。
私がフルVaultで実施した埋め込み空間トポロジー分析は、これらの改善がどこで最も効果を発揮するかを明らかにしています。密なクラスター(AIツーリング、セキュリティ)は用語が一貫しているため既にうまく検索できています。クラスター間のスパースなブリッジ領域が、リトリーバーが最も苦戦する場所であり、関係インデックスと意図分類が最大の改善をもたらす領域です。
FAQ
なぜ専用のベクトルデータベースではなくSQLiteなのか?
検索スタック全体が、外部依存ゼロの1つのファイルで動作します。SQLiteのWALモードが複数のClaude Codeセッションからの同時読み取りを処理します。sqlite-vec拡張が、別途Pinecone、Weaviate、Qdrantインスタンスを必要とせずにベクトルKNN検索を追加します。4 49,746チャンクでクエリレイテンシは23msです。1 専用のベクトルデータベースは、83 MBに収まるシングルユーザーのナレッジベースに対して運用の複雑さ(ホスティング、バックアップ、認証)を追加することになります。
なぜOpenAIの埋め込みやより大きなモデルではなくModel2Vecなのか?
3つの理由があります。レイテンシ、プライバシー、コストです。Model2Vecはネットワーク呼び出しなしでCPU速度でローカルに実行されます。3 個人ノートがマシンから外に出ることはありません。APIベースの埋め込みは現在のVaultサイズでフルリインデックスあたり約$0.30のコストがかかります。11 単体では些細な金額ですが、49,746チャンクにわたるラウンドトリップレイテンシと個人コンテンツのプライバシー露出が実際のコストです。
Reciprocal Rank Fusionとは何か、いつ使うべきか?
RRFはトレーニングデータ、スコアキャリブレーション、定数k以外のハイパーパラメータチューニングを必要としません。7 学習された融合モデルはトレーニングのためにラベル付きの関連性判定が必要ですが、個人ナレッジベースにはそれが存在しません。RRFは有用な結果を生み出すための障壁が最も低い融合手法です。互換性のないスコアタイプを生成する検索手法のランク付きリストを組み合わせる際にRRFを使用してください。
ローカルリトリーバーはどのようにClaude Codeに接続するのか?
PreToolUseフックがリトリーバーのsearch()メソッドを現在のプロンプトで呼び出し、上位結果をファイルパスとセクション見出し付きのコンテキストブロックとしてフォーマットし、会話に注入します。エージェントは生のファイルではなく焦点を絞ったチャンクを確認できます。max_tokensパラメータが注入されるコンテキストを予算内に収めます。
検索システムでシークレットがインデックスされるのをどう防ぐのか?
保存前にすべてのチャンクに対してクレデンシャルフィルターを実行します。このシステムのフィルターは、JWT、ベアラートークン、秘密鍵に対する21のベンダー固有パターンと11の汎用検出器をマッチングします。20 マッチしたコンテンツを[REDACTED:pattern-name]トークンに置換し、埋め込みの前に実行されるため、ベクトル表現はクレデンシャルパターンをエンコードしません。
参考文献
-
Author’s production data. 49,746 chunks, 16,894 files, 83.56 MB SQLite database, 7,771 signals processed across 14 months. Query latency (23ms) measured via
time.perf_counter()in retriever.py, wrapping the full search path: BM25 lookup, query embedding via Model2Vec, vector KNN search, and RRF fusion.grep -rlmeasured at 11-66 seconds depending on term frequency (Apple M3 Pro, APFS). Full reindex measured at ~4 minutes on Apple M3 Pro. Incremental measured at <10 seconds for typical daily changes. FTS5-only search became unusable for the author above ~3,000 files due to keyword collision rates. ↩↩↩↩↩↩ -
HN thread: “Stop Burning Your Context Window”. Comments from danw1979 and tclancy requesting a detailed write-up. ↩
-
Model2Vec: Distill a Small Fast Model from any Sentence Transformer. Minish Lab, 2024. The potion-base-8M model uses static word embeddings distilled from a sentence transformer, producing 256-dimensional vectors without running attention layers. ↩↩↩
-
sqlite-vec: A vector search SQLite extension. Alex Garcia, 2024. Provides
vec0virtual tables for KNN vector search within SQLite, using the same query interface as standard tables. ↩↩↩ -
SQLite FTS5 Extension. SQLite documentation. FTS5 provides full-text search with BM25 ranking, content-sync tables, and configurable column weights via the
bm25()auxiliary function. ↩↩↩ -
Reimers, N. and Gurevych, I. Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. EMNLP, 2019. Foundational work on dense semantic similarity for text retrieval, establishing the vector search approach used in hybrid retrieval systems. ↩
-
Cormack, G.V., Clarke, C.L.A., and Buettcher, S. Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. SIGIR, 2009. Introduces RRF with k=60 as a parameter-free method for combining ranked lists that outperforms trained fusion models. ↩↩↩↩
-
Author’s implementation.
chunker.pysplits at H2 boundaries in the_split_at_headingsfunction, with fallback to H3 then paragraph splitting for sections exceeding 2,000 characters. MIN_CHUNK_CHARS=30, MAX_CHUNK_CHARS=2000.index_vault.pyembeds in batches of 64 (BATCH_SIZE=64). ↩↩ -
van Dongen, T. et al. Model2Vec: Turn any Sentence Transformer into a Small Fast Model. arXiv, 2025. Describes the distillation approach producing static embeddings from sentence transformers with 50-500x inference speedup. ↩↩↩
-
Author’s measurement. 256-dim vectors at 49,746 chunks produce 83 MB SQLite. Extrapolating to 768-dim vectors: ~215 MB. To 1024-dim: ~280 MB. Marginal quality improvement on short markdown chunks (avg 200-400 words) does not justify the storage and latency increase. ↩
-
OpenAI Embeddings Pricing. text-embedding-3-small: $0.02 per million tokens. Estimated vault cost per full reindex: ~$0.30 based on average chunk length of ~200 tokens. ↩↩
-
SQLite Write-Ahead Logging. SQLite documentation. WAL mode allows concurrent readers with a single writer, suitable for the retriever’s read-heavy access pattern. ↩
-
Author’s query trace. Ran “PostToolUse hook for context compression” against BM25-only, vector-only, and hybrid modes. Results captured from retriever.py with
methodfield tracking which search path produced each result. ↩ -
Robertson, S. and Zaragoza, H. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval, 2009. Survey of the BM25 family of ranking functions and their theoretical foundations. ↩↩
-
Karpukhin, V. et al. Dense Passage Retrieval for Open-Domain Question Answering. EMNLP, 2020. Demonstrated that learned dense representations outperform BM25 by 9-19% on open-domain QA benchmarks, establishing dense retrieval as a complement to lexical search. ↩
-
Luan, Y. et al. Sparse, Dense, and Attentional Representations for Text Retrieval. TACL, 2021. Analysis of hybrid sparse-dense retrieval on MS MARCO, showing consistent improvements over single-modality approaches. ↩
-
MTEB: Massive Text Embedding Benchmark. Muennighoff, N. et al., 2023. potion-base-8M scores 50.03 average MTEB vs 56.09 for all-MiniLM-L6-v2 (89.2% retention). Per-task breakdown: Classification 64.44, Clustering 32.93, Retrieval 31.71, STS 73.24. Source: Model2Vec results. ↩
-
Gao, Y. et al. Retrieval-Augmented Generation for Large Language Models: A Survey. arXiv, 2024. Survey of RAG architectures including analysis of chunking strategies and their impact on retrieval quality. ↩
-
Thakur, N. et al. BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models. NeurIPS, 2021. Demonstrates high variance in retrieval performance across domains, underscoring the need for domain-specific evaluation. ↩
-
Author’s configuration and credential filter implementation.
memory-config.jsondefines 22allowed_foldersand 5excluded_alwaysentries.credential_filter.pydefines 21 vendor-specificCREDENTIAL_PATTERNS(OpenAI through Turnstile) plus 9 generic single-line patterns (DB URLs, bearer tokens, JWTs, passwords, secrets, API keys, auth tokens, base64 secrets) and 2 multiline patterns (RSA/SSH private keys, PGP keys). Total: 32 patterns. ↩↩↩