ブラウザ内で意味検索|IndexedDBとTransformers.jsによるLocal-First AI実装

はじめに

こんにちは、NTT西日本の中川です。
本記事では、IndexedDB(保存)と Transformers.js(計算)を組み合わせ、ブラウザ内でメモの意味検索までのLocal-First AI入門を、ミニサンプルを用いて紹介します。
本記事は2026年6月時点の情報に基づきます。

生成AIが身近になった一方で、「APIにすべて任せればよいのでは?」と感じる方も多いと思います。

私も最初はそう思っていました。ただ、議事メモをクラウドに貼るたびに「この本文、送ってよいのかな」と迷う場面があり、そのたびに判断するのは負担に感じたので、ブラウザ内で完結する検索を試してみたところ、それなりに使い所があるように感じたため、サンプルを交えてご紹介します。

対象読者

  • LocalStorageやIndexedDBの基本的な使い方は理解している方
  • AIをアプリケーションに組み込みたいが、サーバー費用やプライバシー面に課題を感じている方
  • Local-First AI の有用性が抽象的で、自社プロダクトのどこで効くかイメージしづらい方
  • 「AIにコードを書かせる」段階から、「AIをブラウザ環境へ最適化する」段階へステップアップしたい方

目次

1. 背景・目的:なぜ今「Local-First AI」なのか?

1.1 Local-First AI とは何か

Local-First AI は、「すべてのAIをブラウザだけで完結させる」ことと同義ではありません。クラウドのサーバーなどに頼るのではなく、自身のパソコンやスマートフォンなど手元の端末(ローカル環境)上でAIの処理や学習を完結させる考え方の事を指します。本記事で扱うのは、こんな設計です。

  • データ(メモ・ログ・下書き)はブラウザ内(IndexedDB)に置く
  • 取得(埋め込み・類似検索・分類など)は端末側で回す
  • 文章生成(長文の要約・高度な回答)は必要なときだけクラウドAPIへ寄せる

つまりハイブリッドな構成です。「通常のLLMの圧倒的な賢さ」よりも、送りたくないデータを送らない/待ちを減らす/API代を抑える、といった要件にマッチする考え方になります。

1.2 イメージしやすい Before / After(社内メモ検索の例)

社内Webツールを想定した、体験の違いです。

Before(クラウドAPI中心)

  • 議事メモ100件の中から検索したい
  • 入力のたびに自前のバックエンドAPI経由で、Embeddingモデル(テキストや画像などの非構造化データを数値の配列(ベクトル)に変換する技術のこと)へ本文を送信し、埋め込み化
  • 懸念: 情報持ち出しポリシー、ログ保存、API課金、回線が弱いと待ちが発生してしまう

After(Local-First 寄り)

  • メモ本文は IndexedDB に保存(ブラウザ内)
  • 「先週の障害対応」などのクエリは端末内で埋め込み化 → 類似メモを上位表示
  • 本文を外部へ送らない設計にしやすい(初回のモデル取得通信は別途発生)
  • モデル取得後の検索は、埋め込み計算を端末側で実行し、サーバーへ本文を送らずに応答できる

この記事のサンプルは After のうち「意味が近いメモを探す」部分だけです。要約やチャット生成までは扱わないので留意してください。

1.3 本記事の知識が活きるユースケース

次のようなユースケースでは本記事の考え方が活躍できるかなと思います。
ご自身のプロダクトと照らし合わせて検討してみてください。

シーン 困りごと Local-First でやりやすいこと
社内・個人のメモ・議事録の検索 クラウドLLMに貼ると情報管理が不安 本文を送らず意味検索(本記事のデモ)
回線が不安定な現場・店舗・イベント 回線が不安定でAPI待ちが致命的 モデル取得後はオフライン寄りで検索・分類
入力中の補助UI サーバー往復で入力の反応が鈍くなる Worker + ローカル推論で体感速度を改善
企画・検証フェーズ サーバー構築前に体験を試したい ローカルファイルで仮説検証

1.4 現実的な構成:ローカルとクラウドの役割分担

