GrooveSeek

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

View the Project on GitHub alphabet-h/grooveseek

アーキテクチャ

groove のソース構造とデータフロー。コードを拡張・修正するコントリビュータ向け。

English version: ARCHITECTURE.md

ソース別の責務

ファイル 役割
grooveseek/src/lib.rs (v0.7.1+) ライブラリクレートのルート。下記モジュールを grooveseek::* として再公開し、benches/tests/ から内部 API をサブプロセス経由なしで呼び出せるようにする。ライブラリの公開面は意図的に unstable であり、外部利用者向けではない
grooveseek/src/main.rs バイナリエントリ。clap CLI が groove の持つサブコマンドをすべてディスパッチする。各サブコマンドは usage.ja.md に、service の各 verb はその service 節にある。use grooveseek::*; 経由で lib を呼ぶ。groove.toml 読み込みと CLI 引数へのマージ。JSON / text 出力フォーマッタ
grooveseek/src/config.rs 4 階層の groove.toml 探索 (--config フラグ → CWD → .git 祖先 (CWD + 最大 19 祖先) → バイナリ隣 legacy)。Config::discover()ConfigSource enum を返し、main.rs が起動時に tracing で出す。CLI > 設定ファイル > 既定値 の優先順位を解決。config が設定していて env 未設定の場合のみ FASTEMBED_CACHE_DIR を env に注入
grooveseek/src/server.rs rmcp ServerHandler 実装。6 つの MCP ツールをディスパッチ。searchdb.search_hybrid 経由で結果を SearchResponse ラッパ (low_confidence / match_spans / filter_applied) に包んで返す (v0.3.0 で BREAKING、CHANGELOG 参照)。状態は KbCore が持ち、各ツールは本体を spawn_blocking でそこへ流す薄い async ラッパ。長い処理は async worker ではなく blocking スレッドを占有する
grooveseek/src/server/search.rs (v1.0.0+) search 側の半分: search tool の本体、走らせる pipeline、そのどちらかが見るより前に request を縛る上限。db.rs の分割と同じやり方で server.rs から切り出した — 本体は byte 単位で同一、順序もそのまま、mod tests は親に残す。変わったのは可視性だけで、親がまだ呼ぶ / 名指しする 3 つが private から pub(super) になった。親に残ったのは tool 面そのもの (#[tool_router] / #[tool_handler] の impl と、パラメタ・レスポンスの型)
grooveseek/src/server/documents.rs (v1.0.0+) KB から文書を読むこと、およびそもそも読んでよいかの判断: get_document / get_best_practice の本体、両者が通る 4 段のパス検査、適用される size 上限。server/search.rs と同じ条件で server.rs から分割。pub(super) が付いたものはすべて、可視性を一切変えずに移してから cargo check が名指ししたもの
grooveseek/src/server/kb_uri.rs (v1.0.0+) kb:// resource 面のコーパス側: このサーバがどの文書を渡すか、それにどの URI を出すか、resources/read で何が返るか。下の resources.rsURI 側 — kb:// 文字列を組み立てて分解するだけでコーパスを知らない。こちらが知っている側。他の 2 つと同じ条件で server.rs から分割
grooveseek/src/prompts.rs (v0.22.0+) MCP の prompts 面: summarize_topic / deep_dive / whats_new / find_gaps。rmcp の #[prompt] / #[prompt_router] で宣言し、server.rsServerHandler に付けた #[prompt_handler] がディスパッチする。各ハンドラは直下の自由関数を呼ぶだけで、テストは KbServer (= Database + Embedder を持つ) を構築せずに本番のテキストを叩ける — watcher::should_process_parts を切り出したのと同じ理由・同じ形。メッセージは text のみ (embedded resource を使うと resources capability の実装義務が生じる)。設定ではなくコンパイル時固定なのは、prompt 本文がモデルに渡るテキストであり groove.toml発見されるため、[prompts] セクションは restrict_untrusted における kb_path と同じ特権カテゴリに入ってしまうから。router は vis = "pub" で生成し、server.rs 側のハンドラからモジュールを跨いで参照する
grooveseek/src/resources.rs (v0.22.0+) MCP の resources 面: kb:// URI の codec と resources/list の背後にあるグループ化を、DB を一切見ない純関数として置く (encoding 規則を直接テストできるように)。kb://topic/<prefix> は topic group = パスの先頭 1〜2 セグメントで、indexer が category / topic を導出するのと同じ規則なので、同期用の 2 本目のクエリ無しに DB と一致する。kb://doc/<path> は文書 1 件。group は KbCore::servable_document_paths() (= 索引のパスから ServableRules が渡さないものを除いたもの) から作り、read も同じクエリで照合するので、listing が出した URI が読み返しで拒否されることがない (ADR-0004)。ServableRules (server.rs) はそのクエリと search hit の uri両方の背後にある唯一の述語で、判定は「現在の parser registry で開けない拡張子」と「read の cap を超える記録済み size」の 2 つ (ADR-0005)。区切りは / のまま、それ以外は percent-encode。traversal 検査は decode の後に走る (%2e%2e%2f../ であり、先に検査すると素通りする)。対象 corpus には非 ASCII も空白も無いが、それらを含むパスでテストしている — 発火しない encoder は誰も確かめていない encoder だから
grooveseek/src/schema_compat.rs (v0.14.0+) 上記ツール用に公開する JSON Schema を、サーバから出る前に正規化する。schemarsOption<T> を union type ({"type": ["string", "null"]}) として導出し、Rust の整数幅を format: uint32 として刻む。どちらも JSON Schema 2020-12 として正当だが、strict な tool-calling ランタイムは union を拒否し、その format を知らない。frontmatter を検証する schema.rs とは無関係 — 名前は近いが対象が違う
grooveseek/src/service/ (v0.8.0+) クロスプラットフォーム OS ユーザサービスインストーラ。mod.rs (= ServiceBackend trait + InstallContext + ServiceState)、install.rs / uninstall.rs / status.rs (= orchestration)、linux.rs / macos.rs / windows.rs (= OS 別 backend、cfg-gated)。加えて cfg gate の外に意図的に置いた 2 module があり、全 OS leg で compile + テストされる: render.rs (v0.14.0+、unit / plist のテンプレートと escape 処理 — plist の誤りは以前は macOS runner でしか検出できなかった)、powershell.rs (v0.14.0+、powershell.exe の出力に対する UTF-8 前置と strict / diagnostic の 2 種類の decoder)。Phase 1 = user-level のみ (= admin / sudo 不要、Linux systemd-user / macOS LaunchAgent / Windows Task Scheduler AT_LOGON)。groove service install は Rust crate のみで自己登録 (= NSSM / WiX / 3rd-party tooling 不使用)。Windows backend (v0.8.3+) は Command::new("powershell") 経由で Register-ScheduledTask -Action -Trigger -Settings cmdlet を呼ぶ — schtasks /Create /XML は v0.8.0 → v0.8.3 で locale / elevation / Principal の 3 段階問題により放棄、詳細は .dev/knowledge/windows-task-scheduler-pitfalls.md 参照。
grooveseek/src/indexer.rs walkdir で Registry::extensions() の拡張子を走査。全 read 経路 (初回スキャン / index_single_disk_entry / reindex_single_file / rename_single_file) は links::read_checked を通る (v0.20.0+、read_to_string ではなくバイト読み)。file を 1 回 open し、その handle から「中身を使ってよいか」を判定してから読む。生バイトは SHA-256 で hash して content-hash 差分検出に使う — 既存の UTF-8 KB では旧文字列 hash と一致するため no-op。Parser trait (parse_bytes) でパース → embedder で embedding → db に格納。per-file skip 隔離: read 失敗 / size cap 超過 / parse_bytes エラーはそのファイルだけ skip (warning ログ) して全体を abort せず、skip したパスは削除扱いにせずインデックスに保持する。refusal (SingleResult::Refused) はそれらと区別する — 「開いた対象が、集めた時のファイルではない」という意味で、rename_single_file は専用の outcome に写して「rename 成功」と報告しない。watcher と共有する増分 API (reindex_single_file / deindex_single_file / rename_single_file)
grooveseek/src/indexer/progress.rs (v0.7.8+) ProgressReporter + ProgressMode enum。groove index の per-file 出力を制御: Verbose (既定) / Quiet (--quiet) / Auto (--progress、TTY = indicatif::ProgressBar、非 TTY = 定期 Progress: N/M (P%) 行)。MCP server rebuild_index ツールは Quiet 固定。bar lifetime は rebuild_index 内に閉じる lazy init (start_indexing(total) 経由) で Backfilled / Found 行は plain eprintln! のままにし、bar との衝突を構造的に回避する
grooveseek/src/parser/ Parser trait + Registry。mod.rs (Frontmatter / Chunk / ParsedDocument に加え、indexer / server の全 call site が経由するエントリポイント parse_bytes(bytes, path_hint, exclude_headings) -> Result<ParsedDocument> を定義。parse_bytes は extension trait ParserExt 側に blanket impl (impl<T: Parser + ?Sized>) として置いてあり、parser 側が定義を持てない。常に Parser::parse_bytes_inner (同じシグネチャ) を catch_unwind 越しに呼ぶため、parser や依存 crate のどこで panic しても per-file の Err になり index 実行全体が落ちない (default method だと override で隔離を素通りできてしまうので採らない)。parse_bytes_inner の default impl は UTF-8 検証 → parse へ委譲するため md/txt は override 不要、バイナリ形式 parser はこれを直接 override する。is_binary() (既定 false) はバイナリ parser を示し、get_document の size cap 分類と quality filter 免除判定に使う。MAX_RAW_BINARY_BYTES = 50 MiB はバイナリ形式共通の生バイト上限で、indexer の size-skip guard と get_document の両方で共有。テキスト形式には v0.17.0 以降 MAX_RAW_TEXT_BYTES = 50 MiB が index 時に同じ役割を果たすので、無制限にメモリへ載る形式は無い)、markdown.rstxt.rspdf.rs (v0.10.0+、詳細下記)、ooxml.rs / xlsx.rs / docx.rs / pptx.rs (v0.11.0+、詳細下記)、panic_guard.rs (詳細下記)、registry.rs (拡張子ルックアップ、binary_extensions())
grooveseek/src/parser/code/ (v1.2.0+) ソースコードを定義 1 つ = chunk 1 つで parse する。[parsers].enabled = ["md", "rs"] で opt-in。mod.rs が言語非依存の側を持つ: LoadedGrammar (grammar と、それに対して検証済みの tags query)、CodeParser (1 拡張子 1 instance、is_binary() == false なのでコードも散文と同じく quality filter で採点される)、そして chunker。定義は grammar 自身の tags.scm から取る。定義の範囲は直上の comment ノードの連なりのぶん手前へ伸ばすので、doc comment がその定義の chunk と上の gap chunk の両方に入ることはない。予算を超えた定義は入れ子の定義へ再帰し、入れ子が無ければ行で割る (method は入れ子を持たないので、こちらが常道)。どの定義も覆わないバイトは見出し無しの gap chunk になり、quality filter の短さ閾値に満たない断片は捨てる (ただし捨てるとファイルが何も産まなくなる場合は残す)。スコープ名は定義木ではなく Node::parent() を辿って name / type / trait field から取る — Rust の tags query は impl ブロックを参照として capture するため。symbol_kind は言語キーワードではなく tags の語をそのまま運ぶ (class が struct / enum / union を覆う) ので、grammar はデータのままで済む。MAX_RAW_CODE_BYTES = 1 MiB、比較は >get_document の cap と境界を揃える — tree-sitter の allocator は OOM で abort し unwind しないので panic_guard では拾えず、parser が見る前に拒む必要がある。static_rust.rs は既定 on の grammar-rust feature の後ろに焼き込まれた Rust grammar。plugin.rs (v1.3.0+) はもう 1 つの到着経路で、利用者が置いたライブラリを dlopen し、export の存在 / groove 自身の ABI 版 / grammar の tree-sitter ABI がこのランタイムの範囲か / tags query が compile できるか / 拡張子が 1 個で妥当か を検査してから、同じ LoadedGrammar を返す。ディレクトリを列挙せず 固定の id→ライブラリ名表から名指しで開く — ライブラリを開くと symbol を見る前に初期化ルーチンが走るため。受理したライブラリは意図的に leak する (parse table と tags query がその中にあるため)。ADR-0012 / ADR-0013 参照。
grooveseek/src/parser/panic_guard.rs ParserExt::parse_bytes の panic 隔離機構。catch_parser_panic が parser を catch_unwind 下で実行し、panic を Err("<path>: <id> parser panicked: <payload>") に変換する (payload を残すので indexer の skip 行に原因が出る)。wrapper panic hook は 一度だけ install して以後入れ替えず、RAII guard が立てる thread-local flag を見て parse 中のそのスレッド自身 の backtrace だけを抑制する (呼び出しごとに hook を差し替える方式は 2 スレッド同時 parse で race する)。元は PDF 専用 (v0.10.0) だったものを docx/xlsx/pptx にも効かせるためここへ移設した。これが無いと crafted な spreadsheet 1 個で groove index が丸ごと落ちる (calamine の get_dimension は減算を checked していないため、ref="B2:A1" は debug assertion 有効ビルドで panic する)
grooveseek/src/parser/pdf.rs (v0.10.0+) PdfParseris_binary() == true[parsers].enabled = ["md", "pdf"] でオプトイン。oxidize-pdf (PdfReader + PdfDocument::extract_text) でページ単位にテキストを抽出し、空でない各ページが 1 チャンク (見出し p.Nlevel: None) になる。PDF の Title / CreationDate メタデータを frontmatter に反映し、Title が無ければファイル名派生タイトルに fallback する。oxidize-pdf 内部で発生し得る未知の panic は per-file Err (indexer の skip + warn) に正規化されて index 実行全体の abort を防ぐ — この catch_unwind は元々ここにあったが、現在は全 parser に効く Parser::parse_bytes / parser/panic_guard.rs 側にある。抽出したページは 2 つの門をこの順序で通る (reject_unindexable_pages)。第 1 に、文字化け検出 (v0.15.1+)。シグナルは相補的な 2 つ: C1 制御コード (U+0080–U+009F) が全文字の 1% に達したテキストを reject する — UTF-16BE を 1 バイトずつ読むと U+8000–U+9FFF の上位バイト (および濁点かなの下位バイト) が C1 に落ちるが、正しく復号できたテキストにこの領域は現れない (正しく抽出できた 6 サンプルで 0.00%、誤デコードした 4 サンプルで 3.61〜15.59% と実測で完全に分離) — さらに、同じ誤復号の byte-pair シグネチャが全文字の 30% 以上なら reject する (長い run は run 単体のパリティ判定 — 集中は先頭側限定で、1A2A3A… のような逆向きの交互識別子は flag しない、2〜7 文字 (per-run 判定が発火可能な長さ未満) の短い run は文書全体でペアを集約して判定 — ラベル配置の単語リストは 4 文字 token に割れることを 148 chars/page で実測)。こちらは C1 を 1 つも出さない唯一の形である清音かなのみのテキスト (上位バイトが全て 0x30 → あいうえお… が純 ASCII 0B0D0F… になる。当時の pin だった oxidize-pdf 4.1.1 で C1 0.00%・407 chars/page を実測。v0.15.2 以降の pin 4.3.0 はこの形を正抽出するため、門は防衛層として残る) を受け持つ。この門を先に置くのは、誤デコードが 1 文字を 2 文字に増やすため、文字化けの方が下の密度の門を通過してしまうから (実測 1052 chars/page)。正しく抽出できた薄いテキスト (29 chars/page) は通過しない。第 2 に、平均抽出文字数が 50 chars/page 未満の PDF を reject する。このヒューリスティックはスキャン / 画像のみの PDF (text layer なし、OCR 非対応) のために書かれたが、それ専用ではない — 正しくデコードできていて本当に 1 ページあたりの文字が少ない PDF (表紙、ラベル、図版主体の資料) もここに落ちる。閾値を下げないのは、電子的に載せたページ番号と「CONFIDENTIAL」スタンプだけを持つスキャン PDF が 39 chars/page を出すため。文字数だけでは価値のない定型文と密度の低い本文を分離できない。そのため診断メッセージは「スキャン」と断定せず、また原因を閉じた形で列挙もせず、測った値と代表的な原因を開いた列挙として出す。/ToUnicode 付き TrueType サブセットを埋め込む日本語 PDF (Word / LibreOffice / Google ドキュメントの出力) は正しく抽出できる。かつて文字化けしたのは予約 CMap を使い /ToUnicode を持たない CID-keyed フォントの場合で、根本原因は upstream 側 — oxidize-pdf が /DescendantFonts を CIDFont が間接参照のときしか読まなかった。本プロジェクトが報告・修正し (bzsanti/oxidizePdf#469、修正は oxidize-pdf 4.3.0 = v0.15.2 以降の pin に収録)、この形も正抽出できるようになった。mojibake の門は他起因の復号失敗への防衛層として維持する。暗号化 PDF は PdfReader::new / extract_text から Err として現れる (oxidize-pdf の ParseResult ベースのエラー設計、パスワード対応なし)。後処理として保守的な行末ハイフン結合 (-\n は両隣が ASCII 小文字の場合のみ) とよく使われるリガチャ (fi/fl/ff/ffi/ffl) の正規化を行う
grooveseek/src/parser/ooxml.rs (v0.11.0+) xlsx.rs / docx.rs / pptx.rs が共有する OOXML zip/XML helper (parser struct 自体は持たない)。read_zip_entry は zip パート 1 個を生バイトで読む。core_xml_frontmatter / parse_core_xmldocProps/core.xml (Dublin Core: dc:title / dcterms:created または modified / cp:keywords) を Frontmatter にマップし、パート不在または title が空なら filename 派生タイトルに fallback する。local_name_pub は QName から namespace prefix を除いた local part を取る (cp:titletitle)、要素名判定を prefix 非依存にする。resolve_general_ref は quick-xml の Event::GeneralRef (entity 参照 &amp; 等が Event::Text に畳み込まれず別 event として届く挙動、pin している 0.41 で実測確認済) を解決する — docx.rs と pptx.rs の両方が同じ char-ref / named-entity 処理を必要とするためここに共通化した
grooveseek/src/parser/xlsx.rs (v0.11.0+) XlsxParser (.xlsx)、is_binary() == trueXlsParser (.xls) は残っているが v0.14.0 以降 registry から到達しない (AU-06): calamine は Xls::new の中でシートごとに密なセル格子を作り、BIFF が縛るのはシート 1 枚 (65,536 × 256 = Data 512 MB) であって workbook ではないため、最大矩形のシートを多数宣言した小さなファイルが groove に制御が戻る前にメモリを使い切る。しかも割り当て失敗は skip ではなくプロセス異常終了になる。塞ぐには Xls::new の前に CFB コンテナから BOUNDSHEET / DIMENSIONS を自前で読む必要があり、.xls の要望が出るまで見送っている。両者は parse_workbook_bytes を共通の入口とし、そこから形式別に dispatch する。ワークブックは登録拡張子に一致する reader (calamine::Xlsx / calamine::Xls) で開く (形式 probe はしない = 拡張子と実体が食い違う payload は parse せず reject)。.xlsx は開く前に解凍 pre-flight を通す: entry を、申告 uncompressed size実際に展開したバイト数 (出力は捨て、残り budget + 1 バイトで打ち切る) の両方で MAX_RAW_BINARY_BYTES と照合する (= 「archive 全体の展開量が cap 以内」という、名前に言及しない不変条件)。zip 仕様は申告値を強制せず zip 8.6 も deflate 出力を申告値で bound しないため、zip-bomb を止めるのは後者 (実測: 申告 10 バイトの 101 KB crafted workbook が 100 MB に展開された)。拡張子で対象を絞らないのは意図的で、calamine はパートを rels の Target で解決しファイル名を見ない (xl/worksheets/payload も worksheet として読む) ため、名前ベースの選別は 3 回破られている (固定パス → .rels 漏れ → 拡張子前提そのもの)。代償として xl/media/ の画像も budget に乗るので、raw cap 付近かつ中身の大半が画像の workbook は skip され得る。その後、空でないシートごとに 1 チャンク (見出し Sheet: <name>、行ごとにセルをタブ結合) を生成し、SHEET_MAX_BYTES (1 MiB) で truncate する — 行単位の境界セマンティクス (合計を cap 超過させた行はそのまま丸ごと emit してから、そのシートの抽出を打ち切る。行途中では絶対に切らない)。frontmatter は、バイト列が zip として開ける場合 (.xlsx は該当) は ooxml::core_xml_frontmatter 経由で docProps/core.xml から取得する
grooveseek/src/parser/docx.rs (v0.11.0+) DocxParseris_binary() == trueword/document.xml を段落 (<w:p>) 単位で読み、<w:pStyle w:val="HeadingN"> をセクション境界として扱う — markdown.rs が Markdown 見出しに使うのと同じ見出し階層チャンク化規則で、exclude_headings 対応も含む (除外見出し配下の本文は次の非除外見出しまで捨てる)。表 (<w:tbl>) のテキストは特別扱い不要: OOXML 上の入れ子構造 w:tbl > w:tr > w:tc > w:p > w:r > w:t により、表セルのテキストは通常の <w:p> 境界処理を通って自然に現在のセクション本文に取り込まれる。frontmatter は ooxml::core_xml_frontmatter 経由
grooveseek/src/parser/pptx.rs (v0.11.0+) PptxParseris_binary() == trueppt/slides/slideN.xml エントリを集めて数値のスライド番号順にソートする (zip 内の格納順ではない)。スライドごとに 1 チャンク (見出しは ctrTitle/title placeholder shape にテキストがあれば Slide N: <title>、無ければ素の Slide N) を生成し、スライド内の表テキストも本文に含める。発表者ノートは末尾 [notes] セクションとして付加し、スライドの ppt/slides/_rels/slideN.xml.rels を読んで notesSlide relationship の Target を解決する — 意図的に同番号ファイル (slideN.xmlnotesSlideN.xml) の推測 heuristic にはしていない。編集後にスライド番号とノート番号がずれたケースで dry-run (plan Task 3.7) が誤帰属を実証したため。frontmatter は ooxml::core_xml_frontmatter 経由
grooveseek/src/doctor.rs (v0.23.0+) groove doctor の検査本体。2 群ある。整合性: chunks / vec_chunks / fts_chunks が各 chunk について一致しているか — ずれてもエラーは出ず、hybrid search の片側から結果が黙って消えるだけ。backfill_fts が存在すること自体がその証拠。提示可能性: resource 面がどの索引済み文書を出していないか。paths_with_unregistered_extensionServableRules再実装せずそのまま呼ぶので、報告がサーバの実際の挙動から乖離しない。報告するだけで修復しない (paths_with_unregistered_extension が既に宣言している契約) で、各検出に直し方のコマンドが付く。SQL は db/meta.rs 側 — Database::conn が db モジュール private なのが理由だが、結果として最も近い親戚である backfill_fts の隣に置かれる
grooveseek/src/exclusion.rs (v0.21.0+) KB のパスが対象外かどうかを決める唯一の場所。KB を歩く / 監視する 3 面 (full index walk、binary target 側にいてライブラリの公開 API 越しにここへ来る validate walk、live watcher) が同じものを呼ぶ。ExclusionRules::load が組み込みの .git / .svn / node_modules fail-safe と exclude_dirs<kb_path>/.grooveignore を合成する — root の 1 枚のみ、.gitignore は見ない、case_insensitive(true)追加済み glob に遡及しないので必ず最初に呼ぶ。is_excluded(rel, is_dir) は祖先を 1 段ずつ dir として先に判定し、最初の除外で打ち切る。これが「枝刈りする walk」と一致する形で、git の「除外されたディレクトリ配下は ! で復活できない」規則そのもの。ignore crate はマッチャとしてのみ使い walkdir は残す — WalkBuilderhidden() が既定 true (Windows では「dot 始まり または FILE_ATTRIBUTE_HIDDEN」)、add_ignore が walk root ではなくプロセスの cwd 基準、require_git 次第で .gitignore の扱いが変わる、という 3 つの既定を持ち込むため。matched_path_or_any_parents意図的に不使用: 実測で「walk が決して到達しないファイル」に Whitelist を返すので、使うと drift が「どの API を呼んだか」のレベルで復活する。ファイル自体は links::read_checked 経由で読み、64 KiB / 1000 パターンで打ち切り、先頭 BOM を剥がす。読めなければ warn を出してそのファイル無しで続行する。境界は索引でありアクセスではない (validate_get_document_path 参照)
grooveseek/src/links.rs (v0.19.0+) hardlink 検出。symlink を既に拒否している 3 面 (full index / watcher / get_document) で使う。hard_link_count は Unix では symlink_metadatanlink、Windows では GetFileInformationByHandle (MetadataExt::number_of_links が nightly、かつ walkdir の WIN32_FIND_DATAW は link count を持たないため)。is_multiply_linkedfail-open — 同じ判定が deindex も gate しており、削除済みファイルには link count が無い。呼び出しは全箇所で拡張子フィルタの後: Windows は open が要り、Linux では全ディレクトリの link count が 2 以上になるため。(v0.20.0+) 第 2 の入口 read_checked が実際に中身を通す方で、file を 1 回だけ open し、link 数・ファイル種別・size cap をその 1 回の fstat から取ってから同じ descriptorでバイト列を読む。これで「walk が検査した後にそのパスへ hardlink を rename で被せる」経路が塞がる。Unix では open に O_NOFOLLOWO_NONBLOCK を付ける (symlink 差し替えを拒否、FIFO で止まらない)。Windows はどちらも付けない — symlink 作成に管理者権限が要り、reparse point を拒否すると OneDrive placeholder が全滅するため。module doc に「閉じないもの」(link→unlink、途中ディレクトリの symlink、link count を常に 1 と答えるファイルシステム) と「KB はセキュリティ境界ではない」ことを明記してある
grooveseek/src/poison.rs (v0.19.0+) poison した mutex から復帰する (panic を引き継がない)。recover / recover_try は mutex ではなく LockResult を受け取る — server の meta-test が呼び出し側の literal .lock() を数えているため。recover_db はさらに、Drop 時の ROLLBACK が失敗して開いたままになった transaction を巻き戻す (rusqlite はそのエラーを握り潰すので、以後の &self 書き込みが誰も commit しない transaction に吸い込まれる)。最初の復帰だけ warn、以後は debug (poison は sticky なので、毎回 warn するとリクエストごとに出続ける)
grooveseek/src/markdown.rs crate::parser::markdown::MarkdownParser への薄い shim。legacy parse() / parse_with_excludes() 公開 API を維持
grooveseek/src/watcher.rs notify-debouncer-full を tokio channel 越しに受信。拡張子 + path でフィルタして indexer::{reindex,deindex,rename}_single_file にディスパッチ。MCP サーバと並走 (tokio::spawn)。path 側の判定は index walk と同一の exclusion::ExclusionRules を使う。(v0.21.0+) batch に <kb_path>/.grooveignore が含まれていたら、分類より先にその規則を組み直す — ignore ファイルは registry にある拡張子を持たないので、通常の経路に流すと「変えようとしているフィルタ自身」に弾かれて変更に気づけない。run_watch_loop が state を単独所有しているため、再読込は &mut だけで済みロックは不要
grooveseek/src/transport/ MCP transport 抽象。mod.rs (Transport enum + CLI/config 解決)、stdio.rs (stdio)、http.rs (rmcp StreamableHttpService + axum、/mcp/healthz をマウント。v0.8.0+ で admin sub-router を追加: /ui + /api/admin/statusadmin_security_headers (CSP + nosniff、最外なので拒否応答にも載る) で包む。検証を行う route はすべて 1 枚の dns_rebinding_gate を通る (ADR-0009): peer → Host → Origin の順で、群ごとに別の DnsRebindingGate state を渡す — /mcp は effective な host / origin リスト、admin は allowed_admin_hosts + 同じ origin リスト + peer loopback 要求、/healthz は host リストのみ、しかも healthz_public = false の時だけ (既定では gate 無しで mount される)。rmcp 側の検査には意図的に空リストを渡すので、/mcp もここで検証される — しかも session gate の外側、つまり席の確保より前。/api/search は v0.27.0 で削除された。現在 /ui/mcp 経由で検索する)。KbServerShared を Arc 共有し session factory で接続ごとに軽量ハンドルを生成
grooveseek/src/transport/webui_index.html (v0.8.0+) /ui で配る運用者向け画面、transport/http.rs::ui_indexinclude_str! 経由 embed。Raw HTML + JS、CSS framework 不使用、外部リクエスト無し、textContent / createElement のみで XSS 安全 (= innerHTML 不使用)。状態帯 + 検索ボックスで、検索は /mcp を通すので Streamable HTTP 上の MCP クライアントの最小実例も兼ねる。ナレッジ閲覧は 1.x で MCP クライアント側へ移す予定 — stability.ja.md 参照。
crates/groove-tray/ (v0.9.0+) Windows 限定 system tray binary (groove-tray.exe、GUI subsystem) で daemon の監視 + lifecycle 制御。5 秒間隔で /api/admin/status を polling し、4 状態 status dot (緑 = healthy / 黄 = indexing / 赤 = 1 分以上 down / 灰 = polling 待ち) を描画、right-click menu 6 項目 (Status / Open Web UI / Start / Stop / Restart / Quit Tray)。start は PowerShell Start-ScheduledTask (= grooveseek/src/service/windows.rs と同 path) だが、stop は違う — v0.14.0 以降は /api/admin/status から daemon の pid を読み、Win32 API でそのプロセスを終了する (OpenProcess 1 回で handle を取り、image 名検証と TerminateProcess を同じ handle 上で行うので pid 再利用に当たらない)。Stop-ScheduledTask は両分岐で走るが、成否を決める役ではない: pid で止めた後は v0.9.1 以前の install が持つ task instance を掃除する best-effort (失敗は log して無視)、probe が使える pid を返さなかった場合 (status endpoint 不達を含む) は fallback として実行し、そのエラーは即 return せず後段の確認に委ねる。いずれの経路でも成否は daemon の設定 bind アドレスを bind できるか で判定し、機構自身の戻り値は信用しない。dual event loop: tao を main thread、tokio runtime を別 thread で spawn、EventLoopProxy::send_event で bridge。panic hook + tracing-appender::rolling::daily%LOCALAPPDATA%\groove\logs\tray.YYYY-MM-DD に log 出力。library API (install::install_autostart / uninstall_autostart) は groove service install --with-tray / service uninstall / service tray-install / service tray-uninstall から呼ばれ、shell:startup .lnk shortcut を PowerShell WScript.Shell COM 経由で管理。cargo-dist は groove-tray.exex86_64-pc-windows-msvc のみ artifact 化。
crates/groove-svc/ (v0.9.1+) Windows 限定の launcher binary (groove-svc.exe)。目的は groove.exe serveコンソールウィンドウ無しで起動することだけ。groove.exe は console-subsystem binary で、Windows はプロセス開始の に conhost を割り当てるため、プロセス内で隠しても ~1 秒間フラッシュしてから消える (プラットフォーム側の未修正挙動、microsoft/terminal#249)。本 crate は windows_subsystem = "windows" なので子に渡す console を持たない — が、それだけでは足りない: 継承できる console が無い console-subsystem の子は自分で AllocConsole() を呼び、結局新しい可視ウィンドウを作る。したがって spawn 時に CREATE_NO_WINDOW (0x0800_0000) を 必ず 渡し、CreateProcess に子の console 割り当てを skip させる。この flag と windows-subsystem の親の組み合わせで初めて 0-flash になる。stdio を null にして groove.exe を detach spawn し、自身は即 exit する。v0.9.1 以降、Task Scheduler の Action は groove.exe ではなくこちらを指す。child_argsserve を無条件に前置するため、grooveseek/src/service/windows.rs::resolve_action_target は Action の -Argument 節を空にする — この不変条件は 次回ログオンまで破綻が表面化しないので両側とも unit test 済。非 Windows ビルドは fail-fast stub にコンパイルされ workspace は全 OS でビルドできる。cargo-dist は x86_64-pc-windows-msvc のみ artifact 化。tray への影響: scheduled task 自身のプロセスは即終了するため、daemon の停止に Stop-ScheduledTask は使えない (tray 行を参照)。
crates/groove-grammar-abi/ (v1.2.0+) groove と tree-sitter grammar の間の契約。GrammarDescriptor が言語名・主張する単一の拡張子・parse table・tags query を運び、ABI_VERSION は grammar 側の tree-sitter ABI とは別の groove 自身の契約番号で、plugin がこれを宣言するので loader は不一致を拒める。焼き込まれた Rust grammar は descriptor を直接組み立て、別の動的ライブラリとして配られる grammar は同じものを組み立てて C ABI 越しに export する (loader は v1.3.0)。意図的にロジックを持たない — grammar が差し出すのはデータで、それを使う処理はすべて groove 側にある。これが「言語追加に言語ごとのコードを要らなくする」根拠。#![forbid(unsafe_code)]publish = false、バイナリとしては配布しない。ADR-0013 参照。
crates/groove-grammar-python/ (v1.3.0+) ロード可能なライブラリとしての Python grammar — groove が焼き込まない最初の grammar。src/lib.rsgroove_grammar_abi::groove_grammar_plugin! の呼び出し 1 つで、言語名 (python)・主張する拡張子 (py)・tree-sitter-python から取る parse table と tags query を渡すだけ。C ABI に関することは macro 側にあるので、2 つ目の grammar は manifest と macro 呼び出し 1 つであって、FFI 面をもう一度レビューする作業ではない。cdylib としてビルドされるので、cargo は groove_grammar_python にプラットフォームのライブラリ装飾を付けた名前を出す — parser/code/plugin.rs がファイルを探す時に組み立てるのと同じ名前。cargo-dist は独立した app として配布する: [[bin]] を持たない package には dist = true だけでは足りないので、manifest は package-libraries = ["cdylib"]cdylibs = [...] も持つ。targets意図的に書かない — Windows 限定の 2 つのバイナリが狭めているのと違い、workspace の 4 プラットフォームを継承させる。archive は package 名で作られるので、package 名とライブラリ名は cargo の 2 つの綴りで同じ 1 つの名前でなければならない。
grooveseek/src/schema.rs Frontmatter スキーマ検証。kb_path 直下の groove-schema.toml を読み、required / type / pattern / enum / min_length / max_length / allow_empty を検証。groove validate CLI から呼ばれ、text / JSON / GitHub annotation 形式で報告
grooveseek/src/embedder.rs fastembed-rs の薄いラッパ。ModelChoice で embedding モデル (BGE-small-en-v1.5 / BGE-M3) を選択。RerankerChoice + Reranker で optional な cross-encoder 再ランク
grooveseek/src/db.rs rusqlite + sqlite-vec + FTS5 (trigram)。chunks / vec_chunks / fts_chunks スキーマと CRUD を管理。search_hybrid (Reciprocal Rank Fusion。定数 k と bm25 列重みは v0.13.0 以降 [search.fusion] で設定可能、既定は k = 602.0 / 1.0 / 1.0) と v0.7.0 で追加した unbounded variant (MMR / parent retriever 用) を提供。SearchFilters 構造体でフィルタ引数 (path glob / tags / date range / min_quality) を集約、MatchSpan でバイトオフセット引用を表現 (v0.3.0 追加)。chunks.level (v0.7.0 追加) で h2 / h3 を区別
grooveseek/src/db/schema.rs (v0.15.0+) スキーマ作成と前方マイグレーション。全コンストラクタが呼ぶ Database::init から実行されるので、DB を開くことが更新することにあたる。
grooveseek/src/db/search.rs (v0.15.0+) 検索: ベクトル KNN、FTS5 候補、両者を融合する RRF。挙動が数値として観測できる側の半分。
grooveseek/src/db/fts_query.rs (v0.16.0+) クエリ文字列を db/search.rs が投げる FTS5 の MATCH 式にコンパイルする。parse_query"..." の区間を逐語で温存し (FTS5 自身の doubled-quote 規約)、残りを Separator で、さらに文字種境界 (漢字 / ひらがな / カタカナ / それ以外の語構成文字) で割り、3 文字の trigram 下限に満たない run は同じ群の中で隣接 run に連結し、できた phrase を ` OR ` で結合する (重複除去、上限 32 個)。出力される phrase は必ず入力の連続部分文字列 — trigram tokenizer は部分文字列しか照合できないので、そうでない phrase は原理的に何にもマッチしない。phrase が 1 つも作れないクエリは、3 文字以上なら v0.16.0 以前の形式 (trim 後のクエリ全体を 1 phrase) に fallback し、それ未満ならベクトル単独になる。(v1.1.0+) whitespace 区切りの group の先頭にある - は同じ規則でトークン化されるが検索ではなく除外に回り、別枠で上限 32 個。ParsedQuery::match_expr が両側を (positives) NOT (negatives) として結合し、ParsedQuery::positive_text は除外 group を切り落とした raw query で、embedder・reranker・match_spans に渡る (ADR-0011 参照)。query 側だけの変更で schema も tokenizer も不変 = 再 index は不要
grooveseek/src/db/storage.rs (v0.15.0+) ドキュメントとチャンク。1 文書の書き込みは documents / chunks / fts_chunks / vec_chunks を整合させる複数テーブル操作なので、呼び出し側が tx を持っていない時だけ自分で開く (is_autocommit())。
grooveseek/src/db/meta.rs (v0.15.0+) index 単位のメタデータ・統計・全体メンテナンス: index_meta (埋め込みモデル / 次元 / context mode)、document / chunk 件数、rename 検出用の path→hash 表、および (AU-71) corpus_snapshot — 件数と索引済み chunk の digest を 1 トランザクション内で読む。
grooveseek/src/mmr.rs (v0.7.0+) Maximal Marginal Relevance の貪欲再ランク + 類似度キャッシュ。mmr_select は post-rerank の候補プールに対して動き、[search.mmr] 設定または per-call mmr パラメータで gating される
grooveseek/src/parent.rs (v0.7.0+) 表示時 parent retriever。apply_parent_retriever がヒットチャンクを expand_adjacent (level 整合な隣接 sibling マージ) または expand_whole_document (whole_doc_threshold_tokens 未満チャンクの全文 fallback) で拡張する。score / rank / match_spans は元のヒットを保ち、content と新フィールド expanded_from のみが変わる
grooveseek/src/quality.rs チャンク単位の品質スコアリング (長さ / 定型語 / 構造シグナル)
grooveseek/src/graph.rs ベクトルインデックス上での Connection Graph BFS。get_connection_graph MCP ツールと groove graph CLI から利用
grooveseek/src/graph_render.rs 完成した connection graph を図にする。Graphviz DOT と、依存に頼らず自前でレイアウトする単体 SVG (探索結果は木なので、深さ = 列 / 兄弟順 = 行で置ける)。CLI 専用で、MCP ツールは JSON のまま
grooveseek/src/eval.rs groove eval CLI 用のリトリーバル品質評価 (opt-in)。Golden YAML を parse し、各クエリを db.search_hybrid で実行、recall@k / MRR / nDCG@k を計算。<kb_path>/.groove-eval-history.json を読み書きして前回との差分を表示。ConfigFingerprint (v0.7.0+) は mmr / parent_retriever / fusion (v0.13.0+) を optional に保持し、設定違いの eval 実行を別 history entry として区別する。いずれもビルトイン既定値と異なるときだけ記録するため、旧 baseline との比較は維持される。索引済みコーパスも走査し (v0.24.0+)、golden query を 2 件以上逐語で含む文書を stderr と findings に報告する (exit code は動かさない)。serve / search / index の挙動は一切変えない
grooveseek/src/tune.rs (v0.13.0+) groove tune CLI 用の測定ツール (opt-in)。RRF 定数と FTS5 bm25 列重みの固定グリッドを golden query セット上で掃引し、nested leave-one-query-out CV (paired SE / selection stability / 副指標の非悪化。sign test も算出して report に載せるが decide は参照しない) で結果をガードした上で、貼り付け可能な [search.fusion] スニペットか「既定値維持」の結論のどちらかを出力する。自動では何も適用せず、reranker も一切使わない。evalGoldenSet / compute_query_metricsdb::fuse_rrf_ids を再利用する
grooveseek/src/tune/grid.rs (v0.15.0+) groove tune が掃引するパラメータ空間と、掃引中に持ち回る per-query 状態。
grooveseek/src/tune/stats.rs (v0.15.0+) 採否判定の統計 — 平均・標本 SD・paired SE・sign test と、採用閾値 ADOPT_MIN_MEAN_DELTA / ADOPT_SE_MULTIPLIER / STABILITY_MIN
grooveseek/src/tune/report.rs (v0.15.0+) 掃引結果の stdout 向け整形 (text は print!、JSON 形式も)。
grooveseek/src/test_support.rs #[cfg(test)] 限定src/** の unit test が共有する一時ディレクトリ生成 — 全テストが同じ作り方をするために置いてある。名前は PID + ナノ秒 + アトミックカウンタで、前 2 つだけでは同一プロセスの並列スレッドで衝突する (推測ではなく実測)。Drop guard が木ごと消す。tempfile crate は意図的に使わない。tests/ 配下の integration test からは届かないので、そちらは tests/common/temp.rs に自前の実装を持つ

データフロー

.md / .txt / .pdf / .docx / .xlsx / .pptx / .rs ファイル (.py は plugin が要る)
(Registry::extensions() でフィルタ。既定で有効なのは .md のみ)
     │
     ▼ walkdir
indexer.rs: SHA-256 content-hash を chunks.hash と比較
     │
     ▼ 変更ありのファイルのみ
parser/: 拡張子で Parser を選択 → frontmatter + title 抽出 + チャンク化
     │
     ▼
embedder.rs: fastembed で embedding 生成
              (BGE-small-en-v1.5 → 384 次元、BGE-M3 → 1024 次元)
     │
     ▼
db.rs: chunks (メタデータ) + vec_chunks (embedding)
       + fts_chunks (FTS5 trigram) に UPSERT

検索時、search ツールはハイブリッド検索を実行する:

v0.7.0 のフルパイプラインは RRF → reranker → MMR → parent retriever → match_spans。各段は対応する設定が off なら no-op となるため、既定では v0.7.0 以前の挙動に等しい。narrative は retrieval-pipeline.ja.md を参照。

Contextual Retrieval (v0.12.0+)

静的 Contextual Retrieval (feature-46) は、各チャンクが embedder / FTS index / reranker に渡る前に、ドキュメント構造由来の breadcrumb を前置する機能。すべて index 時に LLM 呼び出しなしで決定論的に生成される。[contextual].enabled で on/off する (v0.12.0 時点の既定は off ―― judgment gate の結果として false-by-default に転換した経緯は docs/usage.ja.md の「Contextual Retrieval」節の A/B 数値を参照)。

Embedding キャッシュの解決

embedder.rs::resolve_cache_dir() が以下の順で解決する:

  1. FASTEMBED_CACHE_DIR 環境変数 (最優先)
  2. OS 標準キャッシュディレクトリ + fastembed:
    • Linux: ~/.cache/fastembed
    • macOS: ~/Library/Caches/fastembed
    • Windows: %LOCALAPPDATA%\fastembed
  3. CWD 直下の .fastembed_cache/ (最終フォールバック)

初回実行時、選択した ONNX モデルが HuggingFace hub 互換のキャッシュ構造で DL される (BGE-small: 約 130 MB、BGE-M3: 約 2.3 GB、BGE-reranker-v2-m3: 約 2.3 GB)。2 回目以降は再 DL されない。

fastembed-rs の native TLS が HuggingFace への接続に失敗する場合 (企業プロキシや TLS inspection の影響) は、docs/clients.ja.md の「HuggingFace の TLS 失敗への対処」節を参照して huggingface_hub CLI で迂回する。

CLI 出力規約

groove CLI は stdout = データ出力 / stderr = 進捗 の規約に従う:

新規 subprocess test を書く場合は、grooveseek/src/main.rs の対応する Commands::* block を grep して、その subcommand が stdout / stderr のどちらに書くかを必ず先に確認する。arm 自身ではなく helper (print_search_results / print_graph / print_validate_report / print_doctor_report) が出力している場合がある点に注意。上の 2 つのリストを読むこと。数えないこと — 責務分離は ADR-0010 が決着させ docs/stability.ja.md が凍結しており、リストの横に書いた数はリストと別に腐るserve は別枠: CLI 出力は無いが、既定の stdio transport では MCP プロトコル自体 が stdout を占有する (subprocess harness が drain し続けねばならないのはこのため)。grep は println! だけでなく print! 対象にすること — eval / tune の text 分岐はそちらを使っている。

主要な依存