Python & AI Tutorials Logo
LangChain & LangGraph

9. 最初のRAGシステムの構築

これまで構築してきたすべてのアプリは、LLMの事前学習された知識のみに依存していました。そのため、LLMが学習したことのない情報、例えば社内の内部文書や製品マニュアルについての質問には答えられませんでした。

RAG(Retrieval-Augmented Generation) はこの問題を解決します。ユーザーの質問が来ると、まず関連するドキュメントを検索し、次に検索されたコンテンツを質問と一緒にLLMに渡すことで、そのコンテンツに基づいて回答できるようにします。LLMの推論能力とドキュメントの知識を組み合わせているのです。

この章では、ドキュメントの準備(読み込み、チャンク化、埋め込み)から検索ベースの回答生成まで、完全なRAGパイプラインを構築します。完成したシステムは、質問が来ると関連するドキュメントを検索し、それらを質問と一緒にLLMに渡すことで、そのドキュメントコンテンツに基づいて回答します。情報がドキュメントにある場合は正確に答え、ない場合は正直に「わかりません」と言います。これが信頼できるRAGの本質です。

9.1) RAGの理解

9.1.1) 問題: LLMはあなたのデータを知らない

LLMはWikipedia、ニュース記事、公開コードなどのインターネットデータで学習されています。社内の内部文書や昨日受け取った契約書については知りません。そのため、次のような質問には答えられません:

  • 「当社の休暇ポリシーは何ですか?」
  • 「今四半期の売上レポートを要約してください」
  • 「今受け取った契約書の返金条件は何ですか?」
python
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
# LLMが見たことのないプライベートドキュメントについて質問する
response = llm.invoke("Acme Corpの返金ポリシーは何ですか?")
print(response.content)

出力:

Acme Corpの返金ポリシーに関する具体的な情報は持っていません。最も正確で最新の情報については、
公式ウェブサイトを確認するか、カスタマーサポートチームに直接お問い合わせいただくことをお勧めします。

この例では、LLMは正直に知らないと言っています。(または、もっともらしく聞こえる回答を幻覚する可能性もあります。)

しかし、返金ポリシーのドキュメントを質問と一緒に提供したらどうでしょうか? LLMは提供されたコンテンツに基づいて正確な回答を提供するでしょう。これがRAGの背後にある核心的なアイデアです。

9.1.2) ドキュメントをどのように提供すべきか?

最も単純なアプローチは、ドキュメント全体をプロンプトにコピー&ペーストすることです。これは実際、短いドキュメントに対してはうまく機能します。

python
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
# 実際にはこれよりもはるかに長いですが、以下がドキュメント全体であると仮定しましょう
document_text = """
返金ポリシー(2026年1月発効):
- 購入から30日以内であれば、元のレシートがあれば全額返金。
- 30日後は、ストアクレジットのみ。
- デジタル製品はダウンロード後は返金不可。
- 欠陥品はいつでも全額返金可能。
"""
 
response = llm.invoke(
    f"""次のドキュメントに基づいて回答してください:
{document_text}
 
質問: デジタル製品の返金ポリシーは何ですか?"""
)
print(response.content)

出力:

デジタル製品はダウンロード後は返金不可です。
...

これは短いドキュメントに対してはうまく機能します。しかし、ドキュメントが非常に大きい場合はどうでしょうか? これは次のような深刻な問題を引き起こします:

1. コンテキストウィンドウの制限: LLMには一度に処理できるトークン数に制限があります。GPT-5-miniの場合、40万トークンです。しかし、会社全体のドキュメントはこれを簡単に超える可能性があります。収まったとしても、コンテキストが長くなるにつれて応答が遅くなり、精度が低下します。

2. コスト: LLM APIはトークンごとに課金されます。実際に必要なのは1つか2つの段落だけなのに、ドキュメント全体を送信するとコストが急増します。

3. 精度の低下: ドキュメント全体を含めると、実際に必要な情報が無関係なコンテンツに埋もれてしまいます。LLMの注意が無関係な情報に向けられ、回答の質が低下します。

RAGは、ドキュメントの関連する部分のみを検索して提供することで、これら3つの問題すべてを解決します。

9.1.3) 核心的なアイデア: 関連する部分を検索して質問と一緒に提供する

RAGの本質はシンプルです: 質問をLLMに送信する前に、まずドキュメントの関連する部分を見つけて、質問と一緒に提供します。

仕組みは次のとおりです:

  1. ユーザーが質問をする。
  2. システムがドキュメントストアから関連するコンテンツを検索(Retrieval)する。
  3. 検索されたコンテンツが質問と一緒にプロンプトに追加(Augmentation)される。
  4. LLMが検索されたコンテンツに基づいて回答を生成(Generation)する。