全部ローカルに寄せる必要はありません。役割を分けると設計しやすいです。

  • ブラウザ: 保存・検索・分類など「自分のデータにだけ効く処理」
  • クラウド: 品質最優先の生成、全社ナレッジ横断など「データを送ってもよい処理」

1.5 ベクトル検索を用いたアプリケーションが直面しやすい3つの課題

こうしたユースケースが効く背景として、次の3つがよく出てきます。

  1. APIコスト: すべてのリクエストをサーバーへ送ると、ユーザー増・利用回数に伴いコストが膨らみやすい。
  2. プライバシー: 議事メモ・顧客対応メモなど、外部サーバーへ送りたくないデータがある。
  3. 応答性(レイテンシー): ネットワーク経由の推論は、通常、待機時間が発生する。

IndexedDB(保存)と Transformers.js などを使った端末内推論(計算)を組み合わせると、これらを同時に緩和しやすい場面があります(端末性能・ブラウザ対応などの制約はあります)。

1.6 Local-First AIが向かないケース

万能ではないので、向かないケースも書いておきます。

  • 高品質な長文生成が主目的
  • 全端末で同じ品質を厳密に保証したい
  • 社内文書を数百万件単位で横断検索したい(専用ベクトルDBが必要)
  • モデル配布・更新を厳格に統制したい環境

こうした要件が主なら、サーバー側のRAG(検索拡張生成)やクラウドAPIを主役にし、ローカルは補助とする構成が現実的です。 今回のデモがやっているのは、これだけです。

自分のメモだけを、キーワード一致ではなく「意味が近い順」で探せる。メモ本文を自前APIへ送らずに済む。

RAG(検索拡張生成)でいうと、本デモは R(Retrieval=検索)だけです。LLM への渡し方や回答生成(G)は扱いません。

2. 技術的アプローチ:IndexedDB(長期記憶)× Transformers.js(推論)× Worker(滑らかさ)

先ほどの図で示した「ブラウザ内」を、具体的な技術要素に分解します。

2.1 ブラウザを「AIの脳」にする:Transformers.jsと推論バックエンド(WASM/WebGPU)

現代のブラウザはWebAssemblyやWebGPU APIを通じて、高速な演算を活用できる環境が増えてきました。ここにJavaScript向け推論ライブラリ(例:Transformers.js)を組み合わせると、Python環境や自前サーバーに依存せず、ブラウザ上で推論を実行できる構成を取りやすくなります。

サンプルは、Transformers.js を CDN(jsDelivr)から読み込むだけで動かします。ビルドツールは使いません。

Transformers.js は環境に応じて WebAssembly(WASM)や WebGPU などへフォールバックします。本記事のサンプルコードは、デフォルトの WASM バックエンドで推論します。

2.2 IndexedDBを「AIの長期記憶」として使う

RAG(検索拡張生成)は「必要な知識を検索してから生成に渡す」考え方です。ここでは、ブラウザ内に保存したメモ群を埋め込み(ベクトル)化してIndexedDBに保存し、クエリに近いものを取り出す、というできるだけ軽めな構成にしています。

また、ブラウザでAI処理を動かす際のボトルネックになりやすいのが、メインスレッドの占有です。UI描画・入力・スクロールが詰まると、ユーザー体験に悪影響が出ます。メインスレッドと Worker で役割を分けます。

  • メインスレッド: UIの描画、ユーザー入力、結果表示
  • Web Worker: モデルロード、埋め込み計算、類似度計算、IndexedDBへの書き込み

3. 設計上の前提

3.1 この記事で「守ること/守れないこと」

「ローカルに保存する=安全」ではありません。ここは誤解が起きやすいので、前提を設けます。

  • 守りやすいこと(設計でコントロールしやすい)
    • ユーザーが入力したメモ本文を、自前APIへ送らない設計にできる
    • APIコストを抑えやすい(端末内推論に寄せるほど)
  • 守れないこと(別対策が必要)
    • 端末のマルウェア感染、悪性ブラウザ拡張、端末盗難などはローカル保存だけでは防げない
    • XSS(クロスサイトスクリプティング)で同一オリジン上のデータを読まれるリスクは致命的(CSP=Content Security Policy・サニタイズなどが前提)
  • 初回だけ外部通信が発生する
    • Transformers.js はモデル重みを CDN / Hugging Face から取得します(メモ本文そのものを送るわけではないが、通信自体は発生します)
    • 社内プロキシやファイアウォールで外部 CDN が制限されている環境では、初回のモデル取得が失敗することがあります(別途ネットワーク設定の確認が必要)

