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?

【完全版】HeyGenにRAG実装!Pinecone連携で賢いアバター構築

Posted at

はじめに

どうしてもPineconeのRAGを使って、インタラクティブアバターを作りたいと思っていました。
副業でお客様と相談して、今回はHeyGenの知識ベース(デフォルト)を使うことにしましたが、今後知識が増えると、それでは対応しきれなくなります。やはりRAGによる実現が必要だと考えていました。
しかし、6ヶ月間いろいろな資料を調べましたが、なかなかうまくいきませんでした。
そして今日(2026年1月2日)、ついに動作するようになったので、ソースコードを紹介します。
これが皆様のプログラミング意欲の向上につながれば幸いです。

利用したソースコードは、以下です。

Pineconeスクリプト

RAGのデータベースにはPineconeを採用しました。 アバターに質問に答えてもらうには、まず知識となるデータをPineconeに登録する必要があります。

以下、データ登録用のスクリプトです。

scripts/setup-pinecone-data.js (Pineconeにレコード追加)

const { Pinecone } = require('@pinecone-database/pinecone');
const OpenAI = require('openai').default;

// 環境変数の読み込み
require('dotenv').config({ path: '.env' });

// 設定
const PINECONE_API_KEY = process.env.PINECONE_API_KEY;
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const PINECONE_INDEX_NAME = process.env.PINECONE_INDEX_NAME || 'knowledge-base';

// サンプルナレッジデータ
const SAMPLE_DOCUMENTS = [
  {
    id: 'doc_101',
    text: '木村たろうは、北海道出身です。',
    source: 'kimura_info',
  },
  {
    id: 'doc_102',
    text: '木村たろうは、30歳です。',
    source: 'kimura_info',
  },
  {
    id: 'doc_103',
    text: '木村たろうは、ほげほげ高校を卒業し、ダメダ大学に進学しました。',
    source: 'kimura_info',
  },
  {
    id: 'doc_104',
    text: '木村たろうは、現在IT関連の作業をしています。',
    source: 'kimura_info',
  },
  {
    id: 'doc_105',
    text: '木村たろうの趣味はプログラミングです。特に現在はインタラクティブアバターに興味を持っています。',
    source: 'kimura_info',
  },
  {
    id: 'doc_106',
    text: '木村たろうは、システム開発の副業をしています。',
    source: 'kimura_info',
  },
  {
    id: 'doc_107',
    text: '木村たろうは、主にQiitaに技術記事を書いています。',
    source: 'kimura_info',
  },
];

// クライアント初期化
let pineconeClient = null;
let openaiClient = null;

function initClients() {
  if (!PINECONE_API_KEY || !OPENAI_API_KEY) {
    throw new Error('API keys are missing. Please check your .env.local file.');
  }

  pineconeClient = new Pinecone({ apiKey: PINECONE_API_KEY });
  openaiClient = new OpenAI({ apiKey: OPENAI_API_KEY });

  console.log('✅ クライアント初期化完了');
}

/**
 * Pineconeインデックスをセットアップ
 */
async function setupPineconeIndex() {
  console.log('🔧 Pineconeインデックスをセットアップ中...');

  try {
    // 既存のインデックスを確認
    const indexes = await pineconeClient.listIndexes();
    const existingIndex = indexes.indexes?.find(
      (idx) => idx.name === PINECONE_INDEX_NAME
    );

    if (!existingIndex) {
      console.log(`  ✓ インデックス '${PINECONE_INDEX_NAME}' を作成中...`);
      await pineconeClient.createIndex({
        name: PINECONE_INDEX_NAME,
        dimension: 1536, // text-embedding-3-smallの次元数
        metric: 'cosine',
        spec: {
          serverless: {
            cloud: 'aws',
            region: 'us-east-1',
          },
        },
      });
      console.log(`  ✓ インデックス '${PINECONE_INDEX_NAME}' を作成しました!`);

      // インデックスの初期化を待つ
      console.log('  ⏳ インデックスの初期化を待っています(10秒)...');
      await new Promise((resolve) => setTimeout(resolve, 10000));
    } else {
      console.log(`  ✓ インデックス '${PINECONE_INDEX_NAME}' は既に存在します`);
    }

    return pineconeClient.index(PINECONE_INDEX_NAME);
  } catch (error) {
    console.error('❌ インデックスセットアップエラー:', error);
    throw error;
  }
}

/**
 * テキストをベクトルに変換
 */
