RAG 检索质量治理:从答非所问到精准召回的分块、混合检索与重排实战

我们的 RAG 问答在演示那天近乎完美,答案条条有据还能贴出处,老板当场拍板上线;可两周后客服投诉单堆了一摞:问"怎么退订自动续费"答的是"如何开通会员",问"企业版并发上限"系统一本正经编了个文档里根本没有的数字,最扎心的一条反馈是"它说得很流畅,但就是不对"——这正是 RAG 最危险的失败模式:不报错,只一本正经地胡说。这篇把检索质量从「能演示」治理到「能扛生产」的实录写下来,结论先放这儿:RAG 答得不对,九成是检索没把对的内容捞回来,而不是大模型不够聪明。治理从定位开始——把召回片段直接打印出来人眼看,分清是检索的锅还是生成的锅(统计下来八成是检索失败),再一刀一刀补:第一刀修分块,别按固定字符硬切把一句完整流程劈两半,改成沿语义边界切并留 overlap;第二刀上混合检索,纯向量对 SLA、99.95% 这类精确术语不敏感,补一路 BM25 关键词召回、用 RRF 按排名融合免去调权重;第三刀加 cross-encoder 重排,把正确片段从第 12 位顶进 token 预算放得下的 top-4;第四刀做查询改写,用小模型把用户口语翻译成文档术语、多路检索合并;第五刀治生成端,prompt 里写死"只依据片段作答、结论标引用编号、无据就明确拒答",把"一本正经编数字"彻底堵死。最关键的是搭了带标注的评估集,用 Recall@k 和 MRR 量化每一刀(Recall 从 0.58 抬到 0.92、MRR 从 0.41 抬到 0.81),每改一处都重测确认真涨且无回归。文中给出召回诊断脚本、RecursiveCharacterTextSplitter 语义分块、RRF 融合、cross-encoder 两阶段检索、multi-query 改写、防幻觉 prompt、评估代码,一张 RAG 链路图、一棵「先取证再动手」的排查决策树、五刀效果对比表,以及进了工程规范的七条检索规矩和四个几乎人人都会踩的误区。

我们的 RAG 问答在演示那天表现得近乎完美:产品同学随手问了七八个问题,答案条条有理有据,还能把出处文档贴出来,老板当场拍板上线。可上线两周后,客服那边的投诉单堆了一摞:用户问"怎么退订自动续费",系统答的是"如何开通会员";用户问"企业版的并发上限是多少",系统一本正经地编了个数字,而文档里压根没写过这个数。最扎心的一条反馈是:"它说得很流畅,但就是不对。"

这正是 RAG 最危险的失败模式——它不会报错,只会一本正经地胡说。我花了大半个月把这套检索质量从"能演示"治理到"能扛生产",过程里推翻了好几个想当然的假设。这篇就把这趟治理实录写下来:从怎么定位"到底是检索的锅还是生成的锅"开始,一层一层把分块、召回、重排、查询理解和兜底都补上,每一步都附上当时真实的取证和改法。结论先放这儿:RAG 答得不对,九成是检索没把对的内容捞回来,而不是大模型不够聪明。

先定位:到底是检索错了,还是生成错了

治理的第一步不是急着换模型、调 prompt,而是先把问题切开:RAG 的回答分两段——检索(把相关文档片段捞回来)和生成(让大模型基于这些片段作答)。答得不对,可能是检索阶段压根没捞到对的片段,也可能是片段对了但模型没用好。这两者的修法完全不同,混在一起调就是瞎忙。

定位的办法很朴素:把检索召回的原始片段直接打印出来,人眼看一眼对不对。我写了个小脚本,对每条投诉 case,把检索 top-k 的片段连同相似度分数一起 dump 出来:

# 把检索结果摊开来看,定位是"没捞到"还是"捞到了没用好"
def diagnose(query: str, k: int = 5):
    hits = retriever.search(query, top_k=k)
    print(f"问题: {query}\n")
    for i, h in enumerate(hits):
        print(f"[{i}] score={h.score:.3f} | 来源: {h.source}")
        print(f"    {h.text[:120]}...\n")
    # 关键判断:top-k 里到底有没有能回答这个问题的片段?
    # 有 → 检索 OK,问题在生成/prompt
    # 没有 → 检索没召回,先治检索(本文重点)

diagnose("怎么退订自动续费")

结果一目了然:那条"退订自动续费"的 case,检索回来的五个片段全是讲"如何开通会员""会员权益介绍"的,真正讲退订的那段文档根本没进 top-5。这说明问题出在检索,不在生成——模型其实很老实,它只是基于捞回来的错片段如实作答而已。我把所有投诉 case 跑了一遍,统计下来八成以上都是检索失败,这下方向就明确了:重点治检索。

