Python & AI Tutorials Logo
LangChain & LangGraph

10. より賢い検索: フィルタ、しきい値、MMR

第9章では、シンプルなRAGシステムを構築しました。これは、ドキュメントをチャンクに分割し、ChromaDBに埋め込み、ユーザーの質問が来たときに関連するチャンクを検索してプロンプトに含めるというパイプラインでした。これにより、社内ドキュメントや製品マニュアルのような、LLMが学習したことのない情報についても質問に答えられるようになりました。すべてが問題なく動作しているように見えました。

しかし、より幅広い種類の質問をしてみると、弱点がすぐに浮かび上がってきます。消費者向けの返金ポリシーについて質問すると従業員ストアの内容が混ざり込んだり、どのドキュメントにも存在しない内容を質問するとLLMがもっともらしい答えをでっち上げたり、検索結果の件数を増やすと逆に回答品質が低下したりします。

この章では、これら3つの問題を1つずつ解決していきます。メタデータフィルタリングを使ってどのドキュメントを検索するかを制限し、類似度スコアのしきい値を使って質問と無関係な結果を除外し、KとMMRのチューニングを使ってLLMに渡すチャンクの量と多様性を改善します。新しいツールは必要ありません。すでに知っているsimilarity_search()Chromaベクトルストアの設定と使い方を洗練させていきます。

10.1) 私たちのRAGの何が問題なのか?

第9章では、ベクトルストアに2つのドキュメント — 返金ポリシーと配送ポリシー — だけを入れ、それぞれのドキュメントは別々のトピックを扱っていました。また、答えがドキュメント内に明確に存在するか、明確に存在しないかのいずれかの質問だけをテストしました。今回は、より現実的なシナリオを作成します。従業員ストアガイドをベクトルストアに追加します。このドキュメントにも返金関連の内容が含まれていますが、これは一般消費者向けではなく従業員向けのものです。そして、さまざまな質問をして、どのような問題が発生するか見てみましょう。

データ準備: 従業員ストアガイドの追加

参考までに、第9章の2つのドキュメントを以下に示します。

data/docs/refund_policy.md:

markdown
# Refund Policy
 
**Effective Date**: January 1, 2026
 
## Standard Returns
 
All physical products may be returned within 30 days of purchase for a full refund.
The original receipt or order confirmation email is required. Items must be in their
original packaging and unused condition.
 
After 30 days, returns are accepted for store credit only. Store credit does not expire.
 
## Digital Products
 
Digital products (software licenses, e-books, online courses) are non-refundable
once the download or access link has been activated. If you experience technical
issues preventing access, contact support within 7 days for a replacement or refund.
 
## Defective Items
 
Defective items may be returned at any time for a full refund or replacement.
Please include a description of the defect. Shipping costs for defective returns
are covered by the company.
 
## Subscription Services
 
Monthly subscriptions may be cancelled at any time. Refunds are prorated based on
the remaining days in the billing cycle. Annual subscriptions may be refunded in full
within the first 14 days. After 14 days, no refund is available but access continues until the end of the billing period.

data/docs/shipping_info.md:

markdown
# Shipping Information
 
## Domestic Shipping
 
Standard shipping (5-7 business days): Free on orders over $50, otherwise $5.99.
Express shipping (2-3 business days): $12.99.
Overnight shipping (next business day): $24.99.
 
## International Shipping
 
International orders are shipped via tracked airmail. Delivery times vary by
destination, typically 10-21 business days. International shipping costs are
calculated at checkout based on weight and destination.
 
Customs duties and import taxes are the responsibility of the buyer and are not included in the shipping cost.
 
## Order Tracking
 
All orders include a tracking number sent via email within 24 hours of shipment.
Track your order through the tracking link in your email or through the carrier's website.
 
## Lost or Damaged Packages
 
If your package is lost or arrives damaged, contact support within 48 hours.
We will ship a replacement at no additional cost. For damaged items, please
provide photos of the damage and packaging.

ここに従業員ストアガイドを追加します。

data/docs/employee_store.mdを作成します:

markdown
# Employee Store Guide
 
## Eligibility and Benefits
 
Employees can purchase company products at a 30% discount through the internal employee store.
The monthly purchase limit is $500, and payment can be made via payroll deduction or benefit points.
 
## Ordering and Shipping
 
