RAG(Retrieval-Augmented Generation, 検索拡張生成)は、外部データをLLMに取り込んで精度の高い回答を得る技術です。
前回までに実装したRAGは、LLMのfunction callingによる検索と生成を分離した構成でした。
本記事では、その実装を LangChainを使って再構成しつつ、処理の意味を改めて整理 する形でリファクタリングします。
RAGとは何か:基本の整理
RAGは、LLM単体では不可能な最新・固有データへの参照を、検索(Retrieval)によって補強し、その検索結果を生成(Generation)に組み込む手法です。
外部コーパスやナレッジベースをVector DBなどで検索し、それをプロンプトとしてLLMに渡すことでドメイン固有の情報で回答を生成します。
前回までの記事で、OllamaとChromaDBを使ったRAG実装を何パターンか紹介しています。 https://pareido.jp/ai/ai-text/rag-function-calling-generation-separation/
LangChainとは
LangChainは、RAG構築をはじめとしたLLMアプリケーション開発を体系化するPython向けライブラリです。
ドキュメントのロード・分割、Embedding生成、ベクトル検索、プロンプト管理などを標準APIで統合して扱いやすくします。
公式ドキュメントにもRAGチュートリアルがあり、検索付きQ&Aアプリ構築の基本が説明されています。
(英語)Build a RAG agent with LangChain – Docs by LangChain
LangChainでRAGを実装する基本構成
本稿で扱うRAG構成は、LangChain公式チュートリアルで紹介されている典型的な「Retriever → Chain → LLM」という形とは異なっています。
LangChainとしてはAgenticな構成として扱うことも可能ですが、本実装ではRAGを強制するために検索ロジックを明示的に定義しています。
Agentic RAGは検索回数や検索範囲がLLMの判断に依存するため、RAGを使わない場合もあり、検索制御を厳密に行いたい場合には向いていません。
検索ワードの判断はLLMに任せつつ、検索そのものはアプリケーション側で必ず1回だけ実行し、生成フェーズのみをChainとして構造化しています。
import ollama
import json
from pathlib import Path
import re
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
# 生成用 Ollama(別マシンの Ollama を想定)
GENERATION_OLLAMA_BASE_URL = "http://REMOTE_HOST:11434"
from langchain_ollama import ChatOllama
from langchain_core.tools import tool
from langchain_chroma import Chroma
from langchain_ollama import OllamaEmbeddings
# ChromaDB 初期化
embedding = OllamaEmbeddings(model="nomic-embed-text")
vectorstore = Chroma(
collection_name="tono_monogatari",
embedding_function=embedding,
persist_directory=None
)
# sample.txt 読み込み(遠野物語)
# 前提フォーマット:
# 【<話番号>:<題目>:L<行番号>】本文テキスト
text_path = Path(__file__).parent / "sample.txt"
documents = []
metadatas = []
header_pattern = re.compile(r"^【(\d+):([^:]+):L(\d+)】\s*(.+)$")
with open(text_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
m = header_pattern.match(line)
if not m:
continue
section_id = int(m.group(1))
theme = m.group(2)
line_no = int(m.group(3))
text = m.group(4)
documents.append(text)
metadatas.append({
"theme": theme,
"section_id": section_id,
"line": line_no
})
# ドキュメント登録
vectorstore.add_texts(
texts=documents,
metadatas=metadatas
)
# 検索関数(RAG)
def retrieve_documents(query: str) -> str:
# 1) ベクトル検索
results = vectorstore.similarity_search(
query,
k=3
)
if not results:
return ""
main_theme = results[0].metadata.get("theme")
# 2) 同じ題目のチャンクを追加取得
same_theme_results = vectorstore.get(
where={"theme": main_theme}
)
all_docs = []
seen = set()
# 3) 重複排除しつつ結合
for d in results:
if d.page_content not in seen:
seen.add(d.page_content)
all_docs.append(d.page_content)
for d in same_theme_results["documents"]:
if d not in seen:
seen.add(d)
all_docs.append(d)
return "\n".join(all_docs)
# LangChain Tool 定義(中身は元ロジック)
@tool
def retrieve_documents_tool(query: str) -> str:
"""遠野物語の文書から、質問に関係する記述を検索する"""
return retrieve_documents(query)
# System プロンプト(語り部)
system_prompt = """
あなたは遠野の里に古くから伝わる物語を語り継ぐ語り部です。
学術的・説明的・百科事典的な文章を書いてはいけません。
「〜とは」「〜である」といった解説調は禁止です。
必ず、囲炉裏端で昔話を聞かせるように、
聞き手に語りかける口調で語ってください。
事実を列挙するのではなく、「語り」として話してください。
語りの中では、次のような言い回しを自然に使って構いません。
「〜と伝えられておる」
「〜と人は申す」
「里ではそう語られてきた」
「確かなことは分からぬが」
ただし、語る内容は retrieve_documents 関数によって取得された
検索結果に含まれる事実の範囲に限定してください。
検索結果にない出来事・設定・結末を想像で補ってはいけません。
語りは、必ず次の順で構成してください。
1. 聞き手に向けた語りかけの導入
2. 検索結果に基づく物語の語り
3. 断定を避け、余韻を残す締め
"""
# ユーザー入力
user_question = "なにか知っている話について教えてください"
# 1回目:LLMに検索判断させる
function_llm = ChatOllama(
model="llama3.1:8b",
temperature=0
)
function_llm = function_llm.bind_tools([retrieve_documents_tool])
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_question}
]
response = function_llm.invoke(messages)
if not response.tool_calls:
raise RuntimeError("LLMが検索を実行しませんでした")
tool_call = response.tool_calls[0]
args = tool_call["args"]
# 検索 → 文脈組み立て → 生成 を Chain として定義
generation_llm = ChatOllama(
model="gpt-oss:20b",
temperature=0.7,
base_url=GENERATION_OLLAMA_BASE_URL
)
build_messages = RunnableLambda(
lambda x: [
{"role": "system", "content": system_prompt},
{
"role": "system",
"content": (
"以下は retrieve_documents で取得した検索結果です。\n"
"検索結果はあなた自身の知識として、含まれる事実の範囲に限定して利用してください。\n"
"想像による出来事・設定・結末を付け足してはいけません。\n"
f"{retrieve_documents(x['query'])}"
)
},
{"role": "user", "content": x["query"]}
]
)
generation_chain = (
RunnablePassthrough()
| build_messages
| generation_llm
)
final_response = generation_chain.invoke(
{"query": args["query"]}
).content
print("質問:", user_question)
print("回答:", final_response)
1 データ準備とテキスト分割
本実装では、LangChainのLoader(TextLoader / UnstructuredLoader など)は使用していません。 対象データは、あらかじめ整形されたテキストファイルをPython標準のファイル読み込みで処理しています。
これは、単なるテキスト分割ではなく、 各行に「話番号・題目・行番号」といった構造化情報が埋め込まれており、 正規表現によって意味単位で明示的にパースするためです。
この処理により、本文テキストだけでなく、
後段の検索ロジックで使用する theme や section_id を
metadata として同時に生成しています。
LangChainのLoaderは汎用的な文書読み込みに適していますが、 今回のように 独自フォーマットを前提とした構造化パースが必要な場合は、 アプリケーション側で明示的に処理したほうが設計上分かりやすくなります。
将来的にMarkdownやPDFなど複数形式を扱う場合には、 Loaderの導入を検討する余地がありますが、 本稿ではコードの意味を変えないことを優先し、あえて使用していません。
2 EmbeddingsとVector Store
チャンクをEmbeddingモデルで数値ベクトル化し、ベクトルデータベース(例:Chroma, FAISSなど)に格納します。検索対象として扱うための準備です。
本実装では、各チャンクをEmbeddingモデルによって数値ベクトル化し、 その結果をVector Storeとして Chroma DB に格納しています。 これは、後段の検索処理で類似度検索を行うための前処理です。
Embeddingの生成には nomic-embed-text を使用し、
LangChainの OllamaEmbeddings を通じてベクトル化しています。
この構成により、Embeddingモデルを将来的に差し替える場合でも、
検索ロジック側への影響を最小限に抑えられます。
LangChainは複数のVector DBに対応しているため、 将来的にデータ量が増えた場合や分散検索が必要になった場合に置き換えが容易になります。 本実装ではあくまで 現在のコードに沿った構成を採用しています。
3 検索ロジックの設計(Retrieverを使わない理由)
本実装では、LangChainのRetrieverクラスは使用していません。 検索処理はアプリケーション側の関数として明示的に定義しています。
これは、単純な類似度検索ではなく、 最初にヒットした文書の題目(theme)を基準に、 同じ話題に属する文脈をまとめ直す独自ロジックを含むためです。
検索回数や検索範囲を厳密に制御するため、 検索処理をChainの内部に隠さず、あえて外に切り出しています。
4 LLMへの統合
検索結果を用いた生成フェーズのみを、LangChainのChainとして構造化しています。
Chainの中では、
- 検索結果を含むプロンプトの組み立て
- 生成用LLM(gpt-oss:20b)の呼び出し
を直列につないでいます。
RunnablePassthroughはChainの起点として入力を受け取り、 RunnableLambdaで検索結果を埋め込んだmessagesを構築し、 最終的に生成モデルへ渡す構成です。
まとめ
本稿で紹介した構成は、検索制御と生成品質を重視するRAG実装に適しています。
LangChainで構築したRAGは、チャットボット、ドメインQ&A、生成支援ツールなど幅広いAIアプリケーションへの拡張が容易になります。
将来的には、LangChainのAgentフレームワークを活用して、より動的な検索・生成制御を実現することも可能です。




