RAGの検索精度が低い原因|Embedding・チャンク・検索件数を順番に確認

RAGの検索精度が低い原因|Embedding・チャンク・検索件数を順番に確認 AI開発

RAGを実装したものの、質問と関係のない文章が検索されたり、正解がデータベースにあるのに取得できなかったりすることがあります。

このとき、すぐにEmbeddingモデルを変更するのはおすすめできません。

RAGは、元データの抽出、チャンク分割、Embeddingの生成、ベクトル検索、メタデータフィルタ、検索結果の並べ替え、LLMによる回答生成という複数の処理で構成されています。

回答が間違っていても、検索までは成功しており、最後の生成プロンプトだけに問題がある場合があります。反対に、LLMへ正しい指示を与えていても、必要なチャンクが検索されていなければ正しい回答は作れません。

Microsoftも、RAGを評価するときは取り込み、チャンク、Embedding、検索、生成を段階ごとに確認し、最終回答だけで原因を判断しないよう案内しています。

この記事では、RAGの検索精度が低い原因を、Embedding、チャンク、検索件数の順に切り分ける方法を解説します。チャンクサイズとオーバーラップの決め方はRAGのチャンクサイズはいくつが正解?分割単位・オーバーラップを比較、OpenAI APIの基本は【TypeScript】OpenAI API入門もあわせてご覧ください。

スポンサーリンク

最初に検索と回答生成を分けて確認する

RAGの精度を調べるときは、検索結果とLLMの回答を分けて確認します。

ユーザーから「返品期限は何日ですか」と質問された場合、最初にベクトルデータベースから返されたチャンクを表示します。

検索結果の中に「商品到着後30日以内」という文章が含まれているのに、LLMが「14日以内」と答えた場合、検索ではなく回答生成に問題があります。

一方、検索結果に返品期限を含むチャンクが一つもない場合は、プロンプトを変更しても根本的な解決にはなりません。

RAGの問題は、次の三つに分けて考えると原因を特定しやすくなります。

検索結果に正解が含まれない場合は、取り込み、Embedding、チャンク、検索条件を確認します。

正解は含まれるものの順位が低い場合は、検索方式、検索件数、リランキングを確認します。

正解のチャンクが上位にあるのに回答が間違う場合は、コンテキストの渡し方と生成プロンプトを確認します。

OpenAIのFile Searchでは、通常は検索されたチャンクそのものがレスポンスへ含まれません。includefile_search_call.resultsを指定すると、モデルへ渡された検索結果を確認できます。

src/inspect-file-search.ts
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const vectorStoreId =
  process.env.OPENAI_VECTOR_STORE_ID;

const model = process.env.OPENAI_MODEL;

if (!vectorStoreId || !model) {
  throw new Error(
    "OPENAI_VECTOR_STORE_IDとOPENAI_MODELを設定してください。",
  );
}

const response = await openai.responses.create({
  model,
  input: "返品できる期限を教えてください。",
  tools: [
    {
      type: "file_search",
      vector_store_ids: [vectorStoreId],
      max_num_results: 10,
    },
  ],
  include: ["file_search_call.results"],
});

console.dir(response.output, {
  depth: null,
});

回答文だけをログへ残すのではなく、どのファイルのどのチャンクが、どのスコアで検索されたかを記録することが重要です。

正解となる情報が本当に登録されているか確認する

検索設定を調整する前に、回答の根拠となる情報が検索インデックスへ登録されているかを確認します。

元のPDFやWebページに正解が書かれていても、テキスト抽出時に欠落している可能性があります。

PDFの表が画像として保存されている場合、通常のテキスト抽出では内容を取得できないことがあります。二段組みのPDFでは、左右の文章が誤った順番で結合されることもあります。

ヘッダーやフッターが各ページへ繰り返し追加され、本文よりも企業名やページ番号の割合が大きくなっている場合もあります。

固定長分割は実装しやすい一方で、単語、文章、段落を途中で切断し、意味の流れを壊す可能性があります。文書構造が重要なPDFでは、見出し、段落、表などを認識して分割するほうが、意味の一貫したチャンクを作りやすくなります。チャンクの分割単位についてはRAGのチャンクサイズはいくつが正解?分割単位・オーバーラップを比較で詳しく解説しています。