Employee store orders are placed through the internal portal, and delivery is only available to the company address.
Orders are delivered within 3-5 business days, and shipping is free.
 
## Refund Policy
 
Refunds are available within 7 days of purchase for unopened items only.
Cash refunds are not available; refunds are credited as benefit points.
After opening, only exchanges are allowed, limited to one exchange per identical product.
 
## Contact
 
For employee store inquiries, please contact HR at hr@acme.com.

data/docs/ディレクトリには、refund_policy.mdshipping_info.mdemployee_store.mdの3つのファイルが含まれるようになりました。第9章のインジェスト用スクリプトを再実行して、ベクトルストアを再構築します。

python
# ingest.py — 第9章と同じインジェストパイプライン
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
loader = DirectoryLoader(
    "data/docs/", glob="**/*.md",
    loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"Loaded {len(documents)} documents")
 
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"Created {len(chunks)} chunks")
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=embedding_model,
    persist_directory="data/chroma_db",
    collection_name="company_docs",
)
print(f"Stored {len(chunks)} chunks in ChromaDB")

出力:

Loaded 3 documents
Created 12 chunks
Stored 12 chunks in ChromaDB

問題1: 無関係なドキュメントが検索結果に混ざる

返金条件を検索してみましょう。

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,
)
 
results = vector_store.similarity_search("What are the refund conditions?", k=3)
 
for i, doc in enumerate(results):
    source = doc.metadata["source"]
    print(f"Result {i+1} [{source}] {doc.page_content[:80]}...")

出力:

Result 1 [data/docs/refund_policy.md] ## Standard Returns
All physical products may be returned within 30 days of pur...
 
Result 2 [data/docs/employee_store.md] ## Refund Policy
Refunds are available within 7 days of purchase for unopened i...
 
Result 3 [data/docs/refund_policy.md] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...

Result 2を見てください。顧客が返金条件について質問したのに、従業員ストアの返金ポリシーが結果に含まれています。消費者向けの返金ポリシーは30日以内の全額返金を認めていますが、従業員ストアは未開封品に限り7日以内の返金しか認めず、返金は特典ポイントとして付与されます。両方のポリシーがまとめてLLMに渡されると、顧客に従業員専用の返金条件が提示されてしまう可能性があります。

問題2: 関連する内容が存在しない場合でも結果が返される

次に、私たちのドキュメントのどこにも存在しない内容について質問してみましょう。第9章で学んだsimilarity_search_with_score()を使って、距離の値も確認します。ChromaDBでは、距離の値が小さいほど類似度が高いことを意味します。

python
results = vector_store.similarity_search_with_score(
    "What is the hiring process at this company?", k=3
)
 
for doc, score in results:
    source = doc.metadata["source"]
    print(f"[dist={score:.4f}] [{source}] {doc.page_content[:80]}...")

出力:

[dist=1.4648] [data/docs/employee_store.md] ## Refund Policy
Refunds are available within 7 days of purchase for unopened i...
 
[dist=1.4699] [data/docs/employee_store.md] ## Eligibility and Benefits
Employees can purchase company products at a 30% di...
 
[dist=1.4743] [data/docs/refund_policy.md] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...

採用プロセスに関する情報はどこにもありません。距離の値はすべて1.4を超えており、非常に低い類似度を示しているにもかかわらず、similarity_search_with_score()は3つのチャンクを返しました。これらのチャンクがRAGチェーンに渡されたとき、どのような答えが生成されるか見てみましょう。

python
from rag_chain import build_rag_chain  # 第9章のRAGチェーン
 
chain = build_rag_chain()
answer = chain.invoke("What is the hiring process at this company?")
print(answer)

出力:

I don't have enough information to answer that question.

LLMは、答えるのに十分な情報がないと回答しました。これは、build_rag_chain()のシステムプロンプトに「十分な情報がない場合はそう言う」という指示を含めたためです。しかし、LLMが常にこの判断を下せるとは限りません。検索されたチャンクに質問と関連しているように見えるフレーズが含まれていると、LLMはその内容に基づいて誤った答えを生成してしまう可能性があります。

問題3: 検索結果を増やせば必ず役に立つのか?

「コンテキストが多い方がいいのでは?」と思うかもしれません。kを3から10に増やせば、必要なチャンクが含まれる可能性は確かに高まります。しかし同時に、無関係なチャンクもより多く入ってきます。LLMはこれらすべてのチャンクをコンテキストとして受け取り、そこから答えを生成するため、不要または誤った情報が回答に紛れ込むことがあります。コンテキストが多いことが、必ずしも良い答えを意味するわけではありません。