失败类型 占比 表现 根因方向
检索没召回 ~62% top-k 里没有相关片段 分块 / 召回策略
召回了但排得太靠后 ~21% 相关片段在 k 之外 缺重排
查询表述对不上 ~9% 口语 vs 文档术语 查询改写
片段对但生成跑偏 ~8% 有据却答错/编造 prompt / 兜底

RAG 的整条链路画出来是这样的,后面每一刀都对应链路上的一个环节——先看清全貌,再逐段下手:

第一刀:分块策略,别把一句话切两半

检索召回不准,头号元凶是分块(chunking)切得太糙。我们最初图省事,按固定 500 字符硬切,完全不管语义边界。结果就是一段完整的"退订流程"被从中间劈开:前半句"进入账户设置页面后"分到一个块,后半句"点击取消自动续费并确认"分到另一个块。用户问退订时,这两个半截块的向量都和问题不够像,谁都没被召回。

正确的做法是按语义结构切,并让相邻块之间留一段重叠。优先沿标题、段落这些自然边界切;切不开的长段落,用固定窗口但加 overlap,让跨边界的句子在两个块里都能保留完整上下文:

# ❌ 旧:按字符硬切,无视语义,把完整流程劈成两半
# chunks = [text[i:i+500] for i in range(0, len(text), 500)]

# ✅ 新:优先按结构切,长块再用带 overlap 的窗口
from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    # 按优先级尝试分隔符:先段落,再换行,再句号,最后才硬切
    separators=["\n\n", "\n", "。", "!", "?", " ", ""],
    chunk_size=400,
    chunk_overlap=80,        # 相邻块重叠 80 字,跨边界句子两边都保留
    length_function=len,
)
chunks = splitter.split_text(doc_text)

# 给每个块挂上来源元数据,后面才能做引用和过滤
docs = [{"text": c, "source": doc_id, "title": doc_title} for c in chunks]

这里有两个反复验证过的经验值得记下来:一是 chunk_overlap 不能省,它是跨边界语义的保险;二是 chunk_size 不是越大越好——块太大,一个块里塞了好几个主题,向量被"平均"得谁都不像,反而召回变差;块太小,又会丢上下文。我们最终在 300~500 字这个区间反复试,对我们这种 FAQ + 操作手册混合的文档,400 字配 80 重叠的召回命中率最高。分块没有万能参数,得拿自己的真实问题集去测召回命中率,而不是抄一个默认值了事。

第二刀:混合检索,别只靠向量

分块修好后,召回准了一截,但还有一类问题死活搜不对:用户问"企业版 SLA 是多少",文档里白纸黑字写着"企业版 SLA 承诺 99.95%",可纯向量检索就是召不回来。原因是向量检索擅长语义相似,却对精确的关键词、专有名词、型号编码不敏感——"SLA""99.95%"这种术语,语义嵌入会把它和一堆"服务保障""可用性"的泛泛内容混在一起,反而盖过了那条精确命中的。

解药是混合检索(hybrid search):向量召回 + 关键词召回(BM25)双路并行,再把两路结果融合。向量负责"意思相近",BM25 负责"字面命中",两者互补。融合时用一个经典又好用的算法 RRF(Reciprocal Rank Fusion),它不看两边的原始分数(量纲根本不可比),只看各自的排名,按排名的倒数加权合并:

# 混合检索:向量召回 + BM25 召回,用 RRF 按排名融合
def hybrid_search(query: str, k: int = 20):
    vec_hits = vector_store.search(query, top_k=k)   # 语义召回
    bm25_hits = bm25_index.search(query, top_k=k)    # 关键词召回

    # RRF:只看排名不看原始分,避免两路分数量纲不可比的问题
    scores = {}
    C = 60  # RRF 常数,削弱头部排名的统治力,经验值 60
    for rank, h in enumerate(vec_hits):
        scores[h.doc_id] = scores.get(h.doc_id, 0) + 1.0 / (C + rank)
    for rank, h in enumerate(bm25_hits):
        scores[h.doc_id] = scores.get(h.doc_id, 0) + 1.0 / (C + rank)

    # 按融合分排序,取合并后的候选集
    merged = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    return [doc_id for doc_id, _ in merged[:k]]