3.2 埋め込み化が遅い・動かないときの対処

端末やブラウザによって、埋め込みの速さはかなり変わります。本記事のサンプルは、Transformers.js の既定どおり WASM(CPU上で動く方式)で動く前提です。WebGPU が使える環境では速くなることもありますが、必須ではありません。

困ったときは、この順で試すとよいです。

  1. まず動かす — WASM で登録・検索まで通す(今回のデモはここまで)
  2. 余力があれば速くする — 端末が対応していれば WebGPU を使う(Transformers.js が自動で選ぶ)
  3. それでも厳しい — 意味検索をやめるのではなく、負荷を下げる(キーワード検索に切り替える、登録件数や topK を減らす など)

4. 【実装】ブラウザだけで動く「ローカルAIメモ検索」サンプル

4.1 何を作るか(完成イメージ)

1章の After を、HTML とJavaScriptの2ファイルで動かす段階です。メモ登録 → 埋め込み保存 → クエリで意味検索、という流れだけに絞っています。チャット回答や社外API連携は範囲外です。

4.2 ファイル構成

次の2ファイルを同じフォルダに置きます。

index.html              … UI(メインスレッド)
embedding.worker.js     … 埋め込み化・IndexedDB・検索(Worker)

4.3 試し方

  1. この章の index.htmlembedding.worker.js のコードを、それぞれ同名ファイルとして同じフォルダに保存する
  2. Visual Studio Code の Live Server などで、そのフォルダを http://localhost:... 経由で開く(file:// で直接開くと Worker が動かないことが多いです)
  3. Google Chrome または Microsoft Edge で index.html を表示する
  4. 「デモデータを一括登録」でサンプルを入れる(初回はモデル取得で数十秒かかることがあります)。「登録」から1件ずつ足して試してもかまいません
  5. 下の検証表と結果を比べたいときだけ、件数が6件でなければ「すべて削除」→「デモデータを一括登録」で揃える
  6. 検証表のクエリで検索してみる(メモが6件未満でも検索は動きます。表示件数は、登録件数と5件の少ない方まで)

動作確認の目安: 検証表を使う場合、各クエリで「期待される上位デモデータ」が1位、環境によっては2位以内なら意図どおりです。利用モデルは Xenova/all-MiniLM-L6-v2 で、英語向けが主です。日本語のデモでも動きますが、順位は端末やクエリで前後することがあります。結果の数値(例: 0.165)の読み方は、続く「検索結果のスコアとコサイン類似度」を見てください。Transformers.js は @xenova/transformers@2.17.2(CDN)で固定しています。

4.3.1 デモデータと検索クエリ例

「デモデータを一括登録」すると6件入ります。6件しか扱えないわけではなく、下の検証表がこの6件を想定しているだけです。

一括登録されるデモデータ

  1. [障害対応] 2026-03-15 認証基盤タイムアウトの件。原因はDBのコネクションプール枯渇。再発防止策としてリトライ上限を3回に変更し、アラート閾値を調整した。
  2. [手順] 社外からのVPN接続について。社外ネットワークからはポータル経由でのみアクセス可能。パスワードはITサポートにチケットを発行して取得すること。
  3. [顧客A社] 契約上の特記事項:ログに個人情報(氏名・メアド)を一切残さないこと。問い合わせ対応は専任のサポート窓口を経由すること。
  4. [議事録] 4/1 開発定例。次期リリースの目玉はRAG機能の強化。インフラ費用を抑えるため、一部の検索処理をフロントエンド(ブラウザ側)にオフロードする方針で合意。
  5. [技術メモ] 埋め込み推論は Transformers.js(WASM)が既定。端末が対応していれば WebGPU バックエンドで GPU 推論に切り替え可能。
  6. [オンボーディング] 新入社員向けPCセットアップ手順。初期パスワードは入社初日に配布される資料を参照。まずはセキュリティ研修動画の視聴を完了させること。

