🔮

Rails 8 + React + pgvectorで作る偶発的発見を生むナレッジマネジメントシステム

に公開

はじめに

あなたは3ヶ月前に書いたメモの内容を覚えていますか。多くの場合、答えはNoでしょう。わたしたちは日々大量の情報をメモやノートとして蓄積していますが、その多くは時間とともに忘れ去られ、二度と参照されることはありません。

さらに深刻な問題があります。異なるプロジェクトや文脈で記録した情報同士のつながりに気づくことは、ほぼ不可能です。例えば、Aプロジェクトで学んだ技術的知見が、半年後に始まったBプロジェクトで役立つことがあります。しかし、その関連性に気づくのは偶然に頼るしかありません。

本記事では、こうした課題を技術的に解決するために開発した「Serendipity Engine」の設計と実装を解説します。このシステムは、Rails 8、React 18、PostgreSQLのpgvector拡張を組み合わせて、ナレッジベース内の意外なつながりを自動的に発見し、忘れられた知識を再浮上させます。

セレンディピティとは

セレンディピティは「偶然の幸運な発見」を意味する言葉です。語源は、ペルシャの民話「セレンディップの三人の王子」に由来します。

科学史において、セレンディピティによる発見は数多く存在します。例えば、ペニシリンの発見は、アレクサンダー・フレミングが培養皿にカビが混入したことに気づき、そのカビが細菌の増殖を阻害していることを観察したことから始まりました。彼が「汚染された培養皿」を単なる失敗として捨てていたら、この発見はなかったでしょう。

本システムが目指すのは、このような「偶然の発見」をデジタルな知識管理の世界で再現することです。ユーザーが探していなかった情報同士のつながりを、AIが自動的に発見して提示します。これにより、過去の知識が新しい文脈で再評価され、新たな洞察が生まれる可能性があります。

ソースコードはserendipity-engineとして公開しています。

なぜこのシステムを作ったのか

実際に直面した課題

複数のプロジェクトを並行して進める開発者として、以下の問題に直面していました。

あるとき、新しいプロジェクトでパフォーマンス問題に悩んでいました。数日間試行錯誤した後、ふと以前に携わった別プロジェクトのメモを見返したところ、ほとんど同じ問題の解決策が記録されていたのです。この経験から、「過去の自分の知識を効果的に活用できていない」という課題を強く認識しました。

従来の検索システムの限界

一般的な検索システムには以下の問題があります。

まず、探したいものが明確でなければ検索できません。上記の例では、わたしは「パフォーマンス問題」を解決したかったのであり、「半年前のメモ」を探していたわけではありません。つまり、検索クエリを適切に定式化できない状況では、検索システムは役に立ちません。

次に、キーワードが一致しないと見つかりません。過去のメモでは「レスポンス速度」と書いていたものを、今回は「パフォーマンス」と表現していました。このような表記の揺れは、キーワードベースの検索では対応できません。

さらに、異なるプロジェクト間のつながりに気づけません。人間の脳は、関連する情報を自動的に想起しますが、従来のシステムにはこの機能がありません。

最後に、長期間アクセスされていない情報は埋もれていきます。価値ある情報であっても、時間とともに検索結果の下位に沈んでいきます。

Serendipity Engineのアプローチ

本システムは、従来の「既知の問いに対する答えを探す」検索とは異なり、「まだ発見されていない問いを浮かび上がらせる」アプローチを採用しています。

具体的には、以下の3つの核となる機能を実装しています。

第一に、セマンティック検索による意味的な類似性の発見です。pgvectorを使用したベクトル検索により、キーワードが一致しなくても意味的に類似した情報を発見できます。

第二に、長期間アクセスされていない価値ある情報の再浮上です。アクセス履歴を分析し、過去に有用だったが最近使われていない情報を自動的に提示します。

第三に、異なるプロジェクト間の隠れた関連性の可視化です。プロジェクトの境界を超えて、意外なつながりを発見し、グラフとして可視化します。

システムアーキテクチャ

技術スタックと選定理由

本システムは以下の技術で構築されています。各技術の選定には明確な理由があります。

