最小RAGを作ると中身はこうなる

プログラミング

1. はじめに

AIは少し触ったことがある、RAGも概要は知っている──くらいの人向けに、
最小構成のRAGを作ると内部がどうなるかを、このリポジトリの一次情報(コードと出力ファイル)だけで整理します。

この記事で使ったシステムはこれです: https://github.com/daiki3027/rag-demo/tree/v0

結論として、このデモは “LLMで文章生成するRAG” ではなく、検索で取れた文書本文をそのまま返す(extractive) ところまでを最小実装したものです。

また、この内容を手元で再現しながら見るための動画も作成しました。動画では次をやります。

  • フロントエンドから /api/reindex を実行する
  • コード側の documents.jsonl と、生成された vectors.jsonl / meta.json を見る
  • フロントエンドから /api/query を実行して、回答とコストを見る
  • 全然ちがう質問でも /api/query を実行して、回答とコストを見る
  • スレッショルド(threshold)を低くしたことで誤答が起きることに気づけるようにする
  • フロントエンドから /api/eval を実行して、ポジティブは問題ない一方でネガティブに問題があることを見る

🎥 YouTube


2. ここで言う「RAG」の定義(この実装がRAGと言える理由)

RAG(Retrieval-Augmented Generation)は「検索で取り出した根拠を使って回答する」方式です。ざっくり R(検索)→A(根拠を付ける)→G(回答を作る) の流れになります。

このリポジトリは次を実装しています。

  • R(検索): 文書と質問をベクトル化して、cosine類似度で top-k を取る
  • A(根拠): sources として top-k を返す(= 何を根拠にしたか見える)
  • G(回答): LLMで文章生成はせず、top1文書の本文を answer として返す(抽出型)

図にするとこうです。

This demo (extractive RAG)
  question -> embed -> retrieve(top5) -> threshold -> answer=top1.text + sources

Typical RAG (generative RAG)
  question -> embed -> retrieve(topk) -> prompt(sources) -> LLM -> generated answer

3. 最小構成にするために削っているもの(= 期待しないこと)

この実装には、よくあるRAGの要素がいくつか意図的にありません。

  • chunking(文書分割・オーバーラップ): なし(1行=1文書の単位で扱う)
  • rerank(再ランキング): なし
  • ベクトルDB: なし(vectors.jsonl を毎回読み、全件走査でcosine計算)
  • LLM生成(回答文生成): なし(top1本文を返す)

この「削った結果」でも、RAGのコアである “埋め込み → 検索 → 閾値で拒否/採用 → 根拠提示” の挙動は観察できます。


4. データセット(実験の土台)

このリポジトリには固定のseedが入っています。

  • seed文書: backend/data/seed/documents.jsonl(50件)
  • QA(評価用): backend/data/seed/qa.jsonl(60問)

RAGは「評価がないと改善できない」ので、最初から /api/eval があるのがポイントです。

backend/data/seed/documents.jsonl  --/api/reindex-->  backend/data/index/vectors.jsonl + meta.json
backend/data/seed/qa.jsonl         --/api/eval----->  hit@5 / no-answer accuracy (+ failures)

5. 実装の流れ(内部で何が起きているか)

主要ファイルだけ拾うと、このあたりです。

  • 埋め込み(dummy / OpenAI): backend/app/rag/embeddings.py
  • インデックス作成(seed→ベクトル化→保存): backend/app/rag/indexer.py
  • 検索(cosine・全件走査・top-k): backend/app/rag/retrieve.py
  • Query API(閾値/拒否/回答): backend/app/api/query.py
  • Eval(Hit@5 / No-answer accuracy): backend/app/rag/eval.py

呼び出し関係はざっくりこうです。

UI(Next.js) -> FastAPI
  /api/reindex -> embeddings -> vectors.jsonl/meta.json
  /api/query   -> embeddings -> cosine検索 -> answer/sources
  /api/eval    -> 60問ループ -> hit@5 / no-answer accuracy

5.0 「文字をベクトルにする」とは何か(なぜ計算できるのか)

埋め込み(embedding)は、文字列を“意味の座標”に変換するイメージです。テキストを「地図上の場所」に置くと思うと分かりやすく、話題が近い文ほど近くに集まります。

この「座標」がベクトルです。実際の地図は2次元ですが、埋め込みは 128次元や1536次元 の“超高次元地図”だと思ってください(このデモの vectors.jsonlvector が数値配列になっているのがそれです)。

図で言うと、「文章がモデルを通ると座標(ベクトル)が出てくる」というだけです。

text ("ログインには…")  --->  embedding model  --->  vector [0.04, 0.01, ...]
text ("パスワードを…")  --->  embedding model  --->  vector [0.03, 0.02, ...]