async function embedText(text) {
  const response = await openaiClient.embeddings.create({
    model: 'text-embedding-3-small',
    input: text,
  });
  return response.data[0].embedding;
}

/**
 * サンプルドキュメントをPineconeにアップロード
 */
async function uploadDocuments(index) {
  console.log(`\n📤 ${SAMPLE_DOCUMENTS.length}件のドキュメントをアップロード中...`);

  try {
    const vectors = [];

    for (const doc of SAMPLE_DOCUMENTS) {
      console.log(`  処理中: ${doc.id} - ${doc.text.slice(0, 50)}...`);

      // テキストをベクトル化
      const vector = await embedText(doc.text);

      // メタデータと共に保存
      vectors.push({
        id: doc.id,
        values: vector,
        metadata: {
          text: doc.text,
          source: doc.source,
        },
      });
    }

    // 一括アップロード
    await index.upsert(vectors);
    console.log(`  ✓ ${vectors.length}件のドキュメントをアップロードしました!`);
  } catch (error) {
    console.error('❌ アップロードエラー:', error);
    throw error;
  }
}

/**
 * データが正しくアップロードされたか確認
 */
async function verifyData(index) {
  console.log('\n✅ データ確認中...');

  try {
    const stats = await index.describeIndexStats();
    console.log(`  ✓ 登録ベクトル数: ${stats.totalRecordCount || 0}`);
    console.log(`  ✓ 次元数: ${stats.dimension || 0}`);
  } catch (error) {
    console.error('❌ データ確認エラー:', error);
    throw error;
  }
}

/**
 * メイン処理
 */
async function main() {
  console.log('='.repeat(60));
  console.log('Pinecone サンプルデータ登録スクリプト');
  console.log('='.repeat(60));
  console.log('');

  try {
    // 1. クライアント初期化
    initClients();

    // 2. インデックスのセットアップ
    const index = await setupPineconeIndex();

    // 3. ドキュメントのアップロード
    await uploadDocuments(index);

    // 4. データ確認
    await verifyData(index);

    console.log('\n✨ セットアップ完了!');
    console.log('   これで HeyGen Interactive Avatar でナレッジ検索が使えます。');
    console.log('');
  } catch (error) {
    console.error('\n❌ エラーが発生しました:', error.message);
    process.exit(1);
  }
}

// 実行
main();

その後に、dotenvをインストールしたのち、スクリプトを実行します。

npm install dotenv
node scripts/setup-pinecone-data.js

デバッグモード

開発中はデバッグモードでブレークポイントを使いながら実行することをお勧めします。 これにより、バグの原因特定や修正が劇的に速くなります。

.vscode/launch.json

VSCodeでF5キーを押すだけでデバッグモードが起動し、コードの任意の場所で実行を止めて変数の中身を確認できるようになります。

前回の記事でも記述しましたが、再掲いたします。

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Next.js: フルスタックデバッグ",
      "type": "node",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "runtimeExecutable": "npm",
      "runtimeArgs": ["run", "dev"],
      "console": "integratedTerminal",
      "serverReadyAction": {
        "pattern": "- Local:.+(https?://[^\\s]+)",
        "uriFormat": "%s",
        "action": "debugWithChrome"
      },
      "skipFiles": ["<node_internals>/**", "**/node_modules/**"]
    },
    {
      "name": "Next.js: サーバーサイドデバッグ",
      "type": "node",
      "request": "attach",
      "port": 9229,
      "skipFiles": ["<node_internals>/**"]
    },
    {
      "name": "Next.js: クライアントサイドデバッグ",
      "type": "chrome",
      "request": "launch",
      "url": "http://localhost:3000",
      "webRoot": "${workspaceFolder}",
      "sourceMapPathOverrides": {
        "webpack://_N_E/*": "${webRoot}/*"
      }
    }
  ]
}

テキストチャットでのRAG実装

既存のコードへの影響を最小限に抑えるため、必要最低限の修正でPineconeのRAGを組み込みました。 前回の記事から大幅に改良を加えています。

環境変数の設定(.env)

まず、3つのAPIキーを設定します。
OpenAIの役割: 質問や知識データを数値(ベクトル)に変換し、Pineconeで類似検索できるようにします。

.env
# HeyGen - アバターを動かすため
HEYGEN_API_KEY=sk_V2_******************************
NEXT_PUBLIC_BASE_API_URL=https://api.heygen.com

