GrooveSeek

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

View the Project on GitHub alphabet-h/grooveseek

検索フィルタ

search MCP ツールは複数のフィルタを受け付ける。フィルタは AND セマンティクスで合成される (すべての条件が一致した chunk のみ results に現れる)。

English version: filters.md

クイックリファレンス

パラメータ 型 例 効果
category string "deep-dive" documents.category と完全一致
topic string "mcp" documents.topic と完全一致
path_globs string[] ["docs/**", "!docs/draft/**"] glob include / exclude
tags_any string[] ["rust", "wasm"] OR — いずれかの tag が一致
tags_all string[] ["draft"] AND — 全 tag が一致
date_from string "2026-01-01" hit.date >= from (lex 比較)
date_to string "2026-12-31" hit.date <= to (lex 比較)
min_quality number 0.5 quality filter の閾値 ([quality_filter].threshold) をこの呼び出しだけ上書き
include_low_quality bool true この呼び出しでは quality filter を無効化 (min_quality: 0.0 と等価、意図が明示的)
min_confidence_ratio number 1.5 low_confidence フラグの閾値
fields object {"status": ["active"], "environment": ["dev", "prod"]} スキーマが宣言した frontmatter key (v1.9.0+): 各 key が列挙値のいずれかを持つ
fields_not object {"status": ["deprecated"]} 列挙値のいずれかを持つ文書を除外。key を持たない文書は残る

path_globs

{
  "path_globs": ["docs/**", "!docs/draft/**"]
  // "docs/a.md" は通る、"docs/draft/b.md" は除外、"notes/c.md" は除外
}

tags_any と tags_all

これらは documents.tags (YAML frontmatter の tags: 配列) を対象にする。

{
  "tags_all": ["rust"],
  "tags_any": ["async", "concurrency"]
  // "rust" タグかつ ("async" or "concurrency") を持つ docs にマッチ
}

ソースコード (v1.2.0+) のタグは frontmatter ではなく parser が付ける: ソースファイル由来の chunk はすべて code と lang:<name> を持つ。結果は既定でコードと散文が混ざり、それを 分けるのがこの filter である — そのために search の parameter を足してはいない。

{ "tags_any": ["code"] }        // 定義だけ
{ "path_globs": ["!**/*.rs"] }  // 散文だけ。exclude だけの list は有効

YAML frontmatter が parse できなかった Markdown (v1.7.0+) にも、同じ理由で parser が frontmatter:unparsed の tag を付ける: title / date / topic が空なので、 他のどの filter でも届かないから。

{ "tags_any": ["frontmatter:unparsed"] }  // frontmatter が壊れたまま索引された文書

date_from / date_to

{
  "date_from": "2026-01-01",
  "date_to":   "2026-04-30"
}

date 形式が混在するとき ("2026-04-26 12:00:00 +0900" と "2026-04-26T12:00:00+09:00" など) は lex 順序が崩れる。KB 内で形式を 統一すること。

fields と fields_not (v1.9.0+)

これらは document_fields を対象にする — groove-schema.toml が index 構築時に 宣言していた frontmatter key の値であって、documents.tags でも documents.category でもない。スキーマが宣言していない key は index に無いので、 その key で filter しても何にもマッチしない (エラーにはならない)。

{
  "fields":     { "status": ["active"], "environment": ["dev", "prod"] },
  "fields_not": { "team": ["archived"] }
  // = status is active, environment is dev or prod, and team is not archived
}

index はスキーマに従う: groove index は毎回 knowledge base root の groove-schema.toml を読み、宣言 key の集合が変わったときは変更の無い Markdown 文書も全部読み直して、re-embedding なしで行を書き換える。この値を 持つのは .md 文書だけ。

その書き換えの最中 — あるいは中断された後、または旧版で作った index に対して groove index をまだ一度も回していないとき — index には宣言 key の集合が記録されて おらず、fields / fields_not を伴う検索は 1 つのエラーで拒まれる (コマンドラインでも tool の error でも同じ 1 文)。行が新旧どちらの宣言のものか判別できないため。 どちらの filter も付けない検索には影響しない。groove index を完了させてから再試行する。 この状態は groove status が Declared fields: pending と出し、groove doctor が declared-fields-pending として、検索を打つ前に報告する (v1.11.0+)。

