はじめに
表題の通り、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 | 検索した文書をもとに回答を生成する |
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 でその節から回答を作ります。


