はじめに 前回の、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 キーをクライアントに露出させないためです。
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 ; 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' ) ; 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 ツールを使って関連情報を検索してください - 検索結果を元に、正確で具体的な回答を提供してください - 回答は簡潔に、要点のみを伝えてください ` , input_audio_transcription : { model : "whisper-1" , } , turn_detection : { type : "server_vad" , threshold : 0.8 , prefix_padding_ms : 800 , silence_duration_ms : 1200 , } , 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 ; 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 を呼び出すシンプルなプロキシです。
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 , 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 という使い分けになりそうです。社内問い合わせ対応のような用途では、Realtime API が適していると感じました。
料金について Realtime API の難点として、 料金がかなり高い ことが挙げられます。
料金表(1M トークンあたり) ■ Text tokens
■ Audio tokens
■ 実際どのくらいかかるか
簡単な挨拶のやりとりでどの程度の料金になるか試算してみます。
会話例
> ユーザー:「こんにちは」 > GPT:「こんにちは、お話しできてうれしいです。何かお手伝いできることがあれば、遠慮なく教えてくださいね。」 試算 (音声トークンで概算)
たった一往復の挨拶で約 2 円。10 分程度の会話になると数十円〜100 円以上になることも珍しくありません。
社内向けの検証用途であれば許容範囲かもしれませんが、本番サービスとして大規模に展開する場合はコスト面での検討が必要です。
まとめ OpenAI Realtime API と RAG を組み合わせて、社内ナレッジに答えるボイスボットを作りました。
実装してみて感じたのは以下の点です。
低遅延は体験を大きく変える : 数秒のラグでも会話では気になる 割り込みができることの重要性 : 長い回答を途中で止められる Function Calling との相性の良さ : RAG との連携が自然にできる 今後は複数のナレッジベースの切り替えや、音声による申請実行などにも拡張していきたいと考えています。
参考