LLMは学習した範囲のことは流暢に答えますが、学習後に起きた最新の出来事や、社内文書のような非公開データは知りません。知らないことを聞かれると、「わかりません」と答えることもあれば、それらしい嘘(ハルシネーション)を返すこともあります。
RAGは、外部の文書を検索してLLMに渡し、その文書に基づいて答えさせる手法です。この記事では、RAGがどういうもので、なぜ必要かを、実際に小さなRAGを動かしながら確認します。数十行の自己完結スクリプトを用意して、「検索して渡す/渡さない」で答えがどう変わるかを試します。
LLMのAPI呼び出しそのものは、記事「LLM APIの呼び出しとAPIキー管理:Gemini・GPT・Claude」で扱っています。LLM APIの呼び出し方やAPIキーの発行、.envの用意についてはそちらを参照してください。Pythonの環境がまだの場合は「Python Install ManagerでのPython環境構築」や「uvのインストールと使い方」を先にご覧ください。
目次
はじめに
この記事の目的は、RAG(Retrieval-Augmented Generation)がどういうもので、なぜ必要なのかを最小のRAGスクリプトを用いて確認することです。このスクリプトを用いて「文書を検索して渡す/渡さない」で答えがどう変わるかを実際に見ます。
事前準備として、APIキーを発行して.envに置き、次のライブラリをインストールします。
pip install litellm python-dotenv tenacity
# uvの場合
uv add litellm python-dotenv tenacityLLMの仕組みとその限界
RAGがなぜ必要かは、LLMの仕組みと、その仕組みから来る限界を押さえると腑に落ちます。
LLMは、大量の文章で学習し、続きのトークンを確率的に予測するモデルです。トークンは、モデルが文章を処理するときの文字のかたまりの単位です。「東京は日本の」に続けて「首都」を選ぶ、というように、それまでの文脈から次に来そうなトークンを次々に選んで文章を作ります。予測は確率的なので、同じ質問でも毎回まったく同じ答えになるとは限りません。
この仕組みには、次のような限界があります。
知識カットオフ - LLMは学習した時点までの情報しか持ちません。学習後に起きた出来事や、そもそも学習データに入っていない非公開の情報(社内文書、契約書、個人のメモなど)は知りません。
ハルシネーション - 知らないことを聞かれても、LLMは「知らない」と回答するとは限らず、それらしい文章を生成してしまうことがあります。もっともらしいのに事実と違う、この現象をハルシネーション(幻覚)と呼びます。次に来そうなトークンを選ぶ仕組み上、「正しいこと」ではなく「ありそうなこと」を出してしまうために起きます。
コンテキストウィンドウ - LLMに一度に渡せるトークン量には上限があり、これをコンテキストウィンドウと呼びます。手元の文書をすべて丸ごと渡せば知識を補えそうですが、上限があるため大量の文書はそのままでは渡しきれません。また、入力・出力のトークン数に応じて料金がかかるので、渡す量はコストにも直結します。
つまりLLMは、「一般的な知識をもとに文章を作る」のは得意でも、「最新の情報」や「手元の非公開データ」に基づいて正確に答えるのは、そのままでは苦手です。ここを補うのがRAGです。
RAGとは何か
RAG(検索拡張生成)は、「質問に関連する文書を外部から検索し、それをLLMに渡してから答えさせる手法」です。LLMが学習で覚えた知識だけに頼るのではなく、その場で見つけた文書を根拠にして答えを作らせます。この「答えを検索した文書に基づかせる」ことを、接地(grounding)と呼びます。
RAGは、前節で見たLLMの限界に対応する形で、主に次の3つの問題を解きます。
| 問題 | 対応 |
|---|---|
| 最新情報 | 学習後の新しい情報でも、その文書を検索して渡せば答えに反映できる。 |
| 非公開・社内データ | 学習データに入っていない社内文書なども、検索対象にすれば答えられる。 |
| 根拠の提示 | どの文書を根拠にしたかを示せるので、答えが正しいかを人が検証できる。 |
この考え方自体は新しいものではなく、RAGという名前と枠組みは2020年の論文(Lewis et al., 2020)で提案されました。以来、外部知識をLLMに組み合わせる実務の定番になっています。
なお、RAGはあくまで「検索して渡してから生成する」手法であり、その用途は社内文書のQA、検索、エージェントなどさまざまです。チャット形式はその一つの見せ方にすぎず、「RAG=チャットボット」ではありません。
RAGの基本の流れ(取り込み・検索・拡張・生成)
RAGは、大きく4つの段階でできています。
| 工程 | 英語名 | 内容 |
|---|---|---|
| ① 取り込み | indexing | PDFやWebページなどの文章を、検索できる形に準備しておきます。実務ではここでテキストを抽出し、扱いやすい大きさに分割して保存します。 |
| ② 検索 | retrieval | 質問が来たら、準備しておいた文書の中から関連するものを探します。 |
| ③ 拡張 | augmentation | 見つかった文書を質問とセットにして、プロンプトを組み立てます。 |
| ④ 生成 | generation | 並べられた質問とデータを読み、そのデータに基づいて回答を生成します。 |
このうち「関連文書を探す部分」を担うのが検索器(retriever)、「文書をもとに答えを書く部分」を担うのが生成器(generator)です。検索器と生成器の間にある拡張は、この2つをつなぐプロンプト組み立ての工程にあたります。
一番の要点は、基本の動作はとてもシンプルだということです。検索した文書をプロンプトに差し込んでLLMに渡す、ただそれだけです。次のハンズオンで、この最小の形を実際に動かします。
最小のRAGを作って動かす
「検索して渡す」だけのRAGを、最小の形で作ります。
構成要素
■知識ベースの文章
知識ベースとして、この記事のために用意した架空の会社「にゃんにゃん運輸」の社内文書を使います。架空の会社なので、LLMはその制度を学習していません。だからこそ「RAGなし/あり」の差がはっきり出ます。
docs = [
"にゃんにゃん運輸の在宅勤務制度では、週3日まで在宅で働けます。利用するには前月末までに申請します。",
"にゃんにゃん運輸の経費精算は毎月20日締めです。専用システムから申請し、領収書に肉球スタンプが必要です。",
"にゃんにゃん運輸の有給休暇は入社時に10日付与され、取得はその日の気分で申請してください。",
"にゃんにゃん運輸のオフィスは東京・大阪・福岡の3拠点です。来客時はチュールを持ってきてください。",
]■ 検索アルゴリズム
検索はキーワードの重なりで探す簡単な方式(キーワード検索)にします。
まず、クエリ(検索に投げる入力)と知識ベースの各文章をそれぞれ2文字単位で1文字ずつずらしながら分割します。自然言語処理や統計処理において、文章や単語の並びを「連続する2文字」のまとまりにして、1文字ずらしながら分割することをバイグラムといいます。
def bigrams(text):
return {text[i:i + 2] for i in range(len(text) - 1)} # 2文字ずつの集合それぞれを分割した後に、共通する並びの数で採点して、多い順に上位を返します。たとえば、クエリが「在宅勤務」なら「在宅」「宅勤」「勤務」といった並びが、在宅勤務について書かれた文書と多く一致します。
# キーワードの重なりで近い文章を探す
def search(query, docs, top_k=2):
q = bigrams(query)
# バイグラムで作成した2つの集合(クエリと文章をそれぞれ分割したもの)の積集合の要素数をスコアにする。
# それを(スコア、文章)を要素とするリストという形でまとめる。
scored = [(len(q & bigrams(d)), d) for d in docs]
scored.sort(key=lambda x: x[0], reverse=True) # スコア(タプルの0番目)が大きい順に並べる
return [d for score, d in scored[:top_k] if score > 0] # スコアの上位top_k件の文章を返す(0件は除く)これは語の意味を理解しているわけではなく、文字の重なりを数えているだけの粗い方式ですが、キーワードが一致する文書を拾うには十分です。
■ LLMのAPIの呼び出し
LLMのAPIの呼び出しにはLiteLLMを使用します。使用方法の詳細はこちらの記事を参照します。
DEFAULT_MODEL = "anthropic/claude-haiku-4-5" # 既定のモデル(引数で変更可)
def chat(messages, model=DEFAULT_MODEL, num_retries=2, stream=False, temperature=None, max_tokens=1024, **kwargs):
"""LLMを呼ぶ(同期版)。"""
if temperature is not None:
kwargs["temperature"] = temperature
response = completion(
model=model,
messages=messages,
num_retries=num_retries,
stream=stream,
max_tokens=max_tokens,
**kwargs,
)
if stream:
return response
return response.choices[0].message.content■ RAGなしの生成
systemがシステムプロンプト、userがユーザープロンプトです。システムプロンプトには役割などを、ユーザープロンプトにはLLMに送る質問を書きます。
def answer_without_rag(question):
messages = [
{"role": "system", "content": "日本語で簡潔に答えてください。"},
{"role": "user", "content": question},
]
return chat(messages, max_tokens=4096)■ RAGありの生成
RAGの本体です。やっていることは「①検索する→②見つかった文書をコンテキストにまとめる→③プロンプトに差し込む→④LLMに答えさせる」だけです。システムプロンプトで「コンテキストだけを根拠に答える」「無ければ分からないと答える」と指示しているのがポイントで、これによってLLMを渡した文書に接地させます。
def answer_with_rag(question, docs):
found = search(question, docs) # 1) 関連文書を検索
context = "\n".join(f"- {d}" for d in found) # 2) コンテキストにまとめる
messages = [
{"role": "system",
"content": "以下のコンテキストだけを根拠に、日本語で簡潔に答えてください。"
"コンテキストに答えが無ければ「わかりません」と答えてください。"},
{"role": "user",
"content": f"コンテキスト:\n{context}\n\n質問: {question}"}, # 3) 差し込む
]
return chat(messages, max_tokens=4096) コード全体
スクリプト全体を以下に示します。使用するモデルはanthropic/claude-haiku-4-5になっていますので、適宜変更してください。chat関数のmodel引数で変更できます。
import os
from litellm import completion
from dotenv import load_dotenv
load_dotenv() # .envからAPIキーを環境変数へ
DEFAULT_MODEL = "anthropic/claude-haiku-4-5" # 既定のモデル(引数で変更可)
# LLMを呼び出すための汎用関数
def chat(messages, model=DEFAULT_MODEL, num_retries=2, stream=False, temperature=None, max_tokens=1024, **kwargs):
"""LLMを呼ぶ(同期版)。"""
if temperature is not None:
kwargs["temperature"] = temperature
response = completion(
model=model,
messages=messages,
num_retries=num_retries,
stream=stream,
max_tokens=max_tokens,
**kwargs,
)
if stream:
return response
return response.choices[0].message.content
# 知識ベース
docs = [
"にゃんにゃん運輸の在宅勤務制度では、週3日まで在宅で働けます。利用するには前月末までに申請します。",
"にゃんにゃん運輸の経費精算は毎月20日締めです。専用システムから申請し、領収書に肉球スタンプが必要です。",
"にゃんにゃん運輸の有給休暇は入社時に10日付与され、取得はその日の気分で申請してください。",
"にゃんにゃん運輸のオフィスは東京・大阪・福岡の3拠点です。来客時はチュールを持ってきてください。",
]
# バイグラム
def bigrams(text):
return {text[i:i + 2] for i in range(len(text) - 1)} # 2文字ずつの集合
# キーワード検索
def search(query, docs, top_k=2):
q = bigrams(query)
# バイグラムで作成した2つの集合(クエリと文章をそれぞれ分割したもの)の積集合の要素数をスコアにする。
# それを(スコア、文章)を要素とするリストという形でまとめる。
scored = [(len(q & bigrams(d)), d) for d in docs]
scored.sort(key=lambda x: x[0], reverse=True) # スコア(タプルの0番目)が大きい順に並べる
return [d for score, d in scored[:top_k] if score > 0] # スコアの上位top_k件の文章を返す(0件は除く)
# RAGありの生成
def answer_with_rag(question, docs):
found = search(question, docs) # 1) 関連文書を検索
context = "\n".join(f"- {d}" for d in found) # 2) コンテキストにまとめる
messages = [
{"role": "system",
"content": "以下のコンテキストだけを根拠に、日本語で簡潔に答えてください。"
"コンテキストに答えが無ければ「わかりません」と答えてください。"},
{"role": "user",
"content": f"コンテキスト:\n{context}\n\n質問: {question}"}, # 3) 差し込む
]
return chat(messages, max_tokens=4096) # 4) LLMに答えさせる
# RAGなしの生成
def answer_without_rag(question):
messages = [
{"role": "system", "content": "日本語で簡潔に答えてください。"},
{"role": "user", "content": question},
]
return chat(messages, max_tokens=4096)
if __name__ == "__main__":
question = "にゃんにゃん運輸の在宅勤務は、週何日まで在宅で働けますか?"
print("【RAGなし】そのまま聞く")
print(answer_without_rag(question))
print("\n【RAGあり】社内文書を検索して渡す")
print(answer_with_rag(question, docs))anthropic/claude-haiku-4-5を使用した場合には、次の回答になりました。
【RAGなし】そのまま聞く
申し訳ございませんが、「にゃんにゃん運輸」という企業についての情報は持っていません。
この企業の在宅勤務制度について確認されたいのであれば、以下の方法をお勧めします:
1. **企業の公式ウェブサイト**を確認する
2. **採用情報ページ**を見る
3. **企業の採用担当者に直接問い合わせる**
4. **求人サイト**(Indeed、リクルートなど)の詳細情報を確認する
正確な情報を得るため、直接企業に問い合わせることをお勧めします。
【RAGあり】社内文書を検索して渡す
にゃんにゃん運輸の在宅勤務制度では、週3日まで在宅で働けます。
gemini/gemini-2.5-flash-liteを使用した場合には、次の回答になりました。
【RAGなし】そのまま聞く
にゃんにゃん運輸の在宅勤務制度について、週何日まで在宅で働けるかというご質問ですね。
申し訳ありませんが、**「にゃんにゃん運輸」という会社名では、そのような制度の詳細に関する公的な情報を見つけることができませんでした。**
もしかすると、会社名が少し違う、あるいは非公開の情報である可能性も考えられます。
もし、この会社にお勤めの方、あるいは関係者の方がいらっしゃいましたら、社内規定や担当部署にご確認いただくのが最も確実な方法となります。
【RAGあり】社内文書を検索して渡す
週3日まで在宅で働けます。
LLMによる回答は確率で変わるため、毎回同じ回答になるとは限りません。
RAGなしではにゃんにゃん運輸は架空の会社なので、モデルはその制度を学習していません。このため、正確な情報が得られないことを回答し、問い合わせることを勧めています(今回は、完全に検索にヒットしないような名前を選んだため、ハルシネーションは起きなかったのかもしれません)。
一方、RAGありでは、先に社内文書を検索し、見つかった文書をコンテキストに差し込んでから聞きます。今回のデータには「週3日まで在宅で働けます」という文があり、検索でこの文書が選ばれるので、モデルはそれを根拠に答えられています。
なお、検索で何も当たらなければコンテキストは空になります。そのときは「分からなければ分からないと答える」という指示に沿わせることで、渡した文書の範囲を超えて勝手に作らないようにできます。これも接地の一部です。
いつRAGを使い、いつ使わないか
RAGは万能ではなく、他のやり方のほうが向く場面もあります。LLMまわりで「知らないこと・手元のデータに答えさせる」代表的な手段を並べると、次のようになります。あわせて、ハルシネーションの起きやすさの傾向も付けます。
| 手法 | 得意なこと | 苦手なこと | ハルシネーションの傾向 |
|---|---|---|---|
| 素のLLM(そのまま聞く) | 一般的な知識、文章の生成・要約 | 最新情報、非公開データ | 起きやすい |
| 全文検索(キーワード等) | 該当箇所を正確に探し出す | 質問への「回答文」は作らない | 生成しないので起きない |
| RAG | 外部文書に基づく回答と根拠の提示 | 検索の質に依存する | 起きにくい(根拠で検証できる) |
| 長いコンテキスト(全文を渡す) | 少量の文書をまるごと参照 | 大量文書、コスト、速度 | 起きにくい(渡した範囲なら) |
| ファインチューニング | 口調・形式・分類などの学習 | 知識の最新化、根拠の提示 | 知識の更新目的では対策にならない |
ざっくりした使い分けは次のとおりです。手元の文書が少量なら、検索せずにその全文をそのままLLMに渡す(長いコンテキスト)ほうが簡単なこともあります。文書が大量にあって毎回全部は渡せない、最新の情報や社内データに根拠つきで答えたい、という場合にRAGが向きます。ファインチューニングは「新しい知識を覚えさせる」用途には不向きで(覚えさせたつもりでも誤りが増えることすらあります)、口調や出力形式をそろえる用途に向きます。
ここに挙げた傾向はあくまで一般的なもので、実際の精度・コスト・速度は、アルゴリズムによって変わります。また、技術の進歩の速い分野であるため、欠点が解消される可能性もあります。
まとめ
RAGが何で、なぜ必要かを、最小のRAGを動かしながら確認しました。要点を以下にまとめます。
- LLMは続きのトークンを確率的に予測する仕組みで、知識カットオフ・ハルシネーション・コンテキストウィンドウという限界がある。
- RAGは、関連文書を検索してLLMに渡し、答えをその文書に接地(grounding)させる手法である。
- RAGは「最新情報」「非公開・社内データ」「根拠の提示」という3つの問題を解く。
- 基本の流れは、取り込み(indexing)→検索(retrieval)→拡張(augmentation)→生成(generation)。基本動作は「検索した文書をプロンプトに差し込んで渡す」だけである。
- 手元の文書が少量なら全文投入、大量なら根拠つきで答えられるRAG、というように他の手法と使い分ける。
キーワード検索の弱点(言い換えに弱い)を埋めるには、意味の近さで探す意味検索が有効です。
参考資料
- RAGの原論文:Patrick Lewis et al. "Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks", NeurIPS 2020.
https://proceedings.neurips.cc/paper_files/paper/2020/file/6b493230205f780e1bc26945df7481e5-Paper.pdf - LiteLLM公式ドキュメント(Getting Started):https://docs.litellm.ai/docs/