AI 笔记 · 2026 年 11 月 11 日

RAG 不是向量数据库的堆砌:一次检索质量优化实录

把文档灌进向量库只是起点。这篇记录一次检索质量排查:如何区分没召回和召回没用,切片、混合检索、重排各自解决哪一段,以及怎样用五十条标注把每次改动量化。

演示环境里 RAG 几乎不会失败:文档干净、问题标准、top_k 取回来的内容刚好就是答案。真实流量一进来就变了。用户问「退款几天到账」,系统答的是退货流程;问「能不能开专票」,引用的是个人发票的说明。

最初的处理是继续灌文档、换更大的索引。两周过去,没有任何改善。问题不在库里存了多少向量,而在检索这一步有没有把包含答案的那一小段取回来。

先分清是没召回,还是召回没用#

RAG 链路上有两个独立环节:检索取出候选段落,生成照着段落写答案。两种失败在用户眼里都是「答错了」,修法却相反。

判断方法很土但有效:拿 30 到 50 条真实问题,人工标出「哪几段足以回答它」,然后只检查检索结果里有没有这些段落。命中率低,是检索问题;命中率正常但答案仍然错,才轮到 prompt 和模型。

这份标注同时就是回归测试集。之后每次动切片、动权重、加重排,跑一遍就知道是变好还是变坏,不用靠感觉。

python
"""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 要有上限,超出上下文预算的召回是负收益。

完

#RAG#检索#向量检索#评测
回到顶部 ↑