これら3つのステップがRAG(Retrieval-Augmented Generation)という名前の由来です。

9.1.4) 関連するコンテンツをどのように検索するか? (キーワードマッチングの限界)

検索ステップはRAGにとって重要です。適切な回答を得るには、関連するコンテンツを提供する必要があります。では、質問に関連するコンテンツをどのように検索するのでしょうか?

最も単純な方法はキーワードマッチングです: 質問の単語を含むドキュメントを見つけます。例えば、誰かが「デジタル製品の返金ポリシーは何ですか?」と尋ねた場合、「返金」「デジタル」「製品」という単語を含むドキュメントを検索します。

しかし、キーワードマッチングには重大な弱点があります: 完全な単語の一致しか見つけられません。

次のような内容の返金ポリシードキュメントがあるとしましょう:

「購入から30日以内であれば全額返金可能。」

ユーザーが「お金を返してもらうにはどうすればいいですか?」と尋ねたらどうなるでしょうか? このドキュメントは検索されません。ドキュメントには「お金を返してもらう」というフレーズが含まれていません。人間は「返金」と「お金を返してもらう」が同じ意味であることを理解しますが、キーワード検索は単語のみを一致させるため、見つけることができません。

キーワード検索は単語のみを一致させます。意味が同じでも、単語が異なれば見つけられません。

解決策はセマンティック検索です。そしてそれを可能にするのが埋め込みです。

9.1.5) 埋め込み: テキストを数値ベクトルに変換する

埋め込み(Embedding) はテキストの意味を数値のリスト(ベクトル)として表現します。埋め込みモデルにテキストを入力すると、数百から数千の数値のベクトルに変換されます。

python
from langchain_openai import OpenAIEmbeddings
 
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# 単一の文を埋め込む
vector = embeddings_model.embed_query("お金を返してもらうにはどうすればいいですか?")
 
print(f"ベクトルの次元数: {len(vector)}")
print(f"最初の5つの値: {vector[:5]}")

出力:

ベクトルの次元数: 1536
最初の5つの値: [0.0123, -0.0456, 0.0789, -0.0234, 0.0567]

次元数はベクトルを構成する値の数です。text-embedding-3-smallモデルはすべてのテキストを1,536個の数値として表現します。

なぜこれほど多くの数値が必要なのでしょうか? 各次元が意味の異なる側面を捉えているからです:

  • いくつかの次元は「動作/状態」を区別するかもしれません
  • 他の次元は「具体的/抽象的」の度合いを表すかもしれません
  • さらに他の次元は「肯定的/否定的」な感情を示すかもしれません
  • ... (1,536個の意味的特徴—ただし、各次元が実際に何を表しているかは解釈できません)

2次元座標(x, y)が平面上の点を表すように、1,536次元のベクトルは1,536次元の「意味空間」における点を表します。次元が多いほど、意味のより細かい区別が可能になります。

似た意味は意味空間で近くに配置されます。 「返金方法」と「お金を返してもらう」は異なる単語を使用していますが、似た意味を持つため、意味空間で近くに配置されます。

9.1.6) セマンティック検索: 似た意味、近い距離

ドキュメントとクエリの両方をベクトルに変換したら、ベクトル間の類似度を測定することで最も関連性の高いドキュメントを見つけることができます。これはセマンティック検索と呼ばれます—キーワードマッチングではなく、意味的類似性による検索です。

最も一般的な類似度の尺度はコサイン類似度で、2つのベクトル間の角度を測定します。ベクトルが似た方向を向いている場合、類似度は高くなります。1.0に近いほど非常に似た意味を持ち、0に近いほど関連性が低いことを意味します。

返金期間は何ですか?

[0.12, -0.45, 0.78, ...]

購入から30日以内であれば全額返金可能。

[0.14, -0.42, 0.80, ...]

当社のオフィスはシアトルにあります

[-0.67, 0.33, -0.11, ...]

近い!
(類似度 ≈ 0.6415)

離れている
(類似度 ≈ 0.1706)

実際に計算してみましょう:

python
from langchain_openai import OpenAIEmbeddings
import numpy as np
 
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# クエリと2つの候補ドキュメントを埋め込む
query_vec = embeddings_model.embed_query("返金期間は何ですか?")
doc1_vec = embeddings_model.embed_query("購入から30日以内であれば全額返金可能。")  # 関連
doc2_vec = embeddings_model.embed_query("当社のオフィスはシアトルのダウンタウンにあります。")  # 無関係
 
def cosine_similarity(a, b):
    """2つのベクトル間のコサイン類似度を計算します。"""
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
 
sim1 = cosine_similarity(query_vec, doc1_vec)
sim2 = cosine_similarity(query_vec, doc2_vec)
 