では、なぜこれで検索(計算)ができるのか。理由はシンプルで、

1) 質問も文書も 同じルール(同じ埋め込みモデル) で座標に変換する
2) すると「意味が近いもの」は 近い座標 に来るように学習されている
3) あとは座標同士の近さを計算すれば、「近い文書ランキング」が作れる

というだけです。近さの計算(このデモでは cosine類似度)を、次でたとえ話込みで説明します。

5.0.1 「近さ」の計算(cosine類似度)をたとえ話で

このデモの検索は cosine類似度です(backend/app/rag/retrieve.py)。これは「ベクトル同士を 引き算して距離を出す」というより、“向きがどれだけ同じか” を見る計算です。

たとえ話:

  • ベクトルを「矢印」だと思う
  • 同じ話題のテキストは、だいたい 同じ方向 の矢印になる
  • 違う話題だと、矢印の向きがズレる

cosine類似度は、この矢印の向きの一致度を -1.01.0 のスコアで返します(雑に言うと、1.0 に近いほど「同じ方向」)。長さ(どれだけ長い矢印か)は割り算で打ち消すので、「文章が長い/短い」より 特徴のパターン(向き) が重視されます。

y
^
|        c
|        ^   (別の話題: 向きがズレる)
|        |
o -----> a -----> b ------------------------------> x
         (同じ話題: 向きが近い)

cosine(a, b) は高く、cosine(a, c) は低い(直感の図。実際は高次元)

5.1 インデックス作成(/api/reindex)

/api/reindex はやっていることが単純です。

  1. seed文書(50件)を読む
  2. 文書本文を埋め込みベクトルに変換する(dummy or OpenAI)
  3. backend/data/index/vectors.jsonl に「本文+ベクトル」を保存する
  4. backend/data/index/meta.json にメタ情報(次元・件数・usage)を保存する
/api/reindex
  documents.jsonl
       |
       v
  embed_texts(まとめて埋め込み)
       |
       +--> vectors.jsonl (doc_id, text, vector...)
       |
       +--> meta.json (embedding_dim, vector_count, usage...)

5.2 問い合わせ(/api/query)

/api/query は次の流れです。

  1. 質問を埋め込みベクトル化
  2. vectors.jsonl を読み、全件に対して cosine 類似度を計算
  3. スコア順に top-k(デフォルト5件)を返す
  4. top1スコアが閾値未満なら拒否(sources空)
  5. 閾値以上なら answer = top1文書の本文、sources = top5 を返す

図にするとこうです。

documents.jsonl --reindex--> vectors.jsonl/meta.json

question --embed--> query_vec
query_vec x (all doc_vecs) --cosine--> scores --sort--> top_k
  | top1 < threshold -> refuse (sources=[])
  | else -> answer=top1.text (sources=top_k)

6. 実験結果として「目に見えるもの」

ここからが本題で、「最小構成で作ると、どんな結果がどこに出るか」です。

6.1 インデックスの結果は meta.json に残る(コストも)

まず「検索が数値(ベクトル)で行われている」ことを一番手早く確認するなら、backend/data/index/vectors.jsonl を見るのが分かりやすいです。

6.1.1 vectors.jsonl の中身(1行の例)

vectors.jsonlJSONL(1行=1JSON) で、各行が「1文書ぶんの本文+埋め込みベクトル」です。実データの先頭行を短くした例はこんな形です(vector は本当は1536次元なので先頭だけ表示しています)。

{
  "doc_id": "doc_01",
  "title": "Doc 01",
  "text": "ログインにはメールアドレスとパスワードが必要です。",
  "vector": [0.040728673, 0.017093875, 0.008199926, 0.0029567268, 0.083739065, 0.011351549, -0.014926241, 0.02768485 /* ... 1536次元 */]
}

クエリ側も同じようにベクトル化され、検索は「クエリのベクトル」と「各文書のベクトル」の cosine類似度(数値) を全件ぶん計算して、スコア順に並べるだけです(実装: backend/app/rag/retrieve.py)。

vectors.jsonl (1行=1文書)
  doc_id / title / text / vector[dim]
                    |
                    +---> 検索時は vector を使って cosine を計算する

backend/data/index/meta.json は “実験ログ” です。たとえばこのリポジトリには、次のような実行結果が入っています(抜粋)。

{
  "embedding_provider": "openai",
  "embedding_dim": 1536,
  "vector_count": 50,
  "usage": { "model": "text-embedding-3-small", "prompt_tokens": 937, "cost_usd": 0.00001874 }
}

