DBC Tech Academy / PostgreSQL / 初級

似ている商品を
ベクトルで探す既存のテーブルにベクトル列を足して、類似検索を動かす

「雨の日でも滑りにくい子ども用の靴」と検索されて、「キッズ レインシューズ」が返る。語は 1 つも一致していません。これがベクトル検索で、PostgreSQL なら拡張を 1 つ入れて列を 1 つ足せば、いま動いている商品テーブルの上でそのまま動きます。この講座では、インデックスを張らずにここまでを組みます。1 万件までなら、それで足ります。

no keyword matched
-- :q = 「雨の日でも滑りにくい子ども用の靴」の埋め込み SELECT product_name, price, round((embedding <=> :q)::numeric, 3) AS distance FROM products WHERE embedding IS NOT NULL ORDER BY embedding <=> :q LIMIT 5; product_name | price | distance ---------------------------------+-------+---------- キッズ レインシューズ 防滑ソール | 2980 | 0.182 ジュニア 防水スニーカー | 4200 | 0.214 子ども用 長靴 ライト付き | 1980 | 0.231 キッズ サンダル ノンスリップ | 2480 | 0.297 大人用 レインブーツ | 5800 | 0.341 Time: 6.8 ms (10,000 行、インデックスなし)

検索語と商品名で一致している語は1 つもありません。「子ども」と「キッズ」、「滑りにくい」と「防滑」が、意味の近さで結び付いています。

What you will have

読み終えたとき、
動いているもの。

新しいテーブルも、新しいサーバーも増えません。いま動いている商品テーブルに、列が 2 つ増えるだけです。

ALTER TABLE列を 1 つ足すだけ

products テーブルに embedding 列を足して、そこに並べ替えの軸を持たせます。

ベクトル検索は、新しい検索システムを横に建てることではありません。いまの WHERE はそのままに、「意味で並べる」軸を ORDER BY に足すだけです。だから既存の在庫・価格・カテゴリーの条件と、そのまま組み合わせられます。

  • embedding 列と embedded_at 列 商品説明から作った埋め込みと、それを計算した日時。
  • 検索 SQL が 3 つ 検索語から探す、商品から似た商品を探す、条件で絞って探す。
  • 更新に追従する仕組み 説明文が変わったら埋め込みを作り直す、トリガーとバッチ。
  • 確かめた結果 検索語 20 個で上位 10 件を目で見た記録と、実行計画。
拡張を入れる 列を足す 説明文を埋め込む ORDER BY で並べる

PostgreSQL 15 以上

pgvector 0.7 以上を入れます。手元で試すだけなら pgvector/pgvector:pg16 の Docker イメージが最短です。

埋め込みモデル

この講座は Ollama の bge-m3。無料で、手元で動いて、日本語に対応しています。API のモデルでも手順は同じです。

1 万件まで

インデックスは張りません。全件を読んでも 10 ミリ秒以内です。張るべき件数は 9 節で見きわめます。

How it works

文を数の並びにすると、
似た文が近くに来ます。

埋め込み(embedding)は、文を 1,024 個の数の並びに変換したものです。この並びをベクトルと呼び、1,024 次元の空間の 1 点として扱います。変換するのは埋め込みモデルで、大量の文章から「同じ文脈に出てくる語は近い」を学んでいます。だから「子ども」と「キッズ」、「滑りにくい」と「防滑」が近い点になります。 点にふれると、最も近い 3 つが線で結ばれます。

この図は 1,024 次元を 2 次元に潰したものです。塊の作られ方と近さの向きは実物どおりですが、実際の距離は 1,024 次元で測るので、図で近く見える 2 点が SQL では 3 位と 4 位のように入れ替わることがあります。自分のデータでは、7 節の SQL が返す順位を正としてください。2 次元の図は、なぜ近くなるのかを納得するためのものです。

検索は、点の近さで並べること

検索語も同じモデルで埋め込み、その点に近い順に商品を並べます。これが pgvector の仕事です。

embedding <=> :q が 2 点の距離を返し、ORDER BY で近い順に並べ、LIMIT で上位だけを取ります。SQL としては、普通の並べ替えです。特別な構文も、専用の関数も要りません。

ORDER BY embedding <=> :q LIMIT 10; -- 距離が小さいほど「似ている」

向くもの、向かないもの

向く向かない
商品説明、レビュー、問い合わせ文型番、JAN コード
言い換えを吸収したい検索価格や在庫の条件
素材・用途・対象者の言葉固有名詞の完全一致

型番や数値は、これまでどおり WHERE で扱います。ベクトル検索はそれを置き換えるのではなく、WHERE に足す並べ替えの軸です。置き換えようとすると、症例 03 が起きます。

