AI智能体项目实战记录(29):RAG 知识库优化(下)——用 Cross-Encoder 重排提升召回精度

发布时间:2026/10/31 20:15:44
AI智能体项目实战记录(29):RAG 知识库优化(下)——用 Cross-Encoder 重排提升召回精度

AI智能体项目实战记录(29):RAG 知识库优化(下)——用 Cross-Encoder 重排提升召回精度

1. 引言

前两篇解决了"存哪里、查哪个库":

篇号内容
27(上)建库表、kb_id元数据、上传选库、旧集合迁移
28(中)检索过滤、库路由、空结果兜底、权限收敛

这一篇解决最后一个问题:排序准不准。

现状很直接——检索只做了一次向量相似度排序,没有任何重排:

问 题 → embedding → Milvus.search(top5, filter=kb_id) → 直接拼进 prompt

这篇把 cross-encoder 重排补上,并且做成开关式:默认关闭、模型缺失自动降级,不影响现有链路。


2. 为什么向量排序不够

2.1 双塔的先天限制

embedding 检索是"双塔"结构:

query ──▶ [编码器] ──▶ 向量 ─┐ ├──▶ 余弦相似度 doc ──▶ [编码器] ──▶ 向量 ─┘

两边的编码互不知道对方,只是最后算个距离。好处是 doc 可以离线预编码、查询时只算一次 query 向量;坏处是缺少词级/句级交互。

实际表现:

现象例子
词面相似但答非所问问"补贴标准",召回"补贴申请流程"
长文档切片后语义稀释一段里只有一句相关,整体向量被拉偏
同义改写漏排问"优惠政策",文档写"扶持措施"
专有名词被泛化问某个具体产品型号,召回同系列其它型号

2.2 现在的"补救"其实是让大模型做重排

rag_tool的提示词里有这么一句:

- 并非搜索结果的所有内容都与用户的问题密切相关,你需要结合问题,对搜索结果进行甄别、筛选。

这等于把"排序"这件事丢给大模型,代价是:

  • 5 条无关内容照样进 prompt,prompt 变长、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_ENABLED=false # 重排模型目录(sentence-transformers 格式,如 bge-reranker-base 的本地路径) RERANK_MODEL_PATH="" # 重排设备,留空跟随 DEVICE RERANK_DEVICE="" # 召回池倍数:先取 top_k * 该值,再重排截断 RERANK_CANDIDATES=3 # 重排后保留条数,0 表示与检索 top_k 一致 RERANK_TOP_N=0 # 重排推理 batch size RERANK_BATCH_SIZE=8

settings.py对应 6 个字段,全部有默认值,不配置也能正常启动。

4.2 重排服务

新增app/service/system/rerank_service.py:

_model=None_load_failed=False_lock=threading.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_failed=TruereturnNonetry:fromsentence_transformersimportCrossEncoder device=(setting.rerank_deviceorsetting.deviceor"cpu").strip()_model=CrossEncoder(path,max_length=512,device=device)logger.info(f"[rerank] 重排模型已加载:{path}(device={device})")exceptExceptionase:logger.error(f"[rerank] 重排模型加载失败,后续按向量顺序返回:{e}")_load_failed=Truereturn_modeldefrerank(question:str,hits:List[Dict[str,Any]],top_n:int)->List[Dict[str,Any]]:ifnothits:return[]iftop_n<=0ortop_n>len(hits):top_n=len(hits)model=_get_model()ifmodelisNone:returnhits[:top_n]# 降级:模型不可用,保持向量顺序try:pairs=[(question,hit.get("doc_str")or"")forhitinhits]scores=model.predict(pairs,batch_size=max(1,int(setting.rerank_batch_sizeor8)),show_progress_bar=False,)forhit,scoreinzip(hits,scores):hit["rerank_score"]=float(score)ranked=sorted(hits,key=lambdax:x.get("rerank_score",0.0),reverse=True)returnranked[:top_n]exceptExceptionase:logger.warning(f"[rerank] 重排失败,回退为向量顺序:{e}")returnhits[:top_n]

四个设计点:

