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;
}
トークン見積もりの罠 — length / 4 は日本語で約3.9倍外れる
上のコードには、私が実際に踏んだ地雷がそのまま埋まっています。Math.ceil(combined.length / 4) の一行です。
「1トークンおよそ4文字」は英語圏の資料でよく見かける経験則で、英語のうちは十分に実用的です。ところが日本語では、この前提が根本から崩れます。cl100k_base トークナイザで実際に測ってみました。
言語 1トークンあたりの文字数(実測平均) / 4 前提とのずれ
日本語(5サンプル) 1.02 文字 約 3.9倍 過小評価
英語(5サンプル) 5.49 文字 0.73倍(安全側に外れる)
日本語は、ほぼ1文字が1トークンです。つまり / 4 で見積もったチャンクは、実際には4倍近い量のトークン を抱えています。英語で書かれたチュートリアルをそのまま持ち込むと気づけない理由が、ここにあります。英語では見積もりが安全側(実際より多め)に外れるので、何の問題も起きないのです。
これが実害に変わるのは、埋め込みモデルの入力上限とぶつかった瞬間です。Cloudflare の @cf/baai/bge-large-en-v1.5 は、公式のモデル情報に Maximum Input Tokens: 512 と明記されています。上限を超えた分は例外にならず、静かに切り捨てられます。
2,018文字(実測2,084トークン)の日本語ドキュメントを、両方の分割ロジックに通してみました。
分割ロジック 生成チャンク数 各チャンクのトークン数 512超のチャンク モデルに捨てられるトークン
length / 4 で見積もり1 2,084 1 / 1 1,572(75.4%)
トークナイザで実測 5 352 / 447 / 443 / 429 / 413 0 / 5 0(0.0%)
投入したドキュメントの四分の三が、インデックスに一度も載っていませんでした。ログには何も出ません。リコールが上がらない理由を検索側でいくら探しても、見つかるはずがなかったわけです。
修正は、見積もりをやめて実際に数えるだけです。
// src/lib/rag/chunker.ts(修正版)
// 文字数からの推定をやめ、トークナイザで実測する
import { encoding_for_model, get_encoding } from "tiktoken" ;
// モデルの上限512に対し、余裕を見て448で切る
// (オーバーラップ分と、稀に混ざる長い固有名詞のぶれを吸収するため)
const CHUNK_CONFIG = {
maxTokens: 448 ,
overlapTokens: 64 ,
minTokens: 100 ,
hardLimit: 512 , // @cf/baai/bge-large-en-v1.5 の Maximum Input Tokens
} as const ;
const enc = get_encoding ( "cl100k_base" );
export function countTokens ( text : string ) : number {
return enc. encode (text). length ;
}
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;
if ( countTokens (combined) > CHUNK_CONFIG .maxTokens && currentChunk) {
chunks. push ( makeChunk (currentChunk, source, sectionTitle, position ++ ));
// オーバーラップもトークン単位で取る(文字数で取ると同じ罠を踏む)
currentChunk = `${ takeLastTokens ( currentChunk , CHUNK_CONFIG . overlapTokens ) } \n\n ${ para }` ;
} else {
currentChunk = combined;
}
}
if (currentChunk. trim ()) {
chunks. push ( makeChunk (currentChunk, source, sectionTitle, position ++ ));
}
}
// 最後の砦: 上限を超えたチャンクは黙って通さない
for ( const c of chunks) {
if (c.metadata.tokenCount > CHUNK_CONFIG .hardLimit) {
throw new Error (
`chunk ${ c . id } is ${ c . metadata . tokenCount } tokens (limit ${ CHUNK_CONFIG . hardLimit })`
);
}
}
return chunks;
}
function makeChunk (
content : string ,
source : string ,
section : string ,
position : number
) : DocumentChunk {
const trimmed = content. trim ();
return {
id: `${ source }-${ position }` ,
content: trimmed,
metadata: { source, section, position, tokenCount: countTokens (trimmed) },
};
}
function takeLastTokens ( text : string , n : number ) : string {
const ids = enc. encode (text);
if (ids. length <= n) return text;
return new TextDecoder (). decode (enc. decode (ids. slice ( - n)));
}
書き足した価値がいちばん大きいのは、最後の throw だと思っています。切り捨ては黙って起きるからこそ厄介なので、パイプラインの側で必ず音を立てさせる。一度これを入れておけば、後からドキュメントの書式が変わっても同じ穴には落ちません。
埋め込みモデルの選定 — -en- を日本語に使わない
チャンク分割を直したあと、次に見直したのが埋め込みモデルそのものでした。最初に選んでいた @cf/baai/bge-large-en-v1.5 は、モデルIDに -en- と入っているとおり英語向けのモデルです。日本語のFAQを英語向けモデルに通しても動きはしますし、ベクトルも返ってきます。ただ、返ってきた数字が日本語の意味をどれだけ捉えているかは別の話です。
Cloudflare Workers AI には多言語対応の @cf/baai/bge-m3 があり、公式ページには「Multi-Functionality, Multi-Linguality, and Multi-Granularity embeddings model」と説明されています。両者を公式のモデル情報で並べると、日本語を扱う側にとっては選択の余地がほとんどありませんでした。
項目 @cf/baai/bge-large-en-v1.5@cf/baai/bge-m3
言語 英語向け(モデルID に -en-) 多言語
入力上限 Maximum Input Tokens 512 Context Window 60,000 トークン
単価 $0.20 / 100万入力トークン $0.012 / 100万入力トークン
バッチ上限 1リクエスト最大100件 バッチ対応
単価が16.7倍違います。200万文字(実測 約195.7万トークン)の日本語ナレッジベースを丸ごと再インデックスした場合の埋め込みコストは、前者が約$0.39、後者が約$0.023。金額そのものは小さく見えますが、比率はドキュメント量に比例してそのまま効いてきます。入力上限が512から60,000へ広がる点も、チャンクサイズの設計自由度という意味で大きい違いです。
安いほうが多言語対応で上限も広い、という珍しく迷いようのない構図でした。私はここで bge-m3 に寄せています。
なお bge-m3 のドキュメントは、パラメータ表が query と contexts[] を示している一方で、掲載されているコードサンプルは { text: [...] } を送っており、記述が食い違っています。移行するときは、いきなり全件を流さず、まず数件で戻り値の形を目視してから本流に載せることをおすすめします。
ベクトル埋め込みとインデックス構築
チャンク分割されたドキュメントをベクトル埋め込みに変換し、ベクトルデータベースに格納します。ここでは Cloudflare Vectorize を使用する例を示しますが、Pinecone や Weaviate にも同じパターンが適用できます。
// src/lib/rag/embedder.ts
// ベクトル埋め込みの生成とインデックス格納
interface EmbeddingResult {
chunkId : string ;
vector : number [];
dimensions : number ;
}
// Vectorize のインデックスは作成時に次元数を固定する。
// ここを実モデルの出力次元と揃えないと upsert が丸ごと落ちる。
// bge-large-en-v1.5 は公式表記で 1,024 次元(768 ではない)。
const EMBEDDING_DIM = 1024 ;
export async function generateEmbeddings (
chunks : DocumentChunk [],
env : { AI : Ai }
) : Promise < EmbeddingResult []> {
// Cloudflare Workers AI の埋め込みモデルを使用
// 公式スキーマ上、text 配列の maxItems は 100。余裕を見て 50 で回す
const batchSize = 50 ;
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);
// 多言語モデルを使う。英語向けの bge-large-en-v1.5 を日本語に当てない
const response = await env. AI . run (
"@cf/baai/bge-m3" ,
{ text: texts }
);
// 期待される出力: { data: Array<number[]> }
for ( let j = 0 ; j < batch. length ; j ++ ) {
results. push ({
chunkId: batch[j].id,
vector: response.data[j],
dimensions: EMBEDDING_DIM ,
});
}
}
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: ベクトル検索
// 検索側とインデックス側は必ず同じモデル・同じ pooling で揃える。
// 片方だけ差し替えるとベクトル空間がずれ、スコアが意味を失う。
const queryEmbedding = await env. AI . run (
"@cf/baai/bge-m3" ,
{ 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 []; // 最新の会話履歴
totalTokens : 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,
totalTokens: countConversationTokens (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,
totalTokens: countConversationTokens (recentMessages) + countConversationTokens ([
{ 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;
}
import { countTokens } from "@/lib/rag/chunker" ;
// 会話履歴でも同じ数え方を使う。ここを文字数推定のままにすると、
// 日本語の履歴を実際の約4分の1に見積もり、窓が縮まないままコンテキストが溢れる。
function countConversationTokens ( messages : Message []) : number {
return messages. reduce (( sum , m ) => sum + countTokens (m.content), 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が安定して効きました。これは扱うコンテンツの性質で変わるので、必ず自分のデータで一度測ることをおすすめします。
あのリコール率の表は、横軸のラベルが間違っていた
前節のチャンクサイズ比較には、後日談があります。トークン見積もりの罠に気づいたあと表を読み直して、私は少しばかり気まずい思いをしました。
あの測定を回していた当時のコードは length / 4 で見積もっていました。つまり「512トークン」と書いた設定は、実際には2,048文字で切っていた のです。日本語では2,000トークン前後。埋め込みモデルの上限512を大きく超えていたので、モデルに届いていたのは各チャンクの先頭四分の一だけでした。
リコール率の数字自体は、実際に測ったものなので嘘ではありません。ずれていたのは横軸のラベルです。文字数で読み直すと、こうなります。
当時のラベル 実際の分割幅 モデルに届いていた量 上位3件のリコール率
1,200トークン 約4,800文字 先頭512トークンのみ 約72%
768トークン 約3,072文字 先頭512トークンのみ 約81%
512トークン 約2,048文字 先頭512トークンのみ 約88%
256トークン 約1,024文字 ほぼ全量 約83%
こう並べると、当時の解釈も半分は的を外していたことが分かります。「小さくするほど良いわけではない」という結論は正しかったのですが、上の3行が効いていた理由は、チャンクが小さくなったからというより、捨てられる割合が減ったから でした。256トークン(約1,024文字)の行だけが唯一ほぼ全量を届けており、そこで初めて「文脈が切れて逆に低下」という本来の現象が観測できていた、という読み方になります。
トークナイザで実測するように直したあと、同じFAQデータで測り直しました。448トークンで切った現在の設定は、上位3件のリコール率で約91%です。当時のベストだった88%から、劇的とは言えないまでも確かな改善でした。四分の三を捨てていた状態で88%出ていたのは、FAQという素材が「答えの核心が段落の先頭に来やすい」性質を持っていたおかげだろうと考えています。運が良かっただけで、設計としては褒められたものではありません。
得られた教訓は、チャンクサイズの最適値そのものよりも、測定対象が本当に自分の思っている対象かを一度疑う ことのほうにありました。私はこの経験以降、RAGの調整に入る前に必ず「モデルに実際に届いたトークン数」をログに出すようにしています。
マルチモデルフォールバックは「保険」ではなく「日常」だった
実装時はフォールバックを「めったに起きない障害への保険」と捉えていました。ですが実運用のログを集計すると、プライマリモデルがタイムアウト・レート超過・一時的な5xxでフォールバックに落ちる割合は、ピーク帯で全リクエストの3〜5%にのぼりました。月数万リクエストの規模でも、無視できない頻度です。
ここで効いたのが、フォールバック先を「より速い軽量モデル」に倒す設計です。プライマリの高性能モデルが詰まっている時間帯は全体が混み合っていることが多く、同等性能の別モデルに振っても再び詰まります。むしろ flash 系の軽量モデルへ即座に逃がしたほうが、体感のレスポンスが安定しました。
本番投入前に潰しておきたい7項目
数か月の運用で「最初からやっておけばよかった」と感じた項目を、チェックリストにまとめます。
埋め込み再生成のトリガーを明確化する(ドキュメント更新時のみ再生成し、毎回作り直さない。コストが10倍変わります)
ストリーミングのタイムアウトを2段階で持つ(初回トークンまでと、全体完了まで)
レート制限はユーザー単位とIP単位の両方を持つ(片方だけだと簡単に回避されます)
フォールバック発火をログに必ず記録し、発火率を毎週見る
会話メモリの上限を「トークン数」で管理する(メッセージ件数ではなく)
失敗時のユーザー向け文言を用意する(「混み合っています」と素直に出すほうが離脱が減りました)
月次でコスト内訳を埋め込み・推論・ベクトル検索に分解する
特に1番は効果が大きく、毎リクエストで埋め込みを作り直していた初期実装から、更新時のみ再生成に変えただけで、埋め込みAPIコストがほぼ1桁下がりました。可観測性とコストの両立は、アプリ事業で広告収益と運用費のバランスを長く見てきた感覚とそのまま地続きで、ここが甘いと小規模個人開発ではすぐ赤字に傾きます。
まとめ
次に手を動かすなら、RAGパイプラインより先に一行だけ足してください。チャンクを埋め込みに投げる直前で、トークナイザで数えた実トークン数をログに出す。それだけで、この記事で私が数週間かけて遠回りした問題は初日に見つかります。
設計の要点は、セマンティックチャンキング、Zodスキーマによる型安全なFunction Calling、トークン数で管理する会話メモリ、そして軽量モデルへ倒すフォールバックの四つでした。ただ、どれも「モデルに届いている内容が、自分の意図したとおりであること」の上に乗っています。土台が静かにずれていると、その上でどれだけ丁寧に調整しても数字は動きません。
切り捨て・次元数の不一致・検索側とインデックス側のモデルずれ。RAGの不調は、精度の問題に見えて配線の問題であることが少なくありませんでした。うまくいかないときは、賢くする前に、まず届いているかを確かめてみてください。
長い記事に最後までお付き合いいただき、ありがとうございました。同じ場所でつまずいた方の時間が少しでも短くなれば、この遠回りにも意味があったと思えます。