これまでの記事で、MacBook Air M4でOllamaを動かし、Open WebUIで快適に使い、APIをPythonから叩いてチャットCLIを作るところまで進めてきました。今回は応用編として、RAG(Retrieval-Augmented Generation) の最小サンプルを実装し、ローカルLLMに「学習データにない情報」を答えさせる仕組みを理解します。
RAGとは何か
RAGはRetrieval-Augmented Generation(検索拡張生成)の略で、LLMが学習していない外部の文書を検索して取り出し、その内容を参考にして回答を生成する仕組みです。
LLMには以下の弱点があります。
- 学習データのカットオフ問題: 学習時点より新しい情報を知らない
- 社内ドキュメントを知らない: 個人や組織固有の情報は学習に含まれない
- ハルシネーション: 知らないことをそれっぽい嘘で答えてしまう
RAGはこれらを「事前に関連文書を検索し、その内容と一緒に質問をLLMに投げる」ことで解決します。
RAGの仕組み
RAGの動作は2つのフェーズに分かれます。
事前準備フェーズ
- ドキュメントを用意する
- 各ドキュメントをembeddingモデルでベクトル(数値の配列)に変換する
- ベクトルデータベース(ChromaDBなど)に保存する
質問応答フェーズ
- ユーザーの質問をベクトル化する
- ベクトルデータベースで「質問ベクトルに近いドキュメント」を検索する
- 検索結果をプロンプトに埋め込んでLLMに質問する
- LLMが文書を参照して回答を生成する
ベクトル化(embedding)は、テキストを「意味を表す数値の配列」に変換する処理です。意味が近い文章は近いベクトルになる性質があるため、単語が一致しなくても意味的に関連する文書を見つけられます。これがキーワード検索にはない、ベクトル検索の強みです。
必要なツール
今回のサンプルで使うのは以下の3つです。
| ツール | 役割 |
|---|---|
| Ollama(gemma3:12b) | 質問に答えるチャット用LLM |
| Ollama(nomic-embed-text) | テキストをベクトル化する専用モデル |
| ChromaDB | ベクトルデータベース(Pythonで簡単に扱える) |
nomic-embed-text は約274MBの軽量モデルで、テキストのベクトル化に特化しています(チャットには使えません)。
環境構築
embeddingモデルのダウンロード
ollama pull nomic-embed-text
完了後、ollama list で確認できます。
NAME ID SIZE MODIFIED
nomic-embed-text:latest 0a109f422b47 274 MB 6 seconds ago
gemma3:12b f4031aab637d 8.1 GB 2 hours ago
Pythonライブラリのインストール
前回のAPI活用編で作った仮想環境を使い回します。
cd ~/works/ollama-chat-cli
source .venv/bin/activate
pip install chromadb
ChromaDBは依存関係が多いため、インストールに2〜3分かかることがあります。
サンプルコード
架空の社内FAQを使ってRAGを実装します。5件のドキュメントを用意し、それに関する質問にLLMが答えられるかを検証します。
rag_sample.py を作成します。
"""
RAGの最小サンプル
- 架空のFAQをドキュメントとして用意
- embeddingでベクトル化してChromaDBに保存
- ユーザーの質問に対して、関連ドキュメントを検索してLLMに渡す
"""
import ollama
import chromadb
EMBED_MODEL = "nomic-embed-text"
CHAT_MODEL = "gemma3:12b"
# 架空のFAQドキュメント(社内マニュアル想定)
DOCUMENTS = [
{
"id": "doc1",
"text": "アナライズギア社のリモートワーク制度では、週3日まで在宅勤務が認められています。在宅勤務を希望する場合は、前日までにSlackで上長に申請してください。",
},
{
"id": "doc2",
"text": "経費精算はfreee会計を使用します。月末締めで翌月5日までに申請を完了させてください。領収書は写真をアップロードする方式で、原本の提出は不要です。",
},
{
"id": "doc3",
"text": "技術ブログへの記事投稿は、書きたい人が自由に書ける制度です。投稿前にレビュー担当者にSlackで連絡し、公開タイミングを調整してください。原稿料は1記事あたり5,000円です。",
},
{
"id": "doc4",
"text": "有給休暇の取得は、3営業日前までに勤怠管理システムから申請してください。連続5日以上の取得は、所属チームのリーダーとの事前相談が必要です。",
},
{
"id": "doc5",
"text": "オフィスの利用時間は平日の8時から22時までです。土日祝日に利用したい場合は、セキュリティの都合上、前週金曜までに総務に申請してください。",
},
]
def get_embedding(text: str) -> list:
"""テキストをベクトル化"""
response = ollama.embeddings(model=EMBED_MODEL, prompt=text)
return response["embedding"]
def setup_vector_db():
"""ChromaDBにドキュメントを登録"""
print("ベクトルDBをセットアップ中...")
client = chromadb.Client()
try:
client.delete_collection("faq")
except Exception:
pass
collection = client.create_collection("faq")
for doc in DOCUMENTS:
embedding = get_embedding(doc["text"])
collection.add(
ids=[doc["id"]],
embeddings=[embedding],
documents=[doc["text"]],
)
print(f" 登録: {doc['id']}")
print("セットアップ完了\n")
return collection
def search_documents(collection, query: str, top_k: int = 2) -> list:
"""質問に関連するドキュメントを検索"""
query_embedding = get_embedding(query)
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k,
)
return results["documents"][0]
def answer_with_rag(collection, question: str) -> str:
"""RAGで質問に答える"""
relevant_docs = search_documents(collection, question, top_k=2)
print("--- 検索された関連文書 ---")
for i, doc in enumerate(relevant_docs, 1):
print(f"[{i}] {doc[:80]}...")
print()
context = "\n\n".join(f"- {doc}" for doc in relevant_docs)
prompt = f"""以下の社内マニュアルを参考に、質問に答えてください。
マニュアルに記載されていない内容は「マニュアルに記載がありません」と答えてください。
【社内マニュアル】
{context}
【質問】
{question}
【回答】"""
response = ollama.chat(
model=CHAT_MODEL,
messages=[{"role": "user", "content": prompt}],
)
return response["message"]["content"]
def main():
print("=== RAGサンプル ===\n")
collection = setup_vector_db()
print("質問を入力してください(exitで終了)")
print("例: リモートワークは週何日まで?")
print()
while True:
try:
question = input("質問> ").strip()
except (KeyboardInterrupt, EOFError):
print("\n終了します")
break
if not question:
continue
if question.lower() in {"exit", "quit", "bye"}:
print("終了します")
break
print()
answer = answer_with_rag(collection, question)
print("--- LLMの回答 ---")
print(answer)
print()
if __name__ == "__main__":
main()
実装のポイント
embeddingの取得
ollama.embeddings() でテキストをベクトル化します。nomic-embed-text モデルは768次元のベクトルを返します。
response = ollama.embeddings(model=EMBED_MODEL, prompt=text)
embedding = response["embedding"] # 長さ768のfloat配列
ChromaDBへの保存
collection.add() で、ベクトル・元のテキスト・IDをセットで保存します。検索時にベクトルからテキストを引けるようにするためです。
collection.add(
ids=[doc["id"]],
embeddings=[embedding],
documents=[doc["text"]],
)
類似検索
collection.query() に質問のベクトルを渡すと、近いベクトルを持つドキュメントが返ります。n_results で取得件数を指定します。
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k,
)
プロンプトへの埋め込み
検索結果をプロンプトに埋め込んでLLMに渡します。「マニュアルに記載されていない内容は『マニュアルに記載がありません』と答えてください」という指示が重要で、これがハルシネーション抑制に効きます。
実行と動作確認
python rag_sample.py
起動時に5件のドキュメントがベクトル化されてDBに登録されます。その後、質問プロンプトが表示されます。
=== RAGサンプル ===
ベクトルDBをセットアップ中...
登録: doc1
登録: doc2
登録: doc3
登録: doc4
登録: doc5
セットアップ完了
質問を入力してください(exitで終了)
ここから4種類の質問パターンでRAGの挙動を検証します。
ケース1: 基本的な質問
質問> リモートワークは週何日まで可能ですか?
--- 検索された関連文書 ---
[1] アナライズギア社のリモートワーク制度では、週3日まで在宅勤務が認められています...
[2] オフィスの利用時間は平日の8時から22時までです...
--- LLMの回答 ---
リモートワークは週3日まで可能です。
doc1が1位でヒットし、LLMが正確に回答しました。2位に「オフィス利用」が選ばれているのは、両方とも「働き方」に関する文書として意味的に近いと判定されたためです。
ケース2: LLMが絶対に知らない情報
質問> 技術ブログを書いたら報酬はもらえますか?
--- 検索された関連文書 ---
[1] 技術ブログへの記事投稿は、書きたい人が自由に書ける制度です...原稿料は1記事あたり5...
[2] 経費精算はfreee会計を使用します...
--- LLMの回答 ---
はい、技術ブログの記事投稿で報酬はもらえます。1記事あたり5,000円です。
これがRAGの真価です。「アナライズギア社のブログ報酬が1記事5,000円」という情報は、Gemma 3の学習データには絶対に含まれていません。それでもRAGによって正確に答えられています。
ケース3: 範囲外の質問(ハルシネーション抑制)
質問> 退職金制度について教えて
--- 検索された関連文書 ---
[1] オフィスの利用時間は平日の8時から22時までです...
[2] 有給休暇の取得は、3営業日前までに勤怠管理システムから申請してください...
--- LLMの回答 ---
マニュアルに記載がありません。
退職金に関する文書が存在しないため、検索結果は関連性の低いものが返っています。しかしLLMはプロンプトの指示に従い「マニュアルに記載がありません」と正直に答えました。一般論を勝手に語り始めることを防げています。
ケース4: 完全に範囲外の質問
質問> お弁当の補助はありますか?
--- 検索された関連文書 ---
[1] オフィスの利用時間は平日の8時から22時までです...
[2] アナライズギア社のリモートワーク制度では、週3日まで在宅勤務が認められています...
--- LLMの回答 ---
マニュアルに記載がありません。
ケース3と同様、検索結果はあるものの内容が一致しないため、LLMが適切に「分からない」と答えています。
サンプルから見えてきたこと
このシンプルなサンプルだけで、RAGの主要な特性が確認できました。
| 確認できた特性 | 該当ケース |
|---|---|
| 意味検索が機能する | ケース1(「在宅勤務」「リモートワーク」が同義として扱える) |
| LLMが知らない情報を答えられる | ケース2(架空の社内情報) |
| ハルシネーションを抑制できる | ケース3、4(推測で答えない) |
| LLMがノイズを無視できる | ケース2(2位の経費精算は無視された) |
特に注目したいのは、検索の2位に多少関係ない文書が含まれていても、LLMが「これは関係ない」と判断して無視できる点です。検索精度100%でなくてもRAGは機能するということで、これは実用化の上で重要な特性です。
RAGの実装上の注意点
サンプルを通じて見えてきた、実装時に気をつけるべきポイントをまとめます。
embeddingモデルとチャットモデルは別物
nomic-embed-text はベクトル化専用で、チャットには使えません。逆に gemma3:12b はチャット専用で、embeddingには使いません。役割が違うため、用途に応じてモデルを使い分ける必要があります。
プロンプトの指示が挙動を決める
「マニュアルに記載されていない内容は『マニュアルに記載がありません』と答えてください」という1文を削除すると、LLMが推測で答え始めることがあります。RAGの精度は、検索の精度だけでなくプロンプト設計にも依存します。
検索件数(top_k)のチューニング
今回は top_k=2 で固定していますが、ドキュメント量や種類によって最適値は変わります。多すぎるとノイズが増え、少なすぎると関連情報を取りこぼします。
まとめ
本記事では以下の内容を扱いました。
- RAG(Retrieval-Augmented Generation)の概念と仕組み
- Ollamaのembeddingモデル(nomic-embed-text)とChromaDBの組み合わせ
- 最小構成のRAGサンプルの実装と動作確認
- 4種類の質問パターンによるRAGの特性検証
たった150行程度のPythonコードで、ローカル環境で完結するRAGシステムが作れることが分かりました。クラウドのAPIを使わず、機密情報を外部に送信せずに、自分のドキュメントをLLMで活用できるのはローカルLLMの大きな価値です。
ここから先の発展としては、ローカルのMarkdownファイル群を読み込ませる、WordPressからエクスポートしたブログ記事をRAG化する、大きなドキュメントをチャンク分割して扱う、ベクトルDBを永続化する、など実用的なバリエーションが豊富にあります。本記事のサンプルがその出発点になれば幸いです。


コメント