この記事について

前回の記事では voracle の設計と初期実装 – ONNX embedding + ColBERT reranking、MCP サーバー、distil コマンドまでを書いた。今回はその続きで、research パイプラインを実戦投入してから安定させるまでの約1週間の話になる。

「勝手に賢くなる」外部記憶装置という設計思想

voracle を作ったもともとの動機は「あれ、それ」で曖昧に探せるセマンティック検索だった。ただ、research パイプラインを組んだあたりから、もう少し大きな構想が見えてきた。

日常の開発で何かを調べるとき、ブラウザで検索して読んで閉じる。その知見はブラウザの履歴に埋もれて二度と出てこない。voracle の research コマンドは、そのプロセスを vault に接続する。Brave Search API で取得したウェブコンテンツを自動で Markdown に変換し、サマリを生成して vault に保存する。

これの意味するところは、自分の関心事が意識せずに集積されていくということだ。能動的に CLI を叩いて探索しなくても、日常の開発で調べた知見がObsidian vault – 自分の外部記憶装置に、意図せず溜まっていく。そして開発サイクルの中で AI エージェントが vault を検索して「前にこれ調べてましたよね」と返してくる。知識が循環する構図になる。

この設計思想を裏で支えるために、3月末から4月頭にかけて research パイプラインの安定化をやった。


vault の構造化 – インデックス対象を「確定した知識」に絞る

まず vault のディレクトリ構成を整理した。voracle がインデックスするのは確定したナレッジだけにしたい。作業中の下書きやシステムファイルが混ざると検索ノイズになる。

  obsidian/
├── articles/           # 自分の記事
│   ├── _drafts/        # ラフ(draft段階)
│   └── _processed/     # 記事済みアーカイブ
├── web-research/       # research コマンドの出力先
│   └── {category}/note_id.md
├── tasks/              # タスク管理
├── thoughts/           # 関心事メモ
└── works/              # 作業記録
    └── development/
        ├── _claude/
        ├── _codex/
        └── _gemini/
  

除外ルールは単純にした。_ プレフィックスと . プレフィックスは一律でインデックスしない。_drafts/_processed/.search_result.jsonl のような中間成果物は全部ここに引っかかる。

  # vault の状態確認
voracle -status
  
  vaults: 1
  obsidian: 847 notes, 2,341 chunks
  excluded: _drafts, _processed, _claude, _codex, _gemini
index: .voracle.usearch (f16 HNSW, 2341 vectors)
store: .voracle.db (SQLite)
  

この設計で検索対象は「書き終わった記事」「research で取得したウェブ知見」「自分のメモ」に絞られる。半端な状態のものは入ってこない。


research の実戦投入 – TurboQuant を調べてみる

vault の構造を整えた同じ日に、research コマンドを実際のリサーチタスクに投入した。Google の TurboQuant とローカル LLM の量子化手法について調べる。

  voracle research "TurboQuant|local LLM"
  

パイプラインの動作は以下のとおり:

  1. クエリからキーワードを抽出
  2. Brave Search API で日英両言語の Web + News を検索
  3. URL を重複排除してリスト化
  4. 各 URL で取得、html-to-markdown-rs で Markdown に変換
  5. コンテンツの質とノベルティでスコアリング
  6. 上位 N 件を vault の web-research/ に Markdown ファイルとして保存
  7. サマリと frontmatter(トピック、カテゴリ、難易度)を自動生成

結果は 61,813 文字、約 26,540 トークンに達した。Claude Code のツール結果サイズ上限を超えたのでファイル経由で読むことになったが、リサーチとしてはまともに動いた。

日英の dual-language 検索がここで効いている。英語で “TurboQuant quantization” を探しつつ、日本語でも「量子化 ローカル LLM」を並行して投げる。カウンター言語のキーワード選定にはトピック設定ファイルの maxsim マッチングを使っていて、片方の言語で拾えない情報をもう片方で補完する仕組みになっている。 追記: 日本語の記事はあまり有用なものがみつからないのと、英語ベースで探したものほうがまた和訳のない良い情報が見つかるのでスコアリングを調整して、殆どの記事は英語ベースでとるようにした。


