Heroku Connect で同期した Sales Cloud のデータを pgvector でベクトル検索してみた

:::note
Heroku Advent Calendar 2023 24日目の記事です🎄
:::

## はじめに

はいどうもー！リバネス開発チームのトミー（@tomyf）です٩( 'ω' )و

今回は、最近発表された Heroku Postgres 15 の pgvector の機能を使う機会があったので、初めて知ったことや気がついたことを記事にしたいと思います。

最近、弊社の Salesforce に蓄積されている某イベントの申請書群を対象に、検索できるようにしたいという要望があり、せっかくなので pgvector を試してみようとなったのがきっかけです。

## ベクトル検索とは？

当初、私は
(´・ω・)?「pgvector ってなんぞ？」 → ベクトル検索ができるようになるらしい
(´・ω・)?「ベクトル検索ってなんぞ？」 → データを多次元空間の空間で表して、検索ワードから距離の近いデータを検索するものらしい
( ^ω^ )「ほ〜ん、vector型のカラムにしてWhere文で比較すれば良いのかな？」
っと言った感じでした。

実際は、AIの力でデータをベクトル表現に変換する必要があり、その工程をエンべディングと呼ばれる事を知りました。
つまり、ベクトル変換を利用するためには事前にすべてのデータをエンべディング（=ベクトル化）する必要があります。

今回、エンべディングには OpenAI API の Embeddings API を利用しました。
この Embeddings API はデータを1536次元のベクトルに変換してくれます。

## Heroku Connect で同期したテーブルは vector 型を使えない！？

こちらの記事から抜粋です。
https://blog.heroku.com/pgvector-launch

> Search Salesforce Data: Use Heroku Connect to synchronize Salesforce data into Heroku, then create a new table with the embeddings since Heroku Connect can’t synchronize vector data types. This unlocks a whole new possibility to extend Salesforce like searching for similar support cases with embeddings from Service Cloud cases.

こちらを要約するとこうなります。

> Heroku Connect を使用して、ベクトルデータ型を同期することができないため、エンベディングを使用して新しいテーブルを作成する必要があります。これにより、エンベディングを使用して類似のサポートケースを検索するなど、機能を拡張することができます。

つまり、 Heroku Connect で同期したテーブルの `sfid` を外部キーとするテーブルを作成してから、そちらにベクトル型のカラムを追加してね。っということでした。

## Heroku Posgres のバージョンを15にアップグレードする

pgvector に対応したのは Heroku Postgres 15 なので、まずはバージョンをアップグレードする必要があります。
弊社では Heroku Connect を使用しているので、こちらの記事を参考に進めていきました。
https://devcenter.heroku.com/ja/articles/heroku-connect-maintenance-operations#upgrading-the-version-of-a-heroku-postgres-database

以下の流れで、バージョンアップを行います。

1. フォロワーデータベースをプロビジョニングする

    ```
    heroku addons:create heroku-postgresql:standard-2 --follow 
    HEROKU_POSTGRESQL_{カラー}_URL --app {アプリ名}
    ```

2. アプリをメンテナンスモードにする
3. Heroku Connect を停止する
4. フォロワーデータベースが追いついたか確認する

    ```
    heroku pg:info --app {アプリ名}
    ```
    
    `Behind By: 0 commits` なら追いついている

5. フォロワデータベースの PostgreSQL バージョンをアップグレードする

    ```
    heroku pg:upgrade HEROKU_POSTGRESQL_{新しく作られたカラー}_URL --app {アプリ名}
    ```
    
    `heroku pg:wait HEROKU_POSTGRESQL_{新しく作られたカラー}_URL --app {アプリ名}` で進行状況を監視できる

6. バージョンアップしたフォロワデータベースをプライマリデータベースにする

    ```
    heroku pg:promote HEROKU_POSTGRESQL_{新しく作られたカラー}_URL --app {アプリ名}
    ```

## pgvector を有効にする

Heroku Postgres のバージョンが15になったのを確認したら、以下のコマンドで pgvector を有効化させます。

