Ollama: RAGパイプライン

RAGはAIに知識の金庫を与える——推測で答えるのではなく、文書から真実を検索する。

💡 ヒント: RAGの中核原理は「先に検索、後に生成」——モデルは検索された文書断片に基づいてのみ回答し、記憶からは回答しない。これにより「ハルシネーション」(情報の捏造)が大幅に減少するが、検索品質が回答品質を直接決定する。チャンキング戦略と埋め込みモデルの選択が鍵。

📋 前提条件: まず以下を習得していること

1. 学べること


2. SaaS起業家のリアルな事例

⚠️ 警告: RAGはハルシネーションを排除できない——発生確率を大幅に下げるだけ。検索された文書断片が無関係または不十分な場合、モデルが依然として「捏造」する可能性がある。重要な戦略:モデルに検索結果の範囲内でのみ回答するよう制約し、システムプロンプトに「関連情報が文書にない場合は『確認できません』と回答すること」を追加。

(1) ペインポイント:モデルが製品の詳細を知らない

AliceのSupportBotは一般的なカスタマーサービスには対応できるが、特定の製品返品ポリシー、保証条項、価格詳細を知らない。モデルが頻繁に情報を「捏造」し、顧客からの苦情が絶えない。

(2) ソリューション:RAGでモデルが実際の文書に基づいて回答

製品マニュアル、FAQ、ポリシー文書をベクトルデータベースにインポートし、関連断片を検索して、モデルに事実に基づいて回答を生成させる:

PYTHON
# RAG: Retrieve relevant docs, then generate answer
docs = retriever.invoke("What is the warranty for headphones?")
answer = llm.invoke(f"Based on: {docs}\nAnswer: What is the warranty?")

3. RAGアーキテクチャ詳細

💡 ヒント: RAGの中核原理は「先に検索、後に生成」——モデルは検索された文書断片に基づいてのみ回答し、記憶からは回答しない。これにより「ハルシネーション」(情報の捏造)が大幅に減少するが、検索品質が回答品質を直接決定する。チャンキング戦略と埋め込みモデルの選択が鍵。

(1) エンドツーエンドパイプラインフロー

100%
flowchart LR
    A[文書] --> B[ローダー]
    B --> C[スプリッター]
    C --> D[埋め込みモデル<br/>nomic-embed-text]
    D --> E[ベクトルストア<br/>Chroma/FAISS]
    F[ユーザー質問] --> G[質問埋め込み]
    G --> E
    E --> H[Top-Kチャンク]
    H --> I[LLM生成<br/>qwen2.5]
    I --> J[回答]

(2) ステージ別内訳

ステージ ツール 入力 出力
ロード PyPDFLoader / TextLoader PDF/MD/TXT 文書リスト
チャンク RecursiveCharacterTextSplitter 文書 チャンクリスト
埋め込み OllamaEmbeddings チャンクテキスト ベクトル(768次元)
保存 Chroma / FAISS ベクトル+テキスト インデックス
検索 類似度検索 質問ベクトル Top-Kチャンク
生成 ChatOllama 質問+チャンク 回答

4. Ollama埋め込み

⚠️ : 埋め込みモデルの選択は検索品質に直接影響——nomic-embed-text(768次元)は英語で良好だが中国語では平均的、mxbai-embed-large(1024次元)は精度が高いがサイズが2倍。中国語シナリオでは複数モデルをテストして最適なものを選ぶこと——「大きいほど良い」とは限らない。

(1) 埋め込みモデル比較

モデル 次元数 サイズ 速度 ユースケース
nomic-embed-text 768 274 MB 高速 汎用英語
mxbai-embed-large 1024 670 MB 中程度 高精度
all-minilm 384 46 MB 非常に高速 軽量

(2) 埋め込みモデル選択ガイド

シナリオ 推奨モデル 理由
汎用RAG nomic-embed-text 最もコスパが良い
高精度検索 mxbai-embed-large 高次元、識別力が高い
リソース制限 all-minilm 最小・最速
中国語中心 nomic-embed-text 中国語性能は許容範囲

(3) ▶ サンプル:Ollama埋め込みの基本

PYTHON
from langchain_ollama import OllamaEmbeddings

# Initialize embeddings model
embeddings = OllamaEmbeddings(model="nomic-embed-text")

# Embed a single text
vector = embeddings.embed_query("What is the return policy?")
print(f"Dimension: {len(vector)}")  # 768

# Embed multiple texts
vectors = embeddings.embed_documents([
    "Free shipping on orders over $50",
    "30-day return policy for all products",
    "Warranty covers manufacturing defects for 1 year"
])
print(f"Embedded {len(vectors)} documents")

出力:

TEXT
# Execution successful

5. 文書ロードとチャンキング

(1) 文書ローダー