レイヤー 技術 選定理由
Frontend React 18 + TypeScript + Vite TanStack Queryによる効率的なサーバー状態管理と、Viteによる高速なHMRが開発体験を大幅に向上させます
Backend Rails 8 API + Devise-JWT Rails 8で標準化されたSolid Queueにより、Redisなしでバックグラウンドジョブを実行できます。Devise-JWTはステートレスなAPI認証を簡潔に実装できます
Database PostgreSQL 16 + pgvector pgvectorはPostgreSQLのネイティブ拡張として動作するため、別途ベクトルデータベースを導入する必要がありません。HNSWインデックスにより高速な近似最近傍探索が可能です
Background Jobs Solid Queue Rails 8の標準ジョブキューです。PostgreSQLをバックエンドとして使用するため、Redisなどの追加インフラが不要です。リトライ機能や優先度制御も組み込まれています
AI OpenAI Embeddings API text-embedding-3-smallモデルは、1536次元のベクトルを生成し、日本語テキストに対して高い精度を発揮します。API経由で利用できるため、GPUなどのインフラが不要です

技術選定で特に重視したのは、インフラの複雑さを最小限に抑えることです。PostgreSQL一つで、リレーショナルデータ、ベクトル検索、ジョブキューの全てを賄えます。これにより、開発環境のセットアップが容易になり、本番環境の運用コストも削減できます。

システム構成図

システムは以下の4つのレイヤーで構成されています。

セマンティック検索の実装

pgvectorによるベクトル検索

セマンティック検索の核となるのは、pgvectorを使ったベクトル類似度検索です。

データベーススキーマは以下のように設計しています。

# db/migrate/xxx_create_chunks.rb
create_table :chunks do |t|
  t.references :note, null: false, foreign_key: true
  t.integer :chunk_index, null: false
  t.text :content, null: false
  t.vector :embedding, limit: 1536  # OpenAI text-embedding-3-small
  t.timestamps
end

# HNSWインデックスによる高速検索
add_index :chunks, :embedding, 
          using: :hnsw, 
          opclass: :vector_cosine_ops

HNSWインデックスを使用することで、1536次元のベクトル空間における近似最近傍探索を高速に実行できます。

HNSWは階層的なグラフ構造を使用したアルゴリズムで、線形探索と比較して劇的に高速です。実測では、10万件のチャンクから類似度上位10件を取得する処理が、インデックスなしでは約5秒かかっていたものが、HNSWインデックスを使用することで50ミリ秒以下に短縮されました。これは100倍の高速化です。

Embedding生成の非同期処理

ノートの作成や更新時に、OpenAI APIを使ってEmbeddingを生成します。この処理は時間がかかるため、バックグラウンドジョブで非同期に実行します。

# app/jobs/embedding_job.rb
class EmbeddingJob < ApplicationJob
  queue_as :default

  def perform(note_id)
    note = Note.find(note_id)
    
    # ノートを1000文字ごとのチャンクに分割
    chunks = ChunkingService.new(note).call
    
    # 各チャンクのEmbeddingを生成
    chunks.each do |chunk_data|
      embedding = EmbeddingService.new.generate_embeddings([chunk_data[:content]]).first
      
      Chunk.create!(
        note: note,
        chunk_index: chunk_data[:index],
        content: chunk_data[:content],
        embedding: embedding
      )
    end
    
    # 関連性分析をキューに追加
    ConnectionBuildJob.perform_later(note_id)
  end
end

チャンク分割戦略

長文のノートを効果的に検索するため、適切なサイズにチャンク分割します。

# app/services/chunking_service.rb
class ChunkingService
  CHUNK_SIZE = 1000
  OVERLAP_SIZE = 200

  def initialize(note)
    @note = note
  end

  def call
    content = @note.content
    chunks = []
    index = 0
    position = 0

    while position < content.length
      chunk_end = [position + CHUNK_SIZE, content.length].min
      chunk_content = content[position...chunk_end]
      
      chunks << {
        index: index,
        content: chunk_content
      }
      
      index += 1
      position += CHUNK_SIZE - OVERLAP_SIZE
    end

    chunks
  end
end

200文字のオーバーラップを設けることで、チャンク境界での文脈の断絶を防いでいます。

このチャンクサイズは、いくつかの実験を経て決定しました。500文字では文脈が不足し、検索精度が低下しました。逆に2000文字では、一つのチャンクに複数のトピックが含まれてしまい、やはり精度が低下しました。1000文字が、単一のトピックを適切に表現できる最適なサイズでした。

