Skip to content

[Feature] search コマンド実装(全文検索・タグ/パス/見出し/種別フィルタ) #9

@Kewton

Description

@Kewton

概要

commandindex search コマンドを実装する。tantivy インデックスを使った全文検索と各種フィルタリングを提供する。

背景・動機

Phase 1 のユーザー向け主機能。インデックスされた Markdown 知識を高速に検索・取得できるようにする。

提案する解決策

CLI オプション(Phase 1 対象)

commandindex search "認証の流れ"                        # 全文検索
commandindex search "認証" --tag auth                   # タグ絞り込み
commandindex search "認証" --path docs/                  # パス絞り込み
commandindex search "認証" --type markdown               # 種別絞り込み
commandindex search "認証" --heading "認証フロー"        # 見出し絞り込み
commandindex search "認証" --tag auth --path src/ --format json  # 組み合わせ
commandindex search "認証" --limit 10                    # 件数制限
フラグ デフォルト 説明
(なし) String(位置引数) 必須 全文検索(body + heading + tags)
--tag <tag> Option<String> None frontmatter タグで絞り込み(AND 条件)
--path <path> Option<String> None パスプレフィックスで絞り込み
--type <type> Option<String> None ファイル種別で絞り込み(markdown 等)
--heading <text> Option<String> None 見出しフィールド限定の絞り込み(query との AND 条件)
--format <fmt> OutputFormat human 出力形式(human / json / path)
--limit <n> usize 20 結果件数の上限

: query は必須。--tag/--heading は query と組み合わせてフィルタとして使用する。

検索ロジック

  • tantivy の BM25 スコアリングを使用
  • 複数フラグは AND 条件で結合

フィルタ実装方式

フィルタ 実装方式 詳細
--tag tantivy クエリ(QueryParser 経由、tags フィールド限定検索) ユーザー入力を QueryParser で tags フィールドに対してパースし、lindera トークナイザと整合性のあるクエリを構築
--path ポストフィルタ(path.starts_with(prefix) path は STRING 型のためポストフィルタで実現
--type ポストフィルタ(path の拡張子から種別判定) Phase 1 では markdown (.md) のみ対応
--heading tantivy クエリ(QueryParser 経由、heading フィールド限定検索、query との AND 結合) heading は ja_text でインデックス済み

tantivy クエリ構築の詳細

query, --tag, --heading を BooleanQuery で AND 結合:

use tantivy::query::{BooleanQuery, Occur};

let mut sub_queries = vec![];

// 1. メインクエリ: heading + body + tags の3フィールド
let main_parser = QueryParser::for_index(&index, vec![heading, body, tags]);
sub_queries.push((Occur::Must, main_parser.parse_query(&options.query)?));

// 2. --tag: tags フィールド限定
if let Some(ref tag) = options.tag {
    let tag_parser = QueryParser::for_index(&index, vec![tags]);
    sub_queries.push((Occur::Must, tag_parser.parse_query(tag)?));
}

// 3. --heading: heading フィールド限定
if let Some(ref heading_query) = options.heading {
    let heading_parser = QueryParser::for_index(&index, vec![heading]);
    sub_queries.push((Occur::Must, heading_parser.parse_query(heading_query)?));
}

let combined = BooleanQuery::new(sub_queries);

ポストフィルタと limit の戦略

ポストフィルタ(--path, --type)が指定されている場合:

  • tantivy から limit * 5 件を1回で取得(リトライなし、シンプルに)
  • Rust 側で path.starts_with / 拡張子判定のフィルタを適用
  • フィルタ後の結果を limit 件まで切り詰め
  • ポストフィルタなしの場合は tantivy の limit をそのまま使用

Phase 1 ではシンプルな1回取得方式を採用。件数不足が問題になる場合は Phase 2 で段階取得を検討。

検索 API 設計

IndexReaderWrappersearch_with_options() メソッドを新規追加(既存の search() は後方互換として維持):

pub struct SearchOptions {
    pub query: String,
    pub tag: Option<String>,
    pub heading: Option<String>,
    pub limit: usize,
}

pub struct SearchFilters {
    pub path_prefix: Option<String>,
    pub file_type: Option<String>,
}

impl IndexReaderWrapper {
    /// 既存メソッド(後方互換)
    pub fn search(&self, query_str: &str, limit: usize) -> Result<Vec<SearchResult>, ReaderError> { ... }

    /// フィルタ付き検索(新規)
    pub fn search_with_options(
        &self,
        options: &SearchOptions,
        filters: &SearchFilters,
    ) -> Result<Vec<SearchResult>, ReaderError> { ... }
}
  • SearchOptions: tantivy BooleanQuery として構築
  • SearchFilters: 検索結果取得後にRust側で適用するポストフィルタ
  • 戻り値は Result<Vec<SearchResult>, ReaderError>(cli 側で SearchError に変換)

ファイル種別マッピング

--type 値 対象拡張子
markdown .md

Phase 1 では markdown (.md) のみ対応。Phase 3 以降で code 等を追加予定。

実装対象ファイル

変更ファイル

ファイル 変更内容
src/main.rs Commands::Search にオプション追加(tag, path, file_type, heading, limit)、ハンドラで cli::search::run 呼び出し
src/indexer/reader.rs SearchOptions/SearchFilters 構造体、search_with_options メソッド追加、BooleanQuery import
src/cli/mod.rs pub mod search 追加
tests/cli_args.rs search_subcommand_exits_with_not_implemented → search_without_index_shows_error + search_with_all_options_accepted に置き換え

新規ファイル

ファイル 内容
src/cli/search.rs search コマンド実行ロジック(run 関数、SearchError 型)

エラー型設計

pub enum SearchError {
    IndexNotFound,
    Reader(ReaderError),
    Output(OutputError),
}

impl fmt::Display for SearchError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SearchError::IndexNotFound => write!(f, "Index not found. Run `commandindex index` first."),
            SearchError::Reader(e) => write!(f, "Search error: {e}"),
            SearchError::Output(e) => write!(f, "Output error: {e}"),
        }
    }
}