RRF 的妙处在于它不需要给两路检索调权重——你不用纠结"向量占七成还是 BM25 占六成",它用排名的倒数自然地让"两路都排得靠前"的文档冒头。上线混合检索后,那类精确术语 case 的召回率从不到五成跳到九成以上,而且几乎没有副作用,因为 RRF 对纯语义 case 也照顾得很好。纯向量是 RAG 的默认起点,但绝不该是终点;凡是文档里有大量专有名词、编号、精确数值的场景,混合检索几乎是必选项。

第三刀:重排,把对的那条顶到最前面

混合检索把候选集的"覆盖面"做大了——相关片段基本都在 top-20 里了。但新问题来了:真正最相关的那条,常常不在最前面。而拼 prompt 时受 token 预算限制,只能塞进去 top-3 ~ top-5,排在第 12 位的正确答案照样进不了 prompt,等于白召回。

这一步要靠重排(rerank)。前面的向量/BM25 召回是"双塔"模式——问题和文档各自独立编码再算相似度,快但糙;重排用的是 cross-encoder,把"问题 + 候选片段"拼成一对一起喂进模型,直接输出一个精细的相关性分数,准得多,只是慢,所以只能用在"先粗召回一批、再精排一小批"的第二阶段:

# 两阶段:先 hybrid 粗召回 20 条,再用 cross-encoder 精排取 top-4
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")  # 中文友好的重排模型

def retrieve(query: str, final_k: int = 4):
    # 阶段一:粗召回(快,覆盖面大)
    candidate_ids = hybrid_search(query, k=20)
    candidates = [doc_store[i] for i in candidate_ids]

    # 阶段二:精排(慢,但准)——问题与每个候选成对打分
    pairs = [(query, c["text"]) for c in candidates]
    scores = reranker.predict(pairs)
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)

    # 只把精排后的 top-k 塞进 prompt,省 token 又提质量
    return [c for c, _ in ranked[:final_k]]

加上重排是整套治理里"单点收益"最大的一刀:之前"召回了但排太靠后"那 21% 的失败 case,绝大多数被它救了回来,因为正确片段终于能稳定进到 top-4。代价是每次查询多了一次 cross-encoder 推理,延迟涨了一两百毫秒——我们的对策是把粗召回的候选数从 20 控制在一个合理范围(再多重排就太慢),并给重排模型上了 GPU。"先广撒网粗召回,再精挑细选重排"这套两阶段范式,是把检索质量和延迟同时拿捏住的标准答案。

第四刀:查询改写,让口语对上文档术语

检索链路两端各说各话,是另一类隐蔽的失败。用户在搜索框里打的是大白话——"会员怎么不自动扣费了""充值没到账找谁",而文档是规规矩矩的书面语——"自动续费的扣款规则""充值异常的处理流程"。这两种表述的向量天然有距离,检索就容易对不上。更麻烦的是有些问题一句话里其实藏了两个意图("企业版多少钱、支持几个子账号"),用原始查询整体去检索,哪个都召不全。

对策是在检索之前,先让一个轻量的大模型把用户的原始问题改写、扩展成几个更贴近文档表述的查询,然后多路并行检索、结果合并。这一步业界叫 multi-query 或查询扩展,本质是用模型的语言能力来弥合"用户口语"和"文档术语"之间的鸿沟:

# 查询改写:把口语问题扩展成多个贴近文档表述的检索式
REWRITE_PROMPT = """你是检索助手。把用户问题改写成 3 个不同角度的检索查询,
覆盖可能的同义表述和拆分出的子问题,每行一个,不要解释。
用户问题:{q}"""

def expand_query(q: str) -> list[str]:
    resp = llm.complete(REWRITE_PROMPT.format(q=q))
    queries = [line.strip() for line in resp.splitlines() if line.strip()]
    return [q] + queries          # 原始问题也保留,避免改写跑偏丢信息

def retrieve_with_rewrite(q: str, final_k: int = 4):
    all_candidates = {}
    for sub_q in expand_query(q):           # 多路检索
        for c in retrieve(sub_q, final_k=8):
            all_candidates[c["id"]] = c     # 按 id 去重合并
    # 合并后的候选,统一用原始问题做最终重排(保证排序对齐真实意图)
    return rerank(q, list(all_candidates.values()))[:final_k]

这一刀治好了那 9% "表述对不上"的 case,尤其是多意图问题——拆开后每个子问题都能各自召回到对应文档。要注意两点:改写要用快而便宜的小模型(它只做扩展,不做最终回答,没必要上最强的),否则每次查询多几次大模型调用,延迟和成本都吃不消;另外一定要把原始查询也保留在检索集里,万一改写跑偏了,原始问题还能兜底。查询改写的核心思想是:与其指望文档去迁就用户的随口一问,不如让模型先把问题翻译成文档听得懂的话。

