那是晚上十一点,我被线上告警吵起来。用户搜索“低风险理财”,返回的全是股票,加了个“仅看可购买”的过滤条件,结果直接空了。查了半天,发现是查询里bool套bool,filter子句把向量召回的所有结果都挡掉了——因为向量检索召回的是“语义上像”的内容,压根儿没管用户租户和上架状态,而关键词匹配到的那些文档,又因为向量得分太高被排名挤出了前N,根本走不到过滤这一步。那一刻我意识到,混合检索根本不是把“语义+关键词+过滤”拼在一起那么简单,拼错了,就是灾难。
先说过滤。很多人不理解为什么过滤要单独拎出来说。语义检索是找“像”的,关键词检索是找“有”的,过滤是“必须是”的。这三类条件性质完全不同。过滤字段一般包括:租户ID、产品状态、库存数量、上架时间、用户可见性。这些字段你不能让它们参与打分,否则整个相关性分数全乱套。最典型的错误就是把filter当成must写进去。必须写的语义是:我要结果必带上这个条件,但我不希望它影响相关性排序。所以用filter子句没问题,但位置很讲究。
在ES里,如果你用knn检索,filter可以放在knn内部,这样是先过滤再向量搜索,性能好,也不会污染分数。如果你用post_filter,那是搜完topN再过滤,结果可能凑不够数,甚至出现空集。我那次空集就是这么来的:向量召回的前50个里没有一个满足“可购买”,而关键词命中又因为分数低被截断。所以,对向量检索,filter一定要放在knn子句里,对关键词检索,filter放在bool的filter子句里。这样两边召回的都是合规文档,融合后再做业务过滤,宁可多召回一点,别在源头就砍掉。
再说关键词检索。关键词在高精确匹配场景下有不可替代的价值,比如产品编码“ABC-123”、品牌名“Nike”、专有术语“Docker”。语义模型对这些玩意儿可能给你乱七八糟的同义词,但关键词就是精确命中。所以混合检索里的关键词部分,不能用简单的match,要结合match_phrase和term,并给准确的字段加权。比如标题命中给的权重要大于正文。这里有个坑是:关键词检索的结果集和语义结果集会重复,别直接用or拼成一个大query,因为ES的评分机制会把两个检索的分数混在一起,导致顺序怪异。正确做法是多路召回,最后在内存里做融合。
融合方式我强烈推荐RRF(Reciprocal Rank Fusion)。原理简单到你不敢相信:给每个结果分别在两路里的排名加个倒数,排名第一得1/61,排名第十得1/70,然后把两路分数加起来。这样量纲一致,不受向量分数高低波动影响。你不需要去调什么归一化参数,不用管BM25是0.8还是1.5。代码就这么几行:
defrrf_fuse(kw_hits,vec_hits,k=60):fused={}forrank,hitinenumerate(kw_hits):fused[hit["_id"]]=fused.get(hit["_id"],0)+1.0/(k+rank+1)forrank,hitinenumerate(vec_hits):fused[hit["_id"]]=fused.get(hit["_id"],0)+1.0/(k+rank+1)returnsorted(fused.items(),key=lambdax:-x[1])这里k取60是惯例,你调成50也行,差别不大。注释要写明白:这个融合函数假设两个结果集都已经带上了硬过滤条件,别拿没过滤的数据往里塞,否则后面还得查一遍库。
有个细节很多人不注意:两路召回数量要大致对等。如果你语义检索取了100条,关键词只取10条,那关键词命中的文档即使排名第一,RRF分数也只有1/61,而语义第一百名还能得1/160,差距不太大,但整体上关键词的作用被削弱。我一般两路都取50到100,数量一致,融合效果最稳。
再来说后置过滤。即使召回前已经做了filter,业务层可能还有更复杂的规则,比如“这个用户所在的VIP分组不能看某些位点”,这类规则写不进ES查询,或者写进去太麻烦。那就在融合后再过滤一遍。但注意,过滤一定要在去重之后、排序之前,不然可能把高分结果误杀。比如同一篇文档在语义和关键词两路都召回了,融合后的分是两路叠加的,后过滤时如果用“只保留doc_id在某个集合内”的方式,要等到叠加完了再做。别在每路结果里各自过滤后再融合,那样会丢掉重复召回的分数加成。
我现在的标准流水线是:查询解析——把用户输入拆成三个部分:纯关键词、语义向量、过滤条件。然后并行发ES:一个bool query处理关键词并带filter,一个knn query处理向量并带filter。两路结果都取topN,跑到内存里做RRF融合。融合完,再上业务过滤器,最后截断到需要的大小。这玩意儿我管它叫“三明治混合检索”——上下两层过滤,中间是两路召回和融合。
对了,还有一个坑得说。有些工程师喜欢把过滤条件用script_score包一层,等于硬生生给过滤字段算了个分。比如写“if (status==1) score += 100”这种操作。这会让向量检索的分数彻底失衡,因为没有这个条件的文档分数可能全部变成0。哪怕你只是加1,也会改变原有排序。硬条件就该用filter机制,而不是score机制。你如果不确定一个条件该算分还是过滤,问自己一句:如果这个条件不满足,用户看到结果后会骂娘吗?会骂,就放filter。如果只是更偏好,那才考虑boost。
关于向量检索的filter,ES 8.x的knn支持内嵌filter,但旧版本不支持。如果你还在用7.x,只能先向量召回再在应用层过滤。这时候就考验你的胆子了:召回量要多设几倍,比如你最终想要20条,至少召回100条,否则过滤后可能只剩3条。我当年在7.16上就是让num_candidates大一点,但太多了会慢,这个度得自己压测。实在不行,把业务过滤条件变成索引上的routing,按租户划分shard,效率高得飞起。
写到这里,我给你的经验不是教科书式的那套,而是我踩完坑之后的取舍。第一,混合检索不是越多路越好,两路足够:一路精匹配,一路宽语义。过滤加三路?没必要。第二,不要沉迷调权重。你会掉进“关键词命中但语义分低”和“语义近但关键词没中”的死循环,RRF能让你睡个好觉。第三,永远保证过滤条件在召回前和融合后各做一次,前者保证性能,后者保证正确。第四,把代码里的过滤逻辑抽成公共函数,传给关键词和向量两个查询,别复制粘贴。第五,如果你发现线上搜索结果空集,别先查算法,先查filter条件是不是和上下文无关——比如把默认值0当成了合法值导致全过滤掉。
就这些。下一个专题我写排序重排那些事,再见。