print(f"クエリ vs '返金'ドキュメント:    {sim1:.4f}")
print(f"クエリ vs 'オフィス'ドキュメント:   {sim2:.4f}")

出力:

クエリ vs '返金'ドキュメント:    0.6415
クエリ vs 'オフィス'ドキュメント:   0.1706

(実際の値はモデルによって異なる場合があります)

返金ドキュメントのスコアがはるかに高くなっています。埋め込みモデルは「返金期間」と「30日以内に全額返金」が意味的に関連していることを理解しています。これがセマンティック検索であり、RAGの核心的なメカニズムです。

9.1.7) RAGパイプラインの概要

これまで学んだ概念を組み合わせると、次のRAGパイプラインが作成されます:

フェーズ2: 検索と回答生成

フェーズ1: 知識の取り込み

📄 完全なドキュメント

✂️ チャンクに分割

🔢 チャンクを埋め込む

ベクトルストア

❓ ユーザーの質問

🔢 質問を埋め込む

🔍 類似度検索

📋 関連チャンクを抽出

📝 プロンプト拡張
(質問 + 関連チャンク)

🤖 LLM

関連チャンクに基づく回答

パイプラインは2つのフェーズで構成されています:

知識の取り込み(最初に一度、またはドキュメントが変更されたときに実行):

  1. ドキュメント読み込み: さまざまなソース(Markdown、PDFなど)からテキストデータを抽出します。
  2. テキスト分割(チャンク化): 長いドキュメントを小さなチャンクに分割して、検索精度を向上させ、LLM入力制限に準拠します。
  3. ベクトル変換(埋め込み): 埋め込みモデルを使用してチャンクを意味ベースの数値ベクトルに変換します。
  4. ベクトル保存: 変換されたベクトルと元のテキストをベクトルデータベースに保存します(インデックス化)。

検索と回答生成(各ユーザーの質問に対して実行):

  1. 質問の埋め込み: 取り込み時に使用したのと同じモデルを使用して、ユーザーの質問を数値ベクトルに変換します。
  2. 類似度検索(検索): ベクトルデータベースから質問ベクトルに意味的に最も近い上位K個のチャンクを抽出します。
  3. プロンプト拡張: 元の質問と検索されたチャンクを組み合わせてプロンプトを拡張します。
  4. 回答生成: LLMが提供されたチャンクを参照して、根拠に基づく回答を生成します。

9.2) ドキュメントの読み込みとチャンク化

このセクションでは、RAGパイプラインの知識の取り込みフェーズの最初の2つのステップをカバーします:

  1. ドキュメント読み込み: ファイルからテキストデータを読み取る
  2. テキスト分割(チャンク化): テキストデータを小さく検索可能な部分に分割する

次のセクション(9.3)では、これらのチャンクをベクトルに変換して保存する方法を学びます。

9.2.1) ファイルからドキュメントを読み込む

RAGパイプラインの最初のステップは、ドキュメントをPythonオブジェクトに読み込むことです。LangChainはドキュメントローダーを提供しています—さまざまなファイル形式をサポートするクラスです。主なローダーは次のとおりです:

  • TextLoader: プレーンテキスト(.txt)とMarkdown(.md)ファイル
  • PyPDFLoader: PDF(.pdf)ファイル、ページごとに読み込まれます
  • CSVLoader: CSV(.csv)ファイル、各行が個別のドキュメントとして読み込まれます
  • UnstructuredMarkdownLoader: Markdown(.md)ファイル、構造を認識します(ヘッダー、リストなど)

どのローダーを使用しても、結果は常にDocumentオブジェクトのリストとして返されます。各Documentには2つの重要な属性があります:

  • page_content: ドキュメントのテキストコンテンツ
  • metadata: ファイルパスやページ番号などのメタ情報を含む辞書

このチュートリアルでは、TextLoaderを使用してMarkdownファイルを読み込みます。

サンプルドキュメントの準備

まず、作業するサンプルドキュメントを作成しましょう。プロジェクトにdata/docs/フォルダを作成し、次のファイルを追加します:

bash
mkdir -p data/docs

data/docs/refund_policy.mdを作成:

markdown
# 返金ポリシー
 
**発効日**: 2026年1月1日
 
## 標準返品
 
すべての物理製品は、購入から30日以内であれば全額返金で返品できます。
元のレシートまたは注文確認メールが必要です。商品は元のパッケージに入っており、
未使用の状態である必要があります。
 
30日後は、ストアクレジットのみで返品を受け付けます。ストアクレジットに有効期限はありません。
 
## デジタル製品
 