# Pinecone - 知識データベースとして使用
PINECONE_API_KEY=pcsk_*************************
PINECONE_INDEX_NAME=heygen-knowledge

# OpenAI - テキストをベクトル化(embedding)するために使用
OPENAI_API_KEY=sk-proj-*************************


package.json

今回、PineconeとOpenAIの2つのライブラリを新規追加しました。
以下のコマンドを実行すると、package.jsonに自動的に追加されます。

npm install @pinecone-database/pinecone
npm install openai

念のため、package.jsonを以下に示す。

package.json
{
  "name": "next-app-template",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint . --ext .ts,.tsx,.js,.jsx"
  },
  "dependencies": {
    "@heygen/streaming-avatar": "^2.0.13",
    "@pinecone-database/pinecone": "^6.1.3",
    "@radix-ui/react-select": "^2.1.7",
    "@radix-ui/react-switch": "^1.1.4",
    "@radix-ui/react-toggle-group": "^1.1.3",
    "ahooks": "^3.8.4",
    "dotenv": "^17.2.3",
    "next": "^15.3.0",
    "openai": "^4.104.0",
    "react": "^19.1.0",
    "react-dom": "^19.1.0"
  },
  "devDependencies": {
    "@next/eslint-plugin-next": "^15.3.1",
    "@types/node": "20.5.7",
    "@types/react": "^19.0.1",
    "@types/react-dom": "^19.1.2",
    "@typescript-eslint/eslint-plugin": "^8.31.0",
    "@typescript-eslint/parser": "^8.31.0",
    "autoprefixer": "10.4.19",
    "eslint": "^9.25.1",
    "eslint-config-prettier": "^10.1.2",
    "eslint-plugin-import": "^2.31.0",
    "eslint-plugin-node": "^11.1.0",
    "eslint-plugin-prettier": "^5.2.6",
    "eslint-plugin-react": "^7.37.5",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-unused-imports": "^4.1.4",
    "postcss": "8.4.38",
    "tailwindcss": "^3.4.17",
    "typescript": "5.0.4"
  }
}

検索APIの実装(app/api/knowledge-search/route.ts)

ユーザーの質問に関連する情報を、PineconeのDBから上位3件取得するロジックを実装しました。 これにより、質問内容に応じて最も関連性の高いデータを自動で取得できるようになります。

route.ts
// app/api/knowledge-search/route.ts
// Pineconeナレッジベース検索API

import { Pinecone } from '@pinecone-database/pinecone';
import OpenAI from 'openai';
import { NextRequest } from 'next/server';

const PINECONE_API_KEY = process.env.PINECONE_API_KEY;
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const PINECONE_INDEX_NAME = process.env.PINECONE_INDEX_NAME || 'knowledge-base';

// クライアント初期化(グローバルで1回のみ)
let pineconeClient: Pinecone | null = null;
let openaiClient: OpenAI | null = null;

function initClients() {
  if (!pineconeClient && PINECONE_API_KEY) {
    pineconeClient = new Pinecone({ apiKey: PINECONE_API_KEY });
  }
  if (!openaiClient && OPENAI_API_KEY) {
    openaiClient = new OpenAI({ apiKey: OPENAI_API_KEY });
  }
}

