はじめに こんにちは、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 とは何か 1.2 イメージしやすい Before / After(社内メモ検索の例) Before(クラウドAPI中心) After(Local-First 寄り) 1.3 本記事の知識が活きるユースケース 1.4 現実的な構成:ローカルとクラウドの役割分担 1.5 ベクトル検索を用いたアプリケーションが直面しやすい3つの課題 1.6 Local-First AIが向かないケース 2. 技術的アプローチ:IndexedDB(長期記憶)× Transformers.js(推論)× Worker(滑らかさ) 2.1 ブラウザを「AIの脳」にする:Transformers.jsと推論バックエンド(WASM/WebGPU) 2.2 IndexedDBを「AIの長期記憶」として使う 3. 設計上の前提 3.1 この記事で「守ること/守れないこと」 3.2 埋め込み化が遅い・動かないときの対処 4. 【実装】ブラウザだけで動く「ローカルAIメモ検索」サンプル 4.1 何を作るか(完成イメージ) 4.2 ファイル構成 4.3 試し方 4.3.1 デモデータと検索クエリ例 4.3.2 検索結果のスコアとコサイン類似度 判定の流れ(検索1回あたり) コサイン類似度(意味の近さ)とは スコアの見方 4.4 index.html(UI・Worker起動) 4.5 embedding.worker.js(CDN + IndexedDB + 検索) 4.6 動かない時のチェックポイント 4.7 つまずきポイント 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.が提供するライブラリです。 記載の会社名・製品名は、それぞれ各社の商標または登録商標です。