デジタル製品(ソフトウェアライセンス、電子書籍、オンラインコース)は、
ダウンロードまたはアクセスリンクがアクティブ化された後は返金不可です。
アクセスを妨げる技術的な問題が発生した場合は、7日以内にサポートに連絡して
交換または返金を受けてください。
 
## 欠陥品
 
欠陥品はいつでも全額返金または交換で返品できます。
欠陥の説明を含めてください。欠陥品の返品にかかる送料は
会社が負担します。
 
## サブスクリプションサービス
 
月額サブスクリプションはいつでもキャンセルできます。返金は
請求サイクルの残り日数に基づいて日割り計算されます。年間サブスクリプションは
最初の14日以内であれば全額返金できます。14日後は返金はありませんが、
請求期間の終了までアクセスは継続されます。

data/docs/shipping_info.mdを作成:

markdown
# 配送情報
 
## 国内配送
 
標準配送(5-7営業日): 50ドル以上の注文は無料、それ以外は5.99ドル。
速達配送(2-3営業日): 12.99ドル。
翌日配送(翌営業日): 24.99ドル。
 
## 国際配送
 
国際注文は追跡可能な航空便で発送されます。配達時間は
目的地によって異なり、通常10-21営業日です。国際配送料は
重量と目的地に基づいてチェックアウト時に計算されます。
 
関税と輸入税は購入者の責任であり、配送料には含まれていません。
 
## 注文追跡
 
すべての注文には、発送から24時間以内にメールで送信される追跡番号が含まれます。
メール内の追跡リンクまたは配送業者のウェブサイトから注文を追跡できます。
 
## 紛失または破損した荷物
 
荷物が紛失したり破損して到着した場合は、48時間以内にサポートに連絡してください。
追加費用なしで交換品を発送します。破損した商品については、
破損と梱包の写真を提供してください。

次に、TextLoaderを使用してこれらのファイルを読み込みます:

python
from pathlib import Path
from langchain_community.document_loaders import TextLoader
 
# data/docsディレクトリからすべての.mdファイルを読み込む
docs_dir = Path("data/docs")
 
for md_file in docs_dir.glob("*.md"):
    loader = TextLoader(str(md_file), encoding="utf-8")
    docs = loader.load()
 
    if docs:  # ファイルが空でないことを確認
        doc = docs[0]  # 単一ファイル = 単一Document
        print(f"ファイル: {doc.metadata['source']}")
        print(f"長さ: {len(doc.page_content)} 文字")
        print(f"プレビュー: {doc.page_content[:80]}...")
        print()

出力:

ファイル: data/docs/refund_policy.md
長さ: 1166 文字
プレビュー: # 返金ポリシー
...
 
ファイル: data/docs/shipping_info.md
長さ: 972 文字
プレビュー: # 配送情報
...

注意: TextLoaderは単一のファイルパスを入力として受け取りますが、他のローダーとの一貫したインターフェースのためにList[Document]を返します。(例えば、PDFLoaderは複数のDocumentを返します—ページごとに1つ。)

9.2.2) ドキュメントをチャンクに分割する: チャンク化

上記の2つのドキュメントは、このチュートリアルのために意図的に短くしています。実際のアプリケーションでは、数百ページまたは数千ページの長さのドキュメントを扱うことがよくあります。ドキュメント全体を単一のベクトルとして埋め込むと、数千の概念が1つに圧縮されてしまい、実際に必要なものを正確に検索することが不可能になります。

チャンク化は、ドキュメントを小さく意味のある部分に分割するプロセスです。目標はシンプルです: ユーザーが質問をしたとき、回答に直接関連する特定の段落のみが検索されるべきであり、ドキュメント全体ではありません。

チャンクサイズは検索と回答の質の両方に直接影響します:

  • 大きすぎる: 複数のトピックが1つのチャンクに混在し、埋め込みの精度が低下し、検索が困難になります。適切なチャンクが見つかっても、無関係なコンテンツがLLMに渡され、回答の質が低下します。
  • 小さすぎる: LLMが正しく回答するのに十分な情報を受け取れない可能性があります。例えば、「標準配送は5.99ドルです」という文だけが検索された場合、LLMはこれが50ドル未満の注文にのみ適用されることを知ることができません。
  • ちょうど良い: 各チャンクが十分なコンテキストを持つ1つのトピックをカバーし、正確な検索と回答を可能にします。

9.2.3) チャンクサイズとオーバーラップの制御

