排查静默型RAG故障:检索、分块、过滤与评估问题
RAG的回答可能听起来正确,但实际上却是错误的。一本以Python为视角的指南,涵盖检索、分块、元数据过滤、评估、混合搜索及结果验证等内容。
来源: RAG Is Failing Quietly: A Debugging Playbook for Python Teams
无人察觉的故障
最令人头疼的RAG故障并非那些产生明显异常的。这类故障很容易识别:机器人会承认未找到任何内容,向量数据库出现故障,嵌入任务崩溃,或者生成的答案没有引用依据。
那些悄无声息的故障代价更高。系统成功检索到文档,答案表述流畅,引用看起来也合理,控制面板也显示检索成功。但领域专家阅读回复后却指出其错误。通常就在这时,团队才会意识到自己一直在错误的层面进行调试。
RAG并非单一功能,而是一系列相互叠加的决策过程。需要有人决定如何理解并重写问题、在何处分割文档、调用哪个检索工具、用哪些元数据筛选候选项、如何重新排序候选结果、最终将什么内容放入提示词中、在证据不足时模型应如何表现,以及书面答案是否真正基于所提供的信息得出。仅关注最终提示词意味着在问题已经出现后才去处理。
正在调试的流程
一个真实的系统更接近于以下这种复杂结构,而非幻灯片上那种简化的图表:
flowchart LR
A[User question] --> B[Query rewrite]
B --> C[Retriever]
C --> D[Reranker]
D --> E[Evidence pack]
E --> F[Answer generator]
F --> G[Verifier]
G --> H[Final answer]
C --> I[Trace log]
D --> I
E --> I
F --> I
G --> I
任何环节都可能出错。生成器往往成为众矢之的,因为它是最显眼的阶段,但实际上许多故障源自更上游的环节。检索器可能选错了时间范围;重排器可能提升了无关紧要的相似文本片段;证据包可能因为令牌预算不足而遗漏了唯一有用的段落。良好的调试方法就是要让每个环节都清晰可见。
故障1:相似文本并非有用的证据
向量搜索能识别语义相似性,但这并不总是与任务相关。比如有用户询问在企业客户适用第二季度政策更新后是否可以退款。检索器可能会仅仅因为其中提到了“企业”“退款”和“政策”就返回相关内容,但那些内容可能已经过时、不完整,或者与第二季度的政策毫无关联。
一份有用的追踪信息会显示检索到的文档编号、版本、生效日期、相似度得分、重新排序得分、所应用的元数据过滤器,以及最终答案是否引用了该文档。如果日志中仅显示类似0.83这样的得分,就无法诊断出问题所在。
故障2:分块处理破坏了含义
分块处理并非单纯的存储细节,它直接决定答案的质量。一项退款政策可能包含适用条件、例外情况、企业附加条款、生效日期以及地区差异等内容。如果分块工具将生效日期与例外情况分开,答案就可能会引用适用条件段落,却忽略那些会改变结果的条件。
优质的文本块应包含稳定的文档ID、章节标题、版本号、日期、足以保持语义完整的周边文本,以及指向原始内容的引用。但这些并不意味着文本块必须庞大,过大的文本块只会增加冗余信息。理想的情况是文本块能够体现决策转折点。
错误3:缺少元数据过滤器
许多RAG系统最初都是纯语义搜索系统。但当同一主题出现在不同版本、产品、租户、地区或权限组中时,这种模式就不再适用了。关于当前企业退款政策的查询,不应要求检索系统搜索该公司所有相关的退款文档,而应先通过过滤器进行筛选。
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class SearchFilters:
product: str | None
customer_tier: str | None
region: str | None
as_of: date
permission_group: str
def build_filters(user_context: dict) -> SearchFilters:
return SearchFilters(
product=user_context.get("product"),
customer_tier=user_context.get("tier"),
region=user_context.get("region"),
as_of=date.today(),
permission_group=user_context["permission_group"],
)
顺序很重要。在让模型进行推理之前,先应用权限和新鲜度过滤。试图获取私有或过时的上下文并寄希望于提示词能解决这些问题是不明智的。
错误4:仅包含成功案例的评估集
改进RAG系统的最快方法就是使用一个规模小但经过精心设计的评估集。不必使用上千个示例,先从大约三十个能反映系统实际故障场景的示例开始:
- 答案不在语料库中的问题
- 需要两个来源才能回答的问题
- 旧策略与新策略存在冲突的问题
- 针对不同产品但表述相似的问题
- 依赖表格回答的问题
- 正确答案需要转交给人处理的问题
一个简单的检索框架可能如下所示:
from dataclasses import dataclass
@dataclass(frozen=True)
class RagCase:
question: str
required_doc_ids: set[str]
forbidden_doc_ids: set[str]
def evaluate_retrieval(cases: list[RagCase], retrieve) -> dict:
total = len(cases)
hit = 0
leaked_forbidden = 0
for case in cases:
results = retrieve(case.question)
retrieved_ids = {item["doc_id"] for item in results}
if case.required_doc_ids & retrieved_ids:
hit += 1
if case.forbidden_doc_ids & retrieved_ids:
leaked_forbidden += 1
return {
"cases": total,
"required_hit_rate": hit / total,
"forbidden_leak_rate": leaked_forbidden / total,
}
它刻意保持简单。虽然无法确保答案的质量,但能在模型看到任何上下文之前捕捉到大量检索失败的情况。
错误5:在没有证据的情况下评判答案
RAG生成的答案应依据实际提供的证据进行评分,而非整个语料库。这一区别在三个方面很重要:如果知识库中存在正确答案但未被检索到,就无法期望生成器知晓该答案,这就是检索失败;如果正确证据已被检索到但答案却忽略了它,那就是生成失败;如果答案引用的来源无法支持其主张,那就是依据缺失失败。这三种情况都需要相应的标签来标识。
@dataclass(frozen=True)
class AnswerEval:
question: str
answer: str
evidence_doc_ids: set[str]
expected_claims: set[str]
def simple_claim_check(eval_case: AnswerEval) -> dict:
answer_lower = eval_case.answer.lower()
missing = [
claim
for claim in eval_case.expected_claims
if claim.lower() not in answer_lower
]
return {
"passed": len(missing) == 0,
"missing_claims": missing,
"evidence_count": len(eval_case.evidence_doc_ids),
}
在处理较为微妙的主张时,生产流程可能会加入人工审核或大型语言模型评估,但即便只是粗略的检查,也总比含糊的肯定要好。
更好的追踪机制
每个答案都应留下结构化的追踪记录:
import time
import uuid
from dataclasses import dataclass, field
@dataclass
class RagTrace:
run_id: str = field(default_factory=lambda: str(uuid.uuid4()))
started_at: float = field(default_factory=time.time)
query: str = ""
rewritten_query: str | None = None
filters: dict = field(default_factory=dict)
retrieved: list[dict] = field(default_factory=list)
evidence_doc_ids: list[str] = field(default_factory=list)
prompt_tokens: int = 0
completion_tokens: int = 0
verifier_result: str | None = None
latency_ms: int | None = None
def finish_trace(trace: RagTrace) -> RagTrace:
trace.latency_ms = int((time.time() - trace.started_at) * 1000)
return trace
有了它,各种实际问题都能得到解答:是否搜索了正确的集合?过滤器是否去除了正确的文档?检索工具是否找出了预期的来源?证据集中是否包含了过多背景信息?验证者是否注意到了缺失的引用?延迟是源于检索、生成还是验证环节?如果没有这些问题的答案,那就还称不上RAG系统,只不过是一个附带搜索框的提示工具而已。
混合搜索往往是乏味的解决方案
团队们常常过快地从单纯的向量搜索转向代理式RAG。其实应该先尝试混合检索方式。当用户与文档使用不同的词汇时,语义搜索会更有效。而在需要精确匹配字面字符串的情况下,比如标识符、错误代码、字段名称或法律条款,关键词搜索则更为合适。实际上,真正的系统往往需要结合这两种方法使用。
def hybrid_rank(vector_results: list[dict], keyword_results: list[dict]) -> list[dict]:
scores: dict[str, float] = {}
items: dict[str, dict] = {}
for rank, item in enumerate(vector_results, start=1):
doc_id = item["doc_id"]
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (rank + 10)
items[doc_id] = item
for rank, item in enumerate(keyword_results, start=1):
doc_id = item["doc_id"]
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (rank + 10)
items[doc_id] = item
return sorted(
items.values(),
key=lambda item: scores[item["doc_id"]],
reverse=True,
)
这种互斥排名式的合并方式虽不复杂,却十分实用。它既能奖励同时出现在两个结果列表中的文档,又能让任一检索系统有机会找到重要内容。
何时使用智能检索更有效
智能RAG适用于需要深入调查的问题。适合的应用场景包括:初次检索往往缺乏足够证据的案例、涉及多份文档的查询、需要在SQL、向量搜索和API调用之间选择的流程、必须判断自身是否拥有足够证据的模型,以及通过更换查询条件重新尝试能提升效果的流程。
不太适合的场景则是简单的FAQ回答、低延迟的自动补全功能、稳定的文档查询,以及每次额外调用模型都会产生过高成本的场景。只有当问题本身确实需要智能检索才能解决时才应采用该技术;如果元数据过滤器就能解决问题,那就直接使用过滤器。
生产环境检查清单
在指责模型之前,请先检查以下几点:
- 文档是否有版本控制?
- 在检索之前是否已实施权限管理?
- 过时的文档是否会默认被排除?
- 分块处理是否基于语义而非仅字符数?
- 关键词搜索能否覆盖精确术语?
- 表格和图片是否被单独处理?
- 评估是否包含无答案的情况?
- 是否记录了检索到的文档ID?
- 引用的来源是否经过验证?
- 答案质量是否与检索质量分开评估?
大多数RAG系统故障并非难以理解,只是未被察觉而已。
证据链出现断裂的地方
RAG让大语言模型系统显得更加务实,但这种务实性并非自动形成。模型只能使用它所接收到的证据,检索器只能找到那些被正确索引的内容,索引系统只能提供那些被保留下来的元数据,评估者也只能发现那些被刻意标注出来的问题。这项工作不如演示那么引人注目,却是建立信任的基础。
因此,当RAG系统悄然出错时,不应从提示词入手,更好的问题是:证据链在何处断裂了?
相关阅读
- 现代人工智能的工作原理:大语言模型、提示词设计、RAG、智能体与MCP详解 —— 用通俗语言讲解人工智能技术栈:大语言模型如何预测字符、四种提示词技巧、结合嵌入技术的RAG、ReAct智能体循环以及MCP的功能。