検索クエリと期待される上位デモデータ

検索クエリ 期待される上位デモデータ
先週の認証エラーの再発防止 メモ1
社外から社内システムに入る メモ2
個人情報の取り扱いルール メモ3
コスト削減の設計 メモ4
GPU 推論 メモ5
新人の初期設定 メモ6
登壇 該当メモなし(語句一致なし・相対順位のみ)

4.3.2 検索結果のスコアとコサイン類似度

検索結果は、だいたい次の形で出ます。

0.165 (意味 0.165) [語句一致 +0.12] - [id=5] [技術メモ] 埋め込み推論は Transformers.js(WASM)が既定。…
0.411 (意味 0.411) [語句一致なし] - [id=14] [手順] 社外からのVPN接続について。…

先頭の 0.165 は最終スコアで、括弧内の 意味 0.165 はコサイン類似度(意味の近さ)だけを抜き出した値です。[語句一致 +0.12] はクエリ文字列が本文に含まれるときの加点、[語句一致なし] は加点が付いていないことを示します。サンプルでは searchScore でスコアを作り、降順に並べています。

判定の流れ(検索1回あたり)

「検索」ボタンを押してから結果が並ぶまでの流れです(Web Worker 内で完結し、メモ本文は外部 API へ送りません)。

topK(トップケイ)は、スコア順の上位K件だけ返す、という意味です。K は返す件数で、変数名として topK と書くことが多いです。本サンプルでは worker.postMessage({ type: 'search', payload: { query, topK: 5 } }) のとおり 5件です。

メモ1件あたりのスコア計算(searchScore)は、意味の近さ(ベクトル)と語句一致(文字列)を組み合わせています。

登録時は逆方向です。メモ本文を埋め込み化して textvector を IndexedDB に保存します(検索のたびに再計算しません)。

コサイン類似度(意味の近さ)とは

Transformers.js のような埋め込みモデルは、メモ本文も検索語もベクトル(数値の並び)に変換します。似た意味の文は、似たベクトルになります。意味で探すときは、ベクトル同士の近さで並べるのが一般的です。

その近さを測る指標がコサイン類似度です。画面の (意味 0.xxx) がこれで、おおむね 0〜1 の範囲です(1に近いほど意味が近い)。検索の方法自体は他にもあり、語が本文に含まれるかだけを見るキーワード検索(Google検索に近い)もよく使われます。本デモは意味検索を主にし、語句一致は [語句一致] の小幅な加点として足しています。そのため「障害」「タイムアウト」といった語が本文に無くても、内容が近ければ上位に来ます。

最終スコアは、コサイン類似度に、検索語が本文にそのまま含まれるときの小幅な加点([語句一致])を足したものです。

スコアの見方

スコアを読むときは、次の3つを見れば十分です。

  • 順位:0.05 でも 0.4 でも、数値より並びが自然かを見る
  • [語句一致]:付いていれば、検索語が本文に含まれている。無ければコサイン類似度だけで並んでいる
  • 件数:最大5件まで出るが、登録が3件なら3件まで。近い順に並んでいるだけのことがある

検証表どおり6件入れた状態で 登壇 を検索すると、0.411 などの数値が出て「見つかったのかな?」と迷いました。どのメモにもその語はなく、すべて [語句一致なし] でした。数字が付いていても、登録されているメモのうち相対的に近かっただけ、という意味です。画面下の注意文と (意味 0.xxx) もあわせて見てください。

4.4 index.html(UI・Worker起動)

