OllamaとChromaDBでローカルRAGを実装する:最小サンプルで仕組みを理解する

これまでの記事で、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つのフェーズに分かれます。

事前準備フェーズ

  1. ドキュメントを用意する
  2. 各ドキュメントをembeddingモデルでベクトル(数値の配列)に変換する
  3. ベクトルデータベース(ChromaDBなど)に保存する

質問応答フェーズ

  1. ユーザーの質問をベクトル化する
  2. ベクトルデータベースで「質問ベクトルに近いドキュメント」を検索する
  3. 検索結果をプロンプトに埋め込んでLLMに質問する
  4. 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を永続化する、など実用的なバリエーションが豊富にあります。本記事のサンプルがその出発点になれば幸いです。

コメント

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