CategoryResourceRepost/极客时间专栏/数据结构与算法之美/实战篇/53 | 算法实战(二):剖析搜索引擎背后的经典数据结构和算法.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

174 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="53 | 算法实战(二):剖析搜索引擎背后的经典数据结构和算法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a8/8d/a84d7173f440a0421af1368ccae31e8d.mp3"></audio>
像百度、Google这样的搜索引擎在我们平时的工作、生活中几乎天天都会用到。如果我们把搜索引擎也当作一个互联网产品的话那它跟社交、电商这些类型的产品相比有一个非常大的区别那就是它是一个技术驱动的产品。所谓技术驱动是指搜索引擎实现起来技术难度非常大技术的好坏直接决定了这个产品的核心竞争力。
在搜索引擎的设计与实现中会用到大量的算法。有很多针对特定问题的算法也有很多我们专栏中讲到的基础算法。所以百度、Google这样的搜索引擎公司在面试的时候会格外重视考察候选人的算法能力。
**今天我就借助搜索引擎,这样一个非常有技术含量的产品,来给你展示一下,数据结构和算法是如何应用在其中的。**
## 整体系统介绍
像Google这样的大型商用搜索引擎有成千上万的工程师十年如一日地对它进行优化改进所以它所包含的技术细节非常多。我很难、也没有这个能力通过一篇文章把所有细节都讲清楚当然这也不是我们专栏所专注的内容。
所以接下来的讲解我主要给你展示如何在一台机器上假设这台机器的内存是8GB 硬盘是100多GB通过少量的代码实现一个小型搜索引擎。不过麻雀虽小五脏俱全。跟大型搜索引擎相比实现这样一个小型搜索引擎所用到的理论基础是相通的。
搜索引擎大致可以分为四个部分:**搜集**、**分析**、**索引**、**查询**。其中搜集就是我们常说的利用爬虫爬取网页。分析主要负责网页内容抽取、分词构建临时索引计算PageRank值这几部分工作。索引主要负责通过分析阶段得到的临时索引构建倒排索引。查询主要负责响应用户的请求根据倒排索引获取相关网页计算网页排名返回查询结果给用户。
接下来,我就按照网页处理的生命周期,从这四个阶段,依次来给你讲解,一个网页从被爬取到最终展示给用户,这样一个完整的过程。与此同时,我会穿插讲解,这个过程中需要用到哪些数据结构和算法。
## 搜集
现在,互联网越来越发达,网站越来越多,对应的网页也就越来越多。对于搜索引擎来说,它事先并不知道网页都在哪里。打个比方来说就是,我们只知道海里面有很多鱼,但却并不知道鱼在哪里。那搜索引擎是如何爬取网页的呢?
搜索引擎把整个互联网看作数据结构中的有向图,把每个页面看作一个顶点。如果某个页面中包含另外一个页面的链接,那我们就在两个顶点之间连一条有向边。我们可以利用图的遍历搜索算法,来遍历整个互联网中的网页。
我们前面介绍过两种图的遍历方法,深度优先和广度优先。搜索引擎采用的是广度优先搜索策略。具体点讲的话,那就是,我们先找一些比较知名的网页(专业的叫法是权重比较高)的链接(比如新浪主页网址、腾讯主页网址等),作为种子网页链接,放入到队列中。爬虫按照广度优先的策略,不停地从队列中取出链接,然后去爬取对应的网页,解析出网页里包含的其他网页链接,再将解析出来的链接添加到队列中。
基本的原理就是这么简单。但落实到实现层面,还有很多技术细节。我下面借助搜集阶段涉及的几个重要文件,来给你解释一下搜集工程都有哪些关键技术细节。
### 1.待爬取网页链接文件links.bin
在广度优先搜索爬取页面的过程中爬虫会不停地解析页面链接将其放到队列中。于是队列中的链接就会越来越多可能会多到内存放不下。所以我们用一个存储在磁盘中的文件links.bin来作为广度优先搜索中的队列。爬虫从links.bin文件中取出链接去爬取对应的页面。等爬取到网页之后将解析出来的链接直接存储到links.bin文件中。
这样用文件来存储网页链接的方式,还有其他好处。比如,支持断点续爬。也就是说,当机器断电之后,网页链接不会丢失;当机器重启之后,还可以从之前爬取到的位置继续爬取。
关于如何解析页面获取链接,我额外多说几句。我们可以把整个页面看作一个大的字符串,然后利用字符串匹配算法,在这个大字符串中,搜索`&lt;link&gt;`这样一个网页标签,然后顺序读取`&lt;link&gt;&lt;/link&gt;`之间的字符串。这其实就是网页链接。
### 2.网页判重文件bloom_filter.bin
如何避免重复爬取相同的网页呢?这个问题我们在[位图](https://time.geekbang.org/column/article/76827)那一节已经讲过了。使用布隆过滤器,我们就可以快速并且非常节省内存地实现网页的判重。
不过,还是刚刚那个问题,如果我们把布隆过滤器存储在内存中,那机器宕机重启之后,布隆过滤器就被清空了。这样就可能导致大量已经爬取的网页会被重复爬取。
这个问题该怎么解决呢我们可以定期地比如每隔半小时将布隆过滤器持久化到磁盘中存储在bloom_filter.bin文件中。这样即便出现机器宕机也只会丢失布隆过滤器中的部分数据。当机器重启之后我们就可以重新读取磁盘中的bloom_filter.bin文件将其恢复到内存中。
### 3.原始网页存储文件doc_raw.bin
爬取到网页之后,我们需要将其存储下来,以备后面离线分析、索引之用。那如何存储海量的原始网页数据呢?
如果我们把每个网页都存储为一个独立的文件那磁盘中的文件就会非常多数量可能会有几千万甚至上亿。常用的文件系统显然不适合存储如此多的文件。所以我们可以把多个网页存储在一个文件中。每个网页之间通过一定的标识进行分隔方便后续读取。具体的存储格式如下图所示。其中doc_id这个字段是网页的编号我们待会儿再解释。
<img src="https://static001.geekbang.org/resource/image/19/4d/195c9a1dceaaa9f4d2483fa91455404d.jpg" alt="">
当然这样的一个文件也不能太大因为文件系统对文件的大小也有一定的限制。所以我们可以设置每个文件的大小不能超过一定的值比如1GB。随着越来越多的网页被添加到文件中文件的大小就会越来越大当超过1GB的时候我们就创建一个新的文件用来存储新爬取的网页。
假设一台机器的硬盘大小是100GB左右一个网页的平均大小是64KB。那在一台机器上我们可以存储100万到200万左右的网页。假设我们的机器的带宽是10MB那下载100GB的网页大约需要10000秒。也就是说爬取100多万的网页也就是只需要花费几小时的时间。
### 4.网页链接及其编号的对应文件doc_id.bin
刚刚我们提到了网页编号这个概念我现在解释一下。网页编号实际上就是给每个网页分配一个唯一的ID方便我们后续对网页进行分析、索引。那如何给网页编号呢
我们可以按照网页被爬取的先后顺序从小到大依次编号。具体是这样做的我们维护一个中心的计数器每爬取到一个网页之后就从计数器中拿一个号码分配给这个网页然后计数器加一。在存储网页的同时我们将网页链接跟编号之间的对应关系存储在另一个doc_id.bin文件中。
**爬虫在爬取网页的过程中涉及的四个重要的文件我就介绍完了。其中links.bin和bloom_filter.bin这两个文件是爬虫自身所用的。另外的两个doc_raw.bin、doc_id.bin是作为搜集阶段的成果供后面的分析、索引、查询用的。**
## 分析
网页爬取下来之后,我们需要对网页进行离线分析。分析阶段主要包括两个步骤,第一个是抽取网页文本信息,第二个是分词并创建临时索引。我们逐一来讲解。
### 1.抽取网页文本信息
网页是半结构化数据里面夹杂着各种标签、JavaScript代码、CSS样式。对于搜索引擎来说它只关心网页中的文本信息也就是网页显示在浏览器中时能被用户肉眼看到的那部分信息。我们如何从半结构化的网页中抽取出搜索引擎关系的文本信息呢
我们之所以把网页叫作半结构化数据,是因为它本身是按照一定的规则来书写的。这个规则就是**HTML语法规范**。我们依靠HTML标签来抽取网页中的文本信息。这个抽取的过程大体可以分为两步。
第一步是去掉JavaScript代码、CSS格式以及下拉框中的内容因为下拉框在用户不操作的情况下也是看不到的。也就是`&lt;style&gt;&lt;/style&gt;``&lt;script&gt;&lt;/script&gt;``&lt;option&gt;&lt;/option&gt;`这三组标签之间的内容。我们可以利用AC自动机这种多模式串匹配算法在网页这个大字符串中一次性查找`&lt;style&gt;`, `&lt;script&gt;`, `&lt;option&gt;`这三个关键词。当找到某个关键词出现的位置之后,我们只需要依次往后遍历,直到对应结束标签(`&lt;/style&gt;`, `&lt;/script&gt;`, `&lt;/option`)为止。而这期间遍历到的字符串连带着标签就应该从网页中删除。
第二步是去掉所有HTML标签。这一步也是通过字符串匹配算法来实现的。过程跟第一步类似我就不重复讲了。
### 2.分词并创建临时索引
经过上面的处理之后,我们就从网页中抽取出了我们关心的文本信息。接下来,我们要对文本信息进行分词,并且创建临时索引。
对于英文网页来说,分词非常简单。我们只需要通过空格、标点符号等分隔符,将每个单词分割开来就可以了。但是,对于中文来说,分词就复杂太多了。我这里介绍一种比较简单的思路,基于字典和规则的分词方法。
其中,字典也叫词库,里面包含大量常用的词语(我们可以直接从网上下载别人整理好的)。我们借助词库并采用最长匹配规则,来对文本进行分词。所谓最长匹配,也就是匹配尽可能长的词语。我举个例子解释一下。
比如要分词的文本是“中国人民解放了”我们词库中有“中国”“中国人”“中国人民”“中国人民解放军”这几个词那我们就取最长匹配也就是“中国人民”划为一个词而不是把“中国”、“中国人”划为一个词。具体到实现层面我们可以将词库中的单词构建成Trie树结构然后拿网页文本在Trie树中匹配。
每个网页的文本信息在分词完成之后我们都得到一组单词列表。我们把单词与网页之间的对应关系写入到一个临时索引文件中tmp_Index.bin这个临时索引文件用来构建倒排索引文件。临时索引文件的格式如下
<img src="https://static001.geekbang.org/resource/image/15/1e/156ee98c0ad5763a082c1f3002d6051e.jpg" alt="">
在临时索引文件中我们存储的是单词编号也就是图中的term_id而非单词本身。这样做的目的主要是为了节省存储的空间。那这些单词的编号是怎么来的呢
给单词编号的方式,跟给网页编号类似。我们维护一个计数器,每当从网页文本信息中分割出一个新的单词的时候,我们就从计数器中取一个编号,分配给它,然后计数器加一。
在这个过程中,我们还需要使用散列表,记录已经编过号的单词。在对网页文本信息分词的过程中,我们拿分割出来的单词,先到散列表中查找,如果找到,那就直接使用已有的编号;如果没有找到,我们再去计数器中拿号码,并且将这个新单词以及编号添加到散列表中。
当所有的网页处理分词及写入临时索引完成之后我们再将这个单词跟编号之间的对应关系写入到磁盘文件中并命名为term_id.bin。
**经过分析阶段我们得到了两个重要的文件。它们分别是临时索引文件tmp_index.bin和单词编号文件term_id.bin。**
## 索引
索引阶段主要负责将分析阶段产生的临时索引,构建成倒排索引。倒排索引( Inverted index中记录了每个单词以及包含它的网页列表。文字描述比较难理解我画了一张倒排索引的结构图你一看就明白。
<img src="https://static001.geekbang.org/resource/image/de/34/de1f212bc669312a499bbbf2ee3a3734.jpg" alt="">
我们刚刚讲到,在临时索引文件中,记录的是单词跟每个包含它的文档之间的对应关系。那如何通过临时索引文件,构建出倒排索引文件呢?这是一个非常典型的算法问题,你可以先自己思考一下,再看我下面的讲解。
解决这个问题的方法有很多。考虑到临时索引文件很大,无法一次性加载到内存中,搜索引擎一般会选择使用**多路归并排序**的方法来实现。
我们先对临时索引文件按照单词编号的大小进行排序。因为临时索引很大所以一般基于内存的排序算法就没法处理这个问题了。我们可以用之前讲到的归并排序的处理思想将其分割成多个小文件先对每个小文件独立排序最后再合并在一起。当然实际的软件开发中我们其实可以直接利用MapReduce来处理。
临时索引文件排序完成之后,相同的单词就被排列到了一起。我们只需要顺序地遍历排好序的临时索引文件,就能将每个单词对应的网页编号列表找出来,然后把它们存储在倒排索引文件中。具体的处理过程,我画成了一张图。通过图,你应该更容易理解。
<img src="https://static001.geekbang.org/resource/image/c9/e6/c91c960472d88233f60d5d4ce6538ee6.jpg" alt="">
除了倒排文件之外我们还需要一个文件来记录每个单词编号在倒排索引文件中的偏移位置。我们把这个文件命名为term_offset.bin。这个文件的作用是帮助我们快速地查找某个单词编号在倒排索引中存储的位置进而快速地从倒排索引中读取单词编号对应的网页编号列表。
<img src="https://static001.geekbang.org/resource/image/de/54/deb2fd01ea6f7e1df9da1ad3a8da5854.jpg" alt="">
**经过索引阶段的处理我们得到了两个有价值的文件它们分别是倒排索引文件index.bin和记录单词编号在索引文件中的偏移位置的文件term_offset.bin。**
## 查询
前面三个阶段的处理,只是为了最后的查询做铺垫。因此,现在我们就要利用之前产生的几个文件,来实现最终的用户搜索功能。
<li>
doc_id.bin记录网页链接和编号之间的对应关系。
</li>
<li>
term_id.bin记录单词和编号之间的对应关系。
</li>
<li>
index.bin倒排索引文件记录每个单词编号以及对应包含它的网页编号列表。
</li>
<li>
term_offsert.bin记录每个单词编号在倒排索引文件中的偏移位置。
</li>
这四个文件中除了倒排索引文件index.bin比较大之外其他的都比较小。为了方便快速查找数据我们将其他三个文件都加载到内存中并且组织成散列表这种数据结构。
当用户在搜索框中输入某个查询文本的时候我们先对用户输入的文本进行分词处理。假设分词之后我们得到k个单词。
我们拿这k个单词去term_id.bin对应的散列表中查找对应的单词编号。经过这个查询之后我们得到了这k个单词对应的单词编号。
我们拿这k个单词编号去term_offset.bin对应的散列表中查找每个单词编号在倒排索引文件中的偏移位置。经过这个查询之后我们得到了k个偏移位置。
我们拿这k个偏移位置去倒排索引index.bin查找k个单词对应的包含它的网页编号列表。经过这一步查询之后我们得到了k个网页编号列表。
我们针对这k个网页编号列表统计每个网页编号出现的次数。具体到实现层面我们可以借助散列表来进行统计。统计得到的结果我们按照出现次数的多少从小到大排序。出现次数越多说明包含越多的用户查询单词用户输入的搜索文本经过分词之后的单词
经过这一系列查询我们就得到了一组排好序的网页编号。我们拿着网页编号去doc_id.bin文件中查找对应的网页链接分页显示给用户就可以了。
## 总结引申
今天,我给你展示了一个小型搜索引擎的设计思路。这只是一个搜索引擎设计的基本原理,有很多优化、细节我们并未涉及,比如计算网页权重的[PageRank](https://zh.wikipedia.org/wiki/PageRank)算法、计算查询结果排名的[tf](https://zh.wikipedia.org/wiki/Tf-idf)[-](https://zh.wikipedia.org/wiki/Tf-idf)[idf](https://zh.wikipedia.org/wiki/Tf-idf)模型等等。
在讲解的过程中我们涉及的数据结构和算法有图、散列表、Trie树、布隆过滤器、单模式字符串匹配算法、AC自动机、广度优先遍历、归并排序等。如果对其中哪些内容不清楚你可以回到对应的章节进行复习。
最后如果有时间的话我强烈建议你按照我的思路自己写代码实现一个简单的搜索引擎。这样写出来的即便只是一个demo但对于你深入理解数据结构和算法也是很有帮助的。
## 课后思考
<li>
图的遍历方法有两种,深度优先和广度优先。我们讲到,搜索引擎中的爬虫是通过广度优先策略来爬取网页的。搜索引擎为什么选择广度优先策略,而不是深度优先策略呢?
</li>
<li>
大部分搜索引擎在结果显示的时候,都支持摘要信息和网页快照。实际上,你只需要对我今天讲的设计思路,稍加改造,就可以支持这两项功能。你知道如何改造吗?
</li>
欢迎留言和我分享,也欢迎点击“请朋友读”,把今天的内容分享给你的好友,和他一起讨论、学习。