開発情報・ナレッジ

投稿者: ShiningStar株式会社 2025年11月17日 (月)

AIが回答作成をアシスト!ベクトル検索による類似お問い合わせサジェスト機能の実装ガイド

SPIRALに蓄積されたお問い合わせ履歴は、顧客対応の品質を向上させるための貴重な資産です。
しかし、「キーワードが完全一致しないと探せない」「表記ゆれでヒットしない」といった理由で、
過去の類似お問い合わせを探すのに時間がかかっていませんか?

本記事では、AIを活用したベクトル検索を導入し、
お問い合わせ担当者の回答作成を劇的に効率化する「類似お問い合わせサジェスト機能」の実装方法を紹介します。
SPIRALとAIサービスを連携させることで、よりスマートな顧客対応を実現しましょう。

本記事で実現すること

お問い合わせフォームに入力された内容と「意味が近い」過去のお問い合わせを自動で検索。
検索結果をリアルタイムで担当者に提示し、回答作成をサポート。
「ログインできない」「サインイン不可」のような表記ゆれにも対応した高精度な検索。

アーキテクチャの概要

今回のシステムでは、以下の3つのサービスを連携させて実装します。
なぜなら、SPIRAL単体ではテキストを意味的に解釈してベクトル化する機能や、
そのベクトルを高速に検索する機能を持たないため、
それぞれの役割に特化した専門のサービスを利用する必要があるからです。

1. SPIRAL: 顧客からのお問い合わせ履歴が蓄積されているデータベース。
2. OpenAI API: お問い合わせ内容(テキスト)を、AIが意味を解釈できる数値の配列(ベクトル)に変換します。
【ご注意】
OpenAI APIを利用するには、公式サイトでアカウントを作成し、APIキーを発行する必要があります。
また、APIの利用は原則として有料であり、処理量に応じた従量課金が発生しますのでご注意ください。
3. Pinecone: ベクトルデータを高速に検索することに特化した専門データベース。ベクトル化されたお問い合わせを保存し、類似検索を行います。

ベクトル検索の仕組み

なぜAIを使うと「意味が近い」ものを探せるのでしょうか。その鍵となるのが「ベクトル検索」です。

1. テキストを「ベクトル」に変換(Embedding)
まず、OpenAIのような「埋め込みモデル」と呼ばれるAIが、
文章の意味や文脈を読み取り、それを数値の配列(ベクトル)に変換します。
例えば、「ログインできない」と「サインインできない」は、
単語は違いますが意味が近いため、ベクトル空間上でも非常に近い位置にプロットされます。

2. ベクトル空間で「最も近い隣人」を探す
次に、ベクトルデータベース(Pinecone)が、
新しく入力されたお問い合わせのベクトルと、
既にデータベースに保存されている無数のベクトルとの距離を計算します。
そして、最も距離が近い(=意味が近い)ベクトルをいくつか見つけ出してきます。
これがベクトル検索の基本的な仕組みです。

Pineconeの設定手順

ベクトル検索を始めるために、まずはベクトルを保存する器(Index)をPineconeで作成します。手順は非常にシンプルです。

1. Pineconeにログインし、「Create Index」をクリックします。
2. Index名(例:
'spiral-inquiries'
)を入力します。

3. Dimensions(次元数)を設定します。
利用するOpenAIのモデルによって次元数が決まっています。
例えば、
'text-embedding-3-small'
を利用する場合は
1536
を指定してください。
4. Metricは
'Cosine'
を選択するのが一般的です。

5. 「Create Index」ボタンを押して作成完了です。

Indexが作成されると、APIキーやIndexホスト名が取得できるようになります。
これらはPHPコードからPineconeに接続するために必要です。

前提:SPIRAL DB(お問い合わせDB)

このサンプルでは、SPIRALのデータベースに以下のようなフィールドを含む、
過去のお問い合わせデータを保存しているものとして処理を行います。
  • カテゴリ(category):テキスト
  • お問い合わせタイトル(inquiry_title):テキスト
  • お問い合わせ内容(inquiry_body):テキスト
  • 回答内容(answer_body):テキストエリア

実装ステップ1: SPIRALのデータをPineconeに同期する

まず、SPIRALに蓄積されている過去のお問い合わせデータをベクトル化し、
Pineconeに登録するためのバッチ処理を実装します。
この処理は、例えば1日に1回、夜間などに定期実行することを想定しています。
スケジュールトリガのアクション>PHP実行にて、定期実行を設定し、
PHPコードを記載して実行する様にしてください。