Before you start

用意するものは、
3 つだけ。

拡張、埋め込みモデル、そして「確かめるための検索語」です。3 つめを飛ばすと、後で良くなったかどうかが分からなくなります。

pgvector を入れる

CREATE EXTENSION IF NOT EXISTS vector; SELECT extversion FROM pg_extension WHERE extname = 'vector';

確かめ方。版が 0.7 以上であること。5 節で触れる halfvec 型が使えるのはこの版からです。手元に PostgreSQL がなければ、次の 1 行で足ります。

docker run -d -e POSTGRES_PASSWORD=pw \ -p 5432:5432 pgvector/pgvector:pg16

埋め込みモデルを用意する

ollama pull bge-m3 # 数の並びが返り、長さは 1,024 個 curl -s http://localhost:11434/api/embed \ -d '{"model":"bge-m3","input":"防水の子ども靴"}' \ | python3 -c "import sys,json; \ print(len(json.load(sys.stdin)['embeddings'][0]))"

ここで出た 1,024 が、そのまま 5 節の列の次元になります。

モデルは 1 つに決めます。取り込みと検索の両方で同じものを使ってください。違うモデルを使うと、別の空間の点どうしを比べることになり、距離に意味がなくなります。エラーは出ず、順位だけが無意味になります。

確かめる検索語を 20 個決める

現場で実際に打たれた検索語を 20 個集め、それぞれに「この商品が上位に来てほしい」を 1〜3 件付けます。8 節の動作確認で使います。

検索語上位に来てほしい商品この対で確かめること
雨の日でも滑りにくい子ども用の靴キッズ レインシューズ 防滑ソール語が一致しない言い換え
冬の通勤用 暖かいコートウール メルトンコート用途からの検索
ノート PC の充電器USB-C 充電器 65W上位語からの検索

言い換えを含む検索語を必ず入れてください。「防水の子ども靴」と「キッズ レインシューズ」のように語が一致しない対があると、ベクトル検索の効きが確かめられます。語が一致する検索語ばかりを集めると、LIKE による検索との違いが見えず、導入した意味を測れません。

Which distance

距離の測り方が、
3 つあります。

pgvector には距離の演算子が 3 つあり、何を「近い」と見なすかが違います。 演算子を切り替えて、選ばれる商品が入れ替わる様子を確かめてみましょう。図の中の検索語(紺の菱形)はドラッグで動かせます。

灰色の線は、原点から各商品へのベクトルです。線の向きが「何について書かれているか」、長さが「その度合いの強さ」にあたります。青く塗られた点が、その演算子で選ばれた上位です。

この図は、3 つの距離が「何を近いと見なすか」の違いを 2 次元で再現しています。実際の埋め込みは 1,024 次元です。Ollama の埋め込み API は、長さを 1 にそろえた値を返します。長さがそろっていると 3 つの演算子が返す順位はほぼ一致するので、この図ほどの差は出ません。差が出るのは、長さをそろえないモデルや API を使ったときです。自分の環境で確かめるなら、返ったベクトルの二乗和の平方根が 1 になるかを見てください。1 でなければ、演算子の選び方がそのまま順位に効きます。

演算子測るもの使いどき
<=>2 点の向きの違い(コサイン距離。0 が同じ向き、1 が直交)文の埋め込みはまずこれ。言い回しの強さでベクトルが長くなっても影響を受けない
<->2 点の直線距離(ユークリッド)ベクトルの長さそのものに意味があるとき。画像の特徴量など
<#>内積の符号を反転した値長さがそろっていると確かめたうえで、速度が要るとき

迷ったら <=> です。文の長さや言い回しの強さでベクトルの長さが変わっても、向きだけを見るので影響を受けません。<#> に替えるのは、モデルが長さを 1 にそろえていると確かめたあと、速度が必要になってからです。

The column

列を 2 つ足して、
更新に追従させる。

ベクトルを入れる列と、いつ計算したかを持つ列です。この 2 つと、この下のトリガーで、説明文の更新に自動で追従します。

ALTER TABLE products ADD COLUMN embedding vector(1024), ADD COLUMN embedded_at timestamptz; COMMENT ON COLUMN products.embedding IS '商品名・カテゴリー名・説明文の埋め込み(bge-m3、1024 次元)。NULL は未計算'; COMMENT ON COLUMN products.embedded_at IS 'embedding を計算した日時。元になる列が変わると NULL に戻る';

次元は、列に固定されます

vector(1024) の 1024 は、3 節で確かめたモデルの出力の長さです。違う長さのベクトルは入りません。モデルを替えて次元が変わったら、列ごと作り直しになります。

