ARTICLE DETAIL

资讯详情

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

混合检索与RAG:从向量召回到BM25重排的完整实践

混合检索与RAG:从向量召回到BM25重排的完整实践 1. 为什么只有向量召回不够用混合检索的整体设计思路先说个结论单靠向量召回做 RAG在真实业务里几乎一定会翻车。我见过不少团队上来就选一个向量库把文档切块、做 embedding、灌进去然后觉得万事大吉。结果上线之后用户问“合同编号 A-2024-0812 对应的付款条款是什么”向量召回死活找不到再问“那个上个月发的关于接口超时的公告”因为用户表达得模棱两可向量路召回的结果又不靠前。问题并不出在 embedding 模型不够强而是向量检索这个模式本身就有盲区。1.1 向量召回的优势与盲区向量召回Dense Retrieval太适合处理“语义相关”了。你搜“怎么让程序跑得更快”它能找回“性能优化”“CPU 占用过高排查”这类意思相近、字面完全不同的内容。这是它最大的价值也是它能成为 RAG 主流召回方式的根本原因。但它的盲区同样明显精确匹配弱型号、编号、日期、人名、订单号这类信息embedding 模型的表征并不稳定稍微换个写法向量就偏了。专有名词不友好中小公司内部的系统名称、产品代号训练 embedding 模型时根本没见过向量会落在奇怪的位置。高频词与停用词干扰比如“系统异常”这种高频说法语义向量可能被常见词主导真正关键的“异常码 5040”反而没被强调。长尾查询吃力用户提问口语化、指代不明时原始 query 直接拿去检索向量路几乎是在猜。这些盲区恰好是传统信息检索也就是大家常说的搜索引擎那一套擅长的词项匹配、词频统计、倒排索引在精确词和稀有词上非常稳。1.2 混合检索不是简单相加而是要穿插在完整链条里很多人把混合检索理解成“向量召回一批BM25 召回一批两个结果拼在一起”。这条路能起步但效果一般。真正的混合检索全链路是在 RAG 的三个环节分别做文章召回之前对用户的原始问题做查询增强Query Enhancement让问题变得更适合检索。召回之中向量库和传统的稀疏检索各走一路各自召回各自擅长的候选结果。召回之后用重排模型或者融合算法把两路结果放在同一把尺子下重新排序取最优的前 N 条喂给大模型。这三个位置缺一不可。查询增强解决的是“问题不对”双路召回解决的是“召回不全”重排解决的是“排序不准”。很多项目只做了中间那一步觉得混合检索“就那样”其实是因为两头的功夫没到位。这篇文章我会按这三个环节逐一拆解并在最后给出带代码的完整落地示例。你在公司做内部知识库也好做个人本地 RAG 也好这套链路不用全上按需裁剪就行。但如果你想达到“能上线、能扛住真实用户 query”的程度这三个位置迟早都得补齐。2. 查询增强让问题先去“瘦身”和“补全”查询增强Query Enhancement / Query Rewriting是混合检索里最容易被忽略、但性价比最高的一环。它的本质是在拿用户 query 去检索之前先用轻量手段把 query 改造成更容易被召回系统命中形态。2.1 什么场景必须做查询增强不是所有 query 都需要增强。我建议你先看有没有这几类典型问题口语化和指代不清用户问“这个功能怎么用”但“这个功能”在对话里指的是“导出报表”。如果不把上文信息补进去检索系统根本不知道“这个”是什么。query 过长、噪声多“帮我查一下我们公司那个合同里面说的关于如果对方延迟发货的话我们要怎么处理的条款”——这句话里真正适合检索的只有“合同”“延迟发货”“处理条款”几个词。多轮对话没有上下文用户先问“你们支持哪些数据库”再问“那连接池怎么配”第二句脱离了“数据库”这个话题背景。中英文混输、同义词混用“报销 sop”和“报销标准操作流程”字面上完全不同但指的是同一个东西。在这些场景下直接拿原始 query 去召回两路检索都很难有理想结果。向量路可能因为语义相关勉强召回但排位不靠前BM25 路基本就是看词项重合差得更远。2.2 三类常用增强手段第一类压缩与改写用一个较小的 LLM 把用户原始输入改写成一个适合检索的短 query。核心思路是去掉废话保留实体和关键动作。我常用的 prompt 大概是这样的你是一个检索查询优化助手。用户会输入一个原始问题请把它改写成适合搜索引擎检索的简洁查询语句。 要求 1. 保留专有名词、编号、人名、产品名 2. 去掉口语化表达、冗余修饰和上下文指代 3. 如果原始问题中存在代词如“它”“这个”“对方”根据上下文补全为明确词汇 4. 只输出改写后的查询语句不要任何解释 原始问题{user_query}实际执行时你会发现这个改写步骤成本很低但带来的召回提升非常明显。尤其对混合检索来说干净、精简的 query 对 BM25 路是救命级的优化——因为稀疏检索极度依赖词项重合。第二类扩展与补充改写是把输入变精简扩展是反过来给原始 query 增加同义词、中英文、术语变体让两路召回都能命中更多候选。举个例子用户搜“报销流程”我们可以扩展成报销流程报销标准操作流程报销 SOPexpense reimbursement procedure这些扩展项不用全部拼到一个 query 里可以拆成多条子查询分别召回然后合并候选集。做扩展时我建议优先依赖已有的同义词表而不是每次都用 LLM 现编否则实时生成的内容质量不稳定还可能引入偏离原意的词项。第三类HyDE假设性答案检索HyDE 的思路很有意思既然 query 和文档之间存在表达鸿沟那就先用 LLM 根据 query 写一段假设性的答案再用这段答案去做向量检索。因为答案的用词、句式更接近文档风格embedding 后的相似度往往比原始 query 更高。这个方法在某些场景下效果惊人但我不建议无脑用理由有两点如果 LLM 生成的假设性答案是错的向量召回会被带偏而且偏得很远多一次 LLM 调用多一份延迟和成本。我的建议是只在语义搜索类场景比如“用户描述现象想找对应解决方案”使用 HyDE不要用在精确检索类场景比如编号、型号查询。2.3 用增强的时机与成本控制一个常见的误区是“每次用户提问都要做增强”。这既浪费钱也引入了不稳定因素。比较合理的方式是加一个前置判断如果原始 query 长度适中、包含明确的专有名词不需要增强直接召回如果 query 包含代词、长度过长、明显口语化才有必要走改写如果走到多轮对话场景优先补上下文其次才是改写。判断逻辑可以用规则也可以用一个小分类模型。在 RAG 链路里我建议第一版先用规则省心且可控。规则里最核心的是两个标准长度阈值与代词检测。下面这段伪代码是我在实际项目里用过的思路def need_rewrite(query, history): if len(query) 3: return True if any(pronoun in query for pronoun in [这个, 那个, 它, 他, 她, 对方, 上面]): return True if len(query) 80: return True if history and not _has_core_noun(query): return True return False记住查询增强的目的是提高召回上限而不是让每个 query 都被模型折腾一遍。3. 双路召回向量库和搜索引擎各干各的活双路召回Two-Stage Retrieval 的前半段是混合检索的主体框架。这两路分别是稠密向量路基于 embedding 模型的语义召回由向量库执行稀疏词汇路基于词项精确匹配的召回本质上是传统搜索引擎的检索逻辑由 Elasticsearch、OpenSearch 这类引擎执行。很多人一听到“搜索引擎”就以为要搭个完整的搜索平台其实没那么重。你要理解的是这条路在数学上等于 BM25 这一经典排序函数外加倒排索引结构。它解决的是“精确词命中”的问题专治向量路的软肋。3.1 两路召回的分工与互补逻辑我把这两路的特性放在一起对比过很多次基本是这样的对比维度向量召回Dense稀疏召回BM25/ES匹配方式语义相似度词项重合度擅长场景同义改写、语义相关、口语描述编号、型号、专有名词、稀有词对中文分词依赖低高分词器直接影响效果召回结果特点结果多且泛噪声也多结果精准但覆盖面窄索引资源消耗高需要向量索引低倒排索引非常成熟看出来了吗两路的问题恰好相反向量路“该找到的找不全”稀疏路“找到了但找不广”。所以双路召回的正确姿势是两路并行各自把各自的候选拉出来然后把候选集合并交给后端的重排模块。你不需要让两路在召回阶段分个胜负只要保证真相关文档至少出现在其中一路的候选里就行。3.2 落地时的索引与检索参数这里给一套我验证过的基础参数你可以按照自己的数据量调整。向量路向量库我用得比较多的是 Faiss 和 Milvus。如果是轻量个人项目Chroma 或者 sqlite-vec 也够用。几个关键点top_k建议取 20~50不要只取 5 个。因为重排阶段有能力筛掉不相关的召回阶段宁可多拿候选也不要漏。Faiss 的nprobe参数控制在 10~30 之间。太小会漏掉高相似向量太大查询延迟会明显上升。距离度量默认用余弦相似度embedding 模型如果是默认归一化的直接内积也行。稀疏路如果是中文场景前提是分词做好。拿 Elasticsearch 举例IK 分词或者针对自己字段自定义分词器差别非常大。默认的 standard analyzer 在中文上几乎没有意义这坑我踩过很多次。BM25 有两个关键参数k1控制词频饱和程度默认 1.2。对短文本可适当调小1.0 左右对长文本可以调大。b控制文档长度归一化强度默认 0.75。如果文档长度差异很大b 适当调小0.5~0.6避免长文档被过度打压。此外稀疏路的top_k建议比向量路稍低我一般设置 10~20。因为稀疏路相关文档分布更集中多了反而全是噪声。3.3 两路结果合并时的注意事项最简单的方式是直接做 Set Union把两路返回的 doc_id 合并成候选集。但这里有几个容易踩的细节不要丢失得分信息。合并候选集时两路的分数要留着后面重排时如果用 RRF需要知道每条记录是哪一路召回的、排第几名。不要一开始就做高阈值截断。向量路某条结果相似度只有 0.45就有可能是唯一一条中的那条BM25 路返回的第 15 名也可能是对的。召回阶段放水重排阶段收紧这个顺序不能反。候选集合并后记得去重。同一个文档可能同时被两路召回但它对应的文本块 id 可能因为切分方式不同而不同去重时要基于文本块的 hash而不是文档级 id。双路召回做完你手里应该有一个规模在 20~50 左右的候选文本块集合。接下来就是重排的活。4. 重排把候选集放到同一把尺子下重新排序双路召回给你的是一堆“可能有用”的文本块但它们的分数没有可比性向量的 0.7 和 BM25 的 15.8 根本不是一个量纲。你直接按原始分数排序必然是一团乱。重排Rerank要解决的就是这个问题。4.1 为什么双路召回的结果不能直接相加排序假设向量路返回了三条结果分数分别是 0.82、0.75、0.63BM25 路返回两条分数是 18.2 和 9.6。你能不能得出“18.2 一定比 0.82 更相关”的结论不能。两者的分数空间完全不同而且各自的误差模式也不同。哪怕你对 BM25 分数做 min-max 归一化也只是把分布拉齐了并不代表两路分数的可靠性一致。所以重排有两种主流思路不看分数只看名次这就是 RRFReciprocal Rank Fusion的思路。引入一个专门的相关性打分模型Cross-Encoder 类的重排模型直接把“问题 文本块”拼起来打一个统一的相关性分数。4.2 两种重排方案的对比与选型方案一RRF 无模型融合RRF 的核心公式是用名次倒数的累加值作为最终分数score(doc) Σ 1 / (k rank_i(doc))其中rank_i(doc)是文档在第 i 路召回中的排名k一般取 60。RRF 的好处是零模型、零训练、零延迟只依赖两路召回的相对排名。它特别适合快速验证“双路召回 融合”到底有没有效果。缺点是它不考虑每条结果实际有多相关只考虑“在不在前面”。方案二Cross-Encoder 重排模型Cross-Encoder 的做法是把 query 和候选文本块拼接成一个序列输入到 BERT 类模型里由模型输出一个相关性分数。这和双塔式的 embedding 模型完全不同双塔在召回阶段可以预计算向量但 Cross-Encoder 必须实时推理所以它通常只用于几十条候选的重排。目前国内用起来比较顺手的开源模型是 BGE-Reranker 系列比如bge-reranker-v2-m3或者bge-reranker-base。如果你本地部署用 Ollama 拉一个 qwen 小模型也能做类似的事但对重排这类短文本相关性判断任务专用 reranker 的性价比明显更高。我给的选型建议是这样候选集在 20 条以下、延迟要求高直接用 RRF。候选集 20~50 条、对准确率有要求先用 RRF 过滤到 10 条再用 Cross-Encoder 精排。数据量小但质量要求极高直接对全部候选做 Cross-Encoder 重排。4.3 重排之后要不要叠加业务规则实际业务里纯模型打分的重排往往还是不够。你需要混入一些规则比如时间衰减公告、新闻类知识库越新的内容越应该排前面。来源权重公司内部技术 Wiki 的优先级高于外部爬取的文档。字段加权标题命中比正文命中更有说服力高亮词得分可以加成。我的做法是在 Cross-Encoder 分数基础上叠加一层高斯分布的时间权重然后做截断。模板如下final_score rerank_score time_penalty source_bonus要注意的是规则权重的总和不宜超过模型分数的 30%否则模型辛辛苦苦学出来的相关性就被规则盖过去了。重排之后从前 50 个候选里取前 3~5 条才是真正要送给 LLM 的高质量上下文。走到这里混合检索的“召回”部分才算是闭环了。5. 全链路落地 demo从查询到回答的完整代码这部分我直接给一份最小可用的端到端实现。技术栈选型尽量轻方便你本地复现向量库Chroma本地文件型无需服务稀疏检索直接用rank_bm25库做内存版 BM25避免为了 demo 去部署 ESLLMOllama 加载本地模型比如qwen2.5:7b重排先用 RRF如果想上 Cross-Encoder我会额外给出bge-reranker-base的调用示例建议你先跑通这套再决定要不要把 BM25 替换成 ES把 Chroma 替换成 Milvus。5.1 准备环境与数据先把依赖装好pip install chromadb rank-bm25 sentence-transformers langchain langchain-community假设你有一批知识文档按段落切块后存储。5.2 双路召回实现向量路与稀疏路先构建向量索引和 BM25 索引import chromadb from chromadb.utils import embedding_functions # 使用本地 embedding或者接 Ollama 的 embedding 接口 embedding_model embedding_functions.OllamaEmbeddingFunction( model_namenomic-embed-text, urlhttp://localhost:11434/api/embeddings ) client chromadb.PersistentClient(path./chroma_db) collection client.get_or_create_collection( namekb_docs, embedding_functionembedding_model ) # 灌数据chunks 是切好的文本块列表 collection.add( idsdoc_ids, documentschunks, metadatasmetadatas )然后写 BM25 索引import jieba # 中文分词 from rank_bm25 import BM25Okapi tokenized_docs [list(jieba.cut(doc)) for doc in chunks] bm25 BM25Okapi(tokenized_docs)双路召回def vector_search(query, top_k30): results collection.query( query_texts[query], n_resultstop_k, include[documents, distances, metadatas] ) return results这里注意collection.query默认用的是余弦距离返回的distances越小越相关后面融合时如果你想用相似度可以用1 - distance或者归一化处理一下。def bm25_search(query, top_k20): tokens list(jieba.cut(query)) scores bm25.get_scores(tokens) ranked_indices scores.argsort()[::-1][:top_k] return [(i, scores[i]) for i in ranked_indices] def hybrid_recall(query): vec_results vector_search(query) bm25_results bm25_search(query) # 合并候选保留每路的名次与得分 candidates {} for rank, (doc_id, distance) in enumerate(vec_results): candidates[doc_id] { doc: doc_id, vec_rank: rank 1, bm25_rank: None, vec_score: 1 - distance } for rank, (idx, score) in enumerate(bm25_results): doc_id chunk_ids[idx] if doc_id not in candidates: candidates[doc_id] { doc: doc_id, vec_rank: None, bm25_rank: rank 1, vec_score: 0 } else: candidates[doc_id][bm25_rank] rank 1 return list(candidates.values())5.3 查询增强的实现与接入查询增强使用 Ollama 调用本地 LLMimport ollama def enhance_query(user_query, historyNone): prompt f 你是一个检索查询优化助手。用户输入一个原始问题请把它改写为适合检索的简洁查询语句。 要求 1. 保留专有名词、编号、人名、产品名 2. 去掉口语化表达与冗余修饰 3. 若存在代词请根据上下文补全为明确词汇 4. 只输出改写后的查询语句不要任何解释 原始问题{user_query} if history: prompt f多轮对话历史{history}\n\n prompt response ollama.chat( modelqwen2.5:7b, messages[{role: user, content: prompt}] ) return response[message][content].strip()这里我加了一个history参数多轮场景下把前文拼到 prompt 里让模型有足够信息消除指代。实际接入链路时建议放在hybrid_recall之前先判断要不要增强再决定是否调用。5.4 重排实现RRF 与 Cross-EncoderRRF 很简单def rrf_fuse(candidates, k60): for cand in candidates: score 0.0 if cand[vec_rank] is not None: score 1.0 / (k cand[vec_rank]) if cand[bm25_rank] is not None: score 1.0 / (k cand[bm25_rank]) cand[fusion_score] score candidates.sort(keylambda x: x[fusion_score], reverseTrue) return candidates如果你有更强的重排要求可以用 BGE-Rerankerfrom sentence_transformers import CrossEncoder reranker CrossEncoder(BAAI/bge-reranker-base) def cross_encoder_rerank(query, candidates, top_k5): pairs [[query, cand[doc]] for cand in candidates] scores reranker.predict(pairs) for cand, score in zip(candidates, scores): cand[rerank_score] float(score) candidates.sort(keylambda x: x[rerank_score], reverseTrue) return candidates[:top_k]要注意的是bge-reranker-base对中文支持不错但推理时间比 RRF 高一个量级。本地没有 GPU 的情况下建议你直接用 RRF或者只对前 10 条做 Cross-Encoder 精排。5.5 把最终结果拼给 LLM 生成回答重排完成后把前 N 条文本块拼成上下文喂给生成模型def generate_answer(query, top_docs): context \n\n---\n\n.join( f文档{idx1}{doc} for idx, doc in enumerate(top_docs) ) prompt f 请根据以下检索到的文档内容回答用户的问题。 如果文档内容不足以回答问题请明确说明“根据现有资料无法确答”。 回答时不要编造信息。 检索到的文档 {context} 用户问题 {query} response ollama.chat( modelqwen2.5:7b, messages[{role: user, content: prompt}] ) return response[message][content]到这里一个完整可跑的“查询增强 双路召回 重排 生成”链路就通了。6. 评测与避坑混合检索到底有没有变好很多人在本地跑通混合检索 demo 之后第一反应是“看起来还行”但一到真实场景就崩。问题出在缺少一套评测方法和几个关键坑的经验。6.1 上线之前先测 hit rate在评估 RAG 检索效果时核心指标是hit rate也就是“真实相关文档是否被召回前 N 条”。这是整个链路最值得先测的指标因为后续生成质量再高上下文里没有正确答案一切都是白搭。评测步骤我一般这么做人工构建 50~100 条“问题 正确文档 id”的评测集分别跑三类链路纯向量、纯 BM25、混合检索计算每个链路 top 5 / top 10 的 hit rate再做一轮重排前后的对比看 top 1 命中率有没有提升。我经常遇到的情况是纯向量 hit rate 大概 60%纯 BM25 大概 45%混合不加重排大概 75%加完重排能到 85% 以上。这个提升幅度非常典型你也应该能看到类似的趋势。如果你发现混合检索的 hit rate 还不如纯向量优先检查两路候选集合并后的规模是否太小向量路召回结果明显偏离主题优先检查 embedding 模型和 chunk 切分策略BM25 路几乎没召回任何东西优先检查分词器和中文 query 的处理。6.2 常见问题速查表问题现象可能原因解决思路混合检索比单路更差两路候选集合并后规模太小或 RRF 中 k 值过大候选集扩大到 30~50把 k 调到 60 以内重新测试BM25 路中文效果极差使用了默认分词器中文被切成单字换成 IK 分词或 jieba 预处理重建索引查询增强后反而召回变差改写模型把专有名词弄丢了检查 prompt 中是否强制保留专有名词必要时用规则把原始 query 中的编号信息拼回改写结果Cross-Encoder 重排太慢候选集太大或模型太大先用 RRF 截断到 10 条再上 Cross-Encoder或换更小的 reranker两路分数量大差异导致排序错乱直接把原始分数加和改用 RRF 或统一做归一化后再加权重知识库里有图片但召回不到图片没有走 OCR 或多模态 embedding 流程文本抽取阶段对图片先 OCR或者对图像单独建多模态索引不要把图片二进制直接塞进文本向量库6.3 几个容易踩的隐性坑查询增强结果和原文连接不上的问题增强后的 query 可能语义太泛导致向量路召回结果“看起来相关但细节不对”。解决办法是把原始 query 和增强后的 query 一起送进检索系统而不是只用增强结果。两条 query 各召回一部分再做融合稳定性明显更好。chunk 切分策略对混合检索影响极大向量路对 chunk 边界不敏感但 BM25 对完整词项非常敏感。如果文本块切得太碎一个完整的“合同编号 A-2024”被切成了“合同编号”和“A-2024”两块BM25 路就彻底废了。建议按段落优先切分尽量不跨标题保留完整句子边界。重排模型不一定能救回所有漏召回的内容。重排只是对召回候选重新排序如果真实相关文档没进入候选集重排再怎么努力也无济于事。所以我反复强调召回阶段可以多放水但不要做高阈值截断。多轮对话场景下历史记录的压缩时机如果历史已经很长全部塞给改写模型会导致关键实体被稀释。建议只保留最近两轮对话并把之前的用户问题做摘要后附在后面既保证上下文又控制噪声。6.4 我的实操体会做混合检索项目最有价值的投资其实是评测集。花半天时间建一套稳定的评测集后面所有的优化都能在数据上看到变化而不是靠“感觉效果好一点”。我自己的经验是先用 RRF 起步让链路先跑通等数据量大了之后再上 Cross-Encoder 精排。不要一上来就堆模型不然出问题的时候你根本分不清是哪一环的锅。最后再分享一个小技巧把查询增强、双路召回、重排三个模块各自独立记录日志包括每条 query 的命中路径。等线上出问题时你能直接看到用户的问题是被哪一路召回的、重排把它提到了第几位。这套日志设计做得好排查问题的效率能翻一倍。混合检索链路不是一次搭完就结束的东西它需要你持续观察、持续调整而调整的每一步都应该建立在可量化的评测上。
返回列表