また、オーバーラップなしでチャンク分割すると、重要な情報がチャンク境界で分断されてしまう問題がありました。200文字のオーバーラップにより、この問題を解決しています。

セマンティック検索の実行

Neighborgemを使用して、コサイン類似度による検索を実装します。

# app/models/chunk.rb
class Chunk < ApplicationRecord
  belongs_to :note
  has_neighbors :embedding

  scope :for_user, ->(user) {
    joins(:note).where(notes: { user_id: user.id })
  }

  def self.semantic_search(query_embedding, user:, limit: 10)
    for_user(user)
      .nearest_neighbors(:embedding, query_embedding, distance: "cosine")
      .limit(limit)
  end
end

検索時には、クエリ文字列のEmbeddingを生成し、それに最も近いチャンクを取得します。

# app/controllers/api/v1/search_controller.rb
def index
  query = params[:q]
  
  # クエリのEmbeddingを生成
  query_embedding = EmbeddingService.new.generate_embeddings([query]).first
  
  # 類似チャンクを検索
  chunks = Chunk.semantic_search(
    query_embedding,
    user: current_user,
    limit: 20
  )
  
  # チャンクからノートを取得し、重複を除去
  notes = chunks.map(&:note).uniq
  
  render json: notes
end

AI発見エンジンの実装

発見エンジンは、本システムの最も重要な機能です。ユーザーが能動的に検索しなくても、システムが自動的に価値ある情報を提示します。

発見アルゴリズムの設計思想

発見アルゴリズムを設計する際、以下の3つの原則を重視しました。

第一に、ユーザーの認知負荷を最小限に抑えることです。大量の発見を提示してもユーザーは処理しきれません。そのため、1日あたり最大5件に制限し、関連性スコアの高いものだけを提示します。

第二に、多様性を確保することです。同じタイプの発見ばかりでは、ユーザーの興味を引けません。そのため、3種類の異なる発見アルゴリズムを組み合わせています。

第三に、説明可能性を重視することです。なぜその発見が提示されたのか、ユーザーが理解できなければ信頼されません。そのため、各発見には関連性スコアと説明文を付与しています。

3種類の発見アルゴリズム

システムは以下の3種類の発見を自動的に生成します。

1. Forgotten Gems

長期間アクセスされていない価値ある情報を再浮上させます。

# app/services/discovery_engine.rb
def find_forgotten_gems(user)
  # 30日以上アクセスされていないノート
  old_notes = user.notes
    .where("last_accessed_at < ?", 30.days.ago)
    .where("access_count >= ?", 3)
  
  # 最近のノートとの類似度を計算
  recent_notes = user.notes
    .where("updated_at > ?", 30.days.ago)
  
  discoveries = []
  
  old_notes.each do |old_note|
    old_chunk = old_note.chunks.first
    next unless old_chunk
    
    recent_notes.each do |recent_note|
      recent_chunk = recent_note.chunks.first
      next unless recent_chunk
      
      similarity = calculate_similarity(old_chunk, recent_chunk)
      
      if similarity >= SIMILARITY_THRESHOLD
        discoveries << create_discovery(
          user: user,
          type: :forgotten_gem,
          source: old_note,
          target: recent_note,
          score: similarity
        )
      end
    end
  end
  
  discoveries.take(3)
end

2. Bridge

異なるプロジェクト間の予期しないつながりを発見します。

def find_bridges(user)
  projects = user.projects.includes(:notes)
  return [] if projects.count < 2
  
  discoveries = []
  
  # プロジェクトの全組み合わせを生成
  projects.combination(2).each do |proj_a, proj_b|
    best_similarity = 0
    best_pair = nil
    
    # 各プロジェクトのノート間で最も類似度の高いペアを探す
    proj_a.notes.each do |note_a|
      chunk_a = note_a.chunks.first
      next unless chunk_a
      
      proj_b.notes.each do |note_b|
        chunk_b = note_b.chunks.first
        next unless chunk_b
        
        similarity = calculate_similarity(chunk_a, chunk_b)
        
        if similarity > best_similarity
          best_similarity = similarity
          best_pair = [note_a, note_b]
        end
      end
    end
    
    if best_similarity >= SIMILARITY_THRESHOLD && best_pair
      discoveries << create_discovery(
        user: user,
        type: :bridge,
        source: best_pair[0],
        target: best_pair[1],
        score: best_similarity
      )
    end
  end
  
  discoveries.take(3)
