はじめに
前回の、Gemini 2.5 Flash Preview TTS を試しました。
今回は OpenAI Realtime API を使って、社内のナレッジベースから回答を返すボイスボットを作りました。RAG(Retrieval-Augmented Generation)で検索した結果をもとに回答する仕組みです。
動画では、挨拶の後に「MVP制度について教えて」と聞いて、回答の途中で「ありがとうございました」と割り込んでいます。このような自然な会話の割り込みができるのが Realtime API の特徴です。
アーキテクチャ
全体の構成は以下のとおりです。
ポイントは Function Calling で RAG 検索を呼び出している ところです。ユーザーが質問すると、GPT が「ナレッジベースを検索したほうがいい」と判断して search_knowledge を呼び出します。検索結果を受け取ったら、それをもとに回答を生成する流れです。
RAG には Dify を使用しました。ナレッジベースの構築が容易で、API でシンプルに呼び出せます。
実装
Next.js 16(App Router)で実装しています。
セッショントークンの発行
Realtime API に接続するには、まずサーバー側でエフェメラルトークンを発行します。API キーをクライアントに露出させないためです。
// src/app/api/session/route.ts
import { NextResponse } from "next/server";
import OpenAI from "openai";
export async function POST() {
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const session = await openai.beta.realtime.sessions.create({
model: "gpt-realtime-2025-08-28",
voice: "sage",
});
return NextResponse.json(session);
}
WebRTC で接続
クライアント側では WebRTC で接続します。
async connect(): Promise<void> {
// トークン取得
const tokenResponse = await fetch('/api/session', { method: 'POST' });
const session = await tokenResponse.json();
const ephemeralKey = session.client_secret.value;
// PeerConnection 作成
this.peerConnection = new RTCPeerConnection();
// 音声再生の設定
this.audioElement = document.createElement('audio');
this.audioElement.autoplay = true;
this.peerConnection.ontrack = (event) => {
this.audioElement.srcObject = event.streams[0];
};
// マイク入力
this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.mediaStream.getTracks().forEach((track) => {
this.peerConnection.addTrack(track, this.mediaStream);
});
// データチャネル(イベント送受信用)
this.dataChannel = this.peerConnection.createDataChannel('oai-events');
// SDP のやりとり
const offer = await this.peerConnection.createOffer();
await this.peerConnection.setLocalDescription(offer);
const sdpResponse = await fetch(
`https://api.openai.com/v1/realtime?model=gpt-realtime-2025-08-28`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${ephemeralKey}`,
'Content-Type': 'application/sdp',
},
body: offer.sdp,
}
);
const answerSdp = await sdpResponse.text();
await this.peerConnection.setRemoteDescription({
type: 'answer',
sdp: answerSdp,
});
}
WebRTC の接続順序には注意が必要です。createOffer() → setLocalDescription() → OpenAI に送信 → setRemoteDescription() の順番を守らないと接続できません。
セッション設定と Function Calling
接続できたら、セッションの設定を送ります。ここで RAG 検索用のツールを定義します。
this.dataChannel.onopen = () => {
this.sendEvent({
type: "session.update",
session: {
instructions: `You are a helpful assistant with access to a knowledge base.
- 日本語で応答してください
- ユーザーの質問に答える際は、まず search_knowledge ツールを使って関連情報を検索してください
- 検索結果を元に、正確で具体的な回答を提供してください
- 回答は簡潔に、要点のみを伝えてください`,
// Whisper で文字起こし
input_audio_transcription: {
model: "whisper-1",
},
// Server VAD の設定
turn_detection: {
type: "server_vad",
threshold: 0.8,
prefix_padding_ms: 800,
silence_duration_ms: 1200,
},
// RAG 検索用のツール
tools: [
{
type: "function",
name: "search_knowledge",
description: `社内の経理・総務に関するナレッジベースを検索します。
検索対象:就業規則、社内ルール、経費精算、勤怠管理、福利厚生、各種申請手続きなど。`,
parameters: {
type: "object",
properties: {
query: {
type: "string",
description: "ユーザーの質問",
},
},
required: ["query"],
},
},
],
},
});
};
Function Calling の処理
search_knowledge が呼ばれたら Dify API で検索して、結果を返します。
private async handleFunctionCall(event: Record<string, unknown>): Promise<void> {
const functionName = event.name as string;
const callId = event.call_id as string;
const args = JSON.parse(event.arguments as string);
if (functionName === 'search_knowledge') {
const response = await fetch('/api/dify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: args.query,
conversationId: this.difyConversationId,
}),
});
const data = await response.json();
this.difyConversationId = data.conversationId;
// 結果を OpenAI に返す
this.sendEvent({
type: 'conversation.item.create',
item: {
type: 'function_call_output',
call_id: callId,
output: JSON.stringify({
success: true,
result: data.answer,
}),
},
});
this.sendEvent({ type: 'response.create' });
}
}
Dify API のプロキシ
バックエンドで Dify を呼び出すシンプルなプロキシです。
// src/app/api/dify/route.ts
export async function POST(request: NextRequest) {
const { query, conversationId } = await request.json();
const response = await fetch("https://api.dify.ai/v1/chat-messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DIFY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
inputs: {},
query,
response_mode: "blocking",
conversation_id: conversationId || "",
user: "voice-assistant-user",
}),
});
const data = await response.json();
return NextResponse.json({
answer: data.answer,
conversationId: data.conversation_id,
});
}
工夫した点
Server VAD のチューニング
デフォルト設定では感度が高すぎて、小さな音でも発話と判定されることがありました。閾値を上げて、沈黙判定も長めに設定することで改善しています。
turn_detection: {
type: 'server_vad',
threshold: 0.8, // デフォルト 0.5 → 0.8
prefix_padding_ms: 800,
silence_duration_ms: 1200, // 長めに設定して途切れ防止
}
固有名詞の扱い
RAG で検索するとき、固有名詞(制度名、部署名など)が正確に伝わらないと検索精度が落ちます。プロンプトで「聞き取ったとおりに入力する」よう明示的に指示しました。
description: `社内の経理・総務に関するナレッジベースを検索します。
必ずユーザーの発話をそのまま正確にqueryに入力してください。
固有名詞や制度名は聞き取ったとおりに入力することが重要です。`;
会話 ID の保持
Dify の conversation_id を保持しておくと、「さっきの件について詳しく」といったフォローアップ質問にも対応できます。
ハマった点
データチャネルの状態確認
イベントを送る前にデータチャネルが開いているか確認しないとエラーになります。
sendEvent(event: Record<string, unknown>): void {
if (this.dataChannel?.readyState === 'open') {
this.dataChannel.send(JSON.stringify(event));
}
}
マイクのミュート
接続中にマイクをミュート/アンミュートするには、トラックの enabled を操作します。
setMuted(muted: boolean): void {
this.isMuted = muted;
if (this.mediaStream) {
this.mediaStream.getTracks().forEach((track) => {
track.enabled = !muted;
});
}
}
Gemini TTS との使い分け
前回の Gemini TTS と比較すると、それぞれ得意な領域が異なります。
| Realtime API | Gemini TTS | |
|---|---|---|
| レイテンシ | 低遅延 | やや遅め |
| 割り込み | 可能 | 不可 |
| 感情表現 | 標準的 | 演技指導可能 |
| RAG 統合 | Function Calling で容易 | 自前で実装 |
即応性が重要な場面では Realtime API、表現力が求められる場面では Gemini TTS という使い分けになりそうです。社内問い合わせ対応のような用途では、Realtime API が適していると感じました。
料金について
Realtime API の難点として、料金がかなり高いことが挙げられます。
料金表(1M トークンあたり)
Text tokens
| 種別 | 価格 |
|---|---|
| Input | $4.00 |
| Cached input | $0.40 |
| Output | $16.00 |
Audio tokens
| 種別 | 価格 |
|---|---|
| Input | $32.00 |
| Cached input | $0.40 |
| Output | $64.00 |
実際どのくらいかかるか
簡単な挨拶のやりとりでどの程度の料金になるか試算してみます。
会話例
ユーザー:「こんにちは」
GPT:「こんにちは、お話しできてうれしいです。何かお手伝いできることがあれば、遠慮なく教えてくださいね。」
試算(音声トークンで概算)
| 項目 | トークン数(目安) | 単価 | 金額 |
|---|---|---|---|
| Audio Input(ユーザー発話) | 約 50 tokens | $32.00 / 1M | $0.0016 |
| Audio Output(GPT 応答) | 約 200 tokens | $64.00 / 1M | $0.0128 |
| 合計 | 約 $0.015(約 2.3 円) |
たった一往復の挨拶で約 2 円。10 分程度の会話になると数十円〜100 円以上になることも珍しくありません。
社内向けの検証用途であれば許容範囲かもしれませんが、本番サービスとして大規模に展開する場合はコスト面での検討が必要です。
まとめ
OpenAI Realtime API と RAG を組み合わせて、社内ナレッジに答えるボイスボットを作りました。
実装してみて感じたのは以下の点です。
- 低遅延は体験を大きく変える: 数秒のラグでも会話では気になる
- 割り込みができることの重要性: 長い回答を途中で止められる
- Function Calling との相性の良さ: RAG との連携が自然にできる
今後は複数のナレッジベースの切り替えや、音声による申請実行などにも拡張していきたいと考えています。