モバイル&ワイヤレスブロードバンドでインターネットへ

gwaw.jp
 
GWAW.JP / WEBGPU × AI / HYBRID RAG
統合編 検証から本番へ

ブラウザ検索 × VPS生成
ハイブリッドRAGを本番投入する

前編でベクトル検索を、後編でLLMをブラウザで動かした。その2つの結論を統合し、 「検索はブラウザ、生成はサーバー」のハイブリッドRAGを設計・実装し、 実際に本番サイトへ投入するまでの全記録。失敗と修正も含めて。

ブラウザ検索 VPS生成 RabbitMQ llama.cpp WebSocket

01

2つの検証が出した答え

この三部作は、「AIコンシェルジュをどこまでブラウザで動かせるか」を追ってきた。 前編・後編で各部品を実機で検証した結果、答えははっきり分かれた。

部品ブラウザで動かすと実測での判断
ベクトル検索 軽量・高速・端末非依存(前編) ✓ ブラウザに移す
LLM生成 端末依存8倍・ロード4分・品質不安定(後編) ✗ VPSに残す
💡 設計原則
軽くて端末非依存な処理はブラウザへ、重くて品質が要る処理はサーバーへ。
「すべてブラウザで」という理想は美しいが、後編の実測(同じモデルで生成速度が 端末により8倍違う、初回ロード4分)が、その理想を退けた。 代わりに選んだのがハイブリッド——検索と生成を分業する構成だ。

この記事は、その設計を実際に本番サイト (sasagawa.tokyo のAIコンシェルジュ)へ投入するまでの全記録である。 すでに存在していたVPS完結型RAGを、どう最小の変更でハイブリッド化したか。 そして途中で踏んだ失敗をどう直したか。

02

現状:すべてVPSで動いていた

出発点は、ConoHa VPS(2GB RAM / CPUのみ)で動く既存のRAGだった。 ブラウザは表示だけで、検索も生成もすべてVPSが担っていた。

既存の処理フロー(handle_query)
// ブラウザ --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)
⚠️ 2GB RAM の圧迫
問題は、埋め込みモデル(SentenceTransformer)と生成モデル(llama.cpp)が 両方常駐していたこと。2GBに対してこれは重い。 検索をブラウザに移せば、埋め込みモデルをVPSから外せる—— これがハイブリッド化の直接の動機になった。
規模記事 220件 / チャンク 2000件
埋め込みintfloat/multilingual-e5-small(384次元)
生成llama.cpp(CPU推論)
通信WebSocket + RabbitMQ(三queue構成)
VPSConoHa 2GB RAM / 3 vCPU / GPUなし
03

ハイブリッドの全体像

変更後のアーキテクチャはこうなる。検索がブラウザに移り、VPSは生成に専念する。 3つの層——ブラウザ・Producer・Consumer——の責務が明快に分かれる。

① BROWSER

検索する

  • kb.json(VPS生成)を読む
  • e5でクエリを埋め込み
  • コサイン類似度で上位K件
  • chunk_id だけ送信
② PRODUCER (ws_server)

中継する

  • generate を受理
  • キュー制限・タイムアウト
  • RabbitMQ 通常キューへ
③ CONSUMER (consumer.py)

生成する

  • chunk_id からDB本文取得
  • (埋め込み・検索しない)
  • llama.cpp でストリーム生成
▼ chunk_ids を送信(本文は送らない)   /   ▲ 生成トークンをストリーム
🔑 決定的な変更点
retrieve()(埋め込み+検索)がVPSから消える。 ブラウザが送るのは検索結果のchunk_id だけで、本文は送らない。 VPSは受け取ったidで自分のDBから本文を引く。 これにより「埋め込みモデルをVPSから外す」道が開け、 かつ本文がブラウザに露出しない(改ざん不可)という副次的な安全性も得られる。
04

最重要の検証:埋め込みは一致するか

実装の前に、絶対に確かめるべきことがあった。 ブラウザのe5(Transformers.js)とVPSのe5(sentence-transformers)が、 同じテキストに同じベクトルを返すか。 ここがズレると、ブラウザ検索の結果がサーバー版と変わってしまう。

比較自己コサイン類似度判定
ONNX-fp32 vs VPS-fp321.000000完全同一
ONNX-q8 vs VPS-fp320.9954〜0.9963わずかにズレる
✅ 検索順位は全実装で完全一致
fp32同士は文字通り同一ベクトル(Xenova/multilingual-e5-smallintfloat/... のONNX変換版で、重みが同じ)。
q8は絶対値が0.4%ズレるが、ズレは系統的で相対順位は保たれる。 検索は上位K件の順位しか使わないので、q8クエリでも結果は変わらないことを実測で確認した。
💡 検証から得た設計改善
この結果から、設計を一段堅牢にした。文書ベクトルはVPS(fp32)が生成してkb.jsonに書き出す。 ブラウザが埋め込むのは「クエリだけ」。こうすれば文書側の実装差は原理的にゼロになり、 差が出るのはクエリ埋め込みだけ——そしてそれも順位に影響しないと確認済み。二重の安全だ。
05

実装①:知識ベースをJSONで配信

