SPIRALに蓄積されたお問い合わせ履歴は、顧客対応の品質を向上させるための貴重な資産です。
しかし、「キーワードが完全一致しないと探せない」「表記ゆれでヒットしない」といった理由で、
過去の類似お問い合わせを探すのに時間がかかっていませんか?
本記事では、AIを活用したベクトル検索を導入し、
お問い合わせ担当者の回答作成を劇的に効率化する「類似お問い合わせサジェスト機能」の実装方法を紹介します。
SPIRALとAIサービスを連携させることで、よりスマートな顧客対応を実現しましょう。
本記事で実現すること
・ 検索結果をリアルタイムで担当者に提示し、回答作成をサポート。
・ 「ログインできない」「サインイン不可」のような表記ゆれにも対応した高精度な検索。
アーキテクチャの概要
今回のシステムでは、以下の3つのサービスを連携させて実装します。
なぜなら、SPIRAL単体ではテキストを意味的に解釈してベクトル化する機能や、
そのベクトルを高速に検索する機能を持たないため、
それぞれの役割に特化した専門のサービスを利用する必要があるからです。
2. OpenAI API: お問い合わせ内容(テキスト)を、AIが意味を解釈できる数値の配列(ベクトル)に変換します。
OpenAI APIを利用するには、公式サイトでアカウントを作成し、APIキーを発行する必要があります。
また、APIの利用は原則として有料であり、処理量に応じた従量課金が発生しますのでご注意ください。
ベクトル検索の仕組み
なぜAIを使うと「意味が近い」ものを探せるのでしょうか。その鍵となるのが「ベクトル検索」です。
まず、OpenAIのような「埋め込みモデル」と呼ばれるAIが、
文章の意味や文脈を読み取り、それを数値の配列(ベクトル)に変換します。
例えば、「ログインできない」と「サインインできない」は、
単語は違いますが意味が近いため、ベクトル空間上でも非常に近い位置にプロットされます。
2. ベクトル空間で「最も近い隣人」を探す
次に、ベクトルデータベース(Pinecone)が、
新しく入力されたお問い合わせのベクトルと、
既にデータベースに保存されている無数のベクトルとの距離を計算します。
そして、最も距離が近い(=意味が近い)ベクトルをいくつか見つけ出してきます。
これがベクトル検索の基本的な仕組みです。
Pineconeの設定手順
ベクトル検索を始めるために、まずはベクトルを保存する器(Index)をPineconeで作成します。手順は非常にシンプルです。
2. Index名(例:
'spiral-inquiries')を入力します。
3. Dimensions(次元数)を設定します。
例えば、
'text-embedding-3-small'を利用する場合は
1536を指定してください。
'Cosine'を選択するのが一般的です。
5. 「Create Index」ボタンを押して作成完了です。
Indexが作成されると、APIキーやIndexホスト名が取得できるようになります。
これらはPHPコードからPineconeに接続するために必要です。
前提:SPIRAL DB(お問い合わせDB)
過去のお問い合わせデータを保存しているものとして処理を行います。
- カテゴリ(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>
まとめ
ベクトル検索を用いた類似お問い合わせサジェスト機能の実装方法を紹介しました。
これまでキーワード検索では埋もれてしまっていた過去の貴重なナレッジを、
AIの力で掘り起こし、顧客対応の品質向上と業務効率化に繋げることができます。
ぜひ、この機会にAIを活用した新しいSPIRALの活用法をご検討ください。