T-CREATOR

WebLLM 中心のクライアントサイド RAG 設計:IndexedDB とベクトル検索の組み立て

WebLLM 中心のクライアントサイド RAG 設計:IndexedDB とベクトル検索の組み立て

ブラウザ上で動作する LLM として注目を集める WebLLM ですが、単体での推論だけでなく、RAG(Retrieval-Augmented Generation)と組み合わせることで、より実用的なアプリケーションを構築できます。

サーバーを介さず、完全にクライアントサイドで動作する RAG システムは、プライバシー保護やオフライン動作といった独自の価値を提供します。本記事では、WebLLM を中心に据えた IndexedDB とベクトル検索を活用したクライアントサイド RAG の設計パターンを、アーキテクチャの観点から詳しく解説いたします。

背景

RAG がもたらす LLM の可能性

RAG は、外部のナレッジベースから関連情報を検索し、その情報をプロンプトに含めることで LLM の回答精度を向上させる手法です。従来の LLM は学習データに含まれない最新情報や、特定ドメインの詳細な知識については回答が不正確になりがちでした。

RAG を導入することで、以下のような利点が得られますね。

  • 最新情報や独自データに基づいた回答が可能
  • ハルシネーション(事実と異なる回答)の削減
  • モデルの再学習なしに知識ベースの更新が可能

クライアントサイド RAG の意義

サーバーサイドでの RAG 実装は既に広く普及していますが、クライアントサイドでの実装には特有の価値があります。

#特性クライアントサイド RAGサーバーサイド RAG
1プライバシー★★★ データが外部送信されない★ サーバーにデータ送信が必要
2オフライン動作★★★ 完全オフライン可能★ ネットワーク必須
3レイテンシ★★ ローカル処理で低遅延★★ ネットワーク遅延あり
4スケーラビリティ★★★ ユーザー端末に分散★ サーバー負荷集中
5初期導入コスト★★★ インフラ不要★ サーバー構築必要

クライアントサイド RAG は、特に機密情報を扱うアプリケーションや、ネットワーク接続が不安定な環境で威力を発揮するでしょう。

WebLLM と IndexedDB の組み合わせ

WebLLM はブラウザ上で LLM を動作させるライブラリであり、WebGPU を活用した高速な推論を実現します。一方、IndexedDB はブラウザに組み込まれた NoSQL データベースで、大容量のデータを永続化できます。

この 2 つの技術を組み合わせることで、完全にクライアントサイドで動作する RAG システムの構築が可能になりますね。

以下の図は、WebLLM と IndexedDB を中心としたクライアントサイド RAG の全体像を示しています。

mermaidflowchart TB
  user["ユーザー"] -->|質問入力| ui["UI レイヤー"]
  ui -->|クエリ| retrieval["検索レイヤー"]
  retrieval -->|ベクトル化| embedding["Embedding<br/>生成"]
  embedding -->|検索実行| vector["ベクトル検索<br/>エンジン"]
  vector -->|データ取得| idb[("IndexedDB<br/>ナレッジベース")]
  retrieval -->|関連文書| prompt["プロンプト<br/>構築"]
  prompt -->|推論リクエスト| webllm["WebLLM<br/>エンジン"]
  webllm -->|回答生成| ui
  ui -->|結果表示| user

図で理解できる要点:

  • ユーザーの質問は検索レイヤーでベクトル化され、IndexedDB から関連文書を取得
  • 取得した文書は WebLLM に渡され、コンテキストを含めた回答を生成
  • すべての処理がブラウザ内で完結し、外部通信が不要

課題

クライアントサイド RAG の実装における障壁

完全にクライアントサイドで RAG を実装する際には、いくつかの技術的課題に直面します。

1. ベクトル検索エンジンの選択

サーバーサイドでは Pinecone や Qdrant といった専用のベクトルデータベースを利用できますが、ブラウザ環境では制約があります。IndexedDB は一般的な NoSQL データベースであり、ベクトル検索機能を持っていません。

そのため、自前でベクトル検索のロジックを実装するか、軽量なライブラリを組み合わせる必要があるでしょう。

2. Embedding モデルの実行環境

