mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
mod
This commit is contained in:
105
极客时间专栏/检索技术核心20讲/进阶实战篇/06 | 数据库检索:如何使用B+树对海量磁盘数据建立索引?.md
Normal file
105
极客时间专栏/检索技术核心20讲/进阶实战篇/06 | 数据库检索:如何使用B+树对海量磁盘数据建立索引?.md
Normal file
@@ -0,0 +1,105 @@
|
||||
<audio id="audio" title="06 | 数据库检索:如何使用B+树对海量磁盘数据建立索引?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/56/bb/56fc14493c14a0b468010094b5d2abbb.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
在基础篇中,我们学习了许多和检索相关的数据结构和技术。但是在大规模的数据环境下,这些技术的应用往往会遇到一些问题,比如说,无法将数据全部加载进内存。再比如说,无法支持索引的高效实时更新。而且,对于复杂的系统和业务场景,我们往往需要对基础的检索技术进行组合和升级。这就需要我们对实际的业务问题和解决方案十分了解。
|
||||
|
||||
所以,从这一讲开始,我会和你一起探讨实际工作中的系统和业务问题,分享给你一些工业界中常见的解决方案,帮助你积累对应的行业经验,让你能够解决工作中的检索难题。
|
||||
|
||||
在工业界中,我们经常会遇到的一个问题,许多系统要处理的数据量非常庞大,数据无法全部存储在内存中,需要借助磁盘完成存储和检索。我们熟悉的关系型数据库,比如MySQL和Oracle,就是这样的典型系统。
|
||||
|
||||
数据库中支持多种索引方式,比如,哈希索引、全文索引和B+树索引,其中B+树索引是使用最频繁的类型。因此,今天我们就一起来聊一聊磁盘上的数据检索有什么特点,以及为什么B+树能对磁盘上的大规模数据进行高效索引。
|
||||
|
||||
## 磁盘和内存中数据的读写效率有什么不同?
|
||||
|
||||
首先,我们来探讨一下,存储在内存中和磁盘中的数据,在检索效率方面有什么不同。
|
||||
|
||||
内存是半导体元件。对于内存而言,只要给出了内存地址,我们就可以直接访问该地址取出数据。这个过程具有高效的随机访问特性,因此内存也叫**随机访问存储器**(Random Access Memory,即RAM)。内存的访问速度很快,但是价格相对较昂贵,因此一般的计算机内存空间都相对较小。
|
||||
|
||||
而磁盘是机械器件。磁盘访问数据时,需要等磁盘盘片旋转到磁头下,才能读取相应的数据。尽管磁盘的旋转速度很快,但是和内存的随机访问相比,性能差距非常大。到底有多大呢?一般来说,如果是随机读写,会有10万到100万倍左右的差距。但如果是顺序访问大批量数据的话,磁盘的性能和内存就是一个数量级的。为什么会这样呢?这和磁盘的读写原理有关。那具体是怎么回事呢?
|
||||
|
||||
磁盘的最小读写单位是扇区,较早期的磁盘一个扇区是512字节。随着磁盘技术的发展,目前常见的磁盘扇区是4K个字节。操作系统一次会读写多个扇区,所以操作系统的最小读写单位是**块**(Block),也叫作**簇**(Cluster)。当我们要从磁盘中读取一个数据时,操作系统会一次性将整个块都读出来。因此,对于大批量的顺序读写来说,磁盘的效率会比随机读写高许多。
|
||||
|
||||
现在你已经了解磁盘的特点了,那我们就可以来看一下,如果使用之前学过的检索技术来检索磁盘中的数据,检索效率会是怎样的呢?
|
||||
|
||||
假设有一个有序数组存储在硬盘中,如果它足够大,那么它会存储在多个块中。当我们要对这个数组使用二分查找时,需要先找到中间元素所在的块,将这个块从磁盘中读到内存里,然后在内存中进行二分查找。如果下一步要读的元素在其他块中,则需要再将相应块从磁盘中读入内存。直到查询结束,这个过程可能会多次访问磁盘。我们可以看到,这样的检索性能非常低。
|
||||
|
||||
由于磁盘相对于内存而言访问速度实在太慢,因此,对于磁盘上数据的高效检索,我们有一个极其重要的原则:**对磁盘的访问次数要尽可能的少**!
|
||||
|
||||
那问题来了,我们应该如何减少磁盘的访问次数呢?将索引和数据分离就是一种常见的设计思路。
|
||||
|
||||
## 如何将索引和数据分离?
|
||||
|
||||
我们以查询用户信息为例。我们知道,一个系统中的用户信息非常多,除了有唯一标识的ID以外,还有名字、邮箱、手机、兴趣爱好以及文章列表等各种信息。一个保存了所有用户信息的数组往往非常大,无法全部放在内存中,因此我们会将它存储在磁盘中。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/56/3ad283ed20ba36a8f5f8350ee4bd7d56.jpg" alt="">
|
||||
|
||||
当我们以用户的ID进行检索时,这个检索过程其实并不需要读取存储用户的具体信息。因此,我们可以生成一个只用于检索的有序索引数组。数组中的每个元素存两个值,一个是用户ID,另一个是这个用户信息在磁盘上的位置,那么这个数组的空间就会很小,也就可以放入内存中了。这种用有序数组做索引的方法,叫作**线性索引**(Linear Index)。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/1e/0e7ca7c9a2c9c373353ae5ec824f4f1e.jpg" alt="">
|
||||
|
||||
在数据频繁变化的场景中,有序数组并不是一个最好的选择,二叉检索树或者哈希表往往更有普适性。但是,哈希表由于缺乏范围检索的能力,在一些场合也不适用。因此,二叉检索树这种树形结构是许多常见检索系统的实施方案。那么,上图中的线性索引结构,就变成下图这个样子。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/02/b4/0203a7cc903e3acf38e47a59ad3aa6b4.jpeg" alt="">
|
||||
|
||||
尽管二叉检索树可以解决数据动态修改的问题,但在索引数据很大的情况下,依然会有数据无法完全加载到内存中。这种情况我们应该怎么办呢?
|
||||
|
||||
一个很自然的思路,就是将索引数据也存在磁盘中。那如果是树形索引,我们应该将哪些节点存入磁盘,又要如何从磁盘中读出这些数据进行检索呢?你可以先想一想,然后我们一起来看看业界常用的解决方案B+树是怎么做的。
|
||||
|
||||
## 如何理解B+树的数据结构?
|
||||
|
||||
B+树是检索技术中非常重要的一个部分。这是为什么呢?因为**B+树给出了将树形索引的所有节点都存在磁盘上的高效检索方案**,使得索引技术摆脱了内存空间的限制,得到了广泛的应用。
|
||||
|
||||
前面我们讲了,操作系统对磁盘数据的访问是以块为单位的。因此,如果我们想将树型索引的一个节点从磁盘中读出,即使该节点的数据量很小(比如说只有几个字节),但磁盘依然会将整个块的数据全部读出来,而不是只读这一小部分数据,这会让有效读取效率很低。**B+树的一个关键设计,就是让一个节点的大小等于一个块的大小。节点内存储的数据,不是一个元素,而是一个可以装m个元素的有序数组**。这样一来,我们就可以将磁盘一次读取的数据全部利用起来,使得读取效率最大化。
|
||||
|
||||
B+树还有另一个设计,就是将所有的节点分为内部节点和叶子节点。尽管内部节点和叶子节点的数据结构是一样的,但存储的内容是不同的。
|
||||
|
||||
内部节点仅存储key和维持树形结构的指针,并不存储key对应的数据(无论是具体数据还是文件位置信息)。这样内部节点就能存储更多的索引数据,我们也就可以使用最少的内部节点,将所有数据组织起来了。而叶子节点仅存储key和对应数据,不存储维持树形结构的指针。通过这样的设计,B+树就能做到节点的空间利用率最大化。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/a9/eb/a994e93f2fdd38291998cba8149270eb.jpg" alt="">
|
||||
|
||||
此外,B+树还将同一层的所有节点串成了有序的双向链表,这样一来,B+树就同时具备了良好的范围查询能力和灵活调整的能力了。
|
||||
|
||||
因此,B+树是一棵完全平衡的m阶多叉树。所谓的m阶,指的是每个节点**最多**有m个子节点,并且每个节点里都存了一个紧凑的可包含m个元素的数组。<img src="https://static001.geekbang.org/resource/image/72/65/72499a6180cfb1ee7e3c33e6ca433b65.jpg" alt="">
|
||||
|
||||
## B+树是如何检索的?
|
||||
|
||||
这样的结构,使得B+树可以作为一个完整的文件全部存储在磁盘中。当从根节点开始查询时,通过一次磁盘访问,我们就能将文件中的根节点这个数据块读出,然后在根节点的有序数组中进行二分查找。
|
||||
|
||||
具体的查找过程是这样的:我们先确认要寻找的查询值,位于数组中哪两个相邻元素中间,然后我们将第一个元素对应的指针读出,获得下一个block的位置。读出下一个block的节点数据后,我们再对它进行同样处理。这样,B+树会逐层访问内部节点,直到读出叶子节点。对于叶子节点中的数组,直接使用二分查找算法,我们就可以判断查找的元素是否存在。如果存在,我们就可以得到该查询值对应的存储数据。如果这个数据是详细信息的位置指针,那我们还需要再访问磁盘一次,将详细信息读出。
|
||||
|
||||
我们前面说了,B+树是一棵完全平衡的m阶多叉树。所以,B+树的一个节点就能存储一个包含m个元素的数组,这样的话,一个只有2到4层的B+树,就能索引数量级非常大的数据了,因此B+树的层数往往很矮。比如说,一个4K的节点的内部可以存储400个元素,那么一个4层的B+树最多能存储400^4,也就是256亿个元素。
|
||||
|
||||
不过,因为B+树只有4层,这就意味着我们最多只需要读取4次磁盘就能到达叶子节点。并且,我们还可以通过将上面几层的内部节点全部读入内存的方式,来降低磁盘读取的次数。
|
||||
|
||||
比如说,对于一个4层的B+树,每个节点大小为4K,那么第一层根节点就是4K,第二层最多有400个节点,一共就是1.6M;第三层最多有400^2,也就是160000个节点,一共就是640M。对于现在常见的计算机来说,前三层的内部节点其实都可以存储在内存中,只有第四层的叶子节点才需要存储在磁盘中。这样一来,我们就只需要读取一次磁盘即可。这也是为什么,B+树要将内部节点和叶子节点区分开的原因。通过这种只让内部节点存储索引数据的设计,我们就能更容易地把内部节点全部加载到内存中了。
|
||||
|
||||
## B+树是如何动态调整的?
|
||||
|
||||
现在,你已经知道B+树的结构和原理了。那B+树在“新增节点”和“删除节点”这样的动态变化场景中,又是怎么操作的呢?接下来,让我们一起来看一下。
|
||||
|
||||
首先,我们来看插入数据。由于具体的数据都是存储在叶子节点上的,因此,数据的插入也是从叶子节点开始的。以一个节点有3个元素的B+树为例,如果我们要插入一个ID=6的节点,首先要查询到对应的叶子节点。如果叶子节点的数组未满,那么直接将该元素插入数组即可。具体过程如下图所示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/d4/9ed9028cab65e6530966faae00d0d3d4.jpg" alt="">
|
||||
|
||||
如果我们插入的是ID=10的节点呢?按之前的逻辑,我们应该插入到ID 9后面,但是ID 9所在的这个节点已经存满了3个节点,无法继续存入了。因此,我们需要将该叶子节点**分裂**。分裂的逻辑就是生成一个新节点,并将数据在两个节点中平分。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/67/f2/674115e7c61637e56791e001ea840af2.jpg" alt="">
|
||||
|
||||
叶子节点分裂完成以后,上一层的内部节点也需要修改。但如果上一层的父节点也是满的,那么上一层的父节点也需要分裂。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/03/57/03af63ed8cd065743bd8b2bd812e5057.jpg" alt="">
|
||||
|
||||
内部节点调整好了,下一步我们就要调整根节点了。由于根节点未满,因此我们不需要分裂,直接修改即可。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/53/0f/53fd14349369951706f53abd1eff560f.jpg" alt="">
|
||||
|
||||
删除数据也类似,如果节点数组较满,直接删除;如果删除后数组有一半以上的空间为空,那为了提高节点的空间利用率,该节点需要将左右两边兄弟节点的元素转移过来。可以成功转移的条件是,元素转移后该节点及其兄弟节点的空间必须都能维持在半满以上。如果无法满足这个条件,就说明兄弟节点其实也足够空闲,那我们直接将该节点的元素并入兄弟节点,然后删除该节点即可。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容就先讲到这里。你会发现,即使是复杂的B+树,我们将它拆解开来,其实也是由简单的数组、链表和树组成的,而且B+树的检索过程其实也是二分查找。因此,如果B+树完全加载在内存中的话,它的检索效率其实并不会比有序数组或者二叉检索树更高,也还是二分查找的log(n)的效率。并且,它还比数组和二叉检索树更加复杂,还会带来额外的开销。
|
||||
|
||||
但是,B+树最大的优点在于,它提供了将索引数据存在磁盘中,以及高效检索的方案。这让检索技术摆脱了内存的限制,得到了更广泛地使用。
|
||||
|
||||
另外,这一节还有一个很重要的设计思想需要你掌握,那就是将索引和数据分离。通过这样的方式,我们能将索引的数组大小保持在一个较小的范围内,让它能加载在内存中。在许多大规模系统中,都是使用这个设计思想来精简索引的。而且,B+树的内部节点和叶子节点的区分,其实也是索引和数据分离的一次实践。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
最后,咱们来看一道讨论题。
|
||||
|
||||
B+树有一个很大的优势,就是适合做范围查询。如果我们要检索值在x到y之间的所有元素,你会怎么操作呢?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这篇文章分享给你的朋友。
|
||||
97
极客时间专栏/检索技术核心20讲/进阶实战篇/07 | NoSQL检索:为什么日志系统主要用LSM树而非B+树?.md
Normal file
97
极客时间专栏/检索技术核心20讲/进阶实战篇/07 | NoSQL检索:为什么日志系统主要用LSM树而非B+树?.md
Normal file
@@ -0,0 +1,97 @@
|
||||
<audio id="audio" title="07 | NoSQL检索:为什么日志系统主要用LSM树而非B+树?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/f0/d89d99e1d66abd8d29c33534606cfcf0.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
B+树作为检索引擎中的核心技术得到了广泛的使用,尤其是在关系型数据库中。
|
||||
|
||||
但是,在关系型数据库之外,还有许多常见的大数据应用场景,比如,日志系统、监控系统。这些应用场景有一个共同的特点,那就是数据会持续地大量生成,而且相比于检索操作,它们的写入操作会非常频繁。另外,即使是检索操作,往往也不是全范围的随机检索,更多的是针对近期数据的检索。
|
||||
|
||||
那对于这些应用场景来说,使用关系型数据库中的B+树是否合适呢?
|
||||
|
||||
我们知道,B+树的数据都存储在叶子节点中,而叶子节点一般都存储在磁盘中。因此,每次插入的新数据都需要随机写入磁盘,而随机写入的性能非常慢。如果是一个日志系统,每秒钟要写入上千条甚至上万条数据,这样的磁盘操作代价会使得系统性能急剧下降,甚至无法使用。
|
||||
|
||||
那么,针对这种频繁写入的场景,是否有更合适的存储结构和检索技术呢?今天,我们就来聊一聊另一种常见的设计思路和检索技术:**LSM树**(Log Structured Merge Trees)。LSM树也是近年来许多火热的NoSQL数据库中使用的检索技术。
|
||||
|
||||
## 如何利用批量写入代替多次随机写入?
|
||||
|
||||
刚才我们提到B+树随机写入慢的问题,对于这个问题,我们现在来思考一下优化想法。操作系统对磁盘的读写是以块为单位的,我们能否以块为单位写入,而不是每次插入一个数据都要随机写入磁盘呢?这样是不是就可以大幅度减少写入操作了呢?
|
||||
|
||||
LSM树就是根据这个思路设计了这样一个机制:当数据写入时,延迟写磁盘,将数据先存放在内存中的树里,进行常规的存储和查询。当内存中的树持续变大达到阈值时,再批量地以块为单位写入磁盘的树中。因此,LSM树至少需要由两棵树组成,一棵是存储在内存中较小的C0树,另一棵是存储在磁盘中较大的C1树。简单起见,接下来我们就假设只有C0树和C1树。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/32/61/3254e0cc752753de51e436e0f18ea761.jpg" alt="">
|
||||
|
||||
C1树存储在磁盘中,因此我们可以直接使用B+树来生成。那对于全部存储在内存中的C0树,我们该如何生成呢?在上一讲的重点回顾中我们分析过,在数据都能加载在内存中的时候,B+树并不是最合适的选择,它的效率并不会更高。因此,C0树我们可以选择其他的数据结构来实现,比如平衡二叉树甚至跳表等。但是为了让你更简单、清晰地理解LSM树的核心理念,我们可以假设C0树也是一棵B+树。
|
||||
|
||||
那现在C0树和C1树就都是B+树生成的了,但是相比于普通B+树生成的C0树,C1树有一个特点:所有的叶子节点都是满的。为什么会这样呢?原因就是,C1树不需要支持随机写入了,我们完全可以等内存中的数据写满一个叶子节点之后,再批量写入磁盘。因此,每个叶子节点都是满的,不需要预留空位来支持新数据的随机写入。
|
||||
|
||||
## 如何保证批量写之前系统崩溃可以恢复?
|
||||
|
||||
B+树随机写入慢的问题,我们已经知道解决的方案了。现在第二个问题来了:如果机器断电或系统崩溃了,那内存中还未写入磁盘的数据岂不就永远丢失了?这种情况我们该如何解决呢?
|
||||
|
||||
为了保证内存中的数据在系统崩溃后能恢复,工业界会使用**WAL技术**(Write Ahead Log,预写日志技术)将数据第一时间高效写入磁盘进行备份。WAL技术保存和恢复数据的具体步骤,我这里总结了一下。
|
||||
|
||||
1. 内存中的程序在处理数据时,会先将对数据的修改作为一条记录,顺序写入磁盘的log文件作为备份。由于磁盘文件的顺序追加写入效率很高,因此许多应用场景都可以接受这种备份处理。
|
||||
1. 在数据写入log文件后,备份就成功了。接下来,该数据就可以长期驻留在内存中了。
|
||||
1. 系统会周期性地检查内存中的数据是否都被处理完了(比如,被删除或者写入磁盘),并且生成对应的检查点(Check Point)记录在磁盘中。然后,我们就可以随时删除被处理完的数据了。这样一来,log文件就不会无限增长了。
|
||||
1. 系统崩溃重启,我们只需要从磁盘中读取检查点,就能知道最后一次成功处理的数据在log文件中的位置。接下来,我们就可以把这个位置之后未被处理的数据,从log文件中读出,然后重新加载到内存中。
|
||||
|
||||
通过这种预先将数据写入log文件备份,并在处理完成后生成检查点的机制,我们就可以安心地使用内存来存储和检索数据了。
|
||||
|
||||
## 如何将内存数据与磁盘数据合并?
|
||||
|
||||
解决了内存中数据备份的问题,我们就可以接着写入数据了。内存中C0树的大小是有上限的,那当C0树被写满之后,我们要怎么把它转换到磁盘中的C1树上呢?这就涉及**滚动合并**(Rolling Merge)的过程了。
|
||||
|
||||
我们可以参考两个有序链表归并排序的过程,将C0树和C1树的所有叶子节点中存储的数据,看作是两个有序链表,那滚动合并问题就变成了我们熟悉的两个有序链表的归并问题。不过由于涉及磁盘操作,那为了提高写入效率和检索效率,我们还需要针对磁盘的特性,在一些归并细节上进行优化。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/6e/5ef5e0fde225587076b2f6d673f1c26e.jpg" alt="">
|
||||
|
||||
由于磁盘具有顺序读写效率高的特性,因此,为了提高C1树中节点的读写性能,除了根节点以外的节点都要尽可能地存放到连续的块中,让它们能作为一个整体单位来读写。这种包含多个节点的块就叫作**多页块**(Multi-Pages Block)。
|
||||
|
||||
下面,我们来讲一下滚动归并的过程。在进行滚动归并的时候,系统会遵循以下几个步骤。
|
||||
|
||||
第一步,以多页块为单位,将C1树的当前叶子节点从前往后读入内存。读入内存的多页块,叫作清空块(Emptying Block),意思是处理完以后会被清空。
|
||||
|
||||
第二步,将C0树的叶子节点和清空块中的数据进行归并排序,把归并的结果写入内存的一个新块中,叫作填充块(Filling Block)。
|
||||
|
||||
第三步,如果填充块写满了,我们就要将填充块作为新的叶节点集合顺序写入磁盘。这个时候,如果C0树的叶子节点和清空块都没有遍历完,我们就继续遍历归并,将数据写入新的填充块。如果清空块遍历完了,我们就去C1树中顺序读取新的多页块,加载到清空块中。
|
||||
|
||||
第四步,重复第三步,直到遍历完C0树和C1树的所有叶子节点,并将所有的归并结果写入到磁盘。这个时候,我们就可以同时删除C0树和C1树中被处理过的叶子节点。这样就完成了滚动归并的过程。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/b1/8d66098f003da6d5845993f6f5cee1b1.jpeg" alt="">
|
||||
|
||||
在C0树到C1树的滚动归并过程中,你会看到,几乎所有的读写操作都是以多页块为单位,将多个叶子节点进行顺序读写的。而且,因为磁盘的顺序读写性能和内存是一个数量级的,这使得LSM树的性能得到了大幅的提升。
|
||||
|
||||
## LSM树是如何检索的?
|
||||
|
||||
现在你已经知道LSM树的组织过程了,我们可以来看,LSM树是如何完成检索的。
|
||||
|
||||
因为同时存在C0和C1树,所以要查询一个key时,我们会先到C0树中查询。如果查询到了则直接返回,不用再去查询C1树了。
|
||||
|
||||
而且,C0树会存储最新的一批数据,所以C0树中的数据一定会比C1树中的新。因此,如果一个系统的检索主要是针对近期数据的,那么大部分数据我们都能在内存中查到,检索效率就会非常高。
|
||||
|
||||
那如果我们在C0树中没有查询到key呢?这个时候,系统就会去磁盘中的C1树查询。在C1树中查到了,我们能直接返回吗?如果没有特殊处理的话,其实并不能。你可以先想想,这是为什么。
|
||||
|
||||
我们先来考虑一种情况:一个数据已经被写入系统了,并且我们也把它写入C1树了。但是,在最新的操作中,这个数据被删除了,那我们自然不会在C0树中查询到这个数据。可是它依然存在于C1树之中。
|
||||
|
||||
这种情况下,我们在C1树中检索到的就是过期的数据。既然是过期的数据,那为了不影响检索结果,我们能否从C1树中将这个数据删除呢?删除的思路没有错,但是不要忘了,我们不希望对C1树进行随机访问。这个时候,我们又该怎么处理呢?
|
||||
|
||||
我们依然可以采取延迟写入和批量操作的思路。对于被删除的数据,我们会将这些数据的key插入到C0树中,并且存入删除标志。如果C0树中已经存有这些数据,我们就将C0树中这些数据对应的key都加上删除标志。
|
||||
|
||||
这样一来,当我们在C0树中查询时,如果查到了一个带着删除标志的key,就直接返回查询失败,我们也就不用去查询C1树了。在滚动归并的时候,我们会查看数据在C0树中是否带有删除标志。如果有,滚动归并时就将它放弃。这样C1树就能批量完成“数据删除”的动作。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容就先讲到这里。我们一起来回顾一下,你要掌握的重点内容。
|
||||
|
||||
在写大于读的应用场景下,尤其是在日志系统和监控系统这类应用中,我们可以选用基于LSM树的NoSQL数据库,这是比B+树更合适的技术方案。
|
||||
|
||||
LSM树具有以下3个特点:
|
||||
|
||||
1. 将索引分为内存和磁盘两部分,并在内存达到阈值时启动树合并(Merge Trees);
|
||||
1. 用批量写入代替随机写入,并且用预写日志WAL技术保证内存数据,在系统崩溃后可以被恢复;
|
||||
1. 数据采取类似日志追加写的方式写入(Log Structured)磁盘,以顺序写的方式提高写入效率。
|
||||
|
||||
LSM树的这些特点,使得它相对于B+树,在写入性能上有大幅提升。所以,许多NoSQL系统都使用LSM树作为检索引擎,而且还对LSM树进行了优化以提升检索性能。在后面的章节中我们会介绍,工业界中实际使用的LSM树是如何实现的,帮助你对LSM树有更深入的认识。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
为了方便你理解,文章中我直接用B+树实现的C0树。但是,对于纯内存操作,其他的类树结构会更合适。如果让你来设计的话,你会采用怎么样的结构作为C0树呢?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这篇文章分享给你的朋友。
|
||||
79
极客时间专栏/检索技术核心20讲/进阶实战篇/08 | 索引构建:搜索引擎如何为万亿级别网站生成索引?.md
Normal file
79
极客时间专栏/检索技术核心20讲/进阶实战篇/08 | 索引构建:搜索引擎如何为万亿级别网站生成索引?.md
Normal file
@@ -0,0 +1,79 @@
|
||||
<audio id="audio" title="08 | 索引构建:搜索引擎如何为万亿级别网站生成索引?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4b/0f/4b3abb33cdb58a53735c34e34f02f30f.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
对基于内容或者属性的检索场景,我们可以使用倒排索引完成高效的检索。但是,在一些超大规模的数据应用场景中,比如搜索引擎,它会对万亿级别的网站进行索引,生成的倒排索引会非常庞大,根本无法存储在内存中。这种情况下,我们能否像B+树或者LSM树那样,将数据存入磁盘呢?今天,我们就来聊一聊这个问题。
|
||||
|
||||
## 如何生成大于内存容量的倒排索引?
|
||||
|
||||
我们先来回顾一下,对于能够在内存中处理的小规模的文档集合,我们是如何生成基于哈希表的倒排索引的。步骤如下:
|
||||
|
||||
1. 给每个文档编号,作为它们的唯一标识,并且排好序;
|
||||
1. 顺序扫描每一个文档,将当前扫描的文档中的所有内容生成<关键字,文档ID,关键字位置>数据对,并将所有的<关键字,文档ID,关键字位置>这样的数据对,都以关键字为key存入倒排表(位置信息如果不需要可以省略);
|
||||
<li>重复第2步,直到处理完所有文档。这样就生成一个基于内存的倒排索引。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/0d/2ccc78df6ebbd4d716318d5113fa090d.jpg" alt=""></li>
|
||||
|
||||
对于大规模的文档集合,如果我们能将它分割成多个小规模文档集合,是不是就可以在内存中建立倒排索引了呢?这些存储在内存中的小规模文档的倒排索引,最终又是怎样变成一个完整的大规模的倒排索引存储在磁盘中的呢?这两个问题,你可以先思考一下,然后我们一起来看工业界是怎么做的。
|
||||
|
||||
首先,搜索引擎这种工业级的倒排索引表的实现,会比我们之前学习过的更复杂一些。比如说,如果文档中出现了“极客时间”四个字,那除了这四个字本身可能被作为关键词加入词典以外,“极客”和“时间”还有“极客时间”这三个词也可能会被加入词典。因此,完整的词典中词的数量会非常大,可能会达到几百万甚至是几千万的级别。并且,每个词因为长度不一样,所占据的存储空间也会不同。
|
||||
|
||||
所以,为了方便后续的处理,我们不仅会为词典中的每个词编号,还会把每个词对应的字符串存储在词典中。此外,在posting list中,除了记录文档ID,我们还会记录该词在该文档中出现的每个位置、出现次数等信息。因此,posting list中的每一个节点都是一个复杂的结构体,每个结构体以文档ID为唯一标识。完整的倒排索引表结构如下图所示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/6e/c6039f816ba83e0845a87129b128106e.jpg" alt="">
|
||||
|
||||
那么,我们怎样才能生成这样一个工业级的倒排索引呢?
|
||||
|
||||
首先,我们可以将大规模文档均匀划分为多个小的文档集合,并按照之前的方法,为每个小的文档集合在内存中生成倒排索引。
|
||||
|
||||
接下来,我们需要将内存中的倒排索引存入磁盘,生成一个临时倒排文件。我们先将内存中的文档列表按照关键词的字符串大小进行排序,然后从小到大,将关键词以及对应的文档列表作为一条记录写入临时倒排文件。这样一来,临时文件中的每条记录就都是有序的了。
|
||||
|
||||
而且,在临时文件中,我们并不需要存储关键词的编号。原因在于每个临时文件的编号都是局部的,并不是全局唯一的,不能作为最终的唯一编号,所以无需保存。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/83/06/833a6a1aa057ff3c91bf24a14deb1d06.jpg" alt="">
|
||||
|
||||
我们依次处理每一批小规模的文档集合,为每一批小规模文档集合生成一份对应的临时文件。等文档全部处理完以后,我们就得到了磁盘上的多个临时文件。
|
||||
|
||||
那磁盘上的多个临时文件该如何合并呢?这又要用到我们熟悉的多路归并技术了。每个临时文件里的每一条记录都是根据关键词有序排列的,因此我们在做多路归并的时候,需要先将所有临时文件当前记录的关键词取出。如果关键词相同的,我们就可以将对应的posting list读出,并且合并了。
|
||||
|
||||
如果posting list可以完全读入内存,那我们就可以直接在内存中完成合并,然后把合并结果作为一条完整的记录写入最终的倒排文件中;如果posting list过大无法装入内存,但posting list里面的元素本身又是有序的,我们也可以将posting list从前往后分段读入内存进行处理,直到处理完所有分段。这样我们就完成了一条完整记录的归并。
|
||||
|
||||
每完成一条完整记录的归并,我们就可以为这一条记录的关键词赋上一个编号,这样每个关键词就有了全局唯一的编号。重复这个过程,直到多个临时文件归并结束,这样我们就可以得到最终完整的倒排文件。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/00/f1/00f9769908311fc598a3abc49fb71bf1.jpg" alt="">
|
||||
|
||||
这种将大任务分解为多个小任务,最终根据key来归并的思路,其实和分布式计算Map Reduce的思路是十分相似的。因此,这种将大规模文档拆分成多个小规模文档集合,再生成倒排文件的方案,可以非常方便地迁移到Map Reduce的框架上,在多台机器上同时运行,大幅度提升倒排文件的生成效率。那如果你想了解更多的内容,你可以看看Google在2004年发表的经典的map reduce论文,论文里面就说了使用map reduce来构建倒排索引是当时最成功的一个应用。
|
||||
|
||||
## 如何使用磁盘上的倒排文件进行检索?
|
||||
|
||||
那对于这样一个大规模的倒排文件,我们在检索的时候是怎么使用的呢?其实,使用的时候有一条核心原则,那就是**内存的检索效率比磁盘高许多,因此,能加载到内存中的数据,我们要尽可能加载到内存中**。
|
||||
|
||||
我们知道,一个倒排索引由两部分构成,一部分是key集合的词典,另一部分是key对应的文档列表。在许多应用中,词典这一部分数据量不会很大,可以在内存中加载。因此,我们完全可以将倒排文件中的所有key读出,在内存中使用哈希表建立词典。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/94/75/94c7d76248febf1dda83b03b20493d75.jpg" alt="">
|
||||
|
||||
那么,当有查询发生时,通过检索内存中的哈希表,我们就能找到对应的key,然后将磁盘中key对应的postling list读到内存中进行处理了。
|
||||
|
||||
说到这里,你可能会有疑问,如果词典本身也很大,只能存储在磁盘,无法加载到内存中该怎么办呢?其实,你可以试着将词典看作一个有序的key的序列,那这个场景是不是就变得很熟悉了?是的,我们完全可以用B+树来完成词典的检索。
|
||||
|
||||
这样一来,我们就可以把检索过程总结成两个步骤。第一步,我们使用B+树或类似的技术,查询到对应的词典中的关键字。第二步,我们将这个关键字对应的posting list读出,在内存中进行处理。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/ad/b38d7575d90ac7b56e1c3c828bd5cfad.jpg" alt="">
|
||||
|
||||
到这里,检索过程我们就说完了。不过,还有一种情况你需要考虑,那就是如果posting list非常长,它是很有可能无法加载到内存中进行处理的。比如说,在搜索引擎中,一些热门的关键词可能会出现在上亿个页面中,这些热门关键词对应的posting list就会非常大。那这样的情况下,我们该怎么办呢?
|
||||
|
||||
其实,这个问题在本质上和词典无法加载到内存中是一样的。而且,posting list中的数据也是有序的。因此,我们完全可以对长度过大的posting list也进行类似B+树的索引,只读取有用的数据块到内存中,从而降低磁盘访问次数。包括在Lucene中,也是使用类似的思想,用分层跳表来实现posting list,从而能将posting list分层加载到内存中。而对于长度不大的posting list,我们仍然可以直接加载到内存中。
|
||||
|
||||
此外,如果内存空间足够大,我们还能使用缓存技术,比如LRU缓存,它会将频繁使用的posting list长期保存在内存中。这样一来,当需要频繁使用该posting list的时候,我们可以直接从内存中获取,而不需要重复读取磁盘,也就减少了磁盘IO,从而提升了系统的检索效率。
|
||||
|
||||
总之,对于大规模倒排索引文件的使用,本质上还是我们之前学过的检索技术之间的组合应用。因为倒排文件分为词典和文档列表两部分,所以,检索过程其实就是分别对词典和文档列表的访问过程。因此,只要你知道如何对磁盘上的词典和文档列表进行索引和检索,你就能很好地掌握大规模倒排文件的检索过程。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我们学习了使用多文件归并的方式对万亿级别的网页生成倒排索引,还学习了针对这样大规模倒排索引文件的检索,可以通过查询词典和查询文档列表这两个阶段来实现。
|
||||
|
||||
除此之外,我们接触了两个很基础但也很重要的设计思想。
|
||||
|
||||
一个是尽可能地将数据加载到内存中,因为内存的检索效率大大高于磁盘。那为了将数据更多地加载到内存中,索引压缩是一个重要的研究方向,目前有很多成熟的技术可以实现对词典和对文档列表的压缩。比如说在Lucene中,就使用了类似于前缀树的技术FST,来对词典进行前后缀的压缩,使得词典可以加载到内存中。
|
||||
|
||||
另一个是将大数据集合拆成多个小数据集合来处理。这其实就是分布式系统的核心思想。在大规模系统中,使用分布式技术进行加速是很重要的一个方向。不过,今天我们只是学习了利用分布式的思想来构建索引,在后面的课程中,我们还会进一步地学习,如何利用分布式技术优化检索效率。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
词典如果能加载在内存中,就会大幅提升检索效率。在哈希表过大无法存入内存的情况下,我们是否还有可能使用其他占用内存空间更小的数据结构,来将词典完全加载在内存中?有序数组和二叉树是否可行?为什么?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这篇文章分享给你的朋友。
|
||||
103
极客时间专栏/检索技术核心20讲/进阶实战篇/09 | 索引更新:刚发布的文章就能被搜到,这是怎么做到的?.md
Normal file
103
极客时间专栏/检索技术核心20讲/进阶实战篇/09 | 索引更新:刚发布的文章就能被搜到,这是怎么做到的?.md
Normal file
@@ -0,0 +1,103 @@
|
||||
<audio id="audio" title="09 | 索引更新:刚发布的文章就能被搜到,这是怎么做到的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ec/b6/ec785da352de2086105e2c49dffef2b6.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
在前面的课程中,我们讲到,倒排索引是许多检索系统的核心实现方案。比如,搜索引擎对万亿级别网页的索引,就是使用倒排索引实现的。我们还讲到,对于超大规模的网页建立索引会非常耗时,工业界往往会使用分布式技术来并行处理。
|
||||
|
||||
对于发布较久的网页,搜索引擎可以有充足的时间来构建索引。但是一些新的网页和文章,往往发布了几分钟就可以被用户搜索到。这又是怎么做到的呢?今天,我们就来聊一聊这个问题。
|
||||
|
||||
## 工业界如何更新内存中的索引?
|
||||
|
||||
我们先来看这么一个问题:如果现在有一个小规模的倒排索引,它能完全加载在内存中。当有新文章进入内存的时候,倒排索引该如何更新呢?这个问题看似简单,但是实现起来却非常复杂。
|
||||
|
||||
我们能想到最直接的解决思路是,只要解析新文章有哪些关键词,然后将文章ID加入倒排表中关键词对应的文档列表即可。没错,在没有其他用户使用的情况下,这样的方法是可行的。但如果你有过一定的工程经验,你就会知道,在实际应用中,必然会有多个用户同时访问这个索引。
|
||||
|
||||
这个时候,如果我们直接更新倒排索引,就可能造成用户访问错误,甚至会引发程序崩溃。因此,一般来说,我们会对倒排表加上“读写锁”,然后再更新。但是,加上“锁”之后会带来频繁的读写锁切换,整个系统的检索效率会比无锁状态有所下降。
|
||||
|
||||
因此,为了使得系统有更好的性能,在工业界的实现中,我们会使用一种叫做“**Double Buffer(双缓冲)机制**”的解决方案,使得我们可以在无锁状态下对索引完成更新。
|
||||
|
||||
所谓“Double Buffer”,就是在内存中同时保存两份一样的索引,一个是索引A,一个是索引B。我们会使用一个指针p指向索引A,表示索引A是当前可访问的索引。那么用户在访问时就会通过指针p去访问索引A。这个时候,如果我们要更新,只更新索引B。这样,索引A和索引B之间就不存在读写竞争的问题了。因此,在这个过程中,索引A和索引B都可以保持无锁的状态。
|
||||
|
||||
那更新完索引B之后,我们该如何告知用户应该来访问索引B呢?这时候,我们可以将指针p通过[原子操作](https://www.infoq.cn/article/atomic-operations-and-contention)(即无法被打断的最细粒度操作,在Java和C++11等语言中都有相应实现)从A直接切换到B上。接着,我们就把索引B当作“只读索引”,然后更新索引A。
|
||||
|
||||
通过这样的机制,我们就能同时维护两个倒排索引,保持一个读、一个写,并且来回切换,最终完成高性能的索引更新。不过,为了避免切换太频繁,我们并不是每来一条新数据就更新,而是积累一批新数据以后再批量更新。这就是工业界常用的Double Buffer机制。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/f7/ff14e4247a2fc68bfe8f1b13c7d767f7.jpg" alt="">
|
||||
|
||||
用Double Buffer机制更新索引是一个高效的方案,追求检索性能的应用场景常常会使用这种方案。但是对于索引到了一定量级的应用而言,使用Double Buffer会带来翻倍的内存资源开销。比如说,像搜索引擎这样万亿级网页的索引规模,数据大部分存储在磁盘上,更是无法直接使用Double Buffer机制进行更新的。因此,我们还是需要寻找其他的解决方案。
|
||||
|
||||
## 如何使用“全量索引结合增量索引”方案?
|
||||
|
||||
对于大规模的索引更新,工业界常用“全量索引结合增量索引”的方案来完成。下面,我们就一起来探讨一下,这个方案是如何实现索引更新的。
|
||||
|
||||
首先,系统会周期性地处理全部的数据,生成一份完整的索引,也就是**全量索引**。这个索引不可以被实时修改,因此为了提高检索效率,我们可以不加“锁”。那对于实时更新的数据我们应该怎样处理呢?我们会将新接收到的数据单独建立一个可以存在内存中的倒排索引,也就是**增量索引**。当查询发生的时候,我们会同时查询全量索引和增量索引,将合并的结果作为总的结果输出。这就是“**全量索引结合增量索引**”的更新方案。
|
||||
|
||||
其实这个方案还能结合我们上面讲的Double Buffer机制来优化。因为增量索引相对全量索引而言会小很多,内存资源消耗在可承受范围,所以我们可以使用Double Buffer机制对增量索引进行索引更新。这样一来,增量索引就可以做到无锁访问。而全量索引本身就是只读的,也不需要加锁。因此,整个检索过程都可以做到无锁访问,也就提高了系统的检索效率。
|
||||
|
||||
“全量索引结合增量索引”的检索方案,可以很好地处理新增的数据。那对于删除的数据,如果我们不做特殊处理,会有什么问题呢?下面,我们一起来分析一下。
|
||||
|
||||
假设,一个数据存储在全量索引中,但是在最新的实时操作中,它被删除了,那么在增量索引中,这个数据并不存在。当我们检索的时候,增量索引会返回空,但全量索引会返回这个数据。如果我们直接合并这两个检索结果,这个数据就会被留下作为检索结果返回,但是这个数据明明已经被删除了,这就会造成错误。
|
||||
|
||||
要解决这个问题,我们就需要在增量索引中保留删除的信息。最常见的解决方案是增加一个删除列表,将被删除的数据记录在列表中,然后检索的时候,我们将全量倒排表和增量倒排表的检索结果和删除列表作对比。如果结果数据存在于删除列表中,就说明该数据是无效的,我们直接删除它即可。
|
||||
|
||||
因此,完整的“全量索引结合增量索引”检索方案,需要在增量索引中保存一个删除列表。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/92/14/927bbd6cb53ceafc61384e0109d6a414.jpg" alt="">
|
||||
|
||||
## 增量索引空间的持续增长如何处理?
|
||||
|
||||
“全量索引结合增量索引”的方案非常实用,但是内存毕竟有限。如果我们不对内存中的增量索引做任何处理,那随着时间推移,内存就会被写满。因此,我们需要在合适的时机将增量索引合并到全量索引中,释放增量索引的内存空间。
|
||||
|
||||
将增量索引合并到全量索引中的常见方法有3种,分别是:完全重建法、再合并法和滚动合并法。下面,我们一一来看。
|
||||
|
||||
### 1. 完全重建法
|
||||
|
||||
如果增量索引的增长速度不算很快,或者全量索引重建的代价不大,那么我们完全可以在增量索引写满内存空间之前,完全重建一次全量索引,然后将系统查询切换到新的全量索引上。
|
||||
|
||||
这样一来,之前旧的增量索引的空间也可以得到释放。这种方案叫作完全重建法。它对于大部分规模不大的检索系统而言,是十分简单可行的方案。
|
||||
|
||||
### 2. 再合并法
|
||||
|
||||
尽管完全重建法的流程很简单,但是效率并不是最优的。
|
||||
|
||||
在[第8讲](https://time.geekbang.org/column/article/222810)中我们讲过,对于较大规模的检索系统而言,在构建索引的时候,我们常常会将大数据集分割成多个小数据集,分别建立小索引,再把它们合并成一个大索引。
|
||||
|
||||
借助这样的思路,我们完全可以把全量索引想象成是一个已经将多个小索引合并好的大索引,再把增量索引想象成是一个新增的小索引。这样一来,我们完全可以直接归并全量索引和增量索引,生成一个新的全量索引,这也就避免了从头处理所有文档的重复开销。这种方法就是效率更高的再合并法。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/db/1e/dbdff3486450a78abe1148cd43ba721e.jpg" alt="">
|
||||
|
||||
### 3. 滚动合并法
|
||||
|
||||
不过,如果全量索引和增量索引的量级差距过大,那么再合并法的效率依然不高。
|
||||
|
||||
为什么这么说呢?我们以搜索引擎为例来分析一下。在搜索引擎中,增量索引只有上万条记录,但全量索引可能有万亿条记录。这样的两个倒排索引合并的过程中,只有少数词典中的关键词和文档列表会被修改,其他大量的关键词和文档列表都会从旧的全量索引中被原样复制出来,再重写入到新的全量索引中,这会带来非常大的无谓的磁盘读写开销。因此,对于这种量级差距过大的全量索引和增量索引的归并来说,如何避免无谓的数据复制就是一个核心问题。
|
||||
|
||||
最直接的解决思路就是**原地更新法**。所谓“原地更新法”,就是不生成新的全量索引,直接在旧的全量索引上修改。
|
||||
|
||||
但这种方法在工程实现上其实效率并不高,原因有两点。
|
||||
|
||||
首先,它要求倒排文件要拆散成多个小文件,每个关键词对应的文档列表为一个小文件,这样才可以将增量索引中对应的变化直接在对应的小文件上单独修改。但这种超大规模量级的零散小文件的高效读写,许多操作系统是很难支持的。
|
||||
|
||||
其次,由于只有一份全量索引同时支持读和写,那我们就需要“加锁”,这肯定也会影响检索效率。因此,在一些大规模工程中,我们并不会使用原地更新法。
|
||||
|
||||
这就又回到了我们前面要解决的核心问题,也就是如何避免无谓的数据复制,那在工业界中常用的减少无谓数据复制的方法就是**滚动合并法**。所谓滚动合并法,就是先生成多个不同层级的索引,然后逐层合并。
|
||||
|
||||
比如说,一个检索系统在磁盘中保存了全量索引、周级索引和天级索引。所谓**周级索引**,就是根据本周的新数据生成的一份索引,那**天级索引**就是根据每天的新数据生成的一份索引。在滚动合并法中,当内存中的增量索引增长到一定体量时,我们会用再合并法将它合并到磁盘上当天的天级索引文件中。
|
||||
|
||||
由于天级的索引文件条数远远没有全量索引多,因此这不会造成大量的无谓数据复制。等系统中积累了7天的天级索引文件后,我们就可以将这7个天级索引文件合并成一个新的周级索引文件。因此,在每次合并增量索引和全量索引的时候,通过这样逐层滚动合并的方式,就不会进行大量的无谓数据复制的开销。这个过程就叫作滚动合并法。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/36/8ef104a67bdeebaf57e16a895cf4d936.jpg" alt="">
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我们介绍了工业界中,不同规模的倒排索引对应的索引更新方法。
|
||||
|
||||
对于内存资源足够的小规模索引,我们可以直接使用**Double Buffer机制**更新内存中的索引;对于内存资源紧张的大规模索引,我们可以使用“**全量索引结合增量索引**”的方案来更新内存中的索引。
|
||||
|
||||
在“全量索引结合增量索引”的方案中,全量索引根据内存资源的使用情况不同,它既可以存在内存中,也可以存在磁盘上。而增量索引则需要全部存在内存中。
|
||||
|
||||
当增量索引增长到上限时,我们需要合并增量索引和全量索引,根据索引的规模和增长速度,我们可以使用的合并方法有完全重建法、再合并法和滚动合并法。
|
||||
|
||||
除此之外,我们还讲了一个很重要的工业设计思想,就是读写分离。实际上,高效的索引更新方案都应用了读写分离的思想,将主要的数据检索放在一个只读的组件上。这样,检索时就不会有读写同时发生的竞争状态了,也就避免了加锁。事实上,无论是Double Buffer机制,还是全量索引结合增量索引,都是读写分离的典型例子。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
为什么在增量索引的方案中,对于删除的数据,我们不是像LSM树一样在索引中直接做删除标记,而是额外增加一个删除列表?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程。如果有收获,也欢迎把这篇文章分享给你的朋友。
|
||||
95
极客时间专栏/检索技术核心20讲/进阶实战篇/10 | 索引拆分:大规模检索系统如何使用分布式技术加速检索?.md
Normal file
95
极客时间专栏/检索技术核心20讲/进阶实战篇/10 | 索引拆分:大规模检索系统如何使用分布式技术加速检索?.md
Normal file
@@ -0,0 +1,95 @@
|
||||
<audio id="audio" title="10 | 索引拆分:大规模检索系统如何使用分布式技术加速检索?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/f4/9f793d4a8da04def61d34c317f3384f4.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
在互联网行业中,分布式系统是一个非常重要的技术方向。我们熟悉的搜索引擎、广告引擎和推荐引擎,这些大规模的检索系统都采用了分布式技术。
|
||||
|
||||
分布式技术有什么优点呢?**分布式技术就是将大任务分解成多个子任务,使用多台服务器共同承担任务,让整体系统的服务能力相比于单机系统得到了大幅提升**。而且,在[第8讲](https://time.geekbang.org/column/article/222810)中我们就讲过,在索引构建的时候,我们可以使用分布式技术来提升索引构建的效率。
|
||||
|
||||
那今天,我们就来聊一聊,大规模检索系统中是如何使用分布式技术来加速检索的。
|
||||
|
||||
## 简单的分布式结构是什么样的?
|
||||
|
||||
一个完备的分布式系统会有复杂的服务管理机制,包括服务注册、服务发现、负载均衡、流量控制、远程调用和冗余备份等。在这里,我们先抛开分布式系统的实现细节,回归到它的本质,也就是从“让多台服务器共同承担任务”入手,来看一个简单的分布式检索系统是怎样工作的。
|
||||
|
||||
首先,我们需要一台接收请求的服务器,但是该服务器并不执行具体的查询工作,它只负责任务分发,我们把它叫作**分发服务器**。真正执行检索任务的是**多台索引服务器**,每台索引服务器上都保存着完整的倒排索引,它们都能完成检索的工作。
|
||||
|
||||
当分发服务器接到请求时,它会根据负载均衡机制,将当前查询请求发给某台较为空闲的索引服务器进行查询。具体的检索工作由该台索引服务器独立完成,并返回结果。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/df/5ba0f02fc5607409831cc0256a62eedf.jpg" alt="">
|
||||
|
||||
现在,分布式检索系统的结构你已经知道了,那它的效率怎么样呢?举个例子,如果一台索引服务器一秒钟能处理1000条请求,那我们同时使用10台索引服务器,整个系统一秒钟就能处理10000条请求了。也就是说,这样简单的分布式系统,就能大幅提升整个检索系统的处理能力。
|
||||
|
||||
但是,这种简单的分布式系统有一个问题:它仅能提升检索系统整体的“吞吐量”,而不能缩短一个查询的检索时间。也就是说,如果单机处理一个查询请求的耗时是1秒钟,那不管我们增加了多少台机器,单次查询的检索时间依然是1秒钟。所以,如果我们想要缩短检索时间,这样的分布式系统是无法发挥作用的。
|
||||
|
||||
那么,我们能否利用多台机器,来提升单次检索的效率呢?我们先来回顾一下,在前面讨论工业级的倒排索引时我们说过,对于存储在磁盘上的大规模索引数据,我们要尽可能地将数据加载到内存中,以此来减少磁盘访问次数,从而提升检索效率。
|
||||
|
||||
根据这个思路,当多台服务器的总内存量远远大于单机的内存时,我们可以把倒排索引拆分开,分散加载到每台服务器的内存中。这样,我们就可以避免或者减少磁盘访问,从而提升单次检索的效率了。
|
||||
|
||||
即使原来的索引都能加载到内存中,索引拆分依然可以帮助我们提升单次检索的效率。这是因为,检索时间和数据规模是正相关的。当索引拆分以后,每台服务器上加载的数据都会比全量数据少,那每台服务器上的单次查询所消耗的时间也就随之减少了。
|
||||
|
||||
因此,索引拆分是检索加速的一个重要优化方案,至于索引应该如何拆分,以及拆分后该如何检索,工业界也有很多不同的实现方法。你可以先自己想一想,然后我们再一起来看看,工业界一般都是怎么做的。
|
||||
|
||||
## 如何进行业务拆分?
|
||||
|
||||
首先,在工业界中一个最直接的索引拆分思路,是根据业务进行索引拆分。那具体该如何拆分呢?
|
||||
|
||||
我来举个例子。在图书管理系统中,有许多不同国籍的作家的作品。如果我们将它们分成国内作品和国外作品两大类,分别建立两个倒排索引,这就完成了索引拆分。索引拆分之后,我们可以使用不同的服务器加载不同的索引。在检索的时候,我们需要先判断检索的是国内作品还是国外作品,然后在检索界面上做好选择,这样系统就可以只在一个索引上查询了。如果我们不能确认是哪类作品,那也没关系,系统可以在两个索引中并行查找,然后将结果汇总。
|
||||
|
||||
你会看到,基于业务的拆分是一个实用的索引拆分方案,在许多应用场景中都可以使用。但是这种方案和业务的耦合性太强,需要根据不同的业务需求灵活调整。那我们有没有更通用的技术解决方案呢?你可以先想一下,然后我们一起来讨论。
|
||||
|
||||
## 如何基于文档进行拆分?
|
||||
|
||||
以搜索引擎为例,一个通用的方案是借鉴索引构建的拆分思路,将大规模文档集合随机划分为多个小规模的文档集合分别处理。这样我们就可以基于文档进行拆分,建立起多个倒排索引了。其中,每个倒排索引都是一个索引分片,它们分别由不同的索引服务器负责。每个索引分片只包含部分文档,所以它们的posting list都不会太长,这样单机的检索效率也就得到了提升。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/39/eec1b7de0974b1d0ac62b8b043504439.jpg" alt="">
|
||||
|
||||
但是,这样拆分出来的任意一个单独的索引分片,它检索出来的结果都不完整,我们还需要合并操作才能得到最后的检索结果。因此,对于基于文档进行拆分的分布式方案,我们的检索流程可以总结为3个步骤:
|
||||
|
||||
1. 分发服务器接受查询请求,将请求发送给所有不同索引分片的索引服务器;
|
||||
1. 每台索引服务器根据自己加载的索引分片进行检索,将查询结果返回分发服务器;
|
||||
1. 分发服务器将所有返回的结果进行合并处理,再返回最终结果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/c0/2d98f7658d29f1d6cef4a21af7238fc0.jpeg" alt="">
|
||||
|
||||
这种基于文档拆分的方案是随机划分的,所以我们可以不用关心业务细节。而且每个索引分片的大小都能足够相近,因此,这种拆分方式能很均匀地划分检索空间和分担检索负载。并且,如果我们将索引数据分成合适的份数,是有可能将所有数据都加载到内存中的。由于每个索引分片中的文档列表都不长,因此每台机器对于单个请求都能在更短的时间内返回,从而加速了检索效率。
|
||||
|
||||
但是,分片的数量也不宜过多。这是因为,一个查询请求会被复制到所有的索引分片上,如果分片过多的话,每台加载索引分片的服务器都要返回n个检索结果,这会带来成倍的网络传输开销。而且,分片越多,分发服务器需要合并的工作量也会越大,这会使得分发服务器成为瓶颈,造成性能下降。因此,对于索引分片数量,我们需要考虑系统的实际情况进行合理的设置。
|
||||
|
||||
## 如何基于关键词进行拆分?
|
||||
|
||||
在搜索引擎中,为了解决分片过多导致一次请求被复制成多次的问题,我们还可以使用另一种拆分方案,那就是基于关键词进行拆分。这种方案将词典划分成多个分片,分别加载到不同的索引服务器上。每台索引服务器上的词典都是不完整的,但是词典中关键词对应的文档列表都是完整的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/3a/d8ef8131d943d4ed7b812dd30e51ba3a.jpg" alt="">
|
||||
|
||||
当用户查询时,如果只有一个关键词,那我们只需要查询存有这个关键词的一台索引服务器,就能得到完整的文档列表,而不需要给所有的索引服务器都发送请求;当用户同时查询两个关键词时,如果这两个关键词也同时属于一个索引分片的话,那系统依然只需要查询一台索引服务器即可。如果分别属于两个分片,那我们就需要发起两次查询,再由分发服务器进行结果合并。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d3/1b/d38afa0d9b400798fd30a9c0e147e91b.jpg" alt="">
|
||||
|
||||
也就是说,在查询词少的情况下,如果能合理分片,我们就可以大幅降低请求复制的代价了。
|
||||
|
||||
但是这种切分方案也带来了很多复杂的管理问题,比如,如果查询词很多并且没有被划分到同一个分片中,那么请求依然会被多次复制。再比如,以及如果有的关键词是高频词,那么对应的文档列表会非常长,检索性能也会急剧下降。此外,还有新增文档的索引修改问题,系统热点查询负载均衡的问题等。
|
||||
|
||||
因此,除了少数的高性能检索场景有需求以外,一般我们还是基于文档进行索引拆分。这样,系统的扩展性和可运维性都会更好。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容就先讲到这里。我们一起来总结一下,你要掌握的重点内容。
|
||||
|
||||
首先,利用分布式技术,我们可以将倒排索引进行索引拆分。索引拆分的好处是:一方面是能将更多的索引数据加载到内存中,降低磁盘访问次数,使得检索效率能得到大幅度的提升;另一方面是基于文档的拆分,能将一个查询请求复制成多份,由多台索引服务器并行完成,单次检索的时间也能得到缩短。
|
||||
|
||||
其次,除了搜索引擎,其他大规模数据检索引擎,如广告引擎、推荐引擎等也都使用了类似的索引拆分技术。只是由于它们处理的对象不是文档,因此对于拆分方式的命名也不同。
|
||||
|
||||
一般来说,根据处理对象将倒排索引进行拆分,每个索引分片都可能有完整的词典,但posting list不完整,这种拆分方案叫作**水平拆分**。如果是根据倒排索引中的关键词进行拆分,每个索引分片的词典都不完整,但是词典中的关键词对应的posting list是完整的,这种拆分方案叫作**垂直拆分**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/9e/56dfa700a087ea1984a7fcda8a6d409e.jpg" alt="">
|
||||
|
||||
总之,**合理的索引拆分是分布式检索加速的重要手段,也是工业界的有效实践经验**。因此,我希望你能好好地理解今天的内容。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
为什么说基于文档拆分的方案会比基于关键词拆分的方案更好维护?你可以结合以下2个问题来考虑一下:
|
||||
|
||||
1. 当有新文档加入时,会影响多少台索引服务器?
|
||||
1. 当某些关键词是热点,会被大量查询时,每台服务器的负载是否均衡?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这篇文章分享给你的朋友。
|
||||
144
极客时间专栏/检索技术核心20讲/进阶实战篇/11|精准Top K检索:搜索结果是怎么进行打分排序的?.md
Normal file
144
极客时间专栏/检索技术核心20讲/进阶实战篇/11|精准Top K检索:搜索结果是怎么进行打分排序的?.md
Normal file
@@ -0,0 +1,144 @@
|
||||
<audio id="audio" title="11|精准Top K检索:搜索结果是怎么进行打分排序的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/97/2a/9741af74f5acc7301ba1bb78ebf43b2a.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
在搜索引擎的检索结果中,排在前面几页的检索结果往往质量更好,更符合我们的要求。一般来说,这些高质量检索结果的排名越靠前,这个搜索引擎的用户体验也就越好。可以说,检索结果的排序是否合理,往往决定了一个检索系统的质量。
|
||||
|
||||
所以,在搜索引擎这样的大规模检索系统中,排序是非常核心的一个环节。简单来说,排序就是搜索引擎对符合用户要求的检索结果进行打分,选出得分最高的K个检索结果的过程。这个过程也叫作Top K检索。
|
||||
|
||||
今天,我就和你仔细来聊一聊,搜索引擎在Top K检索中,是如何进行打分排序的。
|
||||
|
||||
## 经典的TF-IDF算法是什么?
|
||||
|
||||
在搜索引擎的应用场景中,检索结果文档和用户输入的查询词之间的相关性越强,网页排名就越靠前。所以,在搜索引擎对检索结果的打分中,查询词和结果文档的相关性是一个非常重要的判断因子。
|
||||
|
||||
那要计算相关性,就必须要提到经典的TF-IDF算法了,它能很好地表示一个词在一个文档中的权重。TF-IDF算法的公式是:**相关性= TF`*`IDF**。其中,TF是**词频**(Term Frequency),IDF是**逆文档频率**(Inverse Document Frequency)。
|
||||
|
||||
在利用TF-IDF算法计算相关性之前,我们还要理解几个重要概念,分别是词频、文档频率和逆文档频率。
|
||||
|
||||
**词频**定义的就是一个词项在文档中出现的次数。换一句话说就是,如果一个词项出现了越多次,那这个词在文档中就越重要。
|
||||
|
||||
**文档频率**(Document Frequency),指的是这个词项出现在了多少个文档中。你也可以理解为,如果一个词出现在越多的文档中,那这个词就越普遍,越没有区分度。一个极端的例子,比如“的”字,它基本上在每个文档中都会出现,所以它的区分度就非常低。
|
||||
|
||||
那为了方便理解和计算相关性,我们又引入了一个**逆文档频率**的概念。逆文档频率是对文档频率取倒数,它的值越大,这个词的的区分度就越大。
|
||||
|
||||
因此, TF`*`IDF表示了我们综合考虑了一个词项的重要性和区分度,结合这两个维度,我们就计算出了一个词项和文档的相关性。不过,在计算的过程中,我们会对TF和IDF的值都使用对数函数进行平滑处理。处理过程如下图所示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/17/8d/173efe31a43f745f33006f6e3fd54e8d.jpg" alt=""><br>
|
||||
使用“相关性 = TF`*`IDF”,我们可以计算一个词项在一个文档中的权重。但是,很多情况下,一个查询中会有多个词项。不过,这也不用担心,处理起来也很简单。我们直接把每个词项和文档的相关性累加起来,就能计算出查询词和文档的总相关性了。
|
||||
|
||||
这么说可能比较抽象,我列举了一些具体的数字,我们一起动手来计算一下相关性。假设查询词是“极客时间”,它被分成了两个词项“极客”和“时间”。现在有两个文档都包含了“极客”和“时间”,在文档1中,“极客”出现了10次,“时间”出现了10次。而在文档2中,“极客”出现了1次,“时间”出现了100次。
|
||||
|
||||
计算TF-IDF需要的数据如下表所示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/ff/5a582cdf2001c40b396076940848a0ff.jpg" alt=""><br>
|
||||
那两个文档的最终相关性得分如下:
|
||||
|
||||
文档1打分 =TF`*`IDF(极客)+ TF`*`IDF(时间)= (1+lg(10)) * 2 + (1+lg(10)) * 1 = 4 + 2 = 6
|
||||
|
||||
文档2打分 = TF`*`IDF(极客)+ TF`*`IDF(时间)=(1+lg(1)) * 2 + (1+lg(100)) * 1 = 2 + 3 = 5
|
||||
|
||||
你会发现,尽管“时间”这个词项在文档2中出现了非常多次,但是,由于“时间”这个词项的IDF值比较低,因此,文档2的打分并没有文档1高。
|
||||
|
||||
## 如何使用概率模型中的BM25算法进行打分?
|
||||
|
||||
不过,在实际使用中,我们往往不会直接使用TF-IDF来计算相关性,而是会以TF-IDF为基础,使用向量模型或者概率模型等更复杂的算法来打分。比如说,概率模型中的**BM25**(Best Matching 25)算法,这个经典算法就可以看作是TF-IDF算法的一种升级。接下来,我们就一起来看看,BM25算法是怎么打分的。
|
||||
|
||||
BM25算法的一个重要的设计思想是,**它认为词频和相关性的关系并不是线性的**。也就是说,随着词频的增加,相关性的增加会越来越不明显,并且还会有一个阈值上限。当词频达到阈值以后,那相关性就不会再增长了。
|
||||
|
||||
因此,BM25对于TF的使用,设立了一个公式,公式如下:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/11/ea696534a736b5ca7a7d6eae21c59211.jpg" alt="">
|
||||
|
||||
在这个公式中,随着tf的值逐步变大,权重会趋向于k1 + 1这个固定的阈值上限(将公式的分子分母同时除以tf,就能看出这个上限)。其中,k1是可以人工调整的参数。k1越大,权重上限越大,收敛速度越慢,表示tf越重要。在极端情况下,也就是当k1 = 0时,就表示tf不重要。比如,在下图中,当k1 = 3就比k1 = 1.2时的权重上限要高很多。那按照经验来说,我们会把k1设为1.2。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/63/5685db18a2d833ebd4145b88999f5b63.jpeg" alt="">
|
||||
|
||||
除了考虑词频,BM25算法还考虑了文档长度的影响,也就是同样一个词项,如果在两篇文档中出现了相同的次数,但一篇文档比较长,而另一篇文档比较短,那一般来说,短文档中这个词项会更重要。这个时候,我们需要在上面的公式中,加入文档长度相关的因子。那么,整个公式就会被改造成如下的样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/2b/d074a5268f9740f03603260876bc942b.jpg" alt="">
|
||||
|
||||
你会看到,分母中的k1部分被乘上了文档长度的权重。其中,l表示当前文档的长度,而L表示全部文档的平均长度。l越长,分母中的k1就会越大,整体的相关性权重就会越小。
|
||||
|
||||
这个公式中除了k1,还有一个可以人工调整的参数b。它的取值范围是0到1,它 代表了文档长度的重要性。当b取0时,我们可以完全不考虑文档长度的影响;而当b取1时,k1的重要性要按照文档长度进行等比例缩放。按照经验,我们会把b设置为0.75,这样的计算效果会比较好。
|
||||
|
||||
除此以外,如果查询词比较复杂,比如说一个词项会重复出现,那我们也可以把它看作是一个短文档,用类似的方法计算词项在查询词中的权重。举个例子,如果我们的查询词是“极客们的极客时间课程”,那么“极客”这个词项,其实在查询词中就出现了两次,它的权重应该比“时间”“课程”这些只出现一次的词项更重要。因此,BM25对词项在查询词中的权重计算公式如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/cc/aee9601e28286c8cd1d413a4937893cc.jpg" alt="">
|
||||
|
||||
其中tf<sub>q</sub> 表示词项在查询词q中的词频,而k2是可以人工调整的参数,它和k1的参数作用是类似的。由于查询词一般不会太长,所以词频也不会很大,因此,我们没必要像对待文档一下,用k1 = 1.2这么小的范围对它进行控制。我们可以放大词频的作用,把k2设置在0~10之间。极端情况下,也就是当k2取0时,表示我们可以完全不考虑查询词中的词项权重。
|
||||
|
||||
好了,前面我们说了这么多种权重公式,有基础的权重公式、文档中词项的权重公式和查询词中词项的权重公式。那在实际使用BM25算法打分的时候,我们该怎么使用这些公式呢?其实,我们可以回顾一下标准的TF-IDF,把其中的TF进行扩展,变为“文档中词项权重”和“查询词中词项权重”的乘积。这样,我们就得到了BM25算法计算一个词项和指定文档相关性的打分公式,公式如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/6e/82ba995c606f11945661895df1f3036e.jpg" alt="">
|
||||
|
||||
你会看到,它由IDF、文档中词项权重以及查询词中词项权重这三部分共同组成。
|
||||
|
||||
如果一个查询词q中有多个词项t,那我们就要把每一个词项t和文档d的相关性都计算出来,最后累加。这样,我们就得到了这个查询词q和文档d的相关性打分结果,我们用score(q,d)来表示,公式如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/e8/d01fae777eb1d93fe83d65a8c2b637e8.jpg" alt="">
|
||||
|
||||
这就是完整的BM25算法的表达式了。尽管这个公式看起来比较复杂,但是经过我们刚才一步一步的拆解,你应该可以很好地理解它了,它其实就是对TF-IDF的算法中的TF做了更细致的处理而已。其实,BM25中的IDF的部分,我们也还可以优化,比如,基于二值独立模型对它进行退化处理(这是另一个分析相关性的模型,这里就不具体展开说了)之后,我们就可以得到一个和IDF相似的优化表示,公式如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/83/f5b2ce4b23bd532e124c11fa56db9083.jpg" alt="">
|
||||
|
||||
你可以将它视为IDF的变体,用来替换公式中原有的IDF部分。
|
||||
|
||||
总结来说,BM25算法就是一个对查询词和文档的相关性进行打分的概率模型算法。BM25算法考虑了四个因子,分别为IDF、文档长度、文档中的词频以及查询词中的词频。并且,公式中还加入了3个可以人工调整大小的参数,分别是 :k1、k2和b。
|
||||
|
||||
因此,BM25算法的效果比TF-IDF更好,应用也更广泛。比如说,在Lucene和Elastic Search这些搜索框架,以及Google这类常见的搜索引擎中,就都支持BM25排序。不过要用好它,你需要结合我们今天讲的内容,更清楚地理解它的原理。这样才能根据不同的场景,去调整相应的参数,从而取得更好的效果。
|
||||
|
||||
## 如何使用机器学习来进行打分?
|
||||
|
||||
随着搜索引擎的越来越重视搜索结果的排序和效果,我们需要考虑的因子也越来越多。比如说,官方的网站是不是会比个人网页在打分上有更高的权重?用户的历史点击行为是否也是相关性的一个衡量指标?
|
||||
|
||||
在当前的主流搜索引擎中,用来打分的主要因子已经有几百种了。如果我们要将这么多的相关因子都考虑进来,再加入更多的参数,那BM25算法是无法满足我们的需求的。
|
||||
|
||||
这个时候,机器学习就可以派上用场了。利用机器学习打分是近些年来比较热门的研究领域,也是许多搜索引擎目前正在使用的打分机制。
|
||||
|
||||
那机器学习具体是怎么打分的呢?原理很简单,就是把不同的打分因子进行加权求和。比如说,有n个打分因子,分别为x<sub>1</sub>到x<sub>n</sub>,而每个因子都有不同的权重,我们记为w<sub>1</sub>到w<sub>n</sub>,那打分公式就是:
|
||||
|
||||
那你可能会问了,公式中的权重要如何确定呢?这就需要我们利用训练数据,让机器学习在离线阶段,自动学出最合适的权重。这样,就避免了人工制定公式和权重的问题。
|
||||
|
||||
当然,这个打分公式是不能直接使用的,因为它的取值范围是负无穷到正无穷。这是一个跨度很广的范围,并不好衡量和比较相关性。一般来说,我们会使用Sigmoid函数对score进行处理,让它处于(0,1)范围内。
|
||||
|
||||
Sigmoid函数的取值范围是(0,1),它的函数公式和图像如下所示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/d1/e8caac9f39afe3edd5ce5ddc62c0ced1.jpg" alt="">
|
||||
|
||||
Sigmoid函数的特点就是:x值越大,y值越接近于1;x值越小,y值越接近于0。并且,x值在中间一段范围内,相关性的变化最明显,而在两头会发生边际效应递减的现象,这其实也符合我们的日常经验。比方说,一个2-3人的项目要赶进度,一开始增加1、2个人进来,项目进度会提升明显。但如果我们再持续加人进来,那项目的加速就会变平缓了。
|
||||
|
||||
这个打分方案,就是工业界常见的**逻辑回归模型**(Logistic Regression)(至于为什么逻辑回归模型的表现形式是Sigmoid函数,这是另一个话题,这里就不展开说了)。当然,工业界除了逻辑回归模型的打分方案,还有支持向量机模型、梯度下降树等。并且,随着深度学习的发展,也演化出了越来越多的复杂打分算法,比如,使用**深度神经网络模型**(DNN)和相关的变种等。由于机器学习和深度学习是专门的领域,因此相关的打分算法我就不展开了。在这一讲中,你只要记住,机器学习打分模型可以比人工规则打分的方式处理更多的因子,能更好地调整参数就可以了。
|
||||
|
||||
## 如何根据打分结果快速进行Top K检索?
|
||||
|
||||
在给所有的文档打完分以后,接下来,我们就要完成排序的工作了。一般来说,我们可以使用任意一种高效的排序算法来完成排序,比如说,我们可以使用快速排序,它排序的时间代价是O(n log n)。但是,我们还要考虑到,搜索引擎检索出来结果的数量级可能是千万级别的。在这种情况下,即便是O(n log n)的时间代价,也会是一个非常巨大的时间损耗。
|
||||
|
||||
那对于这个问题,我们该怎么优化呢?
|
||||
|
||||
其实,你可以回想一下,我们在使用搜索引擎的时候,一般都不会翻超过100页(如果有兴趣,你可以试着翻翻,看100页以后搜索引擎会显示什么),而且,平均一页只显示10条数据。也就是说,搜索引擎其实只需要显示前1000条数据就够了。因此,在实际系统中,我们不需要返回所有结果,只需要返回Top K个结果就可以。这就是许多大规模检索系统应用的的**Top K检索**了。而且,我们前面的打分过程都是非常精准的,所以我们今天学习的也叫作**精准Top K检索**。
|
||||
|
||||
当然还有非精准的Top K检索,这里先卖个关子,我会在下一讲详细来讲。
|
||||
|
||||
那再回到优化排序上,由于只需要选取Top K个结果,因此我们可以使用堆排序来代替全排序。这样我们就能把排序的时间代价降低到O(n) + O(k log n)(即建堆时间+在堆中选择最大的k个值的时间),而不是原来的O(n log n)。举个例子,如果k是1000,n是1000万,那排序性能就提高了近6倍!这是一个非常有效的性能提升。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好了,今天的内容就先讲到这里。我们一起来回顾一下,你要掌握的重点内容。
|
||||
|
||||
首先,我们讲了3种打分方法,分别是经典算法TF-IDF、概率模型BM25算法以及机器学习打分。
|
||||
|
||||
在TF-IDF中, TF代表了词项在文档中的权重,而IDF则体现了词项的区分度。尽管TF-IDF很简单,但它是许多更复杂的打分算法的基础。比如说,在使用机器学习进行打分的时候,我们也可以直接将TF-IDF作为一个因子来处理。
|
||||
|
||||
BM25算法则是概率模型中最成功的相关性打分算法。它认为TF对于相关性的影响是有上限的,所以,它不仅同时考虑了IDF、文档长度、文档中的词频,以及查询词中的词频这四个因子, 还给出了3个可以人工调整的参数。这让它的打分效果得到了广泛的认可,能够应用到很多检索系统中。
|
||||
|
||||
不过,因为机器学习可以更大规模地引入更多的打分因子,并且可以自动学习出各个打分因子的权重。所以,利用机器学习进行相关性打分,已经成了目前大规模检索引擎的标配。
|
||||
|
||||
完成打分阶段之后,排序阶段我们要重视排序的效率。对于精准Top K检索,我们可以使用堆排序来代替全排序,只返回我们认为最重要的k个结果。这样,时间代价就是O(n) + O(k log n) ,在数据量级非常大的情况下,它比O(n log n)的检索性能会高得多。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
<li>
|
||||
在今天介绍的精准Top K检索的过程中,你觉得哪个部分是最耗时的?是打分还是排序?
|
||||
</li>
|
||||
<li>
|
||||
你觉得机器学习打分的优点在哪里?你是否使用过机器学习打分?可以把你的使用场景分享出来。
|
||||
</li>
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的想法。如果有收获,也欢迎把这一讲分享给你的朋友。
|
||||
@@ -0,0 +1,97 @@
|
||||
<audio id="audio" title="12 | 非精准Top K检索:如何给检索结果的排序过程装上“加速器”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/51/f8/510877eee6b746356be368111003a1f8.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
上一讲,我们详细讲解了Top K检索的打分排序过程,并且还提到可以使用堆排序代替全排序,来大幅降低排序的时间代价。然而,对于这整个检索过程来说,精准复杂的打分开销要比排序大得多。因此,如果我们想更大幅度地提升检索性能,优化打分过程是一个重要的研究方向。那打分过程具体该怎么优化呢?今天,我们就来聊聊这个问题。
|
||||
|
||||
## 什么是非精准的Top K检索?
|
||||
|
||||
想要优化打分过程,一个很自然的思路就是通过简化打分机制,来降低打分开销。但是简化之后,我们的排序结果就不精准了。这该怎么办呢?这个问题先不着急解决,我们先来看看不精准的排序结果对用户会有什么影响。
|
||||
|
||||
其实,在搜索引擎中,排在第一页的结果并不一定是分数最高的。但由于用户在搜索时,本来就没有明确的目标网页,所以只要第一页的网页内容能满足用户的需求,那这就是高质量的检索结果了。
|
||||
|
||||
不仅如此,在推荐引擎中也是一样。推荐系统会根据用户的历史行为进行推荐,可推荐的物品非常多。比如说,如果用户曾经购买过《C++程序设计》这本书,那接下来我们既可以推荐《C++编程实战》,也可以推荐《C++编程宝典》。无论我们推荐哪一本,可能对用户来说差别都不大。
|
||||
|
||||
我们发现,其实在很多实际的应用场景中,**高质量的检索结果并不一定要非常精准,我们只需要保证质量足够高的结果,被包含在最终的Top K个结果中就够了**。这就是**非精准Top K检索的思路**。
|
||||
|
||||
实际上,在工业界中,我们会使用非精准Top K检索结合精准Top K检索的方案,来保证高效地检索出高质量的 结果。具体来说,就是把检索排序过程分为两个阶段:第一阶段,我们会进行非精准的Top K检索,将所有的检索结果进行简单的初步筛选,留下k1个结果,这样处理代价会小很多(这个阶段也被称为召回阶段);第二个阶段,就是使用精准Top K检索,也就是使用复杂的打分机制,来对这k1个结果进行打分和排序,最终选出k2个最精准的结果返回(这个阶段也被称为排序阶段)。
|
||||
|
||||
其实,这个流程你应该很熟悉。这就像我们在招聘时,会先根据简历筛选,再根据面试结果进行筛选。简历筛选的效率很高,但是不精准;面试比较耗时,但能更好地判断候选人的能力,这就属于精准挑选了。
|
||||
|
||||
再说回到工业界的检索方案,非精准Top K检索到底是怎么使用简单的机制,来“加速”检索过程的呢?加速的效果如何呢?我们一起来看看。
|
||||
|
||||
## 非精准Top K检索如何实现?
|
||||
|
||||
在非精准Top K检索中,一个降低打分计算复杂度的重要思路是:**尽可能地将计算放到离线环节,而不是在线环节**。这样,在线环节我们就只需要进行简单的计算,然后快速截断就可以了。一个极端的方案就是根据检索结果的静态质量得分进行打分和截断。具体该怎么做呢?我们一起来看。
|
||||
|
||||
### 1. 根据静态质量得分排序截断
|
||||
|
||||
所谓静态质量得分,指的是不考虑检索结果和实时检索词的相关性,打分计算仅和结果自身的质量有关。这样,所有的打分计算就都可以在离线环节完成了。也就是说,我们只需要根据离线算好的静态质量得分直接截断,就可以加速检索的过程了。这么说可能比较抽象,我们通过一个例子来解释一下。
|
||||
|
||||
以搜索引擎为例,我们可以不考虑搜索词和网页之间复杂的相关性计算,只根据网站自身的质量打分排序。比如说,使用Page Rank算法([Google的核心算法,通过分析网页链接的引用关系来判断网页的质量](http://ilpubs.stanford.edu:8090/422/1/1999-66.pdf))离线计算好每个网站的质量分,当一个搜索词要返回多个网站时,我们只需要根据网站质量分排序,将质量最好的Top K个网站返回即可。
|
||||
|
||||
不过,为了能快速返回Top K个结果,我们需要改造一下倒排索引中的posting list的组织方式。我们讲过,倒排索引的posting list都是按文档ID进行排序的。如果希望根据静态质量得分快速截断的话,那我们就应该将posting list按照静态质量得分,由高到低排序。对于分数相同的文档,再以文档ID二次排序。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/03/16/03d377079560c983d70c853d51f5cf16.jpeg" alt="" title="按静态质量得分排序">
|
||||
|
||||
这样一来,在检索的时候,如果只有一个关键词,那我们只需要查出该关键词对应的posting list,截取前k个结果即可。但是如果我们要同时查询两个关键词,截断的过程就会复杂一些。尽管比较复杂,我们可以总结为两步:第一步,我们取出这两个关键词的posting list,但不直接截断;第二步,我们对这两个posting list归并排序。留下分数和文档ID都相同的条目作为结果集合,当结果集合中的条目达到k个时,我们就直接结束归并。如果是查询多个关键词,步骤也一样。
|
||||
|
||||
那在这个过程中,我们为什么可以对这两个posting list进行归并排序呢?这是因为文档是严格按照静态质量得分排列的。如果文档1的分数大于文档2,那在这两个posting list中文档1都会排在文档2前面。而且,对于分数相同的文档,它们也会按照ID进行二次排序。所以,任意的两个文档在不同的posting list中,是会具有相同的排序次序的。也因此,我们可以使用归并的方式来处理这两个posting list。
|
||||
|
||||
总结来说,在使用静态质量得分选取非精准Top K个结果的过程中,因为没有实时的复杂运算,仅有简单的截断操作,所以它和复杂的精准检索打分相比,开销几乎可以忽略不计。因此,在对相关性要求不高的场景下,如果使用静态质量得分可以满足系统需求,这会是一个非常合适的方案。但如果应用场景对相关性的要求比较高,那我们还得采用其他考虑相关性的非精准检索方案。
|
||||
|
||||
### 2. 根据词频得分排序截断
|
||||
|
||||
既然说到了相关性,就必须要提到词频了。我们在上一讲说过,词频记录了一个关键词在文档中出现的次数,可以代表关键词在文档中的重要性。而且,词频的计算是在索引构建的时候,也就是在离线环节完成的,并且它还可以直接存储在posting list中。
|
||||
|
||||
这就给了我们一个启发,我们可以考虑使用词频来对posting list中的文档进行截断。具体该怎么做呢?我们可以像使用静态质量得分一样,直接使用词频的值降序排序posting list吗?你可以先自己想一想,然后和我一起分析。
|
||||
|
||||
假设,搜索词中只有一个关键词,那我们只需要查出该关键词对应的posting list,截取前k个结果就可以了。这时候,这个方法是可以正常工作的。
|
||||
|
||||
但是如果搜索词中有两个关键词A和B,就可能出现这么一种情况:文档1中出现了2次关键词A,1次关键词B;文档2中出现了1次关键词A,2次关键词B。那么,在关键词A的posting list中,文档1的分数是2,文档2的分数是1,文档1排在文档2前面。但是在关键词B的posting list中,文档2的分数是2,文档1的分数是1,文档2排在文档1前面。
|
||||
|
||||
这个时候,文档1和文档2在不同的posting list中的排序是不同的,因此,我们无法使用归并排序的方法将它们快速合并和截断。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/62/636562a949ae9db36f09583723990862.jpeg" alt="" title="以词频数值排序导致无法归并">
|
||||
|
||||
既然问题出在排序上,那我们能否既用上词频的分值,又保持ID有序呢?有这么一个解决思路,就是对posting list,我们先根据词频大小选出远多于k的前r个结果,然后将这r个结果按文档ID排序,这样就兼顾了相关性和快速归并截断的问题。这种根据某种权重将posting list中的元素进行排序,并提前截取r个最优结果的方案,就叫作**胜者表**。
|
||||
|
||||
胜者表的优点在于,它的排序方案更加灵活。比如说,我可以同时结合词频和静态质量得分进行排序(比如说权重 = 词频 + 静态质量得分),这样就同时考虑了相关性和结果质量两个维度。然后,我们对于每个posting list提前截断r个结果,再按文档ID排序即可。
|
||||
|
||||
但是有一点需要注意,胜者表的提前截断是有风险的,它可能会造成归并后的结果不满k个。比如说,文档1同时包含关键词A和B,但它既不在关键词A的前r个结果中,也不在关键词B的前r个结果中,那它就不会被选出来。在极端情况下,比如,关键词A的前r个结果都是仅包含A的文档,而关键词B的前r个结果都是仅包含B的文档,那关键词A和B的前r个结果的归并结果就是空的!这就会造成检索结果的丢失。
|
||||
|
||||
### 3. 使用分层索引
|
||||
|
||||
对于胜者表可能丢失检索结果的问题,我们有一种更通用的解决方案:**分层索引**。我们可以同时考虑相关性和结果质量,用离线计算的方式先给所有文档完成打分,然后将得分最高的m个文档作为高分文档,单独建立一个高质量索引,其他的文档则作为低质量索引。高质量索引和低质量索引的posting list都可以根据静态质量得分来排序,以方便检索的时候能快速截断。那具体是怎么检索的呢?我们一起来看看。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/27/f1/27389ea8c3865c59e625d5ba01e422f1.jpg" alt="" title="分层索引的Top K检索">
|
||||
|
||||
在实际检索的时候,我们会先去高质量索引中查询,如果高质量索引中可以返回的结果大于k个,我们直接截取Top K个结果返回即可;如果高质量索引中的检索结果不足k个,那我们再去低质量索引中查询,补全到k个结果,然后终止查询。通过这样的分层索引,我们就能快速地完成Top K的检索了。
|
||||
|
||||
相比于前面两种优化方案,分层索引是最通用的一种。而且,分层索引还可以看作是一种特殊的索引拆分,它可以和我们前面学过的索引拆分技术并存。比如说,对于高质量索引和低质量索引,我们还可以通过文档拆分的方式,将它们分为多个索引分片,使用分布式技术来进一步加速检索。
|
||||
|
||||
到这里,非精准Top K检索的三种实现方法我们都讲完了。总结来说,这些方法都是把非精准Top K检索应用在了离线环节,实际上,非精准Top K检索的思想还可以拓展应用到在线环节。也就是说,**我们还能在倒排检索结束后,精准打分排序前,插入一个“非精准打分”环节**,让我们能以较低的代价,快速过滤掉大部分的低质量结果,从而降低最终进行精准打分的性能开销。
|
||||
|
||||
除此之外,我还想补充一点。我们说的“非精准打分”和“精准打分”其实是相对的。这怎么理解呢?
|
||||
|
||||
举个例子,如果我们的“精准打分”环节采用的是传统的机器学习打分方式,如逻辑回归、梯度下降树等。那“非精准打分”环节就可以采用相对轻量级的打分方案,比如说采用TF-IDF方案,甚至是BM25方案等。而如果“精准打分”环节采用的是更复杂的深度学习的打分方式,比如使用了DNN模型,那么相对来说,“非精准打分”环节就可以采用逻辑回归这些方案了。
|
||||
|
||||
所以说,无论非精准打分的方案是什么,只要和精准打分相比,“能使用更小的代价,快速减少检索范围”,这就足够了。而这也是在前面多次出现过的检索加速的核心思想。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我们主要学习了利用非精准Top K检索为检索过程“加速”。
|
||||
|
||||
非精准Top K检索实现加速的方法主要有三种,分别是根据静态质量得分排序截断,以及使用胜者表,利用词频进行相关性判断进行截断,还有使用分层索引,对一次查询请求进行两层检索。
|
||||
|
||||
这三种方法的核心思路都是,尽可能地将计算从在线环节转移到离线环节,让我们在在线环节中,也就是在倒排检索的时候,只需要进行少量的判断,就能快速截断Top K个结果,从而大幅提升检索引擎的检索效率。
|
||||
|
||||
此外,我们还能将非精准Top K检索拓展到线上环节,通过引入“非精准打分”的环节,来进一步减少参与“精准打分”的检索结果数量。
|
||||
|
||||
最后,在工业界中,完整的Top K检索是由非精准Top K检索和精准Top K共同完成的。这种设计的核心思想,是希望用更小的代价快速减少检索排序范围,从而提升整体在线检索的效率。我把它的实现过程总结成了一张示意图,你可以参考它来梳理、巩固今天的内容。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/81/56/81620b228164d406870a6136731d2e56.jpg" alt="" title="完整Top K检索的过程示意图">
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
1. 在分层索引中,posting list中的文档为什么还要根据静态质量得分排序?排序应该是升序还是降序?
|
||||
1. 对于非精准Top K检索,你有没有相关的方法或者应用场景可以分享呢?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这一讲分享给你的朋友。
|
||||
112
极客时间专栏/检索技术核心20讲/进阶实战篇/13 | 空间检索(上):如何用Geohash实现“查找附近的人”功能?.md
Normal file
112
极客时间专栏/检索技术核心20讲/进阶实战篇/13 | 空间检索(上):如何用Geohash实现“查找附近的人”功能?.md
Normal file
@@ -0,0 +1,112 @@
|
||||
<audio id="audio" title="13 | 空间检索(上):如何用Geohash实现“查找附近的人”功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e1/42/e1c654ea810a99b91610a88f93615442.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
现在,越来越多的互联网应用在提供基于地理位置的服务。这些基于地理位置服务,本质上都是检索附近的人或者物的服务。比如说,社交软件可以浏览附近的人,餐饮平台可以查找附近的餐厅,还有出行平台会显示附近的车等。那如果你的老板希望你能为公司的应用开发相关的功能,比如说实现一个“查询附近的人”功能,你会怎么做呢?
|
||||
|
||||
一个很容易想到的方案是,把所有人的坐标取出来,计算每个人和自己当前坐标的距离。然后把它们全排序,并且根据距离远近在地图上列出来。但是仔细想想你就会发现,这种方案在大规模的系统中并不可行。
|
||||
|
||||
这是因为,如果系统中的人数到达了一定的量级,那计算和所有人的距离再排序,这会是一个非常巨大的代价。尽管,我们可以使用堆排序代替全排序来降低排序代价,但取出所有人的位置信息并计算距离,这本身就是一个很大的开销。
|
||||
|
||||
那在大规模系统中实现“查找附近的人功能”,我们有什么更高效的检索方案呢?今天我们就来聊聊这个问题。
|
||||
|
||||
## 使用非精准检索的思路实现“查找附近的人”
|
||||
|
||||
事实上,“查找附近的人”和“检索相关的网页”这两个功能的本质是非常相似的。在这两个功能的实现中,我们都没有明确的检索目标,也就都不需要非常精准的检索结果,只需要保证质量足够高的结果包含在Top K个结果中就够了。所以,非精准Top K检索也可以作为优化方案,来实现“查找附近的人”功能。那具体是如何实现的呢?
|
||||
|
||||
我们可以通过限定“附近”的范围来减少检索空间。一般来说,同一个城市的人往往会比不同城市的人距离更近。所以,我们不需要去查询所有的人,只需要去查询自己所在城市的人,然后计算出自己和他们的距离就可以了,这样就能大大缩小检索范围了。那在同一个城市中,我们也可以优先检索同一个区的用户,来再次缩小检索范围。这就是**非精准检索的思路了**。
|
||||
|
||||
在这种限定“附近”区域的检索方案中,为了进一步提高检索效率,我们可以将所有的检索空间划分为多个区域并做好编号,然后以区域编号为key做好索引。这样,当我们需要查询附近的人时,先快速查询到自己所属的区域,然后再将该区域中所有人的位置取出,计算和每一个人的距离就可以了。在这个过程中,划分检索空间以及对其编号是最关键的一步,那具体怎么操作呢?我们接着往下看。
|
||||
|
||||
## 如何对区域进行划分和编号?
|
||||
|
||||
对于一个完整的二维空间,我们可以用二分的思想将它均匀划分。也就是在水平方向上一分为二,在垂直方向上也一分为二。这样一个空间就会被均匀地划分为四个子空间,这四个子空间,我们可以用两个比特位来编号。在水平方向上,我们用0来表示左边的区域,用1来表示右边的区域;在垂直方向上,我们用0来表示下面的区域,用1来表示上面的区域。因此,这四个区域,从左下角开始按照顺时针的顺序,分别是00、01、11和10。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/64/7b5fe4f79b6b5515e10fd6ea3fc26064.jpeg" alt="" title="区域划分和编号">
|
||||
|
||||
接下来,如果要继续划分空间,我们依然沿用这个思路,将每个区域再分为四块。这样,整个空间就被划分成了16块区域,那对应的编号也会再增加两位。比如说,01编号的区域被划分成了4小块,那这四小块的编号就是在01后面追加两位编码,分别为 01 00、01 01、 01 10、 01 11。依次类推,我们可以将整个空间持续细分。具体划分到什么粒度,就取决于应用对于“附近”的定义和需求了。
|
||||
|
||||
这种区域编码的方式有2个优点:
|
||||
|
||||
1. 区域有层次关系:如果两个区域的前缀是相同的,说明它们属于同一个大区域;
|
||||
1. 区域编码带有分割意义:奇数位的编号代表了垂直切分,偶数位的编号代表了水平切分,这会方便区域编码的计算(奇偶位是从右边以第0位开始数起的)。
|
||||
|
||||
## 如何快速查询同个区域的人?
|
||||
|
||||
那有了这样的区域编码方式以后,我们该怎么查询呢?这就要说到区域编码的一个特点了:**区域编码能将二维空间的两个维度用一维编码表示**。利用这个特点,我们就可以使用一维空间中常见的检索技术快速查找了。我们可以将区域编码作为key,用有序数组存储,这样就可以用二分查找进行检索了。
|
||||
|
||||
如果有效区域动态增加,那我们还可以使用二叉检索树、跳表等检索技术来索引。在一些系统的实现中,比如Redis,它就可以直接支持类似的地理位置编码的存入和检索,内部的实现方式是,使用跳表按照区域编码进行排序和查找。此外,如果希望检索效率更高,我们还可以使用哈希表来实现区域的查询。
|
||||
|
||||
这样一来,当我们想要查询附近的人时,只需要根据自己的坐标,计算出自己所属区域的编码,然后在索引中查询出所有属于该区域的用户,计算这些用户和自己的距离,最后排序展现即可。
|
||||
|
||||
不过,这种非精准检索的方案,会带来一定的误差。也就是说,我们找到的所谓“附近的人”,其实只是和你同一区域的人而已,并不一定是离你最近的。比如说,你的位置正好处于一个区域的边缘,那离你最近的人,也可能是在你的邻接区域里。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/b8/f2039589483ba7a9d4c2c73568d55cb8.jpeg" alt="" title="邻接区域距离可能更近">
|
||||
|
||||
好在,在“查找附近的人”这类目的性不明确的应用中,这样的误差我们也是可以接受的。但是,在另一些有精准查询需求的应用中,是不允许存在这类误差的。比如说,在游戏场景中,角色技能的攻击范围必须是精准的,它要求技能覆盖范围内的所有敌人都应该受到伤害,不能有遗漏。那这是怎么做到的呢?你可以先想一想,然后再来看我的分析。
|
||||
|
||||
## 如何精准查询附近的人?
|
||||
|
||||
既然邻接区域的人距离我们更近,那我们是不是可以建立一个更大的候选集合,把这些邻接区域的用户都加进去,再一起计算距离和排序,这样问题是不是就解决了呢?我们先试着操作一下。
|
||||
|
||||
对于目标所在的当前区域,我们可以根据期望的查询半径,以当前区域为中心向周围扩散,从而将周围的区域都包含进来。假设,查询半径正好是一个区域边长的一半,那我们只要将目标区域周围一圈,也就是8个邻接区域中的用户都加入候选集,这就肯定不会有遗漏了。这时,虽然计算量提高了8倍,但我们可以给出精准的解了。
|
||||
|
||||
如果要降低计算量,我们可以将区域划分的粒度提高一个量级。这样,区域的划分就更精准,在查询半径不变的情况下,需要检索的用户的数量就会更少(查询范围对比见下图中两个红框部分)。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/dd/3d3559effa9a38c7e05f85b75d497add.jpeg" alt="" title="更细粒度地划分区域">
|
||||
|
||||
知道了要查询的区域有哪些,那我们怎么快速寻找这些区域的编码呢?这就要回到我们区域编码的方案本身了。前面我们说了,区域编码可以根据奇偶位拆成水平编码和垂直编码这两块,如果一个区域编码是0110,那它的水平编码就是01,垂直编码就是10。那该区域右边一个区域的水平编码的值就比它自己的大1,垂直编码则相同。因此,**我们通过分解出当前区域的水平编码和垂直编码,对对应的编码值进行加1或者减1的操作,就能得到不同方向上邻接的8个区域的编码了**。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/e7/d7/e7e2973d140c951ad1b150f9e0186cd7.jpeg" alt="" title="区域编码规则">
|
||||
|
||||
以上,就是精准查询附近人的检索过程,我们可以总结为两步:第一步,先查询出自己所属的区域编码,再计算出周围8个邻接区域的区域编码;第二步,在索引中查询9次,取出所有属于这些区域中的人,精准计算每一个人和自己的距离,最后排序输出结果。
|
||||
|
||||
## 什么是Geohash编码?
|
||||
|
||||
说到这,你可能会有疑问了,在实际工作中,用户对应的都是实际的地理位置坐标,那它和二维空间的区域编码又是怎么联系起来的呢?别着急,我们慢慢说。
|
||||
|
||||
实际上,我们会将地球看作是一个大的二维空间,那经纬度就是水平和垂直的两个切分方向。在给出一个用户的经纬度坐标之后,我们通过对地球的经纬度区间不断二分,就能得到这个用户所属的区域编码了。这么说可能比较抽象,我来举个例子。
|
||||
|
||||
我们知道,地球的纬度区间是[-90,90],经度是[-180,180]。如果给出的用户纬度(垂直方向)坐标是39.983429,经度(水平方向)坐标是116.490273,那我们求这个用户所属的区域编码的过程,就可以总结为3步:
|
||||
|
||||
<li>
|
||||
在纬度方向上,第一次二分,39.983429在[0,90]之间,[0,90]属于空间的上半边,因此我们得到编码1。然后在[0,90]这个空间上,第二次二分,39.983429在[0,45]之间,[0,45]属于区间的下半边,因此我们得到编码0。两次划分之后,我们得到的编码就是10。
|
||||
</li>
|
||||
<li>
|
||||
在经度方向上,第一次二分,116.490273在[0,180]之间,[0,180]属于空间的右半边,因此我们得到编码1。然后在[0,180]这个空间上,第二次二分,116.490273在[90,180]之间,[90,180]还是属于区间的右半边,因此我们得到的编码还是1。两次划分之后,我们得到的编码就是11。
|
||||
</li>
|
||||
<li>
|
||||
我们把纬度的编码和经度的编码交叉组合起来,先是经度,再是纬度。这样就构成了区域编码,区域编码为 1110。
|
||||
</li>
|
||||
|
||||
你会发现,在上面的例子中,我们只二分了两次。实际上,如果区域划分的粒度非常细,我们就要持续、多次二分。而每多二分一次,我们就需要增加一个比特位来表示编码。如果经度和纬度各二分15次的话,那我们就需要30个比特位来表示一个位置的编码。那上面例子中的编码就会是11100 11101 00100 01111 00110 11110。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/35/5eb820345e2ccce69ed84a96eeba7135.jpeg" alt="" title="计算编码的过程示意图">
|
||||
|
||||
这样得到的编码会特别长,那为了简化编码的表示,我们可以以5个比特位为一个单位,把长编码转为base32编码,最终得到的就是wx4g6y。这样30个比特位,我们只需要用6个字符就可以表示了。
|
||||
|
||||
这样做不仅存储会更简单,而且具有相同前缀的区域属于同一个大区域,看起来也非常直观。**这种将经纬度坐标转换为字符串的编码方式,就叫作Geohash编码**。大多数应用都会使用Geohash编码进行地理位置的表示,以及在很多系统中,比如,Redis、MySQL以及Elastic Search中,也都支持Geohash数据的存储和查询。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/ef/cee63fc368d7c7a765ce887f9b201fef.jpg" alt="" title="十进制转为base32编码字符对照表">
|
||||
|
||||
那在实际转换的过程中,由于不同长度的Geohash代表不同大小的覆盖区域,因此我们可以结合GeoHash字符长度和覆盖区域对照表,根据自己的应用需要选择合适的Geohash编码长度。这个对照表让我们在使用Geohash编码的时候方便很多。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/92/3de8e51e2746d77eeeeb9bbfefc2a492.jpeg" alt="" title="字符长度和覆盖区域对照表">
|
||||
|
||||
不过,Geohash编码也有缺点。由于Geohash编码的一个字符就代表了5个比特位,因此每当字符长度变化一个单位,区域的覆盖度变化跨度就是32倍(2^5),这会导致区域范围划分不够精细。
|
||||
|
||||
因此,当发现粒度划分不符合自己应用的需求时,我们其实可以将Geohash编码转换回二进制编码的表示方式。这样,编码长度变化的单位就是1个比特位了,区域覆盖度变化跨度就是2倍,我们就可以更灵活地调整自己期望的区域覆盖度了。实际上,在许多系统的底层实现中,虽然都支持以字符串形式输入Geohash编码,但是在内存中的存储和计算都是以二进制的方式来进行的。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我们重点学习了利用空间检索的技术来查找附近的人。
|
||||
|
||||
首先,我们通过将二维空间在水平和垂直方向上不停二分,可以生成一维的区域编码,然后我们可以使用一维空间的检索技术对区域编码做好索引。
|
||||
|
||||
在查询时,我们可以使用非精准的检索思路,直接检索相应的区域编码,就可以查找到“附近的人”了。但如果要进行精准检索,我们就需要根据检索半径将扩大检索范围,一并检索周边的区域,然后将所有的检索结果进行精确的距离计算,最终给出整体排序。这也是一个典型的“非精准Top K检索-精准Top K检索”的应用案例。因此,当你需要基于地理位置,进行查找或推荐服务的开发时,可以根据具体需求,灵活使用今天学习到的检索方案。
|
||||
|
||||
此外,我们还学习了Geohash编码,Geohash编码是很常见的一种编码方式,它将真实世界的地理位置根据经纬度进行区域编码,再使用base32编码生成一维的字符串编码,使得区域编码在显示和存储上都更加方便。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
<li>
|
||||
如果一个应用期望支持“查找附近的人”的功能。在初期用户量不大的时候,我们使用什么索引技术比较合理?在后期用户量大的时候,为了加快检索效率,我们又可以采用什么检索技术?为什么?
|
||||
</li>
|
||||
<li>
|
||||
如果之前的应用选择了5个字符串的Geohash编码,进行区域划分(区域范围为4.9 km * 4.9 km),那当我们想查询10公里内的人,这个时候该如何进行查询呢?使用什么索引技术会比较合适呢?
|
||||
</li>
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这一讲分享给你的朋友。
|
||||
@@ -0,0 +1,94 @@
|
||||
<audio id="audio" title="14 | 空间检索(下):“查找最近的加油站”和“查找附近的人”有何不同?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/37/4a/37f97921d1919beffba437c4387bb24a.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
上一讲我们讲了,对于查询范围固定的应用需求,比如“查找附近的人”,我们可以根据规划好的查询区域大小,均匀划分所有的空间,然后用GeoHash将坐标转换为区域编码,以该区域编码作为Key开始检索。这样,我们就可以查到并取出该区域中的目标数据,对这些数据进行精准计算然后排序输出了。
|
||||
|
||||
但是,并不是所有应用的查询范围都是不变的。**在一些基于地理位置的服务中,我们并不关心检索结果是否就在我们“附近”,而是必须要找到“最近”的一批满足我们要求的结果**。这怎么理解呢?
|
||||
|
||||
我来举个例子,我们在长途自驾游的时候,突然发现车快没油了。这个时候,我们要在一个导航地图中查找最近的k个加油站给车加油,这些加油站可能并不在我们附近,但地图又必须要返回最近的k个结果。类似的情况还有很多,比如说,我们要查询最近的医院有哪些,查询最近的超市有哪些。那对于这一类的查询,如果当前范围内查不到,系统就需要自动调整查询范围,直到能返回k个结果为止。
|
||||
|
||||
对于这种需要动态调整范围的查询场景,我们有什么高效的检索方案呢?今天,我们就来探讨一下这个问题。
|
||||
|
||||
## 直接进行多次查询会有什么问题?
|
||||
|
||||
我们就以查找最近的加油站为例,一个直观的想法是,我们可以先获得当前位置的GeoHash编码,然后根据需求不停扩大查询范围进行多次查询,最后合并查询结果。这么说比较抽象,我们来分析一个具体的位置编码。
|
||||
|
||||
假设我们当前地址的GeoHash编码为wx4g6yc8,那我们可以先用wx4g6yc8去查找当前区域的加油站。如果查询的结果为空,我们就扩大范围。扩大查询范围的思路有两种。
|
||||
|
||||
第一种思路是,一圈一圈扩大范围。具体来说就是,我们第一次查询周边8个邻接区域,如果查询结果依然为空,就再扩大一圈,查询再外圈的16个区域。如果还是不够,下一次我们就查询再外圈的24个区域,依此类推。你会发现,这种方案的查询次数会成倍地增加,它的效率并不高。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/ea/b8c83e0e14cde461eec4b0b49f0cbfea.jpg" alt="" title="逐步扩大查询周边区域">
|
||||
|
||||
另一种思路是,我们每次都将查询单位大幅提高。比如说,直接将GeoHash编码去掉最后一位,用wx4g6yc再次去查询。如果有结果返回,但是不满足要返回Top K个的要求,那我们就继续扩大范围,再去掉一个编码,用wx4g6y去查询。就这样不停扩大单位的进行反复查询,直到结果大于k个为止。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/fc/a1b1510445a0467d3a995620a80523fc.jpg" alt="" title="逐步扩大查询单位(以二进制区域编码为例,每次扩大4倍)">
|
||||
|
||||
和第一种查询思路相比,在第二种思路中,我们每次查询的区域单位都得到了大范围的提升,因此,查询次数不会太多。比如说,对于一个长度为8的GeoHash编码,我们最多只需要查询8次(如果要求精准检索,那每次查询就扩展到周围8个同样大小的邻接区域即可,后面我就不再解释了)。
|
||||
|
||||
这个检索方案虽然用很少的次数就能“查询最近的k个结果”,但我们还需要保证,每次的查询请求都能快速返回结果。这就要求我们采用合适的索引技术,来处理GeoHash的每个层级。
|
||||
|
||||
比如说,如果使用基于哈希表的倒排检索来实现,我们就需要在GeoHash每个粒度层级上都分别建立一个单独的倒排表。这就意味着,每个层级的倒排表中都会出现全部的加油站,数据会被复制多次,这会带来非常大的存储开销。那我们是否有优化存储的方案呢?
|
||||
|
||||
我们可以利用GeoHash编码一维可排序的特点,使用数组或二叉检索树来存储和检索。由于数组和二叉检索树都可以支持范围查询,因此我们只需要建立一份粒度最细的索引就可以了。这样,当我们要检索更大范围的区域时,可以直接将原来的查询改写为范围查询。具体怎么做呢?
|
||||
|
||||
我来举个例子。在检索完wx4g6yc8这个区域编码以后,如果结果数量不够,还要检索wx4g6yc这个更大范围的区域编码,我们只要将查询改写为“查找区域编码在wx4g6yc0至wx4g6ycz之间的元素”,就可以利用同一个索引,来完成更高一个层级的区域查询了。同理,如果结果数量依然不够,那下一步我们就查询“区域编码在wx4g6y00至wx4g6yzz之间的元素”,依此类推。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/c6/e5c2a638c5a081469913e52aa98fe4c6.jpg" alt="" title="利用有序数组查询示例">
|
||||
|
||||
但是,这种方案有一个缺点,那就是在每次调整范围查询时,我们都要从头开始进行二分查找,不能充分利用上一次已经查询到的位置信息,这会带来无谓的重复检索的开销。那该如何优化呢?你可以先想一想,然后我们一起来看解决方案。
|
||||
|
||||
## 如何利用四叉树动态调整查询范围?
|
||||
|
||||
上一讲我们讲过,许多系统对于GeoHash的底层实现,其实都是使用二进制进行存储和计算的。而二进制区域编码的生成过程,就是一个逐渐二分空间的过程,经过二分后的区域之间是有层次关系的。如果我们把这个过程画下来,它就很像我们之前讲过的树形结构。
|
||||
|
||||
因此,我们可以尝试用树形结构来进行索引。这里,我们就要引入一个新的数据结构**四叉树**了。四叉树的树根节点代表了整个空间,每个节点的四个分叉分别表示四个子空间。其中,树根和中间节点不存储数据,只记录分叉指针。而数据只记录在最小的区域,也就是叶子节点上。
|
||||
|
||||
如果我们从根节点开始,不停地四分下去,直到每个分支的叶子节点都是最小粒度区域。那这样构建出来的四叉树,每个节点都有四个子节点,就叫作**满四叉树**。
|
||||
|
||||
对于满四叉树的每个节点,我们都可以编号。换句话说,我们可以按00、01、10、11的编号,来区分满四叉树的四个子节点。这样一来,只要我们从根节点遍历到叶子节点,然后将路径上每个节点的编号连起来,那最后得到的编码就是这个叶子节点所代表的区域编码。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/85/f5/85674c6f1d812695e6512ea55cbe4ff5.jpg" alt="" title="满四叉树">
|
||||
|
||||
好了,现在我们知道了四叉树的结构和特点了,那我们怎么利用它完成自动调整范围的Top K检索呢?下面,我们通过一个例子来看看。
|
||||
|
||||
假设一个人所属的最小区域编码是0110,那我们在检索的时候,就以0110为Key,沿着四叉树的对应分支去寻找相应的区域,查询路径为01-10。如果查找到了叶子节点,并且返回的结果大于k个,就可以直接结束检索。如果返回结果不足k个,我们就得递归返回到上一层的父节点,然后以这整个父节点的区域编码为目标进行检索。这样,我们就避免了要再次从树根检索到父节点的开销,从而提升了检索效率。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/96/96/9661a343a32946b6bd6d96fd4736f196.jpg" alt="" title="自动调整范围的Top K检索">
|
||||
|
||||
## 如何利用非满四叉树优化存储空间?
|
||||
|
||||
尽管,我们使用以最小区域单位为叶子节点的满四叉树,能够很好的提升检索效率,但是在数据稀疏的时候,许多叶子节点中的数据可能是空的,这就很有可能造成大量的空间浪费。为了避免出现空间浪费,我们有一种改进方案是,使用动态节点分裂的**非满四叉树**。
|
||||
|
||||
首先,我们可以给每个叶子节点规定一个容纳上限。比如说,我们可以将上限设置为n。那么,一开始的四叉树只有一个根节点,这个根节点同时也是叶子节点,它表明了当前的全部空间范围。当有数据加入的时候,我们直接记录在这个节点中,查询时也只查询这个节点即可。因此,当插入的数据个数小于n时,我们不需要进行任何复杂的查找操作,只需要将根节点的所有数据读出,然后进行距离计算并排序即可。
|
||||
|
||||
随着加入的数据越来越多,如果一个叶子节点的容量超出了容纳上限,我们就将该节点进行分裂。首先,我们将该节点转为中间节点,然后,我们会为这个节点生成1至4个叶子节点(注意:不是一定要生成4个叶子节点),并将原来存在这个节点上的数据都转入到对应的叶子节点中。这样,我们就完成了分裂。
|
||||
|
||||
不过,有一种极端的情况是,这些数据都会转入到同一个下层叶子节点上。这时,我们就需要继续分裂这个叶子节点,直到每个叶子节点的容量在阈值下为止。
|
||||
|
||||
通过这种动态生成叶节点的方案,我们就能得到一棵非满四叉树。和满四叉树相比,它的叶子节点会更少,而且每个叶子节点表示的区域范围也可能是不一样的。这使得非满四叉树具有更好的空间利用率。非满四叉树的查询过程和满四叉树十分相似,也是根据当前的区域编码,找到对应的叶子节点,并根据该叶子节点上存储的数据数量,判断是否要递归扩大范围。这里我就不再详细说了。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/ee/c7/ee48d9c5df4625321c8a06db4dde7cc7.jpg" alt="" title="非满四叉树-动态分裂叶节点">
|
||||
|
||||
## 如何用前缀树优化GeoHash编码的索引?
|
||||
|
||||
上面,我们都是用二进制编码来说明的。你可能会问,如果我们使用了GeoHash编码方式,是否也可以用类似的检索技术来索引呢?当然是可以的。实际上,对于字符串的检索,**有一种专门的数据结构,叫作前缀树(Trie树)。**
|
||||
|
||||
前缀树的思路和四叉树非常相似,它也是一种逐层划分检索空间的数据结构。它的根节点代表了整个检索空间,然后每个中间节点和叶子节点都只存储一个字符,代表一个分支。这样,从根节点到叶子节点的路径连起来,就是一个完整的字符串。因此,当使用GeoHash编码来表示区域时,我们可以建立一个前缀树来进行索引,前缀树的每个节点最多会有32个子节点。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/a4/43/a466fc2217c89d537a587547a0589143.jpeg" alt="" title="前缀树">
|
||||
|
||||
那如何利用前缀树来检索呢?举个例子,当我们查询wx4g6yc8这个区域时,我们会沿着w-x-4-g-6-y-c-8的路径,检索到对应的叶子节点,然后取出这个叶子节点上存储的数据。如果这个区域的数据不足k个,就返回到父节点上,检索对应的区域,直到返回结果达到k个为止。由于整体思路和四叉树是十分相似的,这里就不展开细说了。
|
||||
|
||||
此外,前缀树除了用在GeoHash编码的检索上,也经常用于字典的检索,因此也叫字典树。字典树适用于匹配字符串的检索场合。
|
||||
|
||||
总结来说,利用树形结构来划分空间提高检索效率的方案,它的应用非常广泛。对于更高维度空间的最近邻检索,我们也可以使用类似的检索方案来划分空间。比如说,在三维空间中,八叉树就是常见的检索方案。那拓展到更高的维度,如k维,我们还可以使用**k-d树**(K-Dimensional Tree)来检索。
|
||||
|
||||
k-d树一种是更通用的,对任意维度都可以使用的检索方案。k-d树和四叉树、八叉树的检索思路并不相同,它在划分子空间的时候,并不是直接将整个空间划分为2^k个子空间,而是会选出最有区分度的一个维度,将该维度的空间进行二分,然后对划分出的子空间再进行同样的二分处理,所以,它实际上是一个二叉树。而且,由于它的分支数和维度k的具体值无关,因此具有更好的通用性。
|
||||
|
||||
事实上,k-d树在维度规模不大的场景下,确实具有不错的检索效率。但是,在成百上千的超高维度的场景中,k-d树的性能会急剧下降。那在高维空间中,我们又该如何快速地查找到最近的k个对象呢?这个问题,也是搜索引擎和推荐引擎在很多应用场景中都要解决问题。在后面两讲中,我们会对它作详细讲解。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我们重点学习了,在二维空间中利用四叉树,来快速寻找最近的k个元素的方法。
|
||||
|
||||
在需要动态调整查询范围的场景下,对于二进制编码的二维空间的最近邻检索问题,我们可以通过四叉树来完成。四叉树可以很好地快速划分查询空间,并通过递归的方式高效地扩大查询范围。但是满四叉树经常会造成无谓的空间浪费,为了避免这个问题,在实际应用的时候,我们会选择使用非满四叉树来存储和索引编码。对于GeoHash编码的二维空间最近邻检索问题,我们也能通过类似的前缀树来提高检索效率。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
在非满四叉树的分裂过程中,为什么一个节点不一定会生成4个叶子节点?你能举一个例子吗?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这一讲分享给你的朋友。
|
||||
111
极客时间专栏/检索技术核心20讲/进阶实战篇/15 | 最近邻检索(上):如何用局部敏感哈希快速过滤相似文章?.md
Normal file
111
极客时间专栏/检索技术核心20讲/进阶实战篇/15 | 最近邻检索(上):如何用局部敏感哈希快速过滤相似文章?.md
Normal file
@@ -0,0 +1,111 @@
|
||||
<audio id="audio" title="15 | 最近邻检索(上):如何用局部敏感哈希快速过滤相似文章?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/5d/6dae9bacd07e78bf362404c637c4f95d.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
在搜索引擎和推荐引擎中,往往有很多文章的内容是非常相似的,它们可能只有一些修饰词不同。如果在搜索结果或者推荐结果中,我们将这些文章不加过滤就全部展现出来,那用户可能在第一页看到的都是几乎相同的内容。这样的话,用户的使用体验就会非常糟糕。因此,在搜索引擎和推荐引擎中,对相似文章去重是一个非常重要的环节。
|
||||
|
||||
对相似文章去重,本质上就是把相似的文章都检索出来。今天,我们就来聊聊如何快速检索相似的文章。
|
||||
|
||||
## 如何在向量空间中进行近邻检索?
|
||||
|
||||
既然是要讨论相似文章的检索,那我们就得知道,一篇文章是怎么用计算机能理解的形式表示出来的,以及怎么计算两篇文章的相似性。最常见的方式就是使用**向量空间模型**(Vector Space Model)。所谓向量空间模型,就是将所有文档中出现过的所有关键词都提取出来。如果一共有n个关键词,那每个关键词就是一个维度,这就组成了一个n维的向量空间。
|
||||
|
||||
那一篇文档具体该如何表示呢?我们可以假设,一篇文章中有k(0<k<=n)个关键词,如果第k个关键词在这个文档中的权重是w,那这个文档在第k维上的值就是w。一般来说,我们会以一个关键词在这篇文档中的TF-IDF值作为w的值。而如果文章不包含第k个关键词,那它在第k维上的值就是0,那我们也可以认为这个维度的权重就是0。这样,我们就可以用一个n维的向量来表示一个文档了,也就是<w<sub>1</sub>,w<sub>2</sub>,w<sub>3</sub>,……w<sub>n</sub>>。这样一来,每一个文档就都是n维向量空间中的一个点。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/78/f486531c01fd62d0cfbc529f58fd1878.jpg" alt="" title="一个文档的向量化表示">
|
||||
|
||||
那接下来,计算两个文章相似度就变成了计算两个向量的相似度。计算向量相似度实际上就是计算两个向量的距离,距离越小,它们就越相似。具体在计算的时候,我们可以使用很多种距离度量方式。比如说,我们可以采用余弦距离,或者采用欧氏距离等。一般来说,我们会采用余弦距离来计算向量相似度。
|
||||
|
||||
拓展到搜索引擎和推荐引擎中,因为每个文档都是n维向量中的一个点,所以查询相似文章的问题,就变成了在n维空间中,查询离一个点距离最近的k个点的问题。如果把这些“点”想象成“人”,这不就和我们在二维空间中查询附近的人的问题非常类似了吗?这就给了我们一个启发,我们是不是也能用类似的检索技术来解决它呢?下面,我们一起来看一下。
|
||||
|
||||
首先,在十几维量级的低维空间中,我们可以使用k-d树进行k维空间的近邻检索,它的性能还是不错的。但随着维度的增加, 如果我们还要精准找到最邻近的k个点,k-d需要不停递归来探索邻接区域,检索效率就会急剧下降,甚至接近于遍历代价。当关键词是几万乃至百万级别时,文档的向量空间可能是一个上万维甚至是百万维的超高维空间,使用k-d树就更难以完成检索工作了。因此,我们需要寻找更简单、高效的方案。
|
||||
|
||||
这个时候,使用非精准Top K检索代替精准Top K检索的方案就又可以派上用场了。这是为什么呢?因为高维空间本身就很抽象,在用向量空间中的一个点表示一个对象的过程中,如果我们选择了不同的权重计算方式,那得到的向量就会不同,所以这种表示方法本身就已经损失了一定的精确性。
|
||||
|
||||
因此,对于高维空间的近邻检索问题,我们可以使用**近似最近邻检索**(Approximate Nearest Neighbor)来实现。你可以先想一想查询附近的人是怎么实现的,然后再和我一起来看高维空间的近似最近邻检索是怎么做的。
|
||||
|
||||
## 什么是局部敏感哈希?
|
||||
|
||||
借助非精准检索的思路,我们可以将高维空间的点也进行区域划分,然后为每个区域都生成一个简单的一维编码。这样,当我们要查找一个点最邻近的k个点的时候,直接计算出区域编码就能高效检索出同一个区域的所有对象了。
|
||||
|
||||
也因此,我们就能得出一个结论,那就是同一个区域中的不同的点,通过统一的计算过程,都能得到相同的区域编码。这种将复杂对象映射成简单编码的过程,是不是很像哈希的思路?
|
||||
|
||||
所以,我们可以利用哈希的思路,将高维空间中的点映射成低维空间中的一维编码。换句话说,我们通过计算不同文章的哈希值,就能得到一维哈希编码。如果两篇文章内容100%相同,那它们的哈希值就是相同的,也就相当于编码相同。
|
||||
|
||||
不过,如果我们用的是普通的哈希函数,只要文档中的关键词有一些轻微的变化(如改变了一个字),哈希值就会有很大的差异。但我们又希望,整体相似度高的两篇文档,通过哈希计算以后得到的值也是相近的。因此,工业界设计了一种哈希函数,它可以让相似的数据通过哈希计算后,生成的哈希值是相近的(甚至是相等的)。这种哈希函数就叫作**局部敏感哈希**(Locality-Sensitive Hashing)。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/f3/ca5e8c281594b8813d1700c8e04badf3.jpg" alt="" title="普通哈希 VS 局部敏感哈希">
|
||||
|
||||
其实局部敏感哈希并不神秘。让我们以熟悉的二维空间为例来进一步解释一下。
|
||||
|
||||
在二维空间中,我们随意划一条直线就能将它一分为二,我们把直线上方的点的哈希值定为1,把直线下方的点的哈希值定为0。这样就完成一个简单的哈希映射。通过这样的随机划分,两个很接近的点被同时划入同一边的概率,就会远大于其他节点。也就是说,这两个节点的哈希值相同的概率会远大于其他节点。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/78/d9b56935c705f1ee82dfa6402ccd3a78.jpg" alt="" title="二维空间的随机划分">
|
||||
|
||||
当然,这样的划分有很大的随机性,不一定可靠。但是,如果我们连续做了n次这样的随机划分,这两个点每次都在同一边,那我们就可以认为它们在很大概率上是相近的。因此,我们只要在n次随机划分的过程中,记录下每一个点在每次划分后的值是0还是1,就能得到一个n位的包含0和1的序列了。这个序列就是我们得到的哈希值,也就是区域编码。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/9d/d63a7eefc4de1702b4008af74f3f519d.jpeg" alt="" title="将二维空间划分n次,生成n位的比特位哈希值作为区域编码">
|
||||
|
||||
因此,对于高维空间,我们构造局部敏感哈希函数的方案是,随机地生成n个超平面,每个超平面都将高维空间划分为两部分。位于超平面上面的点的哈希值为1,位于超平面下方的点的哈希值为0。由于有n个超平面,因此一个点会被判断n次,生成一个n位的包含0和1的序列,它就是这个点的哈希值。这就是一个基于超平面划分的局部敏感哈希构造方法。(为了方便你直观理解,我简单说成了判断一个点位于超平面的上面还是下面。在更严谨的数学表示中,其实是求一个点的向量和超平面上法向量的余弦值,通过余弦值的正负判断是1还是0。这里,你理解原理就可以了,严谨的数学分析我就不展开了。)
|
||||
|
||||
如果有两个点的哈希值是完全一样的,就说明它们被n个超平面都划分到了同一边,它们有很大的概率是相近的。即使哈希值不完全一样,只要它们在n个比特位中有大部分是相同的,也能说明它们有很高的相近概率。
|
||||
|
||||
上面我们说的判断标准都比较笼统,实际上,在利用局部敏感哈希值来判断文章相似性的时候,我们会以表示比特位差异数的**海明距离**(Hamming Distance)为标准。我们可以认为如果两个对象的哈希值的海明距离低于k,它们就是相近的。举个例子,如果有两个哈希值,比特位分别为00000和10000。你可以看到,它们只有第一个比特位不一样,那它们的海明距离就是1。如果我们认为海明距离在2之内的哈希值都是相似的,那它们就是相似的。
|
||||
|
||||
## SimHash是怎么构造的?
|
||||
|
||||
不过,这种构造局部敏感哈希函数的方式也有一些缺陷:在原来的空间中,不同维度本来是有着不同权重的,权重代表了不同关键词的重要性,是一个很重要的信息。但是空间被n个超平面随机划分以后,权重信息在某种程度上就被丢弃了。
|
||||
|
||||
那为了保留维度上的权重,并且简化整个函数的生成过程,Google提出了一种简单有效的局部敏感哈希函数,叫作**SimHash**。它其实是使用一个普通哈希函数代替了n次随机超平面划分,并且这个普通哈希函数的作用对象也不是文档,而是文档中的每一个关键词。这样一来,我们就能在计算的时候保留下关键词的权重了。这么说有些抽象,让我们一起来看看SimHash的实现细节。
|
||||
|
||||
方便起见,我们就以Google官方介绍的64位的SimHash为例,来说一说它构造过程。整个过程,我们可以总结为5步。
|
||||
|
||||
1. 选择一个能将关键词映射到64位正整数的普通哈希函数。
|
||||
1. 使用该哈希函数给文档中的每个关键词生成一个64位的哈希值,并将该哈希值中的0修改为-1。比如说,关键词A的哈希值编码为<1,0,1,1,0>,那我们做完转换以后,编码就变成了<1,-1,1,1,-1>。
|
||||
1. 将关键词的编码乘上关键词自己的权重。如果关键词编码为<1,-1,1,1,-1>,关键词的权重为2,最后我们得到的关键词编码就变成了<2,-2,2,2,-2>。
|
||||
1. 将所有关键词的编码按位相加,合成一个编码。如果两个关键词的编码分别为<2,-2,2,2,-2>和<3,3,-3,3,3>,那它们相加以后就会得到<5,1,-1,5,1>。
|
||||
<li>将最终得到的编码中大于0的值变为1,小于等于0的变为0。这样,编码<5,1,-1,5, 1>就会被转换为<1,1,0,1,1>。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/05/3b224142a044a6fdebeb128f2df7a605.jpg" alt="" title="SimHash生成过程"></li>
|
||||
|
||||
通过这样巧妙的构造,SimHash将每个关键词的权重保留并且叠加,一直留到最后,从而使得高权重的关键词的影响能被保留。从上图中你可以看到,整个文档的SimHash值和权重最大的关键词word 2的哈希值是一样的。这就体现了高权重的关键词对文档的最终哈希值的影响。此外,SimHash通过一个简单的普通哈希函数就能生成64位哈希值,这替代了随机划分64个超平面的复杂工作,也让整个函数的实现更简单。
|
||||
|
||||
## 如何对局部敏感哈希值进行相似检索?
|
||||
|
||||
和其他局部敏感哈希函数一样,如果两个文档的SimHash值的海明距离小于k,我们就认为它们是相似的。举个例子,在Google的实现中,k的取值为3。这个时候,检索相似文章的问题变成了要找出海明距离在3之内的所有文档。如果是一个个文档比对的话,这就是一个遍历过程,效率很低。有没有更高效的检索方案呢?
|
||||
|
||||
一个直观的想法是,我们可以针对每一个比特位做索引。由于每个比特位只有0和1这2个值,一共有64个比特位,也就一共有2*64共128个不同的Key。因此我们可以使用倒排索引,将所有的文档根据自己每个比特位的值,加入到对应的倒排索引的posting list中。这样,当要查询和一个文档相似的其他文档的时候,我们只需要通过3步就可以实现了,具体的步骤如下:
|
||||
|
||||
1. 计算出待查询文档的SimHash值;
|
||||
1. 以该SimHash值中每个比特位的值作为Key,去倒排索引中查询,将相同位置具有相同值的文档都召回;
|
||||
1. 合并这些文档,并一一判断它们和要查询的文档之间的海明距离是否在3之内,留下满足条件的。
|
||||
|
||||
我们发现,在这个过程中,只要有一个比特位的值相同,文档就会被召回。也就是说,这个方案和遍历所有文档相比,其实只能排除掉“比特位完全不同的文档”。因此,这种方法的检索效率并不高。
|
||||
|
||||
这又该怎么优化呢?Google利用**抽屉原理**设计了一个更高效的检索方法。什么是抽屉原理呢?简单来说,如果我们有3个苹果要放入4个抽屉,就至少有一个抽屉会是空的。那应用到检索上,Google会将哈希值平均切为4段,如果两个哈希值的比特位差异不超过3个,那这三个差异的比特位最多出现在3个段中,也就是说至少有一个段的比特位是完全相同的!因此,我们可以将前面的查询优化为“有一段比特位完全相同的文档会被召回”。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/5f/e566d9735f25c51fd50dbbd089c7035f.jpg" alt="" title="如果海明距离小于3,那么4段中至少有一段完全相同">
|
||||
|
||||
根据这个思路,我们可以将每一个文档都根据比特位划分为4段,以每一段的16个比特位的值作为Key,建立4个倒排索引。检索的时候,我们会把要查询文档的SimHash值也分为4段,然后分别去对应的倒排索引中,查询和自己这一段比特位完全相同的文档。最后,将返回的四个posting list合并,并一一判断它们的的海明距离是否在3之内。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/37/d0/375295f5ac305500205c06322a7c8ed0.jpeg" alt="" title="分段查询">
|
||||
|
||||
通过使用SimHash函数和分段检索(抽屉原理),使得Google能在百亿级别的网页中快速完成过滤相似网页的功能,从而保证了搜索结果的质量。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我们重点学习了使用局部敏感哈希的方法过滤相似文章。
|
||||
|
||||
我们可以使用向量空间模型将文章表示为高维空间中的点,从而将相似文章过滤问题转为高维空间的最近邻检索问题。对于高维空间的最近邻检索问题,我们可以使用非精准的检索思路,使用局部敏感哈希为高维空间的点生成低维的哈希值。
|
||||
|
||||
局部敏感哈希有许多构造方法,我们主要讲了随机超平面划分和SimHash两种方法。相比于随机超平面划分,SimHash能保留每一个关键词的权重,并且它的函数实现也更简单。
|
||||
|
||||
那对于局部敏感哈希的相似检索,我们可以使用海明距离定义相似度,用抽屉原理进行分段划分,从而可以建立对应的倒排索引,完成高效检索。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/32/cd/32c0cc283e8aee0fe7173587ca469ccd.jpg" alt="" title="知识总结">
|
||||
|
||||
实际上,不仅过滤相似文章可以使用局部敏感哈希,在拍照识图和摇一摇搜歌等应用场景中,我们都可以使用它来快速检索。以图像检索为例,我们可以对图像进行特征分析,用向量来表示一张图片,这样一张图片就是高维空间中的一个点了,图像检索就也抽象成了高维空间中的近邻检索问题,也就可以使用局部敏感哈希来完成了。
|
||||
|
||||
当然基于局部敏感哈希的检索也有它的局限性。以相似文章检索为例,局部敏感哈希更擅长处理字面上的相似而不是语义上的相似。比如,一篇文章介绍的是随机超平面划分,另一篇文章介绍的是SimHash,两篇文章可能在字面上差距很大,但内容领域其实是相似的。好的推荐系统在用户看完随机超平面划分的文章后,还可以推荐SimHash这篇文章,但局部敏感哈希在这种语义相似的推荐系统中就不适用了。
|
||||
|
||||
因此,对于更灵活的相似检索问题,工业界还有许多的解决方法,我们后面再详细介绍。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
1.对于SimHash,如果将海明距离在4之内的文章都定义为相似的,那我们应该将哈希值分为几段进行索引和查询呢?
|
||||
|
||||
2.SimHash的算法能否应用到文章以外的其他对象?你能举个例子吗?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这一讲分享给你的朋友。
|
||||
137
极客时间专栏/检索技术核心20讲/进阶实战篇/16 | 最近邻检索(下):如何用乘积量化实现“拍照识花”功能?.md
Normal file
137
极客时间专栏/检索技术核心20讲/进阶实战篇/16 | 最近邻检索(下):如何用乘积量化实现“拍照识花”功能?.md
Normal file
@@ -0,0 +1,137 @@
|
||||
<audio id="audio" title="16 | 最近邻检索(下):如何用乘积量化实现“拍照识花”功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b6/c8/b6a1e936932dadaadfd7311a0fdfffc8.mp3"></audio>
|
||||
|
||||
你好,我是陈东。
|
||||
|
||||
随着AI技术的快速发展,以图搜图、拍图识物已经是许多平台上的常见功能了。比如说,在搜索引擎中,我们可以直接上传图片进行反向搜索。在购物平台中,我们可以直接拍照进行商品搜索。包括在一些其他的应用中,我们还能拍照识别植物品种等等。这些功能都依赖于高效的图片检索技术,那它究竟是怎么实现的呢?今天,我们就来聊一聊这个问题。
|
||||
|
||||
## 聚类算法和局部敏感哈希的区别?
|
||||
|
||||
检索图片和检索文章一样,我们首先需要用向量空间模型将图片表示出来,也就是将一个图片对象转化为高维空间中的一个点。这样图片检索问题就又变成了我们熟悉的高维空间的相似检索问题。
|
||||
|
||||
如果我们把每个图片中的像素点看作一个维度,把像素点的RGB值作为该维度上的值,那一张图片的维度会是百万级别的。这么高的维度,检索起来会非常复杂,我们该怎么处理呢?我们可以像提取文章关键词一样,对图片进行特征提取来压缩维度。
|
||||
|
||||
要想实现图片特征提取,我们有很多种深度学习的方法可以选择。比如,使用卷积神经网络(CNN)提取图片特征。这样,用一个512到1024维度的向量空间模型,我们就可以很好地描述图像了,但这依然是一个非常高的维度空间。因此,我们仍然需要使用一些近似最邻近检索技术来加速检索过程。
|
||||
|
||||
一种常用的近似最邻近检索方法,是使用局部敏感哈希对高维数据进行降维处理,将高维空间的点划到有限的区域中。这样,通过判断要查询的点所在的区域,我们就能快速取出这个区域的所有候选集了。
|
||||
|
||||
不过,在上一讲中我们也提到,局部敏感哈希由于哈希函数构造相对比较简单,往往更适合计算字面上的相似性(表面特征的相似性),而不是语义上的相似性(本质上的相似性)。这怎么理解呢?举个例子,即便是面对同一种花,不同的人在不同的地点拍出来的照片,在角度、背景、花的形状上也会有比较大的差异。也就是说,这两张图片的表面特征其实差异很大,这让我们没办法利用局部敏感哈希,来合理评估它们的相似度。
|
||||
|
||||
而且,局部敏感哈希其实是一种粒度很粗的非精准检索方案。以SimHash为例,它能将上百万的高维空间压缩到64位的比特位中,这自然也会损失不少的精确性。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/c6/3d30166fba8d4af8917e53fa4a4d3ac6.jpg" alt="" title="表面特征差异很大的同一种花的对比示意图">
|
||||
|
||||
因此,更常见的一种方案,是使用聚类算法来划分空间。和简单的局部敏感哈希算法相比,聚类算法能将空间中的点更灵活地划分成多个类,并且保留了向量的高维度,使得我们可以更准确地计算向量间的距离。好的聚类算法要保证类内的点足够接近,不同类之间的距离足够大。一种常见的聚类算法是K-means算法(K-平均算法)。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/b5/0c9793222bb1a062d7135a88914ae2b5.jpg" alt="" title="局部敏感哈希空间划分 VS 聚类空间划分">
|
||||
|
||||
K-means聚类算法的思想其实很“朴素”,它将所有的点划分为k个类,每个类都有一个**类中心向量**。在构建聚类的时候,我们希望每个类内的点都是紧密靠近类中心的。用严谨的数学语言来说,K-means聚类算法的优化目标是,**类内的点到类中心的距离均值总和最短**。因此,K-means聚类算法具体的计算步骤如下:
|
||||
|
||||
1. 随机选择k个节点,作为初始的k个聚类的中心;
|
||||
1. 针对所有的节点,计算它们和k个聚类中心的距离,将节点归入离它最近的类中;
|
||||
1. 针对k个类,统计每个类内节点的向量均值,作为每个类的新的中心向量;
|
||||
<li>重复第2步和第3步,**重新计算每个节点和新的类中心的距离,将节点再次划分到最近的类中,然后再更新类的中心节点向量**。经过多次迭代,直到节点分类不再变化,或者迭代次数达到上限,我们停止算法。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/81/4f/8115bc2286b78f9e65f2a2fdb4faef4f.jpeg" alt="" title="K-means 算法计算流程图"></li>
|
||||
|
||||
以上,就是K-means聚类算法的计算过程了,那使用聚类算法代替了局部敏感哈希以后,我们该怎么进行相似检索呢?
|
||||
|
||||
## 如何使用聚类算法进行相似检索?
|
||||
|
||||
首先,对于所有的数据,我们先用聚类算法将它们划分到不同的类中。在具体操作之前,我们会给聚类的个数设定一个目标。假设聚类的个数是1024个,那所有的点就会被分到这1024个类中。这样,我们就可以用每个聚类的ID作为Key,来建立倒排索引了。
|
||||
|
||||
建立好索引之后,当要查询一个点邻近的点时,我们直接计算该点和所有聚类中心的距离,将离查询点最近的聚类作为该点所属的聚类。因此,以该聚类的ID为Key去倒排索引中查询,我们就可以取出所有该聚类中的节点列表了。然后,我们遍历整个节点列表,计算每个点和查询点的距离,取出Top K个结果进行返回。
|
||||
|
||||
这个过程中会有两种常见情况出现。第一种,最近的聚类中的节点数非常多。这个时候,我们就计算该聚类中的所有节点和查询点的距离,这个代价会很大。这该怎么优化呢?这时,我们可以参考二分查找算法不断划分子空间划分的思路,使用层次聚类将一个聚类中的节点,再次划分成多个聚类。这样,在该聚类中查找相近的点时,我们通过继续判断查询点和哪个子聚类更相近,就能快速减少检索空间,从而提升检索效率了。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/b6/15/b69ff3fcaa5a8f1ad192f1714ae43215.jpg" alt="" title="层次聚类检索过程示意图">
|
||||
|
||||
第二种,该聚类中的候选集不足Top K个,或者我们担心聚类算法的相似判断不够精准,导致最近的聚类中的结果不够好。那我们还可以再去查询次邻近的聚类,将这些聚类中的候选集取出,计算每个点和查询点的距离,补全最近的Top K个点。
|
||||
|
||||
## 如何使用乘积量化压缩向量?
|
||||
|
||||
对于向量的相似检索,除了检索算法本身以外,如何优化存储空间也是我们必须要关注的一个技术问题。以1024维的向量为例,因为每个向量维度值是一个浮点数(浮点数就是小数,一个浮点数有4个字节),所以一个向量就有4K个字节。如果是上亿级别的数据,光是存储向量就需要几百G的内存,这会导致向量检索难以在内存中完成检索。
|
||||
|
||||
因此,为了能更好地将向量加载到内存中,我们需要压缩向量的表示。比如说,我们可以用聚类中心的向量代替聚类中的每个向量。这样,一个类内的点都可以用这个类的ID来代替和存储,我们也就节省了存储每个向量的空间开销。那计算查询向量和原始样本向量距离的过程,也就可以改为计算查询向量和对应聚类中心向量的距离了。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/e3/5a/e364eb5c372b58192b88b029842de05a.jpg" alt="" title="用聚类中心代替样本点">
|
||||
|
||||
想要压缩向量,我们往往会使用**向量量化**(Vector Quantization)技术。其中,我们最常用的是**乘积量化**(Product Quantization)技术。
|
||||
|
||||
乍一看,你会觉得乘积量化是个非常晦涩难懂的概念,但它其实并没有那么复杂。接下来,我就把它拆分成乘积和量化这两个概念,来为你详细解释一下。
|
||||
|
||||
**量化指的就是将一个空间划分为多个区域,然后为每个区域编码标识**。比如说,一个二维空间<x,y>可以被划为两块,那我们只需要1个比特位就能分别为这两个区域编码了,它们的空间编码分别是0和1。那对二维空间中的任意一个点来说,它要么属于区域0,要么属于区域1。
|
||||
|
||||
这样,我们就可以用1个比特位的0或1编码,来代替任意一个点的二维空间坐标<x,y>了 。假设x和y是两个浮点数,各4个字节,那它们一共是8个字节。如果我们将8个字节的坐标用1个比特位来表示,就能达到压缩存储空间的目的了。前面我们说的用聚类ID代替具体的向量来进行压缩,也是同样的原理。
|
||||
|
||||
而**乘积指的是高维空间可以看作是由多个低维空间相乘得到的**。我们还是以二维空间<x,y>为例,它就是由两个一维空间<x>和<y>相乘得到。类似的还有,三维空间<x,y,z>是由一个二维空间<x,y>和一个一维空间<z>相乘得到,依此类推。</z></y></x>
|
||||
|
||||
那将高维空间分解成多个低维空间的乘积有什么好处呢?它能降低数据的存储量。比如说,二维空间是由一维的x轴和y轴相乘得到。x轴上有4个点x1到x4,y轴上有4个点y1到y4,这四个点的交叉乘积,会在二维空间形成16个点。但是,如果我们仅存储一维空间中,x轴和y轴的各4个点,一共只需要存储8个一维的点,这会比存储16个二维的点更节省空间。
|
||||
|
||||
总结来说,对向量进行乘积量化,其实就是将向量的高维空间看成是多个子空间的乘积,然后针对每个子空间,再用聚类技术分成多个区域。最后,给每个区域生成一个唯一编码,也就是聚类ID。
|
||||
|
||||
好了,乘积量化压缩向量的原理我们已经知道了。接下来,我们就通过一个例子来说说,乘积量化压缩样本向量的具体操作过程。
|
||||
|
||||
如果我们的样本向量都是1024维的浮点数向量,那我们可以将它分为4段,这样每一段就都是一个256维的浮点向量。然后,在每一段的256维的空间里,我们用聚类算法将这256维空间再划分为256个聚类。接着,我们可以用1至256作为ID,来为这256个聚类中心编号。这样,我们就得到了256 * 4 共1024个聚类中心,每个聚类中心都是一个256维的浮点数向量(256 * 4字节 = 1024字节)。最后,我们将这1024个聚类中心向量都存储下来。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/20/c6/204ab74bf747ee1308454fb1ff91f3c6.jpg" alt="" title="记录256*4个聚类向量中心示意图">
|
||||
|
||||
这样,对于这个空间中的每个向量,我们就不需要再精确记录它在每一维上的权重了。我们只需要将每个向量都分为四段,让**每段子向量都根据聚类算法找到所属的聚类,然后用它所属聚类的ID来表示这段子向量**就可以了。
|
||||
|
||||
因为聚类ID是从1到256的,所以我们只需要8个比特位就可以表示这个聚类ID了。由于完整的样本向量有四段,因此我们用4个聚类ID就可以表示一个完整的样本向量了,也就一共只需要32个比特位。因此,一个1024维的原始浮点数向量(共1024 * 4 字节)使用乘积量化压缩后,存储空间变为了32个比特位,空间使用只有原来的1/1024。存储空间被大幅降低之后,所有的样本向量就有可能都被加载到内存中了。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/12/f1e2e8a56fb4ca40de8bc0cfeb514c12.jpg" alt="" title="压缩前后向量的存储空间对比图">
|
||||
|
||||
## 如何计算查询向量和压缩样本向量的距离(相似性)?
|
||||
|
||||
这样,我们就得到了一个压缩后的样本向量,它是一个32个比特位的向量。这个时候,如果要我们查询一个新向量和样本向量之间的距离,也就是它们之间的相似性,我们该怎么做呢?这里我要强调一下,一般来说,要查询的新向量都是一个未被压缩过的向量。也就是说在我们的例子中,它是一个1024维的浮点向量。
|
||||
|
||||
好了,明确了这一点之后,我们接着来说一下计算过程。这整个计算过程会涉及3个主要向量,分别是**样本向量**、**查询向量**以及**聚类中心向量**。你在理解这个过程的时候,要注意分清楚它们。
|
||||
|
||||
那接下来,我们一起来看一下具体的计算过程。
|
||||
|
||||
首先,我们在对所有样本点生成聚类时,需要记录下**聚类中心向量**的向量值,作为后面计算距离的依据。由于1024维向量会分成4段,每段有256个聚类。因此,我们共需要存储1024个聚类中所有中心向量的数据。
|
||||
|
||||
然后,对于**查询向量**,我们也将它分为4段,每段也是一个256维的向量。对于查询向量的每一段子向量,我们要分别计算它和之前存储的对应的256个聚类中心向量的距离,并用一张距离表存下来。由于有4段,因此一共有4个距离表。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/e6/dd/e67d8f8adc92486a250b5781b9e015dd.jpg" alt="" title="计算查询向量和聚类中心向量的距离表过程示意图">
|
||||
|
||||
当计算查询向量和样本向量的距离时,我们将查询向量和样本向量都分为4段子空间。然后分别计算出每段子空间中,查询子向量和样本子向量的距离。这时,我们可以用聚类中心向量代替样本子向量。这样,求查询子向量和样本子向量的距离,就转换为求查询子向量和对应的聚类中心向量的距离。那我们只需要将样本子向量的聚类ID作为key去查距离表,就能在O(1)的时间代价内知道这个距离了。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/74/d9/749af0780fdd1d7ac8f3641095ed70d9.jpg" alt="" title="获得全部查询子向量和样本子向量近似距离的过程示意图">
|
||||
|
||||
最后,我们将得到的四段距离按欧氏距离的方式计算,合并起来,即可得到查询向量和样本向量的距离,距离计算公式:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/64/1a/644e03d03748728edff3332f39e82a1a.jpg" alt="">
|
||||
|
||||
以上,就是计算查询向量和样本向量之间距离的过程了。你会看到,原本两个高维向量的复杂的距离计算,被4次O(1)时间代价的查表操作代替之后,就变成了常数级的时间代价。因此,在对压缩后的样本向量进行相似查找的时候,我们即便是使用遍历的方式进行计算,时间代价也会减少许多。
|
||||
|
||||
而计算查询向量到每个聚类中心的距离,我们也只需要在查询开始的时候计算一次,就可以生成1024个距离表,在后面对比每个样本向量时,这个对比表就可以反复使用了。
|
||||
|
||||
## 如何对乘积量化进行倒排索引?
|
||||
|
||||
尽管使用乘积量化的方案,我们已经可以用很低的代价来遍历所有的样本向量,计算每个样本向量和查询向量的距离了。但是我们依然希望能用更高效的检索技术代替遍历,来提高检索效率。因此,结合前面的知识,我们可以将聚类、乘积量化和倒排索引综合使用,让整体检索更高效。下面,我就来具体说说,在建立索引和查询这两个过程中,它们是怎么综合使用的。
|
||||
|
||||
首先,我们来说建立索引的过程,我把它总结为3步。
|
||||
|
||||
1. 使用K-means聚类,将所有的样本向量分为1024个聚类,以聚类ID为Key建立倒排索引。
|
||||
1. 对于每个聚类中的样本向量,计算它们和聚类中心的差值,得到新的向量。你也可以认为这是以聚类中心作为原点重新建立向量空间,然后更新该聚类中的每个样本向量。
|
||||
<li>使用乘积量化的方式,压缩存储每个聚类中新的样本向量。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/ba/d3/ba2da0119e3e53448e31c5824433d0d3.jpg" alt="" title="一个样本向量加入倒排索引的过程示意图"></li>
|
||||
|
||||
建好索引之后,我们再来说说查询的过程,它也可以总结为3步。
|
||||
|
||||
1. 当查询向量到来时,先计算它离哪个聚类中心最近,然后查找倒排表,取出该聚类中所有的向量。
|
||||
1. 计算查询向量和聚类中心的差值,得到新的查询向量。
|
||||
<li>对新的查询向量,使用乘积量化的距离计算法,来遍历该聚类中的所有压缩样本向量,取出最近的k个结果返回。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/75/b2/75fbb780bbbc5d412f660bb76fc717b2.jpg" alt="" title="查询向量查询倒排索引的过程示意图"></li>
|
||||
|
||||
这样,我们就同时结合了聚类、乘积量化和倒排索引的检索技术,使得我们能在压缩向量节省存储空间的同时,也通过快速减少检索空间的方式,提高了检索效率。通过这样的组合技术,我们能解决大量的图片检索问题。比如说,以图搜图、拍照识物,人脸识别等等。
|
||||
|
||||
实际上,除了图像检索领域,在文章推荐、商品推荐等推荐领域中,我们也都可以用类似的检索技术,来快速返回大量的结果。尤其是随着AI技术的发展,越来越多的对象需要用特征向量来表示。所以,针对这些对象的检索问题,其实都会转换为高维空间的近似检索问题,那我们今天讲的内容就完全可以派上用场了。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我们学习了在高维向量空间中实现近似最邻近检索的方法。相对于局部敏感哈希,使用聚类技术能实现更灵活的分类能力,并且聚类技术还支持层次聚类,它能更快速地划分检索空间。
|
||||
|
||||
此外,对于高维的向量检索,如何优化存储空间也是我们需要考虑的一个问题。这个时候,可以使用乘积量化的方法来压缩样本向量,让我们能在内存中运行向量检索的算法。
|
||||
|
||||
那为了进一步提高检索率和优化存储空间,我们还能将聚类技术、乘积量化和倒排索引技术结合使用。这也是目前图像检索和文章推荐等领域中,非常重要的设计思想和实现方案。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/c3/0bc88d22a8ae6ac44e4a7e5180c7ebc3.jpg" alt="" title="知识总结">
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
1.为什么使用聚类中心向量来代替聚类中的样本向量,我们就可以达到节省存储空间的目的?
|
||||
|
||||
2.如果二维空间中有16个点,它们是由x轴的1、2、3、4四个点,以及y轴的1、2、3、4四个点两两相乘组合成的。那么,对于二维空间中的这16个样本点,如果使用乘积量化的思路,你会怎么进行压缩存储?当我们新增了一个点(17,17)时,它的查询过程又是怎么样的?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这一讲分享给你的朋友。
|
||||
24
极客时间专栏/检索技术核心20讲/进阶实战篇/测一测 | 高性能检索系统的实战知识,你掌握了多少?.md
Normal file
24
极客时间专栏/检索技术核心20讲/进阶实战篇/测一测 | 高性能检索系统的实战知识,你掌握了多少?.md
Normal file
@@ -0,0 +1,24 @@
|
||||
<audio id="audio" title="测一测 | 高性能检索系统的实战知识,你掌握了多少?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ae/38/ae32bcb7db5ed1d25fe003626c159138.mp3"></audio>
|
||||
|
||||
你好,我是陈东。欢迎来到进阶实战篇的测试环节!
|
||||
|
||||
在进阶实战篇中,我们针对一些应用中的实际问题,学习了对应的经典解决方案。这其中涉及了很多高级的检索知识,以及一些高性能检索系统的设计思想。这些知识,无论是对你现在的工作来说,还是对你之后自己设计系统、设计应用都会有非常大的帮助。
|
||||
|
||||
那这些知识你都掌握了多少呢?为了让你能检验自己的学习效果,同时也能巩固之前讲过的知识,我特别给你准备了一套测试题。和基础篇的测试一样,题目不多,依然是20道单选题,也同样建议你在30分钟内完成。
|
||||
|
||||
当然,我还为你准备了一道主观题。可以好好想想,利用我们进阶篇学到的知识怎么来解答,最后,希望你能把思考过程和最终答案都写在留言区,我们一起探讨。我会在下周三把解题思路放到评论区,一定要来看啊。
|
||||
|
||||
还等什么,点击下面的按钮开始测试吧!<br>
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=131&exam_id=283)
|
||||
|
||||
## 主观题
|
||||
|
||||
假设有一个移动互联网应用,要实现找到附近具有相同兴趣的人功能。这里面的相同兴趣,指的是具有相同兴趣标签的人。如果一个人身上有多个标签,那只要有一个标签和其他人相同,就算有相同兴趣。
|
||||
|
||||
在这种情况下,我们需要支持以下功能:
|
||||
|
||||
1. 列出附近兴趣相同的人,允许结果为空;
|
||||
1. 系统要具备实时性,如果有用户的标签发生变化或者位置发生变化,需要及时在系统中得到体现;
|
||||
1. 如果附近兴趣相同的人很多,那么需要将这些人进行排序,需要设计排序方案。
|
||||
|
||||
如果使用我们在进阶实战篇中学到的知识,你会怎么来设计和实现这个功能呢?
|
||||
114
极客时间专栏/检索技术核心20讲/进阶实战篇/特别加餐 | 高性能检索系统中的设计漫谈.md
Normal file
114
极客时间专栏/检索技术核心20讲/进阶实战篇/特别加餐 | 高性能检索系统中的设计漫谈.md
Normal file
@@ -0,0 +1,114 @@
|
||||
<audio id="audio" title="特别加餐 | 高性能检索系统中的设计漫谈" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2a/2c/2a0fc421dd0ca0631fbf0094e2a9ee2c.mp3"></audio>
|
||||
|
||||
你好,我是陈东。欢迎来到检索专栏的第三次加餐时间。
|
||||
|
||||
在进阶篇的讲解过程中,我们经常会提起一些设计思想,包括索引与数据分离、减少磁盘IO、读写分离和分层处理等方案。这些设计思想看似很简单,但是应用非常广泛,在许多复杂的高性能系统中,我们都能看到类似的设计和实现。不过,前面我们并没有深入来讲,你可能理解得还不是很透彻。
|
||||
|
||||
所以,今天我会把专栏中出现过的相关案例进行汇总和对比,再结合相应的案例扩展,以及进一步的分析讨论,来帮助你更好地理解这些设计思想的本质。并且,我还会总结出一些可以参考的通用经验,让你能更好地设计和实现自己的高性能检索系统。
|
||||
|
||||
## 设计思想一:索引与数据分离
|
||||
|
||||
我要说的第一个设计思想就是索引与数据分离。索引与数据分离是一种解耦的设计思想,它能帮助我们更好地聚焦在索引的优化上。
|
||||
|
||||
比如说,对于无法完全加载到内存中的数据,对它进行索引和数据分离之后,我们就可以利用内存的高性能来加速索引的访问了。[第6讲](https://time.geekbang.org/column/article/222768)中的线性索引的设计,以及B+树中区分中间节点和叶子节点的设计,就都使用了索引和数据分离的设计思想。
|
||||
|
||||
那如果索引和数据都可以加载在内存中了,我们还需要使用索引和数据分离吗?在这种情况下,将索引和数据分离,我们依然可以提高检索效率。以[第5讲](https://time.geekbang.org/column/article/219268)中查找唐诗的场景为例,我们将所有的唐诗存在一个正排索引中,然后以关键词为Key建立倒排索引。倒排索引中只会记录唐诗的ID,不会记录每首唐诗的内容。这样做会有以下3个优点。
|
||||
|
||||
1. 节约存储空间,我们不需要在posting list中重复记录唐诗的内容。
|
||||
1. 减少检索过程中的复制代价。在倒排索引的检索过程中,我们需要将posting list中的元素进行多次比较和复制等操作。如果每个元素都存了复杂的数据,而不是简单的ID,那复制代价我们也不能忽略。
|
||||
<li>保持索引的简单有效,让我们可以使用更多的优化手段加速检索过程。在[加餐1](https://time.geekbang.org/column/article/221292)中我们讲过,如果posting list中都存着简单ID的话,我们可以将posting list转为位图来存储和检索,以及还可以使用Roaring Bitmap来提高检索效率。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/9c/d1cfadb61b0160b95c63d443cd5fb29c.jpg" alt="" title="索引与数据分离的设计示意图"></li>
|
||||
|
||||
总结来说就是,索引与数据分离的设计理念可以让索引保持简洁和高效,来帮助我们聚焦在索引的优化技术上。因此,保持索引的简洁高效是我们需要重点关注的。
|
||||
|
||||
当然,我相信你刚开始设计一个新系统的时候,可以很容易做到这一点。但也正因为它非常基础,恰恰就成了我们工作中最容易忽视的地方。
|
||||
|
||||
而一旦我们忽视了,那随着系统的变化,索引中掺杂的数据越来越多、越来越复杂,系统的检索性能就会慢慢下降。这时,我们需要牢记**奥卡姆剃刀原理,也就是“如无必要,勿增实体”这个基础原则**,来保证索引一直处于简洁高效的状态。
|
||||
|
||||
当然,索引和数据分离也会带来一些弊端,如不一致性。怎么理解呢?我们可以考虑这么一个场景:数据已经修改或是删除了,但索引还没来得及更新。如果这个时候我们访问索引,那得到的结果就很有可能是错误的。
|
||||
|
||||
那这个错误的结果会造成什么影响呢?这就要分情况讨论了。
|
||||
|
||||
对于不要求强一致性的应用场景,比如说在某些应用中更新用户头像时,我们可以接受这种临时性错误,只要能保证系统的最终一致性即可。但如果在要求强一致性的应用场景中,比如说在金融系统中进行和金钱有关的操作时,我们就需要做好一致性管理,我们可以对索引和数据进行统一加锁处理,或者直接将索引和数据合并。这么说比较抽象,我们以MySQL中的B+树为例,来看看它是怎么管理一致性的。
|
||||
|
||||
MySQL中的B+树实现其实有两种,一种是MyISAM引擎,另一种是InnoDB引擎。它们的核心区别就在于,数据和索引是否是分离的。
|
||||
|
||||
在MyISAM引擎中,B+树的叶子节点仅存储了数据的位置指针,这是一种索引和数据分离的设计方案,叫作非聚集索引。如果要保证MyISAM的数据一致性,那我们需要在表级别上进行加锁处理。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/c9/dc4d1906373946db6ab45dae64eb9dc9.jpg" alt="" title="MyISAM的索引数据分离设计">
|
||||
|
||||
在InnoDB中,B+树的叶子节点直接存储了具体数据,这是一种索引和数据一体的方案。叫作聚集索引。由于数据直接就存在索引的叶子节点中,因此InnoDB不需要给全表加锁来保证一致性,它只需要支持行级的锁就可以了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/fc/89568b50381a7750bfb6d934e8a208fc.jpg" alt="" title="InnoDB的索引数据一体设计">
|
||||
|
||||
所以你看,索引和数据分离也不是万能的“银弹”,我们需要结合具体的使用场景,来进行合适的设计。
|
||||
|
||||
## 设计思想二:减少磁盘IO
|
||||
|
||||
第6讲我们说过,在大规模系统中,数据往往无法全部存储在内存中。因此,系统必然会涉及磁盘的读写操作。在这种应用场景下,**尽可能减少磁盘IO,往往是保证系统具有高性能的核心设计思路**。
|
||||
|
||||
减少磁盘IO的一种常见设计,**是将频繁读取的数据加载到内存中**。前面讲到的索引和数据分离的方案,就使得我们可以优先将索引加载在内存中,从而提高系统检索效率。
|
||||
|
||||
当频繁读取的数据也就是索引太大,而无法装入内存的时候,我们不会简单地使用跳表或者哈希表加载索引,而会采用更复杂的,具有压缩性质的前缀树这类数据结构和算法来压缩索引,让它能够放入内存中。
|
||||
|
||||
尽管,单纯从数据结构和检索效率来看,前缀树的检索性能是低于跳表或者哈希表的,但如果我们考虑到内存和磁盘在性能上的巨大差异的话,那这种压缩索引的优势就体现出来了。最典型的例子就是lucene中用FST(Finite State Transducer,中文:有限状态转换器)来存索引字典。
|
||||
|
||||
除了使用索引和数据分离,在高维空间中使用[乘积量化](https://time.geekbang.org/column/article/231760)对数据进行压缩和检索,以及使用[分布式技术](https://time.geekbang.org/column/article/225869)将索引分片加载到不同的机器的内存中,也都可以减少磁盘的IO操作。
|
||||
|
||||
而**如果不可避免要对磁盘进行读写,那我们要尽量避免随机读写**。我们可以使用[第7讲](https://time.geekbang.org/column/article/222768)中学习到的,使用预写日志技术以及利用磁盘的局部性原理,来顺序写大批量数据,从而提高磁盘访问效率。这些都是一些优秀的开源软件使用的设计方案,比如,我们熟悉的基于LSM树的Hbase和Kafka,就都采用了类似的设计。
|
||||
|
||||
你也许会问,如果改用SSD来存储数据( SSD具有高性能的随机读写能力),那我们是不是就不用再关注减少磁盘IO的设计思想和技术了?当然不是,关于这个问题,我们可以从两方面来分析。
|
||||
|
||||
一方面,虽然SSD的确比磁盘快了许多,但SSD的性能和内存相比,依然会有1到2个数量级的巨大差距。甚至不同型号的SSD之间,性能也可能会有成倍的差距。再有,俗话说“一分钱一分货”,内存和SSD的价格差异,其实也反映了它们随机读写性能的差距。因此内存是最快的,我们依然要尽可能把数据都存放在内存中。
|
||||
|
||||
另一方面,SSD的批量顺序写依然比随机写有更高的效率。为什么呢?让我们先来了解一下SSD的工作原理。对于SSD而言,它以页(Page,一个Page为4K-16K)为读写单位,以块(Block,256个Page为一个Block)为垃圾回收单位。由于SSD不支持原地更新的方式修改一个页,因此当我们写数据到页中时,SSD需要将原有的页标记为失效,并将原来该页的数据和新写入的数据一起,写入到一个新的页中。那被标记为无效的页,会被垃圾回收机制处理,而垃圾回收又是一个很慢的操作过程。因此,随机写会造成大量的垃圾块,从而导致系统性能下降。所以,对于SSD而言,批量顺序写的性能依然会大幅高于随机写。
|
||||
|
||||
总结来说,无论存储介质技术如何变化,将索引数据尽可能地全部存储在最快的介质中,始终是保证高性能检索的一个重要设计思路。此外,对于每种介质的读写性能,我们都需要进行了解,这样才能做出合理的高性能设计。
|
||||
|
||||
## 设计思想三:读写分离
|
||||
|
||||
接下来,我们再说说读写分离,它也是很常见的一种设计方案。在高并发的场景下,如果系统需要对同一份数据同时进行读和写的操作,为了保证线程安全以及数据一致性,我们往往需要对数据操作加锁,但这会导致系统的性能降低。
|
||||
|
||||
而读写分离的设计方案,可以让所有的读操作只读一份数据,让所有的写操作作用在另一份数据上。这样就不存在读写竞争的场景,系统也就不需要加锁和切换了。在读操作频率高于写操作的应用场景中,使用读写分离的设计思想,可以大幅度提升系统的性能。很多我们熟悉的架构都采用这样的设计。
|
||||
|
||||
比如说,MySQL的Master - Slave架构。在MySQL的Master - Slave的架构设计中,master负责接收写请求。在数据写入master以后,master会和多个slave进行同步,将数据更新到所有的slave中。所有的slave仅负责处理读请求。这样,MySQL就可以完成读写分离了。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/27/d2c548887e5c8617ca38069b50ed5c27.jpg" alt="" title="Master – slave架构实现读写分离示意图">
|
||||
|
||||
其实不仅仅是MySQL,Redis中也存在类似的Master - Slave的读写分离设计。包括在LevelDB中,MemTable - Immutable MemTable 的设计,其实也是读写分离的一个具体案例。
|
||||
|
||||
除了MySQL和Redis这类的数据存储系统,在倒排索引类的检索系统中,其实也有着类似的设计思路。比如说,在[第9讲](https://time.geekbang.org/column/article/222807)中我们讲过,对于索引更新这样的功能,我们往往会使用Double Buffer机制,以及全量索引+增量索引的机制来实现读写分离。通过这样的机制,我们才能保证搜索引擎、广告引擎、推荐引擎等系统能在高并发的应用场景下,依然具备实时的索引更新能力。
|
||||
|
||||
## 设计思想四:分层处理
|
||||
|
||||
在大规模检索系统中,不同数据的价值是不一样的,如果我们都使用同样的处理技术,其实会造成系统资源的浪费。因此,将数据分层处理也是非常基础且重要的一种设计。
|
||||
|
||||
最典型的例子就是在[第12讲](https://time.geekbang.org/column/article/227161)中,我们提到的非精准Top K检索 + 精准 Top K检索的设计思路了。简单回顾一下,如果我们对所有的检索结果集都进行耗时复杂的精准打分,那会带来大量的资源浪费。所以,我们会先进行初步筛选,快速选出可能比较好的结果,再进行精准筛选。这样的分层打分机制,其实非常像我们在招聘过程中,先进行简历筛选,再进行面试的处理流程。可见,这种设计思路有着非常广泛的使用场景。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/81/56/81620b228164d406870a6136731d2e56.jpg" alt="" title="分层检索+分层打分示意图">
|
||||
|
||||
包括我们前面提到的将索引放在内存中而不是磁盘上,这其实也是一种分层处理的思路。我们对索引进行分层,把最有价值的索引放在内存中,保证检索效率。而价值较低的大规模索引,则可以存在磁盘中。
|
||||
|
||||
如果你再仔细回想一下就会发现,这其实和第7讲中,LSM树将它认为最有价值的近期数据放在内存中,然后将其他数据放在磁盘上的思路是非常相似的。甚至是硬件设计也是这样,CPU中的一级缓存和二级缓存,也是基于同样的分层设计理念,将最有价值的数据,存在离CPU最近,也最贵的介质中。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/dc/2b6ac0f3dcb370c0251ed159dca3bfdc.jpg" alt="" title="硬件分层架构设计">
|
||||
|
||||
此外,还有一个典型的分层处理的设计实例就是,为了保证系统的稳定性,我们往往需要在系统负载过高时,启用自动降级机制。而好的自动降级机制,其实也是对流量进行分层处理,将系统认为有价值的流量保留,然后抛弃低价值的流量。
|
||||
|
||||
总结来说,如果我们在应用场景中,对少数的数据进行处理,能带来大量的产出,那分层处理就是一种可行的设计思路。这其实就是**二八原则:20%的资源带来了80%的产出**。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
今天,我们讨论了索引和数据分离、减少磁盘IO、读写分离、以及分层处理这四种设计思想。你会发现,这些设计思想说起来并不复杂,但几乎无处不在。为了方便你使用,我总结了一下,它们在使用上的一些通用经验。
|
||||
|
||||
首先,索引和数据分离能让我们遵循“奥卡姆剃刀原则”,聚焦于索引的优化技术上。但我们还要注意,索引和数据分离可能会带来数据的不一致性,因此需要合理使用。
|
||||
|
||||
其次,减少磁盘IO有两种主要思路:一种是尽可能将索引加载到最快的内存中;另一种是对磁盘进行批量顺序读写。并且,即便存储介质技术在不断变化,但我们只要保持关注这两个优化方向,就可以设计出高性能的系统。
|
||||
|
||||
接着,在读写分离的设计中,使用Master - Slave的架构设计也是一种常见方案,无论是MySQL还是Redis都采用了这种设计方案。而基于倒排索引的检索系统,也有着类似的Double Buffer和全量索引结合增量索引的机制。
|
||||
|
||||
最后,分层处理其实就是遵循二八原则,将核心资源用来处理核心的数据,从而提升整体系统的性能。
|
||||
|
||||
当你要设计或实现高并发系统时,你可以试试使用这些思想来指导你设计和审视系统,相信这会让你的系统变得更加高效。
|
||||
|
||||
## 课堂讨论
|
||||
|
||||
对于今天我们讨论的这些设计思想,你有什么案例可以分享出来吗?
|
||||
|
||||
欢迎在留言区畅所欲言,说出你的案例和思考。如果有收获,也欢迎把这一讲分享给你的朋友。
|
||||
Reference in New Issue
Block a user