🙌

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を指定した後

compose.yaml
    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を利用する。コストに関しては以下を参照いただきたいが、身構える程の量のコストは、少なくともこの記事で行っている内容程度では到達しない。

https://platform.openai.com/docs/pricing#embeddings

アプリケーションの作成

ここでは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のライブラリーを利用。

https://packagist.org/packages/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