取り込み後のチャンクは、ベクトルデータベースだけに保存せず、人間が確認できる形でも保存します。

src/inspect-chunk.ts
type StoredChunk = {
  id: string;
  documentId: string;
  documentTitle: string;
  headingPath: string[];
  pageNumber: number | null;
  chunkIndex: number;
  text: string;
};

function inspectChunk(chunk: StoredChunk): void {
  console.log({
    id: chunk.id,
    documentId: chunk.documentId,
    title: chunk.documentTitle,
    heading:
      chunk.headingPath.join(" > "),
    pageNumber: chunk.pageNumber,
    chunkIndex: chunk.chunkIndex,
    length: chunk.text.length,
    text: chunk.text,
  });
}

元データに正解があるだけでは不十分です。

検索対象のチャンクに、正解とその条件が読み取れる形で残っている必要があります。

検索用の質問だけで元チャンクを探す

LLMを使わず、ベクトルストアへ直接検索を実行すると、検索段階の問題を切り分けられます。

OpenAIのRetrieval APIでは、自然言語の質問をvectorStores.search()へ渡すと、チャンク、類似度スコア、元ファイルなどを取得できます。標準では最大10件が返り、max_num_resultsによって最大50件まで指定できます。

src/debug-search.ts
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

async function debugSearch(
  query: string,
): Promise<void> {
  const vectorStoreId =
    process.env.OPENAI_VECTOR_STORE_ID;

  if (!vectorStoreId) {
    throw new Error(
      "OPENAI_VECTOR_STORE_IDを設定してください。",
    );
  }

  const results =
    await openai.vectorStores.search(
      vectorStoreId,
      {
        query,
        max_num_results: 20,
        rewrite_query: true,
        ranking_options: {
          ranker: "auto",
          score_threshold: 0,
        },
      },
    );

  console.log({
    originalQuery: query,
    searchQuery:
      results.search_query,
    resultCount:
      results.data.length,
  });

  for (
    const [
      index,
      result,
    ] of results.data.entries()
  ) {
    const text = result.content
      .filter(
        (
          content,
        ): content is {
          type: "text";
          text: string;
        } =>
          content.type === "text",
      )
      .map((content) => content.text)
      .join("\n");

    console.log({
      rank: index + 1,
      score: result.score,
      fileId: result.file_id,
      filename: result.filename,
      attributes:
        result.attributes,
      text,
    });
  }
}

void debugSearch(
  "返品できる期限を教えてください。",
);

調査時はscore_thresholdを0に近い値へ下げ、上位20~50件程度まで確認します。

正解チャンクが20位以内にも出ていない場合は、top_kを増やすだけでは本番の精度を改善しにくくなります。Embedding、チャンク、検索クエリ、フィルタのいずれかを確認する必要があります。

正解チャンクが5位や10位にある場合は、検索件数やリランキングによって改善できる可能性があります。

Embeddingのモデルが統一されているか確認する

Embeddingは、文章を数値のベクトルへ変換し、文章同士の意味的な近さを比較するために使用します。

OpenAIのEmbeddingでは、ベクトル間の距離が小さいほど文章の関連性が高く、検索では質問と各チャンクのベクトルを比較して順位を決定します。OpenAIはコサイン類似度の利用を推奨しており、OpenAIのEmbeddingは長さ1へ正規化されているため、コサイン類似度とユークリッド距離は同じ順位になります。

検索精度が突然低下した場合は、登録時と検索時で同じEmbeddingモデルを使用しているかを確認します。

次のような状態では、正しい比較ができません。

モデル不一致の例
文書の登録時: embedding-model-a
質問の検索時: embedding-model-b

モデル名が同じでも、次元数を変更できるAPIでは、登録時と検索時の次元数を一致させる必要があります。

Embeddingモデルを変更した場合は、質問側だけでなく、保存済みのすべてのチャンクを新しいモデルで再Embeddingします。

src/create-embedding.ts
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

async function createEmbedding(
  text: string,
): Promise<number[]> {
  const embeddingModel =
    process.env.OPENAI_EMBEDDING_MODEL;

  if (!embeddingModel) {
    throw new Error(
      "OPENAI_EMBEDDING_MODELを設定してください。",
    );
  }

  const response =
    await openai.embeddings.create({
      model: embeddingModel,
      input: text,
      encoding_format: "float",
    });

  const embedding =
    response.data[0]?.embedding;

  if (!embedding) {
    throw new Error(
      "Embeddingを取得できませんでした。",
    );
  }

  return embedding;
}

