【ベクトル類似度検索】DuckDBを用いたVSS(Vector Similarity Search)の検証
update:2025/07/13
Category : データベース VSS
duckdb is all you need.
想定される読者
VSSはテキストや画像などをエンベッド(埋め込み)し、ベクトル空間に落とし込み、その内容の類似性を評価する際などに用います。 近年流行しているRAG(Retrieval-Augmented Generation:検索拡張生成)や画像分類などで使われているものです。
今回はそんなVSSの利用時にDuckDBというDBがとても良いと言う紹介をし、実例を込みで紹介します。
DuckDBの使いやすさと性能の高さの両方を布教できたらなと考えています。
AIアプリの開発をする方、DBの選定に悩んでいる方、duckdbを知っているけれど魅力はまだ知らないという方などを想定しています。 どうぞ最後までお付き合いください。
DuckDBとは
DuckDBとは無料で使えるOSSで、非常にシンプルで高速。取り回しが良く、Linux,Windows,MacOSでも使え、なおかつ豊富な拡張機能を提供するデータベースです。PythonやJava,Rと言った言語から呼び出すこともできます。
GitHubでも公開されており、ビルド済みのバイナリファイルを手元に持ってくる事で誰でもどこでも簡単に使用することができます。基本的にC++で書かれており、メモリの管理も自前で実装するなどしていてかなり高速です。
JSONやCSV,Parquet(Arrow)ファイルなどを簡単に扱えるという強みもあり、大規模なデータを簡単に扱うことができます。
インストール
curl https://install.duckdb.org | sh
で最新バージョンのインストールできます。
特定のバージョンが良いという場合はGitHubにてリリースされているので、そちらからgz圧縮されたバイナリを持ってきて展開すれば使えます。
便利さ
ファイル置換
duckdbの便利さとして簡単にファイルを変換できる点が挙げられます。
例えばjsonをparquetに変換する際は
INSERT INTO {table_name}
SELECT * FROM read_json('{input_dir}/*.json.gz', ignore_errors=true,
columns ={
'title': 'TEXT',
}
);
COPY {table_name} TO '{output_file}' (FORMAT PARQUET, COMPRESSION GZIP);
とするだけでよいです。(テーブル定義の欄は空でも可能だが、たまにバグるのでおすすめしない)
他にも、複数のファイルを一つにまとめたい場合や、複数のファイルに渡って点在する要素を取り出して一つのファイルにまとめるなどと言ったことが簡単にできてしまいます。 とてもお手軽なんですね。
S3の直接参照
またDuckDBはAWSのS3ストレージのファイルも直接参照できます。
INSTALL 'httpfs';
LOAD 'httpfs';
SET s3_region='{AWS_REGION}';
SET s3_access_key_id='{AWS_ACCESS_KEY_ID}';
SET s3_secret_access_key='{AWS_SECRET_ACCESS_KEY}';
として直接S3のURLを
SELECT *
FROM read_parquet('s3://hoge.com/fuga.parquet')
のように読み込むこともできます。通信コストを考えるとサービスには適さないでしょうが、個人開発する方やワンショットで検証したい場合には便利だと思います。
pythonで用いる場合
pip install duckdb
でインストールし、
import duckdb
conn = duckdb.connect()
sql = """
SELECT *
FROM read_parquet('{parquet}')
"""
conn.execute(sql)
conn.commit()
のようにして手軽に使えます。
ベクトル類似度検索とは
VSSを知っている方は読み飛ばしてください。
機械学習の領域などで、画像やテキストなどをベクトルで表現することを埋め込み(embedding)といいます。
仮にベクトルに意味を持たせられれば、dogを(1,0)とし、catを(-1,0)のように対のように表現できるかもしれません。 (あるいは全く似ていないという意味でdogを(1,0)、catを(0,1)のように表現するかもしれません。)
画像でもこれは同様で、以下の図の様にCNNやVision Transformerでは画像をベクトル空間に埋め込む事で評価をしています。
近年ではテキストや単語、あるいは画像までもを統一された次元数のベクトル空間に落とし込むことでマルチモーダルに拡張する試みも見られます。
この様に統一化されたベクトル空間において、ある1つのクエリに対して他の要素がどの程度似ているかどうかを評価し、ランキングすることベクトル類似度検索(Vector Simillarity Search:VSS)を言います。
よく使われる指標はコサイン類似度で、高校で習ったでしょう内積を用いて
とし、の大きさで評価するものです。
ほぼ同じなら1(0),反対なら-1(),全く別の要素なら0(直交)となりますね。
DuckDBにおけるVSS
DuckDBではベクトル類似度の評価をSQLで手軽に実行できます。
CREATE TABLE embeddings (vec FLOAT[3]);
INSERT INTO embeddings VALUES
([2.0, 4.0, 6.0]),
([2.0, 3.0, 4.0]),
([10.0, 10.0, 10.0]),
([0.0, 0.0, 0.0]),
([1.1, 2.1, 3.1]);
SELECT *FROM embeddings
ORDER BY array_cosine_distance(vec, [-1.01, -2.01, -3.0]::FLOAT[3])
array_cosine_distanceの部分でコサイン類似度を算出しているんですね!
他にも以下のような距離関数が用意されています。
array_distance:L2距離(ユークリッド距離)array_negative_inner_product:負の内積(内積の符号を反転)array_cosine_distance:コサイン距離(1 - コサイン類似度)
特にarray_negative_inner_productは、正規化されたベクトル(単位ベクトル)に対してはコサイン類似度と同じ順序を提供し、計算が高速なため推奨される場合があります。
検証
まずはその性能の高さを証明します。
- 100万行の要素(ベクトル)を格納するparquetファイルを用意する
- ランダムでベクトルを生成する
- Duckdbを用いてVSSを実行し、かかった時間と消費したメモリを計測する
という手順で実験していきます。
実行環境
CPU
Architecture: x86_64
CPU(s): 12
On-line CPU(s) list: 0-11
Vendor ID: AuthenticAMD
Model name: AMD Ryzen 5 PRO 5650GE with Radeon Graphics
CPU max MHz: 4480.0000
CPU min MHz: 400.0000
Caches (sum of all):
L1d: 192 KiB (6 instances)
L1i: 192 KiB (6 instances)
L2: 3 MiB (6 instances)
L3: 16 MiB (1 instance)
メモリ
RANGE SIZE STATE REMOVABLE BLOCK
0x0000000000000000-0x00000000bfffffff 3G online yes 0-23
0x0000000100000000-0x000000081fffffff 28.5G online yes 32-259
Memory block size: 128M
Total online memory: 31.5G
Total offline memory: 0B
OS
$ cat lsb-release
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=24.04
DISTRIB_CODENAME=noble
DISTRIB_DESCRIPTION="Ubuntu 24.04.2 LTS"
DuckDB
- バージョン:v1.3.1
実験で使用するparquetファイルの定義
- 100万行。
- C++でparquetの生成コードを作成し、実験した。
| Field | Type |
|---|---|
| id | string |
| embed | vector(float32)(768次元) |
ランダムで作成するC++のコードはこのリンクにおいておきます。もしお手元でも検証されたいという方がいらっしゃればどうぞお試しください。
sudo wget https://apache.jfrog.io/artifactory/arrow/$(lsb_release --id --short | tr 'A-Z' 'a-z')/apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb
sudo apt install -y -V ./apache-arrow-apt-source-latest-$(lsb_release --codename --short).deb
sudo apt update
sudo apt install -y -V libarrow-dev libparquet-dev
で必要なライブラリをインストールし、
g++ make_random_parquet.cpp -o make_random_parquet `pkg-config --cflags --libs arrow parquet`
でビルドできます。
検証スクリプト
雛形のSQLを用意し、shellスクリプトでsedなどを用いて文字列置換をし、それを直接Duckdbに読ませることで実行します。
今回は以下のsqlを用いて
- Pythonのduckdbパケージで呼び出す
- Pythonからshellを実行する
- shellで呼び出す
の3通りを検証します。
CREATE TABLE {table_name} AS
SELECT * FROM read_parquet('{input_parquet}');
SELECT * FROM {table_name}
ORDER BY array_cosine_distance(embed::DOUBLE[{VECTOR_DIM}], {VECTOR})
LIMIT 10;
{input_parquet}:parquetファイル{VECTOR}:ランダムで生成したクエリのベクトル{VECTOR_DIM}:ベクトルの次元数(今回は768){table_name}:適当に決めて良い。hogeでもfoobarでもなんでも。
実行時間
| 実行方法 | 実行時間 (100回の検索) | 平均検索時間 | 検索/秒 |
|---|---|---|---|
| Python | 1327.01 秒 | 13270.13 ms | 0.08 |
| python(shell) | 1340.51 秒 | 13405.09 ms | 0.07 |
| shell | 1789.00秒 | 17888ms | 0.05 |
shellで実行した時間が一番長くなっているのが気になりますが、基本的にはどのフレームワークで実行してもそれなりの 性能が出せることがわかったと思います。
消費メモリ
基本的に読み込むparquetの大きさに比例します。
今回使用したparquetファイルの中味はfp32の768次元のベクトルが100万件なので GB 程度の大きさで、実際DuckDBが消費するメモリもこれと同程度になります。
また、以下のようにDuckDBはテーブルを作成せず直接ディスクのファイルを読み込むということもでき、 この場合は消費メモリはほとんど0になります。
SELECT * FROM read_parquet('{input_parquet}')
またDuckDBの特徴として、swapメモリを使用しないことが挙げられます。搭載するメモリの80%を上限とし、残りはうまいことディスクを参照することで消費メモリを減らしているようです。(src/main/config.cppのSetDefaultMaxMemory()関数参照)
sambaを用いたリモートサーバの検証
実際大規模なデータを扱う際はリモートのサーバにおいておくことが多いと思います。
そこでここではDuckDBのキャッシュの効きを評価し、ファイルロードの遅延をどの程度削減できているかを検証します。
以前私が【🍆】Network Attached Storage(NAS)をminiPC(GMKtec NucBoxG9)で作ろう!という記事の中でnasを作成しました。今回はこのnasを私の所属する研究室に配置し、自宅と遠隔で通信してみてどの程度遅延を削減できているかを検証します。
実験内容
1回目に読み込んだときと、二回目に読み込んだときとでどの程度差が出るかを検証しましょう。
環境
サーバを別でもう1台用意し、sambaを用いてファイル共有。
- ホスト側でマウントしてコンテナにvolume mountする形で共有する
$ pwd
/mnt/nas/parquet_file
$ ls
01.parquet 03.parquet 05.parquet 07.parquet
02.parquet 04.parquet 06.parquet
1ファイル辺りは3GB程度.
- ネットワーク図
- 通信速度
後述(iperf3を用いて計測する)
検証方法
検索sqlを
SELECT *
FROM read_parquet('{parquet}')
ORDER BY array_cosine_distance(embed::DOUBLE[{VECTOR_DIM}], {vector_str}::DOUBLE[{VECTOR_DIM}])
LIMIT {k};
として
pq_list = os.listdir(parquet_dir)
pq_list = natsorted([f for f in pq_list if f.endswith('.parquet')])
for f in tqdm(pq_list):
embedding = np.random.rand(768).astype(np.float32)
start = time.time()
db.search(f"{parquet_dir}/{f}", embedding, k=5)
end = time.time()
embedding = np.random.rand(768).astype(np.float32)
no_cache_time = end - start
start = time.time()
db.search(f"{parquet_dir}/{f}", embedding, k=5)
end = time.time()
cache_time = end - start
result.append((no_cache_time, cache_time))
のように、一回目と二回目におけるvssにかかった時間を比較した。
実行時間
| 試行 | 1回目 (no cache) | 2回目 (cache) |
|---|---|---|
| 平均 | 82.49 seconds | 10.49seconds |
| 最大 | 90.90 seconds | 10.78 seconds |
| 最小 | 76.99 seconds | 10.08 seconds |
| 平均差分 | 0.00seconds | -72.00 seconds |
結論
連続ヒットする場合はテーブルを作成しないでも十分高速と言えます。 duckbのキャッシュの性能の高さが見えたかと思います。
通信速度
iperf3を用いてTCP、UDPで計測しました。
tailscaleの背景をまだ終えていないが、tailscaleがp2pのUDP通信である一方sambaがTCPなので両方計測しました。 参考値程度にとどめていただきたいです。
- TCP
[ ID] Interval Transfer Bitrate Retr
[ 5] 0.00-10.00 sec 308 MBytes 258 Mbits/sec 168 sender
[ 5] 0.00-10.01 sec 306 MBytes 257 Mbits/sec receiver
- UDP
[ ID] Interval Transfer Bitrate Jitter Lost/Total Datagrams
[ 5] 0.00-10.00 sec 1.25 MBytes 1.05 Mbits/sec 0.000 ms 0/1068 (0%) sender
[ 5] 0.00-10.01 sec 1.25 MBytes 1.05 Mbits/sec 0.205 ms 0/1068 (0%) receiver
UDPの速度が対して出ていないのが気になりますが、今回は本題ではないので一旦保留します。
最後に
VSSを始めたい方、既存のDBからの移行を検討している方には、DuckDBが最適な選択肢の一つと言えるでしょう。無料で使えるOSSでありながら、商用データベースに匹敵する性能と使いやすさを提供しています。
ぜひ一度お試しいただき、その魅力を体感してみてください!