Ollama: RAGパイプライン
RAGはAIに知識の金庫を与える——推測で答えるのではなく、文書から真実を検索する。
💡 ヒント: RAGの中核原理は「先に検索、後に生成」——モデルは検索された文書断片に基づいてのみ回答し、記憶からは回答しない。これにより「ハルシネーション」(情報の捏造)が大幅に減少するが、検索品質が回答品質を直接決定する。チャンキング戦略と埋め込みモデルの選択が鍵。
📋 前提条件: まず以下を習得していること
- レッスン13: LangChain統合
1. 学べること
- RAGアーキテクチャ:文書ロード → チャンク分割 → 埋め込み → ベクトル保存 → 検索 → 生成
- Ollama埋め込みモデルの使用
- ベクトルデータベースの選択:Chroma / FAISS / Qdrant
- 文書チャンキング戦略とパラメータチューニング
- AliceのSupportBot V3:製品ナレッジベースRAG
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) エンドツーエンドパイプラインフロー
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 | 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つの指標を評価:忠実性(回答がコンテキストに忠実か)、回答関連性(回答が質問に即しているか)、コンテキスト精度(検索精度)、コンテキスト再現率。
📖 まとめ
- RAG6ステップパイプライン:ロード → チャンク → 埋め込み → 保存 → 検索 → 生成
- nomic-embed-textはOllamaのデフォルト埋め込みモデル、768次元、最もコスパが良い
- RecursiveCharacterTextSplitterが推奨チャンキングツール;chunk_size=500が良い開始点
- 開発はChroma、高速検索はFAISS、本番はQdrant
- RAGチェーンはLCELで構成:retriever | format | prompt | llm | parser
- SupportBot V3はRAGで回答を証拠に基づかせ、「捏造」問題を解消
📝 練習問題
- 基本(難易度 ⭐):OllamaEmbeddingsで5つの製品FAQテキストをChromaに保存し、類似度検索を1回実行する。
- 中級(難易度 ⭐⭐):完全なRAGパイプラインを構築——テキストファイルのロード、チャンキング、埋め込み、保存から検索生成まで、3つの質問でテストする。
- 上級(難易度 ⭐⭐⭐):SupportBot V3のナレッジベースを構築——10以上の製品文書を準備し、RAGシステムを構築し、同じ質問でRAGあり/なしの回答品質を比較する。