0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OpenAI Realtime API × RAG で社内ナレッジに答えるボイスボットを作る

Last updated at Posted at 2026-01-18

はじめに

前回の、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 との連携が自然にできる

今後は複数のナレッジベースの切り替えや、音声による申請実行などにも拡張していきたいと考えています。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?