PHPコード:スケジュールトリガ>アクション>PHP実行

このコードは、SPIRAL DBから過去のお問い合わせ履歴を取得し、
OpenAIのEmbedding APIで各お問い合わせをベクトル化、
最後にPineconeに一括で登録(Upsert)します。

<?php
// ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼
// 1. 各種API情報を設定
// ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼

// --- SPIRAL API Settings ---
define("API_URL", "https://api.spiral-platform.com/v1");
define("API_KEY", "APIキー");
define("APP_ROLE", "アプリロール識別名");
define("APP_ID", "アプリID");
define("DB_ID", "DBID");

// --- OpenAI API Settings ---
$openaiApiKey = 'YOUR_OPENAI_API_KEY'; // OpenAI APIキー
$openaiEmbeddingModel = 'text-embedding-3-small'; // 埋め込みモデル

// --- Pinecone API Settings ---
$pineconeApiKey = 'YOUR_PINECONE_API_KEY'; // Pinecone APIキー
$pineconeIndexHost = 'YOUR_PINECONE_INDEX_HOST'; // Pinecone Indexのホスト (例: index-name-xxxxxxx.svc.us-west1-gcp.pinecone.io)


// =================================================
// データ同期処理
// =================================================

echo "Sync process started.\n";

// 1. SPIRALからデータを取得
$commonBase = CommonBase::getInstance();

$resultRecordListSelect = $commonBase->apiCurlAction("GET", "/apps/". APP_ID. "/dbs/". DB_ID. "/records?limit=200");

if (empty($resultRecordListSelect['items'])) {
    die("No records found in SPIRAL. Exiting.\n");
}

echo "Found " . count($resultRecordListSelect['items']) . " records.\n";

// 2. OpenAI APIでテキストをベクトル化
echo "2. Vectorizing text with OpenAI...\n";
$textsToVectorize = array_map(function($record) {
    // 質問のタイトルと本文を結合して、ベクトルを生成する
    $combinedText = $record['inquiry_title'] . "\n" . $record['inquiry_body'];
    return $combinedText;
}, $resultRecordListSelect['items']);

$embeddingData = [
    'input' => $textsToVectorize,
    'model' => $openaiEmbeddingModel
];

$ch = curl_init('https://api.openai.com/v1/embeddings');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($embeddingData));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $openaiApiKey,
    'Content-Type: application/json'
]);
$openaiResponse = curl_exec($ch);
curl_close($ch);

$embeddingResult = json_decode($openaiResponse, true);

if (empty($embeddingResult['data'])) {
    die("Failed to get embeddings from OpenAI. Exiting.\n");
}

// 3. Pineconeにデータを登録 (Upsert)
echo "3. Upserting vectors to Pinecone...\n";
$vectorsToUpsert = [];
foreach ($embeddingResult['data'] as $index => $embedding) {
    $vectorsToUpsert[] = [
        'id' => (string)$resultRecordListSelect['items'][$index]['_id'], // SPIRALのレコードIDを文字列として使用
        'values' => $embedding['embedding']
    ];
}

$pineconeUpsertUrl = "https://{$pineconeIndexHost}/vectors/upsert";
$upsertData = ['vectors' => $vectorsToUpsert];

$ch = curl_init($pineconeUpsertUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($upsertData));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Api-Key: ' . $pineconeApiKey,
    'Content-Type: application/json'
]);
$pineconeResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($httpCode == 200) {
    echo "Sync process completed successfully!\n";
    echo $pineconeResponse;
} else {
    echo "Error during Pinecone upsert. Status code: {$httpCode}\n";
    echo $pineconeResponse;
}

//------------------------------
// 共通モジュール
//------------------------------
class CommonBase {
    /**
     * シングルトンインスタンス
     * @var UserManager
     */
    protected static $singleton;

