先にPostgreSQL関連の記事を投稿しましたが、長年MySQL(MariaDB)を愛用してきた私が今更ながらPostgreSQLにチャレンジしているのは、ベクトル検索を試してみたいと思ったためです。
MySQLに関しても派生的なサービス(有償?)ではベクトル検索に対応したものもあるようですが、MySQL本体としては未サポートのようで、直近で気軽に(無償で)試してみられる状況にはなさそうです。
と言うことで、今回はローカルPCの開発環境(Mac + VirtualBox + Vagrant + Ubuntu22.04 + PostgreSQL17)でベクトル検索を使えるようにしたいと思います。
ベクトル検索とは
「ベクトル検索」とは、ベクトルを対象とした検索です。当たり前ですね。
「ベクトル」とは数学的な定義で言えば「大きさと向きを持った量」と言ったところですが、簡単に言ってしまえば数値の集合であり、プログラム的には配列で表現されるものです。
ただ、ベクトル検索の対象となるベクトル(配列)はテキスト、画像、音声などのデータの意味や特徴を数値化したものです。この、対象となるデータの意味や特徴をベクトルに変換する操作を「エンベディング」と言います。
つまり、何らかの対象データをエンベディングにより数値の集合体に変換した結果、と言うのがベクトル検索に絡めた場合の「ベクトル」の意味と言って良いでしょう。
ベクトルに関して「距離」の概念を導入することができます。
例えば2次元のベクトルA(1,1)に対して、他の3つのベクトルB(2,2)、C(1,-1)、D(1,0)との距離を考えてみます。
ベクトルの終点間の距離(正確にはユークリッド距離)を「ベクトル間の距離」とすると、Aと他のベクトルとの距離は、Bは√2、Cは2、Dは1になります。
これは、Aに対してはDが最も近く、B,Cの順で遠くなっていると判断できるようになったことを意味します。
この判断方法を検索条件に適用すれば、事前に登録された大量のベクトルの中から所定のベクトルに近い順で一定個数のレコードを抽出してくることも可能になる訳です。
「ベクトル」は対象データの意味や特徴を数値化したものであり、距離の近いベクトルは意味や特徴の類似性が高いと言えます(この辺はエンベディングの方法にも依存すると思いますが)。
つまり、対象データをベクトル化することで、意味や特徴の近さ(類似度)での検索が可能になります。
これは従来の検索が所定の値との全体もしくは部分的な「一致」を基準に行なっていたこととは大きく異なる特徴であり、それを可能とするのが「ベクトル検索」です。
蛇足ながら、実際のベクトル検索における類似度の判定基準は上記で説明した「ユークリッド距離」だけではないのですが、ここでは話が複雑になるので割愛します。
パッケージインストール(pgvector)
ベクトル検索はPostgreSQLの標準的な機能として実現されているのではなく、pgvectorなる拡張機能を追加インストールすることで利用可能になります。
# apt install postgresql-17-pgvector
パッケージが正しくインストールできたか確認してみましょう。
# apt list --installed | grep postgresql
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
postgresql-17-pgvector/jammy-pgdg,now 0.8.0-1.pgdg22.04+1 amd64 [installed]
postgresql-17/jammy-pgdg,now 17.2-1.pgdg22.04+1 amd64 [installed]
postgresql-client-17/jammy-pgdg,now 17.2-1.pgdg22.04+1 amd64 [installed,automatic]
postgresql-client-common/jammy-pgdg,now 267.pgdg22.04+1 all [installed,automatic]
postgresql-common/jammy-pgdg,now 267.pgdg22.04+1 all [installed,automatic]
問題なさそうです。
テーブル作成
psqlでPostgreSQLにアクセスし、テーブルを作成していきます。
# psql
postgres=# CREATE DATABASE testdb;
CREATE DATABASE
postgres=# \c testdb;
You are now connected to database "testdb" as user "postgres".
testdb=# \dx
List of installed extensions
Name | Version | Schema | Description
---------+---------+------------+------------------------------
plpgsql | 1.0 | pg_catalog | PL/pgSQL procedural language
(1 row)
testdb=# CREATE EXTENSION vector;
CREATE EXTENSION
testdb=# \dx
List of installed extensions
Name | Version | Schema | Description
---------+---------+------------+------------------------------------------------------
plpgsql | 1.0 | pg_catalog | PL/pgSQL procedural language
vector | 0.8.0 | public | vector data type and ivfflat and hnsw access methods
(2 rows)
まずはデータベース「testdb」を生成し、「CREATE EXTENSION」で先にインストールした拡張機能「pgvector」を有効にしています。
この操作はデータベースごとに行う必要があるようです。
また、指定する拡張機能の名称が「pgvector」ではなく「vector」である点も要注意です。
「\dx」は使用可能となっている拡張機能を確認するためのコマンドです。
「CREATE EXTENSION」実行後に「vector」機能が使用可能になっていることが分かります。
次に上記で生成したデータベースにテーブルを作成します。
testdb=# CREATE TABLE items (id bigserial PRIMARY KEY, embedding vector(3));
CREATE TABLE
testdb=# \dt
List of relations
Schema | Name | Type | Owner
--------+-------+-------+----------
public | items | table | postgres
(1 row)
テーブルはidとembeddingの2つのカラムから構成され、idはプライマリキー、embeddingは検索対象となるベクトルです。
embeddingの型が「vector」である点が重要です。次元数を指定する必要があるようなので、今回は簡単に3次元にしました。
テストデータ生成
今回はあくまで簡単な動作確認を行うことが目的なので、検索対象となるベクトルデータを適当に生成したいと思います。
Gemini先生から以下のようなSQLを教えていただきました。
testdb=# INSERT INTO items (embedding)
SELECT ARRAY[random(), random(), random()]::vector
FROM generate_series(1, 100);
INSERT 0 100
random関数は0から1未満の倍精度浮動小数点数型のランダムな値を返すようです。
上記SQLを実行することで、0から1未満のランダムな値を要素に持つ3次元のベクトル100個を生成できます。
最初の5個を確認してみましょう。
testdb=# select * from items limit 5;
id | embedding
----+------------------------------------
1 | [0.5710897,0.8096412,0.65953577]
2 | [0.9841591,0.2524546,0.74214983]
3 | [0.20843579,0.76459855,0.84265333]
4 | [0.12362185,0.69251436,0.14435722]
5 | [0.29529455,0.4365992,0.9215352]
(5 rows)
確かに0から1未満のランダムな数値3個から構成されるベクトルが生成されているようです。
ベクトル検索の実行
では、いよいよベクトル検索を行なってみましょう。
対象となるベクトルを考えるのが面倒なので、生成されたベクトルの中から4番目のベクトルを指定して検索してみたいと思います。
testdb=# SELECT
id,
embedding,
embedding <-> '[0.12362185,0.69251436,0.14435722]' AS distance
FROM items
ORDER BY distance
LIMIT 10;
id | embedding | distance
----+-------------------------------------+---------------------
4 | [0.12362185,0.69251436,0.14435722] | 0
55 | [0.20468295,0.670855,0.19496687] | 0.09798655915415093
40 | [0.12729597,0.4585914,0.023927728] | 0.2631286960170064
60 | [0.407607,0.7406687,0.1059569] | 0.2905873084494271
9 | [0.43927667,0.70147645,0.18590301] | 0.3185032712418081
95 | [0.33291745,0.9551113,0.059494425] | 0.3463574806093081
68 | [0.088931456,0.40865183,0.36994162] | 0.36423850327926366
25 | [0.3149057,0.45645246,0.3772269] | 0.3828093859487499
7 | [0.37682474,0.41681066,0.22852291] | 0.3836770707207596
99 | [0.46199197,0.48390046,0.117284425] | 0.39843071202498515
(10 rows)
上記ではカラム「embedding」に対して演算子「<->」を用いて計算した結果を「distance」というデータとして定義しています。
演算子「<->」はその後に指定されたベクトルとのユークリッド距離を計算するもので、つまりは「distance」は指定されたベクトル([0.12362185,0.69251436,0.14435722])と「embedding」の各値のユークリッド距離ということになります。
さらに「ORDER BY」で「distance」が小さい(近い)順にソートし、「LIMIT」で上位10件に絞っています。
結果も上記通りで、同じベクトル(ID=4)に関しては「distance」が0になるのは当然として、その他のベクトルに関してもそれっぽい結果が出ているように見えます。
では、検索対象のベクトルの最初の値に0.1プラスしてみましょう。
結果は以下の通り。
testdb=# SELECT
id,
embedding,
embedding <-> '[0.22362185,0.69251436,0.14435722]' AS distance
FROM items
ORDER BY distance
LIMIT 10;
id | embedding | distance
----+-------------------------------------+---------------------
55 | [0.20468295,0.670855,0.19496687] | 0.05821637780403456
4 | [0.12362185,0.69251436,0.14435722] | 0.09999999422579987
60 | [0.407607,0.7406687,0.1059569] | 0.1940205089239784
9 | [0.43927667,0.70147645,0.18590301] | 0.21980303811009852
40 | [0.12729597,0.4585914,0.023927728] | 0.28018188310362857
95 | [0.33291745,0.9551113,0.059494425] | 0.2968238359834101
99 | [0.46199197,0.48390046,0.117284425] | 0.31791981969663874
7 | [0.37682474,0.41681066,0.22852291] | 0.326446805655915
47 | [0.48867017,0.8958795,0.09652027] | 0.3374853625478919
25 | [0.3149057,0.45645246,0.3772269] | 0.3439276734594193
先ほど2番目の近さにあったID=55のベクトルが最も近いと判断されるようになりました。
ID=4とID=55は比較的似た数値で構成されており、一番差が大きかった最初の値がID=55に近い値に変わっているので、上記結果は直感的にも納得できるものかと思います。
では、別の演算も試してみましょう。
以下では演算子「<=>」を使っています。
これは「コサイン距離」なるものを計算するものです。
testdb=# SELECT
id,
embedding,
embedding <=> '[0.12362185,0.69251436,0.14435722]' AS distance
FROM items
ORDER BY distance
LIMIT 10;
id | embedding | distance
----+------------------------------------+----------------------
4 | [0.12362185,0.69251436,0.14435722] | 0
55 | [0.20468295,0.670855,0.19496687] | 0.009090136836020668
40 | [0.12729597,0.4585914,0.023927728] | 0.015884631074191136
95 | [0.33291745,0.9551113,0.059494425] | 0.02259510390617181
60 | [0.407607,0.7406687,0.1059569] | 0.05434929340951733
47 | [0.48867017,0.8958795,0.09652027] | 0.05617696524781379
9 | [0.43927667,0.70147645,0.18590301] | 0.06935690773057346
74 | [0.54690385,0.89856815,0.34991896] | 0.0699755665977434
17 | [0.62180436,0.9451308,0.3144874] | 0.07880918764414113
45 | [0.54410166,0.86179245,0.39861217] | 0.08178555737481874
(10 rows)
最初の3件まではユークリッド距離の場合と同じですが、それ以降は違っています。
ユークリッド距離は純粋に2つのベクトルの終点間の距離を表す値ですが、コサイン距離はベクトルの大きさを考慮せず(正規化なる操作により向きが同じまま大きさが1になるように変換した上で)ベクトルの向きの近さのみを数値化したものらしいので、この違いが生じているようです。
このように、同じベクトル検索でも「近さ」の判断基準に用いる値が変わると結果も変わるため適切な選択が重要になりそうです。
まとめ
期待通りベクトル検索が試せるようになりましたが、原理的には2つのベクトルの「距離」を計算し、その近いものから順に所定の個数を抽出してくるだけであるため、現時点ではあまり感動はありませんね。
やはり、実際に何らかのデータをベクトル化してみて、その検索結果として確かに類似度の高い物が抽出できたという感触を得てみたいものです。
そのためにはエンベディングの方法が重要になるかと思いますが、その辺は次の機会に。