RAG入門|検索拡張生成を実装で学ぶ

Claude Code活用

※ この記事にはアフィリエイトリンクが含まれています。詳細はプライバシーポリシーをご確認ください。

「ChatGPTに社内ドキュメントの内容を聞いたら、もっともらしい嘘が返ってきた」という経験は多くのエンジニアが持っているはずです。LLM単体では学習時点の知識しか持たず、最新情報や社内固有の文書には答えられません。

この問題を実装レベルで解く定石がRAG(Retrieval-Augmented Generation/検索拡張生成)です。外部の知識ベースを検索して取得した情報をプロンプトに差し込み、LLMの生成を補強する仕組みを指します。

本記事はコードが書けるエンジニア向けに、RAGの基本フローから最小実装、チャンク分割やリランキングといった精度を左右する工夫まで、実装視点で整理します。「ベクトルDBに突っ込めば動く」という雑な理解で止めず、各ステップの設計判断まで踏み込みます。

僕は普段、社内ナレッジ検索の自動化や、自分のブログ記事を対象にしたQ&Aボット試作にRAGを使っています。最初の数回は「動くけど精度が出ない」状態でしたが、チャンク粒度とリランキングを直すだけで体感が変わりました。本記事はそこで効いた工夫を中心にまとめます。

本記事はAI学び直し|エンジニアの実務スキル4階層シリーズの第4回(第3階層:AIアプリ開発)にあたります。前回のEmbedding/ベクトル化を踏まえ、それを実際のアプリ機能として組み立てる回です。

こんな方に読んでほしい

  • 社内文書やマニュアルをLLMに答えさせる仕組みを作りたいエンジニア
  • RAGという言葉は知っているが、内部のフローをコードで追ったことがない方
  • vector storeに入れただけで精度が出ず、原因切り分けに困っている方
  • チャンク分割やリランキングの設計判断を、実装視点で整理したい方

RAGとは何か

結論:外部知識を検索してLLMに差し込む技術

RAGとは、クエリに関連する情報を外部の知識ベースから検索し、その内容をプロンプトに差し込んでLLMに回答させる仕組みです。LLMが「事前学習で知っていること」だけでなく、「いま手元にあるドキュメント」も使って答えられるようになります。

ChatGPTやClaudeをそのまま使うと、学習データのカットオフ以降の情報や、社内固有のナレッジには答えられません。RAGはこのギャップを埋めるための、現時点で最も実装が確立されたアプローチです。

なぜRAGが必要か

RAGを採用する動機は、おおむね4つに整理できます。

  • ハルシネーション抑止:根拠となる文書を渡すことで、もっともらしい嘘の発生確率を下げる
  • 知識更新:モデル再学習なしに、ドキュメントを追加・更新するだけで知識を増やせる
  • 社内文書活用:機密情報をモデルに学習させず、検索範囲だけに限定できる
  • 引用可能性:回答の根拠となった文書を提示でき、利用者が検証できる

このうち、業務利用で特に効くのは引用可能性です。LLMの回答を鵜呑みにできない場面でも、「この文書のこの段落を根拠にしています」と示せれば、人間がレビューして採用できます。検証コストを下げるという意味で、生成AI活用のボトルネックを外す効果があります。

主要な実装フレームワークの公式ドキュメントは一次情報として強力です。LangChain RAGチュートリアルLlamaIndex Conceptsを併読すると、両者の設計思想の違いが見えてきます。

RAGの基本フロー

RAGの処理は、クエリ→検索→コンテキスト挿入→生成の4ステップに分解できます。各ステップで設計判断が必要になるので、まず全体像を押さえます。

RAGの基本フロー|クエリ→ベクトル検索→コンテキスト挿入→LLM生成→引用付き回答

4ステップの中身

  • クエリ:ユーザーの質問を受け取り、必要に応じて検索用に整形する
  • 検索:知識ベースから関連文書を取り出す。キーワード/ベクトル/ハイブリッドの選択がある
  • コンテキスト挿入:取得した文書をプロンプトに埋め込む。トークン数とフォーマットの設計が要点
  • 生成:LLMに回答させる。引用元を明示させるプロンプトが事故防止になる

このうち、初学者が軽視しがちなのがコンテキスト挿入のフォーマットです。検索結果をベタ貼りするか、メタデータ付きで整形するかで、引用の精度が大きく変わります。

僕も最初の試作では、検索ヒット文書を改行区切りで貼り付けただけのプロンプトを使っていました。引用精度が出ず、出典を聞いても曖昧な回答が返るので、メタデータ付きフォーマットに切り替えたところ精度が一気に上がりました。「LLMが文書の境界を認識できる」フォーマットにするのが要点です。

検索方式:キーワード/ベクトル/ハイブリッド