メインスレッドは UI と Worker へのメッセージ送受信だけを担当します。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Local-First AI Memo Search</title>
    <style>
      body {
        font-family: system-ui, sans-serif;
        max-width: 720px;
        margin: 2rem auto;
        padding: 0 1rem;
        line-height: 1.6;
      }
      textarea,
      input {
        width: 100%;
        box-sizing: border-box;
      }
      button {
        margin-top: 0.5rem;
      }
      #status {
        min-height: 1.5rem;
        color: #333;
        margin-top: 1rem;
      }
      ol,
      ul {
        padding-left: 1.25rem;
      }
      #memos li {
        margin-bottom: 0.5rem;
        font-size: 0.95rem;
      }
      #memos .meta {
        color: #666;
        font-size: 0.85rem;
      }
      .note {
        font-size: 0.9rem;
        color: #555;
        background: #f6f6f6;
        padding: 0.75rem 1rem;
        border-radius: 6px;
      }
    </style>
  </head>
  <body>
    <h1>Local-First AI Memo Search</h1>
    <section>
      <h2>メモ登録</h2>
      <textarea id="memo" rows="4" placeholder="メモを入力"></textarea>
      <button id="add" type="button">登録</button>
      <button id="loadDemo" type="button" style="margin-left: 1rem;">デモデータを一括登録</button>
      <button id="clearAll" type="button" style="margin-left: 1rem;">すべて削除</button>
    </section>

    <section>
      <h2 id="memosHeading">登録済みメモ(読み込み中…)</h2>
      <ul id="memos"></ul>
    </section>

    <section>
      <h2>検索</h2>
      <input
        id="q"
        type="search"
        placeholder="例: WebGPU 推論 / 社外から社内システム / 登壇(無関係クエリの例)"
      />
      <button id="go" type="button">検索</button>
      <p class="note" style="margin-top: 0.5rem;">
        結果は <code>最終スコア (意味スコア) [語句一致]</code> の順です。語句一致が無いときは、
        登録メモの中でベクトルが相対的に近い順に並んでいるだけで、必ずしも関連メモがあるわけではありません。
      </p>
      <ol id="results"></ol>
    </section>

    <div id="status" aria-live="polite"></div>

    <script type="module">
      const memoEl = document.getElementById('memo');
      const addBtn = document.getElementById('add');
      const loadDemoBtn = document.getElementById('loadDemo');
      const clearAllBtn = document.getElementById('clearAll');
      const memosHeading = document.getElementById('memosHeading');
      const memosEl = document.getElementById('memos');
      const qEl = document.getElementById('q');
      const goBtn = document.getElementById('go');
      const resultsEl = document.getElementById('results');
      const statusEl = document.getElementById('status');

      const worker = new Worker('./embedding.worker.js', { type: 'module' });

      function refreshMemos() {
        worker.postMessage({ type: 'listMemos' });
      }

      function renderMemos(memos) {
        memosHeading.textContent = `登録済みメモ(${memos.length}件)`;
        memosEl.innerHTML = '';
        if (memos.length === 0) {
          const li = document.createElement('li');
          li.textContent = 'まだメモは登録されていません。「デモデータを一括登録」で試せます。';
          memosEl.appendChild(li);
          return;
        }
        for (const m of memos) {
          const li = document.createElement('li');
          const meta = document.createElement('div');
          meta.className = 'meta';
          meta.textContent = `id=${m.id}`;
          li.appendChild(meta);
          li.appendChild(document.createTextNode(m.text));
          memosEl.appendChild(li);
        }
      }

      worker.onmessage = (ev) => {
        const { type, payload } = ev.data;

        if (type === 'listMemosResult') {
          renderMemos(payload.memos);
          return;
        }

        if (type === 'addMemoResult') {
          statusEl.textContent = `登録しました(id=${payload.id})`;
          refreshMemos();
          return;
        }

        if (type === 'loadDemoResult') {
          statusEl.textContent = `デモデータ(${payload.count}件)を一括登録しました(既存データは置き換え済み)。`;
          refreshMemos();
          return;
        }

        if (type === 'clearAllResult') {
          statusEl.textContent = 'すべてのメモを削除しました。';
          refreshMemos();
          return;
        }

        if (type === 'searchResult') {
          resultsEl.innerHTML = '';
          if (payload.items.length === 0) {
            const li = document.createElement('li');
            li.textContent = '該当するメモがありません';
            resultsEl.appendChild(li);
            statusEl.textContent = '';
          } else {
            const anyKeyword = payload.items.some((it) => it.keywordMatch);
            for (const it of payload.items) {
              const li = document.createElement('li');
              const kwTag = it.keywordMatch
                ? ` [語句一致 +${(it.keywordBoost ?? 0.12).toFixed(2)}]`
                : ' [語句一致なし]';
              li.textContent = `${it.score.toFixed(3)} (意味 ${it.semantic.toFixed(3)})${kwTag} - [id=${it.id}] ${it.text}`;
              resultsEl.appendChild(li);
            }
            if (!anyKeyword) {
              statusEl.textContent =
                'いずれも語句一致なしです。表示は topK=5 件の「相対順位」であり、クエリに近いメモが無い場合でもスコア付きで並びます。本番ではスコア閾値やメタデータ絞り込みを検討してください。';
            } else {
              statusEl.textContent = '';
            }
          }
          return;
        }

        if (type === 'error') {
          statusEl.textContent = `エラー: ${payload.message}`;
        }
      };

      addBtn.addEventListener('click', () => {
        const text = memoEl.value.trim();
        if (!text) return;
        statusEl.textContent =
          '登録中...(初回はモデル取得のため数十秒かかる場合があります)';
        worker.postMessage({ type: 'addMemo', payload: { text } });
        memoEl.value = '';
      });

      loadDemoBtn.addEventListener('click', () => {
        statusEl.textContent =
          'デモデータを登録中...(初回はモデル取得のため数十秒かかる場合があります)';
        worker.postMessage({ type: 'loadDemoData' });
      });

      clearAllBtn.addEventListener('click', () => {
        if (!confirm('登録済みメモをすべて削除します。よろしいですか?')) return;
        worker.postMessage({ type: 'clearAllMemos' });
      });

      goBtn.addEventListener('click', () => {
        const query = qEl.value.trim();
        if (!query) return;
        resultsEl.innerHTML = '';
        statusEl.textContent = '検索中...';
        worker.postMessage({ type: 'search', payload: { query, topK: 5 } });
      });

      refreshMemos();
    </script>
  </body>