Rust のマルチバイト文字パニック – 実戦投入すると壊れる

同じ日にもう一発。research をもう少し広いクエリで実行した。

  voracle research "TurboQuant|ColBERT LLM|Modern BERT"
  

パニックした。

  thread 'main' panicked at src/infra/inference/lfm.rs:237:49:
byte index 2000 is not a char boundary; it is inside '━' (bytes 1998..2001)
  

原因はすぐにわかった。(claudeが)サマリ生成前にテキストを切り詰める処理で、バイト位置でスライスしていた:

  // NG: バイト位置 2000 が文字の途中に落ちる可能性がある
let truncated = if text.len() > 2000 { &text[..2000] } else { text };
  

text.len() は Rust ではバイト数を返す。罫線文字 (U+2501)は UTF-8 で 3 バイト(1998..2001)を占めていて、バイト位置 2000 がちょうどその文字の2バイト目に当たっていた。Brave Search 経由で取得するウェブコンテンツには罫線、記号、日本語がふつうに混ざるので、これは確実に踏むバグだった。

修正は floor_char_boundary を使う:

  let truncated = if text.len() > 2000 {
    let boundary = text.floor_char_boundary(2000);
    &text[..boundary]
} else {
    text
};
  

floor_char_boundary は指定バイト位置以下の最も近い文字境界を返す。2000 の位置が文字の途中なら 1998 まで戻してくれる。Rust 1.73 で安定化された API で、まさにこの用途のためにある。

テストも追加:

  #[test]
fn truncate_multibyte_boundary() {
    // '━' (U+2501) は UTF-8 で 3 バイト (0xE2 0x94 0x81)
    let text = "a".repeat(1998) + "━━━";
    assert_eq!(text.len(), 2007); // 1998 + 9
    let boundary = text.floor_char_boundary(2000);
    assert_eq!(boundary, 1998);
    let truncated = &text[..boundary];
    assert!(truncated.is_char_boundary(truncated.len()));
}
  

この手のバグは英語だけのテストデータでは見つからない。日本語コンテンツを実際に食わせて初めて出てくる。実戦投入の価値はこういうところにある。


ONNX 推論エンジンの Qwen3 移行

もうひとつ大きめの変更をやった。

voracle の summarize / keyword extraction / frontmatter 生成は LiquidAI の LFM2.5-350M-ONNX で動かしていたが、ライセンスの制約から onnx-community/Qwen3.5-0.8B-ONNX に切り替えた。

選定理由:

  • Apache 2.0 ライセンスで商用利用に問題がない
  • 0.8B パラメータで推論負荷が現実的
  • ONNX 形式(INT8/FP32)で直接ロードできる

infra/inference/ モジュールの構成は維持しつつ、LFM2.5 が担っていた箇所を Qwen3.5-0.8B に差し替えた:

  src/infra/inference/
  embed.rs     # 密ベクトル埋め込み(pplx-embed-v1-0.6b → 変更なし)
  colbert.rs   # ColBERT MaxSim リランキング(変更なし)
  lfm.rs       # LFM2.5 → Qwen3.5-0.8B に置き換え
  

ONNX Runtime は ort = "=2.0.0-rc.12" のバージョン固定。これは multi-bert-inference や edge-retrieval と共通の方針で、ort クレートは RC 版の間に破壊的変更が入ることがあるため、動作確認済みのバージョンに固定している。

  # Cargo.toml
[dependencies]
ort = "=2.0.0-rc.12"
tokenizers = "0.21"
  

トークナイザーは tokenizers クレートで tokenizer.json を読む。Qwen3 系は SentencePiece ベースではなく独自のトークナイザーフォーマットだが、HuggingFace の tokenizers クレートがそのまま対応している。

  # モデル配置