    public function __construct() {
        if (self::$singleton) {
            throw new Exception('must be singleton');
        }
        self::$singleton = $this;
    }
    /**
     * シングルトンインスタンスを返す
     * @return UserManager
     */
    public static function getInstance() {
        if (!self::$singleton) {
            return new CommonBase();
        } else {
            return self::$singleton;
        }
    }
    /**
     * V2用 API送信ロジック
     * @return Result
     */
    function apiCurlAction($method, $addUrlPass, $data = null, $multiPart = null, $jsonDecode = null) {
        $header = array(
            "Authorization:Bearer ". API_KEY,
            "X-Spiral-Api-Version: 1.1",
        );
        if($multiPart) {
            $header = array_merge($header, array($multiPart));
        } else {
            $header = array_merge($header, array("Content-Type:application/json"));
        }
        if(APP_ROLE){
			$header = array_merge($header, array("X-Spiral-App-Role: ".APP_ROLE));
		}
        // curl
        $curl = curl_init();
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($curl, CURLOPT_URL, API_URL. $addUrlPass);
        curl_setopt($curl, CURLOPT_HTTPHEADER, $header);
        if ($method == "POST") {
            if ($multiPart) {
                curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
            } else {
                curl_setopt($curl, CURLOPT_POSTFIELDS , json_encode($data));
            }
            curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
        }
        if ($method == "PATCH") {
            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
            curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
        }
        if ($method == "DELETE") {
            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
            curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
        }
        $response = curl_exec($curl);
        if (curl_errno($curl)) echo curl_error($curl);
        curl_close($curl);
        if($jsonDecode){
			return $response;
		}else{
            return json_decode($response, true);
		}
    }
}
?>
            

実装ステップ2: お問い合わせ回答フォームに入力した内容をトリガーに、Pineconeへ類似お問い合わせを検索する

次に、オペレーターがお問い合わせ回答フォームに入力した内容をトリガーに、
Pineconeへ類似お問い合わせを検索する処理を実装します。
この処理は、SPIRALのフォームの確認画面ステップなどに組み込むことを想定しています。

PHPコード:お問い合わせ回答フォーム用ページ>PHP

このコードは、フォームから受け取った検索クエリをベクトル化し、
Pineconeに問い合わせます。
Pineconeからは類似度の高い順にSPIRALのレコードIDリストが返却されるので、
そのIDを使ってSPIRALから詳細な回答内容を取得し、画面に表示します。

<?php
// ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼
// 1. 各種API情報を設定
// ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼

// --- SPIRAL API Settings ---
define("API_URL", "https://api.spiral-platform.com/v1");
define("API_KEY", "APIキー");
define("APP_ROLE", "アプリロール識別名");
define("APP_ID", "アプリID");
define("DB_ID", "DBID");

// --- OpenAI API Settings ---
$openaiApiKey = 'YOUR_OPENAI_API_KEY'; // OpenAI APIキー
$openaiEmbeddingModel = 'text-embedding-3-small'; // 埋め込みモデル

// --- Pinecone API Settings ---
$pineconeApiKey = 'YOUR_PINECONE_API_KEY'; // Pinecone APIキー
$pineconeIndexHost = 'YOUR_PINECONE_INDEX_HOST'; // Pinecone Indexのホスト (例: index-name-xxxxxxx.svc.us-west1-gcp.pinecone.io)

//以下step2のみ実行
$registForm = $SPIRAL->getRegistrationForm("contactAIForm"); //登録フォームブロックの識別子を指定
$step = $registForm->getStep();

if($step == "2"){
// ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼
// 2. 検索クエリを設定
// ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼

$inquiry_title = $SPIRAL->getParam("inquiry_title"); //お問い合わせタイトルの識別名
$inquiry_body = $SPIRAL->getParam("inquiry_body"); //お問い合わせ内容の識別名

$searchQuery = $inquiry_title.$inquiry_body;

// =================================================
// 検索処理
// =================================================

// 1. OpenAI APIで検索クエリをベクトル化
$embeddingData = [
    'input' => $searchQuery,
    'model' => $openaiEmbeddingModel
];

$ch = curl_init('https://api.openai.com/v1/embeddings');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($embeddingData));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Authorization: Bearer ' . $openaiApiKey,
    'Content-Type: application/json'
]);
$openaiResponse = curl_exec($ch);
curl_close($ch);

$embeddingResult = json_decode($openaiResponse, true);

if (empty($embeddingResult['data'][0]['embedding'])) {
    die(json_encode(['error' => 'Failed to get embedding from OpenAI.']));
}

$searchVector = $embeddingResult['data'][0]['embedding'];

// 2. Pineconeで類似ベクトルを検索 (Query)
$pineconeQueryUrl = "https://{$pineconeIndexHost}/query";
$queryData = [
    'vector' => $searchVector,
    'topK' => 5, // 上位5件の類似結果を取得
    'includeValues' => false
];

$ch = curl_init($pineconeQueryUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($queryData));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Api-Key: ' . $pineconeApiKey,
    'Content-Type: application/json'
]);
$pineconeResponse = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($httpCode != 200) {
    die(json_encode([
        'error' => 'Error during Pinecone query.',
        'status_code' => $httpCode,
        'response' => $pineconeResponse
    ]));
}