</html>

4.5 embedding.worker.js(CDN + IndexedDB + 検索)

Worker 側で Transformers.js(CDN)を読み込み、IndexedDB(生API)へ保存・検索します。

import { pipeline } from 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.2';

const DB_NAME = 'AI_Memory';
const DB_VERSION = 1;
const STORE_NAME = 'memos';
const MODEL_ID = 'Xenova/all-MiniLM-L6-v2';

let embedder;

// --- IndexedDB(生API) ---
function openDb() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VERSION);
    req.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        const store = db.createObjectStore(STORE_NAME, {
          keyPath: 'id',
          autoIncrement: true,
        });
        store.createIndex('timestamp', 'timestamp');
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

function dbAdd(record) {
  return openDb().then(
    (db) =>
      new Promise((resolve, reject) => {
        const tx = db.transaction(STORE_NAME, 'readwrite');
        const req = tx.objectStore(STORE_NAME).add(record);
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
        tx.oncomplete = () => db.close();
      }),
  );
}

function dbGetAll() {
  return openDb().then(
    (db) =>
      new Promise((resolve, reject) => {
        const tx = db.transaction(STORE_NAME, 'readonly');
        const req = tx.objectStore(STORE_NAME).getAll();
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => reject(req.error);
        tx.oncomplete = () => db.close();
      }),
  );
}

function dbClearAll() {
  return openDb().then(
    (db) =>
      new Promise((resolve, reject) => {
        const tx = db.transaction(STORE_NAME, 'readwrite');
        const req = tx.objectStore(STORE_NAME).clear();
        req.onsuccess = () => resolve();
        req.onerror = () => reject(req.error);
        tx.oncomplete = () => db.close();
      }),
  );
}

async function listMemosForUi() {
  const all = await dbGetAll();
  return all
    .map((m) => ({ id: m.id, text: m.text, timestamp: m.timestamp }))
    .sort((a, b) => b.timestamp - a.timestamp);
}

// --- ベクトルユーティリティ ---
function float32ToBuffer(f32) {
  return f32.buffer.slice(f32.byteOffset, f32.byteOffset + f32.byteLength);
}

function bufferToFloat32(buf) {
  return new Float32Array(buf);
}