ローダー フォーマット インストール
PyPDFLoader PDF pip install pypdf
TextLoader TXT 組み込み
UnstructuredMarkdownLoader Markdown pip install unstructured
CSVLoader CSV 組み込み
UnstructuredHTMLLoader HTML/URL pip install unstructured

(2) チャンキング戦略比較

戦略 パラメータ 適する文書 メリット/デメリット
固定サイズ chunk_size=500, overlap=50 汎用 シンプルだが意味を切断する可能性
再帰的文字 chunk_size=500, overlap=50, separators 構造化文書 段落境界を保持
意味的チャンキング similarity_threshold 長文書 最高品質だが計算コストが高い
💡 ヒント: RecursiveCharacterTextSplitterが推奨選択。chunk_size=500-1000、overlap=50-100が経験的に最適な値。

(3) ▶ サンプル:文書ロードとチャンキング

PYTHON
from langchain_community.document_loaders import TextLoader, PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

# Load text document
loader = TextLoader("product_faq.txt")
docs = loader.load()
print(f"Loaded {len(docs)} document(s)")

# Split into chunks
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separators=["\n\n", "\n", ". ", " ", ""]
)
chunks = splitter.split_documents(docs)
print(f"Split into {len(chunks)} chunks")
print(f"First chunk preview: {chunks[0].page_content[:100]}...")

出力:

TEXT
# Execution successful

6. ベクトルデータベースの選択

(1) 3大ベクトルストア比較

項目 Chroma FAISS Qdrant
タイプ 組み込み インメモリ クライアント/サーバー
永続化 ✅ ローカルファイル ❌ メモリのみ ✅ ディスク/クラウド
インストール pip install pip install + faiss-cpu Docker / pip
最適用途 開発/小規模 高速検索 本番環境
フィルタリング ✅ メタデータフィルタ ✅ 高度なフィルタリング
分散

(2) ▶ サンプル:Chromaベクトル保存と検索

PYTHON
from langchain_ollama import OllamaEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_text_splitters import RecursiveCharacterTextSplitter

# Prepare documents
documents = [
    "Return policy: 30 days from purchase, items must be in original condition.",
    "Shipping: Free for orders over $50. Standard delivery takes 3-5 business days.",
    "Warranty: All electronics have a 1-year manufacturer warranty.",
    "International shipping: Available to 50+ countries. Import duties may apply.",
    "Refund process: Full refund within 5-7 business days after we receive the return."
]

# Split documents
splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=20)
chunks = splitter.create_documents(documents)

# Create vector store
embeddings = OllamaEmbeddings(model="nomic-embed-text")
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db"
)

# Similarity search
results = vectorstore.similarity_search("How do I get a refund?", k=2)
for doc in results:
    print(f"- {doc.page_content}")

出力:

TEXT
# Execution successful

(3) ▶ サンプル:FAISSベクトル保存

PYTHON
from langchain_ollama import OllamaEmbeddings
from langchain_community.vectorstores import FAISS

embeddings = OllamaEmbeddings(model="nomic-embed-text")

# Create FAISS index
texts = [
    "Product A: Wireless headphones, $89.99, Bluetooth 5.3",
    "Product B: Laptop stand, $34.99, Adjustable height",
    "Product C: USB-C cable, $12.99, 2-meter length"
]
vectorstore = FAISS.from_texts(texts, embeddings)

# Save and load
vectorstore.save_local("./faiss_index")
loaded_vs = FAISS.load_local("./faiss_index", embeddings,
                              allow_dangerous_deserialization=True)

# Search
results = loaded_vs.similarity_search("headphones", k=2)
for doc in results:
    print(doc.page_content)

出力:

TEXT
# Execution successful

7. 完全RAGパイプラインとSupportBot V3

(1) ▶ サンプル:完全RAGパイプライン

PYTHON
from langchain_ollama import ChatOllama, OllamaEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

# Build retriever
embeddings = OllamaEmbeddings(model="nomic-embed-text")
vectorstore = Chroma(persist_directory="./chroma_db", embedding_function=embeddings)
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})

# Build RAG chain
llm = ChatOllama(model="qwen2.5", temperature=0.3)

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are SupportBot. Answer based ONLY on the following context. "
               "If the answer is not in the context, say 'I don't have that information. "
               "Let me connect you with a human agent.'\n\nContext:\n{context}"),
    ("human", "{question}")
])

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

# Ask questions
answer = rag_chain.invoke("What is the return policy?")
print(answer)

出力:

TEXT
# Function defined successfully

8. 総合サンプル:SupportBot V3 RAGカスタマーサービスシステム

PYTHON
# ============================================
# Comprehensive: SupportBot V3 with RAG
# Product knowledge base powered customer service
# ============================================

from langchain_ollama import ChatOllama, OllamaEmbeddings
from langchain_community.document_loaders import TextLoader
from langchain_community.vectorstores import Chroma
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from pathlib import Path