第五刀:prompt 拼装与防幻觉,让它"无据就说不知道"

检索这条线治到这儿,该召回的基本都召回了。剩下那 8% "片段对了却答错或编造"的,问题出在最后一段——生成。最典型的就是开头那个"企业版并发上限"的 case:文档里根本没这个数,但模型不甘心说"不知道",硬是顺着语感编了一个。这是大模型的天性,得靠 prompt 和约束去管。

管法有三条,都在拼装 prompt 这一步落地:一是把检索片段和"只能依据片段作答"的硬约束写进 system 角色;二是要求每条结论标注引用编号,逼模型把话锚定到具体出处;三是明确授权它"找不到依据就直说不知道",给它一条不必编造的退路。

# 拼装带引用约束 + 防幻觉指令的 prompt
SYSTEM = """你是客服问答助手。严格遵守:
1. 只能依据【参考资料】回答,不得使用资料外的知识或推测。
2. 每个结论后用 [编号] 标注依据的资料,如 [2]。
3. 若参考资料里没有相关信息,直接回答"暂未找到相关说明,建议联系人工客服",
   绝对不要编造数字、政策或流程。"""

def build_prompt(query: str, chunks: list[dict]) -> list[dict]:
    refs = "\n\n".join(
        f"[{i+1}] (来源:{c['title']})\n{c['text']}"
        for i, c in enumerate(chunks)
    )
    user = f"【参考资料】\n{refs}\n\n【用户问题】\n{query}"
    return [{"role": "system", "content": SYSTEM},
            {"role": "user", "content": user}]

这套约束下去,"一本正经编数字"的情况基本绝迹——模型遇到文档没覆盖的问题,会老老实实回"暂未找到相关说明",而不是瞎编。带 [编号] 引用还有个额外好处:用户能点开出处自己核对,信任度直接上一个台阶,客服也能快速判断答案靠不靠谱。对 RAG 来说,"敢于说不知道"不是能力缺陷,而是一种必须显式设计出来的可靠性——一个会编造的助手,比一个会拒答的助手危险得多。

不能只靠手感:把检索质量变成可量化的指标

前面五刀每一刀我都说"召回率提升了多少",这些数字不是拍脑袋估的,而是有一套评估机制在背后撑着。没有评估,优化就是在黑暗里挥拳——你不知道改了到底是变好还是变坏,也不知道修了 A 会不会把 B 弄回归。所以治理早期我就先搭了个小评估集:从真实问答日志里抽一两百条有代表性的问题,人工标注出每条问题对应的"标准答案文档"。

# 用标注好的评估集,量化检索质量(命中率 + 命中位置)
def evaluate(eval_set, retrieve_fn, k=4):
    hit, mrr = 0, 0.0
    for case in eval_set:           # case = {"q": 问题, "gold": 标准文档id集}
        results = retrieve_fn(case["q"], final_k=k)
        ids = [r["id"] for r in results]
        # Recall@k:top-k 里有没有命中标准文档
        if set(ids) & case["gold"]:
            hit += 1
        # MRR:第一个命中的排名倒数,越靠前分越高(衡量排序质量)
        for rank, _id in enumerate(ids):
            if _id in case["gold"]:
                mrr += 1.0 / (rank + 1)
                break
    n = len(eval_set)
    return {"Recall@k": hit / n, "MRR": mrr / n}

# 每改一刀就重跑一遍,用数字确认是真的变好,而不是自我感觉良好
print(evaluate(eval_set, retrieve_with_rewrite))

有了这把尺子,每一刀的效果都能用 Recall@k(召回命中率)和 MRR(命中排名质量)两个数字说话,改完一刀就重跑一遍,确认是真涨而不是错觉,也能第一时间发现回归。把五刀的累计效果摊在一张表上,杠杆在哪一目了然:

阶段 关键改动 Recall@4 MRR
起点 固定字符分块 + 纯向量检索 0.58 0.41
第一刀 语义分块 + overlap 0.71 0.49
第二刀 混合检索(向量 + BM25,RRF) 0.83 0.55
第三刀 cross-encoder 重排 0.86 0.78
第四刀 查询改写 / 多查询 0.92 0.81
第五刀 引用约束 + 防幻觉(生成端) 0.92 0.81

表里看得很清楚:Recall 主要靠分块、混合检索、查询改写一路抬上去(把对的内容捞进候选集),MRR 的大跳则是重排带来的(把对的内容顶到最前)。第五刀防幻觉不改检索指标,但它把"片段对却答错"的最后一类生产事故堵死了,体感质量的提升远超表格数字。