検索方式は3種類あり、それぞれ得意領域が違います。

  • キーワード検索:BM25等の古典手法。固有名詞・型番・コード片の検索に強い
  • ベクトル検索:Embeddingの類似度で探す。意味的に近い文書を取れるが、固有名詞には弱い
  • ハイブリッド検索:両者のスコアを重み付けで合算。実務ではこれが標準解になりつつある

「とりあえずベクトル検索」で始めると、製品コードや型番のような完全一致が必要なクエリで精度が出ません。ハイブリッド方式を最初から想定して設計するほうが、後の手戻りが少ないです。

実装の最小コード例

まず最小構成のRAGを書いてみます。ベクトルストアに文書を登録し、クエリで検索し、結果をプロンプトに差し込む流れです。

// 最小構成のRAG(擬似コード/TypeScript)
async function ragAnswer(question: string): Promise<string> {
  // 1. クエリをベクトル化
  const queryVec = await embed(question);

  // 2. ベクトルストアから上位kの関連文書を取得
  const docs = await vectorStore.search(queryVec, { topK: 5 });

  // 3. コンテキストを整形してプロンプトに挿入
  const context = docs
    .map((d, i) => `[${i + 1}] (${d.source})\n${d.text}`)
    .join("\n\n");

  // 4. LLMに回答させる(根拠の引用を強制)
  const res = await llm.chat({
    system: "次のコンテキストだけを根拠に回答してください。" +
            "根拠が無ければ「不明」と答え、推測で補完しないこと。" +
            "回答末尾に [1][2] 形式で引用元を明示すること。",
    user: `コンテキスト:\n${context}\n\n質問: ${question}`,
    temperature: 0,
  });
  return res.content;
}

30行未満ですが、これだけでRAGの形になります。重要なのはsystemメッセージで「コンテキスト外の知識を使わせない」と明示する点です。これを書かないと、LLMは取得文書を無視して、事前学習の知識から勝手に答え始めます。

ベクトルストアの実装は、Pinecone/Qdrant/Weaviate/pgvectorなど選択肢が多数あります。最初はpgvectorのようなSQL拡張で十分です。スケールしてから専用DBに移行する方針で動けます。

チャンク分割とリランキング

RAGの精度を左右する最大の要素は、チャンク分割の粒度検索結果のリランキングです。最小実装で動かしたあと、精度が出ないときに最初に見直すべき2点です。

チャンク分割:粒度とオーバーラップ

長い文書をベクトル化するには、適切なサイズに分割(チャンキング)する必要があります。粒度の設計が雑だと、検索ヒットしても文脈が欠けて回答できないケースが頻発します。

// オーバーラップ付きチャンク分割(簡易版)
function chunkText(
  text: string,
  chunkSize = 500,    // 1チャンクの目安文字数
  overlap = 100,      // 前後で重複させる文字数
): string[] {
  const chunks: string[] = [];
  let start = 0;
  while (start < text.length) {
    const end = Math.min(start + chunkSize, text.length);
    chunks.push(text.slice(start, end));
    if (end === text.length) break;
    start = end - overlap;
  }
  return chunks;
}

ポイントはオーバーラップです。チャンクの境界に重要な文がまたがると検索精度が落ちるため、前後を100字程度重複させて取りこぼしを防ぎます。

チャンクサイズの目安は、日本語で300〜600字程度が扱いやすい範囲です。短すぎると文脈が欠け、長すぎると無関係な内容が混ざって検索精度が落ちます。文書の性質によって調整する必要があります。

マニュアルやFAQのように構造が明確な文書では、文字数で機械的に切るより、見出しや段落で区切るほうが精度が出ます。Markdownの#####を区切り点にするだけでも、結果が変わります。

リランキング:topKの中から選び直す

ベクトル検索は意味の近さで上位を返しますが、「近い」と「答えに使える」は別です。topK=20で広めに取ってから、より高精度なモデルでスコアリングし直すリランキングが効きます。

// Cohere Rerankを使う例(擬似コード)
import { CohereClient } from "cohere-ai";
const cohere = new CohereClient({ token: process.env.COHERE_KEY });

async function searchWithRerank(query: string, docs: Doc[]) {
  // 1. ベクトル検索で広めに候補を取る
  const queryVec = await embed(query);
  const candidates = await vectorStore.search(queryVec, { topK: 20 });

  // 2. Rerankモデルで関連度を再計算
  const reranked = await cohere.rerank({
    query,
    documents: candidates.map(c => c.text),
    topN: 5,
  });

  // 3. 上位5件だけプロンプトに渡す
  return reranked.results.map(r => candidates[r.index]);
}

リランキング無しでtopK=3を直接使うと、精度が頭打ちになりやすいです。広く取って絞る2段階にするだけで、回答の事実性が一段上がります。

専用APIを使わなくても、簡易的にはクエリと候補文書の類似度を別モデルで計算し直す自前実装でも効果が出ます。Embeddingとリランカーは別物として設計するのが定石です。