データベースには、ベクトルだけでなく、生成に使用したモデル名、次元数、前処理のバージョンも保存します。

src/embedding-metadata.ts
type EmbeddingMetadata = {
  model: string;
  dimensions: number;
  preprocessingVersion: string;
  chunkingVersion: string;
  createdAt: string;
};

Embeddingモデルを変更したデータと古いデータを同じインデックスへ混在させないことが重要です。

Embedding前の文章を変更しすぎていないか確認する

Embeddingの前処理として、HTMLタグや不要な改行を削除することは有効です。

しかし、文章を過度に短縮すると、検索に必要な情報まで失われます。

たとえば、次のチャンクでは「30日以内」だけでなく、返品、商品到着、未開封という情報が検索に役立ちます。

良い例
未開封の商品は、商品到着後30日以内であれば返品できます。

数値だけを残した次のデータでは、何の30日なのか分かりません。

悪い例
30日以内

一方、本文だけを保存し、見出しや文書名を除去した場合も検索精度が下がることがあります。

悪い例2
電源ボタンを10秒間押し続けてください。

この文章だけでは、どの製品のどの操作なのか分かりません。

Embeddingする文章には、文書タイトルや見出し階層を追加します。

見出しを追加した例
製品A
トラブルシューティング
強制再起動の方法

電源ボタンを10秒間押し続けてください。

ただし、すべてのチャンクへ長い共通文を追加しすぎると、異なるチャンク同士が似たベクトルになる可能性があります。

製品名、カテゴリ、見出しなど、検索意図を区別するために必要な情報へ絞ります。

日本語と英語が混在している場合を確認する

多言語対応のEmbeddingモデルでも、略語、製品型番、社内用語、専門用語では検索順位が安定しない場合があります。

ユーザーが「有休」と検索し、文書内では「年次有給休暇」とだけ書かれているケースを考えます。

意味検索で取得できる可能性はありますが、短い質問や固有の社内用語では、十分な類似度が出ないことがあります。

検索クエリへ言い換えを追加する方法があります。

機の同義語展開例
元の質問:
有休はいつまで繰り越せる?

検索用クエリ:
年次有給休暇 有給休暇 繰越期限 失効

OpenAIのRetrieval APIには、自然な会話文を検索に適した形式へ変換するrewrite_queryがあります。書き換え後のクエリはsearch_queryから確認できます。

クエリ書き換えを有効にした場合も、元の質問と書き換え後の検索語を両方ログへ残します。

書き換えによって重要な型番、エラーコード、製品名が削除されていないか確認するためです。

チャンクが小さすぎないか確認する

正解となる文章がデータベースへ登録されていても、チャンクの分割位置が悪いと検索結果だけで意味を理解できません。

次の文章を考えます。

元の文章
商品到着後30日以内であれば返品できます。
ただし、開封済みソフトウェアは返品対象外です。
初期不良の場合は開封後も交換できます。

一文ずつ別チャンクにすると、「30日以内」という一般条件だけが検索され、開封済みソフトウェアの例外が取得されない可能性があります。

回答に必要な条件や例外が別チャンクへ分かれている場合は、チャンクサイズを大きくするか、オーバーラップを追加します。

見出し単位の親チャンクを作り、検索用の小さな子チャンクがヒットしたときに親チャンク全体をLLMへ渡す方法も有効です。

Microsoftは、回答の完全性が低い場合、Embeddingモデルを確認したうえで、固定長チャンクのサイズを増やすことや、必要なデータがそもそも存在するかを確認するよう案内しています。

チャンクが大きすぎないか確認する

チャンクを大きくすれば、常に検索精度が上がるわけではありません。

返品、支払い、配送、会員登録について書かれたページ全体を一つのチャンクにすると、一つのベクトルへ複数の話題が混ざります。

ユーザーが返品期限を質問しても、チャンク全体では配送や支払いに関する文章の割合が大きくなり、返品に特化した短いチャンクより類似度が低くなる可能性があります。