end

3. Daily

最近更新されたノートと予期しない関連を発見します。

def find_daily_discoveries(user)
  recent_notes = user.notes
    .where("updated_at > ?", 7.days.ago)
    .includes(:chunks, :project)
  
  discoveries = []
  
  recent_notes.each do |note|
    chunk = note.chunks.first
    next unless chunk
    
    # 異なるプロジェクトのノートを検索
    similar_chunks = Chunk
      .for_user(user)
      .where.not(note_id: note.id)
      .nearest_neighbors(:embedding, chunk.embedding, distance: "cosine")
      .limit(5)
    
    similar_chunks.each do |similar_chunk|
      next if similar_chunk.note.project_id == note.project_id
      
      similarity = 1 - chunk.neighbor_distance(similar_chunk.embedding, distance: "cosine")
      
      if similarity >= SIMILARITY_THRESHOLD
        discoveries << create_discovery(
          user: user,
          type: :daily,
          source: note,
          target: similar_chunk.note,
          score: similarity
        )
      end
    end
  end
  
  discoveries.take(3)
end

日次自動実行

発見は毎日午前6時に自動的に生成されます。

# app/jobs/daily_discovery_job.rb
class DailyDiscoveryJob < ApplicationJob
  queue_as :default

  def perform
    User.find_each do |user|
      # ノートが5件未満のユーザーはスキップ
      next if user.notes.count < 5
      
      begin
        engine = DiscoveryEngine.new
        engine.generate_daily_discoveries(user)
      rescue StandardError => e
        Rails.logger.error("Discovery generation failed for user #{user.id}: #{e.message}")
      end
    end
  end
end

Solid Queueの定期実行機能を使用して、ジョブをスケジュールします。

# config/recurring.yml
production:
  daily_discovery:
    class: DailyDiscoveryJob
    schedule: "0 6 * * *"
    queue: default

グラフ可視化の実装

React Flowによるインタラクティブなグラフ

ノート間の関連性をReact Flowを使って可視化します。

// frontend/src/components/GraphView.tsx
import ReactFlow, { 
  Node, 
  Edge, 
  Controls, 
  Background 
} from 'reactflow';

interface GraphData {
  nodes: Array<{
    id: string;
    title: string;
    project_name: string;
  }>;
  edges: Array<{
    source_id: string;
    target_id: string;
    strength: number;
  }>;
}

export function GraphView({ appId }: { appId: string }) {
  const { data, isLoading } = useQuery({
    queryKey: ['graph', appId],
    queryFn: () => graphApi.getGraphData(appId)
  });

  if (isLoading) return <LoadingSpinner />;

  const nodes: Node[] = data.nodes.map(node => ({
    id: node.id,
    type: 'default',
    data: { 
      label: node.title,
      project: node.project_name
    },
    position: { x: 0, y: 0 } // レイアウトアルゴリズムで計算
  }));

  const edges: Edge[] = data.edges.map(edge => ({
    id: `${edge.source_id}-${edge.target_id}`,
    source: edge.source_id,
    target: edge.target_id,
    animated: edge.strength > 0.8,
    style: { 
      strokeWidth: edge.strength * 3 
    }
  }));

  return (
    <div style={{ height: '600px' }}>
      <ReactFlow
        nodes={nodes}
        edges={edges}
        fitView
      >
        <Controls />
        <Background />
      </ReactFlow>
    </div>
  );
}

サーバーサイドでのグラフデータ生成

グラフデータはバックエンドで生成し、フロントエンドに提供します。

# app/services/connection_analyzer.rb
class ConnectionAnalyzer
  def initialize(user)
    @user = user
  end

  def generate_graph_data(limit: 100, min_strength: 0.5)
    notes = @user.notes
      .includes(:project, :connections)
      .limit(limit)
    
    nodes = notes.map do |note|
      {
        id: note.id.to_s,
        title: note.title,
        project_name: note.project&.name || "未分類"
      }
    end
    
    edges = []
    
    notes.each do |note|
      note.connections
        .where("strength >= ?", min_strength)
        .each do |connection|
          edges << {
            source_id: connection.source_note_id.to_s,
            target_id: connection.target_note_id.to_s,
            strength: connection.strength
          }
        end
    end
    
    { nodes: nodes, edges: edges }
  end