コマンドラインでは同じ filter が --field key=value (繰り返し可; 同じ key 2 回は OR、違う key は AND) と --field-not key=value。ペアは最初の = で 分けるので、値に = やカンマを含められる。

docs/eval.ja.md が deprecated-trap として記録しているベンチマークケース (deprecated な runbook が後継より上位に来る) は、まさに --field-not status=deprecated が解決するものである。

low_confidence と min_confidence_ratio

レスポンス wrapper のトップレベルに low_confidence: bool が付く。top hit の score が他と比べて 目立って高くない ときに true になる:

low_confidence ⇔ (results.len() >= 2)
                 AND (mean(scores) > 0.0)
                 AND (max(scores) / mean(scores) < min_confidence_ratio)

low_confidence: true の意味は「マッチがダンゴ状態 — Claude は引用を 権威として扱うのを慎重に」。results 自体はそのまま返ってくる。フラグは あくまで助言。

何を検出し、何を検出しないか

このフラグはヒューリスティックであり、実測したので、式から推測させるのでは なくここに書いておく。どれだけ重みを置くかを決める上で効く限界が 2 つある。

rerank はこのフラグを丸ごと無効にし得る。 cross-encoder はロジットで スコアを付けるので無関係な chunk は強く負になり、結果集合の平均はしばしば負に なる。そうなると上の mean(scores) > 0.0 が、散らばりに関係なく false を返す。 bge-v2-m3 で 25 クエリ (各 10 件) を測った結果、コーパスに答が無いクエリを 含めて全部 false だった。

手元のクエリでそうなるかは、モデル・件数・返った chunk の関連度で決まる — 本当に良いマッチだけが少数返れば平均は正になり、比は通常どおり比較される。 つまり rerank 下でフラグが必ず消えるわけではない。信用できないのが正確な 言い方で、rerank が走ったときの false は何も意味しない (符号チェックが出す false と区別が付かない)。true の方は式が言うとおりの意味を持つ。

rerank 無しで反応しているのは、融合後スコア分布の形であって正解性ではない。 RRF は hit が現れた脚ごとに 1 / (rrf_k + rank + 1) を与えるので、 top1 を割る平均は ①各 hit を何本の脚が拾ったか ②各脚が何位に置いたか ③rrf_k ④要求件数で動く。どれも「top hit が正しいか」ではない。

実際の代償は 2 つの測定が示している。20 文書のコーパスで 25 クエリ全部が rank 1 で正解しているのに 14 件で発火した。そして同じクエリが別コーパスでは まったく違う位置に来る: 答が無い 12 クエリの median が、そのコーパスでは 1.08、121 文書のコーパスでは 1.40 — 後者は前者で正解していたクエリが 居た位置である。

測定した標本では、比を押し上げていたのは片方の脚しか拾わなかった hit だった。 「重なりを測っている」という読み方はここから来ている。ただしそれは規則ではなく そのコーパスについての観測として扱うこと — 上の要因は、重なりが変わらないままでも 比を動かし得る。

ノイズではない。コーパスを固定すれば、答が無いクエリの方が低く出る。だが 2 つのナレッジベースで同じ意味を持つ閾値が存在しない。既定値を特定の コーパスに合わせて調整せず、そのままにしてあるのはこのため。

どちらの限界も、意図した挙動ではなく未処理の課題として記録してある。 フィールド自体は 1.0 で凍結するが、式と既定値は明示的に凍結しない (docs/stability.ja.md)。

category と tags_any の違い (検索軸が別)

これらは index 上で 別のフィールド:

deep-dive/mcp/x.md にある tags: ["mcp", "rust"] を持つドキュメントは、 フィルタ category: "deep-dive" に マッチするが、 tags_any: ["deep-dive"] では マッチしない。これらは別軸。

フィルタの組み合わせ

すべて AND で合成される:

{
  "path_globs": ["docs/**"],
  "tags_all":   ["rust"],
  "date_from":  "2026-01-01",
  "fields_not": { "status": ["deprecated"] }
  // = docs/ 配下、"rust" タグ、2026 年以降、deprecated ではない
}

関連