2つの検証が出した答え
この三部作は、「AIコンシェルジュをどこまでブラウザで動かせるか」を追ってきた。 前編・後編で各部品を実機で検証した結果、答えははっきり分かれた。
| 部品 | ブラウザで動かすと | 実測での判断 |
|---|---|---|
| ベクトル検索 | 軽量・高速・端末非依存(前編) | ✓ ブラウザに移す |
| LLM生成 | 端末依存8倍・ロード4分・品質不安定(後編) | ✗ VPSに残す |
軽くて端末非依存な処理はブラウザへ、重くて品質が要る処理はサーバーへ。
「すべてブラウザで」という理想は美しいが、後編の実測(同じモデルで生成速度が 端末により8倍違う、初回ロード4分)が、その理想を退けた。 代わりに選んだのがハイブリッド——検索と生成を分業する構成だ。
この記事は、その設計を実際に本番サイト (sasagawa.tokyo のAIコンシェルジュ)へ投入するまでの全記録である。 すでに存在していたVPS完結型RAGを、どう最小の変更でハイブリッド化したか。 そして途中で踏んだ失敗をどう直したか。
現状:すべてVPSで動いていた
出発点は、ConoHa VPS(2GB RAM / CPUのみ)で動く既存のRAGだった。 ブラウザは表示だけで、検索も生成もすべてVPSが担っていた。
// ブラウザ --WebSocket--> ws_server --RabbitMQ--> consumer.py async def handle_query(payload): contexts = await retrieve(question) # ① e5埋め込み + ChromaDB検索(VPS) contexts = trim_contexts(contexts) # ② トークン調整 prompt = build_prompt(question, contexts) for token in llm(prompt, stream=True): # ③ llama.cpp 生成 send(token)
問題は、埋め込みモデル(SentenceTransformer)と生成モデル(llama.cpp)が 両方常駐していたこと。2GBに対してこれは重い。 検索をブラウザに移せば、埋め込みモデルをVPSから外せる—— これがハイブリッド化の直接の動機になった。
| 規模 | 記事 220件 / チャンク 2000件 |
| 埋め込み | intfloat/multilingual-e5-small(384次元) |
| 生成 | llama.cpp(CPU推論) |
| 通信 | WebSocket + RabbitMQ(三queue構成) |
| VPS | ConoHa 2GB RAM / 3 vCPU / GPUなし |
ハイブリッドの全体像
変更後のアーキテクチャはこうなる。検索がブラウザに移り、VPSは生成に専念する。 3つの層——ブラウザ・Producer・Consumer——の責務が明快に分かれる。
検索する
- kb.json(VPS生成)を読む
- e5でクエリを埋め込み
- コサイン類似度で上位K件
- chunk_id だけ送信
中継する
- generate を受理
- キュー制限・タイムアウト
- RabbitMQ 通常キューへ
生成する
- chunk_id からDB本文取得
- (埋め込み・検索しない)
- llama.cpp でストリーム生成
retrieve()(埋め込み+検索)がVPSから消える。
ブラウザが送るのは検索結果のchunk_id だけで、本文は送らない。
VPSは受け取ったidで自分のDBから本文を引く。
これにより「埋め込みモデルをVPSから外す」道が開け、
かつ本文がブラウザに露出しない(改ざん不可)という副次的な安全性も得られる。
最重要の検証:埋め込みは一致するか
実装の前に、絶対に確かめるべきことがあった。 ブラウザのe5(Transformers.js)とVPSのe5(sentence-transformers)が、 同じテキストに同じベクトルを返すか。 ここがズレると、ブラウザ検索の結果がサーバー版と変わってしまう。
| 比較 | 自己コサイン類似度 | 判定 |
|---|---|---|
| ONNX-fp32 vs VPS-fp32 | 1.000000 | 完全同一 |
| ONNX-q8 vs VPS-fp32 | 0.9954〜0.9963 | わずかにズレる |
fp32同士は文字通り同一ベクトル(
Xenova/multilingual-e5-small は
intfloat/... のONNX変換版で、重みが同じ)。q8は絶対値が0.4%ズレるが、ズレは系統的で相対順位は保たれる。 検索は上位K件の順位しか使わないので、q8クエリでも結果は変わらないことを実測で確認した。
この結果から、設計を一段堅牢にした。文書ベクトルはVPS(fp32)が生成してkb.jsonに書き出す。 ブラウザが埋め込むのは「クエリだけ」。こうすれば文書側の実装差は原理的にゼロになり、 差が出るのはクエリ埋め込みだけ——そしてそれも順位に影響しないと確認済み。二重の安全だ。
実装①:知識ベースをJSONで配信
VPS側で、ChromaDBの全チャンクを kb.json として書き出す。
既存の記事更新関数 add_articles() の末尾に1行足すだけで、
scrape / rescrape などすべての更新フローに乗る。
def export_vector_json(collection, model, cfg): data = collection.get(include=["metadatas", "embeddings"]) # documents(本文)は含めない = ブラウザに本文を出さない chunks = [] for cid, meta, emb in zip(data["ids"], data["metadatas"], data["embeddings"]): chunks.append({ "id": cid, # "url#chunkN" "title": meta["title"], "url": meta["url"], "vec": [round(float(x), 6) for x in emb], # fp32ベクトル }) # アトミック書き込み(一時ファイル→rename)で配信中を壊さない
ディスク上 7.06MB → Apache
mod_deflate の転送圧縮で2.4MB(34%)。
.gz を直接置くとブラウザ自動展開がApache設定依存で不確実なので、
非圧縮JSONを置き、転送時圧縮に任せるのが堅い。
ETag+Cache-Controlで2回目以降はダウンロードなし。
実装②:ブラウザで検索、idだけ送る
公開UIの AIConcierge クラスに検索モジュールを足す。
既存の受信処理は一切変えず、送信だけを差し替える。
async sendMessage() { // ハイブリッド: ブラウザで検索できるなら if (this.searchReady) { const hits = await this.search.search(text, topK); this.showContextPreview(hits); // 参照文書を即表示(体感UP) this.ws.send(JSON.stringify({ action: "generate", question: text, chunk_ids: hits.map(h => h.id), // ← idだけ。本文は送らない })); return; } // フォールバック: 従来のサーバー検索 this.ws.send(JSON.stringify({ action: "query", question: text })); }
kb.jsonのロード失敗・埋め込み失敗・検索0件——どのケースでも 従来の
action:"query"(サーバー検索)に落ちる。
hybridSearch: false にすれば即座に元通り。
新機能を入れても、既存の動作は絶対に壊れない設計にした。
実装③:VPSは生成に専念
Consumer側は handle_generate を新設。
既存の handle_query と違うのは文書の入手方法だけ。
検索(retrieve)を呼ばず、idからDBを引く。
# handle_query(既存) contexts = await retrieve(question) # VPSが埋め込み+検索 # handle_generate(新規) got = collection.get(ids=chunk_ids, # DBから直接取得(埋め込み不要) include=["documents", "metadatas"]) # get()は順序不保証 → chunk_idsの順(類似度順)に並べ直す id2idx = {cid: i for i, cid in enumerate(got["ids"])} contexts = [... for cid in chunk_ids if cid in id2idx] # 以降(trim/プロンプト/ストリーム/ログ)は handle_query と完全に同一
ブラウザは本文を送らず、VPSは自分のDBの本文しか使わない。 だから悪意あるユーザーが本文を改ざんして送り込むことが原理的にできない。 プロンプトインジェクション対策のためのハッシュ照合すら不要になった。 「本文を配信しない」判断が、サイズ削減とセキュリティを同時に実現した。
踏んだ3つの落とし穴
設計通りには進まなかった。本番投入までに3つの落とし穴を踏み、そのたびに直した。 これらは実装で最も学びの多い部分だ。
公開UIが
generate を送るようにしたのに、
中継役の ws_server.py が generate を知らなかった。
query しか通常キューに流していなかったため、generate が
管理キューに回り、生成が返らなかった。修正:バリデーション・キュー制限・タイムアウト監視・payload構築の 4箇所を「
query と同じ扱い」に広げた。3層のうち1層でも漏れると通らない、という教訓。
検索モジュールの初期化
init() にタイムアウトが無かった。
4G回線でモデルDLやkb.json取得が詰まると、Promise.all が永久pendingになり、
init が返らずUIが固まった。修正:各ロードに
Promise.race でタイムアウトを付け、
二重起動ガードも追加。詰まっても必ず返り、失敗時はサーバー検索へフォールバックする。
kb.json(展開後7MB)をlocalStorageにキャッシュしようとして気づいた—— localStorageは多くのブラウザで5MB上限。7MBは入らない。
修正:JS側のキャッシュをやめ、ブラウザのHTTPキャッシュ (ETag + Cache-Control)に任せる。サーバーは既にETagを返していたので、
Cache-Control を足すだけで2回目以降のダウンロードが消えた。
「ブラウザが元々持っている仕組みを使う」のが一番堅い。
落とし穴②を直した後、今度はフォールバックが起きた。 各ステップに個別ログを仕込んでおいたおかげで、コンソールに
kb.json 失敗: Unexpected token '<' と出た——
kb.jsonの代わりにHTML(404ページ)が返っていたとすぐ分かった。
推測で直す前に「どこで何が起きたか」を可視化する。デバッグの鉄則を再確認した。
本番稼働、そして残したもの
3つの落とし穴を越え、ハイブリッドRAGは本番で動いた。 「資本回収係数とは」と尋ねると、ブラウザがiseeit.jpのFP用語記事を正しく検索し、 VPSがその本文を根拠に正確な回答を生成する。検索も生成も、意図通りに繋がった。
検索がブラウザへ
クエリ埋め込みと検索がクライアント側で完結。VPSは往復と検索負荷から解放された。
本文露出ゼロ
ブラウザはidだけ送る。本文はVPSのDBから引くので改ざん不可。安全性が上がった。
フォールバック
サーバー検索の経路を残した。ブラウザ検索が使えない環境でも従来通り動く。
埋め込みモデルをVPSから完全撤去すれば、2GB RAMはさらに楽になる。 だが今はまだフォールバック(サーバー検索)を残している。 ブラウザ検索が全環境で安定することを本番で見届けてから、最後の撤去に進む。 実験公開のサイトとして、動くものを壊さないことを最優先にした判断だ。
前編:ブラウザのベクトル検索は実用的。
後編:ブラウザのLLM生成は動くが実用は苦しい。
統合編:だから検索はブラウザ、生成はサーバー——実測が導いた分業を、本番で形にした。
「すべてブラウザで」より「適材適所」。それが2つの実機と1つのVPSが教えてくれた答えだった。