:RAG 知识库优化(下)——用 Cross-Encoder 重排提升召回精度)
AI智能体项目实战记录29RAG 知识库优化下——用 Cross-Encoder 重排提升召回精度1. 引言前两篇解决了存哪里、查哪个库篇号内容27上建库表、kb_id元数据、上传选库、旧集合迁移28中检索过滤、库路由、空结果兜底、权限收敛这一篇解决最后一个问题排序准不准。现状很直接——检索只做了一次向量相似度排序没有任何重排问 题 → embedding → Milvus.search(top5, filterkb_id) → 直接拼进 prompt这篇把 cross-encoder 重排补上并且做成开关式默认关闭、模型缺失自动降级不影响现有链路。2. 为什么向量排序不够2.1 双塔的先天限制embedding 检索是双塔结构query ──▶ [编码器] ──▶ 向量 ─┐ ├──▶ 余弦相似度 doc ──▶ [编码器] ──▶ 向量 ─┘两边的编码互不知道对方只是最后算个距离。好处是 doc 可以离线预编码、查询时只算一次 query 向量坏处是缺少词级/句级交互。实际表现现象例子词面相似但答非所问问补贴标准召回补贴申请流程长文档切片后语义稀释一段里只有一句相关整体向量被拉偏同义改写漏排问优惠政策文档写扶持措施专有名词被泛化问某个具体产品型号召回同系列其它型号2.2 现在的补救其实是让大模型做重排rag_tool的提示词里有这么一句- 并非搜索结果的所有内容都与用户的问题密切相关你需要结合问题对搜索结果进行甄别、筛选。这等于把排序这件事丢给大模型代价是5 条无关内容照样进 promptprompt 变长、token 白花大模型在长上下文里容易看漏尤其在 3~5 条都很像的时候结果不可控——同一问题两次回答可能引用不同片段。3. 方案召回与排序分成两段query │ ├─ ① 向量召回双塔快 top_k × RERANK_CANDIDATES 15 条 │ ├─ ② 重排cross-encoder准 15 对 (query, chunk) 联合打分 │ └─ ③ 截断 取 top 5 进 prompt关键在第一步召回池必须放大。如果只召回 5 条正确答案可能压根没进来重排再准也没用。默认放大 3 倍5 → 15。cross-encoder 的结构[CLS] query [SEP] chunk [SEP] ──▶ Transformer ──▶ 相关性分数query 和 chunk 拼在一起过一遍模型能捕捉这句话是否回答了这个问题而不是两个向量像不像。4. 代码实现4.1 配置.env# 向量重排Rerank # 是否开启重排默认关闭开启后先扩大召回池再用 cross-encoder 重排 RERANK_ENABLEDfalse # 重排模型目录sentence-transformers 格式如 bge-reranker-base 的本地路径 RERANK_MODEL_PATH # 重排设备留空跟随 DEVICE RERANK_DEVICE # 召回池倍数先取 top_k * 该值再重排截断 RERANK_CANDIDATES3 # 重排后保留条数0 表示与检索 top_k 一致 RERANK_TOP_N0 # 重排推理 batch size RERANK_BATCH_SIZE8settings.py对应 6 个字段全部有默认值不配置也能正常启动。4.2 重排服务新增app/service/system/rerank_service.py_modelNone_load_failedFalse_lockthreading.Lock()def_get_model():懒加载 cross-encoder 模型进程内单例失败后不再重试global_model,_load_failedif_modelisnotNoneor_load_failed:return_modelwith_lock:if_modelisnotNoneor_load_failed:return_model path(setting.rerank_model_pathor).strip()ifnotpath:logger.warning([rerank] 未配置 RERANK_MODEL_PATH跳过重排按向量顺序返回)_load_failedTruereturnNonetry:fromsentence_transformersimportCrossEncoder device(setting.rerank_deviceorsetting.deviceorcpu).strip()_modelCrossEncoder(path,max_length512,devicedevice)logger.info(f[rerank] 重排模型已加载:{path}(device{device}))exceptExceptionase:logger.error(f[rerank] 重排模型加载失败后续按向量顺序返回:{e})_load_failedTruereturn_modeldefrerank(question:str,hits:List[Dict[str,Any]],top_n:int)-List[Dict[str,Any]]:ifnothits:return[]iftop_n0ortop_nlen(hits):top_nlen(hits)model_get_model()ifmodelisNone:returnhits[:top_n]# 降级模型不可用保持向量顺序try:pairs[(question,hit.get(doc_str)or)forhitinhits]scoresmodel.predict(pairs,batch_sizemax(1,int(setting.rerank_batch_sizeor8)),show_progress_barFalse,)forhit,scoreinzip(hits,scores):hit[rerank_score]float(score)rankedsorted(hits,keylambdax:x.get(rerank_score,0.0),reverseTrue)returnranked[:top_n]exceptExceptionase:logger.warning(f[rerank] 重排失败回退为向量顺序:{e})returnhits[:top_n]四个设计点点原因懒加载 单例不在 import 时吃内存多线程并发时只加载一次失败只告警一次_load_failed标记避免每次请求都刷日志、都试一遍加载模型缺失直接降级没配路径就按向量顺序返回功能不中断推理异常降级单条 chunk 过长、显存不足等也不影响问答4.3 接入检索链路只改knowledge_search_service.search()一处# 开启重排时先扩大召回池否则重排没有可选空间pooltop_kifsetting.rerank_enabled:timesmax(1,int(setting.rerank_candidatesor1))poolmax(top_k,top_k*times)try:hits_search_with_filter(question,codes,pool)exceptExceptionase:logger.warning(f[kb-search] 按知识库过滤检索失败降级为全库检索:{e})hits_search_legacy(question,pool)# 兜底显式指定了库但没结果扩大到全部可见库再试一次ifnothitsandkb_codes:...# 重排用 cross-encoder 对 (question, chunk) 联合打分后截断ifsetting.rerank_enabledandhits:keepint(setting.rerank_top_nor0)ortop_k hitsrerank_service.rerank(question,hits,keep)returnhits[:top_k]注意pool要贯穿三处正常检索、降级检索、兜底重试否则打开重排后召回池还是 5 条。5. 实测5.1 未配置模型时自动降级[rerank] 未配置 RERANK_MODEL_PATH跳过重排按向量顺序返回 available: False fallback order: [1, 2]打开开关但没配模型不会报错、不会拖慢检索只是按原顺序返回。5.2 排序与截断桩模型验证用一个给第 2 条高分的假模型验证排序链路注入桩模型后 reranked: [(2, 0.9), (3, 0.5)] ← 按 rerank 分数重排并截断到 2 条说明predict → 写回 rerank_score → 排序 → 截断这条链路是通的。真实模型只需把RERANK_MODEL_PATH指向本地目录即可代码不用改。5.3 检索链路整体验证未开重排: [(chat_py系统技术架构与部署文档.md, 0.557), (chat_py系统技术架构与部署文档.md, 0.557), (AI智能体项目实战记录14…skill技能接入….md, 0.547)] 开启重排召回池 5→9: [rerank0.9, rerank0.5, rerank0.1] 条数一致: True召回池从 5 扩到 9top_k3 × 3重排后仍返回 3 条。本地环境没有下载 reranker 模型所以上面第 2、3 组用的是桩模型验证的是接入是否正确、降级是否生效真实效果需要配上模型后观察。6. 上线步骤1. 下载模型任选其一 - modelscopeBAAI/bge-reranker-base - huggingfaceBAAI/bge-reranker-base 2. 配置 .env RERANK_ENABLEDtrue RERANK_MODEL_PATHD:/AI_Model/modelscope/AI-ModelScope/bge-reranker-base 3. 重启服务确认日志 [rerank] 重排模型已加载: ... (devicecpu) 4. 观察三件事 - 检索耗时日志里 cost - 命中片段与问题的相关性 - CPU 占用重排和 embedding 会争核模型选型模型体积量级CPU 延迟15 条适用bge-reranker-base中约 50~150ms中文为主、CPU 部署推荐起步bge-reranker-v2-m3大约 150~400ms效果更好需要更多内存远程 rerank API—网络往返 100~300ms不想占本地资源调参建议参数建议RERANK_CANDIDATES315 条候选库大且噪声多可以到 4~5RERANK_TOP_N0跟随 top_k5prompt 预算紧可以设 3RERANK_BATCH_SIZECPU 8GPU 16~32RERANK_DEVICE留空跟随DEVICE有 GPU 建议显式cuda7. 注意事项#坑说明1召回池不放大只对 5 条重排等于没重排正确结果可能根本没进来2分数混用COSINE0~1与 rerank logits无界量纲不同只能各自排序不要加权平均3多库时单库霸榜召回池放大后仍可能被一个库占满必要时按库分配候选再合并4CPU 争抢重排与 embedding 都是 CPU 密集型RERANK_BATCH_SIZE别开太大必要时限线程5延迟叠加检索 20ms 重排 ~100ms 大模型 2s占比不大但接口 P99 会上升6模型版本固定与 embedding 同理换 reranker 不要求重灌向量它不改向量但排序风格会变要一起评估最后一个点值得展开reranker 换模型不用重灌向量它只在检索时对文本打分不产生入库向量。这是它比换 embedding 模型低风险的地方。8. 小结这一篇只做了一件事把召回和排序拆开。召回继续用双塔向量快、可离线建索引排序换成 cross-encoder慢一点但能看到 query 与 chunk 的交互全链路开关式默认关闭、模型缺失降级、推理异常降级只改了一个入口函数其他代码工具、主管、MCP完全不用动。三篇串起来看27上 数据怎么存 → 建库表 kb_id 元数据 上传选库 28中 查哪个库 → 检索过滤 库路由 空结果兜底 29下 怎么排得更准 → 扩大召回 cross-encoder 重排再往下可以做的方向混合检索向量 关键词BM25 / sparse vector专有名词和编号类问题提升明显检索缓存相同问题直接复用命中省一次 embedding 重排按库配额召回多库场景下每库各取 N 条避免单库占满评估集攒 50~100 个问题 → 正确文档样本改任何检索参数前后跑一遍用数字说话而不是凭感觉。