end

パフォーマンス最適化

N+1クエリの防止

Railsのincludesを使用して、N+1クエリを防止します。

# app/controllers/api/v1/notes_controller.rb
def index
  notes = current_user.notes
    .includes(:project, :tags, :chunks)
    .order(updated_at: :desc)
    .page(params[:page])
    .per(20)
  
  render json: notes
end

Embeddingのキャッシュ

同じ内容のノートに対して重複してEmbeddingを生成しないよう、キャッシュを実装します。

# app/services/embedding_service.rb
class EmbeddingService
  def generate_embeddings(texts)
    texts.map do |text|
      cache_key = "embedding:#{Digest::SHA256.hexdigest(text)}"
      
      Rails.cache.fetch(cache_key, expires_in: 30.days) do
        response = client.embeddings(
          parameters: {
            model: "text-embedding-3-small",
            input: text
          }
        )
        
        response.dig("data", 0, "embedding")
      end
    end
  end
end

バックグラウンドジョブの優先度制御

Solid Queueの優先度機能を使用して、ジョブの実行順序を制御します。

# app/jobs/embedding_job.rb
class EmbeddingJob < ApplicationJob
  queue_as :default
  priority 10  # 高優先度
end

# app/jobs/daily_discovery_job.rb
class DailyDiscoveryJob < ApplicationJob
  queue_as :default
  priority 50  # 低優先度
end

セキュリティ対策

JWT認証の実装

Devise-JWTを使用して、ステートレスな認証を実装します。

# config/initializers/devise.rb
Devise.setup do |config|
  config.jwt do |jwt|
    jwt.secret = Rails.application.credentials.jwt_secret_key
    jwt.dispatch_requests = [
      ['POST', %r{^/api/v1/auth/login$}]
    ]
    jwt.revocation_requests = [
      ['DELETE', %r{^/api/v1/auth/logout$}]
    ]
    jwt.expiration_time = 24.hours.to_i
  end
end

アクセス制御

ユーザーは自身のリソースのみにアクセスできるよう制御します。

# app/controllers/api/v1/notes_controller.rb
class Api::V1::NotesController < ApplicationController
  before_action :authenticate_user!
  before_action :set_note, only: [:show, :update, :destroy]

  private

  def set_note
    @note = current_user.notes.find(params[:id])
  rescue ActiveRecord::RecordNotFound
    render json: { error: "Note not found" }, status: :forbidden
  end
end

SQL インジェクション対策

ActiveRecordのパラメータバインディングを使用して、SQLインジェクションを防止します。

# 安全な実装
Note.where("title LIKE ?", "%#{params[:q]}%")

# 危険な実装(使用しない)
Note.where("title LIKE '%#{params[:q]}%'")

テスト戦略

RSpecによる包括的なテスト

モデル、サービス、コントローラー、ジョブの全てをテストします。

# spec/services/discovery_engine_spec.rb
RSpec.describe DiscoveryEngine do
  describe "#generate_daily_discoveries" do
    let(:user) { create(:user) }
    let(:project_a) { create(:project, user: user) }
    let(:project_b) { create(:project, user: user) }
    
    before do
      # プロジェクトAのノート
      note_a = create(:note, user: user, project: project_a)
      create(:chunk, note: note_a, embedding: [0.1] * 1536)
      
      # プロジェクトBのノート(類似度が高い)
      note_b = create(:note, user: user, project: project_b)
      create(:chunk, note: note_b, embedding: [0.11] * 1536)
    end

    it "異なるプロジェクト間の関連性を発見する" do
      engine = described_class.new
      discoveries = engine.generate_daily_discoveries(user)
      
      expect(discoveries).not_to be_empty
      expect(discoveries.first.discovery_type).to eq("bridge")
    end
  end
end

VCRによる外部API呼び出しのモック化

OpenAI APIの呼び出しをVCRでモック化し、テストを高速化します。

# spec/support/vcr.rb
VCR.configure do |config|
  config.cassette_library_dir = "spec/fixtures/vcr_cassettes"
  config.hook_into :webmock
  config.filter_sensitive_data('<OPENAI_API_KEY>') { ENV['OPENAI_API_KEY'] }
