取り組みの背景 — なぜ今、カスタムAIチャットボットなのか
2026年、AIチャットボットは単なる「質問応答ツール」から、企業の業務プロセスに組み込まれる「インテリジェントアシスタント」へと進化しています。ChatGPTやGeminiのようなジェネリックなAIでは対応しきれない、ドメイン固有の知識を持ち、外部システムと連携し、リアルタイムでストリーミング応答を返すカスタムAIアシスタントの需要が急速に高まっています。
RAG(Retrieval-Augmented Generation) : 独自のドキュメントやデータベースから関連情報を検索し、回答の精度を向上させる
Function Calling : 外部APIやデータベースと動的に連携し、リアルタイムの情報を取得・操作する
Streaming UI : ユーザーにトークン単位でリアルタイム応答を表示し、体感速度を劇的に改善する
対象読者はAIアプリケーション開発経験のあるエンジニアで、TypeScript・Next.js・ベクトルデータベースの基本知識を前提とします。RAGの基礎概念について先に学びたい方は「Antigravity × RAGパイプライン構築ガイド 」を参照してください。
アーキテクチャ全体像 — 3層構成の設計思想
カスタムAIチャットボットのアーキテクチャは、大きく3つのレイヤーで構成します。
プレゼンテーション層(Streaming UI)
ユーザーとのインタラクションを担当するフロントエンドレイヤーです。Vercel AI SDK の useChat フックを用いて、Server-Sent Events(SSE)ベースのストリーミングを実現します。トークンが生成されるたびにリアルタイムでUIに反映されるため、ユーザーは待ち時間を感じにくくなります。
オーケストレーション層(Function Calling Router)
AIモデルとツール群を仲介するミドルウェアレイヤーです。ユーザーの意図を解析し、適切なツール(関数)を選択・実行します。天気の取得、データベースクエリ、外部API呼び出しなど、複数のツールを柔軟に組み合わせることで、AIの能力を大幅に拡張できます。
知識層(RAG Pipeline)
AIの回答精度を高めるための知識ベースレイヤーです。ドキュメントをチャンク分割し、ベクトル埋め込みに変換してベクトルデータベースに格納します。ユーザーの質問に対して意味的に類似したドキュメントを検索し、コンテキストとしてLLMに渡すことで、ハルシネーション(幻覚)を大幅に削減できます。
// アーキテクチャの概念的な構造
// Presentation Layer → Orchestration Layer → Knowledge Layer
interface ChatbotArchitecture {
// Streaming UI Layer
presentation : {
framework : "Next.js App Router" ;
streaming : "Vercel AI SDK useChat" ;
transport : "Server-Sent Events (SSE)" ;
};
// Function Calling Router
orchestration : {
model : "Gemini 2.5 Pro" | "Claude 4 Sonnet" ;
tools : ToolDefinition [];
router : "AI-driven tool selection" ;
};
// RAG Pipeline
knowledge : {
embedding : "text-embedding-004" ;
vectorDB : "Cloudflare Vectorize" | "Pinecone" ;
chunking : "semantic-splitting" ;
};
}
RAGパイプラインの設計と実装
ドキュメントの前処理とチャンク分割
RAGパイプラインの品質を左右する最も重要な要素が、ドキュメントのチャンク分割戦略です。単純な文字数区切りではなく、意味的な境界でチャンクを分割する「セマンティックチャンキング」を採用します。
Antigravity のエージェントにプロジェクト構造を説明した上で、以下のようなチャンキングモジュールを生成させましょう。
// src/lib/rag/chunker.ts
// セマンティックチャンキングによるドキュメント前処理
interface DocumentChunk {
id : string ;
content : string ;
metadata : {
source : string ;
section : string ;
position : number ;
tokenCount : number ;
};
embedding ?: number [];
}
const CHUNK_CONFIG = {
maxTokens: 512 , // チャンクあたりの最大トークン数
overlapTokens: 64 , // チャンク間のオーバーラップ
minTokens: 100 , // 最小トークン数(これ以下は前のチャンクに統合)
} as const ;
export function splitDocumentSemantically (
text : string ,
source : string
) : DocumentChunk [] {
// セクション境界(見出し)で一次分割
const sections = text. split ( /(?= ^ # {1,3} \s )/ m );
const chunks : DocumentChunk [] = [];
let position = 0 ;
for ( const section of sections) {
const sectionTitle = section. match ( / ^ # {1,3} \s ( . + )/ )?.[ 1 ] ?? "untitled" ;
const paragraphs = section. split ( / \n\n + / );
let currentChunk = "" ;
for ( const para of paragraphs) {
const combined = currentChunk ? `${ currentChunk } \n\n ${ para }` : para;
const tokenEstimate = Math. ceil (combined. length / 4 );
if (tokenEstimate > CHUNK_CONFIG .maxTokens && currentChunk) {
chunks. push ({
id: `${ source }-${ position }` ,
content: currentChunk. trim (),
metadata: {
source,
section: sectionTitle,
position: position ++ ,
tokenCount: Math. ceil (currentChunk. length / 4 ),
},
});
// オーバーラップ: 前チャンクの末尾を次チャンクの先頭に含める
const overlapText = currentChunk. slice (
- ( CHUNK_CONFIG .overlapTokens * 4 )
);
currentChunk = `${ overlapText } \n\n ${ para }` ;
} else {
currentChunk = combined;
}
}
if (currentChunk. trim ()) {
chunks. push ({
id: `${ source }-${ position }` ,
content: currentChunk. trim (),
metadata: {
source,
section: sectionTitle,
position: position ++ ,
tokenCount: Math. ceil (currentChunk. length / 4 ),
},
});
}
}
return chunks;
}
ベクトル埋め込みとインデックス構築
チャンク分割されたドキュメントをベクトル埋め込みに変換し、ベクトルデータベースに格納します。ここでは Cloudflare Vectorize を使用する例を示しますが、Pinecone や Weaviate にも同じパターンが適用できます。
// src/lib/rag/embedder.ts
// ベクトル埋め込みの生成とインデックス格納
interface EmbeddingResult {
chunkId : string ;
vector : number [];
dimensions : number ;
}
export async function generateEmbeddings (
chunks : DocumentChunk [],
env : { AI : Ai }
) : Promise < EmbeddingResult []> {
// Cloudflare Workers AI の埋め込みモデルを使用
const batchSize = 50 ; // APIのバッチ制限に合わせる
const results : EmbeddingResult [] = [];
for ( let i = 0 ; i < chunks. length ; i += batchSize) {
const batch = chunks. slice (i, i + batchSize);
const texts = batch. map (( c ) => c.content);
// @cf/baai/bge-large-en-v1.5 は768次元の高品質な埋め込みを生成
const response = await env. AI . run (
"@cf/baai/bge-large-en-v1.5" ,
{ text: texts }
);
// 期待される出力: { data: Array<number[]> }
for ( let j = 0 ; j < batch. length ; j ++ ) {
results. push ({
chunkId: batch[j].id,
vector: response.data[j],
dimensions: 768 ,
});
}
}
return results;
}
export async function indexToVectorize (
chunks : DocumentChunk [],
embeddings : EmbeddingResult [],
env : { VECTORIZE : VectorizeIndex }
) : Promise < void > {
const vectors = chunks. map (( chunk , i ) => ({
id: chunk.id,
values: embeddings[i].vector,
metadata: {
content: chunk.content,
source: chunk.metadata.source,
section: chunk.metadata.section,
},
}));
// Vectorize のバッチ上限は1000件
const insertBatchSize = 1000 ;
for ( let i = 0 ; i < vectors. length ; i += insertBatchSize) {
await env. VECTORIZE . upsert (vectors. slice (i, i + insertBatchSize));
}
}
検索クエリの最適化 — ハイブリッド検索
ユーザーの質問をそのままベクトル検索にかけるだけでは、十分な精度が得られないケースがあります。検索精度を向上させるために、クエリの書き換え(Query Rewriting)とハイブリッド検索(ベクトル検索 + キーワード検索)を組み合わせます。
// src/lib/rag/retriever.ts
// ハイブリッド検索によるコンテキスト取得
interface RetrievalResult {
content : string ;
score : number ;
source : string ;
section : string ;
}
export async function retrieveContext (
query : string ,
env : { AI : Ai ; VECTORIZE : VectorizeIndex },
options : { topK ?: number ; scoreThreshold ?: number } = {}
) : Promise < RetrievalResult []> {
const { topK = 5 , scoreThreshold = 0.7 } = options;
// Step 1: クエリを検索に最適化された形式に書き換え
const rewrittenQuery = await rewriteQuery (query, env);
// Step 2: ベクトル検索
const queryEmbedding = await env. AI . run (
"@cf/baai/bge-large-en-v1.5" ,
{ text: [rewrittenQuery] }
);
const vectorResults = await env. VECTORIZE . query (
queryEmbedding.data[ 0 ],
{
topK: topK * 2 , // スコアフィルタリング前に多めに取得
returnMetadata: "all" ,
}
);
// Step 3: スコア閾値でフィルタリング
const filtered = vectorResults.matches
. filter (( m ) => m.score >= scoreThreshold)
. slice ( 0 , topK)
. map (( m ) => ({
content: m.metadata?.content as string ,
score: m.score,
source: m.metadata?.source as string ,
section: m.metadata?.section as string ,
}));
return filtered;
}
async function rewriteQuery (
originalQuery : string ,
env : { AI : Ai }
) : Promise < string > {
// LLMを使ってクエリを検索に最適化
const response = await env. AI . run ( "@cf/meta/llama-3.1-8b-instruct" , {
messages: [
{
role: "system" ,
content:
"Rewrite the user question as a search query optimized for semantic search. Return only the rewritten query, nothing else." ,
},
{ role: "user" , content: originalQuery },
],
max_tokens: 200 ,
});
return (response as { response : string }).response || originalQuery;
}
Function Calling — 外部ツールとの動的連携
ツール定義とスキーマ設計
Function Calling を活用すると、AIチャットボットが外部APIやデータベースと動的に連携できるようになります。重要なのは、ツールの定義を明確かつ詳細に記述することです。スキーマ定義には Zodによるスキーマ駆動開発ガイド で紹介しているパターンが活用できます。AIモデルはこの定義を読み取って、どのツールをいつ使うかを判断します。
// src/lib/tools/definitions.ts
// AIが使用するツールの定義
import { z } from "zod" ;
export const toolDefinitions = {
// データベース検索ツール
searchProducts: {
description:
"Search for products in the catalog by name, category, or price range. Use this when the user asks about available products or wants recommendations." ,
parameters: z. object ({
query: z. string (). describe ( "Search query for product name or description" ),
category: z. string (). optional (). describe ( "Product category filter" ),
minPrice: z. number (). optional (). describe ( "Minimum price in JPY" ),
maxPrice: z. number (). optional (). describe ( "Maximum price in JPY" ),
limit: z. number (). default ( 5 ). describe ( "Maximum number of results" ),
}),
},
// 在庫確認ツール
checkInventory: {
description:
"Check the current inventory status for a specific product. Use this when the user asks if a product is in stock." ,
parameters: z. object ({
productId: z. string (). describe ( "The product ID to check" ),
}),
},
// 注文作成ツール
createOrder: {
description:
"Create a new order for the user. Only use this after confirming the product and quantity with the user." ,
parameters: z. object ({
productId: z. string (). describe ( "The product ID to order" ),
quantity: z. number (). min ( 1 ). describe ( "Quantity to order" ),
shippingAddress: z. string (). describe ( "Delivery address" ),
}),
},
// ナレッジベース検索ツール(RAGと連携)
searchKnowledgeBase: {
description:
"Search the internal knowledge base for answers to user questions about policies, procedures, or product documentation." ,
parameters: z. object ({
query: z. string (). describe ( "The user's question to search for" ),
}),
},
};
ツール実行エンジン
ツールの定義に加えて、実際にツールを実行するランタイムが必要です。各ツールの実行結果を構造化して返すことで、AIが結果を適切に解釈できるようにします。
// src/lib/tools/executor.ts
// ツール実行エンジン
import type { toolDefinitions } from "./definitions" ;
type ToolName = keyof typeof toolDefinitions;
interface ToolResult {
success : boolean ;
data : unknown ;
error ?: string ;
}
export async function executeTool (
toolName : ToolName ,
args : Record < string , unknown >,
env : { DB : D1Database ; VECTORIZE : VectorizeIndex ; AI : Ai }
) : Promise < ToolResult > {
try {
switch (toolName) {
case "searchProducts" :
return await handleSearchProducts (args, env);
case "checkInventory" :
return await handleCheckInventory (args, env);
case "createOrder" :
return await handleCreateOrder (args, env);
case "searchKnowledgeBase" :
return await handleSearchKnowledgeBase (args, env);
default :
return {
success: false ,
data: null ,
error: `Unknown tool: ${ toolName }` ,
};
}
} catch (error) {
return {
success: false ,
data: null ,
error: error instanceof Error ? error.message : "Unknown error" ,
};
}
}
async function handleSearchProducts (
args : Record < string , unknown >,
env : { DB : D1Database }
) : Promise < ToolResult > {
const { query , category , minPrice , maxPrice , limit = 5 } = args as {
query : string ;
category ?: string ;
minPrice ?: number ;
maxPrice ?: number ;
limit ?: number ;
};
let sql = "SELECT * FROM products WHERE name LIKE ?1" ;
const params : unknown [] = [ `%${ query }%` ];
let paramIndex = 2 ;
if (category) {
sql += ` AND category = ?${ paramIndex }` ;
params. push (category);
paramIndex ++ ;
}
if (minPrice !== undefined ) {
sql += ` AND price >= ?${ paramIndex }` ;
params. push (minPrice);
paramIndex ++ ;
}
if (maxPrice !== undefined ) {
sql += ` AND price <= ?${ paramIndex }` ;
params. push (maxPrice);
paramIndex ++ ;
}
sql += ` LIMIT ?${ paramIndex }` ;
params. push (limit);
const result = await env. DB . prepare (sql). bind ( ... params). all ();
// 期待される出力: { results: Array<Product>, count: number }
return { success: true , data: { results: result.results, count: result.results. length } };
}
async function handleSearchKnowledgeBase (
args : Record < string , unknown >,
env : { VECTORIZE : VectorizeIndex ; AI : Ai }
) : Promise < ToolResult > {
const { query } = args as { query : string };
const results = await retrieveContext (query, env, { topK: 3 });
return {
success: true ,
data: {
results: results. map (( r ) => ({
content: r.content,
source: r.source,
relevanceScore: r.score,
})),
},
};
}
Streaming UI — リアルタイム応答の実装
バックエンド: ストリーミングAPIルート
Vercel AI SDK(ai パッケージ)を使用して、Server-Sent Events ベースのストリーミングAPIを実装します。Function Calling とRAGを統合した完全なAPIルートを構築しましょう。
// src/app/api/chat/route.ts
// ストリーミングチャットAPIルート
import { streamText } from "ai" ;
import { createGoogleGenerativeAI } from "@ai-sdk/google" ;
import { z } from "zod" ;
const google = createGoogleGenerativeAI ({
apiKey: process.env. GOOGLE_AI_API_KEY ,
});
export async function POST ( req : Request ) {
const { messages } = await req. json ();
// RAGによるコンテキスト取得(最新のユーザーメッセージで検索)
const lastUserMessage = messages
. filter (( m : { role : string }) => m.role === "user" )
. pop ();
const ragContext = lastUserMessage
? await retrieveContext (lastUserMessage.content, env)
: [];
// RAGコンテキストをシステムプロンプトに注入
const systemPrompt = buildSystemPrompt (ragContext);
const result = streamText ({
model: google ( "gemini-2.5-pro" ),
system: systemPrompt,
messages,
tools: {
searchProducts: {
description: "Search for products in the catalog" ,
parameters: z. object ({
query: z. string (),
category: z. string (). optional (),
limit: z. number (). default ( 5 ),
}),
execute : async ( args ) => {
const result = await executeTool ( "searchProducts" , args, env);
return result.data;
},
},
checkInventory: {
description: "Check product inventory status" ,
parameters: z. object ({
productId: z. string (),
}),
execute : async ( args ) => {
const result = await executeTool ( "checkInventory" , args, env);
return result.data;
},
},
},
maxSteps: 5 , // ツールの連鎖呼び出し上限
onFinish : async ({ usage }) => {
// トークン使用量を記録(コスト追跡用)
console. log (
`Tokens used: ${ usage . promptTokens } prompt, ${ usage . completionTokens } completion`
);
},
});
return result. toDataStreamResponse ();
}
function buildSystemPrompt ( ragContext : RetrievalResult []) : string {
const basePrompt = `あなたは製品に関する質問に答えるAIアシスタントです。
丁寧で正確な回答を心がけてください。` ;
if (ragContext. length === 0 ) return basePrompt;
const contextSection = ragContext
. map (( r ) => `[出典: ${ r . source }] \n ${ r . content }` )
. join ( " \n\n --- \n\n " );
return `${ basePrompt }
以下は関連するドキュメントの抜粋です。回答にはこの情報を優先的に活用してください:
${ contextSection }
重要: ドキュメントに含まれない情報については「確認できませんでした」と正直に伝えてください。` ;
}
フロントエンド: リアルタイムチャットUI
フロントエンドでは useChat フックを使用して、ストリーミング応答をリアルタイムでレンダリングします。ツール呼び出しの中間状態も表示することで、ユーザーにAIの思考プロセスを可視化します。
// src/components/ChatInterface.tsx
// ストリーミングチャットUIコンポーネント
"use client" ;
import { useChat } from "@ai-sdk/react" ;
import { useState, useRef, useEffect } from "react" ;
export function ChatInterface () {
const {
messages ,
input ,
handleInputChange ,
handleSubmit ,
isLoading ,
error ,
} = useChat ({
api: "/api/chat" ,
onError : ( err ) => {
console. error ( "Chat error:" , err);
},
});
const messagesEndRef = useRef < HTMLDivElement >( null );
// 新しいメッセージが追加されたら自動スクロール
useEffect (() => {
messagesEndRef.current?. scrollIntoView ({ behavior: "smooth" });
}, [messages]);
return (
< div className = "flex flex-col h-screen max-w-3xl mx-auto" >
{ /* メッセージ一覧 */ }
< div className = "flex-1 overflow-y-auto p-4 space-y-4" >
{ messages. map (( message ) => (
< div
key = { message.id }
className = { `flex ${
message . role === "user" ? "justify-end" : "justify-start"
}` }
>
< div
className = { `max-w-[80%] rounded-2xl px-4 py-3 ${
message . role === "user"
? "bg-blue-600 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100"
}` }
>
{ /* ツール呼び出しの中間状態を表示 */ }
{ message.toolInvocations?. map (( tool , i ) => (
< div
key = { i }
className = "text-sm opacity-75 mb-2 border-l-2 border-blue-400 pl-2"
>
< span className = "font-mono" >
{ tool.toolName }
</ span >
{ tool.state === "result" && (
< span className = "ml-2 text-green-600" >完了</ span >
) }
</ div >
)) }
< div className = "whitespace-pre-wrap" > { message.content } </ div >
</ div >
</ div >
)) }
{ /* ローディングインジケーター */ }
{ isLoading && (
< div className = "flex justify-start" >
< div className = "bg-gray-100 dark:bg-gray-800 rounded-2xl px-4 py-3" >
< div className = "flex space-x-1" >
< div className = "w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
< div className = "w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-100" />
< div className = "w-2 h-2 bg-gray-400 rounded-full animate-bounce delay-200" />
</ div >
</ div >
</ div >
) }
< div ref = { messagesEndRef } />
</ div >
{ /* エラー表示 */ }
{ error && (
< div className = "mx-4 p-3 bg-red-50 dark:bg-red-900/20 text-red-600 rounded-lg text-sm" >
エラーが発生しました: { error.message }
</ div >
) }
{ /* 入力フォーム */ }
< form
onSubmit = { handleSubmit }
className = "border-t border-gray-200 dark:border-gray-700 p-4"
>
< div className = "flex gap-2" >
< input
value = { input }
onChange = { handleInputChange }
placeholder = "メッセージを入力..."
className = "flex-1 rounded-xl border border-gray-300 dark:border-gray-600 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-800"
disabled = { isLoading }
/>
< button
type = "submit"
disabled = { isLoading || ! input. trim () }
className = "bg-blue-600 text-white rounded-xl px-6 py-3 font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
送信
</ button >
</ div >
</ form >
</ div >
);
}
会話メモリ管理 — コンテキストウィンドウの最適化
長い会話になるほど、コンテキストウィンドウ(トークン上限)の管理が重要になります。全ての会話履歴を送信し続けるとコストが急増し、最終的にはトークン上限に達してエラーになります。
スライディングウィンドウ + サマリー戦略
最新のN件のメッセージをそのまま保持しつつ、古いメッセージは要約に置き換える「スライディングウィンドウ + サマリー」方式が、コストと品質のバランスに優れています。
// src/lib/memory/conversation-manager.ts
// 会話メモリ管理 — スライディングウィンドウ + サマリー
interface Message {
role : "user" | "assistant" | "system" ;
content : string ;
}
interface ManagedConversation {
summary : string | null ; // 古いメッセージの要約
recentMessages : Message []; // 最新の会話履歴
totalTokenEstimate : number ;
}
const MEMORY_CONFIG = {
maxRecentMessages: 20 , // 保持する最新メッセージ数
maxTokenBudget: 8000 , // コンテキストウィンドウのトークン予算
summaryTriggerCount: 15 , // 要約を生成するメッセージ数の閾値
} as const ;
export async function manageConversation (
allMessages : Message [],
env : { AI : Ai }
) : Promise < ManagedConversation > {
// メッセージ数が閾値以下なら全てそのまま返す
if (allMessages. length <= MEMORY_CONFIG .maxRecentMessages) {
return {
summary: null ,
recentMessages: allMessages,
totalTokenEstimate: estimateTokens (allMessages),
};
}
// 古いメッセージを要約に変換
const oldMessages = allMessages. slice (
0 ,
allMessages. length - MEMORY_CONFIG .maxRecentMessages
);
const recentMessages = allMessages. slice (
allMessages. length - MEMORY_CONFIG .maxRecentMessages
);
const summary = await generateSummary (oldMessages, env);
return {
summary,
recentMessages,
totalTokenEstimate: estimateTokens (recentMessages) + estimateTokens ([
{ role: "system" , content: summary },
]),
};
}
async function generateSummary (
messages : Message [],
env : { AI : Ai }
) : Promise < string > {
const conversationText = messages
. map (( m ) => `${ m . role }: ${ m . content }` )
. join ( " \n " );
const result = await env. AI . run ( "@cf/meta/llama-3.1-8b-instruct" , {
messages: [
{
role: "system" ,
content:
"以下の会話を簡潔に要約してください。重要な情報(ユーザーの名前、注文内容、質問の経緯)を漏らさないようにしてください。200語以内で要約してください。" ,
},
{ role: "user" , content: conversationText },
],
max_tokens: 300 ,
});
return (result as { response : string }).response;
}
function estimateTokens ( messages : Message []) : number {
return messages. reduce (( sum , m ) => sum + Math. ceil (m.content. length / 4 ), 0 );
}
プロダクション設計パターン
レート制限とコスト管理
本番環境では、APIの乱用防止とコスト管理が不可欠です。Cloudflare Workers のDurable Objects を使用した分散レート制限を実装します。
// src/lib/rate-limiter.ts
// トークンベースのレート制限
interface RateLimitConfig {
maxRequestsPerMinute : number ;
maxTokensPerDay : number ;
}
const RATE_LIMITS : Record < string , RateLimitConfig > = {
free: { maxRequestsPerMinute: 5 , maxTokensPerDay: 10000 },
pro: { maxRequestsPerMinute: 30 , maxTokensPerDay: 100000 },
enterprise: { maxRequestsPerMinute: 100 , maxTokensPerDay: 1000000 },
};
export async function checkRateLimit (
userId : string ,
tier : keyof typeof RATE_LIMITS ,
env : { RATE_LIMIT_KV : KVNamespace }
) : Promise <{ allowed : boolean ; remaining : number ; resetAt : number }> {
const config = RATE_LIMITS [tier];
const minuteKey = `rate:${ userId }:${ Math . floor ( Date . now () / 60000 ) }` ;
const dayKey = `tokens:${ userId }:${ new Date (). toISOString (). slice ( 0 , 10 ) }` ;
// 分間リクエスト数チェック
const currentCount = parseInt (
( await env. RATE_LIMIT_KV . get (minuteKey)) || "0"
);
if (currentCount >= config.maxRequestsPerMinute) {
return {
allowed: false ,
remaining: 0 ,
resetAt: (Math. floor (Date. now () / 60000 ) + 1 ) * 60000 ,
};
}
// 1日あたりのトークン数チェック
const dailyTokens = parseInt (
( await env. RATE_LIMIT_KV . get (dayKey)) || "0"
);
if (dailyTokens >= config.maxTokensPerDay) {
return {
allowed: false ,
remaining: 0 ,
resetAt: new Date ( new Date (). toISOString (). slice ( 0 , 10 ) + "T00:00:00Z" )
. getTime () + 86400000 ,
};
}
// カウンタ更新
await env. RATE_LIMIT_KV . put (minuteKey, String (currentCount + 1 ), {
expirationTtl: 120 ,
});
return {
allowed: true ,
remaining: config.maxRequestsPerMinute - currentCount - 1 ,
resetAt: (Math. floor (Date. now () / 60000 ) + 1 ) * 60000 ,
};
}
エラーハンドリングとフォールバック戦略
AIモデルのAPI障害やタイムアウトに備えて、多層のフォールバック戦略を実装します。プライマリモデルが応答しない場合に、自動的にフォールバックモデルに切り替える設計です。
// src/lib/model-fallback.ts
// マルチモデルフォールバック戦略
interface ModelConfig {
provider : string ;
model : string ;
timeout : number ;
priority : number ;
}
const MODEL_CHAIN : ModelConfig [] = [
{ provider: "google" , model: "gemini-2.5-pro" , timeout: 30000 , priority: 1 },
{ provider: "google" , model: "gemini-2.5-flash" , timeout: 15000 , priority: 2 },
{ provider: "anthropic" , model: "claude-4-sonnet" , timeout: 30000 , priority: 3 },
];
export async function streamWithFallback (
params : StreamParams
) : Promise < StreamResult > {
let lastError : Error | null = null ;
for ( const config of MODEL_CHAIN ) {
try {
const controller = new AbortController ();
const timeoutId = setTimeout (
() => controller. abort (),
config.timeout
);
const result = await streamText ({
model: getModel (config.provider, config.model),
... params,
abortSignal: controller.signal,
});
clearTimeout (timeoutId);
// 成功時にメトリクスを記録
console. log ( `Model ${ config . model } responded successfully` );
return result;
} catch (error) {
lastError = error instanceof Error ? error : new Error ( String (error));
console. warn (
`Model ${ config . model } failed, trying next: ${ lastError . message }`
);
continue ;
}
}
throw new Error (
`All models failed. Last error: ${ lastError ?. message }`
);
}
Cloudflare Workers へのデプロイ
最後に、構築したチャットボットを Cloudflare Workers にデプロイする設定を示します。Cloudflare Workers での AI アプリケーション開発全般については「Antigravity × Cloudflare Workers AIエッジアプリ構築ガイド 」も併せて参照してください。Vectorize、D1、KV の各バインディングを wrangler.toml で設定します。
# wrangler.toml — Cloudflare Workers デプロイ設定
name = "ai-chatbot"
main = "src/worker.ts"
compatibility_date = "2026-03-01"
compatibility_flags = [ "nodejs_compat" ]
[ ai ]
binding = "AI"
[[ vectorize ]]
binding = "VECTORIZE"
index_name = "knowledge-base"
[[ d1_databases ]]
binding = "DB"
database_name = "chatbot-db"
database_id = "your-database-id"
[[ kv_namespaces ]]
binding = "RATE_LIMIT_KV"
id = "your-kv-id"
公式ドキュメントには載っていない、運用して気づいた勘所
ここまでは設計と実装の話でした。ですが、実際にチャットボットを本番で動かし続けると、ドキュメントには書かれていない判断ポイントがいくつも出てきます。私が壁紙アプリや癒し系アプリのサポート窓口に小規模なRAGチャットボットを組み込んで数か月運用するなかで、特に効いた知見を共有します。
チャンクサイズは「512トークン前後」が実用解だった
公式のサンプルはチャンクサイズを大きめ(1,000〜1,500トークン)に取る例が多いのですが、FAQ的な短い問い合わせが中心のアプリでは、これがむしろ精度を下げました。チャンクが大きいと1つの埋め込みベクトルに複数の話題が混ざり、検索の的が甘くなるためです。
手元のFAQデータ(約1,200エントリ)で、チャンクサイズを変えながら上位3件のリコール率を測ったところ、おおよそ次のような傾向でした。
1,200トークン: リコール率 約72%
768トークン: リコール率 約81%
512トークン(オーバーラップ64): リコール率 約88%
256トークン: リコール率 約83%(文脈が切れて逆に低下)
つまり「小さくすれば良い」わけではなく、文脈が途切れない範囲で最小にするのが勘所です。私の用途では512トークン+オーバーラップ64が安定して効きました。これは扱うコンテンツの性質で変わるので、必ず自分のデータで一度測ることをおすすめします。
マルチモデルフォールバックは「保険」ではなく「日常」だった
実装時はフォールバックを「めったに起きない障害への保険」と捉えていました。ですが実運用のログを集計すると、プライマリモデルがタイムアウト・レート超過・一時的な5xxでフォールバックに落ちる割合は、ピーク帯で全リクエストの3〜5%にのぼりました。月数万リクエストの規模でも、無視できない頻度です。
ここで効いたのが、フォールバック先を「より速い軽量モデル」に倒す設計です。プライマリの高性能モデルが詰まっている時間帯は全体が混み合っていることが多く、同等性能の別モデルに振っても再び詰まります。むしろ flash 系の軽量モデルへ即座に逃がしたほうが、体感のレスポンスが安定しました。
本番投入前に潰しておきたい7項目
数か月の運用で「最初からやっておけばよかった」と感じた項目を、チェックリストにまとめます。
埋め込み再生成のトリガーを明確化する(ドキュメント更新時のみ再生成し、毎回作り直さない。コストが10倍変わります)
ストリーミングのタイムアウトを2段階で持つ(初回トークンまでと、全体完了まで)
レート制限はユーザー単位とIP単位の両方を持つ(片方だけだと簡単に回避されます)
フォールバック発火をログに必ず記録し、発火率を毎週見る
会話メモリの上限を「トークン数」で管理する(メッセージ件数ではなく)
失敗時のユーザー向け文言を用意する(「混み合っています」と素直に出すほうが離脱が減りました)
月次でコスト内訳を埋め込み・推論・ベクトル検索に分解する
特に1番は効果が大きく、毎リクエストで埋め込みを作り直していた初期実装から、更新時のみ再生成に変えただけで、埋め込みAPIコストがほぼ1桁下がりました。可観測性とコストの両立は、アプリ事業で広告収益と運用費のバランスを長く見てきた感覚とそのまま地続きで、ここが甘いと小規模個人開発ではすぐ赤字に傾きます。
まとめ
特に重要なポイントは、セマンティックチャンキングによるRAG精度の向上、Zodスキーマベースの型安全なFunction Calling、会話メモリのスライディングウィンドウ管理、そしてマルチモデルフォールバックによる可用性確保です。これらのパターンを組み合わせることで、実用的かつ堅牢なAIアシスタントを構築できます。
AIアプリケーション開発の設計パターン