export async function POST(request: NextRequest) {
  try {
    // APIキーのチェック
    if (!PINECONE_API_KEY || !OPENAI_API_KEY) {
      console.warn('Pinecone or OpenAI API key is missing');
      return new Response(
        JSON.stringify({ 
          success: false,
          context: '',
          error: 'API keys not configured' 
        }),
        { status: 200, headers: { 'Content-Type': 'application/json' } }
      );
    }

    initClients();

    // リクエストボディから質問を取得
    let body: any = {};
    try {
      const text = await request.text();
      if (text) {
        body = JSON.parse(text);
      }
    } catch (e) {
      console.error('JSON parse error:', e);
    }

    const query = body.query || body.question || '';

    if (!query.trim()) {
      return new Response(
        JSON.stringify({ success: false, context: '' }),
        { status: 200, headers: { 'Content-Type': 'application/json' } }
      );
    }

    console.log('[Knowledge Search] Query:', query);

    // 1. クエリをベクトル化
    const embeddingResponse = await openaiClient!.embeddings.create({
      model: 'text-embedding-3-small',
      input: query,
    });
    const queryVector = embeddingResponse.data[0].embedding;

    // 2. Pineconeで検索
    const index = pineconeClient!.index(PINECONE_INDEX_NAME);
    const searchResults = await index.query({
      vector: queryVector,
      topK: 3,
      includeMetadata: true,
    });

    console.log('[Knowledge Search] Found', searchResults.matches.length, 'matches');

    // 3. 検索結果を整形
    const knowledgeItems = searchResults.matches.map((match) => ({
      score: match.score || 0,
      text: (match.metadata?.text as string) || '',
      source: (match.metadata?.source as string) || 'unknown',
    }));

    // 4. コンテキストを作成(アバターに渡す情報)
    const context = knowledgeItems
      .filter(item => item.score > 0.7) // スコアが0.7以上のもののみ
      .map((item, index) => `参考情報${index + 1}: ${item.text}`)
      .join('\n');

    console.log('[Knowledge Search] Context length:', context.length);

    // 5. レスポンスを返す
    return new Response(
      JSON.stringify({
        success: true,
        context: context,
        sources: knowledgeItems,
      }),
      { status: 200, headers: { 'Content-Type': 'application/json' } }
    );

  } catch (error) {
    console.error('[Knowledge Search] Error:', error);

    // エラーが発生しても空のコンテキストを返す(フォールバック)
    return new Response(
      JSON.stringify({ 
        success: false, 
        context: '',
        error: error instanceof Error ? error.message : 'Unknown error'
      }),
      { status: 200, headers: { 'Content-Type': 'application/json' } }
    );
  }
}

テキスト入力での検索機能追加(components/AvatarSession/TextInput.tsx)

最初のステップとして、テキストチャットでRAGが使えるように実装しました。
ユーザーがメッセージを送信する際、自動的にPineconeで関連情報を検索し、その結果を含めてアバターに送信します。これにより、アバターは検索された知識を基に、より正確な回答ができるようになります。

TextInput.tsx
import { TaskType, TaskMode } from "@heygen/streaming-avatar";
import React, { useCallback, useEffect, useState } from "react";
import { usePrevious } from "ahooks";

import { Select } from "../Select";
import { Button } from "../Button";
import { SendIcon } from "../Icons";
import { useTextChat } from "../logic/useTextChat";
import { Input } from "../Input";
import { useConversationState } from "../logic/useConversationState";

// ★ 新規追加: Pinecone検索関数
async function searchKnowledge(query: string): Promise<string> {
  try {
    const response = await fetch('/api/knowledge-search', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query }),
    });

    const data = await response.json();
    
    if (data.success && data.context) {
      console.log('[TextInput] Knowledge found:', data.context.slice(0, 100));
      return data.context;
    }
    
    return '';
  } catch (error) {
    console.error('[TextInput] Knowledge search error:', error);
    return '';
  }
}

