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