// 3. 結果からIDのリストを抽出
$pineconeResult = json_decode($pineconeResponse, true);
$ids = [];
if (isset($pineconeResult['matches']) && !empty($pineconeResult['matches'])) {
    foreach ($pineconeResult['matches'] as $match) {
        $ids[] = $match['id'];
    }
}

// 4. SPIRALからレコードを取得
$commonBase = CommonBase::getInstance();

$resultRecordSelect = [];
foreach ($ids as $id) {
    $recordId = $id;
    $resultRecordSelect[] = $commonBase->apiCurlAction("GET", "/apps/". APP_ID. "/dbs/". DB_ID. "/records/". $recordId);
}

$SPIRAL->setTHValue("resultRecordSelect",$resultRecordSelect);
}
//------------------------------
// 共通モジュール
//------------------------------
class CommonBase {
    /**
     * シングルトンインスタンス
     * @var UserManager
     */
    protected static $singleton;

    public function __construct() {
        if (self::$singleton) {
            throw new Exception('must be singleton');
        }
        self::$singleton = $this;
    }
    /**
     * シングルトンインスタンスを返す
     * @return UserManager
     */
    public static function getInstance() {
        if (!self::$singleton) {
            return new CommonBase();
        } else {
            return self::$singleton;
        }
    }
    /**
     * V2用 API送信ロジック
     * @return Result
     */
    function apiCurlAction($method, $addUrlPass, $data = null, $multiPart = null, $jsonDecode = null) {
        $header = array(
            "Authorization:Bearer ". API_KEY,
            "X-Spiral-Api-Version: 1.1",
        );
        if($multiPart) {
            $header = array_merge($header, array($multiPart));
        } else {
            $header = array_merge($header, array("Content-Type:application/json"));
        }
        if(APP_ROLE){
			$header = array_merge($header, array("X-Spiral-App-Role: ".APP_ROLE));
		}
        // curl
        $curl = curl_init();
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($curl, CURLOPT_URL, API_URL. $addUrlPass);
        curl_setopt($curl, CURLOPT_HTTPHEADER, $header);
        if ($method == "POST") {
            if ($multiPart) {
                curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
            } else {
                curl_setopt($curl, CURLOPT_POSTFIELDS , json_encode($data));
            }
            curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
        }
        if ($method == "PATCH") {
            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
            curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
        }
        if ($method == "DELETE") {
            curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($data));
            curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
        }
        $response = curl_exec($curl);
        if (curl_errno($curl)) echo curl_error($curl);
        curl_close($curl);
        if($jsonDecode){
			return $response;
		}else{
            return json_decode($response, true);
		}
    }
}
?>
            
HTMLコード:お問い合わせ回答フォームブロック>ソース編集>確認ステップ(step2)

このコードは、PHPにてPineconeから取得した類似度の高い順にSPIRALのレコードIDリストを元に、
取得したSPIRALレコードをThymeleafで表示するコードになります。

<!-- お問い合わせ履歴 -->
<div th:if="${cp.result.value.resultRecordSelect}">
  <h2>過去のお問い合わせ履歴</h2>

  <div th:each="rec : ${cp.result.value.resultRecordSelect}">
    <!-- rec.item を一旦 item という変数に代入 -->
    <div th:with="item=${rec.item}" style="border:1px solid #ccc; padding:10px; margin-bottom:10px;">

      <p>
        <strong>タイトル:</strong>
        <span th:text="${item.inquiry_title}"></span>
      </p>

      <p>
        <strong>カテゴリ:</strong>
        <span th:text="${item.category}"></span>
      </p>

      <p>
        <strong>受付日:</strong>
        <!-- createdAt 全体をそのまま表示(加工しない) -->
        <span th:text="${item._createdAt}"></span>
      </p>

      <p>
        <strong>内容:</strong><br>
        <span th:text="${item.inquiry_body}"></span>
      </p>

      <p>
        <strong>回答:</strong><br>
        <span th:text="${item.answer_body}"></span>
      </p>

    </div>
  </div>
</div>
            

まとめ

本記事では、SPIRALとAIサービス(OpenAI, Pinecone)を連携させ、
ベクトル検索を用いた類似お問い合わせサジェスト機能の実装方法を紹介しました。
これまでキーワード検索では埋もれてしまっていた過去の貴重なナレッジを、
AIの力で掘り起こし、顧客対応の品質向上と業務効率化に繋げることができます。
ぜひ、この機会にAIを活用した新しいSPIRALの活用法をご検討ください。
解決しない場合はこちら コンテンツに関しての
要望はこちら