点原因
懒加载 + 单例不在 import 时吃内存;多线程并发时只加载一次
失败只告警一次_load_failed标记,避免每次请求都刷日志、都试一遍加载
模型缺失直接降级没配路径就按向量顺序返回,功能不中断
推理异常降级单条 chunk 过长、显存不足等也不影响问答

4.3 接入检索链路

只改knowledge_search_service.search()一处:

# 开启重排时先扩大召回池,否则重排没有可选空间pool=top_kifsetting.rerank_enabled:times=max(1,int(setting.rerank_candidatesor1))pool=max(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:keep=int(setting.rerank_top_nor0)ortop_k hits=rerank_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): ['rerank=0.9', 'rerank=0.5', 'rerank=0.1'] 条数一致: True

召回池从 5 扩到 9(top_k=3 × 3),重排后仍返回 3 条。

本地环境没有下载 reranker 模型,所以上面第 2、3 组用的是桩模型,验证的是"接入是否正确、降级是否生效";真实效果需要配上模型后观察。


6. 上线步骤

1. 下载模型(任选其一) - modelscope:BAAI/bge-reranker-base - huggingface:BAAI/bge-reranker-base 2. 配置 .env RERANK_ENABLED=true RERANK_MODEL_PATH="D:/AI_Model/modelscope/AI-ModelScope/bge-reranker-base" 3. 重启服务,确认日志 [rerank] 重排模型已加载: ... (device=cpu) 4. 观察三件事 - 检索耗时(日志里 cost) - 命中片段与问题的相关性 - CPU 占用(重排和 embedding 会争核)

模型选型:

模型体积量级CPU 延迟(15 条)适用
bge-reranker-base中约 50~150ms中文为主、CPU 部署,推荐起步
bge-reranker-v2-m3大约 150~400ms效果更好,需要更多内存
远程 rerank API—网络往返 100~300ms不想占本地资源

调参建议:

参数建议
RERANK_CANDIDATES3(15 条候选);库大且噪声多可以到 4~5
RERANK_TOP_N0(跟随 top_k=5);prompt 预算紧可以设 3
RERANK_BATCH_SIZECPU 8;GPU 16~32
RERANK_DEVICE留空跟随DEVICE;有 GPU 建议显式cuda

7. 注意事项

#坑说明
1召回池不放大只对 5 条重排等于没重排,正确结果可能根本没进来
2分数混用COSINE(0~1)与 rerank logits(无界)量纲不同,只能各自排序,不要加权平均
3多库时单库霸榜召回池放大后仍可能被一个库占满,必要时按库分配候选再合并
4CPU 争抢重排与 embedding 都是 CPU 密集型,RERANK_BATCH_SIZE别开太大,必要时限线程
5延迟叠加检索 20ms + 重排 ~100ms + 大模型 2s,占比不大,但接口 P99 会上升
6模型版本固定与 embedding 同理,换 reranker 不要求重灌向量(它不改向量),但排序风格会变,要一起评估

最后一个点值得展开:reranker 换模型不用重灌向量(它只在检索时对文本打分,不产生入库向量)。这是它比换 embedding 模型低风险的地方。


8. 小结

这一篇只做了一件事:把"召回"和"排序"拆开。

  1. 召回继续用双塔向量(快、可离线建索引);
  2. 排序换成 cross-encoder(慢一点,但能看到 query 与 chunk 的交互);
  3. 全链路开关式:默认关闭、模型缺失降级、推理异常降级;
  4. 只改了一个入口函数,其他代码(工具、主管、MCP)完全不用动。

三篇串起来看:

27(上) 数据怎么存 → 建库表 + kb_id 元数据 + 上传选库 28(中) 查哪个库 → 检索过滤 + 库路由 + 空结果兜底 29(下) 怎么排得更准 → 扩大召回 + cross-encoder 重排

再往下可以做的方向:

  • 混合检索:向量 + 关键词(BM25 / sparse vector),专有名词和编号类问题提升明显;
  • 检索缓存:相同问题直接复用命中,省一次 embedding + 重排;
  • 按库配额召回:多库场景下每库各取 N 条,避免单库占满;
  • 评估集:攒 50~100 个"问题 → 正确文档"样本,改任何检索参数前后跑一遍,用数字说话而不是凭感觉。