ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RAG召回优化实战:混合检索全链路拆解与工程落地

RAG召回优化实战:混合检索全链路拆解与工程落地 先讲一个我上周刚遇到的事用户问“手机屏幕摔碎了去官方店修大概多少钱”知识库里明明有“碎屏保服务条款”和“官方维修价目表2024”两个文档纯向量检索却把这两篇都排到了十几名开外最后模型只能硬编了个价格出来。这类问题在我做过的 RAG 项目里太常见了。问题往往不在生成而在召回——该找的东西没找回来后面的模型再强也白搭。这也是我现在做知识库问答几乎一律采用混合检索 RAG 全链路的原因查询增强、双路召回、重排三段叠加之后才能真正把“能回答的问题”范围撑开。这篇内容我按实际落地的顺序把这套链路拆开讲清楚适合正在搭 RAG、或者已经跑了纯向量检索但召回效果一直不理想的同学参考。1. 混合检索的整体思路先把“召回”这个瓶颈拆开看1.1 大多数 RAG 项目卡在召回环节RAG 的标准链路是 indexing → retrieval → augmentation → generation其中 embedding 模型负责把文档和查询映射到向量空间向量索引负责在召回阶段加速检索。但只要你多接几个真实业务问题就会发现向量检索的召回能力并不像论文里看起来那么完美。原因在于用户 query 的分布极其离散。有一部分问题是语义型的比如“内存不够用怎么办”和“设备存储空间不足怎么清理”语义上是一回事向量检索可以轻松匹配但另一部分问题是精确型的比如查“SN 码 A1B2C3D4 的保修状态”比如搜“《劳动合同法》第四十七条”比如问“你们 2024 年 3 月发布的那款白色 256G 型号的参数”——这些对向量检索来说几乎是灾难。语义向量对字面精确匹配不敏感会把“A1B2C3D4”和“A1B2C3D5”映射到很接近的位置但这两个是完全不同的实体。最典型的例子是型号、编号、人名、地址、法律条文序号这类文本。你拿向量去召回得到的往往是“语义相关但字面不相关”的文档而用户真正想要的是那份提到确切编号的原始文档。这就是为什么我把召回看作 RAG 的第一瓶颈生成阶段再强的模型也没办法从缺失的上下文里“无中生有”出事实来幻觉的根源有很大一部分就在这里。我见过太多团队在生成侧反复优化提示词、换大模型结果 hit rate 纹丝不动。后来把召回链路从纯向量换成混合检索效果立刻就不一样了。原因很简单召回决定了 RAG 的上限生成只是在下限之上做文章。1.2 双路召回不是堆叠是分工混合检索的核心理念不是“多跑一路结果拼在一起”而是让两路完全不同的检索机制各自发挥擅长的能力再做融合。我把两路的分工用一张表说明维度向量检索Dense关键词检索Sparse / BM25匹配原理语义相似度词项重合度TF-IDF 加权重擅长场景同义改写、口语化表达、跨语言专名、编号、型号、精确词、低频词典型短板对精确匹配不敏感容易丢失实体对同义词、语序变化、抽象表述无能为力索引成本需要 embedding 模型和向量索引只需要倒排索引资源开销低查询延迟依赖 ANN 搜索通常毫秒级倒排索引查询通常毫秒级甚至更低从这张表能看出来两路的能力几乎是互补的很少重叠。真正的问题是很多项目图省事只做一路要么只上向量库要么只用 Elasticsearch 做关键词检索。前者漏精确信息后者漏语义信息怎么选都吃亏。因此在做全链路设计时我的思路是让两条路各跑各的向量路负责“大意相同”的文档关键词路负责“字面一致”的文档最后把两路结果拿去做融合和重排而不是简单地取个并集。尤其是当知识库规模突破几万条、或者数据里有大量产品型号和技术参数时这种分工带来的收益会非常明显。数据量小的时候比如只有几百条测试文档纯向量可能看不出差距但一旦到了生产级别召回差异就非常显著了。2. 查询增强先让问题变得更“好检索”2.1 查询改写与意图补全我一直觉得查询增强是整个混合检索链路里最被低估的一环。很多人把精力全花在 embedding 模型和索引调优上却忽略了用户 query 本身往往不适合直接拿去检索。用户提问通常有几个毛病太短、太口语化、有指代、有省略。比如用户问“它会不会漏电”这背后可能是在问上一轮讨论的某款充电宝用户问“离职赔偿怎么算”实际指的是“经济补偿金标准”如果直接拿“离职赔偿”去检索向量模型可能找到相关文档但关键词路大概率匹配不上。这时候就需要一个查询改写模块在检索之前先把 query 加工成更适合检索的形式。我实际用的方案是轻量级 LLM 改写加一个简单的提示词模板system_prompt 你是一个检索辅助助手。你的任务不是回答用户问题而是把用户问句改写成适合检索的形式。 要求 1. 保留原始意图不添加事实上不存在的信息 2. 补充缺失的主语和指代对象 3. 把口语、缩写展开成书面表达 4. 输出 3 个候选问法每个一行不要编号。 def rewrite_query(user_input: str, llm) - list[str]: prompt f原始问题{user_input}\n改写后的检索问法 response llm.chat(system_prompt, prompt, temperature0.3, max_tokens128) return [line.strip() for line in response.splitlines() if line.strip()]注意几点改写用 temperature 设置在 0.3 左右太高容易发挥过头引入噪声输出的多个候选问法可以全部送去检索然后把结果合在一起相当于一种查询扩展。改写和翻译不一样不要添加任何模型臆测的信息比如用户没提“退货”就不要主动把“退货政策”加进改写结果那是策略层该做的不是改写层该做的。除了 LLM 改写还有一种思路叫HyDEHypothetical Document Embeddings用 LLM 根据问题生成一个“假设存在的完美答案文档”然后拿这段生成的文本去做向量检索。直觉是用户问句太短直接算 query 和 doc 的相似度噪声大但一篇“假设的答案”往往和真实文档共享更多词汇和句式能提高召回。实测下来HyDE 对开放式问题、综述类问题效果明显对精确型问题反而可能引入幻觉所以我会把它作为可选分支不默认全开。2.2 查询路由让不同问题走不同通道查询增强的另一层是路由。不是所有问题都需要走完整混合检索链路——有些简单问题一个精确的关键词查询就够了有些复杂问题需要先拆解成子问题再分别检索还有些问题本质上要调用工具、查数据库根本不该检索知识库。我的经验是先用规则做粗粒度路由再用轻量模型做细粒度分类。规则很简单如果 query 里包含订单号、型号、发票号这类强标识直接走关键词通道不用向量如果 query 超过 30 个字且包含多个意图点判定为复杂问题走分解流程。规则覆盖不了的再用一个小分类模型或者 LLM 做意图识别。路由做得好不只是为了效果更是为了省成本和降延迟。我见过有人把所有 query 都送去跑一遍大模型改写加重排结果 50% 的简单查询被白白拖慢了 1 秒以上。混合检索应该懂得“该简单的简单该复杂的复杂”。3. 双路召回向量库和搜索引擎各跑各的最后融合3.1 向量路从切块到 Embedding向量路是双路召回里大家最熟的一条但细节坑最多。先说切块。切块策略直接决定召回的上限我常用的三种固定窗口切块按 token 数或字符数硬切简单但容易切断语义适合新闻、说明书这种段落边界不太重要的文本。语义切块按段落、标题、或者句子边界切尽量保证每个 chunk 是完整语义单元。用 LangChain 的RecursiveCharacterTextSplitter或者专门的文档解析工具比如 Unstructured、Haystack 的分割器都能做。父子块切块小 chunk 负责精确匹配大 chunk 负责提供上下文召回时命中子块、返回父块。这个模式在知识库问答里非常好用能兼顾精确率和上下文完整性。有热搜词问“有没有本地的 RAG 文本拆解工具”答案是肯定有。上面提到的 Unstructured、LlamaIndex 的节点解析器、LangChain 的文本分割器都可以本地跑不需要调用在线服务。我个人的偏好是文档结构规整的用RecursiveCharacterTextSplitter 段落感知文档结构乱的先用 Unstructured 抽出来再按标题切。切块之后是 embedding 模型选型。中文场景我用得最多的是bge-m3它支持中文、英文和长文本而且同时产出 dense 和 sparse 两种向量对混合检索特别友好如果预算充足且对效果要求高text-embedding-3-large也很稳。选模型时的经验是先在自己的业务数据上跑一个小规模的检索评测不要直接用公开 benchmark 排名选型很多开源模型在通用语料上表现好落到具体行业里就露馅。索引构建注意两点一是向量维度要和模型输出一致二是按业务域建多个 collection 比全部塞进一个大索引更容易调优。我试过把所有业务数据放进一个向量库结果跨域噪声经常干扰召回拆成产品库、售后库、政策库之后每一路的精度都明显提升。向量路伪代码大致长这样from sentence_transformers import SentenceTransformer from pymilvus import Collection model SentenceTransformer(BAAI/bge-m3) collection Collection(knowledge_base) def dense_recall(query: str, top_k: int 20): query_vec model.encode(query, normalize_embeddingsTrue) result collection.search( data[query_vec], anns_fielddense_vec, param{metric_type: IP, params: {nprobe: 16}}, limittop_k, output_fields[doc_id, text], ) return [hit.id for hit in result[0]]3.2 关键词路BM25 与稀疏向量关键词路的主力算法是BM25虽然它诞生几十年了但在精确匹配这件事上至今依然能打。BM25 打分核心是词频、逆文档频率和文档长度归一化体现在公式里就是 k1 和 b 两个超参。k1 控制词频的饱和程度b 控制文档长度对分数的影响。默认值一般是 k11.2、b0.75这个组合在大部分语料上表现稳定没有充分理由不要去调它我见过很多人一上来就调参调到过拟合评估集换一批数据立刻崩。搜索引擎选型看团队基础如果公司已经有Elasticsearch或OpenSearch直接用自带的 BM25加一个索引就行成本最低。如果新起项目且数据量不大用SQLite FTS5都够零运维。如果已经用了Milvus可以直接用它的 Sparse 向量能力做关键词召回少维护一套系统。如果数据量上万且对中文分词要求高那 ES 的 IK 分词器 自定义词典是稳妥的选择。中文分词是关键。BM25 对英文是天然的按空格分词对中文就得靠分词器。默认的分词器经常把“碎屏保”切成“碎/屏/保”召回质量大打折扣。我在项目里的做法是给 ES 配 IK 分词器同时维护一个业务词库把产品型号、专有名词、内部术语塞进去强制不拆分。这一步如果不做后面重排再好也救不回来。BM25 路的实现用 rank_bm25 库最简单from rank_bm25 import BM25Okapi tokenized_docs [doc[text].split() for doc in docs] # 中文场景替换为自己的分词器 bm25 BM25Okapi(tokenized_docs) scores bm25.get_scores(query.split()) ranked sorted(range(len(scores)), keylambda i: scores[i], reverseTrue)如果嫌 BM25 的分词和调参麻烦可以上稀疏向量模型比如 SPLADE 或者 bge-m3 里带 sparse 输出。稀疏向量的意思是把文本映射成一个高维稀疏向量维度对应词项模型可以自动学习出哪些词重要不再依赖手工分词。好处是精度通常比 BM25 高坏处是需要额外的推理开销。我的建议是简单场景先用 BM25遇到明显瓶颈再换稀疏向量不要一上来就上重武器。3.3 两路结果的融合策略两路结果都出来了怎么合直接取并集再让重排模型打分是一种方案但问题在于向量路的分数和 BM25 的分数根本不是同一个分布没法直接比较。所以我更推荐用RRFReciprocal Rank Fusion。RRF 的思路非常朴素不看分数只看位置。两路结果里文档排名越靠前得分越高同时在两路结果里都出现的文档会得到额外加成这正好让“语义相关 字面匹配”的文档自然浮上来而且不需要任何分数归一化。公式很简单RRF_score(d) Σ 1 / (k rank_i(d))k 是平滑因子论文里取 60 效果稳定。实现如下def rrf_fusion(result_lists: list[list[str]], k: int 60) - list[str]: scores: dict[str, float] {} for rank_list in result_lists: for rank, doc_id in enumerate(rank_list): scores[doc_id] scores.get(doc_id, 0) 1.0 / (k rank 1) return [doc_id for doc_id, _ in sorted(scores.items(), keylambda x: x[1], reverseTrue)]举个例子用户搜“碎屏保维修费用”向量路召回了“碎屏保服务介绍”“手机维修周期说明”“常见问题汇总”BM25 路召回了“碎屏保服务介绍”“碎屏保价格表”“售后政策 2024”。RRF 融合后“碎屏保服务介绍”在两路都排第 1得分最高稳居第一“碎屏保价格表”虽然向量路没召回但关键词路排得靠前也能进入最终融合列表。这就是混合检索的价值——两路的盲区被互相补上了。融合之后要注意一个度融合列表的长度控制在 50 条左右就够了别把两路 Top50 全并进来重排阶段和生成阶段都受不住那么多噪声。4. 重排在精排阶段把垃圾结果踢出去4.1 为什么召回集不能直接给 LLM如果两路召回各取 20 条、融合完 40 条直接把 Top5 喂给 LLM你很快会发现答案质量很不稳定。原因很简单召回模型的目标是“别漏掉相关文档”所以它会把很多“沾边但不精准”的结果也带进来。一个 Top50 的召回列表里可能只有 5 条真正相关剩下 45 条全是干扰项。LLM 看到一堆不相关的内容轻则答非所问重则被误导编造。重排的目的就是在召回和生成之间加一道“精筛”。它用一个更精确、但也更昂贵的相关性模型把召回集重新打分排序只把最相关的几条交给生成阶段。重排的意义不是“多一步处理”而是让精度和召回的矛盾在两端各自解决召回阶段放宽抓到相关文档重排阶段收窄滤掉噪声。重排模型大多采用Cross-Encoder架构。Bi-Encoder 是把 query 和 doc 分别编码成向量再算相似度速度快但丢失了二者之间的交互信息Cross-Encoder 把 query 和 doc 拼成一句话一起过 Transformer直接输出相关性分数交互信息完整但速度慢。两者的关系可以类比为Bi-Encoder 是看简历筛人Cross-Encoder 是面对面面试。面试更准但成本高所以不能拿它面所有人。维度Bi-EncoderCross-Encoder计算方式分离编码向量相似度拼接输入模型直接打分相关性建模间接、有损直接、全面推理速度快可批量慢单条打分适用位置召回阶段重排阶段4.2 重排模型的选型与接入开源重排模型里我常用的是bge-reranker-base和bge-reranker-v2-m3。v2-m3 支持多语言、效果更好但模型更大本地 CPU 推理会比较吃力生产环境建议至少上一张消费级 GPU或者用 API。FlagEmbedding 库直接可以加载from FlagEmbedding import FlagReranker reranker FlagReranker(BAAI/bge-reranker-v2-m3, use_fp16True) def rerank(query: str, docs: list[dict], top_k: int 5) - list[dict]: pairs [[query, doc[text]] for doc in docs] scores reranker.compute_score(pairs) ranked sorted(zip(docs, scores), keylambda x: x[1], reverseTrue) return [doc for doc, _ in ranked[:top_k]]注意一个坑bge-reranker-v2-m3 的输入长度限制是 512 个 token。如果你的文档块很长直接整个塞进去会被截断丢掉关键信息。我踩过一次之后做了一个简单的滑窗处理def rerank_with_sliding_window(query: str, docs: list[dict], window_size: int 480, stride: int 160): for doc in docs: tokens doc[text].split() # 实际用 tokenizer best_score float(-inf) for start in range(0, max(len(tokens), 1), stride): window tokens[start:start window_size] score reranker.compute_score([[query, .join(window)]]) best_score max(best_score, score) doc[rerank_score] best_score return sorted(docs, keylambda x: x[rerank_score], reverseTrue)滑窗的核心思路是不要求整个文档都相关只要文档里有一段和 query 高度相关就值得把这篇文档捞回来。这个思路对长文档尤其有效比直接截断头部靠谱得多。另外还有一个分支是用 LLM 做重排也就是把 TopN 文档列表一次性丢给大模型让它按相关性排序输出。这种方式效果通常不错但速度慢、成本高更适合离线评测或者对延迟不敏感的场景。我一般只在构建评估集、对比不同重排模型时用它线上实时链路还是用 cross-encoder。4.3 重排在混合链路里的位置整个链路的顺序是query 进来先做查询增强改写、路由然后向量路和 BM25 路并行召回RRF 融合成一个 50 条的候选列表再交给重排模型精排取 Top3~5 作为生成阶段的上下文。重排放在融合之后而不是在每条路上分别做是因为重排模型太贵能少算一次就少算一次。融合后的候选集量级是几十条重排模型在这个量级上打分延迟可以接受。如果是纯向量检索的场景没有关键词路我也会在召回后接一层重排哪怕只把 Top20 重排成 Top5对生成质量的提升都比换个更大参数的生成模型来得直观。实际经验告诉我加了重排之后 hit rate或者说“答案相关文档进 Top3 的概率”通常能提升十几个百分点。具体数字取决于数据噪声程度但方向几乎是一致的召回负责“找得到”重排负责“找得准”。5. 评估与常见问题用数据说话别凭感觉调参5.1 Hit Rate 与 MRR 的工程化计算混合检索链路搭完之后第一件事不是“试试效果好不好”而是建立一套可复现的评估集和指标。我强烈建议先做评估再调优否则你根本分不清改动到底有没有用。两个最核心的指标Hit Rate召回命中率对每个 query如果标准答案文档出现在召回或重排后的 TopK 里就算命中。Hit Rate 衡量的是“系统有没有把该找的找回来”。MRRMean Reciprocal Rank标准答案文档在结果列表里的位置的倒数取平均。MRR 衡量的是“找回来的东西排得够不够靠前”。计算代码很简单def hit_rate_and_mrr(predicted_lists: list[list[str]], golden_lists: list[list[str]], k: int 5): hit 0 mrr 0.0 for preds, golds in zip(predicted_lists, golden_lists): gold_set set(golds) cut preds[:k] if any(pid in gold_set for pid in cut): hit 1 for i, pid in enumerate(cut): if pid in gold_set: mrr 1.0 / (i 1) break n len(predicted_lists) return hit / n, mrr / n评估集从哪来如果线上有用户 query 日志就抽一批真实问题人工标注每条 query 对应的标准文档如果没有就从知识库里按业务场景人工构造 100~200 条 query。量不在多关键是覆盖语义型、精确型、混合型三类问题否则评估结果偏向某一路会掩盖真实瓶颈。调优顺序也有讲究先看 Hit Rate如果 Top20 里都没有相关文档说明召回有问题重排调得再好也没用Hit Rate 达标后再看 MRR 和重排精度。不要在召回都没做好的时候花大力气琢磨重排模型的温度参数那是本末倒置。5.2 踩坑实录与排查清单整理一下我在混合检索 RAG 项目里遇到的高频问题做成排查清单现象可能原因解决方案向量召回全空向量维度与索引不匹配Collection 没建好检查 embedding 维度重建索引用简单 query 自测BM25 搜中文效果差分词器不识别业务词换 IK 分词器加自定义词典把专有名词强制成词两路召回结果重合度太高数据本身同质化严重混合检索优势出不来检查数据切块是否过粗考虑增加精确型字段的权重重排拖慢接口响应超时重排候选太多或模型太大限制融合后候选数为 20~50重排只精排 Top50 以内知识库里有图片但搜不到普通 RAG 链路不支持直接检索图片先 OCR 转文本或用多模态 embedding 把图片向量化再进入向量库本地跑文本拆解工具太慢文档解析格式复杂扫描件、表格先用 OCR 预处理再走 Unstructured 等工具表格用专门解析器LLM 答案忽略关键上下文TopK 里混入太多不相关段落降低 TopK或加强重排精度日志里检查送入生成的上下文质量有一个反复踩的坑值得单独说重排 TopK 不是越大越好。有人觉得重排完取 Top10 总比 Top3 信息多但实际上 Top10 里后几位带来的噪声和 token 开销往往比收益大。我现在的默认配置是召回各取 Top20RRF 融合后取 Top50重排后只留 Top3~5。除非是长文档综述类任务否则 TopK 不要超过 5。另外“RAG 知识库能存储图片吗”这个问题经常有人问。严格来说普通文本 RAG 的检索链路不管是向量还是 BM25都是针对文本设计的。图片要进知识库要么先 OCR 成文本要么用多模态 embedding 模型把图片单独向量化再用单独的图搜链路。指望 BM25 搜图片是不现实的这个边界从一开始就要想清楚。5.3 混合检索之上的延伸GraphRAG、Agentic RAG 与多模态 RAG混合检索这套链路是 RAG 的“基础盘子”在这个盘子上最近又长出了不少衍生方向。GraphRAG 解决的是“知识割裂”问题——传统 RAG 把文档切成块之后块与块之间的关联信息就丢了而 GraphRAG 用知识图谱把实体和关系显式建出来既能做语义召回也能沿图结构做多跳推理。本体 RAGOntology RAG更强调领域概念的约束适合产业知识结构复杂的场景。Agentic RAG 则把“检索”从一次固定调用变成了智能体决策模型可以根据当前上下文判断是继续检索、改写查询、还是调用工具查数据库检索策略是动态的。这些方向都很值得尝试但我个人的体会是先别急着追新概念。如果你的混合检索链路连命中率都还不稳定GraphRAG 和 Agentic RAG 只会把问题藏得更深。把双路召回和重排做扎实再去叠加这些高级玩法每一步的效果都是可衡量的。我最后再分享一个真实项目里的体会。之前给一个售后知识库做 RAG原本是纯向量检索hit rate 一直在 0.6 上下徘徊怎么调 embedding 都没用。后来我把链路改成“查询改写 向量/BM25 双路 RRF 融合 bge-reranker 重排”同一套评估集上 hit rate 到了 0.85 以上。最明显的变化不是某个单一指标而是用户问那种带型号、带编号的问题时系统终于能稳定找到那篇原文了。做技术优化最让人踏实的时刻就是你看到那条“理论上应该被召回”的文档终于出现在结果里——混合检索全链路给我的就是这种确定性。
返回列表