meta.json/api/reindex を叩くたびに更新されるので、「直近の再インデックスの結果」が残るメモだと思ってください。

ここから分かること:

  • 文書50件を埋め込んだ(vector_count: 50
  • OpenAI embeddings を使った(embedding_provider: openai
  • 次元は1536(embedding_dim: 1536
  • 埋め込みに使ったトークン量とコストが残っている(usage

LLM生成をしていないので、外部コストが出る可能性があるのは基本的に 埋め込みだけです。

6.2 Queryの結果は「answer」と「sources」で観察できる

/api/query の返却は「answer」と「sources」がコアです。

この“最小RAG”で観察したいポイントは2つだけです。

  • sourcesが妥当か: top-k に「質問に関係ある文書」が並ぶか
  • 拒否できるか: 関係ない質問のときに閾値で落ちるか(sourcesが空になるか)
QueryResponse
  answer  : top1.text (抽出) または 拒否文
  sources : top5 [{doc_id, score, text}]
               |
               +--> 「なぜその回答になったか」を人間が確認できる根拠
(重要)threshold の分岐

top1.score が低い  ---------------------->  top1.score が高い
   |                                           |
   v                                           v
拒否文 + sources=[]                       answer=top1.text + sources=top5

6.3 Evalで「検索としての性能」を数値にできる

/api/eval は、qa.jsonl の60問を回して次を返します。

  • hit_at_5: ポジ(答えあり)問題で、正解docが top5 に入った割合
  • no_answer_accuracy: ネガ(答えなし)問題で、「答えなし」と正しく拒否できた割合
qa.jsonl の各問題

  [positive] gold_doc_id がある
    -> 検索top5に gold_doc_id が入っていれば Hit
    -> hit_at_5 = Hit数 / positive_total

  [negative] gold_doc_id がない
    -> max_score < threshold なら「拒否できた」
    -> no_answer_accuracy = 正しく拒否できた数 / negative_total

RAGの改善は、まずこの2軸(当たる/断る)を見ながら進めるのが分かりやすいです。


7. “最小RAG”から見える学び(実装が小さいから分かること)

  • まず検索を評価できる(hit@5no_answer_accuracy)ので、改善の方向が決めやすい
  • 閾値(RETRIEVER_THRESHOLD)は “断る勇気” を作る(上げるほど拒否しやすい)
threshold:          low ------------------------------ high
answer rate:        high ----------------------------> low
refusal rate:       low  ----------------------------> high

(直感)
- thresholdを上げるほど「答えない(拒否)」が増える
- その結果、ネガ(答えなし)の正解率は上がりやすい一方、ポジ(答えあり)は「答えなし」と誤判定しやすくなります

例(ネガ: 本当に答えがないのに答えてしまうのを防ぐ):

  • 質問: 「今日の天気は?」(seed文書がプロダクトFAQだとすると、そもそも根拠がない)
  • thresholdが低い: どれかの文書がそこそこ似ている扱いになり、本文を返してしまう(誤答)
  • thresholdを上げる: top1スコアが閾値未満になりやすくなり、「根拠がないので回答できません」で止まる(正解)

例(ポジ: 本当は答えがあるのに、答えなしと誤判定する):

  • 質問: 「ログインに必要なものは?」
  • 本来は seed に「ログインにはメールアドレスとパスワードが必要です。」のような文書があり、検索で当てたい
  • ただし、表現ゆれや埋め込みのブレで top1スコアが閾値に届かないと、答えがあるのに拒否してしまう(誤答)

8. 次に足すなら何?(v0→v1 の道筋)

このデモを “よくあるRAG” に寄せたいなら、だいたい次の順番です。

v0(このリポジトリ): retrieve + threshold + extract(answer=top1)
        |
        +--> v1: chunking を入れて「当たりやすく」する
        |
        +--> v2: sources をプロンプトに入れて LLM 生成(典型RAG)
        |
        +--> v3: rerank / vector DB / cache(規模が増えたら)

9. 再現手順(最短)

バックエンドを起動したら、まずインデックスを作って、クエリと評価を叩くだけです。

1) /api/reindex で seed -> index を作る
2) /api/query   で answer/sources の挙動を見る
3) /api/eval    で hit@5 / no-answer accuracy を確認する
curl -X POST http://localhost:8000/api/reindex
curl -X POST http://localhost:8000/api/query -H 'Content-Type: application/json' -d '{"question":"..."}'
curl -X POST http://localhost:8000/api/eval

埋め込みプロバイダは backend/.envEMBEDDING_PROVIDERdummy / openai)で切り替えます。OpenAIを使う場合は OPENAI_API_KEY が必要です。


コメント

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