固定長分割ではなく、見出し、段落、表、関数など、文書の構造を優先して分割します。

構造を維持したチャンクは意味の一貫性を保ちやすく、複数のチャンクをLLMへ渡したときの関連性も改善しやすくなります。

一般的なマニュアルでは、まず400~800トークン程度を比較対象とし、256、512、800など複数の設定で評価します。最適な値は文書と質問によって異なるため、一つの数値だけを正解として固定しません。具体的な比較手順はRAGのチャンクサイズはいくつが正解?分割単位・オーバーラップを比較で解説しています。

短すぎるチャンクが大量にできていないか確認する

チャンクサイズを512トークンに設定していても、実際のチャンクがすべて512トークンになるとは限りません。

見出しや段落を優先すると、数十トークンしかないチャンクが大量に作られることがあります。

次のようなチャンクは、それだけでは検索対象として十分な意味を持ちません。

短すぎる例1
注意事項
短すぎる例2
対象外となります。

短い見出しや一文は、同じセクション内の前後の文章と結合します。

取り込み後には、平均チャンクサイズだけでなく、最小値、中央値、95パーセンタイル、最大値を確認します。この確認方法はRAGのチャンクサイズはいくつが正解?分割単位・オーバーラップを比較で紹介しているコードをそのまま利用できます。

中央値が極端に小さい場合は、見出しやリスト項目が別々のチャンクになっていないか確認します。

最大値が設定した上限を超えている場合は、表、コードブロック、改行のない文章などが分割されずに残っている可能性があります。

オーバーラップが不足していないか確認する

チャンクの境界付近に正解がある場合、オーバーラップが少なすぎると前提と結論が分離されます。

512トークンのチャンクであれば、最初は50~100トークン程度のオーバーラップから比較できます。

ただし、オーバーラップを増やしすぎると、ほぼ同じ内容を持つ隣接チャンクが検索上位を占めます。

検索結果10件のうち5件が同じ段落の重複であれば、別の文書や例外規定を取得できる枠が減ります。

検索結果へ文書ID、親セクションID、チャンク番号を表示し、同じ文書の連続したチャンクだけが並んでいないか確認します。

意味のある見出しや段落で分割できている場合は、オーバーラップを小さくできます。

メタデータフィルタが正解を除外していないか確認する

Embeddingやチャンクが正常でも、フィルタ条件が間違っていると正解を取得できません。

たとえば、最新版の規約だけを検索するつもりで、次の条件を設定したとします。

想定していた条件
documentVersion = 2026

実際のメタデータが文字列の2026-08で保存されていれば、一件も一致しない可能性があります。

ユーザーの部署、言語、製品、公開状態、文書の有効期間などで検索対象を絞っている場合は、フィルタありとフィルタなしの結果を比較します。

OpenAIのRetrieval APIでは、属性を使って日付、地域、カテゴリなどを検索前に絞り込めます。比較演算子やandorを組み合わせたフィルタも利用できます。

フィルタなしでは正解が1位に出るのに、フィルタありでは消える場合、ベクトル検索ではなくメタデータの登録またはフィルタ条件が原因です。

src/attribute-filter.ts
const results =
  await openai.vectorStores.search(
    vectorStoreId,
    {
      query:
        "製品Aの保証期間を教えてください。",
      max_num_results: 20,
      attribute_filter: {
        type: "and",
        filters: [
          {
            type: "eq",
            key: "product",
            value: "product-a",
          },
          {
            type: "eq",
            key: "status",
            value: "published",
          },
        ],
      },
    },
  );

マルチテナントのRAGでは、フィルタを無効化した状態を本番で使用してはいけません。

調査環境でフィルタなしの検索を行い、フィルタ原因かどうかを切り分けたあと、本番では必ずテナントや権限条件を適用します。

検索件数が少なすぎないか確認する

検索件数を表すtop_kmax_num_resultsが小さすぎると、必要なチャンクが検索候補に入らない可能性があります。

返品の一般条件、対象外商品、初期不良の例外が別チャンクにある場合、上位1件だけでは完全な回答を作れません。

OpenAIのFile Searchでも、検索結果を少なくするとトークン数とレイテンシを減らせる一方で、回答品質が低下する可能性があると説明されています。

