Transformers.js でブラウザ完結の意味検索を作る
@huggingface/transformers の feature-extraction pipeline で文章を embedding(意味ベクトル)に変換し、cosine 類似度でランキングする意味検索をサーバー推論ゼロで実装するパターン。pipeline の初期化と dtype(量子化)の選択、モデルのキャッシュ挙動、日本語対応モデルの選び方とサイズの現実、UI をブロックしない読み込み設計まで、クリックでモデルを取得する触れる demo 付きで整理する実装メモ。
検証日: 2026-07-26
使用バージョン:
@huggingface/transformers@4.2.0対象: キーワード一致(部分文字列 / BM25)では拾えない「言い換え・意図」で検索したい人。サーバー API を増やさずにサイト内検索を賢くしたい人
Transformers.js(Hugging Face 公式の JS 版 Transformers。ONNX Runtime を内蔵し、モデルをブラウザ内で推論する)で、意味検索(semantic search — 文字の一致ではなく embedding の近さで探す検索)をブラウザ完結で作る。クエリはどこにも送信されず、ホスティングは静的ファイルのみ。動く demo 付き。
触って試す
「モデルを読み込む」でモデル(約 23MB)を取得すると検索できる。例えば “work with time and calendars” と入れると、time / calendar という単語を含まない “parse and format dates” の文が最上位に来る — これがキーワード検索との違い。
1. 全体像 — 検索が動くまでの 3 段階
① モデル DL(初回のみ、以降ブラウザ cache)
② corpus(検索対象の文書)を embedding 化して配列で保持
③ クエリを embedding 化 → 全 corpus と cosine 類似度 → 降順ソート
計算量の主役は ① と ②。③ のクエリ 1 回分は数十 ms なので、体感は「初回ロードだけ重く、検索は即時」になる。この構造から UI 設計が決まる: ①② はユーザ操作(クリック)を起点に進捗を見せながら実行し、③ は同期的に見せてよい。
2. 最小サンプル(pipeline → embedding → cosine)
pnpm add @huggingface/transformers@4.2.0
次のコードは feature-extraction pipeline(文章 → ベクトルの変換器)を作り、2 つの文の類似度を出す:
import { pipeline } from "@huggingface/transformers";
// 初回はモデルを Hugging Face Hub から DL(以降は Cache Storage から)
const extractor = await pipeline(
"feature-extraction",
"Xenova/all-MiniLM-L6-v2",
{ dtype: "q8" }, // 8bit 量子化(約 23MB)。省略時はモデル既定
);
// pooling: "mean" で「文全体で 1 本のベクトル」に、normalize で長さを 1 に
const out = await extractor(
["How to parse dates", "work with calendars"],
{ pooling: "mean", normalize: true },
);
// out.dims = [2, 384]。normalize 済みなので内積 = cosine 類似度
const [a, b] = [out.data.slice(0, 384), out.data.slice(384)];
const score = a.reduce((s: number, v: number, i: number) => s + v * b[i], 0);
ポイント:
pooling: "mean"とnormalize: trueをセットで付ける。付けないとトークンごとのベクトル([文数, トークン数, 384])が返ってきて、次元が合わず cosine が計算できないnormalize: true済みなら cosine 類似度はただの内積になり、自前実装が 3 行で済む- 返り値は Tensor。
out.dataが flat なFloat32Array、out.dimsが形状。文ごとにsliceで切り出す
3. dtype(量子化)の選択とモデルサイズ
dtype はモデルの重みの精度で、ダウンロードサイズと精度のトレードオフを決める:
| dtype | サイズ(MiniLM-L6 の場合) | 用途 |
|---|---|---|
fp32 | ~90MB | 精度基準。ブラウザ配信には重い |
q8 | ~23MB | 既定候補。embedding 用途では体感差がほぼない |
q4 | ~14MB | 生成系 LLM 向け。embedding では劣化が見えることがある |
embedding モデルは q8 から試すのが妥当。取得したモデルは ブラウザの Cache Storage に保存され、2 回目以降はネットワークに出ない(DevTools → Application → Cache Storage → transformers-cache で確認できる)。
4. 日本語で使うには — モデル選びとサイズの現実
demo の all-MiniLM-L6-v2 は英語向け。日本語クエリを扱うには多言語モデルに替えるだけでコードは同じだが、サイズが跳ね上がることは知っておく:
| モデル | q8 サイズ | 日本語 |
|---|---|---|
Xenova/all-MiniLM-L6-v2 | ~23MB | ✖ |
Xenova/multilingual-e5-small | ~118MB | ◎ 100+ 言語 |
Xenova/paraphrase-multilingual-MiniLM-L12-v2 | ~120MB | ○ |
多言語モデルが大きいのは語彙(トークン語彙 25 万件)の embedding 行列が支配的なため。100MB 超を訪問者に配るかは用途次第で、サイズを正面から表示して opt-in にする(demo と同じくクリックで取得・進捗表示)のが誠実な設計。なお multilingual-e5-* 系は学習時の形式に合わせて、corpus 側に passage: 、クエリ側に query: の接頭辞を付けると精度が上がる(モデルカード記載の仕様)。
さらに品質を追う場合(蒸留・自前 fine-tune・ONNX Runtime 直叩き)は、ONNX Runtime Web + 蒸留の記事がこの先の工程を扱っている。
5. UI をブロックしない読み込み設計
モデル DL と corpus embedding 化は数秒〜数十秒かかりうるので、demo では次の形にしている:
- ページ表示時には何も読み込まない。ボタンクリックで開始(サイズを明示)
progress_callbackで DL 進捗(%)を表示する:
const extractor = await pipeline("feature-extraction", MODEL_ID, {
dtype: "q8",
progress_callback: (p) => {
if (p.status === "progress") setProgress(Math.round(p.progress ?? 0));
},
});
- corpus の embedding は一度計算したら
Float32Arrayの配列で保持し、検索ごとに再計算しない
corpus が数千件を超えるなら、embedding 化自体をビルド時(Node で同じ Transformers.js が動く)に済ませて JSON/バイナリで配信し、ブラウザではクエリ 1 本だけ推論する構成に切り替える — 訪問者の計算量が「corpus 全体」から「クエリ 1 本」に落ちる。
つまずいたポイント
- 結果の次元が
[1, 7, 384]になって cosine が合わない:pooling: "mean"の付け忘れ。トークンごとのベクトルが返っている progress_callbackが何度も 100% を報告する: コールバックはファイル単位(model / tokenizer / config)で呼ばれる。p.fileを見て.onnxのときだけ進捗バーに反映する- スコアが全部 0.9 以上で差がつかない: normalize 忘れ + 短文同士だと起きやすい。
normalize: trueを確認し、それでも近い場合はモデルを 1 段大きいものに(MiniLM は 384 次元で表現力に上限がある) - ビルドツールで dev は動くのに本番 build で WASM が見つからない: bundler の設定によっては onnxruntime の
.wasmが拾われない。Transformers.js は既定で WASM を CDN(jsDelivr)から読むので通常は問題ないが、CSP で外部ドメインを塞いでいるサイトではenv.backends.onnx.wasm.wasmPathsを自サイトのパスに向けて.wasmを自前配置する Module not found: fs系の警告: Node 兼用ライブラリなので bundler が Node 組み込みを解決しようとする。実行時には使われないので、bundler 側でfs/pathを external/empty に落とす(Vite は既定で問題なし)
向くケース / 向かないケース
| 判定 | |
|---|---|
| サイト内検索の「言い換え対応」(数百〜数千件) | ◎ 本命。静的ホスティングだけで完結 |
| プライバシー要件が厳しい検索(入力を外に出せない) | ◎ 推論が端末内で閉じる |
| 数万件以上の corpus | △ 総当たり cosine が線形に効く。近似最近傍(HNSW 等)の導入を検討 |
| 最高精度の検索(商用検索エンジン級) | ✖ サーバー側の大型 embedding モデル + rerank に軍配 |