Laravel × pgvector × Embeddingで作る「類似記事検索」の最短ルート
完成イメージ

左側の「類似記事」に注目
前提
Zennには記事をexportする機能がある。これを行うとarticles/ books/ scraps/ という3つのDirに記事が入っているzipがDLできる。冒頭のデモはarticles/ にexportされたfrontmatter付きmarkdownから手元のLaravel 12 Starter Kit内に記事を復元し、加えて 「類似検索」の機能を加えてみるというデモンストレーションとなる。
なお、ここではアプリケーション作成についてはClaude Codeで一括で作成していることもあり、詳細な開発の解説は行わない。本記事は「類似検索」について必要な知識に絞って解説する。
技術スタックやインフラ
まず、類似検索についてはPostgreSQLのpgvectorという拡張機能を利用する事にする。さらにOpenAIのAPIを通じ、embeddingというAPIコールを利用している。従って
- PostgreSQL
- OpenAIのAPI Key
の2つが無いと進められないし、逆に言えばこの2つだけあれば既存のシステムに、このような拡張機能を取り付ける事が可能だという事だ。
本稿で利用している環境
pgvector入りのpostgresql環境に関してはLaravel Sailを利用するのが速い。artisan sail:installでpostgresqlを指定した後
pgsql:
image: 'pgvector/pgvector:pg18'
作成されたcompose.yamlに対し、このようにimageにpgvector/pgvector:pg18を指定するだけで準備はOK。
AIへのAPI接続は必須
冒頭で述べた通り、本稿ではAIを用いたEmbeddingを利用する。
類似検索と聞くと、
テキスト A と B, C, D… を総当たりで比較し、
「A は C に 37% 似ている」
などと計算しているイメージを持たれがちだが、
実際にはそんなことはしていない。
AI は文章を「文字列」としてではなく、「何を表現しているか」という意味の塊として理解できる。
そのため、
- テキスト A
- B, C, D…
を一度すべて AI に渡し、AI がそれぞれを 数値ベクトルへ写像(mapping) する。この数値化されたベクトル同士の距離を計算することで、「意味的にどれだけ近いか」を高速に判定できる。
この処理を ベクター化(Embedding) と呼ぶ。
embeddingはAPIコールを伴いAIのリソースを借りるため、この点においてはいくばくかのコストは必要となる。ここではOpenAIのtext-embedding-3-smallを利用する。コストに関しては以下を参照いただきたいが、身構える程の量のコストは、少なくともこの記事で行っている内容程度では到達しない。
アプリケーションの作成
ここではClaude Codeでembeddingから何から何まで一度全て作らせたので、これに関してまず完成したものを見ていこう

弊記事の一覧

