「ベクトル検索はキーワード検索の上位互換」
言い換えには強く、型番・固有名詞・数値には弱いという、はっきりした得手不得手があります。置き換えるのではなく、WHERE の条件に「意味で並べる軸」を足すものです。型番検索を消して置き換えると、いま当たっている検索が当たらなくなります。
DBC Tech Academy / PostgreSQL / 初級
「雨の日でも滑りにくい子ども用の靴」と検索されて、「キッズ レインシューズ」が返る。語は 1 つも一致していません。これがベクトル検索で、PostgreSQL なら拡張を 1 つ入れて列を 1 つ足せば、いま動いている商品テーブルの上でそのまま動きます。この講座では、インデックスを張らずにここまでを組みます。1 万件までなら、それで足ります。
検索語と商品名で一致している語は1 つもありません。「子ども」と「キッズ」、「滑りにくい」と「防滑」が、意味の近さで結び付いています。
What you will have
新しいテーブルも、新しいサーバーも増えません。いま動いている商品テーブルに、列が 2 つ増えるだけです。
ベクトル検索は、新しい検索システムを横に建てることではありません。いまの WHERE はそのままに、「意味で並べる」軸を ORDER BY に足すだけです。だから既存の在庫・価格・カテゴリーの条件と、そのまま組み合わせられます。
pgvector 0.7 以上を入れます。手元で試すだけなら pgvector/pgvector:pg16 の Docker イメージが最短です。
この講座は Ollama の bge-m3。無料で、手元で動いて、日本語に対応しています。API のモデルでも手順は同じです。
インデックスは張りません。全件を読んでも 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 としては、普通の並べ替えです。特別な構文も、専用の関数も要りません。
| 向く | 向かない |
|---|---|
| 商品説明、レビュー、問い合わせ文 | 型番、JAN コード |
| 言い換えを吸収したい検索 | 価格や在庫の条件 |
| 素材・用途・対象者の言葉 | 固有名詞の完全一致 |
型番や数値は、これまでどおり WHERE で扱います。ベクトル検索はそれを置き換えるのではなく、WHERE に足す並べ替えの軸です。置き換えようとすると、症例 03 が起きます。
Before you start
拡張、埋め込みモデル、そして「確かめるための検索語」です。3 つめを飛ばすと、後で良くなったかどうかが分からなくなります。
確かめ方。版が 0.7 以上であること。5 節で触れる halfvec 型が使えるのはこの版からです。手元に PostgreSQL がなければ、次の 1 行で足ります。
ここで出た 1,024 が、そのまま 5 節の列の次元になります。
モデルは 1 つに決めます。取り込みと検索の両方で同じものを使ってください。違うモデルを使うと、別の空間の点どうしを比べることになり、距離に意味がなくなります。エラーは出ず、順位だけが無意味になります。
現場で実際に打たれた検索語を 20 個集め、それぞれに「この商品が上位に来てほしい」を 1〜3 件付けます。8 節の動作確認で使います。
| 検索語 | 上位に来てほしい商品 | この対で確かめること |
|---|---|---|
| 雨の日でも滑りにくい子ども用の靴 | キッズ レインシューズ 防滑ソール | 語が一致しない言い換え |
| 冬の通勤用 暖かいコート | ウール メルトンコート | 用途からの検索 |
| ノート PC の充電器 | USB-C 充電器 65W | 上位語からの検索 |
言い換えを含む検索語を必ず入れてください。「防水の子ども靴」と「キッズ レインシューズ」のように語が一致しない対があると、ベクトル検索の効きが確かめられます。語が一致する検索語ばかりを集めると、LIKE による検索との違いが見えず、導入した意味を測れません。
Which distance
pgvector には距離の演算子が 3 つあり、何を「近い」と見なすかが違います。 演算子を切り替えて、選ばれる商品が入れ替わる様子を確かめてみましょう。図の中の検索語(紺の菱形)はドラッグで動かせます。
灰色の線は、原点から各商品へのベクトルです。線の向きが「何について書かれているか」、長さが「その度合いの強さ」にあたります。青く塗られた点が、その演算子で選ばれた上位です。
この図は、3 つの距離が「何を近いと見なすか」の違いを 2 次元で再現しています。実際の埋め込みは 1,024 次元です。Ollama の埋め込み API は、長さを 1 にそろえた値を返します。長さがそろっていると 3 つの演算子が返す順位はほぼ一致するので、この図ほどの差は出ません。差が出るのは、長さをそろえないモデルや API を使ったときです。自分の環境で確かめるなら、返ったベクトルの二乗和の平方根が 1 になるかを見てください。1 でなければ、演算子の選び方がそのまま順位に効きます。
| 演算子 | 測るもの | 使いどき |
|---|---|---|
<=> | 2 点の向きの違い(コサイン距離。0 が同じ向き、1 が直交) | 文の埋め込みはまずこれ。言い回しの強さでベクトルが長くなっても影響を受けない |
<-> | 2 点の直線距離(ユークリッド) | ベクトルの長さそのものに意味があるとき。画像の特徴量など |
<#> | 内積の符号を反転した値 | 長さがそろっていると確かめたうえで、速度が要るとき |
迷ったら <=> です。文の長さや言い回しの強さでベクトルの長さが変わっても、向きだけを見るので影響を受けません。<#> に替えるのは、モデルが長さを 1 にそろえていると確かめたあと、速度が必要になってからです。
The column
ベクトルを入れる列と、いつ計算したかを持つ列です。この 2 つと、この下のトリガーで、説明文の更新に自動で追従します。
vector(1024) の 1024 は、3 節で確かめたモデルの出力の長さです。違う長さのベクトルは入りません。モデルを替えて次元が変わったら、列ごと作り直しになります。
次元の違うモデルを試したいときは、embedding_v2 vector(768) のように別の列を足して並走させ、比べてから片方を落とします。
新しく登録された商品は、埋め込みが入るまで NULL です。検索の SQL には WHERE embedding IS NOT NULL を必ず付けてください。
付けないと、NULL との距離が NULL になり、並び順が崩れます。エラーにはならず、結果に混ざります。
説明文を直したのに埋め込みが古いままだと、直す前の内容で検索に掛かります。トリガーで、元になる列が変わったときに embedding を NULL に戻します。
これで「更新に追従する」が、アプリ側に 1 行も書かずに成立します。商品を編集する画面がいくつあっても、どこから更新されても、埋め込みは必ず作り直しの対象になります。あとは 6 節のバッチを定期実行するだけです。
halfvec(1024) は 1 つの数を半分のバイト数で持つ型です。テーブルサイズが半分になり、文の検索では順位がほとんど変わりません。vector で始めて、9 節で件数が増えたときに検討してください。embedding 列は 1 行あたり約 4 KB あります。一覧画面で 100 行返すだけで 400 KB を運びます。SELECT * があれば、列を足す前に直してください。列を足した瞬間に、これまで速かった画面が遅くなります。原因が新しい列だと気付くまでに時間がかかる種類の事故です。Filling it
埋め込む文は、「人が読んで似ていると判断する材料」だけにします。ここに余計なものを入れると、どんな検索語でも同じ商品が上位に来るようになります。
| 入れる | 入れない |
|---|---|
| 商品名 | 価格、在庫数 |
| カテゴリー名 | 商品 ID、型番 |
| 説明文(素材・用途・対象者) | 送料無料、ポイント 2 倍 |
定型文が最も危険です。「※アウトレット品のため返品不可。送料無料」のような文は全商品に共通なので、それを持つ商品どうしを互いに近づけてしまいます。結果として、検索語によらず同じ商品群が上位に来ます(症例 01)。価格や ID も、数字の並びが似ている商品を近づける害があります。
組み立て方は 1 つに決めて、全商品で同じにします。途中で順番を変えると、変える前と後の行が違う基準で埋め込まれ、比べられなくなります。
WHERE embedding IS NULL で拾うので、初回は全件、2 回目以降は新規と更新された行だけを処理します。5 節のトリガーと組み合わせると、このバッチを 1 本置くだけで追従が完成します。件数を数えずに済み、前回どこまで処理したかを覚えておく必要もありません。
pending が 0 になれば完了です。この問い合わせは 10 節でも監視に使います。増え続けていれば、バッチが止まっています。
1 万件を 64 件ずつで 160 回、手元の Mac で 5〜10 分かかります。API のモデルを使う場合は本数とトークン数で課金されるので、WHERE embedding IS NULL を外して全件を計算し直さないでください。テストのつもりで 1 回流すと、そのぶんがそのまま請求されます。
Querying
検索語から探す、商品から似た商品を探す、条件で絞って探す。どれも ORDER BY と LIMIT だけです。
:q は検索語を 3 節と同じモデルで埋め込んだベクトルです。アプリ側で ollama.embed を 1 回呼び、その結果をプレースホルダに渡します。SELECT 側の distance は表示と動作確認のためで、並び順を決めるのは ORDER BY 側です。
商品ページの「似ている商品」では、埋め込みモデルを呼ぶ必要がありません。その商品の埋め込みが、すでに列に入っているからです。ページを開くたびにモデルを呼ばずに済むので、表示は SQL 1 本ぶんの時間で終わります。p.product_id <> :product_id を忘れると、距離 0 の自分自身が 1 位に来ます。
インデックスがない間は、WHERE で絞ってから残りを並べ替えるので、条件をどれだけ足しても 10 件返ります。これが全件走査の利点です。9 節でインデックスを張ると、この前提が変わります。
検索語の埋め込みは、取り込みと同じモデル・同じ前処理で作ってください。モデルによっては文書側と検索語側で指定を分けるものがあり、その場合は検索語側の指定を使います。bge-m3 にはこの区別がありません。区別のあるモデルで取り違えると、エラーは出ず、順位の精度だけが落ちます。
Does it actually work
初級のうちは、自動の指標より目で見るほうが確実です。3 節で決めた検索語 20 個を 7 節の SQL に流し、来てほしい商品が上位 10 件に入っているかを数えます。
| 検索語 | 来てほしい商品 | 順位 |
|---|---|---|
| 雨の日でも滑りにくい子ども用の靴 | キッズ レインシューズ | 1 |
| 冬の通勤用 暖かいコート | ウール メルトンコート | 3 |
| ノート PC の充電器 | USB-C 充電器 65W | 14 |
20 個のうち 16 個以上で上位 10 件に入っていれば、最初の目標として十分です。入らなかったものは、11 節の症例のどれかに当たります。この表を残しておくと、埋め込む文を変えたときに良くなったか悪くなったかが分かります。
Seq Scan と Sort が出ていれば、想定どおりの全件走査です。1 万件で 10 ミリ秒以内なら、商品検索の応答としては十分です。読み方の詳細は PostgreSQL 実行計画の読み方で扱っています。
EXPLAIN ANALYZE は SQL を実際に実行します。上の SELECT なら害はありませんが、同じ書き方を UPDATE や DELETE に付けると、本当に更新され、削除されます。
How far it scales
インデックスなしの検索は、件数に比例して遅くなります。どこまで持つかを見きわめてから、張るかどうかを決めます。 件数と次元を動かして、自分の規模がどこにあるかを確かめてみましょう。
検索時間は、件数と次元の掛け算に比例します。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 つだけです。
embedding を NULL に戻し、6 節のバッチが拾います。10 分おきの cron で十分です。pending の件数です。6 節の問い合わせを監視に入れ、増え続けていればバッチが止まっていると分かります。埋め込みが古いことは、検索結果を見ても気付けません。SELECT pg_size_pretty(pg_total_relation_size('products'))。9 節の目安に近づいたら、halfvec かインデックスの検討に入ります。embedding_v2 に全件そろってから検索 SQL を切り替え、旧列を落とします。切り替える前に、3 節の検索語 20 個で新旧を比べてください。新しいモデルのほうが良いとは限りません。product_id を 1 行ずつ保存すると、20 個の検索語を実際の検索語から増やしていけます。Read the symptom
類似検索を入れた直後によく持ち込まれる 3 つの症状です。それぞれ、どこに原因があるのかを考えてみましょう。
Myth vs. reality
どれも導入の初期に判断を誤らせ、あとから作り直しになる種類の勘違いです。
言い換えには強く、型番・固有名詞・数値には弱いという、はっきりした得手不得手があります。置き換えるのではなく、WHERE の条件に「意味で並べる軸」を足すものです。型番検索を消して置き換えると、いま当たっている検索が当たらなくなります。
次元は保存量と検索時間に比例して増えますが、上位 10 件が良くなるかどうかは自分の検索語で測らなければ分かりません。同じモデルで次元を選べるなら、小さいほうから試してください。大きいほうを選ぶ理由は、測って差が出たときにだけ生まれます。
1 万件なら全件走査で 10 ミリ秒以内です。しかも pgvector のインデックスは近似なので、張ると結果が変わり、それを測る手順が新たに要ります。張らずに済む件数のうちは、張らないほうが正確で、運用も簡単です。
Knowledge check
答えを当てるだけでなく、なぜそう判断できるのかを説明できれば合格です。
選択肢を1つ選んでください。
The whole thing in one line
目次