Yyatmita

ハイブリッド検索で「レシピだけ」を引く——構造フィルタの pre/post と遅延スキーマ進化

全文検索とベクトル検索を RRF でマージするハイブリッド検索は、『この種類だけ』に絞るのが驚くほど難しい。Obsidian 全 vault からレシピだけを引きたい——その素朴な要望から、2 層フィルタ+遅延スキーマ進化という設計に落ち着くまでの記録。

自分のエージェント基盤を組む#agent-stack#rag#hybrid-search#qdrant#sqlite#obsidian

全文検索(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)
frontmatterJSON を 丸ごと保存済み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_pathtags で評価。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 標準化は次段(未実装)。