ドキュメントをチャンクに分割するには、テキストスプリッターが必要です。テキストスプリッターは、長いドキュメントを小さな部分に分割するLangChainツールです。適切なスプリッターを選択することが重要です。

  • RecursiveCharacterTextSplitter: 階層的な順序で複数のセパレーターを試して、できるだけ多くのコンテキストを保持します。一般的な目的で最も広く使用されているスプリッターです。
  • CharacterTextSplitter: 単一のセパレーター(デフォルト: \n\n)で分割します。シンプルな構造のドキュメントに適しています。
  • MarkdownHeaderTextSplitter: Markdownヘッダー(###)で分割します。ドキュメントの目次構造を保持したい場合に効果的です。

なぜRecursiveCharacterTextSplitterが効果的なのか?

このスプリッターは、最大から最小の単位までセパレーターを試して、最適な分割点を見つけることで機能します。デフォルトの順序は次のとおりです(separatorsパラメータで変更可能):

段落(\n\n) → 改行(\n) → 単語( )

常に最大の意味のある単位で最初に分割しようとします。段落がchunk_sizeを超える場合は、改行、次に単語にフォールバックします。単語の途中で恣意的に切断するのではなく、常に最も自然な分割点を見つけるため、結果のチャンクは意味的に完全な情報を含む可能性が高くなります。

主要なパラメータ

  • chunk_size: チャンクあたりの最大文字数。例えば、chunk_size=400は、どのチャンクも400文字を超えないことを意味します。
  • chunk_overlap: 隣接するチャンク間の重複文字数。例えば、chunk_overlap=80は、1つのチャンクの最後の80文字が次のチャンクの先頭で繰り返されることを意味します。
  • separators: テキストを分割するために使用されるセパレーターのリストで、優先順位順に試されます。現在のセパレーターで分割するとchunk_sizeを超える場合、chunk_sizeを超えないように次のセパレーターが試されます。

オーバーラップとは何か、なぜ必要なのか?

オーバーラップとは、隣接するチャンクがいくつかのコンテンツを共有することを意味します—1つのチャンクの終わりが次のチャンクの始まりに含まれます。

この理由は、各チャンクが十分なコンテキストを持って単独で成立できるようにするためです。以前に何があったかの知識なしにドキュメントの一部を読むと、なぜ特定のコンテンツが言及されているのか理解するのが難しい場合があります。オーバーラップは、1つのチャンクの終わりが次のチャンクに流れ込むようにするため、どのチャンクが検索されても、コンテンツが自然に読めます。

📄 元のドキュメント
段落1 | 段落2 | 段落3 | 段落4

✂️ 分割

📋 チャンク1

段落1

📋 チャンク2

段落1の終わり
+ 段落2

📋 チャンク3

段落2の終わり
+ 段落3

📋 チャンク4

段落3の終わり
+ 段落4

では、実際にドキュメントを分割してみましょう:

python
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
 
# ドキュメントを読み込む
loader = TextLoader("data/docs/refund_policy.md", encoding="utf-8")
docs = loader.load()
 
# スプリッターを設定する
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " "],
)
 
chunks = text_splitter.split_documents(docs)
 
print(f"{len(chunks)}個のチャンクに分割されました\n")
for i, chunk in enumerate(chunks):
    print(f"--- チャンク{i} (ソース: {chunk.metadata['source']}) ---")
    print(f"長さ: {len(chunk.page_content)} 文字")
    print(chunk.page_content[:120])
    print()

出力:

4個のチャンクに分割されました
 
--- チャンク0 (ソース: data/docs/refund_policy.md) ---
長さ: 374 文字
# 返金ポリシー
...
 
--- チャンク1 (ソース: data/docs/refund_policy.md) ---
長さ: 267 文字
## デジタル製品
...
 
--- チャンク2 (ソース: data/docs/refund_policy.md) ---
長さ: 204 文字
## 欠陥品
...
 
--- チャンク3 (ソース: data/docs/refund_policy.md) ---
長さ: 315 文字
## サブスクリプションサービス
...

注意: 上記の例では、オーバーラップは発生しませんでした。これは、各段落が最初のセパレーター(\n## )に基づいてchunk_size内にきれいに分割されたためです。オーバーラップは、特定の段落がchunk_sizeより長く、2つ以上の部分に分割する必要がある場合にのみ発生します。

9.3) ChromaDBを使用したベクトル保存と検索

9.3.1) ベクトルストアとは何か?

ベクトルストア(ベクトルデータベースとも呼ばれます)は、埋め込みベクトルを使用してデータを保存および検索するために最適化されたデータベースです。正確なフィールド値でクエリする従来のデータベース(SELECT * FROM products WHERE category = 'electronics')とは異なり、ベクトルストアはクエリに最も似た意味を持つアイテムを見つけます。

RAGでは、ベクトルストアはドキュメントチャンクとその埋め込みを保持します。ユーザーが質問をすると、質問がベクトルに変換され、ベクトルストアが最も似たベクトルを持つチャンクを検索します。

