ハイブリッド検索で「レシピだけ」を引く——構造フィルタの pre/post と遅延スキーマ進化
全文検索とベクトル検索を RRF でマージするハイブリッド検索は、『この種類だけ』に絞るのが驚くほど難しい。Obsidian 全 vault からレシピだけを引きたい——その素朴な要望から、2 層フィルタ+遅延スキーマ進化という設計に落ち着くまでの記録。
全文検索(FTS)とベクトル検索を RRF でマージする「ハイブリッド検索」は強力だが、「この種類の文書だけに絞る」 が驚くほど難しい。本稿は、個人ナレッジベース(Obsidian 全 vault)から レシピだけを対象に検索したい という素朴な要望が、ハイブリッド検索の構造的な弱点を露わにし、「2 層フィルタ+遅延スキーマ進化」という設計に落ち着くまでの記録である。実装済みの土台(ハイブリッド検索・frontmatter の保存)の上に、post-filter を最小実装して 1 例で実証 した(§5)。pre-filter への昇格・CLI 標準化は方針確定・次段にある。
これは「サーチャを司書のように振る舞わせる」取り組みの 片翼(絞り込み) の話だ。もう片翼(目次を骨格にした構造化チャンキング)は別稿に譲る。
1. 問題 — 「レシピだけ引きたい」、しかし現実は散らかっている
ナレッジベースには 6,500 件のノートがある。「玉ねぎを使う作り置き」を引きたいのに、ハイブリッド検索は トレード記録も市況メモもレシピも区別なく 投げ返してくる。まず 棚を絞りたい —— 図書館で「料理の棚はどこ?」と聞くのと同じだ。
ところが実データを覗くと、素朴な前提が崩れる。
- レシピ的なノートは 202 件あるが、vault をまたいで散在 している(Web クリップ、プロジェクトメモ、技術ノートの中…)。
- きれいな
type: recipeのような 共通タグは無い。frontmatter のtypeフィールドは別用途(トレードログ等)の値が支配的で、レシピ用の統一キーは存在しない。
つまり 「何のキーで絞るか」自体が事前に決まっていない。 「レシピ」という棚は、フォルダにもタグにも素直には対応していない。フィルタの設計は、この散らかった現実から始めるしかない。
2. なぜハイブリッドだと絞り込みが難しいのか — フィルタ能力の非対称
ハイブリッド検索は 2 つのエンジンの結果を RRF でマージする。問題は、両者のフィルタ能力が根本的に非対称 なことだ。
| キーワード側(SQLite FTS) | ベクトル側(Qdrant) | |
|---|---|---|
| frontmatter | JSON を 丸ごと保存済み | payload に載せたキーしか filter 不可 |
| 任意キー絞り込み | json_extract(frontmatter,'$.'||key) で 実行時に任意キー対応(schema-on-read) | payload に 事前展開 が必要(schema-on-write) |
パス階層(dir/ 以下) | path LIKE 'dir/%' で 前方一致 | 完全一致/集合一致のみで前方一致が苦手 |
- SQLite(JSON1)は schema-on-read: JSON を丸ごと持てば、後からどのキーでも引ける。事前設計が要らない。散らかった frontmatter にはこれが効く。
- Qdrant は schema-on-write: 速い filtered ANN(絞り込み付き近傍検索)には payload index が要る。これは怠慢ではなく、filtered HNSW を速く保つための物理的必然。「任意のキーを事前コストゼロで高速フィルタ」は原理的に無理。
要するに、柔軟さ(schema-on-read)と速さ(schema-on-write)はトレードオフ で、ハイブリッドはその両極を 1 クエリで跨ぐことになる。
3. ベクトル DB を乗り換えれば消える? — 検討と却下
非対称を消すなら、pgvector / LanceDB / Weaviate / Milvus / Elasticsearch のような「フィルタが 1 言語で書け、jsonb / dynamic mapping で任意キーも引ける」DB に畳む手はある。が、結論は 現構成(Qdrant + SQLite)を畳まない。
- どの DB でも、高速フィルタには結局 index が要る。schema-on-read はタダではない(フィルタ時に全件 JSON 評価になる)。乗り換えても本質は変わらない。
- LanceDB 等の GPU 活用は index 構築フェーズのみ で、個人 KB 規模では恩恵が薄い。GPU の本丸は embedding 生成 で、それは別建ての embedding サーバ(常駐)が既に担っている。ベクトル DB を GPU で選ぶ理由は弱い。
「非対称が嫌だから DB を変える」は、問題を移動させるだけだった。
4. 採用方針 — 2 層フィルタ + 遅延昇格
全 frontmatter キー
├─ ホット(よく絞る: kind, tags 等)→ Qdrant payload + index に昇格 = pre-filter(速い・正確)
└─ それ以外(任意キー) → SQLite frontmatter JSON のまま = post-filter(柔軟)
- 強いキーは pre-filter: 頻繁に絞るキーはエンジンに焼き込み(payload index)、混入を防ぐ。速くて正確。
- 任意キーは post-filter: RRF マージ後、結果の
source_path群で SQLite の frontmatter を一括取得し Python で評価する。任意キー・型・ネストに柔軟。弱点(フィルタで top-k が目減りする)は over-fetch(内部 limit を ×3〜5 で取る) で緩和する。 - パス階層は前方一致: 「この vault / このフォルダ以下」は
path LIKE 'dir/%'で素直に効く。散在するレシピには、パス+内容+ある frontmatter の 合わせ技 になる。 - 遅延昇格(lazy promotion): どのキーを pre-filter に昇格するか 事前に決めない。検索ログでホットなキーを観測してから Qdrant に昇格する(payload に載せる+payload index を張る、の 2 手。
set_payloadで済み re-embed は不要)。
散らかった現実(§1)に対しては、まず post-filter(schema-on-read)で「ありものの frontmatter + パス + 内容」で引き、運用しながら「レシピを絞るのに効くキー」が定まってきたら、それだけ pre-filter に固める。
5. 実証 — post-filter を最小実装して動かす
設計だけでは「絵に描いた餅」なので、§4 の post-filter を最小実装して 1 例だけ動かした(over-fetch ×5 → RRF マージ済み結果を source_path/tags で評価。embedding は不要、数十行)。
題材の妙は 「コスト計算」 というクエリだ。この語はドメインで意味が割れる —— トレードの損益計算、API の課金、そしてレシピの 原価計算。ハイブリッド検索に素で投げると、レシピは出てこない。
クエリ: "コスト 計算" (Obsidian 全 vault, over-fetch 30 → uniq 21 件、うちレシピ 2 件)
post-filter 述語: source_path に "/レシピ/" を含む or tags に "レシピ"
── BEFORE(無フィルタ top6)── レシピ 0 件
・ it_tech_vault/…/Monte Carlo Simulations カルマーレシオについて > 計算方法
・ it_tech_vault/…/Claude Code in Action 実用的な Hooks の高度活用
・ it_tech_vault/trading/…株価予測 学習可能な位置埋め込みの仕組み
・ it_tech_vault/AI/Clawdbot モデル設定マニュアル
・ it_tech_vault/!プロジェクト サイト開設後チェックリスト
・ it_tech_vault/AI/Clawdbot 請求同期(OpenAI Costs API)
── AFTER(post-filter=レシピだけ)──
🍳 main_vault/レシピ/飲み物 必要な情報 > 計算方法 > 結論
🍳 main_vault/レシピ/マイレシピまとめ 必要な情報 > 計算方法 > 結論
素のハイブリッド検索は トレード・AI・課金 API を返してレシピを 1 件も出さない。post-filter を噛ませると、同じクエリから レシピの原価計算 だけが残る。これが「棚を絞る」の最小の証拠だ。
この例の絞り込みキーは パス前方一致(/レシピ/)+タグ(レシピ) で、§1 の「散らかった現実」に対し ありもの(パス・タグ)で引けた —— schema-on-read で始める、の実演でもある。なお本実装は post-filter まで。ホットキーを Qdrant payload index に昇格する pre-filter(--filter/--under の CLI 化・遅延昇格)は次段に置く。
状態: post-filter の最小 PoC(
scripts/filter_demo.py)は動作。pre-filter 昇格・CLI 標準化は未実装。
6. 教訓 — フィルタは pre/post の二択ではなく連続体
全文・ベクトル・構造化フィルタを RRF で繋ぐ構成では、「全部エンジンに焼く(schema-on-write)」か「全部後で評価する(schema-on-read)」かの二択ではない。
使用実績で pre/post の境界を動かす。 schema-on-read で始め、ホットなキーだけ schema-on-write に固める —— いわば 「遅延スキーマ進化」。
事前に完璧なスキーマを設計しようとすると、§1 のような「散らかった現実」で必ず外す。柔軟に始めて、効くものだけ後から固める ほうが、個人 KB のような雑多なコーパスには合っている。
7. これは「司書サーチ」の片翼
ハイブリッド索引は 構造に盲目 だ。司書のように「料理の棚から、この作り置きの工程」を返させるには、生の索引に 文書構造を注入 する必要がある。その注入には 2 つの面がある。
- 絞り込み(本稿): frontmatter スキーマで「どの棚か」を絞る。pre/post の境界=schema-on-write/read をどこに引くか。
- 取り出し(続編): 目次を骨格にしたチャンキングで「その節そのもの」を所在つきで返す。
どちらも本質は同じ —— 構造盲目なハイブリッド索引に、文書のもつ構造を入れ、何を焼き込み(write)何を都度評価する(read)かを選ぶ。本稿はその片翼、絞り込みの設計記である。
状態: ハイブリッド検索・frontmatter の保存(schema-on-read の土台)と post-filter の最小 PoC(
scripts/filter_demo.py・§5 で実証) は実装済み。pre-filter への遅延昇格 /--filter・--underの CLI 標準化は次段(未実装)。