Semantic search over a Markdown knowledge base, served over MCP.
English: retrieval-pipeline.md
groove がクエリ実行時に走らせる完全なパイプラインを解説する。v0.7.0+ で追加された MMR 多様性再ランクと parent retriever 展開のチューニング指針も含む。
query
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 1. Hybrid 候補生成 │
│ vec_chunks MATCH (top-N) + fts_chunks MATCH + bm25 │
│ └─→ Reciprocal Rank Fusion (k=60, configurable) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. (任意) Cross-encoder reranker │
│ Transformer で候補プールを再スコア │
│ (BGE-reranker-v2-m3 / jina-v2-ml / bge-base) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. (任意, v0.7.0+) MMR 多様性再ランク │
│ 貪欲: max λ·rel(c) − (1−λ)·max_sim(c, picked) │
│ − same_doc_penalty · 1[doc(c) ∈ picked] │
│ 拡大した候補プールから `limit` 個を選択 │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. (任意, v0.7.0+) Parent retriever 展開 │
│ 各ヒットチャンクについて: │
│ tokens < whole_doc_threshold_tokens → ドキュメント全体 │
│ (max_expanded で │
│ cap) │
│ else → 隣接 sibling │
│ マージ (level 整合)│
│ score / rank / path / match_spans は不変 │
│ `expanded_from` に展開元の range を載せる │
└─────────────────────────────────────────────────────────────────┘
│
▼
match_spans → top-`limit` SearchHit を
{results, low_confidence, filter_applied} ラッパに格納
各任意段は対応する設定が off なら no-op となるため、v0.6.x の設定では v0.6.x と bit-identical な出力を返す。
vec_chunks (sqlite-vec、L2 距離 — sqlite-vec 既定のメトリック) と fts_chunks (v0.12.0 以降は heading / context / content の 3 列に対する FTS5 trigram を bm25 でスコアリング、既定では見出しに 2 倍重み) からそれぞれ top-N を取り、Rust 側で Reciprocal Rank Fusion (既定 k = 60、RRF の標準定数) でマージする。クライアントに返す score は RRF スコア (大きいほど良い) で距離ではない。
クエリが FTS5 に届くまで (v0.16.0+): クエリ文字列はそのまま投げられるわけではない。parse_query がクエリを quoted phrase の集合にコンパイルして ` OR ` で結合し、そこから組み立てた MATCH 式が ParsedQuery::match_expr である:
"..." で囲んだ区間は 逐語 phrase として温存される。規約は FTS5 自身の doubled-quote 規約と同じ (phrase 内の "" は literal な " 1 文字)。内容が 3 文字未満の quoted phrase は落とす再ランキングの評価について は 再 / ランキング / の / 評価 / について の run になる再ランキング は ランキング も、システム化 は システム も出すAI について は "について" だけを、ML pipelines は "pipelines" だけを検索する。短い語だけを quote しても救えない — 3 文字未満の quoted phrase は同じ下限で落ちるため。"AI について" のように下限を超える広さで区間ごと quote すれば残せるが、その区間は逐語検索になる。下限そのものは避けられない — trigram tokenizer では 3 文字未満の phrase は何にもマッチしない OR で結合するrust -async は rust を検索し async を除外する。quote されない -word は正の phrase と同じ字種境界・trigram 下限の規則でトークン化されるため、-再ランキング は ランキング も除外する — 複合語だけを除外したいなら quote する (-"再ランキング")。-ab は何も除外しない、除外 phrase にも同じ trigram 下限が効くため。除外はコンパイル済みの正 phrase と (positives) NOT (negatives) の形で結合され、判定は正側のマッチが見るのと同じ FTS5 row — heading / contextual prefix / content の 3 列すべて (本文だけではない) — に対して行われる。ベクトル側は「負の式単独で FTS5 がマッチする chunk id 集合」に入る候補を落とすので、両脚は除外対象について一致する。"-foo" は先頭ハイフンを逐語検索する。除外だけの query は実行されず拒否される。embedder・reranker・match_spans はいずれも除外を切った後のクエリを見る。parent retriever による後段の content 展開は再判定されないため、除外語が展開後のテキストに再登場し得る。両脚が一致しなければならない理由は ADR-0011 を参照つまり 再ランキングの評価について は "再ランキング" OR "ランキング" OR "の評価" OR "について" に、"Foundry Local" の設定 は "Foundry Local" OR "の設定" になる。v0.16.0 より前はクエリ全体を 1 個の phrase にしていたが、trigram tokenizer の上ではこれは逐語の部分文字列検索であり、日本語の自然文クエリでは FTS 候補が 0 件だった — hybrid の FTS 半身が実質死んでおり、ベクトル側だけが動いていた。形態素解析ではなく字種境界を選んだ理由と、この変更が検索品質に与えた影響は ADR-0002 にある。
トークン化で phrase が 1 つも作れなかった場合 — AI と ML のように全断片が下限未満のケース — は、trim 後のクエリ全体が旧来の 1 phrase 形式に fallback するので、この形のクエリが後退することはない。FTS を完全に飛ばしてベクトル単独になるのは、trim 後に 3 文字未満のクエリだけである。クエリ全体を quote すれば旧来の逐語検索をそのまま再現できる。これは query 側だけの変更で、index も schema も tokenizer も変えていない = 再 index は不要。
FTS 半身のコスト。 ORDER BY bm25(...) は LIMIT を適用する前にマッチした全行をスコアリングするため、全文検索側のコストは「要求した件数」ではなく「式がマッチする行数」に比例する。実測 (release ビルド、全 phrase が全行に当たる合成 corpus = 最悪形): 単一 phrase のクエリは 5,000 行で 4.3 ms、20,000 行で 16.0 ms、40,000 行で 32.8 ms。32 phrase の OR は同条件で 46.9 ms / 171 ms / 329 ms。どちらも照合行数に線形で、両者の倍率 (約 10 倍) は corpus サイズによらず一定。コストは phrase 数にもほぼ線形で、20,000 行のとき 1 / 2 / 4 / 8 / 16 / 32 phrase がそれぞれ 17.6 / 22.9 / 34.4 / 44.0 / 81.5 / 172 ms。
つまみを触る前に知っておくべき帰結が 3 つ。第 1 に、上限値を下げてもこのコストは減らない — 40,000 行のとき LIMIT 1 で 339 ms、LIMIT 100 で 329 ms。効くのは数千行を実際に返すほど大きな上限のとき (10,000 で +42 ms) だけで、それは実体化のコストであって照合ではない。第 2 に、index の全行にマッチさせること自体は昔から一般的な部分文字列 1 つで可能だった ("について" 単独で全行)。token 単位のコンパイルは「1 クエリが触れる行数」の上限を上げたのではないが、コストの上限は約 10 倍に上げた。第 3 に、最悪コストを実際に縛れるつまみは phrase 上限 (32) で、コストが phrase 数にほぼ線形だから効く。が、意図して据え置いている。 golden 37 件を実測したところ最大でも 9 phrase で、この上限は実クエリに当たらない。半分にすれば最悪コストも半減するが、正当なクエリが末尾の phrase を失い始める長さも同じだけ半減する。上限を超えたクエリは失敗せず「少なく探す」だけなので、この劣化は静かに起きる — 本プロジェクトが最も避けたい失敗の形である。代わりに切り詰めを warn で出し、現実的な長さのクエリが 2 倍以上の余裕を保つことをテストで固定している。
回帰ガードは絶対時間ではなく倍率を固定しているので、機械や SQLite 版が変わっても意味を保つ (bu03_or_expansion_stays_within_a_small_multiple_of_a_single_phrase)。
除外のコスト (v1.1.0+)。 負の phrase も別枠で 32 個まで打ち切るので、全文検索側の最悪ケースは 32-phrase の OR 式 1 本ではなく、それが NOT で結ばれた 2 本を 1 statement で評価する形になる。ベクトル側には、落とす id 集合を作るための rowid 走査 1 回が加わる — 同じ負の式を fts_chunks に対して bm25 も ORDER BY も無しで流すだけなので、併走する ranking 付き query より軽い: id 走査は 934.5µs (best of 5)、同条件の ranking 付き FTS query は 3.5855ms — どちらも 5,000 chunk の全行にマッチする負の式 (最悪ケース) に対する実測で、比は 0.26。計測は grooveseek/src/db.rs の release ビルド用テスト the_exclusion_id_scan_stays_cheaper_than_the_ranked_fts_query による (詳細: ADR-0011)。除外の無いクエリはこのコストを一切払わない — 走査は負の式があるときだけ走る。
RRF の定数と bm25 の 3 つの列重みは、v0.13.0 以降 groove.toml の [search.fusion] で設定できる (ビルトイン既定値は rrf_k = 60.0、heading / context / content = 2.0 / 1.0 / 1.0)。実測の裏付けが無い限り触らないこと — この 2 つのつまみが自分の KB で検索品質をどれだけ (あるいは全く) 動かさないかは groove tune が報告する。詳細は eval.ja.md を参照。
groove eval が既定で測定するのはこの段。ここを底上げするとパイプライン全体の floor が上がる。
--reranker (または groove.toml の reranker キー) を設定すると、上位 RRF 候補を cross-encoder で再スコアして返す。score 列は RRF から reranker raw score に切り替わる。ファイル由来のモデルは rerank_by_default に従うが、コマンドラインで明示したモデルはそのクエリで無条件に効く。詳細は usage.ja.md の再ランクの節。
MMR が enabled なときは reranker に より大きい候補プール (limit × 5、最小 50) を流して多様性再ランクの操作余地を確保する。MMR off のときは reranker への入力 limit が limit (または reranker のみ on の場合は limit × 5、これは v0.7.0 以前の reranker overfetch を保つ) になる。Parent retriever は プールを拡大しない — 既に選択されたヒットに対する content-only 段なので、--parent-retriever 単独 on のとき reranker 負荷は変わらない。
enable する場面: 多言語 / 言語跨ぎ クエリ、上位 RRF が文脈は近いが topic 違いのケース、複数の expected doc を持つ クエリ (rank-1 → rank-2 の入れ替えが顕著に良くなる)
MMR が何をするか: 上位 limit を score 順で返すのではなく、1 個ずつ貪欲に選択する。各ステップで以下を最大化する候補を選ぶ:
λ · rel(候補) − (1 − λ) · max_similarity(候補, 既選択)
− same_doc_penalty · 1[doc(候補) ∈ 既選択 docs]
rel(候補) は relevance score (RRF または reranker のいずれか stage 2 が出したもの) を min-max で [0, 1] に正規化 したもの。これにより lambda のバランスは score スケール (RRF ≈ 0.01、reranker ≈ [-10, 10]) に依存しないmax_similarity(c, picked) は c の embedding と既選択チャンクの embedding 間の cosine 類似度の最大値same_doc_penalty は c が既選択チャンクと同一 document に属するときに追加で減点される項チューニングノブ (すべて [search.mmr]):
| ノブ | 既定 | 上げる場面 | 下げる場面 |
|---|---|---|---|
enabled |
false |
同一 doc の chunk が 3 つ以上返る、上位 k に冗長性が見える | — |
lambda |
0.7 |
off-topic な結果が混じると言われたとき (関連度寄り) | 範囲を広く取りたい (top-1 関連度を犠牲にしてでも) とき |
same_doc_penalty |
0.0 |
長い章持ちの 1 doc が top-k を支配する KB | 0 のままで OK (similarity 項が大半の重複削減を担当する) |
Eval signal: MMR を on にして groove eval を再走させる。期待される動き:
recall@1 軽く ↓ (MMR は厳密な top-1 を多様性のために手放しうる)recall@5 / recall@10 が ↑ (多様性項によって異なる doc が top-k に入りやすくなる)nDCG@10 は混合 — golden ファイルが多様性を重視するか集中した関連度を重視するかに依存アンチパターン: MMR enabled + lambda = 1.0 は MMR off と等価だが少しだけ遅い (類似度キャッシュは動く)。その場合は MMR を off にすべき — groove はこの footgun を検知すると warn を出す (実効 MMR off だが lambda override が指定されている)
Parent retriever が何をするか: ヒットチャンクが小さい (見出し下の 1 行 bullet など) と LLM が周辺コンテキスト不足で上手く回答できないことがある。Parent retriever は以下のように小さなヒットの content を書き換える:
whole_doc_threshold_tokens (既定 100) 未満のチャンクには文書全体を返す (max_expanded_tokens で cap)max_expanded_tokens まで連結する元のヒットの score / rank / path / match_spans は 保持される。新しい expanded_from フィールドが「どの range が merge されたか」を伝える。relevance ランキングは変わらない — parent retriever は表示内容を入れ替えるだけで順序には触れない。
チューニングノブ (すべて [search.parent_retriever]):
| ノブ | 既定 | 上げる場面 | 下げる場面 |
|---|---|---|---|
enabled |
false |
LLM が断片を引いて follow-up 質問でギャップを埋めようとする | — |
whole_doc_threshold_tokens |
100 |
短いノートを atomic Zettelkasten 形式で index している、ノート全体を context にしたい | 多くは見出しサイズで sibling-merge だけで足りるとき |
max_expanded_tokens |
2000 |
下流 LLM の context 予算が潤沢 (Claude 200K、GPT-4 128K) | 多数の同時 client にレスポンスを返すとき (応答サイズの上限) |
cap の相互作用: max_expanded_tokens は予測可能性のため embedder の最大シーケンス長以下に保つべき。BGE-M3 は 8192 max なので既定 2000 は十分余裕がある。embedder cap を超えて上げると、index 時には embedder が見ていない量のテキストが返される可能性がある。
token_count が NULL の行: v0.7.0 以前の index では chunks.token_count が NULL。Parent retriever はこれらの行に len(content) / 4 フォールバックを使う (indexer 側の estimator と整合)。これがないと cap が silent に bypass される (元の codex が見つけたバグ。tests/search_parent_integration.rs で固定済み)
Eval signal: Parent retriever は recall/MRR/nDCG を変えない — これらの metric は content を見ない。ユーザに見える content quality だけが変わる。groove eval の数値ではなく、LLM answer 品質を before / after で比較する (手動 or LLM-judge ハーネス)
順序は RRF → reranker → MMR → parent retriever → match_spans で固定:
match_spans は parent retriever の後: span は最終的に返される content への byte offset なので、merge 後のテキストに対して計算する必要がある各段の出力が次段の有効な入力となる単調合成可能 4 段と捉えれば良い。段を off にしてもパイプラインの形は変わらず、aggression が落ちるだけ。
既定 (チューニングなし): [search.mmr].enabled と [search.parent_retriever].enabled の両方を false のままに。これは v0.6.x の挙動と完全に一致 — baseline として有用。
LLM-as-RAG-frontend: parent retriever を on (enabled = true、既定値)。LLM が各ヒットでより豊富な context を得て、follow-up search 呼び出しが減る傾向。
多様な content の KB: MMR を on (enabled = true、lambda = 0.7、same_doc_penalty = 0.0)。1 つの document が top-k を flood する場合に推奨。
両方: 両方 on。パイプライン順序により MMR は展開前 content (clean な多様性 signal) を見て、ユーザは展開後 content (LLM context が良い) を見ることになる。
groove eval)<kb>/.groove-eval-history.json に記録される ConfigFingerprint でこれら 4 種を区別できるので、フラグを倒すだけでいつでも再走できる具体的な eval-baseline ノートのテンプレは repo 内の .dev/knowledge/eval-baseline-2026-04-27.md を参照 (private notes、format は CLAUDE.local.md に記載)。