class SupportBotV3:
    def __init__(self, kb_path: str = "./knowledge_base",
                 model: str = "qwen2.5",
                 embed_model: str = "nomic-embed-text"):
        self.llm = ChatOllama(model=model, temperature=0.3)
        self.embeddings = OllamaEmbeddings(model=embed_model)
        self.kb_path = kb_path
        self.vectorstore = None
        self.rag_chain = None

    def build_knowledge_base(self, doc_dir: str):
        """Load documents and build vector store."""
        docs = []
        for f in Path(doc_dir).glob("*.txt"):
            loader = TextLoader(str(f))
            docs.extend(loader.load())
        for f in Path(doc_dir).glob("*.md"):
            loader = TextLoader(str(f))
            docs.extend(loader.load())

        splitter = RecursiveCharacterTextSplitter(
            chunk_size=500, chunk_overlap=50
        )
        chunks = splitter.split_documents(docs)

        self.vectorstore = Chroma.from_documents(
            documents=chunks,
            embedding=self.embeddings,
            persist_directory=self.kb_path
        )
        print(f"Indexed {len(chunks)} chunks from {len(docs)} documents")

    def setup_rag_chain(self):
        """Build the RAG chain."""
        if not self.vectorstore:
            self.vectorstore = Chroma(
                persist_directory=self.kb_path,
                embedding_function=self.embeddings
            )

        retriever = self.vectorstore.as_retriever(search_kwargs={"k": 3})

        prompt = ChatPromptTemplate.from_messages([
            ("system", (
                "You are SupportBot for GlobalShop e-commerce. "
                "Answer based ONLY on the provided context. "
                "If not in context, say: 'Let me connect you with a human agent.' "
                "Be concise (2-3 sentences).\n\nContext:\n{context}"
            )),
            ("human", "{question}")
        ])

        def format_docs(docs):
            return "\n\n".join(d.page_content for d in docs)

        self.rag_chain = (
            {"context": retriever | format_docs,
             "question": RunnablePassthrough()}
            | prompt | self.llm | StrOutputParser()
        )

    def ask(self, question: str) -> str:
        """Ask a question using RAG."""
        if not self.rag_chain:
            self.setup_rag_chain()
        return self.rag_chain.invoke(question)

# Usage
if __name__ == "__main__":
    bot = SupportBotV3()

    # First time: build knowledge base
    # bot.build_knowledge_base("./docs")

    # Ask questions
    questions = [
        "What is the return policy for electronics?",
        "How long does international shipping take?",
        "Do you offer free returns?",
    ]
    for q in questions:
        answer = bot.ask(q)
        print(f"Q: {q}")
        print(f"A: {answer}\n")

❓ よくある質問

Q RAGとファインチューニングはどちらを選ぶべき?
A RAGは知識が頻繁に更新されるシナリオ(製品FAQ、ポリシー文書)に適し、再学習不要。ファインチューニングはモデルの行動パターンを変更する用途。90%のエンタープライズシナリオではRAGが適している。
Q chunk_sizeはどう選ぶ?
A 500〜1000文字が良い開始点。短文書(FAQ)は200〜500、長文書(マニュアル)は500〜1000。overlapはchunk_sizeの10〜20%に設定。
Q 検索結果が不正確な場合は?
A 試行:1)高次元埋め込みモデル(mxbai-embed-large)に切替;2)k値を増やす(3→5);3)MMR検索で多様性を向上;4)文書チャンキング品質を改善。
Q ChromaとFAISSはどう選ぶ?
A 開発中はChroma(自動永続化、フィルタリング対応)。極端な速度が必要で全データがメモリに収まる場合はFAISS。本番環境ではQdrant(分散、高度なフィルタリング)。
Q RAGのトークン消費はどう見積もる?
A 各クエリの消費 = 質問トークン + top-kチャンクトークン + 生成回答トークン。k=3、chunk_size=500の場合、入力は約1500〜2000トークン。ローカル推論にはトークンコストなし。
Q RAGの品質を評価するには?
A RAGASフレームワークで4つの指標を評価:忠実性(回答がコンテキストに忠実か)、回答関連性(回答が質問に即しているか)、コンテキスト精度(検索精度)、コンテキスト再現率。

📖 まとめ


📝 練習問題

  1. 基本(難易度 ⭐):OllamaEmbeddingsで5つの製品FAQテキストをChromaに保存し、類似度検索を1回実行する。
  2. 中級(難易度 ⭐⭐):完全なRAGパイプラインを構築——テキストファイルのロード、チャンキング、埋め込み、保存から検索生成まで、3つの質問でテストする。
  3. 上級(難易度 ⭐⭐⭐):SupportBot V3のナレッジベースを構築——10以上の製品文書を準備し、RAGシステムを構築し、同じ質問でRAGあり/なしの回答品質を比較する。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%