GrooveSeek

Semantic search over a Markdown knowledge base, served over MCP.

View the Project on GitHub alphabet-h/grooveseek

Retrieval パイプライン (RRF → reranker → MMR → parent retriever)

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 な出力を返す。

Stage 1 — Hybrid 候補生成 (常時 on)

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 である:

つまり 再ランキングの評価について"再ランキング" 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.0heading / context / content = 2.0 / 1.0 / 1.0)。実測の裏付けが無い限り触らないこと — この 2 つのつまみが自分の KB で検索品質をどれだけ (あるいは全く) 動かさないかは groove tune が報告する。詳細は eval.ja.md を参照。

groove eval が既定で測定するのはこの段。ここを底上げするとパイプライン全体の floor が上がる。

Stage 2 — Reranker (任意, v0.1.0+)

--reranker (または groove.tomlreranker キー) を設定すると、上位 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 の入れ替えが顕著に良くなる)

Stage 3 — MMR 多様性再ランク (任意, v0.7.0+)

MMR が何をするか: 上位 limit を score 順で返すのではなく、1 個ずつ貪欲に選択する。各ステップで以下を最大化する候補を選ぶ:

λ · rel(候補) − (1 − λ) · max_similarity(候補, 既選択)
              − same_doc_penalty · 1[doc(候補) ∈ 既選択 docs]

チューニングノブ (すべて [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 を再走させる。期待される動き:

アンチパターン: MMR enabled + lambda = 1.0 は MMR off と等価だが少しだけ遅い (類似度キャッシュは動く)。その場合は MMR を off にすべき — groove はこの footgun を検知すると warn を出す (実効 MMR off だが lambda override が指定されている)

Stage 4 — Parent retriever (任意, v0.7.0+)

Parent retriever が何をするか: ヒットチャンクが小さい (見出し下の 1 行 bullet など) と LLM が周辺コンテキスト不足で上手く回答できないことがある。Parent retriever は以下のように小さなヒットの content を書き換える:

元のヒットの 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 で固定:

各段の出力が次段の有効な入力となる単調合成可能 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 = truelambda = 0.7same_doc_penalty = 0.0)。1 つの document が top-k を flood する場合に推奨。

両方: 両方 on。パイプライン順序により MMR は展開前 content (clean な多様性 signal) を見て、ユーザは展開後 content (LLM context が良い) を見ることになる。

Eval を踏まえたチューニング ワークフロー

  1. 両方 off で baseline を取る (groove eval)
  2. MMR を on にして再走、recall@k / nDCG@k を比較。あなたの golden set にとって多様性のトレードオフが妥当か判断
  3. 独立に parent retriever を on (MMR は off) にして再走。recall/nDCG はほぼ変わらないはず。変わったら bug 報告 — parent retriever は設計上 content-only 段
  4. 両方 on にして v0.7.0 のリファレンス eval を実行
  5. <kb>/.groove-eval-history.json に記録される ConfigFingerprint でこれら 4 種を区別できるので、フラグを倒すだけでいつでも再走できる

具体的な eval-baseline ノートのテンプレは repo 内の .dev/knowledge/eval-baseline-2026-04-27.md を参照 (private notes、format は CLAUDE.local.md に記載)。