This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,132 @@
<audio id="audio" title="17 | 存储系统从检索技术角度剖析LevelDB的架构设计思想" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/ee/035d94d60c742b53406700a780054cee.mp3"></audio>
你好,我是陈东。
LevelDB是由Google开源的存储系统的代表在工业界中被广泛地使用。它的性能非常突出官方公布的LevelDB的随机读性能可以达到6万条记录/秒。那这是怎么做到的呢这就和LevelDB的具体设计和实现有关了。
LevelDB是基于LSM树优化而来的存储系统。都做了哪些优化呢我们知道LSM树会将索引分为内存和磁盘两部分并在内存达到阈值时启动树合并。但是这里面存在着大量的细节问题。比如说数据在内存中如何高效检索数据是如何高效地从内存转移到磁盘的以及我们如何在磁盘中对数据进行组织管理还有数据是如何从磁盘中高效地检索出来的
其实这些问题也是很有代表性的工业级系统的实现问题。LevelDB针对这些问题使用了大量的检索技术进行优化设计。今天我们就一起来看看LevelDB究竟是怎么优化检索系统提高效率的。
## 如何利用读写分离设计将内存数据高效存储到磁盘?
首先对内存中索引的高效检索我们可以用很多检索技术如红黑树、跳表等这些数据结构会比B+树更高效。因此LevelDB对于LSM树的第一个改进就是使用跳表代替B+树来实现内存中的C0树。
解决了第一个问题。那接下来的问题就是内存数据要如何高效存储到磁盘。在第7讲中我们说过我们是将内存中的C0树和磁盘上的C1树归并来存储的。但如果内存中的数据一边被写入修改一边被写入磁盘我们在归并的时候就会遇到数据的一致性管理问题。一般来说这种情况是需要进行“加锁”处理的但“加锁”处理又会大幅度降低检索效率。
为此LevelDB做了读写分离的设计。它将内存中的数据分为两块一块叫作**MemTable**,它是可读可写的。另一块叫作**Immutable MemTable**,它是只读的。这两块数据的数据结构完全一样,都是跳表。那它们是怎么应用的呢?
具体来说就是当MemTable的存储数据达到上限时我们直接将它切换为只读的Immutable MemTable然后重新生成一个新的MemTable来支持新数据的写入和查询。这时将内存索引存储到磁盘的问题就变成了将Immutable MemTable写入磁盘的问题。而且由于Immutable MemTable是只读的因此它不需要加锁就可以高效地写入磁盘中。
好了数据的一致性管理问题解决了我们接着看C0树和C1树的归并。在原始LSM树的设计中内存索引写入磁盘时是直接和磁盘中的C1树进行归并的。但如果工程中也这么实现的话会有两个很严重的问题
1. 合并代价很高因为C1树很大而C0树很小这会导致它们在合并时产生大量的磁盘IO
1. 合并频率会很频繁由于C0树很小很容易被写满因此系统会频繁进行C0树和C1树的合并这样频繁合并会带来的大量磁盘IO这更是系统无法承受的。
那针对这两个问题LevelDB采用了延迟合并的设计来优化。具体来说就是先将Immutable MemTable顺序快速写入磁盘直接变成一个个**SSTable**Sorted String Table文件之后再对这些SSTable文件进行合并。这样就避免了C0树和C1树昂贵的合并代价。至于SSTable文件是什么以及多个SSTable文件怎么合并我们一会儿再详细分析。
好了现在你已经知道了内存数据高效存储到磁盘上的具体方案了。那在这种方案下数据又是如何检索的呢在检索一个数据的时候我们会先在MemTable中查找如果查找不到再去Immutable MemTable中查找。如果Immutable MemTable也查询不到我们才会到磁盘中去查找。
<img src="https://static001.geekbang.org/resource/image/22/1a/22cbb79dd84126a66b12e1b50c58991a.jpeg" alt="" title="增加Immutable MemTable设计的示意图">
因为磁盘中原有的C1树被多个较小的SSTable文件代替了。那现在我们要解决的问题就变成了如何快速提高磁盘中多个SSTable文件的检索效率。
## SSTable的分层管理设计
我们知道SSTable文件是由Immutable MemTable将数据顺序导入生成的。尽管SSTable中的数据是有序的但是每个SSTable覆盖的数据范围都是没有规律的所以SSTable之间的数据很可能有重叠。
比如说第一个SSTable中的数据从1到1000第二个SSTable中的数据从500到1500。那么当我们要查询600这个数据时我们并不清楚应该在第一个SSTable中查找还是在第二个SSTable中查找。最差的情况是我们需要查询每一个SSTable这会带来非常巨大的磁盘访问开销。
<img src="https://static001.geekbang.org/resource/image/5f/02/5f197f2664d0358e03989ef7ae2e7e02.jpeg" alt="" title="范围重叠时查询多个SSTable的示意图">
因此对于SSTable文件我们需要将它整理一下将SSTable文件中存的数据进行重新划分让每个SSTable的覆盖范围不重叠。这样我们就能将SSTable按照覆盖范围来排序了。并且由于每个SSTable覆盖范围不重叠当我们需要查找数据的时候我们只需要通过二分查找的方式找到对应的一个SSTable文件就可以在这个SSTable中完成查询了。<br>
<img src="https://static001.geekbang.org/resource/image/4d/a7/4de515f8b4f7f90cc99112fe5b2b2da7.jpeg" alt="" title="范围不重叠时只需查询一个SSTable的示意图">
但是要让所有SSTable文件的覆盖范围不重叠不是一个很简单的事情。为什么这么说呢我们看一下这个处理过程。系统在最开始时只会生成一个SSTable文件这时候我们不需要进行任何处理当系统生成第二个SSTable的时候为了保证覆盖范围不重合我们需要将这两个SSTable用多路归并的方式处理生成新的SSTable文件。
那为了方便查询我们要保证每个SSTable文件不要太大。因此LevelDB还控制了每个SSTable文件的容量上限不超过2M。这样一来两个SSTable合并就会生成1个到2个新的SSTable。
这时新的SSTable文件之间的覆盖范围就不重合了。当系统再新增一个SSTable时我们还用之前的处理方式来计算这个新的SSTable的覆盖范围然后和已经排好序的SSTable比较找出覆盖范围有重合的所有SSTable进行多路归并。这种多个SSTable进行多路归并生成新的多个SSTable的过程也叫作Compaction。
<img src="https://static001.geekbang.org/resource/image/32/0a/32e551a4f13d5630b7a0e43bef556b0a.jpeg" alt="" title="SSTable保持有序的多路归并过程">
随着SSTable文件的增多多路归并的对象也会增多。那么最差的情况会是什么呢最差的情况是所有的SSTable都要进行多路归并。这几乎是一个不可能被接受的时间消耗系统的读写性能都会受到很严重的影响。
那我们该怎么降低多路归并涉及的SSTable个数呢在[第9讲](https://time.geekbang.org/column/article/222807)中我们提到过对于少量索引数据和大规模索引数据的合并我们可以采用滚动合并法来避免大量数据的无效复制。因此LevelDB也采用了这个方法将SSTable进行分层管理然后逐层滚动合并。这就是LevelDB的分层思想也是LevelDB的命名原因。接下来我们就一起来看看LevelDB具体是怎么设计的。
首先,**从Immutable MemTable转成的SSTable会被放在Level 0 层。**Level 0 层最多可以放4个SSTable文件。当Level 0层满了以后我们就要将它们进行多路归并生成新的有序的多个SSTable文件这一层有序的SSTable文件就是Level 1 层。
接下来如果Level 0 层又存入了新的4个SSTable文件那么就需要和Level 1层中相关的SSTable进行多路归并了。但前面我们也分析过如果Level 1中的SSTable数量很多那么在大规模的文件合并时磁盘IO代价会非常大。因此LevelDB的解决方案就是**给Level 1中的SSTable文件的总容量设定一个上限**默认设置为10M这样多路归并时就有了一个代价上限。
当Level 1层的SSTable文件总容量达到了上限之后我们就需要选择一个SSTable的文件将它并入下一层为保证一层中每个SSTable文件都有机会并入下一层我们选择SSTable文件的逻辑是轮流选择。也就是说第一次我们选择了文件A下一次就选择文件A后的一个文件。**下一层会将容量上限翻10倍**这样就能容纳更多的SSTable了。依此类推如果下一层也存满了我们就在该层中选择一个SSTable继续并入下一层。这就是LevelDB的分层设计了。
<img src="https://static001.geekbang.org/resource/image/ca/5a/ca6dad0aaa0eb1303b5c1bb17241915a.jpeg" alt="" title="LevelDB的层次结构示意图">
尽管LevelDB通过限制每层的文件总容量大小能保证做多路归并时会有一个开销上限。但是层数越大容量上限就越大那发生在下层的多路归并依然会造成大量的磁盘IO开销。这该怎么办呢
对于这个问题LevelDB是通过加入一个限制条件解决的。在多路归并生成第n层的SSTable文件时LevelDB会判断生成的SSTable和第n+1层的重合覆盖度如果重合覆盖度超过了10个文件就结束这个SSTable的生成继续生成下一个SSTable文件。
通过这个限制,**LevelDB就保证了第n层的任何一个SSTable要和第n+1层做多路归并时最多不会有超过10个SSTable参与**,从而保证了归并性能。
## 如何查找对应的SSTable文件
在理解了这样的架构之后,我们再来看看当我们想在磁盘中查找一个元素时,具体是怎么操作的。
首先我们会在Level 0 层中进行查找。由于Level 0层的SSTable没有做过多路归并处理它们的覆盖范围是有重合的。因此我们需要检查Level 0层中所有符合条件的SSTable在其中查找对应的元素。如果Level 0没有查到那么就下沉一层继续查找。
而从Level 1开始每一层的SSTable都做过了处理这能保证覆盖范围不重合的。因此对于同一层中的SSTable我们可以使用二分查找算法快速定位唯一的一个SSTable文件。如果查到了就返回对应的SSTable文件如果没有查到就继续沉入下一层直到查到了或查询结束。
<img src="https://static001.geekbang.org/resource/image/57/8b/57cd22fa67cba386d83686a31434e08b.jpeg" alt="" title="LevelDB分层检索过程示意图">
可以看到通过这样的一种架构设计我们就将SSTable进行了有序的管理使得查询操作可以快速被限定在有限的SSTable中从而达到了加速检索的目的。
## SSTable文件中的检索加速
那在定位到了对应的SSTable文件后接下来我们该怎么查询指定的元素呢这个时候前面我们学过的一些检索技术现在就可以派上用场了。
首先LevelDB使用索引与数据分离的设计思想将SSTable分为数据存储区和数据索引区两大部分。
<img src="https://static001.geekbang.org/resource/image/53/40/53d347e57ffee9a7ea14dde2b5f4a340.jpeg" alt="" title="SSTable文件格式">
我们在读取SSTable文件时不需要将整个SSTable文件全部读入内存只需要先将数据索引区中的相关数据读入内存就可以了。这样就能大幅减少磁盘IO次数。
然后我们需要快速确定这个SSTable是否包含查询的元素。对于这种是否存在的状态查询我们可以使用前面讲过的BloomFilter技术进行高效检索。也就是说我们可以从数据索引区中读出BloomFilter的数据。这样我们就可以使用O(1)的时间代价在BloomFilter中查询。如果查询结果是不存在我们就跳过这个SSTable文件。而如果BloomFilter中查询的结果是存在我们就继续进行精确查找。
在进行精确查找时我们将数据索引区中的Index Block读出Index Block中的每条记录都记录了每个Data Block的最小分隔key、起始位置还有block的大小。由于所有的记录都是根据Key排好序的因此我们可以使用二分查找算法在Index Block中找到我们想查询的Key。
那最后一步就是将这个Key对应的Data block从SSTable文件中读出来这样我们就完成了数据的查找和读取。
## 利用缓存加速检索SSTable文件的过程
在加速检索SSTable文件的过程中你会发现每次对SSTable进行二分查找时我们都需要将Index Block和相应的Data Block分别从磁盘读入内存这样就会造成两次磁盘I/O操作。我们知道磁盘I/O操作在性能上和内存相比是非常慢的这也会影响数据的检索速度。那这个环节我们该如何优化呢常见的一种解决方案就是使用缓存。LevelDB具体是怎么做的呢
针对这两次读磁盘操作LevelDB分别设计了table cache和block cache两个缓存。其中block cache是配置可选的它是将最近使用的Data Block加载在内存中。而table cache则是将最近使用的SSTable的Index Block加载在内存中。这两个缓存都使用LRU机制进行替换管理。
那么当我们想读取一个SSTable的Index Block时首先要去table cache中查找。如果查到了就可以避免一次磁盘操作从而提高检索效率。同理如果接下来要读取对应的Data Block数据那么我们也先去block cache中查找。如果未命中我们才会去真正读磁盘。
这样一来我们就可以省去非常耗时的I/O操作从而加速相关的检索操作了。
## 重点回顾
好了今天我们学习了LevelDB提升检索效率的优化方案。下面我带你总结回顾一下今天的重点内容。
首先在内存中检索数据的环节LevelDB使用跳表代替B+树,提高了内存检索效率。
其次在将数据从内存写入磁盘的环节LevelDB先是使用了**读写分离**的设计增加了一个只读的Immutable MemTable结构避免了给内存索引加锁。然后LevelDB又采用了**延迟合并**设计来优化归并。具体来说就是它先快速将C0树落盘生成SSTable文件再使用其他异步进程对这些SSTable文件合并处理。
而在管理多个SSTable文件的环节LevelDB使用**分层和滚动合并**的设计来组织多个SSTable文件避免了C0树和C1树的合并带来的大量数据被复制的问题。
最后在磁盘中检索数据的环节因为SSTable文件是有序的所以我们通过**多层二分查找**的方式就能快速定位到需要查询的SSTable文件。接着在SSTable文件内查找元素时LevelDB先是使用**索引与数据分离**的设计减少磁盘IO又使用**BloomFilter和二分查找**来完成检索加速。加速检索的过程中LevelDB又使用**缓存技术**,将会被反复读取的数据缓存在内存中,从而避免了磁盘开销。
总的来说一个高性能的系统会综合使用多种检索技术。而LevelDB的实现就可以看作是我们之前学过的各种检索技术的落地实践。因此这一节的内容我建议你多看几遍这对我们之后的学习也会有非常大的帮助。
## 课堂讨论
<li>
当我们查询一个key时为什么在某一层的SSTable中查到了以后就可以直接返回不用再去下一层查找了呢如果下一层也有SSTable存储了这个key呢
</li>
<li>
为什么从Level 1层开始我们是限制SSTable的总容量大小而不是像在Level 0层一样限制SSTable的数量 提示SSTable的生成过程会受到约束无法保证每一个SSTable文件的大小
</li>
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这一讲分享给你的朋友。

View File

@@ -0,0 +1,116 @@
<audio id="audio" title="18 | 搜索引擎:输入搜索词以后,搜索引擎是怎么工作的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c7/68/c77abe1e8b3603b43252261920b92568.mp3"></audio>
你好,我是陈东。今天我来讲讲搜索引擎的核心架构。
搜索引擎你应该非常熟悉,它是我们学习和工作中非常重要的一个工具。它的特点是能在万亿级别的网页中,快速寻找出我们需要的信息。可以说,以搜索引擎为代表的检索技术,是所有基于文本和关键词的检索系统都可以学习和参考的。
那今天,我们就一起来聊一聊,在输入搜索词以后,搜索引擎是怎么工作的。
首先,我们一起来了解一下搜索引擎的核心架构和工作过程。然后再重点分析其中的检索系统。
## 搜索引擎的整体架构和工作过程
搜索引擎会涉及非常多技术领域。其中,比较重要的有网页抓取、文本分析、检索模型、索引技术、链接分析、反作弊、云存储和云计算。正是因为涉及的领域非常多,所以搜索引擎完整的系统架构也非常复杂,会由许多子系统组成。
不过,我们可以从功能结构上,把搜索引擎的核心系统分为三部分,分别是爬虫系统、索引系统和检索系统。<br>
<img src="https://static001.geekbang.org/resource/image/c4/07/c4ad7eff4b692d25921d54c785197e07.jpg" alt="" title="搜索引擎核心架构示意图">
接下来,我们就分别说说,这三部分子系统具体的作用和工作过程。
**首先是爬虫系统。**
一个好的搜索引擎必须要能采集足够多的网页。因此我们需要通过高性能的爬虫系统来完成持续的网页抓取并且将抓取到的网页存入存储平台中。一般来说我们可以将抓取到的网页存放在基于LSM树的HBase中以便支持数据的高效读写。
**其次是索引系统。**
在爬虫系统抓取到网页之后,我们需要对这些网页进行一系列的处理,它们才可以变成可用的索引。处理可以分为两个阶段,首先是对网页进行预处理,主要的手段包括相似网页去重、网页质量分析、分词处理等工作,然后是对网页进行反作弊的分析工作,来避免一些作弊网页干扰搜索结果。
处理好网页之后,我们就要为搜索引擎生成索引,索引的生成过程主要可以分为三步。
**第一步,索引拆分**。由于抓取到的网页量级非常大,把它们全部都生成索引不太现实,因此我们会在离线阶段,根据之前的网页预处理结果,进行计算和筛选,分别分离出高质量和普通质量的网页集合。这样,我们就能进行分层索引了([第12讲](https://time.geekbang.org/column/article/227161))。当然,无论是高质量的网页集合还是普通质量的网页集合,数据量都不小。因此,我们还需要进行基于文档的拆分([第10讲](https://time.geekbang.org/column/article/225869)),以便生成索引。
**第二步,索引构建**。在确认了索引的分片机制以后我们可以使用Map Reduce服务来为每个索引分片生成对应的任务然后生成相应的倒排索引文件[第8讲](https://time.geekbang.org/column/article/222810))。每个倒排索引文件代表一个索引分片,它们都可以加载到线上的服务器中,来提供检索服务。
**第三步,索引更新**。为了保证能实时更新数据,搜索引擎会使用全量索引结合增量索引的机制来完成索引更新。并且由于搜索引擎的全量索引数据量巨大,因此,我们一般使用滚动合并法来完成索引更新([第9讲](https://time.geekbang.org/column/article/222807))。
有了这样创建出来的索引之后,搜索引擎就可以为万亿级别的网页提供高效的检索服务了。
**最后是检索系统。**
在检索阶段,如果用户搜索了一个关键词,那么搜索引擎首先需要做查询分析,也就是通过分析查询词本身以及用户行为特征,找出用户的真实查询意图。如果发现查询词有误或者结果很少,搜索引擎还会进行拼写纠正或相关查询推荐,然后再以改写后的查询词去检索服务中查询结果。
在检索服务中搜索引擎会将查询词发送给相应的索引分片索引分片通过倒排索引的检索机制将自己所负责的分片结果返回。对于返回的结果搜索引擎再根据相关性分析和质量分析使用机器学习进行打分选出Top K个结果[第11讲](https://time.geekbang.org/column/article/226100))来完成检索。
以上就是一个搜索引擎的完整的工作机制了。那与广告引擎和推荐引擎相比,**搜索引擎最大的特点,就是它有一个很强的检索约束条件,那就是用户输入的查询词。可以说,查询词是搜索引擎进行检索的最核心的信息。**但是很多时候,用户输入的查询词是含糊的、不精准的,甚至是带有错误的。还有一种可能是,用户输入的查询词不在倒排索引中。
这些问题也都是搜索引擎要解决的核心问题。因此,接下来,我们就以搜索“极客时间”为例,来讲讲搜索引擎的解决方案。
## 搜索引擎是如何进行查询分析的?
一般来说,用户在搜索的时候,搜索词往往会非常简短,很难完全体现用户的实际意图。而如果我们无法准确地理解用户的真实意图,那搜索结果的准确性就无从谈起了。因此,搜索引擎中检索系统的第一步,一定是进行查询分析。具体来说,就是理解用户输入的搜索词,并且对输错的查询词进行查询纠正,以及对意图不明的查询词进行查询推荐。那查询分析具体该怎么做呢?
在查询分析的过程中我们主要会对搜索词进行分词粒度分析、词的属性分析、用户需求分析等工作。其中分词粒度分析直接关系到我们以什么key去倒排索引中检索而属性分析和需求分析则可以帮助我们在打分排序时有更多的因子可以考虑。因此**分词粒度分析是查询分析的基础**。那什么是分词粒度分析呢?<br>
<img src="https://static001.geekbang.org/resource/image/60/11/602bdfacb4e902835ece292fc8b04e11.jpg" alt="" title="查询分析工作示意图">
分词粒度分析是中文搜索中特有的一个环节。因为中文词和英文词相比,最大的区别是词与词之间没有明确的分隔标志(空格)。因此,对于中文的搜索输入,我们要做的第一件事情,是使用分词工具进行合理的分词。但分词,就会带来一个分词粒度的问题。
比如说,当用户输入“极客时间”时:如果我们按单字来切分,这个搜索词就会变成“极/客/时/间”这四个检索词;如果是按“极客/时间”来切分,就会变成两个检索词的组合;如果是不做任何分词,将“极客时间”当成一个整体,那就是一个搜索短语。切分的方式这么多,到底我们该怎么选择呢?
一般来说,我们会使用默认的标准分词粒度再结合整个短语,作为我们的检索关键词去倒排索引中检索,这就叫作混合粒度的分词方式。那“极客时间”就会被分为【极客、时间、极客时间】这样的检索词组合。如果检索后返回的结果数量不足,那我们还会去查询【极、客、时、间】这样的更细粒度的单字组合。<br>
<img src="https://static001.geekbang.org/resource/image/3c/0f/3c7269d52b0062b336565c4d8e63630f.jpg" alt="" title="中文分词粒度分析示意图">
## 搜索引擎是如何进行查询纠错的?
以上,都是在用户输入正确搜索词时的查询分析。那如果用户的输入有误,比如说,将“极客时间”输成了“即可时间”,或者是“级可时间”,搜索引擎又会怎么办呢?这个时候,我们就需要用到查询纠错功能和查询推荐功能了。
我们先来说一说查询纠错功能是如何使用的。查询纠错的过程一般会分为三个步骤,分别是错误判断、候选召回和打分排序。<br>
<img src="https://static001.geekbang.org/resource/image/2a/21/2ae5ac04a082e1926d6e0cc692759b21.jpg" alt="" title="查询纠错过程示意图">
一般来说,在错误判断阶段,我们会根据人工编辑以及对搜索日志进行数据挖掘,得到常见字典和混淆字典。然后,我们使用哈希表或者字典树等结构来对字典进行索引,使得这两个字典具有高效的检索能力。如果某个分词后的检索词,我们无法在常用字典中查询到,或者它出现在了混淆字典中,那就说明这个词很可能是错误的。因此,我们还需要启动后续的候选召回和打分排序步骤。
不过,近年来,基于语言模型和机器学习的错误判断方式被广泛地使用。这种判断方式具体来说就是,我们会在用户输入检索词后,先对其进行置信度判断,如果得分过低,再进入后续的纠错过程。这能帮助我们更好地进行纠错。为什么这么说呢?我们来看一个例子,如果我们将“极客”错误地输入成了“级可”,通过检索常用字典和混淆字典,我们是有可能发现这个错误的。但如果我们错输成“即可”,由于“即可”本身也是一个合理的词,因此我们就需要使用基于语言模型和机器学习的方法,计算“即可”这个词出现在这个上下文中的置信度,才能发现有错。
在错误判断完成之后就进入候选召回阶段了。在候选召回中我们会预估查询词出错的每种可能性提前准备好可能的正确结果。一般情况下中文输入有2种常见的出错情况。
第1种拼音相同但是字不同。这时我们就要将相同拼音的词作为候选集以拼音为Key进行检索。第2种是字形相似那我们就生成一个相似字型的词典通过该词典召回候选集。此外还有根据编辑距离进行相似召回根据机器学习得到候选集进行召回等。通过这些不同的纠错方式我们就能得到可能的纠错结果集合了。
最后我们要对众多的纠错结果进行打分排序。在这个过程中我们可以使用各种常见的机器学习和深度学习算法进行打分判断你可以回忆一下11讲我们讲过的那些方法将得分最高的纠错结果返回。这样就完成了整个查询纠错过程。
好了,到这里,我们就把查询纠错的过程说完了。至于查询推荐,则更多的是分析搜索日志的结果,用“查询会话”“点击图”等技术,来分析哪些检索词之间有相关性。比如说,如果检索“极客时间”和检索“极客邦”的用户都会浏览相同的网页,那么“极客邦”就很有可能出现在“极客时间”的相关推荐中。
因此,查询推荐可以提供出更多的关键词,帮助搜索引擎召回更多的结果。它一般会在关键词不足的场景下被启用,或是作为补充提示出现。所以,关于查询推荐我就不再多说了,你只要记住查询推荐的原理就可以了。
总的来说,通过查询分析、查询纠错、查询推荐的过程,搜索引擎就能对用户的意图有一个更深入的理解。那接下来,我们就通过得到的一系列关键词,也就是【极客、时间、极客时间】,去查询倒排索引了。
## 搜索引擎是如何完成短语检索的?
首先,我们可以使用“极客时间”作为一个完整的关键词去倒排索引中查找。如果倒排索引中能查询到这个关键词,并且返回的结果集足够,那这样的检索结果是非常精准的。但是,这依赖于我们在构建索引的时候,必须将“极客时间”作为一个关键词进行处理。
可是在构建倒排索引的时候,我们一般是通过分析搜索日志,将一些常见的热门短语作为关键词加入倒排索引中。由于能被直接作为关键词的短语数量不会太多,因此,如果“极客时间”没有被识别为热门短语进行单独处理的话,那我们拿着“极客时间”这个短语作为关键词,直接查询的结果就是空的。
在这种情况下,我们就会使用更细粒度的分词结果,也就是使用“极客”和“时间”这两个关键词,去做两次检索,然后将得到的结果求交集合并。不过,这样做就会有一个问题:如果只是简单地将这两个关键词检索出来的文档列表求交集合并,那我们最终得到的结果并不一定会包含带有“极客时间”的文档。这又是为什么呢?
你可以考虑一下这种情况:如果有一个网页中有一句话是“一个极客往往没有时间打游戏”。那我们搜索“极客”“时间”这两个关键词的时候,这个网页就会被检索出来。但这是我们期望的检索结果吗?并不是。因为“极客”和“时间”的位置离得太远了。
那如果我们能记录下关键词出现在文档中的位置,并且在合并文档列表的时候,判断两个关键词是否接近,不就可以解决这个问题?没错,这种方法就叫作**位置信息索引法**。我们会通过两个关键词的位置关系来判断该文档和检索词的相关性。位置越远,相关性就越小,如果位置直接邻接在一起,相关性就最高。
如果是两个以上的关键词联合查询,那我们会将同时包含所有关键词的最小片段称为最小窗口,然后通过衡量查询结果中最小窗口的长度,来判断多个关键词是否接近。这么说比较抽象,我们来举个例子。当我们分别以“极”“客”“时”“间”这四个字作为关键词查询时,如果一个文档中有这么一句话“**极**多**客**人,一**时**之**间**”那字符“极”到字符“间”之间就是9个字符。也就是说在这句话中覆盖“极”“客”“时”“间”这四个关键词的最小窗口长度就是9。
有了这个方法,我们就可以将搜索结果按照最小窗口长度排序,然后留下相关性最高的一批结果了。这样,我们就完成“极客时间”的短语检索了。
## 重点回顾
今天,我们主要讲了搜索引擎的整体架构和工作原理。并且,由于搜索引擎的业务特点会非常依赖用户输入的查询词,因此,我们还重点讨论了搜索引擎对查询词进行的一系列特殊处理技术。
通常的流程是,先对查询词进行查询分析,搜索引擎通过对查询词进行不同粒度的分词,得到多个检索词。在这个过程中,搜索引擎还会通过查询纠错和相似推荐,拓展出更多的检索词候选。
然后,搜索引擎会利用得到的检索词在倒排索引中进行短语检索。这个时候,搜索引擎会通过位置信息索引法,来判断检索结果和检索词的相关性。最后,搜索引擎会通过对搜索结果中最小窗口的长度排序,留下相关性最高的结果。
除此之外,你还会看到很有意思的一点:查询纠错中也存在候选召回和打分排序这两个环节。实际上,许多业务的核心检索过程,都可以抽象为候选召回和打分排序这两个阶段,包括我们后面会讲到的广告系统和推荐系统也是一样。因此,如何将一个业务根据自身的特点,抽象成合适的检索过程,是 一个很重要的设计能力。那这部分内容我希望你能多看几遍,来加深理解,后面的课程中,我们也会继续学习相关的内容。
## 课堂讨论
1. 在使用位置信息索引法中我们在计算最小窗口的时候需要保证关键词是有序的。如果这个时候有两个关键词的话我们可以先固定第一个关键词然后只找它和第二个关键词的距离就可以了。那如果有3个关键词我们又该如何保证次序呢
1. 对于搜索引擎的检索技术,你还有什么想要了解和讨论的?
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这一讲分享给你的朋友。

View File

@@ -0,0 +1,126 @@
<audio id="audio" title="19 | 广告系统广告引擎如何做到在0.1s内返回广告信息?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d7/a5/d79de3ce4d69aa34ad4ea9b7a0b559a5.mp3"></audio>
你好,我是陈东。今天我们来讲广告系统。
说到广告系统,很多人可能没有那么熟悉。但是在互联网行业中,广告系统其实是非常重要,并且非常有代表性的一种系统。
一方面是因为广告是许多互联网公司的重要营收来源。比如我们熟悉的Google和Facebook它们的广告收入就占公司总收入的80%以上。因此,尽管许多互联网公司的主营业务并不一样,有的是搜索引擎,有的是电商平台,有的是视频平台等等。但是,它们背后都有着相似的广告业务线。
另一方面互联网广告对于工程和算法有着强烈的依赖。强大的工程和算法让现在的互联网广告能做到千人千面。最常见的我们在打开网站的一瞬间广告系统就会通过实时的分析计算从百万甚至千万的广告候选集中为我们这一次的广告请求选出专属的广告。而且整个响应广告请求的处理过程只需要0.1秒就能完成。
那在大型广告系统中,广告的请求量其实非常大,每秒钟可能有几十万甚至上百万次。因此,广告系统是一个典型的高并发低延迟系统。事实上,这背后离不开一个高性能的广告检索引擎的支持。那今天,我们就来聊一聊,广告系统中负责检索功能的广告引擎架构。
## 广告引擎的整体架构和工作过程
首先,我们来了解两个基本概念。
互联网广告分为搜索广告和展示广告两大类。简单来说搜索广告就是用户主动输入关键词以后搜索引擎在返回结果中展示出的相关广告。而展示广告则是在搜索引擎之外的网站或App中用户在浏览页面的情况下被动看到的广告。比如说在打开一些App时出现的开屏广告以及朋友圈中的广告等等。<br>
<img src="https://static001.geekbang.org/resource/image/9f/02/9fffd31417da6e9dd04599361c75df02.jpg" alt="" title="搜索广告和展示广告示例">
尽管这两种广告的业务形态不太一样,但是它们后台的广告引擎本质上都是相似的,主要的区别是约束条件上的不同。
在搜索广告中,因为它和搜索词有很强的相关性,所以,我们需要针对搜索词进行一系列的分析,这和我们上一讲说过的查询分析过程类似,这里我就不多讲了。而展示广告没有搜索词的约束条件,展示能力也就更灵活。因此,今天我们主要以展示广告为例,来说一说从用户打开网站到看到广告,广告系统是如何工作的。
为了方便你理解,我梳理了一张广告引擎的核心功能架构图。接下来,我就依据这个架构图,从**用户浏览**和**广告主投放广告**这两个方面,来为你详讲解一下广告引擎的工作过程。<br>
<img src="https://static001.geekbang.org/resource/image/79/a0/7990e7a990043087c1ce5bfa944063a0.jpg" alt="" title="广告引擎架构示意图">
一方面当用户浏览网页时网页会向服务端发起一个广告请求。服务端接到广告请求后会先进行请求解析也就是通过用户在系统中的唯一ID、网站地址以及广告位ID去后台查询相关的广告请求的扩展信息。
那怎么查询呢一般来说通过系统之前对用户的长期行为收集和分析我们就能知道该用户的喜好比如喜欢看篮球、喜欢购物等。根据得到的结果我们会为用户打上相应的标签。同理对于各种网页和广告位我们也会分析好网页分类等信息。然后我们会提前将这些分析好的结果保存在Key-value数据库中以支持快速查询。这样一来广告请求解析就可以通过查询Key-value数据库得到相关信息了。
另一方面广告主在投放广告时为了保证广告的后续效果往往会进行广告设置也就是给广告投放加上一些定向投放的条件。比如说只投放给北京的用户年龄段在20岁以上对篮球感兴趣使用某一型号的手机等。这些限制条件我们都可以用标签的形式来表示。因此一个广告设置抽象出来就是一系列标签的组合。
所以我们说,**广告引擎处理一个广告请求的过程,本质上就是根据用户的广告请求信息,找出标签匹配的广告设置,并将广告进行排序返回的过程**。这一点非常重要,我们后面讲的内容都是围绕它来展开的,我希望你能记住它。
返回广告以后,我们还需要收集广告的后续监测数据,比如说是否展现给了用户,以及是否被用户点击等后续行为。那有些后续行为还涉及广告计费,比如,如果广告是按点击付费的话,那么只要有用户点击了广告,就会产生对应的费用。这时广告系统不仅需要进行相应的计费,还需要快速修改系统中的广告数据,使得系统能在广告主的预算花完之后就立即停止投放。
好了,以上就是广告引擎的工作过程。你会发现,尽管广告引擎在业务形态和流程上都有自己的特点,但是,它的核心检索流程和搜索引擎是类似的,也分为了索引构建、检索召回候选集和排序返回这三个部分。不过,和搜索引擎相比,由于广告引擎没有明确的关键词限制,因此在如何构造倒排索引上,广告引擎会有更大的灵活度。
接下来我们就一起来看看广告引擎是怎么结合自己的业务特点来进行高性能的检索设计从而能在0.1秒内返回合适的广告。
## 标签检索:合理使用标签过滤和划分索引空间
广告引擎的索引设计思路是将广告设置的标签作为Key来构建倒排索引在posting list中记录对应的广告设置列表然后为标签进行ID编号让系统处理标签的过程能更高效。这么说比较抽象我来举个例子。
如果广告设置的标签是“地域北京”“兴趣篮球”“媒体体育网站”那我们可以使用一个32位的整数为每个标签进行编号。具体来说就是将32位的整数分为两部分高位用来表示定向类型低位用来表示这个定向类型下具体的标签。
比如说我们采用高8位作为定向类型的编码用来表示地域定向、兴趣定向和媒体定向等。用低24位则作为这个定向类型下面的具体内容。那在地域定向里低24位就是每个地区或者城市自己的编码。这样我们就可以将广告设置的标签都转为一个编号了高、低位的分配是可以根据实际需求灵活调整的<br>
<img src="https://static001.geekbang.org/resource/image/73/e5/732f35d1da5df3dc95fcaec13b8b6fe5.jpg" alt="" title="标签编码示意图">
### 1. 将标签加入过滤列表
那是不是所有的标签都可以作为倒排索引的Key呢你可以先自己想一想我们先来看一个例子。
如果所有的广告投放设置都选择投放在App上那么“媒体类型App”这个标签后面的posting list就保存了所有的广告设置。但是这样的标签并不能将广告设置区分开。为了解决这个问题我们可以使用类似TF-IDF算法中计算IDF的方式找出区分度低的标签不将它们加入倒排索引。
那我们什么时候使用这些标签呢?我们可以将这些标签加入“过滤列表”中,然后在倒排索引中检索出结果以后,加上一个过滤环节,也就是对检索结果进行遍历,在遍历过程中使用“过滤列表”中的标签进行检查,这样就完成了标签是否匹配的判断。
### 2. 用标签进行索引分片
其实对于标签的匹配使用我们还有其他的方案。我们再来看一个例子假设平台中有一半的广告投放设置希望投放在移动App上另一半希望投放在PC网站上那如果我们以“媒体类型App”和“媒体类型PC网站”作为标签来建立倒排索引的话这样的标签是有区分度的。但是由于这两个标签后面的posting list都会非常长各自都保存着一半的广告设置。因此在进行posting list归并的时候实际上就等于要遍历一半的广告设置。这反而会降低检索效率。
因此,对于“媒体类型”这类(以及“性别”、“操作系统”等)具有少量的标签值,但是每个标签值都有大规模区分度的设置维度来说,我们可以不把它们加入到倒排索引中,而是根据标签来将广告设置进行**分片**。也就是把投放PC网站的广告设置作为一组投放App的广告设置作为另外一组分别建立倒排索引。
如果这样的有区分度的设置维度不止一个那我们就使用树形结构进行划分将最有区分度的设置维度如“媒体类型”作为根节点不同的设置值作为分叉如PC网站和App就是“媒体类型”维度下的两个分叉。在这个节点下如果有其他的设置也具有足够的区分度那也可以作为子节点继续划分。然后对于被划分到同一个叶子节点下的一组我们再利用标签建立倒排索引。<br>
<img src="https://static001.geekbang.org/resource/image/56/89/56d22e65e832602752874fa9e55fa089.jpg" alt="" title="树 + 倒排的索引结构示意图">
通过这样的树形结构,我们根据广告请求上的标签,就能快速定位到要找的索引分片,之后,再查找分片中的倒排索引就可以了。
总结来说广告设置对广告引擎来说就像搜索词对搜索引擎一样重要。但是对于广告设置我们不会像关键词一样全部加入倒排索引中而是会分别加入到三个环节中第一个环节作为树形结构的节点分叉进行分流第二个环节作为倒排索引的Key第三个环节在遍历候选结果时作为过滤条件。通过这样的设计广告引擎中的检索空间就能被快速降低从而提升检索效率快速返回候选结果了。
## 向量检索:提供智能匹配能力
随着广告业务的演化,目前很多平台提供了一种新的广告投放模式:不是由广告主设置广告定向,而是由广告引擎在保证广告效果的前提下,自己决定如何召回广告。在这种情况下,广告引擎就可以摆脱标签的限制,使用向量来表示和检索,也就可以更精准地挖掘出合适的广告了。为什么要摆脱标签的限制呢?
我们来看个例子,在之前的标签系统中,当广告主想将广告投放给“喜欢篮球的人”时,如果一个用户身上的标签只有“喜欢运动”,那这个广告是不会投放给这个用户的。但如果广告主不进行广告定向限定,而是由广告引擎来决定如何召回广告,那广告引擎是可以针对“喜欢运动”的人投放这条广告的。
具体是怎么做的呢?我们可以将广告设置和用户兴趣都表示为高维空间的向量,这样,原来的每个标签就都是向量的一个维度了。然后我们使用最近邻检索技术,找到最近的点就可以返回结果了。这样的设计,本质上是使用机器的智能定向设置,代替了广告主手动的定向设置,从而大幅提升了广告设置的效率和效果。<br>
<img src="https://static001.geekbang.org/resource/image/de/94/de4a54928f12ef7549e23817c6d15a94.jpg" alt="" title="标签检索和向量检索的对比">
不过在我们使用向量检索来代替标签检索之后系统的性能压力也会更大因此为了保证广告引擎能在0.1秒内返回广告检索结果我们需要对向量检索进行加速操作。这时我们可以使用第16讲中“聚类+倒排索引+乘积量化”的实现方案,来搭建广告引擎的向量检索系统,从而提高向量检索的检索效率。
## 打分排序:用非精准打分结合深度学习模型的精准打分
广告引擎除了在召回环节和搜索引擎不一样之外在打分排序环节也有自己的特点。这主要是因为它们需要返回的结果数量不同。具体来说就是在搜索引擎中我们要返回Top K个结果但是在展示广告业务中广告引擎往往最后只会返回一条广告结果因此对于最后选出来的这一条广告我们希望它和用户的匹配越精准越好。所以在广告引擎中我们会使用复杂的深度学习模型来打分排序。
但如果在召回阶段选出的候选广告数量很多那全部使用开销很大的深度学习模型来进行打分的话我们是很难将单次检索结果控制在0.1秒之内的。而且,如果召回的候选广告数量有几千条,广告引擎最终又只能选出一条,那这几千条的候选广告都使用深度学习模型进行计算,会造成大量的资源浪费。
为了解决这个问题我们可以在召回和精准打分排序之间加入一个非精准打分的环节来更合理地使用资源。具体来说就是我们可以基于简单的机器学习模型如逻辑回归模型LR、梯度提升决策树GBDT、因子分解机FM配合少量的特征来完成这个非精准打分环节将候选广告的数量限制在几十个的量级。然后我们再使用深度学习模型来进行精准打分最后选出分数最高的一个广告进行投放。这样我们就能大幅节省计算资源提升检索效率了。<br>
<img src="https://static001.geekbang.org/resource/image/35/f3/35d28ceb7ee802c3b0e96b91750f4ff3.jpg" alt="" title="召回 + 非精准打分 + 精准打分">
## 索引精简:在索引构建环节缩小检索空间
除了优化在线的召回和打分环节的检索效率之外,广告业务的特点,使得我们还可以在离线的索引构建环节,通过缩小检索空间来优化。这是因为,广告引擎和搜索引擎中检索对象的生命周期有着很大的不同。一般来说,一个网页只要上线就会存在很久,但是一个广告设置的状态却经常变化。这怎么理解呢?
比如说,当广告设置限定了投放的时间段时,那这个广告可能上午是有效的,下午就处于停投状态了。再比如说,如果广告预算花完了,那广告也会变为停投状态,但是充值后又会恢复成有效状态。举了这么多例子,我其实就是想告诉你,广告设置的生命周期变化非常快。
因此,如果我们不考虑这些情况,直接将所有的广告设置都加载到系统中进行索引和检索,然后在遍历过滤的环节,再来检查这些状态进行判断的话,就会带来大量的判断开销。
这种情况下,我们该怎么办呢?我们可以将过滤条件提前到离线的索引构建的环节。这是因为,这些过滤条件和定向设置没有关系,所以我们完全可以在索引构建的时候,就将这些广告设置过滤掉,仅为当前有效的广告设置进行索引,这样检索空间也就得到了大幅压缩。<br>
<img src="https://static001.geekbang.org/resource/image/72/13/7261d56d5cf9ce026c94046b5a116313.jpg" alt="" title="过滤条件前置到索引构建环节">
当然,这种提前过滤有一个前提条件,那就是广告引擎需要提供实时高效的索引更新能力。好在,广告投放设置的体量一般不会像网页数那么庞大,一般都可以全部加载到内存中,因此,我们使用全量索引结合增量索引的更新机制,就可以对线上的索引进行实时更新了。
## 重点回顾
今天我们以展示广告为例学习了广告引擎的工作原理。并且重点学习了针对展示广告的特点在不同的环节进行灵活的设计来实现高性能的广告引擎。这些优化设计我们可以概括为以下4点。
<li>
在标签检索引擎中,我们通过合理地将标签使用在树形检索+倒排索引+结果过滤这三个环节,来提高检索效率。
</li>
<li>
在向量检索引擎中,我们可以使用聚类+倒排索引+乘积量化的技术来加速检索。
</li>
<li>
在打分排序环节,增加一个非精准打分环节,这样我们就可以大幅降低使用深度学习模型带来的开销。
</li>
<li>
<p>在索引构建环节,我们还可以将一些过滤条件前置,仅将当前有效的广告设置加入索引,然后通过全量索引+增量索引的更新方式,来保证过滤逻辑的有效。<br>
<img src="https://static001.geekbang.org/resource/image/ff/3b/ffb2cbe60de1e018336db0301cd8913b.jpg" alt=""></p>
</li>
## 课堂讨论
假设我们使用“媒体类型”作为树形检索的节点“PC网站”和“APP”作为两个分叉并且允许广告主选择“既在PC网站投放又在APP上投放”。如果有少量的广告主使用了这种投放我们的索引分片应该怎么调整针对这道题中的索引分片我们必须加载到不同服务器上才能发挥效果还是即使在单台服务器也能发挥效果为什么
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这一讲分享给你的朋友。

View File

@@ -0,0 +1,164 @@
<audio id="audio" title="20 | 推荐引擎:没有搜索词,“头条”怎么找到你感兴趣的文章?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/28/9c/287f0e0aaf2e29cd451d2ef1942a239c.mp3"></audio>
你好,我是陈东。今天我来和你讲讲推荐引擎。
我们每天都会接触推荐引擎最常见的就是当我们用手机浏览资讯类App的时候经常会用到的“下拉刷新”功能。你会发现每次刷新之后这些App都能给你推荐你最关心的“头条信息”。
那这些资讯类的App是怎么在没有搜索词的情况下仅凭下拉刷新就可以在海量的文章中检索出你感兴趣的内容并且推荐给你的呢这就和推荐引擎中的检索技术有关了。那今天我就以资讯类App推荐文章为例来和你聊一聊推荐引擎中的检索技术。
## 推荐引擎的整体架构和工作过程
我们知道,检索引擎的灵活程度和系统的检索约束条件有关。那我们先来看一下针对不同的引擎,系统的检索约束条件分别是什么。
在搜索引擎中系统的强约束条件是用户输入的搜索词。而在广告引擎中系统的强约束条件是广告主设置的定向要求。但是在资讯类App推荐引擎中因为所有的用户操作只有“下拉刷新”这一个动作所以外界输入的检索约束条件其实非常少。
因此,**相比于搜索引擎和广告引擎,推荐引擎具有更灵活的检索能力,也就是可以使用更灵活的检索技术,来进行文章的召回服务**。这也是推荐引擎相比于搜索引擎和广告引擎最大的不同之处。
那一个推荐引擎是怎么工作的呢?我按照功能划分,梳理出了推荐引擎的核心模块。<br>
<img src="https://static001.geekbang.org/resource/image/f6/77/f6e2ab9724a4e6c1bb2b5160129b6c77.jpg" alt="" title="推荐引擎架构示意图">
**那接下来,我就结合这个架构图,来说说推荐引擎的核心工作流程。**
首先,因为没有搜索词,所以推荐引擎并不能直接得知用户的意图和喜好。为了解决这个问题,推荐引擎会收集用户对不同文章的行为数据,包括曝光、点击、阅读、收藏、点赞和评论等等。
然后,我们会通过这些收集来的数据,在离线环节挖掘每一个用户兴趣,从而能对用户分类来生成完整的用户画像。在用户画像中,一个用户会拥有不同的标签,这些标签会有不同的权重,所有的权重都会随着时间的变化而衰减。比如说,如果一个用户长时间没有继续这个行为,那标签就会逐步弱化。再比如说一个用户的兴趣发生变化了,那最新的兴趣标签的权重就会大于老的兴趣标签。通过这样的机制,我们就能更好地理解用户的喜好了。
但是,只给用户打上标签还不够,我们也要给文章打上标签。在这个过程中,我们除了要提取文章中的关键词以外,更多的要对文章中的内容做语义分析工作,比如,文章分类、主题词提取、主题提取等等。通过这些方式,推荐引擎就能为每一篇文章都生成文章画像了。
有了用户画像、文章画像以及用户对文章的行为记录以后我们就可以根据需求灵活地使用不同的推荐算法来为用户推荐文章了。主要的推荐算法有2大类分别是基于统计的静态召回算法和个性化召回算法。
所谓基于统计的静态召回,指的是根据当前系统对于文章的统计数据来进行推荐。比如说,我们可以在离线环节,提前统计好点击量最大、评论最多、收藏最多、收藏率上升最快的文章等。然后在线上环节将这些热门文章推荐给所有用户。它比较适合作为个性化召回不足时候的补充方案。
接下来我们重点来讲讲个性化召回算法因为一般来说我们提到推荐算法的时候指的都是个性化召回算法。个性化召回也有许多不同的方案最有代表性的两种个性化召回就是基于内容的召回Content Based以及基于协同过滤的召回Collaborative Filtering
接下来,我们就重点讲讲这两种个性化召回方案。
## 基于内容的召回
基于内容的召回,就是我们根据文章的内容,判断这篇文章是否符合用户的喜好。具体怎么做呢?我们可以判断用户画像和文章画像中的标签或关键词是否相同,如果相同,就说明这篇文章的内容符合用户喜好,那我们就可以召回这篇文章。
这个时候,我们要解决的问题,其实就又变成了我们熟悉的标签匹配问题([第19讲](https://time.geekbang.org/column/article/235336)。因此我们完全可以用标签和关键词作为Key来建立倒排索引。这样我们就能针对用户的喜好召回内容匹配的文章了。
当然,在上一讲中我们也说了,基于标签的召回可能会漏掉许多候选集合。因此,我们可以使用向量空间模型,将标签匹配改为高维向量空间的最近邻检索问题。这样,我们就能达到更灵活的召回目的了。
以上就是基于内容的召回的具体方法,对于向量空间的最邻近检索,我们在前面已经详细讲过,这里就不再重复了。不过,如果要将基于内容召回的技术用在推荐系统中,我们就需要充分理解它的特点和效果。那接下来,我们就来说说基于内容的召回在数据依赖、个性化和冷启动方面的优缺点。
首先优点有3个分别是**不需要其他用户数据,可以针对小众用户给出个性化的推荐,以及可以推荐冷启动的新文章**。同样的缺点也有3个分别是**依赖于用户画像系统和文章画像系统,无法挖掘出用户的潜在兴趣,以及无法给冷启动的新用户推荐文章。**这些优缺点出现的原因,我在下面的表格中都给出了解释,理解起来应该不难,那我在这里就不多说了。<br>
<img src="https://static001.geekbang.org/resource/image/64/66/64ce4e8c89a2b7b944289f5115783866.jpg" alt="" title="优缺点对照表">
## 基于协同过滤的召回
协同过滤是推荐引擎中最具有代表性的方法。协同过滤和基于内容的召回方法最大的区别就在于,它并不依赖内容本身来进行推荐,而是基于大众用户和这篇文章的互动关系来进行推荐。
其中,协同过滤还可以分为两大类:一类是传统的基于数据统计的**Memory-based**的协同过滤算法,也叫做**基于邻域的算法**,代表算法有**基于用户的协同过滤**User CFUser Collaboration Filter和**基于物品的协同过滤**Item CFItem Collaboration Filter另一类是升级版的**基于模型的Model-based的协同过滤算法**。今天我们还是将重点放在协同过滤的基础算法也就是Memory-based上其他的就先不展开了。
### 1. 基于用户的协同过滤
基于用户的协同过滤的思想其实并不复杂,说白了就是将和你相似的用户看过的文章也推荐给你。那在具体操作的时候会分为两步:
1. 找到和你最相似的一批用户
1. 将这批用户看过,但你没看过的文章推荐给你
接下来,我们通过一个例子,来直观感受一下基于用户的协同过滤过程是怎么样的。
首先如果User 1看过Item 1和Item 2而User 3和User 4也看过Item 1 和 Item 2那么User 1和User 3、User 4就是相似用户。这样一来如果User 3和User 4还分别看过Item 3 和 Item 4我们就可以将Item 3 和 Item 4都推荐给User 1了。<br>
<img src="https://static001.geekbang.org/resource/image/58/9b/58b8909ea3e0b32b40afd4e97bbff79b.jpg" alt="" title="基于用户的协同过滤">
在这个过程中,定义相似的用户并且将它们找出来是最重要的一步。那具体怎么做呢?我们先将上面的例子变成一个表格,这样看起来更清晰。
<img src="https://static001.geekbang.org/resource/image/91/5d/9110418a92bff7b28771b6baad1c0b5d.jpg" alt="">
你会看到,这个表格里的每篇文章下面都对应着一些数字,这其实就是每个用户对每篇文章的喜爱程度(具体可以通过用户的点击次数、收藏、评论和转发等行为计算出来的)。
那基于这张表如果要找出和User 1相似的用户我们可以将Item 1到Item n看作是一个n维空间那每个用户都可以表示为n维空间中的一个向量然后把他对这个物品的喜好程度作为每个维度上的值。
这样一来如何找到最相似的一批用户的问题就变成了如何在n维向量空间中找到和User 1这个点最接近的点的问题。对于向量相似度我们一般使用余弦距离来计算。<br>
<img src="https://static001.geekbang.org/resource/image/1b/39/1ba92fde0deb2b95e52cd5feb46e5e39.jpg" alt="" title="余弦距离公式">
计算的过程我就不多说了,我直接把计算出来的相似度的结果告诉你。
<img src="https://static001.geekbang.org/resource/image/ec/ad/ec757918de2c9aea736e82cb9f5a4dad.jpg" alt="">
因此这三个用户和User 1的相似度的排序就是User 3&gt;User 4&gt;User 2。根据这个方法我们就能取Top K个相似用户然后将他们看过的Item取出来进行排序推荐了。那在这个例子中我们就可以取User 3和User 4作为相似用户然后将Item 3和Item 4取出来排序推荐。
那么问题来了我们是应该先推荐Item 3还是Item 4呢这个时候我们可以将相似用户对于做个物品的喜好进行加权打分累加然后优先推荐分数最高的。接下来我们就分别计算一下Item 3还是Item 4的推荐打分。
Item 3的推荐打分是1`*`0.73=0.73User 3的喜好度`*`User 3和User 1的相似度<br>
Item 4的推荐打分是2`*`0.54 = 1.08User 4的喜好度`*`User 4和User 1的相似度<br>
<img src="https://static001.geekbang.org/resource/image/57/60/57c96a55082db8b356db5355e244bd60.jpg" alt=""><br>
因此根据计算得到的结果我们会优先推荐Item 4再推荐Item 3。
这就是基于用户的协同过滤的算法思想了。不过,如果要实时计算当前用户和所有其他用户的相似程度,这其实是一个遍历的操作,会非常耗时。对此,推荐系统有两种解决方案,分别是**把相似计算放在离线环节,以及在实时阶段使用向量检索来近似地完成计算更新**。下面,我们一一来看。
**第一种方案,将相似计算放在离线环节。**
具体来说就是我们可以在离线环节为每个用户计算出这样一个推荐列表然后使用Key-value数据库如使用Redis把它加载到在线检索部分。这样当User 1打开App时我们通过查询这个Key-value数据库就可以快速查询到推荐的文章列表了。而且这个推荐列表可以通过周期性的重新全量计算来完成更新。这个方案的优点是实时环节非常简单就是一个查表操作但是缺点是更新不够及时。
**第二种方案,在实时阶段使用向量检索来近似地完成计算更新**
在第一步“寻找相似用户”的时候,我们不需要精确地计算和每个用户的相似度,而是可以使用聚类+倒排索引+乘积量化的方案来快速地检索出和User 1最近邻的k个用户然后将这些用户喜好的物品取出来进行加权打分并排序即可。
你看,这不就又变成了我们熟悉的实时“召回+打分排序”的过程了嘛。这个方案的优点是实时性很好,只要用户有了变化,就能马上反馈出来,但是缺点是实时阶段的检索过程很复杂,并且由于采用了非精准的近邻检索技术,因此结果也不够精确。
总结来说,基于用户的协同过滤既不依赖于文章本身的属性挖掘,也不会造成用户的兴趣被局限在历史的兴趣范围中。它能将用户没看过,但是其他相似用户喜欢的文章推荐出来,因此特别适用于资讯类的平台。不过它也有自己的不足,它适用于用户数不太大的场合。因为一旦用户数太大,计算所有的用户两两之间的相似度,也会是一个非常巨大的开销。
### 2. 基于物品的协同过滤
基于物品的协同过滤,简单来说就是基于你之前看过的物品,找出相似的物品并进行推荐。它的实现也分为两步:
1. 计算出用户看过的每个物品的相似物品
1. 将这些相似物品进行排序,然后再推荐
我们还是通过上面的例子来进一步分析这个过程。假设User 1看过Item 1和Item 2那我们就需要寻找Item 1 的相似物品和Item 2的相似的物品将它们推荐给User 1。
所以这次我们要将刚才的表格换一个维度来看也就是把User和Item的位置对调重新画出这个表格。<br>
<img src="https://static001.geekbang.org/resource/image/eb/51/eb760d3c8c523f8c0bd00002493fac51.jpg" alt="">
由于这一次是寻找相似的物品因此我们把User 1到User n作为维度构建一个n维的向量空间然后把每一个物品用这个向量空间中的一个向量来表示。
那么要查找和Item 1相似的物品我们就要先找出最接近的k1个Item向量然后用Item 1对User1的权重乘上每个Item和Item 1的相似度就能得到这k1个Item的推荐度。
同理对于Item 2我们也可以找出k2个Item并计算出这k2个Item的推荐度。最后我们将k1个Item和k2个Item进行合并将相同Item的推荐度累加就能得到每个Item对于User 1的推荐度了。具体的计算过程我就不详细列出来了你可以自己算一下。
这就是基于物品的协同过滤的算法思想了。在实际实现的时候推荐引擎为了加快检索效率会将2个步骤分别放在两个环节。
首先将第一步“寻找每个物品的相似物品列表”放在离线环节。具体的操作是以Item ID为Key以相似物品列表为posting list来生成倒排索引再把它存入线上的Key-value数据库中。
其次是把“对相似物品进行排序”放在实时环节。这样一来当我们需要对一个用户推荐新的物品时只以这个用户看过的Item为Key去Key-value数据库中取出所有推荐的Item列表然后将这些Item合并后排序即可。
你会发现通过这样的设计基于物品的协同过滤算法就能简单地支持实时反馈了。那总结来说和基于用户的协同过滤算法相比基于物品的协同过滤算法更注重于用户的兴趣传承而基于用户的协同过滤算法会更注重于社会化的推荐。因此新闻资讯类的App更倾向于使用基于用户的协同过滤算法而电商平台更倾向于使用基于物品的协同过滤算法。
## 如何对多种召回方案进行选择和排序?
通过前面的分析,你会发现,不同的推荐算法根据各自的特点,会召回不同的候选文章。那我们应该选择哪种方案呢?
实际上,因为推荐引擎并没有检索限定条件,所以它可以从不同的维度来进行推荐,而且不同的用户对于不同的推荐方案也有不同的接受度。综合来说,我们很难说哪一种方案一定就是最好的。因此,在推荐引擎的实现中,我们更多的是采用**混合推荐法,也就是一并使用上面的所有方案**。
那这就带来了一个问题:每种召回方案都会返回大量的候选集,这会使得系统难以承受排序计算的代价。为了解决这个问题,推荐引擎中采用了分层打分过滤的排序方式。<br>
<img src="https://static001.geekbang.org/resource/image/23/f9/23404433dbbd8f1ffc3b5701ca84d5f9.jpg" alt="" title="分层打分过滤示意图">
下面,我就结合上面给出的示意图来说一下分层打分过滤的过程。
首先,每一个召回通路都会使用自己的非精准打分算法,截取千级别之内的候选集。然后,推荐引擎会合并这多个召回通路截取的几千个结果,也就是使用简单的机器学习模型进行非精准打分,选出最好的上百个结果。最后,推荐引擎会使用精准的深度学习模型,选出最好的几十个结果返回给用户。这就是用户看到的最终的推荐结果了。
以上,就是推荐引擎从召回到排序的完整检索过程了。
## 重点回顾
今天,我们重点学习了推荐引擎中的个性化召回算法,它又为基于内容的召回和基于协同过滤的召回。我们来总结一下它们各自的特点。
首先,基于内容的召回,本质上是基于用户画像和文章画像进行匹配召回。在实际操作中,我们可以使用标签检索和向量检索来完成基于内容的召回。
然后,基于协同过滤的召回我们重点讲了基于用户的协同过滤和基于物品的协同过滤。基于用户的协同过滤,会先寻找和当前用户最相似的用户,再将这些用户看过的物品推荐出来;而基于物品的协同过滤,则是先整理出相似的物品列表,再根据当前用户看过的物品,找出对应的物品列表,最后进行合并推荐。
总的来说,这两种协同过滤算法都是先寻找最邻近的“邻居”,再进行打分排序。也就是说,**这两种协同过滤算法抽象起来看,依然是使用“检索-排序”的检索技术来实现的**。
此外,在实际工作中,推荐引擎会同时使用多种召回技术进行混合推荐。而在使用混合推荐法的时候,系统会进行多层的打分过滤,来保证检索性能。<br>
<img src="https://static001.geekbang.org/resource/image/10/05/1098bba52d709137fe968ff5effdd305.jpg" alt="">
## 课堂讨论
1. 对于文章中提到的基于物品的协同过滤召回以文中的数据为例子你能按文中介绍的方式使用余弦距离计算出每个Item 和 Item 1的相似度吗
1. 关于搜索引擎、广告引擎、推荐引擎,你觉得它们有哪些设计可以相互借鉴?为什么?
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这一讲分享给你的朋友。