次元の違うモデルを試したいときは、embedding_v2 vector(768) のように別の列を足して並走させ、比べてから片方を落とします。

NULL は「まだ計算していない」

新しく登録された商品は、埋め込みが入るまで NULL です。検索の SQL には WHERE embedding IS NOT NULL を必ず付けてください。

付けないと、NULL との距離が NULL になり、並び順が崩れます。エラーにはならず、結果に混ざります。

説明文が変わったら、埋め込みを捨てる

説明文を直したのに埋め込みが古いままだと、直す前の内容で検索に掛かります。トリガーで、元になる列が変わったときに embedding を NULL に戻します。

CREATE OR REPLACE FUNCTION products_reset_embedding() RETURNS trigger AS $$ BEGIN -- 埋め込みの元になる列が変わったら、未計算に戻す。 -- 6 節のバッチが NULL の行を拾って計算し直す IF NEW.product_name IS DISTINCT FROM OLD.product_name OR NEW.category_name IS DISTINCT FROM OLD.category_name OR NEW.description IS DISTINCT FROM OLD.description THEN NEW.embedding := NULL; NEW.embedded_at := NULL; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER products_reset_embedding BEFORE UPDATE ON products FOR EACH ROW EXECUTE FUNCTION products_reset_embedding();

これで「更新に追従する」が、アプリ側に 1 行も書かずに成立します。商品を編集する画面がいくつあっても、どこから更新されても、埋め込みは必ず作り直しの対象になります。あとは 6 節のバッチを定期実行するだけです。

vector か halfvec か

  • halfvec(1024) は 1 つの数を半分のバイト数で持つ型です。テーブルサイズが半分になり、文の検索では順位がほとんど変わりません。
  • 1 万件なら差は 40 MB と 20 MB です。まず vector で始めて、9 節で件数が増えたときに検討してください。

SELECT * を先に消す

  • embedding 列は 1 行あたり約 4 KB あります。一覧画面で 100 行返すだけで 400 KB を運びます。
  • 既存のアプリに SELECT * があれば、列を足す前に直してください。列を足した瞬間に、これまで速かった画面が遅くなります。原因が新しい列だと気付くまでに時間がかかる種類の事故です。

Filling it

何を埋め込むかで、
結果が決まります。

埋め込む文は、「人が読んで似ていると判断する材料」だけにします。ここに余計なものを入れると、どんな検索語でも同じ商品が上位に来るようになります。

入れるもの、入れないもの

入れる入れない
商品名価格、在庫数
カテゴリー名商品 ID、型番
説明文(素材・用途・対象者)送料無料、ポイント 2 倍

定型文が最も危険です。「※アウトレット品のため返品不可。送料無料」のような文は全商品に共通なので、それを持つ商品どうしを互いに近づけてしまいます。結果として、検索語によらず同じ商品群が上位に来ます(症例 01)。価格や ID も、数字の並びが似ている商品を近づける害があります。

埋め込む文を組み立てる

def text_for_embedding(row) -> str: # 商品名を先頭に置く。検索語は商品名に近い # 言い回しで来ることが多いため return (f"{row['product_name']}\n" f"{row['category_name']}\n" f"{row['description']}")

組み立て方は 1 つに決めて、全商品で同じにします。途中で順番を変えると、変える前と後の行が違う基準で埋め込まれ、比べられなくなります。

まとめて計算して入れる

import ollama EMBEDDING_MODEL = "bge-m3" BATCH_SIZE = 64 def embed_pending(conn) -> int: """embedding が NULL の行を拾い、計算して入れる。処理した件数を返す""" done = 0 with conn.cursor() as cur: while True: cur.execute(""" SELECT product_id, product_name, category_name, description FROM products WHERE embedding IS NULL ORDER BY product_id LIMIT %(limit)s """, {"limit": BATCH_SIZE}) rows = cur.fetchall() if not rows: return done vectors = ollama.embed( model=EMBEDDING_MODEL, input=[text_for_embedding(r) for r in rows])["embeddings"] cur.executemany(""" UPDATE products SET embedding = %(embedding)s, embedded_at = now() WHERE product_id = %(product_id)s """, [{"product_id": r["product_id"], "embedding": v} for r, v in zip(rows, vectors)]) conn.commit() done += len(rows)

WHERE embedding IS NULL で拾うので、初回は全件、2 回目以降は新規と更新された行だけを処理します。5 節のトリガーと組み合わせると、このバッチを 1 本置くだけで追従が完成します。件数を数えずに済み、前回どこまで処理したかを覚えておく必要もありません。

入ったかの確かめ方

SELECT count(*) FILTER (WHERE embedding IS NULL) AS pending, count(*) FILTER (WHERE embedding IS NOT NULL) AS done FROM products;

pending が 0 になれば完了です。この問い合わせは 10 節でも監視に使います。増え続けていれば、バッチが止まっています。

1 万件を 64 件ずつで 160 回、手元の Mac で 5〜10 分かかります。API のモデルを使う場合は本数とトークン数で課金されるので、WHERE embedding IS NULL を外して全件を計算し直さないでください。テストのつもりで 1 回流すと、そのぶんがそのまま請求されます。

Querying

検索は、3 通り。

検索語から探す、商品から似た商品を探す、条件で絞って探す。どれも ORDER BY と LIMIT だけです。

検索語から探す

SELECT product_id, product_name, price, embedding <=> :q AS distance FROM products WHERE embedding IS NOT NULL ORDER BY embedding <=> :q LIMIT 10;

:q は検索語を 3 節と同じモデルで埋め込んだベクトルです。アプリ側で ollama.embed を 1 回呼び、その結果をプレースホルダに渡します。SELECT 側の distance は表示と動作確認のためで、並び順を決めるのは ORDER BY 側です。

商品から似た商品を探す

SELECT p.product_id, p.product_name, p.price, p.embedding <=> base.embedding AS distance FROM products AS p, (SELECT embedding FROM products WHERE product_id = :product_id) AS base WHERE p.embedding IS NOT NULL AND p.product_id <> :product_id ORDER BY p.embedding <=> base.embedding LIMIT 6;

商品ページの「似ている商品」では、埋め込みモデルを呼ぶ必要がありません。その商品の埋め込みが、すでに列に入っているからです。ページを開くたびにモデルを呼ばずに済むので、表示は SQL 1 本ぶんの時間で終わります。p.product_id <> :product_id を忘れると、距離 0 の自分自身が 1 位に来ます。

条件で絞って探す

SELECT product_id, product_name, price, embedding <=> :q AS distance FROM products WHERE embedding IS NOT NULL AND category_id = :category_id AND price <= :max_price ORDER BY embedding <=> :q LIMIT 10;

インデックスがない間は、WHERE で絞ってから残りを並べ替えるので、条件をどれだけ足しても 10 件返ります。これが全件走査の利点です。9 節でインデックスを張ると、この前提が変わります。

検索語の埋め込みは、取り込みと同じモデル・同じ前処理で作ってください。モデルによっては文書側と検索語側で指定を分けるものがあり、その場合は検索語側の指定を使います。bge-m3 にはこの区別がありません。区別のあるモデルで取り違えると、エラーは出ず、順位の精度だけが落ちます。

Does it actually work

20 個の検索語で、
上位を目で見る。

初級のうちは、自動の指標より目で見るほうが確実です。3 節で決めた検索語 20 個を 7 節の SQL に流し、来てほしい商品が上位 10 件に入っているかを数えます。

記録の形

検索語来てほしい商品順位
雨の日でも滑りにくい子ども用の靴キッズ レインシューズ1
冬の通勤用 暖かいコートウール メルトンコート3
ノート PC の充電器USB-C 充電器 65W14

20 個のうち 16 個以上で上位 10 件に入っていれば、最初の目標として十分です。入らなかったものは、11 節の症例のどれかに当たります。この表を残しておくと、埋め込む文を変えたときに良くなったか悪くなったかが分かります。

実行計画と時間を見る

Limit (actual time=6.412..6.415 rows=10 loops=1) -> Sort (actual time=6.410..6.411 rows=10 loops=1) Sort Key: ((embedding <=> '[...]'::vector)) Sort Method: top-N heapsort Memory: 26kB -> Seq Scan on products (actual time=0.021..4.980 rows=10000 loops=1) Filter: (embedding IS NOT NULL) Execution Time: 6.488 ms

Seq Scan と Sort が出ていれば、想定どおりの全件走査です。1 万件で 10 ミリ秒以内なら、商品検索の応答としては十分です。読み方の詳細は PostgreSQL 実行計画の読み方で扱っています。

EXPLAIN ANALYZE は SQL を実際に実行します。上の SELECT なら害はありませんが、同じ書き方を UPDATE や DELETE に付けると、本当に更新され、削除されます。

How far it scales

いつインデックスが
要るのかを、先に知る。

インデックスなしの検索は、件数に比例して遅くなります。どこまで持つかを見きわめてから、張るかどうかを決めます。 件数と次元を動かして、自分の規模がどこにあるかを確かめてみましょう。

-件数
-ベクトル列のサイズ
-全件走査の時間
-1 件あたり