VPS側で、ChromaDBの全チャンクを kb.json として書き出す。 既存の記事更新関数 add_articles() の末尾に1行足すだけで、 scrape / rescrape などすべての更新フローに乗る。

本文は載せない — id・タイトル・URL・ベクトルのみ
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)で配信中を壊さない
📦 実サイズ(2000チャンク実測)
ディスク上 7.06MB → Apache mod_deflate の転送圧縮で2.4MB(34%)。 .gz を直接置くとブラウザ自動展開がApache設定依存で不確実なので、 非圧縮JSONを置き、転送時圧縮に任せるのが堅い。 ETag+Cache-Controlで2回目以降はダウンロードなし。
06

実装②:ブラウザで検索、idだけ送る

公開UIの AIConcierge クラスに検索モジュールを足す。 既存の受信処理は一切変えず、送信だけを差し替える。

query → generate、本文でなくidを送る
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 にすれば即座に元通り。 新機能を入れても、既存の動作は絶対に壊れない設計にした。
07

実装③:VPSは生成に専念

Consumer側は handle_generate を新設。 既存の handle_query違うのは文書の入手方法だけ。 検索(retrieve)を呼ばず、idからDBを引く。

retrieve() が消える = 埋め込み負荷が消える
# 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 と完全に同一
🔒 なぜidだけで安全なのか
ブラウザは本文を送らず、VPSは自分のDBの本文しか使わない。 だから悪意あるユーザーが本文を改ざんして送り込むことが原理的にできない。 プロンプトインジェクション対策のためのハッシュ照合すら不要になった。 「本文を配信しない」判断が、サイズ削減とセキュリティを同時に実現した。
08

踏んだ3つの落とし穴

設計通りには進まなかった。本番投入までに3つの落とし穴を踏み、そのたびに直した。 これらは実装で最も学びの多い部分だ。

🕳️ 落とし穴① Producer の見落とし
公開UIが generate を送るようにしたのに、 中継役の ws_server.pygenerate を知らなかった。 query しか通常キューに流していなかったため、generate が 管理キューに回り、生成が返らなかった。
修正:バリデーション・キュー制限・タイムアウト監視・payload構築の 4箇所を「query と同じ扱い」に広げた。3層のうち1層でも漏れると通らない、という教訓。
🕳️ 落とし穴② init() が返らなくなる
検索モジュールの初期化 init()タイムアウトが無かった。 4G回線でモデルDLやkb.json取得が詰まると、Promise.all が永久pendingになり、 init が返らずUIが固まった。
修正:各ロードに Promise.race でタイムアウトを付け、 二重起動ガードも追加。詰まっても必ず返り、失敗時はサーバー検索へフォールバックする。
🕳️ 落とし穴③ localStorage の 5MB 壁
kb.json(展開後7MB)をlocalStorageにキャッシュしようとして気づいた—— localStorageは多くのブラウザで5MB上限。7MBは入らない。
修正:JS側のキャッシュをやめ、ブラウザのHTTPキャッシュ (ETag + Cache-Control)に任せる。サーバーは既にETagを返していたので、 Cache-Control を足すだけで2回目以降のダウンロードが消えた。 「ブラウザが元々持っている仕組みを使う」のが一番堅い。
🔍 診断ログが真因を一発で照らした
落とし穴②を直した後、今度はフォールバックが起きた。 各ステップに個別ログを仕込んでおいたおかげで、コンソールに kb.json 失敗: Unexpected token '<' と出た—— kb.jsonの代わりにHTML(404ページ)が返っていたとすぐ分かった。 推測で直す前に「どこで何が起きたか」を可視化する。デバッグの鉄則を再確認した。
09

本番稼働、そして残したもの

3つの落とし穴を越え、ハイブリッドRAGは本番で動いた。 「資本回収係数とは」と尋ねると、ブラウザがiseeit.jpのFP用語記事を正しく検索し、 VPSがその本文を根拠に正確な回答を生成する。検索も生成も、意図通りに繋がった。

得たもの 01

検索がブラウザへ

クエリ埋め込みと検索がクライアント側で完結。VPSは往復と検索負荷から解放された。

得たもの 02

本文露出ゼロ

ブラウザはidだけ送る。本文はVPSのDBから引くので改ざん不可。安全性が上がった。

残したもの

フォールバック

サーバー検索の経路を残した。ブラウザ検索が使えない環境でも従来通り動く。

🎯 「完璧を待たず、安全網を張って本番投入する」
埋め込みモデルをVPSから完全撤去すれば、2GB RAMはさらに楽になる。 だが今はまだフォールバック(サーバー検索)を残している。 ブラウザ検索が全環境で安定することを本番で見届けてから、最後の撤去に進む。 実験公開のサイトとして、動くものを壊さないことを最優先にした判断だ。
📝 三部作が確かめたこと
前編:ブラウザのベクトル検索は実用的
後編:ブラウザのLLM生成は動くが実用は苦しい
統合編:だから検索はブラウザ、生成はサーバー——実測が導いた分業を、本番で形にした。
「すべてブラウザで」より「適材適所」。それが2つの実機と1つのVPSが教えてくれた答えだった。
SERIES — 最初から読む
前編:WebGPUでベクトル検索を実装する

『ハイブリッドRAGを本番投入する — ブラウザ検索 × VPS生成』を公開しました。