「Gemini API でチャット機能をつくるのは簡単だけれど、その会話履歴を本番品質で保存しようとした瞬間に詰まる」— この感覚、覚えがある方は多いのではないでしょうか。私自身、個人開発の AI アプリで何度もこの壁にぶつかりました。最初は会話全体を JSON カラムに丸ごと突っ込むのですが、ユーザーが増えるとスキーマ変更で苦しくなり、メッセージの分岐管理を入れた途端に整合性が崩れ、最終的に書き直すことになるのです。一度経験すると分かるのですが、AI チャットのデータ層は普通の CRUD アプリのデータ層とは別物です。
ここでは、Gemini API のマルチターン会話を Drizzle ORM で型安全に保存・検索するための本番設計を、実装コードとともに整理していきます。チャット UI のフロントエンドではなく、その下にある「データ層」をどう設計するかが主題です。Gemini SDK の Content[] 型と Drizzle のスキーマ推論を結びつけ、ストリーミング応答の途中保存、スレッドの分岐管理、Function Calling 履歴の保存と再生、pgvector を使った会話の意味検索、運用コストの計測、そして本番で必ずぶつかる落とし穴の回避まで、自分のアプリに今日から組み込める粒度で扱います。
なぜ AI チャットのデータ層は通常の CRUD と違うのか
通常の CRUD アプリでは、レコードは「作成して、読んで、更新して、削除する」だけで済みます。ところが AI チャットの履歴には、いくつか特殊な性質があります。
まず メッセージは入れ子構造 です。Gemini API の Content は role と parts を持ち、parts には text だけでなく inlineData(画像)、functionCall、functionResponse が混在します。これを「テキストカラムに連結する」設計にすると、画像や Function Calling が登場した瞬間に再設計が必要になります。
次に 会話は分岐します 。ユーザーが「やっぱり前のメッセージから別の方向で生成し直したい」と言ったとき、過去のスレッドを書き換えるのではなく、その時点から新しい枝として保持したいケースが多いのです。Git のコミットツリーに近い構造が要ります。
そして ストリーミング応答は途中で切れます 。本番ではネットワーク切断、レート制限、タイムアウトで応答が中断します。再接続できるように、生成途中の状態をどこまで保存しておくかを決めておく必要があります。
最後に 検索は意味で行いたい 。「あのとき話した API の話、もう一度見たい」のような曖昧な要求に応えるには、テキスト一致ではなく埋め込みベクトルでの類似検索が必要になります。
これらを「あとで考える」と先送りにすると、必ず後で構造の作り直しが発生します。最初から設計に組み込んでおきたいところです。
これらの特殊性に共通しているのは、「会話というドメインの自然な構造を、リレーショナルなテーブルにどう落とし込むか」が設計の中心にあるという点です。私はかつて MongoDB で同じ問題を扱ったことがありますが、JSON 構造をそのまま持てる代わりにトランザクションや結合の柔軟性で苦労しました。Postgres + JSONB という組み合わせは、リレーショナルの強みと半構造化データの柔軟性をバランス良く取れるので、AI チャットのデータ層には特に向いていると感じています。Drizzle ORM はこの組み合わせを TypeScript の世界から無理なく扱える、現時点では数少ない選択肢の一つです。
なぜ Drizzle ORM か — Prisma ではなくこちらを選ぶ理由
私は個人開発のバックエンドで Prisma も Drizzle も両方使ってきました。Gemini API のような型構造が複雑な相手と組み合わせるなら、現時点では Drizzle のほうが扱いやすいというのが正直な感想です。
理由は3つあります。第一に、Drizzle はスキーマ定義から TypeScript の型を直接推論 します。InferSelectModel<typeof messages> のようにテーブルから型を取り出して、それをそのまま API ハンドラの戻り値に使えます。Gemini SDK も TypeScript で型がしっかり定義されているので、両者を結ぶのにキャスト地獄に陥らずに済みます。
第二に、Drizzle のクエリビルダは SQL に近い構文 で書けます。AI 関連のクエリは「ベクトル類似度で並べ替えつつ、ユーザー権限とスレッド可視性で絞り込み、最新 N 件だけ取得する」のような複雑なものが増えがちですが、Prisma の findMany で表現するより SQL 風に書いたほうが見通しが良いのです。
第三に、Drizzle は ランタイムが薄い 。Cloudflare Workers や Vercel Edge Functions のような制約のある環境でも問題なく動きます。Gemini API は Edge から呼びたい場面が多いので、データ層も同じ環境で動かせるのは大きな利点です。
もちろん Prisma にも長所はあります。マイグレーションの自動生成、Studio によるデータ閲覧、Accelerate による接続プールなど、運用ツールが充実しています。チームで運用する規模なら Prisma を選ぶ判断も全く理にかなっています。今回は「個人〜小規模で AI 機能を素早く本番投入したい」というシナリオを前提に Drizzle で組み立てます。
実際の開発では、Drizzle のもう一つの利点として 「どのクエリが実行されるかが SQL レベルで予測しやすい」 ことが挙げられます。Prisma のようにクエリビルダの抽象度が高いと、本番でスローログを確認したときに「このコードがこの SQL になっていたのか」と驚くことがありました。Drizzle は select 句や join の対応関係がほぼそのまま SQL に落ちるので、パフォーマンスチューニングをするときに脳内モデルと実行計画がぴったり一致します。AI チャットのように「履歴 N 件を取りつつ、最新 M 件は別条件で集計する」のような複合クエリを書く場面では、この透明性が大きな安心感に繋がります。
私の場合、Drizzle に切り替えてから本番デプロイ前のクエリレビューが10分の1の時間で終わるようになりました。これは個人開発のように「自分以外にレビュアーがいない」状況では効果が大きく、見落としによる本番障害を減らせる地味だけれど効く改善です。
スキーマ設計 — Gemini の Content 構造をそのまま受け止める
最初に、Gemini API の Content 型を確認しておきます。@google/genai パッケージの型定義を見ると、おおよそ次のような構造です。
// Gemini SDK の型(簡略化)
interface Content {
role : 'user' | 'model' ;
parts : Part [];
}
type Part =
| { text : string }
| { inlineData : { mimeType : string ; data : string } }
| { functionCall : { name : string ; args : Record < string , unknown > } }
| { functionResponse : { name : string ; response : Record < string , unknown > } };
このうち、テキストだけを保存するのは簡単です。問題はマルチモーダルパートと Function Calling です。これらを別テーブルに正規化する設計と、JSON カラムに丸ごと入れる設計のどちらにすべきか、よく相談を受けます。
私のおすすめは 「メッセージ単位は1行、parts は JSONB で保持、ただし検索や統計に使うフィールドだけ展開列にする」 というハイブリッド型です。理由は、parts の構造が将来も Google 側で拡張される可能性が高く、その都度マイグレーションを走らせたくないからです。一方で「画像を含むメッセージだけ抽出したい」「Function Calling が発生したターンだけ集計したい」のような要求は頻発するので、それらだけを has_image、has_function_call のような boolean 列に展開しておくのが現実解です。
Drizzle で書くと次のようになります。
// db/schema.ts
import {
pgTable,
uuid,
text,
timestamp,
jsonb,
boolean,
integer,
index,
} from 'drizzle-orm/pg-core' ;
import { sql } from 'drizzle-orm' ;
// スレッド(会話セッション)
export const threads = pgTable (
'threads' ,
{
id: uuid ( 'id' ). defaultRandom (). primaryKey (),
userId: uuid ( 'user_id' ). notNull (),
title: text ( 'title' ). notNull (). default ( 'New chat' ),
parentMessageId: uuid ( 'parent_message_id' ), // 分岐元(NULL ならルート)
createdAt: timestamp ( 'created_at' , { withTimezone: true })
. notNull ()
. defaultNow (),
updatedAt: timestamp ( 'updated_at' , { withTimezone: true })
. notNull ()
. defaultNow (),
},
( t ) => ({
userIdx: index ( 'threads_user_idx' ). on (t.userId, t.updatedAt),
})
);
// メッセージ
export const messages = pgTable (
'messages' ,
{
id: uuid ( 'id' ). defaultRandom (). primaryKey (),
threadId: uuid ( 'thread_id' )
. notNull ()
. references (() => threads.id, { onDelete: 'cascade' }),
role: text ( 'role' , { enum: [ 'user' , 'model' , 'system' ] }). notNull (),
parts: jsonb ( 'parts' ). notNull (), // Gemini Part[] をそのまま
// ── 検索・集計用に展開する列 ──
textPreview: text ( 'text_preview' ), // 全文検索用に冒頭抜粋
hasImage: boolean ( 'has_image' ). notNull (). default ( false ),
hasFunctionCall: boolean ( 'has_function_call' ). notNull (). default ( false ),
inputTokens: integer ( 'input_tokens' ),
outputTokens: integer ( 'output_tokens' ),
// ── ストリーミング状態 ──
status: text ( 'status' , {
enum: [ 'streaming' , 'completed' , 'failed' , 'cancelled' ],
})
. notNull ()
. default ( 'completed' ),
finishReason: text ( 'finish_reason' ),
// ── タイムスタンプ ──
createdAt: timestamp ( 'created_at' , { withTimezone: true })
. notNull ()
. defaultNow (),
completedAt: timestamp ( 'completed_at' , { withTimezone: true }),
},
( t ) => ({
threadIdx: index ( 'messages_thread_idx' ). on (t.threadId, t.createdAt),
statusIdx: index ( 'messages_status_idx' ). on (t.status),
})
);
export type Message = typeof messages.$inferSelect;
export type NewMessage = typeof messages.$inferInsert;
export type Thread = typeof threads.$inferSelect;
ここで parentMessageId を threads テーブルに置いているのが分岐対応の核心です。新しいスレッドが「あるメッセージから派生した枝」だと記録できれば、UI 側でツリー構造を再構築できます。
parts を jsonb で持つことで、Gemini SDK から受け取った Part[] をそのまま JSON.stringify せずに格納できます。Drizzle は JSONB に対して TypeScript の型を直接付与できるので、parts の中身に型を効かせたい場合は次のように $type を使います。
import type { Part } from '@google/genai' ;
parts : jsonb ( 'parts' ). $type < Part []>(). notNull (),
これで messages.parts の型が Part[] として推論され、フロント・バックエンドのどこから参照しても Gemini SDK と同じ型のまま扱えるようになります。
このハイブリッド型を採用する利点は、スキーマの将来変更コストを線形ではなく対数的に抑えられる ことです。新しい Part 種別が SDK に追加されても parts JSONB はそのまま受け止められますし、検索条件として頻繁に使うものだけを後から展開列に追加すれば良いのです。テーブル定義に列を増やすマイグレーションは、JSONB の中身を書き換えるマイグレーションよりはるかに軽い操作なので、本番でもダウンタイムなしで実行できます。
逆にやってはいけないのが、最初から text_part_1、text_part_2、image_url_1 のように Part 種別ごとにカラムを切ってしまう設計です。これは「Gemini の Part 構造が今のままで凍結している」前提に乗っているのと同じで、実際には Live API、Code Execution、Spatial Understanding と新しい種類が次々に追加されています。スキーマの抽象度を Gemini SDK と同じ粒度に揃えておくのが、結果的に最も保守コストの低い選択になります。
ストリーミング応答を「途中保存」する書き込みパターン
Gemini の generateContentStream を使うと、応答は分割されたチャンクで届きます。本番ではこのチャンクを「クライアントに転送しつつ、サーバー側でも全部蓄積し、最後に DB に書く」のが基本です。ところが、ユーザーが画面を閉じた、ネットワークが切れた、レート制限で途中失敗した、といったケースでは「全部蓄積した後に DB に書く」が成立しません。
私が本番で採用しているのは、メッセージ行を最初に streaming 状態で作り、ストリームが進むたびに parts を更新し、終了時に completed に切り替える パターンです。途中で切れても、streaming 状態のレコードがそのまま残り、後から「中断された会話を再開しますか?」のフローに繋げられます。
// app/api/chat/route.ts
import { GoogleGenAI } from '@google/genai' ;
import { db } from '@/db' ;
import { messages } from '@/db/schema' ;
import { eq } from 'drizzle-orm' ;
export async function POST ( req : Request ) {
const { threadId , userMessage } = await req. json ();
const ai = new GoogleGenAI ({ apiKey: process.env. YOUR_GEMINI_API_KEY ! });
// 1) ユーザーメッセージを即座に保存
await db. insert (messages). values ({
threadId,
role: 'user' ,
parts: [{ text: userMessage }],
textPreview: userMessage. slice ( 0 , 200 ),
status: 'completed' ,
completedAt: new Date (),
});
// 2) モデル応答行を streaming 状態で先に作る
const [ modelRow ] = await db
. insert (messages)
. values ({
threadId,
role: 'model' ,
parts: [],
status: 'streaming' ,
})
. returning ({ id: messages.id });
// 3) 過去の履歴を取得して Gemini に渡す
const history = await db.query.messages. findMany ({
where : ( m , { eq , and , ne }) =>
and ( eq (m.threadId, threadId), eq (m.status, 'completed' )),
orderBy : ( m , { asc }) => asc (m.createdAt),
});
const contents = history. map (( m ) => ({
role: m.role === 'model' ? 'model' : 'user' ,
parts: m.parts as { text : string }[],
}));
// 4) ストリーミング応答を受けながら段階的に保存
const stream = await ai.models. generateContentStream ({
model: 'gemini-2.5-pro' ,
contents,
});
const collected : string [] = [];
let inputTokens = 0 ;
let outputTokens = 0 ;
let finishReason : string | undefined ;
const transformStream = new TransformStream ({
async transform ( chunk : any , controller ) {
const text = chunk.text ?? '' ;
if (text) {
collected. push (text);
controller. enqueue ( new TextEncoder (). encode (text));
}
if (chunk.usageMetadata) {
inputTokens = chunk.usageMetadata.promptTokenCount ?? inputTokens;
outputTokens = chunk.usageMetadata.candidatesTokenCount ?? outputTokens;
}
if (chunk.candidates?.[ 0 ]?.finishReason) {
finishReason = chunk.candidates[ 0 ].finishReason;
}
// 5秒ごとに途中保存(チャンク数で判定)
if (collected. length % 20 === 0 ) {
await db
. update (messages)
. set ({ parts: [{ text: collected. join ( '' ) }] })
. where ( eq (messages.id, modelRow.id));
}
},
async flush () {
// 終了時に最終状態を確定
const fullText = collected. join ( '' );
await db
. update (messages)
. set ({
parts: [{ text: fullText }],
textPreview: fullText. slice ( 0 , 200 ),
inputTokens,
outputTokens,
status: 'completed' ,
finishReason: finishReason ?? 'STOP' ,
completedAt: new Date (),
})
. where ( eq (messages.id, modelRow.id));
},
});
return new Response (
(stream as any ).readable. pipeThrough (transformStream),
{ headers: { 'Content-Type' : 'text/plain; charset=utf-8' } }
);
}
このパターンの肝は、書き込み頻度を「20チャンクごと」「最終時」の二段構え にしている点です。1チャンクごとに書くと DB 負荷が跳ね上がりますし、最後にしか書かないと中断時に何も残りません。20という数字は経験則で、Gemini 2.5 Pro なら1秒あたり数回の更新になり、Postgres に負担をかけずに済みます。
このパターンを採用したことで、本番で ありがちな「途中で切れたメッセージが Loading のまま消えない」現象がほぼゼロになりました。以前は障害対応で1日に何件もこのチケットが上がってきていたのですが、status 列での状態管理を入れた途端に「まずデータベースで状態を見る → そこから判断する」という流れが定着し、原因の切り分けも一段階で済むようになります。設計の小さな違いが、運用負荷に直接効いてくる実例です。
なお、ストリーミング途中保存のタイミングは「チャンク数」だけでなく「経過秒数」を併用するのが理想です。Gemini の応答速度はモデルや時間帯で揺らぎます。Date.now() を保持しておき「直前の保存から3秒以上経過していたら書く」を加えると、応答が遅い時間帯でも保存間隔がブレません。本番では PostgreSQL の pg_stat_statements で書き込み頻度を観察し、コストとリスクのバランス点を探ると良いでしょう。
中断検知は別の経路で行います。Edge ランタイムなら req.signal.aborted をリッスンし、abort されたら status を cancelled に更新します。Node ランタイムでも同様です。これを忘れると streaming 状態のレコードが残ったままになり、後の「未完了メッセージを表示」ロジックで誤動作します。
スレッド分岐 — Git のブランチに似た構造で履歴を保つ
「3つ前のメッセージから別方向に話を進めたい」というユースケースに対応するには、スレッドを fork する 設計にします。新しいスレッドを作りつつ、その parentMessageId に分岐元のメッセージ ID を記録するのです。
// lib/forkThread.ts
import { db } from '@/db' ;
import { threads, messages } from '@/db/schema' ;
import { eq, lte } from 'drizzle-orm' ;
export async function forkThread ( params : {
sourceThreadId : string ;
forkAtMessageId : string ;
userId : string ;
}) {
// 1) 分岐元メッセージを取得
const sourceMessage = await db.query.messages. findFirst ({
where : ( m , { eq }) => eq (m.id, params.forkAtMessageId),
});
if ( ! sourceMessage) throw new Error ( 'Source message not found' );
// 2) 新しいスレッドを作成(parentMessageId に分岐元を記録)
const [ newThread ] = await db
. insert (threads)
. values ({
userId: params.userId,
title: 'Forked chat' ,
parentMessageId: params.forkAtMessageId,
})
. returning ();
// 3) 分岐点までのメッセージを新スレッドに複製
const ancestors = await db.query.messages. findMany ({
where : ( m , { and , eq , lte }) =>
and (
eq (m.threadId, params.sourceThreadId),
lte (m.createdAt, sourceMessage.createdAt)
),
orderBy : ( m , { asc }) => asc (m.createdAt),
});
if (ancestors. length > 0 ) {
await db. insert (messages). values (
ancestors. map (( m ) => ({
threadId: newThread.id,
role: m.role,
parts: m.parts,
textPreview: m.textPreview,
hasImage: m.hasImage,
hasFunctionCall: m.hasFunctionCall,
inputTokens: m.inputTokens,
outputTokens: m.outputTokens,
status: m.status,
finishReason: m.finishReason,
completedAt: m.completedAt,
}))
);
}
return newThread;
}
複製するか、参照だけ持つかは設計判断です。複製すると履歴の編集が独立して行えますが、ストレージコストが線形に増えます。参照方式(分岐元スレッドの最初の N 件は元スレッドから読む)はストレージは節約できますが、クエリが複雑になります。私は 「分岐は頻繁ではない」前提で複製方式 を推します。それが破綻するほどの規模になったら、その時点で参照方式に切り替えれば良いのです。設計の単純さを優先するという判断です。
実装してみると分かるのですが、分岐機能は「あったら便利」ではなく「ないと不便」な機能です。Gemini に長文を生成させたとき、最後の段落だけやり直したいという需要は驚くほど多く、これがないとユーザーは長い文章を一度生成し直す羽目になります。Drizzle で parentMessageId を持たせておけば、UI 側で「この時点から別の選択肢を試す」ボタンを出すだけで、データベース構造を変えずに分岐機能を提供できるようになります。
ストレージコストが心配になる規模になったら、分岐スレッドの「親メッセージへの参照」だけを残し、メッセージ自体は親スレッドから読み出す方式に切り替えます。その時点では既に十分な利用状況データが集まっているはずなので、どの粒度で分離すべきかの判断が経験則だけでなく数字でできるようになります。最初から完璧を目指すより、運用データに基づいて最適化する順序のほうが、私の経験では失敗が少ない選択でした。
Function Calling 履歴の保存と再生
Function Calling を含む会話を保存するときに詰まりやすいのが、functionResponse を次のターンの contents に含めないと Gemini が文脈を見失う ことです。これは Gemini の API 仕様で、functionCall と functionResponse がペアで履歴に存在しないと、次のリクエストが「直前のツール呼び出しが何だったか分からない」状態になります。
スキーマ側では特別なことは不要で、先ほどの parts: jsonb('parts').$type<Part[]>() がそのまま functionCall や functionResponse を受け取れます。問題は再生時で、履歴を Gemini に渡すときに、role の扱いを間違えやすいのです。
// lib/replayHistory.ts
import type { Content, Part } from '@google/genai' ;
export function rebuildContents ( messages : { role : string ; parts : Part [] }[]) : Content [] {
return messages. map (( m ) => {
// functionResponse を含むメッセージは role: 'user' として扱う
// (Gemini の仕様上、ツール応答はユーザー側からのコンテキストとして渡す)
const hasFunctionResponse = m.parts. some (
( p ) => 'functionResponse' in p
);
const role = hasFunctionResponse ? 'user' : m.role === 'model' ? 'model' : 'user' ;
return { role, parts: m.parts };
});
}
このロールの読み替えを忘れると、Gemini が "Invalid role for functionResponse" のようなエラーを返します。私が初めて Function Calling を本番投入したとき、まさにこのパターンで2時間ほど詰まりました。スキーマには現れないが、再生ロジックには必須の知識です。
もう一つ実装で詰まりやすいのが、Function Calling の呼び出しと応答が「ペアであるべき」のに、応答が失敗したケースの扱い です。たとえば外部 API の呼び出しがタイムアウトしたとき、functionCall だけが履歴に残り functionResponse が記録されないと、次のターンで Gemini が「ツールの結果待ち」状態のまま新しい応答を返そうとして混乱します。私のおすすめは、ツール呼び出しが失敗しても必ず functionResponse に { error: "..." } を入れて履歴に残す ことです。Gemini はエラー応答を受け取ると、それを踏まえて「もう一度試しますか?」「別の方法で対応しましょうか?」のような自然な提案をしてくれます。エラーをモデルに伝えることで、ユーザー体験が一段良くなる珍しい例です。
これに加えて、Function Calling の args には機密情報を含めないように設計するのも本番では大切です。args はそのまま JSONB として保存され、ログにも残ります。ユーザーの個人情報をツールの引数で渡すと、ログ閲覧の権限を持つ全員にアクセスされうる状態になります。設計レベルで「IDだけ渡してツール側で取得する」「機密項目は別の暗号化されたチャネルで処理する」のような方針を立てておくと、後から「あれ、この情報が平文で残ってしまっている」という事故を防げます。
pgvector で意味検索可能なチャットアーカイブにする
過去の会話を「キーワード一致」ではなく「意味の近さ」で検索したいときは、各メッセージの埋め込みベクトルを別テーブルに保持します。Postgres なら pgvector 拡張が定番で、Drizzle にも公式コミュニティの pgvector 型サポートが揃っています。pgvector の運用に踏み込みたい場合はGemini API × pgvector で構築する意味検索エンジン本番ガイドも合わせて参考になります。
// db/schema-vector.ts
import { pgTable, uuid, integer, index } from 'drizzle-orm/pg-core' ;
import { vector } from 'drizzle-orm/pg-core' ; // drizzle-orm v0.30+
import { messages } from './schema' ;
export const messageEmbeddings = pgTable (
'message_embeddings' ,
{
messageId: uuid ( 'message_id' )
. primaryKey ()
. references (() => messages.id, { onDelete: 'cascade' }),
embedding: vector ( 'embedding' , { dimensions: 768 }). notNull (),
model: text ( 'model' ). notNull (). default ( 'gemini-embedding-001' ),
},
( t ) => ({
embeddingIdx: index ( 'embedding_hnsw_idx' ). using (
'hnsw' ,
t.embedding. op ( 'vector_cosine_ops' )
),
})
);
埋め込み生成は Gemini の Embedding API を呼びます。メッセージが completed になったタイミングで非同期に処理するのが理想です。
// lib/embedMessage.ts
import { GoogleGenAI } from '@google/genai' ;
import { db } from '@/db' ;
import { messageEmbeddings } from '@/db/schema-vector' ;
const ai = new GoogleGenAI ({ apiKey: process.env. YOUR_GEMINI_API_KEY ! });
export async function embedAndStore ( messageId : string , text : string ) {
if ( ! text. trim ()) return ;
try {
const result = await ai.models. embedContent ({
model: 'gemini-embedding-001' ,
contents: text. slice ( 0 , 8000 ), // 入力長を制限
});
const embedding = result.embeddings?.[ 0 ]?.values;
if ( ! embedding) return ;
await db
. insert (messageEmbeddings)
. values ({ messageId, embedding })
. onConflictDoUpdate ({
target: messageEmbeddings.messageId,
set: { embedding },
});
} catch (err) {
// 埋め込み失敗はメイン応答をブロックしない
console. error ( 'Embedding failed:' , messageId, err);
}
}
検索クエリは Drizzle の SQL テンプレートで書きます。
import { sql } from 'drizzle-orm' ;
export async function searchSimilarMessages (
queryEmbedding : number [],
userId : string ,
limit = 10
) {
return db
. select ({
messageId: messageEmbeddings.messageId,
threadId: messages.threadId,
preview: messages.textPreview,
similarity: sql < number > `1 - (${ messageEmbeddings . embedding } <=> ${ queryEmbedding }::vector)` ,
})
. from (messageEmbeddings)
. innerJoin (messages, eq (messages.id, messageEmbeddings.messageId))
. innerJoin (threads, eq (threads.id, messages.threadId))
. where ( eq (threads.userId, userId))
. orderBy ( sql `${ messageEmbeddings . embedding } <=> ${ queryEmbedding }::vector` )
. limit (limit);
}
<=> はコサイン距離演算子で、1 - 距離 を類似度として返しています。HNSW インデックスを張っておけば、数十万メッセージ規模でも数十ミリ秒で結果が返ります。本番ではユーザー権限で必ず絞り込むことを忘れずに(上の例では threads.userId でフィルタしています)。
ベクトル検索を本番に入れて気づいたのは、「埋め込みは生成タイミングを工夫するだけで、コストとレスポンスの両方が大きく変わる」 ことです。私は最初、メッセージが届いたらすぐに同期で埋め込みを生成していたのですが、これだとユーザーへの応答時間が伸びてしまいました。応答自体は先にユーザーに返し、埋め込み生成は Cloudflare Workers の Queue に積んで非同期で処理する構成に変えてから、体感速度が明らかに良くなりました。
もう一つ重要なのが 再埋め込みの戦略 です。Gemini の埋め込みモデルは進化していくので、半年〜1年に一度は全メッセージを新モデルで再生成したくなります。messageEmbeddings テーブルに model 列を持たせているのはこのためで、過去のモデルで生成したベクトルを段階的に置き換えるバックフィルジョブを書くのが楽になります。pgvector のインデックスはモデルが混ざっていても動きますが、検索精度を保つためには同じモデルで生成したベクトル同士で比較するのが原則です。
マイグレーション戦略 — parts の構造変化に備える
Drizzle のマイグレーションは drizzle-kit generate で自動生成できますが、AI チャットのスキーマで悩ましいのは parts: jsonb の中身は型変換が DB に伝わらない 点です。たとえば「text フィールドを content に名前変更したい」と思っても、JSONB の中身までは drizzle-kit は変換してくれません。
私のおすすめは、parts には常に Gemini SDK の最新型をそのまま入れておき、変換が必要なときはアプリケーション層で読み出し時にマッピングする 方針です。DB マイグレーションで JSONB の中身を書き換えると、何百万行のフルスキャンが走り、本番が止まります。
ただし、新しい Part 種別が SDK に追加されたときは、hasImage などの展開列も更新したいことがあります。その場合は、新しい Part 種別が初めて書き込まれたタイミング以降の行だけを対象にバックフィルジョブを走らせます。
// scripts/backfill-has-video.ts
import { db } from '@/db' ;
import { messages } from '@/db/schema' ;
import { sql } from 'drizzle-orm' ;
await db. execute ( sql `
UPDATE messages
SET has_video = true
WHERE has_video = false
AND parts::text LIKE '%"mimeType":"video/%'
AND created_at >= NOW() - INTERVAL '30 days'
` );
LIKE で JSONB を文字列マッチングしているのは意図的です。jsonb_path_exists は柔軟ですが遅いので、単純な存在判定なら LIKE のほうが速いことが多いのです。
本番でハマりやすい3つの落とし穴
1. parts の JSON サイズが Postgres の TOAST 閾値を超えてクエリが遅くなる
Postgres は1行が 2KB を超えると外部ストレージ(TOAST)に退避します。長文応答や複数の inlineData(画像 base64)を含む parts は簡単にこの閾値を超え、SELECT * が突然遅くなります。
対策は2つあります。第一に、画像の inlineData は別テーブルに分離する 。ストレージは S3 や R2 に上げて、parts には URL だけ残します。第二に、一覧表示用のクエリでは parts を SELECT しない 。textPreview だけで一覧を作り、詳細表示で初めて parts を取得します。
2. ストリーミング途中の中断レコードが溜まり続ける
status: 'streaming' のままになったレコードは、サーバー再起動やデプロイ中のリクエスト途絶で必ず発生します。これを放置すると、ユーザーの履歴に「ロード中…」が永遠に残ります。
対策は 定期クリーンアップジョブ です。status: 'streaming' かつ created_at が10分以上前のレコードを failed に切り替えるバッチを cron で5分おきに走らせます。Cloudflare Workers の Cron Triggers や Vercel Cron で十分実装できます。
await db
. update (messages)
. set ({ status: 'failed' , finishReason: 'TIMEOUT' })
. where (
and (
eq (messages.status, 'streaming' ),
lte (messages.createdAt, new Date (Date. now () - 10 * 60 * 1000 ))
)
);
3. Drizzle のリレーショナルクエリで with を多用すると N+1 が発生する
Drizzle の db.query.threads.findMany({ with: { messages: true } }) は便利ですが、内部で複数クエリに展開される場合があります。スレッド一覧 + 各スレッドの最終メッセージを取りたいときは、明示的に JOIN + LATERAL サブクエリを書いたほうが速いことが多いです。
const threadsWithLastMessage = await db. execute ( sql `
SELECT t.*, m.text_preview AS last_preview, m.created_at AS last_message_at
FROM threads t
LEFT JOIN LATERAL (
SELECT text_preview, created_at
FROM messages
WHERE thread_id = t.id AND status = 'completed'
ORDER BY created_at DESC
LIMIT 1
) m ON true
WHERE t.user_id = ${ userId }
ORDER BY t.updated_at DESC
LIMIT 50
` );
Drizzle はこのような生 SQL も型ヒント付きで書けるので、抽象化と素の SQL を場面で使い分けるのが本番向きです。Gemini の構造化出力をスキーマレベルで縛りたい場合はGemini API × Pydantic で型安全な構造化出力を本番品質にするが参考になります。
トークン使用量と原価の計測 — 1ユーザーあたりのコストを把握する
AI チャットを本番運用すると、「誰がいくら使っているのか」が即座に分からない というのが地味に効いてきます。月末に Google Cloud の請求が想定の3倍だった、という事故は珍しくありません。Drizzle のスキーマで inputTokens と outputTokens を持っているのは、この事故を予防するためでもあります。
ユーザーごとの月次コストを集計するクエリは、SQL 一発で書けます。実コストをさらに下げたい場合、入力プロンプトのキャッシュ戦略を組み合わせると効果が大きく、Gemini API のコンテキストキャッシュでコストを最適化する に詳細があります。
// lib/usage-report.ts
import { sql, gte, eq, and } from 'drizzle-orm' ;
import { db } from '@/db' ;
import { messages, threads } from '@/db/schema' ;
// Gemini 2.5 Pro の例(USD per 1M tokens、2026年5月時点の概算)
const PRICE_PER_M_TOKENS = { input: 1.25 , output: 5.0 };
export async function userMonthlyCost ( userId : string , monthStart : Date ) {
const result = await db
. select ({
inputTokens: sql < number > `SUM(${ messages . inputTokens })::int` ,
outputTokens: sql < number > `SUM(${ messages . outputTokens })::int` ,
messageCount: sql < number > `COUNT(*)::int` ,
})
. from (messages)
. innerJoin (threads, eq (threads.id, messages.threadId))
. where (
and (
eq (threads.userId, userId),
eq (messages.role, 'model' ),
gte (messages.createdAt, monthStart)
)
);
const row = result[ 0 ];
const inputUsd = (row.inputTokens / 1_000_000 ) * PRICE_PER_M_TOKENS .input;
const outputUsd = (row.outputTokens / 1_000_000 ) * PRICE_PER_M_TOKENS .output;
return {
inputTokens: row.inputTokens ?? 0 ,
outputTokens: row.outputTokens ?? 0 ,
messageCount: row.messageCount ?? 0 ,
estimatedUsd: Number ((inputUsd + outputUsd). toFixed ( 4 )),
};
}
このクエリを毎日 cron で回し、ユーザーごとの累積コストを別テーブルに保存しておけば、「フリープランで月 $2 を超えたユーザーには使用上限を表示」「有料プランの上位 1% を抽出して使用感をヒアリング」のような運用が無理なく回ります。inputTokens を usageMetadata から取り損ねると後追いで計算ができなくなるので、ストリーミング保存時に必ず確定させておくのが重要です。
テスト戦略 — Gemini を呼ばずにデータ層をテストする
Drizzle の良いところの一つは、テストで Gemini API を呼ばずにデータ層単体をテストしやすい 点です。Gemini SDK の戻り値を真似た fixture を作り、それを直接 messages テーブルに書き込んでスレッド構造の検証をします。
// __tests__/thread-fork.test.ts
import { describe, it, expect, beforeEach } from 'vitest' ;
import { db } from '@/db' ;
import { threads, messages } from '@/db/schema' ;
import { forkThread } from '@/lib/forkThread' ;
describe ( 'forkThread' , () => {
beforeEach ( async () => {
await db. delete (messages);
await db. delete (threads);
});
it ( '複製方式で分岐元までのメッセージを引き継ぐ' , async () => {
const userId = '00000000-0000-0000-0000-000000000001' ;
const [ thread ] = await db
. insert (threads)
. values ({ userId, title: 'Source' })
. returning ();
const inserted = await db
. insert (messages)
. values ([
{ threadId: thread.id, role: 'user' , parts: [{ text: 'Q1' }] },
{ threadId: thread.id, role: 'model' , parts: [{ text: 'A1' }] },
{ threadId: thread.id, role: 'user' , parts: [{ text: 'Q2' }] },
{ threadId: thread.id, role: 'model' , parts: [{ text: 'A2' }] },
])
. returning ();
const fork = await forkThread ({
sourceThreadId: thread.id,
forkAtMessageId: inserted[ 1 ].id, // A1 までを引き継ぐ
userId,
});
const forkMessages = await db.query.messages. findMany ({
where : ( m , { eq }) => eq (m.threadId, fork.id),
orderBy : ( m , { asc }) => asc (m.createdAt),
});
expect (forkMessages. length ). toBe ( 2 );
expect (fork.parentMessageId). toBe (inserted[ 1 ].id);
});
});
CI では Postgres を Docker で起動して drizzle-kit push でスキーマを当て、テストを流す構成にしておくと、スキーマ変更とコード変更が必ず同時にレビューされるようになります。本番では、Gemini を実際に呼ぶ E2E テストは別レイヤーに分離し、デプロイ前のスモークテストとして1日1回だけ走らせるくらいで十分です。
認可とマルチテナント分離 — 漏れは「アプリ層 + DB 層」の二重防御で
AI チャットの履歴は機微情報を含みます。プロンプトインジェクションでユーザーが「他人のチャットを見せて」と頼むケースもあり、認可を1か所だけで担保するのは危険です。私が必ず採用しているのは、アプリ層のクエリで userId フィルタを必須にしつつ、DB 層では Row Level Security(RLS)を有効化 する二重防御です。
-- migrations/0002_enable_rls.sql
ALTER TABLE threads ENABLE ROW LEVEL SECURITY ;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY ;
CREATE POLICY threads_owner_only ON threads
FOR ALL
USING (user_id = current_setting( 'app.current_user_id' , true)::uuid);
CREATE POLICY messages_via_thread ON messages
FOR ALL
USING (
thread_id IN (
SELECT id FROM threads
WHERE user_id = current_setting( 'app.current_user_id' , true)::uuid
)
);
Drizzle 側ではリクエストごとにセッション変数をセットしてからクエリを発行します。
// lib/db-with-user.ts
import { db } from '@/db' ;
import { sql } from 'drizzle-orm' ;
export async function withUser < T >( userId : string , fn : () => Promise < T >) : Promise < T > {
await db. execute ( sql `SET LOCAL app.current_user_id = ${ userId }` );
return fn ();
}
// 使い方
await withUser (req.userId, async () => {
return db.query.threads. findMany ({ /* userId フィルタ不要 */ });
});
アプリ層の WHERE 条件を書き忘れても、RLS が漏れを止めます。逆に、RLS だけに頼ると SET LOCAL を忘れた瞬間に全件取れてしまうので、両方必要です。コードレビューで userId フィルタの有無を見るより、CI で RLS が有効か検証するほうがミスを減らせます。
全体を振り返って — 次の一歩
ここまで読んでくださってありがとうございます。AI チャットの本番データ層は、最初の設計でほぼ将来の運用負荷が決まる領域だと感じています。今日からできる具体的な一歩としては、まず ローカルの Postgres を起動して、上記の messages スキーマだけを Drizzle で実装し、自分の Gemini 実装と繋いでみる ことをおすすめします。parts を JSONB で持つ感覚が掴めたら、ストリーミング途中保存、分岐、pgvector の順で1日ずつ足していけば、無理なく本番品質まで育てられます。
Gemini API のチャット機能は、フロント側のストリーミング UI に目が行きがちですが、実は「データ層をきちんと設計したかどうか」がプロダクトの寿命を決めます。Drizzle ORM の型推論を信じて、any を一切書かない AI バックエンドを組み立ててみてください。一度この感触を覚えると、次に新機能を追加するときの安心感が全く違います。
スキーマ設計の判断軸が体系的にまとまっており、今回のような「JSONB と展開列のバランス」をどう判断すべきか考えるときの基礎になります。