はじめに
以前書いた「pgvectorで学ぶVector Database(RAGの基礎)」の続きです。
もしVector Databaseの概要を知らなければ、そちらの記事の図を見ていただければなんとなくわかると思います。
また、記事に出てくる用語は「補足」章にまとめています。必要であれば参照してください。
前回の記事では、20件の英語のサンプル文を入れてベクトル検索を動かしてみたのですが、最後のおまけでIVFFlatインデックスを貼って検索したときに、インデックスを貼る前とコサイン距離の数値も順位もまったく同じになる、という結果になっていました。
近似検索なのに結果が変わらないのはデータ件数が少なすぎたからとArgonさんに回答を頂いたので、その他機能とともに検証します。今回は日本語の文章データを3,000件入れて、psqlでログインして各機能を手で動かしながら試してみました。
検証に使ったファイル等はこちらにおいてます:https://github.com/hsmto25519/pgvector-test
今回の検証の前提
検証に入る前に、データの作り方についていくつか前提を置いておきます。
まず、実際にドキュメントなどを対象にRAGを組む場合は、長い文章を適切な長さに分割する処理が必要になりますが、今回はpgvector自体の動きを見ることが目的なので、分割処理は省略して最初から1文ずつに分かれた日本語の文章がある前提で進めます。
また、テーブルへ入れる日本語の文章については、社内規定やIT運用のマニュアルを想定した20カテゴリ・3,000件のセンテンスをAIに出力してもらい、1つのJSONファイル(sentences_3000.json)に用意して使っています。
検証準備
環境確認
前回と同じく、WSL2(Ubuntu 24.04)上のDockerとPythonを使います。
まずはWSL上の環境を確認しておきます。
lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 24.04.1 LTS
Release: 24.04
Codename: noble
docker --version
Docker version 29.1.3, build 29.1.3-0ubuntu3~24.04.2
python3 --version
Python 3.12.3
DockerコンテナはPostgreSQL 18ベースの pgvector/pgvector:pg18 を使います。
services:
db:
image: pgvector/pgvector:pg18
container_name: pgvector_pg18_test
ports:
- "127.0.0.1:5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: secret0001
POSTGRES_DB: vector_db
volumes:
- pgdata:/var/lib/postgresql
volumes:
pgdata:
コンテナを起動して psql でログインし、バージョンと vector 拡張を確認します。PostgreSQL 18.6、pgvectorは 0.8.7 (現時点の最新版) が入っています。
docker exec -it pgvector_pg18_test psql -U postgres -d vector_db
psql (18.6 (Debian 18.6-1.pgdg12+2))
Type "help" for help.
vector_db=# CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION
vector_db=# \dx
List of installed extensions
Name | Version | Schema | Description
---------+---------+------------+------------------------------------------------------
plpgsql | 1.0 | pg_catalog | PL/pgSQL procedural language
vector | 0.8.7 | public | vector data type and ivfflat and hnsw access methods
(2 rows)
データ投入(Python)
3,000件のデータを手で入れるのは大変なので、データを入れるところだけPython(insert_data.py)で自動化します。
今回は sentences_3000.json に用意した3,000件の日本語文章を読み込み、Embeddingモデルを使ってベクトル化してからPostgreSQLへ投入します。初回実行時はモデルのダウンロードが走るためトータルで1分前後かかります。
- 利用Embeddingモデル:
intfloat/multilingual-e5-small(384次元)- モデル容量約500MB
また、あとでpsqlにログインして手動でSQLを実行するときに384次元の長い配列を毎回貼らなくて済むよう、検索クエリ用の文章(PostgreSQLのインデックスを使ってベクトル検索を高速化する方法)とそのベクトルを query_target という別テーブルに1件保存しています。
なお、multilingual-e5系のモデルは検索対象の文章に "passage: "、検索クエリに "query: " というプレフィックスを付けてベクトル化する仕様になっているため、コード内でもそれを付けて変換しています。
import json
import psycopg2
from psycopg2.extras import execute_values
from sentence_transformers import SentenceTransformer
MODEL_NAME = "intfloat/multilingual-e5-small"
# 1. 事前に用意した3,000件の日本語文章データを読み込む
with open("sentences_3000.json", "r", encoding="utf-8") as f:
data = json.load(f)
# 2. Embeddingモデルで3,000件の文章をベクトル化(E5モデルの仕様に合わせて "passage: " を付与)
model = SentenceTransformer(MODEL_NAME)
passages = ["passage: " + item["sentence"] for item in data]
embeddings = model.encode(passages, batch_size=128, show_progress_bar=True)
# 3. PostgreSQLへ接続してテーブル作成・データ投入
conn = psycopg2.connect(
host="localhost",
port=5432,
database="vector_db",
user="postgres",
password="secret0001",
)
conn.autocommit = True
cursor = conn.cursor()
cursor.execute("CREATE EXTENSION IF NOT EXISTS vector;")
cursor.execute("DROP TABLE IF EXISTS items;")
cursor.execute("""
CREATE TABLE items (
id SERIAL PRIMARY KEY,
category TEXT,
sentence TEXT,
embedding VECTOR(384)
);
""")
rows = []
for item, emb in zip(data, embeddings):
vec_str = "[" + ",".join(f"{float(v):.6f}" for v in emb) + "]"
rows.append((item["category"], item["sentence"], vec_str))
execute_values(
cursor,
"INSERT INTO items (category, sentence, embedding) VALUES %s",
rows,
)
cursor.execute("ANALYZE items;")
# 4. 検索用のクエリ文とベクトルを別テーブルに1件保存(検索クエリには "query: " を付与)
query_text = "PostgreSQLのインデックスを使ってベクトル検索を高速化する方法"
query_emb = model.encode("query: " + query_text)
query_vec = "[" + ",".join(f"{float(v):.6f}" for v in query_emb) + "]"
cursor.execute("DROP TABLE IF EXISTS query_target;")
cursor.execute("""
CREATE TABLE query_target (
q_text TEXT,
q_vec VECTOR(384)
);
""")
cursor.execute(
"INSERT INTO query_target VALUES (%s, %s::vector);",
(query_text, query_vec),
)
cursor.execute("ANALYZE query_target;")
cursor.close()
conn.close()
print("done")
スクリプトを実行してデータを入れます。
python insert_data.py
Batches: 100%|██████████████████████████████████████████| 24/24 [00:35<00:00, 1.47s/it]
done
データが入ったので、ここからはpsqlでPostgresにログインして手動でコマンドを実行していきます。
docker exec -it pgvector_pg18_test psql -U postgres -d vector_db
vector_db=# SELECT id, category, sentence FROM items WHERE id <= 5 ORDER BY id;
id | category | sentence
----+------------------+----------------------------------------------------------------------------
1 | データベース | vacuumを実行することで不要な領域を回収してテーブルの肥大化を防ぎます。
2 | 経費精算 | 交通費や出張旅費の精算は月末までに領収書を添付して申請してください。
3 | ネットワーク | 社内無線LANに接続する際は専用の証明書を端末にインストールしてください。
4 | コンテナ・Docker | 開発用のデータベース環境はDockerコンテナを使ってローカルで起動できます。
5 | セキュリティ規定 | 業務用アカウントのパスワードは12文字以上で設定し使い回しを避けてください。
(5 rows)
vector_db=# SELECT q_text FROM query_target;
q_text
--------------------------------------------------------------
PostgreSQLのインデックスを使ってベクトル検索を高速化する方法
(1 row)
(SELECT q_vec FROM query_target) を条件に使うことで、このクエリ文のベクトルを使った検索ができます。
検証
1. ベクトルデータのサイズとページ配置
まず、入れた384次元のベクトルと日本語の文章がPostgresの中で何バイトくらい使っているのか見てみます。
vector_db=# SELECT
vector_dims(embedding) AS dims,
pg_column_size(embedding) AS vector_bytes,
pg_column_size(items.*) AS total_row_bytes
FROM items LIMIT 1;
dims | vector_bytes | total_row_bytes
------+--------------+-----------------
384 | 1544 | 1696
(1 row)
vector型は1次元あたり4バイト(32-bit float)なので、4 Byte × 384次元 + 8 Byte(ヘッダ) = 1544 Bytesになっています。category や sentence の日本語テキストを合わせるとさらに増え、この例だと1696 Byteです。
続いて、各行がどのページに入っているかを ctid(ページ番号, ページ内の行番号)で見てみます。
vector_db=# SELECT id, ctid, pg_column_size(items.*) AS row_bytes
FROM items WHERE id <= 8 ORDER BY id;
id | ctid | row_bytes
----+-------+-----------
1 | (0,1) | 1696
2 | (0,2) | 1688
3 | (0,3) | 1700
4 | (0,4) | 1700
5 | (1,1) | 1708
6 | (1,2) | 1704
7 | (1,3) | 1700
8 | (1,4) | 1700
(8 rows)
Postgresの1ページはデフォルトで8KB(8,192 Bytes)なので、1行約1,700 Bytesだと1ページに4行((0,1)〜(0,4))入ったところで次のページ (1,1) に移っているのが分かります。
今回は3,000件入れたので、テーブル全体では 3,000 ÷ 4 = 750ページ(6,000 kB)になります。
2. インデックスなし(Seq Scan)と IVFFlat / HNSW の比較
前回の記事のおまけで気になっていた部分の検証です。
3,000件(750ページ)のデータに対して、インデックスなし(Seq Scan)、IVFFlat、HNSW でそれぞれ上位5件を検索し、EXPLAIN (ANALYZE, BUFFERS) で実行時間と読み込んだページ数、検索結果の精度を見てみます。
インデックスなし(Seq Scan)
まずはインデックスを貼らずに検索してみます。
vector_db=# SELECT id, category, sentence FROM items
ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
id | category | sentence
------+--------------+------------------------------------------------------------------------------------------------------
141 | データベース | PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1341 | データベース | 原則として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1741 | データベース | 基本方針として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2541 | データベース | セキュリティと品質維持のため、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2941 | データベース | 全社共通のルールとして、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
(5 rows)
vector_db=# EXPLAIN (ANALYZE, BUFFERS)
SELECT id, category, sentence FROM items
ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
Limit (cost=838.34..838.35 rows=5 width=188) (actual time=2.227..2.231 rows=5.00 loops=1)
Buffers: shared hit=751
InitPlan 1
-> Seq Scan on query_target (cost=0.00..1.01 rows=1 width=1544) (actual time=0.003..0.005 rows=1.00 loops=1)
Buffers: shared hit=1
-> Sort (cost=837.33..844.83 rows=3000 width=188) (actual time=2.225..2.226 rows=5.00 loops=1)
Sort Key: ((items.embedding <=> (InitPlan 1).col1))
Sort Method: top-N heapsort Memory: 27kB
Buffers: shared hit=751
-> Seq Scan on items (cost=0.00..787.50 rows=3000 width=188) (actual time=0.023..1.910 rows=3000.00 loops=1)
Buffers: shared hit=751
Planning Time: 0.057 ms
Execution Time: 2.273 ms
(13 rows)
Seq Scanなので、items テーブルの750ページとquery_targetの1ページを合わせた751ページ(Buffers: shared hit=751)を読み込んで、3,000件すべてと距離を計算しています。実行時間は2.273 msでした。
検索クエリ(PostgreSQLのインデックスを使ってベクトル検索を高速化する方法)に対して、データベースカテゴリの「PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。」など似た文5件(ID: 141, 1341, 1741, 2541, 2941)が上位5件として返ってきており、これが全件計算したときの正解の5件になります。
IVFFlat インデックス
次に、前回と同じようにIVFFlatインデックスを lists = 100(100個のグループに分割)で作成して検索してみます。
検索時にいくつのグループを調べるかを指定する ivfflat.probesを1と5に変えて実行してみました。
vector_db=# CREATE INDEX items_ivfflat_idx ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
CREATE INDEX
vector_db=# SET ivfflat.probes = 1;
SET
vector_db=# SELECT id, category, sentence FROM items
ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
id | category | sentence
------+--------------+------------------------------------------------------------------------------------------------------
141 | データベース | PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1341 | データベース | 原則として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1741 | データベース | 基本方針として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2541 | データベース | セキュリティと品質維持のため、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2941 | データベース | 全社共通のルールとして、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
(5 rows)
vector_db=# EXPLAIN (ANALYZE, BUFFERS)
SELECT id, category, sentence FROM items
ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=17.74..25.56 rows=5 width=188) (actual time=0.036..0.041 rows=5.00 loops=1)
Buffers: shared hit=32
InitPlan 1
-> Seq Scan on query_target (cost=0.00..1.01 rows=1 width=1544) (actual time=0.003..0.003 rows=1.00 loops=1)
Buffers: shared hit=1
-> Index Scan using items_ivfflat_idx on items (cost=16.73..4710.00 rows=3000 width=188) (actual time=0.035..0.040 rows=5.00 loops=1)
Order By: (embedding <=> (InitPlan 1).col1)
Index Searches: 1
Buffers: shared hit=32
Planning:
Buffers: shared hit=1
Planning Time: 0.032 ms
Execution Time: 0.051 ms
(13 rows)
probes = 1のときは、100個に分けたグループのうち一番近い1グループだけを見に行くので、読み込んだバッファ数が751 → 32に激減し、実行時間も2.273 ms → 0.051 msと40倍速くなりました。
今回はAIにサンプル文をまとめて生成してもらったせいか似た文章が多くて偏りがあり、たまたま上位5件とも同じ一番近いグループに入っていたため、probes = 1でも5件すべて正解と一致していました。データにばらつきがあってグループの境界付近に正解が散らばっている場合は、probes = 1だと別グループの正解を取りこぼすことがあります。
続いてivfflat.probes = 5に増やしてみます。
vector_db=# SET ivfflat.probes = 5;
SET
vector_db=# SELECT id, category, sentence FROM items
ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
id | category | sentence
------+--------------+------------------------------------------------------------------------------------------------------
141 | データベース | PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1341 | データベース | 原則として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1741 | データベース | 基本方針として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2541 | データベース | セキュリティと品質維持のため、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2941 | データベース | 全社共通のルールとして、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
(5 rows)
vector_db=# EXPLAIN (ANALYZE, BUFFERS)
SELECT id, category, sentence FROM items
ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=84.64..92.35 rows=5 width=188) (actual time=0.093..0.097 rows=5.00 loops=1)
Buffers: shared hit=78
InitPlan 1
-> Seq Scan on query_target (cost=0.00..1.01 rows=1 width=1544) (actual time=0.003..0.003 rows=1.00 loops=1)
Buffers: shared hit=1
-> Index Scan using items_ivfflat_idx on items (cost=83.62..4710.00 rows=3000 width=188) (actual time=0.092..0.096 rows=5.00 loops=1)
Order By: (embedding <=> (InitPlan 1).col1)
Index Searches: 1
Buffers: shared hit=78
Planning:
Buffers: shared hit=1
Planning Time: 0.032 ms
Execution Time: 0.106 ms
(13 rows)
probes = 5にすると近い5グループを見に行くようになり、読み込むページ数は32 → 78に増えますが、それでも実行時間は0.106 msと、Seq Scan(2.273 ms・751ページ)の20倍以上速くなっています。
なお、IVFFlatのグループ分けはインデックス作成時に乱数で決める部分があるようで、同じデータでもインデックスを作り直すと読み込むページ数などの結果が一致しない場合があります。私も2回目に動かしたときはshared hit=61になりました。後述するHNSWも同様のようです。
簡単に補足です。
近似最近傍探索(ANN)というのは、距離の計算式自体を大雑把に近似しているわけではなく、調べる対象の範囲(グループやグラフの探索範囲)を絞ることで読み込むページ数と計算回数を減らす仕組みです。そのため、探索範囲に入ったデータについてはコサイン距離の数値自体は厳密計算とまったく同じになり、近いグループの中に上位データが収まっていれば順位も数値も変わらない、ということみたいです。これで前回の疑問になっていた部分は検証できたかと思います。
HNSW インデックス
続いて、一度IVFFlatインデックスを消してから、HNSWインデックスを作成して試してみます。
vector_db=# DROP INDEX items_ivfflat_idx;
DROP INDEX
vector_db=# CREATE INDEX items_hnsw_idx ON items USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
CREATE INDEX
vector_db=# SELECT id, category, sentence FROM items
ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
id | category | sentence
------+--------------+------------------------------------------------------------------------------------------------------
141 | データベース | PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1341 | データベース | 原則として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1741 | データベース | 基本方針として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2541 | データベース | セキュリティと品質維持のため、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2941 | データベース | 全社共通のルールとして、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
(5 rows)
vector_db=# EXPLAIN (ANALYZE, BUFFERS)
SELECT id, category, sentence FROM items
ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=470.69..480.01 rows=5 width=188) (actual time=0.208..0.212 rows=5.00 loops=1)
Buffers: shared hit=436
InitPlan 1
-> Seq Scan on query_target (cost=0.00..1.01 rows=1 width=1544) (actual time=0.007..0.007 rows=1.00 loops=1)
Buffers: shared hit=1
-> Index Scan using items_hnsw_idx on items (cost=469.68..6064.00 rows=3000 width=188) (actual time=0.207..0.210 rows=5.00 loops=1)
Order By: (embedding <=> (InitPlan 1).col1)
Index Searches: 1
Buffers: shared hit=436
Planning:
Buffers: shared hit=1
Planning Time: 0.069 ms
Execution Time: 0.229 ms
(13 rows)
HNSWは5件とも正解と一致し、実行時間は0.229 msでした。
比較のためにIVFFlatももう一度作って、テーブルと各インデックスのサイズも見比べてみます。
vector_db=# CREATE INDEX items_ivfflat_idx ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
CREATE INDEX
vector_db=# SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS size
FROM pg_class
WHERE relname IN ('items', 'items_ivfflat_idx', 'items_hnsw_idx');
relname | size
-------------------+---------
items | 6000 kB
items_hnsw_idx | 6008 kB
items_ivfflat_idx | 5264 kB
(3 rows)
ここまでの実行時間・バッファ読込数・精度・サイズをまとめると以下のようになりました。
| 検索方法 | 実行時間 | 読込ページ数 (shared hit) |
上位5件の一致数 | インデックスサイズ |
|---|---|---|---|---|
インデックスなし (Seq Scan) |
2.273 ms |
751 |
5 / 5(基準) | - |
IVFFlat (probes = 1) |
0.051 ms |
32 |
5 / 5 | 5264 kB |
IVFFlat (probes = 5) |
0.106 ms |
78 |
5 / 5 | 5264 kB |
HNSW(デフォルト) |
0.229 ms |
436 |
5 / 5 | 6008 kB |
IVFFlatは探索するグループ数を絞れるのでバッファ読込数も実行時間もかなり小さく抑えられますが、あらかじめデータが入っている状態で作成しないとグループ分けがうまくいかないという注意点があります。一方、HNSWはデータ追加時にもグラフを繋いでくれてデフォルト設定のまま高い精度が出るので扱いやすいかなと思います。その分、グラフのリンク情報を持つのでインデックスサイズはテーブル本体と同じくらいの大きさ(6008 kB)になっています。
3. 距離計算演算子の比較
前回はコサイン距離(<=>)だけを使いましたが、pgvectorでは全部で6種類の距離計算演算子が用意されています。
| 演算子 | 距離の種類 | 対応する型 | 備考 |
|---|---|---|---|
<=> |
コサイン距離(Cosine) |
vector, halfvec, sparsevec
|
ベクトルの向きの違い(0〜2) |
<-> |
L2距離 |
vector, halfvec, sparsevec
|
2点間の直線距離 |
<#> |
負の内積(Inner Product) |
vector, halfvec, sparsevec
|
内積にマイナスを掛けた値 |
<+> |
L1距離 |
vector, halfvec, sparsevec
|
各次元の差の絶対値の合計 |
<~> |
ハミング距離(Hamming) | bit |
異なるビットの個数 |
<%> |
ジャッカード距離(Jaccard) | bit |
集合の重なり具合(0〜1) |
文章のベクトル検索(RAGなど)では、文章の長さや単語の出現回数によってベクトルの大きさ(長さ)が変わっても、ベクトルの向き(意味の方向)がどれくらい近いかを測れるコサイン距離を使うのが一般的です。
なお、Postgresのインデックススキャンは ORDER BY 演算子 ASCで使う仕組みになっているため、内積も似ているほど値が小さくなるように符号を反転させた負の内積(<#>)になっているようです。
ここではvector型で使える4つの演算子(<=>, <->, <#>, <+>)について、以下の2点に着目して比べてみます。
- 各演算子の実行時間(psqlの
\timingで計測) - コサイン距離で出てきた上位5件と比べて、他の演算子だとどの文章が近いと判定されるか(検索クエリ「PostgreSQLのインデックスを使ってベクトル検索を高速化する方法」に対する近さ)
純粋な距離計算の時間を比べるため、いったんインデックスを削除し、計測をオンにして4つの演算子を順番に実行してみます。
vector_db=# DROP INDEX items_ivfflat_idx, items_hnsw_idx;
DROP INDEX
vector_db=# \timing
Timing is on.
vector_db=# SELECT id, round((embedding <=> (SELECT q_vec FROM query_target))::numeric, 6) AS cosine_dist, sentence
FROM items ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
id | cosine_dist | sentence
------+-------------+------------------------------------------------------------------------------------------------------
141 | 0.086783 | PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1341 | 0.087194 | 原則として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1741 | 0.091633 | 基本方針として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2541 | 0.097568 | セキュリティと品質維持のため、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2941 | 0.098207 | 全社共通のルールとして、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
(5 rows)
Time: 3.897 ms
vector_db=# SELECT id, round((embedding <-> (SELECT q_vec FROM query_target))::numeric, 6) AS l2_dist, sentence
FROM items ORDER BY embedding <-> (SELECT q_vec FROM query_target) LIMIT 5;
id | l2_dist | sentence
------+----------+------------------------------------------------------------------------------------------------------
141 | 0.416612 | PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1341 | 0.417598 | 原則として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1741 | 0.428096 | 基本方針として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2541 | 0.441743 | セキュリティと品質維持のため、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2941 | 0.443185 | 全社共通のルールとして、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
(5 rows)
Time: 2.706 ms
vector_db=# SELECT id, round((embedding <#> (SELECT q_vec FROM query_target))::numeric, 6) AS neg_ip, sentence
FROM items ORDER BY embedding <#> (SELECT q_vec FROM query_target) LIMIT 5;
id | neg_ip | sentence
------+-----------+------------------------------------------------------------------------------------------------------
141 | -0.913217 | PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1341 | -0.912806 | 原則として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1741 | -0.908367 | 基本方針として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2541 | -0.902432 | セキュリティと品質維持のため、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2941 | -0.901793 | 全社共通のルールとして、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
(5 rows)
Time: 2.695 ms
vector_db=# SELECT id, round((embedding <+> (SELECT q_vec FROM query_target))::numeric, 6) AS l1_dist, sentence
FROM items ORDER BY embedding <+> (SELECT q_vec FROM query_target) LIMIT 5;
id | l1_dist | sentence
------+----------+------------------------------------------------------------------------------------------------
141 | 6.382686 | PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1341 | 6.445336 | 原則として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1741 | 6.559072 | 基本方針として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2941 | 6.849228 | 全社共通のルールとして、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
541 | 6.869993 | 社内ルールとして、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
(5 rows)
Time: 3.595 ms
4つの演算子の実行時間と、上位5件の結果をまとめると以下の通りです。
| 演算子 | 実行時間 | 上位5件のID | コサイン距離との比較 |
|---|---|---|---|
<=>(コサイン距離) |
3.897 ms |
141, 1341, 1741, 2541, 2941 |
基準(クエリ文に意味が一番近い上位5件) |
<->(L2距離) |
2.706 ms |
141, 1341, 1741, 2541, 2941 |
上位5件・順位ともコサイン距離と完全に一致 |
<#>(負の内積) |
2.695 ms |
141, 1341, 1741, 2541, 2941 |
実行時間が最速で、上位5件・順位ともコサイン距離と完全に一致 |
<+>(L1距離) |
3.595 ms |
141, 1341, 1741, 2941, 541 |
1〜3位は同じだが、4位と5位が入れ替わる |
※文章の比較は一般的にコサイン距離で実施することが多いため、コサイン距離を基準にしています
実行時間で見ると、各次元の掛け算と足し算だけで済む負の内積が2.695 msで一番速く、ベクトルの長さで割る計算が入るコサイン距離が3.897 msと一番時間がかかっています。
また、検索結果の順位を見ると、コサイン距離、L2距離、負の内積の3つが1位から5位までまったく同じ結果になりました。
4. 量子化(halfvec / bit)でどのくらいサイズが下がるか
先ほどHNSWインデックスがテーブル本体と同じくらいのサイズになっていましたが、データ量が増えてきたときにメモリやディスクを節約する機能として、halfvec(16-bit浮動小数点)と binary_quantizeが使えます。
文章検索で一般的なコサイン距離をベースに、元のvector(384)からhalfvec(384)・bit(384)にしたときに、サイズ・実行時間・検索結果がどう変わるかを比べてみました(※ bit型は0と1のビット列でコサイン距離が使えないため、bitのみハミング距離 <~> を使います)。
vector_db=# ALTER TABLE items
ADD COLUMN embedding_half halfvec(384),
ADD COLUMN embedding_bit bit(384);
ALTER TABLE
vector_db=# UPDATE items
SET embedding_half = embedding::halfvec(384),
embedding_bit = binary_quantize(embedding)::bit(384);
UPDATE 3000
vector_db=# ANALYZE items;
ANALYZE
vector_db=# CREATE INDEX items_hnsw_vec_idx ON items USING hnsw (embedding vector_cosine_ops);
CREATE INDEX
vector_db=# CREATE INDEX items_hnsw_half_idx ON items USING hnsw (embedding_half halfvec_cosine_ops);
CREATE INDEX
vector_db=# CREATE INDEX items_hnsw_bit_idx ON items USING hnsw (embedding_bit bit_hamming_ops);
CREATE INDEX
まず、1カラムあたりのバイト数と、HNSWインデックスのサイズを見てみます。
vector_db=# SELECT
pg_column_size(embedding) AS vec_bytes,
pg_column_size(embedding_half) AS half_bytes,
pg_column_size(embedding_bit) AS bit_bytes
FROM items LIMIT 1;
vec_bytes | half_bytes | bit_bytes
-----------+------------+-----------
1540 | 776 | 53
(1 row)
vector_db=# SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS index_size
FROM pg_class
WHERE relname IN ('items_hnsw_vec_idx', 'items_hnsw_half_idx', 'items_hnsw_bit_idx');
relname | index_size
---------------------+------------
items_hnsw_vec_idx | 6008 kB
items_hnsw_half_idx | 3440 kB
items_hnsw_bit_idx | 1032 kB
(3 rows)
そして、実行時間を計測しながらそれぞれのインデックスを使って上位5件を検索してみます。
vector_db=# SELECT id, round((embedding <=> (SELECT q_vec FROM query_target))::numeric, 6) AS cosine_dist, sentence
FROM items ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
id | cosine_dist | sentence
------+-------------+------------------------------------------------------------------------------------------------------
141 | 0.086783 | PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1341 | 0.087194 | 原則として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1741 | 0.091633 | 基本方針として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2541 | 0.097568 | セキュリティと品質維持のため、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2941 | 0.098207 | 全社共通のルールとして、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
(5 rows)
Time: 0.774 ms
vector_db=# SELECT id, round((embedding_half <=> (SELECT q_vec::halfvec(384) FROM query_target))::numeric, 6) AS cosine_dist, sentence
FROM items ORDER BY embedding_half <=> (SELECT q_vec::halfvec(384) FROM query_target) LIMIT 5;
id | cosine_dist | sentence
------+-------------+------------------------------------------------------------------------------------------------------
141 | 0.086797 | PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1341 | 0.087198 | 原則として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1741 | 0.091638 | 基本方針として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2541 | 0.097574 | セキュリティと品質維持のため、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2941 | 0.098213 | 全社共通のルールとして、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
(5 rows)
Time: 1.045 ms
vector_db=# SELECT id, (embedding_bit <~> (SELECT binary_quantize(q_vec)::bit(384) FROM query_target)) AS hamming_dist, sentence
FROM items ORDER BY embedding_bit <~> (SELECT binary_quantize(q_vec)::bit(384) FROM query_target) LIMIT 5;
id | hamming_dist | sentence
------+--------------+------------------------------------------------------------------------------------------
1741 | 27 | 基本方針として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
2141 | 27 | 日常業務において、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
141 | 27 | PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
1341 | 27 | 原則として、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
541 | 28 | 社内ルールとして、PostgreSQLでインデックスを作成すると検索クエリの処理が高速になります。
(5 rows)
Time: 0.611 ms
結果をまとめると以下のようになりました。
| データ型(演算子) | 1カラムのサイズ | HNSWインデックスサイズ | 実行時間 |
vector(コサイン距離)との結果比較 |
|---|---|---|---|---|
vector (<=>) |
1540 Bytes |
6008 kB |
0.774 ms |
基準(上位5件:141, 1341, 1741, 2541, 2941) |
halfvec (<=>) |
776 Bytes |
3440 kB |
1.045 ms |
上位5件・順位とも完全に一致(距離の小数第5位以下のみ微差) |
bit (<~>) |
53 Bytes |
1032 kB |
0.611 ms |
似た文章が上位に入るが、同率(27)が並び順位が入れ替わる |
halfvecにすると、カラムサイズもHNSWインデックスのサイズも約半分(6008 kB → 3440 kB)になり、コサイン距離の値も小数第5位以下がほんの少し変わるだけで、検索結果の上位5件は元のvectorとまったく同じでした。実行時間だけ見るとvector(0.774 ms)よりhalfvec(1.045 ms)の方が少し大きくなっていますが、3,000件くらいだとどちらも1ms前後で終わってしまうので、クエリ内の型キャストや実行タイミングの誤差かなと思います(もっと件数大きくしてやり直したいですが大変なので省略)。精度をほぼ落とさずにメモリ・ディスク使用量を半分近く減らせるので、普段使いならhalfvecはかなり良さそうです。
一方、bit型はサイズが53 Bytes(インデックスも約1/6の1032 kB)、実行時間も0.611 msまで下がり、意味が近い「PostgreSQLでインデックスを作成すると〜」の文章群をちゃんと拾えていますが、1次元を正か負かの1ビットに丸めて整数のハミング距離で比較するため、上位4件がすべて距離27で同率になり細かな順位は入れ替わっていました。
5. WHERE句で絞り込んだときの結果0件問題と Iterative Index Scans
最後に、Iterative Index Scans(反復インデックススキャン)を試してみます。
ベクトル検索をするときに WHERE category = 'セキュリティ規定' のようにカテゴリで絞り込みたいケースはよくあると思いますが、HNSWインデックスを使って絞り込み検索をすると、結果が0件になってしまうことがあります。この挙動は hnsw.iterative_scan の設定で切り替えることができます。デフォルトは offです。
今回は全体の5%(3,000件中150件)が該当するcategory = 'セキュリティ規定'という条件で、hnsw.ef_search = 20にして上位5件を検索してみます。
まず hnsw.iterative_scan = off に設定した場合です。
vector_db=# \timing off
Timing is off.
vector_db=# SET hnsw.ef_search = 20;
SET
vector_db=# SET hnsw.iterative_scan = off;
SET
vector_db=# SELECT id, category, sentence FROM items
WHERE category = 'セキュリティ規定'
ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
id | category | sentence
----+----------+----------
(0 rows)
vector_db=# EXPLAIN (ANALYZE, BUFFERS)
SELECT id, category, sentence FROM items
WHERE category = 'セキュリティ規定'
ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=289.93..526.84 rows=5 width=188) (actual time=0.277..0.278 rows=0.00 loops=1)
Buffers: shared hit=290
InitPlan 1
-> Seq Scan on query_target (cost=0.00..1.01 rows=1 width=1544) (actual time=0.006..0.007 rows=1.00 loops=1)
Buffers: shared hit=1
-> Index Scan using items_hnsw_vec_idx on items (cost=288.92..7396.38 rows=150 width=188) (actual time=0.276..0.277 rows=0.00 loops=1)
Order By: (embedding <=> (InitPlan 1).col1)
Filter: (category = 'セキュリティ規定'::text)
Rows Removed by Filter: 20
Index Searches: 1
Buffers: shared hit=290
Planning:
Buffers: shared hit=1
Planning Time: 0.077 ms
Execution Time: 0.295 ms
(15 rows)
iterative_scan = offのときは、まずHNSWインデックスでベクトルが近い上位20件(ef_search = 20)だけを取ってきて、その20件に対して Filter: (category = 'セキュリティ規定') をかけています。上位20件がすべてデータベースカテゴリの文などで埋まっており、セキュリティ規定が1件も含まれていなかったため(Rows Removed by Filter: 20)、テーブル全体には150件あるのに (0 rows) になってしまいました。
次に hnsw.iterative_scan = relaxed_order に切り替えて同じSQLを実行してみます。
vector_db=# SET hnsw.iterative_scan = relaxed_order;
SET
vector_db=# SELECT id, category, sentence FROM items
WHERE category = 'セキュリティ規定'
ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
id | category | sentence
------+------------------+--------------------------------------------------------------------------------------------------------------------------------------
345 | セキュリティ規定 | Webアプリケーションを公開する前には脆弱性診断を実施しSQLインジェクションやXSS対策を確認します。
365 | セキュリティ規定 | パスワードマネージャーを利用してサービスごとに異なるランダムな高強度パスワードを生成・管理します。
1945 | セキュリティ規定 | 基本方針として、Webアプリケーションを公開する前には脆弱性診断を実施しSQLインジェクションやXSS対策を確認します。
1545 | セキュリティ規定 | 原則として、Webアプリケーションを公開する前には脆弱性診断を実施しSQLインジェクションやXSS対策を確認します。
2585 | セキュリティ規定 | セキュリティと品質維持のため、ソースコードや設定ファイルを個人のGitHubアカウントや外部のパブリックリポジトリへ公開してはいけません。
(5 rows)
vector_db=# EXPLAIN (ANALYZE, BUFFERS)
SELECT id, category, sentence FROM items
WHERE category = 'セキュリティ規定'
ORDER BY embedding <=> (SELECT q_vec FROM query_target) LIMIT 5;
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=289.93..526.84 rows=5 width=188) (actual time=1.501..2.168 rows=5.00 loops=1)
Buffers: shared hit=3237
InitPlan 1
-> Seq Scan on query_target (cost=0.00..1.01 rows=1 width=1544) (actual time=0.007..0.008 rows=1.00 loops=1)
Buffers: shared hit=1
-> Index Scan using items_hnsw_vec_idx on items (cost=288.92..7396.38 rows=150 width=188) (actual time=1.500..2.165 rows=5.00 loops=1)
Order By: (embedding <=> (InitPlan 1).col1)
Filter: (category = 'セキュリティ規定'::text)
Rows Removed by Filter: 528
Index Searches: 1
Buffers: shared hit=3237
Planning:
Buffers: shared hit=1
Planning Time: 0.113 ms
Execution Time: 2.192 ms
(15 rows)
iterative_scan = relaxed_orderに設定すると、ちゃんとセキュリティ規定の中からSQLインジェクション対策などデータベースに近い文章が5件返ってきました。
EXPLAINの結果を見比べると、裏側で何が起きたかがわかります。offのときはRows Removed by Filter: 20(最初の20件を調べて全部弾かれた時点で終了)だったのに対し、relaxed_orderではRows Removed by Filter: 528、rows=5.00となっており、さらに読み込んだページ数もshared hit=290 → 3237に増えています。つまり、最初の20件で見つからなくても探索を打ち切らず、条件に合うデータが LIMIT 5 件集まるまで(合計533件分)インデックスを自動で掘り進めてくれたことが確認できました。
後片付け
一通り検証が終わったら、作成したテーブルを削除してpsqlから抜けます(テーブルに紐づくインデックスも一緒に削除されます)。
vector_db=# DROP TABLE items, query_target;
DROP TABLE
vector_db=# \q
補足
用語まとめ
最後に、今回の検証やVector Database周りでよく出てくる用語を日本語と英語でまとめておきます。
| 日本語 | 英語 | 説明 |
|---|---|---|
| 埋め込み / ベクトル化 | Embedding | テキストや画像などの意味を、数百〜数千個の数値の配列(ベクトル)に変換すること。 |
| 次元数 | Dimensions | 1つのベクトルに含まれる数値の個数。今回使ったデータは384次元。 |
| 正規化 | Normalization | ベクトルの長さを 1 に揃えること。正規化しておくと内積とコサイン距離の順位が同じになる。 |
| 最近傍探索(厳密検索) | Exact Nearest Neighbor (ENN) / k-NN | 全データと距離を計算して正確な上位K件を探す方法。インデックスなしの Seq Scan がこれにあたる。 |
| 近似最近傍探索 | Approximate Nearest Neighbor (ANN) | 探索する範囲を絞ることで、多少の取りこぼしを許容する代わりに高速に検索する手法。IVFFlat や HNSW が該当する。 |
| 再現率 | Recall | 厳密検索での正解上位K件のうち、近似検索で何割を拾えたかを表す指標。 |
| 転置ファイルインデックス | IVFFlat (Inverted File Flat) | データをあらかじめ複数のグループ(lists)に分けておき、検索時は近いグループ(probes 個)だけを調べるインデックス。 |
| 階層的ナビゲーション可能スモールワールド | HNSW (Hierarchical Navigable Small World) | データを多層のグラフ構造で繋いで探索するインデックス。事前学習なしで高い精度が出やすい。 |
| 量子化 | Quantization | 32-bit浮動小数点(vector)をより少ないビット数で表現して、メモリやディスク使用量を減らす手法。 |
| 半精度浮動小数点(スカラー量子化) | Half-Precision Float (halfvec) |
1次元を32-bitから16-bit(2バイト)に減らすデータ型。精度をあまり落とさずにサイズを約半分にできる。 |
| バイナリ量子化 | Binary Quantization (binary_quantize) |
1次元を正負の 0 か 1(1ビット)に圧縮する手法。データサイズは約1/32になる。 |
| 疎ベクトル / 密ベクトル | Sparse Vector (sparsevec) / Dense Vector |
ほとんどの要素に値が入っている通常のベクトルが Dense Vector、単語の出現頻度のように大半の要素が 0 のベクトルが Sparse Vector。 |
| 再ランキング | Re-ranking | 軽いインデックス(bit など)で多めに候補を取ってきたあと、元の vector などで距離を計算し直して上位K件に並べ替える手法。 |
| 反復インデックススキャン | Iterative Index Scans |
WHERE 句で絞り込みをしたときに、LIMIT の件数に達するまでインデックス探索を自動で繰り返す機能。 |
| ページ(ブロック) | Page / Block | Postgresがディスクやメモリ(shared_buffers)を読み書きする基本単位(デフォルトは8KB)。 |
おわりに
前回の記事で疑問に残っていた「なぜ近似検索なのに結果や距離の数値が変わらなかったのか」というところから、pgvectorの各機能まで一通り実機で動かして確認できました。
実際に日本語の文章データを入れてpsqlでEXPLAIN (ANALYZE, BUFFERS)やインデックスのサイズを見ながら動かすと、裏側で何をやっているのかイメージしやすかったです。特にドキュメントを読んだだけではIterative Index Scansの何がIterative(反復)なのか理解できなかったのですが、実際の動きを見たらわかりやすかったです。
なお、今回の検証で試した機能や設定については基本的にすべてpgvector公式GitHubのREADMEに載っているので、細かいパラメータや仕様が気になった方はそちらもあわせて参照してみてください。
pgvectorはもうすぐ 0.9.0が公開されそうです。楽しみですね!
以上です。最後まで読んでいただきありがとうございます!
参考
- pgvector (GitHub)
- 前回の記事:pgvectorで学ぶVector Database(RAGの基礎)