不具合報告の類似検索を PostgreSQL で作る:pgvector・埋め込み・ハイブリッド検索

過去の不具合報告から似た事例を探すなら、いま使っている PostgreSQL に pgvector を入れ、報告文を埋め込みモデルでベクトルにして同じ表に持たせ、キーワード検索と合成して引くのが、専用のベクトルデータベースを足すより手間の少ない形です。この記事では、2026年10月時点の pgvector 0.8.7 と PostgreSQL 16、CPU だけで動く埋め込みの Python ライブラリで、その形を組みます。コードは使い捨ての PostgreSQL に対して実際に動かし、検索結果と、絞り込みで結果が足りなくなる現象まで確かめています。報告の取り出しと分類は 構造化出力で不具合報告を JSON にする に、データにする考え方は 不具合報告と作業日報をデータに変える に書いたので、ここでは「似たものを探す」ところに絞ります。
構成
不具合報告(自由記述)
│
▼ 1. 埋め込みモデル(CPU、社内で完結)
384 次元のベクトル
│
▼ 2. PostgreSQL の1つの表に本文と一緒に保存
defect_reports(本文・工程・ベクトル)
│ ├─ HNSW 索引(意味の近さで引く)
│ └─ GIN 索引(語句の一致で引く)
▼ 3. 質問を同じモデルでベクトルにして検索
意味の検索 + キーワードの検索 → 順位を合成して上位3件
埋め込みとは、文章を数百個の数字の並び(ベクトル)に変換し、意味の近い文章ほど近い値にする仕組みです。報告文を言い換えても同じ事例が引けるのは、このためです。外部の API を使わずに済ませたいので、モデルは自分のサーバーで動かします。
準備
pip install fastembed "psycopg[binary]" pgvector numpy
2026年10月時点の版は、fastembed が 0.9.0、psycopg が 3.3.6、Python 側の pgvector が 0.5.1 です。fastembed は ONNX Runtime で推論するライブラリで、私の環境では GPU なしで動きました。名前が同じで紛らわしいのですが、pip で入れる pgvector は Python から使うための小さな部品で、PostgreSQL 本体に入れる拡張とは別物です。
拡張の本体は、2026年10月1日公開の 0.8.7 が最新です。この版では IVFFlat 索引(この記事では使わない種類の索引)を作るときのバッファあふれが直っています。ここに注意があります。Ubuntu 24.04 の標準パッケージ postgresql-16-pgvector は、私の環境では 0.6.0 でした。後で使う反復スキャン(5 章)は 0.8.0(2024年10月公開)で入った機能なので、標準パッケージのままでは動きません。この記事では 0.8.7 のソースを取得し、postgresql-server-dev-16 を入れて make と make install で入れ、ALTER EXTENSION vector UPDATE で更新しました。マネージドの PostgreSQL を使う場合は、提供されている pgvector の版を先に確かめてください。
埋め込みモデルには、sentence-transformers の paraphrase-multilingual-MiniLM-L12-v2 を使います。384 次元、50 以上の言語(日本語を含む)で学習され、ライセンスは Apache-2.0 です。小さく速い代わりに、扱える長さは 128 トークンまでで、それを超える部分は読まれません。数行で済む不具合報告の見出しや要約には足りますが、長い是正報告の全文を丸ごと入れる用途には向きません。
1. テーブルと索引を作る
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE defect_reports (
id bigserial PRIMARY KEY,
process text NOT NULL,
body text NOT NULL,
embedding vector(384) NOT NULL
);
CREATE INDEX ON defect_reports USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON defect_reports USING gin (body gin_trgm_ops);
vector(384) の 384 はモデルの次元数と一致させます。索引を付けられるのは vector 型で 2,000 次元までです。
HNSW は、近いものどうしを結んだ階層状のグラフをたどって素早く探す索引で、結果は近似です。作るときの既定値は、1 つの点がつなぐ数 m が 16、構築時の候補数 ef_construction が 64。検索時の候補数 hnsw.ef_search は 40 です。大きくすると見落としは減り、遅くなります。距離の演算子は、コサイン距離が <=>、L2 距離が <->、内積が <#> で、索引を作るときの演算子クラス(ここでは vector_cosine_ops)と、検索の ORDER BY で使う演算子は揃えてください。揃っていないと、索引は使われずに全件を順に調べます。
もう 1 つの GIN 索引は、3 文字ずつに分けた断片(トライグラム)を使う pg_trgm のものです。これは 4 章で、語句の一致を速く探すために使います。
2. 埋め込みを作って入れる
# embed.py
import numpy as np
from fastembed import TextEmbedding
MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
_model = TextEmbedding(MODEL)
def embed(texts: list[str]) -> np.ndarray:
return np.array(list(_model.embed(texts)), dtype="float32")
# load.py
import psycopg
from pgvector.psycopg import register_vector
from embed import embed
conn = psycopg.connect("postgresql://app@db.example.local/factory", autocommit=True)
register_vector(conn)
reports = [
("プレス", "プレス加工品の端にバリが出ている。金型の摩耗を疑う。"),
("プレス", "切断面にかえりが残り、手で触ると引っかかる。"),
# ...
]
vectors = embed([body for _, body in reports])
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO defect_reports (process, body, embedding) VALUES (%s, %s, %s)",
[(p, b, v) for (p, b), v in zip(reports, vectors)],
)
初回の実行時にモデルのファイル(約 0.2GB)が Hugging Face から取得されます。取得できない閉じた環境では、あらかじめ別の PC で取得して持ち込みます。
私の環境で出力のベクトルの長さを調べると、1 に揃っておらず約 2.8 でした。コサイン距離は長さに影響されないので、そのまま入れて問題ありません。内積(<#>)で比べる場合だけは、自分で長さを 1 に揃えてから入れる必要があります。
3. 意味で探す
q = embed(["金属の切り口がとがっている"])[0]
rows = conn.execute(
"SELECT id, process, body FROM defect_reports ORDER BY embedding <=> %s LIMIT 3",
(q,),
).fetchall()
検証には、架空の不具合報告を 41 件作りました。プレス、旋盤、成形、溶接、塗装、組立、検査、熱処理、めっき、電子の各工程で、同じ現象を別の言い方で書いたものを混ぜてあります。たとえば「バリが出ている」「かえりが残り」「縁が鋭く尖っている」は、同じ現象の 3 通りの書き方です。この表に上の質問を投げると、上位 3 件はこの 3 件でした。「バリ」も「かえり」も含まない質問で、語句の一致では引けない報告に届いています。
4. キーワードと混ぜる
意味の検索だけでは、設備名や部品名のような固有の語句に弱くなります。キーワードの一致と順位を合わせる(ハイブリッド検索)のが定石で、pgvector の README も、PostgreSQL の全文検索と併用し、順位の合成には RRF(Reciprocal Rank Fusion)を使うよう案内しています。RRF は、それぞれの検索での順位を 1 / (60 + 順位) に直して足し合わせる方法です。60 は慣例的に使われる定数で、この記事の試行では調整していません。
日本語で気をつけたいのが、キーワード側です。PostgreSQL の標準の全文検索は空白で語を区切る英語向けの作りなので、日本語の文には向かないと見て、この記事では試していません。代わりに、前述の pg_trgm と GIN 索引を使い、LIKE '%語句%' を速くします。pg_trgm の類似度(similarity、word_similarity)で順位を付けようとして、はまりました。「はんだ」のように短い語で word_similarity を測ると、「基板のはんだ付けでブリッジが発生した。」に対して 0.25 にしかならず、しきい値で絞ると 1 件も出ませんでした。pg_trgm は語の前後に空白を補って断片を作りますが、日本語の文は 1 つの長い「語」として扱われるため、文中にある短い語の前後の断片が本文側に存在しないからです。順位付けには使わず、一致するかどうかの判定(LIKE)にだけ使い、順位は本文の短い順としました。
HYBRID = """
WITH v AS (
SELECT id, row_number() OVER (ORDER BY embedding <=> %(q)s) AS r
FROM defect_reports
ORDER BY embedding <=> %(q)s
LIMIT 20
), k AS (
SELECT id, row_number() OVER (ORDER BY length(body), id) AS r
FROM defect_reports
WHERE body LIKE '%%' || %(kw)s || '%%'
ORDER BY length(body), id
LIMIT 20
)
SELECT d.id, d.process, d.body, sum(1.0 / (60 + t.r)) AS score
FROM (SELECT * FROM v UNION ALL SELECT * FROM k) t
JOIN defect_reports d USING (id)
GROUP BY d.id
ORDER BY score DESC
LIMIT 3
"""
rows = conn.execute(HYBRID, {"q": embed([question])[0], "kw": "はんだ"}).fetchall()
SQL の中の %% は、psycopg では % を表します。LIKE の検索が索引を使えることは、41 件の表で逐次スキャンを禁じて確かめ、GIN 索引のビットマップスキャンが選ばれました。ただし 41 件では速度の差は測れないので、速さについては何も言えません。
試した 6 つの質問で、上位 3 件に正解がいくつ入ったかを数えました。正解は、私が各質問に対し、同じ現象を書いた報告として前もって決めた 2〜3 件です。
| 質問(言い換えた文) | 正解数 | キーワードのみ | 意味のみ | 合成 |
|---|---|---|---|---|
| 金属の切り口がとがっている(キーワード「バリ」) | 3 | 1 | 3 | 3 |
| 部品が穴に入らない、太すぎる(「寸法」) | 3 | 1 | 1 | 1 |
| 気密検査で空気が抜ける(「漏れ」) | 3 | 2 | 2 | 2 |
| 回転する部品から高い音がする(「異音」) | 2 | 1 | 2 | 2 |
| 測定器の点検期限が切れたまま使っていた(「校正」) | 2 | 2 | 2 | 2 |
| 電子基板の接合が不十分で電気が通らない(「はんだ」) | 2 | 2 | 1 | 2 |
| 合計 | 15 | 9 | 11 | 12 |
5. 絞り込むと件数が足りなくなる
実務では「熱処理工程だけ」「この設備だけ」のように、条件で絞って似たものを探します。HNSW 索引では、ここに落とし穴があります。索引は先に近い順に候補(既定は 40 件)を取り出し、そのあとで WHERE の条件を当てるので、条件に合うものが少ないと、LIMIT に満たない件数しか返りません。
仕組みを確かめるため、乱数で作った 384 次元のベクトルを 30,000 件入れ、そのうち約 1%(292 件)を「熱処理」にして試しました。ベクトルは乱数なので、検索の精度の話ではなく、件数が減る現象の確認です。LIMIT 10 で「熱処理」だけを探した結果です。
| 設定 | 返った件数 |
|---|---|
既定(hnsw.ef_search = 40) | 2 |
hnsw.ef_search = 200 | 2 |
hnsw.iterative_scan = relaxed_order | 10 |
hnsw.iterative_scan = strict_order | 10 |
条件に合う行が 292 件もあるのに、既定では 2 件しか返りませんでした。候補数を 200 に増やしても変わりませんでした。直したのは、0.8.0 で入った反復スキャンです。足りない間は索引を先へたどり続ける機能で、次のように設定します。
SET hnsw.iterative_scan = relaxed_order;
SELECT id, body FROM defect_reports
WHERE process = '熱処理'
ORDER BY embedding <=> $1
LIMIT 10;
strict_order は距離の順を厳密に保ち、relaxed_order は順番が少し前後することを許す代わりに、より多くを見つけます。どこまで先へたどるかは hnsw.max_scan_tuples(既定 20,000)で制限されます。条件に合う行が非常に少ないときは、索引ではなく通常の絞り込みから全件を距離順に並べたほうが確実な場合もあります。この記事の試行では、その境目は調べていません。
はまりどころ
- 質問と報告で、同じモデルを使う。モデルを替えると次元数も空間も変わるので、報告を全件入れ直すことになります。表に、どのモデルで作ったかの列を持たせておくと、あとで切り替えやすくなります。
- 128 トークンの上限。長い報告は、見出しと要約の短い文を別に作ってその埋め込みを持つか、段落に分けて持ちます。長文での挙動は、この記事では試していません。
- 検索の結果は、そのまま信じない。似た事例の候補を出す道具で、同じ原因だという保証はありません。品質の記録として使う前に、人が原因と処置を読んで確かめます。
- 機密の扱い。この構成は、モデルも検索も社内で完結します。外部の埋め込み API に切り替えると、不具合報告の本文が社外に出ます。取引先の図番や顧客名を含む場合は、生成 AI に入れる前に確かめる点を先に確認してください。
- アクセス権。報告に行単位の見せる範囲があるなら、PostgreSQL の行レベルセキュリティを併用します。ベクトルで検索しても、権限の判定は外れません。
この記事で実行していないものは、41 件を超える規模の速度の測定、日本語に強い他の埋め込みモデルとの比較、GPU での推論、Python 以外の言語での実装です。
出典
- pgvector「pgvector」README(HNSW の既定値、反復スキャン、距離の演算子、次元の上限、ハイブリッド検索)(2026年10月17日確認) https://github.com/pgvector/pgvector
- pgvector「CHANGELOG」(0.8.7 は 2026年10月1日、0.8.0 は 2024年10月30日) https://github.com/pgvector/pgvector/blob/master/CHANGELOG.md
- PostgreSQL「F.35. pg_trgm」(PostgreSQL 16 の文書) https://www.postgresql.org/docs/16/pgtrgm.html
- Hugging Face「sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2」(384 次元、最大 128 トークン、Apache-2.0) https://huggingface.co/sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2
- Sentence Transformers「Pretrained Models」(多言語モデルの対応言語に ja を含む) https://www.sbert.net/docs/sentence_transformer/pretrained_models.html
- Qdrant「FastEmbed」(ONNX Runtime による推論) https://qdrant.github.io/fastembed/


