技術深掘り・ワークフロー
技術深掘り:CLIと意味検索はどう動くか
200件超のガイドから、目の前の課題に合う1件を、トークンを無駄にせず、オフラインで、ミリ秒で見つける。その検索の仕組みを3世代の作り替えから解説した技術記事です。
土台の考え方:段階的な開示と、エージェント側のRAG
ガイド全部は1.7MBのMarkdownです。これを AGENTS.md に貼るのは現実的ではありません。そこで検索CLIを持つ構成になっています。
- 平常時、エージェントが見ているのはスキルの説明文だけです(約205トークン)。
- フロントエンドの課題で、エージェントがCLIで検索します。
modern-web-guidance search "animate height auto"のような形で、関連する10件(約530トークン)が返ります。 - 選んだガイドを
modern-web-guidance retrieve animate-to-intrinsic-sizesで丸ごと取り込みます(約800トークン)。互換性、フォールバック、書き方の要点が入ります。
MCPサーバではなくCLIにした理由も書かれています。MCPはトークンの無駄か、動的な道具発見による起動の不確かさがあること。Web検索は古い回答や参照文書が混ざり、ネットワーク越しで数秒かかり、オフラインでは動きません。ローカルのCLIはCPUでミリ秒で動きます。
3世代の作り替え
| 指標 | 第1世代 | 第2世代 | 第3世代(現在) |
|---|---|---|---|
| 中身 | LanceDB+ネイティブONNX | TensorFlow.js+MiniLM-L6-v2 | 静的なInt8埋め込み×Gemini埋め込み+BM25 |
| パッケージの大きさ | 507MB(展開後) | 23.4MB | 7.1MB |
| 検索の初回(コールド)の遅さ | 約35ms(ただしビルドできれば) | 770ms | 185ms |
| 検索語の埋め込み(温まった後) | 約34ms | 約227ms | 30µs(0.03ms) |
| 最大メモリ | 400MB超 | 346MB | 145MB |
| 実地の検索の的中率(Hit@K) | 未測定 | 80.6%→調整後94.7% | 95.4%(クラウドのGemini埋め込みAPIでは96.9%) |
第1世代:507MB
LanceDB、Apache Arrow、Transformers.jsとネイティブのONNXランタイムの構成です。開発者の機械では動きましたが、スキルを入れるときにアーキテクチャ依存のバイナリのダウンロードやC++のコンパイルが走り、クラウドのサンドボックスでは失敗しがちでした。展開後は507MBありました。
第2世代:23MB
ネイティブを捨て、純JavaScriptのCPUエンジンに替えました。TensorFlow.jsの400超のモジュールからMiniLMが使う26のカーネルだけを残して6MB→830KBにし、ベクトルの置き場はLanceDBから、ビルド時に圧縮したベクトルとメモリ上の内積走査(1ms未満)に替えました。埋め込み器は遅延読み込みにして、retrieve と list は10ms未満で終わります。依存なしで23MBになり、約75万回の検索が実行されました。
第2世代が実地で壊れたところ
スキルが入っているのに古いコードを書いた実行を調べると、検索の穴が2つ見つかりました。
- 語彙が古い。MiniLMの学習は2018〜2020年頃で、単語分割の語彙が2020年以降のWeb APIを知りません。
fetchpriorityが「fetch」「##pr」「##ior」「##ity」の4つの意味のない断片に割れてしまいます。 - しきい値が厳しすぎた。関係ないガイドを返さないための類似度0.38の下限が、正しい一致まで捨てていました。「confirm destructive action(破壊的な操作の確認)」と「dialog」の類似度は0.349で、下限未満でした。検索が空を返すと、スキルは全ガイドの説明文(約1,500トークン)を読み込む指示になっていて、無駄も出ていました。
実例では、削除確認のプロンプトに対してエージェントが正しく検索したのに結果が空で、結局 window.confirm() を書きました。本来は <dialog closedby="any"> のガイドがある課題です。
直し方:実地のデータで測り、ハイブリッド検索にする
元のベンチマークはガイド自身から作った850件の合成クエリで、ガイドの言い回しに寄りすぎていて、この失敗を捉えられませんでした。新しい評価は、実際のCLIの利用記録から取った約4,000件の検索(記録されるのはエージェントの2〜5語の検索引数だけで、利用者のプロンプトやコードは記録されません。DISABLE_TELEMETRY=1 で止められます)と、毎夜のエージェント評価から取った1,029件で作られています。
- キーワード検索と意味検索の併用。ベクトルだけでは
:user-invalidやshowModalのような正確なAPI名がぼやけ、キーワードだけではAPI名を知らない意図の検索を取りこぼします。依存なしのBM25キーワード索引を足し、各ガイドの対象Web機能IDも索引に入れて、両方の順位を融合しました。空が返るのは、意味の類似度が0.30未満で、かつキーワードの一致が0件のときだけになりました。 - 結果は10件を簡潔に。エージェントは類似度やトークン数を見ていなかったため、その欄を落とし、同じ約530トークンの予算で5件の詳しい結果から10件の簡潔な結果(IDと説明だけ)に替えました。
この2つで、実地クエリでの的中率は80.6%から94.7%に上がりました。
第3世代:事前に焼いた埋め込みと、7行のループ
それでも残った問題は重さです。実際の検索の86.7%は2〜5語(中央値3語)しかないのに、そのたびにTensorFlow.jsを起動し、22.6MBのニューラルネットを読み、初回770msと最大346MBのメモリを使っていました。
発想の転換は、ガイド側と検索側で同じモデルを使う必要はない、という点です。ガイドは約200件で、版を切った後は変わりません。そこでガイド側のベクトルは、公開時にクラウドで gemini-embedding-001 を使って事前に作り、256次元のInt8ベクトルとして同梱します(圧縮で335KB)。検索側は、知っている単語ごとのベクトルの表を引き、重み付きで足すだけです。実行時の処理は、記事の題のとおり7行のループに収まります。
// 各単語の重みとInt8のスケールは scales[i] に畳み込み済み
for (const [i, f] of enc) {
const wt = scales[i] * f;
const offset = i * 256;
for (let k = 0; k < 256; k++) {
out[k] += wt * table[offset + k];
}
}
単語の表は、29,165語の語彙(fetchpriority、light-dismiss、popover、scrollytelling のような現代のWeb用語が1語として入っています)を同じ埋め込みで作り、33,000件の実在のWeb検索で調整したものです。「light」+「dismiss」が照明ではなく <dialog closedby="any"> のほうを指すように、組み合わせの着地点まで調整されています。表全体は圧縮で5.3MBです。
ガイド側にも同じ単語表を使う方法は試されましたが、数百語の平均を取ると全ガイドが「普通の英語」の近くの狭い範囲に潰れてしまい、関係ない検索が高スコアになる失敗が起きました。短い検索側だけ静的な表、ガイド側は本格的なモデル、という非対称な組み合わせが、クラウド級の精度とオフライン30µsの両立になっています。
作例:検索の感触
実際のCLIは意味検索とキーワード検索の併用です。下はキーワード一致だけで再現した簡易版で、雰囲気をつかむためのものです。例えば「dialog」「フォーム 検証」「パスキー」と入れてみてください。
本物はターミナルで試せます。
npx modern-web-guidance@latest search "confirm destructive action"
# → dialog ガイドが先頭に返ります(第2世代では空でした)
短縮URL(投稿の記載):https://goo.gle/4zJWa2F / 解決先:https://developer.chrome.com/blog/mwg-technical-deep-dive