export const TextInput: React.FC = () => {
  const { sendMessage, sendMessageSync, repeatMessage, repeatMessageSync } =
    useTextChat();
  const { startListening, stopListening } = useConversationState();
  const [taskType, setTaskType] = useState<TaskType>(TaskType.TALK);
  const [taskMode, setTaskMode] = useState<TaskMode>(TaskMode.ASYNC);
  const [message, setMessage] = useState("");
  const [isSearching, setIsSearching] = useState(false); // ★ 新規追加

  // ★ 修正: Pinecone検索を統合
  const handleSend = useCallback(async () => {
    if (message.trim() === "") {
      return;
    }

    setIsSearching(true);

    try {
      let finalMessage = message;

      // TALKモードの場合のみPinecone検索を実行
      if (taskType === TaskType.TALK) {
        console.log('[TextInput] Searching knowledge for:', message);
        
        const knowledgeContext = await searchKnowledge(message);
        
        // ナレッジが見つかった場合、メッセージに追加
        if (knowledgeContext) {
          finalMessage = `${message}\n\n以下の情報を参考にしてください:\n${knowledgeContext}`;
          console.log('[TextInput] Enhanced message with knowledge');
        }
      }

      // アバターにメッセージを送信
      if (taskType === TaskType.TALK) {
        taskMode === TaskMode.SYNC
          ? sendMessageSync(finalMessage)
          : sendMessage(finalMessage);
      } else {
        taskMode === TaskMode.SYNC
          ? repeatMessageSync(finalMessage)
          : repeatMessage(finalMessage);
      }

      setMessage("");
    } catch (error) {
      console.error('[TextInput] Error sending message:', error);
      // エラーが発生しても元のメッセージを送信
      if (taskType === TaskType.TALK) {
        taskMode === TaskMode.SYNC
          ? sendMessageSync(message)
          : sendMessage(message);
      } else {
        taskMode === TaskMode.SYNC
          ? repeatMessageSync(message)
          : repeatMessage(message);
      }
      setMessage("");
    } finally {
      setIsSearching(false);
    }
  }, [
    taskType,
    taskMode,
    message,
    sendMessage,
    sendMessageSync,
    repeatMessage,
    repeatMessageSync,
  ]);

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Enter" && !isSearching) {
        handleSend();
      }
    };

    window.addEventListener("keydown", handleKeyDown);

    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [handleSend, isSearching]);

  const previousText = usePrevious(message);

  useEffect(() => {
    if (!previousText && message) {
      startListening();
    } else if (previousText && !message) {
      stopListening();
    }
  }, [message, previousText, startListening, stopListening]);

  return (
    <div className="flex flex-row gap-2 items-end w-full">
      <Select
        isSelected={(option) => option === taskType}
        options={Object.values(TaskType)}
        renderOption={(option) => option.toUpperCase()}
        value={taskType.toUpperCase()}
        onSelect={setTaskType}
      />
      <Select
        isSelected={(option) => option === taskMode}
        options={Object.values(TaskMode)}
        renderOption={(option) => option.toUpperCase()}
        value={taskMode.toUpperCase()}
        onSelect={setTaskMode}
      />
      <Input
        className="min-w-[500px]"
        placeholder={`Type something for the avatar to ${taskType === TaskType.REPEAT ? "repeat" : "respond"}...`}
        value={message}
        onChange={setMessage}
      />
      <Button 
        className="!p-2" 
        onClick={handleSend}
        disabled={isSearching} // ★ 新規追加: 検索中は無効化
      >
        {isSearching ? (
          <div className="animate-spin h-5 w-5 border-2 border-white border-t-transparent rounded-full" />
        ) : (
          <SendIcon size={20} />
        )}
      </Button>
    </div>
  );
};

音声チャットでのRAG実装

段階的に開発を進めるため、テキストチャットの次は音声チャットに対応しました。
音声処理はテキストとは別のコードで動いているため、このファイルを修正することで、テキストに加えて音声でもRAGを使った会話ができるようになります。

修正ファイル(components/InteractiveAvatar.tsx)

当初はAudioInput.tsxを修正すべきだと考えていましたが、実際のコードを読み解くと、InteractiveAvatar.tsxを修正する必要があることが分かりました。
このファイルでは、ユーザーの音声入力を検知し、話し終わったタイミングでPinecone検索を実行するロジックを追加しています。

InteractiveAvatar.tsx
// components/InteractiveAvatar.tsx (修正版 - 正しいイベントを使用)
// 音声チャットでPinecone検索 - AVATAR_END_MESSAGEを使用

import {
  AvatarQuality,
  StreamingEvents,
  VoiceChatTransport,
  VoiceEmotion,
  StartAvatarRequest,
  STTProvider,
  ElevenLabsModel,
} from "@heygen/streaming-avatar";
import { useEffect, useRef, useState } from "react";
import { useMemoizedFn, useUnmount } from "ahooks";

import { Button } from "./Button";
import { AvatarConfig } from "./AvatarConfig";
import { AvatarVideo } from "./AvatarSession/AvatarVideo";
import { useStreamingAvatarSession } from "./logic/useStreamingAvatarSession";
import { AvatarControls } from "./AvatarSession/AvatarControls";
import { useVoiceChat } from "./logic/useVoiceChat";
import { StreamingAvatarProvider, StreamingAvatarSessionState } from "./logic";
import { LoadingIcon } from "./Icons";
import { MessageHistory } from "./AvatarSession/MessageHistory";

import { AVATARS } from "@/app/lib/constants";

const DEFAULT_CONFIG: StartAvatarRequest = {
  quality: AvatarQuality.Low,
  avatarName: AVATARS[0].avatar_id,
  knowledgeId: undefined,
  voice: {
    rate: 1.5,
    emotion: VoiceEmotion.EXCITED,
    model: ElevenLabsModel.eleven_flash_v2_5,
  },
  language: "ja",
  voiceChatTransport: VoiceChatTransport.WEBSOCKET,
  sttSettings: {
    provider: STTProvider.DEEPGRAM,
  },
};

