10. 更智能的检索:过滤器、阈值与 MMR
在第 9 章中,我们构建了一个简单的 RAG 系统。它是一个流水线,将文档切分成块,嵌入到 ChromaDB 中,当用户提出问题时,检索相关的块并将其包含到提示中。这使得 LLM 能够回答它从未训练过的信息相关的问题,比如公司内部文档和产品手册。一切看起来都运行得很好。
但只要尝试提出更多样化的问题,弱点就会很快暴露出来。你询问消费者退款政策,结果员工商店的内容混了进来;或者你询问某个不在任何文档中的内容,LLM 却编造出一个看似合理的答案;又或者你增加了搜索结果的数量,答案质量反而下降了。
在本章中,我们将逐一解决这三个问题。我们将使用元数据过滤(metadata filtering) 来限制要搜索的文档,使用相似度分数阈值(similarity score threshold) 来排除与问题无关的结果,并使用 K 与 MMR 调优 来改善输入给 LLM 的块的数量和多样性。无需新工具。我们是在优化你已经熟悉的 similarity_search() 和 Chroma 向量存储的配置与用法。
10.1) 我们的 RAG 出了什么问题?
在第 9 章中,我们只往向量存储中放入了两个文档——一份退款政策和一份配送政策——而且每个文档都涵盖一个独立的主题。我们测试的问题,答案也都明确地存在于或不存在于文档中。这一次,我们将创建一个更贴近现实的场景。我们将往向量存储中添加一份员工商店指南。这份文档同样包含退款相关的内容,但它是面向员工的,而非面向普通消费者。然后我们提出各种问题,看看会出现哪些问题。
数据准备:添加员工商店指南
以下是第 9 章中的两个文档,供参考。
data/docs/refund_policy.md:
# 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:
# 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:
# 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.md、shipping_info.md、employee_store.md。重新运行第 9 章的导入脚本来重建向量存储。
# 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:无关文档混入搜索结果
让我们搜索退款条件。
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...看看结果 2。客户询问的是退款条件,而员工商店的退款政策却被包含到了结果中。消费者退款政策允许在 30 天内全额退款,但员工商店只允许未开封商品在 7 天内退款,且退款以福利积分形式发放。如果将这两份政策一起传给 LLM,客户就可能被告知仅适用于员工的退款条款。
问题 2:即使不存在相关内容也会返回结果
现在让我们询问一个在我们的文档中任何地方都不存在的内容。我们将使用在第 9 章学到的 similarity_search_with_score(),以便同时查看距离值。在 ChromaDB 中,距离值越低意味着相似度越高。
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() 仍然返回了三个块。让我们看看当这些块被传给 RAG 链时会产生什么样的答案。
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 会接收所有这些块作为上下文并据此生成答案,不必要或错误的信息可能最终出现在回应中。上下文更多并不一定意味着答案更好。
此外,所有检索到的块都会作为 token 传给 LLM。随着 k 增大,API 调用成本增加,响应时间也会变慢。
我们已经识别出三个问题。现在让我们逐一解决它们。
10.2) 元数据过滤:缩小搜索空间
在问题 1 中,当我们搜索退款条件时,客户退款政策和员工商店退款政策一起出现了。这是因为我们没有告诉 similarity_search() 应该在哪些文档中搜索。
元数据过滤(metadata filtering) 会为每个块附加类别、来源、发布年份等属性,然后在运行相似度搜索之前根据这些属性过滤块。只有符合条件的块才会进入相似度计算。它的作用类似于 SQL 的 WHERE 子句。
10.2.1) 文档内容 vs. 文档元数据
我们在第 9 章学到的 Document 对象包含两样东西:
page_content:文本本身。它会被嵌入成向量,也是相似度搜索操作的对象。metadata:一个字典,保存来源和类别等属性。这些值不会被嵌入。
相似度搜索作用于 page_content,而元数据过滤作用于 metadata 信息。
10.2.2) 重建向量存储:添加元数据
我们将添加一个用于过滤的 category 属性并重建向量存储。我们只需在 10.1 的 ingest.py 中添加元数据赋值代码即可。
# 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 元数据
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 相同的查询进行搜索,但添加一个过滤器,只搜索面向客户的文档。
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 等逻辑运算符将它们组合起来。
# $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 个结果。
这个问题可以通过设置距离阈值(threshold) 来解决。距离超过阈值的结果不会被包含到传给 LLM 的上下文中。
那么我们应该设置什么样的阈值呢?让我们先比较一下文档中有相关内容的问题和没有相关内容的问题之间的距离值。
10.3.1) 比较距离:有相关内容与无相关内容的问题
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 以上。像这样测试若干问题,然后选择一个能清晰区分这两种情况的阈值。合适的阈值会因嵌入模型、文档的性质以及块大小而有所不同,因此最好用你自己的数据直接测试来确定它。
10.3.2) 按距离阈值过滤搜索结果
阈值确定后,让我们构建一个函数来过滤掉超过阈值的结果。只有通过过滤的块才会被包含到传给 LLM 的上下文中。如果没有块保留在阈值以下,我们就完全跳过 LLM 调用,直接回复"我没有足够的信息来回答这个问题。"
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):
"""只返回距离阈值以下的块。如果没有任何块通过则返回 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让我们用之前相同的问题来测试。
# 有相关内容的问题
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 进行搜索,并检查每个块的距离值。
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 的 token 更多,从而增加 API 调用成本。
- 更慢的响应:需要处理的 token 越多,响应时间就越长。
10.4.2) MMR:同时兼顾相关性与多样性
在真实环境中,随着文档集合的增长,出现多个内容相似的块是很常见的。第 9 章的 chunk_overlap 设置使相邻的块共享一部分内容,这也是重复的来源之一。在这种情况下,即使用 k=3 搜索,也可能返回三个几乎相同的块。
MMR(Maximum Marginal Relevance,最大边际相关性) 是一种防止结果偏向相同内容的搜索方法。标准相似度搜索返回与查询最接近的 k 个块,这可能导致相似的块聚集在顶部。MMR 优先选择那些既与查询相关、又与已选结果不同的块。
它的工作原理如下:
- 与标准相似度搜索一样,它首先检索出与查询最接近的
fetch_k个候选块。 - 它选择与查询最接近的块作为第一个结果。
- 从剩余的候选块中,它选择下一个既与查询相关、又在内容上不同于已选块的块。
- 重复第 3 步,直到选出
k个块。
最终得到的是一组既保持相关性、又避免内容重叠的块。
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) 何时停止调优
有多个参数需要调整:k、fetch_k、元数据过滤器、距离阈值等等。遵循以下简单规则有助于你高效地进行调优:
- 准备若干问题——一些在文档中有相关内容,一些则没有。
- 运行这些问题并直接检查检索到的块。
- 如果发现问题,每次只更改一个参数,然后用相同的问题重新测试。
如果有相关内容的问题能产生正确答案,而没有相关内容的问题导致弃答,你就达到了基线质量。从一开始就把设置调到完美并不容易。针对实际使用中发现的问题进行响应,并逐步改进。