Semantic search over a Markdown knowledge base, served over MCP.
OR phrase にコンパイルするkb-mcp のハイブリッド検索は 2 つの検索器を Reciprocal Rank Fusion で融合する — 埋め込みに対する sqlite-vec の KNN と、trigram tokenizer の上に載る SQLite FTS5 である。 v0.16.0 より前、FTS 側はユーザのクエリを単一の quoted phrase で包んだものを受け取って いた。trigram tokenizer の下では quoted phrase は連続部分文字列の照合なので、この構成は クエリ全体を逐語で探していたことになる。
キーワードならこれで一応動く。しかし文では何にも当たらない — 質問文がそのままの形で 書かれている文書は存在しないからである。dogfood のナレッジベース (650 文書 / 9,419 chunk) で実測したところ、自然文の golden query 10 件は全件が FTS 候補 0 件、main golden 26 件 のうち融合すべき入力が 2 つ揃っていたのは 16 件だけだった。残りではハイブリッドは ハイブリッドではない — RRF の入力は片方だけで、システムは「ハイブリッド」を名乗りながら ベクトル単独検索として動いていた。
この欠陥は外からは見えない。エラーにならず、候補数はユーザに出ず、結果は返る — 片方の 検索器から出た、質の落ちた結果が。15 リリース生き延びたうえ、テストは構造的にこれを 検出できなかった。すべての融合テストが「FTS で当たる chunk」をクエリ埋め込みと同じ位置に 置いていたため、ベクトル半身だけで assert が満たされていたからである。
本決定が答えるのは、「部分文字列でしか照合できない tokenizer」と「単語が空白で区切られない
日本語中心のコーパス」という前提の下で、クエリをどう FTS5 の MATCH 式に変換するか、である。
採用: 3 — 字種境界で分割。分割の利得の大半を、そのコストを払わずに得られること、 そしてクエリ文字列の純粋関数であり網羅的にテストできることが理由である。
再ランキングの評価について は
"再ランキング" OR "ランキング" OR "の評価" OR "について" にコンパイルされる。
日本語では字種の遷移が語境界の粗いが実効的な代理になる — 複合語は漢字の連なり、
外来語はカタカナの連なり、文法的な助詞はひらがな、という対応があるためである。
dogfood コーパスで、同一の scratch copy を使って前後を実測した (bge-m3、reranker なし):
| before | after | |
|---|---|---|
| 融合の入力が 2 つ揃う golden query | 16/26 | 26/26 |
| MRR (main / binary golden) | 0.955 / 0.939 | 0.962 / 0.955 |
| recall@10 | 0.954 | 0.965 |
| recall@5 | 0.926 | 0.906 |
他を採らなかった理由:
AI と ML のように全断片が trigram の下限未満のとき)、
trim 後のクエリ全体を逐語で探す。これにより v0.15.x より悪くなるクエリ類型は無いVec<String> を返す関数 1 つなので、呼び出し側に触れずに形態素
分割器へ差し替えられる。候補として非公開に記録しているAND で結ぶ案は、選択肢 1 と同じ証拠で却下した。文の token は 1 つの
chunk に共起しないので、連言は空の結果集合を再現するだけである。OR で候補を広げ、
順位付けは bm25 と RRF に委ねる — 融合アーキテクチャとはそういうものである" * : ^ ( )
および NEAR / AND / OR / NOT を構文として扱う。したがって任意のユーザクエリは
syntax error (= FTS 半身だけでなく検索全体が失敗する) になるか、意図しない演算子に
なるかのどちらかである。kb-mcp が出す phrase をすべて quote + escape しているのは
この理由による2 つが、ユーザおよび保存データとの契約になった:
"..." に意味が生じた。 quote された領域は「探す対象のテキスト」へ
escape されるのではなく、逐語 phrase として保持される。これが v0.16.0 以前の挙動を
明示的に要求する方法であり、複数語からなる英語の固有名を 1 つに保つ方法でもある。
したがって到達可能な文書集合は旧実装の上位集合ではない — "a""b" は以前は本文中の
逐語 "a""b" を探していたが、今は a"b を探すConfigFingerprint が fts_query_version を持つ。 本変更より前の評価履歴は
比較不能である (同じ golden query を同じ index に投げても候補が変わる) ため、
kb-mcp eval --fail-on-regression は境界をまたぐ比較を拒否する — 実際には手法が
変わっただけのものを「退行」や「改善」として報告しないためである。当該 field を持たない
fingerprint は version 1 として読まれるAI について の AI は片側が空白・逆側が端で、3 文字の trigram 下限に届かない。
全文検索側は について だけを探す。単独で quote しても救えない (3 文字未満の quoted
phrase は同じ下限で落ちる) が、より広い領域を quote すれば救える — その領域を逐語で
探すことと引き換えになるLIMIT はこれを縛れない — ORDER BY bm25(...) が先に全マッチをスコアリング
するためである。効く lever は phrase 上限 32 だけであり、上記の検索品質評価をこの値で
取っているため据え置いているdb/fts_query.rs に 4 段ぶんの unit test 38 本と property test 1 本を置いている。
加えて別に 50 入力のテストが、生成した式を実際の MATCH に流して FTS5 が受理することを
確かめる — 文字列比較では「受理されるか」は決して確かめられず、受理されない式は
全文検索側だけでなく検索全体を失敗させるためfts_or_expansion_is_one_statement_over_the_union_of_its_phrases が「展開は和集合であり、
1 文で実行される」を固定する。数えているのは SQLite から trace した実行文であって、
Rust 側ラッパへの呼び出し回数ではないbu03_or_expansion_stays_within_a_small_multiple_of_a_single_phrase が絶対時間ではなく
コストの倍率を縛るfts_decides_the_top_rank_when_the_vector_leg_prefers_another_chunk は、全文検索側が
融合後の順位に寄与しなくなると落ちる — 本変更以前はどのテストも持っていなかった性質であるdocs/retrieval-pipeline.ja.md — 結果として得られた機構とそのコストモデルCHANGELOG.md の v0.16.0 → Changed