類似した記事が出ている
これを利用し、作成されたアプリケーションの内容を以下に解説していく
データー構造
以下の2本のテーブルが作成された
Schema::create('articles', function (Blueprint $table) {
$table->string('id', 32)->primary(); // hex filename without .md
$table->string('title');
$table->string('emoji', 10)->nullable();
$table->enum('type', ['tech', 'idea'])->default('tech');
$table->json('topics');
$table->boolean('published')->default(false);
$table->timestamp('published_at')->nullable();
$table->text('content'); // Raw markdown content
$table->text('content_for_embedding')->nullable(); // Preprocessed text for embedding
$table->timestamps();
// Indexes for filtering
$table->index('type');
$table->index('published');
$table->index('published_at');
});
これはzenn式markdownのfrontmatterを分解し、挿入するだけのものであり、シンプルにzennの記事を取り込み、メタデーターを含めて蓄積している。
続いて以下のテーブル。
DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
Schema::create('article_embeddings', function (Blueprint $table) {
$table->id();
$table->string('article_id', 32);
$table->vector('embedding', 1536); // text-embedding-3-small dimensions
$table->string('model', 50)->default('text-embedding-3-small');
$table->integer('token_count')->nullable();
$table->timestamps();
$table->foreign('article_id')
->references('id')
->on('articles')
->onDelete('cascade');
$table->index('article_id');
});
// Create HNSW index for fast approximate nearest neighbor search
DB::statement('CREATE INDEX article_embeddings_embedding_idx ON article_embeddings USING hnsw (embedding vector_cosine_ops)');
こちらがembeddingの本丸であり
$table->vector('embedding', 1536); // text-embedding-3-small dimensions
このようなvector()タイプのカラムが見える。ここで1536サイズになっているが、これはembeddingの世界では「次元(dimensions)」と呼ばれるものであり、これはベクトルの解像度、あるいは情報量の細かさを意味する。text-embedding-3-small では1536次元を返すため、このサイズが指定されている。
なお、この構成は
- 1アーティクル
- ↑全体のベクター数値を格納
という1対1の大胆な構造になっており、これは多分に問題を孕むのであるが、初手としてまず概念を理解する必要があるため、このような形になっている。冒頭のデモのGIFアニメを見ての通り、「一応動く」(ただし、この「一応」さが後々厄介になる所でもある)
前処理コマンドの用意
以下の2つのコマンドが用意された
-
artisan articles:import# 記事の取り込み -
artisan embeddings:generate# 取り込んだ記事のembedding
まずarticles:importで記事を取り込む。実行イメージは以下の通り
admin@ip-172-31-38-184:~/zenn-search$ vendor/bin/sail artisan migrate:fresh --seed
Dropping all tables ........................................... 18.40ms DONE
INFO Preparing database.
Creating migration table ....................................... 5.30ms DONE
INFO Running migrations.
0001_01_01_000000_create_users_table .......................... 10.01ms DONE
0001_01_01_000001_create_cache_table ........................... 5.12ms DONE
0001_01_01_000002_create_jobs_table ............................ 9.74ms DONE
2022_08_03_000000_create_vector_extension ...................... 0.71ms DONE
2025_08_26_100418_add_two_factor_columns_to_users_table ........ 2.37ms DONE
2026_01_07_204850_create_articles_table ........................ 5.73ms DONE
2026_01_07_204931_create_article_embeddings_table .............. 6.80ms DONE
INFO Seeding database.
admin@ip-172-31-38-184:~/zenn-search$ ./vendor/bin/sail artisan articles:import
記事のインポートを開始します...
インポート完了:
成功: 143
失敗: 0
このようにZenn Markdownからfrontmatterを分解し、内部に取り込んだファイルが143あった。
ベクター化とEmbedding
取り込めた記事をartisan embeddings:generateでembeddingする。
ここではopenai-php/laravelのライブラリーを利用。
これを正しくセットアップしOpenAIのキーをセットした後、以下のOpenAI::embeddings()メソッドを利用しembeddingする
use OpenAI\Laravel\Facades\OpenAI;
// ...
$response = OpenAI::embeddings()->create([
'model' => self::MODEL, // text-embedding-3-small
'input' => $texts,
]);
これにより、inputに対するベクトル値を含むレスポンスを$responseで受けとっている
最終的に受けとった値を
ArticleEmbedding::updateOrCreate(
['article_id' => $article->id],
[
'embedding' => $item['embedding'],
'model' => self::MODEL, // text-embedding-3-small を利用している
'token_count' => (int) ($response['usage']['total_tokens'] / count($texts)),
]
);
このような形で埋めこみをDBに保存している。
実行
実際にartisan embeddings:generateを実行した結果
$ ./vendor/bin/sail artisan embeddings:generate
143件の記事を処理します...
推定コスト: $0.0014
続行しますか? (yes/no) [no]:
> yes
143/143 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
完了:
成功: 125
失敗: 18
ここで「失敗」が18見られる。これは問答無用にformatterを除外したmarkdownをすべてAPIに投げた結果tokenがでかすぎて拒絶されたものである。このように何も考えずに行うとそれなりに失敗もあるのであるが、成功したものだけでも類似検索は可能なので以降、この成功したものだけで様子を見てみよう。
関連検索の実装
成功したもののみではあるが、これらの関連検索がControllerでどのように処理されているか見てみよう。
showコントローラーの抜粋
public function show(string $id): Response
{
$article = Article::query()
->published()
->with('embedding')
->findOrFail($id);
$similarArticles = null;
if ($article->embedding) {
try {
$similarArticles = $this->similarArticleService
->findSimilar($article, 5, ['published' => true])
->map(fn ($a) => [
'id' => $a->id,
'title' => $a->title,
'emoji' => $a->emoji,
'topics' => $a->topics,
'similarity' => round($a->similarity, 2),
]);
} catch (\RuntimeException $e) {
// No embedding available
$similarArticles = null;
}
}
ここで重要なのは、関連検索は「Embedding が存在する記事に対してのみ」実行されているという点だ。
さらに関連検索は関連検索は SimilarArticleService::findSimilar()に切り出されている。
$similarArticles = $this->similarArticleService->findSimilar()
これを具体的に見ると
public function findSimilar(
Article $sourceArticle,
int $limit = 5,
array $filters = []
): Collection {
$embedding = $sourceArticle->embedding;
if (! $embedding) {
throw new \RuntimeException("Article {$sourceArticle->id} has no embedding");
}
$query = Article::query()
->select('articles.*')
->selectRaw('1 - (article_embeddings.embedding <=> ?) as similarity', [$embedding->embedding])
->join('article_embeddings', 'articles.id', '=', 'article_embeddings.article_id')
->where('articles.id', '!=', $sourceArticle->id); // Exclude source article
// Apply filters
if (! empty($filters['published'])) {
$query->published();
}
if (! empty($filters['type'])) {
$query->where('type', $filters['type']);
}
if (! empty($filters['topics'])) {
$query->withTopics($filters['topics']);
}
if (! empty($filters['min_similarity'])) {
$query->havingRaw('similarity >= ?', [$filters['min_similarity']]);
}
return $query
->orderByDesc('similarity')
->limit($limit)
->get();
}
いくつかfilterが入っているので長くなっているが、実装を簡略化すると、コアは以下のようになっている。
$query = Article::query()
->select('articles.*')
->selectRaw('1 - (article_embeddings.embedding <=> ?) as similarity', [$embedding->embedding])
->join('article_embeddings', 'articles.id', '=', 'article_embeddings.article_id')
->where('articles.id', '!=', $sourceArticle->id); // Exclude source article
return $query
->orderByDesc('similarity')
->limit($limit)
->get();
ここで使われている <=> 演算子が、pgvector によるベクトル距離計算の肝である。
SQLで見てみよう
これを具体的なSQLで表現するなら以下の通りとなる
SELECT
a.id,
a.title,
1 - (
ae.embedding <=> (
SELECT embedding
FROM article_embeddings
WHERE article_id = '352b067c59d52e'
)
) AS similarity
FROM articles a
JOIN article_embeddings ae
ON a.id = ae.article_id
WHERE a.id <> '352b067c59d52e'
ORDER BY similarity DESC
LIMIT 5;
やっていることは非常に単純だ。
- 基準となる記事の embedding を取得
- 他の記事の embedding との距離を計算
距離が近い順に並べる
これだけで「それっぽい関連検索」が成立している。
実行結果
実際にこの SQL を実行すると、以下のような結果が得られる。
id | title | similarity
----------------+-------------------------------------------------------------------------------------------------------+--------------------
d2b7d4b07866ef | laravelの多言語化とスターターキットの多言語化をいろいろ踏まえて考えた結果の弊パッケージupdate | 0.727770220238607
bc3722aef08893 | AIエージェントで学ぶ実装・整形・テストの流れ:Codex × Laravel Starter Kit | 0.6817809560037847
3c1f700297663e | Laravel 12 Starter KitをAIで拡張する:セルフサインアップ・アピアランス・2FA制御 | 0.6803126740642105
9c0a7f4679cab0 | Laravel設定画面を実装する:/admin/settings・i18n・タイムゾーン運用の実務 | 0.6729014320910938
ce85174ec7769a | Playwright MCPで「実際の画面」ベースでZenn記法クローンを作ってみた(Claude Code増量キャンペーンにつき) | 0.6679206493880109
(5 rows)

実行結果
この結果をそのままフロントエンドに渡し、
View 側で「類似記事」として表示している。
この実装の問題点と今後の課題
ここまでで「Embedding + pgvector による類似記事検索」は一通り成立した。ただし、本実装は まず動かすことを優先した初手であり、実運用や精度の観点では多くの課題を抱えている。
以下に、現時点で明確になっている問題点と、今後の改善ポイントを整理する。
1. 1記事 = 1ベクトル構成の限界
本稿では、1記事につき1つの embedding を生成している。
これは概念理解には分かりやすいが、現実的には以下の問題を引き起こす。
- 記事が長いほど token 制限に引っかかりやすい(実際に失敗が発生している)
- 複数の話題を含む記事では、ベクトルが平均化されて意味がぼやける
- 冒頭・結論・コード例など、重要度の違いを表現できない
今後の課題
- 見出し単位・段落単位などで chunking を導入する
- 複数 chunk から記事全体の代表ベクトルをどう作るかを検討する
- 検索結果は最終的に記事単位へ再集約する
2. Embedding 用テキスト前処理が雑
現在は frontmatter を除去した Markdown 全文をそのまま embedding に渡しているが、これはノイズが多い。
- コードブロックやログが意味ベクトルを歪める
- URL、コマンド、エラー文が過剰に影響する
- Markdown の構造情報(見出し・箇条書き)を活かしていない
今後の課題
- embedding 用にテキストを整形する(コード削除・圧縮、URL除去など)
- 「タイトル + 見出し + 要約 + 本文」のように入力設計を行う
- コードが重要な記事では、自然文とコードを別ベクトルに分ける選択肢も検討する
また、実際にプロダクトとして投入する場合は、ここまでに挙げた改善点に加えて各種チューニングが必要になる。加えて、そのチューニングが本当に効いているかを検証する工程も避けられない。
この部分が一番泥臭い。最終的には 人手で結果を確認しながら「どこまで精度を高めるか」を決める必要がある。つまり、精度向上は技術課題であると同時に コスト判断でもある。
最低限、token 制限で弾かれている記事のように「そもそも土俵に上がっていない」ものは通すべきだとしても、そこから先はトレードオフになる。
- 多少の精度ブレは許容して、低コストで回すのか
- ばらつきを許さず、追加の前処理・再生成・評価フローに投資するのか
「何となく関連っぽい記事が出ていれば十分」な機能もあれば、「外すと致命的」な機能もある。ここは最早、技術だけで正解が決まる領域ではない。プロダクトの性質、ユーザー期待値、運用体制を踏まえた 現場判断が必要になる。
そしてこの意思決定を担うのは、実装担当ではなく、最終的には プロダクトオーナー/運用責任者(上流) の役割になるだろう。
次回以降
もうちょっとchunk化をしっかりやった後RAGまで実装する、予定...
Discussion