9.3.2) ベクトルストアの選択とChromaDBのセットアップ

人気のあるベクトルストアには、ChromaDBPineconeWeaviatepgvector(PostgreSQL拡張機能)があります。これらは、ホスティングモデル(ローカルvsクラウド)、スケール、運用の複雑さが異なります。この本では、ChromaDBを使用します—オープンソースで、サーバーセットアップなしでローカルマシンで完全に実行でき、開発だけでなく中小規模の本番ワークロードにも役立ちます。

ChromaDBはいくつかの方法で使用できます:

  • ローカルモード(pip): Pythonライブラリとしてインストールし、すぐに使用できます。別のサーバーインフラストラクチャなしで、ローカルディレクトリにデータを保存およびロードできます。
  • スタンドアロンサーバー(Docker): ChromaDBを別のサーバープロセスとして実行します。複数のアプリケーションが同じベクトルストアを共有する必要がある場合に便利です。
  • マネージドクラウドサービス(Chroma Cloud): ChromaDBをクラウドサービスとして使用します。Chroma Cloudはホスティング、スケーリング、メンテナンスを処理し、インフラストラクチャ管理の負担なしで安定したサービスを提供できます。

pipを使用してChromaDBをインストールしましょう:

bash
pip install chromadb langchain-chroma

chromadbはコアベクトルストアライブラリで、langchain-chromaはLangChainライブラリ内でChromaDBを直接使用できるようにする統合パッケージです。

9.3.3) 埋め込みモデルの選択

最初に決定すべきことは、どの埋め込みモデルを使用するかです。埋め込みベクトルは、同じモデルによって生成された場合にのみ比較できます。したがって、ドキュメントの保存とクエリの両方に同じ埋め込みモデルを使用する必要があります。

OpenAIは次の埋め込みモデルを提供しています:

モデル次元数備考
text-embedding-3-small1536品質とコストのバランスが良い
text-embedding-3-large3072より高品質、より高コスト

この本では、OpenAIのtext-embedding-3-smallモデルを使用します。低コストで高効率を提供し、一般的な検索、RAG、コストを意識したプロジェクトに実用的な選択肢です。

python
from langchain_openai import OpenAIEmbeddings
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# 動作することを確認する
test_vector = embedding_model.embed_query("test")
print(f"埋め込みの次元数: {len(test_vector)}")

出力:

埋め込みの次元数: 1536

コストに関する注意: 埋め込みAPI呼び出しはLLM呼び出しよりもはるかに安価ですが、コストは発生します。ドキュメントをデータベースに保存する(インデックス化)とき、チャンクごとに1回のAPI呼び出しが必要で、ユーザーが質問をする(検索)とき、質問に対して1回のAPI呼び出しが必要です。現在の価格については、OpenAI価格ページを参照してください。

9.3.4) ChromaDBにチャンクを保存する

では、すべてをまとめましょう。ドキュメントを読み込み、チャンクに分割し、チャンクを埋め込み、埋め込みベクトルと一緒にChromaDBに保存します。

python
# ingest.py - 完全な取り込みパイプライン
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# ステップ1: ドキュメントを読み込む
# DirectoryLoader: ディレクトリをスキャンし、一致するファイルを読み込みます。
# 実際の読み込みは、loader_clsで指定されたローダーに委任されます。
loader = DirectoryLoader(
    "data/docs/", glob="**/*.md",
    loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"{len(documents)}個のドキュメントを読み込みました")
 
# ステップ2: チャンクに分割する
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"{len(chunks)}個のチャンクを作成しました")
 
# ステップ3: 埋め込みモデルを作成する
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
# ステップ4: ベクトルストアを作成してチャンクを取り込む
vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=embedding_model,
    persist_directory="data/chroma_db",
    collection_name="company_docs",
)
 
print(f"{len(chunks)}個のチャンクをdata/chroma_db/のChromaDBに保存しました")

出力:

2個のドキュメントを読み込みました
8個のチャンクを作成しました
8個のチャンクをdata/chroma_db/のChromaDBに保存しました

Chroma.from_documents()メソッドは、1回の呼び出しで2つのタスクを実行します:

  1. documentsパラメータを介して提供されたチャンクを埋め込みモデルに渡して、埋め込みベクトルを取得します。
  2. 各チャンクを埋め込みベクトルと一緒にChromaDBに保存します。

9.3.5) 永続化されたベクトルストアの読み込み

前のセクションでは、ドキュメントをベクトルストアに保存しました。この保存操作は、最初に一度だけ(またはドキュメントが変更されたときに)実行する必要があります。その後は、永続化されたベクトルストアを読み込んで直接使用できます。