end

# spec/services/embedding_service_spec.rb
RSpec.describe EmbeddingService do
  describe "#generate_embeddings" do
    it "OpenAI APIを使ってEmbeddingを生成する", :vcr do
      service = described_class.new
      embeddings = service.generate_embeddings(["テストテキスト"])
      
      expect(embeddings).to be_an(Array)
      expect(embeddings.first).to be_an(Array)
      expect(embeddings.first.length).to eq(1536)
    end
  end
end

PlaywrightによるE2Eテスト

フロントエンドのE2EテストをPlaywrightで実装します。

// frontend/e2e/notes.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Notes', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'password');
    await page.click('button[type="submit"]');
  });

  test('ノートを作成できる', async ({ page }) => {
    await page.goto('/notes');
    await page.click('text=新規ノート');
    
    await page.fill('input[name="title"]', 'テストノート');
    await page.fill('textarea[name="content"]', 'テスト内容');
    await page.click('button:has-text("保存")');
    
    await expect(page.locator('text=テストノート')).toBeVisible();
  });
});

Docker環境での開発

マルチステージビルド

開発環境と本番環境で異なるDockerイメージを使用します。

# backend/Dockerfile
FROM ruby:3.3.6-slim AS base

WORKDIR /app

# 依存関係のインストール
FROM base AS deps
COPY Gemfile Gemfile.lock ./
RUN bundle install

# 開発環境
FROM deps AS development
COPY . .
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0"]

# 本番環境
FROM deps AS production
COPY . .
RUN bundle exec rails assets:precompile
CMD ["bundle", "exec", "puma", "-C", "config/puma.rb"]

Docker Composeによる環境構築

全てのサービスをDocker Composeで管理します。

# compose.yml
services:
  db:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_USER: serendipity
      POSTGRES_PASSWORD: serendipity_dev
      POSTGRES_DB: serendipity_development
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

  backend:
    build:
      context: ./backend
      target: development
    environment:
      DATABASE_URL: postgres://serendipity:serendipity_dev@db:5432/serendipity_development
      RAILS_ENV: development
      OPENAI_API_KEY: ${OPENAI_API_KEY}
    depends_on:
      - db
    ports:
      - "3000:3000"

  frontend:
    build:
      context: ./frontend
      target: development
    environment:
      VITE_API_URL: http://localhost:3000
    ports:
      - "5173:5173"
    depends_on:
      - backend

volumes:
  postgres_data:
  bundle_cache:

実装から得られた学び

パフォーマンスの実測値

本システムを実際に運用して得られたパフォーマンスデータを共有します。

セマンティック検索のレスポンスタイムは、平均で200ミリ秒です。内訳は、Embedding生成に150ミリ秒、ベクトル検索に50ミリ秒です。ユーザー体験を損なわない範囲に収まっています。

発見生成のバッチ処理は、ユーザーあたり平均3秒です。100ユーザーの処理に約5分かかります。毎日午前6時に実行しているため、ユーザーがログインする頃には新しい発見が準備されています。

グラフ可視化は、100ノード・200エッジの描画に約1秒かかります。React Flowの最適化により、スムーズなインタラクションを実現しています。

技術的な課題と解決策

開発中に直面した主な課題と、その解決策を紹介します。

第一の課題は、Embedding生成のコストでした。OpenAI APIの課金は、処理したトークン数に応じて発生します。当初、ノートを更新するたびに全チャンクのEmbeddingを再生成していたため、コストが膨らみました。解決策として、内容が変更されたチャンクのみを再生成するようにし、コストを約70%削減しました。

第二の課題は、発見の精度でした。初期のアルゴリズムでは、類似度が高くても実際には関連性の低い発見が多く生成されました。解決策として、類似度の閾値を0.75に設定し、さらにプロジェクトの異なるノート同士を優先することで、精度を向上させました。

第三の課題は、N+1クエリによるパフォーマンス低下でした。Railsのincludesを適切に使用することで、データベースクエリを大幅に削減しました。

Rails 8のベストプラクティス

本システムは、Rails 8の推奨構成とベストプラクティスを採用しています。

Puma設定の最適化

Pumaの設定を最適化することで、同時リクエスト処理能力を向上させています。

