TECH PLAY

プログラミング

イベント

マガジン

技術ブログ

社会インフラ業務で需要が増加しているGIS(地理情報システム)について解説します。代表的なツール(ArcGIS、QGISなど)の種類やトレンド、都市計画などの用途、活用に必要なプログラミング言語などの基礎知識をご紹介します。
はじめに こんにちは、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.が提供するライブラリです。 記載の会社名・製品名は、それぞれ各社の商標または登録商標です。
はじめに # AIテクニカルセクターの藤堂です。セクターが独立組織となって以降、全社でAI活用を広げる取り組みが本格化しています。そのような中、6月には 第40回 JSAI2026 に参加しました。 前半ではポスター発表をした、士業資格試験を対象としたLLMベンチマークShigyoBenchについて簡単に触れ、AIの実用化についての所感を述べます。 後半では、人工知能学会で印象に残った発表と、今後のAI研究について思ったことを記します。 どういう論文か # ShigyoBenchは、 日本の士業資格試験を対象にしたLLMベンチマークデータセット です。宅建・行政書士・弁理士・司法書士・司法試験(予備試験含む)・不動産鑑定士・公認会計士の8試験、短答式8,979問を統一フォーマットで整備し、複数のLLMで評価実験を行いました。データセットは Hugging Face で公開しています。 論文の位置づけとしては、専門領域の知識を試験問題で定量評価する研究です。士業領域(行政書士・弁理士・宅建・不動産鑑定士・司法書士など)とCPA全科目について、これまで再現可能な共通ベンチがなかった空白を埋める、というのが動機です。 評価の結果、モデルや試験によって差は大きく、Gemini-3-Proは一部の試験で合格ラインを上回る一方、多くのモデル・科目ではまだ届かない、というのが本研究の結論です。 たとえば次のような問題があります。以下は、 google/gemma-4-26B-A4B-it で推論させて不正解となった一例です。 司法試験・令和2年・民法第8問 # 問題 即時取得に関する次のアからオまでの各記述のうち、判例の趣旨に照らし誤っているものを組み合わせたものは、後記1から5までのうちどれか。 ア. Aは、自己所有の宝石をBに売却して現実の引渡しをした。その後、Bは宝石をCに売却して現実の引渡しをした。さらにその後、AはAB間の売買契約をBの強迫を理由として取り消した。この場合、Cは即時取得により宝石の所有権を取得することはない。 イ. 未成年者Aは、自己所有の宝石をBに売却して現実の引渡しをした。その後、AはAB間の売買契約を未成年であることを理由として取り消した。この場合、Bが即時取得により宝石の所有権を取得することはない。 ウ. Aは、B所有の宝石をBから賃借して引渡しを受けた上、宝石をCに預けていたが、宝石をDに売却し、Cに対し宝石を今後Dのために占有するよう命じ、Dがこれを承諾した。この場合、Dは宝石がA所有であると信じ、かつそのことに過失がなかったとしても、即時取得により宝石の所有権を取得することはない。 エ. Aは、Bが置き忘れた宝石を、自己所有物であると過失なく信じて持ち帰った。この場合、Aが即時取得により宝石の所有権を取得することはない。 オ. Aは、BがCから賃借していた宝石を盗み、Dに贈与した。Dが宝石をAの所有物であると過失なく信じて現実の引渡しを受けた場合、Bは宝石の盗難時から2年間は、Dに宝石の回復を請求することができる。 ア ウ 2. ア エ 3. イ ウ 4. イ オ 5. エ オ google/gemma-4-26B-A4B-it の回答 この問題は、民法192条の「即時取得」に関する記述の正誤を判断し、誤っているものの組み合わせを選ぶ問題です。 ### 各記述の検討 **ア. 正しい** 即時取得が成立するためには、「取引行為」が必要です。Bの強迫による取消しの場合、取消しによって契約は遡及的に無効となりますが、判例によれば、取消しによって権利を失った者(A)からさらに譲り受けた第三者(C)が即時取得を行うためには、**「取消し前」に取引が行われていなければなりません**。しかし、本問ではAが取消しをした「後」にCが譲り受けているわけではなく、時系列としては「A→B(売買)→C(売買)」の後に「Aが取消し」をしています。 ここで重要なのは、強迫による取消しは「詐欺」とは異なり、取消しによって権利が遡及的に失われますが、即時取得の要件である「有効な取引」の観点から、強迫による取消しがなされた場合、Bは無権利者となります。しかし、判例の趣旨(および通説的な考え方)では、強迫による取消しは、善意無過失の第三者(C)に対してもその効力を主張できる(Cは即時取得できない)とされています。したがって、「Cは即時取得により所有権を取得することはない」という記述は正しいです。 **イ. 誤り** 即時取得が成立するためには、占有者の「権利」が欠けていること(無権利者からの取得)が必要ですが、取引行為自体は有効である必要があります。未成年者が単独で行った契約の取消しは、行為能力の瑕疵によるものですが、これは即時取得を妨げる理由にはなりません。Bが善意無過失であれば、即時取得により所有権を取得できます。したがって、「Bが即時取得により所有権を取得することはない」という記述は誤りです。 **ウ. 正しい** 即時取得が成立するためには、「占有の譲渡」などの直接的な占有の移転が必要です。本問のように、AがCに預けていたものを、Dに対して「占有改定(CがDのために占有するよう命じる)」によって占有を移転させた場合、判例は**占有改定による即時取得の成立を否定**しています。したがって、「Dは……即時取得により宝石の所有権を取得することはない」という記述は正しいです。 **エ. 正しい** 即時取得は「取引行為(売買、贈与など)」によって占有を取得する場合に成立します。本問のように、他人の物を勝手に持ち帰る行為(占有の事実上の取得)は「取引行為」には当たらないため、即時取得は成立しません。したがって、記述は正しいです。 **オ. 誤り** 民法193条により、盗品または遺失物の占有者は、盗難または遺失の時から**2年間**、即時取得者に対してその物の回復を請求できます。しかし、本問のDは、Aが「盗んだ物」であることを知らない(善意無過失)ため、即時取得が成立します。即時取得が成立した場合、Dは有効に所有権を取得します。たとえ民法193条の期間内であっても、即時取得によって権利が確定している以上、元の所有者BはDに対して返還を請求することはできません(即時取得は、193条の規定にかかわらず、有効な取得を認めるものです)。したがって、記述は誤りです。 ### まとめ - ア:正しい - イ:誤り - ウ:正しい - エ:正しい - オ:誤り 誤っているものは **イ** と **オ** です。 選択肢を確認すると、イとオの組み合わせは「4」となります。 <answer>4</answer> 正解 : 1 手法や結果の詳細については 論文 をご覧ください。 実用化への道のり # LLMが登場し、ShigyoBenchのような人間と同じ試験問題を使ってLLMの専門知識を測ることが、AI評価の標準として定着しました。それにより、国際数学オリンピックや東京大学の入試試験、医師国家試験など、いくつもの試験がフロンティアLLMで解かれることが知られています。RAGなどを組み合わせれば士業試験で合格ラインに届かせることも、十分あり得ると思います。 ポスター発表時のディスカッションやいくつかのセッションでも、人間の仕事はなくなっていくのでは、という議論が盛んにありました。 一方で、試験問題が解けることと、実用化できることは、別問題だということが現在、議論されています。 最も進んでいる領域の1つと考えられるのが医療分野です。Gong ら(2025)のシステマティックレビューでは、39の医療LLMベンチマークを整理しました。 国家試験(USMLE)型の知識ベース評価では、先行モデルが84〜90%の正答率に達します。 対照的に、診療現場に近い実践ベースの評価では45〜69%程度にとどまります(いわゆる knowledge-practice gap)。 「試験問題が解けることと、患者さんに直接LLMの出力を出せることは別」ということを、知り合いのお医者さんにも聞いたことがあります。 これは医療だけの問題ではなく、実用化にはいくつかのステップがあるのだろうと思います。自由記述での推論、不確実性の管理、マルチターンの対話、文脈の統合、安全性といった実践的なベンチマークに何が求められるかが、議論の焦点となっています。 ソフトウェア開発では、少し様子が違います。こうした「試験と現場のギャップ」を十分に議論する前に、コーディング支援やエージェントが現場に入り始めている印象があります。おそらく、コードは試して直しやすく、フィードバックも速いからでしょう。医療や法律のように、一度の誤りから取り返しのつかない結果が生じにくい、という違いもあるのかもしれません。ただ、1つのシステムにかかわる複雑なコードやドキュメント類の品質をどのように保つのか、テストの合格率だけでは見えないリスクもあります。私自身、実案件でLLMを使いながら、その便利さと危うさの両方を感じています。 フィジカルAIへの期待 # JSAI2026では、ロボティクスやフィジカルAI(Physical AI)に関する発表が数多く見られました。業務都合でその中の1つのセッションしか聞けなかったのですが、日本でも研究者や学生の関心が集まっていることを実感しました。 最も印象に残った発表は、「基盤モデル時代におけるPhysical AI」セッションでの「 自動運転向けの多視点の動画生成における世界ハンドオフ整合性指標の設計 」です。 拡散モデルによる映像生成モデルが登場し、自動運転用の合成データ作成などに役立てられています。そのようなモデルで複数の視点からカメラで写したような映像が取得できますが、それらカメラの間で物理的な不整合(あるカメラでセダンだった車が別のカメラで別の車種になるなど)が発生します。そのような不整合を定量的に評価しようという論文です。 画像におけるハルシネーションという言い方をされていて、原理的には確かにそういったことが起こるなと思いますし、そのことを定量化したという点で価値があります。 松尾豊教授が、「JSAIの3分の1は国際学会で発表できるレベルにある」と言及されていました。個人的な体感として、論文数が増えた現在、そこまで高い割合ではないかなと思いますが、本発表は国際レベルの内容で、このように優秀な若者がフィジカルAIの問題をどんどん解いていくのだろうなと思いました。 生成AIがAI研究自体を変え始めている # 特定のセッションというより、学会全体を通して感じたのは、生成AIがAI研究の進め方そのものを変え始めている、ということでした。 文献の当たり方、プログラミング、実験の設計、データの整理、モデルの学習といった、これまで研究者が時間をかけていた作業の多くを、LLMが肩代わりし始めています。負荷の中心は「実装する」「情報を集める」から、「何を問うか」「どう測るか」へ移っているように見えます。試行が短くなるほど次の成果が出やすくなり、それがまた試行を短くする、というサイクルになっているように見えます。 個人的には、ひとつの論題を深掘りするより、こうしたサイクルを回しやすくする環境づくりに時間を使うようになってきました。 Cursor や Codex のようなコーディングエージェントがあります。 各種 AI API と接続したアプリケーション、Google Colaboratory や Modal のような GPU 基盤も利用しています。 いま利用しているツールを図にすると、おおむね次の構成です。 モデルの使い分け、先行研究の整理の仕方、どこで人間が判断を挟むか、どのGPU をどの実験に回すか。このようにメタ視点で、実験環境全体にリソースを割くことにより、研究が加速していきます。 明日には似た研究が発表されているかもしれない。研究が加速する中でいちばん難しいのは、何を題材にするかだと思います。 おわりに # 現地には最終日しか行けませんでしたが、ポスター発表では名だたるLLMベンダーの方々とお話ができて大変充実していました。よもや国内にAIモデルベンダーと呼ばれる人々が現れるとは5年前には想像もつきませんでしたが、彼らにはどんどん世界にチャレンジしていってもらいたいです。 また、以前お仕事を一緒にさせていただいた懐かしい人にも出会えて、様々なお話ができました。このようなことはリアルの醍醐味ですし、AIコミュニティというのは広そうで狭いコミュニティなのだなと実感しました。 次は長崎です。 参考文献 # 本研究(ShigyoBench) 藤堂真登, 石川真之介. ShigyoBench: 日本の士業資格試験を対象としたLLMベンチマークデータセットの構築と評価. JSAI2026 論文集 , 2026. https://pub.confit.atlas.jp/ja/event/jsai2026/presentation/5Yin-A-16 / データセット: https://huggingface.co/datasets/todo1111/shigyobench 実用化・医療 Gong EJ, Bang CS, Lee JJ, Baik GH. Knowledge-Practice Performance Gap in Clinical LLMs: Systematic Review of 39 Benchmarks. J Med Internet Res . 2025;27:e84120. https://doi.org/10.2196/84120 学会で印象に残った発表 キム ボンジュン, et al. 自動運転向けの多視点の動画生成における世界ハンドオフ整合性指標の設計. JSAI2026 論文集 (基盤モデル時代におけるPhysical AI), 2026. https://pub.confit.atlas.jp/ja/event/jsai2026/presentation/2G4-OS-47a-02 大会 人工知能学会. 2026年度 JSAI(第40回)

動画

書籍