以下の内容はhttps://product.plex.co.jp/entry/vector-search-with-railsより取得しました。


Rails + pgvector でベクトル検索を実装してみた

概要

  • Rails + PostgreSQL の既存構成に pgvector を追加して、ベクトル検索を検証してみた
  • 外部 API や Python を使わず、Ruby だけで Embedding 生成から検索まで完結する

ベクトル検索とは?

テキスト、画像、音声などのデータを Embedding(埋め込み) により数値ベクトルに変換し、その「意味」や「文脈」の近さを数値的に評価し、情報を検索する技術です。

単語の一致に依存せず、意味の類似性を考慮した検索が可能です。


Embedding(埋め込み)とは?

テキストを「数値の配列」に変換

Embedding モデルにテキスト(画像・音声も可)を渡すと、Float の配列(= ベクトル)に変換されます。 次元数はモデルによって異なります(今回の ruri-v3-30m は 256 次元、OpenAI text-embedding-3-large は 3,072 次元)。

model = Informers.pipeline('embedding', 'sirasagi62/ruri-v3-30m-ONNX')
vector = model.call("リリースの進捗", model_output: 'sentence_embedding', pooling: 'none')
#=> [-0.0297, 0.0384, 0.0042, -0.0710, 0.0958, ...]

vector.size
#=> 256

ローカルでも Embedding できる

Embedding モデルは「テキストを数値に変換する」だけの軽量なモデルです。 OpenAI などの Embedding API を使わずとも、ローカルで実行できます。

今回使った ruri-v3-30m は:

  • ONNX 形式に変換済みモデルを利用
  • informers gem を使うことで、Ruby プロセス内で直接推論できる
よくある構成: OpenAI API → Embedding(有料)
今回の構成:   informers gem → ローカル ONNX モデル → Embedding(無料)

ONNX(Open Neural Network Exchange)とは?

ML モデルの共通フォーマットです。PyTorch や TensorFlow など特定のフレームワークに依存せず、ONNX Runtime さえあればどの言語からでも推論できます。つまり Python で学習したモデルを Ruby から動かせます。

類似度の計測

ベクトル間の類似度は、「ベクトル空間上での距離や角度」が指標として評価されます。 いくつかの指標がありますが、レコメンドシステムでは Cosine 類似度がよく使われます。

指標 説明
ユークリッド距離 点間の直線距離
Cosine 類似度 ベクトル間の角度に基づく距離。方向性の相関を測る

今回の検証では Cosine 類似度を使っています。


実装例

使用技術

技術 説明
pgvector PostgreSQL の拡張機能。ベクトル型・距離計算・HNSW インデックスなどが使える
neighbor Rails で最近傍探索を行うための gem
informers ONNX 形式のモデルを Ruby から直接実行するための gem
ruri-v3-30m 日本語テキストに特化した軽量 Embedding モデル(30M パラメータ)

全てローカルの Ruby + PostgreSQL で完結します。

簡易フロー

pgvector を PostgreSQL にインストール

pgvector 単体なら公式イメージが利用できます。

既存の PostgreSQL イメージに追加する場合は、以下のように Dockerfile を作成します:

FROM postgres:16