調査時は、top_kを3、5、10、20と変更し、正解チャンクが何位に現れるかを確認します。

正解が8位にあるなら、top_k: 5では取得できません。

ただし、単純にtop_kを20へ増やしてすべてLLMへ渡すと、無関係なチャンクによってコンテキストが薄まります。

検索では候補を多めに取得し、その後にリランキングやスコアしきい値で絞る二段階方式が有効です。

検索件数が多すぎないか確認する

検索結果が多いほど、回答品質が上がるわけではありません。

必要な情報を含むチャンクが上位にあるにもかかわらず、無関係なチャンクも大量にLLMへ渡すと、モデルが別の文章を優先する可能性があります。

Microsoftは、回答の完全性が高いのに検索チャンクの利用率が低い場合、不要な結果が多い可能性があるため、top_kを減らすことを対策として挙げています。反対に、必要な情報が不足している場合は、チャンクサイズまたはtop_kを増やす方法を案内しています。

検索結果の利用率を確認するときは、LLMの回答が各チャンクのどの部分を使ったかを記録します。

10件取得しても、回答に使われたのが1件だけなら、検索件数を減らせる可能性があります。

一方、10件中6件がそれぞれ別の条件を補っている場合は、件数を維持する必要があります。

固定のスコアしきい値を信用しすぎない

類似度スコアが一定値未満の結果を除外すると、無関係な文章を減らせます。

OpenAIのRetrieval APIでは、score_thresholdを0から1の範囲で指定できます。値を高くすると関連性の高い結果だけに限定できますが、有用なチャンクも除外する可能性があります。

src/score-threshold.ts
const results =
  await openai.vectorStores.search(
    vectorStoreId,
    {
      query:
        "開封後でも返品できますか。",
      max_num_results: 20,
      ranking_options: {
        ranker: "auto",
        score_threshold: 0.35,
      },
    },
  );

スコアの絶対値は、Embeddingモデル、検索サービス、データの内容、チャンクサイズによって分布が変わります。

別のプロジェクトで使えた0.7というしきい値を、そのまま採用するのは避けます。

正解質問と、答えが存在しない質問の両方を用意し、スコア分布を比較して決定します。

しきい値が高すぎると正解を落とし、低すぎると無関係な結果をLLMへ渡します。

正解チャンクが低順位ならハイブリッド検索を試す

Embeddingによる意味検索は、質問と文書で同じ単語を使っていなくても関連する文章を取得できます。

OpenAIの公式例でも、「月へ行った時期」という検索に対して、同じ単語をほとんど含まない「最初の月面着陸は1969年7月」という文章を取得できることが示されています。

一方、意味検索は製品型番、エラーコード、関数名、契約条項番号などの完全一致が重要な検索を苦手とする場合があります。

次のような検索では、キーワード一致が強い手掛かりになります。

キーワード一致が強い例
WHEA-Logger 18
ERR_CONNECTION_RESET
RTX-5090-XG-24GB
第12条第3項
getServerSideProps

この場合は、ベクトル検索とキーワード検索を組み合わせたハイブリッド検索を検討します。

OpenAIのRetrieval APIでは、ハイブリッド検索のEmbedding側とテキスト側の重みを調整できます。Embeddingの重みを増やすと意味的な一致を重視し、テキストの重みを増やすと文字列の一致を重視します。

src/hybrid-search.ts
const results =
  await openai.vectorStores.search(
    vectorStoreId,
    {
      query:
        "WHEA-Logger 18の原因",
      max_num_results: 20,
      ranking_options: {
        ranker: "auto",
        score_threshold: 0,
        hybrid_search: {
          embedding_weight: 0.4,
          text_weight: 0.6,
        },
      },
    },
  );

自然な質問が中心ならEmbedding側を強め、型番やコード検索が中心ならテキスト側を強める方法があります。

最終的な重みは評価データで決定します。

候補を広く取得してリランキングする

ベクトル検索の上位数件だけで正解を確定しようとすると、検索漏れが発生しやすくなります。

最初の検索では20~50件程度を候補として取得し、その後にリランカーで関連性を再評価して、上位3~8件程度をLLMへ渡す方法があります。

第一段階では正解を落とさないことを重視し、第二段階では無関係なチャンクを除くことを重視します。

