概要:ブラウザ完結RAGは可能か
RAG(Retrieval-Augmented Generation)は通常、サーバー上のベクトルDBとLLMで構成します。 しかしすべてブラウザ内で完結させることは可能でしょうか。 この記事では、RAGの心臓部であるベクトル検索だけを取り出し、 WebGPU で実装できるかを検証します。
(WebGPU/WASM)
コサイン類似度
(本記事では扱わない)
LLM生成(WebLLM)は敢えて切り離します。 「正しい文書が引けているか」を先に確かめないと、RAG全体が成立しないためです。 検索が破綻していれば、どんな優秀なLLMを載せても意味がありません。まず土台を検証します。
動作環境・注意事項
| Chrome 113+ | ✔ 推奨。WebGPU で埋め込み・検索とも高速 |
| Edge 113+ | ✔ 動作します |
| Firefox | △ WebGPU 未対応。埋め込みは WASM、検索は CPU で動作 |
| Safari 18+ | △ 実験的対応 |
| 初回ロード | △ モデル(約120MB)のダウンロードに時間がかかります |
| 通信 | △ 初回のみ Hugging Face からモデルを取得します(以降キャッシュ) |
| Android (Pixel 10) | △ WebGPU埋め込みが NaN を返すため、埋め込みのみ自動的に WASM へ切替(後述) |
| モバイル | △ メモリ制約により動作しない場合があります |
モデルのダウンロード(約120MB)とWebGPU初期化はモバイルでも成功しました。 ただし埋め込み生成でGPU固有の問題を踏んだため、自動フォールバック機構を実装しています(セクション07)。
ブラウザ完結の代償として、初回だけ約120MBのモデルをダウンロードします。 2回目以降はブラウザキャッシュから読み込まれるため高速です。 この「初回の重さ」が、ブラウザ完結RAGの最大の実用課題です。
インタラクティブ・デモ
動作原理:テキストをベクトルにする
ベクトル検索の核心は、「意味が近いテキストは、ベクトル空間でも近くに配置される」という性質です。 埋め込みモデルは、テキストを384次元の数値ベクトルに変換します。
文書を事前にベクトル化
知識ベースの全文書を埋め込みモデルに通し、384次元ベクトルの行列(N×384)として保持します。これは一度だけ行います。
クエリをベクトル化
ユーザーの質問も同じモデルで384次元ベクトルに変換します。同じ空間に写像されるのが重要です。
全文書との類似度を計算
クエリベクトル(1×384)と文書行列(N×384)の内積を取り、類似度スコアを得ます。ここがWebGPUの出番です。
コサイン類似度は
dot(a,b) / (|a| × |b|) ですが、
Transformers.js の normalize: true でL2正規化されたベクトルを得れば |a| = |b| = 1 となり、
単純な内積だけで済みます。WGSLが大幅にシンプルになります。
コードのポイント解説
// ESモジュールとしてCDNから直接import import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0'; // 埋め込みパイプラインを作成(WebGPU優先、非対応ならWASM) const extractor = await pipeline( 'feature-extraction', 'Xenova/multilingual-e5-small', // 日本語対応・384次元 { device: 'webgpu' } // ← これだけでGPU実行 ); // テキストを384次元ベクトルに変換(L2正規化つき) const output = await extractor('WGSLのバリア同期について', { pooling: 'mean', // トークン平均を取る normalize: true, // L2正規化 → 内積=コサイン類似度に }); const vec = output.data; // Float32Array(384)
device: 'webgpu' を指定するだけで、ONNX Runtime Web が
埋め込みモデルの推論をGPUで実行します。非対応環境では自動的にWASM(CPU)にフォールバックするため、
エラーハンドリングは不要です。
// 1スレッド = 1文書の類似度を担当 @group(0) @binding(0) var<storage, read> query: array<f32>; // 384 @group(0) @binding(1) var<storage, read> docs: array<f32>; // N×384 @group(0) @binding(2) var<storage, read_write> scores: array<f32>; // N @group(0) @binding(3) var<uniform> p: Params; // numDocs, dim @compute @workgroup_size(64) fn main(@builtin(global_invocation_id) gid: vec3u) { let docId = gid.x; if (docId >= p.numDocs) { return; } // この文書のベクトルとクエリの内積を計算 var dot = 0.0; let offset = docId * p.dim; // 文書ベクトルの開始位置 for (var i = 0u; i < p.dim; i = i + 1u) { dot = dot + query[i] * docs[offset + i]; } // 正規化済みなので、内積 = コサイン類似度 scores[docId] = dot; }
ハイライト部分が検索の核心です。各スレッドが1文書を担当し、384次元の内積を計算します。 1万文書なら1万スレッドが同時に走ります。行列積(記事⑪)の特殊ケース—— クエリが1行だけの「行列 × ベクトル」演算と見ることもできます。
// GPUから類似度スコアを取得 const scores = new Float32Array(readBuf.getMappedRange()); // スコアとインデックスをペアにしてソート const ranked = Array.from(scores) .map((score, idx) => ({ score, idx })) .sort((a, b) => b.score - a.score) // 降順 .slice(0, topK); // 上位K件 // 文書本体と紐付けて返す return ranked.map(r => ({ ...knowledgeBase[r.idx], score: r.score, }));
ソート自体はCPUで行います。文書数が数万程度なら一瞬です。 百万件を超えるとGPU上でのソート(bitonic sort等)が必要になりますが、 ブラウザで扱う規模では過剰最適化です。
文書ベクトル行列は
文書数 × 384次元 × 4バイト のバッファになります。
10万文書なら約146MB——これはWebGPUの
maxStorageBufferBindingSize(既定 128MB)を超えます。384次元・128MB制限では 約87,000文書が上限です。 本デモでは
device.limits.maxStorageBufferBindingSize から実際の上限を取得し、
超える文書数はGPU計測をスキップしています。それ以上の規模を扱うなら、文書行列をチャンクに分割して複数回ディスパッチするか、 ベクトルを量子化(f32→f16やint8)してサイズを削る必要があります。
CPU vs WebGPU ベンチマーク
ベクトル検索は本当にGPUで速くなるのでしょうか。 合成ベクトルで文書数を増やしながら、CPU実装と比較します。
SEARCH TIME vs DOC COUNT(LOG-LOG)
| DOCS | CPU | ナイーブ | コアレス | +vec4 | SPEEDUP |
|---|---|---|---|---|---|
| ベンチマークを実行してください | |||||
※ ナイーブ=文書ごとに連続配置 / コアレス=転置して次元ごとに連続 / +vec4=さらに1スレッドが4文書を同時処理。
※ SPEEDUP は最適化版(+vec4)の CPU 比。
実機で踏んだ2つの地雷
Android(Pixel 10)で実行して、設計レベルの重大なバグを2つ発見しました。 どちらも実機で動かさなければ気づけないものでした。
モデルのロードは成功し、WebGPUも有効、検索も走る——なのに類似度スコアが全て NaN。 原因は埋め込みモデル側でした。ONNX Runtime Web の WebGPU バックエンドは、 一部のモバイルGPU(Pixel 10)で LayerNorm/Softmax の数値計算が破綻し NaN を生じることがあります。
対策:①
dtype: 'fp32' を明示(fp16は不安定)。
② ロード直後にテスト埋め込みで NaN 検査を行い、検出したら
埋め込みだけ WASM(CPU) に自動フォールバックする。
ベクトル検索(自作WGSL)は引き続きWebGPUで動かせます。教訓:GPUは「動いている」ように見えても、数値が壊れていることがある。
初回実装では、GPUがどの文書数でもCPUの約5倍遅く、速くなる境界が現れませんでした。 50,000文書でCPU 29ms に対し GPU 136ms。
原因は検索のたびに全文書ベクトルをGPUへ再アップロードしていたこと。 50,000文書 = 73MBの転送が毎回発生し、計算時間を完全に支配していました。 GPUの計算自体は1ms未満だったのです。
対策:文書ベクトルは起動時に一度だけGPUへ転送して常駐させ、 検索時に送るのはクエリ(1.5KB)だけにする。
教訓:GPUの速さはデータ転送に食い潰される。「何を常駐させるか」が設計の本質。
// ❌ 悪い実装:検索のたびに全文書を転送(73MB × 毎回) async function searchGPU(queryVec, docVecs, numDocs) { const dBuf = device.createBuffer({ size: numDocs*DIM*4, ... }); device.queue.writeBuffer(dBuf, 0, docVecs); // ← 毎回73MB! ... } // ✅ 正しい実装:文書は一度だけ転送して常駐 function createSearchContext(docVecs, numDocs) { const dBuf = device.createBuffer({ size: numDocs*DIM*4, ... }); device.queue.writeBuffer(dBuf, 0, docVecs); // ← 起動時に1回だけ return { qBuf, dBuf, sBuf, uBuf, rBuf, bind, numDocs }; } async function searchWithCtx(ctx, queryVec) { device.queue.writeBuffer(ctx.qBuf, 0, queryVec); // ← クエリ1.5KBのみ // ディスパッチしてスコアを読むだけ }
上の2つを直してGPUはCPUを上回りましたが、速度差はわずか1.2倍。 Pixel実機の数字を分析すると異常が見えました。
50,000文書の計算量は 38 MFLOP。GPU 23.3ms → 実効 1.6 GFLOP/s。 Pixel 10 の理論性能は1,000 GFLOP/s級ですから、0.1%しか使えていません。 メモリ帯域も 3.3 GB/s(モバイルGPUは30〜60 GB/s級)と全く使い切れていません。
原因はメモリアクセスパターン(非コアレス)でした。
最大の最適化:メモリコアレッシング
GPUは「隣り合うスレッドが、隣り合うメモリを読む」ときに最高効率を発揮します。 これをコアレッシング(合体アクセス)と呼びます。 複数のメモリ要求を1本のトランザクションにまとめられるためです。
次元ごとに全文書が連続配置(dimension-major)されるようにレイアウトを変えます:
スレッド0 → docsT[0], docsT[50000], ...スレッド1 → docsT[1], docsT[50001], ... ← 隣接!同じ次元 i について、隣接スレッドが隣接アドレスを読みます。 GPUはこれを1本のメモリ要求にまとめられます。
// ❌ ナイーブ:文書ごとに連続(row-major) for (var i = 0u; i < p.dim; i = i + 1u) { dot = dot + query[i] * docs[docId * p.dim + i]; // ↑ スレッドごとに384離れる } // ✅ コアレス:次元ごとに連続(転置 / dimension-major) for (var i = 0u; i < p.dim; i = i + 1u) { dot = dot + query[i] * docsT[i * p.numDocs + docId]; // ↑ 隣接スレッドが隣接アドレス } // 転置はアップロード時に一度だけ行えばよい(CPU側で実行) function transposeDocs(docVecs, numDocs) { const out = new Float32Array(numDocs * DIM); for (let d = 0; d < numDocs; d++) for (let i = 0; i < DIM; i++) out[i * numDocs + d] = docVecs[d * DIM + i]; return out; }
行列積のタイル化と本質は同じです。計算量は1バイトも減っていないのに、 メモリの読み方を整えるだけで劇的に速くなる。 GPU最適化とは、多くの場合「計算を減らす」ことではなく 「メモリアクセスを整える」ことなのです。
さらに一歩:vec4f で1スレッドが4文書を処理
コアレス化してもまだ帯域を使い切れていませんでした(実効7 GB/s、モバイルGPUは25〜30 GB/s級)。 原因はスカラー f32 を1個ずつ読んでいたこと。GPUは16バイト単位で読む方が効率的です。
// docsT4[i * numDocs4 + d4] は vec4f // = 次元 i における「4文書分」の値がまとまったもの @group(0) @binding(1) var<storage, read> docsT4: array<vec4<f32>>; @compute @workgroup_size(64) fn main(@builtin(global_invocation_id) gid: vec3u) { let d4 = gid.x; // 4文書ぶんのインデックス if (d4 >= p.numDocs4) { return; } var acc = vec4<f32>(0.0, 0.0, 0.0, 0.0); for (var i = 0u; i < p.dim; i = i + 1u) { // スカラーを vec4 にブロードキャスト乗算 → 4文書を一度に acc = acc + query[i] * docsT4[i * p.numDocs4 + d4]; } scores4[d4] = acc; // 4件のスコアを一度に書き出す }
メモリ命令が1/4になり、1命令で16バイトを読むためコアレスも完璧になります。 文書数は4の倍数にパディングし、余りは0埋め(スコア0になるので無害)します。
GPU側の3段階を実測しました。計算量は1バイトも減っていません—— メモリの読み方を整えただけです。
| 文書数 | ナイーブ | コアレス | +vec4 | 改善 |
|---|---|---|---|---|
| 5,000 | 8.60 | 5.00 | 3.80 | 2.3× |
| 20,000 | 14.40 | 7.50 | 5.40 | 2.7× |
| 50,000 | 23.70 | 14.50 | 10.40 | 2.3× |
Pixel実測では、100文書でも1,000文書でもGPUは約4msかかりました。 文書数を10倍にしても 3.9ms → 4.1ms とほぼ変わりません。
これは
submit() → mapAsync() の往復レイテンシで、
計算量に関係なく発生する固定費です。一方CPUは100文書なら0.1msで終わります。数千文書以下では、GPUは原理的にCPUに勝てません。 これはWebGPU設計上の制約であり、シェーダをいくら最適化しても消せません。
最適化を進める過程で、CPU側の計測が物理的にありえない値を出しました。 5,000文書(2.30ms)が1,000文書(3.70ms)より速い——内積は完全なO(N)なので、これは不可能です。
原因はウォームアップの非対称。GPU側にはウォームアップを入れていたのに、 CPU側には入れていませんでした。 JavaScriptエンジンは関数が何度も呼ばれて初めて機械語に最適化コンパイルするため、 最初の数点は未最適化の遅いコードで計測されていたのです。
対策:計測前にCPU側も十分に空実行してJITを効かせ、GPUと同じ土俵に乗せる。 さらに「O(N)から逸脱していたら警告する」健全性チェックも実装しました。
教訓:比較する対象は必ず同じ条件に揃える。片方だけウォームアップするのは不正な計測。
iPad mini(Safari)で計測すると、結果がすべて整数になりました (0.00 / 1.00 / 2.00 / 3.00 / 16.00…)。
Safari は Spectre 対策として
performance.now() の分解能を
1ms に意図的に丸めています。つまり「1.00ms」は実際には0.5〜1.49msのどこかで、
1ms未満の差は測定不能。GPUの高速な処理ほど、まともに測れません。対策:1回ずつ測るのをやめ、 合計が60ms以上になるまで反復して割り算する(アモチゼーション)。 0.4msの処理なら150回反復して60msを測り、÷150で0.40msを得ます。 反復回数は自動調整し、タイマー分解能も検出して表示します。
教訓:ブラウザによって時計の精度が違う。速い処理ほど、1回の計測では真値が見えない。
ダウンロード100%の直後、画面が真っ白に——Safariのタブがメモリ不足で落ちました。
原因は、地雷①(NaN対策)で
dtype: 'fp32' を明示したこと。
これによりモデルが約120MBと大きくなり、ONNX Runtime の展開時に
iPad mini のメモリ上限を超えました。一つの対策が別の問題を生んだ形です。対策:q8量子化版(約30MB)をデフォルトにし、 精度をUIで選べるようにしました。q8はWASM実行でNaNも起きず、メモリも1/4です。
教訓:ブラウザ完結AIの最大の敵は「メモリ」。精度より軽さを優先すべき場面がある。
最大の発見:最適なカーネルは端末で逆転する
Pixel 10 と iPad mini で同じベンチマークを実行したところ、 最速のカーネルが逆転しました。
| 50,000文書 | ナイーブ | コアレス | +vec4 | 最速 |
|---|---|---|---|---|
| Pixel 10 | 22.70 | 15.30 | 11.30 | +vec4 |
| iPad mini (Apple) | 4.12 | 2.97 | 3.50 | コアレス |
Pixel 10 では vec4 がコアレスより26%速いのに、 iPad mini では18%遅くなりました。
理由はスレッド数と考えられます。vec4版は1スレッドが4文書を担当するため、 スレッド数が1/4になります(50,000文書 → 12,500スレッド)。 iPad mini は多数のスレッドでメモリ遅延を隠す設計のため、 スレッドを減らす最適化が裏目に出たのでしょう。 一方 Pixel 10 は幅の広いメモリ読み込みの恩恵が上回りました。
固定オーバーヘッド
Pixel 約3.4ms に対し、iPad は約1.0ms。AppleのWebGPU実装は往復レイテンシが3倍以上軽い。
GPU絶対性能
50,000文書の最良値は Pixel 11.30ms vs iPad 2.97ms。iPadが3.8倍高速。
タイマー精度
Chromeは高分解能だが、Safariは1msに丸める。同じコードでも計測の見え方が全く違う。
「vec4が最速」と決め打ちすると、iPad mini では損をします。 本デモは各文書数で最速のカーネルを自動判定し、 「この端末での最速カーネル」をKPIに表示するようにしました。
実用のRAGなら、起動時に小さなキャリブレーションを走らせて最適カーネルを選ぶのが正解です。 GPU最適化に「万能の正解」はなく、端末ごとに測って決めるしかありません。
考察とRAGへの道
小規模ではCPUで十分
数十〜数百文書ならCPUで1ms未満。GPUの転送コストが上回り、むしろ遅くなります。記事⑥⑪と同じ構図です。
大規模でGPUが効く
数万〜数十万文書でGPUが明確に勝ちます。大規模な知識ベースを扱うなら、WebGPUの価値が出ます。
ボトルネックは埋め込み側
検索が1msでも、クエリの埋め込み生成に数十msかかります。RAG全体では埋め込み・LLM推論が支配的です。
サーバーコストゼロ
すべてブラウザ内で完結するため、サーバー費用が一切かかりません。静的ホスティングだけでAI検索が動きます。
「住宅ローンの返済シミュレーション」で検索すると、正解の住宅ローン計算機が 0.887 でトップ。 しかし全く無関係なモンテカルロ法の記事も 0.812 と出ます。
e5系モデルは類似度の下限が高い(0.75〜0.80あたりが「無関係」の水準)という特性があります。 順位付けは正しく機能しますが、「0.8以上なら関連あり」といった絶対しきい値は使えません。
RAGで「関連文書が無ければ答えない」を実装するなら、 絶対値ではなくスコアの分布(1位と2位の差など)を見る設計が必要です。
ブラウザ完結のベクトル検索は技術的に成立します。 ただし現実的な文書数(数十〜数百)ではGPUの恩恵は小さく、 真価が出るのは大規模知識ベースです。
一方で「サーバー不要」という価値は文書数に関係なく得られます。 次のステップは、この検索結果を LLM(WebLLM)に渡して回答を生成させることです。