はじめに
こんにちは、NTT西日本の中川です。
本記事では、IndexedDB(保存)と Transformers.js(計算)を組み合わせ、ブラウザ内でメモの意味検索までのLocal-First AI入門を、ミニサンプルを用いて紹介します。
本記事は2026年6月時点の情報に基づきます。
生成AIが身近になった一方で、「APIにすべて任せればよいのでは?」と感じる方も多いと思います。
私も最初はそう思っていました。ただ、議事メモをクラウドに貼るたびに「この本文、送ってよいのかな」と迷う場面があり、そのたびに判断するのは負担に感じたので、ブラウザ内で完結する検索を試してみたところ、それなりに使い所があるように感じたため、サンプルを交えてご紹介します。
対象読者
- LocalStorageやIndexedDBの基本的な使い方は理解している方
- AIをアプリケーションに組み込みたいが、サーバー費用やプライバシー面に課題を感じている方
- Local-First AI の有用性が抽象的で、自社プロダクトのどこで効くかイメージしづらい方
- 「AIにコードを書かせる」段階から、「AIをブラウザ環境へ最適化する」段階へステップアップしたい方
目次
- はじめに
- 対象読者
- 目次
- 1. 背景・目的:なぜ今「Local-First AI」なのか?
- 2. 技術的アプローチ:IndexedDB(長期記憶)× Transformers.js(推論)× Worker(滑らかさ)
- 3. 設計上の前提
- 4. 【実装】ブラウザだけで動く「ローカルAIメモ検索」サンプル
- 5. まとめ: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つがよく出てきます。
- APIコスト: すべてのリクエストをサーバーへ送ると、ユーザー増・利用回数に伴いコストが膨らみやすい。
- プライバシー: 議事メモ・顧客対応メモなど、外部サーバーへ送りたくないデータがある。
- 応答性(レイテンシー): ネットワーク経由の推論は、通常、待機時間が発生する。
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 が使える環境では速くなることもありますが、必須ではありません。
困ったときは、この順で試すとよいです。
- まず動かす — WASM で登録・検索まで通す(今回のデモはここまで)
- 余力があれば速くする — 端末が対応していれば WebGPU を使う(Transformers.js が自動で選ぶ)
- それでも厳しい — 意味検索をやめるのではなく、負荷を下げる(キーワード検索に切り替える、登録件数や topK を減らす など)
4. 【実装】ブラウザだけで動く「ローカルAIメモ検索」サンプル
4.1 何を作るか(完成イメージ)
1章の After を、HTML とJavaScriptの2ファイルで動かす段階です。メモ登録 → 埋め込み保存 → クエリで意味検索、という流れだけに絞っています。チャット回答や社外API連携は範囲外です。
4.2 ファイル構成
次の2ファイルを同じフォルダに置きます。
index.html … UI(メインスレッド) embedding.worker.js … 埋め込み化・IndexedDB・検索(Worker)
4.3 試し方
- この章の
index.htmlとembedding.worker.jsのコードを、それぞれ同名ファイルとして同じフォルダに保存する - Visual Studio Code の Live Server などで、そのフォルダを
http://localhost:...経由で開く(file://で直接開くと Worker が動かないことが多いです) - Google Chrome または Microsoft Edge で
index.htmlを表示する - 「デモデータを一括登録」でサンプルを入れる(初回はモデル取得で数十秒かかることがあります)。「登録」から1件ずつ足して試してもかまいません
- 下の検証表と結果を比べたいときだけ、件数が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件を想定しているだけです。
一括登録されるデモデータ
[障害対応] 2026-03-15 認証基盤タイムアウトの件。原因はDBのコネクションプール枯渇。再発防止策としてリトライ上限を3回に変更し、アラート閾値を調整した。[手順] 社外からのVPN接続について。社外ネットワークからはポータル経由でのみアクセス可能。パスワードはITサポートにチケットを発行して取得すること。[顧客A社] 契約上の特記事項:ログに個人情報(氏名・メアド)を一切残さないこと。問い合わせ対応は専任のサポート窓口を経由すること。[議事録] 4/1 開発定例。次期リリースの目玉はRAG機能の強化。インフラ費用を抑えるため、一部の検索処理をフロントエンド(ブラウザ側)にオフロードする方針で合意。[技術メモ] 埋め込み推論は Transformers.js(WASM)が既定。端末が対応していれば WebGPU バックエンドで GPU 推論に切り替え可能。[オンボーディング] 新入社員向け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)は、意味の近さ(ベクトル)と語句一致(文字列)を組み合わせています。

登録時は逆方向です。メモ本文を埋め込み化して text と vector を 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.が提供するライブラリです。
- 記載の会社名・製品名は、それぞれ各社の商標または登録商標です。