ls ~/.local/share/voracle/models/
  
  qwen3.5-0.8b-onnx/
  model.onnx
  model_quantized.onnx
  tokenizer.json
  config.json
pplx-embed-v1-0.6b/
  model.onnx
  tokenizer.json
mxbai-edge-colbert/
  model.onnx
  tokenizer.json
  

切り替え後の research パイプラインで生成されるサマリの品質は LFM2.5 と同等以上かな、正直悪くないとしか。特に日本語コンテンツのサマリは改善された感触があった。


research パイプラインの安定化

research パイプラインのインポートロジック全体をレビューし、不足していたテストを追加した。

パイプラインの全体フロー

改めて整理すると、research コマンドは以下の順序で動く:

  クエリ入力
  → キーワード抽出 (Qwen3.5-0.8B)
  → Brave Search (primary language + counter language)
  → URL 重複排除・ドメインストライク除外で HTML 取得
  → tree-sitter-html でコンテンツ抽出 + ボイラープレート除去
  → html-to-markdown-rs で Markdown 変換
  → スコアリング → 上位 N 件を選出
  → vault writer で web-research/ に保存
  → サマリ + frontmatter 生成 (Qwen3.5-0.8B)
  

ドメインストライクは、ペイウォールや空コンテンツを繰り返し返すドメインを自動で除外する仕組みだ。3回以上失敗したドメインは Brave Search のクエリに -site: で除外が入る。ドメイン単位での管理なので、特定のドメインだけ見てもしょうがないので全体の検索品質を守れる。

  # ストライク状況の確認
voracle admin strikes

# 誤爆したドメインの復帰
voracle admin allow example.com
  

テスト追加

レビューで見つけたテストの穴を埋めた:

  • writer.rs: vault 書き込み時の既存ファイルとの衝突処理
  • resolver.rs: _ / . プレフィックス除外ルールの境界ケース
  • extraction/: 空コンテンツやエラーレスポンスのハンドリング
  #[test]
fn writer_avoids_overwriting_existing_note() {
    let dir = tempdir().unwrap();
    let writer = VaultWriter::new(dir.path());

    // 同一 URL で2回書き込み
    writer.write_research_note(&entry_a).unwrap();
    writer.write_research_note(&entry_a_updated).unwrap();

    // 2回目は別ファイルになる(上書きしない)
    let files: Vec<_> = fs::read_dir(dir.path()).unwrap().collect();
    assert_eq!(files.len(), 2);
}

#[test]
fn resolver_excludes_underscore_prefix() {
    let dir = tempdir().unwrap();
    fs::create_dir_all(dir.path().join("_drafts")).unwrap();
    fs::write(dir.path().join("_drafts/note.md"), "# test").unwrap();
    fs::write(dir.path().join("visible.md"), "# test").unwrap();

    let resolver = VaultResolver::new(dir.path());
    let notes = resolver.scan();
    assert_eq!(notes.len(), 1);
    assert_eq!(notes[0].file_name(), "visible.md");
}
  

agent-gateway への MCP プリセット統合

vault と research パイプラインが安定したところで、agent-gateway の MCP プリセットに voracle を追加した。

agent-gateway は LLM エージェントが MCP ツールを使うためのゲートウェイで、プリセット定義で各ツールのサーバー設定を管理している:

  // internal/agent/mcp/presets.go

func PresetVoracle(command string) ServerConfig {
    if command == "" {
        command = "voracle"
    }
    return ServerConfig{
        Name:    "voracle",
        Command: command,
        Args:    []string{"--server"},
    }
}

func PresetOkitegami(command string) ServerConfig {
    if command == "" {
        command = "okitegami"
    }
    return ServerConfig{
        Name:    "okitegami",
        Command: command,
        Args:    []string{"--server"},
    }
}
  