リランキングへ渡す文章には、本文だけでなく、文書タイトル、見出し、更新日なども含めます。

src/reranking-candidate.ts
type SearchCandidate = {
  id: string;
  documentId: string;
  title: string;
  heading: string;
  text: string;
  vectorScore: number;
};

type RerankedCandidate =
  SearchCandidate & {
    rerankScore: number;
};

同じ文書の重複チャンクが大量にある場合は、リランキング前または後に重複を除去します。

同じ親セクションに属するチャンクが複数ヒットした場合は、最もスコアの高いチャンクを残すか、連続範囲を結合します。

ベクトルインデックスの近似検索を確認する

大量のベクトルを検索するデータベースでは、HNSWやIVFFlatなどの近似最近傍検索が使われます。

近似検索は高速ですが、常に数学的に最も近いベクトルをすべて返すとは限りません。

pgvectorは正確な最近傍検索と近似検索の両方に対応しています。HNSWでは検索時の候補数を増やすとRecallを改善できますが、検索速度とのトレードオフが生じます。

検索精度を調査するときは、同じ質問を近似インデックスありと正確検索で比較します。

正確検索では正解が上位に出るのに、近似検索では消える場合は、Embeddingやチャンクではなくインデックス設定が原因です。

pgvectorでコサイン類似度を使う場合は、コサイン距離演算子<=>を使用し、類似度へ変換する場合は1 - cosine distanceで計算できます。インデックスを利用する検索では、距離演算子を昇順でORDER BYし、LIMITを指定する必要があります。

search.sql
SELECT
  id,
  document_id,
  content,
  1 - (embedding <=> $1::vector)
    AS similarity
FROM chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1::vector
LIMIT 20;

インデックスを作成した距離方式と、検索クエリの演算子が一致していることも確認します。

コサイン距離用のインデックスを作成したのに、検索ではL2距離を使っている場合、想定したインデックスや順位にならない可能性があります。

クエリが短すぎる場合は文脈を補う

「いつまで?」「これは使える?」「前のプランとの違いは?」のような質問では、検索に必要な主語が不足しています。

チャット画面では直前の会話から意味を理解できますが、ベクトル検索へ最後の質問だけを渡すと、何について検索すべきか分かりません。

直前の会話から、検索用の独立した質問を生成します。

会話から検索クエリを生成
会話:
ユーザー: 製品Aの保証について教えて
AI: 製品Aには通常保証があります
ユーザー: いつまで?

検索用クエリ:
製品Aの保証期間はいつまでですか

ただし、会話履歴全体をそのままEmbeddingすると、不要な話題も混ざります。

検索用のクエリは、現在の質問を単独で理解できる短い文へ書き換えます。

LangChainのRAGアーキテクチャでも、曖昧な質問を検索しやすく書き換えるQuery enhancementと、取得結果が十分かを確認するRetrieval validationが中間処理として挙げられています。

一つの質問に複数の検索意図がないか確認する

「料金、解約期限、返金条件を教えてください」という質問には、複数の検索意図があります。

質問全体を一つのベクトルにすると、料金に近いチャンクだけが上位へ集まり、解約期限や返金条件が取得されない可能性があります。

複数の意図へ分解して検索します。

src/sub-queries.ts
const subQueries = [
  "サービスの利用料金",
  "サービスを解約できる期限",
  "解約時の返金条件",
];

各検索結果を統合し、同じチャンクを重複除去してからリランキングします。

src/unique-by-id.ts
function uniqueById<T extends { id: string }>(
  values: readonly T[],
): T[] {
  const map = new Map<string, T>();

  for (const value of values) {
    const existing =
      map.get(value.id);

    if (
      !existing ||
      ("score" in value &&
        "score" in existing &&
        typeof value.score === "number" &&
        typeof existing.score === "number" &&
        value.score > existing.score)
    ) {
      map.set(value.id, value);
    }
  }

  return [...map.values()];
}

検索意図の分解を行う場合は、検索回数と料金が増えるため、複数条件を含む質問だけに限定します。

答えが存在しない質問も評価する

RAGの評価を正解がある質問だけで行うと、無関係なチャンクを自信を持って返す問題を検出できません。

