Transformers.js v4で日本語の意味検索を作る|ブラウザ内で埋め込みベクトルを計算

言葉ではなく意味で探す:索引カードと虫眼鏡で表す日本語の意味検索 ブラウザAI
カテゴリー
ブラウザAI
公開日
2026.09.20

はじめに

一般的なキーワード検索は、入力された単語が文章に含まれているかを調べます。そのため、「料金を支払う方法」で検索したとき、「クレジットカードで決済できます」という文章を見つけられない場合があります。

単語が完全一致しなくても、文章の意味が近いかどうかで探す方法が意味検索(セマンティック検索)です。

この記事ではTransformers.jsを使い、日本語文章を数値の配列である埋め込みベクトルへ変換します。検索文と登録文章のベクトルを比較し、内容の近い文章を順位付きで表示するアプリを作ります。

完成するアプリには、あらかじめ次のような文章を登録します。

商品の代金はクレジットカードまたは銀行振込で支払えます。
注文後30日以内であれば返品を申し込めます。
配送状況はマイページの注文履歴から確認できます。

利用者が「荷物が今どこにあるか知りたい」と入力すると、「配送状況はマイページの注文履歴から確認できます」が上位に表示されることを目指します。

外部の埋め込みAPIやベクトルデータベースは使用しません。

意味検索の仕組み

意味検索では、文章をそのまま比較するのではなく、モデルを使って固定長の数値配列へ変換します。この配列を埋め込みベクトルと呼びます。

今回のモデルは、各文章を384個の数値へ変換します。

文章
  ↓ 埋め込みモデル
[0.042, -0.018, 0.071, ... 合計384個]

意味が近い文章は、ベクトル空間でも近い方向になるようモデルが学習されています。ただし、埋め込みは文章の意味を完全に理解した正解データではありません。モデル、言語、文章の長さ、分野によって検索品質は変わります。

コサイン類似度

2つのベクトルがどれくらい同じ方向を向いているかを測る方法の一つがコサイン類似度です。

Transformers.jsの推論時にnormalize: trueを指定すると、ベクトルの長さが1になるよう正規化されます。正規化済みベクトル同士では、各要素を掛けて足し合わせる内積を使って類似度を計算できます。

function dotProduct(a, b) {
  if (a.length !== b.length) {
    throw new Error("ベクトルの次元数が一致しません。");
  }

  return a.reduce((sum, value, index) => sum + value * b[index], 0);
}

スコアは一般に大きいほど近いことを表しますが、固定した「正解のしきい値」があるわけではありません。モデルとデータを使って評価する必要があります。

E5モデルの接頭辞

今回は日本語を含む複数言語に対応したmultilingual-e5-smallを使用します。

E5モデルでは、検索文の先頭へquery: 、検索対象文章の先頭へpassage: を付ける使い方がモデルカードで案内されています。

query: 荷物が今どこにあるか知りたい
passage: 配送状況はマイページの注文履歴から確認できます。

これは表示上の飾りではなく、モデルへ文章の役割を伝える入力の一部です。他の埋め込みモデルへ変更する場合は、そのモデルカードに書かれた入力形式を確認してください。

必要な環境

  • Node.js 22.12以降の22系、または24系(Viteの実行環境)
  • ES Modules対応ブラウザ
  • 初回モデル取得用のインターネット接続
  • ローカルHTTPサーバー

端末内推論なのでAPIキーは不要です。ただし初回はモデルを取得するため通信が発生します。

プロジェクトを準備する

mkdir semantic-search
cd semantic-search
npm init -y
npm install @huggingface/transformers@4.2.0
npm install --save-dev vite@7.3.6

index.htmlmain.jsを作成します。