检索答不对时,照这棵树排查

这套治理走完,我把排查思路沉淀成一棵决策树。下次再遇到"RAG 答不对",别急着换模型或改 prompt,先按这棵树一步步定位,绝大多数问题都能落到某一个具体环节上:

我们后来定下的几条 RAG 检索规矩

这通治理之后,下面几条进了我们的 RAG 工程规范,新接入的知识库一律照此搭建:

  1. 先定位再动手:答不对先 dump 召回片段,分清是检索锅还是生成锅,别上来就换模型、调 prompt。
  2. 分块按语义切并留 overlap,块大小拿真实问题集测出来,不照抄默认值。
  3. 检索默认混合:向量管语义、BM25 管字面,用 RRF 按排名融合,免去调权重。
  4. 粗召回 + 重排两阶段:广撒网召回一批,再用 cross-encoder 精排进 prompt。
  5. 查询先改写再检索,用小模型把口语翻译成文档术语,原始问题保留兜底。
  6. 生成端强约束:只依据片段作答、结论标引用、无据就明确拒答,绝不让它编。
  7. 检索质量必须可量化:维护带标注的评估集,每改一处都重测 Recall@k 与 MRR,确认真涨且无回归。

几个几乎人人都会踩的误区

这套方法分享给团队其他做 RAG 的同事时,我发现有几个误区几乎人人都会踩一次,这里专门拎出来,帮你省下我当初踩坑的时间。

第一个误区是一答不对就怪大模型不够强,急着换更贵的模型。我自己最初也这么想,差点就去申请预算换顶配模型了。但 dump 召回片段后才发现,八成问题是检索压根没把对的内容捞回来——这种情况下,模型再强也是巧妇难为无米之炊,你喂给它的就是错片段,它能怎么办?RAG 的质量天花板,很大程度上是被检索而不是被生成模型决定的。把钱和精力先砸在检索上,性价比远高于换模型。

第二个误区是迷信纯向量检索,觉得有了 embedding 就万事大吉。向量检索确实优雅,但它对精确关键词、专有名词、型号、数字天然不敏感。很多人遇到"明明文档里有却搜不到"百思不得其解,其实加一路最朴素的 BM25 关键词召回就解决了。别因为 BM25 "看起来很传统"就看不上它——在混合检索里,它恰恰是向量的最佳搭档。

第三个误区是分块时只调 chunk_size,忽略 overlap 和语义边界。有人把块切得很大以为能保留更多上下文,结果一个块里混了好几个主题,向量被平均得谁都不像,召回反而更差。分块的关键不在大小数字本身,而在"是否沿语义边界切、是否留重叠"。把一句完整的话切两半,是检索失败最隐蔽也最常见的源头。

第四个误区是凭感觉优化,不建评估集。"我改了之后感觉好多了"——这种话在 RAG 优化里最不可信。没有量化指标,你既证明不了改动有效,也发现不了改 A 弄回归了 B。哪怕只标注一两百条评估集,投入一两天,后续每一次优化都能用数字说话,这笔账怎么算都划算。评估集是 RAG 工程从"玄学调参"走向"工程优化"的分水岭。

写在最后

回头看这趟治理,最大的体会是:RAG 看着是个"接个大模型 + 塞点文档"就能跑的简单系统,但要让它在生产里真正可靠,功夫几乎全在检索这条不起眼的链路上。分块决定了知识能不能被完整地存进去,混合检索和查询改写决定了对的内容能不能被捞出来,重排决定了它能不能排到模型看得见的地方,而生成端的约束决定了模型会不会老实交代"我不知道"。这几环里任何一环松了,整个问答的可信度就会从那个缺口漏掉。

而所有这些手段背后,其实是同一条朴素的主线:先把问题定位到具体环节,再用可量化的指标驱动每一步改进。别被"换个更强的模型就好了"这种念头带跑,大多数时候,真正的杠杆在你以为最不起眼的检索细节里。一个会标注出处、敢于说"不知道"的问答系统,远比一个流畅却时常编造的系统值得信赖——而这份信赖,正是靠把检索这条链路上的每一刀都认真补齐,一点一点攒出来的。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Docker 镜像从 1.4GB 瘦到 90MB:多阶段构建、层缓存与 BuildKit 提速实战

2026-5-29 18:32:00

技术教程

订单系统抗大促架构演进:异步削峰、库存预扣、服务拆分与最终一致性

2026-5-29 18:42:31

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索