ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

企业知识库 RAG 多路召回与重排:从 38% 命中率到 91% 的工程复盘

企业知识库 RAG 多路召回与重排:从 38% 命中率到 91% 的工程复盘 1. 背景售后知识库问答命中率只有 38%我朋友的公司是一家做企业级 SaaS 的厂商产品里内置了一个面向客户售后团队的「知识库问答助手」。客户把产品手册、工单沉淀、故障排查文档全部灌进来售后人员直接在对话框里提问期望秒级返回答案。上线三个月我朋友拿到一组不太好看的数据日均查询 1.2 万次检索命中率Top-5 内包含正确答案只有 38%平均首响耗时 1.8 秒。售后同学的真实反馈是「问它不如自己翻文档」知识库助手基本处于半废弃状态。一开始我朋友怀疑是 Embedding 模型选得不对换过 bge-large-zh、text-embedding-ada-002命中率也就涨到 42% 左右卡住不动了。后来把失败 query 拉出来逐条看才发现问题根本不在向量化而在召回策略太单一。2. 踩坑单路向量召回的三层失效我朋友把 2000 条失败 query 做了人工标注归纳出三类典型失效第一类关键词精确匹配失效。售后问「A 型号设备的恢复出厂设置」文档里写的是「A 设备重置」。向量语义上接近但 Top-5 里经常排不进。这类占比约 41%。第二类专有名词/型号被向量化稀释。问「B-2000 报错 0x7F」Embedding 把 B-2000 和 0x7F 这种 token 切碎后语义权重被稀释精确型号反而匹配不到。占比约 33%。第三类长文档切片后语义碎片化。我朋友把文档按 512 token 切块答案跨在两个 chunk 之间时单块向量和 query 的相似度都不高导致漏召回。占比约 26%。根因一句话总结向量召回擅长「语义相近」但企业知识库里有大量「关键词精确、语义稀疏」的查询单靠一路向量召回必然漏。3. 方案多路召回 Rerank 重排3.1 方案对比方案召回策略优点缺点A仅向量召回单路 Embedding Top-K实现简单、语义泛化好精确匹配弱、专有名词易漏B向量 BM25 多路召回无重排两路各自 Top-K 后直接合并去重召回率提升明显合并后排序混乱精确与语义分数不可比C向量 BM25 多路召回 Rerank 重排两路召回 Top-50送入 Cross-Encoder 重排取 Top-5精度最高、排序合理多一次模型推理需控制耗时我们最终选了方案 C。选型依据售后场景对「答案准」的诉求远高于「快几毫秒」且 Rerank 模型bge-reranker-large在 GPU 上单条推理约 30ms完全可接受。3.2 整体架构用户 QueryQuery 改写与意图识别向量召回 Top-50BM25 关键词召回 Top-50合并去重Rerank 重排 Top-5LLM 生成答案Query 进来后先做轻量改写补全同义词、提取专有名词然后并行走向量召回和 BM25 召回各自取 Top-50合并去重后交给 Rerank 模型统一打分排序最后取 Top-5 送入 LLM。4. 实操环境与代码4.1 环境版本组件版本Python3.10langchain0.2.11langchain-community0.2.10pgvector0.7.0PostgreSQL 16bge-large-zh-v1.5Embedding 模型bge-reranker-largeRerank 模型rank_bm250.2.24.2 多路召回 重排核心代码fromrank_bm25importBM25Okapifromsentence_transformersimportCrossEncoderfrompgvector.sqlalchemyimportVector# 向量召回pgvector 中按余弦相似度取 Top-50defvector_recall(query_emb,top_k50):sql SELECT chunk_id, content, 1 - (embedding :emb) AS score FROM doc_chunks ORDER BY embedding :emb LIMIT :top_k # 执行 SQL返回 (chunk_id, content, score)# BM25 召回对分词后的语料建索引取 Top-50defbm25_recall(query_tokens,top_k50):bm25BM25Okapi(corpus_tokens)# corpus_tokens 为全量文档分词结果scoresbm25.get_scores(query_tokens)top_idxsorted(range(len(scores)),keylambdai:scores[i],reverseTrue)[:top_k]return[(chunk_ids[i],corpus[i],scores[i])foriintop_idx]# RerankCross-Encoder 对合并后的候选统一打分rerankerCrossEncoder(BAAI/bge-reranker-large)defrerank(query,candidates,top_k5):pairs[(query,content)for_,content,_incandidates]scoresreranker.predict(pairs)rankedsorted(zip(candidates,scores),keylambdax:x[1],reverseTrue)return[item[0]foriteminranked[:top_k]]预期运行结果单条 query 全链路耗时约 220ms向量召回 40ms BM25 20ms Rerank 150ms 网络开销相比之前单路向量召回的 1.8 秒反而更快——因为之前是把 Top-5 直接喂给 LLM现在 Rerank 后 Top-5 质量更高LLM 一次就能生成正确答案减少了重试。5. 踩坑与排错5.1 坑一BM25 中文分词缺失导致召回为 0第一次跑 BM25发现很多 query 召回结果为空。排查发现rank_bm25默认按空格分词而中文没有空格整个 query 被当成一个 tokenBM25 完全失效。报错现象BM25Okapi对中文 query 返回的 scores 全为 0。解决接入 jieba 分词对 query 和文档统一分词后再进 BM25。importjiebadeftokenize(text):returnlist(jieba.cut(text))corpus_tokens[tokenize(doc)fordocincorpus]query_tokenstokenize(query)5.2 坑二Rerank 输入过长导致显存溢出bge-reranker-large 最大输入长度 512 token但我们的文档 chunk 有 512 token加上 query 后拼接超长推理时报CUDA out of memory。报错信息RuntimeError: CUDA out of memory. Tried to allocate 128.00 MiB解决把文档 chunk 从 512 token 降到 256 token同时 Rerank 前对候选做一次长度截断超过 480 token 的 chunk 先按句号切分取关键句。5.3 坑三两路召回分数不可比导致合并后排序错乱早期方案 B 直接把向量分数和 BM25 分数相加排序结果 BM25 分数普遍偏高Top-5 几乎全被 BM25 占据向量召回的语义优势被淹没。解决放弃手工融合分数统一交给 Rerank 模型打分。Rerank 的 Cross-Encoder 天然把 query 和 chunk 拼接后计算相关性分数天然可比不需要人工设计权重。6. 验证数据与效果上线后观察两周核心指标变化指标优化前优化后检索命中率Top-538%91%平均首响耗时1.8s0.9s售后问题解决率无需人工介入31%67%日均查询量1.2 万2.8 万命中率从 38% 涨到 91%耗时反而从 1.8s 降到 0.9s。原因在于之前单路向量召回经常漏召回LLM 拿不到正确答案会「硬编」用户不满意就反复追问拖高了整体耗时现在召回准了一次问答就结束。7. 复盘什么场景该用 / 不该用适用场景知识库里有大量专有名词、型号、编号查询以精确匹配为主文档切片后存在跨 chunk 的答案碎片化问题对答案准确率要求高能接受多一次 Rerank 推理的延迟成本。不适用场景纯闲聊型问答语义泛化为主BM25 收益不大对延迟极度敏感如实时客服流式交互要求 200msRerank 的 150ms 可能成为瓶颈此时可考虑用更轻量的 bge-reranker-base 或蒸馏后的模型知识库体量极小1000 条单路向量召回已足够多路召回属于过度设计。边界提醒Rerank 不是万能的它依赖前两路召回的候选质量。如果向量和 BM25 都漏掉了正确答案Rerank 也无能为力。所以多路召回的核心价值在于「提高召回上限」Rerank 的价值在于「从候选里挑出最准的」两者缺一不可。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进