// ★ Pinecone検索関数
async function searchKnowledge(query: string): Promise<string> {
  console.log('[Pinecone] Searching for:', query);
  
  try {
    const response = await fetch('/api/knowledge-search', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query }),
    });

    const data = await response.json();
    
    if (data.success && data.context) {
      console.log('[Pinecone] ✅ Knowledge found:', data.context.slice(0, 150));
      return data.context;
    }
    
    console.log('[Pinecone] No knowledge found');
    return '';
  } catch (error) {
    console.error('[Pinecone] Search error:', error);
    return '';
  }
}

function InteractiveAvatar() {
  const { initAvatar, startAvatar, stopAvatar, sessionState, stream } =
    useStreamingAvatarSession();
  const { startVoiceChat } = useVoiceChat();

  const [config, setConfig] = useState<StartAvatarRequest>(DEFAULT_CONFIG);
  
  // ★ 音声チャットフラグとユーザーメッセージを保持
  const [isVoiceChatMode, setIsVoiceChatMode] = useState(false);
  const lastUserMessageRef = useRef<string>("");

  const mediaStream = useRef<HTMLVideoElement>(null);

  async function fetchAccessToken() {
    try {
      const response = await fetch("/api/get-access-token", {
        method: "POST",
      });
      const token = await response.text();

      console.log("Access Token:", token);

      return token;
    } catch (error) {
      console.error("Error fetching access token:", error);
      throw error;
    }
  }

  const startSessionV2 = useMemoizedFn(async (isVoice: boolean) => {
    console.log('[Session] Starting session, isVoice:', isVoice);
    
    try {
      setIsVoiceChatMode(isVoice);
      
      const newToken = await fetchAccessToken();
      const avatar = initAvatar(newToken);

      avatar.on(StreamingEvents.AVATAR_START_TALKING, (e) => {
        console.log("Avatar started talking", e);
      });
      avatar.on(StreamingEvents.AVATAR_STOP_TALKING, (e) => {
        console.log("Avatar stopped talking", e);
      });
      avatar.on(StreamingEvents.STREAM_DISCONNECTED, () => {
        console.log("Stream disconnected");
      });
      avatar.on(StreamingEvents.STREAM_READY, (event) => {
        console.log(">>>>> Stream ready:", event.detail);
      });
      
      // ★★★ USER_START - ユーザーが話し始めた
      avatar.on(StreamingEvents.USER_START, (event) => {
        console.log(">>>>> User started talking:", event);
        lastUserMessageRef.current = ""; // リセット
      });
      
      // ★★★ USER_STOP - ユーザーが話し終わった
      avatar.on(StreamingEvents.USER_STOP, async (event) => {
        console.log(">>>>> User stopped talking:", event);
        console.log("[Voice] Event detail:", event?.detail);
        
        if (isVoice && lastUserMessageRef.current) {
          const userMessage = lastUserMessageRef.current;
          console.log("[Voice] Processing user message:", userMessage);
          
          // Pineconeで検索
          const knowledgeContext = await searchKnowledge(userMessage);
          
          if (knowledgeContext) {
            console.log("[Voice] ✅ Knowledge retrieved, length:", knowledgeContext.length);
            
            // ナレッジをアバターに送信
            try {
              // アバターに追加のコンテキストを提供
              await avatar.speak({
                text: `参考情報: ${knowledgeContext}`,
              });
              console.log("[Voice] Knowledge sent to avatar");
            } catch (err) {
              console.error("[Voice] Error sending knowledge:", err);
            }
          }
        }
      });

      // ★★★ USER_TALKING_MESSAGE - ユーザーの発言内容を取得
      avatar.on(StreamingEvents.USER_TALKING_MESSAGE, (event) => {
        console.log(">>>>> User talking message:", event);
        const message = event?.detail?.message || event?.detail?.text || '';
        if (message) {
          lastUserMessageRef.current = message;
          console.log("[Voice] Captured user message:", message);
        }
      });
      
      // ★★★ USER_END_MESSAGE - ユーザーのメッセージ確定
      avatar.on(StreamingEvents.USER_END_MESSAGE, async (event) => {
        console.log("========================================");
        console.log(">>>>> USER_END_MESSAGE EVENT FIRED ✅");
        console.log("========================================");
        console.log("[Voice] Event:", event);
        console.log("[Voice] Event detail:", event?.detail);
        
        // メッセージを取得
        const userMessage = 
          event?.detail?.message || 
          event?.detail?.text ||
          event?.message ||
          lastUserMessageRef.current ||
          '';
        
        console.log("[Voice] User message:", userMessage);
        
        if (userMessage && isVoice) {
          console.log("[Voice] ✅ Starting Pinecone search...");
          
          // Pineconeで検索
          const knowledgeContext = await searchKnowledge(userMessage);
          
          if (knowledgeContext) {
            console.log("[Voice] ✅ Knowledge found!");
            // 検索結果は次の回答生成時に使用される
          } else {
            console.log("[Voice] ⚠️ No knowledge found");
          }
        } else {
          console.log("[Voice] Skipping - userMessage:", userMessage, "isVoice:", isVoice);
        }
      });

      avatar.on(StreamingEvents.AVATAR_TALKING_MESSAGE, (event) => {
        console.log(">>>>> Avatar talking message:", event);
      });
      avatar.on(StreamingEvents.AVATAR_END_MESSAGE, (event) => {
        console.log(">>>>> Avatar end message:", event);
      });

      await startAvatar(config);

      if (isVoice) {
        console.log('[Session] Starting voice chat...');
        await startVoiceChat();
        console.log('[Session] Voice chat started');
      }
    } catch (error) {
      console.error("Error starting avatar session:", error);
    }
  });

  useUnmount(() => {
    stopAvatar();
  });

  useEffect(() => {
    if (stream && mediaStream.current) {
      mediaStream.current.srcObject = stream;
      mediaStream.current.onloadedmetadata = () => {
        mediaStream.current!.play();
      };
    }
  }, [mediaStream, stream]);

  return (
    <div className="w-full flex flex-col gap-4">
      <div className="flex flex-col rounded-xl bg-zinc-900 overflow-hidden">
        <div className="relative w-full aspect-video overflow-hidden flex flex-col items-center justify-center">
          {sessionState !== StreamingAvatarSessionState.INACTIVE ? (
            <AvatarVideo ref={mediaStream} />
          ) : (
            <AvatarConfig config={config} onConfigChange={setConfig} />
          )}
        </div>
        <div className="flex flex-col gap-3 items-center justify-center p-4 border-t border-zinc-700 w-full">
          {sessionState === StreamingAvatarSessionState.CONNECTED ? (
            <AvatarControls />
          ) : sessionState === StreamingAvatarSessionState.INACTIVE ? (
            <div className="flex flex-row gap-4">
              <Button onClick={() => startSessionV2(true)}>
                Start Voice Chat
              </Button>
              <Button onClick={() => startSessionV2(false)}>
                Start Text Chat
              </Button>
            </div>
          ) : (
            <LoadingIcon />
          )}
        </div>
      </div>
      {sessionState === StreamingAvatarSessionState.CONNECTED && (
        <MessageHistory />
      )}
    </div>
  );
}