「製品Aは水中で使えますか」のように、文書内に答えがない質問も評価データへ含めます。

答えがない場合は、検索スコアが低い結果しか返らないことや、最終回答が「資料から確認できません」となることを確認します。

しきい値を低くしすぎると、答えがない質問でも何らかのチャンクが返り、LLMが無理に回答を作る可能性があります。

答えありの質問ではRecallを高く保ち、答えなしの質問では不要な回答を抑えられる境界を探します。

Recall@kとMRRで検索を評価する

検索精度は、回答文の印象ではなく、正解チャンクが上位何件に含まれたかで測定します。

Recall@kは、必要な正解文書やチャンクのうち、上位k件で取得できた割合です。

MRRは、最初の正解が何位に現れたかを評価します。1位なら1、2位なら0.5、5位なら0.2として、全質問の平均を求めます。計算用のTypeScriptコードはRAGのチャンクサイズはいくつが正解?分割単位・オーバーラップを比較evaluateRetrieval()関数をそのまま流用できるため、この記事では結果の読み方に絞って解説します。

HitRate@5が高く、MRRが低い場合は、正解は取得できているものの順位が低い状態です。

この場合は、リランキング、クエリ書き換え、見出しの追加、ハイブリッド検索を試します。

Recall@20でも低い場合は、候補取得の段階で正解を落としています。Embedding、チャンク、フィルタ、インデックスを確認します。

正解チャンクのスコア分布を記録する

単にRecallだけを見るのではなく、正解チャンクと不正解チャンクのスコア分布を保存します。

src/scored-evaluation-result.ts
type ScoredEvaluationResult = {
  query: string;
  resultId: string;
  score: number;
  relevant: boolean;
  rank: number;
};

正解と不正解のスコアが明確に分かれている場合は、score_thresholdを設定しやすくなります。

正解の平均スコアと不正解の平均スコアがほぼ同じ場合は、しきい値だけで改善するのは困難です。

Embeddingモデル、検索クエリ、チャンクの内容を改善する必要があります。

質問の種類ごとにスコア分布が違うこともあります。

自然文の質問、型番検索、エラーコード検索、日付検索を分けて集計すると、ハイブリッド検索や個別の検索方式が必要かを判断しやすくなります。

一度に複数の設定を変えない

検索精度が低いと、Embeddingモデル、チャンクサイズ、top_k、リランキング、プロンプトを一度に変更したくなります。

しかし、複数を同時に変えると、どの変更が改善につながったのか分かりません。

まず現在の構成をベースラインとして保存します。

次にEmbeddingモデルだけを変更し、同じチャンクと検索条件で比較します。

その後、Embeddingモデルを固定してチャンクサイズを比較します。

最後に、検索件数、しきい値、ハイブリッド検索、リランキングを調整します。

Microsoftも、RAGの評価では各ハイパーパラメータと結果を細かい段階とシステム全体の両方で記録し、継続的に再評価するよう案内しています。

src/retrieval-experiment.ts
type RetrievalExperiment = {
  experimentId: string;
  embeddingModel: string;
  embeddingDimensions: number;
  chunkingVersion: string;
  chunkSize: number;
  chunkOverlap: number;
  searchType:
    | "vector"
    | "keyword"
    | "hybrid";
  topK: number;
  scoreThreshold: number;
  reranker: string | null;
  createdAt: string;
};

実験ごとに同じ評価質問を使い、Recall、MRR、レイテンシ、入力トークン数を記録します。

正解チャンクが上位なら生成プロンプトを確認する

検索結果の1位に正解があるのに回答が間違う場合、Embeddingやチャンクを変更する必要はありません。

LLMへ渡すコンテキストの形式とプロンプトを確認します。

検索結果を単純に連結すると、どこまでが一つの文書なのか分かりにくくなります。

連結だけの悪い例
文書1
タイトル: 返品規約
内容: 商品到着後30日以内...

文書2
タイトル: ソフトウェア製品の例外
内容: 開封済みソフトウェアは返品対象外...

文書ごとに区切り、タイトル、更新日、出典IDを付けます。

プロンプトでは、提供した資料だけを根拠にすること、情報がない場合は推測しないこと、複数の資料が矛盾する場合は新しい文書を優先することなどを指定します。

