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.jsonl の vector が数値配列になっているのがそれです)。
図で言うと、「文章がモデルを通ると座標(ベクトル)が出てくる」というだけです。
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.0 〜 1.0 のスコアで返します(雑に言うと、1.0 に近いほど「同じ方向」)。長さ(どれだけ長い矢印か)は割り算で打ち消すので、「文章が長い/短い」より 特徴のパターン(向き) が重視されます。
y
^
| c
| ^ (別の話題: 向きがズレる)
| |
o -----> a -----> b ------------------------------> x
(同じ話題: 向きが近い)
cosine(a, b) は高く、cosine(a, c) は低い(直感の図。実際は高次元)
5.1 インデックス作成(/api/reindex)
/api/reindex はやっていることが単純です。
- seed文書(50件)を読む
- 文書本文を埋め込みベクトルに変換する(dummy or OpenAI)
backend/data/index/vectors.jsonlに「本文+ベクトル」を保存する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 は次の流れです。
- 質問を埋め込みベクトル化
vectors.jsonlを読み、全件に対して cosine 類似度を計算- スコア順に top-k(デフォルト5件)を返す
- top1スコアが閾値未満なら拒否(sources空)
- 閾値以上なら 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.jsonl は JSONL(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@5とno_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/.env の EMBEDDING_PROVIDER(dummy / openai)で切り替えます。OpenAIを使う場合は OPENAI_API_KEY が必要です。

コメント