さらに、検索されたすべてのチャンクはトークンとしてLLMに渡されます。kが大きくなるにつれて、API呼び出しのコストが増加し、応答時間が遅くなります。

3つの問題を特定しました。それでは、1つずつ解決していきましょう。

10.2) メタデータフィルタリング: 検索空間を絞り込む

問題1で返金条件を検索したとき、顧客向けの返金ポリシーと従業員ストアの返金ポリシーが一緒に表示されました。これは、similarity_search()にどのドキュメントを検索するかを伝えていなかったために起こりました。

メタデータフィルタリングは、各チャンクにカテゴリ、ソース、発行年などの属性を付与し、類似度検索を実行する前にこれらの属性に基づいてチャンクをフィルタリングします。条件に一致するチャンクだけが類似度計算を通過します。これはSQLのWHERE句に似た役割を果たします。

10.2.1) ドキュメントの内容 vs ドキュメントのメタデータ

第9章で学んだDocumentオブジェクトには、2つのものが含まれています:

  • page_content: テキストそのものです。ベクトルに埋め込まれ、類似度検索が対象とするものです。
  • metadata: ソースやカテゴリなどの属性を保持する辞書です。これらの値は埋め込まれません。

類似度検索はpage_contentを対象とし、メタデータフィルタリングはmetadataの情報を対象とします。

10.2.2) ベクトルストアの再構築: メタデータの追加

フィルタリング用にcategory属性を追加して、ベクトルストアを再構築します。10.1のingest.pyにメタデータを割り当てるコードを追加するだけです。

python
# ingest_with_metadata.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: ドキュメントを読み込む (10.1と同じ)
loader = DirectoryLoader(
    "data/docs/", glob="**/*.md",
    loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"Loaded {len(documents)} documents")
 
# ステップ2: [新規] ファイル名に基づいてカテゴリのメタデータを割り当てる
CATEGORY_MAP = {
    "refund_policy.md": "customer",
    "shipping_info.md": "customer",
    "employee_store.md": "employee",
}
for doc in documents:
    filename = doc.metadata["source"].split("/")[-1]
    doc.metadata["category"] = CATEGORY_MAP.get(filename, "unknown")
 
# ステップ3: チャンクに分割する (10.1と同じ)
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"Created {len(chunks)} chunks")
 
# ステップ4: ベクトルストアを構築する (10.1と同じ)
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=embedding_model,
    persist_directory="data/chroma_db",
    collection_name="company_docs",
)
print(f"Stored {len(chunks)} chunks in ChromaDB")

出力:

Loaded 3 documents
Created 12 chunks
Stored 12 chunks in ChromaDB

元のingest.pyとの唯一の違いは、各ドキュメントにcategoryメタデータを追加したことです。

10.2.3) 検索にフィルタを適用する

これで、similarity_search()filterパラメータを使って、どのドキュメントを検索するかを制限できます。問題1と同じクエリで検索しますが、顧客向けのドキュメントだけを検索するようにフィルタを追加します。

python
results = vector_store.similarity_search(
    "What are the refund conditions?",
    k=3,
    filter={"category": "customer"},
)
 
for i, doc in enumerate(results):
    source = doc.metadata["source"]
    category = doc.metadata["category"]
    print(f"Result {i+1} [{category}] [{source}] {doc.page_content[:80]}...")

出力:

Result 1 [customer] [data/docs/refund_policy.md] ## Standard Returns
All physical products may be returned within 30 days of pur...
 
Result 2 [customer] [data/docs/refund_policy.md] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...
 
Result 3 [customer] [data/docs/refund_policy.md] ## Digital Products
Digital products (software licenses, e-books, online course...

問題1で混ざり込んでいた従業員ストアの返金ポリシーは、もう含まれていません。これは、category"customer"に等しいチャンクだけを検索するように指定したためです。

複数の条件を組み合わせる

上記の例では、単一の条件(category"customer"に等しい)を使用しました。複数の条件を同時に適用する必要がある場合は、$and$orのような論理演算子で組み合わせることができます。

python
# $and: すべての条件を満たすチャンクのみ
filter={
    "$and": [
        {"category": "customer"},
        {"source": "data/docs/refund_policy.md"},
    ]
}
 
# $or: いずれかの条件を満たすチャンク
filter={
    "$or": [
        {"source": "data/docs/refund_policy.md"},
        {"source": "data/docs/shipping_info.md"},
    ]
}

その他の演算子には、$ne(等しくない)、$gt(より大きい)、$lt(より小さい)などがあります。演算子の完全なリストについては、ChromaDBのドキュメントを参照してください。

10.3) 類似度しきい値: 関連性の低い結果を除外する

