キーワード検索は文字列を照合します。セマンティック検索は 意味を照合します — 「ログインを忘れました」が、共通する語が 1つもないのに「パスワードのリセット方法」という記事を見つけます。その 仕組みが埋め込みです。モデルが各テキストを数千個の数値からなるベクトルに 変換し、似た意味を持つテキストはその空間で近くに配置されます。一度この ベクトルを生成して保存すれば、何でも関連度で順位づけできます — あらゆる RAG システムの検索(リトリーバル)にあたる半分です。
Brievio は /v1/embeddings を OpenAI 互換の ドロップインエンドポイントとして提供しているので、SDK の呼び出しは あなたが既に書いているものとまったく同じ — 変わるのは base_url だけです。埋め込みは安価で、支払うのは正直な トークン数であり、失敗した 4xx/5xx の呼び出しに費用はかかりません。 本記事はその実践的な道筋です。ベクトルを生成し、コサイン類似度で スコアリングし、pgvector で保存・検索し、その結果を検索パイプラインに 組み込むまで — 実際に効いてくる注意点も添えて。
埋め込みを生成する
呼び出しは1回。文字列のリストを渡すと、同じ順序でベクトルのリストが 返ってきます。必ずバッチ処理を — 1リクエストに数百件入れても、1件ずつ 処理するのとトークン単価は同じで、何百回もの往復を節約できます。
# OpenAI SDK で埋め込みを生成 — 呼び出しは同じ、変えるのは base_url だけ。
from openai import OpenAI
client = OpenAI(
api_key="sk-brievio-...",
base_url="https://api.brievio.com/v1",
)
EMBED_MODEL = "text-embedding-3-large" # モデルは1つに決めて使い続ける
def embed(texts: list[str]) -> list[list[float]]:
# 1回の呼び出しで数百件までまとめて投げる — 往復回数は大幅に減り、
# トークン単価は同じ。レスポンスは入力の順序を保持する。
resp = client.embeddings.create(model=EMBED_MODEL, input=texts)
return [row.embedding for row in resp.data]
vecs = embed(["パスワードはどうやってリセットしますか?", "請求書はどこにありますか?"])
print(len(vecs), "x", len(vecs[0])) # 2 x 3072 (次元数はモデル依存)
# usage は正直に報告される — 埋め込みも他の呼び出しと同じくトークンで計測される
print(resp.usage.prompt_tokens, "トークン課金")ベクトルの長さ(その次元数)はモデルで固定されます — 一般的には 768、1536、3072 です。次元数が高いほどわずかにニュアンスを 多く捉えますが、保存と比較のコストは上がります。最も重要な 唯一のルール — 埋め込みモデルは1つに決めて、絶対に混ぜないこと。 あるモデルのベクトルと別のモデルのベクトルは、互いに異なり 互換性のない空間に存在します — 比較してもノイズしか出ません。利用できる 埋め込みモデルとその次元数は、実際の モデルカタログで確認してください。モデルごとの 価格は 料金ページに掲載しています。
コサイン類似度でスコアリングする
最も近い候補を見つけるには、クエリのベクトルを保存済みのベクトルと 比較します。標準的な指標がコサイン類似度です。 2つのベクトルがなす角度を測り、長さは無視するので、大きさではなく向き (意味)に注目します。1.0 は同じ向き、0 は無関係を意味します。
# セマンティック検索 = クエリを埋め込み、保存済みの全ベクトルとスコアを比べ、
# 最も近いものを返す。スコアリング関数がコサイン類似度。
import numpy as np
def cosine(a: np.ndarray, b: np.ndarray) -> float:
# 1.0 = 同じ向き、0 = 無関係、-1 = 正反対。
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
# 多くの埋め込みモデルは最初から単位長ベクトルを返す。その場合、
# コサイン類似度は単なる内積になる — 大規模ではこちらの方が安い:
def cosine_unit(a: np.ndarray, b: np.ndarray) -> float:
return float(a @ b)
query = np.array(embed(["ログインを忘れました"])[0])
corpus = np.array(embed(documents)) # (N, dim)
scores = corpus @ query / (
np.linalg.norm(corpus, axis=1) * np.linalg.norm(query)
)
top = scores.argsort()[::-1][:5] # 上位5件のインデックス
for i in top:
print(round(float(scores[i]), 3), documents[i][:60])実践上の注意が2つ。1つ目、多くの埋め込みモデルは最初から単位長ベクトルを 返します — その場合コサイン類似度は単なる内積になり、これこそが数万件規模の ベクトルに対する総当たり検索をデータベース不要で十分速くしている理由です。 2つ目、総当たりのループはおおよそ数万件の下限程度までなら問題ありません。 それを超えると、クエリのたびに全ベクトルを走査するのは遅くなり、 インデックス付きの本格的なベクトルストアが欲しくなります。
pgvector で保存・検索する
データが既に Postgres にあるなら、別途ベクトルデータベースは必要 ありません — pgvector 拡張が vector 型のカラムと最近傍演算子を追加します。各チャンクを 一度埋め込み、ベクトルをそのテキストの隣に保存し、検索は Postgres に任せます。
-- pgvector は Postgres をベクトルストアに変える。一度有効化すれば、
-- 各チャンクの埋め込みをそのテキストやメタデータと並べて保存できる。
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id text NOT NULL,
content text NOT NULL,
embedding vector(3072) -- モデルの次元数と一致させる必要がある
);
-- 近似最近傍インデックス。pgvector では <=> がコサイン距離
-- (0 = 最も近い)。インデックスは一括ロードの後に作る、前ではなく。
CREATE INDEX ON chunks
USING hnsw (embedding vector_cosine_ops);
-- 検索: クエリの埋め込みをパラメータ($1)として渡し、最も近い行を取る。
-- 距離の昇順 = 最も類似したものが先。
SELECT id, doc_id, content, 1 - (embedding <=> $1) AS similarity
FROM chunks
WHERE doc_id = ANY($2) -- 任意のメタデータ事前フィルタ
ORDER BY embedding <=> $1
LIMIT 5;<=> 演算子はコサイン距離(0 = 同一)なので、1 - (embedding <=> $1) で 類似度スコアに戻せます。hnsw インデックスは検索を近似的に しますが高速です — ほとんどの検索ワークロードでは、わずかな再現率の トレードオフは目に見えません。そしてこれは一括ロードの後に 作るものであり、決して前ではありません。任意の WHERE 句は 静かなる切り札です。テナント、文書、言語、日付でベクトル検索の前に 絞り込むことで、ユーザーが見る権限のない近傍を取得してしまうことが 決してなくなります。
RAG に組み込む
検索拡張生成(RAG)は、2つのステップを縫い合わせたものです。関連する コンテキストを見つけるセマンティック検索、そしてそのコンテキストを使って 答える生成モデルです。検索側は上で述べたすべて。生成側は通常の チャット補完であり — ここで安価な埋め込みモデルと強力な推論モデルを 組み合わせます。
- チャンクに分け、それから埋め込む。 文書を おおよそ数百トークンの一節に、少しの重なりを持たせて分割し、それぞれを 埋め込んで保存します。チャンク分割は、多くのチームが最も投資を怠る レバーです。チャンクが大きすぎると答えがノイズに埋もれ、小さすぎると 意味を成立させているコンテキストを失います。自分のデータで調整して ください。
- クエリ時に検索する。 ユーザーの質問を同じモデルで埋め込み、上位 3〜8 件のチャンクを取り出し、そのテキストを コンテキストとしてプロンプトに貼り付けます。
- 答えを生成する。 そのコンテキストと質問を、能力の高い モデル — 同じ Brievio エンドポイント経由の Claude や Gemini — に送り、 与えられたコンテキストのみから答え、どのチャンクを使ったか出典を 示すよう指示します。
経済性が成り立つのは、2つの半分の価格帯が大きく違うからです。コーパス 全体の埋め込みは一度きりの低コストなバッチ処理であり、再埋め込みは コンテンツが変わったときだけ発生します。クエリあたりのコストは検索では なく生成ステップが支配的です — だからこそ、当社の AI API コスト最適化プレイブック にあるコスト管理の手法(静的な指示文をプロンプトキャッシュする、出力に 上限をかける、タスクごとに適切なモデルを選ぶ)の方が、埋め込み側の どんな工夫よりもはるかに効きます。
実際に効いてくる注意点
- ベクトルはモデル間で互換性がない。 埋め込みモデルを 切り替えたら、コーパス全体を再埋め込みしなければなりません — クエリと 保存済みのベクトルは同じモデルから来る必要があり、さもなければスコアは 無意味です。モデルの選択はスキーマの決定として扱ってください。
- 次元数は保存と速度のトレードオフ。 3072 次元の ベクトルは 768 次元の4倍のバイト数で、比較も遅くなります。あなたの タスクにとって大きい方が自動的に優れているわけではありません — 最大の モデルに費用を払う前に、自分のクエリで再現率を測ってください。
- チャンクの質はモデルの質に勝る。 RAG の悪い答えの ほとんどは、弱い埋め込みモデルではなく、悪いチャンクに行き着きます。 文書の構造を尊重してください — 見出しと段落で分割し、文字数の 機械的な数え方ではなく。
- セマンティック検索は厳密な語を取りこぼしうる。 製品の SKU、エラーコード、名前などは、キーワード検索の方がうまく一致する こともあります。実際、最も強力なシステムはハイブリッドです。 ベクトル類似度と、古典的な全文検索や BM25 スコアを組み合わせます。
- 埋め込みにもコンテキスト上限がある。 モデルの入力 上限を超えたテキストは黙って切り詰められます — そうなると、あなたの 「埋め込み」は長すぎるチャンクの先頭部分しか表していません。チャンクは 上限に対して余裕を持って収めてください。
具体的な結論
セマンティック検索は4つの可動部品です。埋め込みモデル、類似度の指標、 ストア、そしてチャンク分割の戦略。埋め込みモデルは1つ選んでそれに コミットする。コサイン類似度を使う。総当たりの NumPy から始め、コーパスが 単一スキャンを超えて大きくなったら pgvector の HNSW インデックスへ 進む。そしてチューニングの予算はチャンク分割に注ぐ — 検索の品質はそこで 勝ち負けが決まるからです。Brievio を通せば、埋め込みの呼び出しは あなたが既に知っている OpenAI SDK の1行を変えるだけで、正直な トークン数で計測され、生成ステップに使う Claude や Gemini の モデルのすぐ隣にあります — 1つのキー、1つの base_url で、 RAG パイプライン全体が揃います。エンドポイントのリファレンスと 実行可能なクイックスタートはドキュメントに あります。