CLI --type フィールド名

/// Search the index
Search {
    query: String,
    #[arg(long, value_enum, default_value_t = OutputFormat::Human)]
    format: OutputFormat,
    #[arg(long)]
    tag: Option<String>,
    #[arg(long)]
    path: Option<String>,
    #[arg(long = "type")]
    file_type: Option<String>,
    #[arg(long)]
    heading: Option<String>,
    #[arg(long, default_value_t = 20)]
    limit: usize,
}

インデックス存在確認

search ハンドラの先頭で .commandindex/tantivy ディレクトリの存在を確認し、未作成時はエラー表示。

検索結果0件時の出力

No results found.

stdout への format_results 呼び出しは省略し、stderr に上記メッセージを表示。

tags 格納形式の統一(関連修正)

現在 cli/index.rsfm.tags.join(", ")(カンマ+スペース区切り)で格納しているが、output/mod.rsparse_tags()split_whitespace() を使用。本 Issue の範囲で以下を統一:

  • 格納形式: fm.tags.join(" ")(スペース区切り)に変更
  • parse_tags: 変更なし(split_whitespace で整合性あり)

受け入れ基準

  • 全文検索(日本語・英語)が動作する
  • --tag によるタグ絞り込みが動作する
  • --path によるパス絞り込みが動作する
  • --type による種別絞り込みが動作する
  • --heading による見出し検索が動作する
  • --format で出力形式を切り替えられる
  • --limit で結果件数を制限できる
  • 複数フラグの組み合わせが AND 条件で動作する
  • インデックス未作成時にエラーメッセージと案内が表示される
  • 検索結果0件の場合に適切なメッセージが表示される
  • 検索レスポンスが 500ms 以内(1500ファイル規模、20件取得)
  • cargo test / clippy / fmt 全パス
  • 既存テスト(indexer_tantivy.rs, output_format.rs)が壊れない

依存 Issue

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions