最終更新:

Cloudflare Workers AI + RAG実践: エッジでのAIアプリケーション構築


Cloudflare Workers AIを使えば、グローバルなエッジネットワーク上でAIモデルを実行できます。本記事では、RAG(Retrieval-Augmented Generation)パターンを実装し、Vectorizeを使ったベクトル検索と組み合わせて、実用的なAIアプリケーションを構築する方法を解説します。

Cloudflare Workers AIとは

特徴

  • エッジで実行: 世界中のCloudflareエッジロケーションでAIモデルを実行
  • 低レイテンシ: ユーザーに最も近い場所で処理
  • 従量課金: 使った分だけ支払い、アイドル時のコストなし
  • 豊富なモデル: LLM、埋め込みモデル、画像生成など多様なモデルを利用可能

利用可能な主要モデル

// テキスト生成
'@cf/meta/llama-3.1-8b-instruct'
'@cf/mistral/mistral-7b-instruct-v0.2'
'@cf/qwen/qwen1.5-14b-chat-awq'

// 埋め込みモデル
'@cf/baai/bge-base-en-v1.5'
'@cf/baai/bge-large-en-v1.5'
'@cf/baai/bge-small-en-v1.5'

// 画像生成
'@cf/stabilityai/stable-diffusion-xl-base-1.0'
'@cf/bytedance/stable-diffusion-xl-lightning'

基本セットアップ

プロジェクト作成

# Cloudflare Workersプロジェクトを作成
npm create cloudflare@latest my-ai-app

cd my-ai-app

# Wranglerで必要なバインディングを設定

wrangler.toml設定

name = "ai-rag-app"
main = "src/index.ts"
compatibility_date = "2025-01-01"

# Workers AI binding
[ai]
binding = "AI"

# Vectorize index
[[vectorize]]
binding = "VECTORIZE"
index_name = "document-embeddings"

# KV for caching (optional)
[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-namespace-id"

# D1 database for metadata
[[d1_databases]]
binding = "DB"
database_name = "rag-db"
database_id = "your-db-id"

Vectorizeインデックスの作成

# ベクトル検索用のインデックスを作成
wrangler vectorize create document-embeddings \
  --dimensions=768 \
  --metric=cosine

D1データベースのセットアップ

-- schema.sql
CREATE TABLE documents (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  url TEXT,
  metadata TEXT,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

CREATE INDEX idx_documents_created_at ON documents(created_at);
# データベースを作成
wrangler d1 create rag-db

# スキーマを適用
wrangler d1 execute rag-db --file=./schema.sql

RAGシステムの実装

1. 型定義

// types.ts
export interface Env {
  AI: Ai;
  VECTORIZE: VectorizeIndex;
  DB: D1Database;
  CACHE?: KVNamespace;
}

export interface Document {
  id: string;
  title: string;
  content: string;
  url?: string;
  metadata?: Record<string, any>;
  created_at: number;
  updated_at: number;
}

export interface SearchResult {
  id: string;
  score: number;
  document: Document;
}

export interface RAGResponse {
  answer: string;
  sources: SearchResult[];
  metadata: {
    query: string;
    model: string;
    processingTimeMs: number;
  };
}

2. ドキュメントの埋め込みと保存

// lib/embeddings.ts
import type { Env, Document } from './types';

export async function generateEmbedding(
  text: string,
  env: Env
): Promise<number[]> {
  const response = await env.AI.run('@cf/baai/bge-base-en-v1.5', {
    text: [text],
  });

  return response.data[0];
}

export async function indexDocument(
  document: Document,
  env: Env
): Promise<void> {
  // 1. ドキュメントをD1に保存
  await env.DB.prepare(`
    INSERT INTO documents (id, title, content, url, metadata, created_at, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, ?)
  `).bind(
    document.id,
    document.title,
    document.content,
    document.url || null,
    JSON.stringify(document.metadata || {}),
    document.created_at,
    document.updated_at
  ).run();

  // 2. 埋め込みを生成
  const embedding = await generateEmbedding(
    `${document.title}\n\n${document.content}`,
    env
  );

  // 3. Vectorizeにインデックス
  await env.VECTORIZE.upsert([
    {
      id: document.id,
      values: embedding,
      metadata: {
        title: document.title,
        url: document.url,
      },
    },
  ]);
}

export async function indexDocuments(
  documents: Document[],
  env: Env
): Promise<void> {
  // バッチ処理で効率化
  const batchSize = 10;

  for (let i = 0; i < documents.length; i += batchSize) {
    const batch = documents.slice(i, i + batchSize);

    await Promise.all(
      batch.map(doc => indexDocument(doc, env))
    );

    console.log(`Indexed ${Math.min(i + batchSize, documents.length)}/${documents.length} documents`);
  }
}

3. ベクトル検索の実装

// lib/search.ts
import type { Env, SearchResult } from './types';

export async function searchDocuments(
  query: string,
  env: Env,
  options: {
    topK?: number;
    threshold?: number;
  } = {}
): Promise<SearchResult[]> {
  const { topK = 5, threshold = 0.5 } = options;

  // 1. クエリの埋め込みを生成
  const queryEmbedding = await generateEmbedding(query, env);

  // 2. Vectorizeで類似検索
  const matches = await env.VECTORIZE.query(queryEmbedding, {
    topK,
    returnMetadata: true,
  });

  // 3. スコアでフィルタリング
  const filteredMatches = matches.matches.filter(
    match => match.score >= threshold
  );

  // 4. D1からドキュメント本体を取得
  const documentIds = filteredMatches.map(m => m.id);

  if (documentIds.length === 0) {
    return [];
  }

  const placeholders = documentIds.map(() => '?').join(',');
  const documents = await env.DB.prepare(`
    SELECT * FROM documents WHERE id IN (${placeholders})
  `).bind(...documentIds).all();

  // 5. 結果を組み合わせ
  const results: SearchResult[] = filteredMatches.map(match => {
    const doc = documents.results.find(d => d.id === match.id);

    return {
      id: match.id,
      score: match.score,
      document: {
        id: doc.id,
        title: doc.title,
        content: doc.content,
        url: doc.url,
        metadata: JSON.parse(doc.metadata || '{}'),
        created_at: doc.created_at,
        updated_at: doc.updated_at,
      },
    };
  });

  return results;
}

4. RAG推論の実装

// lib/rag.ts
import type { Env, RAGResponse, SearchResult } from './types';

function buildPrompt(query: string, context: SearchResult[]): string {
  const contextText = context
    .map((result, index) => {
      return `[${index + 1}] ${result.document.title}\n${result.document.content}\nSource: ${result.document.url || 'N/A'}\n`;
    })
    .join('\n---\n\n');

  return `以下の情報を参考に、質問に日本語で答えてください。
情報に含まれていない内容については、「提供された情報からは分かりません」と答えてください。
回答には必ず参考にした情報源の番号を明記してください。

# 参考情報

${contextText}

# 質問

${query}

# 回答

`;
}

export async function generateRAGResponse(
  query: string,
  env: Env,
  options: {
    model?: string;
    topK?: number;
    temperature?: number;
  } = {}
): Promise<RAGResponse> {
  const startTime = Date.now();
  const {
    model = '@cf/meta/llama-3.1-8b-instruct',
    topK = 5,
    temperature = 0.7,
  } = options;

  // 1. 関連ドキュメントを検索
  const searchResults = await searchDocuments(query, env, { topK });

  if (searchResults.length === 0) {
    return {
      answer: '関連する情報が見つかりませんでした。別の質問を試してください。',
      sources: [],
      metadata: {
        query,
        model,
        processingTimeMs: Date.now() - startTime,
      },
    };
  }

  // 2. プロンプトを構築
  const prompt = buildPrompt(query, searchResults);

  // 3. LLMで回答を生成
  const response = await env.AI.run(model, {
    prompt,
    max_tokens: 1024,
    temperature,
  });

  return {
    answer: response.response,
    sources: searchResults,
    metadata: {
      query,
      model,
      processingTimeMs: Date.now() - startTime,
    },
  };
}

5. Workerのエンドポイント

// src/index.ts
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import type { Env, Document } from './types';
import { indexDocument, indexDocuments } from './lib/embeddings';
import { searchDocuments } from './lib/search';
import { generateRAGResponse } from './lib/rag';

const app = new Hono<{ Bindings: Env }>();

app.use('/*', cors());

// ヘルスチェック
app.get('/', (c) => {
  return c.json({ status: 'ok', message: 'AI RAG API is running' });
});

// ドキュメントのインデックス化
app.post('/api/documents', async (c) => {
  try {
    const document: Document = await c.req.json();

    // IDとタイムスタンプを自動生成
    document.id = document.id || crypto.randomUUID();
    document.created_at = document.created_at || Date.now();
    document.updated_at = Date.now();

    await indexDocument(document, c.env);

    return c.json({ success: true, id: document.id });
  } catch (error) {
    return c.json({ error: error.message }, 500);
  }
});

// バッチインデックス化
app.post('/api/documents/batch', async (c) => {
  try {
    const { documents }: { documents: Document[] } = await c.req.json();

    // IDとタイムスタンプを自動生成
    documents.forEach(doc => {
      doc.id = doc.id || crypto.randomUUID();
      doc.created_at = doc.created_at || Date.now();
      doc.updated_at = Date.now();
    });

    await indexDocuments(documents, c.env);

    return c.json({
      success: true,
      count: documents.length,
      ids: documents.map(d => d.id),
    });
  } catch (error) {
    return c.json({ error: error.message }, 500);
  }
});

// ベクトル検索
app.get('/api/search', async (c) => {
  try {
    const query = c.req.query('q');
    const topK = parseInt(c.req.query('topK') || '5');

    if (!query) {
      return c.json({ error: 'Query parameter "q" is required' }, 400);
    }

    const results = await searchDocuments(query, c.env, { topK });

    return c.json({ results });
  } catch (error) {
    return c.json({ error: error.message }, 500);
  }
});

// RAG推論
app.post('/api/chat', async (c) => {
  try {
    const { query, model, topK, temperature } = await c.req.json();

    if (!query) {
      return c.json({ error: 'Query is required' }, 400);
    }

    const response = await generateRAGResponse(query, c.env, {
      model,
      topK,
      temperature,
    });

    return c.json(response);
  } catch (error) {
    return c.json({ error: error.message }, 500);
  }
});

// ストリーミングRAG
app.post('/api/chat/stream', async (c) => {
  const { query, model, topK } = await c.req.json();

  if (!query) {
    return c.json({ error: 'Query is required' }, 400);
  }

  // 関連ドキュメントを検索
  const searchResults = await searchDocuments(query, c.env, { topK });
  const prompt = buildPrompt(query, searchResults);

  // ストリーミングレスポンス
  const stream = await env.AI.run(model || '@cf/meta/llama-3.1-8b-instruct', {
    prompt,
    stream: true,
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
});

export default app;

実践的な使用例

1. ドキュメントをクロールしてインデックス化

// scripts/crawl-and-index.ts
async function crawlAndIndex(urls: string[]) {
  const documents: Document[] = [];

  for (const url of urls) {
    // Webページをフェッチ
    const response = await fetch(url);
    const html = await response.text();

    // HTMLをパース(例: cheerioを使用)
    const $ = cheerio.load(html);
    const title = $('h1').first().text() || $('title').text();
    const content = $('article, main, .content')
      .text()
      .replace(/\s+/g, ' ')
      .trim();

    documents.push({
      id: crypto.randomUUID(),
      title,
      content,
      url,
      metadata: {
        crawled_at: Date.now(),
      },
      created_at: Date.now(),
      updated_at: Date.now(),
    });
  }

  // バッチでインデックス化
  const response = await fetch('https://your-worker.workers.dev/api/documents/batch', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ documents }),
  });

  const result = await response.json();
  console.log(`Indexed ${result.count} documents`);
}

// 使用例: 自社ドキュメントをインデックス化
crawlAndIndex([
  'https://docs.example.com/getting-started',
  'https://docs.example.com/api-reference',
  'https://docs.example.com/best-practices',
]);

2. フロントエンドとの統合

// app/chat/page.tsx
'use client';

import { useState } from 'react';

interface Message {
  role: 'user' | 'assistant';
  content: string;
  sources?: any[];
}

export default function ChatPage() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!input.trim()) return;

    const userMessage: Message = { role: 'user', content: input };
    setMessages(prev => [...prev, userMessage]);
    setInput('');
    setIsLoading(true);

    try {
      const response = await fetch('https://your-worker.workers.dev/api/chat', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ query: input }),
      });

      const data = await response.json();

      const assistantMessage: Message = {
        role: 'assistant',
        content: data.answer,
        sources: data.sources,
      };

      setMessages(prev => [...prev, assistantMessage]);
    } catch (error) {
      console.error('Error:', error);
    } finally {
      setIsLoading(false);
    }
  }

  return (
    <div className="container mx-auto max-w-4xl p-4">
      <h1 className="text-3xl font-bold mb-6">AI Chat with RAG</h1>

      <div className="space-y-4 mb-4">
        {messages.map((message, index) => (
          <div
            key={index}
            className={`p-4 rounded-lg ${
              message.role === 'user'
                ? 'bg-blue-100 ml-auto max-w-[80%]'
                : 'bg-gray-100 mr-auto max-w-[80%]'
            }`}
          >
            <p className="whitespace-pre-wrap">{message.content}</p>

            {message.sources && message.sources.length > 0 && (
              <div className="mt-4 pt-4 border-t">
                <p className="text-sm font-semibold mb-2">参考文献:</p>
                <ul className="text-sm space-y-1">
                  {message.sources.map((source, idx) => (
                    <li key={idx}>
                      <a
                        href={source.document.url}
                        target="_blank"
                        rel="noopener noreferrer"
                        className="text-blue-600 hover:underline"
                      >
                        [{idx + 1}] {source.document.title} (類似度: {(source.score * 100).toFixed(1)}%)
                      </a>
                    </li>
                  ))}
                </ul>
              </div>
            )}
          </div>
        ))}

        {isLoading && (
          <div className="bg-gray-100 p-4 rounded-lg mr-auto max-w-[80%]">
            <p className="text-gray-500">考え中...</p>
          </div>
        )}
      </div>

      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="質問を入力してください"
          className="flex-1 p-3 border rounded-lg"
          disabled={isLoading}
        />
        <button
          type="submit"
          disabled={isLoading}
          className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
        >
          送信
        </button>
      </form>
    </div>
  );
}

3. キャッシング戦略

// lib/cache.ts
export async function getCachedResponse(
  query: string,
  env: Env
): Promise<RAGResponse | null> {
  if (!env.CACHE) return null;

  const cacheKey = `rag:${hashQuery(query)}`;
  const cached = await env.CACHE.get(cacheKey, 'json');

  return cached as RAGResponse | null;
}

export async function setCachedResponse(
  query: string,
  response: RAGResponse,
  env: Env,
  ttl: number = 3600 // 1 hour
): Promise<void> {
  if (!env.CACHE) return;

  const cacheKey = `rag:${hashQuery(query)}`;
  await env.CACHE.put(cacheKey, JSON.stringify(response), {
    expirationTtl: ttl,
  });
}

function hashQuery(query: string): string {
  // 簡易的なハッシュ関数
  return btoa(query.toLowerCase().trim()).replace(/[^a-zA-Z0-9]/g, '');
}

// RAG関数に統合
export async function generateRAGResponseWithCache(
  query: string,
  env: Env,
  options = {}
): Promise<RAGResponse> {
  // キャッシュをチェック
  const cached = await getCachedResponse(query, env);
  if (cached) {
    return { ...cached, metadata: { ...cached.metadata, cached: true } };
  }

  // キャッシュがなければ生成
  const response = await generateRAGResponse(query, env, options);

  // キャッシュに保存
  await setCachedResponse(query, response, env);

  return response;
}

まとめ

Cloudflare Workers AIとRAGを組み合わせることで、以下が実現できます:

  • 低レイテンシ: エッジでの実行により、世界中どこでも高速
  • スケーラブル: Cloudflareのグローバルネットワークで自動スケール
  • コスト効率: サーバーレスで従量課金、アイドル時のコストなし
  • 高精度: RAGにより、最新情報に基づいた回答を生成
  • 簡単な統合: APIとして提供し、あらゆるフロントエンドから利用可能

Cloudflare Workers AIは、エッジでのAIアプリケーション構築の新しい選択肢として、非常に有望です。