「Firebase で AI 機能を追加したいけど、Gemini API を直接叩くだけでいいのか、Genkit を使うべきなのかがよくわからない」——この判断で迷って数時間を費やしたことはありませんか。私にはあります。
Firebase Genkit は、Google が 2024 年にリリースした AI アプリ構築フレームワークです。単なる Gemini API のラッパーではなく、ローカルでの開発・デバッグ・テストから本番デプロイ・監視まで、AI 機能のライフサイクル全体をカバーするように設計されています。そして Antigravity との相性が、使ってみると想像以上によいのです。
ここで扱うのはGenkit を本番アプリで実際に使って学んだ経験をもとに、Antigravity の AI 補完を最大限に活かしながら Genkit アプリを設計・実装・デプロイする完全なプロセスをお伝えします。
Firebase Genkit が解決する問題
Gemini API を直接使えばいいのでは、という疑問はもっともです。シンプルなテキスト生成なら API を直接呼ぶだけで十分です。ただし、プロダクションレベルの AI 機能を構築しようとすると、すぐに共通の課題に直面します。
まずフローのデバッグの難しさがあります。「どのプロンプトがどのレスポンスを返したのか」「どのツール呼び出しでエラーが起きたのか」を追跡しようとすると、自前のロギング基盤が必要になります。次にテストの書きにくさの問題があります。LLM の呼び出しを含む処理は、モックなしではユニットテストが書けません。さらに複数モデルの切り替えも面倒です。開発環境では軽量モデル、本番では高精度モデルを使いたいとき、コードを大幅に書き換えることになりがちです。
Genkit はこれらを「フロー」という抽象化レイヤーで解決します。フローは、LLM の呼び出しや外部 API、ツール実行をまとめた単位で、実行トレース・入出力の型定義・ローカルデバッグ UI がデフォルトで付いてきます。
Antigravity との相性がよい理由は単純で、Genkit は TypeScript ファーストで設計されており、型情報が豊富です。Antigravity のタブ補完が、フローのオプション・プロバイダーの設定・Zod スキーマをほぼ完全な精度で補完してくれます。フロー定義のボイラープレートを書く時間が大幅に短縮されます。
環境セットアップ
まず、Genkit を使うプロジェクトをセットアップします。
# プロジェクト初期化
mkdir my-genkit-app && cd my-genkit-app
npm init -y
npm install -D typescript ts-node @types/node
# Genkit コアとプラグイン
npm install genkit @genkit-ai/googleai
npm install @genkit-ai/firebase # Firebase デプロイ用
# TypeScript 設定
npx tsc --init --module nodenext --moduleResolution nodenext --strict
tsconfig.json に以下を追加します。Antigravity の補完がより正確に動作するよう、パス設定を明示しておくのがポイントです。
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
Genkit インスタンスの初期化
src/genkit.ts に Genkit インスタンスを作成します。このファイルをアプリ全体で共有することで、設定の一元管理ができます。
// src/genkit.ts
import { genkit } from "genkit";
import { googleAI } from "@genkit-ai/googleai";
// 環境変数から API キーを取得(ハードコードは厳禁)
const GOOGLE_API_KEY = process.env.GOOGLE_AI_API_KEY;
if (\!GOOGLE_API_KEY) {
throw new Error("GOOGLE_AI_API_KEY environment variable is required");
}
export const ai = genkit({
plugins: [
googleAI({ apiKey: GOOGLE_API_KEY }),
],
// ローカル開発時のデバッグ UI を有効化
promptDir: "./prompts",
});
// よく使うモデルを定数として定義(後で切り替えが簡単になる)
export const MODELS = {
fast: "googleai/gemini-2.0-flash",
balanced: "googleai/gemini-2.5-pro",
local: "googleai/gemma-4-9b-it", // ローカルテスト用
} as const;
.env ファイルの管理は必ず dotenv 経由で行います。コミット前に .gitignore に .env が含まれていることを確認してください。
# .env(絶対にコミットしない)
GOOGLE_AI_API_KEY=YOUR_GOOGLE_AI_API_KEY
NODE_ENV=development
Antigravity のエージェントに .gitignore の確認を依頼するのが習慣になると、シークレットの漏洩リスクを防げます。これは小さな習慣ですが、チーム開発では特に重要です。
基本フローの実装
Genkit の中心概念は「フロー」です。フローは、入力スキーマ・出力スキーマを Zod で定義した、型安全な非同期処理ユニットです。
シンプルなテキスト生成フロー
// src/flows/summarize.ts
import { z } from "genkit";
import { ai, MODELS } from "../genkit";
// 入力・出力スキーマを Zod で定義
const SummarizeInput = z.object({
text: z.string().min(1).max(10000).describe("要約するテキスト"),
language: z.enum(["ja", "en"]).default("ja").describe("出力言語"),
maxLength: z.number().int().min(50).max(500).default(200).describe("最大文字数"),
});
const SummarizeOutput = z.object({
summary: z.string().describe("生成された要約"),
keyPoints: z.array(z.string()).describe("主要なポイント"),
wordCount: z.number().describe("元テキストの文字数"),
});
export const summarizeFlow = ai.defineFlow(
{
name: "summarizeFlow",
inputSchema: SummarizeInput,
outputSchema: SummarizeOutput,
},
async (input) => {
const { output } = await ai.generate({
model: MODELS.fast,
output: { schema: SummarizeOutput },
prompt: `以下のテキストを${input.language === "ja" ? "日本語" : "英語"}で要約してください。
最大${input.maxLength}文字で、主要なポイントも3つ以内で抽出してください。
テキスト:
${input.text}`,
});
if (\!output) {
throw new Error("LLM からの出力が空でした");
}
return {
...output,
wordCount: input.text.length,
};
}
);
このフローを実行するには、Genkit の開発サーバーを起動します。
# ローカルデバッグ UI を起動(http://localhost:4000)
npx genkit start -- npx ts-node src/index.ts
ブラウザで http://localhost:4000 を開くと、フローへの入力を GUI から試せる開発 UI が表示されます。この UI は実際の LLM 呼び出しのトレースも記録するため、デバッグが格段に楽になります。私が Genkit を使い始めて最初に「これはいい」と思ったのがこの UI です。
エントリポイントの設定
// src/index.ts
import { summarizeFlow } from "./flows/summarize";
// フローをエクスポート(Genkit がフロー一覧を認識するために必要)
export { summarizeFlow };
// 開発環境ではローカルサーバーを起動
if (process.env.NODE_ENV === "development") {
console.log("🚀 Genkit development server starting...");
}
構造化出力とツール連携
実際のプロダクトで AI を使うとき、単純なテキスト生成よりも「特定の形式でデータを取得したい」「外部 API を呼び出して結果を組み込みたい」というケースが多いはずです。
ツール(Function Calling)の定義
Genkit でのツール定義は、Zod スキーマと処理関数を組み合わせた形式です。
// src/tools/search.ts
import { z } from "genkit";
import { ai } from "../genkit";
export const searchProductsTool = ai.defineTool(
{
name: "searchProducts",
description: "商品データベースを検索して、条件に合う商品リストを返します",
inputSchema: z.object({
query: z.string().describe("検索キーワード"),
category: z.string().optional().describe("カテゴリフィルター"),
maxResults: z.number().int().min(1).max(20).default(5),
}),
outputSchema: z.object({
products: z.array(
z.object({
id: z.string(),
name: z.string(),
price: z.number(),
category: z.string(),
})
),
totalCount: z.number(),
}),
},
async (input) => {
// 実際のデータベース呼び出しに置き換える
// エラーハンドリングを必ず含めること
try {
const mockProducts = [
{ id: "prod-001", name: `${input.query} 関連商品A`, price: 1980, category: input.category ?? "general" },
{ id: "prod-002", name: `${input.query} 関連商品B`, price: 3280, category: input.category ?? "general" },
].slice(0, input.maxResults);
return { products: mockProducts, totalCount: mockProducts.length };
} catch (error) {
const message = error instanceof Error ? error.message : "Database error";
throw new Error(`商品検索に失敗しました: ${message}`);
}
}
);
マルチターン会話フローの実装
ショッピングアシスタントのような、会話の文脈を維持するフローの実装例です。
// src/flows/shopping-assistant.ts
import { z } from "genkit";
import { ai, MODELS } from "../genkit";
import { searchProductsTool } from "../tools/search";
const AssistantInput = z.object({
message: z.string().describe("ユーザーのメッセージ"),
conversationHistory: z
.array(
z.object({
role: z.enum(["user", "model"]),
content: z.string(),
})
)
.default([])
.describe("これまでの会話履歴"),
});
const AssistantOutput = z.object({
reply: z.string(),
recommendedProducts: z
.array(z.object({ id: z.string(), name: z.string(), price: z.number() }))
.default([]),
requiresHumanSupport: z.boolean().default(false),
});
export const shoppingAssistantFlow = ai.defineFlow(
{
name: "shoppingAssistantFlow",
inputSchema: AssistantInput,
outputSchema: AssistantOutput,
},
async (input) => {
// 会話履歴を Genkit のメッセージ形式に変換
const history = input.conversationHistory.map((msg) => ({
role: msg.role,
content: [{ text: msg.content }],
}));
const { output } = await ai.generate({
model: MODELS.balanced,
tools: [searchProductsTool],
output: { schema: AssistantOutput },
messages: [
...history,
{ role: "user" as const, content: [{ text: input.message }] },
],
system: `あなたはショッピングアシスタントです。
ユーザーの質問に対して、商品検索ツールを使って適切な商品を提案してください。
回答は常に日本語で、丁寧で親しみやすいトーンで行ってください。
対応できない質問はrequiresHumanSupportをtrueに設定してください。`,
});
if (\!output) {
throw new Error("アシスタントからの応答が空でした");
}
return output;
}
);
ツールを使うフローでは、LLM が「どのツールをいつ呼ぶか」を自律的に判断します。Genkit はこのツール呼び出しのループを自動的に処理してくれるため、関数呼び出しの結果を再度 LLM に渡す処理を自前で実装する必要がありません。
RAGパイプラインの構築
RAG(Retrieval-Augmented Generation)は、外部データベースから関連情報を取得して LLM に渡すパターンです。Genkit では Firestore Vector Search と組み合わせることで、スケーラブルな RAG パイプラインを構築できます。
Firestore Vector Search のセットアップ
# Firebase CLI と Firestore 拡張機能
npm install -g firebase-tools
firebase init firestore
# Genkit Firebase プラグインと Admin SDK
npm install @genkit-ai/firebase firebase-admin
ドキュメントのインデックス化
// src/rag/indexer.ts
import { z } from "genkit";
import { ai } from "../genkit";
import { getFirestore, FieldValue } from "firebase-admin/firestore";
import { initializeApp, getApps } from "firebase-admin/app";
if (\!getApps().length) {
initializeApp();
}
const db = getFirestore();
// テキストをチャンクに分割(オーバーラップ付き)
function chunkText(text: string, chunkSize = 800, overlap = 100): string[] {
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
chunks.push(text.slice(start, end));
start = end - overlap;
if (start >= text.length) break;
}
return chunks;
}
export const indexDocumentFlow = ai.defineFlow(
{
name: "indexDocumentFlow",
inputSchema: z.object({
documentId: z.string(),
content: z.string(),
metadata: z.record(z.string()).default({}),
}),
outputSchema: z.object({
chunksIndexed: z.number(),
status: z.string(),
}),
},
async (input) => {
const chunks = chunkText(input.content);
let chunksIndexed = 0;
for (const chunk of chunks) {
try {
// Gemini のエンベディングモデルでベクトル化
const embeddingResult = await ai.embed({
embedder: "googleai/text-embedding-004",
content: chunk,
});
await db.collection("document_chunks").add({
documentId: input.documentId,
content: chunk,
embedding: FieldValue.vector(embeddingResult.embedding),
metadata: input.metadata,
createdAt: FieldValue.serverTimestamp(),
});
chunksIndexed++;
} catch (error) {
// チャンク単位でエラーをログに記録し、処理を継続する
console.error(`Chunk ${chunksIndexed} indexing failed:`, error);
// 致命的なエラーの場合のみスロー
if (chunksIndexed === 0) {
throw error;
}
}
}
return { chunksIndexed, status: chunksIndexed > 0 ? "success" : "failed" };
}
);
検索・生成フロー
// src/rag/query.ts
import { z } from "genkit";
import { ai, MODELS } from "../genkit";
import { getFirestore, FieldValue } from "firebase-admin/firestore";
const db = getFirestore();
export const ragQueryFlow = ai.defineFlow(
{
name: "ragQueryFlow",
inputSchema: z.object({
question: z.string().min(1),
topK: z.number().int().min(1).max(10).default(3),
}),
outputSchema: z.object({
answer: z.string(),
sources: z.array(
z.object({
documentId: z.string(),
excerpt: z.string(),
relevanceScore: z.number(),
})
),
}),
},
async (input) => {
// 質問文をベクトル化
const queryEmbedding = await ai.embed({
embedder: "googleai/text-embedding-004",
content: input.question,
});
// Firestore Vector Search で類似チャンクを取得
const vectorQuery = db
.collection("document_chunks")
.findNearest("embedding", FieldValue.vector(queryEmbedding.embedding), {
limit: input.topK,
distanceMeasure: "COSINE",
});
const snapshot = await vectorQuery.get();
const sources = snapshot.docs.map((doc) => ({
documentId: doc.data().documentId as string,
excerpt: doc.data().content as string,
relevanceScore: 1 - (doc.data().distance ?? 0),
}));
if (sources.length === 0) {
return {
answer: "関連するドキュメントが見つかりませんでした。",
sources: [],
};
}
// 取得したコンテキストを使って回答生成
const context = sources
.map((s, i) => `[参照${i + 1}] ${s.excerpt}`)
.join("\n\n");
const { output } = await ai.generate({
model: MODELS.balanced,
output: { schema: z.object({ answer: z.string() }) },
prompt: `以下の参照情報をもとに、質問に対して正確かつ簡潔に日本語で答えてください。
参照情報にない内容は「その情報は見つかりませんでした」と答えてください。
参照情報:
${context}
質問: ${input.question}`,
});
return {
answer: output?.answer ?? "回答を生成できませんでした",
sources,
};
}
);
チャンクサイズの設計は RAG の精度に直結します。私の経験では、日本語テキストの場合は英語より小さい 600〜800 文字程度が適切なことが多いです。文字数ベースで設定するためです。オーバーラップを設けることで、チャンク境界での文脈断絶を防ぎます。
ストリーミングレスポンスの実装
チャットインターフェースなど、応答をリアルタイムで表示したいユースケースでは、ストリーミングが不可欠です。Genkit のストリーミングフローは、Server-Sent Events(SSE)での配信と相性がよい設計になっています。
// src/flows/streaming-chat.ts
import { z } from "genkit";
import { ai, MODELS } from "../genkit";
import type { IncomingMessage, ServerResponse } from "http";
const StreamingChatInput = z.object({
message: z.string(),
sessionId: z.string(),
});
export const streamingChatFlow = ai.defineStreamingFlow(
{
name: "streamingChatFlow",
inputSchema: StreamingChatInput,
outputSchema: z.string(),
streamSchema: z.string(),
},
async (input, streamingCallback) => {
const { stream, response } = await ai.generateStream({
model: MODELS.fast,
prompt: input.message,
config: { maxOutputTokens: 2048, temperature: 0.7 },
});
// ストリームチャンクをコールバックで転送
try {
for await (const chunk of stream) {
const text = chunk.text;
if (text) {
streamingCallback(text);
}
}
} finally {
// ストリームを確実に終了させる(メモリリーク防止)
await response.catch(() => {});
}
return (await response).text;
}
);
// Express / Hono での SSE エンドポイント実装
export function createSSEHandler() {
return async (req: IncomingMessage, res: ServerResponse) => {
const body = await parseBody(req);
const input = StreamingChatInput.parse(body);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("Access-Control-Allow-Origin", "*");
try {
const { stream } = streamingChatFlow.stream(input);
for await (const chunk of stream) {
res.write(`data: ${JSON.stringify({ text: chunk })}\n\n`);
}
res.write("data: [DONE]\n\n");
} catch (error) {
const msg = error instanceof Error ? error.message : "Unknown error";
res.write(`data: ${JSON.stringify({ error: msg })}\n\n`);
} finally {
res.end();
}
};
}
async function parseBody(req: IncomingMessage): Promise<unknown> {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => { body += chunk; });
req.on("end", () => {
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
});
req.on("error", reject);
});
}
エラーハンドリングとリトライ戦略
本番環境で最も重要なのは、LLM のエラーやレート制限への対処です。Genkit にはリトライ機能が組み込まれていますが、細かい制御は自前で実装する必要があります。
// src/utils/retry.ts
interface RetryOptions {
maxAttempts: number;
initialDelayMs: number;
maxDelayMs: number;
backoffFactor: number;
}
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
maxAttempts: 3,
initialDelayMs: 1000,
maxDelayMs: 30000,
backoffFactor: 2,
};
// 指数バックオフ付きリトライ
export async function withRetry<T>(
fn: () => Promise<T>,
options: Partial<RetryOptions> = {}
): Promise<T> {
const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
let lastError: Error | null = null;
let delay = opts.initialDelayMs;
for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// リトライ不要なエラーは即時スロー
if (isNonRetryableError(error)) {
throw lastError;
}
if (attempt < opts.maxAttempts) {
console.warn(`Attempt ${attempt} failed: ${lastError.message}. Retrying in ${delay}ms...`);
await sleep(delay);
delay = Math.min(delay * opts.backoffFactor, opts.maxDelayMs);
}
}
}
throw new Error(
`All ${opts.maxAttempts} attempts failed. Last error: ${lastError?.message}`
);
}
function isNonRetryableError(error: unknown): boolean {
if (error instanceof Error) {
const msg = error.message.toLowerCase();
// バリデーション・認証エラーはリトライしない
return (
msg.includes("invalid_argument") ||
msg.includes("unauthenticated") ||
msg.includes("permission_denied")
);
}
return false;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// プライマリ → フォールバックモデルの切り替え
export async function withModelFallback<T>(
primaryFn: () => Promise<T>,
fallbackFn: () => Promise<T>
): Promise<T> {
try {
return await withRetry(primaryFn, { maxAttempts: 2 });
} catch (error) {
console.warn("Primary model failed, switching to fallback:", error);
return await withRetry(fallbackFn, { maxAttempts: 2 });
}
}
よくある間違いと解決策
実際に Genkit を本番で使った際につまずいたポイントを3つ紹介します。
① Zod スキーマと LLM の出力が一致しないエラー
output.schema を指定しても、LLM が期待通りの形式を返さないことがあります。特に z.enum() で選択肢を制限している場合に起きがちです。
// ❌ 問題のある実装:enum が厳格すぎる
const OutputSchema = z.object({
category: z.enum(["tech", "lifestyle", "food"]),
// LLM が "Technology" と返すと ZodError で失敗する
});
// ✅ 正しい実装:transform で正規化する
const OutputSchema = z.object({
category: z.string().transform((val) => {
const normalized = val.toLowerCase();
if (normalized.includes("tech")) return "tech";
if (normalized.includes("life")) return "lifestyle";
if (normalized.includes("food")) return "food";
return "tech"; // デフォルト値
}),
});
② ストリーミングフローのメモリリーク
for await...of でストリームを消費する際、エラー発生時にストリームが適切にクローズされないと、メモリリークが起きることがあります。
// ❌ 問題のある実装
const { stream } = await ai.generateStream({ ... });
for await (const chunk of stream) {
process(chunk); // ここで例外が起きるとストリームが残る
}
// ✅ 正しい実装:try-finally でストリームを確実にクローズ
const { stream, response } = await ai.generateStream({ ... });
try {
for await (const chunk of stream) {
process(chunk);
}
} catch (error) {
console.error("Stream processing error:", error);
throw error;
} finally {
await response.catch(() => {}); // エラーは無視してストリームを終了
}
③ フロー間の循環依存によるモジュールロードエラー
複数のフローが互いをインポートすると、Node.js の循環依存エラーが発生します。フロー定義からツールとユーティリティを分離することで解決できます。
// ❌ 問題のある構造
// flow-a.ts → flow-b.ts をインポート
// flow-b.ts → flow-a.ts をインポート(循環)
// ✅ 正しい構造
// src/tools/shared-tools.ts ← 共有ツールはここに集める
// src/flows/flow-a.ts ← shared-tools のみインポート
// src/flows/flow-b.ts ← shared-tools のみインポート
Firebase App Hosting へのデプロイ
Genkit アプリは Firebase App Hosting(Cloud Run ベース)へ直接デプロイできます。
# apphosting.yaml
runConfig:
cpu: 1
memoryMiB: 512
concurrency: 80
minInstances: 0 # コスト削減のため0から始める
maxInstances: 10
env:
- variable: GOOGLE_AI_API_KEY
secret: google-ai-api-key # Firebase Secret Manager で管理
- variable: NODE_ENV
value: production
// src/server.ts — デプロイ用エントリポイント
import express from "express";
import { summarizeFlow } from "./flows/summarize";
import { ragQueryFlow } from "./rag/query";
const app = express();
app.use(express.json({ limit: "10mb" }));
app.get("/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
app.post("/api/summarize", async (req, res) => {
try {
const result = await summarizeFlow(req.body);
res.json({ success: true, data: result });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
app.post("/api/rag/query", async (req, res) => {
try {
const result = await ragQueryFlow(req.body);
res.json({ success: true, data: result });
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
res.status(500).json({ success: false, error: message });
}
});
const PORT = process.env.PORT ?? 8080;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
デプロイは firebase deploy --only hosting の一コマンドです。App Hosting が自動的に Cloud Run コンテナをビルド・デプロイしてくれます。
Antigravity での開発ワークフロー
Antigravity でこのプロジェクトを開発する際、カスタムルールの設定が効果的です。
# .antigravity/rules.md
## Genkit フロー開発ルール
- Zod スキーマは必ず入出力両方に定義する
- エラーハンドリングには withRetry ユーティリティを使う
- モデルは MODELS 定数から選択する(直接文字列を書かない)
- ストリーミングフローは必ず try-finally を使う
このルールを設定しておくと、Antigravity のエージェントがフロー生成時に自動的にこのパターンを適用してくれます。「新しいフローを書いて」と依頼するだけで、型定義からエラーハンドリングまで含んだ完全なコードが出てきます。
コスト管理の視点
Genkit では、フローごとにモデルを分けることでコストを最適化できます。私が実際に使っている方針は、「即時応答が必要な対話系は gemini-2.0-flash、精度が求められるバッチ処理は gemini-2.5-pro」という使い分けです。フローの設計段階でコストを意識しておくと、後でモデルを変更する際の工数も最小限になります。
Genkit と組み合わせると特に効果的な関連記事として、Antigravity × Gemini API 高度統合マスタークラス(Function Calling・ストリーミング実装)、Firebase × Antigravity MCP サーバー完全ガイド、Antigravity × Supabase 完全活用ガイドもあわせてご参照いただけます。
個人開発者の視点から(実体験メモ)
全体を振り返って
Firebase Genkit は「LLM を使う複雑さを隠蔽しながら、本番で必要な可観測性と型安全性を提供する」という点で、Gemini API を直接叩くよりも開発・運用コストが下がります。Antigravity の補完がフローの型情報を正確に推論してくれるため、初期セットアップのスピードも大幅に改善します。
まず試してほしいのは、ローカルの開発 UI(genkit start)です。自分が書いたフローをブラウザから実行して、LLM の入出力とトレースをリアルタイムで確認できる体験は、「AI 機能のデバッグはつらい」という先入観を変えてくれるはずです。次のステップとして、RAGパイプラインの構築にチャレンジしてみてください。