前回のまとめ
前回の要点は以下
- ベクトル検索は「文字」ではなく「意味」で検索できる
- 「文章」を「数値」に変換するのがembedding(埋め込み)
- 数値(ベクトル)同士の「近さ」を計算するのが「コサイン類似度(距離)」
そして、サンプルコードでは
- 実際のベクトルの保存先にPostgreSQL
- embeddingの計算にはPython + SentenceTransformer
- コサイン類似度の計算にPython + numpy
を使用しました。
課題感洗い出し
サンプルコードは問題なく動作したと思いますが、実際にRAGを使った実用的なベクトル検索を実装する場合、上記のような方式だといくつかの問題が生じてきます。
前回のサンプルコードの流れは以下です。
この方式での最大の問題点が、
「全件データ読み出し」
を行っているということです。
さらに、「全件データ」に対し「コサイン類似度」の計算を行っています。
サンプルデータのように100件程度のデータであればどうということはないのですが、これが数万件、数十万件、数百万件、数千万件〜となってくると、
- Python側の計算量が増大
- メモリ消費大、転送量大
→ ユーザが快適なレスポンスが得られない
→ アプリケーション側のリソース負担が大きくなる
という結果になりかねず、実用性に欠けそうです。
では、どうするか?
このような場合、「類似度の計算」の部分をアプリケーションから切り離し、DB側に処理させることを検討します。
選択肢としては主に以下になりますが、
- 専用のベクトルDBを導入する(Weaviate, Chroma, Qdrant等)
- マネージド型のベクトルDBを導入する(Pinecone等)
- RDBを拡張してベクトルDBとして利用する(PostgreSQL + pgvector)
今回は 「3. RDBを拡張してベクトルDBとして利用する(PostgreSQL + pgvector)」
で行きたいと思います。
理由としては以下になります
- セットアップが簡単
- オープンソースなので費用ゼロ
- ある程度の基本性能を持っている
ついでに、専用のベクトルDBはどのようなケースで選択されるのかという話ですが、この種のものは「データが大規模」「高トラフィック」な状況で優位性が出てきます。
例えば、レコード数が数百万件〜で、頻繁にアクセスされるような場合は、専用のベクトルDBでRAGシステムを組んだ方がパフォーマンス面で良好な結果が得られると思われます。
逆にそこまでの規模には至らないという場合、堅牢なRDBであるPostgreSQLをpgvectorで拡張するという選択も十分現実的といえるでしょう。
pgvectorについて
pgvectorはPostgreSQLの標準的な拡張機能(Extension)です。
この拡張機能をインストールすると、PostgreSQLに「コサイン類似度」などの「ベクトル演算」の機能を持たせることが出来ます。
また「コサイン類似度」だけでなく、「直線距離」や「内積」の計算など可能になります。
さらに、RDBでおなじみの「インデックス」もベクトル列に対して設定可能になり、計算・検索時間を短縮することも可能です。
pgvectorでPostgreSQLを拡張する
環境の準備
では、早速pgvectorを導入していきましょう。
Dockerfileを以下のように編集します。
FROM pgvector/pgvector:pg15
# Python環境
RUN apt-get update && apt-get install -y python3 python3-pip python3-venv
# 仮想環境作成
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# ライブラリ
RUN pip install sentence-transformers numpy psycopg2-binary
前回からの変更点は、イメージがpgvector入りのものに変わったところです。
FROM pgvector/pgvector:pg15
起動します。
docker compose down
docker compose up -d --build
前回同様、しばらく待つとビルドが完了するはずです。
ログの最後に
✔ Container ragdb-db-1 Started
のような表示があればOKです。
これでコンテナの準備が終わりました。
DBスキーマの変更
次に既存のDBテーブルdocumentsに手を入れます。
pgvectorを使ってベクトル検索をするためには、vector型のカラムを用意する必要があります。
すでにFLOAT[]型のカラムembeddingがありますが、これはパフォーマンス比較などのために残しておき、新たにvector(384)型のカラムembedding_vを追加します。
この(384)はベクトルの「次元数」を指します。
DBにログインします。
docker exec -it ragdb-db-1 bash
psql -U user -d ragdb
まずはテーブルを改修する前に、pgvectorを有効化しなければいけません。
ragdb=# CREATE EXTENSION vector;
CREATE EXTENSION
そして、ベクトル用のカラムを追加します。
ALTER TABLE documents
ADD COLUMN embedding_v vector(384);
ベクトルを再投入
前回と同じように、新しく作ったカラムに対し埋め込みのベクトルを生成し、各レコードに値をセットする必要があります。
処理自体は全く同じなので、前回使用したembed.pyをほぼそのまま使います。
変更する箇所は以下のように、カラム名をembedding_vに変更するだけです。
# embeddingを保存(pgvector対応)
cur.execute(
"UPDATE documents SET embedding_v = %s WHERE id = %s",
(vec, id)
)
※pgvectorを導入しても、embeddingの処理は依然としてPython側が受け持ちます
psqlを抜けて(\q)実行します。
python3 embed.py
少し待たされますが、ベクトルの再投入が完了するはずです。
確認するためには、DBにログインして、
select embedding_v from documents limit 1;
などとすると良いでしょう。
SQLでベクトル検索が出来る
さて、ここまで準備が整えば、あとはSQLのみでベクトル検索が出来るようになります。
SQLは、以下のような形になります。
SELECT
id,
content,
1 - (embedding <=> '[...query vector...]') AS score
FROM documents
ORDER BY score DESC
LIMIT 5;
このSQLの中に「<=>」という部分がありますが、ここが「類似度」の計算をしている部分です。
ただし、注意すべきこととして、ここで得られる数値は「コサイン類似度」ではなく 「コサイン距離」 と呼ばれるものになります。
「類似度」=どれくらい似ているか
「距離」=どのくらい離れているか
「コサイン距離」は、 「1からコサイン類似度を引いたもの」 になりますので、数値の大小と類似度の関係が逆転します。
つまり、
コサイン類似度=「どれくらい似ているか」
(似ていない)0→→→→→→→→1(似ている)
コサイン距離=「どれくらい離れているか」
(近づいている=似ている)0→→→→→→→→1(離れている=似ていない)
ということです。
「コサイン距離」の方に着目すると、SQLは以下の形になります。
SELECT
id,
content,
embedding <=> '[...query vector...]' AS distance
FROM documents
ORDER BY distance
LIMIT 5;
「類似度」と「距離」、どちらに着目しても結果は同じですので 感覚に合う方で選んでもらって大丈夫です。
さて、このSQLを使ってクエリを投げてみたくなると思いますが、上記SQLの中で'[...query vector...]'となっている部分に384次元の配列データを代入する必要があり、扱いに手こずりそうです。
そこで、このSQLの部分を含むベクトル検索を行うためのPythonスクリプトを用意します。
ファイル名はpg_search.pyとでもしておきましょう。
import psycopg2
from sentence_transformers import SentenceTransformer
# 埋め込みモデルの準備
model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
# クエリをセット
query = "DB"
# クエリをベクトル化
q_vec = model.encode(query).tolist()
# PostgreSQLに接続
print("DBに接続")
conn = psycopg2.connect(
host="localhost",
database="ragdb",
user="user",
password="password"
)
# SQLを発行
cur = conn.cursor()
cur.execute("""
SELECT
content,
embedding_v <=> %s::vector AS distance
FROM documents
ORDER BY distance
LIMIT 10
""", (q_vec,))
# 結果を受け取る
rows = cur.fetchall()
print(f"検索クエリ: {query}\n")
for content, distance in rows:
print(f"{distance:.4f} : {content}")
cur.close()
conn.close()
処理の流れはコメントを見ていただければ理解できると思いますが、前回との大きな違いは
「全件取得+Pythonで計算」をやっていた処理を SQL 1発で終わらせているという点です。
つまり、
前回:
- DB → 全件取得 → Pythonでループ → 類似度計算
今回:
- DB → 類似度計算込みで上位n件だけ返す
ということです。
また一点補足ですが、SQLのSELECT文の中で
embedding_v <=> %s::vector AS distance
となっていますが、ここでは「::vector」によってPythonからリスト型で渡されたデータをvector型にキャスト(型変換)しています。
「<=>」を使うときは両側がvector型になっていないとエラーになりますのでご注意ください。
ベクトル検索実行
実行してみます。
python3 pg_search.py
すると、以下のような検索結果が出てきます。
0.5012 : Q: MongoDBとは何ですか? A: ドキュメント指向データベースです
0.5077 : Q: Oracle Databaseとは何ですか? A: 商用の高性能データベースです
0.5174 : Q: SQLとは何ですか? A: データベースを操作するための言語です
0.5203 : Q: SQL Serverとは何ですか? A: Microsoftのデータベース製品です
0.5406 : Q: MySQLとは何ですか? A: 人気のあるリレーショナルデータベースです
0.5497 : Q: DynamoDBとは何ですか? A: AWSのNoSQLデータベースです
0.5899 : Q: トランザクションとは何ですか? A: データの整合性を保つ単位です
0.5994 : Q: Cassandraとは何ですか? A: 分散型NoSQLデータベースです
0.6071 : Q: EC2とは何ですか? A: 仮想サーバーサービスです
0.6088 : Q: VPCとは何ですか? A: 仮想ネットワークです
こちらの検索結果、前回説明しました「Python版」の結果と全く同じです。
それもそのはず、 同じベクトル情報を使って同じ計算をしている ので、同じ結果になるのが当然です。
つまり、処理をする部分が「アプリケーション側」→「DB側」っと移っただけで、クエリのベクトルも同じ、文章のベクトルも同じ、計算式も同じなので結果も同じ、というわけです。
このようにアプリケーション側とDB側を分離したことにより、
- アプリケーション側の計算負荷が減る→データ数増加の影響を受けない
- データ数が増えても高速な検索が期待できる→スケールしやすい
- 役割分担が明確→将来的にDBのリプレイスにも対応しやすい
と言ったメリットが生まれます。
ベクトル検索(改良版)
せっかくなので色々検索を試してみたいところです。
やり方は前回と同じように、クエリの文字列を変えて実行を繰り返すだけなのですが、毎回ファイルを編集して再実行する手間を省くため、対話式に改造しましょう。
そして、すでにお気付きかも知れませんが、現在のコードは毎回以下の部分でモデルのロードをしているため、DB接続に入るまでの処理時間が割と長いです。
# 埋め込みモデルの準備
model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
ここを毎回ロードするのは無駄なので、最初の1回のみモデルをロードするようにします。
さらに、DB接続も毎回行わず、使い回しするようにします。
コードは以下です。
import psycopg2
from sentence_transformers import SentenceTransformer
# 埋め込みモデルの準備
model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2')
# PostgreSQLに接続
print("DBに接続")
conn = psycopg2.connect(
host="localhost",
database="ragdb",
user="user",
password="password"
)
while True:
# クエリをセット
query = input("検索クエリ(qで終了):")
if query == "q":
break
# クエリをベクトル化
q_vec = model.encode(query).tolist()
# SQLを発行
cur = conn.cursor()
cur.execute("""
SELECT
content,
embedding_v <=> %s::vector AS distance
FROM documents
ORDER BY distance
LIMIT 10
""", (q_vec,))
# 結果を受け取る
rows = cur.fetchall()
print(f"検索クエリ: {query}\n")
for content, distance in rows:
print(f"{distance:.4f} : {content}")
cur.close()
conn.close()
実行すると、連続して検索が実行できるようになります。
python3 pg_search.py
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
(略)
検索クエリ(qで終了):AWS
DBに接続
WARNING: database "ragdb" has a collation version mismatch
DETAIL: The database was created using collation version 2.41, but the operating system provides version 2.36.
HINT: Rebuild all objects in this database that use the default collation and run ALTER DATABASE ragdb REFRESH COLLATION VERSION, or build PostgreSQL with the right library version.
検索クエリ: AWS
0.4391 : Q: AWSとは何ですか? A: Amazonのクラウドサービスです
0.4982 : Q: Redshiftとは何ですか? A: AWSのデータウェアハウスです
0.5713 : Q: IAMとは何ですか? A: アクセス管理サービスです
0.5872 : Q: S3とは何ですか? A: オブジェクトストレージです
0.6162 : Q: DynamoDBとは何ですか? A: AWSのNoSQLデータベースです
0.6244 : Q: Redisとは何ですか? A: インメモリ型の高速データストアです
0.6294 : Q: YAMLとは何ですか? A: 設定ファイル形式です
0.6448 : Q: RAGとは何ですか? A: 検索と生成を組み合わせた手法です
0.6877 : Q: 牛丼とは何ですか? A: 牛肉の丼料理です
0.6901 : Q: EC2とは何ですか? A: 仮想サーバーサービスです
検索クエリ(qで終了):猫はどういう動物ですか?
DBに接続
WARNING: database "ragdb" has a collation version mismatch
DETAIL: The database was created using collation version 2.41, but the operating system provides version 2.36.
HINT: Rebuild all objects in this database that use the default collation and run ALTER DATABASE ragdb REFRESH COLLATION VERSION, or build PostgreSQL with the right library version.
検索クエリ: 猫はどういう動物ですか?
0.1608 : Q: 猫とは何ですか? A: 小型で俊敏な動物です
0.4244 : Q: 犬とは何ですか? A: 人に飼われることが多い動物です
0.5453 : Q: 魚とは何ですか? A: 水中で生活する生き物です
0.5935 : Q: 牛丼とは何ですか? A: 牛肉の丼料理です
0.5943 : Q: PCとは何ですか? A: 個人用コンピュータです
0.5944 : Q: Pythonとは何ですか? A: シンプルで読みやすいプログラミング言語です
0.6040 : Q: カレーとは何ですか? A: スパイス料理です
0.6367 : Q: Pythonのリストとは何ですか? A: 複数の値を保持する構造です
0.6528 : Q: CSVとは何ですか? A: カンマ区切りのデータ形式です
0.6642 : Q: コーヒーとは何ですか? A: カフェイン飲料です
DBに接続
WARNING: database "ragdb" has a collation version mismatch
DETAIL: The database was created using collation version 2.41, but the operating system provides version 2.36.
HINT: Rebuild all objects in this database that use the default collation and run ALTER DATABASE ragdb REFRESH COLLATION VERSION, or build PostgreSQL with the right library version.
検索クエリ: S3について教えて
0.1937 : Q: S3とは何ですか? A: オブジェクトストレージです
0.5373 : Q: Redshiftとは何ですか? A: AWSのデータウェアハウスです
0.6270 : Q: Kubernetesとは何ですか? A: コンテナ管理ツールです
0.6338 : Q: DynamoDBとは何ですか? A: AWSのNoSQLデータベースです
0.6384 : Q: AWSとは何ですか? A: Amazonのクラウドサービスです
0.6504 : Q: Redisとは何ですか? A: インメモリ型の高速データストアです
0.6667 : Q: EC2とは何ですか? A: 仮想サーバーサービスです
0.6725 : Q: Rustとは何ですか? A: 安全性と高速性を重視した言語です
0.6756 : Q: SQL Serverとは何ですか? A: Microsoftのデータベース製品です
0.6813 : Q: CI/CDとは何ですか? A: 自動ビルドとデプロイの仕組みです
検索クエリ(qで終了):q
モデルのロードが省略され、サクサク検索出来てますね!
色々なクエリで試すことで、
- ベクトル検索の「癖(特性)」
- クエリと結果の「良い組み合わせ」「ダメな組み合わせ」
- 結果の「直感に反する」部分(ノイズ)→何か別の単語に引っ張られている可能性
などが感覚的に分かってきますので、是非繰り返しトライしてみてください!
現状残る課題
さて、ここまでのコードでも快適な検索が実現していますが、それでもまだ課題があります。
現状では、DB側で「全件データ」に対し「コサイン距離」の計算を行っています。
単純計算と言えど、データ数が増えるとDB側の負荷も無視できなくなります。
そして、「データが増えるほど検索が遅くなる」というシステムになってしまいスケールがしにくくなります。
そこで登場するのが 「インデックス」 です。
ベクトルデータのための「インデックス」
RDBを使いこなしている人にとっては、 「インデックス」 はおなじみのものでしょう。
「インデックス」とはデータに対する「目次」「索引」のようなもので、これがあるとDBは全件スキャンする必要がなく、目的のデータまで最短距離で辿り着くことができます。
つまり、効率的なデータ探索のためにはなくてはならない仕組みなのです。
そしてpgvector(および多くのベクトルDB)では、ベクトルデータに対しても「インデックス」を張ることができるようになっています。
その仕組みはRDBにおける「インデックス」とは異なる、独特の方法です。
普通のインデックスは、「正確な一致を高速化する」ものであるのに対し、ベクトルインデックスは「近いもの」を高速に「近似」検索します。
その仕組みは使用するアルゴリズムによって異なりますが、データを「クラスタ(似たもの同士のグループ)」と呼ばれる集団に分け、検索時に特定のクラスタのみをスキャンする仕組みが良く使われています。
そして、
「近似」検索である以上、データの「取りこぼし」の問題は避けることが出来ません。
インデックスを張ることにより高速なベクトル検索を実現する事ができますが、多かれ少なかれ 「精度」とのトレードオフが発生する という点に留意が必要です。
ただし実用上は、上位数件を見る限りほぼ問題ない精度となるケースがほとんどです。
ベクトルインデックスのアルゴリズム
現状良く使われるアルゴリズムが以下の2つです。
- IVFFLAT (Inverted File Flat)
- HNSW (Hierarchical Navigable Small World)
IVFFLATは先に説明した「クラスタ分け」に基づいたアルゴリズムです。
対してHNSWの方は、データを複数の「層」に分けて最初は粗く、徐々に細かく探索する方法です。
詳細について省きますが、興味のある方は以下のサイトなどをご参照ください。
参考
https://www.myscale.com/blog/ja/everything-about-vector-indexing/
実用上のポイントとして
効率(計算スピード・省メモリ)重視 → IVFFLAT
精度重視 → HNSW
と押さえておいてもらえれば大丈夫です。
pgvectorにおけるインデックス作成(IVFFLAT)
まずはデータ全体の分析を行います。
ANALYZEコマンドでデータの分布などの統計を取ることが出来ます。
ANALYZE documents;
次にインデックスを作成します。
CREATE INDEX idx_documents_embedding
ON documents
USING ivfflat (embedding_v vector_cosine_ops)
WITH (lists = 100);
解説しますと、
USING ivfflat(embedding_v vector_cosine_ops)
で以下の設定が可能です。
- ベクトルインデックスのアルゴリズムを設定(
ivfflat,hnsw) - 対象のカラムを設定
- 計算方式を設定
- コサイン距離(vector_cosine_ops)
- 直線距離(vector_l2_ops)
- 内積(vector_ip_ops)※正確には「負の内積」
WITH (lists = 100);
の部分で、クラスターの数を設定します。
あまり深く考えず、 「データ数/1000」 くらいで良いとされています。
またアルゴリズム自体のデフォルト・パラメータをセットすることが出来ます。
例えば、probesという設定値があります。
この設定値は、「どれだけ広く探すか」→「何個のクラスタを探索するか」というパラメータで、
小さい → 速い・荒い
大きい → 遅い・正確
となり、デフォルト値は「10」です。
これを変更したい場合は
SET ivfflat.probes = 50; -- 精度重視
などとします。
今回のサンプルデータは100件しかないので、インデックス追加の恩恵はほぼ受けられません(全件スキャンでも十分)。
そこで次回は大量のデータを投入し、実際にインデックスの効果を試してみたいと思います。
そして、今行っているベクトル検索を発展させ、より「ピンポイント」に検索できる「ハイブリッド検索」の方法も併せて紹介していきます。
↓↓↓ 続編 ↓↓↓
おまけ:IVFFLATとHNSWの違いを直感的に
実は、HNSWの方は少々難解なアルゴリズムで、イメージを掴むことに苦労してしまいました。
そこで、ChatGPTに「ざっくりとした概念」を作ってくれるよう、お願いしてみました。
以下、ChatGPTの説明
IVFFLATのイメージ
グループ分け(分類)
• 本棚・フォルダ構造
これはITコーナー
これは料理コーナー
これは動物コーナー
👉 探すときは「該当のコーナー」だけを探すので速い
■ HNSWの正体
HNSWのイメージ
👉 「ナビゲーション」
イメージ
渋谷でラーメン屋を探す
に対して、以下のように探す
東京 → 渋谷 → ラーメン → 美味しい店
👉 東京中の飲食店を総舐めしなくても良いので速い
IVFFlat的発想
エリア分けして渋谷エリアだけ探す
HNSW的発想
知ってる店 → 近くの店 → さらに近い店
HNSWは
👉 ショートカット付きの地図
遠距離ジャンプ(上層)
↓
近距離移動(下層)
だから速い
いかがでしょうか?
HNSWの実際の理論は「グラフ構造」を理解する必要があり、結構歯応えがあると思いますので必要に応じてご自身で調べていただければと思います。
参考にした記事(pgvector)
続編は
👇👇👇