北川广海の梦

北川广海の梦

全文检索集群下得分计算

38
2024-02-08

ES 搜索评分

ElasticSearch 对分布式支持十分出色。通过 shard 分片,能够将索引拆分到不同的节点,提供更优的整体性能。

然而这样的做法,虽然对可扩展性提供了很好的支持,但是搜索本身更加复杂了。

全文检索的返回结果都是有序的。一般来说,排名越高的文档,其搜索匹配度也就越高,越可能是用户想要的搜索结果。对于一篇文档,ES 采用了特定的算法来进行得分计算。具体算法过程可以参考这篇文章

由上面的文章,可以总结得出,一个文档的得分高低,主要取决于两个部分:

  • 文档本身与搜索关键项的相关性,例如文档长度比较短,但是出现了一次关键词。和一篇很长的小说,出现了一次关键词,那么前者更加匹配的可能性显然更高。再比如,一个关键词在一篇文档中出现了一次,在另一篇文档中出现了三次。那么,后者显然可能更相关。

  • 关键词本身的“稀有程度”。例如 我搜索了一个常见词,它在几百篇文档中都出现了。那么显然,这个关键词的得分占比就不会太高,因为它太常见了。相反,如果一个专有名词,出现频率很低,那么包含这个关键词的文档就有较高的可能性是相关的。

由上面的两条结论,我们可以推理出在分布式条件下,ES的得分计算与单机是不同的。

在单机情况下。一个索引没有被拆分,直接就对应一个 lucene 索引。那么上面提到的第二点,”稀有程度“,一定是毫无异议全局相同的。

但是在分布式情况下,一个索引被拆分位多个 shard。每个 shard 就是一个lucene 实例。这时来了一个请求,需要同时对 A、B 两个 shard 进行搜索。那么 A 节点的搜索结果里的评分,其得分相关性只能根据 A 节点自身的统计信息(统计信息包含了用于计算得分的信息,例如每个关键词出现了多少次,文档的平均长度等)对得分进行计算。而 B 节点也做了相同的事情。这就会导致排名结果的不确定性。因为没有一个全局的统计信息,来进行正确的得分计算。

事实上,ES 解决的思路也正是通过 全局统计信息来实现的。

当一个查询到来时,这个节点会变成一个协调节点(只是针对本次请求而言),然后会对其它 shard 所在的节点进行一次预查询。预查询不会真正执行 搜索,而是获取各个节点的统计信息。例如 A节点的文档平均长度,A 节点中,本次搜索用到的 关键词的出现次数。之后协调节点会将从其它 shard 搜集到的 统计信息进行合并,得到一个全局的统计信息,然后将这个正确的统计信息,发送给其它节点,其它节点再真正执行搜索,并且按照这个全局统计信息进行得分计算。

Zincsearch 全局搜素

zincsearch 是基于 Golang 实现的 轻量级搜索引擎。其具有内存占用低,启动快,并且搜索速度不逊色于 ES 的特点。但是缺点是不支持分布式,我们对其进行 fork,并加入了集群支持(主要通过 S3等开源分布式存储,采用存储-计算 分离的结构实现)

我们选择使用 zincsearch 的主要原因是它更轻量级,更便于部署。并且对多索引支持更友好。我们的使用场景为 资料库搜索,一个用户可能同时搜索几百个资料库。一个资料库对应一个 zincsearch 底层的 bluge 索引

以上是问题背景。在搜索单索引时,zincsearch 也同样表现良好。但是跨索引 搜索,也遇到了同样的 排名不确定的问题。很显然,这是因为各个索引都使用自己的统计信息,它们的搜索结果相互之间是不可比较的。并且我们不仅在集群下存在问题,单机下搜索也是同样的(跨索引)

有了 ES 的思路,我们决定采用相同的方式解决。当然,我们得首先解决单机模式下的夸索引搜索。单机下的实现思路也是相同的。在夸索引搜索时,先对 query 进行解析,得到本次搜索用到的所有 字段和 term。然后遍历本次需要搜索的索引,拿到统计信息再合并即可。bluge 并没有直接暴露出 API能够获取统计信息。所以我们修改了 bluge的源码 ,暴露了一些API。

集群下的实现,总体和 ES 大同小异。主要的差别是,我们有统一的 无状态 proxy 作为 API 入口,所以自然不需要协调节点的角色。proxy即可胜任。proxy 首先解析出本次查询针对哪些索引。调用这些 索引所在的节点提供的获取统计信息的API。之后 proxy 进行统一合并。再将合并后的结果和 query 一起发送给执行节点,最终拿到所有的结果。