# config/puma.rb
workers Integer(ENV.fetch("WEB_CONCURRENCY", 2))
threads_count = Integer(ENV.fetch("RAILS_MAX_THREADS", 5))
threads threads_count, threads_count
preload_app!

on_worker_boot do
  ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
end

ポイントは以下の通りです。

まず、workersで複数プロセスを起動し、並列処理能力を向上させます。WEB_CONCURRENCY環境変数で制御できるため、サーバーのスペックに応じて調整可能です。

次に、threadsでスレッドプールを設定し、IO並列性を向上させます。RAILS_MAX_THREADS環境変数で制御します。

さらに、preload_app!でアプリケーションを事前ロードし、Copy-on-Writeによるメモリ効率を最大化します。

最後に、on_worker_bootで各Workerのデータベース接続プールを再確立します。これはpreload_app!を使う場合に必須です。

N+1クエリ対策

すべてのコントローラーでincludesを使用して、関連データを事前ロードしています。

# app/controllers/api/v1/notes_controller.rb
def index
  @notes = current_user.notes.active
                       .includes(:project, :tags)  # N+1対策
                       .order(updated_at: :desc)
  
  render json: @notes.as_json(
    include: {
      project: { only: [:id, :name, :color] },
      tags: { only: [:id, :name, :color] }
    }
  )
end

as_jsonで関連データをシリアライズする場合、includesを使わないとN+1クエリが発生します。事前ロードにより、データベースクエリを大幅に削減できます。

Solid Queue/Cacheの活用

Rails 8で標準化されたSolid QueueとSolid Cacheを採用しています。

# config/environments/production.rb
config.cache_store = :solid_cache_store
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }

Solid Queueは、PostgreSQLをバックエンドとして使用するため、Redisなどの追加インフラが不要です。リトライ機能や優先度制御も組み込まれています。

# app/jobs/embedding_job.rb
class EmbeddingJob < ApplicationJob
  queue_as :default
  
  # 優先度設定(数値が小さいほど高優先度)
  self.priority = 10
  
  # リトライ設定
  retry_on Faraday::ConnectionFailed, wait: :polynomially_longer, attempts: 5
  discard_on ActiveRecord::RecordNotFound
  
  def perform(note_id)
    # ...
  end
end

Kamalによるデプロイ

Rails 8標準のデプロイツールKamalを使用しています。

# config/deploy.yml
service: backend
image: backend

servers:
  web:
    - 192.168.0.1

env:
  secret:
    - RAILS_MASTER_KEY
  clear:
    SOLID_QUEUE_IN_PUMA: true
    WEB_CONCURRENCY: 2

Kamalは、Dockerと統合されたデプロイツールで、以下のコマンドで簡単にデプロイできます。

# デプロイ実行
bin/kamal deploy

# SSL証明書の自動生成(Let's Encrypt)
# config/deploy.ymlで設定

まとめ

本記事では、Rails 8とReact 18、pgvectorを組み合わせて、AIを活用したナレッジマネジメントシステムを構築する方法を解説しました。

主要な技術的ポイントは以下の通りです。

まず、pgvectorとHNSWインデックスにより、PostgreSQL単体で高速なベクトル検索を実現しました。別途ベクトルデータベースを導入する必要がないため、インフラがシンプルになります。

次に、OpenAI Embeddingsを使ったセマンティック検索により、キーワードが一致しなくても意味的に類似した情報を発見できます。

さらに、3種類の発見アルゴリズムを組み合わせることで、多様なセレンディピティ体験を提供します。

加えて、React Flowによるインタラクティブなグラフ可視化により、ノート間の関連性を直感的に理解できます。

そして、Solid Queueによる効率的なバックグラウンド処理により、Redisなしでジョブキューを実現しました。

また、Puma設定の最適化、N+1クエリ対策、Kamalによるデプロイなど、Rails 8のベストプラクティスを採用しています。

最後に、RSpec、VCR、Playwrightを使った包括的なテスト戦略により、品質を保証しています。

このシステムは、単なる検索ツールではなく、ユーザーの知識ベースから新たな洞察を引き出すプラットフォームとして機能します。特に、異なるプロジェクト間のつながりを発見する機能は、知識労働者にとって大きな価値を提供します。

ソースコードはGitHubで公開していますので、ぜひ参考にしてください。

参考リンク

Discussion