「社内マニュアルをChatGPTに読ませたい」「自社プロダクトのドキュメントを質問応答化したい」「ハルシネーションを減らして根拠付きで答えてほしい」——2026年現在、こうした要件の答えは事実上一つしかありません。RAG(Retrieval-Augmented Generation)です。本記事は RAG を「概念として知っている」段階から「自分で本番に乗せられる」段階へ引き上げる本格実装ガイドです。シンプルRAGからハイブリッド検索、Reranking、HyDE、Self-Querying、Parent Document Retriever、Agentic RAG、GraphRAG、評価(RAGAS)、運用監視まで、コピペで動く TypeScript / JavaScript / Python コードを 40本以上 並べて、欠落しがちな「実装の勘どころ」を全部書きます。
- RAG とは何か〜LLM 単体の限界を埋める検索強化生成
- シンプルRAG実装〜7ステップで動く最小構成
- ドキュメント収集とローダー実装
- チャンク分割戦略〜精度を決める一番のレバー
- embeddings 生成〜OpenAI / Voyage / Cohere の使い分け
- ベクトルDB〜5大プロダクトの実装比較
- メタデータフィルタとハイブリッド検索で精度を底上げ
- Reranking で「最後の精度」を上げる
- クエリ変換テクニック〜Multi-query / HyDE / Self-querying
- 高度な検索戦略〜Parent / Contextual Compression / Time-weighted
- Agentic RAG〜LLM自身に検索計画を立てさせる
- GraphRAG と Knowledge Graph 連携
- 評価と品質保証〜RAGAS / TruLens
- プロダクション運用〜キャッシュ / ストリーミング / Citation
- 監視と継続改善〜LangSmith / Helicone
- 本番デプロイ〜Vercel AI SDK + Edge + Vector の構成例
- 学習の次のステップ〜独学とスクールの使い分け
- まとめ〜RAGは「検索の質」が9割
RAG とは何か〜LLM 単体の限界を埋める検索強化生成
LLM 単体は「学習時点までの一般知識」しか持ちません。社内固有の情報、最新の仕様変更、ユーザーごとのコンテキストは知らない。そこで「質問に関連するドキュメントを検索 → そのドキュメントを LLM に渡して回答生成」という二段構成にするのが RAG です。Fine-tuning と違い、知識の追加が ドキュメントを足すだけ で済み、根拠(Citation)も提示できるため、エンタープライズ用途のデファクトになりました。2023年に登場した当初は「ベクトルDBに突っ込んで類似度検索するだけ」というシンプルな実装が主流でしたが、2026年現在は Reranker・HyDE・Agentic・GraphRAG といった発展形が登場し、用途ごとに最適パターンを選ぶ時代になっています。本記事ではこれらをコードレベルで全部つなぎ込みます。
RAG が解決する課題は大きく3つあります。ひとつ目は「最新情報への追随」で、LLM の学習データは半年〜1年前で止まっているため、最新の規程や仕様変更には答えられません。ふたつ目は「社内固有知識の付与」で、社外秘ドキュメントは学習データに入りようがないため RAG 以外に手段がありません。みっつ目は「根拠の提示」で、医療・法務・金融といった領域では「どこにそう書いてあるか」を示せないと使い物にならないため、ベクトル検索で取ってきた原文を Citation として返せる RAG が必須になります。逆に「文体を統一したい」「JSON で必ず返したい」といった振る舞いの調整は RAG では難しく、Fine-tuning の領分です。
RAG の基本構造
[ユーザー質問] ↓ [Embedding化] → [ベクトルDB検索] → [上位K件取得] ↓ [Rerank(任意)] ↓ [LLMにcontextとして渡す] ↓ [根拠付き回答]
Fine-tuning ではなく RAG を選ぶ判断基準
「知識を足したい」なら RAG、「振る舞いを変えたい(口調・JSON出力など)」なら Fine-tuning、というのが2026年の鉄則です。RAG は更新コストが低く、Fine-tuning は推論コストが低い。両者は対立せず、振る舞いを Fine-tuning、知識を RAG、というハイブリッド構成も増えています。さらに最近は Long Context モデル(Gemini 1.5 Pro の2Mトークン等)の登場により「全部 context に突っ込めば RAG いらないのでは?」という議論もありますが、コストとレイテンシ、そして Lost in the Middle 問題(中間にある情報を LLM が無視する現象)を考えると、依然として「必要な情報だけ厳選して渡す」RAG のほうが本番品質では優位です。
2026年の標準スタック
# フロント・API Next.js (App Router) / Vercel AI SDK v5 # Embeddings OpenAI text-embedding-3-large / Voyage voyage-3 / Cohere embed-v4 # ベクトルDB Pinecone / Supabase pgvector / Weaviate / Qdrant / Chroma # 再ランキング Cohere Rerank v3 / Voyage rerank-2 # 評価・監視 RAGAS / TruLens / LangSmith / Helicone
シンプルRAG実装〜7ステップで動く最小構成
まずは飾りなしの最小 RAG を TypeScript で。ステップは固定で、(1)ドキュメント読込、(2)チャンク分割、(3)Embedding 生成、(4)ベクトルDBに保存、(5)質問を Embedding 化、(6)類似検索、(7)LLM に context として渡す、の7段です。この7ステップは、後で出てくる Agentic RAG や GraphRAG であっても土台として変わりません。まず最小実装を完全に動かしてから、各ステップを差し替えていくのが学習効率の高い進め方です。本節のコードは Pinecone を使っていますが、Supabase pgvector でも構造は同じなので、好きなベクトルDBに置き換えて構いません。
注意点として、Embedding 生成は1リクエストで複数チャンクをまとめて投げる(embedMany)ほうがコストもレイテンシも有利です。OpenAI の場合 1回の embed リクエストで最大 2048 個のテキストを処理できるので、ドキュメント全部を一気に投入できます。一方で、ユーザーの質問を embed する側は1件ずつなので、Redis などで軽くキャッシュしてあげると体感速度がかなり変わります。後段で Semantic Cache の節があるので、本番化する際はそちらも導入してください。
依存パッケージ
pnpm add ai @ai-sdk/openai openai pnpm add @pinecone-database/pinecone pnpm add langchain @langchain/textsplitters pnpm add pdf-parse mammoth cheerio pnpm add zod dotenv
環境変数
# .env.local OPENAI_API_KEY=sk-... PINECONE_API_KEY=... PINECONE_INDEX=rag-demo COHERE_API_KEY=... VOYAGE_API_KEY=...
最小RAGの全体コード
// src/rag-minimal.ts
import { openai } from "@ai-sdk/openai";
import { embed, embedMany, generateText } from "ai";
import { Pinecone } from "@pinecone-database/pinecone";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
const index = pc.index(process.env.PINECONE_INDEX!);
export async function ingest(text: string, source: string) {
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 800,
chunkOverlap: 100,
});
const chunks = await splitter.splitText(text);
const { embeddings } = await embedMany({
model: openai.embedding("text-embedding-3-small"),
values: chunks,
});
await index.upsert(
chunks.map((c, i) => ({
id: `${source}-${i}`,
values: embeddings[i],
metadata: { source, text: c },
})),
);
}
export async function ask(question: string) {
const { embedding } = await embed({
model: openai.embedding("text-embedding-3-small"),
value: question,
});
const result = await index.query({
vector: embedding,
topK: 5,
includeMetadata: true,
});
const context = result.matches
.map((m) => m.metadata?.text)
.filter(Boolean)
.join("n---n");
const { text } = await generateText({
model: openai("gpt-4o-mini"),
system: "あなたは渡されたcontextだけを根拠に日本語で答えるアシスタントです。contextに答えがなければ「分かりません」と答えてください。",
prompt: `# 質問n${question}nn# contextn${context}`,
});
return text;
}
CLIで叩いて動作確認
// src/cli.ts
import "dotenv/config";
import fs from "node:fs/promises";
import { ingest, ask } from "./rag-minimal";
const [, , cmd, ...rest] = process.argv;
if (cmd === "ingest") {
const file = rest[0];
const text = await fs.readFile(file, "utf-8");
await ingest(text, file);
console.log("ingested:", file);
} else if (cmd === "ask") {
const q = rest.join(" ");
const a = await ask(q);
console.log(a);
}
ドキュメント収集とローダー実装
PDF を読む
// src/loaders/pdf.ts
import pdfParse from "pdf-parse";
import fs from "node:fs/promises";
export async function loadPdf(path: string): Promise<string> {
const buf = await fs.readFile(path);
const data = await pdfParse(buf);
return data.text;
}
Word(.docx)を読む
// src/loaders/docx.ts
import mammoth from "mammoth";
export async function loadDocx(path: string): Promise<string> {
const { value } = await mammoth.extractRawText({ path });
return value;
}
HTML/Webページを読む
// src/loaders/web.ts
import * as cheerio from "cheerio";
export async function loadUrl(url: string) {
const res = await fetch(url);
const html = await res.text();
const $ = cheerio.load(html);
$("script,style,nav,footer,header").remove();
return {
title: $("title").text(),
text: $("body").text().replace(/s+/g, " ").trim(),
};
}
Markdown を構造を保ったまま読む
// src/loaders/markdown.ts
import fs from "node:fs/promises";
export async function loadMarkdown(path: string) {
const text = await fs.readFile(path, "utf-8");
const sections = text.split(/^##s+/m);
return sections.map((s, i) => ({
heading: i === 0 ? "intro" : s.split("n")[0],
body: s,
}));
}
チャンク分割戦略〜精度を決める一番のレバー
RAG の検索精度は実は「Embedding モデルの優劣」より「チャンク分割の質」のほうが効きます。小さすぎると文脈が消え、大きすぎると関係ない情報が混ざる。経験則として日本語なら 400〜800 文字 + overlap 50〜150 が初期値です。技術ドキュメントのように1セクションが長いものは 800〜1200 まで広げてよく、FAQ のように Q&A 単位で完結しているものは「Q&A 1組=1チャンク」にするのが最適解になります。コードを含むドキュメントは特に注意が必要で、関数の途中で分割するとどちらのチャンクも意味が壊れるため、後述する Code-Aware 分割が必須です。
もう一つ忘れがちなのが「メタデータの埋め込み」です。チャンクのテキストの先頭に「[第3章 経費精算規程] [更新日: 2025-12-01]」のような見出しを足しておくと、ベクトル空間上で関連クエリと近くなり、ヒット率が体感で 10〜20% 上がります。特に Markdown の階層が深いドキュメントでは効果が大きく、章タイトルをチャンクヘッダに自動付与するだけで検索品質が大きく改善します。
Recursive splitter(汎用・第一選択)
// src/chunking/recursive.ts
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
export function recursiveChunks(text: string) {
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 600,
chunkOverlap: 80,
separators: ["nn", "n", "。", "、", " ", ""],
});
return splitter.splitText(text);
}
Markdown 構造を保つ分割
// src/chunking/markdown.ts
import { MarkdownTextSplitter } from "@langchain/textsplitters";
export function mdChunks(md: string) {
const splitter = new MarkdownTextSplitter({
chunkSize: 800,
chunkOverlap: 100,
});
return splitter.splitText(md);
}
セマンティック分割(意味のまとまりで切る)
// src/chunking/semantic.ts
import { openai } from "@ai-sdk/openai";
import { embedMany } from "ai";
export async function semanticChunks(text: string) {
const sentences = text.split(/(?<=。)/).filter(Boolean);
const { embeddings } = await embedMany({
model: openai.embedding("text-embedding-3-small"),
values: sentences,
});
const chunks: string[] = [];
let current = sentences[0];
let currentVec = embeddings[0];
for (let i = 1; i < sentences.length; i++) {
const sim = cosine(currentVec, embeddings[i]);
if (sim > 0.78 && current.length < 800) {
current += sentences[i];
} else {
chunks.push(current);
current = sentences[i];
currentVec = embeddings[i];
}
}
chunks.push(current);
return chunks;
}
function cosine(a: number[], b: number[]) {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] ** 2;
nb += b[i] ** 2;
}
return dot / (Math.sqrt(na) * Math.sqrt(nb));
}
コードブロックを残す Code-Aware 分割
// src/chunking/code-aware.ts
export function codeAwareChunks(md: string) {
const blocks = md.split(/(```[sS]*?```)/g);
const out: string[] = [];
let buf = "";
for (const b of blocks) {
if (b.startsWith("```")) {
if (buf) { out.push(buf); buf = ""; }
out.push(b);
} else {
buf += b;
if (buf.length > 600) { out.push(buf); buf = ""; }
}
}
if (buf) out.push(buf);
return out;
}
embeddings 生成〜OpenAI / Voyage / Cohere の使い分け
OpenAI embeddings
// src/embeddings/openai.ts
import { openai } from "@ai-sdk/openai";
import { embedMany } from "ai";
export async function openaiEmbed(values: string[]) {
const { embeddings } = await embedMany({
model: openai.embedding("text-embedding-3-large", {
dimensions: 1024, // 1536 から削減して保存コスト削減
}),
values,
});
return embeddings;
}
Voyage embeddings(検索精度が高い)
// src/embeddings/voyage.ts
export async function voyageEmbed(texts: string[]) {
const res = await fetch("https://api.voyageai.com/v1/embeddings", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.VOYAGE_API_KEY}`,
},
body: JSON.stringify({
input: texts,
model: "voyage-3",
input_type: "document",
}),
});
const json = await res.json();
return json.data.map((d: any) => d.embedding) as number[][];
}
Cohere embeddings(多言語に強い)
// src/embeddings/cohere.ts
import { CohereClient } from "cohere-ai";
const cohere = new CohereClient({ token: process.env.COHERE_API_KEY! });
export async function cohereEmbed(texts: string[]) {
const r = await cohere.embed({
texts,
model: "embed-multilingual-v3.0",
inputType: "search_document",
});
return r.embeddings as number[][];
}
Query 用と Document 用の入力タイプを分ける
// 検索クエリ側は input_type="query" を指定
const r = await cohere.embed({
texts: [userQuestion],
model: "embed-multilingual-v3.0",
inputType: "search_query", // ←ここ重要
});
ベクトルDB〜5大プロダクトの実装比較
Pinecone(マネージド・運用が楽)
// src/db/pinecone.ts
import { Pinecone } from "@pinecone-database/pinecone";
const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
const index = pc.index("rag-demo");
export async function pineconeUpsert(
items: { id: string; vector: number[]; meta: Record<string, any> }[],
) {
await index.upsert(items.map(i => ({
id: i.id, values: i.vector, metadata: i.meta,
})));
}
export async function pineconeQuery(vector: number[], topK = 8) {
return index.query({ vector, topK, includeMetadata: true });
}
Supabase pgvector(SQLで完結)
-- SQL: 拡張とテーブル create extension if not exists vector; create table documents ( id bigserial primary key, content text, source text, embedding vector(1024) ); create index on documents using hnsw (embedding vector_cosine_ops);
// src/db/supabase.ts
import { createClient } from "@supabase/supabase-js";
const sb = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_KEY!);
export async function pgvectorInsert(content: string, source: string, vec: number[]) {
await sb.from("documents").insert({ content, source, embedding: vec });
}
export async function pgvectorSearch(vec: number[], k = 8) {
const { data } = await sb.rpc("match_documents", { query_embedding: vec, match_count: k });
return data;
}
-- SQL: 検索用RPC create or replace function match_documents( query_embedding vector(1024), match_count int ) returns table (id bigint, content text, source text, similarity float) language sql as $$ select id, content, source, 1 - (embedding <=> query_embedding) as similarity from documents order by embedding <=> query_embedding limit match_count; $$;
Weaviate(GraphQL ベース)
// src/db/weaviate.ts
import weaviate from "weaviate-client";
const client = await weaviate.connectToWeaviateCloud(process.env.WV_URL!, {
authCredentials: new weaviate.ApiKey(process.env.WV_KEY!),
});
export async function wvSearch(query: string) {
const col = client.collections.get("Docs");
return col.query.nearText(query, { limit: 8, returnMetadata: ["score"] });
}
Qdrant(自前ホスト向き)
// src/db/qdrant.ts
import { QdrantClient } from "@qdrant/js-client-rest";
const q = new QdrantClient({ url: "http://localhost:6333" });
export async function qdrantSearch(vec: number[]) {
return q.search("docs", { vector: vec, limit: 8, with_payload: true });
}
Chroma(ローカル開発に便利)
# pip install chromadb
import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
col = client.get_or_create_collection("docs")
col.add(documents=["..."], embeddings=[[...]], ids=["1"])
res = col.query(query_embeddings=[[...]], n_results=5)
メタデータフィルタとハイブリッド検索で精度を底上げ
メタデータでスコープを絞る
// 例: 部署=engineering かつ 公開済みドキュメントだけ検索
const res = await index.query({
vector: qvec,
topK: 8,
includeMetadata: true,
filter: {
department: { $eq: "engineering" },
status: { $eq: "published" },
updated_at: { $gte: 1735689600 },
},
});
BM25 を併用するハイブリッド検索
// src/search/hybrid.ts
import BM25 from "okapibm25";
export function bm25Search(corpus: string[], query: string, k = 8) {
const tokenized = corpus.map(c => c.split(/s+|、|。/).filter(Boolean));
const qTokens = query.split(/s+|、|。/).filter(Boolean);
const scores = BM25(tokenized, qTokens, { k1: 1.5, b: 0.75 }) as number[];
return scores
.map((s, i) => ({ idx: i, score: s, text: corpus[i] }))
.sort((a, b) => b.score - a.score)
.slice(0, k);
}
RRF(Reciprocal Rank Fusion)で結合
// src/search/rrf.ts
export function rrf(
lists: { id: string }[][],
k = 60,
) {
const score: Record<string, number> = {};
for (const list of lists) {
list.forEach((item, rank) => {
score[item.id] = (score[item.id] ?? 0) + 1 / (k + rank + 1);
});
}
return Object.entries(score)
.sort((a, b) => b[1] - a[1])
.map(([id, s]) => ({ id, score: s }));
}
ハイブリッド検索を組み合わせる
// src/search/hybrid-orchestrator.ts
export async function hybridSearch(query: string) {
const [vecHits, bmHits] = await Promise.all([
vectorSearch(query),
Promise.resolve(bm25Search(corpus, query)),
]);
const fused = rrf([vecHits, bmHits]);
return fused.slice(0, 8);
}
Reranking で「最後の精度」を上げる
top-K に20件取って Reranker で8件に絞る、というのが2026年のベストプラクティスです。ベクトル類似度は「だいたい近い」順、Reranker は「質問への答えとして正しい」順。役割が違うので併用すべきです。Reranker を入れるかどうかで RAGAS の context_precision が 0.6 から 0.85 に跳ね上がる、というのは実プロジェクトで何度も観測される現象で、コスト増(1クエリ 0.001〜0.002 ドル程度)に対するリターンが極めて大きい工程です。
注意点として、Reranker に投げる候補数は多すぎてもダメで、20〜40 件が経験上のスイートスポットです。100 件投げるとレイテンシが伸びるだけで精度はほぼ上がりません。逆に 5 件しか投げないと「そもそも正解がベクトル検索で漏れている」ケースを救えないため、ベクトル検索の topK と Reranker の topN を別々に管理し、前者 30 → 後者 8 のような構成にするのが定石です。
Cohere Rerank v3
// src/rerank/cohere.ts
import { CohereClient } from "cohere-ai";
const cohere = new CohereClient({ token: process.env.COHERE_API_KEY! });
export async function cohereRerank(query: string, docs: string[]) {
const r = await cohere.rerank({
query,
documents: docs,
model: "rerank-multilingual-v3.0",
topN: 8,
});
return r.results.map((x: any) => ({
text: docs[x.index],
score: x.relevanceScore,
}));
}
Voyage rerank-2
// src/rerank/voyage.ts
export async function voyageRerank(query: string, docs: string[]) {
const r = await fetch("https://api.voyageai.com/v1/rerank", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.VOYAGE_API_KEY}`,
},
body: JSON.stringify({
query, documents: docs, model: "rerank-2", top_k: 8,
}),
});
const json = await r.json();
return json.data.map((d: any) => ({ text: docs[d.index], score: d.relevance_score }));
}
LLM 自身を Reranker にする(コストは高いが精度◎)
// src/rerank/llm.ts
import { openai } from "@ai-sdk/openai";
import { generateObject } from "ai";
import { z } from "zod";
export async function llmRerank(query: string, docs: string[]) {
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: z.object({
ranking: z.array(z.object({ index: z.number(), score: z.number() })),
}),
prompt: `次の質問に最も答えになるドキュメントを並べ替え、index と 0-1 のscore を返してください。nn質問: ${query}nnドキュメント:n${docs.map((d, i) => `[${i}] ${d}`).join("n")}`,
});
return object.ranking.sort((a, b) => b.score - a.score);
}
クエリ変換テクニック〜Multi-query / HyDE / Self-querying
Multi-query retrieval(複数言い換えで網羅性UP)
// src/transform/multi-query.ts
import { openai } from "@ai-sdk/openai";
import { generateObject } from "ai";
import { z } from "zod";
export async function multiQuery(question: string) {
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: z.object({ queries: z.array(z.string()).length(4) }),
prompt: `次の質問を別の言い回しに4つ書き換えてください。検索用なので名詞中心で短く。n質問: ${question}`,
});
return [question, ...object.queries];
}
HyDE(Hypothetical Document Embeddings)
// src/transform/hyde.ts
import { openai } from "@ai-sdk/openai";
import { generateText, embed } from "ai";
export async function hydeEmbedding(question: string) {
const { text: pseudo } = await generateText({
model: openai("gpt-4o-mini"),
prompt: `次の質問に対する「仮の正答ドキュメント」を200字程度で書いてください。事実性は問わず、検索ヒット用のテキストです。n質問: ${question}`,
});
const { embedding } = await embed({
model: openai.embedding("text-embedding-3-small"),
value: pseudo,
});
return embedding;
}
Self-querying(自然文をフィルタに変換)
// src/transform/self-querying.ts
import { openai } from "@ai-sdk/openai";
import { generateObject } from "ai";
import { z } from "zod";
const Filter = z.object({
semantic: z.string(),
filter: z.object({
department: z.string().optional(),
year: z.number().optional(),
status: z.enum(["draft", "published"]).optional(),
}),
});
export async function selfQuery(q: string) {
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: Filter,
prompt: `次の質問から、semantic(検索本文)とfilter(メタデータ条件)を抽出してください。n質問: ${q}`,
});
return object;
}
Step-back prompting(抽象度を一段上げて検索)
// src/transform/step-back.ts
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
export async function stepBack(q: string) {
const { text } = await generateText({
model: openai("gpt-4o-mini"),
prompt: `質問を一段抽象化した上位質問に書き換えてください。例: 「Aさんの2024年度の交通費精算上限は?」→「経費精算規程の概要」n質問: ${q}`,
});
return text;
}
高度な検索戦略〜Parent / Contextual Compression / Time-weighted
Parent Document Retriever(小チャンクで検索→大チャンクで文脈)
// src/strategy/parent-doc.ts
type Child = { id: string; parentId: string; text: string; vec: number[] };
type Parent = { id: string; text: string };
const parents = new Map<string, Parent>();
const children: Child[] = [];
export async function retrieveWithParent(qvec: number[]) {
const top = await topKVector(qvec, 12);
const parentIds = new Set(top.map(t => t.parentId));
return [...parentIds].map(id => parents.get(id)!).filter(Boolean);
}
Contextual Compression(LLMで関連箇所だけ抽出)
// src/strategy/compression.ts
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
export async function compress(query: string, docs: string[]) {
const out: string[] = [];
for (const d of docs) {
const { text } = await generateText({
model: openai("gpt-4o-mini"),
prompt: `次の質問に関係する文だけをドキュメントから抜き出してください。関係なければ「無関係」と書いてください。n質問: ${query}nドキュメント:n${d}`,
});
if (!text.includes("無関係")) out.push(text);
}
return out;
}
Time-weighted retrieval(新しいドキュメントを優遇)
// src/strategy/time-weighted.ts
export function timeWeight(score: number, updatedAt: number, halfLifeDays = 60) {
const ageDays = (Date.now() - updatedAt) / 86400000;
const decay = Math.pow(0.5, ageDays / halfLifeDays);
return score * 0.7 + decay * 0.3;
}
export function rankWithTime(hits: { score: number; updatedAt: number; id: string }[]) {
return hits
.map(h => ({ ...h, weighted: timeWeight(h.score, h.updatedAt) }))
.sort((a, b) => b.weighted - a.weighted);
}
MMR(Maximal Marginal Relevance)で多様性を確保
// src/strategy/mmr.ts
export function mmr(
qvec: number[],
candidates: { vec: number[]; text: string }[],
k = 6,
lambda = 0.5,
) {
const selected: typeof candidates = [];
const pool = [...candidates];
while (selected.length < k && pool.length) {
let best = -Infinity, bestIdx = 0;
pool.forEach((c, i) => {
const rel = cosine(qvec, c.vec);
const div = selected.length === 0 ? 0
: Math.max(...selected.map(s => cosine(s.vec, c.vec)));
const score = lambda * rel - (1 - lambda) * div;
if (score > best) { best = score; bestIdx = i; }
});
selected.push(pool[bestIdx]);
pool.splice(bestIdx, 1);
}
return selected;
}
function cosine(a: number[], b: number[]) {
let d=0, na=0, nb=0;
for (let i=0;i<a.length;i++){ d+=a[i]*b[i]; na+=a[i]**2; nb+=b[i]**2; }
return d / (Math.sqrt(na)*Math.sqrt(nb));
}
Agentic RAG〜LLM自身に検索計画を立てさせる
固定 pipeline の RAG には限界があります。複雑な質問は「まず A を検索 → 結果を踏まえて B を検索 → 統合」のように動的計画が必要。これを LLM に Tool Calling 経由でやらせるのが Agentic RAG です。たとえば「先月のXXプロジェクトの予算超過の原因と、再発防止策の社内事例を教えて」という質問は、(1)XXプロジェクトの収支データを検索、(2)超過要因を特定、(3)同様の再発防止事例を社内ドキュメントから検索、という3段階を要し、ユーザーの最初の一文だけからは検索クエリが組めません。LLM自身に検索クエリを設計させ、結果を見て次のクエリを決める「動的検索」が Agentic RAG の本質です。
Agentic にすると精度は上がる一方、レイテンシとコストは跳ねます。1質問あたり 3〜5回 LLM が呼ばれるため、ユーザー体感で 10〜30 秒の待ち時間になりがちです。そこで本番では「単純な質問は固定パイプラインで即答、複雑そうな質問だけ Agentic に切り替える」というルーティングを噛ませるのが現実的です。質問の複雑度を LLM に分類させて2系統に分岐させるだけでも、コストは平均で1/3 に圧縮できます。
Tool Calling で検索を関数化
// src/agentic/tools.ts
import { tool } from "ai";
import { z } from "zod";
export const searchTool = tool({
description: "社内ナレッジベースを検索する",
parameters: z.object({
query: z.string(),
department: z.string().optional(),
}),
execute: async ({ query, department }) => {
return await hybridSearch(query, { department });
},
});
export const calcTool = tool({
description: "数式を評価する",
parameters: z.object({ expr: z.string() }),
execute: async ({ expr }) => eval(expr),
});
Agentic ループの実装
// src/agentic/agent.ts
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
import { searchTool, calcTool } from "./tools";
export async function agenticRag(question: string) {
const { text, steps } = await generateText({
model: openai("gpt-4o"),
system: "あなたはRAGエージェントです。必要に応じてsearchを複数回呼び出し、最後に根拠付きで答えてください。",
prompt: question,
tools: { search: searchTool, calc: calcTool },
maxSteps: 5,
});
return { answer: text, traces: steps };
}
Plan-and-Execute スタイル
// src/agentic/plan-execute.ts
import { openai } from "@ai-sdk/openai";
import { generateObject } from "ai";
import { z } from "zod";
const Plan = z.object({
steps: z.array(z.object({
action: z.enum(["search", "answer"]),
query: z.string().optional(),
})),
});
export async function planExecute(question: string) {
const { object: plan } = await generateObject({
model: openai("gpt-4o-mini"),
schema: Plan,
prompt: `質問: ${question}n答えるまでの検索ステップを設計してください。`,
});
const collected: string[] = [];
for (const s of plan.steps) {
if (s.action === "search" && s.query) {
const r = await hybridSearch(s.query);
collected.push(...r.map(x => x.text));
}
}
return finalAnswer(question, collected);
}
GraphRAG と Knowledge Graph 連携
ベクトル検索だけだと「Aさんの上司は誰?」のような関係性の質問に弱い。エンティティとリレーションを Knowledge Graph として持ち、検索結果と統合するのが GraphRAG です。Microsoft Research が2024年に発表した GraphRAG 論文以降、エンタープライズ用途で急速に普及しており、特に組織図・契約関係・部品表・治療プロトコルといった「ノード間の関係に意味がある」ドキュメント群で従来比2倍以上の精度を出すことが報告されています。
実装の勘どころは、(1)ドキュメントから LLM でトリプル(主語-述語-目的語)を抽出し、(2)Neo4j などのグラフDBに保存、(3)検索時はベクトル検索でエントリポイントとなるエンティティを特定し、(4)グラフ上を1〜2ホップたどって関連エンティティを集める、という流れです。グラフ抽出は精度が命なので、抽出専用のプロンプトを丁寧に作り、人間レビューで100件程度サンプリングしながらチューニングする工程が必須になります。
LLM でエンティティ抽出
// src/graph/extract.ts
import { openai } from "@ai-sdk/openai";
import { generateObject } from "ai";
import { z } from "zod";
const Triple = z.object({
triples: z.array(z.object({
subject: z.string(),
predicate: z.string(),
object: z.string(),
})),
});
export async function extractTriples(text: string) {
const { object } = await generateObject({
model: openai("gpt-4o-mini"),
schema: Triple,
prompt: `次の文章から (subject, predicate, object) の知識トリプルを抽出してください:n${text}`,
});
return object.triples;
}
Neo4j に保存
// src/graph/neo4j.ts
import neo4j from "neo4j-driver";
const driver = neo4j.driver("bolt://localhost:7687",
neo4j.auth.basic("neo4j", "password"));
export async function saveTriple(s: string, p: string, o: string) {
const session = driver.session();
await session.run(
`MERGE (a:Entity {name: $s})
MERGE (b:Entity {name: $o})
MERGE (a)-[:REL {type: $p}]->(b)`,
{ s, p, o },
);
await session.close();
}
Cypher クエリで関係を引く
// src/graph/query.ts
export async function findRelated(entity: string, hops = 2) {
const session = driver.session();
const r = await session.run(
`MATCH (a:Entity {name: $name})-[r*1..${hops}]-(b)
RETURN DISTINCT b.name as name LIMIT 20`,
{ name: entity },
);
await session.close();
return r.records.map(rec => rec.get("name"));
}
評価と品質保証〜RAGAS / TruLens
RAGAS で4指標を測る
# pip install ragas datasets
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision, context_recall
from datasets import Dataset
data = Dataset.from_dict({
"question": ["有給休暇は何日?"],
"answer": ["年20日です"],
"contexts": [["弊社の有給は入社1年で年20日付与..."]],
"ground_truth": ["20日"],
})
result = evaluate(data, metrics=[faithfulness, answer_relevancy, context_precision, context_recall])
print(result)
自前の評価ハーネス(TS)
// src/eval/harness.ts
type Case = { q: string; expected: string };
export async function evalRag(cases: Case[]) {
const results = [];
for (const c of cases) {
const start = Date.now();
const a = await ask(c.q);
const latency = Date.now() - start;
const judge = await llmJudge(c.q, c.expected, a);
results.push({ ...c, actual: a, latency, ...judge });
}
return results;
}
async function llmJudge(q: string, expected: string, actual: string) {
const { object } = await generateObject({
model: openai("gpt-4o"),
schema: z.object({
correct: z.boolean(),
reason: z.string(),
score: z.number(),
}),
prompt: `質問: ${q}n正答: ${expected}n回答: ${actual}n正解か?0-1のscoreで。`,
});
return object;
}
回帰テスト用Vitest
// tests/rag.test.ts
import { describe, it, expect } from "vitest";
import { ask } from "../src/rag-minimal";
describe("RAG", () => {
it("有給休暇日数を返す", async () => {
const a = await ask("有給休暇は何日もらえますか?");
expect(a).toMatch(/20日/);
});
it("知らない情報はわからないと答える", async () => {
const a = await ask("社長の好きな食べ物は?");
expect(a).toMatch(/分かりません|わかりません/);
});
});
プロダクション運用〜キャッシュ / ストリーミング / Citation
Embedding キャッシュ(同一クエリはRedisから返す)
// src/cache/embed-cache.ts
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
export async function cachedEmbed(text: string) {
const key = `emb:${hash(text)}`;
const cached = await redis.get<number[]>(key);
if (cached) return cached;
const { embedding } = await embed({
model: openai.embedding("text-embedding-3-small"),
value: text,
});
await redis.set(key, embedding, { ex: 60 * 60 * 24 * 7 });
return embedding;
}
function hash(s: string) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return h.toString(36);
}
Semantic Cache(意味的に近い質問にもキャッシュ)
// src/cache/semantic-cache.ts
const cacheIndex = pc.index("answer-cache");
export async function semanticCacheGet(q: string, threshold = 0.95) {
const vec = await cachedEmbed(q);
const r = await cacheIndex.query({ vector: vec, topK: 1, includeMetadata: true });
const top = r.matches[0];
if (top && top.score! >= threshold) return top.metadata?.answer as string;
return null;
}
export async function semanticCacheSet(q: string, answer: string) {
const vec = await cachedEmbed(q);
await cacheIndex.upsert([{ id: hash(q), values: vec, metadata: { answer } }]);
}
ストリーミング応答(Next.js App Router)
// app/api/rag/route.ts
import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
export const runtime = "edge";
export async function POST(req: Request) {
const { question } = await req.json();
const context = await hybridSearch(question);
const result = streamText({
model: openai("gpt-4o-mini"),
system: "contextのみを根拠に答えてください。出典は[番号]で示してください。",
prompt: `質問: ${question}nncontext:n${context.map((c, i) => `[${i+1}] ${c.text}`).join("n")}`,
});
return result.toDataStreamResponse();
}
Citation 表示(UI側)
// app/rag/page.tsx
"use client";
import { useChat } from "ai/react";
export default function RagChat() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: "/api/rag",
});
return (
<div>
{messages.map((m) => (
<div key={m.id}>
<strong>{m.role}:</strong>
{m.content.split(/([d+])/).map((p, i) =>
/[d+]/.test(p) ? <sup key={i}>{p}</sup> : <span key={i}>{p}</span>
)}
</div>
))}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
</form>
</div>
);
}
PII マスキング(個人情報を Embedding に乗せない)
// src/privacy/pii.ts
export function maskPii(text: string) {
return text
.replace(/[w.+-]+@[w-]+.[w.-]+/g, "[EMAIL]")
.replace(/0d{1,4}-d{1,4}-d{4}/g, "[PHONE]")
.replace(/bd{4}s?d{4}s?d{4}s?d{4}b/g, "[CARD]")
.replace(/bd{3}-d{4}b/g, "[ZIP]");
}
レート制限と再試行
// src/util/retry.ts
export async function retry<T>(fn: () => Promise<T>, max = 3) {
let lastErr: unknown;
for (let i = 0; i < max; i++) {
try { return await fn(); }
catch (e) {
lastErr = e;
await new Promise(r => setTimeout(r, 2 ** i * 500));
}
}
throw lastErr;
}
監視と継続改善〜LangSmith / Helicone
LangSmith でトレース
# 環境変数を入れるだけでLLM呼び出しが全部記録される LANGCHAIN_TRACING_V2=true LANGCHAIN_API_KEY=ls-... LANGCHAIN_PROJECT=rag-prod
Helicone で OpenAI 呼び出しを横取り
// src/observe/helicone.ts
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: "https://oai.helicone.ai/v1",
defaultHeaders: {
"Helicone-Auth": `Bearer ${process.env.HELICONE_API_KEY}`,
"Helicone-Property-App": "rag",
},
});
独自のメトリクス収集(PostgreSQL)
-- migrations/rag_metrics.sql create table rag_logs ( id bigserial primary key, ts timestamptz default now(), question text, answer text, retrieved_ids text[], latency_ms int, tokens_in int, tokens_out int, user_feedback smallint );
// src/observe/log.ts
export async function logQuery(row: {
question: string; answer: string;
retrieved: string[]; latency: number;
tin: number; tout: number;
}) {
await sb.from("rag_logs").insert({
question: row.question, answer: row.answer,
retrieved_ids: row.retrieved, latency_ms: row.latency,
tokens_in: row.tin, tokens_out: row.tout,
});
}
👍👎フィードバック収集
// app/api/feedback/route.ts
export async function POST(req: Request) {
const { logId, vote } = await req.json();
await sb.from("rag_logs").update({ user_feedback: vote }).eq("id", logId);
return Response.json({ ok: true });
}
本番デプロイ〜Vercel AI SDK + Edge + Vector の構成例
vercel.json 設定例
{
"functions": {
"app/api/rag/route.ts": { "maxDuration": 30 },
"app/api/ingest/route.ts": { "maxDuration": 300 }
}
}
Ingest をバックグラウンドJobに
// app/api/ingest/route.ts
import { waitUntil } from "@vercel/functions";
export async function POST(req: Request) {
const { url } = await req.json();
waitUntil(ingestUrl(url)); // 即200を返してバックグラウンド実行
return Response.json({ accepted: true });
}
クライアントから1行で呼ぶ
// frontend
const res = await fetch("/api/rag", {
method: "POST",
body: JSON.stringify({ question }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
process.stdout.write(decoder.decode(value));
}
プロダクション・チェックリスト
[ ] チャンクサイズを再現可能なpipelineで生成している [ ] 評価データセット(50問以上)が手元にある [ ] LLMモデルとEmbeddingモデルのバージョンをconfig化 [ ] Rerankerの有無を比較計測した [ ] PIIマスキングを通している [ ] Citationを必ず出している [ ] ハルシネーション検出(faithfulness)を回している [ ] ログを残し、👍👎を取れる [ ] Embedding cost を概算 monitoring [ ] 検索失敗時のfallback(知りませんと答える)テスト済
学習の次のステップ〜独学とスクールの使い分け
ここまでで RAG の主要パターンは一通り押さえました。次は実プロジェクトを回しながら、評価指標を見て改善を回す段階です。独学だけで全部習得しようとすると Embedding の選定・チャンク戦略・評価設計のどこかで詰まりがち。特に評価設計は経験者からフィードバックを受けないと「なんとなく動いている」状態から抜け出せず、本番投入後にハルシネーションでクレームが来てから慌てて再設計、というのが典型的な失敗パターンです。実務に活きるカリキュラムを持ったスクールでメンターから直接フィードバックをもらうと、半年遠回りせずに済みます。
RAG の習得難所は「Embedding モデルの選定」「チャンク戦略の試行錯誤」「Reranker の効果検証」「評価データセットの設計」「本番モニタリングの作り込み」の5点で、いずれも書籍だけでは判断軸が育ちません。週1〜2回のメンタリングがある環境で、自分のプロジェクトを題材に質問できる学習スタイルが結局いちばん早い、というのが2026年の生成AI実務者の共通認識です。
テックアカデミーのAIコースや侍エンジニアの生成AI実践コースは LangChain / ベクトルDB の実装課題が組まれており、本記事の内容を実プロジェクトに移植する段階のサポートに向いています。働きながら学びたい人は DMM WEBCAMP のオンライン、転職前提なら レバテックカレッジ 経由の紹介を組み合わせるのが現実的です。
関連実装記事
本記事と合わせて読みたい記事:
- AIでWebアプリを作る完全実装ガイド〜Vercel AI SDK・streaming・チャット・RAG入門〜 — 本記事の前段にあたる Vercel AI SDK の基礎
- Cursor完全実践ガイド2026〜Composer・Tab補完・AI Rules・Agent Mode徹底〜 — RAG実装を加速させるAIエディタ
- AIコーディングツール完全比較2026 — 開発環境の選定
まとめ〜RAGは「検索の質」が9割
RAG の精度はモデルではなく、(1)ドキュメント整備、(2)チャンク戦略、(3)Rerankerの有無、(4)評価ループの設計、で決まります。本記事のコードはそのまま組み合わせれば本番に乗せられます。最初は最小RAGで動かし、評価データを作り、Rerankerと HyDE を試し、Agentic に拡張する——この順序を守れば、半年後には「ハルシネーションなく根拠付きで答える」自社AIが手元にあります。あとは手を動かすだけです。
最後にひとつだけ補足すると、RAG の品質改善は「派手な手法を入れる」より「地味な前処理を直す」ほうがリターンが大きいのが現実です。チャンクヘッダにメタデータを足す、PDF からのテキスト抽出を見直す、評価データを30問から100問に増やす——こうした泥臭い改善で context_precision が0.6から0.85に上がる、というのは何度も経験する話で、Agentic や GraphRAG といったキラキラした手法は最後の最後の10%を取りに行く道具です。順番を間違えなければ、RAG は今最もROIの高いAI実装テーマと言っても過言ではありません。

コメント