文書をベクトル化するには Embedding モデルが必要です。サーバーサイドでは OpenAI の Embedding API や HuggingFace のモデルを利用できますが、クライアントサイドでは以下の選択肢を検討する必要があります。

  • WebLLM で Embedding モデルも実行する
  • ONNX Runtime Web などの軽量ランタイムを使用
  • 外部 API を呼び出す(プライバシー面でトレードオフ)

3. データ管理とインデックス設計

IndexedDB にベクトルと元文書を効率的に保存し、高速に検索できるインデックス設計が求められます。特に以下の点が課題となりますね。

  • ベクトルの次元数が大きい場合のストレージ容量
  • 類似度計算の計算コスト
  • データの更新・削除時のインデックス再構築

以下の図は、クライアントサイド RAG における主な課題とその関係性を示しています。

mermaidflowchart LR
  challenge["クライアントサイド<br/>RAG の課題"]
  challenge --> vector["ベクトル検索<br/>エンジンの不在"]
  challenge --> embedding["Embedding モデル<br/>実行環境"]
  challenge --> storage["IndexedDB での<br/>データ管理"]

  vector --> solution1["独自実装 or<br/>ライブラリ活用"]
  embedding --> solution2["WebLLM or<br/>ONNX Runtime"]
  storage --> solution3["インデックス設計<br/>最適化"]

  solution1 --> impl["実装戦略"]
  solution2 --> impl
  solution3 --> impl

図で理解できる要点:

  • 3 つの主要課題に対して、それぞれ適切な解決策を選択する必要がある
  • すべての解決策は最終的に統合された実装戦略に集約される

4. メモリとパフォーマンスの制約

ブラウザ環境ではメモリとパフォーマンスに制約があります。LLM の推論とベクトル検索を同時に実行すると、端末のリソースを圧迫する可能性があるでしょう。

効率的なリソース管理と、処理の優先順位付けが重要になります。

解決策

アーキテクチャパターンの設計

クライアントサイド RAG を実現するために、レイヤー化されたアーキテクチャパターンを採用します。各レイヤーの責務を明確に分離することで、保守性と拡張性を確保できるでしょう。

レイヤー構成

以下の 4 つのレイヤーで構成します。

#レイヤー名責務主要技術
1UI レイヤーユーザー入力の受付と結果表示React, Vue など
2RAG オーケストレーションレイヤー検索と推論の統合制御TypeScript クラス
3データレイヤーベクトル検索と文書管理IndexedDB
4推論レイヤーLLM による回答生成WebLLM

このレイヤー構成により、各機能を独立してテストし、必要に応じて入れ替えることが可能になります。

以下の図は、レイヤー間の依存関係とデータフローを示しています。

mermaidflowchart TB
  subgraph ui_layer["UI レイヤー"]
    react["React Component"]
  end

  subgraph orchestration["RAG オーケストレーション"]
    rag_service["RAG Service"]
    prompt_builder["Prompt Builder"]
  end

  subgraph data_layer["データレイヤー"]
    vector_store["Vector Store"]
    doc_store["Document Store"]
    idb_wrapper["IndexedDB Wrapper"]
  end

  subgraph inference["推論レイヤー"]
    webllm_client["WebLLM Client"]
    embedding_gen["Embedding Generator"]
  end

  react -->|query| rag_service
  rag_service -->|search| vector_store
  rag_service -->|embed| embedding_gen
  vector_store --> idb_wrapper
  doc_store --> idb_wrapper
  rag_service -->|build| prompt_builder
  prompt_builder -->|inference| webllm_client
  webllm_client -->|response| react

図で理解できる要点:

  • UI レイヤーは RAG Service のみに依存し、下位レイヤーを意識しない
  • データレイヤーは IndexedDB Wrapper を通じて統一的にアクセス
  • 推論レイヤーは Embedding 生成と LLM 推論の 2 つの機能を提供

IndexedDB スキーマ設計

ベクトルと文書を効率的に管理するための IndexedDB スキーマを設計します。

オブジェクトストア構成

以下の 2 つのオブジェクトストアを作成します。

typescript// IndexedDB のバージョンと名前を定義
const DB_NAME = 'client_rag_db';
const DB_VERSION = 1;

// オブジェクトストア名
const STORES = {
  DOCUMENTS: 'documents',
  VECTORS: 'vectors',
} as const;

documents ストアは元の文書とメタデータを保存します。

typescript// documents ストアのスキーマ
interface DocumentRecord {
  id: string; // プライマリキー(UUID など)
  content: string; // 元の文書テキスト
  metadata: {
    title?: string;
    source?: string;
    createdAt: number;
    updatedAt: number;
  };
  chunkIds: string[]; // この文書に紐づくチャンク ID のリスト
}

vectors ストアはベクトル化されたチャンクと、対応する文書への参照を保存します。

typescript// vectors ストアのスキーマ
interface VectorRecord {
  id: string; // プライマリキー(UUID など)
  documentId: string; // 元文書への参照
  chunkText: string; // チャンクのテキスト
  vector: number[]; // Embedding ベクトル(768 次元など)
  metadata: {
    chunkIndex: number; // 文書内での順序
    createdAt: number;
  };
}

インデックス設計

効率的な検索のために、以下のインデックスを作成します。

typescript// IndexedDB のセットアップ関数
function setupDatabase(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);

データベースのアップグレード処理でオブジェクトストアとインデックスを作成します。

typescript    request.onupgradeneeded = (event) => {
      const db = (event.target as IDBOpenDBRequest).result;

      // documents ストアの作成
      if (!db.objectStoreNames.contains(STORES.DOCUMENTS)) {
        const docStore = db.createObjectStore(
          STORES.DOCUMENTS,
          { keyPath: 'id' }
        );
        // メタデータでの検索用インデックス
        docStore.createIndex('createdAt', 'metadata.createdAt');
        docStore.createIndex('source', 'metadata.source');
      }

vectors ストアには documentId のインデックスを作成し、特定の文書に紐づくベクトルを高速に取得できるようにします。

typescript      // vectors ストアの作成
      if (!db.objectStoreNames.contains(STORES.VECTORS)) {
        const vectorStore = db.createObjectStore(
          STORES.VECTORS,
          { keyPath: 'id' }
        );
        // 文書 ID でのフィルタリング用インデックス
        vectorStore.createIndex('documentId', 'documentId');
        vectorStore.createIndex('createdAt', 'metadata.createdAt');
      }
    };
  });
}

このスキーマ設計により、文書とベクトルを分離して管理しつつ、効率的な検索が可能になります。

ベクトル検索の実装戦略

IndexedDB にはベクトル検索機能がないため、コサイン類似度を使った検索ロジックを実装します。

ベクトル類似度計算

コサイン類似度は 2 つのベクトル間の角度の余弦を計算し、-1 から 1 の値を返します。1 に近いほど類似度が高いことを示しますね。

typescript// コサイン類似度を計算する関数
function cosineSimilarity(vecA: number[], vecB: number[]): number {
  if (vecA.length !== vecB.length) {
    throw new Error('Vector dimensions must match');
  }

  let dotProduct = 0;
  let normA = 0;
  let normB = 0;

各次元の積の合計(内積)と、各ベクトルのノルム(大きさ)を計算します。

typescriptfor (let i = 0; i < vecA.length; i++) {
  dotProduct += vecA[i] * vecB[i];
  normA += vecA[i] * vecA[i];
  normB += vecB[i] * vecB[i];
}

// ゼロベクトルの場合は類似度 0 を返す
if (normA === 0 || normB === 0) {
  return 0;
}

最後に、内積をノルムの積で割ってコサイン類似度を求めます。

typescript  return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}

トップ K 検索の実装

クエリベクトルに最も類似した K 件のベクトルを検索する関数を実装します。

typescript// トップ K の類似ベクトルを検索
async function searchTopK(
  queryVector: number[],
  k: number = 5
): Promise<VectorRecord[]> {
  const db = await setupDatabase();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction(STORES.VECTORS, 'readonly');
    const store = transaction.objectStore(STORES.VECTORS);

すべてのベクトルを取得し、類似度を計算します。

typescript    const request = store.getAll();

    request.onsuccess = () => {
      const allVectors = request.result as VectorRecord[];

      // 各ベクトルとの類似度を計算
      const scored = allVectors.map(record => ({
        record,
        score: cosineSimilarity(queryVector, record.vector)
      }));

類似度でソートし、上位 K 件を取得します。

typescript      // 類似度の高い順にソート
      scored.sort((a, b) => b.score - a.score);

      // トップ K 件を返す
      const topK = scored.slice(0, k).map(item => item.record);
      resolve(topK);
    };

    request.onerror = () => reject(request.error);
  });
}

この実装はシンプルですが、ベクトル数が増えると計算コストが高くなります。実用的には、後述する最適化手法を適用する必要があるでしょう。

Embedding 生成の選択肢

文書をベクトル化するための Embedding 生成には、いくつかの選択肢があります。

選択肢 1:WebLLM で Embedding モデルを実行

WebLLM は推論だけでなく、Embedding モデルもサポートしています。完全にクライアントサイドで完結させたい場合は、この方法が最適でしょう。

typescriptimport { CreateMLCEngine } from '@mlc-ai/web-llm';

// Embedding モデルのエンジンを初期化
async function initEmbeddingEngine() {
  const engine = await CreateMLCEngine(
    'sentence-transformers/all-MiniLM-L6-v2-q4f16_1-MLC',
    {
      initProgressCallback: (progress) => {
        console.log(`Loading: ${progress.text}`);
      },
    }
  );

  return engine;
}

選択肢 2:ONNX Runtime Web を使用

ONNX Runtime Web は軽量で、Embedding モデルに特化した実行が可能です。WebLLM よりもモデルサイズが小さく、起動が高速な傾向があります。

typescriptimport * as ort from 'onnxruntime-web';

// ONNX モデルのロード
async function loadONNXEmbedding() {
  const session = await ort.InferenceSession.create(
    '/models/all-MiniLM-L6-v2.onnx'
  );

  return session;
}

選択肢 3:外部 API を利用(ハイブリッド)

プライバシー要件が厳しくない場合は、Embedding のみ外部 API を利用し、推論は WebLLM で行うハイブリッド構成も検討できます。

#方式プライバシーパフォーマンス実装難易度
1WebLLM★★★ 完全ローカル★★ モデルサイズ大★★ やや複雑
2ONNX Runtime★★★ 完全ローカル★★★ 軽量高速★★★ 比較的容易
3外部 API★ データ送信あり★★★ ネットワーク次第★★★ 最も容易

本記事では、完全なクライアントサイド実装を目指すため、ONNX Runtime Web を採用する例を後述します。

RAG オーケストレーションの実装

検索と推論を統合的に制御する RAG Service クラスを実装します。

typescript// RAG Service のインターフェース
interface RAGConfig {
  topK: number;           // 検索する文書数
  maxTokens: number;      // LLM の最大トークン数
  temperature: number;    // LLM の温度パラメータ
}

class RAGService {
  private embeddingEngine: any;  // Embedding エンジン
  private llmEngine: any;         // WebLLM エンジン
  private config: RAGConfig;

コンストラクタで各エンジンと設定を受け取ります。

typescript  constructor(
    embeddingEngine: any,
    llmEngine: any,
    config: RAGConfig
  ) {
    this.embeddingEngine = embeddingEngine;
    this.llmEngine = llmEngine;
    this.config = config;
  }

ユーザークエリを受け取り、RAG の全プロセスを実行するメインメソッドです。

typescript  async query(userQuery: string): Promise<string> {
    // 1. クエリをベクトル化
    const queryVector = await this.generateEmbedding(userQuery);

    // 2. 類似文書を検索
    const relevantDocs = await searchTopK(
      queryVector,
      this.config.topK
    );

検索結果をプロンプトに組み込みます。

typescript// 3. プロンプトを構築
const context = relevantDocs
  .map((doc) => doc.chunkText)
  .join('\n\n');

const prompt = this.buildPrompt(userQuery, context);

WebLLM で推論を実行し、回答を生成します。

typescript    // 4. LLM で推論
    const response = await this.llmEngine.chat.completions.create({
      messages: [{ role: 'user', content: prompt }],
      max_tokens: this.config.maxTokens,
      temperature: this.config.temperature
    });

    return response.choices[0].message.content;
  }

Embedding 生成の処理を別メソッドに分離します。

typescript  private async generateEmbedding(text: string): Promise<number[]> {
    // ONNX Runtime での Embedding 生成
    // 実装は次のセクションで詳述
    return await generateEmbeddingWithONNX(
      this.embeddingEngine,
      text
    );
  }

プロンプト構築では、検索結果をコンテキストとして含めます。

typescript  private buildPrompt(query: string, context: string): string {
    return `以下の情報を参考にして質問に回答してください。

【参考情報】
${context}

【質問】
${query}

【回答】`;
  }
}

この RAG Service により、UI レイヤーはシンプルにクエリを投げるだけで、内部で検索と推論が統合的に実行されます。

具体例

実践的な実装例

ここでは、ブラウザ上で動作する簡易的な文書 QA システムを構築する具体例を示します。

プロジェクトのセットアップ

必要なパッケージをインストールします。

bash# プロジェクトの初期化
yarn init -y

# 必要なパッケージのインストール
yarn add @mlc-ai/web-llm onnxruntime-web
yarn add -D typescript @types/node vite

TypeScript の設定ファイルを作成します。

json{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020", "DOM"],
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

ONNX Runtime での Embedding 生成

Embedding 生成の具体的な実装を示します。

typescriptimport * as ort from 'onnxruntime-web';

// Tokenizer の簡易実装(実際は transformers.js などを使用)
function tokenize(text: string): number[] {
  // 実際には BPE トークナイザーを使用
  // ここでは簡易的な実装例として文字コードを使用
  return Array.from(text).map((c) => c.charCodeAt(0));
}

ONNX モデルで Embedding を生成する関数です。

typescriptasync function generateEmbeddingWithONNX(
  session: ort.InferenceSession,
  text: string
): Promise<number[]> {
  // テキストをトークン ID に変換
  const tokens = tokenize(text);

  // パディング(最大長 128 と仮定)
  const maxLength = 128;
  const paddedTokens = tokens.slice(0, maxLength);
  while (paddedTokens.length < maxLength) {
    paddedTokens.push(0);
  }

トークン ID をテンソルに変換し、モデルに入力します。

typescript// テンソルに変換
const inputTensor = new ort.Tensor(
  'int64',
  BigInt64Array.from(paddedTokens.map((t) => BigInt(t))),
  [1, maxLength]
);

// 推論実行
const results = await session.run({
  input_ids: inputTensor,
});

出力テンソルから Embedding ベクトルを取得します。

typescript  // Embedding ベクトルを取得([CLS] トークンの出力を使用)
  const embeddings = results.last_hidden_state;
  const embeddingArray = Array.from(embeddings.data as Float32Array);

  // 最初のトークン([CLS])の Embedding を返す(384 次元と仮定)
  return embeddingArray.slice(0, 384);
}

この実装により、軽量な Embedding 生成が可能になります。

文書の追加とチャンク分割

長い文書を適切なサイズのチャンクに分割し、IndexedDB に保存する処理です。

typescript// 文書をチャンクに分割する関数
function splitIntoChunks(
  text: string,
  chunkSize: number = 500,
  overlap: number = 50
): string[] {
  const chunks: string[] = [];
  let startIndex = 0;

  while (startIndex < text.length) {
    const endIndex = Math.min(
      startIndex + chunkSize,
      text.length
    );
    chunks.push(text.substring(startIndex, endIndex));
    startIndex += chunkSize - overlap;
  }

  return chunks;
}

文書を追加し、各チャンクをベクトル化して保存します。

typescriptasync function addDocument(
  content: string,
  metadata: { title?: string; source?: string },
  embeddingSession: ort.InferenceSession
): Promise<string> {
  const db = await setupDatabase();
  const documentId = crypto.randomUUID();

  // チャンクに分割
  const chunks = splitIntoChunks(content);

各チャンクをベクトル化し、IndexedDB に保存します。

typescript// 各チャンクをベクトル化して保存
const chunkIds: string[] = [];

for (let i = 0; i < chunks.length; i++) {
  const chunkId = crypto.randomUUID();
  const vector = await generateEmbeddingWithONNX(
    embeddingSession,
    chunks[i]
  );

  // vectors ストアに保存
  await saveVector({
    id: chunkId,
    documentId,
    chunkText: chunks[i],
    vector,
    metadata: {
      chunkIndex: i,
      createdAt: Date.now(),
    },
  });

  chunkIds.push(chunkId);
}

元の文書情報を documents ストアに保存します。

typescript  // documents ストアに文書を保存
  await saveDocument({
    id: documentId,
    content,
    metadata: {
      ...metadata,
      createdAt: Date.now(),
      updatedAt: Date.now()
    },
    chunkIds
  });

  return documentId;
}

IndexedDB への保存ヘルパー関数

ベクトルを保存するヘルパー関数です。

typescriptasync function saveVector(
  record: VectorRecord
): Promise<void> {
  const db = await setupDatabase();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction(
      STORES.VECTORS,
      'readwrite'
    );
    const store = transaction.objectStore(STORES.VECTORS);
    const request = store.add(record);

    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
  });
}

文書を保存するヘルパー関数です。

typescriptasync function saveDocument(
  record: DocumentRecord
): Promise<void> {
  const db = await setupDatabase();

  return new Promise((resolve, reject) => {
    const transaction = db.transaction(
      STORES.DOCUMENTS,
      'readwrite'
    );
    const store = transaction.objectStore(STORES.DOCUMENTS);
    const request = store.add(record);

    request.onsuccess = () => resolve();
    request.onerror = () => reject(request.error);
  });
}

React での UI 実装

React を使った簡易的な UI の実装例です。

typescriptimport React, { useState, useEffect } from 'react';
import { CreateMLCEngine } from '@mlc-ai/web-llm';

function RAGApp() {
  const [ragService, setRagService] = useState<RAGService | null>(null);
  const [query, setQuery] = useState('');
  const [response, setResponse] = useState('');
  const [loading, setLoading] = useState(false);

コンポーネントのマウント時に各エンジンを初期化します。

typescriptuseEffect(() => {
  async function init() {
    // WebLLM エンジンの初期化
    const llmEngine = await CreateMLCEngine(
      'Llama-3.2-1B-Instruct-q4f16_1-MLC'
    );

    // ONNX Embedding エンジンの初期化
    const embeddingEngine =
      await ort.InferenceSession.create(
        '/models/all-MiniLM-L6-v2.onnx'
      );

    // RAG Service の作成
    const service = new RAGService(
      embeddingEngine,
      llmEngine,
      {
        topK: 3,
        maxTokens: 512,
        temperature: 0.7,
      }
    );

    setRagService(service);
  }

  init();
}, []);

クエリ送信のハンドラーです。

typescriptconst handleSubmit = async (e: React.FormEvent) => {
  e.preventDefault();
  if (!ragService || !query.trim()) return;

  setLoading(true);
  try {
    const result = await ragService.query(query);
    setResponse(result);
  } catch (error) {
    console.error('Query failed:', error);
    setResponse('エラーが発生しました');
  } finally {
    setLoading(false);
  }
};

UI のレンダリング部分です。

typescript  return (
    <div className="rag-app">
      <h1>クライアントサイド RAG デモ</h1>

      <form onSubmit={handleSubmit}>
        <input
          type="text"
          value={query}
          onChange={(e) => setQuery(e.target.value)}
          placeholder="質問を入力してください"
          disabled={!ragService || loading}
        />
        <button type="submit" disabled={!ragService || loading}>
          {loading ? '処理中...' : '質問する'}
        </button>
      </form>

      {response && (
        <div className="response">
          <h2>回答</h2>
          <p>{response}</p>
        </div>
      )}
    </div>
  );
}

この実装により、ブラウザ上で完結する RAG システムの基本形が完成します。

パフォーマンス最適化のテクニック

実用的なシステムにするために、いくつかの最適化テクニックを適用できます。

1. ベクトル検索の高速化

全件スキャンを避けるため、近似最近傍探索(ANN)を実装します。

typescript// 局所性鋭敏型ハッシュ(LSH)による高速化の例
class LSHIndex {
  private buckets: Map<string, VectorRecord[]>;
  private numHashFunctions: number;

  constructor(numHashFunctions: number = 10) {
    this.buckets = new Map();
    this.numHashFunctions = numHashFunctions;
  }

ハッシュ関数でベクトルをバケットに振り分けます。

typescript  private hash(vector: number[]): string {
    // 簡易的なハッシュ関数(実際は LSH の実装を使用)
    const hashValues: number[] = [];
    for (let i = 0; i < this.numHashFunctions; i++) {
      const randomPlane = this.generateRandomPlane(vector.length);
      const dotProduct = vector.reduce(
        (sum, val, idx) => sum + val * randomPlane[idx],
        0
      );
      hashValues.push(dotProduct > 0 ? 1 : 0);
    }
    return hashValues.join('');
  }

2. Web Worker での並列処理

ベクトル検索を Web Worker に委譲し、UI スレッドをブロックしないようにします。

typescript// worker.ts
self.addEventListener('message', async (e) => {
  const { type, payload } = e.data;

  if (type === 'SEARCH') {
    const { queryVector, k } = payload;
    const results = await searchTopK(queryVector, k);
    self.postMessage({
      type: 'SEARCH_RESULT',
      payload: results,
    });
  }
});

メインスレッドから Web Worker を呼び出します。

typescript// main.ts
const worker = new Worker('/worker.js');

function searchInWorker(
  queryVector: number[],
  k: number
): Promise<VectorRecord[]> {
  return new Promise((resolve) => {
    worker.postMessage({
      type: 'SEARCH',
      payload: { queryVector, k },
    });

    worker.addEventListener('message', (e) => {
      if (e.data.type === 'SEARCH_RESULT') {
        resolve(e.data.payload);
      }
    });
  });
}

3. キャッシュ戦略

頻繁に検索されるクエリの結果をメモリにキャッシュします。

typescriptclass QueryCache {
  private cache: Map<string, { result: string; timestamp: number }>;
  private maxSize: number;
  private ttl: number; // Time to live (ミリ秒)

  constructor(maxSize: number = 100, ttl: number = 300000) {
    this.cache = new Map();
    this.maxSize = maxSize;
    this.ttl = ttl;
  }

クエリのハッシュ値をキーとして結果をキャッシュします。

typescript  get(query: string): string | null {
    const cached = this.cache.get(query);
    if (!cached) return null;

    // TTL チェック
    if (Date.now() - cached.timestamp > this.ttl) {
      this.cache.delete(query);
      return null;
    }

    return cached.result;
  }

  set(query: string, result: string): void {
    // サイズ制限チェック
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }

    this.cache.set(query, {
      result,
      timestamp: Date.now()
    });
  }
}

これらの最適化により、実用的なパフォーマンスを実現できるでしょう。

以下の図は、最適化されたシステムの全体アーキテクチャを示しています。

mermaidflowchart TB
  user["ユーザー"] --> ui["React UI"]
  ui --> cache["Query Cache"]
  cache -->|キャッシュミス| rag["RAG Service"]
  cache -->|キャッシュヒット| ui

  rag --> worker["Web Worker<br/>検索処理"]
  worker --> lsh["LSH Index"]
  lsh --> idb[("IndexedDB")]

  rag --> embed["Embedding<br/>ONNX Runtime"]
  rag --> llm["WebLLM<br/>推論"]

  llm --> ui

図で理解できる要点:

  • Query Cache により重複クエリの処理を高速化
  • Web Worker で検索処理を並列化し、UI の応答性を維持
  • LSH Index により大規模データセットでも高速な近似検索を実現

まとめ

本記事では、WebLLM を中心としたクライアントサイド RAG システムの設計パターンを詳しく解説しました。

完全にブラウザ上で動作する RAG システムは、以下の要素を適切に組み合わせることで実現できます。

  • レイヤー化アーキテクチャ:UI、オーケストレーション、データ、推論の各レイヤーを明確に分離
  • IndexedDB スキーマ設計:文書とベクトルを効率的に管理する 2 つのオブジェクトストア
  • ベクトル検索実装:コサイン類似度によるトップ K 検索と LSH による高速化
  • Embedding 生成:ONNX Runtime Web による軽量な Embedding 生成
  • パフォーマンス最適化:Web Worker、キャッシュ、近似検索の活用

クライアントサイド RAG は、プライバシー保護やオフライン動作という独自の価値を提供しつつ、サーバーコストを削減できる魅力的なアーキテクチャです。WebLLM の進化により、今後さらに実用的なアプリケーションが登場することが期待されますね。

本記事で紹介した設計パターンを基に、皆様独自の RAG システムを構築してみてはいかがでしょうか。ブラウザ上で動作する知的なアシスタントが、新しいユーザー体験を生み出すかもしれません。

関連リンク