export default function InteractiveAvatarWrapper() {
  return (
    <StreamingAvatarProvider basePath={process.env.NEXT_PUBLIC_BASE_API_URL}>
      <InteractiveAvatar />
    </StreamingAvatarProvider>
  );
}

実行結果

画面での動作確認

プログラムを起動すると、アバター選択画面が表示されます。 日本語で会話するため、言語設定で「Japanese」を選択し、「Start Voice Chat」ボタンをクリックします。

image.png

アバターが表示され、会話ができるようになります。 実際に質問してみると、Pineconeに登録した私の年齢や趣味などの情報を正確に答えてくれました。

image.png

コンソールログの確認

ブラウザの開発者ツールで確認すると、Pineconeから正しくデータを取得し、アバターに渡していることがログから確認できます。

image.png

まとめ

以下のソースコードからの追加・変更

修正したファイル

・scripts/setup-pinecone-data.js - データ登録スクリプト(新規)
・app/api/knowledge-search/route.ts - 検索API(新規)
・components/AvatarSession/TextInput.tsx - テキストチャット対応(修正)
・components/InteractiveAvatar.tsx - 音声チャット対応(修正)
・.env - APIキー設定(追加)

実現できたこと

・Pineconeに情報を保存
・テキストで質問するとPineconeから情報を取得
・音声で質問してもPineconeから情報を取得
・アバターが正確な情報で回答

終わりに

6ヶ月かけて、記事を調べ、HeyGenのサポートに問い合わせ、生成AIからアドバイスを受けて、ようやくインタラクティブアバターをRAGで動かすことができました。とても嬉しいです。

もう少し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?