function cosineSimilarity(a, b) {
  if (a.length !== b.length) throw new Error('ベクトル長が一致しません');
  let dot = 0, na = 0, nb = 0;
  for (let i = 0; i < a.length; i++) {
    dot += a[i] * b[i];
    na += a[i] * a[i];
    nb += b[i] * b[i];
  }
  const denom = Math.sqrt(na) * Math.sqrt(nb);
  return denom === 0 ? 0 : dot / denom;
}

/** 短いクエリ向け: 本文にクエリ語が含まれるときだけ小幅ブースト(ハイブリッド検索の簡易版) */
function searchScore(query, qvec, memo) {
  const semantic = cosineSimilarity(qvec, bufferToFloat32(memo.vector));
  const q = query.trim().toLowerCase();
  if (q.length < 2) {
    return { score: semantic, semantic, keywordMatch: false, keywordBoost: 0 };
  }
  const text = memo.text.toLowerCase();
  const keywordMatch = text.includes(q);
  const keywordBoost = keywordMatch ? 0.12 : 0;
  return {
    score: semantic + keywordBoost,
    semantic,
    keywordMatch,
    keywordBoost,
  };
}

// --- 埋め込み(Transformers.js / デフォルトは WASM バックエンド) ---
async function getEmbedder() {
  if (embedder) return embedder;
  embedder = await pipeline('feature-extraction', MODEL_ID);
  return embedder;
}

async function embedText(text) {
  const e = await getEmbedder();
  const out = await e(text, { pooling: 'mean', normalize: true });
  const data = out?.data ?? out;
  if (data instanceof Float32Array) return data;
  if (Array.isArray(data)) return new Float32Array(data.flat());
  throw new Error('埋め込みベクトルの形式が想定外です');
}

// --- メッセージ処理 ---
self.onmessage = async (ev) => {
  try {
    const { type, payload } = ev.data;

    if (type === 'listMemos') {
      const memos = await listMemosForUi();
      self.postMessage({ type: 'listMemosResult', payload: { memos } });
      return;
    }

    if (type === 'clearAllMemos') {
      await dbClearAll();
      self.postMessage({ type: 'clearAllResult', payload: {} });
      return;
    }

    if (type === 'loadDemoData') {
      await dbClearAll();
      const demoMemos = [
        "[障害対応] 2026-03-15 認証基盤タイムアウトの件。原因はDBのコネクションプール枯渇。再発防止策としてリトライ上限を3回に変更し、アラート閾値を調整した。",
        "[手順] 社外からのVPN接続について。社外ネットワークからはポータル経由でのみアクセス可能。パスワードはITサポートにチケットを発行して取得すること。",
        "[顧客A社] 契約上の特記事項:ログに個人情報(氏名・メアド)を一切残さないこと。問い合わせ対応は専任のサポート窓口を経由すること。",
        "[議事録] 4/1 開発定例。次期リリースの目玉はRAG機能の強化。インフラ費用を抑えるため、一部の検索処理をフロントエンド(ブラウザ側)にオフロードする方針で合意。",
        "[技術メモ] 埋め込み推論は Transformers.js(WASM)が既定。端末が対応していれば WebGPU バックエンドで GPU 推論に切り替え可能。",
        "[オンボーディング] 新入社員向けPCセットアップ手順。初期パスワードは入社初日に配布される資料を参照。まずはセキュリティ研修動画の視聴を完了させること。"
      ];
      for (const text of demoMemos) {
        const vec = await embedText(text);
        await dbAdd({
          text,
          vector: float32ToBuffer(vec),
          timestamp: Date.now(),
          model: MODEL_ID,
        });
      }
      self.postMessage({ type: 'loadDemoResult', payload: { count: demoMemos.length } });
      return;
    }

    if (type === 'addMemo') {
      const { text } = payload;
      const vec = await embedText(text);
      const id = await dbAdd({
        text,
        vector: float32ToBuffer(vec),
        timestamp: Date.now(),
        model: MODEL_ID,
      });
      self.postMessage({ type: 'addMemoResult', payload: { id } });
      return;
    }

    if (type === 'search') {
      const { query, topK = 5 } = payload;
      const qvec = await embedText(query);
      const all = await dbGetAll();
      const items = all
        .map((m) => {
          const { score, semantic, keywordMatch, keywordBoost } = searchScore(
            query,
            qvec,
            m,
          );
          return {
            id: m.id,
            text: m.text,
            score,
            semantic,
            keywordMatch,
            keywordBoost,
          };
        })
        .sort((a, b) => b.score - a.score)
        .slice(0, topK);
      self.postMessage({ type: 'searchResult', payload: { items } });
      return;
    }

    self.postMessage({ type: 'error', payload: { message: `不明な操作です: ${type}` } });
  } catch (e) {
    self.postMessage({ type: 'error', payload: { message: String(e?.message ?? e) } });
  }
};