問題2では、採用プロセスについて質問したとき、距離の値が1.4を超えるチャンクが返されました。ChromaDBでは、これほど高い距離の値はほとんど関連性がないことを示します。それでもなお検索されてしまいました。これは、similarity_search()常にk件の結果を返すためです。

この問題は、距離のしきい値を設定することで解決できます。しきい値より遠い結果は、LLMに渡すコンテキストに含めません。

では、どのしきい値を設定すればよいのでしょうか? まず、ドキュメント内に関連する内容がある質問と、ない質問とで距離の値を比較してみましょう。

10.3.1) 距離の比較: 関連する内容がある質問とない質問

python
queries = [
    "Can I cancel a subscription service?",           # 関連する内容が存在する
    "What is the hiring process at this company?",     # 関連する内容がない
]
 
for query in queries:
    print(f"\nQuery: {query}")
    results = vector_store.similarity_search_with_score(query, k=1)
    for doc, score in results:
        print(f"  [dist={score:.4f}] {doc.page_content[:80]}...")

出力:

Query: Can I cancel a subscription service?
  [dist=0.7511] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...
 
Query: What is the hiring process at this company?
  [dist=1.4648] ## Refund Policy
Refunds are available within 7 days of purchase for unopened i...

関連する内容がある質問は距離の値が0.75前後ですが、関連する内容がない質問は1.4を超えています。このようにいくつかの質問をテストして、2つのケースを明確に分けられるしきい値を選びます。適切なしきい値は、埋め込みモデル、ドキュメントの性質、チャンクサイズによって変わることがあるため、自分のデータで直接テストして決定するのが最善です。

10.3.2) 距離のしきい値で検索結果をフィルタリングする

しきい値を設定したら、しきい値を超える結果を除外する関数を作成しましょう。フィルタを通過したチャンクだけが、LLMに渡すコンテキストに含まれます。しきい値以下のチャンクが残らない場合は、LLM呼び出しを完全にスキップして「その質問に答えるのに十分な情報がありません」と応答します。

python
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
def retrieve_or_abstain(query: str, max_distance: float = 1.0, k: int = 3):
    """距離のしきい値以下のチャンクのみを返します。1つも通過しない場合はNoneを返します。"""
    scored = vector_store.similarity_search_with_score(query, k=k)
    good = [doc for doc, dist in scored if dist <= max_distance]
    return good or None
 
def safe_answer(query: str) -> str:
    docs = retrieve_or_abstain(query)
    if docs is None:
        return "I don't have enough information to answer that question."
 
    context = "\n\n".join(d.page_content for d in docs)
    prompt = (
        "Answer the question using ONLY the context below.\n\n"
        f"Context:\n{context}\n\nQuestion: {query}"
    )
    return llm.invoke(prompt).content

先ほどと同じ質問でテストしてみましょう。

python
# 関連する内容がある質問
print(safe_answer("Can I cancel a subscription service?"))
print("---")
# 関連する内容がない質問
print(safe_answer("What is the hiring process at this company?"))

出力:

Yes. Monthly subscriptions may be cancelled at any time, and refunds are prorated
based on the remaining days in the billing cycle. Annual subscriptions may be refunded
in full within the first 14 days...
---
I don't have enough information to answer that question.

10.4) KとMMR: 検索結果の量と多様性を制御する

このセクションでは、kの値を変えると検索結果がどのように変化するかを直接比較し、結果の多様性を改善するMMR検索について学びます。

10.4.1) Kを増やすと何が起こるか?

問題3では、kを増やすと無関係なチャンクもより多く入ってくると述べました。これを検証してみましょう。k=10で検索し、各チャンクの距離の値を調べます。

python
results = vector_store.similarity_search_with_score(
    "What are the refund conditions?",
    k=10,
    filter={"category": "customer"},
)
 
