数据结构与搜索引擎 · 写给完全没接触过的人

倒排索引是什么,Elasticsearch 又是什么

这篇不需要任何前置知识。核心概念其实你早就用过——就是一本书最后几页的"索引"。这篇文章会带着搭一个真正能跑、能验证结果的迷你搜索引擎,配一个可以逐步播放的动画演示。

词项(term) · 拆出来的一个个词 命中/关键步骤 倒排表(postings) · 词对应的文档列表

01 · 从一本书的索引说起

你早就用过倒排索引了

翻开一本厚厚的技术书,最后几页通常有个"索引",长这样:

一本书最后的索引(节选)
并发 ................ 45, 112, 203
锁 ................... 46, 47, 118
死锁 .................. 118, 205

这就是一个倒排索引——"锁"这个词出现在第 46、47、118 页,一目了然。如果没有这个索引,你想知道"死锁"在哪些页出现过,唯一的办法是从第一页翻到最后一页,一页一页找。书越厚,这件事越绝望。搜索引擎、数据库的全文搜索功能、你在网站里点的那个搜索框,解决的都是同一个问题,只是把"书"换成了成千上万篇文档。

02 · 两种索引,方向正好相反

正排索引 vs 倒排索引

正排索引(forward index)

文档 → 词。就是文档本身:第 1 篇文档写的是"the quick brown fox..."。这是数据本来的样子,查"这篇文档写了什么"很快,但反过来查"哪些文档提到了 fox"就得把每篇文档都读一遍。

倒排索引(inverted index)

词 → 文档。把方向倒过来:"fox" 这个词出现在文档 1、3、6。查"哪些文档提到了 fox"变成了查一个词典,一步到位——这正是"倒排"这个名字的来源:把正常的"文档到词"关系倒过来,变成"词到文档"。

下面用 6 篇一句话文档做例子,后面的演示都基于这几篇:

示例文档(6 篇)
1: the quick brown fox jumps over the lazy dog
2: the lazy dog sleeps all day
3: quick fox is a clever animal
4: my dog loves to play in the park
5: the cat sat on the mat
6: quick brown fox and lazy dog are common in stories

03 · 建倒排索引的三步

分词 / 归一化 / 倒排表

分词(tokenize)

把一句话拆成一个个词。英文按空格拆相对简单;中文没有空格,需要专门的分词算法(比如判断"搜索引擎"该拆成"搜索/引擎"还是整体保留)——这是中文搜索比英文麻烦的地方。

归一化 + 停用词

把词统一成小写、去掉标点,再把"the"“is"“a"这类几乎每篇文档都有、没有区分度的高频词(停用词 stopword)过滤掉——它们不会帮你缩小搜索范围,留着只会让索引变大。

倒排表(postings list)

为每个词维护一份"出现在哪些文档里"的清单,清单里的文档编号从小到大排好序——排序这一点很关键,下面查询那一步全靠它加速。

04 · 现场直播

亲手建一个索引,再亲手查一次

下面的演示分两部分:先看着索引一步步被建起来,再看一次真正的查询——两个词的倒排表都排好了序,查询时用两根指针从头往后扫,这个算法叫归并(merge),和归并排序合并两个有序数组是同一个技巧。整个过程用代码真跑过,并且和"笨办法"(挨篇文档暴力扫描)的结果逐条核对一致,不是随手画的动画。

当前文档分词结果
倒排索引(正在建立)
点击"下一步"或"播放"开始。
0 / 0

05 · 为什么这么快

把"暴力扫描"和"倒排索引"放在一起比一比

假设有 100 万篇文档,平均每篇 300 个词。要找出所有包含"倒排索引"这个词的文档:

多个词一起查(比如 AND)呢?两份倒排表都已经排好序,用上面演示里那种"两根指针从头往后走"的归并算法,只需要把两份表各扫一遍——时间只跟"两份表加起来多长"成正比,而不是"表 A 的每一项都去表 B 里找一遍"(那样是长度相乘,慢得多)。伪代码大概是这样:

伪代码:两个有序倒排表求交集(AND)
i, j = 0, 0
result = []
while i < len(listA) and j < len(listB):
    if listA[i] == listB[j]:
        result.append(listA[i])   # 两边都有,命中
        i += 1; j += 1
    elif listA[i] < listB[j]:
        i += 1                    # A 这边小,A 往前走一步
    else:
        j += 1                    # B 这边小,B 往前走一步
return result

为什么必须先排序:如果两份列表是乱序的,指针没法"哪边小就走哪边"这样简单地往前挪,只能退化成挨个去另一边里找,速度立刻打回"暴力"的水平。这也是为什么倒排表建好之后,文档编号必须保持有序——排序不是为了好看,是为了让归并算法能生效。

06 · 光找到还不够,得排出好坏

相关性打分:TF-IDF 与 BM25

倒排索引解决的是"哪些文档包含这个词",但真实搜索还要回答"这些文档里,哪篇跟我最相关"。经典做法叫 TF-IDF,拆开看很好懂:

把 TF 和 IDF 相乘,就是一篇文档对这个词的分数,分数越高排名越靠前。Elasticsearch(以及背后的 Lucene)默认用的是 TF-IDF 的改进版 BM25,主要改进了两点:词频不是越高越好(出现 10 次和出现 100 次,相关性差别不该那么大,需要"边际递减"处理);要考虑文档长度(长文档天然词多,原始词频会占便宜,需要按长度打折)。你不需要记住公式,只要知道:搜索结果的排序,依据的是"词有多稀有"和"词在这篇文档里有多突出",而不是随便排的

07 · Elasticsearch 到底是什么

Lucene 加上"分布式"这层壳

上面讲的分词、倒排表、归并查询、打分排序,全部是一个叫 Lucene 的开源库真正实现的东西(Java 写的,Apache 协议开源)。但 Lucene 只是一个——你得写 Java 代码调用它的 API,而且它天生是单机的,一台机器的硬盘和内存放不下的数据,它管不了。

Elasticsearch 就是在 Lucene 外面包了一层,补上了三件事:

再加上聚合(aggregation)能力——不只是"找到包含某个词的文档",还能做"按小时统计出现错误关键词的日志条数"这类统计分析,这也是为什么 Elasticsearch 常常不只被当搜索引擎用,还被当分析引擎用。

08 · 什么时候该用

真实场景

不适合的场景:需要强一致性的交易型数据(比如银行转账、订单主记录)不该把 Elasticsearch 当成唯一的数据源——它更适合当"专门用来搜索的镜像副本"。常见做法是主数据放在关系型数据库(或者其它专门的存储)里,再同步一份到 Elasticsearch 用来搜索,而不是反过来。

09 · 参考与说明

这篇文章做了哪些简化

☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电