乐于分享
好东西不私藏

为什么 RAG 总是召回错误文档?生产环境检索链路拆解

为什么 RAG 总是召回错误文档?生产环境检索链路拆解
很多 RAG 项目在 Demo 阶段效果不错:准备几十份文档,输入一个标准问题,系统很快就能返回正确答案。

但到了生产环境,问题会突然变得复杂:

  • 用户输入的是缩写、错别字或一句不完整的话;
  • 同一知识点存在多个版本,旧文档反而排在前面;
  • 错误码、产品型号和合同编号无法被准确匹配;
  • 检索结果看起来“语义相关”,却没有真正回答问题;
  • 明明召回了正确片段,最终答案仍然引用了错误内容。

这时,团队最常见的反应是换 Embedding 模型、调大 Top K,或者换一个向量数据库。但真正的问题往往不在某一个组件,而在整条检索链路。

RAG 检索不是一次向量相似度计算,而是一条由查询理解、候选召回、过滤、融合、重排序和上下文组装共同构成的流水线。

我会用一个具体案例,把生产环境中的检索链路逐层拆开,并给出一套基于 LangChain 1.x 的混合检索与 Rerank 实现。


一、先判断:究竟是“没找到”,还是“没答好”?

用户说“RAG 回答错了”,背后至少可能有三类问题:

  1. 检索失败:正确文档没有进入候选集;

  2. 排序失败:正确文档被召回了,但排名太低,没有进入最终上下文;

  3. 生成失败:正确文档已经进入上下文,但大模型忽略、误读或混合了冲突内容。

    这三种问题的解决方式完全不同。

    如果正确文档根本没有进入 Top 50,增加 Prompt 约束通常没有意义;如果正确文档排在第 18 位,而系统只取前 5 条,就应该优化排序;如果前 3 条已经包含完整答案,则应该检查上下文组装和生成环节。

    因此,排查 RAG 的第一步不是改参数,而是保存每个阶段的中间结果:

    至少需要记录:

    • 原始查询和改写后的查询;
    • 各召回通道返回的文档 ID、分数和排名;
    • Metadata Filter 的条件和过滤数量;
    • 融合后的候选列表;
    • Rerank 前后的排名变化;
    • 最终进入 Prompt 的片段及 Token 数;
    • 答案引用的来源。

    没有这些数据,“检索效果差”只能停留在感觉层面。


    二、一个典型案例:语义相似,但答案不对

    假设公司知识库中有三份文档:

    文档内容摘要状态
    AERR_CONN_RESET_1007 的原因和修复步骤当前有效
    B常见网络连接失败的通用排查方法当前有效
    C旧版客户端的 ERR_CONN_RESET_1007 处理方法已废弃

    用户提问:

    Windows 客户端出现 ERR_CONN_RESET_1007,应该怎么处理?

    只使用向量检索时,系统可能返回:

    1. 文档 B:网络连接异常排查;

    2. 文档 C:旧版客户端错误码说明;

    3. 文档 A:当前版本修复步骤。

      从“网络连接失败”这个语义看,三份文档都很相关。但用户真正需要的是同时满足以下条件的内容:

      • 精确包含错误码 ERR_CONN_RESET_1007;
      • 适用于 Windows 客户端;
      • 文档状态为有效;
      • 版本与当前产品一致;
      • 内容中包含可执行的处理步骤。

      向量相似度只回答了“意思像不像”,并没有直接回答“这是不是当前问题的正确依据”。这也是生产环境中最常见的误区:

      相关文档不一定是可回答文档,相似度最高也不等于业务上最正确。


      三、错误文档是怎样进入结果集的?

      1. 查询本身没有表达完整意图

      真实用户很少按照知识库标题提问。他们可能输入:

      • “1007 怎么办”;
      • “客户端又断了”;
      • “刚才那个网络问题”;
      • “升级完打不开”;
      • “报错 conn reset”。

      这些查询可能缺少产品、平台、版本、时间范围和操作目标。Embedding 模型只能编码它看到的文本,无法凭空知道用户没有表达的信息。

      多轮对话中还会出现指代问题。例如用户先问 Windows 客户端,下一轮只说“那 Mac 呢?”。如果直接把第二句话送去检索,“那”和“Mac”很难构成完整检索意图。

      更稳妥的做法是在检索前生成一个独立、可检索的查询

      原始查询:那 Mac 呢?改写查询:Mac 客户端出现 ERR_CONN_RESET_1007 时应该如何处理?

      但查询改写也不能无限扩写。模型如果擅自补充用户没有说过的版本号,反而会把检索带偏。生产环境应该保留原始查询,并对改写结果做长度、实体和权限边界检查。

      2. 文档解析和 Chunking 已经丢失关键信息

      检索发生之前,错误可能已经埋下:

      • PDF 双栏内容被串行解析;
      • 表格只保留了数据,没有保留表头;
      • “适用版本”与操作步骤被切到不同 Chunk;
      • 页面页眉、导航栏和版权信息占据了大量文本;
      • 图片中的错误码没有经过 OCR;
      • 标题只存入 Metadata,却没有参与 Embedding。

      例如一个 Chunk 只包含:

      关闭代理设置,清理本地缓存后重新登录。

      它没有产品名、错误码和适用平台。即使内容是正确的,检索器也很难知道它应该回答哪个问题。

      可在索引文本中补充标题路径:

      [产品:桌面客户端][平台:Windows][错误码:ERR_CONN_RESET_1007][章节:处理步骤]关闭代理设置,清理本地缓存后重新登录。

      展示给用户时仍然可以使用原始正文,不必把这些辅助字段全部塞进最终答案。

      3. 向量检索不擅长所有类型的查询

      向量检索擅长语义匹配,例如:

      “怎么申请年假” ≈ “带薪休假申请流程”

      但对于下面这些内容,关键词检索通常更可靠:

      • 错误码:ERR_CONN_RESET_1007;
      • 产品型号:AX-3200 Pro;
      • API 名称:create_async_engine;
      • 合同编号:HT-2026-0713;
      • 人名、地名、缩写和内部术语。

      原因不难理解:Embedding 会把文本映射为稠密向量,强调总体语义;而 BM25 直接利用词项是否出现及其区分度,对精确字符串更敏感。

      因此,生产环境很少只依赖单一召回通道。更常见的组合是:

      Dense Retrieval:负责语义召回Sparse Retrieval:负责关键词和精确实体Metadata Filter:负责业务硬约束

      4. Metadata 缺失或过滤时机不对

      向量距离无法替代业务规则。下面这些条件更适合写入 Metadata:

      • tenant_id:租户;
      • department:部门;
      • permission_group:权限组;
      • product:产品;
      • platform:平台;
      • version:版本;
      • effective_at、expired_at:生效与失效时间;
      • status:草稿、有效、废弃;
      • language:语言。

      过滤还分为Pre-filterPost-filter

      Pre-filter 先缩小搜索空间,再计算相似度,适合权限、租户和文档状态等硬条件。Post-filter 先召回再过滤,实现简单,但可能出现“召回 20 条,过滤后只剩 1 条”的情况。

      尤其要避免先跨权限召回,再指望 Prompt 告诉模型不要使用。权限隔离必须发生在检索层,而不是生成层。

      5. Top K 配置掩盖了召回与排序问题

      Top K 太小,正确文档可能进不了候选集;Top K 太大,噪声、重复片段和 Token 成本都会增加。

      生产链路通常使用两阶段策略:

      第一阶段:从多路检索器中召回 20~100 个候选第二阶段:Rerank 后保留 3~8 个高质量片段

      第一阶段关注 Recall,尽量不要漏;第二阶段关注 Precision,把真正能回答问题的内容排到前面。

      直接把向量检索的 Top K 从 5 调到 50,并把 50 个 Chunk 全部交给大模型,并不是可靠的优化。更多上下文可能让真正的答案淹没在噪声中,还可能引入互相冲突的版本。

      6. Chunk 重复导致结果看似很多,实际信息单一

      假设相邻 Chunk 有较大的 Overlap,同一段答案可能连续命中 5 次。表面上 Top 5 都很相关,实际上它们来自同一页,其他可能包含答案的文档被挤出了结果集。

      常见处理方式包括:

      • 按 document_id + section_id 去重;
      • 对同一 Parent 下的多个 Child 合并;
      • 使用 MMR 增加结果多样性;
      • 限制单个文档最多进入几个 Chunk;
      • 根据 start_offset 合并相邻片段。

      7. 文档版本冲突,却没有新旧规则

      企业知识库中最危险的错误,不是“没找到”,而是“找到了已经失效的答案”。

      同一制度可能同时存在草稿版、现行版和历史版;同一产品可能有多个大版本。它们的文本高度相似,旧文档甚至可能因为措辞更接近用户问题而排名更高。

      建议在入库时明确保存:

      { ”document_id”: ”client-error-guide”, ”version”: ”4.2”, ”status”: ”active”, ”effective_at”: ”2026-06-01”, ”expired_at”: null, ”supersedes”: ”client-error-guide@4.1}

      默认检索只搜索当前有效版本;只有用户明确询问历史规则时,才放开历史文档。


      四、生产环境为什么需要混合检索?

      混合检索不是简单地“向量结果加上 BM25 结果”,还需要解决不同检索器分数不可比的问题。

      向量数据库可能返回余弦相似度,BM25 返回自己的相关性分数。直接把二者相加通常没有稳定意义。更常见的融合方法是Reciprocal Rank Fusion,简称 RRF

      它不依赖原始分数,只关心文档在各列表中的排名:

      RRF(d) = Σ 1 / (c + rank_i(d))

      其中 c 是平滑常数,常用值为 60;rank_i(d) 是文档 d 在第 i 个召回列表中的名次。

      假设文档 A 在向量检索中排第 6,在 BM25 中排第 1;文档 B 在向量检索中排第 1,但在 BM25 中没有出现。融合后,A 会因为两个通道都提供了证据而获得更稳定的排名。

      混合检索尤其适合以下数据:

      • 技术文档和 API 文档;
      • 客服知识库;
      • 产品手册和故障码;
      • 同时包含自然语言与专有名词的企业文档;
      • 用户表达与文档措辞差异较大的场景。

      五、Rerank 到底在解决什么问题?

      Embedding 检索通常采用双塔结构:查询和文档分别编码,文档向量可以提前计算,因此速度快,适合从百万级数据中找出几十个候选。

      Reranker 通常同时读取“查询 + 候选文档”,逐一判断相关性。它计算更慢,却能捕捉更细粒度的对应关系,例如:

      • 文档是否真的回答了问题,而不只是主题相近;
      • 错误码、平台和处理动作是否同时匹配;
      • 条件、例外和结论是否完整;
      • 文档是否包含用户要求的步骤或数值。

      二者不是替代关系,而是分工关系:

      Embedding 负责从海量文档中快速“捞出来”,Reranker 负责从少量候选中仔细“排清楚”。

      常见方案包括:

      方案优点需要注意
      Cross-Encoder排序精度高,可私有化候选过多时延迟明显
      BGE Reranker中文支持好,开源模型较多需要部署推理服务
      Cohere RerankAPI 使用简单,多语言能力成熟外部调用成本与数据合规
      LLM Rerank可加入复杂业务判断成本高、延迟高、结果稳定性需验证

      不要一开始就对几百个候选做 Rerank。比较实用的起点是:多路召回各取 20~30 条,融合去重后保留 30~50 条,再重排到 5~8 条。


      六、LangChain 1.x 实战:BM25 + 向量检索 + RRF + Rerank

      下面实现一条便于理解的本地链路。示例使用 Chroma、BM25 和开源 Cross-Encoder,实际项目可以替换为 Milvus、Qdrant、Elasticsearch、Cohere Rerank 等组件。

      安装依赖:

      pip install -U langchain-core langchain-community langchain-chroma \ langchain-huggingface rank-bm25 sentence-transformers

      先准备文档。生产环境中,Metadata 应在解析和入库阶段生成:

      from langchain_core.documents import Documentdocuments = [ Document( page_content=( "Windows 客户端出现 ERR_CONN_RESET_1007 时,先关闭系统代理," "清理客户端缓存,然后重新登录。适用于 4.2 及以上版本。" ), metadata={ "doc_id""error-guide-4.2", "title""桌面客户端错误码处理指南", "platform""windows", "status""active", "version""4.2", }, ), Document( page_content=( "网络连接失败时,可检查防火墙、DNS 和代理配置,确认网络可用后重试。" ), metadata={ "doc_id""network-general", "title""通用网络问题排查", "platform""all", "status""active", "version""4.2", }, ), Document( page_content=( "ERR_CONN_RESET_1007:卸载客户端并重新安装 3.x 安装包。" ), metadata={ "doc_id""error-guide-3.x", "title""旧版客户端错误码说明", "platform""windows", "status""deprecated", "version""3.x", }, ),]

      建立 BM25 与向量检索器:

      from langchain_community.retrievers import BM25Retrieverfrom langchain_chroma import Chromafrom langchain_huggingface import HuggingFaceEmbeddingsbm25 = BM25Retriever.from_documents(documents)bm25.k = 10embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-m3", encode_kwargs={"normalize_embeddings"True},)vector_store = Chroma.from_documents( documents=documents, embedding=embeddings, collection_name="support_docs",)vector_retriever = vector_store.as_retriever( search_type="similarity", search_kwargs={ "k"10, "filter": {"status""active"}, },)

      示例为了突出流程,只在向量侧展示了 Filter。正式系统应保证所有召回通道使用相同的权限和有效性约束。最简单的方法是在建立 BM25 索引前就排除用户无权访问或已经废弃的文档。

      接着使用 RRF 融合两个排名列表:

      from collections import defaultdictdef rrf_fusion(result_lists, *, c=60): scores = defaultdict(float) docs_by_id = {} for results in result_lists: for rank, doc in enumerate(results, start=1): doc_id = doc.metadata["doc_id"] scores[doc_id] += 1.0 / (c + rank) docs_by_id[doc_id] = doc ranked_ids = sorted(scores, key=scores.get, reverse=True) return [docs_by_id[doc_id] for doc_id in ranked_ids]query = "Windows 客户端出现 ERR_CONN_RESET_1007,应该怎么处理?"vector_docs = vector_retriever.invoke(query)keyword_docs = bm25.invoke(query)fused_docs = rrf_fusion([vector_docs, keyword_docs])

      最后使用 Cross-Encoder 对融合后的候选重排序:

      from sentence_transformers import CrossEncoderreranker = CrossEncoder("BAAI/bge-reranker-v2-m3")def rerank(query, documents, *, top_n=5): pairs = [(query, doc.page_content) for doc in documents] relevance_scores = reranker.predict(pairs) ranked = sorted( zip(documents, relevance_scores), key=lambda item: float(item[1]), reverse=True, ) return [ (doc, float(score)) for doc, score in ranked[:top_n] ]final_results = rerank(query, fused_docs, top_n=3)for doc, score in final_results: print(round(score, 4), doc.metadata["doc_id"], doc.page_content)

      这段代码展示的是核心机制。真正上线时还需要补上:

      • 中文 BM25 分词,不要直接依赖空格切词;
      • 租户与权限过滤;
      • 查询改写的超时和降级策略;
      • 候选去重与相邻 Chunk 合并;
      • Rerank 批处理、缓存和超时;
      • 统一的链路日志与指标;
      • 最终答案引用和原文定位。

      七、不要只看最终答案:检索应该单独评测

      如果评测时只问大模型“这个回答好不好”,很难定位优化发生在哪一层。检索评测至少应该独立计算以下指标。

      1. Recall@K:正确内容有没有进入候选集

      Recall@K = 前 K 条中被召回的相关文档数 / 所有相关文档数

      如果一个问题只有一个标注答案文档,那么它可以简化为:正确文档是否出现在前 K 条中。

      Recall@K 低,优先检查查询改写、Chunk、Embedding、召回通道和过滤条件。

      2. MRR:第一个正确结果排得够不够靠前

      MRR = Mean(1 / 第一个相关结果的排名)

      正确文档排第 1,得分为 1;排第 5,得分为 0.2。MRR 对首个正确答案的位置非常敏感,适合 FAQ、客服问答等通常只需要一个主要依据的场景。

      3. nDCG@K:多个相关结果的整体排序质量

      当不同文档存在“完全相关、部分相关、不相关”等多级标签时,nDCG 比简单命中率更合适。它既考虑相关程度,也考虑排名位置。

      4. Precision@K:最终上下文中有多少真正有用

      Rerank 后的 5 个片段如果只有 1 个相关,即使 Recall 很高,最终生成仍会受到大量噪声影响。

      除了离线指标,还要记录线上指标:

      • 检索 P50、P95、P99 延迟;
      • 各召回通道的超时率;
      • Rerank 平均候选数;
      • 每次请求送入模型的上下文 Token;
      • 无答案率、引用点击率和用户纠错率;
      • 单次查询成本。

      好的检索方案不是某个指标最高,而是在准确率、延迟、成本和权限安全之间达到可接受的平衡。


      八、如何建立一套真正有用的检索评测集?

      建议从真实业务日志中抽取 100~500 个问题作为第一版,而不是让大模型凭空生成一批“标准问题”。数据至少要覆盖:

      • 用户正常表达;
      • 缩写、别名和口语表达;
      • 错别字与不完整问题;
      • 错误码、型号、编号等精确查询;
      • 多轮对话中的指代;
      • 有权限和无权限问题;
      • 新旧版本冲突;
      • 知识库中没有答案的问题。

      每条样本可以记录:

      { ”query”: ”1007 报错怎么处理”, ”relevant_doc_ids”: [”error-guide-4.2”], ”relevant_chunk_ids”: [”error-guide-4.2#section-6#chunk-2”], ”filters”: { ”platform”: ”windows”, ”status”: ”active” }, ”answerable”: true, ”tags”: [”error_code”, ”short_query”]}

      document_id 适合衡量是否找对文档,chunk_id 适合衡量是否定位到真正包含答案的片段。两者最好同时保留。

      评测时不要只比较“方案 A 的 Recall 是 0.82,方案 B 是 0.85”,还应该按标签拆分。例如总体提升可能来自普通语义问题,但错误码查询反而下降。只有分桶分析,才能知道混合检索到底解决了什么问题。


      九、一套可复用的检索故障排查顺序

      当线上出现错误答案时,可以按照下面的顺序定位:

      第一步:确认答案是否存在

      先检查知识库中有没有答案,以及文档是否成功解析、切分和入库。如果源数据中根本没有答案,系统应该拒答或转人工,而不是继续调整检索参数。

      第二步:检查正确 Chunk 是否进入大候选集

      把召回范围临时扩大到 Top 50 或 Top 100,用于诊断,而不是直接送给大模型。

      • 没进入:检查查询、索引内容、Embedding、BM25 和 Filter;
      • 已进入:说明召回基本成功,继续看排序。

      第三步:检查正确 Chunk 为什么排名低

      观察各通道排名:

      • BM25 高、向量低:可能是精确实体查询,应提高关键词通道权重;
      • 向量高、BM25 低:属于语义改写,向量通道正在发挥作用;
      • 两边都低:检查 Chunk 是否缺少标题、实体或关键上下文;
      • 旧版文档高:检查版本和状态过滤;
      • 同一文档占满 Top K:增加去重、MMR 或单文档配额。

      第四步:检查 Rerank 是否真的改善排序

      对比 Rerank 前后的 MRR、nDCG 和延迟。如果总体指标没有稳定提升,可能是模型不适合当前语言或领域,也可能是候选阶段根本没有召回正确内容。

      第五步:检查最终上下文

      确认是否出现:

      • 正确片段被 Token 截断;
      • 多个版本同时进入上下文;
      • 相邻 Chunk 顺序错误;
      • 表格失去表头;
      • 引用 ID 与正文错位;
      • Prompt 中指令和资料边界不清。

      第六步:最后再检查生成模型

      只有当正确、完整、无冲突的上下文已经送入模型,而答案仍然错误时,才应该重点调整 Prompt、模型或生成参数。

      这个顺序能避免一种常见情况:检索层根本没找到答案,团队却连续几天调整大模型提示词。


      十、生产环境中的推荐基线

      不同业务没有统一的最优配置,但可以从下面这套基线开始:

      查询标准化:清理无意义字符,保留原始查询查询改写:仅在多轮、短查询或别名场景启用权限与状态 Pre-filter:租户、权限、有效版本多路召回:Dense Top 30 + BM25 Top 30RRF 融合:按稳定 doc_id 去重候选整理:合并相邻 Chunk,限制单文档数量Rerank:候选 3050,最终保留 58上下文组装:按文档结构排序,控制 Token 预算生成:要求基于证据回答并返回引用10. 监控:保存每一阶段的排名、分数、耗时和版本

      上线前至少做三组对照:

      方案用途
      纯向量检索基线
      向量 + BM25 + RRF判断混合召回收益
      混合召回 + Rerank判断精排收益与延迟成本

      不要一次同时更换 Embedding、Chunking、检索器和 Reranker。变量全部改变后,即使指标提升,也很难知道收益来自哪里,更无法稳定复现。


      总结

      RAG 总是召回错误文档,通常不是向量数据库“搜错了”,而是整条检索链路中有一个或多个环节没有表达真实业务约束。

      本文最重要的几个结论是:

        当检索链路变得可观察、可评测之后,很多“玄学问题”都会变成可以定位和验证的工程问题。