for i, (doc, dist) in enumerate(results, 1):
    source = doc.metadata["source"].split("/")[-1]
    print(f"{i:>2}. [dist={dist:.4f}] [{source}] {doc.page_content[:80]}...")

出力:

 1. [dist=0.7798] [refund_policy.md] ## Standard Returns
**Effective Date**: January 1, 2026
All physical products ma...
 
 2. [dist=0.8203] [refund_policy.md] ## Defective Items
Defective items may be returned at any time for a full refun...
 
 3. [dist=0.9353] [refund_policy.md] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...
 
 4. [dist=1.4249] [shipping_info.md] ## Lost or Damaged Packages
If your package is lost or arrives damaged, contact...
 
 5. [dist=1.5516] [shipping_info.md] ## Domestic Shipping
Standard shipping (5-7 business days): Free on orders over...
...

上位3件の結果は距離が1.0未満で、すべて返金に関連しています。4件目からは距離が1.4を超えて跳ね上がり、配送情報のような返金条件とは無関係なチャンクが現れ始めます。k=10では、これらすべてのチャンクがLLMに渡されます。

kを増やすことのコストは以下のとおりです:

  • ノイズ: 下位のチャンクは質問とまったく無関係な場合があります。そのようなチャンクがプロンプトに含まれると、LLMは不要または誤った情報を回答に含めてしまう可能性があります。
  • コストの増加: チャンクが増えるとLLMに送るトークンが増え、API呼び出しのコストが増加します。
  • 応答の遅延: 処理するトークンが増えると、応答時間が長くなります。

10.4.2) MMR: 関連性と多様性の両立

実際の環境では、ドキュメントのコレクションが大きくなるにつれて、似た内容の複数のチャンクが現れることがよくあります。隣接するチャンクが一部の内容を共有するようにした第9章のchunk_overlap設定も、重複の原因になります。このような場合、k=3で検索しても、ほぼ同一の3つのチャンクが返ってくることがあります。

MMR (Maximum Marginal Relevance) は、結果が同じ内容に偏るのを防ぐ検索手法です。標準的な類似度検索はクエリに最も近いk個のチャンクを返すため、似たチャンクが上位に固まってしまうことがあります。MMRは、クエリに関連していて、かつすでに選択された結果とは異なるチャンクを優先します。

その仕組みは以下のとおりです:

  1. 標準的な類似度検索と同様に、まずクエリに最も近いfetch_k個の候補チャンクを取得します。
  2. クエリに最も近いチャンクを最初の結果として選択します。
  3. 残りの候補から、クエリに関連していて、かつすでに選択されたチャンクとは内容が異なる次のチャンクを選択します。
  4. k個のチャンクが選択されるまでステップ3を繰り返します。

その結果、関連性を保ちながら内容の重複を避けたチャンクの集合が得られます。

python
results_mmr = vector_store.max_marginal_relevance_search(
    "What are the refund conditions?",
    k=3,
    fetch_k=10,
)
 
for i, doc in enumerate(results_mmr):
    print(f"{i+1}. {doc.page_content[:80]}...")

出力:

1. ## Standard Returns
All physical products may be returned within 30 days of pur...
 
2. ## Defective Items
Defective items may be returned at any time for a full refun...
 
3. ## Digital Products
Digital products (software licenses, e-books, online course...

現在のデータでは、データセットが小さいため標準検索との大きな違いはありません。しかし、ドキュメントが数百、数千に増えると、似たチャンクが結果の上位に頻繁に固まるようになり、ここでMMRが非常に役立ちます。fetch_kはMMRが選択対象とする候補プールのサイズで、10〜20から始めるのが一般的なアプローチです。

10.4.3) いつチューニングをやめるか

調整すべきパラメータは複数あります: kfetch_k、メタデータフィルタ、距離のしきい値など。次のシンプルなルールに従うと、効率的にチューニングできます:

  1. いくつかの質問を用意します — ドキュメント内に関連する内容があるものと、ないものを準備します。
  2. 質問を実行し、検索されたチャンクを直接調べます。
  3. 問題が見つかったら、一度に1つのパラメータだけを変更し、同じ質問で再テストします。

関連する内容がある質問が正しい答えを生成し、関連する内容がない質問が回答の保留につながれば、基準となる品質を達成できています。最初から設定を完璧にするのは簡単ではありません。実際の使用中に発見された問題に対応し、段階的に改善していきましょう。