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 增加同义词、中英文、术语变体,让两路召回都能命中更多候选。
举个例子,用户搜“报销流程”,我们可以扩展成:
- 报销流程
- 报销标准操作流程
- 报销 SOP
- expense 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.63;BM25 路返回两条,分数是 18.2 和 9.6。你能不能得出“18.2 一定比 0.82 更相关”的结论?不能。两者的分数空间完全不同,而且各自的误差模式也不同。哪怕你对 BM25 分数做 min-max 归一化,也只是把分布拉齐了,并不代表两路分数的可靠性一致。
所以重排有两种主流思路:
- 不看分数,只看名次:这就是 RRF(Reciprocal 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 去部署 ES - LLM:Ollama 加载本地模型,比如
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_name="nomic-embed-text", url="http://localhost:11434/api/embeddings" ) client = chromadb.PersistentClient(path="./chroma_db") collection = client.get_or_create_collection( name="kb_docs", embedding_function=embedding_model ) # 灌数据,chunks 是切好的文本块列表 collection.add( ids=doc_ids, documents=chunks, metadatas=metadatas )然后写 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_k=30): results = collection.query( query_texts=[query], n_results=top_k, include=["documents", "distances", "metadatas"] ) return results这里注意,collection.query默认用的是余弦距离,返回的distances越小越相关,后面融合时如果你想用相似度,可以用1 - distance或者归一化处理一下。
def bm25_search(query, top_k=20): 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 调用本地 LLM:
import ollama def enhance_query(user_query, history=None): prompt = f""" 你是一个检索查询优化助手。用户输入一个原始问题,请把它改写为适合检索的简洁查询语句。 要求: 1. 保留专有名词、编号、人名、产品名 2. 去掉口语化表达与冗余修饰 3. 若存在代词,请根据上下文补全为明确词汇 4. 只输出改写后的查询语句,不要任何解释 原始问题:{user_query} """ if history: prompt = f"多轮对话历史:{history}\n\n" + prompt response = ollama.chat( model="qwen2.5:7b", messages=[{"role": "user", "content": prompt}] ) return response["message"]["content"].strip()这里我加了一个history参数,多轮场景下把前文拼到 prompt 里,让模型有足够信息消除指代。实际接入链路时,建议放在hybrid_recall之前,先判断要不要增强,再决定是否调用。
5.4 重排实现:RRF 与 Cross-Encoder
RRF 很简单:
def rrf_fuse(candidates, k=60): 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(key=lambda x: x['fusion_score'], reverse=True) return candidates如果你有更强的重排要求,可以用 BGE-Reranker:
from sentence_transformers import CrossEncoder reranker = CrossEncoder("BAAI/bge-reranker-base") def cross_encoder_rerank(query, candidates, top_k=5): 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(key=lambda x: x['rerank_score'], reverse=True) 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"文档{idx+1}:{doc}" for idx, doc in enumerate(top_docs) ) prompt = f""" 请根据以下检索到的文档内容,回答用户的问题。 如果文档内容不足以回答问题,请明确说明“根据现有资料无法确答”。 回答时不要编造信息。 检索到的文档: {context} 用户问题: {query} """ response = ollama.chat( model="qwen2.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 的命中路径。等线上出问题时,你能直接看到用户的问题是被哪一路召回的、重排把它提到了第几位。这套日志设计做得好,排查问题的效率能翻一倍。混合检索链路不是一次搭完就结束的东西,它需要你持续观察、持续调整,而调整的每一步,都应该建立在可量化的评测上。