RUN set -eux; \
  apt-get update; \
  apt-get install -y --no-install-recommends \
    build-essential \
    git \
    postgresql-server-dev-16 \
    ca-certificates; \
  rm -rf /var/lib/apt/lists/*; \
  \
  git clone --depth 1 https://github.com/pgvector/pgvector.git /tmp/pgvector; \
  cd /tmp/pgvector; \
  make; \
  make install; \
  rm -rf /tmp/pgvector; \
  \
  test -f /usr/share/postgresql/16/extension/vector.control

マイグレーション

pgvector 拡張を有効化

class InstallNeighborVector < ActiveRecord::Migration[8.0]
  def change
    enable_extension 'vector'
  end
end

テーブル作成

今回の検証では問い合わせメッセージを保存するテーブルを作成しました。

class CreateInquiries < ActiveRecord::Migration[8.0]
  def change
    create_table :inquiries do |t|
      t.text :content, null: false
      t.datetime :posted_at, null: false

      t.timestamps
    end

    add_index :inquiries, :posted_at
  end
end

embedding カラム + HNSW インデックス追加

class AddEmbeddingToInquiries < ActiveRecord::Migration[8.0]
  def change
    change_table :inquiries, bulk: true do |t|
      t.column :embedding, :vector, limit: 256
      t.datetime :embedded_at
    end

    add_index :inquiries,
              :embedding,
              using: :hnsw,
              opclass: :vector_cosine_ops,
              where: 'embedding IS NOT NULL',
              name: 'index_inquiries_on_embedding_hnsw'
  end
end

ポイント:

  • :vector 型で 256 次元のベクトルカラムを追加
    • limit: 256 は Embedding モデルの出力次元数に合わせる必要がある
  • opclass: :vector_cosine_ops で Cosine 類似度を指定
  • using: :hnsw で HNSW(Hierarchical Navigable Small World)インデックスを使用

モデル

class Inquiry < ApplicationRecord
  has_neighbors :embedding
end

has_neighbors :embedding の 1 行で nearest_neighbors メソッドが使えるようになります。

Embedding の実行

namespace :inquiry do
  task embedding: :environment do
    model = Informers.pipeline(
      'embedding',
      'sirasagi62/ruri-v3-30m-ONNX'
    )

    Inquiry
      .where(embedded_at: nil)
      .find_each do |inquiry|
      next if inquiry.content.blank?

      vector = model.call(
        inquiry.content,
        model_output: 'sentence_embedding',
        pooling: 'none'
      )

      inquiry.update!(
        embedding: vector,
        embedded_at: Time.current
      )

      puts "embedded inquiry_id=#{inquiry.id}"
    end
  end
end

ポイント:

  • informers が ONNX Runtime を使って Ruby プロセス内でモデルを実行
  • model_output: 'sentence_embedding' で文単位での embedding を取得
    • token_embeddings を指定すると token 毎に embedding される
  • pooling: 'none' は sentence_embedding の場合は不要(既に集約済み)

ベクトル検索の実装

namespace :inquiry do
  task :search, %i[query limit] => :environment do |_, args|
    query = args[:query].to_s
    limit = (args[:limit] || 5).to_i

    embed = Informers.pipeline('embedding', 'sirasagi62/ruri-v3-30m-ONNX')
    query_embedding = embed.call(
      query,
      model_output: 'sentence_embedding',
      pooling: 'none'
    )

    inquiries = Inquiry
      .where.not(embedding: nil)
      .nearest_neighbors(:embedding, query_embedding, distance: 'cosine')
      .limit(limit)

    inquiries.each do |inquiry|
      puts "[dist=#{inquiry.neighbor_distance}] #{inquiry.content}"
    end
  end
end

ポイント:

  • 検索クエリもベクトルに変換し、類似度を求める
  • nearest_neighbors メソッドで Cosine 類似度によるベクトル検索を行う
  • neighbor_distance で類似度スコアを取得できる(0 に近いほど類似)

動作イメージ

社内の問い合わせデータをインポートし、ベクトル検索を行った例です。(公開用に内容を一部変更しています)

$ rails "inquiry:search[ログイン できない, 3]"

[dist=0.079 posted_at=2025-09-03]
インターン生の方でGoogleの権限など異なる可能性があり、
サービスにログインできません。ログインできるよう調整をお願いいたします。
--------------------------------------------------------------------------------

[dist=0.093 posted_at=2025-10-23]
登録用リンクをSMSで送付したところ、
ユーザー側でエラーメッセージが出て開けなかった。
--------------------------------------------------------------------------------

[dist=0.099 posted_at=2025-10-01]
登録リンク経由で登録してもらったが、
ユーザーIDが表示されず、トークルームにも表示されない。

「ログイン できない」という自然言語のクエリで、意味的に近い問い合わせが検索できています。


RAG への発展

今回実装したのは、実は RAG(Retrieval-Augmented Generation) の R の部分です。

RAG は 2 ステップで構成されます:

ステップ 説明
Retrieval クエリに関連するドキュメントをベクトル検索で取得する
Augmented Generation 取得した検索結果を LLM に渡して、自然言語で回答を生成する

例えば「サービスの通知が来ない」と質問すると、ベクトル検索で関連する過去の問い合わせを取得し、それを LLM に渡して対応方法の回答を生成できます。


やってみた所感 & まとめ

Ruby でも実装できた

  • テキスト → Embedding → ベクトル保存 → 最近傍検索という流れさえ理解していれば、gem を組み合わせて簡単に実現できた
  • 難しい部分は gem がやってくれる
  • Python でなくてもできる

次元数が多いほど精度が良いわけではない

  • 当初、384 次元のモデルを使っていたが、256 次元の日本語特化 Embedding モデルの方が精度が良かった
  • 用途に合ったモデルを選ぶことが重要

実務での採用を検討できるレベル

  • コストがかからず、精度も悪くない
  • 1,000 件分のデータでしか試していないが、企業情報の紐付けやチケット検索くらいであれば、十分な精度で実現できそう
    • 1,000 件分のデータ処理は約 1〜2 分程度で完了しており、処理時間の観点でも実運用に耐えうる可能性がある

おわりに

弊社では各事業部でエンジニアを募集しております! 気になるポジションあればお気軽にお問い合わせください。一緒に働きましょう。

SaaS

【業界特化SaaSを連続立ち上げ】2年で顧客数2,400社超え/シニアソフトウェアエンジニア(フルスタック) - 株式会社プレックス

【業界特化SaaSを連続立ち上げ】2年で顧客数2,400社超え/ソフトウェアエンジニア - 株式会社プレックス

PLEX JOB

エッセンシャルワーカー産業の人材課題を解決 | フロントエンドの技術を牽引するテックリード - 株式会社プレックス

エッセンシャルワーカー領域で日本を動かす仕組みをつくるスタートアップのエンジニア - 株式会社プレックス

コーポレート

オペレーションの効率化によって事業成長に貢献するコーポレートエンジニア - 株式会社プレックス


参考リンク

  • pgvector - PostgreSQL のベクトル拡張
  • neighbor - Rails 向け最近傍探索 gem
  • informers - Ruby で ONNX モデルを実行
  • ruri-v3-30m - 日本語特化 Embedding モデル



以上の内容はhttps://product.plex.co.jp/entry/vector-search-with-railsより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14