HTMLを作成する

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>日本語意味検索</title>
    <style>
      body {
        width: min(760px, calc(100% - 32px));
        margin: 40px auto;
        font-family: system-ui, sans-serif;
      }
      textarea, button { width: 100%; box-sizing: border-box; }
      textarea { min-height: 90px; padding: 10px; }
      button { margin-top: 12px; padding: 10px; }
      li { margin-block: 16px; }
    </style>
  </head>
  <body>
    <main>
      <h1>日本語意味検索</h1>
      <label for="query">探したい内容</label>
      <textarea id="query" maxlength="200">荷物が今どこにあるか知りたい</textarea>
      <button id="search" type="button">意味で検索する</button>
      <p id="status" role="status" aria-live="polite">モデルは未読み込みです。</p>
      <ol id="results"></ol>
    </main>
    <script type="module" src="/main.js"></script>
  </body>
</html>

JavaScriptを実装する

import { pipeline } from "@huggingface/transformers";

const MODEL_ID = "Xenova/multilingual-e5-small";

const documents = [
  "商品の代金はクレジットカードまたは銀行振込で支払えます。",
  "注文後30日以内であれば返品を申し込めます。",
  "配送状況はマイページの注文履歴から確認できます。",
  "パスワードを忘れた場合はログイン画面から再設定できます。",
  "領収書は購入完了後にPDF形式でダウンロードできます。",
];

const queryInput = document.querySelector("#query");
const searchButton = document.querySelector("#search");
const status = document.querySelector("#status");
const resultList = document.querySelector("#results");

let extractorPromise;
let documentVectorsPromise;

function getExtractor() {
  extractorPromise ??= pipeline("feature-extraction", MODEL_ID, {
    device: "wasm",
    dtype: "q8",
    progress_callback: (event) => {
      if (event.status === "progress" && Number.isFinite(event.progress)) {
        status.textContent = `モデル読み込み中: ${event.progress.toFixed(0)}%`;
      }
    },
  }).catch((error) => {
    extractorPromise = undefined;
    throw error;
  });

  return extractorPromise;
}

async function embed(texts) {
  const extractor = await getExtractor();
  const output = await extractor(texts, {
    pooling: "mean",
    normalize: true,
  });
  return output.tolist();
}

function dotProduct(a, b) {
  if (a.length !== b.length) {
    throw new Error("ベクトルの次元数が一致しません。");
  }

  return a.reduce((sum, value, index) => sum + value * b[index], 0);
}

function getDocumentVectors() {
  documentVectorsPromise ??= embed(
    documents.map((document) => `passage: ${document}`),
  ).catch((error) => {
    documentVectorsPromise = undefined;
    throw error;
  });

  return documentVectorsPromise;
}

function renderResults(rankedDocuments) {
  resultList.replaceChildren();

  for (const item of rankedDocuments) {
    const li = document.createElement("li");
    const text = document.createElement("p");
    const score = document.createElement("small");

    text.textContent = item.document;
    score.textContent = `類似度スコア: ${item.score.toFixed(3)}`;
    li.append(text, score);
    resultList.append(li);
  }
}

searchButton.addEventListener("click", async () => {
  const query = queryInput.value.trim();

  if (!query) {
    status.textContent = "検索文を入力してください。";
    resultList.replaceChildren();
    return;
  }

  searchButton.disabled = true;
  status.textContent = "文章をベクトルへ変換しています。";

  try {
    const [documentVectors, [queryVector]] = await Promise.all([
      getDocumentVectors(),
      embed([`query: ${query}`]),
    ]);

    const rankedDocuments = documents
      .map((document, index) => ({
        document,
        score: dotProduct(queryVector, documentVectors[index]),
      }))
      .sort((a, b) => b.score - a.score)
      .slice(0, 3);

    renderResults(rankedDocuments);
    status.textContent = "検索が完了しました。";
  } catch (error) {
    console.error(error);
    status.textContent =
      "検索に失敗しました。通信状態とブラウザの空きメモリを確認してください。";
  } finally {
    searchButton.disabled = false;
  }
});

なぜ今回はWASMを使うのか

