TL;DR
- Claude API(Voyage AI提供Embeddings)+ pgvector + Tool Use の3点セットで、外部SaaS不要のRAGシステムを構築する手順を解説する
- PostgreSQL拡張のpgvectorだけでベクトル検索を本番運用できる。Pinecone等の追加コストは不要
- チャンク戦略の比較・クエリ最適化・Tool Useとの統合まで、コピペで動くTypeScriptコード付きで完結する
1. RAGとは何か――Fine-tuningとの違いを理解する
RAGの基本概念(Retrieve → Augment → Generate)
RAG(Retrieval-Augmented Generation)は、LLMの回答生成前に「関連文書を検索してコンテキストに追加する」アーキテクチャだ。LLMの学習データカットオフや社内文書の非公開性という制約を、検索によって動的に補う。
flowchart LR
subgraph インデックス作成
D[文書] --> C[チャンク分割] --> E[Embedding生成] --> V[(pgvector)]
end
subgraph クエリ処理
Q[ユーザー質問] --> QE[Query Embedding] --> S[類似検索] --> V
S --> CTX[コンテキスト構築] --> G[Claude生成] --> A[回答]
end
3ステップの流れは次のとおりだ。
- Retrieve(検索): ユーザーの質問をベクトル化し、ドキュメントDBからコサイン類似度の高い文書を取得する
- Augment(拡張): 取得した文書をシステムプロンプトやコンテキストとしてClaudeへ渡す
- Generate(生成): Claudeが根拠となる文書を参照しながら回答を生成する
RAG vs Fine-tuning 選択基準
| 観点 | RAG | Fine-tuning |
|---|---|---|
| 知識更新コスト | 低(DBに追加するだけ) | 高(再学習が必要) |
| 向いているケース | 社内文書・最新情報検索 | 特定タスクの文体・出力形式の固定化 |
| コスト感 | API呼び出し+DB | GPU学習コスト(数万円〜) |
| 実装難易度 | 中 | 高 |
| 知識の透明性 | 高(ソース文書が追跡可能) | 低(モデル内部に埋め込まれる) |
結論: 社内ナレッジベース・製品ドキュメント・法令情報など「頻繁に更新される情報への回答」が目的なら、ほとんどのケースでRAGが費用対効果で優れる。Fine-tuningは特定ドメインの出力スタイル固定や推論パターンの強化に絞って検討する。
2. アーキテクチャ全体像
データ取り込みパイプライン(インデックス作成フロー)
flowchart TD
SRC[ソース文書\nPDF/Markdown/HTML] -->|テキスト抽出| RAW[生テキスト]
RAW -->|チャンク分割| CHK[チャンク群]
CHK -->|Voyage AI Embeddings| VEC[ベクトル配列 1024次元]
VEC -->|INSERT| PG[(PostgreSQL + pgvector)]
PG -->|HNSWインデックス| IDX[高速検索準備完了]
クエリパイプライン(検索・生成フロー)
flowchart LR
U[ユーザー質問] --> QV[Query Embedding]
QV --> SR[pgvector類似検索 top-k]
SR --> CTX[コンテキスト組み立て]
CTX --> CL[Claude API\nメッセージ生成]
CL --> ANS[最終回答]
インデックス作成はバッチ処理(文書取り込み時に一度だけ)、クエリパイプラインはリクエストごとにリアルタイム実行する。この2フェーズを分けて設計することが、後のスケールアウトを容易にする。
3. pgvectorセットアップ
Dockerでのインストール
最速で始めるにはpgvector公式イメージを使う。
docker run -d \
--name pgvector-dev \
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=ragdb \
-p 5432:5432 \
pgvector/pgvector:pg16
Docker Composeで管理する場合は次の構成を使う。
# docker-compose.yml
services:
db:
image: pgvector/pgvector:pg16
environment:
POSTGRES_PASSWORD: password
POSTGRES_DB: ragdb
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
接続後、拡張を有効化する。
CREATE EXTENSION IF NOT EXISTS vector;
-- 動作確認
SELECT '[1,2,3]'::vector;
Ubuntuへのaptインストール
既存PostgreSQLサーバーがある場合はaptで追加できる。
sudo apt install postgresql-16-pgvector
-- PostgreSQLにログイン後
CREATE EXTENSION IF NOT EXISTS vector;
スキーマ設計とインデックス
voyage-3は1024次元のベクトルを生成する。VECTOR(1024) でカラム型を定義する。
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
embedding VECTOR(1024),
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- HNSWインデックス(pgvector 0.5.0以降の推奨)
-- m: グラフの最大接続数、ef_construction: 構築時の探索幅
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- source_url重複排除用のUNIQUEインデックス(UPSERTで使用)
CREATE UNIQUE INDEX ON documents ((metadata->>'source_url'))
WHERE metadata->>'source_url' IS NOT NULL;
ivfflat vs HNSW の選択基準
| インデックス | 検索精度 | 構築速度 | クエリ速度 | 推奨タイミング |
|---|---|---|---|---|
| ivfflat | 中 | 速い | 中 | 百万件超・定期再構築可能な場合 |
| hnsw | 高 | 遅い | 速い | 通常の本番用途(推奨) |
件数が100万件を超えてHNSW構築が重い場合のみivfflatを検討する。それ未満はhnsw一択でよい。
4. Voyage AI Embeddings APIでベクトル生成
AnthropicのEmbeddingsはVoyage AIが提供するモデルを使用する。APIキーはAnthropic側とVoyage AI側の2種類が存在するが、本記事ではVoyage AIのSDKを直接使用する構成を採用する(公式ドキュメント参照)。
使用モデルの選定
| モデル | ベクトル次元 | 特徴 | 推奨用途 |
|---|---|---|---|
| voyage-3-lite | 512 | 高速・低コスト | 大量バッチ処理・プロトタイプ |
| voyage-3 | 1024 | 高精度 | 本番RAG・精度重視の用途 |
Voyage AI公式の比較によると、voyage-3はvoyage-3-liteと比較して検索精度(NDCG@10)が平均5〜8ポイント高い。本番環境ではvoyage-3を基本とし、レイテンシーやコストがボトルネックになった場合にvoyage-3-liteへ切り替える判断をする。
TypeScriptでのEmbedding生成実装
pnpm add voyageai
// src/lib/embeddings.ts
import { VoyageAIClient } from "voyageai";
const voyageClient = new VoyageAIClient({ apiKey: process.env.VOYAGE_API_KEY });
/** ドキュメント格納時のEmbedding生成 */
export async function generateEmbedding(text: string): Promise<number[]> {
const response = await voyageClient.embed({
input: text,
model: "voyage-3",
inputType: "document",
});
return response.data[0].embedding;
}
/** クエリ検索時のEmbedding生成(input_typeがqueryに変わる) */
export async function generateQueryEmbedding(query: string): Promise<number[]> {
const response = await voyageClient.embed({
input: query,
model: "voyage-3",
inputType: "query",
});
return response.data[0].embedding;
}
inputType の使い分けが重要だ。ドキュメント格納時は "document"、検索クエリには "query" を指定する。Voyage AIのアシンメトリック検索設計により、この区別で検索精度が向上する。
レート制限対策とバッチ処理
大量ドキュメントを処理する場合は、バッチ送信と指数バックオフを組み合わせる。
// src/lib/batch-embed.ts
import { VoyageAIClient } from "voyageai";
const voyageClient = new VoyageAIClient({ apiKey: process.env.VOYAGE_API_KEY });
const BATCH_SIZE = 128; // Voyage AIの最大バッチサイズ
export async function batchEmbed(texts: string[]): Promise<number[][]> {
const results: number[][] = [];
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE);
let attempts = 0;
while (attempts < 3) {
try {
const response = await voyageClient.embed({
input: batch,
model: "voyage-3",
inputType: "document",
});
results.push(...response.data.map((d) => d.embedding));
break;
} catch (err: unknown) {
attempts++;
if (attempts >= 3) throw err;
// 指数バックオフ: 1s, 2s, 4s
await new Promise((r) => setTimeout(r, 1000 * 2 ** (attempts - 1)));
}
}
}
return results;
}
5. チャンク分割戦略
適切なチャンク設計はRAG精度の根幹だ。チャンクが大きすぎるとEmbeddingが平均化されて検索精度が落ち、小さすぎると文脈が失われて回答品質が低下する。
3戦略の比較
| 戦略 | 概要 | メリット | デメリット | 推奨シーン |
|---|---|---|---|---|
| 固定長(token数) | 500tokenごとに分割 | 実装が単純、サイズ均一 | 文脈が途切れやすい | プロトタイプ・実験用 |
| 文境界 | 段落・文末で分割 | 意味が壊れにくい | サイズにばらつきが出る | 一般的なドキュメント |
| セマンティック | Embedding類似度でクラスタリング | 文脈保持が最高 | 実装コストが高い | 精度重視の本番環境 |
目安のチャンクサイズ: 300〜500token。ドキュメント種別(技術文書・FAQ・法令・マニュアル等)ごとに評価指標を計測して最適値を探ることが重要だ。
TypeScriptでの文境界チャンク実装
// src/lib/chunker.ts
interface ChunkOptions {
maxChars?: number; // tokenではなく文字数で管理するシンプル版
overlap?: number; // オーバーラップ文字数
}
/** maxCharsを超える単一段落を、上限以下の断片へ強制分割する */
function splitLongParagraph(para: string, maxChars: number): string[] {
if (para.length <= maxChars) return [para];
const pieces: string[] = [];
for (let i = 0; i < para.length; i += maxChars) {
pieces.push(para.slice(i, i + maxChars));
}
return pieces;
}
/**
* 段落境界でチャンク分割する。
* maxCharsを超えた時点で現在の蓄積を確定し、次のチャンクへオーバーラップする。
* maxCharsを超える長さの段落は、事前にsplitLongParagraphで分割しておく。
*/
export function chunkByParagraph(
text: string,
{ maxChars = 1200, overlap = 150 }: ChunkOptions = {}
): string[] {
const paragraphs = text
.split(/\n\n+/)
.flatMap((para) => splitLongParagraph(para, maxChars));
const chunks: string[] = [];
let current = "";
for (const para of paragraphs) {
if (current.length + para.length > maxChars) {
if (current) {
chunks.push(current.trim());
// オーバーラップ: 直前チャンクの末尾を引き継ぐ
current = current.slice(-overlap) + "\n\n" + para;
} else {
current = para;
}
} else {
current += (current ? "\n\n" : "") + para;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
/** タイトルやメタデータをチャンク先頭に付加してEmbedding精度を向上させる */
export function enrichChunk(chunk: string, title: string, category: string): string {
return `[${category}] ${title}\n\n${chunk}`;
}
改行のない長文(HTMLから抽出した本文など)を渡すと段落境界が見つからず、1段落がmaxCharsを大幅に超えることがある。上記の splitLongParagraph はそのケースを文字数で強制分割するためのガードだ。なお最終チャンクはオーバーラップ分を引き継ぐため、実際の最大長は maxChars + overlap 程度になる。厳密な上限が必要な場合は maxChars にその余裕を見込んで設定する。
enrichChunk はEmbedding精度の改善テクニックで、チャンク先頭にタイトルとカテゴリを付加することでEmbeddingが文書の文脈を正しく捉えやすくなる。
6. ベクトル検索の実装
pgvectorコサイン類似度検索クエリ
-- $1: クエリEmbeddingベクトル(文字列形式)、$2: カテゴリフィルタ、$3: LIMIT数
SELECT
id,
content,
metadata,
1 - (embedding <=> $1::vector) AS similarity
FROM documents
WHERE
($2::text IS NULL OR metadata->>'category' = $2)
AND 1 - (embedding <=> $1::vector) > 0.7 -- 低精度結果を除外
ORDER BY embedding <=> $1::vector
LIMIT $3;
<=> 演算子はpgvectorのコサイン距離演算子だ。1 - (距離) で類似度(0〜1)に変換する。0.7 未満の結果は関連性が低いためフィルタリングしてコンテキスト汚染を防ぐ。
Node.js(pg)からの呼び出し
// src/lib/search.ts
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export interface SearchResult {
id: number;
content: string;
metadata: Record<string, string>;
similarity: number;
}
export async function searchSimilarDocs(
queryEmbedding: number[],
limit = 5,
category?: string
): Promise<SearchResult[]> {
const vectorStr = `[${queryEmbedding.join(",")}]`;
const { rows } = await pool.query<SearchResult>(
`SELECT
id,
content,
metadata,
1 - (embedding <=> $1::vector) AS similarity
FROM documents
WHERE
($2::text IS NULL OR metadata->>'category' = $2)
AND 1 - (embedding <=> $1::vector) > 0.7
ORDER BY embedding <=> $1::vector
LIMIT $3`,
[vectorStr, category ?? null, limit]
);
return rows;
}
クエリ拡張(HyDE)でさらに精度を上げる
HyDE(Hypothetical Document Embeddings)は、ユーザー質問をそのままEmbeddingするのではなく、「もしこの質問に完璧に答えるドキュメントがあれば?」という仮説的な文書をClaudeに先生成させてからEmbeddingする手法だ。
// src/lib/hyde.ts
import Anthropic from "@anthropic-ai/sdk";
import { generateQueryEmbedding } from "./embeddings";
import { searchSimilarDocs } from "./search";
const client = new Anthropic();
export async function hydeSearch(userQuery: string, limit = 5) {
// Step1: 仮説的ドキュメントをClaudeで生成
const hypoResponse = await client.messages.create({
model: "claude-3-5-haiku-20241022",
max_tokens: 256,
messages: [
{
role: "user",
content: `以下の質問に対して、完璧な答えを含むドキュメントの一節を書いてください。質問: ${userQuery}`,
},
],
});
const firstBlock = hypoResponse.content[0];
const hypoDoc =
firstBlock?.type === "text" ? firstBlock.text : userQuery;
// Step2: 仮説的ドキュメントのEmbeddingで検索
const embedding = await generateQueryEmbedding(hypoDoc);
return searchSimilarDocs(embedding, limit);
}
HyDEは実装コストが低い割に精度向上効果が高い。検索精度に課題がある場合は最初に試す価値がある。
7. Tool UseとRAGの統合パターン
素朴なRAGは「毎回固定で検索してコンテキストに追加する」アーキテクチャだ。Claude自身が「いつ検索するか」「何を検索するか」を判断するTool Useと組み合わせると、複数ツール・条件分岐・マルチターン対話に対応した動的RAGを実装できる。
エージェントループの詳細設計については Tool Useエージェントループ設計 も参照されたい。
Tool定義からレスポンス生成までの完全実装
// src/lib/rag-tool-use.ts
import Anthropic from "@anthropic-ai/sdk";
import { generateQueryEmbedding } from "./embeddings";
import { searchSimilarDocs } from "./search";
const client = new Anthropic();
const searchTool: Anthropic.Tool = {
name: "search_documents",
description:
"社内ドキュメントをベクトル検索で取得する。ユーザーの質問に関連する情報が必要な場合に使用する。",
input_schema: {
type: "object" as const,
properties: {
query: {
type: "string",
description: "検索クエリ文字列",
},
category: {
type: "string",
description: "絞り込むカテゴリ(省略可)",
},
},
required: ["query"],
},
};
// 初回・再生成の両方で同じ方針を適用するため定数として共有する
const SYSTEM_PROMPT =
"あなたは社内ドキュメントに基づいて回答するアシスタントです。" +
"必要な情報が不明な場合はsearch_documentsツールを使って検索してから回答してください。" +
"検索結果が見つからなかった場合は「該当する情報が見つかりませんでした」と答えてください。";
export async function ragWithToolUse(userMessage: string): Promise<string> {
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: userMessage },
];
let response = await client.messages.create({
model: "claude-3-5-haiku-20241022",
max_tokens: 1024,
system: SYSTEM_PROMPT,
tools: [searchTool],
messages,
});
// Tool Useループ: stop_reasonが"tool_use"の間は検索→再生成を繰り返す
while (response.stop_reason === "tool_use") {
const toolUseBlock = response.content.find(
(b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
);
if (!toolUseBlock) break;
const input = toolUseBlock.input as { query?: string; category?: string };
if (typeof input.query !== "string" || input.query.length === 0) break;
// 実際の検索実行
const queryEmbedding = await generateQueryEmbedding(input.query);
const docs = await searchSimilarDocs(queryEmbedding, 5, input.category);
// アシスタントのtool_useと検索結果をメッセージ履歴に追加
messages.push({ role: "assistant", content: response.content });
messages.push({
role: "user",
content: [
{
type: "tool_result",
tool_use_id: toolUseBlock.id,
content:
docs.length > 0
? JSON.stringify(docs)
: "No relevant documents found.",
},
],
});
// 検索結果を踏まえてClaudeに再度回答生成させる
response = await client.messages.create({
model: "claude-3-5-haiku-20241022",
max_tokens: 2048,
system: SYSTEM_PROMPT, // 再生成でも方針を維持する
tools: [searchTool],
messages,
});
}
const textBlock = response.content.find(
(b): b is Anthropic.TextBlock => b.type === "text"
);
return textBlock?.text ?? "回答を生成できませんでした。";
}
フォールバック設計(検索ヒットなし時)
system promptに「検索結果がない場合の応答ポリシー」を明示することが重要だ。Claudeが検索結果なしに推測で回答することを防ぐ。
- similarity閾値(0.7未満を除外)でコンテキスト汚染を防ぐ
docsが空配列の場合、上記実装のようにtool_resultへ"No relevant documents found."を返すと、Claudeが「情報が見つからなかった」と正直に伝える("[]"を返すより意図が伝わりやすい)- system promptはTool Useループの再生成時にも毎回渡す。省略すると初回に宣言した応答ポリシーが失われる
8. 精度改善テクニック
本番投入後にRAG精度を継続改善するためのテクニックをまとめる。本番運用の全体チェックリストは LLM本番運用チェックリスト が詳しい。
リランキングとスコア閾値
pgvectorで上位10件を取得後、Voyage AIのリランキングモデルで上位5件に絞る2段階アプローチが有効だ。
// src/lib/rerank.ts
import { VoyageAIClient } from "voyageai";
import { SearchResult } from "./search";
const voyageClient = new VoyageAIClient({ apiKey: process.env.VOYAGE_API_KEY });
export async function rerankDocs(
query: string,
docs: SearchResult[],
topK = 5
): Promise<SearchResult[]> {
// 空配列を渡すとVoyage AI側でエラーになるため事前に返す
if (docs.length === 0) return [];
const response = await voyageClient.rerank({
query,
documents: docs.map((d) => d.content),
model: "rerank-2",
topK,
});
return response.data.map((r) => docs[r.index]);
}
ハイブリッド検索(ベクトル+全文検索)
セマンティック検索と全文検索(キーワードマッチ)を組み合わせることで、固有名詞や専門用語の検索漏れを補う。
-- pgvector(コサイン類似度)+ PostgreSQL標準全文検索(ts_rank)の組み合わせ
-- 重み: ベクトル70%、全文検索30%
SELECT
id,
content,
(
0.7 * (1 - (embedding <=> $1::vector)) +
0.3 * ts_rank(
to_tsvector('simple', content),
plainto_tsquery('simple', $2)
)
) AS hybrid_score
FROM documents
ORDER BY hybrid_score DESC
LIMIT 5;
日本語全文検索を使う場合は pg_bigm 拡張または pgroonga を別途インストールする('japanese' テキスト設定は標準では利用不可)。
チャンク設計の改善ポイント
- タイトル・メタデータをチャンクに結合する:
enrichChunk関数で示したとおり、ドキュメントタイトルとカテゴリをチャンク先頭に付加することでEmbeddingが文書コンテキストを正しく捉える - ドキュメントIDによる重複排除: 同一ドキュメントを再取り込みする場合は
ON CONFLICT ((metadata->>'source_url')) DO UPDATEで重複を防ぐ - メタデータフィルタの活用:
metadata JSONBカラムにcategory,source_url,created_at等を格納し、検索時の絞り込みに使う
-- メタデータ付きUPSERT例
INSERT INTO documents (content, embedding, metadata)
VALUES ($1, $2::vector, $3::jsonb)
ON CONFLICT ((metadata->>'source_url')) WHERE metadata->>'source_url' IS NOT NULL
DO UPDATE SET
content = EXCLUDED.content,
embedding = EXCLUDED.embedding,
metadata = EXCLUDED.metadata,
updated_at = NOW();
FAQ
Q1. RAGとFine-tuningはどちらを選ぶべきですか?
社内文書や頻繁に更新される情報への回答が目的なら RAG を選ぶ。知識の更新がDBへの追加だけで済み、ソース文書を追跡できるため運用コストが低い。Fine-tuningが有効なのは、特定ドメインの出力スタイル・フォーマットを固定したい、または推論パターン自体を変えたいケースに限られる。多くの本番ユースケースではRAGが費用対効果で優れる。
Q2. pgvectorとPineconeはどちらが適していますか?
自社インフラにPostgreSQLがあり、数十万件程度のベクトルデータであればpgvectorが有利だ。追加費用なし、SQL同一トランザクション内でメタデータと一緒に操作できる点が実用的だ。Pinecone等のSaaSが有利なのは、数百万件超のスケール・専任インフラチームなし・マネージドで運用したいケースに絞られる。
Q3. voyage-3とvoyage-3-liteの使い分けは?
プロトタイプや大量バッチ処理にはvoyage-3-lite(512次元、低コスト・高速)を使う。本番RAGで検索精度が重要な場面ではvoyage-3(1024次元)を推奨する。精度差は平均5〜8ポイント(NDCG@10)あるため、本番は原則voyage-3から始めてコストが問題になった場合にliteへ切り替えるという判断が無難だ。
Q4. チャンクサイズはどう決めればよいですか?
目安は300〜500token(文字数換算で約600〜1000字)。長すぎるとEmbeddingが平均化されて検索精度が落ち、短すぎると文脈が失われて回答品質が低下する。ドキュメント種別(技術文書・FAQ・法令・マニュアル等)ごとに評価データセットを用意し、RAGASのContext Relevance指標を測定して最適値を探ることが重要だ。
Q5. Tool Useを使わない素朴なRAGとどう使い分けますか?
固定のクエリパターンで十分なシンプルなQ&Aシステムなら素朴なRAGで実装量が少なくて済む。Tool Useが真価を発揮するのは「複数ツールを選択的に使う」「検索するかどうかをClaudeに判断させる」「マルチターン対話で文脈に応じた検索をしたい」ケースだ。複雑さに見合う理由がある場合のみTool Use統合を選択する。
Q6. RAGの評価はどうすればよいですか?
RAGASフレームワークを使い、3つの指標で定量評価する。Faithfulness(回答が検索結果に忠実か)、Context Relevance(取得文書がクエリに関連しているか)、Answer Relevance(回答がクエリに答えているか)だ。これらを計測して改善サイクルを回すことが、本番RAGの品質向上の近道になる。
まとめ
本記事で示した実装の要点を整理する。
- pgvector + HNSWインデックスでPostgreSQLをベクトルDBとして活用する(外部SaaS不要)
- **Voyage AI SDK(voyage-3)**でEmbeddingを生成し、
inputTypeのdocument/query使い分けで精度を確保する - チャンク設計はタイトル付加とオーバーラップを組み合わせ、300〜500tokenを目安に評価する
- Tool Use統合でClaudeが検索タイミングを自律判断する動的RAGを実現する
- ハイブリッド検索+リランキングで本番精度を改善する
次のステップとして、RAGASによる定量評価パイプラインの構築と、エージェントループのDurableワークフロー設計に進むことを推奨する。
