前两天我们做了一次线上的效果复盘,遇到一个很典型的case:用户问“为什么苹果手机最近耗电特别快”,系统给出的答案里却引用了好几段关于“苹果(水果)营养价值”的资料。当时群里第一反应都是“Embedding模型不行,换个更强的模型”。但当我们把召回链路日志拉出来,发现问题远比模型更基础——整个系统只有一路召回,纯向量检索。所有文档切块后做embedding,用户query也做embedding,最后按cosine距离取topK。问题恰恰就出在这个“只有”上。
很多人把RAG的检索层想简单了,以为把文档塞进向量库就万事大吉。这篇我就想聊聊,为什么大厂RAG从不用纯向量检索,以及工程上到底应该怎么设计召回链路。如果你正在做知识库问答、企业搜索、智能客服,或者正准备把RAG Demo推到生产环境,这篇文章应该能帮你看清很多隐藏的坑。
1. RAG系统里“向量检索能打”的幻觉,是从哪来的
1.1 Demo阶段的错觉:数据量小,什么都好用
我相信绝大多数团队都会经历这个阶段:项目初期,把几千份内部文档切块、Embedding、灌进向量库,搭一个简单的“文档问答”Demo,效果惊艳。无论问什么,系统都能从top5里召回相关片段,回答看起来有模有样。于是团队信心爆棚,觉得向量检索就是RAG的银弹。
这个错觉的根源很简单:数据量小、测试query干净。Demo阶段的数据集通常只有几百到几千个chunk,用户也大多是内部测试者,问的问题都是标准问法。一个几百条文本的集合里,噪声本来就少,哪怕召回算法很粗糙,语义相似的片段也会密集地挤在前面。说白了,不是向量检索强,是题目太简单。
等到数据量涨到百万级chunk,query变成线上真实用户的问法,事情就开始不对劲。你会发现同样的语义检索,召回结果开始飘,甚至出现完全无关但向量距离很近的chunk。向量检索的确能处理语义泛化,但它天生没有精确匹配能力、没有过滤能力、没有约束能力。小数据量把这些劣势掩盖了,数据一上来,劣势就变成系统性缺陷。
1.2 离线评测掩盖了真实召回质量
很多团队评估RAG效果时,都会用“Recall@K”这类指标。做法是准备几百对query和标准答案,看topK里有没有命中正确答案。这个指标在Demo阶段看起来不错,但它有两个很隐蔽的陷阱。
第一个陷阱:评测集的query往往来自文档标题或FAQ的标准问法,干净、规范、没有口语化表达,也没有否定、比较、排除这类复杂逻辑。第二个陷阱:很多“标准答案”在原文里有几乎一模一样的句子,这些case根本不是向量检索的功劳,而是“原文命中”。你测的其实是一个很弱的任务,而不是真实的开放域问答。
我见过一个团队,离线Recall@5做到90%多,上线后直接掉到60%出头。原因就是线上的query太脏:中英混合、同义改写、指代消解、否定表达,甚至还有用户把两个问题揉在一起问的。离线评测完全没有覆盖这些情况。所以我现在对团队的硬性要求是:离线评测集必须从真实用户日志里采样,而不是自己造。否则一切指标都只是自我安慰。
1.3 搜索团队早就验证过的结论:召回永远是多路并行
如果去问做过搜索引擎的老工程师,他们会告诉你一个反直觉但非常朴素的结论:没有一个召回通道能在所有query上通吃。搜索引擎的召回层从来不是“一路最优”,而是“多路并行、互为兜底”。
向量检索只是在语义召回这条路上做得不错,但它覆盖不了精确匹配、实体检索、关系约束、权限过滤这些场景。而一个成熟的检索系统,恰恰是这些能力组合在一起才可靠。RAG本质上就是一个“检索增强生成”系统,它的检索层理应继承搜索领域的这些经验。很多人以为大厂RAG有什么秘密武器,其实没有,他们只是把搜索领域积累的召回架构平移到了RAG里。
2. 语义召回的本质缺陷:为什么“最相似”不等于“最正确”
2.1 向量距离度量的是“表征相似”,不是“逻辑正确”
这是纯向量检索最根本的问题,它衡量的是两个文本在语义向量空间里离得近不近,但“语义接近”不等于“满足条件”。
举几个最常见的bad case:
- 用户问“不含坚果的零食”,向量召回里很可能出现“坚果零食大全推荐”,因为“坚果”和“零食”这两个词同时在文档和query里出现,向量距离非常近。但用户要的恰恰是排除坚果。
- 用户问“A比B便宜,哪个值得买”,文档里写的是“A比B贵200元”,语义向量会认为两句话高度相关,但逻辑上是相反的。
- 用户问“除螨虫以外的过敏原有哪些”,系统召回的是“螨虫过敏原详解”,因为“螨虫”“过敏原”和query重合度极高。
这些case的共同特点是:文本相似度很高,但语义条件不满足。向量模型擅长捕捉“话题相近”,却不擅长处理否定、比较、排除、条件约束这类逻辑关系。你不能指望一个基于统计分布建模的向量模型,去理解“不含”“除……以外”“不超过”这些精确逻辑。
2.2 精确匹配是embedding的盲区
RAG系统里有一类很常见的需求,跟语义泛化完全无关,就是精确匹配。包括但不限于:
- 找特定SKU编号,比如“查询型号为X3-2024的库存说明”
- 按时间范围过滤,比如“2023年之后发布的公告”
- 按版本号查询,比如“v2.3.1版本的更新日志”
- 按归属人查询,比如“某员工名下的项目文档”
这些信息的特征是没有语义变体,不需要“泛化”,需要的是精确命中和范围过滤。而embedding处理这类问题的能力非常弱。你想让向量模型理解“版本号2.3.1大于2.3.0”,它做不到;你想让它理解“2023年之后”是什么意思,它也只能把“2023”和“之后”两个词编码进语义,但结果完全不可控。
所以但凡文档里存在大量实体型、编号型、时间型信息,纯向量检索的召回质量就会肉眼可见地下降。这不是模型不够强,而是任务本质不匹配。你把一个需要关系型数据库或倒排索引完成的任务,硬塞给了一个为“语义相似度”设计的模型。
2.3 一个真实bad case复盘:否定词的教训
去年我们做一个美妆知识库问答,用户问“不含酒精的护肤品有哪些”,系统召回的内容里居然有一篇标题叫“含酒精成分护肤品避雷清单”的文章。从向量距离看,这篇文章和query非常近——它既提到了“酒精”,又提到了“护肤品”,而且内容确实就是在说“含酒精的护肤品有哪些”。模型分不清“不含”这个否定前缀,它只看到了语义重叠。
这个case我们从两个方向修。一是query分析层:先对用户query做意图识别和否定词抽取,把“不含酒精”提取成一个过滤条件,而不是交给向量检索去“理解”。二是召回层:在向量召回之外增加一路关键词倒排召回,专门处理“含酒精”这类精确成分词,这样即使向量召回返回了错误内容,倒排召回也能纠正候选集。最终bad case率降了不少。
这件事给我最大启发是:检索层出了问题,不应该指望模型“再聪明一点”来解决,而是应该把能结构化的问题结构化,把能精确匹配的问题交给精确匹配的组件。
3. 混合检索不是可选加分项,是规模化的刚需配置
3.1 三路召回的分工该怎么定
现在聊混合检索,很多人第一反应是“向量+BM25”。这个想法方向对,但太粗。一个能上生产的检索层,至少要包含三路能力:倒排召回、向量召回、规则/图谱召回。它们的分工各有侧重:
| 召回通道 | 负责能力 | 适用场景 | 典型引擎 |
|---|---|---|---|
| 倒排召回 | 精确匹配、实体、ID、型号 | SKU编号、人名、版本号、常用词 | Elasticsearch、OpenSearch |
| 向量召回 | 语义泛化、同义改写、未知词 | 口语化提问、同义表达、长尾query | 各类向量数据库 |
| 规则/图谱召回 | 关系约束、业务规则、条件过滤 | 权限隔离、时效过滤、多跳关系 | 知识图谱、规则引擎 |
这三种召回不是简单的谁替代谁,而是各管一段。倒排负责“精确命中”,向量负责“语义探索”,规则和图谱负责“约束纠偏”。三者之间是互补关系,不是竞争关系。
3.2 融合排序不是简单把分数加起来
多路召回之后,面临的第一个问题是:不同通道的分数不在一个量纲上。BM25分数可以是从0到几十的实数,向量cosine距离是-1到1,图召回可能是路径长度或布尔值。直接相加完全没意义。
业界最常见的做法是RRF(Reciprocal Rank Fusion),公式很简单:
score = sum( 1 / (k + rank_i) )它的核心思想是只看每个通道里的排名,不看具体分数。k通常取60左右。这种方法的优点是不需要对不同通道的分数做归一化,而且对分数分布异常不敏感。我现在的项目里,默认融合就是RRF,简单、稳定、调参成本低。
如果你要用加权相加,那必须先对每个通道的分数做归一化,比如min-max归一化或z-score归一化,不然分数尺度大的通道会直接淹没其他通道。这个细节很多人会忽略,结果就是融合之后效果还不如单路召回。
3.3 一个可以照抄的检索层骨架
这里我给一个最简版本的伪代码,展示多路召回的链路逻辑:
def hybrid_recall(query, filters, top_k): # 1. 分析query:在线时先判断意图、抽取实体、识别否定词 analysis = query_analysis(query) # 2. 多路并行召回,召回量要给final top_k留出余量 candidates = {} if analysis.has_exact_terms(): candidates['bm25'] = bm25_search( query=query, filters=analysis.filters, size=top_k * 3 ) if analysis.need_semantic(): candidates['vector'] = vector_search( query=analysis.embedding_query, filters=analysis.filters, size=top_k * 3 ) if analysis.need_graph(): candidates['graph'] = graph_query( entities=analysis.entities, filters=analysis.filters, size=top_k ) # 3. 融合排序 merged = rrf_merge(candidates, k=60) # 4. 权限过滤必须在排序前或并行完成,不能后置 merged = permission_filter(merged, user_context) return merged[:top_k]这里面有两个细节值得特别注意。一个是召回量要留余量:最终只需要top10,那每路召回至少取top30甚至top50,否则融合排序和后续的rerank没有操作空间。另一个是权限过滤必须前置或在检索时pushdown,如果等混合排序完再过滤,一旦无权限结果占了多数,最终有效结果可能只有两三个,回答质量直接崩。
4. 大厂索引架构里的真实兵力部署:倒排、图、元数据与三级召回
4.1 倒排索引不是老古董,是精确召回的主力
很多做RAG的年轻人一听到倒排索引就觉得很“传统”,其实在真实生产系统里,倒排索引的地位一点不比向量索引低。原因很简单:大量query需要的是精确命中,而不是语义泛化。
举个最典型的场景:用户问“根据编号XJ-2049查询设备维修记录”。这句话里唯一有价值的信息是“XJ-2049”这个编号。你把它embedding之后,向量库里能找到什么?顶多找到一些包含“XJ”或“2049”的相似片段。但你用倒排索引去精确匹配“XJ-2049”,它能把对应的维修记录全部捞出来。哪个更可靠?一目了然。
所以在我们的索引架构里,Elasticsearch承担的角色不是“备胎”,而是第一道闸门。用户在对话框输入query时,我们会先做实体识别,一旦抽取出编号、型号、人名这类高置信实体,就直接走倒排精确召回,向量召回只是作为补充。
4.2 图结构:关系约束该交给知识图谱,而不是向量
再往上一层,你会发现有些问题连“语义”都不太用得上,纯粹是“关系”问题。比如“这个药和哪种食物不能一起吃”“A部门和B部门的项目有什么关联”“这个接口依赖哪些上游服务”。这些问题的核心是实体之间的关系,而不是文本相似度。
如果用向量检索来回答“药物和食物相克”,它会根据文本表面相似度给你返回一堆“可能相关”的段落,但无法保证关系的正确性。而知识图谱把实体建模成节点、把关系建模成边,回答这类问题时可以在图谱上直接做路径查询,结果确定性强、可解释性强。
我们内部对知识图谱的使用策略是:凡是业务上有明确实体关系、且不能容忍语义歧义的场景,一律纳入图谱管理,不让向量检索碰这类query。图谱召回的结果也不是给LLM当参考,而是作为强约束条件,直接决定候选集能不能进入后续排序。
4.3 元数据过滤:权限和时效放在检索侧,不是排序侧
纯向量检索还有一个特别容易被忽视的问题:它只对“内容”建模,不对“元数据”建模。但生产环境的RAG系统,几乎所有文档都带元数据——所属部门、可见权限、发布时间、文档版本、地域标签等。这些元数据决定了“这条内容该不该出现在这个用户面前”。
很多团队的做法是先做向量topK召回,然后再把召回结果拿去跟权限白名单比对,把无权访问的过滤掉。这种做法在小规模下勉强能用,但风险很大:如果topK里大量结果是当前用户无权访问的文档,那过滤之后有效结果可能只剩两三个,后面的LLM就算再强也无力回天。
正确的做法是把过滤条件直接下推到检索阶段,也就是所谓的filter pushdown。向量库或倒排索引在执行检索时,就同步把“部门=某部门”或“visible_to=当前用户”这类条件加进去,只返回满足条件的候选。这样召回结果才真正可用。
4.4 三级召回设计参考
综合上面的思路,一个能支撑规模化RAG的召回层,通常会设计成三级:
| 召回级别 | 定位 | 典型实现 | 召回量级 |
|---|---|---|---|
| 精确召回 | 实体、ID、唯一约束 | 倒排索引 | 100-200 |
| 语义召回 | 同义改写、口语化、未知词 | 向量索引 | 200-300 |
| 规则纠偏 | 关系约束、权限、时效 | 知识图谱/规则引擎 | 50-100 |
三级召回的结果经过RRF融合,再交给一个轻量级的rerank模型统一打分,最终给LLM的上下文控制在top10到top20。这其中的核心逻辑是:每级召回解决一类问题,融合层做兜底,rerank层做精排。
5. 过滤、去重与权限:向量检索失效的三个高频场景
5.1 权限隔离:embedding学不出“能不能看”
我先说一个很多人不愿意面对的事实:向量模型完全不知道文档的权限边界。它只会告诉你两段文本在语义上像不像,永远不会告诉你“这段内容该不该给这个用户看”。
企业内部文档尤其如此。比如市场部的产品资料,销售团队可以看,外部合作方不能看;某些高管会议纪要是机密,普通员工连搜索都不应该搜到。如果你的RAG系统只做向量检索,等于把“权限判断”这个关键环节交给了不存在的机制。结果就是:用户可能通过一个间接问法,把无权访问的内容从向量库里“套”出来。
我们在一个模拟项目X里测过,加入权限过滤之后,纯向量检索的有效召回率从82%掉到54%。也就是说,辛辛苦苦召回的文档里,将近三成是用户根本没权限看的。这样的系统哪怕上线,也随时可能出安全事故。
5.2 数据时效:新内容还没进索引,用户已经来问了
向量索引的更新成本比倒排索引高很多。一个文档进来,要经过切块、embedding计算、写入索引、构建图结构,整个过程可能有分钟级的延迟。如果业务对时效有强要求,比如新发布的公告、新上架的商品、刚提交的工单状态,纯向量检索就会发生“索引还没更新,用户已经来问”的尴尬。
这个场景最典型的反面教材是电商客服:新品刚上架,销售人员立刻来问“这个新品的卖点有哪些”,但向量索引里还没有这个商品的数据,系统只能回答“没有找到相关信息”。而如果用倒排索引直连业务数据库,或者用规则召回直接查最新库表,这个query根本不需要等embedding更新,实时就能捞到。
混合检索在这里的价值,不是替代向量,而是给时效性强的数据提供另一条低延迟通道。
5.3 重复与近似内容:向量距离做不了唯一性约束
还有一个高频但容易被忽视的问题:同一篇文档可能有多个版本,同一份公告可能在多个栏目下重复发布。向量检索天然会把所有相似内容都召回来,导致top10里出现三个内容几乎一样的chunk。这既浪费了上下文窗口,也让回答变得冗余。
这类问题靠向量距离解决不了,你需要的是按文档ID去重、按版本号取最新、按来源权重排序。这些规则跟语义无关,但恰恰是生产系统必须处理的。没有规则/倒排通道的支持,单靠向量库很难优雅地处理这类约束。
6. 延迟与成本账:纯向量方案在规模下的真实代价
6.1 向量索引的内存和算力开销,远超你预期
先算一笔最朴素的内存账。假设你有1亿条chunk,每条chunk用768维float向量表示,那么原始向量本身占用的内存就是:
1亿 × 768 × 4字节 ≈ 307.2GB这还只是原始向量,没算HNSW这类图索引的额外边信息。HNSW为了加速检索,需要保存每个节点与邻居节点的连接关系,这部分额外内存通常会再吃掉原始向量体积的30%到100%。也就是说,1亿条chunk索引全内存部署,单副本就要500GB到600GB内存。如果你有三四个副本,那直接就是几个TB级别。
这还没完。向量检索看起来是“算相似度”,但在大规模下就是高并发的CPU密集型计算。QPS一高,CPU的浮点运算和内存带宽都会成为瓶颈。所以很多团队用着用着发现,向量库节点从4台加到12台,延迟还是不稳定。
6.2 倒排先筛、向量精排,为什么能省下真金白银
混合检索省钱的核心逻辑很简单:不要让向量检索处理所有候选集,先用低成本的倒排索引把范围缩小。
举个例子。你的业务里有大量query带明确的过滤条件,比如“某个部门”“某个时间范围”“某个产品线”。倒排索引可以秒级把这些过滤条件命中,把候选集从百万级压缩到几千级。此时再对这个几千级的候选集做向量精排,计算量和全量召回完全不是一个量级。这就像你要在整座城市里找一家店,先按行政区、按街道缩小范围,然后再逐一比对,而不是挨家挨户敲门。
这也是为什么很多大厂RAG的向量库节点数量可以控制得很低——不是他们的数据量小,而是真正进入向量计算的候选集被前置过滤掉了。
6.3 我在模拟项目X里算的一笔账
我们在模拟项目X(5000万chunk,日均8000万次查询)里做过一个对比测试。纯向量方案需要12台高配向量库节点,每台512GB内存,机器成本很高。改成“倒排先筛+向量精排+规则纠偏”的混合方案后,倒排索引把大量query的候选集缩小到原来的20%左右,向量节点降到6台。整体资源成本降了约45%,而且由于每路召回的候选集变小,延迟P99反而从380ms降到了210ms。
我必须坦白说,这个数字跟具体数据分布强相关,不同业务会有差异。但方向是共通的:被倒排索引挡掉的向量计算量,每多一点,成本和延迟就好看一点。纯向量方案在规模化的场景下,真的扛不住。
7. 什么时候还能坚持纯向量检索:我给团队的判断清单
7.1 七条判据
说了一大堆混合检索的必要性,但我也不是“逢向量必反”。在特定场景下,纯向量检索完全可以胜任,而且架构更简单、维护成本更低。我的经验是,如果你的项目满足下面这些条件,纯向量检索可以继续用:
| 编号 | 判断条件 | 说明 |
|---|---|---|
| 1 | chunk数低于50万 | 数据量小,内存和延迟压力都可控 |
| 2 | 查询模式固定 | 以FAQ为主,问题集中在少数标准问法 |
| 3 | 不追求事实级精确 | 用户能接受“语义相关”的结果,对引用出处要求不高 |
| 4 | 无权限隔离需求 | 所有文档对所有人可见 |
| 5 | 无强时效约束 | 新文档入库后几分钟内不被搜索也能接受 |
| 6 | 文档实体密度低 | 编号、型号、版本、人名等精确实体很少 |
| 7 | 评测集覆盖真实分布 | 已经用真实线上query验证过,bad case可接受 |
这些条件同时满足的项目,说实话不多。但如果你确实做的是一个内部小型知识库,用户就是几十个人,文档量不大,问题也很固定,那非要上混合检索确实是过度设计。
7.2 即使坚持纯向量,也要做的三件事
即便你决定用纯向量,我也强烈建议至少做三件事,避免线上翻车。
第一件,做数据剪枝。把重复文档、过期文档、没有检索价值的碎片全部清理掉再embedding。向量检索不会帮你判断“这条内容该不该进索引”,它只会忠实返回最相似的内容。索引质量直接决定召回质量。
第二件,给向量戴“元数据帽子”。即使不做复杂过滤,也要把文档的部门、时间、版本这些字段存成属性。这样至少能在必要的时候做粗粒度的过滤,而不是完全裸奔。
第三件,上线bad case监控。不要只盯着Recall@K这种指标。单独维护一个“错误案例库”,每周从线上日志里捞一批真实query,人工标记哪些回答错了,再回归测试。纯向量检索的问题往往不是一开始就爆发,而是随着数据量增长慢慢变多,没有监控的话,你会在用户投诉之后才意识到。
7.3 最后分享一点个人体会
我做过的几个检索项目里,最怕的不是模型效果不够好,而是线上bad case在悄悄增长,但团队没有一套机制去发现它。无论是纯向量还是混合检索,一定要把“真实问题回放”这件事做好。我自己现在每个项目上线前,都会先拉一个月真实用户日志,跑一遍全链路,把每个bad case归类:这是召回问题还是排序问题还是生成问题。只要这里面的比例稳定,系统再烂都不会烂到失控。
RAG的检索层,说到底不是一个模型问题,而是一个架构问题。方向选对了,后面都是细节。