この記事では再現環境を広くするため、明示的にdevice: "wasm"を指定しています。Transformers.jsのブラウザ版ではWASMがCPU実行基盤になります。

WebGPU対応モデルであればdevice: "webgpu"へ変更できますが、短い文章を数件処理するだけなら、モデル取得時間や初期化時間を含めた体感が必ず改善するとは限りません。実際のデータ量で比較してください。

文章ベクトルを再利用する

検索対象文章は検索のたびに変わらないため、documentVectorsPromiseへ計算結果を保存しています。検索ごとに計算するのは検索文のベクトルだけです。

実用アプリでは、文章が追加・更新・削除されたときだけ該当ベクトルを再計算し、IndexedDBやOPFSなどへ保存する構成を検討できます。

起動する

npx vite

ブラウザで表示されたURLを開き、「荷物が今どこにあるか知りたい」と入力して検索します。

期待結果は、配送状況について書かれた文章が上位へ表示されることです。ただし、類似度の具体的な数値や2位以下の順序は、モデルファイルや実行環境の違いで変わる可能性があります。

動作確認

正常系

  • 「支払い方法を教えて」で決済に関する文章が上位になる
  • 「商品を返したい」で返品に関する文章が上位になる
  • 「ログインできない」でパスワード再設定に関する文章が上位になる
  • 2回目以降は登録文章のベクトルを再計算しない

エラー系

  • 空文字や空白だけでは検索を開始しない
  • 200文字を超える入力をHTML側で制限する
  • 初回モデル取得時にオフラインならエラー表示する
  • 初期化が失敗した場合、次のクリックで再試行できる
  • ベクトルの長さが異なる場合は例外にする

検索品質を評価する

アプリが動いたことと、実用的な検索品質が得られたことは別です。

導入前に、実際の利用者が入力しそうな質問と、正解として表示したい文書の組を用意します。例えば50件の質問を用意し、正解文書が上位3件へ入った割合を測ります。

質問: 領収証がほしい
正解: 領収書は購入完了後にPDF形式でダウンロードできます。

モデル変更、文章分割方法、接頭辞、検索対象の単位を変えたときは、同じ評価データで比較します。

実用化するときの注意点

長文をそのまま1件にしない

長いページ全体を1つのベクトルにすると、異なる話題が混ざります。見出しや段落単位で分割し、元ページのURLや見出しをメタデータとして持たせる方法が一般的です。

スコアだけで正解と断定しない

一番高いスコアでも、利用者の質問に答える文章が登録されていない場合があります。一定以下のスコアでは「該当する情報を見つけられませんでした」とする設計も必要です。ただし、しきい値は実データで決めます。

ベクトルは元文章の代わりではない

検索結果を表示するには元文章と対応関係を保存する必要があります。ベクトルだけを保存しても、人が読める回答には戻せません。

機密情報を無条件に埋め込まない

処理が端末内でも、モデルやアプリのキャッシュ、ブラウザストレージ、エクスポート機能などにデータが残る可能性があります。保存期間、削除方法、利用端末の共有状態を考慮してください。

キーワード検索との使い分け

商品番号、エラーコード、氏名など完全一致が重要なデータは、従来のキーワード検索が適しています。意味検索は言い換えに強い一方、固有の文字列を正確に探す用途では取りこぼす場合があります。

実用システムでは、キーワード検索と意味検索の結果を組み合わせるハイブリッド検索も有効です。

関連記事

まとめ

Transformers.jsのfeature-extractionパイプラインを使うと、日本語文章をブラウザ内で埋め込みベクトルへ変換できます。

意味検索を正しく実装するには、モデル固有の入力形式、正規化、ベクトル次元の確認、登録文章ベクトルの再利用、実データによる検索品質評価が必要です。

次の記事では音声認識モデルを利用し、日本語音声をブラウザ内で文字へ変換します。

参考リンク

コメント

タイトルとURLをコピーしました