```sql
CREATE EXTENSION IF NOT EXISTS vector
```

## 環境

- Heroku Postgres 15
- Laravel v9.25.1
- PHP v8.1.26
- pgvector/pgvector v0.1.4

## Laravel で pgvector を扱う

ここからは Laravel (PHP) での処理の内容になります。

### Laravel のマイグレーションで pgvector を有効にする

弊社では Heroku Postgres の状態を Laravel で管理しているので、 pgvector の有効化をマイグレーションで制御します。

```php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        DB::statement('DROP EXTENSION vector');
    }
};
```
### Salesforce の外部キーと vector カラムを持つテーブルを作成する

Heroku Connect で作成されたテーブルに vector カラムを追加することはできないので、外部キーと vector カラムを持つテーブルを作成します。
なお、OpenAI API で作成されるエンべディングに合わせて、 vector は1536次元を設定することにします。

```php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('vectors', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->string('lid_data')->unique();
            $table->vector('embedding', 1536);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('vectors');
    }
};
```

### OpenAI API を利用してエンべディングを作成する

公式ドキュメントを参考に Embeddings API を実行します。
https://platform.openai.com/docs/guides/embeddings/what-are-embeddings

この記事では `$projectEntries` の配列内にある `abstract__c` を対象にエンべディングを行います。
ベクトル化されたデータと元のデータの紐付けをして、 `vectors` テーブルに格納していきます。
また、DBの負荷軽減のために5000件ずつに分けて挿入しています。

```php
private function fetchEmbeddings(Collection $projectEntries)
{
    $apiKey = config('services.openai.api_key');
    $response = Http::withToken($apiKey)->acceptJson()->asJson()->baseUrl('https://api.openai.com')->post('/v1/embeddings', [
        'input' => $projectEntries->pluck('abstract__c')->toArray(),
        'model' => 'text-embedding-ada-002',
    ]);

    collect($response['data'])
        ->map(function ($value) use ($projectEntries) {
            return [
                'id' => Str::orderedUuid()->toString(),
                'lid_data' => $projectEntries[$value['index']]->sfid,
                'embedding' => new PGVector($value['embedding']),
            ];
        })
        ->chunk(5000)
        ->each(function ($values) {
            Vector::upsert($values->toArray(), ['lid_data'], ['embedding']);
        });
}
```

### ベクトル検索をする

これでベクトル検索の事前準備が終わりました！
それでは早速、ベクトル検索を試してみますo(^▽^)o

結果は載せられませんが、満足のいく結果が得られました！
検索する文章の具体性が高い程、関連するデータが出てきやすかったです。

```php
self::search('ロボットを使って海をきれいにする');
```

```php
use App\Models\ProjectEntry;
use App\Models\Vector;

private function search($query)
{
    $apiKey = config('services.openai.api_key');
    $response = Http::withToken($apiKey)->acceptJson()->asJson()->baseUrl('https://api.openai.com')->post('/v1/embeddings', [
        'input' => $query,
        'model' => 'text-embedding-ada-002',
    ]);
    
    $embedding = $response['data'][0]['embedding'];
    
    $projectEntries = ProjectEntry::query()
        ->joinSub(Vector::select(['lid_data', 'embedding']), 'vectors', 'lid_data__c.sfid', '=', 'vectors.lid_data')
        ->nearestNeighbors('embedding', $embedding, Distance::L2)
        ->paginate($perPage);
}
```

※ サブジョインで可能な限り不要な項目を省いてから、ジョインさせています。

## おわりに

Salesforce 内のデータのベクトル検索に対応できました！
結構良さそうな精度でデータを引っ張ってこれるので、重宝しそうです。

所感としてはエンべディングに始まり、エンべディングで終わる感じでした。
検索時にもエンべディングが必要とは思わなかったです。

検索する度にエンべディングが必要になるので、 OpenAI API の使用料には注意が必要ですよ！

それではまたお会いしましょう！
バイバイ〜(´・ω・`)ノシ