src/generation-instructions.ts
const instructions = `
あなたは社内資料を基に回答するアシスタントです。

提供された検索結果だけを根拠に回答してください。
検索結果に答えがない場合は、確認できないと回答してください。
推測や一般知識で不足部分を補ってはいけません。
複数の資料が矛盾する場合は、更新日が新しい資料を優先してください。
回答には参照したsource_idを付けてください。
`.trim();

Microsoftは、関連するチャンクが検索されているのに回答の関連性が低い場合、検索ではなく生成プロンプトを確認するよう案内しています。

RAGの検索精度を確認する順番

RAGの精度が低い場合は、最初に正解情報が元データに存在するかを確認します。

次に、テキスト抽出後のチャンクに正解が残っているかを確認します。

その後、登録時と検索時のEmbeddingモデル、次元数、前処理が一致しているかを調べます。

Embeddingが正常なら、チャンクサイズ、見出し、オーバーラップ、短すぎるチャンクを確認します。

続いて、メタデータフィルタを外した検索と比較し、正解を除外していないかを調べます。

次に、top_kを増やして正解が何位に現れるかを確認します。

正解が候補内にある場合は、ハイブリッド検索、クエリ書き換え、リランキングで順位を改善します。

最後に、正解チャンクが上位にある状態で回答を間違える場合だけ、生成プロンプトとLLMを確認します。

この順番で調べると、回答が間違うたびにEmbeddingモデルを変更する状況を避けられます。

RAGの検索精度に関するよくある質問

Q類似度スコアは何点以上なら正解ですか?

AすべてのRAGで共通する基準はありません。Embeddingモデル、チャンクサイズ、検索サービス、文書の種類によってスコア分布が変わります。正解質問と答えが存在しない質問を評価データへ含め、正解チャンクと不正解チャンクのスコア分布から決定してください。

Qtop_kはいくつにすればよいですか?

A最初は5~10件程度から比較できますが、必要な情報が複数のチャンクに分かれている場合は増やす必要があります。検索候補を20件程度取得し、リランキングで5件程度へ絞る構成も有効です。チャンクが小さい場合は多め、大きい場合は少なめにし、LLMへ渡す総トークン数も確認してください。

Q高性能なEmbeddingモデルへ変えれば直りますか?

A改善する可能性はありますが、チャンクに正解が含まれていない場合や、フィルタで除外されている場合は直りません。モデル変更前に、正解チャンクが検索対象として存在するか、同じモデルで文書と質問をEmbeddingしているかを確認してください。

Q検索結果を増やせば正確になりますか?

A必要な情報を取得できる可能性は上がりますが、無関係なチャンクも増えます。正解が上位20件以内にある場合は、候補を広く取得してリランキングします。正解が上位50件にもない場合は、検索件数ではなくEmbedding、チャンク、検索クエリを改善してください。

Qベクトル検索だけで十分ですか?

A一般的な自然文の意味検索には有効ですが、型番、エラーコード、条文番号、関数名などではキーワード検索が有利です。意味検索とキーワード検索を組み合わせたハイブリッド検索を比較することをおすすめします。

まとめ

RAGの検索精度が低いときは、最終回答だけを見てEmbeddingモデルを変更してはいけません。

最初に、検索されたチャンクを表示し、正解が検索段階で取得されているかを確認します。

正解が検索結果にない場合は、元データの抽出、Embeddingモデルと次元数、チャンクサイズ、見出し、オーバーラップ、メタデータフィルタを順番に調べます。

正解が低い順位にある場合は、検索件数を増やし、クエリ書き換え、ハイブリッド検索、リランキングを比較します。

正解チャンクが上位にあるのに回答が間違う場合は、検索ではなく、LLMへ渡すコンテキストと生成プロンプトを確認します。

検索精度は、数件の質問を目視するだけでなく、Recall@k、HitRate@k、MRR、正解チャンクのスコア分布で測定します。

Embedding、チャンク、top_kを一度に変更せず、同じ評価データで一つずつ比較することが、RAGの問題を最短で特定する方法です。チャンクサイズとオーバーラップの決め方はRAGのチャンクサイズはいくつが正解?分割単位・オーバーラップを比較、OpenAI APIの基本は【TypeScript】OpenAI API入門もあわせてご覧ください。