python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# 永続化されたベクトルストアを読み込む—再埋め込みは不要
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
print(f"{len(vector_store.get()['ids'])}個のチャンクを持つベクトルストアを読み込みました")

出力:

8個のチャンクを持つベクトルストアを読み込みました

これで、ドキュメントを再埋め込みする必要なく、永続化されたベクトルストアを読み込むだけですぐに検索を開始できます。

9.3.6) 類似度検索

ベクトルストアが読み込まれたので、クエリに意味的に似たチャンクを検索できます。top-Kパラメータは、返す結果の数を指定します:

python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
# 質問に関連するチャンクを検索する
query = "デジタル製品を返品できますか?"
results = vector_store.similarity_search(query, k=2)
 
print(f"クエリ: {query}")
print(f"{len(results)}件の結果が見つかりました\n")
 
for i, doc in enumerate(results):
    print(f"--- 結果{i + 1} (ソース: {doc.metadata['source']}) ---")
    print(doc.page_content[:200])
    print()

出力:

クエリ: デジタル製品を返品できますか?
2件の結果が見つかりました
 
--- 結果1 (ソース: data/docs/refund_policy.md) ---
## デジタル製品
 
...
 
--- 結果2 (ソース: data/docs/refund_policy.md) ---
# 返金ポリシー
 
**発効日**: 2026年1月1日
 
## 標準返品
 
...

このクエリでは、デジタル製品チャンクが最も高い類似度で検索され、次に返金ポリシーチャンクが続きました。

similarity_search_with_scoreを使用して、類似度スコアと一緒に結果を取得することもできます:

python
results_with_scores = vector_store.similarity_search_with_score(query, k=2)
 
for doc, score in results_with_scores:
    # ChromaDBは距離を返します(低いほど類似)
    print(f"スコア: {score:.4f} | ソース: {doc.metadata['source']}")
    print(f"  {doc.page_content[:200]}...")
    print()

出力:

スコア: 0.5942 | ソース: data/docs/refund_policy.md
  ## デジタル製品
 
...
 
スコア: 0.9577 | ソース: data/docs/refund_policy.md
  # 返金ポリシー
 
...

ChromaDBは距離スコア(低いほど類似)を使用し、類似度スコア(高いほど類似)ではないことに注意してください。デジタル製品チャンクの距離が0.5942で最も低く、最も関連性の高い結果です。

9.4) 完全なRAGチェーンの構築

次に、完全なRAGシステムを構築します: まず関連するドキュメントを検索し、次にそれらを質問と一緒にLLMに渡して、提供された情報に基づいて回答を生成します。

9.4.1) プロンプトテンプレートの設計

プロンプトテンプレートの最も重要な部分は、提供されたコンテキストのみに基づいて回答するようLLMに指示することです。この指示がないと、LLMは検索結果を無視して、学習データに基づいて回答を捏造する可能性があります。

python
from langchain_core.prompts import ChatPromptTemplate
 
rag_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "あなたはカスタマーサービス担当者です。"
     "提供されたコンテキストのみを使用してユーザーの質問に答えてください。"
     "コンテキストに回答するのに十分な情報が含まれていない場合は、"
     "「その質問に答えるのに十分な情報がありません。」と言ってください。\n\n"
     "コンテキスト:\n{context}"),
    ("human", "{question}"),
])

システムメッセージは、提供されたコンテキストのみを使用して回答するようLLMに強制します。重要なのは、コンテキストが不十分な場合に「十分な情報がありません」と言うように指示することで、LLMがもっともらしいが裏付けのない回答を捏造するのを防ぎます。

9.4.2) RAGチェーンの構築

すべてのコンポーネントが準備できました。レトリーバー、プロンプトテンプレート、LLMを接続するだけです。

完成したRAGシステムは次のように動作します:

  1. ユーザーの質問を受け取る
  2. ベクトルストアから関連するチャンクを検索する
  3. チャンクと質問をプロンプトテンプレートに渡してプロンプトを生成する
  4. LLMで回答を生成する

context

question

ユーザーの質問

ベクトルストア検索

検索されたチャンク
(単一の文字列に結合)

プロンプト生成

LLM

回答

第6章のLCEL |演算子を使用してRAGチェーンを接続しましょう。

python
# rag_chain.py - 完全なRAGパイプライン
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
 
 
def format_docs(docs):
    """検索されたドキュメントを単一のコンテキスト文字列に結合します。"""
    return "\n\n---\n\n".join(doc.page_content for doc in docs)
 
 
