演示环境里 RAG 几乎不会失败:文档干净、问题标准、top_k 取回来的内容刚好就是答案。真实流量一进来就变了。用户问「退款几天到账」,系统答的是退货流程;问「能不能开专票」,引用的是个人发票的说明。
最初的处理是继续灌文档、换更大的索引。两周过去,没有任何改善。问题不在库里存了多少向量,而在检索这一步有没有把包含答案的那一小段取回来。
先分清是没召回,还是召回没用#
RAG 链路上有两个独立环节:检索取出候选段落,生成照着段落写答案。两种失败在用户眼里都是「答错了」,修法却相反。
判断方法很土但有效:拿 30 到 50 条真实问题,人工标出「哪几段足以回答它」,然后只检查检索结果里有没有这些段落。命中率低,是检索问题;命中率正常但答案仍然错,才轮到 prompt 和模型。
这份标注同时就是回归测试集。之后每次动切片、动权重、加重排,跑一遍就知道是变好还是变坏,不用靠感觉。
"""retrieval_eval.py —— 只依赖标准库,直接 python retrieval_eval.py 就能跑"""
from collections import defaultdict
# 标注集:query -> 足以回答它的段落 id,命中任意一段即算召回
GOLD = {
"退款几天到账": {"refund-timeline"},
"能不能开专票": {"invoice-special"},
"发票丢了怎么办": {"invoice-reissue"},
}
def recall_at_k(ranked: dict[str, list[str]], k: int = 5) -> float:
hit = sum(1 for q, gold in GOLD.items() if gold & set(ranked.get(q, [])[:k]))
return round(hit / len(GOLD), 3)
def mrr(ranked: dict[str, list[str]]) -> float:
total = 0.0
for q, gold in GOLD.items():
for rank, doc_id in enumerate(ranked.get(q, []), 1):
if doc_id in gold:
total += 1 / rank # 第一名得 1 分,第二名 0.5
break
return round(total / len(GOLD), 3)
def rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
"""倒数排名融合:把两路结果合成一路,不依赖分数尺度"""
score: dict[str, float] = defaultdict(float)
for ranking in rankings:
for rank, doc_id in enumerate(ranking, 1):
score[doc_id] += 1 / (k + rank) # k 越大,头部优势越平缓
return sorted(score, key=score.get, reverse=True)
if __name__ == "__main__":
# 两路召回结果:BM25 抓字面,dense 抓语义
bm25 = {"退款几天到账": ["refund-policy", "refund-timeline"],
"能不能开专票": ["invoice-personal", "invoice-special"],
"发票丢了怎么办": ["invoice-reissue", "invoice-special"]}
dense = {"退款几天到账": ["refund-timeline", "return-flow"],
"能不能开专票": ["invoice-special", "invoice-personal"],
"发票丢了怎么办": ["invoice-reissue", "invoice-personal"]}
fused = {q: rrf([bm25[q], dense[q]]) for q in GOLD}
print("recall@5 =", recall_at_k(fused), "mrr =", mrr(fused))
两个指标各看一面:recall@k 回答「有没有找到」,mrr 回答「正确段落排在第几」。只看前者,你会忽略「命中了但排在第五位」对生成的影响——答案虽然取回来了,模型的注意力很可能没落在它上面。
切片决定了召回的上限#
多数召回问题在切片那一步就注定了。按固定字数硬切,会把参数表切成两半,也会让「上述条件」和它的上文分离。这种块无论是向量还是关键词都救不回来,因为块本身已经不完整。
我现在默认按标题层级切,再加一层兜底:结构切完超过八百字,按段落继续拆;相邻块留百分之十到十五的重叠。重叠不是越多越好,它直接放大索引体积和噪声。
| 切片方式 | 适合的文档 | 代价 |
|---|---|---|
| 固定长度 | 无结构的纯文本、日志 | 容易切断语义,需要大重叠补偿 |
| 按标题层级 | 手册、规范、FAQ | 依赖文档本身有清晰结构 |
| 按语义段落 | 散文、说明书 | 需要额外聚类,块长度不均 |
| 一问一答对 | 客服工单、社区问答 | 前期清洗成本高,效果通常最好 |
还有一个细节:块里要带上标题路径。一段写「支持三十天内无理由退货」的正文,如果块头没有「售后 / 退货政策」,向量很难判断它讲的是退货而不是物流。
混合检索:把关键词找回来#
向量擅长语义相近,不擅长精确匹配。产品型号、错误码、条款编号、人名这类 token,BM25 常常一击命中,向量模型却把它们压成近似向量,排在后面。
做法是两路各取二十条,用上面代码里的 rrf 融合。倒序排名融合的好处是不用做分数归一化,也不需要在每个查询上调权重,比手工加权稳得多。用两路还是三路,取决于你有没有第三路信号,比如按时间排序或按权限过滤。
融合用的是排名,不是分数。如果你把余弦相似度和 BM25 分数直接相加,量纲不同,调出来的权重换个查询就失效,这也是很多人觉得「混合检索没效果」的真实原因。
重排与 top_k 的取舍#
先召回二十条、重排留下五条,通常比直接召回五条要好。原因不是重排模型更聪明,而是召回阶段的排序本来就不该被完全信任,它是一个高召回的粗筛,不是精排。
但 top_k 不是越大越好。塞进二十段无关内容,模型会被中间某一段带偏,延迟和费用也一起上涨。我的经验区间是:召回二十到四十,重排后留三到六段,具体数字由标注集上的 recall@k 与最终答案正确率共同决定,以实际评测为准。
还有一条纪律:一次只改一个变量。切片、融合、重排同时上线,指标涨了也说不清是哪一步的功劳,下次掉下来同样无法定位。每次只动一处,跑完标注集,把数字记在一张表里,一个月后你会感谢自己。
反复出现的四类失败#
调了两周,值得单独记下来的失败其实只有四类:
- 数字和单位。「三到五个工作日」被答成「三天内」,多半是切片时把前提条件切走了,块里只剩下半句结论。
- 否定句。「不支持货到付款」在向量空间里和「支持货到付款」距离很近,这类查询必须让 BM25 参与,纯向量几乎无解。
- 跨文档的多跳问题。答案要同时用到售后政策和支付说明,单块召回永远不够,得先把问题拆成子查询再分别检索。
- 先取候选再按权限过滤。按角色筛掉之后只剩两三条,召回率骤降。正确顺序是把权限条件下推到检索阶段,而不是在结果上补一刀。
这四类问题都不会被「换更大的模型」修好,它们属于检索链路的结构问题。
结论#
- 先建标注集,再谈优化。没有五十条带标准答案的问题,任何改动都无法判断好坏。
- 用
recall@k定位「没找到」,用最终正确率定位「找到了没用」,两者混在一起讨论只会来回改 prompt。 - 切片优先按结构,块头带标题路径,重叠控制在百分之十到十五。
- 混合检索用倒序排名融合,不要手工加分数。
- 重排值得做,但 top_k 要有上限,超出上下文预算的召回是负收益。
完