01TL;DR
- RAG(Retrieval-Augmented Generation)は「検索してから生成する」という2ステップの構成です。LLMの学習データにないドキュメントを根拠にした回答を実現します。
- 実装の核となる4つの工程はチャンク分割・埋め込み生成・ベクトル検索・引用付き回答生成です。それぞれに設計上の判断ポイントがあります。
- 「それらしいが間違う」現象の主因はLLMが検索結果を無視して自前の知識で補完することです。プロンプト設計と出典明示で抑制できます。
- 検索結果がゼロの場合のフォールバック設計を忘れると、LLMが架空の根拠を生成するリスクがあります。
- サンプルコードはTypeScript(Node.js v20.18.1)、ベクトルDBはPostgreSQL + pgvector v0.7.x を使います。
02はじめに
この記事の対象読者
- 「社内のPDFやWiki記事をLLMに検索させたい」と考えているエンジニア
- RAGという言葉は知っているが、実際にどう実装するかのイメージがまだない人
- ファインチューニングとRAGの違いを整理したい人
TypeScript、Node.js、PostgreSQL の基本的な扱いを前提にします。 LLMのAPI呼び出し経験があると、より読み進めやすいです。
実行環境
- Node.js: v20.18.1
- TypeScript: v5.5.x
- openai: v4.52.x(OpenAI API クライアント)
- pg: v8.12.x(PostgreSQL クライアント)
- pgvector(PostgreSQL 拡張): v0.7.x
- PostgreSQL: v16.x
この記事で得られること
- RAGの全体構成と各コンポーネントの役割
- チャンク分割の戦略と具体的な判断基準
- 埋め込み生成・ベクトル検索の実装パターン
- 出典明示と「根拠なし回答」の設計
- 検索結果ゼロ時のフォールバック設計
03RAGとは何か、なぜ必要か
ファインチューニングとRAGの違い
LLMに社内情報を扱わせるアプローチは主に2つあります。 ファインチューニング(追加学習)とRAGです。
ファインチューニングはモデルの重みを更新するため、ドキュメントが増えるたびに再学習が必要です。 コストと時間がかかり、更新頻度の高い社内資料には向きません。
RAGは学習ではなく「検索して文脈に差し込む」方式です。 ドキュメントが増えてもインデックスを更新するだけで済み、情報の鮮度を保ちやすいです。 また、回答に使った文書を引用として提示できるため、「どこを根拠にしたか」が追跡できます。
それぞれにトレードオフがあり、どちらが優れているというものではありません。 ただ、更新頻度が高く出典の透明性が重要な社内ドキュメント活用には、RAGが選ばれる場面が多いと感じています。
RAGの全体構成
RAGの処理は大きく「インデックス構築フェーズ」と「検索・回答フェーズ」の2つに分かれます。
flowchart TD
subgraph A["インデックス構築フェーズ(バッチ処理)"]
A1["ドキュメント"] --> A2["チャンク分割(文書を小さな断片に分ける)"]
A2 --> A3["埋め込み生成(各チャンクをベクトルに変換)"]
A3 --> A4["ベクトルDB保存"]
end
subgraph B["検索・回答フェーズ(リアルタイム)"]
B1["ユーザーの質問"] --> B2["質問を埋め込みに変換"]
B2 --> B3["ベクトル検索(類似チャンクを上位N件取得)"]
B3 --> B4["プロンプト構築(質問 + 検索結果 + 指示)"]
B4 --> B5["LLM生成"]
B5 --> B6["出典付き回答"]
end
この記事では各フェーズを順番に実装していきます。
04チャンク分割:どう切り分けるか
なぜチャンク分割が必要か
ドキュメント1件をそのままベクトルに変換しても、検索の精度は上がりません。 100ページのPDFを1ベクトルに圧縮すると、「どの部分が質問と関連するか」という情報が失われるためです。
適切な粒度に切り出すことで、「質問に近い箇所だけを拾う」ことができます。
チャンク分割の基本戦略
チャンク分割には主に3つの方針があります。
- 固定長分割:文字数やトークン数で一律に切る
- 文境界分割:文末(句点・改行)で切る
- セクション分割:見出しや段落を単位にする
それぞれに一長一短があり、ドキュメントの性質によって選択が変わります。
| 方針 | 向いているケース | 懸念点 |
|---|---|---|
| 固定長 | 非構造化テキスト | 文脈が途中で切れる |
| 文境界 | 一般的な文書 | 文が長いと粒度が安定しない |
| セクション | 構造化文書(マニュアル等) | セクションが長短バラバラになりやすい |
実装では「文境界で切りつつ上限トークン数を守る」ハイブリッドが扱いやすいです。
また、オーバーラップ(隣接チャンク間で一部を重複させる)を入れると、チャンク境界で文脈が断ち切られる問題を緩和できます。
// src/chunker.ts
// チャンク分割ユーティリティ(文境界 + 固定上限 + オーバーラップ)
export type Chunk = {
text: string;
// ドキュメント内の開始文字位置(インデックス追跡用)
startIndex: number;
endIndex: number;
};
export type ChunkOptions = {
// 1チャンクの最大文字数(目安)
maxChunkSize: number;
// 隣接チャンクと重複させる文字数
overlapSize: number;
};
const DEFAULT_OPTIONS: ChunkOptions = {
maxChunkSize: 800,
overlapSize: 100,
};
/**
* テキストを句点・改行で文に分割し、maxChunkSize を守りながらチャンクを作る。
* オーバーラップは前のチャンクの末尾を次のチャンクの先頭に再利用する形で実現する。
* 制約: 1文が maxChunkSize を超える場合、その文はそのまま1チャンクとして扱われる。
* 極端に長い文(URLや連続するテキストなど)が含まれる場合は、事前に分割しておくことを推奨する。
*/
export function splitIntoChunks(
text: string,
options: Partial<ChunkOptions> = {}
): Chunk[] {
const { maxChunkSize, overlapSize } = { ...DEFAULT_OPTIONS, ...options };
// 日本語・英語の文区切りを検出する簡易パターン
// 句点(。)、感嘆符(!)、疑問符(?)、改行の後で分割する
const sentencePattern = /[。!?\n]+/g;
const sentences: string[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = sentencePattern.exec(text)) !== null) {
const sentence = text.slice(lastIndex, match.index + match[0].length);
if (sentence.trim().length > 0) {
sentences.push(sentence);
}
lastIndex = match.index + match[0].length;
}
// 末尾の残り(区切り文字で終わらない場合)
if (lastIndex < text.length) {
const remaining = text.slice(lastIndex).trim();
if (remaining.length > 0) {
sentences.push(remaining);
}
}
const chunks: Chunk[] = [];
let currentChunk = "";
let currentStart = 0;
let charPosition = 0;
for (const sentence of sentences) {
const wouldExceed = currentChunk.length + sentence.length > maxChunkSize;
if (wouldExceed && currentChunk.length > 0) {
// 現在のチャンクを確定
chunks.push({
text: currentChunk,
startIndex: currentStart,
endIndex: currentStart + currentChunk.length,
});
// オーバーラップ:前チャンクの末尾 overlapSize 文字を次チャンクの先頭に入れる
const overlap = currentChunk.slice(-overlapSize);
currentStart = currentStart + currentChunk.length - overlap.length;
currentChunk = overlap + sentence;
} else {
if (currentChunk.length === 0) {
currentStart = charPosition;
}
currentChunk += sentence;
}
charPosition += sentence.length;
}
// 末尾の残りチャンク
if (currentChunk.length > 0) {
chunks.push({
text: currentChunk,
startIndex: currentStart,
endIndex: currentStart + currentChunk.length,
});
}
return chunks;
}
実際に動かすと次のような出力になります。
入力テキスト:「製品の返品は購入日から30日以内に受け付けます。
返品の際は領収書が必要です。領収書がない場合は対応できません。
返金はカード払いの場合、5〜7営業日かかります。」
チャンク数: 2(maxChunkSize=60, overlapSize=15 の場合)
Chunk[0]: "製品の返品は購入日から30日以内に受け付けます。\n返品の際は領収書が必要です。"
Chunk[1]: "領収書が必要です。領収書がない場合は対応できません。\n返金はカード払いの場合、5〜7営業日かかります。"
オーバーラップにより「領収書が必要です」が両方のチャンクに含まれているのが分かります。
チャンクサイズの目安
チャンクサイズの正解はドキュメントの性質と埋め込みモデルによって変わります。 筆者の経験では、日本語テキストの場合は 400〜800 文字程度を基準にして調整するとよい結果が得やすいです。 ただし「目安」であり、実際には検索品質を測りながらチューニングすることをお勧めします。
05埋め込み生成:テキストをベクトルに変換する
埋め込みとは何か
埋め込み(Embedding)は、テキストを数値の配列(ベクトル)に変換する処理です。 意味が近いテキストは数値的にも近いベクトルになるよう学習されています。
例えば「返品ポリシー」と「返金手続き」は、文字は違いますが意味が近いため、ベクトル空間でも近くなります。 一方で「返品ポリシー」と「採用要件」は意味が遠いため、ベクトル空間でも離れます。
この性質を使ってベクトル類似度で関連文書を検索するのがRAGの中核です。
OpenAI Embeddings API を使った実装
// src/embedder.ts
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
// 使用モデル: text-embedding-3-small(2024年1月リリース)
// 次元数: 1536
export const EMBEDDING_MODEL = "text-embedding-3-small";
export const EMBEDDING_DIMENSIONS = 1536;
/**
* 1件のテキストを埋め込みベクトルに変換する。
* APIコールに失敗した場合はエラーをそのまま投げる。
*/
export async function embedText(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: text,
// dimensions を指定すると短縮版ベクトルを取得できる(精度とコストのトレードオフ)
// ここでは省略してデフォルト次元を使用
});
return response.data[0].embedding;
}
/**
* 複数のテキストをまとめて埋め込む。
* OpenAI API は1リクエストで複数テキストを処理できるため、
* 1件ずつ呼ぶより大幅にコストを削減できる。
*/
export async function embedBatch(texts: string[]): Promise<number[][]> {
if (texts.length === 0) {
return [];
}
// 1リクエストあたりのテキスト数上限(APIの仕様ではなく安全のための自主制限)
const BATCH_SIZE = 100;
const results: number[][] = [];
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const batch = texts.slice(i, i + BATCH_SIZE);
const response = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: batch,
});
// APIレスポンスは入力順を保証しているため、そのまま配列に追加する
const batchEmbeddings = response.data
.sort((a, b) => a.index - b.index)
.map((item) => item.embedding);
results.push(...batchEmbeddings);
}
return results;
}
バッチ処理(embedBatch)を使うと、1件ずつ API を呼び出すより API 呼び出し回数を大幅に減らせます。
インデックス構築時に数千チャンクを処理する場面では、この差が体感できるほど出ます。
06ベクトルDB:pgvector で保存と検索を実装する
pgvector を選ぶ理由
ベクトル検索専用のサービス(Pinecone、Weaviate など)も選択肢にありますが、既存のシステムで PostgreSQL を使っているなら pgvector 拡張を追加するだけで対応できます。 新しいインフラを増やさずに済む点が、社内ドキュメント検索のような規模感では扱いやすいと感じています。 ただし、数百万チャンクを超える規模では専用サービスの方が有利な場面もあります。
テーブル設計
-- pgvector 拡張を有効化(DBに1回だけ実行)
CREATE EXTENSION IF NOT EXISTS vector;
-- ドキュメントのメタデータテーブル
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
source_path TEXT NOT NULL,
-- ドキュメントが更新されたかを判定するためのハッシュ
content_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- チャンクテーブル(埋め込みベクトルを含む)
CREATE TABLE document_chunks (
id SERIAL PRIMARY KEY,
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
text TEXT NOT NULL,
-- text-embedding-3-small の次元数は 1536
embedding VECTOR(1536),
-- 元ドキュメント内の位置情報(デバッグや引用表示に使う)
start_index INTEGER,
end_index INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ベクトル検索用インデックス
-- HNSW(Hierarchical Navigable Small World)インデックスは近似最近傍探索に優れる
-- ef_construction: 構築時の精度と速度のトレードオフ(大きいほど精度↑・構築時間↑)
-- m: グラフの各ノードが持つ最大エッジ数(大きいほど精度↑・メモリ↑)
CREATE INDEX ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (ef_construction = 128, m = 16);
インデックス構築の実装
// src/indexer.ts
import { Pool } from "pg";
import { splitIntoChunks } from "./chunker.js";
import { embedBatch } from "./embedder.js";
import crypto from "crypto";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export type DocumentInput = {
title: string;
sourcePath: string;
content: string;
};
/**
* ドキュメントをチャンク分割・埋め込み生成し、DBに保存する。
* すでに同じハッシュのドキュメントがある場合はスキップする(冪等性)。
*/
export async function indexDocument(doc: DocumentInput): Promise<void> {
const contentHash = crypto
.createHash("sha256")
.update(doc.content)
.digest("hex");
const client = await pool.connect();
try {
await client.query("BEGIN");
// 既存チェック(同一ハッシュがあればスキップ)
const existing = await client.query(
"SELECT id FROM documents WHERE source_path = $1 AND content_hash = $2",
[doc.sourcePath, contentHash]
);
if (existing.rows.length > 0) {
console.log(`スキップ(変更なし): ${doc.sourcePath}`);
await client.query("ROLLBACK");
return;
}
// 古いエントリを削除(ON DELETE CASCADE でチャンクも連動して削除される)
await client.query("DELETE FROM documents WHERE source_path = $1", [
doc.sourcePath,
]);
// ドキュメント登録
const docResult = await client.query(
`INSERT INTO documents (title, source_path, content_hash)
VALUES ($1, $2, $3) RETURNING id`,
[doc.title, doc.sourcePath, contentHash]
);
const documentId = docResult.rows[0].id;
// チャンク分割
const chunks = splitIntoChunks(doc.content, {
maxChunkSize: 800,
overlapSize: 100,
});
console.log(`チャンク数: ${chunks.length}(${doc.title})`);
// バッチ埋め込み(チャンクのテキスト部分のみ渡す)
const embeddings = await embedBatch(chunks.map((c) => c.text));
// チャンクを一括挿入
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const embedding = embeddings[i];
// pgvector の VECTOR 型は "[x1,x2,...,xN]" 形式の文字列で渡す
const embeddingStr = `[${embedding.join(",")}]`;
await client.query(
`INSERT INTO document_chunks
(document_id, chunk_index, text, embedding, start_index, end_index)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
documentId,
i,
chunk.text,
embeddingStr,
chunk.startIndex,
chunk.endIndex,
]
);
}
await client.query("COMMIT");
console.log(`インデックス完了: ${doc.title}(${chunks.length} チャンク)`);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
ベクトル検索の実装
// src/searcher.ts
import { Pool } from "pg";
import { embedText } from "./embedder.js";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export type SearchResult = {
chunkId: number;
documentId: number;
documentTitle: string;
sourcePath: string;
chunkText: string;
// コサイン類似度(1に近いほど質問と意味が近い)
similarity: number;
};
/**
* 質問テキストに意味的に近いチャンクを上位 topK 件返す。
* similarity_threshold 未満のチャンクは除外する。
*/
export async function searchChunks(
query: string,
topK: number = 5,
similarityThreshold: number = 0.5
): Promise<SearchResult[]> {
// 質問を同じモデルで埋め込みに変換する
const queryEmbedding = await embedText(query);
const embeddingStr = `[${queryEmbedding.join(",")}]`;
// <=> は pgvector のコサイン距離演算子
// 距離 = 1 - 類似度 なので、距離が小さいほど類似度が高い
const result = await pool.query(
`SELECT
dc.id AS chunk_id,
dc.document_id,
d.title AS document_title,
d.source_path,
dc.text AS chunk_text,
1 - (dc.embedding <=> $1::vector) AS similarity
FROM document_chunks dc
JOIN documents d ON d.id = dc.document_id
WHERE 1 - (dc.embedding <=> $1::vector) >= $2
ORDER BY dc.embedding <=> $1::vector
LIMIT $3`,
[embeddingStr, similarityThreshold, topK]
);
return result.rows.map((row) => ({
chunkId: row.chunk_id,
documentId: row.document_id,
documentTitle: row.document_title,
sourcePath: row.source_path,
chunkText: row.chunk_text,
similarity: parseFloat(row.similarity),
}));
}
実行例とその出力を示します。
// 動作確認
const results = await searchChunks("返品の期限はいつまでですか", 3, 0.5);
console.log(results);
[
{
chunkId: 42,
documentId: 5,
documentTitle: "購入・返品ポリシー",
sourcePath: "docs/policy/purchase.md",
chunkText: "製品の返品は購入日から30日以内に受け付けます。\n返品の際は領収書が必要です。",
similarity: 0.8912
},
{
chunkId: 43,
documentId: 5,
documentTitle: "購入・返品ポリシー",
sourcePath: "docs/policy/purchase.md",
chunkText: "領収書が必要です。領収書がない場合は対応できません。\n返金はカード払いの場合、5〜7営業日かかります。",
similarity: 0.7634
},
...
]
similarity が 0.5 未満の結果は除外されるため、質問と関係の薄いチャンクは回答文脈に混入しません。
07「それらしいが間違う」を防ぐ:プロンプト設計と引用明示
なぜ間違えるのか
RAGを素朴に実装すると、次のような問題が起きることがあります。
- 検索でヒットした文書に答えが書いていないのに、それらしい回答を生成してしまう
- 検索結果と自前の学習知識を混在させて、どちらを根拠にしたか分からない回答を返す
- 「答えが分からない」と言うべき場面で、憶測で答えてしまう
これらの原因は主に「LLMが検索結果を無視して自前の知識を使う」ことにあります。 プロンプトで制約を明示しないと、LLMは自然に「知っていること」を答えようとします。
回答生成の実装
プロンプトで「提供された文書のみを根拠にし、書かれていないことは言わない」と明示します。
// src/generator.ts
import OpenAI from "openai";
import type { SearchResult } from "./searcher.js";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
export type RagAnswer = {
answer: string;
// 回答生成に使ったチャンクの引用情報
citations: Citation[];
// 検索結果が不十分で回答できなかった場合は true
isUnanswerable: boolean;
};
export type Citation = {
documentTitle: string;
sourcePath: string;
excerpt: string;
similarity: number;
};
/**
* 検索結果を文脈としてLLMに渡し、引用付き回答を生成する。
*/
export async function generateAnswer(
query: string,
searchResults: SearchResult[]
): Promise<RagAnswer> {
// 検索結果がゼロの場合は LLM を呼ばずに即時返答
if (searchResults.length === 0) {
return {
answer:
"ご質問に関連する社内ドキュメントが見つかりませんでした。" +
"他のキーワードで再度お試しいただくか、担当部署にご確認ください。",
citations: [],
isUnanswerable: true,
};
}
// 検索結果を番号付きで整形してプロンプトに差し込む
const contextBlock = searchResults
.map(
(result, index) =>
`[文書 ${index + 1}]\n` +
`タイトル: ${result.documentTitle}\n` +
`パス: ${result.sourcePath}\n` +
`内容:\n${result.chunkText}`
)
.join("\n\n---\n\n");
const systemPrompt = `あなたは社内ドキュメントを検索して回答するアシスタントです。
## ルール
- 回答は必ず「参考文書」として提供された文書の内容のみを根拠にしてください。
- 文書に書かれていない情報は絶対に追加しないでください。
- 文書だけでは答えられない場合は「提供された文書には記載がありません」と明示してください。
- 回答の末尾には、根拠として使用した文書の番号(例: [文書 1])を必ず記載してください。
- 自分の事前知識で補完・推測することは禁止です。`;
const userPrompt = `## 参考文書
${contextBlock}
## 質問
${query}`;
const response = await openai.chat.completions.create({
model: "gpt-5.4-mini",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
});
const rawAnswer = response.choices[0].message.content ?? "";
// 回答が「記載がありません」を含む場合は回答不能フラグを立てる
const isUnanswerable =
rawAnswer.includes("記載がありません") ||
rawAnswer.includes("見つかりませんでした") ||
rawAnswer.includes("情報がありません");
// 回答本文で参照された [文書 N] を解析して引用情報を構築する
const citedIndexes = extractCitedIndexes(rawAnswer);
const citations: Citation[] = citedIndexes
.filter((i) => i >= 0 && i < searchResults.length)
.map((i) => ({
documentTitle: searchResults[i].documentTitle,
sourcePath: searchResults[i].sourcePath,
// 引用テキストは先頭 200 文字に省略
excerpt: searchResults[i].chunkText.slice(0, 200),
similarity: searchResults[i].similarity,
}));
return {
answer: rawAnswer,
citations,
isUnanswerable,
};
}
/**
* 回答テキストから [文書 N] の N を抽出して 0-indexed の配列で返す。
*/
function extractCitedIndexes(text: string): number[] {
const pattern = /\[文書\s*(\d+)\]/g;
const indexes: number[] = [];
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
const oneIndexed = parseInt(match[1], 10);
indexes.push(oneIndexed - 1); // 0-indexed に変換
}
// 重複を除去
return [...new Set(indexes)];
}
GPT-5系での決定性について
従来の gpt-4o 系では temperature: 0 を指定することで決定論的な生成に近づけていました。
GPT-5系(gpt-5.5 / gpt-5.4-mini)は reasoning モデルのため、temperature 等のサンプリングパラメータは原則指定できません。
代わりに reasoning_effort パラメータ("low" / "medium" / "high" 等)で推論コストと品質のバランスを調整できます。
事実に基づいた回答を求めるRAGでは、プロンプト制約(「文書のみを根拠にする」等)による制御がより重要になります。
08全体を繋ぐ:エンドツーエンドの実装
クエリパイプラインの組み合わせ
ここまで実装した各モジュールを組み合わせてエンドツーエンドのパイプラインを作ります。
// src/rag-pipeline.ts
import { searchChunks } from "./searcher.js";
import { generateAnswer } from "./generator.js";
import type { RagAnswer } from "./generator.js";
export type RagQueryOptions = {
// 検索で取得するチャンク数(多いほど文脈が豊富になるが、プロンプトが長くなる)
topK?: number;
// この類似度以上のチャンクのみを使用する
similarityThreshold?: number;
};
/**
* ユーザーの質問を受け取り、検索・生成を経て引用付き回答を返す。
*/
export async function query(
userQuery: string,
options: RagQueryOptions = {}
): Promise<RagAnswer> {
const { topK = 5, similarityThreshold = 0.5 } = options;
// 1. ベクトル検索で関連チャンクを取得
const searchResults = await searchChunks(
userQuery,
topK,
similarityThreshold
);
console.log(
`検索結果: ${searchResults.length} 件(閾値 ${similarityThreshold} 以上)`
);
if (searchResults.length > 0) {
console.log(
`最高類似度: ${searchResults[0].similarity.toFixed(4)}(${searchResults[0].documentTitle})`
);
}
// 2. 回答生成(検索結果ゼロの場合はフォールバック)
const answer = await generateAnswer(userQuery, searchResults);
return answer;
}
動作確認
// src/main.ts
import { query } from "./rag-pipeline.js";
async function main() {
const result = await query("返品できる期限はいつまでですか");
console.log("\n=== 回答 ===");
console.log(result.answer);
if (result.citations.length > 0) {
console.log("\n=== 引用元 ===");
result.citations.forEach((citation, i) => {
console.log(`[${i + 1}] ${citation.documentTitle}`);
console.log(` パス: ${citation.sourcePath}`);
console.log(` 類似度: ${citation.similarity.toFixed(4)}`);
console.log(` 抜粋: ${citation.excerpt}`);
});
}
if (result.isUnanswerable) {
console.log("\n(注)文書内に十分な情報がありませんでした。");
}
}
main().catch(console.error);
実行結果は次のようになります。
検索結果: 2 件(閾値 0.5 以上)
最高類似度: 0.8912(購入・返品ポリシー)
=== 回答 ===
返品の受付は購入日から30日以内です。返品の際には領収書が必要となります。
領収書がない場合は対応できませんのでご注意ください。[文書 1]
=== 引用元 ===
[1] 購入・返品ポリシー
パス: docs/policy/purchase.md
類似度: 0.8912
抜粋: 製品の返品は購入日から30日以内に受け付けます。
返品の際は領収書が必要です。
回答に [文書 1] の引用マーカーが入り、どのドキュメントを根拠にしたかが追跡できます。
09検索結果ゼロ時のフォールバック設計
なぜフォールバックを明示的に設計するか
検索結果がゼロのとき何も考えずにLLMを呼ぶと、LLMは自前の知識で答えを作ろうとします。 その回答は「それらしく聞こえるが根拠がない」状態になり、ユーザーが誤った情報を信じてしまうリスクがあります。
検索結果ゼロのケースは想定内として設計し、意図した振る舞いを実装することが重要です。
フォールバックの選択肢
フォールバックには主に3つのパターンが考えられます。
| パターン | 説明 | 向いているケース |
|---|---|---|
| 即時「分からない」返答 | LLMを呼ばず固定文で返す | 誤情報のリスクを最小化したい場合 |
| 検索クエリの言い換えリトライ | LLMにクエリを言い換えさせて再検索 | ユーザーの表現が迂遠な場合 |
| エスカレーション | 担当者への問い合わせを促す | 社内問い合わせシステムと連携する場合 |
筆者の経験では、まず「即時返答」を実装して誤情報を防ぎ、品質に余裕が出てから「言い換えリトライ」を追加するアプローチが安全でした。
言い換えリトライの実装例
// src/query-rewriter.ts
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
/**
* ユーザーの質問をより検索に適した表現に言い換える。
* 複数のバリエーションを返すことで、リトライの幅を広げる。
*/
export async function rewriteQuery(originalQuery: string): Promise<string[]> {
const response = await openai.chat.completions.create({
model: "gpt-5.4-mini",
messages: [
{
role: "system",
content: `ユーザーの質問を、ドキュメント検索に適した別の表現に言い換えてください。
3つのバリエーションを提案し、{"queries": ["...", "...", "..."]} の形式のJSONのみで返してください。
他の文章は含めないでください。`,
},
{
role: "user",
content: originalQuery,
},
],
response_format: { type: "json_object" },
});
try {
const content = response.choices[0].message.content ?? "{}";
const parsed = JSON.parse(content);
const variants: string[] = parsed.queries ?? [];
return variants.slice(0, 3);
} catch {
// パース失敗時は元のクエリをそのまま返す
return [originalQuery];
}
}
// src/rag-pipeline-with-retry.ts(言い換えリトライ版)
import { searchChunks } from "./searcher.js";
import { generateAnswer } from "./generator.js";
import { rewriteQuery } from "./query-rewriter.js";
import type { RagAnswer } from "./generator.js";
export async function queryWithRetry(userQuery: string): Promise<RagAnswer> {
const SIMILARITY_THRESHOLD = 0.5;
const TOP_K = 5;
// 1回目:元のクエリで検索
let searchResults = await searchChunks(
userQuery,
TOP_K,
SIMILARITY_THRESHOLD
);
// 検索結果がゼロなら言い換えリトライ
if (searchResults.length === 0) {
console.log("検索結果ゼロ。クエリを言い換えてリトライします。");
const rewrittenQueries = await rewriteQuery(userQuery);
for (const rewrittenQuery of rewrittenQueries) {
console.log(`リトライ: "${rewrittenQuery}"`);
searchResults = await searchChunks(
rewrittenQuery,
TOP_K,
SIMILARITY_THRESHOLD
);
if (searchResults.length > 0) {
console.log(`ヒット: ${searchResults.length} 件`);
break;
}
}
}
// それでもゼロならフォールバック(LLMを呼ばない)
return generateAnswer(userQuery, searchResults);
}
10出典明示の UI 表現とその意義
引用を見せることの効果
引用情報を返すだけでなく、ユーザーに見える形で表示することが重要です。
出典を明示することで次の効果があります。
- ユーザーが回答の根拠を自分で確認できる
- 「AIが自信ありげに言ったから正しいはず」という過信を防ぐ
- 「どのドキュメントに書いてあったか分かる」という情報価値が上がる
特に社内ドキュメント検索では、「誰でもソースに戻れる」設計が信頼につながります。
フロントエンドへの出力例
APIレスポンスの設計として参考になる形式を示します。
// 回答APIのレスポンス型例
type RagResponse = {
answer: string;
citations: {
title: string;
// フロントエンドがリンク表示に使えるパス
path: string;
// ハイライト表示用の抜粋テキスト
excerpt: string;
// 0.0〜1.0(信頼度の目安として表示できる)
similarity: number;
}[];
isUnanswerable: boolean;
};
// 実際のレスポンス例
const exampleResponse: RagResponse = {
answer:
"返品の受付は購入日から30日以内です。返品時は領収書が必要です。[文書 1]",
citations: [
{
title: "購入・返品ポリシー",
path: "docs/policy/purchase.md",
excerpt:
"製品の返品は購入日から30日以内に受け付けます。返品の際は領収書が必要です。",
similarity: 0.8912,
},
],
isUnanswerable: false,
};
フロントエンド側では citations の path をリンクにするだけで、ユーザーが原文に戻れる導線が完成します。
11実装上のよくあるつまずきと対処法
類似度の閾値設定
similarityThreshold の初期値として 0.5 を使いましたが、これはあくまで出発点です。
テキストの性質や埋め込みモデルによって「適切な閾値」は変わります。
筆者が実装したシステムでは、閾値を変えながら以下の2指標を目視確認して調整しました。
- 閾値を下げる → 関係のないチャンクが増えるが、取りこぼしが減る
- 閾値を上げる → ノイズが減るが、「検索結果ゼロ」が増える
閾値を変数として管理し、環境変数などから調整できる設計にしておくと後から楽です。
埋め込みモデルと検索モデルの一致
インデックス構築時と検索時で異なる埋め込みモデルを使うと、ベクトル空間が異なるため検索が機能しません。 モデルのバージョンを定数として一か所で管理し、インデックス再構築時には全チャンクを再生成する設計にしておくと安心です。
チャンクが長すぎる・短すぎる
チャンクが長いと1つのチャンクに複数のトピックが混在し、類似度計算の精度が下がります。 チャンクが短いと文脈が失われ、LLMが回答を組み立てにくくなります。 実際には、手元の検索結果を見ながら「このチャンクで回答できるか」を確認しつつ調整するのが現実的です。
12BizPlan(事業計画エージェント)における設計思想の共通点
私たちが開発している BizPlan(事業計画エージェント)では、過去の事業計画ドキュメントを参照して回答する機能が求められる場面があります。 設計の基本思想は本記事で解説したパターンと共通しており、「参照した文書を明示する」「文書に書かれていないことは答えない」という原則を軸にしています。
エージェントが「それらしい回答」を生成してしまうリスクは、業務用途では特に影響が大きいです。 出典の透明性を保つ設計は、ユーザーの信頼を維持するための基本として位置づけています。
13まとめ
本記事では、社内ドキュメントにRAGを適用するための4つの工程を実装しながら解説しました。
- チャンク分割:文境界 + 固定上限 + オーバーラップのハイブリッドで適切な粒度に切り出す
- 埋め込み生成:バッチ処理でAPIコスト削減。インデックス構築と検索で同じモデルを使う
- ベクトル検索:pgvector の HNSW インデックスと類似度閾値でノイズを除去する
- 引用付き回答生成:「文書のみを根拠にする」プロンプト制約で「それらしいが間違う」を抑制する
さらに、検索結果ゼロ時のフォールバック設計(即時返答・言い換えリトライ)まで踏み込みました。
RAGはシンプルな構成でも動き始めますが、品質の差は「誤情報をどこで止めるか」の設計にあります。 引用の透明性と検索ゼロ時の振る舞いを最初から意識した実装が、長く使えるシステムにつながると感じています。
関連記事として「構造化出力とJSONスキーマ設計の基本」(#10)や「ファンクションコーリングのパターン」(#11)も合わせてご参照いただけると、エージェントとの組み合わせ方のイメージが広がります。
14参考文献
- pgvector 公式リポジトリ(2026年6月時点 v0.7.x)
- OpenAI Embeddings API ドキュメント(2026年6月時点)
- OpenAI text-embedding-3-small モデルの説明(2026年6月時点)
- Lewis, P. et al. "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks." NeurIPS 2020.(RAGの原論文)