これで agent-gateway 上のエージェントが voracle を直接呼び出せるようになった。エージェントが vault を検索し、必要に応じて research を実行し、結果を vault に蓄積する。人間が意識しなくても知識が循環する仕組みの、最後のピースが繋がった形になる。


現在の voracle

ここまでの作業は実質半日くらいのコーディングだった。ただ、設計の工夫は毎日の散歩中に思いついたものを少しずつ反映していった結果で、一気に書いたというよりは日常の中で育てた感覚に近い。

現在のコマンド体系はこうなっている:

voracle -h のヘルプ出力
voracle -h -- 現在のコマンド体系

実際に日常で一番使っているのは grepresearch だ。grep はセマンティック検索で、完全一致ではなく意味的に近いチャンクを引っ張ってくる:

  [email protected] ~ % voracle grep "turbo quant"
/Users/ksh3/Development/obsidian/web-research/2026/04/09/technology/2029b5389eccfe38.md:16  [How to test TurboQuant]  (4.2379)
  Verify correctness and measure real-world impact before deploying TurboQuant to production.

/Users/ksh3/Development/obsidian/web-research/2026/04/09/technology/2029b5389eccfe38.md:5  [Triton + vLLM (serving workloads)]  (4.2284)
  Advanced
  Engineers building inference serving pipelines who want to test TurboQuant in a vLLM-like environment

/Users/ksh3/Development/obsidian/web-research/2026/04/09/technology/2029b5389eccfe38.md:2  [How to use TurboQuant]  (4.2177)
  A practical guide for developers who want to try TurboQuant KV cache compression. Covers available implementations, s...
  

research で集めた知見も、自分の記事も、同じインデックスから横断的に引ける:

  [email protected] ~ % voracle grep "GLM-5.1"
/Users/ksh3/Development/obsidian/articles/llm/glm-4-7-flash-cpu-hybrid-gpu-benchmark_en.md:19  [NVFP4 + vLLM Operational Evaluation]  (6.9704)
  In addition to the IQ5_K benchmarks above, GLM-4.7-Flash-NVFP4 was also evaluated on vLLM for operational suitability.

/Users/ksh3/Development/obsidian/articles/llm/glm-4-7-flash-cpu-hybrid-gpu-benchmark_ja.md:33  [共通変数]  (6.8849)
  IMG=compute.home.arpa/ik_llama-cpu:latest
  MO=/mnt/data/hf/hub/models--ubergarm--GLM-4.7-Flash-GGUF

/Users/ksh3/Development/obsidian/web-research/2026/04/08/technology/5ee34b17c638fe00.md:40  [GLM-5 - GlmMoeDsa]  (6.7509)
  The zAI team launches GLM-5, and introduces it as such:
  > GLM-5, targeting complex systems engineering and long-horizon agentic tasks.
  

ドメインストライクも育ってきた。ペイウォールで中身が取れないドメインは自動で除外される:

  [email protected] ~ % voracle admin blocklist
Exclude Domains (strike >= 3 = excluded):
  [X] github.com  40  (paywall)
  [X] www.ponkotsu.dev  11  (paywall)
  [X] medium.com  7  (paywall)
  [X] dev.to  4  (paywall)
  [X] machinelearningmastery.com  3  (paywall)
  ...
  

research コマンドは MCP ツールとしても動くので、Claude Code や agent-gateway のエージェントが開発中にこれを呼ぶ。自分が調べたことも、AI が調べたことも、同じ vault に溜まっていく。コンテキストを消費せずに外部記憶を参照できるのが大きくて、最近は何か調べたいときにまず voracle grep を叩く癖がついた。

散歩中に思いつく工夫の大半は「こういうノイズを減らしたい」「このパターンの検索結果をもっと上に出したい」みたいな小さな改善で、それを家に帰ってから30分くらいで実装する。そういうサイクルが回り始めると、ツール自体が自分の思考パターンに馴染んでくる。