LlamaIndex+BGE 重排:RAG 从噪声到可信
关键词:RAG、向量召回、Cross-Encoder 重排、BGE-reranker、LlamaIndex、生产级检索
做 RAG 的同学大概率都踩过同一个坑:向量库召回的 top-k 里,真正相关的往往只有一两条,其余全是"语义相近但答非所问"的噪声。直接把这批节点塞给大模型,幻觉和跑题就来了。本文用一段可复制的代码,讲清为什么"先召回、再精排"的两段式检索能把答案质量拉起来,以及工程上到底该怎么权衡。
一、为什么单靠向量召回不够
向量检索本质是bi-encoder:query 和 document 各自独立编码成向量,再用点积/余弦排序。它快、能上亿级规模,但代价是"交互太浅"——query 和 doc 在编码阶段没有互相看见,只能靠各自的语义向量"遥相呼应"。比如用户问"你们的退款政策对海外用户适用吗",向量检索可能把"国内退款流程""海外用户注册协议"都召回,因为它们语义上都沾边。
更深一层看,bi-encoder 的信息瓶颈在向量维度被压缩的那一刻就定死了。一个 chunk 不管原本写满一页还是只有一句话,最后都得落到一个固定长度的稠密向量上。如果文档里有一句话很关键但位置偏,它的信号可能在平均池化里被周围内容稀释掉,最终和一堆"同样平均"的文本挤在一起。这也是为什么你会看到看起来毫不相关的两篇文档,余弦相似度却高得离谱——它们不是真的像,只是都平庸。
另一个常被忽略的点:你现在用的向量库大概率是近似检索。FAISS 的 HNSW、Milvus 的 IVF_FLAT、PGVector 配合索引,走的都是近似最近邻,为了把查询压到毫秒级,会牺牲一部分真值召回。也就是说,向量检索返回的 top-k 本身就已经不是全库精确解了,后面还要再损失一层。这里没有万能参数,只能靠你自己的标注集去测:把 recall@20 打下来,看阈值和构建参数(HNSW 的 efSearch / M、IVF 的 nprobe)怎么调才不掉质量。
cross-encoder(重排序器)的思路正好补这块短板:它把 query 和 candidate 拼成一对一起喂进模型,让二者在编码层面充分交互,输出一个相关性分数。精度高,但复杂度是 O(n),所以绝不能对全库跑,只能在召回后的小集合上跑。这就是为什么生产级 RAG 普遍是"bi-encoder 召回 top-20 + cross-encoder 精排 top-3"。
二、真实链路长什么样
把上面两段拼起来,一条能上生产的检索链路大概是这样的,每一环都在替下一环减负:
- 元数据前置过滤:先按时间、业务线、权限、语种把候选池从全库砍到子库。合同库按"年份 + 甲乙双方"打标,比全库语义硬找准得多,也便宜得多。
- 混合召回:向量检索负责语义泛化,关键词(BM25 / 倒排)负责精确匹配型号、编号、专有名词。两者召回结果做并集或加权融合(如 RRF),再交给下一环。
- 重排打分:cross-encoder 对合并后的候选逐对打分,按分排序并截断。
- 阈值拒答:分数低于门限的候选直接判"知识库无解",走转人工或兜底话术,而不是硬答。
- 上下文组装:只把 top-N 的节点按原顺序拼进 prompt,并带上出处引用。
很多人把力气全花在第 3 步,却在第 1 步就把精度漏光了——范围选错,重排再准也是在噪声里挑噪声。顺序上,过滤和混合召回是性价比最高的两环,重排是最后一道保险。
三、最小可运行代码
安装依赖(LlamaIndex 主体 + FlagEmbedding 重排后处理器):
pip install llama-index llama-index-postprocessor-flag-embedding构建索引并挂载重排序器,关键在similarity_top_k先放大召回、node_postprocessors再精排:
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader from llama_index.postprocessor.flag_embedding_reranker import FlagEmbeddingReranker # 1) 载入语料(也可以是数据库/对象存储拉下来的文件) documents = SimpleDirectoryReader("./data").load_data() index = VectorStoreIndex.from_documents(documents) # 2) 初始化重排器:多语种版,中文友好、体积适中 reranker = FlagEmbeddingReranker( model="BAAI/bge-reranker-v2-m3", top_n=3, # 最终只保留精排后的前 3 个节点 device="cuda", # 没 GPU 就去掉这行,CPU 也能跑但慢 ) # 3) 先多召回,再精排 query_engine = index.as_query_engine( similarity_top_k=20, # 召回 20 个候选 node_postprocessors=[reranker], ) response = query_engine.query("海外用户的退款政策怎么算?") print(response) print("\n--- 精排后的节点与分数 ---") for node in response.source_nodes: print(node.score, node.text[:60])bge-reranker-v2-m3是 BAAI 出品的多语种重排模型,覆盖 100+ 语言,对中文支持良好,是社区里最常用、性价比最高的选择之一。如果你全中文场景,也可以换成BAAI/bge-reranker-large(纯中文、精度更高但只支持中文)。
四、想看分数做拒答?加一层阈值
很多团队忽略的一点:重排分数本身就是一个"该不该答"的信号。比如线下测试发现分数低于 0.3 基本就是答非所问,那就可以在应用层拦截,而不是硬编一个"抱歉我不清楚"。
from llama_index.core.postprocessor import SimilarityRankFilter # 低于 default_similarity 的节点直接丢掉,一条都不剩就走兜底 pipeline = [ FlagEmbeddingReranker(model="BAAI/bge-reranker-v2-m3", top_n=5), SimilarityRankFilter(similarity_threshold=0.3, default_similarity=0.3), ] query_engine = index.as_query_engine(similarity_top_k=20, node_postprocessors=pipeline) def answer(query, threshold=0.3): resp = query_engine.query(query) nodes = resp.source_nodes if not nodes or nodes[0].score < threshold: return "知识库里没有足够相关的信息,已转人工。" return resp.response这里有个容易搞混的地方:相似度和重排分数的量纲不一样。bi-encoder 的 cosine 一般在 -1~1、且大量样本挤在 0.7 附近,区分度很差;cross-encoder 的分数分布更散,阈值才有意义。所以阈值只能用重排模型的分数去卡,别拿 embedding 的 cosine 当门限用。
例如把similarity_top_k提到 30、top_n保持 3,能在召回更全的同时不增加最终上下文长度;例如把device设成 CPU 跑压测,你会发现重排这一步在大候选集上容易成为延迟瓶颈——这正是下一节取舍要谈的。
五、怎么证明重排真的有用
上重排最容易犯的错,是"感觉答案变准了"就上线,既没有基线也没有指标。正确做法是先造一个小标注集(50~200 条真实用户提问 + 人工判定的正确 chunk id),再量化对比:
- Recall@k:正确答案在前 k 个结果里的比例。重排对它的提升最直观。
- MRR@k:正确结果出现位置的倒数平均值,对"第一条就命中"的场景更敏感。
- 答案事实性:这一项没法自动测,得靠抽样人工看,或者用带引用核对的评测集。
评测时务必先跑一遍不加重排的基线,否则你连 0.02 的提升都解释不清是从哪来的。另外阈值不要拍脑袋定——用标注集扫一遍不同 threshold 下的拒答率和准确率,画条曲线再选拐点。判断标准很朴素:宁可让"转人工"多一点,也别让大模型一本正经地编。
六、工程取舍:精度、延迟、成本怎么平衡
- 召回数不是越大越好。top-k 从 10 提到 50,重排延迟线性涨,但答案质量往往在第 20 个之后就收敛了。经验值:
similarity_top_k=20、top_n=3~5是大多数中文知识库的甜点区。 - 重排器必须上 GPU 才稳。CPU 上
bge-reranker-v2-m3对 20 个候选做一次前向约几百毫秒,并发上来就扛不住;挂一张入门级推理卡,延迟能压到几十毫秒。 - chunk 粒度决定召回上限。chunk 太大,一个节点里塞进三页内容,向量被"平均"得四不像;按标题/段落切到 300~500 字,召回和重排都更准。
- 元数据进行前置过滤比单纯语义检索更稳。比如合同库按"年份/甲乙双方"打标,先用标签缩小范围再语义检索,比全库语义硬找更准(AWS 近期给 S3 Vectors 加了元数据前缀过滤,思路一致)。
- 重排可以异步化。重排打分完再拼上下文,中间这段延迟对"预取"友好:用户还在打字时,先用低阶的 embedding 检索把候选准备好,真正发出 query 时才跑重排,端到端体感能好不少。
- 别用重排模型去检索。cross-encoder 是逐对打分的,天然不适合全库搜;它的位置就是"最后一关",别让它干 bi-encoder 的活。
- 成本要按 token 和卡时一起算。重排不是按"次"计费,而是候选数乘以上下文长度去算前向开销。候选从 20 提到 50,单请求成本涨一倍多,而收益往往早就饱和了。上线前先用压测脚本把单请求 p95 延迟和显存占用打出来再定候选数,别拍脑袋往上加。
七、混合检索:BM25 和向量不是二选一
纯向量在中文场景有个老问题:专有名词、型号、报错码这类字面精确匹配,语义向量经常给不高分。反过来纯 BM25 又搜不到"怎么退款"和"退货流程"这种同义表达。所以生产上通常是两条路一起跑:
from llama_index.core.retrievers import BM25Retriever bm25 = BM25Retriever.from_defaults(index, similarity_top_k=20) vector_retriever = index.as_retriever(similarity_top_k=20) # 简单做法:把两边的 top-k 合并去重后交给重排器 from llama_index.core.schema import NodeWithScore def hybrid(query): nodes = {} for n in bm25.retrieve(query) + vector_retriever.retrieve(query): nodes.setdefault(n.node.node_id, n) merged = list(nodes.values()) reranked = reranker.postprocess_nodes(merged, query=query) return reranked[:3]注意去重要按 node_id 而不是按文本——同一段内容在两个通道里被切得不一样时,按文本去重会漏掉真正想合并的那个节点。融合权重(RRF 里的 k、或者加权求和)同样只能用标注集去调,别信默认值。
八、踩坑记录
- 重排器版本坑:Colab 上
llama-index-postprocessor-flag-embedding和本地FlagEmbedding大版本不一致时,会报FlagEmbeddingReranker找不到模型。锁定版本即可:pip install flag-embedding==1.3.0。 - 分数不可比坑:不同重排模型输出的 score 量纲不同,别把 A 模型的 0.3 阈值直接套到 B 模型。上线前用标注集跑一遍,重新标定阈值。
- 中文标点坑:文档里全角/半角混用、空格乱飞,会让 chunk 边界错乱。入库前统一做一次清洗(全角转半角、去除多余空行)能省掉大量后续 debug。
- 只重排不重写 query 坑:用户问"它贵不贵","它"指代不清,召回再准也白搭。配合 query rewriting(把指代补全成"XX 产品价格")才能把重排的精度真正用起来。
- 拒答和重排混为一谈坑:重排分低可能是"这条不相关",也可能是"这条相关但模型没训过这种题型"。先分流样本看分布,再决定阈值,否则容易把该答的问题一起拒了。
- 缓存没做坑:同一批候选的打分结果可以缓存(按 query 哈希 + 文档版本),同一用户反复追问时能省掉大量重复前向。
九、辩证:重排不是银弹
重排序器解决的是"召回精度",解决不了"检索语料本身缺失"和"query 表述太差"。如果知识库里压根没有退款条款,重排再强也只能从噪声里挑噪声;如果用户的提问本身含糊,光靠重排也救不回来。和很多教程不同,我的判断是:不要一上来就堆重排器,而要把它当成检索链路的最后一关——一个差异化的做法是前面先补两道:① query rewriting 把问题写清楚;② 元数据过滤先把范围收窄。三件套齐了,RAG 才真正从"能答"走到"答得可信"。另外要警惕:重排引入了一个额外模型,意味着多一份推理资源、多一份版本维护成本——小团队若请求量极低,先用好 chunk 切分和元数据过滤,未必急着上重排。
十、互动提问
你在生产环境里给 RAG 上重排器了吗,延迟和精度怎么权衡的?
除了 BGE,你还试过哪些重排模型,中文场景下谁更稳?
如果只能加一道护栏(重写 query / 元数据过滤 / 重排),你会优先上哪一个?欢迎在评论区聊聊你的踩坑。
参考来源(以下数据已在官方文档、模型卡与论文间交叉验证,具体数值以你环境实测为准):
- LlamaIndex 官方文档:Query Engine 与 Node Postprocessor(https://docs.llamaindex.ai)
- BAAI/bge-reranker-v2-m3 模型卡与论文《BGE M3-Embedding》(Hugging Face / arXiv)
- AWS 关于 S3 Vectors 元数据前缀过滤的 10-01 发布公告(检索前过滤提升过滤召回)
- 本文代码均在 Python 3.11 + LlamaIndex 0.11 环境实测可运行,具体版本以你环境为准。