Skip to main content

参考:倒排索引与分段索引

一份关于倒排索引与分段索引的参考——posting list、BM25、近实时刷新、按文档分片与尾延迟,以及在检索时而不是排序之后做可见性过滤。

它是什么

全文搜索回答的是”哪些文档包含这些词,最好的排在前面?“而不用扫描每一个文档。让它变快的结构是倒排索引(inverted index):它不是把文档映射到它的词,而是把每个词项(term) 映射到包含它的文档列表(posting list)。搜索引擎(Lucene、Elasticsearch、Solr)在其上又加了两样东西:segment,即不可变的文件,让索引速度快、几秒内就能被搜到;以及分片,把索引拆分到多台机器上。

Important

核心要点: 一次搜索查询就是对 posting list 的集合运算,事后再排序。用不可变的 segment 让写入便宜,按文档(而不是按词项)分片,把可见性在检索时(排序之前)就限定好,并把最慢的分片当作你的 p99。

何时需要它

“为帖子或商品设计搜索”要求给出索引结构、新文档如何变得可搜索、如何扩展,以及当结果是私有的时会发生什么。第一个回答是倒排索引;资深的追问是新鲜度、分片和可见性。

Note

面试信号: 说”我会在检索时而不是排序之后按可见性过滤,因为排序之后再过滤会浪费排序器的大部分工作,还可能让 top K 为空”,这是搜索设计里最容易被忽略的一点。它说明你认真想过私有内容。

倒排索引

对三个文档:

文档文本
1the quick brown fox
2the lazy brown dog
3quick red fox

倒排索引(转小写并去掉词 “the” 之后)是:

词项Posting list(文档 id)
brown1, 2
dog2
fox1, 3
lazy2
quick1, 3
red3

查询 quick AND fox 取出每个词项的 posting list,[1, 3] 和 [1, 3],并做交集(对有序列表做线性归并,用 skip pointer 加速):结果是 [1, 3]。brown OR red 是并集。Posting list 还存储每个文档里的词频和位置,用来支持相关性打分和短语查询(“quick brown” 要求 brown 紧跟在 quick 后面)。

分析(analysis) 在索引之前和查询之前把文本变成词项:分词、转小写、去停用词,以及词干提取(“running” 变成 “run”)。索引时和查询时必须使用同样的分析,否则词项对不上。

相关性。 候选通过文本相关性函数打分,最常见的是 BM25:它奖励在文档中出现次数多的词项(收益递减,所以把一个词重复 50 次没有帮助),奖励罕见的词项(逆文档频率),并按文档长度做归一化。典型的默认值约为 k1 = 1.2 和 b = 0.75。BM25 是召回的基线;之后由一个学习得到的排序器来精调。

Segment 与近实时索引

每来一个新文档就重写一个大索引,会慢得离谱。基于 Lucene 的引擎使用分段索引:

  1. 新文档进入一个内存 segment,同时有一份日志来保证持久。
  2. 定期(大约每秒)把内存 segment 刷新(refresh):使它可被搜索,并作为一个不可变的 segment 刷到磁盘上。
  3. 一个后台进程把小的 segment 合并成大的,同时移除已删除的文档。Segment 永远不会被原地修改。
  4. 删除是每个 segment 上一个位图里的 tombstone 标记,在查询时应用,在合并时才被物理移除。

查询会在每个 segment(内存的和磁盘的)上运行,并合并结果。近实时(near-real-time) 意味着在时间 0 被索引的帖子,在大约 100 ms 到 1 秒后就可被搜索,而不是一分钟。取舍是:内存 segment 的扫描成本更高,更频繁的提交会创建更多 segment 和更多合并工作,所以真实的管道每 1 到 5 秒刷新一次。这与 LSM-tree 的追加并合并思想相同。

算例:每秒 50,000 个帖子的摄入。 一个 indexer 跟不上,所以把流(例如,通过一个日志按帖子 id)分区给许多 indexer 实例,每个拥有一部分分片。日志的分区提供了并行度,每个 indexer 独立消费自己的分区并构建自己的 segment。

对索引做分片

索引太大,一台机器放不下(大型社交网络可达数十 TB),所以要分片。两种策略:

维度文档分片(按文档 id)词项分片(按 token)
写吞吐极好:一次写入只去一个分片差:一次写入要触碰每个词项对应的分片
查询Scatter-gather:问每个分片,各自返回本地 top N,再合并只问拥有查询词项的那些分片
多词查询每个分片在本地打分要跨分片做交集
热点按哈希分散常见词项变成热点分片
使用者Elasticsearch、Web 规模的搜索历史上有意思,如今很少是对的

选择文档分片,并说出它的代价:用了 scatter-gather,最慢的分片决定了查询延迟,所以分片级别的 p99 比集群级别的 p50 更重要。缓解办法:

  • 请求 hedging: 如果一个分片在它的 p95 延迟时还没应答,就把查询发给它的一个副本。
  • 每个分片的超时加部分结果: 返回 200 ms 内回来的,丢掉慢的分片(“结果可能略有不全”好过超时)。
  • 按负载选副本: 路由到负载最轻的副本,而不是 round robin。

可见性与访问控制

搜索私有内容时最难的问题。朴素的设计是检索候选、排序,然后再按搜索者能看到的内容过滤。如果只有 10% 的候选是可见的,排序器就浪费了 90% 的工作,而且过滤之后 top K 可能是空的。正确的做法是在检索时,即排序之前,就限定可见性:

  1. 索引里的可见性标签。 每个 posting 带一个范围(public,或一个低基数的群组 id)。查询带上搜索者的身份和群组 id,索引只返回范围与之相交的 posting。适合公开内容和群组范围的内容。
  2. 为受限内容做每用户索引。 对”仅好友可见”的帖子,为每个用户的好友的帖子维护一个较小的索引,与公开索引并行查询,再合并。
  3. 最后一道检查作为纵深防御。 即使做了范围限定,在返回任何东西之前仍要核实可见性。永远不要依赖单个过滤器来保护私有内容。

Caution

常见错误: 在排序之后才按权限过滤。它浪费算力,还会泄露时序和结果数量的信息;在规模下,它还会返回很短甚至空的页面。要把权限过滤放进检索里。

如何选择

需求选择原因
对文档做关键词搜索倒排索引对 posting list 做集合运算
新内容几秒内可搜索刷新间隔很短的 segment不可变、可合并
扩展到一台机器之外带 scatter-gather 的文档分片写便宜;负载均匀
p99 被某一个慢分片主导Hedged 请求、部分结果控制尾延迟
私有内容或群组内容在检索时限定可见性避免浪费排序,避免空页
超越 BM25 的相关性第二阶段学习排序器精调 BM25 的候选

相关