メタデータフィルタの併用

ベクトル検索の前に、メタデータで対象文書を絞り込むと精度と速度の両方が上がります。「2024年以降の文書のみ」「カテゴリAのみ」といった条件を付けるイメージです。

// メタデータフィルタ付き検索
const docs = await vectorStore.search(queryVec, {
  topK: 5,
  filter: {
    category: "engineering",
    publishedAt: { $gte: "2024-01-01" },
  },
});

これを忘れると、古いマニュアルや無関係カテゴリの文書が混ざり込み、回答が混乱します。実運用では、ほぼ必ずフィルタが必要になります。

よくある失敗パターン

RAGの初期実装でつまずきやすい典型パターンを3つ挙げます。事前に知っておくと、デバッグの当たりが付けやすくなります。

失敗1:チャンクが長すぎる/短すぎる

「とりあえず1000字で切った」「文単位で細かく切った」のどちらも、精度劣化の典型原因です。

長すぎると、1チャンク内に複数トピックが混ざって検索の精度が落ちます。短すぎると、文脈が欠けてLLMが回答を組み立てられません。300〜600字を起点に、文書の性質に合わせて調整するのが安全です。

僕は最初の試作で2,000字チャンクから始め、検索結果が雑多になって精度が出ませんでした。500字+オーバーラップ50字に切り直してから一気に改善しました。「1チャンク=1トピック」に近づけることを目安にすると判断しやすいです。

失敗2:リランキング無しでtopK=3に頼る

ベクトル検索の上位3件をそのままLLMに渡す構成は、最小実装としては正解ですが、本番運用では精度不足になりがちです。

「ベクトル的には近いが質問の答えではない」文書が上位に来ることが頻繁に起きます。topK=20で広めに取ってリランクする2段構成に切り替えるだけで、体感が変わります。

僕の体験でも、リランク導入は最大の精度ジャンプでした。チャンク分割を直しても改善しきれなかった検索精度が、リランカーを挟んだ瞬間に「なるほど、人間が見てもこの順位なら納得」という結果に変わります。最初はCohere Rerank APIのような既製のリランカーから試すと早いです。

失敗3:メタデータをLLMに渡さず引用が崩れる

検索結果のテキストだけをプロンプトに貼ると、LLMが引用元を特定できません。ファイル名・URL・章番号などのメタデータをチャンクと一緒に渡すのが鉄則です。

// 引用しやすいフォーマット
const context = docs
  .map((d, i) => `[${i + 1}] source: ${d.source}, section: ${d.section}\n${d.text}`)
  .join("\n\n");

あわせて、systemメッセージで「回答末尾に [番号] 形式で引用を明示すること」と指示します。これで利用者が根拠を辿れるようになり、回答の信頼性が一段上がります。

もっと深く学ぶなら

独学で詰まりやすいAIアプリ開発(第3階層)を体系的に学びたいなら、DMM WEBCAMP 学習コースが選択肢の一つです。転職を急がず、現職を続けながらスキルだけ身につけたい方に向きます。

  • デメリット:受講料が他の独学型より明確に高い/自走できる人には過剰
  • メリット:体系化されたカリキュラム/メンターの伴走でつまずき箇所を早く突破できる

DMM WEBCAMP 学習コースのプランを確認する

シリーズの位置づけと次回予告

本記事はAI学び直し|エンジニアの実務スキル4階層シリーズの第4回です。第3階層「AIアプリ開発」の中核として、RAGを取り上げました。前回のEmbedding/ベクトル化で扱った技術が、RAGの検索ステップでどう使われるかが見えたはずです。

次回(5月6日公開予定)は、LLMに外部ツールを呼び出させるFunction Callingを扱います。RAGが「検索して読む」のに対し、Function Callingは「APIを叩いて操作する」アプローチです。両者を組み合わせて、より能動的なAIアプリを作る土台になります。

まとめ|RAGはフローと粒度の設計が9割

RAGは、クエリ→検索→コンテキスト挿入→生成の4ステップで構成される、外部知識をLLMに使わせるための実装パターンです。

最小実装は30行で書けますが、本番品質に持っていくにはチャンク分割の粒度リランキングの2段検索が必須です。さらにメタデータを併せて渡し、引用を明示させるプロンプトを組めば、回答の信頼性が一段上がります。

明日からの実務では、まずpgvector等の手元のSQL拡張に文書を入れ、最小コード例の形でRAGを動かしてみるのがおすすめです。動かしたあとで、チャンクサイズとリランキングを調整するフェーズに進むと、設計判断の感覚がつかめます。

進展があったらこのブログで共有します。社内文書を対象にした評価データセットの作り方や、Citationの自動チェックについても、できたら定点観測の記事を書いてみたいと考えています。

コメント

タイトルとURLをコピーしました