検索時間は、件数と次元の掛け算に比例します。1 万件・1,024 次元で約 7 ミリ秒を基準にしています。横の破線は、画面の応答として待てる上限の目安です。

この図は、検索時間とサイズが件数と次元に比例することを再現しています。時間は、1 行が何バイトかとメモリの読み出し速度から導いた概算です。1,024 次元は 1 行およそ 4 KB、1 万件で 39 MB。これを順に読んで 1 行あたり 1,024 回の積和を回すので、数ミリ秒という桁になります。絶対値は CPU と、テーブルがメモリに載っているかで数倍変わります。自分の環境では、8 節の EXPLAIN ANALYZE の Execution Time を件数を変えて 3 点取り、その傾きで目盛りを合わせてください。件数を増やすには、既存の行を複製して入れるだけで足ります。

件数判断理由
〜1 万全件走査。インデックスは張らない10 ミリ秒以内で終わり、結果は常に正確です
1 万〜10 万全件走査のまま、応答時間を見る100 ミリ秒前後。要件とメモリに載っているかで決めます
10 万〜インデックス(HNSW)を検討するただし近似になり、上位 10 件の中身が変わります

インデックスを張ると、結果そのものが変わります。通常のインデックスは結果を変えずに速くしますが、pgvector の HNSW は近似で、上位 10 件の一部が本来より遠いものに入れ替わります。エラーも警告も出ません。だから 1 万件で張る理由はなく、張るときは「どれだけ入れ替わったか」を測る手順が要ります。その手順は pgvector HNSW のチューニングで扱っています。

Running it

置いたあと、
見ておくこと。

仕組みそのものはトリガーとバッチで完結しています。人が見るのは 3 つだけです。

日々の追従

  • 商品が増える、説明が変わる。5 節のトリガーが embedding を NULL に戻し、6 節のバッチが拾います。10 分おきの cron で十分です。
  • 確かめ方は pending の件数です。6 節の問い合わせを監視に入れ、増え続けていればバッチが止まっていると分かります。埋め込みが古いことは、検索結果を見ても気付けません。
  • サイズを月に 1 回見ます。SELECT pg_size_pretty(pg_total_relation_size('products'))。9 節の目安に近づいたら、halfvec かインデックスの検討に入ります。

モデルを替えるとき

  • 全件を計算し直します。途中で新旧が混ざると、距離に意味がなくなります。次元が同じでも、モデルが違えば別の空間です。
  • 新しい列に入れてから切り替えます。embedding_v2 に全件そろってから検索 SQL を切り替え、旧列を落とします。切り替える前に、3 節の検索語 20 個で新旧を比べてください。新しいモデルのほうが良いとは限りません。
  • 検索語を記録しておきます。検索語と返した上位 10 件の product_id を 1 行ずつ保存すると、20 個の検索語を実際の検索語から増やしていけます。

Read the symptom

症状から、
原因を見抜く。

類似検索を入れた直後によく持ち込まれる 3 つの症状です。それぞれ、どこに原因があるのかを考えてみましょう。

Myth vs. reality

キーワード検索の感覚で、
誤りやすい 3 点。

どれも導入の初期に判断を誤らせ、あとから作り直しになる種類の勘違いです。

MISCONCEPTION 01

「ベクトル検索はキーワード検索の上位互換」

言い換えには強く、型番・固有名詞・数値には弱いという、はっきりした得手不得手があります。置き換えるのではなく、WHERE の条件に「意味で並べる軸」を足すものです。型番検索を消して置き換えると、いま当たっている検索が当たらなくなります。

MISCONCEPTION 02

「次元が大きいほど賢い」

次元は保存量と検索時間に比例して増えますが、上位 10 件が良くなるかどうかは自分の検索語で測らなければ分かりません。同じモデルで次元を選べるなら、小さいほうから試してください。大きいほうを選ぶ理由は、測って差が出たときにだけ生まれます。

MISCONCEPTION 03

「最初からインデックスが要る」

1 万件なら全件走査で 10 ミリ秒以内です。しかも pgvector のインデックスは近似なので、張ると結果が変わり、それを測る手順が新たに要ります。張らずに済む件数のうちは、張らないほうが正確で、運用も簡単です。

Knowledge check

8問で、理解を
確かめよう。

答えを当てるだけでなく、なぜそう判断できるのかを説明できれば合格です。

YOUR PROGRESS 01 / 08

選択肢を1つ選んでください。

The whole thing in one line

列を 1 つ足して、
ORDER BY に軸を足す。

拡張を入れる
列とトリガー
埋め込みを入れる
20 個で確かめる