def build_rag_chain():
    """完全なRAGチェーンを構築して返します。"""
    # ベクトルストアを読み込む
    embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
    vector_store = Chroma(
        persist_directory="data/chroma_db",
        collection_name="company_docs",
        embedding_function=embedding_model,
    )
 
    # レトリーバーを作成する(k=3は上位3つのチャンクを返すことを意味します)
    retriever = vector_store.as_retriever(search_kwargs={"k": 3})
 
    # プロンプトを定義する
    rag_prompt = ChatPromptTemplate.from_messages([
        ("system",
         "あなたはカスタマーサービス担当者です。"
         "提供されたコンテキストのみを使用してユーザーの質問に答えてください。"
         "コンテキストに回答するのに十分な情報が含まれていない場合は、"
         "「その質問に答えるのに十分な情報がありません。」と言ってください。\n\n"
         "コンテキスト:\n{context}"),
        ("human", "{question}"),
    ])
 
    # LLMを初期化する
    llm = ChatOpenAI(model="gpt-5-mini")
 
    # LCELを使用してチェーンを構成する
    rag_chain = (
        {"context": retriever | format_docs, "question": lambda x: x}
        | rag_prompt
        | llm
        | StrOutputParser()
    )
 
    return rag_chain
 
 
if __name__ == "__main__":
    chain = build_rag_chain()
    answer = chain.invoke("デジタル製品を返品できますか?")
    print(answer)

出力:

デジタル製品は、ダウンロードまたはアクセスリンクがアクティブ化された後は返金不可です。
アクセスを妨げる技術的な問題が発生した場合は、7日以内にサポートに連絡して交換または返金を受けてください。

チェーンの構成をステップバイステップで分解してみましょう:

python
rag_chain = (
    {"context": retriever | format_docs, "question": lambda x: x}
    | rag_prompt
    | llm
    | StrOutputParser()
)

chain.invoke("デジタル製品を返品できますか?")を呼び出すと、次のことが起こります:

  1. 辞書ステップ:
    • retriever | format_docs: 質問でベクトルストアを検索し、チャンクを単一の文字列に結合します
    • lambda x: x: 質問を変更せずに渡します
    • 結果: {"context": "検索されたチャンク(単一の文字列に結合)", "question": "デジタル製品を返品できますか?"}
  2. rag_prompt: プロンプトテンプレートの{context}{question}プレースホルダーを辞書の値で埋めます
  3. llm: 完成したプロンプトをLLMに送信します
  4. StrOutputParser(): LLMの応答からテキストのみを抽出します

LCELの動作の詳細については、第6章を参照してください。

9.4.3) 回答可能な質問と回答不可能な質問でのテスト

RAGシステムは、回答できる質問(情報がドキュメントに存在する)と回答できない質問(情報がドキュメントにない)の両方を処理する必要があります。両方のシナリオをテストしましょう:

python
# test_rag.py - さまざまな質問でRAGチェーンをテストする
from rag_chain import build_rag_chain
 
chain = build_rag_chain()
 
test_questions = [
    # 回答可能—情報がドキュメントにある
    "物理製品の返金ポリシーは何ですか?",
    "速達配送の料金はいくらですか?",
    "6か月後に欠陥品を返品できますか?",
    # 回答不可能—情報がドキュメントにない
    "従業員の休暇ポリシーは何ですか?",
    "どのプログラミング言語が使用されていますか?",
]
 
for question in test_questions:
    print(f"Q: {question}")
    answer = chain.invoke(question)
    print(f"A: {answer}\n")
    print("-" * 60)

出力:

Q: 物理製品の返金ポリシーは何ですか?
A: すべての物理製品は、購入から30日以内であれば全額返金で返品できます。...
 
------------------------------------------------------------
Q: 速達配送の料金はいくらですか?
A: 速達配送(2-3営業日)の料金は12.99ドルです。
 
------------------------------------------------------------
Q: 6か月後に欠陥品を返品できますか?
A: はい。欠陥品はいつでも全額返金または交換で返品できます。...
 
------------------------------------------------------------
Q: 従業員の休暇ポリシーは何ですか?
A: その質問に答えるのに十分な情報がありません。
 
------------------------------------------------------------
Q: どのプログラミング言語が使用されていますか?
A: その質問に答えるのに十分な情報がありません。
 
------------------------------------------------------------

結果は、まさに私たちが望む動作を示しています:

  • 回答可能な質問: 検索されたドキュメントに基づいて正確な回答を提供します。LLMはドキュメントに含まれていない情報を追加しません。
  • 回答不可能な質問: 「その質問に答えるのに十分な情報がありません。」と応答します。LLMは検索されたコンテキストに関連情報がないことを正しく識別し、回答を捏造することを拒否します。

これがRAGの力です。LLMはあなたのデータについての質問に答え、知らないときは正直に認めます。すべての回答はドキュメントに裏付けられており、標準的なLLMよりもはるかに信頼できるシステムになります。