但到了生产环境,问题会突然变得复杂:
用户输入的是缩写、错别字或一句不完整的话; 同一知识点存在多个版本,旧文档反而排在前面; 错误码、产品型号和合同编号无法被准确匹配; 检索结果看起来“语义相关”,却没有真正回答问题; 明明召回了正确片段,最终答案仍然引用了错误内容。
这时,团队最常见的反应是换 Embedding 模型、调大 Top K,或者换一个向量数据库。但真正的问题往往不在某一个组件,而在整条检索链路。
RAG 检索不是一次向量相似度计算,而是一条由查询理解、候选召回、过滤、融合、重排序和上下文组装共同构成的流水线。
我会用一个具体案例,把生产环境中的检索链路逐层拆开,并给出一套基于 LangChain 1.x 的混合检索与 Rerank 实现。
一、先判断:究竟是“没找到”,还是“没答好”?
用户说“RAG 回答错了”,背后至少可能有三类问题:
检索失败:正确文档没有进入候选集;
排序失败:正确文档被召回了,但排名太低,没有进入最终上下文;
生成失败:正确文档已经进入上下文,但大模型忽略、误读或混合了冲突内容。
这三种问题的解决方式完全不同。
如果正确文档根本没有进入 Top 50,增加 Prompt 约束通常没有意义;如果正确文档排在第 18 位,而系统只取前 5 条,就应该优化排序;如果前 3 条已经包含完整答案,则应该检查上下文组装和生成环节。
因此,排查 RAG 的第一步不是改参数,而是保存每个阶段的中间结果:

至少需要记录:
原始查询和改写后的查询; 各召回通道返回的文档 ID、分数和排名; Metadata Filter 的条件和过滤数量; 融合后的候选列表; Rerank 前后的排名变化; 最终进入 Prompt 的片段及 Token 数; 答案引用的来源。
没有这些数据,“检索效果差”只能停留在感觉层面。
二、一个典型案例:语义相似,但答案不对
假设公司知识库中有三份文档:
| 文档 | 内容摘要 | 状态 |
|---|---|---|
| A | ERR_CONN_RESET_1007 的原因和修复步骤 | 当前有效 |
| B | 常见网络连接失败的通用排查方法 | 当前有效 |
| C | 旧版客户端的 ERR_CONN_RESET_1007 处理方法 | 已废弃 |
用户提问:
Windows 客户端出现 ERR_CONN_RESET_1007,应该怎么处理?
只使用向量检索时,系统可能返回:
文档 B:网络连接异常排查;
文档 C:旧版客户端错误码说明;
文档 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-filter和Post-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 Rerank | API 使用简单,多语言能力成熟 | 外部调用成本与数据合规 |
| 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] = docranked_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:候选 30~50,最终保留 5~8上下文组装:按文档结构排序,控制 Token 预算生成:要求基于证据回答并返回引用10. 监控:保存每一阶段的排名、分数、耗时和版本
上线前至少做三组对照:
| 方案 | 用途 |
|---|---|
| 纯向量检索 | 基线 |
| 向量 + BM25 + RRF | 判断混合召回收益 |
| 混合召回 + Rerank | 判断精排收益与延迟成本 |
不要一次同时更换 Embedding、Chunking、检索器和 Reranker。变量全部改变后,即使指标提升,也很难知道收益来自哪里,更无法稳定复现。
总结
RAG 总是召回错误文档,通常不是向量数据库“搜错了”,而是整条检索链路中有一个或多个环节没有表达真实业务约束。
本文最重要的几个结论是:
当检索链路变得可观察、可评测之后,很多“玄学问题”都会变成可以定位和验证的工程问题。
夜雨聆风