0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RAGを学びたい(Step0: NaiveRAG)

0
Last updated at Posted at 2026-11-08

はじめに

表題の通り、RAGについて改めて学んでいきます。RAG自体は昨年あたりから触っており、これまでNaive RAGやGraphRAGを学んできました。ただ、最近になってReranking RAGやCorrective RAG(CRAG)など、これまで知らなかったアーキテクチャがあることを知りました。そこで、これを機にRAGの仕組みを基礎から振り返りながら、さまざまなアーキテクチャを実装して理解を深めていこうと思います。本シリーズでは、Naive RAGをTypeScriptで実装するところから始め、Reranking RAGなどへ段階的に発展させていきます。

今回はその第1回として、Naive RAGの仕組みを振り返り、実際に動かしてみます。

1. Naive RAGとは?

Naive RAGは、検索した文書をそのままLLMに渡して回答を生成する、最も基本的なRAGの構成です。

以下の3つの処理で構成されます。

処理 役割
Indexing 文書をベクトル化して保存する
Retrieval 質問に関連する文書を検索する
Generation 検索した文書をもとに回答を生成する

image.png

2. Naive RAGの処理フロー

Indexingは事前に実行し、RetrievalとGenerationはユーザーから質問を受けた際に実行します。

  • Indexing:文書を小さく分割(チャンク化)し、Embeddingモデルでベクトル化して保存
  • Retrieval:質問をベクトル化し、文書との類似度を計算して上位K件を取得
  • Generation:検索した文書と質問をLLMに渡し、回答を生成

3. 成果物

架空のBtoB SaaS「TaskFlow」のマニュアル検索・問い合わせ回答システムを作成します。

料金・請求・退会・セキュリティなどのマニュアルを検索し、LLMが回答します。

例えば、以下のような質問に対応します。

  • 退会後も料金は請求されますか?
  • Standardプランの月額料金はいくらですか?
  • 退会後、データはいつ削除されますか?

Indexing

Indexingは、質問が来る前に一度だけ走らせる前処理です。TaskFlowのマニュアル4件を12チャンクに分け、それぞれを1536個の数値(ベクトル)にして data/index.json に保存しました。意味が近い文は、この1536次元の空間で近い位置に置かれます。

Embeddingには OpenRouter の openai/text-embedding-3-small を使っています。APIキーは .env の OPENROUTER_API_KEY です。実行は npm run index です。

src/naiverag/
  domain/types.ts
  indexing/chunk.ts
  indexing/embed.ts
  indexing/load-manuals.ts
  indexing/run.ts
  providers/openrouter.ts
  index-manuals.ts