4.6 動かない時のチェックポイント

「検索結果がおかしい」「いつも同じ」ように見えるときは、よくあるのは次のどれかです。

  • Workerが動いていない(type: 'module' を忘れている、file:// で開いている)
  • out.data の形が想定と違う(embedText の分岐で吸収)
  • ベクトルの正規化が効いていない(normalize: true を確認)
  • 保存した vector の復元が壊れている(ArrayBuffer の扱いミス)

4.7 つまずきポイント

  • IndexedDBの永続性: ブラウザ設定やストレージ圧迫状況によっては、データが削除される可能性があります。
  • 初回のモデルロード: 初回はモデル取得で時間がかかります。ローディング表示を出しておくと安心です。
  • 端末差: 推論速度(WASM/WebGPUの処理能力)は端末依存です。フォールバックや件数上限(例: 最大100件)を検討してください。
  • 件数が増えると重くなる: 本サンプルは IndexedDB から全件取得し、1件ずつスコア計算します(6件のデモでは問題になりにくい)。数百件以上では索引付きベクトルDBが必要になります。

5. まとめ:AIにコードを任せ、設計を握ることが大事

「AIにIndexedDBのコードを書いて」と言えば、一瞬で動くものが手に入る時代です。ただ、今回のデモでも、語句一致なしでも特定の件数表示されるといった挙動や、初回だけモデル取得が走る点は、コードを書くだけでは見落としやすいと感じました。設計側(制約、フォールバック、UX)を握る価値は、むしろ大きくなっていると思います。

LocalStorage は一時的な設定、IndexedDB はメモとベクトルの置き場、Transformers.js と Web Worker は推論の実行場所、と分けて考えるとよいです。

HTML 2ファイルとブラウザだけで、保存・埋め込み・類似検索の流れは試せます。動かしたあと、「うちのプロダクトで、外部に送りたくないデータは何か?」をチームで1つ決めてみてください。ローカルに寄せる範囲の判断が、だいぶはっきりしてきます。 ぜひいろいろ試してみてくださいね。

執筆者

中川 拓哉(NTT西日本 デジタル革新本部 デジタル改革推進部所属)
NTT西日本のWebアプリケーションの開発・運営に従事。
好きな技術スタック:TypeScript, Vue.js, GraphQL, Laravel

参考資料・出典

本記事を執筆するにあたり、以下のサイトを参考にしました。

  • MDN Web Docs: IndexedDB(https://developer.mozilla.org/docs/Web/API/IndexedDB_API
  • MDN Web Docs: WebGPU(https://developer.mozilla.org/docs/Web/API/WebGPU_API
  • MDN Web Docs: Web Workers(https://developer.mozilla.org/docs/Web/API/Web_Workers_API
  • Transformers.js(https://github.com/huggingface/transformers.js
  • jsDelivr CDN(https://www.jsdelivr.com/

商標

  • 「Google Chrome」は、Google LLCの商標または登録商標です。
  • 「Microsoft Edge」は、Microsoft Corporationの商標または登録商標です。
  • 「Visual Studio Code」は、Microsoft Corporationの商標または登録商標です。
  • 「Firefox」は、Mozilla Foundationの商標または登録商標です。
  • 「Node.js」は、OpenJS Foundationの商標または登録商標です。
  • 「Hugging Face」は、Hugging Face, Inc.の商標または登録商標です。
  • Transformers.js は、Hugging Face, Inc.が提供するライブラリです。
  • 記載の会社名・製品名は、それぞれ各社の商標または登録商標です。

© NTT WEST, Inc.