data/manuals/*.md
data/index.json

処理の流れ

関係するのは data/manuals、RAGアプリ、OpenRouter、data/index.json です。アプリがマニュアルを読み、見出しごとに分けた本文を text-embedding-3-small へ送り、返ってきたベクトルを本文と一緒に data/index.json へ保存します。

マニュアルを読む

index-manuals.ts が入口です。.env から API キーとモデル名を読み、マニュアルのディレクトリと保存先を渡します。

load-manuals.ts は data/manuals の .md だけを、ファイル名順で読みます。この時点の中身はまだ文字列です。

ファイル 文書タイトル
01-pricing.md 料金プラン
02-billing.md 請求
03-cancellation.md 退会
04-security.md セキュリティ

見出しごとにチャンクへ分ける

1ファイルを丸ごとベクトル化すると、料金の話と退会の話が1本のベクトルに混ざります。質問との近さを測る単位が粗くなるので、## の節ごとに切ります。

# の行は文書タイトルとして覚え、節の本文には入れません。空の節は捨てます。切り出した節には、タイトルと見出しを本文の先頭へ付けます。ベクトルになるのは、この文字列です。

text: `# ${title}\n## ${section.heading}\n\n${body}`,

03-cancellation.md の「退会後の請求」は、次の1件になります。id は 03-cancellation.md#1 です。

# 退会
## 退会後の請求

退会手続きが完了した月の料金は満額請求されます。退会の翌月以降、月額料金は請求されません。年払いの未経過分は返金しません。

タイトルと見出しを付けるのは、節だけだと「何についての30日か」が本文から落ちることがあるからです。ベクトルは、渡した文字列全体の意味を表します。

ファイルと節の対応は次のとおりです。各ファイルが3節なので、4 × 3 で 12 チャンクになります。

実行すると、見出しと文字数はこう出ます。

モデル: openai/text-embedding-3-small
チャンク: 12 件
次元: 1536
保存先: data/index.json
- 01-pricing.md#0 Freeプラン (98 文字)
- 01-pricing.md#1 Standardプラン (126 文字)
- 01-pricing.md#2 Proプラン (115 文字)
- 02-billing.md#0 請求日 (96 文字)
- 02-billing.md#1 領収書 (64 文字)
- 02-billing.md#2 支払い失敗 (98 文字)
- 03-cancellation.md#0 退会できる人 (88 文字)
- 03-cancellation.md#1 退会後の請求 (77 文字)
- 03-cancellation.md#2 退会後のデータ削除 (128 文字)
- 04-security.md#0 通信と保管 (89 文字)
- 04-security.md#1 権限 (90 文字)
- 04-security.md#2 ログイン (67 文字)

まとめてベクトルにする

openrouter.ts は、12個の文字列を配列のまま1回のリクエストで送ります。

POST https://openrouter.ai/api/v1/embeddings
{
  "model": "openai/text-embedding-3-small",
  "input": ["# 料金プラン\n## Freeプラン\n\n...", "... ほか 11 件"],
  "encoding_format": "float"
}

応答の各ベクトルには index が付きます。返ってきた順が前後しても、0番目の本文には0番目のベクトルを付け直します。今回のベクトルはすべて 1536 次元で、次元が揃っていることを保存前に確認しています。

このモデルが一度に受け取れる長さは 8192 トークンです。今回の節はどれも100文字前後なので、節の途中でさらに切る処理は入れていません。

台帳として保存する

run.ts が data/index.json に書く形はこれです。embedding 以外は、あとから「どの節がヒットしたか」を表示するための情報です。

{
  model: "openai/text-embedding-3-small",
  createdAt: "実行時刻",
  chunks: [
    {
      id: "03-cancellation.md#1",
      source: "03-cancellation.md",
      title: "退会",
      heading: "退会後の請求",
      text: "# 退会\n## 退会後の請求\n\n...",
      embedding: [0.012, -0.034, "... ほか 1534 個"]
    }
  ]
}

ここまでが Indexing です。マニュアルを検索単位に割り、同じ Embedding モデルで数値にして、本文と一緒に保存しています。

Retrieval

Retrieval は、ユーザーから質問を受けたときに動く処理です。質問を Indexing と同じ text-embedding-3-small でベクトル化し、data/index.json の12件との近さで節を選びます。今回は上位3件を返します。回答文はまだ作りません。

実行は次のとおりです。

npm run retrieve -- "退会後も料金は請求されますか?"

処理の流れ

関係するのはユーザー、RAGアプリ、data/index.json、OpenRouter です。アプリが保存済みのベクトルを読み、質問を OpenRouter へ送り、返ってきたベクトルと12件の近さを比べます。

コサイン類似度

近さはコサイン類似度で測ります。2本のベクトルの向きがどれだけ揃っているかで、1に近いほど意味が近く、0に近いほど関係が薄くなります。

類似度 = (質問 ・ 節) / (質問の長さ × 節の長さ)

12件すべてを計算し、スコアの高い順に3件を返します。質問のベクトル化には、data/index.json に記録されたモデルを使います。保存時と検索時でモデルが違うと、空間が別物になり、近さの比較が成り立たないためです。

検索結果

「退会後も料金は請求されますか?」の結果です。1位は「退会後の請求」で、類似度は 0.7134 です。

退会後も料金は請求されますか?の検索結果

記事の3つの質問を同じ手順で検索すると、1位は次のようになりました。

質問 1位 類似度
退会後も料金は請求されますか? 退会後の請求 0.7134
Standardプランの月額料金はいくらですか? Proプラン 0.6405
退会後、データはいつ削除されますか? 退会後のデータ削除 0.6561

退会の2問は、対応する節が1位です。料金の質問は Proプランが1位で、Standardプランは2位(0.6026)でした。両方の節に「月額料金」と金額が書かれているため、類似度が近くなります。上位3件には、Standardプランの節も入っています。

ここまでが Retrieval です。

Generation

Generation は、Retrieval で選んだ節と質問を LLM に渡して、回答文を作る処理です。渡す節は上位3件で、モデルは OpenRouter の openai/gpt-4.1-mini です。マニュアルに書いてあることだけを根拠に、日本語で答えるようにしています。マニュアルに答えがない場合は、分からないと答えます。

実行は次のとおりです。

npm run ask -- "退会後も料金は請求されますか?"

処理の流れ

質問を受けたアプリは、先に Retrieval で上位3件を選びます。その本文と質問を、チャット用の OpenRouter へ送ります。Embedding に使った text-embedding-3-small とは別のモデルです。

回答結果

「退会後も料金は請求されますか?」の結果です。参照の1位は「退会後の請求」で、回答は「退会した月は満額請求され、翌月以降は請求されない」でした。

退会後も料金は請求されますか?の回答

同じ手順で3つの質問に答えると、次のようになりました。

質問 回答
退会後も料金は請求されますか? 退会した月は満額請求され、翌月以降は請求されない
Standardプランの月額料金はいくらですか? 4,800円(税別)
退会後、データはいつ削除されますか? 退会完了から30日後

料金の質問は、検索の1位が Proプランのままでした。回答が 4,800円になったのは、上位3件のなかに Standardプランの節があったためです。Naive RAG は検索結果を並べたまま LLM に渡すので、1位がずれていても、近い節が上位に残っていれば答えを拾えます。

ここまでが Naive RAG の一巡です。Indexing で節をベクトルにして保存し、Retrieval で質問に近い節を選び、Generation でその節から回答を作ります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?