123-检索技术核心20讲

导读 | 三步走策略，轻松搞定检索！

你好，我是陈东。欢迎来到《检索技术核心 20 讲》。

今天是课程导读，在正式开始学习检索技术之前，我想和你先聊聊这个专栏的学习方法，目的就是让我们后面的学习能达到事半功倍的效果。

想要高效地学习检索，我的经验是咱得先弄清楚到底都要学哪些内容，给自己一张知识地图，才能做到心中有数。

这里，我根据十多年的工作经验，梳理了和我们的工作有较强相关性的检索知识，并整理出了一张知识全景图，你可以看一看。

在这张图中，我从基础到实际应用，将需要学习的检索技术分为了四个层级，我们按照从下往上的顺序依次来看。

第一层是存储介质层。因为检索效率的高低和数据存储的方式是紧密联系的，所以，存储介质的特性是我们需要学习的基础知识。

第二层是数据结构与算法层。提到“效率”，自然就离不开数据结构和算法。在遇到实际业务的时候，我们要知道如何利用每个数据结构和算法的特点，来提高检索效率。所以，这块内容我们必须要学得很扎实。

第三层是检索专业知识层。如果我们想实现工业界中的检索引擎，需要掌握这些检索技术。我把它们划分为两部分，分别是工程架构和算法策略。这些内容是我们解决常见业务问题的必备知识。

第四层是检索技术的应用层。检索技术在互联网中有许多应用场景，其中最常见的，有搜索引擎、广告引擎、以及推荐引擎。这些业务系统有相似的工程架构和算法部分，也分别有自己独特的业务处理环节。学习它们的实践方法，我们可以更全面、更深入地掌握检索技术。

你可能想说，这些内容看起来可真不少啊！其实，这张图已经是我精简后的了，如果你想要全面掌握检索技术，这张图里提到的每一个方向，你都可以非常深入地去学习。

这么一看，即使有了这张全景图，学习检索依然不容易。那如果想要高效学习，我们具体又该怎么做呢？我们需要有清晰的学习路径和科学、高效的学习方法，我把它们称为“三步走策略”。接下来，我就和你具体来聊一聊。

第一步：夯实基础

万丈高楼平地起。我们刚才说过，检索技术的底层基础知识，离不开数据结构和算法。在检索领域里最常用的基础数据结构和算法，主要有：数组、链表、位图、布隆过滤器、哈希表、二叉检索树、跳表、倒排索引。

因为检索技术本质上就是将数据从存储的地方高效取出的技术，而数据是以什么数据结构存储的，会直接影响到检索效率。所以，对于这些基础知识，我们要重点关注它们的存储特点和检索效率。从中，我们不仅可以学会在合适的场景使用合适的技术，还可以掌握检索的核心设计思想，从而在代码层面提高检索效率。

比如说，数组和链表是最基础的数据结构。从存储特点上来说，它们都属于线性结构。而从检索效率上来说，在数据无序存储，并且本身结构没有做任何优化的情况下，数组和链表的检索效率是 O(n)。那想要提高检索效率，我们就需要结合它们自身的特点，将二分查找的思想加入进去，将检索效率提升到 O(log n)。这其中就体现了检索的核心设计思想：合理组织数据，尽可能快速减少查询范围，提升检索效率。在具体写代码的时候，如果我们能应用这样的设计思想，那检索效率肯定会有大幅提升。

第二步：在实践中将技术落地

多年的工作经验告诉我，工业界的技术落地和基础知识之间还是有很大差距的。解决实际问题的能力，往往是衡量一个工程师水平的标尺。如果我们想加强自己的技术经验积累，除了打好基础，更重要的是要从实践中学习工业界的解决方案。

在开篇词中我们说过，检索技术是数据库、搜索引擎、广告引擎和推荐引擎等热门业务系统的底层技术。所以，这些场景中的实际业务需求，都可以作为我们学习检索技术的“题库”。在“解题”的过程中，我们要重点关注工业界中，针对不同场景的高效检索技术，和热门业务系统中检索架构的设计方案，以及它们各自的特殊处理环节。从中，我们不仅能学到对应的行业经验，还可以了解不同行业中检索架构特点，以此来解决工作中的实际检索难题。

比如说，“刚发布的文章为什么能被搜到”，这就是一个典型的检索实战题，它主要用到的知识就是索引更新。在工业界中实现索引更新的时候，为了追求更高的检索性能，我们一般不会直接对索引加锁，而是会利用“双 buffer 机制”来实现索引更新。但是像搜索引擎这样万亿级网页的索引规模，无法直接使用“双 buffer 机制”来更新，需要使用“全量索引结合增量索引”方案来更新索引。

再比如说，当我们要在系统中使用数据库来进行存储和检索时，那么是使用关系型数据库好呢？还是选择 NoSQL 好呢？这就需要我们对于数据库的检索技术 B+ 树，和 NoSQL 中的 LSM 树有一定的了解。比如，在数据被频繁写入、较少查找的日志系统和监控系统中，我们更应该使用 NoSQL 型数据库。

知道了学习的重点在哪儿，我们就可以很容易地梳理出高效学习的路径了。本专栏就是从这样的角度出发，借助三个模块，帮助你建立全面的检索知识体系。

第三步：搭配高效学习攻略

那除了高效的学习路径，我也有一些学习方法想分享给你。我一共总结了 3 条攻略，希望它们能够帮助你更轻松、高效地学习这个专栏。

1. 多思考、多提问，善用“理解记忆法”

平时，我们想要记住一篇文章中新学的知识点的时候，很多人会说：多读几遍，你就能记住这些知识点了。但事实证明，任何不经过深刻理解的知识点都是“留不住”的，过一阵子我们就会把它忘了。即使我们短时间内没有忘记，“记住”也不等于“学会”。

那对于我们专栏来说，在面对复杂的检索知识的时候，我更建议你通过理解记忆的方式进行学习。具体的方式有啥呢？我比较推荐问答的方式。也就是说，在学习每个知识点的时候，你可以一直问自己几个问题，比如，“这个知识点要解决什么问题？”“如果不用这个方法还有其他的解决方案吗？”“使用这个方法有副作用或者限制吗？”。

慢慢地，你会发现，这种问自己问题的学习方式，不仅能帮你“学会”知识，更重要的是，它还能训练你的思考能力和理解能力。当你在学别的知识的时候，依然可以用同样的方法。

2. 建立自己的知识体系

学会了怎么“记”还不够，因为，随着我们学习的不断深入，知识点会越来越多，而不成体系的学习，往往会事倍而功半。所以，我们需要把它们有效地组织起来，有体系地学习。那么问题来了，我们该如何建立自己的知识体系呢？一般来说，我会用这么两个小技巧：对比和拆解。

在学习一个新知识点的时候，我们可以把它和之前学过的知识点对比，看看它们之间的相同点和不同点，为新、旧知识之间建立联系。

那如果这个新知识点是一个比较复杂的知识点，我们可以试着把它拆解成多个小知识点，拆解之后，我们依然可以用对比的方法，让这些小知识点和旧知识建立联系。

借助这两个小技巧，你就能将零散的知识点关联起来，从而形成一个自己的知识体系了。

3. 有耐心、反复学、多交流

我相信，通过前面这些方法你已经建立起了学习的信心。那这里，我就要给你泼一盆“冷水”了。在刚开始学习检索的时候，就算已经使用了前面的学习方法，我们还是会遇到一种情况：不能完全掌握学到的内容。

不过，你不用担心，这种情况再自然不过了。作为一名“检索老兵”，我想告诉你，实用的学习方法虽然能保证我们少走弯路，但想要真正掌握一项技能，反复学习是非常重要的。尤其是在新知识点比较多的时候，反复学习就更为重要了。

在反复学习的过程中，你需要多看、多听、多思考、多对比。一方面，这能帮助你加强对检索知识的理解；另一方面，在重复学习的过程中，你也能更好地将前后的知识进行梳理，从而将新知识点融入自己的知识体系中。在这个过程中，你还可以多和同事、同行交流、讨论，弥补自己的知识盲点，做到全面地理解知识。

重点回顾

说了这么多，我已经把学习这个专栏的高手攻略，全部分享给你了。但我还是想再和你强调一些重点内容，希望能帮助你应用到自己的学习和工作中。

首先，我们可以从 4 个层级来学习检索技术，分别是存储介质层、数据结构和算法层、检索专业知识层、以及应用层。这里面的内容很多，要熟练掌握不是一件容易的事情。因此，我们需要有科学、高效的学习方法，以及清晰的学习路径。那我也和你分享了学习这个专栏的一些方法。总结来说就是“三步走策略”：夯实基础、在实践中将技术落地，最后搭配高效的学习攻略。

方向已经指明了，路线也清晰了，接下来，我们就可以开始学习了。那最后呢，我还有一句话想要送给你：道阻且长，行则将至，我们一起努力！

课堂讨论

在我今天给到的检索技术的两张图中，你对其中哪部分知识最感兴趣呢？另外，除了我今天和你分享的学习方法，你也可以在留言区说一说，对于这个专栏，你准备怎么学？

开篇词 | 学会检索，快人一步！

你好，我是陈东，本硕都毕业于北京大学，目前是奇虎 360 商业产品事业部资深总监。

提起检索，我真有挺多话要说，因为这是我从学生时代到职场一路走来，一直都在学习和从事的事情。

我在研究生时期就加入了北大网络实验室。这个实验室当时最有名的一个产品，就是 1997 年发布的北大天网，它是国内第一个基于网页索引的中文搜索引擎。在这里，我接触到了搜索引擎的原理，并开始研究海量数据的存储和检索。

后来，我又参加了 IBM“天才孵化计划”，参与了一个基于地理位置的图片社交创新项目。在这里，我遇到了一个特别棘手的挑战，就是要基于每个用户的位置和他们发过的图片进行好友推荐。在这个过程中，我要不断地解决各种检索问题，包括地理位置检索、图片检索、相似用户检索等等。

幸运的是，这些问题都围绕着一个核心问题，也就是如何高效检索。所以，我还是在短时间内很好地完成了这个任务。解决棘手的问题，给我带来了成就感。同时，这也让我产生了一个想法，我想要深入去研究“如何在不同的应用中做好检索”。

于是，毕业后，我先后加入了微软广告技术团队、创业公司聚效广告和奇虎 360。尽管这些都属于互联网广告行业，但它们的背后都离不开检索知识和技术的支持。而这么多年打造高性能广告引擎的工作经历，让我对于如何做好检索，以及如何使用检索技术支持业务，有了深入的思考和理解。

为什么要学习检索技术？

说到这，我想问你一个问题：你理解的检索技术是什么？说实话，在专栏刚开始筹备的时候，我就问过很多人这个问题，不少人的第一反应就是：检索就是搜索引擎或者数据库吧。实际上，检索技术的覆盖范围远不止这些。

不管是在数据库和搜索引擎里，还是在新闻推荐平台、电商平台、生活服务平台中，如果我们把这些业务场景抽象出来，它们的本质其实都是在海量的信息中，快速筛选出我们需要的内容或服务，而这都和检索技术紧密相关。也就是说，即便业务形态不同，但是在这些平台的架构设计中，也都有着相似的检索模块实现。

那检索技术到底是什么呢？我们可以用一句话来概括检索技术：它是更底层的通用技术，它研究的是如何将我们所需的数据高效地取出来。而如何打造高性能的检索引擎，是这些业务都需要面对的核心问题。甚至我们可以说，提供“更快”“更好”的检索服务，其实就是这些公司的核心竞争力之一。

除了解决业务场景，在我们的实际工作中，“如何快速检索数据”也是最常见的需求。

比如说，无论你是从事底层架构开发，还是业务开发，我相信你都有可能会面对“为啥我的程序运行得这么慢”的问题。比如说，我们打开一些网站或应用的时候，常常会看到“loading”或“数据加载中”的提示，这很大可能是因为，程序从数据库中检索相关数据比较缓慢。如果我们能合理地使用数据库的索引功能，往往能提升好几倍的加载速度，从而大大减少用户的等待时间。

再比如说，在具体写代码的时候，我们会用到一些系统提供的容器，比如 ArrayList、LinkedList。尽管这些容器都提供了“查询是否包含某个元素”的功能，但是它们的性能相差甚远，如果不了解这些容器的检索原理，我们就有可能因为使用不当而导致程序运行缓慢。因此，了解和使用合适的检索技术，往往能有效提升整个程序的执行效率。

刚才举的例子都很具体，那我们再从更高的视角，来看看当下我们所处的这个时代。随着 5G 等新技术的普及，我们收集和存储的数据会越来越多。毫无疑问，在这样一个信息爆炸甚至过剩的时代，如何对信息进行高效检索，也将是这个时代必不可少的技能之一。再说回到我们自己的 IT 行业，技术变化之快，我就不必多说了。我相信，掌握好检索技术，能帮助你在行业的快速变化和发展中找到新的机会，让你有更多的机会进入更好的平台，施展自己的才华。

为什么检索技术难学？

说了这么多检索的好处，那说到底，我们怎么才能学好检索技术呢？我曾经想过整理一些材料，给我自己团队的成员进行培训，帮助他们更好地完成工作，但我发现很难找到理想的教材。

我认真了解并且分析之后发现，这主要有两方面原因。

一方面，经典教材大都太过理论化，和实际工作结合不紧密，学习难度太大。比如，《现代信息检索》和《信息检索导论》就是两本非常经典的书籍，但是大部分人都反馈读起它们来比较吃力，书中的内容组织和例子都和我们的实际工作有比较大的差距。

另一方面，和实际工作结合的教材，往往都是从某一行业的视角出发，全面介绍这个行业方方面面的所有技术，而不是专注于某一个基础技术。

比如说，数据库方面的教材，一般会介绍关系模型、SQL 语句、事务处理等内容；搜索引擎方面的教材会介绍爬虫系统、文本挖掘、自然语言处理、网页链接分析等内容，真正涉及检索技术的篇幅并不多。如果以这些书籍为教材，我们根本无法聚焦到检索技术的学习上，难以快速、系统地掌握这门实用的知识。

总结来说，不管是经典教材，还是和实际结合的教材，对读者的知识储备要求都比较高，那无形之中就建立了一个“高门槛”，很多想学习检索技术的人都是被这样的“高门槛”拦在门外的。

于是，我就开始想，那我是不是可以结合我这么多年对检索技术的理解，将我自己多年从事相关工作的经验总结出来呢？于是，经过近半年的精心准备，《检索技术核心 20 讲》这个专栏就诞生了。

专栏是如何设计的？

如果用一句话来概括一下这个专栏的交付目标，那就是，我想通过这个专栏系统地梳理检索技术的知识，去除冗杂的知识旁支，聚焦于最通用、最核心的检索技术，帮助更多有学习热情、有工作需求的工程师找到学习检索的方法，快速入门、积累经验，解决实际工作中的检索问题。

所以，对于这个专栏的内容设计，我给自己定了这么几个目标：

聚焦核心知识，帮你全面了解检索技术。我会将我了解的不同行业、不同系统的检索技术进行提炼，帮助你掌握检索技术的核心知识。我也会将不同行业的检索相关的知识进行体系化的梳理，帮助你更好地将各种检索技术进行横向比较，融会贯通，从而构建起自己的检索知识体系。

注重实用性，帮你解决实际工作中的问题。我会通过工业界中的实际案例，来详细讲解不同行业会用到的检索技术。这些案例覆盖了多个应用场景和环节，能够解决现阶段你工作中遇到的大部分检索问题，让你能够学以致用。并且，我也不会单纯地讲案例，我更多的会引导你通过这个案例进行更深入的思考，让你在之后的学习和工作中能做到举一反三，解决更多实际问题。

破除“高门槛”，帮你提高学习效率。首先，学习这个专栏对你基础知识的要求不多，只要你熟悉数组和链表，知道怎么评估时间代价，你就可以学习这个专栏。并且，为了避免枯燥的原理分析，我会少用甚至是不用公式，更多地使用具体的例子以及大量的配图，来帮助你理解检索相关的知识。除此之外，对于每一讲的知识点，我都做了合理的分配和设计。虽然知识的深度在逐步增加，但是跟着我的节奏，相信你依然可以很容易理解和吸收它们。

基于这几个目标，我把整个专栏的核心内容分成三大部分：基础技术篇、进阶实战篇和系统案例篇。

在基础技术篇，我会以常见但是核心的数据结构和检索算法作为入门，开启整个专栏的讲解。如果你经验尚浅，那这部分内容可以帮助你打好扎实的基础；如果你有一些实战经验，那这部分内容能让你站在检索技术的角度，重新审视之前熟悉的数据结构和算法，帮助你构建自己的检索知识体系。

在进阶实战篇，我会结合工业界的实际应用场景，更深入地介绍一些高级检索技术，总结一些架构设计的思想，让你能学习到许多工业界的实用且有技术深度的解决方案。如果能深入理解并掌握这部分内容，我相信你会成为各种行业的优秀工程师。

在系统案例篇，我会对当前热门的各个方向进行系统分析，比如，存储系统、搜索引擎、广告系统、推荐系统等。从中，你不仅能学到这些行业中是如何应用检索技术的，还可以了解不同行业中检索技术的共同点和不同点。这会帮助你更好地扩展自己的知识面，让你能站在架构师、甚至更高的角度去思考问题和解决问题。

通过学习这个专栏，你不仅能知道这些基础的数据结构在代码级别提升效率的方法，还能够知道在存储系统、搜索引擎、广告系统和推荐系统这些热门架构中，高效率的设计思想以及某些独特环节的技术处理方式，让你对检索技术的理解和使用都能够更上一层楼，从知道“检索技术是什么”，到学会“利用检索技术解决实际问题”，并且更深入理解“为什么这么用”。

专栏马上要开始更新了，这里我还想多说几句。

如果你是一个对检索完全不懂的“新人”，没关系，遇到不懂、不理解的问题，你可以随时给我留言，我会尽我所能给你解答，帮你快速成长；如果你是一个有着多年经验的高级工程师，欢迎你和我分享你工作中遇到的难题，我们共同探讨、一起成长！

最后，我想听你聊一聊，你是怎样理解检索技术的？对于这个专栏你有什么样的期待？欢迎在留言区畅所欲言，我会第一时间给你反馈！

01 | 线性结构检索：从数组和链表的原理初窥检索本质

你好，我是陈东。欢迎来到专栏的第一节，今天我们主要探讨的是，对于数组和链表这样的线性结构，我们是怎么检索的。希望通过这个探讨的过程，你能深入理解检索到底是什么。

你可以先思考一个问题：什么是检索？从字面上来理解，检索其实就是将我们所需要的信息，从存储数据的地方高效取出的一种技术。所以，检索效率和数据存储的方式是紧密联系的。具体来说，就是不同的存储方式，会导致不同的检索效率。那么，研究数据结构的存储特点对检索效率的影响就很有必要了。

那今天，我们就从数组和链表的存储特点入手，先来看一看它们是如何进行检索的。

数组和链表有哪些存储特点？

数组的特点相信你已经很熟悉了，就是用一块连续的内存空间来存储数据。那如果我申请不到连续的内存空间怎么办？这时候链表就可以派上用场了。链表可以申请不连续的空间，通过一个指针按顺序将这些空间串起来，形成一条链，链表也正是因此得名。不过，严格意义上来说，这个叫单链表。如果没有特别说明，下面我所提到的链表，指的都是只有一个后续指针的单链表。

从图片中我们可以看出，数组和链表分别代表了连续空间和不连续空间的最基础的存储方式，它们是线性表（Linear List）的典型代表。其他所有的数据结构，比如栈、队列、二叉树、B+ 树等，都不外乎是这两者的结合和变化。以栈为例，它本质就是一个限制了读写位置的数组，特点是只允许后进先出。

因此，我们只需要从最基础的数组和链表入手，结合实际应用中遇到的问题去思考解决方案，就能逐步地学习和了解更多的数据结构和检索技术。

那么，数组和链表这两种线性的数据结构的检索效率究竟如何呢？我们来具体看一下。

如何使用二分查找提升数组的检索效率？

首先，如果数据是无序存储的话，无论是数组还是链表，想要查找一个指定元素是否存在，在缺乏数据分布信息的情况下，我们只能从头到尾遍历一遍，才能知道其是否存在。这样的检索效率就是 O(n)。当然，如果数据集不大的话，其实直接遍历就可以了。但如果数据集规模较大的话，我们就需要考虑更高效的检索方式。

对于规模较大的数据集，我们往往是先将它通过排序算法转为有序的数据集，然后通过一些检索算法，比如二分查找算法来完成高效的检索。

二分查找也叫折半查找，它的思路很直观，就是将有序数组二分为左右两个部分，通过只在半边进行查找来提升检索效率。那二分查找具体是怎么实现的呢？让我们一起来看看具体的实现步骤。

我们首先会从中间的元素查起，这就会有三种查询结果。

第一种，是中间元素的值等于我们要查询的值。也就是，查到了，那直接返回即可。

如果中间元素的值小于我们想查询的值，那接下来该怎么查呢？这就是第二种情况了。数组是有序的，所以我们以中间元素为分隔，左半边的数组元素一定都小于中间元素，也就是小于我们想查询的值。因此，我们想查询的值只可能存在于右半边的数组中。

对于右半边的数组，我们还是可以继续使用二分查找的思路，再从它的中间查起，重复上面的过程。这样不停地“二分”下去，每次的检索空间都能减少一半，整体的平均查询效率就是 O(log n)，远远小于遍历整个数组的代价 O(n)。

二分查找图示

同理，对于第三种情况，如果中间元素的值大于我们想查询的值，那么我们就只在左边的数组元素查找即可。

由此可见，合理地组织数据的存储可以提高检索效率。检索的核心思路，其实就是通过合理组织数据，尽可能地快速减少查询范围。在专栏后面的章节中，我们会看到更多的检索算法和技术，其实它们的本质都是通过灵活应用各种数据结构的特点来组织数据，从而达到快速减少查询范围的目的。

链表在检索和动态调整上的优缺点

前面我们说了，数据无序存储的话，链表的检索效率很低。那你可能要问了，有序的链表好像也没法儿提高检索效率啊，这是为什么呢？你可以先停下来自己思考一下，然后再看我下面的讲解。

数组的“连续空间存储”带来了可随机访问的特点。在有序数组应用二分查找时，它以 O(1) 的时间代价就可以直接访问到位于中间的数值，然后以中间的数值为分界线，只选择左边或右边继续查找，从而能快速缩小查询范围。

而链表并不具备“随机访问”的特点。当链表想要访问中间的元素时，我们必须从链表头开始，沿着链一步一步遍历过去，才能访问到期望的数值。如果要访问到中间的节点，我们就需要遍历一半的节点，时间代价已经是 O(n/2) 了。从这个方面来看，由于少了“随机访问位置”的特性，链表的检索能力是偏弱的。

但是，任何事情都有两面性，链表的检索能力偏弱，作为弥补，它在动态调整上会更容易。我们可以以 O(1) 的时间代价完成节点的插入和删除，这是“连续空间”的数组所难以做到的。毕竟如果我们要在有序的数组中插入一个元素，为了保证“数组有序”，我们就需要将数组中排在这个元素后面的元素，全部顺序后移一位，这其实是一个 O(n) 的时间代价了。

有序数组和链表插入新元素的操作和时间代价对比

因此，在一些需要频繁插入删除数据的场合，有序数组不见得是最合适的选择。另一方面，在数据量非常大的场合，我们也很难保证能申请到连续空间来构建有序数组。因此，学会合理高效地使用链表，也是非常重要的。

如何灵活改造链表提升检索效率？

本质上，我们学习链表，就是在学习“非连续存储空间”的组织方案。我们知道，对于“非连续空间”，可以用指针将它串联成一个整体。只要掌握了这个思想，我们就可以在不同的应用场景中，设计出适用的数据结构，而不需要拘泥于链表自身的结构限制。

我们可以来看一个简单的改造例子。

比如说，如果我们觉得链表一个节点一个节点遍历太慢，那么我们是不是可以对它做一个简单的改造呢？在掌握了链表的核心思想后，我们很容易就能想到一个改进方案，那就是让链表每个节点不再只是存储一个元素，而是存储一个小的数组。这样我们就能大幅减少节点的数量，从而减少依次遍历节点带来的“低寻址效率”。

比如说，我的链表就只有两个节点，每个节点都存储了一个小的有序数组。这样在检索的时候，我可以用二分查找的思想，先查询第一个节点存储的小数组的末尾元素，看看是否是我们要查询的数字。如果不是，我们要么在第一个节点存储的小数组里，继续二分查找；要么在第二个节点存储的小数组里，继续二分查找。这样的结构就能同时兼顾数组和链表的特点了，而且时间代价也是 O(log n)。

改造的链表

可见，尽管常规的链表只能遍历检索，但是只要我们掌握了“非连续存储空间可以灵活调整”的特性，就可以设计更高效的数据结构和检索算法了。

重点回顾

好了，这一讲的内容差不多了，我们一起回顾一下这一讲的主要内容：以数组和链表为代表的线性结构的检索技术和效率分析。

首先，我们学习了具体的检索方法。对于无序数组，我们可以遍历检索。对于有序数组，我们可以用二分查找。链表具有灵活调整能力，适合用在数据频繁修改的场合。

其次，你应该也开始体会到了检索的一些核心思想：合理组织数据，尽可能快速减少查询范围，可以提升检索效率。

今天的内容其实不难，涉及的核心思想看起来也很简单，但是对于我们掌握检索这门技术非常重要，你一定要好好理解。

随着咱们的课程深入，后面我们会一一解锁更多高级的检索技术和复杂系统，但是核心思路都离不开我们今天所学的内容。

因此，从最基础的数组和链表入手，之后结合具体的问题去思考解决方案，这样可以帮助你一步一步建立起你的知识体系，从而更好地掌握检索原理，达到提高代码效率，提高系统设计能力的目的。

课堂讨论

结合今天学习的数组和链表的检索技术和效率分析，你可以思考一下这两个问题。

对于有序数组的高效检索，我们为什么使用二分查找算法，而不是 3-7 分查找算法，或 4-6 分查找算法？

对于单个查询值 k，我们已经熟悉了如何使用二分查找。那给出两个查询值 x 和 y 作为查询范围，如果要在有序数组中查找出大于 x 和小于 y 之间的所有元素，我们应该怎么做呢？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这篇文章分享给你的朋友。

02 | 非线性结构检索：数据频繁变化的情况下，如何高效检索？

你好，我是陈东。

当我们在电脑中查找文件的时候，我们一般习惯先打开相应的磁盘，再打开文件夹以及子文件夹，最后找到我们需要的文件。这其实就是一个检索路径。如果把所有的文件展开，这个查找路径其实是一个树状结构，也就是一个非线性结构，而不是一个所有文件平铺排列的线性结构。

树状结构：文件组织例子

我们都知道，有层次的文件组织肯定比散乱平铺的文件更容易找到。这样熟悉的一个场景，是不是会给你一个启发：对于零散的数据，非线性的树状结构是否可以帮我们提高检索效率呢？

另一方面，我们也知道，在数据频繁更新的场景中，连续存储的有序数组并不是最合适的存储方案。因为数组为了保持有序必须不停地重建和排序，系统检索性能就会急剧下降。但是，非连续存储的有序链表倒是具有高效插入新数据的能力。因此，我们能否结合上面的例子，使用非线性的树状结构来改造有序链表，让链表也具有二分查找的能力呢？今天，我们就来讨论一下这个问题。

树结构是如何进行二分查找的？

上一讲我们讲了，因为链表并不具备“随机访问”的特点，所以二分查找无法生效。当链表想要访问中间的元素时，我们必须从链表头开始，沿着指针一步一步遍历，需要遍历一半的节点才能到达中间节点，时间代价是 O(n/2)。而有序数组由于可以“随机访问”，因此只需要 O(1) 的时间代价就可以访问到中间节点了。

那如果我们能在链表中以 O(1) 的时间代价快速访问到中间节点，是不是就可以和有序数组一样使用二分查找了？你先想想看该怎么做，然后我们一起来试着改造一下。

直接记录和访问中间节点

既然我们希望能以 O(1) 的时间代价访问中间节点，那将这个节点直接记录下来是不是就可以了？因此，如果我们把中间节点 M 拎出来单独记录，那我们的第一步操作就是直接访问这个中间节点，然后判断这个节点和要查找的元素是否相等。如果相等，则返回查询结果。如果节点元素大于要查找的元素，那我们就到左边的部分继续查找；反之，则在右边部分继续查找。

对于左边或者右边的部分，我们可以将它们视为两个独立的子链表，依然沿用这个逻辑。如果想用 O(1) 的时间代价就能访问这两个子链表的中间节点，我们就应该把左边的中间节点 L 和右边的中间节点 R，单独拎出来记录。

并且，由于我们是在访问完了 M 节点以后，才决定接下来该去访问左边的 L 还是右边的 R。因此，我们需要将 L 和 M，R 和 M 连接起来。我们可以让 M 带有两个指针，一个左指针指向 L，一个右指针指向 R。这样，在访问 M 以后，一旦发现 M 不是我们要查找的节点，那么，我们接下来就可以通过指针快速访问到 L 或者 R 了。

将 M 节点改为带两个指针，指向 L 节点和 R 节点

对于其余的节点，我们也可以进行同样的处理。下面这个结构，你是不是很熟悉？没错，这就是我们常见的二叉树。你可以再观察一下，这个二叉树和普通的二叉树有什么不一样？

二叉检索树结构

没错，这个二叉树是有序的。它的左子树的所有节点的值都小于根节点，同时右子树所有节点的值都大于等于根节点。这样的有序结构，使得它能使用二分查找算法，快速地过滤掉一半的数据。具备了这样特点的二叉树，就是二叉检索树（Binary Search Tree），或者叫二叉排序树（Binary Sorted Tree）。

讲到这里，不知道你有没有发现，尽管有序数组和二叉检索树，在数据结构形态上看起来差异很大，但是在提高检索效率上，它们的核心原理都是一致的。那么，它们是如何提高检索效率的呢？核心原理又一致在哪里呢？接下来，我们就从两个主要方面来看。

将数据有序化，并且根据数据存储的特点进行不同的组织。对于连续存储空间的数组而言，由于它具有“随机访问”的特性，因此直接存储即可；对于非连续存储空间的有序链表而言，由于它不具备“随机访问”的特性，因此，需要将它改造为可以快速访问到中间节点的树状结构。

在进行检索的时候，它们都是通过二分查找的思想从中间节点开始查起。如果不命中，会快速缩小一半的查询空间。这样不停迭代的查询方式，让检索的时间代价能达到 O(log n) 这个级别。

说到这里，你可能会问，二叉检索树的检索时间代价一定是 O(log n) 吗？其实不一定。

二叉检索树的检索空间平衡方案

我们先来看一个例子。假设，一个二叉树的每一个节点的左指针都是空的，右子树的值都大于根节点。那么它满足二叉检索树的特性，是一颗二叉检索树。但是，如果我们把左边的空指针忽略，你会发现它其实就是一个单链表！单链表的检索效率如何呢？其实是 O(n)，而不是 O(log n)。

退化成链表的二叉检索树

为什么会出现这样的情况呢？

最根本的原因是，这样的结构造成了检索空间不平衡。在当前节点不满足查询条件的时候，它无法把“一半的数据”过滤掉，而是只能过滤掉当前检索的这个节点。因此无法达到“快速减小查询范围”的目的。

因此，为了提升检索效率，我们应该尽可能地保证二叉检索树的平衡性，让左右子树尽可能差距不要太大。这样无论我们是继续往左边还是右边检索，都可以过滤掉一半左右的数据。

也正是为了解决这个问题，有更多的数据结构被发明了出来。比如：AVL 树（平衡二叉树）和红黑树，其实它们本质上都是二叉检索树，但它们都在保证左右子树差距不要太大上做了特殊的处理，保证了检索效率，让二叉检索树可以被广泛地使用。比如，我们常见的 C++ 中的 Set 和 Map 等数据结构，底层就是用红黑树实现的。

这里，我就不再详细介绍 AVL 树和红黑树的具体实现了。为了保证检索效率，我们其实只需要在数据的组织上考虑检索空间的平衡划分就好了，这一点都是一样的。

跳表是如何进行二分查找的？

除了二叉检索树，有序链表还有其他快速访问中间节点的改造方案吗？我们知道，链表之所以访问中间节点的效率低，就是因为每个节点只存储了下一个节点的指针，要沿着这个指针遍历每个后续节点才能到达中间节点。那如果我们在节点上增加一个指针，指向更远的节点，比如说跳过后一个节点，直接指向后面第二个节点，那么沿着这个指针遍历，是不是遍历速度就翻倍了呢？

同理，如果我们能增加更多的指针，提供不同步长的遍历能力，比如一次跳过 4 个节点，甚至一半的节点，那我们是不是就可以更快速地访问到中间节点了呢？

这当然是可以实现的。我们可以为链表的某些节点增加更多的指针。这些指针都指向不同距离的后续节点。这样一来，链表就具备了更高效的检索能力。这样的数据结构就是跳表（Skip List）。

一个理想的跳表，就是从链表头开始，用多个不同的步长，每隔 2^n 个节点做一次直接链接（n 取值为 0，1，2……）。跳表中的每个节点都拥有多个不同步长的指针，我们可以在每个节点里，用一个数组 next 来记录这些指针。next 数组的大小就是这个节点的层数，next[0]就是第 0 层的步长为 1 的指针，next[1]就是第 1 层的步长为 2 的指针，next[2]就是第 2 层的步长为 4 的指针，依此类推。你会发现，不同步长的指针，在链表中的分布是非常均匀的，这使得整个链表具有非常平衡的检索结构。

理想的跳表

举个例子，当我们要检索 k=a6时，从第一个节点 a1开始，用最大步长的指针开始遍历，直接就可以访问到中间节点 a5。但是，如果沿着这个最大步长指针继续访问下去，下一个节点是大于 k 的 a9，这说明 k 在 a5和 a9之间。那么，我们就在 a5和 a9之间，用小一个级别的步长继续查询。这时候，a5的下一个元素是 a7，a7依然大于 k 的值，因此，我们会继续在 a5和 a7之间，用再小一个级别的步长查找，这样就找到 a6了。这个过程其实就是二分查找。时间代价是 O(log n)。

跳表的检索空间平衡方案

不知道你有没有注意到，我在前面强调了一个词，那就是“理想的跳表”。为什么要叫它“理想”的跳表呢？难道在实际情况下，跳表不是这样实现的吗？的确不是。当我们要在跳表中插入元素时，节点之间的间隔距离就被改变了。如果要保证理想链表的每隔 2^n 个节点做一次链接的特性，我们就需要重新修改许多节点的后续指针，这会带来很大的开销。

所以，在实际情况下，我们会在检索性能和修改指针代价之间做一个权衡。为了保证检索性能，我们不需要保证跳表是一个“理想”的平衡状态，只需要保证它在大概率上是平衡的就可以了。因此，当新节点插入时，我们不去修改已有的全部指针，而是仅针对新加入的节点为它建立相应的各级别的跳表指针。具体的操作过程，我们一起来看看。

首先，我们需要确认新加入的节点需要具有几层的指针。我们通过随机函数来生成层数，比如说，我们可以写一个函数 RandomLevel()，以 (1/2)^n 的概率决定是否生成第 n 层。这样，通过简单的随机生成指针层数的方式，我们就可以保证指针的分布，在大概率上是平衡的。

在确认了新节点的层数 n 以后，接下来，我们需要将新节点和前后的节点连接起来，也就是为每一层的指针建立前后连接关系。其实每一层的指针链接，你都可以看作是一个独立的单链表的修改，因此我们只需要用单链表插入节点的方式完成指针连接即可。

这么说，可能你理解起来不是很直观，接下来，我通过一个具体的例子进一步给你解释一下。

我们要在一个最高有 3 层指针的跳表中插入一个新元素 k，这个跳表的结构如下图所示。

假设我们通过跳表的检索已经确认了，k 应该插入到 a6和 a7两个节点之间。那接下来，我们要先为新节点随机生成一个层数。假设生成的层数为 2，那我们就要修改第 0 层和第 1 层的指针关系。对于第 0 层的链表，k 需要插入到 a6和 a7之间，我们只需要修改 a6和 a7的第 0 层指针；对于第 1 层的链表，k 需要插入到 a5和 a7之间，我们只需要修改 a5和 a7的第 1 层指针。这样，我们就完成了将 k 插入到跳表中的动作。

通过这样一种方式，我们可以大大减少修改指针的代价。当然，由于新加入节点的层数是随机生成的，因此在节点数目较少的情况下，如果指针分布的不合理，检索性能依然可能不高。但是当节点数较多的时候，指针会趋向均匀分布，查找空间会比较平衡，检索性能会趋向于理想跳表的检索效率，接近 O(log n)。

因此，相比于复杂的平衡二叉检索树，如红黑树，跳表用一种更简单的方式实现了检索空间的平衡。并且，由于跳表保持了链表顺序遍历的能力，在需要遍历功能的场景中，跳表会比红黑树用起来更方便。这也就是为什么，在 Redis 这样的系统中，我们经常会利用跳表来代替红黑树作为底层的数据结构。

重点回顾

好了，关于非线性结构的检索技术，我们就先讲到这里。我们一起回顾一下今天的重点内容。

首先，对于数据频繁变化的应用场景，有序数组并不是最适合的解决方案。我们一般要考虑采用非连续存储的数据结构来灵活调整。同时，为了提高检索效率，我们还要采取合理的组织方式，让这些非连续存储的数据结构能够使用二分查找算法。

数据组织的方式有两种，一种是二叉检索树。一个平衡的二叉检索树使用二分查找的检索效率是 O(log n)，但如果我们不做额外的平衡控制的话，二叉检索树的检索性能最差会退化到 O(n)，也就和单链表一样了。所以，AVL 树和红黑树这样平衡性更强的二叉检索树，在实际工作中应用更多。

除了树结构以外，另一种数据组织方式是跳表。跳表也具备二分查找的能力，理想跳表的检索效率是 O(log n)。为了保证跳表的检索空间平衡，跳表为每个节点随机生成层级，这样的实现方式比 AVL 树和红黑树更简单。

无论是二叉检索树还是跳表，它们都是通过将数据进行合理组织，然后尽可能地平衡划分检索空间，使得我们能采用二分查找的思路快速地缩减查找范围，达到 O(log n) 的检索效率。

除此之外，我们还能发现，当我们从实际问题出发，去思考每个数据结构的特点以及解决方案时，我们就会更好地理解一些高级数据结构和算法的来龙去脉，从而达到更深入地理解和吸收知识的目的。并且，这种思考方式，会在不知不觉中提升你的设计能力以及解决问题的能力。

课堂讨论

今天的内容比较多，你可以结合我留的课堂讨论题，加深理解。

二叉检索树和跳表都能做到 O(log n) 的查询时间代价，还拥有灵活的调整能力，并且调整代价也是 O(log n)（包括了寻找插入位置的时间代价）。而有序数组的查询时间代价也是 O(log n)，调整代价是 O(n)，那这是不是意味着二叉检索树或者跳表可以用来替代有序数组呢？有序数组自己的优势又是什么呢？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这篇文章分享给你的朋友。

03 | 哈希检索：如何根据用户ID快速查询用户信息？

你好，我是陈东。

在实际应用中，我们经常会面临需要根据键（Key）来查询数据的问题。比如说，给你一个用户 ID，要求你查出该用户的具体信息。这样的需求我们应该如何实现呢？你可能会想到，使用有序数组和二叉检索树都可以来实现。具体来说，我们可以将用户 ID 和用户信息作为一个整体的元素，然后以用户 ID 作为 Key 来排序，存入有序数组或者二叉检索树中，这样我们就能通过二分查找算法快速查询到用户信息了。

但是，不管是有序数组、二叉检索树还是跳表，它们的检索效率都是 O(log n)。那有没有更高效的检索方案呢？也就是说，有没有能实现 O(1) 级别的查询方案呢？今天，我们就一起来探讨一下这个问题。

使用 Hash 函数将 Key 转换为数组下标

在第 1 讲中我们说过，数组具有随机访问的特性。那给定一个用户 ID，想要查询对应的用户信息，我们能否利用数组的随机访问特性来实现呢？

我们先来看一个例子。假设系统中的用户 ID 是从 1 开始的整数，并且随着注册数的增加而增加。如果系统中的用户数是有限的，不会大于 10 万。那么用户的 ID 范围就会被固定在 1 到 10 万之间。在数字范围有限的情况下，我们完全可以申请一个长度为 10 万的数组，然后将用户 ID 作为数组下标，从而实现 O(1) 级别的查询能力。

将用户 ID 直接作为下标查询 ，由于数组下标从 0 开始，因此查询时 ID 要减 1

注意，刚才我们举的这个例子中有一个假设：用户的 ID 是一个数字，并且范围有限。符合这种假设的用户 ID 才能作为数组下标，使用数组的随机访问特性，达到 O(1) 时间代价的高效检索能力。那如果用户的 ID 数字范围很大，数组无法申请这么大的空间该怎么办呢？或者，用户的 ID 不是数字而是字符串，还能作为数组下标吗？

我们假设有一个系统使用字符串作为用户 ID。如果有一个用户的 ID 是“tom”，我们该怎么处理呢？我们能否将它转换为一个数字来表示呢？你可以先想一想解决方案，再和我继续往下分析。

我们来考虑这样一种方案：字母表是有限的，只有 26 个，我们可以用字母在字母表中的位置顺序作为数值。于是，就有：“t” = 20，“o” = 15，“m” = 13。我们可以把这个 ID 看作是 26 进制的数字，那么对于“tom”这个字符串，把它转为对应的数值就是 20 * 26^2 + 15*26 + 13 =149123，这是一个小于 26^4 = 456976‬的数。

如果所有用户的 ID 都不超过 3 个字符，使用这个方法，我们用一个可以存储 456976‬个元素的数组就可以存储所有用户的信息了。实际上，工业界有许多更复杂的将字符串转为整数的哈希算法，但核心思想都是利用每个字符的编码和位置信息进行计算，这里我就不展开了。

那如果内存空间有限，我们只能开辟一个存储 10000 个元素的数组该怎么办呢？这个时候，我们可以使用“tom”对应的数值 149123 除以数组长度 10000，得到余数 9123，用这个余数作为数组下标。

这种将对象转为有限范围的正整数的表示方法，就叫作 Hash，翻译过来叫散列，也可以直接音译为哈希。而我们常说的 Hash 函数就是指具体转换方法的函数。我们将对象进行 Hash，用得到的 Hash 值作为数组下标，将对应元素存在数组中。这个数组就叫作哈希表。这样我们就可以利用数组的随机访问特性，达到 O(1) 级别的查询性能。

说到这里，你可能会有疑问了，Hash 函数真的这么神奇吗？如果两个对象的哈希值是相同的怎么办？事实上，任何 Hash 函数都有可能造成对象不同，但 Hash 值相同的冲突。而且，数组空间是有限的，只要被 Hash 的元素个数大于数组上限，就一定会产生冲突。

对于哈希冲突这个问题，我们有两类解决方案: 一类是构造尽可能理想的 Hash 函数，使得 Hash 以后得到的数值尽可能平均分布，从而减少冲突发生的概率；另一类是在冲突发生以后，通过“提供冲突解决方案”来完成存储和查找。最常用的两种冲突解决方案是“开放寻址法”和“链表法”。下面，我就来介绍一下这两种方法，并且重点看看它们对检索效率的影响。

如何利用开放寻址法解决 Hash 冲突？

所谓“开放寻址法”，就是在冲突发生以后，最新的元素需要寻找新空闲的数组位置完成插入。那我们该如何寻找新空闲的位置呢？我们可以使用一种叫作“线性探查”（Linear Probing）的方案来进行查找。

“线性探查”的插入逻辑很简单：在当前位置发现有冲突以后，就顺序去查看数组的下一个位置，看看是否空闲。如果有空闲，就插入；如果不是空闲，再顺序去看下一个位置，直到找到空闲位置插入为止。

查询逻辑也和插入逻辑相似。我们先根据 Hash 值去查找相应数组下标的元素，如果该位置不为空，但是存储元素的 Key 和查询的 Key 不一致，那就顺序到数组下一个位置去检索，就这样依次比较 Key。如果访问元素的 Key 和查询 Key 相等，我们就在哈希表中找到了对应元素；如果遍历到空闲处，依然没有元素的 Key 和查询 Key 相等，则说明哈希表中不存在该元素。

为了帮助你更好地理解，我们来看一个例子。

假设一个哈希表中已经插入了两个 Key，key1 和 key2。其中 Hash(key1) = 1, Hash(key2) = 2。这时，如果我们要插入一个 Hash 值为 1 的 key3。根据线性探查的插入逻辑，通过 3 步，我们就可以将 key3 插入到哈希表下标为 3 的位置中。插入的过程如下：

在查找 key3 的时候，因为 Hash（key3）= 1，我们会从哈希表下标为 1 的位置开始顺序查找，经过 3 步找到 key3，查询结束。

讲到这里，你可能已经发现了一个问题：当我们插入一个 Key 时，如果哈希表已经比较满了，这个 Key 就会沿着数组一直顺序遍历，直到遇到空位置才会成功插入。查询的时候也一样。但是，顺序遍历的代价是 O(n)，这样的检索性能很差。

更糟糕的是，如果我们在插入 key1 后，先插入 key3 再插入 key2，那 key3 就会抢占 key2 的位置，影响 key2 的插入和查询效率。因此，“线性探查”会影响哈希表的整体性能，而不只是 Hash 值冲突的 Key。

为了解决这个问题，我们可以使用“二次探查”（Quadratic Probing）和“双散列”（Double Hash）这两个方法进行优化。下面，我来分别解释一下这两个方法的优化原理。

二次探查就是将线性探查的步长从 i 改为 i^2：第一次探查，位置为 Hash(key) + 1^2；第二次探查，位置为 Hash(key) +2^2；第三次探查，位置为 Hash(key) + 3^2，依此类推。

双散列就是使用多个 Hash 函数来求下标位置，当第一个 Hash 函数求出来的位置冲突时，启用第二个 Hash 函数，算出第二次探查的位置；如果还冲突，则启用第三个 Hash 函数，算出第三次探查的位置，依此类推。

无论是二次探查还是双散列，核心思路其实都是在发生冲突的情况下，将下个位置尽可能地岔开，让数据尽可能地随机分散存储，来降低对不相干 Key 的干扰，从而提高整体的检索效率。

但是，对于开放寻址法来说，无论使用什么优化方案，随着插入的元素越多、哈希表越满，插入和检索的性能也就下降得越厉害。在极端情况下，当哈希表被写满的时候，为了保证能插入新元素，我们只能重新生成一个更大的哈希表，将旧哈希表中的所有数据重新 Hash 一次写入新的哈希表，也就是 Re-Hash，这样会造成非常大的额外开销。因此，在数据动态变化的场景下，使用开放寻址法并不是最合适的方案。

如何利用链表法解决 Hash 冲突？

相比开放寻址法，还有一种更常见的冲突解决方案，链表法。所谓“链表法”，就是在数组中不存储一个具体元素，而是存储一个链表头。如果一个 Key 经过 Hash 函数计算，得到了对应的数组下标，那么我们就将它加入该位置所存的链表的尾部。

这样做的好处是，如果 key3 和 key1 发生了冲突，那么 key3 会通过链表的方式链接在 key1 的后面，而不是去占据 key2 的位置。这样当 key2 插入时，就不会有冲突了。最终效果如下图。

链表法

讲到这里，你可能已经发现了，其实链表法就是将我们前面讲过的数组和链表进行结合，既利用了数组的随机访问特性，又利用了链表的动态修改特性，同时提供了快速查询和动态修改的能力。

想要查询时，我们会先根据查询 Key 的 Hash 值，去查找相应数组下标的链表。如果链表为空，则表示不存在该元素；如果链表不为空，则遍历链表，直到找到 Key 相等的对应元素为止。

但是，如果链表很长，遍历代价还是会很高。那我们有没有更好的检索方案呢？你可以回想一下，在上一讲中我们就是用二叉检索树或跳表代替链表，来提高检索效率的。

实际上，在 JDK1.8 之后，Java 中 HashMap 的实现就是在链表到了一定的长度时，将它转为红黑树；而当红黑树中的节点低于一定阈值时，就将它退化为链表。

链表法：用红黑树来优化长链表

第一个阶段，通过 Hash 函数将要查询的 Key 转为数组下标，去查询对应的位置。这个阶段的查询代价是 O(1) 级别。

第二阶段，将数组下标所存的链表头或树根取出。如果是链表，就使用遍历的方式查找，这部分查询的时间代价是 O(n)。由于链表长度是有上限的，因此实际开销并不会很大，可以视为常数级别时间。如果是红黑树，则用二分查找的方式去查询，时间代价是 O(log n)。如果哈希表中冲突的元素不多，那么落入红黑树的数据规模也不会太大，红黑树中的检索代价也可以视为常数级别时间。

哈希表有什么缺点？

哈希表既有接近 O(1) 的检索效率，又能支持动态数据的场景，看起来非常好，那是不是在任何场景下，我们都可以用它来代替有序数组和二叉检索树呢？答案是否定的。前面我们说了这么多哈希表的优点，下面我们就来讲讲它的缺点。

首先，哈希表接近 O(1) 的检索效率是有前提条件的，就是哈希表要足够大和有足够的空闲位置，否则就会非常容易发生冲突。我们一般用装载因子（load factor）来表示哈希表的填充率。装载因子 = 哈希表中元素个数 / 哈希表的长度。

如果频繁发生冲突，大部分的数据会被持续地添加到链表或二叉检索树中，检索也会发生在链表或者二叉检索树中，这样检索效率就会退化。因此，为了保证哈希表的检索效率，我们需要预估哈希表中的数据量，提前生成足够大的哈希表。按经验来说，我们一般要预留一半以上的空闲位置，哈希表才会有足够优秀的检索效率。这就让哈希表和有序数组、二叉检索树相比，需要的存储空间更多了。

另一方面，尽管哈希表使用 Hash 值直接进行下标访问，带来了 O(1) 级别的查询能力，但是也失去了“有序存储”这个特点。因此，如果我们的查询场景需要遍历数据，或者需要进行范围查询，那么哈希表本身是没有什么加速办法的。比如说，如果我们在一个很大的哈希表中存储了少数的几个元素，为了知道存储了哪些元素，我们只能从哈希表的第一个位置开始遍历，直到结尾，这样性能并不好。

重点回顾

好了，关于哈希检索我们就讲到这里。你会看到，哈希表的本质是一个数组，它通过 Hash 函数将查询的 Key 转为数组下标，利用数组的随机访问特性，使得我们能在 O(1) 的时间代价内完成检索。

尽管哈希检索没有使用二分查找，但无论是设计理想的哈希函数，还是保证哈希表有足够的空闲位置，包括解决冲突的“二次探查”和“双散列”方案，本质上都是希望数据插入哈希表的时候，分布能均衡，这样检索才能更高效。从这个角度来看，其实哈希检索提高检索效率的原理，和二叉检索树需要平衡左右子树深度的原理是一样的，也就是说，高效的检索需要均匀划分检索空间。

另一方面，你会看到，复杂的数据结构和检索算法其实都是由最基础的数据结构和算法组成的。比如说 JDK1.8 中哈希表的实现，就是使用了数组、链表、红黑树这三种数据结构和相应的检索算法。因此，对于这些基础的数据结构，我们需要深刻地理解它们的检索原理和适用场景，这也为我们未来学习更复杂的系统打下了扎实的基础。

课堂讨论

假设一个哈希表是使用开放寻址法实现的，如果我们需要删除其中一个元素，可以直接删除吗？为什么呢？如果这个哈希表是使用链表法实现的会有不同吗？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这篇文章分享给你的朋友。

04 | 状态检索：如何快速判断一个用户是否存在？

你好，我是陈东。

在实际工作中，我们经常需要判断一个对象是否存在。比如说，在注册新用户时，我们需要先快速判断这个用户 ID 是否被注册过；再比如说，在爬虫系统抓取网页之前，我们要判断一个 URL 是否已经被抓取过，从而避免无谓的、重复的抓取工作。

那么，对于这一类是否存在的状态检索需求，如果直接使用我们之前学习过的检索技术，有序数组、二叉检索树以及哈希表来实现的话，它们的检索性能如何呢？是否还有优化的方案呢？今天，我们就一起来讨论一下这些问题。

如何使用数组的随机访问特性提高查询效率？

以注册新用户时查询用户 ID 是否存在为例，我们可以直接使用有序数组、二叉检索树或者哈希表来存储所有的用户 ID。

我们知道，无论是有序数组还是二叉检索树，它们都是使用二分查找的思想从中间元素开始查起的。所以，在查询用户 ID 是否存在时，它们的平均检索时间代价都是 O(log n)，而哈希表的平均检索时间代价是 O(1)。因此，如果我们希望能快速查询出元素是否存在，那哈希表无疑是最合适的选择。不过，如果从工程实现的角度来看的话，哈希表的查询过程还是可以优化的。

比如说，如果我们要查询的对象 ID 本身是正整数类型，而且 ID 范围有上限的话。我们就可以申请一个足够大的数组，让数组的长度超过 ID 的上限。然后，把数组中所有位置的值都初始化为 0。对于存在的用户，我们直接将用户 ID 的值作为数组下标，将该位置的值从 0 设为 1 就可以了。

这种情况下，当我们查询一个用户 ID 是否存在时，会直接以该 ID 为数组下标去访问数组，如果该位置为 1，说明该 ID 存在；如果为 0，就说明该 ID 不存在。和哈希表的查找流程相比，这个流程就节省了计算哈希值得到数组下标的环节，并且直接利用数组随机访问的特性，在 O(1) 的时间内就能判断出元素是否存在，查询效率是最高的。

但是，直接使用 ID 作为数组下标会有一个问题：如果 ID 的范围比较广，比如说在 10 万之内，那我们就需要保证数组的长度大于 10 万。所以，这种方案的占用空间会很大。

而且，如果这个数组是一个 int 32 类型的整型数组，那么每个元素就会占据 4 个字节，用 4 个字节来存储 0 和 1 会是一个巨大的空间浪费。那我们该如何优化呢？你可以先想一想，然后我们一起来讨论。

如何使用位图来减少存储空间？

最直观的一个想法就是，使用最少字节的类型来定义数组。比如说，使用 1 个字节的 char 类型数组，或者使用 bool 类型的数组（在许多系统中，一个 bool 类型的元素也是 1 个字节）。它们和 4 个字节的 int 32 数组相比，空间使用效率提升了 4 倍，这已经算是不错的改善了。

但是，使用 char 类型的数组，依然是一个非常“浪费空间”的方案。因为表示 0 或者 1，理论上只需要一个 bit。所以，如果我们能以 bit 为单位来构建这个数组，那使用空间就是 int 32 数组的 1/32，从而大幅减少了存储使用的内存空间。这种以 bit 为单位构建数组的方案，就叫作 Bitmap，翻译为位图。

位图的优势非常明显，但许多系统中并没有以 bit 为单位的数据类型。因此，我们往往需要对其他类型的数组进行一些转换设计，使其能对相应的 bit 位的位置进行访问，从而实现位图。

我们以 char 类型的数组为例子。假设我们申请了一个 1000 个元素的 char 类型数组，每个 char 元素有 8 个 bit，如果一个 bit 表示一个用户，那么 1000 个元素的 char 类型数组就能表示 8*1000 = 8000 个用户。如果一个用户的 ID 是 11，那么位图中的第 11 个 bit 就表示这个用户是否存在的信息。

这种情况下，我们怎么才能快速访问到第 11 个 bit 呢？

首先，数组是以 char 类型的元素为一个单位的，因此，我们的第一步，就是要找到第 11 个 bit 在数组的第几个元素里。具体的计算过程：一个元素占 8 个 bit，我们用 11 除以 8，得到的结果是 1，余数是 3。这就代表着，第 11 个 bit 存在于第 2 个元素里，并且在第 2 个元素里的位置是第 3 个。

对于第 2 个元素的访问，我们直接使用数组下标[1]就可以在 O(1) 的时间内访问到。对于第 2 个元素中的第 3 个 bit 的访问，我们可以通过位运算，先构造一个二进制为 00100000 的字节（字节的第 3 位为 1），然后和第 2 个元素做 and 运算，就能得知该元素的第 3 位是 1 还是 0。这也是一个时间代价为 O(1) 的操作。这样一来，通过两次 O(1) 时间代价的查找，我们就可以知道第 11 个 bit 的值是 0 还是 1 了。

用户 ID 为 11 的位图定位

尽管位图相对于原始数组来说，在元素存储上已经有了很大的优化，但如果我们还想进一步优化存储空间，是否还有其他的优化方案呢？我们知道，一个数组所占的空间其实就是“数组元素个数 * 每个元素大小”。我们已经将每个元素大小压缩到了最小单位 1 个 bit，如果还要进行优化，那么自然会想到优化“数组元素个数”。

没错，限制数组的长度是一个可行的方案。不过前面我们也说了，数组长度必须大于 ID 的上限。因此，如果我们希望将数组长度压缩到一个足够小的值之内，我们就需要使用哈希函数将大于数组长度的用户 ID，转换为一个小于数组长度的数值作为下标。除此以外，使用哈希函数也带来了另一个优点，那就是我们不需要把用户 ID 限制为正整数了，它也可以是字符串。这样一来，压缩数组长度，并使用哈希函数，就是一个更加通用的解决方案。

但是我们也知道，数组压缩得越小，发生哈希冲突的可能性就会越大，如果两个元素 A 和 B 的哈希值冲突了，映射到了同一个位置。那么，如果我们查询 A 时，该位置的结果为 1，其实并不能说明元素 A 一定存在。因此，如何在数组压缩的情况下缓解哈希冲突，保证一定的查询正确率，是我们面临的主要问题。

在第 3 讲中，我们讲了哈希表解决哈希冲突的两种常用方法：开放寻址法和链表法。开放寻址法中有一个优化方案叫“双散列”，它的原理是使用多个哈希函数来解决冲突问题。我们能否借鉴这个思想，在位图的场景下使用多个哈希函数来降低冲突概率呢？没错，这其实就是布隆过滤器（Bloom Filter）的设计思想。

布隆过滤器最大的特点，就是对一个对象使用多个哈希函数。如果我们使用了 k 个哈希函数，就会得到 k 个哈希值，也就是 k 个下标，我们会把数组中对应下标位置的值都置为 1。布隆过滤器和位图最大的区别就在于，我们不再使用一位来表示一个对象，而是使用 k 位来表示一个对象。这样两个对象的 k 位都相同的概率就会大大降低，从而能够解决哈希冲突的问题了。

Bloom filter 示例

但是，布隆过滤器的查询有一个特点，就是即使任何两个元素的哈希值不冲突，而且我们查询对象的 k 个位置的值都是 1，查询结果为存在，这个结果也可能是错误的。这就叫作布隆过滤器的错误率。

我在下图给出了一个例子。我们可以看到，布隆过滤器中存储了 x 和 y 两个对象，它们对应的 bit 位被置为 1。当我们查询一个不存在的对象 z 时，如果 z 的 k 个哈希值的对应位置的值正好都是 1，z 就会被错误地认定为存在。而且，这个时候，z 和 x，以及 z 和 y，两两之间也并没有发生哈希冲突。

Bloom filter 错误率示例

那遇到“可能存在”这样的情况，我们该怎么办呢？不要忘了我们的使用场景：我们希望用更小的代价快速判断 ID 是否已经被注册了。在这个使用场景中，就算我们无法确认 ID 是否已经被注册了，让用户再换一个 ID 注册，这也不会损害新用户的体验。在系统不要求结果 100% 准确的情况下，我们可以直接当作这个用户 ID 已经被注册了就可以了。这样，我们使用布隆过滤器就可以快速完成“是否存在”的检索。

除此之外，对于布隆过滤器而言，如果哈希函数的个数不合理，比如哈希函数特别多，布隆过滤器的错误率就会变大。因此，除了使用多个哈希函数避免哈希冲突以外，我们还要控制布隆过滤器中哈希函数的个数。有这样一个计算最优哈希函数个数的数学公式: 哈希函数个数 k = (m/n) * ln(2)。其中 m 为 bit 数组长度，n 为要存入的对象的个数。实际上，如果哈希函数个数为 1，且数组长度足够，布隆过滤器就可以退化成一个位图。所以，我们可以认为“位图是只有一个特殊的哈希函数，且没有被压缩长度的布隆过滤器”。

重点回顾

好了，状态检索的内容我们就讲到这里。我们一起来总结一下，这一讲你要掌握的重点内容。

今天，我们主要解决了快速判断一个对象是否存在的问题。相比于有序数组、二叉检索树和哈希表这三种方案，位图和布隆过滤器其实更适合解决这类状态检索的问题。这是因为，在不要求 100% 判断正确的情况下，使用位图和布隆过滤器可以达到 O(1) 时间代价的检索效率，同时空间使用率也非常高效。

虽然位图和布隆过滤器的原理和实现都非常简单，但是在许多复杂的大型系统中都可以见到它们的身影。

比如，存储系统中的数据是存储在磁盘中的，而磁盘中的检索效率非常低，因此，我们往往会先使用内存中的布隆过滤器来快速判断数据是否存在，不存在就直接返回，只有可能存在才会去磁盘检索，这样就避免了为无效数据读取磁盘的额外开销。

再比如，在搜索引擎中，我们也需要使用布隆过滤器快速判断网站是否已经被抓取过，如果一定不存在，我们就直接去抓取；如果可能存在，那我们可以根据需要，直接放弃抓取或者再次确认是否需要抓取。你会发现，这种快速预判断的思想，也是提高应用整体检索性能的一种常见设计思路。

课堂讨论

这节课的内容，你可以结合这道讨论题进一步加深理解：

如果位图中一个元素被删除了，我们可以将对应 bit 位置为 0。但如果布隆过滤器中一个元素被删除了，我们直接将对应的 k 个 bit 位置为 0，会产生什么样的问题呢？为什么？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这篇文章分享给你的朋友。

05 | 倒排索引：如何从海量数据中查询同时带有“极”和“客”的唐诗？

你好，我是陈东。

试想这样一个场景：假设你已经熟读唐诗 300 首了。这个时候，如果我给你一首诗的题目，你可以马上背出这首诗的内容吗？相信你一定可以的。但是如果我问你，有哪些诗中同时包含了“极”字和“客”字？你就不见得能立刻回答出来了。你需要在头脑中一首诗一首诗地回忆，并判断每一首诗的内容是否同时包含了“极”字和“客”字。很显然，第二个问题的难度比第一个问题大得多。

那从程序设计的角度来看，这两个问题对应的检索过程又有什么不同呢？今天，我们就一起来聊一聊，两个非常常见又非常重要的检索技术：正排索引和倒排索引。

什么是倒排索引？

我们先来看比较简单的那个问题：给出一首诗的题目，马上背出内容。这其实就是一个典型的键值查询场景。针对这个场景，我们可以给每首诗一个唯一的编号作为 ID，然后使用哈希表将诗的 ID 作为键（Key），把诗的内容作为键对应的值（Value）。这样，我们就能在 O(1) 的时间代价内，完成对指定 key 的检索。这样一个以对象的唯一 ID 为 key 的哈希索引结构，叫作正排索引（Forward Index）。

哈希表存储所有诗

一般来说，我们会遍历哈希表，遍历的时间代价是 O(n)。在遍历过程中，对于遇到的每一个元素也就是每一首诗，我们需要遍历这首诗中的每一个字符，才能判断是否包含“极”字和“客”字。假设每首诗的平均长度是 k，那遍历一首诗的时间代价就是 O(k)。从这个分析中我们可以发现，这个检索过程全部都是遍历，因此时间代价非常高。对此，有什么优化方法吗？

我们先来分析一下这两个场景。我们会发现，“根据题目查找内容”和“根据关键字查找题目”，这两个问题其实是完全相反的。既然完全相反，那我们能否“反着”建立一个哈希表来帮助我们查找呢？也就是说，如果我们以关键字作为 key 建立哈希表，是不是问题就解决了呢？接下来，我们就试着操作一下。

我们将每个关键字当作 key，将包含了这个关键字的诗的列表当作存储的内容。这样，我们就建立了一个哈希表，根据关键字来查询这个哈希表，在 O(1) 的时间内，我们就能得到包含该关键字的文档列表。这种根据具体内容或属性反过来索引文档标题的结构，我们就叫它倒排索引（Inverted Index）。在倒排索引中，key 的集合叫作字典（Dictionary），一个 key 后面对应的记录集合叫作记录列表（Posting List）。

倒排索引

如何创建倒排索索引？

前面我们介绍了倒排索引的概念，那创建一个倒排索引的过程究竟是怎样的呢？我把这个过程总结成了以下步骤。

给每个文档编号，作为其唯一的标识，并且排好序，然后开始遍历文档（为什么要先排序，然后再遍历文档呢？你可以先想一下，后面我们会解释）。

解析当前文档中的每个关键字，生成 < 关键字，文档 ID，关键字位置 > 这样的数据对。为什么要记录关键字位置这个信息呢？因为在许多检索场景中，都需要显示关键字前后的内容，比如，在组合查询时，我们要判断多个关键字之间是否足够近。所以我们需要记录位置信息，以方便提取相应关键字的位置。

将关键字作为 key 插入哈希表。如果哈希表中已经有这个 key 了，我们就在对应的 posting list 后面追加节点，记录该文档 ID（关键字的位置信息如果需要，也可以一并记录在节点中）；如果哈希表中还没有这个 key，我们就直接插入该 key，并创建 posting list 和对应节点。

重复第 2 步和第 3 步，处理完所有文档，完成倒排索引的创建。

将一个文档解析并加入倒排索引

如何查询同时含有“极”字和“客”字两个 key 的文档？

如果只是查询包含“极”或者“客”这样单个字的文档，我们直接以查询的字作为 key 去倒排索引表中检索，得到的 posting list 就是结果了。但是，如果我们的目的是要查询同时包含“极”和“客”这两个字的文档，那我们该如何操作呢？

我们可以先分别用两个 key 去倒排索引中检索，这样会得到两个不同的 posting list：A 和 B。A 中的文档都包含了“极”字，B 中文档都包含了“客”字。那么，如果一个文档既出现在 A 中，又出现在 B 中，它是不是就同时包含了这两个字呢？按照这个思路，我们只需查找出 A 和 B 的公共元素即可。

那么问题来了，我们该如何在 A 和 B 这两个链表中查找出公共元素呢？如果 A 和 B 都是无序链表，那我们只能将 A 链表和 B 链表中的每个元素分别比对一次，这个时间代价是 O(m*n)。但是，如果两个链表都是有序的，我们就可以用归并排序的方法来遍历 A 和 B 两个链表，时间代价会降低为 O(m + n)，其中 m 是链表 A 的长度，n 是链表 B 的长度。

我把链表归并的过程总结成了 3 个步骤，你可以结合我在图片中给出的例子来理解。

第 1 步，使用指针 p1 和 p2 分别指向有序链表 A 和 B 的第一个元素。

第 2 步，对比 p1 和 p2 指向的节点是否相同，这时会出现 3 种情况：

两者的 id 相同，说明该节点为公共元素，直接将该节点加入归并结果。然后，p1 和 p2 要同时后移，指向下一个元素；

p1 元素的 id 小于 p2 元素的 id，p1 后移，指向 A 链表中下一个元素；

p1 元素的 id 大于 p2 元素的 id，p2 后移，指向 B 链表中下一个元素。

第 3 步，重复第 2 步，直到 p1 或 p2 移动到链表尾为止。

链表归并提取公共元素例子

那对于两个 key 的联合查询来说，除了有“同时存在”这样的场景以外，其实还有很多联合查询的实际例子。比如说，我们可以查询包含“极”或“客”字的诗，也可以查询包含“极”且不包含“客”的诗。这些场景分别对应着集合合并中的交集、并集和差集问题。它们的具体实现方法和“同时存在”的实现方法差不多，也是通过遍历链表对比的方式来完成的。如果感兴趣的话，你可以自己来实现看看，这里我就不再多做阐述了。

此外，在实际应用中，我们可能还需要对多个 key 进行联合查询。比如说，要查询同时包含“极”“客”“时”“间”四个字的诗。这个时候，我们利用多路归并的方法，同时遍历这四个关键词对应的 posting list 即可。实现过程如下图所示。

多路归并

重点回顾

好了，今天的内容就先讲到这里。你会发现，倒排索引的核心其实并不复杂，它的具体实现其实是哈希表，只是它不是将文档 ID 或者题目作为 key，而是反过来，通过将内容或者属性作为 key 来存储对应的文档列表，使得我们能在 O(1) 的时间代价内完成查询。

尽管原理并不复杂，但是倒排索引是许多检索引擎的核心。比如说，数据库的全文索引功能、搜索引擎的索引、广告引擎和推荐引擎，都使用了倒排索引技术来实现检索功能。因此，这一讲的内容我也希望你能好好理解消化，打好扎实的基础。

课堂讨论

今天的内容实践性比较强，你可以结合下面这道课堂讨论题，动手试一试，加深理解。

对于一个检索系统而言，除了根据关键字查询文档，还可能有其他的查询需求。比如说，我们希望查询李白都写了哪些诗。也就是说，如何在“根据内容查询”的基础上，同时支持“根据作者查询”，我们该怎么做呢？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这篇文章分享给你的朋友。

测一测 | 检索算法基础，你掌握了多少？

你好，我是陈东。欢迎来到基础技术篇的测试环节！

经过这几篇的学习，检索相关的基础数据结构和算法，你掌握了多少呢？为了帮助你巩固和复习之前讲到的知识，我精心设计了一套测试题，希望能帮你巩固所学，温故知新。

在这套测试题中，有 20 道选择题，每道题 5 分，满分为 100。这是我们这套测试题最核心的部分。建议你花上 30 分钟，好好完成这套题目。

最后呢，我还为你准备了一道主观题，这道题为选做。 如果你对自己有更高的要求，我希望你可以认真思考一下，然后把你的思考过程和最终答案都写在留言区，我们一起探讨。因为主观题考察的是你的设计能力，所以你可以多思考几天。我会在下周三把解题思路放到评论区置顶，到时，记得来看啊!

还等什么，点击下面按钮开始测试吧！

主观题

假设有一个员工管理系统，它存储了用户的 ID、姓名、所属部门等信息。如果我们需要它支持以下查询能力：

1. 根据员工 ID 查找员工信息，并支持 ID 的范围查询；

2. 根据姓名查询员工信息；

3. 根据部门查询部门里有哪些员工。

那使用我们在基础篇中学习到的知识，你会怎么设计和实现这些功能呢？（小提示：你可以先想一下，这个员工管理系统是怎么存储员工信息的，然后再来设计这些功能）

特别加餐 | 倒排检索加速（一）：工业界如何利用跳表、哈希表、位图进行加速？

你好，我是陈东。欢迎来到检索专栏的第一次加餐时间。

很多同学在留言区提问，说基础篇讲了这么多检索的基础数据结构和算法，那它们在工业界的实际系统中是怎么应用的呢？真正的检索系统和算法又是什么样的呢？

为了帮助你把这些基础的知识，更好地和实际应用结合。我特别准备了两篇加餐，来和你一起聊一聊，这些看似简单的基础技术是怎样在工业界的实际系统中发挥重要作用的。

在许多大型系统中，倒排索引是最常用的检索技术，搜索引擎、广告引擎、推荐引擎等都是基于倒排索引技术实现的。而在倒排索引的检索过程中，两个 posting list 求交集是一个最重要、最耗时的操作。

所以，今天我们就先来看一看，倒排索引在求交集的过程中，是如何借助跳表、哈希表和位图，这些基础数据结构进行加速的。

跳表法加速倒排索引

在第 5 讲中我们讲过，倒排索引中的 posting list 一般是用链表来实现的。当两个 posting list A 和 B 需要合并求交集时，如果我们用归并法来合并的话，时间代价是 O(m+n)。其中，m 为 posting list A 的长度，n 为 posting list B 的长度。

那对于这个归并过程，工业界是如何优化的呢？接下来，我们就通过一个例子来看一下。

假设 posting list A 中的元素为 <1,2,3,4,5,6,7,8……，1000>，这 1000 个元素是按照从 1 到 1000 的顺序递增的。而 posting list B 中的元素，只有 <1,500,1000>3 个。那按照我们之前讲过的归并方法，它们的合并过程就是，在找到相同元素 1 以后，还需要再遍历 498 次链表，才能找到第二个相同元素 500。

链表遍历，时间代价高

很显然，为了找到一个元素，遍历这么多次是很不划算的。那对于链表遍历，我们可以怎么优化呢？实际上，在许多工业界的实践中，比如搜索引擎，还有 Lucene 和 Elasticsearch 等应用中，都是使用跳表来实现 posting list 的。

在上面这个例子中，我们可以将链表改为跳表。这样，在 posting list A 中，我们从第 2 个元素遍历到第 500 个元素，只需要 log(498) 次的量级，会比链表快得多。

跳表查找，检索加速

这个时候你可能就会问了，我们只能用 B 中的每一个元素去 A 中二分查找吗？那在解答这个问题之前，我们先来看下图这个例子。

相互二分查找

你会发现，在寻找 500 这个公共元素的过程中，我们是拿着链表 B 中的 500 作为 key，在链表 A 中进行跳表二分查找的。但是，在查找 1000 这个公共元素的过程中，我们是拿着链表 A 中的元素 1000，在链表 B 中进行跳表二分查找的。

我们把这种方法定义为相互二分查找。那啥叫相互二分查找呢？

你可以这么理解：如果 A 中的当前元素小于 B 中的当前元素，我们就以 B 中的当前元素为 key，在 A 中快速往前跳；如果 B 中的当前元素小于 A 中的当前元素，我们就以 A 中的当前元素为 key，在 B 中快速往前跳。这样一来，整体的检索效率就提升了。

在实际的系统中，如果 posting list 可以都存储在内存中，并且变化不太频繁的话，那我们还可以利用可变长数组来代替链表。

可变长数组的数组的长度可以随着数据的增加而增加。一种简单的可变长数组实现方案就是当数组被写满时，我们直接重新申请一个 2 倍于原数组的新数组，将原数组数据直接导入新数组中。这样，我们就可以应对数据动态增长的场景。

那对于两个 posting list 求交集，我们同样可以先使用可变长数组，再利用相互二分查找进行归并。而且，由于数组的数据在内存的物理空间中是紧凑的，因此 CPU 还可以利用内存的局部性原理来提高检索效率。

哈希表法加速倒排索引

说到高效查询，哈希表 O(1) 级别的查找能力令人印象深刻。那我们有没有能利用哈希表来加速的方法呢？别说，还真有。

哈希表加速的思路其实很简单，就是当两个集合要求交集时，如果一个集合特别大，另一个集合相对比较小，那我们就可以用哈希表来存储大集合。这样，我们就可以拿着小集合中的每一个元素去哈希表中对比：如果查找为存在，那查找的元素就是公共元素；否则，就放弃。

我们还是以前面说的 posting list A 和 B 为例，来进一步解释一下这个过程。由于 Posting list A 有 1000 个元素，而 B 中只有 3 个元素，因此，我们可以将 posting list A 中的元素提前存入哈希表。这样，我们利用 B 中的 3 个元素来查询的时候，每次查询代价都是 O(1)。如果 B 有 m 个元素，那查询代价就是 O(m)。

将 posting list A 中的元素提前存入哈希表

但是，使用哈希表法加速倒排索引有一个前提，就是我们要在查询发生之前，就把 posting list 转为哈希表。这就需要我们提前分析好，哪些 posting list 经常会被拿来求交集，针对这一批 posting list，我们将它们提前存入哈希表。这样，我们就能实现检索加速了。

这里还有一点需要你注意，原始的 posting list 我们也要保留。这是为什么呢？

我们假设有这样一种情况：当我们要给两个 posting list 求交集时，发现这两个 posting list 都已经转为哈希表了。这个时候，由于哈希表没有遍历能力，反而会导致我们无法合并这两个 posting list。因此，在哈希表法的最终改造中，一个 key 后面会有两个指针，一个指向 posting list，另一个指向哈希表（如果哈希表存在）。

除此之外，哈希表法还需要在有很多短 posting list 存在的前提下，才能更好地发挥作用。这是因为哈希表法的查询代价是 O(m)，如果 m 的值很大，那它的性能就不一定会比跳表法更优了。

位图法加速倒排索引

我们知道，位图其实也可以看作是一种特殊的哈希，所以除了哈希表，我们还可以使用位图法来改造链表。如果我们使用位图法，就需要将所有的 posting list 全部改造为位图，这样才能使用位图的位运算来进行检索加速。那具体应该怎么做呢？我们一起来看一下。

首先，我们需要为每个 key 生成同样长度的位图，表示所有的对象空间。然后，如果一个元素出现在该 posting list 中，我们就将位图中该元素对应的位置置为 1。这样就完成了 posting list 的位图改造。

接下来，我们来看一下位图法的查询过程。

如果要查找 posting list A 和 B 的公共元素，我们可以将 A、B 两个位图中对应的位置直接做 and 位运算（复习一下 and 位运算：0 and 0 = 0； 0 and 1 = 0； 1 and 1 = 1）。由于位图的长度是固定的，因此两个位图的合并运算时间代价也是固定的。并且由于 CPU 执行位运算的效率非常快，因此，在位图总长度不是特别长的情况下，位图法的检索效率还是非常高的。

位运算

和哈希表法一样，位图法也有自己的局限性。我总结了以下 3 点，你可以感受一下。

位图法仅适用于只存储 ID 的简单的 posting list。如果 posting list 中需要存储复杂的对象，就不适合用位图来表示 posting list 了。

位图法仅适用于 posting list 中元素稠密的场景。对于 posting list 中元素稀疏的场景，使用位图的运算和存储开销反而会比使用链表更大。

位图法会占用大量的空间。尽管位图仅用 1 个 bit 就能表示元素是否存在，但每个 posting list 都需要表示完整的对象空间。如果 ID 范围是用 int32 类型的数组表示的，那一个位图的大小就约为 512M 字节。如果我们有 1 万个 key，每个 key 都存一个这样的位图，那就需要 5120G 的空间了。这是非常可怕的空间开销啊！

在很多成熟的工业界系统中，为了解决位图的空间消耗问题，我们经常会使用一种压缩位图的技术 Roaring Bitmap 来代替位图。在数据库、全文检索 Lucene、数据分析 Druid 等系统中，你都能看到 Roaring Bitmap 的身影。

升级版位图：Roaring Bitmap

下面我们来学习一下 Roaring Bitmap 的设计思想。

首先，Roaring Bitmap 将一个 32 位的整数分为两部分，一部分是高 16 位，另一部分是低 16 位。对于高 16 位，Roaring Bitmap 将它存储到一个有序数组中，这个有序数组中的每一个值都是一个“桶”；而对于低 16 位，Roaring Bitmap 则将它存储在一个 2^16 的位图中，将相应位置置为 1。这样，每个桶都会对应一个 2^16 的位图。

Roaring Bitmap 存储过程

接下来，如果我们要确认一个元素是否在 Roaring Bitmap 中存在，通过两次查找就能确认了。第一步是以高 16 位在有序数组中二分查找，看对应的桶是否存在。如果存在，第二步就是将桶中的位图取出，拿着低 16 位在位图中查找，判断相应位置是否为 1。第一步查找由于是数组二分查找，因此时间代价是 O（log n）；第二步是位图查找，因此时间代价是 O(1)。

所以你看，这种将有序数组和位图用倒排索引结合起来的设计思路，是能够保证高效检索的。那它到底是怎么节省存储空间的呢？

我们来看一个极端的例子。

如果一个 posting list 中，所有元素的高 16 位都是相同的，那在有序数组部分，我们只需要一个 2 个字节的桶（注：每个桶都是一个 short 型的整数，因此只有 2 个字节。如果数组提前分配好了 2^16 个桶，那就需要 128K 字节的空间，因此使用可变长数组更节省空间）。在低 16 位部分，因为位图长度是固定的，都是 2^16 个 bit，那所占空间就是 8K 个字节。

同样都是 32 位的整数，这样的空间消耗相比于我们在位图法中计算的 512M 字节来说，大大地节省了空间！

你会发现，相比于位图法，这种设计方案就是通过，将不存在的桶的位图空间全部省去这样的方式，来节省存储空间的。而代价就是将高 16 位的查找，从位图的 O(1) 的查找转为有序数组的 log(n) 查找。

那每个桶对应的位图空间，我们是否还能优化呢？

前面我们说过，当位图中的元素太稀疏时，其实我们还不如使用链表。这个时候，链表的计算更快速，存储空间也更节省。Roaring Bitmap 就基于这个思路，对低 16 位的位图部分进行了优化：如果一个桶中存储的数据少于 4096 个，我们就不使用位图，而是直接使用 short 型的有序数组存储数据。同时，我们使用可变长数组机制，让数组的初始化长度是 4，随着元素的增加再逐步调整数组长度，上限是 4096。这样一来，存储空间就会低于 8K，也就小于使用位图所占用的存储空间了。

总结来说，一个桶对应的存储容器有两种，分别是数组容器和位图容器（其实还有一种压缩的 runContainer，它是对连续元素通过只记录初始元素和后续个数。由于它不是基础类型，需要手动调用 runOptimize() 函数才会启用，这里就不展开说了）。那在实际应用的过程中，数组容器和位图容器是如何转换的呢？这里有三种情况。

第一种，在一个桶中刚插入数据时，因为数据量少，所以我们就默认使用数组容器；

第二种，随着数据插入，桶中的数据不断增多，当数组容器中的元素个数大于 4096 个时，就从数组容器转为位图容器；

第三种，随着数据的删除，如果位图容器中的元素个数小于 4096 个，就退化回数组容器。

这个过程是不是很熟悉？没错，这很像第 3节中的 Hashmap 的处理方法。

使用数组容器和位图容器并可以相互转换

好了，前面我们说了这么多 Roaring Bitmap 的压缩位图空间的设计思路。下面，我们回到两个集合 A 和 B 快速求交集的例子中，一起来看一看 Roaring Bitmap 是怎么做的。假设，这里有 Roaring Bitmap 表示的两个集合 A 和 B，那我们求它们交集的过程可以分为 2 步。

第 1 步，比较高 16 位的所有桶，也就是对这两个有序数组求交集，所有相同的桶会被留下来。

第 2 步，对相同的桶里面的元素求交集。这个时候会出现 3 种情况，分别是位图和位图求交集、数组和数组求交集、位图和数组求交集。

其中，位图和位图求交集，我们可以直接使用位运算；数组和数组求交集，我们可以使用相互二分查找（类似跳表法）；位图和数组求交集，我们可以通过遍历数组，在位图中查找数组中的每个元素是否存在（类似哈希表法）。这些方法我们前面都讲过了，那知道了方法，具体怎么操作就是很容易的事情了，你可以再自己尝试一下。

重点回顾

好了，今天的内容讲完了。我们来总结一下，你要掌握的重点内容。

在工业界，我们会利用跳表法、哈希表法和位图法，对倒排索引进行检索加速。

其中，跳表法是将实现倒排索引中的 posting list 的链表改为了跳表，并且使用相互二分查找来提升检索效率；哈希表法就是在有很多短 posting list 存在的前提下，将大的 posting list 转为哈希表，减少查询的时间代价；位图法是在位图总长度不是特别长的情况下，将所有的 posting list 都转为位图，它们进行合并运算的时间代价由位图的长度决定。

并且我们还介绍了位图的升级版本，Roaring Bitmap。很有趣的是，你会发现 Roaring Bitmap 求交集过程的设计实现，本身就是跳表法、哈希表法和位图法的一个综合应用案例。

最后呢，我还想再多说两句。实际上，我写这篇文章就是想告诉你，基础的数据结构和算法组合在一起，就能提供更强大的检索能力，而且这也是大量的工程系统中广泛使用的设计方案。因此，深入理解每一种基础数据结构和算法的特点和适用场景，并且能将它们灵活应用，这能帮助你更好地学习和理解复杂的数据结构和算法，以及更好地学会如何设计复杂的高性能检索系统。

课堂讨论

最后，我们还是来看一道讨论题。

在 Roaring Bitmap 的求交集过程中，有位图和位图求交集、数组和数组求交集、位图和数组求交集这 3 种场景。那它们求交集以后的结果，我们是应该用位图来存储，还是用数组来存储呢？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这篇文章分享给你的朋友。

特别加餐 | 倒排检索加速（二）：如何对联合查询进行加速？

你好，我是陈东。欢迎来到检索专栏的第二次加餐时间。

在上一篇加餐中，我们讲了工业界中，倒排索引是怎么利用基础的数据结构来加速“求交集”过程的。现在，相信你已经对跳表、哈希表和位图的实际使用，有了更深刻的理解和认识了。然而，在日常的检索中，我们往往会面临更复杂的联合查询需求。这个时候，又该如何加速呢？

我们先来看一个例子：在一个系统的倒排索引中，有 4 个不同的 key，分别记录着“北京”“上海”“安卓”“学生”，这些标签分别对应着 4 种人群列表。如果想分析用户的特点，我们需要根据不同的标签来选择不同的人群。这个时候，我们可能会有以下的联合查询方式：

在“北京”或在“上海”，并且使用“安卓”的用户集合。抽象成联合查询表达式就是，（“北京”∪“上海”）∩“安卓”；

在“北京”使用“安卓”，并且是“学生”的用户集合。抽象成联合查询表达式就是，“北京”∩“安卓”∩“学生”。

这只是 2 个比较有代表性的联合查询方式，实际上，联合查询的组合表达可以更长、更复杂。对于联合查询，在工业界中有许多加速检索的研究和方法，比如，调整次序法、快速多路归并法、预先组合法和缓存法。今天，我们就来聊一聊这四种加速方法。

方法一：调整次序法

首先，我们来看调整次序法。那什么是调整次序法呢？接下来，我们就以三个集合的联合查询为例，来一起分析一下。这里我再多说一句，虽然这次讲的是三个集合，但是对于多个集合，我们也是采用同样的处理方法。

假设，这里有 A、B、C 三个集合，集合中的元素个数分别为 2、20、40，而且 A 包含在 B 内，B 包含在 C 内。这里我先补充一点，如果两个集合分别有 m 个元素和 n 个元素，那使用普通的遍历归并合并它们的时间代价为 O(m+n)。接着，如果我们要对 A、B、C 求交集，这个时候，会有几种不同的求交集次序，比如，A∩（B∩C）、（A∩B）∩C 等。那我们该如何选择求交集的次序呢？下面，我们就以这两种求交集次序为例来分析一下，不同的求交集次序对检索效率的影响。

当求交集次序是 A∩（B∩C）时，我们要先对 B 和 C 求交集，时间代价就是 20+40 = 60，得到的结果集是 B，然后 B 再和 A 求交集，时间代价是 2 + 20 = 22。因此，最终一共的时间代价就是 60 + 22 = 82。

那当求交集次序是（A∩B）∩C 时，我们要先对 A 和 B 求交集，时间代价是 2 + 20 = 22，得到的结果集是 A，然后 A 再和 C 求交集，时间代价是 2 + 40 = 42。因此，最终的时间代价就是 22 + 42 = 64。这比之前的代价要小得多。

求 A∩（B∩C）和（A∩B）∩C 的过程

除了对 A、B、C 这三个集合同时取交集以外，还有一种常见的联合查询方式，就是对其中两个集合取并集之后，再和第三个集合取交集，比如 A ∩（B∪C），你可以看我开头举的第一个例子。在这种情况下，如果我们不做任何优化，查询代价是怎么样的呢？让我们一起来看一下。

首先是执行 B∪C 的操作，时间代价是 20 + 40 = 60，结果是 C。然后再和 A 求交集，时间代价是 2+40 = 42。一共是 102。

这样的时间代价非常大，那针对这个查询过程我们还可以怎么优化呢？这种情况下，我们可以尝试使用数学公式，对先求并集再求交集的次序进行改造，我们先来复习一个集合分配律公式：

A∩（B∪C）=（A∩B）∪（A∩C）

然后，我们就可以把先求并集再求交集的操作，转为先求交集再求并集的操作了。那这个时候，查询的时间代价是多少呢？我们一起来看一下。

首先，我们要执行 A∩B 操作，时间代价是 2+20 = 22，结果是 A。然后，我们执行 A∩C 操作，时间代价是 2+40=42，结果也是 A。最后，我们对两个 A 求并集，时间代价是 2+2=4。因此，最终总的时间代价是 22 + 42 + 4 = 68。这比没有优化前的 102 要低得多。

求 A∩（B∪C）和（A∩B）∪（A∩C）的过程

这里有一点需要特别注意，如果求并集的元素很多，比如说（B∪C∪D∪E∪F），那我们用分配律改写的时候，A 就需要分别和 B 到 F 求 5 次交集，再将 5 个结果求并集。这样一来，操作的次数会多很多，性能就有可能下降。因此，我们需要先检查 B 到 F 每个集合的大小，比如说，如果集合中元素个数都明显大于 A，我们预测它们分别和 A 求交集能有提速的效果，那我们就可以使用集合分配律公式来加速检索。

不知道你有没有注意到，在一开始讲这两个例子的时候，我们假设了 A、Ｂ、Ｃ有相互包含的关系，这是为了方便你更好地理解调整操作次序带来的效率差异。那在真实情况中，集合中的关系不会这么理想，但是我们分析得到的结论，依然是有效的。

方法二：快速多路归并法

但是，调整次序法有一个前提，就是集合的大小要有一定的差异，这样的调整效果才会更明显。那如果我们要对多个 posting list 求交集，但是它们的长度差异并不大，这又该如何优化呢？这个时候，我们可以使用跳表法来优化。

在对多个 posting list 求交集的过程中，我们可以利用跳表的性质，快速跳过多个元素，加快多路归并的效率。这种方法，我叫它"快速多路归并法"。在一些搜索引擎和广告引擎中，包括在 Elastic Search 这类框架里，就都使用了这样的技术。那具体是怎么做的呢？我们一起来看一下。

其实，快速多路归并法的思路和实现都非常简单，就是将 n 个链表的当前元素看作一个有序循环数组list[n]。并且，对有序循环数组从小到大依次处理，当有序循环数组中的最小值等于最大值，也就是所有元素都相等时，就说明我们找到了公共元素。

这么说可能比较抽象，下面，我们就以 4 个链表 A、B、C、D 求交集为例，来讲一讲具体的实现步骤。

第 1 步，将 4 个链表的当前第一个元素取出，让它们按照由小到大的顺序进行排序。然后，将链表也按照由小到大有序排列；

第 2 步，用一个变量 max 记录当前 4 个链表头中最大的一个元素的值；

第 3 步，从第一个链表开始，判断当前位置的值是否和 max 相等。如果等于 max，则说明此时所有链表的当前元素都相等，该元素为公共元素，那我们就将该元素取出，然后回到第一步；如果当前位置的值小于 max，则用跳表法快速调整到该链表中第一个大于等于 max 的元素位置；如果新位置元素的值大于 max，则更新 max 的值。

第 4 步，对下一个链表重复第 3 步，就这样依次处理每个链表（处理完第四个链表后循环回到第一个链表，用循环数组实现），直到链表全部遍历完。

为了帮助你加深理解，我在下面的过程图中加了一个具体的例子，你可以对照前面的文字描述一起消化吸收。

上图的例子中，我们通过以上 4 个步骤，找到了公共元素 6。接下来，你可以试着继续用这个方法，去找下一个公共元素 11。这里，我就不再继续举例了。

方法三：预先组合法

接下来，我们来说一说第三种方法，预先组合法。其实预先组合法的核心原理，和我们熟悉的一个系统实现理念一样，就是能提前计算好的，就不要临时计算。换一句话说，对于常见的联合查询，我们可以提前将结果算好，并将该联合查询定义一个 key。那具体该怎么操作呢？

假设，key1、key2 和 key3 分别的查询结果是 A、B、C 三个集合。如果我们经常会计算 A∩B∩C，那我们就可以将 key1+key2+key3 这个查询定义为一个新的组合 key，然后对应的 posting list 就是提前计算好的结果。之后，当我们要计算 A∩B∩C 时，直接去查询这个组合 key，取出对应的 posting list 就可以了。

直接提前组合计算

方法四：缓存法加速联合查询

预先组合的方法非常实用，但是在搜索引擎以及一些具有热搜功能的平台中，经常会出现一些最新的查询组合。这些查询组合请求量也很大，但是由于之前没有出现过，因此我们无法使用预先组合的方案来优化。这个时候，我们会使用缓存技术来优化。

那什么是缓存技术呢？缓存技术就是指将之前的联合查询结果保存下来。这样再出现同样的查询时，我们就不需要重复计算了，而是直接取出之前缓存的结果即可。这里，我们可以借助预先组合法的优化思路，为每一个联合查询定义一个新的 key，将结果作为这个 key 的 posting list 保存下来。

但是，我们还要考虑一个问题：内存空间是有限的，不可能无限缓存所有出现过的查询组合。因此，对于缓存，我们需要进行内容替换管理。一种常用的缓存管理技术是 LRU（Least Recently Used），也叫作最近最少使用替换机制。所谓最近最少使用替换机制，就是如果一个对象长期未被访问，那当缓存满时，它将会被替换。

对于最近最少使用替换机制，一个合适的实现方案是使用双向链表：当一个元素被访问时，将它提到链表头。这个简单的机制能起到的效果是：如果一个元素经常被访问，它就会经常被往前提；如果一个元素长时间未被访问，它渐渐就会被排到链表尾。这样一来，当缓存满时，我们直接删除链表尾的元素即可。

不过，我们希望能快速查询缓存，那链表的访问速度就不满足我们的需求了。因此，我们可以使用 O(1) 查询代价的哈希表来优化。我们向链表中插入元素时，同时向哈希表中插入该元素的 key，然后这个 key 对应的 value 则是链表中这个节点的地址。这样，我们在查询这个 key 的时候，就可以通过查询哈希表，快速找到链表中的对应节点了。因此，使用“双向链表 + 哈希表”是一种常见的实现 LRU 机制的方案。

使用双向链表 + 哈希表实现 LRU 机制

通过使用 LRU 缓存机制，我们就可以将临时的查询组合缓存起来，快速查询出结果，而不需要重复计算了。一旦这个查询组合不是热点了，那它就会被 LRU 机制替换出缓存区，让位给新的热点查询组合。

缓存法在许多高并发的查询场景中，会起到相当大的作用。比如说在搜索引擎中，对于一些特定时段的热门查询，缓存命中率能达到 60% 以上甚至更高，会大大加速系统的检索效率。

重点回顾

好了，今天的内容就讲到这里。今天我们学习了联合查询的 4 种优化方法，我们一起来回顾一下。

第 1 种方法是调整次序法，它是通过从小到大求交集，以及使用集合分配律改写查询，使得检索效率得到提升。

第 2 种方法是快速多路归并法，它是利用跳表快速跳过多个元素的能力，结合优化的多路归并方案，提升多个 posting list 归并性能的。

第 3 种方法是预先组合法，也就是将热门的查询组合提前处理好，作为一个单独的 key，保存提前计算好的 posting list。

第 4 种则是使用缓存法，将临时的热点查询组合进行结果缓存处理，避免重复查询每次都要重复计算。

你会看到，这 4 种方法分别从数学、算法、线下工程和线上工程，这四种不同的方向对联合查询进行了优化。同时使用它们，能让我们从多个维度对联合查询进行加速。

通过上一篇加餐，我相信你也体会到了，如果我们能合理组合和灵活应用简单的数据结构和算法，就能构建出复杂的架构，让它们在工业界的大规模系统中发挥重要的作用。那通过这一篇加餐，你还可以体会到，基础知识的全面性能帮助你从不同的维度去思考，教你用更多的手段去优化系统。

因此，在学习的金字塔中，扎实和稳健的基础知识永远是最重要。多花点时间，打好基础，我相信你一定会有巨大的收获！

课堂讨论

最后，我们还是来看一道讨论题。

对于今天介绍的四种方案，你觉得哪一种给你的印象最深刻？你会尝试在怎么样的场景中使用它？为什么？

欢迎在留言区畅所欲言，说出你的思考过程和答案。如果有收获，也欢迎把这篇文章分享给你的朋友。

06 | 数据库检索：如何使用B+树对海量磁盘数据建立索引？

你好，我是陈东。

在基础篇中，我们学习了许多和检索相关的数据结构和技术。但是在大规模的数据环境下，这些技术的应用往往会遇到一些问题，比如说，无法将数据全部加载进内存。再比如说，无法支持索引的高效实时更新。而且，对于复杂的系统和业务场景，我们往往需要对基础的检索技术进行组合和升级。这就需要我们对实际的业务问题和解决方案十分了解。

所以，从这一讲开始，我会和你一起探讨实际工作中的系统和业务问题，分享给你一些工业界中常见的解决方案，帮助你积累对应的行业经验，让你能够解决工作中的检索难题。

在工业界中，我们经常会遇到的一个问题，许多系统要处理的数据量非常庞大，数据无法全部存储在内存中，需要借助磁盘完成存储和检索。我们熟悉的关系型数据库，比如 MySQL 和 Oracle，就是这样的典型系统。

数据库中支持多种索引方式，比如，哈希索引、全文索引和 B+ 树索引，其中 B+ 树索引是使用最频繁的类型。因此，今天我们就一起来聊一聊磁盘上的数据检索有什么特点，以及为什么 B+ 树能对磁盘上的大规模数据进行高效索引。

磁盘和内存中数据的读写效率有什么不同？

首先，我们来探讨一下，存储在内存中和磁盘中的数据，在检索效率方面有什么不同。

内存是半导体元件。对于内存而言，只要给出了内存地址，我们就可以直接访问该地址取出数据。这个过程具有高效的随机访问特性，因此内存也叫随机访问存储器（Random Access Memory，即 RAM）。内存的访问速度很快，但是价格相对较昂贵，因此一般的计算机内存空间都相对较小。

而磁盘是机械器件。磁盘访问数据时，需要等磁盘盘片旋转到磁头下，才能读取相应的数据。尽管磁盘的旋转速度很快，但是和内存的随机访问相比，性能差距非常大。到底有多大呢？一般来说，如果是随机读写，会有 10 万到 100 万倍左右的差距。但如果是顺序访问大批量数据的话，磁盘的性能和内存就是一个数量级的。为什么会这样呢？这和磁盘的读写原理有关。那具体是怎么回事呢？

磁盘的最小读写单位是扇区，较早期的磁盘一个扇区是 512 字节。随着磁盘技术的发展，目前常见的磁盘扇区是 4K 个字节。操作系统一次会读写多个扇区，所以操作系统的最小读写单位是块（Block），也叫作簇（Cluster）。当我们要从磁盘中读取一个数据时，操作系统会一次性将整个块都读出来。因此，对于大批量的顺序读写来说，磁盘的效率会比随机读写高许多。

现在你已经了解磁盘的特点了，那我们就可以来看一下，如果使用之前学过的检索技术来检索磁盘中的数据，检索效率会是怎样的呢？

假设有一个有序数组存储在硬盘中，如果它足够大，那么它会存储在多个块中。当我们要对这个数组使用二分查找时，需要先找到中间元素所在的块，将这个块从磁盘中读到内存里，然后在内存中进行二分查找。如果下一步要读的元素在其他块中，则需要再将相应块从磁盘中读入内存。直到查询结束，这个过程可能会多次访问磁盘。我们可以看到，这样的检索性能非常低。

由于磁盘相对于内存而言访问速度实在太慢，因此，对于磁盘上数据的高效检索，我们有一个极其重要的原则：对磁盘的访问次数要尽可能的少！

那问题来了，我们应该如何减少磁盘的访问次数呢？将索引和数据分离就是一种常见的设计思路。

如何将索引和数据分离？

我们以查询用户信息为例。我们知道，一个系统中的用户信息非常多，除了有唯一标识的 ID 以外，还有名字、邮箱、手机、兴趣爱好以及文章列表等各种信息。一个保存了所有用户信息的数组往往非常大，无法全部放在内存中，因此我们会将它存储在磁盘中。

常规数组存储

当我们以用户的 ID 进行检索时，这个检索过程其实并不需要读取存储用户的具体信息。因此，我们可以生成一个只用于检索的有序索引数组。数组中的每个元素存两个值，一个是用户 ID，另一个是这个用户信息在磁盘上的位置，那么这个数组的空间就会很小，也就可以放入内存中了。这种用有序数组做索引的方法，叫作线性索引（Linear Index）。

索引与数据分离：线性索引

在数据频繁变化的场景中，有序数组并不是一个最好的选择，二叉检索树或者哈希表往往更有普适性。但是，哈希表由于缺乏范围检索的能力，在一些场合也不适用。因此，二叉检索树这种树形结构是许多常见检索系统的实施方案。那么，上图中的线性索引结构，就变成下图这个样子。

索引与数据分离：树形索引

尽管二叉检索树可以解决数据动态修改的问题，但在索引数据很大的情况下，依然会有数据无法完全加载到内存中。这种情况我们应该怎么办呢？

一个很自然的思路，就是将索引数据也存在磁盘中。那如果是树形索引，我们应该将哪些节点存入磁盘，又要如何从磁盘中读出这些数据进行检索呢？你可以先想一想，然后我们一起来看看业界常用的解决方案 B+ 树是怎么做的。

如何理解 B+ 树的数据结构？

B+ 树是检索技术中非常重要的一个部分。这是为什么呢？因为 B+ 树给出了将树形索引的所有节点都存在磁盘上的高效检索方案，使得索引技术摆脱了内存空间的限制，得到了广泛的应用。

前面我们讲了，操作系统对磁盘数据的访问是以块为单位的。因此，如果我们想将树型索引的一个节点从磁盘中读出，即使该节点的数据量很小（比如说只有几个字节），但磁盘依然会将整个块的数据全部读出来，而不是只读这一小部分数据，这会让有效读取效率很低。B+ 树的一个关键设计，就是让一个节点的大小等于一个块的大小。节点内存储的数据，不是一个元素，而是一个可以装 m 个元素的有序数组。这样一来，我们就可以将磁盘一次读取的数据全部利用起来，使得读取效率最大化。

B+ 树还有另一个设计，就是将所有的节点分为内部节点和叶子节点。尽管内部节点和叶子节点的数据结构是一样的，但存储的内容是不同的。

内部节点仅存储 key 和维持树形结构的指针，并不存储 key 对应的数据（无论是具体数据还是文件位置信息）。这样内部节点就能存储更多的索引数据，我们也就可以使用最少的内部节点，将所有数据组织起来了。而叶子节点仅存储 key 和对应数据，不存储维持树形结构的指针。通过这样的设计，B+ 树就能做到节点的空间利用率最大化。

B+ 树的内部节点和叶子节点

此外，B+ 树还将同一层的所有节点串成了有序的双向链表，这样一来，B+ 树就同时具备了良好的范围查询能力和灵活调整的能力了。

因此，B+ 树是一棵完全平衡的 m 阶多叉树。所谓的 m 阶，指的是每个节点最多有 m 个子节点，并且每个节点里都存了一个紧凑的可包含 m 个元素的数组。

B+ 树的整体结构

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 的节点，首先要查询到对应的叶子节点。如果叶子节点的数组未满，那么直接将该元素插入数组即可。具体过程如下图所示：

插入 ID 6

如果我们插入的是 ID=10 的节点呢？按之前的逻辑，我们应该插入到 ID 9 后面，但是 ID 9 所在的这个节点已经存满了 3 个节点，无法继续存入了。因此，我们需要将该叶子节点分裂。分裂的逻辑就是生成一个新节点，并将数据在两个节点中平分。

插入 ID 10，叶子节点分裂

叶子节点分裂完成以后，上一层的内部节点也需要修改。但如果上一层的父节点也是满的，那么上一层的父节点也需要分裂。

插入 ID 10，内部节点修改

内部节点调整好了，下一步我们就要调整根节点了。由于根节点未满，因此我们不需要分裂，直接修改即可。

插入 ID 10，根节点修改

删除数据也类似，如果节点数组较满，直接删除；如果删除后数组有一半以上的空间为空，那为了提高节点的空间利用率，该节点需要将左右两边兄弟节点的元素转移过来。可以成功转移的条件是，元素转移后该节点及其兄弟节点的空间必须都能维持在半满以上。如果无法满足这个条件，就说明兄弟节点其实也足够空闲，那我们直接将该节点的元素并入兄弟节点，然后删除该节点即可。

重点回顾

好了，今天的内容就先讲到这里。你会发现，即使是复杂的 B+ 树，我们将它拆解开来，其实也是由简单的数组、链表和树组成的，而且 B+ 树的检索过程其实也是二分查找。因此，如果 B+ 树完全加载在内存中的话，它的检索效率其实并不会比有序数组或者二叉检索树更高，也还是二分查找的 log(n) 的效率。并且，它还比数组和二叉检索树更加复杂，还会带来额外的开销。

但是，B+ 树最大的优点在于，它提供了将索引数据存在磁盘中，以及高效检索的方案。这让检索技术摆脱了内存的限制，得到了更广泛地使用。

另外，这一节还有一个很重要的设计思想需要你掌握，那就是将索引和数据分离。通过这样的方式，我们能将索引的数组大小保持在一个较小的范围内，让它能加载在内存中。在许多大规模系统中，都是使用这个设计思想来精简索引的。而且，B+ 树的内部节点和叶子节点的区分，其实也是索引和数据分离的一次实践。

课堂讨论

最后，咱们来看一道讨论题。

B+ 树有一个很大的优势，就是适合做范围查询。如果我们要检索值在 x 到 y 之间的所有元素，你会怎么操作呢？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这篇文章分享给你的朋友。

07 | NoSQL检索：为什么日志系统主要用LSM树而非B+树？

你好，我是陈东。

B+ 树作为检索引擎中的核心技术得到了广泛的使用，尤其是在关系型数据库中。

但是，在关系型数据库之外，还有许多常见的大数据应用场景，比如，日志系统、监控系统。这些应用场景有一个共同的特点，那就是数据会持续地大量生成，而且相比于检索操作，它们的写入操作会非常频繁。另外，即使是检索操作，往往也不是全范围的随机检索，更多的是针对近期数据的检索。

那对于这些应用场景来说，使用关系型数据库中的 B+ 树是否合适呢？

我们知道，B+ 树的数据都存储在叶子节点中，而叶子节点一般都存储在磁盘中。因此，每次插入的新数据都需要随机写入磁盘，而随机写入的性能非常慢。如果是一个日志系统，每秒钟要写入上千条甚至上万条数据，这样的磁盘操作代价会使得系统性能急剧下降，甚至无法使用。

那么，针对这种频繁写入的场景，是否有更合适的存储结构和检索技术呢？今天，我们就来聊一聊另一种常见的设计思路和检索技术：LSM 树（Log Structured Merge Trees）。LSM 树也是近年来许多火热的 NoSQL 数据库中使用的检索技术。

如何利用批量写入代替多次随机写入？

刚才我们提到 B+ 树随机写入慢的问题，对于这个问题，我们现在来思考一下优化想法。操作系统对磁盘的读写是以块为单位的，我们能否以块为单位写入，而不是每次插入一个数据都要随机写入磁盘呢？这样是不是就可以大幅度减少写入操作了呢？

LSM 树就是根据这个思路设计了这样一个机制：当数据写入时，延迟写磁盘，将数据先存放在内存中的树里，进行常规的存储和查询。当内存中的树持续变大达到阈值时，再批量地以块为单位写入磁盘的树中。因此，LSM 树至少需要由两棵树组成，一棵是存储在内存中较小的 C0 树，另一棵是存储在磁盘中较大的 C1 树。简单起见，接下来我们就假设只有 C0 树和 C1 树。

LSM 树由至少 2 部分组成：内存的 C0 树和磁盘的 C1 树

C1 树存储在磁盘中，因此我们可以直接使用 B+ 树来生成。那对于全部存储在内存中的 C0 树，我们该如何生成呢？在上一讲的重点回顾中我们分析过，在数据都能加载在内存中的时候，B+ 树并不是最合适的选择，它的效率并不会更高。因此，C0 树我们可以选择其他的数据结构来实现，比如平衡二叉树甚至跳表等。但是为了让你更简单、清晰地理解 LSM 树的核心理念，我们可以假设 C0 树也是一棵 B+ 树。

那现在 C0 树和 C1 树就都是 B+ 树生成的了，但是相比于普通 B+ 树生成的 C0 树，C1 树有一个特点：所有的叶子节点都是满的。为什么会这样呢？原因就是，C1 树不需要支持随机写入了，我们完全可以等内存中的数据写满一个叶子节点之后，再批量写入磁盘。因此，每个叶子节点都是满的，不需要预留空位来支持新数据的随机写入。

如何保证批量写之前系统崩溃可以恢复？

B+ 树随机写入慢的问题，我们已经知道解决的方案了。现在第二个问题来了：如果机器断电或系统崩溃了，那内存中还未写入磁盘的数据岂不就永远丢失了？这种情况我们该如何解决呢？

为了保证内存中的数据在系统崩溃后能恢复，工业界会使用 WAL 技术（Write Ahead Log，预写日志技术）将数据第一时间高效写入磁盘进行备份。WAL 技术保存和恢复数据的具体步骤，我这里总结了一下。

内存中的程序在处理数据时，会先将对数据的修改作为一条记录，顺序写入磁盘的 log 文件作为备份。由于磁盘文件的顺序追加写入效率很高，因此许多应用场景都可以接受这种备份处理。

在数据写入 log 文件后，备份就成功了。接下来，该数据就可以长期驻留在内存中了。

系统会周期性地检查内存中的数据是否都被处理完了（比如，被删除或者写入磁盘），并且生成对应的检查点（Check Point）记录在磁盘中。然后，我们就可以随时删除被处理完的数据了。这样一来，log 文件就不会无限增长了。

系统崩溃重启，我们只需要从磁盘中读取检查点，就能知道最后一次成功处理的数据在 log 文件中的位置。接下来，我们就可以把这个位置之后未被处理的数据，从 log 文件中读出，然后重新加载到内存中。

通过这种预先将数据写入 log 文件备份，并在处理完成后生成检查点的机制，我们就可以安心地使用内存来存储和检索数据了。

如何将内存数据与磁盘数据合并？

解决了内存中数据备份的问题，我们就可以接着写入数据了。内存中 C0 树的大小是有上限的，那当 C0 树被写满之后，我们要怎么把它转换到磁盘中的 C1 树上呢？这就涉及滚动合并（Rolling Merge）的过程了。

我们可以参考两个有序链表归并排序的过程，将 C0 树和 C1 树的所有叶子节点中存储的数据，看作是两个有序链表，那滚动合并问题就变成了我们熟悉的两个有序链表的归并问题。不过由于涉及磁盘操作，那为了提高写入效率和检索效率，我们还需要针对磁盘的特性，在一些归并细节上进行优化。

C0 树和 C1 树滚动合并可以视为有序链表归并

由于磁盘具有顺序读写效率高的特性，因此，为了提高 C1 树中节点的读写性能，除了根节点以外的节点都要尽可能地存放到连续的块中，让它们能作为一个整体单位来读写。这种包含多个节点的块就叫作多页块（Multi-Pages Block）。

下面，我们来讲一下滚动归并的过程。在进行滚动归并的时候，系统会遵循以下几个步骤。

第一步，以多页块为单位，将 C1 树的当前叶子节点从前往后读入内存。读入内存的多页块，叫作清空块（Emptying Block），意思是处理完以后会被清空。

第二步，将 C0 树的叶子节点和清空块中的数据进行归并排序，把归并的结果写入内存的一个新块中，叫作填充块（Filling Block）。

第三步，如果填充块写满了，我们就要将填充块作为新的叶节点集合顺序写入磁盘。这个时候，如果 C0 树的叶子节点和清空块都没有遍历完，我们就继续遍历归并，将数据写入新的填充块。如果清空块遍历完了，我们就去 C1 树中顺序读取新的多页块，加载到清空块中。

第四步，重复第三步，直到遍历完 C0 树和 C1 树的所有叶子节点，并将所有的归并结果写入到磁盘。这个时候，我们就可以同时删除 C0 树和 C1 树中被处理过的叶子节点。这样就完成了滚动归并的过程。

使用清空块和填充块进行滚动归并

在 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 个特点：

将索引分为内存和磁盘两部分，并在内存达到阈值时启动树合并（Merge Trees）；

用批量写入代替随机写入，并且用预写日志 WAL 技术保证内存数据，在系统崩溃后可以被恢复；

数据采取类似日志追加写的方式写入（Log Structured）磁盘，以顺序写的方式提高写入效率。

LSM 树的这些特点，使得它相对于 B+ 树，在写入性能上有大幅提升。所以，许多 NoSQL 系统都使用 LSM 树作为检索引擎，而且还对 LSM 树进行了优化以提升检索性能。在后面的章节中我们会介绍，工业界中实际使用的 LSM 树是如何实现的，帮助你对 LSM 树有更深入的认识。

课堂讨论

为了方便你理解，文章中我直接用 B+ 树实现的 C0 树。但是，对于纯内存操作，其他的类树结构会更合适。如果让你来设计的话，你会采用怎么样的结构作为 C0 树呢？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这篇文章分享给你的朋友。

08 | 索引构建：搜索引擎如何为万亿级别网站生成索引？

你好，我是陈东。

对基于内容或者属性的检索场景，我们可以使用倒排索引完成高效的检索。但是，在一些超大规模的数据应用场景中，比如搜索引擎，它会对万亿级别的网站进行索引，生成的倒排索引会非常庞大，根本无法存储在内存中。这种情况下，我们能否像 B+ 树或者 LSM 树那样，将数据存入磁盘呢？今天，我们就来聊一聊这个问题。

如何生成大于内存容量的倒排索引？

我们先来回顾一下，对于能够在内存中处理的小规模的文档集合，我们是如何生成基于哈希表的倒排索引的。步骤如下：

给每个文档编号，作为它们的唯一标识，并且排好序；

顺序扫描每一个文档，将当前扫描的文档中的所有内容生成 < 关键字，文档 ID，关键字位置 > 数据对，并将所有的 < 关键字，文档 ID，关键字位置 > 这样的数据对，都以关键字为 key 存入倒排表（位置信息如果不需要可以省略）；

重复第 2 步，直到处理完所有文档。这样就生成一个基于内存的倒排索引。

内存中生成倒排索引

对于大规模的文档集合，如果我们能将它分割成多个小规模文档集合，是不是就可以在内存中建立倒排索引了呢？这些存储在内存中的小规模文档的倒排索引，最终又是怎样变成一个完整的大规模的倒排索引存储在磁盘中的呢？这两个问题，你可以先思考一下，然后我们一起来看工业界是怎么做的。

首先，搜索引擎这种工业级的倒排索引表的实现，会比我们之前学习过的更复杂一些。比如说，如果文档中出现了“极客时间”四个字，那除了这四个字本身可能被作为关键词加入词典以外，“极客”和“时间”还有“极客时间”这三个词也可能会被加入词典。因此，完整的词典中词的数量会非常大，可能会达到几百万甚至是几千万的级别。并且，每个词因为长度不一样，所占据的存储空间也会不同。

所以，为了方便后续的处理，我们不仅会为词典中的每个词编号，还会把每个词对应的字符串存储在词典中。此外，在 posting list 中，除了记录文档 ID，我们还会记录该词在该文档中出现的每个位置、出现次数等信息。因此，posting list 中的每一个节点都是一个复杂的结构体，每个结构体以文档 ID 为唯一标识。完整的倒排索引表结构如下图所示：

倒排索引（哈希表实现）

那么，我们怎样才能生成这样一个工业级的倒排索引呢？

首先，我们可以将大规模文档均匀划分为多个小的文档集合，并按照之前的方法，为每个小的文档集合在内存中生成倒排索引。

接下来，我们需要将内存中的倒排索引存入磁盘，生成一个临时倒排文件。我们先将内存中的文档列表按照关键词的字符串大小进行排序，然后从小到大，将关键词以及对应的文档列表作为一条记录写入临时倒排文件。这样一来，临时文件中的每条记录就都是有序的了。

而且，在临时文件中，我们并不需要存储关键词的编号。原因在于每个临时文件的编号都是局部的，并不是全局唯一的，不能作为最终的唯一编号，所以无需保存。

生成磁盘中的临时文件

我们依次处理每一批小规模的文档集合，为每一批小规模文档集合生成一份对应的临时文件。等文档全部处理完以后，我们就得到了磁盘上的多个临时文件。

那磁盘上的多个临时文件该如何合并呢？这又要用到我们熟悉的多路归并技术了。每个临时文件里的每一条记录都是根据关键词有序排列的，因此我们在做多路归并的时候，需要先将所有临时文件当前记录的关键词取出。如果关键词相同的，我们就可以将对应的 posting list 读出，并且合并了。

如果 posting list 可以完全读入内存，那我们就可以直接在内存中完成合并，然后把合并结果作为一条完整的记录写入最终的倒排文件中；如果 posting list 过大无法装入内存，但 posting list 里面的元素本身又是有序的，我们也可以将 posting list 从前往后分段读入内存进行处理，直到处理完所有分段。这样我们就完成了一条完整记录的归并。

每完成一条完整记录的归并，我们就可以为这一条记录的关键词赋上一个编号，这样每个关键词就有了全局唯一的编号。重复这个过程，直到多个临时文件归并结束，这样我们就可以得到最终完整的倒排文件。

多个临时文件归并生成完整的倒排文件

这种将大任务分解为多个小任务，最终根据 key 来归并的思路，其实和分布式计算 Map Reduce 的思路是十分相似的。因此，这种将大规模文档拆分成多个小规模文档集合，再生成倒排文件的方案，可以非常方便地迁移到 Map Reduce 的框架上，在多台机器上同时运行，大幅度提升倒排文件的生成效率。那如果你想了解更多的内容，你可以看看 Google 在 2004 年发表的经典的 map reduce 论文，论文里面就说了使用 map reduce 来构建倒排索引是当时最成功的一个应用。

如何使用磁盘上的倒排文件进行检索？

那对于这样一个大规模的倒排文件，我们在检索的时候是怎么使用的呢？其实，使用的时候有一条核心原则，那就是内存的检索效率比磁盘高许多，因此，能加载到内存中的数据，我们要尽可能加载到内存中。

我们知道，一个倒排索引由两部分构成，一部分是 key 集合的词典，另一部分是 key 对应的文档列表。在许多应用中，词典这一部分数据量不会很大，可以在内存中加载。因此，我们完全可以将倒排文件中的所有 key 读出，在内存中使用哈希表建立词典。

词典加载在内存中，文档列表存在磁盘

那么，当有查询发生时，通过检索内存中的哈希表，我们就能找到对应的 key，然后将磁盘中 key 对应的 postling list 读到内存中进行处理了。

说到这里，你可能会有疑问，如果词典本身也很大，只能存储在磁盘，无法加载到内存中该怎么办呢？其实，你可以试着将词典看作一个有序的 key 的序列，那这个场景是不是就变得很熟悉了？是的，我们完全可以用 B+ 树来完成词典的检索。

这样一来，我们就可以把检索过程总结成两个步骤。第一步，我们使用 B+ 树或类似的技术，查询到对应的词典中的关键字。第二步，我们将这个关键字对应的 posting list 读出，在内存中进行处理。

词典文件 + 倒排文件

到这里，检索过程我们就说完了。不过，还有一种情况你需要考虑，那就是如果 posting list 非常长，它是很有可能无法加载到内存中进行处理的。比如说，在搜索引擎中，一些热门的关键词可能会出现在上亿个页面中，这些热门关键词对应的 posting list 就会非常大。那这样的情况下，我们该怎么办呢？

其实，这个问题在本质上和词典无法加载到内存中是一样的。而且，posting list 中的数据也是有序的。因此，我们完全可以对长度过大的 posting list 也进行类似 B+ 树的索引，只读取有用的数据块到内存中，从而降低磁盘访问次数。包括在 Lucene 中，也是使用类似的思想，用分层跳表来实现 posting list，从而能将 posting list 分层加载到内存中。而对于长度不大的 posting list，我们仍然可以直接加载到内存中。

此外，如果内存空间足够大，我们还能使用缓存技术，比如 LRU 缓存，它会将频繁使用的 posting list 长期保存在内存中。这样一来，当需要频繁使用该 posting list 的时候，我们可以直接从内存中获取，而不需要重复读取磁盘，也就减少了磁盘 IO，从而提升了系统的检索效率。

总之，对于大规模倒排索引文件的使用，本质上还是我们之前学过的检索技术之间的组合应用。因为倒排文件分为词典和文档列表两部分，所以，检索过程其实就是分别对词典和文档列表的访问过程。因此，只要你知道如何对磁盘上的词典和文档列表进行索引和检索，你就能很好地掌握大规模倒排文件的检索过程。

重点回顾

今天，我们学习了使用多文件归并的方式对万亿级别的网页生成倒排索引，还学习了针对这样大规模倒排索引文件的检索，可以通过查询词典和查询文档列表这两个阶段来实现。

除此之外，我们接触了两个很基础但也很重要的设计思想。

一个是尽可能地将数据加载到内存中，因为内存的检索效率大大高于磁盘。那为了将数据更多地加载到内存中，索引压缩是一个重要的研究方向，目前有很多成熟的技术可以实现对词典和对文档列表的压缩。比如说在 Lucene 中，就使用了类似于前缀树的技术 FST，来对词典进行前后缀的压缩，使得词典可以加载到内存中。

另一个是将大数据集合拆成多个小数据集合来处理。这其实就是分布式系统的核心思想。在大规模系统中，使用分布式技术进行加速是很重要的一个方向。不过，今天我们只是学习了利用分布式的思想来构建索引，在后面的课程中，我们还会进一步地学习，如何利用分布式技术优化检索效率。

课堂讨论

词典如果能加载在内存中，就会大幅提升检索效率。在哈希表过大无法存入内存的情况下，我们是否还有可能使用其他占用内存空间更小的数据结构，来将词典完全加载在内存中？有序数组和二叉树是否可行？为什么？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这篇文章分享给你的朋友。

09 | 索引更新：刚发布的文章就能被搜到，这是怎么做到的？

你好，我是陈东。

在前面的课程中，我们讲到，倒排索引是许多检索系统的核心实现方案。比如，搜索引擎对万亿级别网页的索引，就是使用倒排索引实现的。我们还讲到，对于超大规模的网页建立索引会非常耗时，工业界往往会使用分布式技术来并行处理。

对于发布较久的网页，搜索引擎可以有充足的时间来构建索引。但是一些新的网页和文章，往往发布了几分钟就可以被用户搜索到。这又是怎么做到的呢？今天，我们就来聊一聊这个问题。

工业界如何更新内存中的索引？

我们先来看这么一个问题：如果现在有一个小规模的倒排索引，它能完全加载在内存中。当有新文章进入内存的时候，倒排索引该如何更新呢？这个问题看似简单，但是实现起来却非常复杂。

我们能想到最直接的解决思路是，只要解析新文章有哪些关键词，然后将文章 ID 加入倒排表中关键词对应的文档列表即可。没错，在没有其他用户使用的情况下，这样的方法是可行的。但如果你有过一定的工程经验，你就会知道，在实际应用中，必然会有多个用户同时访问这个索引。

这个时候，如果我们直接更新倒排索引，就可能造成用户访问错误，甚至会引发程序崩溃。因此，一般来说，我们会对倒排表加上“读写锁”，然后再更新。但是，加上“锁”之后会带来频繁的读写锁切换，整个系统的检索效率会比无锁状态有所下降。

因此，为了使得系统有更好的性能，在工业界的实现中，我们会使用一种叫做“Double Buffer（双缓冲）机制”的解决方案，使得我们可以在无锁状态下对索引完成更新。

所谓“Double Buffer”，就是在内存中同时保存两份一样的索引，一个是索引 A，一个是索引 B。我们会使用一个指针 p 指向索引 A，表示索引 A 是当前可访问的索引。那么用户在访问时就会通过指针 p 去访问索引 A。这个时候，如果我们要更新，只更新索引 B。这样，索引 A 和索引 B 之间就不存在读写竞争的问题了。因此，在这个过程中，索引 A 和索引 B 都可以保持无锁的状态。

那更新完索引 B 之后，我们该如何告知用户应该来访问索引 B 呢？这时候，我们可以将指针 p 通过原子操作（即无法被打断的最细粒度操作，在 Java 和 C++11 等语言中都有相应实现）从 A 直接切换到 B 上。接着，我们就把索引 B 当作“只读索引”，然后更新索引 A。

通过这样的机制，我们就能同时维护两个倒排索引，保持一个读、一个写，并且来回切换，最终完成高性能的索引更新。不过，为了避免切换太频繁，我们并不是每来一条新数据就更新，而是积累一批新数据以后再批量更新。这就是工业界常用的 Double Buffer 机制。

用 Double Buffer 机制更新索引

用 Double Buffer 机制更新索引是一个高效的方案，追求检索性能的应用场景常常会使用这种方案。但是对于索引到了一定量级的应用而言，使用 Double Buffer 会带来翻倍的内存资源开销。比如说，像搜索引擎这样万亿级网页的索引规模，数据大部分存储在磁盘上，更是无法直接使用 Double Buffer 机制进行更新的。因此，我们还是需要寻找其他的解决方案。

如何使用“全量索引结合增量索引”方案？

对于大规模的索引更新，工业界常用“全量索引结合增量索引”的方案来完成。下面，我们就一起来探讨一下，这个方案是如何实现索引更新的。

首先，系统会周期性地处理全部的数据，生成一份完整的索引，也就是全量索引。这个索引不可以被实时修改，因此为了提高检索效率，我们可以不加“锁”。那对于实时更新的数据我们应该怎样处理呢？我们会将新接收到的数据单独建立一个可以存在内存中的倒排索引，也就是增量索引。当查询发生的时候，我们会同时查询全量索引和增量索引，将合并的结果作为总的结果输出。这就是“全量索引结合增量索引”的更新方案。

其实这个方案还能结合我们上面讲的 Double Buffer 机制来优化。因为增量索引相对全量索引而言会小很多，内存资源消耗在可承受范围，所以我们可以使用 Double Buffer 机制对增量索引进行索引更新。这样一来，增量索引就可以做到无锁访问。而全量索引本身就是只读的，也不需要加锁。因此，整个检索过程都可以做到无锁访问，也就提高了系统的检索效率。

“全量索引结合增量索引”的检索方案，可以很好地处理新增的数据。那对于删除的数据，如果我们不做特殊处理，会有什么问题呢？下面，我们一起来分析一下。

假设，一个数据存储在全量索引中，但是在最新的实时操作中，它被删除了，那么在增量索引中，这个数据并不存在。当我们检索的时候，增量索引会返回空，但全量索引会返回这个数据。如果我们直接合并这两个检索结果，这个数据就会被留下作为检索结果返回，但是这个数据明明已经被删除了，这就会造成错误。

要解决这个问题，我们就需要在增量索引中保留删除的信息。最常见的解决方案是增加一个删除列表，将被删除的数据记录在列表中，然后检索的时候，我们将全量倒排表和增量倒排表的检索结果和删除列表作对比。如果结果数据存在于删除列表中，就说明该数据是无效的，我们直接删除它即可。

因此，完整的“全量索引结合增量索引”检索方案，需要在增量索引中保存一个删除列表。

全量索引结合增量索引的检索方案

增量索引空间的持续增长如何处理？

“全量索引结合增量索引”的方案非常实用，但是内存毕竟有限。如果我们不对内存中的增量索引做任何处理，那随着时间推移，内存就会被写满。因此，我们需要在合适的时机将增量索引合并到全量索引中，释放增量索引的内存空间。

将增量索引合并到全量索引中的常见方法有 3 种，分别是：完全重建法、再合并法和滚动合并法。下面，我们一一来看。

1. 完全重建法

如果增量索引的增长速度不算很快，或者全量索引重建的代价不大，那么我们完全可以在增量索引写满内存空间之前，完全重建一次全量索引，然后将系统查询切换到新的全量索引上。

这样一来，之前旧的增量索引的空间也可以得到释放。这种方案叫作完全重建法。它对于大部分规模不大的检索系统而言，是十分简单可行的方案。

2. 再合并法

尽管完全重建法的流程很简单，但是效率并不是最优的。

在第 8 讲中我们讲过，对于较大规模的检索系统而言，在构建索引的时候，我们常常会将大数据集分割成多个小数据集，分别建立小索引，再把它们合并成一个大索引。

借助这样的思路，我们完全可以把全量索引想象成是一个已经将多个小索引合并好的大索引，再把增量索引想象成是一个新增的小索引。这样一来，我们完全可以直接归并全量索引和增量索引，生成一个新的全量索引，这也就避免了从头处理所有文档的重复开销。这种方法就是效率更高的再合并法。

再合并法

3. 滚动合并法

不过，如果全量索引和增量索引的量级差距过大，那么再合并法的效率依然不高。

为什么这么说呢？我们以搜索引擎为例来分析一下。在搜索引擎中，增量索引只有上万条记录，但全量索引可能有万亿条记录。这样的两个倒排索引合并的过程中，只有少数词典中的关键词和文档列表会被修改，其他大量的关键词和文档列表都会从旧的全量索引中被原样复制出来，再重写入到新的全量索引中，这会带来非常大的无谓的磁盘读写开销。因此，对于这种量级差距过大的全量索引和增量索引的归并来说，如何避免无谓的数据复制就是一个核心问题。

最直接的解决思路就是原地更新法。所谓“原地更新法”，就是不生成新的全量索引，直接在旧的全量索引上修改。

但这种方法在工程实现上其实效率并不高，原因有两点。

首先，它要求倒排文件要拆散成多个小文件，每个关键词对应的文档列表为一个小文件，这样才可以将增量索引中对应的变化直接在对应的小文件上单独修改。但这种超大规模量级的零散小文件的高效读写，许多操作系统是很难支持的。

其次，由于只有一份全量索引同时支持读和写，那我们就需要“加锁”，这肯定也会影响检索效率。因此，在一些大规模工程中，我们并不会使用原地更新法。

这就又回到了我们前面要解决的核心问题，也就是如何避免无谓的数据复制，那在工业界中常用的减少无谓数据复制的方法就是滚动合并法。所谓滚动合并法，就是先生成多个不同层级的索引，然后逐层合并。

比如说，一个检索系统在磁盘中保存了全量索引、周级索引和天级索引。所谓周级索引，就是根据本周的新数据生成的一份索引，那天级索引就是根据每天的新数据生成的一份索引。在滚动合并法中，当内存中的增量索引增长到一定体量时，我们会用再合并法将它合并到磁盘上当天的天级索引文件中。

由于天级的索引文件条数远远没有全量索引多，因此这不会造成大量的无谓数据复制。等系统中积累了 7 天的天级索引文件后，我们就可以将这 7 个天级索引文件合并成一个新的周级索引文件。因此，在每次合并增量索引和全量索引的时候，通过这样逐层滚动合并的方式，就不会进行大量的无谓数据复制的开销。这个过程就叫作滚动合并法。

滚动合并法

重点回顾

今天，我们介绍了工业界中，不同规模的倒排索引对应的索引更新方法。

对于内存资源足够的小规模索引，我们可以直接使用 Double Buffer 机制更新内存中的索引；对于内存资源紧张的大规模索引，我们可以使用“全量索引结合增量索引”的方案来更新内存中的索引。

在“全量索引结合增量索引”的方案中，全量索引根据内存资源的使用情况不同，它既可以存在内存中，也可以存在磁盘上。而增量索引则需要全部存在内存中。

当增量索引增长到上限时，我们需要合并增量索引和全量索引，根据索引的规模和增长速度，我们可以使用的合并方法有完全重建法、再合并法和滚动合并法。

除此之外，我们还讲了一个很重要的工业设计思想，就是读写分离。实际上，高效的索引更新方案都应用了读写分离的思想，将主要的数据检索放在一个只读的组件上。这样，检索时就不会有读写同时发生的竞争状态了，也就避免了加锁。事实上，无论是 Double Buffer 机制，还是全量索引结合增量索引，都是读写分离的典型例子。

课堂讨论

为什么在增量索引的方案中，对于删除的数据，我们不是像 LSM 树一样在索引中直接做删除标记，而是额外增加一个删除列表？

欢迎在留言区畅所欲言，说出你的思考过程。如果有收获，也欢迎把这篇文章分享给你的朋友。

10 | 索引拆分：大规模检索系统如何使用分布式技术加速检索？

你好，我是陈东。

在互联网行业中，分布式系统是一个非常重要的技术方向。我们熟悉的搜索引擎、广告引擎和推荐引擎，这些大规模的检索系统都采用了分布式技术。

分布式技术有什么优点呢？分布式技术就是将大任务分解成多个子任务，使用多台服务器共同承担任务，让整体系统的服务能力相比于单机系统得到了大幅提升。而且，在第 8 讲中我们就讲过，在索引构建的时候，我们可以使用分布式技术来提升索引构建的效率。

那今天，我们就来聊一聊，大规模检索系统中是如何使用分布式技术来加速检索的。

简单的分布式结构是什么样的？

一个完备的分布式系统会有复杂的服务管理机制，包括服务注册、服务发现、负载均衡、流量控制、远程调用和冗余备份等。在这里，我们先抛开分布式系统的实现细节，回归到它的本质，也就是从“让多台服务器共同承担任务”入手，来看一个简单的分布式检索系统是怎样工作的。

首先，我们需要一台接收请求的服务器，但是该服务器并不执行具体的查询工作，它只负责任务分发，我们把它叫作分发服务器。真正执行检索任务的是多台索引服务器，每台索引服务器上都保存着完整的倒排索引，它们都能完成检索的工作。

当分发服务器接到请求时，它会根据负载均衡机制，将当前查询请求发给某台较为空闲的索引服务器进行查询。具体的检索工作由该台索引服务器独立完成，并返回结果。

简单的分布式检索系统

现在，分布式检索系统的结构你已经知道了，那它的效率怎么样呢？举个例子，如果一台索引服务器一秒钟能处理 1000 条请求，那我们同时使用 10 台索引服务器，整个系统一秒钟就能处理 10000 条请求了。也就是说，这样简单的分布式系统，就能大幅提升整个检索系统的处理能力。

但是，这种简单的分布式系统有一个问题：它仅能提升检索系统整体的“吞吐量”，而不能缩短一个查询的检索时间。也就是说，如果单机处理一个查询请求的耗时是 1 秒钟，那不管我们增加了多少台机器，单次查询的检索时间依然是 1 秒钟。所以，如果我们想要缩短检索时间，这样的分布式系统是无法发挥作用的。

那么，我们能否利用多台机器，来提升单次检索的效率呢？我们先来回顾一下，在前面讨论工业级的倒排索引时我们说过，对于存储在磁盘上的大规模索引数据，我们要尽可能地将数据加载到内存中，以此来减少磁盘访问次数，从而提升检索效率。

根据这个思路，当多台服务器的总内存量远远大于单机的内存时，我们可以把倒排索引拆分开，分散加载到每台服务器的内存中。这样，我们就可以避免或者减少磁盘访问，从而提升单次检索的效率了。

即使原来的索引都能加载到内存中，索引拆分依然可以帮助我们提升单次检索的效率。这是因为，检索时间和数据规模是正相关的。当索引拆分以后，每台服务器上加载的数据都会比全量数据少，那每台服务器上的单次查询所消耗的时间也就随之减少了。

因此，索引拆分是检索加速的一个重要优化方案，至于索引应该如何拆分，以及拆分后该如何检索，工业界也有很多不同的实现方法。你可以先自己想一想，然后我们再一起来看看，工业界一般都是怎么做的。

如何进行业务拆分？

首先，在工业界中一个最直接的索引拆分思路，是根据业务进行索引拆分。那具体该如何拆分呢？

我来举个例子。在图书管理系统中，有许多不同国籍的作家的作品。如果我们将它们分成国内作品和国外作品两大类，分别建立两个倒排索引，这就完成了索引拆分。索引拆分之后，我们可以使用不同的服务器加载不同的索引。在检索的时候，我们需要先判断检索的是国内作品还是国外作品，然后在检索界面上做好选择，这样系统就可以只在一个索引上查询了。如果我们不能确认是哪类作品，那也没关系，系统可以在两个索引中并行查找，然后将结果汇总。

你会看到，基于业务的拆分是一个实用的索引拆分方案，在许多应用场景中都可以使用。但是这种方案和业务的耦合性太强，需要根据不同的业务需求灵活调整。那我们有没有更通用的技术解决方案呢？你可以先想一下，然后我们一起来讨论。

如何基于文档进行拆分？

以搜索引擎为例，一个通用的方案是借鉴索引构建的拆分思路，将大规模文档集合随机划分为多个小规模的文档集合分别处理。这样我们就可以基于文档进行拆分，建立起多个倒排索引了。其中，每个倒排索引都是一个索引分片，它们分别由不同的索引服务器负责。每个索引分片只包含部分文档，所以它们的 posting list 都不会太长，这样单机的检索效率也就得到了提升。

基于文档拆分索引

但是，这样拆分出来的任意一个单独的索引分片，它检索出来的结果都不完整，我们还需要合并操作才能得到最后的检索结果。因此，对于基于文档进行拆分的分布式方案，我们的检索流程可以总结为 3 个步骤：

分发服务器接受查询请求，将请求发送给所有不同索引分片的索引服务器；

每台索引服务器根据自己加载的索引分片进行检索，将查询结果返回分发服务器；

分发服务器将所有返回的结果进行合并处理，再返回最终结果。

基于文档拆分索引的检索过程

这种基于文档拆分的方案是随机划分的，所以我们可以不用关心业务细节。而且每个索引分片的大小都能足够相近，因此，这种拆分方式能很均匀地划分检索空间和分担检索负载。并且，如果我们将索引数据分成合适的份数，是有可能将所有数据都加载到内存中的。由于每个索引分片中的文档列表都不长，因此每台机器对于单个请求都能在更短的时间内返回，从而加速了检索效率。

但是，分片的数量也不宜过多。这是因为，一个查询请求会被复制到所有的索引分片上，如果分片过多的话，每台加载索引分片的服务器都要返回 n 个检索结果，这会带来成倍的网络传输开销。而且，分片越多，分发服务器需要合并的工作量也会越大，这会使得分发服务器成为瓶颈，造成性能下降。因此，对于索引分片数量，我们需要考虑系统的实际情况进行合理的设置。

如何基于关键词进行拆分？

在搜索引擎中，为了解决分片过多导致一次请求被复制成多次的问题，我们还可以使用另一种拆分方案，那就是基于关键词进行拆分。这种方案将词典划分成多个分片，分别加载到不同的索引服务器上。每台索引服务器上的词典都是不完整的，但是词典中关键词对应的文档列表都是完整的。

基于关键词拆分索引

当用户查询时，如果只有一个关键词，那我们只需要查询存有这个关键词的一台索引服务器，就能得到完整的文档列表，而不需要给所有的索引服务器都发送请求；当用户同时查询两个关键词时，如果这两个关键词也同时属于一个索引分片的话，那系统依然只需要查询一台索引服务器即可。如果分别属于两个分片，那我们就需要发起两次查询，再由分发服务器进行结果合并。

基于关键词拆分索引的检索过程

也就是说，在查询词少的情况下，如果能合理分片，我们就可以大幅降低请求复制的代价了。

但是这种切分方案也带来了很多复杂的管理问题，比如，如果查询词很多并且没有被划分到同一个分片中，那么请求依然会被多次复制。再比如，以及如果有的关键词是高频词，那么对应的文档列表会非常长，检索性能也会急剧下降。此外，还有新增文档的索引修改问题，系统热点查询负载均衡的问题等。

因此，除了少数的高性能检索场景有需求以外，一般我们还是基于文档进行索引拆分。这样，系统的扩展性和可运维性都会更好。

重点回顾

好了，今天的内容就先讲到这里。我们一起来总结一下，你要掌握的重点内容。

首先，利用分布式技术，我们可以将倒排索引进行索引拆分。索引拆分的好处是：一方面是能将更多的索引数据加载到内存中，降低磁盘访问次数，使得检索效率能得到大幅度的提升；另一方面是基于文档的拆分，能将一个查询请求复制成多份，由多台索引服务器并行完成，单次检索的时间也能得到缩短。

其次，除了搜索引擎，其他大规模数据检索引擎，如广告引擎、推荐引擎等也都使用了类似的索引拆分技术。只是由于它们处理的对象不是文档，因此对于拆分方式的命名也不同。

一般来说，根据处理对象将倒排索引进行拆分，每个索引分片都可能有完整的词典，但 posting list 不完整，这种拆分方案叫作水平拆分。如果是根据倒排索引中的关键词进行拆分，每个索引分片的词典都不完整，但是词典中的关键词对应的 posting list 是完整的，这种拆分方案叫作垂直拆分。

水平拆分和垂直拆分

总之，合理的索引拆分是分布式检索加速的重要手段，也是工业界的有效实践经验。因此，我希望你能好好地理解今天的内容。

课堂讨论

为什么说基于文档拆分的方案会比基于关键词拆分的方案更好维护？你可以结合以下 2 个问题来考虑一下：

当有新文档加入时，会影响多少台索引服务器？

当某些关键词是热点，会被大量查询时，每台服务器的负载是否均衡？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这篇文章分享给你的朋友。

11｜精准Top K检索：搜索结果是怎么进行打分排序的？

你好，我是陈东。

在搜索引擎的检索结果中，排在前面几页的检索结果往往质量更好，更符合我们的要求。一般来说，这些高质量检索结果的排名越靠前，这个搜索引擎的用户体验也就越好。可以说，检索结果的排序是否合理，往往决定了一个检索系统的质量。

所以，在搜索引擎这样的大规模检索系统中，排序是非常核心的一个环节。简单来说，排序就是搜索引擎对符合用户要求的检索结果进行打分，选出得分最高的 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 的值都使用对数函数进行平滑处理。处理过程如下图所示：

使用“相关性 = TF*IDF”，我们可以计算一个词项在一个文档中的权重。但是，很多情况下，一个查询中会有多个词项。不过，这也不用担心，处理起来也很简单。我们直接把每个词项和文档的相关性累加起来，就能计算出查询词和文档的总相关性了。

这么说可能比较抽象，我列举了一些具体的数字，我们一起动手来计算一下相关性。假设查询词是“极客时间”，它被分成了两个词项“极客”和“时间”。现在有两个文档都包含了“极客”和“时间”，在文档 1 中，“极客”出现了 10 次，“时间”出现了 10 次。而在文档 2 中，“极客”出现了 1 次，“时间”出现了 100 次。

计算 TF-IDF 需要的数据如下表所示：

那两个文档的最终相关性得分如下：

文档 1 打分 =TF*IDF（极客）+ TF*IDF（时间）= (1+log(10)) * 10 + (1+log(10)) * 1 = 20 + 2 = 22

文档 2 打分 = TF*IDF（极客）+ TF*IDF（时间）=（1+log(1)) * 10 + (1+log(100)) * 1 = 10 + 3 = 13

你会发现，尽管“时间”这个词项在文档 2 中出现了非常多次，但是，由于“时间”这个词项的 IDF 值比较低，因此，文档 2 的打分并没有文档 1 高。

如何使用概率模型中的 BM25 算法进行打分？

不过，在实际使用中，我们往往不会直接使用 TF-IDF 来计算相关性，而是会以 TF-IDF 为基础，使用向量模型或者概率模型等更复杂的算法来打分。比如说，概率模型中的 BM25（Best Matching 25）算法，这个经典算法就可以看作是 TF-IDF 算法的一种升级。接下来，我们就一起来看看，BM25 算法是怎么打分的。

BM25 算法的一个重要的设计思想是，它认为词频和相关性的关系并不是线性的。也就是说，随着词频的增加，相关性的增加会越来越不明显，并且还会有一个阈值上限。当词频达到阈值以后，那相关性就不会再增长了。

因此，BM25 对于 TF 的使用，设立了一个公式，公式如下：

在这个公式中，随着 tf 的值逐步变大，权重会趋向于 k1 + 1 这个固定的阈值上限（将公式的分子分母同时除以 tf，就能看出这个上限）。其中，k1 是可以人工调整的参数。k1 越大，权重上限越大，收敛速度越慢，表示 tf 越重要。在极端情况下，也就是当 k1 = 0 时，就表示 tf 不重要。比如，在下图中，当 k1 = 3 就比 k1 = 1.2 时的权重上限要高很多。那按照经验来说，我们会把 k1 设为 1.2。

除了考虑词频，BM25 算法还考虑了文档长度的影响，也就是同样一个词项，如果在两篇文档中出现了相同的次数，但一篇文档比较长，而另一篇文档比较短，那一般来说，短文档中这个词项会更重要。这个时候，我们需要在上面的公式中，加入文档长度相关的因子。那么，整个公式就会被改造成如下的样子：

你会看到，分母中的 k1 部分被乘上了文档长度的权重。其中，l 表示当前文档的长度，而 L 表示全部文档的平均长度。l 越长，分母中的 k1 就会越大，整体的相关性权重就会越小。

这个公式中除了 k1，还有一个可以人工调整的参数 b。它的取值范围是 0 到 1，它 代表了文档长度的重要性。当 b 取 0 时，我们可以完全不考虑文档长度的影响；而当 b 取 1 时，k1 的重要性要按照文档长度进行等比例缩放。按照经验，我们会把 b 设置为 0.75，这样的计算效果会比较好。

除此以外，如果查询词比较复杂，比如说一个词项会重复出现，那我们也可以把它看作是一个短文档，用类似的方法计算词项在查询词中的权重。举个例子，如果我们的查询词是“极客们的极客时间课程”，那么“极客”这个词项，其实在查询词中就出现了两次，它的权重应该比“时间”“课程”这些只出现一次的词项更重要。因此，BM25 对词项在查询词中的权重计算公式如下：

其中 tfq表示词项在查询词 q 中的词频，而 k2 是可以人工调整的参数，它和 k1 的参数作用是类似的。由于查询词一般不会太长，所以词频也不会很大，因此，我们没必要像对待文档一下，用 k1 = 1.2 这么小的范围对它进行控制。我们可以放大词频的作用，把 k2 设置在 0~10 之间。极端情况下，也就是当 k2 取 0 时，表示我们可以完全不考虑查询词中的词项权重。

好了，前面我们说了这么多种权重公式，有基础的权重公式、文档中词项的权重公式和查询词中词项的权重公式。那在实际使用 BM25 算法打分的时候，我们该怎么使用这些公式呢？其实，我们可以回顾一下标准的 TF-IDF，把其中的 TF 进行扩展，变为“文档中词项权重”和“查询词中词项权重”的乘积。这样，我们就得到了 BM25 算法计算一个词项和指定文档相关性的打分公式，公式如下：

你会看到，它由 IDF、文档中词项权重以及查询词中词项权重这三部分共同组成。

如果一个查询词 q 中有多个词项 t，那我们就要把每一个词项 t 和文档 d 的相关性都计算出来，最后累加。这样，我们就得到了这个查询词 q 和文档 d 的相关性打分结果，我们用 score(q,d) 来表示，公式如下:

这就是完整的 BM25 算法的表达式了。尽管这个公式看起来比较复杂，但是经过我们刚才一步一步的拆解，你应该可以很好地理解它了，它其实就是对 TF-IDF 的算法中的 TF 做了更细致的处理而已。其实，BM25 中的 IDF 的部分，我们也还可以优化，比如，基于二值独立模型对它进行退化处理（这是另一个分析相关性的模型，这里就不具体展开说了）之后，我们就可以得到一个和 IDF 相似的优化表示，公式如下：

你可以将它视为 IDF 的变体，用来替换公式中原有的 IDF 部分。

总结来说，BM25 算法就是一个对查询词和文档的相关性进行打分的概率模型算法。BM25 算法考虑了四个因子，分别为 IDF、文档长度、文档中的词频以及查询词中的词频。并且，公式中还加入了 3 个可以人工调整大小的参数，分别是 ：k1、k2 和 b。

因此，BM25 算法的效果比 TF-IDF 更好，应用也更广泛。比如说，在 Lucene 和 Elastic Search 这些搜索框架，以及 Google 这类常见的搜索引擎中，就都支持 BM25 排序。不过要用好它，你需要结合我们今天讲的内容，更清楚地理解它的原理。这样才能根据不同的场景，去调整相应的参数，从而取得更好的效果。

如何使用机器学习来进行打分？

随着搜索引擎的越来越重视搜索结果的排序和效果，我们需要考虑的因子也越来越多。比如说，官方的网站是不是会比个人网页在打分上有更高的权重？用户的历史点击行为是否也是相关性的一个衡量指标？

在当前的主流搜索引擎中，用来打分的主要因子已经有几百种了。如果我们要将这么多的相关因子都考虑进来，再加入更多的参数，那 BM25 算法是无法满足我们的需求的。

这个时候，机器学习就可以派上用场了。利用机器学习打分是近些年来比较热门的研究领域，也是许多搜索引擎目前正在使用的打分机制。

那机器学习具体是怎么打分的呢？原理很简单，就是把不同的打分因子进行加权求和。比如说，有 n 个打分因子，分别为 x1到 xn，而每个因子都有不同的权重，我们记为 w1到 wn，那打分公式就是：

Score = w1* x1+ w2* x2+ w3* x3+ …… + wn* xn

那你可能会问了，公式中的权重要如何确定呢？这就需要我们利用训练数据，让机器学习在离线阶段，自动学出最合适的权重。这样，就避免了人工制定公式和权重的问题。

当然，这个打分公式是不能直接使用的，因为它的取值范围是负无穷到正无穷。这是一个跨度很广的范围，并不好衡量和比较相关性。一般来说，我们会使用 Sigmoid 函数对 score 进行处理，让它处于 (0,1) 范围内。

Sigmoid 函数的取值范围是（0，1），它的函数公式和图像如下所示：

Sigmoid 函数图像（x 代表 score，y 代表相关性）

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) 的检索性能会高得多。

课堂讨论

在今天介绍的精准 Top K 检索的过程中，你觉得哪个部分是最耗时的？是打分还是排序？

你觉得机器学习打分的优点在哪里？你是否使用过机器学习打分？可以把你的使用场景分享出来。

欢迎在留言区畅所欲言，说出你的想法。如果有收获，也欢迎把这一讲分享给你的朋友。

12 | 非精准Top K检索：如何给检索结果的排序过程装上“加速器”？

你好，我是陈东。

上一讲，我们详细讲解了 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 的核心算法，通过分析网页链接的引用关系来判断网页的质量）离线计算好每个网站的质量分，当一个搜索词要返回多个网站时，我们只需要根据网站质量分排序，将质量最好的 Top K 个网站返回即可。

不过，为了能快速返回 Top K 个结果，我们需要改造一下倒排索引中的 posting list 的组织方式。我们讲过，倒排索引的 posting list 都是按文档 ID 进行排序的。如果希望根据静态质量得分快速截断的话，那我们就应该将 posting list 按照静态质量得分，由高到低排序。对于分数相同的文档，再以文档 ID 二次排序。

按静态质量得分排序

这样一来，在检索的时候，如果只有一个关键词，那我们只需要查出该关键词对应的 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 中的排序是不同的，因此，我们无法使用归并排序的方法将它们快速合并和截断。

以词频数值排序导致无法归并

既然问题出在排序上，那我们能否既用上词频的分值，又保持 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 都可以根据静态质量得分来排序，以方便检索的时候能快速截断。那具体是怎么检索的呢？我们一起来看看。

分层索引的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 共同完成的。这种设计的核心思想，是希望用更小的代价快速减少检索排序范围，从而提升整体在线检索的效率。我把它的实现过程总结成了一张示意图，你可以参考它来梳理、巩固今天的内容。

完整Top K检索的过程示意图

课堂讨论

在分层索引中，posting list 中的文档为什么还要根据静态质量得分排序？排序应该是升序还是降序？

对于非精准 Top K 检索，你有没有相关的方法或者应用场景可以分享呢？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这一讲分享给你的朋友。

13 | 空间检索（上）：如何用Geohash实现“查找附近的人”功能？

你好，我是陈东。

现在，越来越多的互联网应用在提供基于地理位置的服务。这些基于地理位置服务，本质上都是检索附近的人或者物的服务。比如说，社交软件可以浏览附近的人，餐饮平台可以查找附近的餐厅，还有出行平台会显示附近的车等。那如果你的老板希望你能为公司的应用开发相关的功能，比如说实现一个“查询附近的人”功能，你会怎么做呢？

一个很容易想到的方案是，把所有人的坐标取出来，计算每个人和自己当前坐标的距离。然后把它们全排序，并且根据距离远近在地图上列出来。但是仔细想想你就会发现，这种方案在大规模的系统中并不可行。

这是因为，如果系统中的人数到达了一定的量级，那计算和所有人的距离再排序，这会是一个非常巨大的代价。尽管，我们可以使用堆排序代替全排序来降低排序代价，但取出所有人的位置信息并计算距离，这本身就是一个很大的开销。

那在大规模系统中实现“查找附近的人功能”，我们有什么更高效的检索方案呢？今天我们就来聊聊这个问题。

使用非精准检索的思路实现“查找附近的人”

事实上，“查找附近的人”和“检索相关的网页”这两个功能的本质是非常相似的。在这两个功能的实现中，我们都没有明确的检索目标，也就都不需要非常精准的检索结果，只需要保证质量足够高的结果包含在 Top K 个结果中就够了。所以，非精准 Top K 检索也可以作为优化方案，来实现“查找附近的人”功能。那具体是如何实现的呢？

我们可以通过限定“附近”的范围来减少检索空间。一般来说，同一个城市的人往往会比不同城市的人距离更近。所以，我们不需要去查询所有的人，只需要去查询自己所在城市的人，然后计算出自己和他们的距离就可以了，这样就能大大缩小检索范围了。那在同一个城市中，我们也可以优先检索同一个区的用户，来再次缩小检索范围。这就是非精准检索的思路了。

在这种限定“附近”区域的检索方案中，为了进一步提高检索效率，我们可以将所有的检索空间划分为多个区域并做好编号，然后以区域编号为 key 做好索引。这样，当我们需要查询附近的人时，先快速查询到自己所属的区域，然后再将该区域中所有人的位置取出，计算和每一个人的距离就可以了。在这个过程中，划分检索空间以及对其编号是最关键的一步，那具体怎么操作呢？我们接着往下看。

如何对区域进行划分和编号？

对于一个完整的二维空间，我们可以用二分的思想将它均匀划分。也就是在水平方向上一分为二，在垂直方向上也一分为二。这样一个空间就会被均匀地划分为四个子空间，这四个子空间，我们可以用两个比特位来编号。在水平方向上，我们用 0 来表示左边的区域，用 1 来表示右边的区域；在垂直方向上，我们用 0 来表示下面的区域，用 1 来表示上面的区域。因此，这四个区域，从左下角开始按照顺时针的顺序，分别是 00、01、11 和 10。

区域划分和编号

接下来，如果要继续划分空间，我们依然沿用这个思路，将每个区域再分为四块。这样，整个空间就被划分成了 16 块区域，那对应的编号也会再增加两位。比如说，01 编号的区域被划分成了 4 小块，那这四小块的编号就是在 01 后面追加两位编码，分别为 01 00、01 01、 01 10、 01 11。依次类推，我们可以将整个空间持续细分。具体划分到什么粒度，就取决于应用对于“附近”的定义和需求了。

这种区域编码的方式有 2 个优点：

区域有层次关系：如果两个区域的前缀是相同的，说明它们属于同一个大区域；

区域编码带有分割意义：奇数位的编号代表了垂直切分，偶数位的编号代表了水平切分，这会方便区域编码的计算（奇偶位是从右边以第 0 位开始数起的）。

如何快速查询同个区域的人？

那有了这样的区域编码方式以后，我们该怎么查询呢？这就要说到区域编码的一个特点了：区域编码能将二维空间的两个维度用一维编码表示。利用这个特点，我们就可以使用一维空间中常见的检索技术快速查找了。我们可以将区域编码作为 key，用有序数组存储，这样就可以用二分查找进行检索了。

如果有效区域动态增加，那我们还可以使用二叉检索树、跳表等检索技术来索引。在一些系统的实现中，比如 Redis，它就可以直接支持类似的地理位置编码的存入和检索，内部的实现方式是，使用跳表按照区域编码进行排序和查找。此外，如果希望检索效率更高，我们还可以使用哈希表来实现区域的查询。

这样一来，当我们想要查询附近的人时，只需要根据自己的坐标，计算出自己所属区域的编码，然后在索引中查询出所有属于该区域的用户，计算这些用户和自己的距离，最后排序展现即可。

不过，这种非精准检索的方案，会带来一定的误差。也就是说，我们找到的所谓“附近的人”，其实只是和你同一区域的人而已，并不一定是离你最近的。比如说，你的位置正好处于一个区域的边缘，那离你最近的人，也可能是在你的邻接区域里。

邻接区域距离可能更近

好在，在“查找附近的人”这类目的性不明确的应用中，这样的误差我们也是可以接受的。但是，在另一些有精准查询需求的应用中，是不允许存在这类误差的。比如说，在游戏场景中，角色技能的攻击范围必须是精准的，它要求技能覆盖范围内的所有敌人都应该受到伤害，不能有遗漏。那这是怎么做到的呢？你可以先想一想，然后再来看我的分析。

如何精准查询附近的人？

既然邻接区域的人距离我们更近，那我们是不是可以建立一个更大的候选集合，把这些邻接区域的用户都加进去，再一起计算距离和排序，这样问题是不是就解决了呢？我们先试着操作一下。

对于目标所在的当前区域，我们可以根据期望的查询半径，以当前区域为中心向周围扩散，从而将周围的区域都包含进来。假设，查询半径正好是一个区域边长的一半，那我们只要将目标区域周围一圈，也就是 8 个邻接区域中的用户都加入候选集，这就肯定不会有遗漏了。这时，虽然计算量提高了 8 倍，但我们可以给出精准的解了。

如果要降低计算量，我们可以将区域划分的粒度提高一个量级。这样，区域的划分就更精准，在查询半径不变的情况下，需要检索的用户的数量就会更少（查询范围对比见下图中两个红框部分）。

更细粒度地划分区域

知道了要查询的区域有哪些，那我们怎么快速寻找这些区域的编码呢？这就要回到我们区域编码的方案本身了。前面我们说了，区域编码可以根据奇偶位拆成水平编码和垂直编码这两块，如果一个区域编码是 0110，那它的水平编码就是 01，垂直编码就是 10。那该区域右边一个区域的水平编码的值就比它自己的大 1，垂直编码则相同。因此，我们通过分解出当前区域的水平编码和垂直编码，对对应的编码值进行加 1 或者减 1 的操作，就能得到不同方向上邻接的 8 个区域的编码了。

区域编码规则

以上，就是精准查询附近人的检索过程，我们可以总结为两步：第一步，先查询出自己所属的区域编码，再计算出周围 8 个邻接区域的区域编码；第二步，在索引中查询 9 次，取出所有属于这些区域中的人，精准计算每一个人和自己的距离，最后排序输出结果。

什么是 Geohash 编码？

说到这，你可能会有疑问了，在实际工作中，用户对应的都是实际的地理位置坐标，那它和二维空间的区域编码又是怎么联系起来的呢？别着急，我们慢慢说。

实际上，我们会将地球看作是一个大的二维空间，那经纬度就是水平和垂直的两个切分方向。在给出一个用户的经纬度坐标之后，我们通过对地球的经纬度区间不断二分，就能得到这个用户所属的区域编码了。这么说可能比较抽象，我来举个例子。

我们知道，地球的纬度区间是[-90,90]，经度是[-180,180]。如果给出的用户纬度（垂直方向）坐标是 39.983429，经度（水平方向）坐标是 116.490273，那我们求这个用户所属的区域编码的过程，就可以总结为 3 步：

在纬度方向上，第一次二分，39.983429 在[0,90]之间，[0,90]属于空间的上半边，因此我们得到编码 1。然后在[0,90]这个空间上，第二次二分，39.983429 在[0,45]之间，[0,45]属于区间的下半边，因此我们得到编码 0。两次划分之后，我们得到的编码就是 10。

在经度方向上，第一次二分，116.490273 在[0,180]之间，[0,180]属于空间的右半边，因此我们得到编码 1。然后在[0,180]这个空间上，第二次二分，116.490273 在[90,180]之间，[90,180]还是属于区间的右半边，因此我们得到的编码还是 1。两次划分之后，我们得到的编码就是 11。

我们把纬度的编码和经度的编码交叉组合起来，先是经度，再是纬度。这样就构成了区域编码，区域编码为 1110。

你会发现，在上面的例子中，我们只二分了两次。实际上，如果区域划分的粒度非常细，我们就要持续、多次二分。而每多二分一次，我们就需要增加一个比特位来表示编码。如果经度和纬度各二分 15 次的话，那我们就需要 30 个比特位来表示一个位置的编码。那上面例子中的编码就会是 11100 11101 00100 01111 00110 11110。

计算编码的过程示意图

这样得到的编码会特别长，那为了简化编码的表示，我们可以以 5 个比特位为一个单位，把长编码转为 base32 编码，最终得到的就是 wx4g6y。这样 30 个比特位，我们只需要用 6 个字符就可以表示了。

这样做不仅存储会更简单，而且具有相同前缀的区域属于同一个大区域，看起来也非常直观。这种将经纬度坐标转换为字符串的编码方式，就叫作 Geohash 编码。大多数应用都会使用 Geohash 编码进行地理位置的表示，以及在很多系统中，比如，Redis、MySQL 以及 Elastic Search 中，也都支持 Geohash 数据的存储和查询。

十进制转为base32编码字符对照表

那在实际转换的过程中，由于不同长度的 Geohash 代表不同大小的覆盖区域，因此我们可以结合 GeoHash 字符长度和覆盖区域对照表，根据自己的应用需要选择合适的 Geohash 编码长度。这个对照表让我们在使用 Geohash 编码的时候方便很多。

字符长度和覆盖区域对照表

不过，Geohash 编码也有缺点。由于 Geohash 编码的一个字符就代表了 5 个比特位，因此每当字符长度变化一个单位，区域的覆盖度变化跨度就是 32 倍（2^5），这会导致区域范围划分不够精细。

因此，当发现粒度划分不符合自己应用的需求时，我们其实可以将 Geohash 编码转换回二进制编码的表示方式。这样，编码长度变化的单位就是 1 个比特位了，区域覆盖度变化跨度就是 2 倍，我们就可以更灵活地调整自己期望的区域覆盖度了。实际上，在许多系统的底层实现中，虽然都支持以字符串形式输入 Geohash 编码，但是在内存中的存储和计算都是以二进制的方式来进行的。

重点回顾

今天，我们重点学习了利用空间检索的技术来查找附近的人。

首先，我们通过将二维空间在水平和垂直方向上不停二分，可以生成一维的区域编码，然后我们可以使用一维空间的检索技术对区域编码做好索引。

在查询时，我们可以使用非精准的检索思路，直接检索相应的区域编码，就可以查找到“附近的人”了。但如果要进行精准检索，我们就需要根据检索半径将扩大检索范围，一并检索周边的区域，然后将所有的检索结果进行精确的距离计算，最终给出整体排序。这也是一个典型的“非精准 Top K 检索 - 精准 Top K 检索”的应用案例。因此，当你需要基于地理位置，进行查找或推荐服务的开发时，可以根据具体需求，灵活使用今天学习到的检索方案。

此外，我们还学习了 Geohash 编码，Geohash 编码是很常见的一种编码方式，它将真实世界的地理位置根据经纬度进行区域编码，再使用 base32 编码生成一维的字符串编码，使得区域编码在显示和存储上都更加方便。

课堂讨论

如果一个应用期望支持“查找附近的人”的功能。在初期用户量不大的时候，我们使用什么索引技术比较合理？在后期用户量大的时候，为了加快检索效率，我们又可以采用什么检索技术？为什么？

如果之前的应用选择了 5 个字符串的 Geohash 编码，进行区域划分（区域范围为 4.9 km * 4.9 km），那当我们想查询 10 公里内的人，这个时候该如何进行查询呢？使用什么索引技术会比较合适呢？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这一讲分享给你的朋友。

14 | 空间检索（下）：“查找最近的加油站”和“查找附近的人”有何不同？

你好，我是陈东。

上一讲我们讲了，对于查询范围固定的应用需求，比如“查找附近的人”，我们可以根据规划好的查询区域大小，均匀划分所有的空间，然后用 GeoHash 将坐标转换为区域编码，以该区域编码作为 Key 开始检索。这样，我们就可以查到并取出该区域中的目标数据，对这些数据进行精准计算然后排序输出了。

但是，并不是所有应用的查询范围都是不变的。在一些基于地理位置的服务中，我们并不关心检索结果是否就在我们“附近”，而是必须要找到“最近”的一批满足我们要求的结果。这怎么理解呢？

我来举个例子，我们在长途自驾游的时候，突然发现车快没油了。这个时候，我们要在一个导航地图中查找最近的 k 个加油站给车加油，这些加油站可能并不在我们附近，但地图又必须要返回最近的 k 个结果。类似的情况还有很多，比如说，我们要查询最近的医院有哪些，查询最近的超市有哪些。那对于这一类的查询，如果当前范围内查不到，系统就需要自动调整查询范围，直到能返回 k 个结果为止。

对于这种需要动态调整范围的查询场景，我们有什么高效的检索方案呢？今天，我们就来探讨一下这个问题。

直接进行多次查询会有什么问题？

我们就以查找最近的加油站为例，一个直观的想法是，我们可以先获得当前位置的 GeoHash 编码，然后根据需求不停扩大查询范围进行多次查询，最后合并查询结果。这么说比较抽象，我们来分析一个具体的位置编码。

假设我们当前地址的 GeoHash 编码为 wx4g6yc8，那我们可以先用 wx4g6yc8 去查找当前区域的加油站。如果查询的结果为空，我们就扩大范围。扩大查询范围的思路有两种。

第一种思路是，一圈一圈扩大范围。具体来说就是，我们第一次查询周边 8 个邻接区域，如果查询结果依然为空，就再扩大一圈，查询再外圈的 16 个区域。如果还是不够，下一次我们就查询再外圈的 24 个区域，依此类推。你会发现，这种方案的查询次数会成倍地增加，它的效率并不高。

逐步扩大查询周边区域

另一种思路是，我们每次都将查询单位大幅提高。比如说，直接将 GeoHash 编码去掉最后一位，用 wx4g6yc 再次去查询。如果有结果返回，但是不满足要返回 Top K 个的要求，那我们就继续扩大范围，再去掉一个编码，用 wx4g6y 去查询。就这样不停扩大单位的进行反复查询，直到结果大于 k 个为止。

逐步扩大查询单位

和第一种查询思路相比，在第二种思路中，我们每次查询的区域单位都得到了大范围的提升，因此，查询次数不会太多。比如说，对于一个长度为 8 的 GeoHash 编码，我们最多只需要查询 8 次（如果要求精准检索，那每次查询就扩展到周围 8 个同样大小的邻接区域即可，后面我就不再解释了）。

这个检索方案虽然用很少的次数就能“查询最近的 k 个结果”，但我们还需要保证，每次的查询请求都能快速返回结果。这就要求我们采用合适的索引技术，来处理 GeoHash 的每个层级。

比如说，如果使用基于哈希表的倒排检索来实现，我们就需要在 GeoHash 每个粒度层级上都分别建立一个单独的倒排表。这就意味着，每个层级的倒排表中都会出现全部的加油站，数据会被复制多次，这会带来非常大的存储开销。那我们是否有优化存储的方案呢？

我们可以利用 GeoHash 编码一维可排序的特点，使用数组或二叉检索树来存储和检索。由于数组和二叉检索树都可以支持范围查询，因此我们只需要建立一份粒度最细的索引就可以了。这样，当我们要检索更大范围的区域时，可以直接将原来的查询改写为范围查询。具体怎么做呢？

我来举个例子。在检索完 wx4g6yc8 这个区域编码以后，如果结果数量不够，还要检索 wx4g6yc 这个更大范围的区域编码，我们只要将查询改写为“查找区域编码在 wx4g6yc0 至 wx4g6ycz 之间的元素”，就可以利用同一个索引，来完成更高一个层级的区域查询了。同理，如果结果数量依然不够，那下一步我们就查询“区域编码在 wx4g6y00 至 wx4g6yzz 之间的元素”，依此类推。

利用有序数组查询示例

但是，这种方案有一个缺点，那就是在每次调整范围查询时，我们都要从头开始进行二分查找，不能充分利用上一次已经查询到的位置信息，这会带来无谓的重复检索的开销。那该如何优化呢？你可以先想一想，然后我们一起来看解决方案。

如何利用四叉树动态调整查询范围？

上一讲我们讲过，许多系统对于 GeoHash 的底层实现，其实都是使用二进制进行存储和计算的。而二进制区域编码的生成过程，就是一个逐渐二分空间的过程，经过二分后的区域之间是有层次关系的。如果我们把这个过程画下来，它就很像我们之前讲过的树形结构。

因此，我们可以尝试用树形结构来进行索引。这里，我们就要引入一个新的数据结构四叉树了。四叉树的树根节点代表了整个空间，每个节点的四个分叉分别表示四个子空间。其中，树根和中间节点不存储数据，只记录分叉指针。而数据只记录在最小的区域，也就是叶子节点上。

如果我们从根节点开始，不停地四分下去，直到每个分支的叶子节点都是最小粒度区域。那这样构建出来的四叉树，每个节点都有四个子节点，就叫作满四叉树。

对于满四叉树的每个节点，我们都可以编号。换句话说，我们可以按 00、01、10、11 的编号，来区分满四叉树的四个子节点。这样一来，只要我们从根节点遍历到叶子节点，然后将路径上每个节点的编号连起来，那最后得到的编码就是这个叶子节点所代表的区域编码。

满四叉树

好了，现在我们知道了四叉树的结构和特点了，那我们怎么利用它完成自动调整范围的 Top K 检索呢？下面，我们通过一个例子来看看。

假设一个人所属的最小区域编码是 0110，那我们在检索的时候，就以 0110 为 Key，沿着四叉树的对应分支去寻找相应的区域，查询路径为 01-10。如果查找到了叶子节点，并且返回的结果大于 k 个，就可以直接结束检索。如果返回结果不足 k 个，我们就得递归返回到上一层的父节点，然后以这整个父节点的区域编码为目标进行检索。这样，我们就避免了要再次从树根检索到父节点的开销，从而提升了检索效率。

自动调整范围的Top K检索

如何利用非满四叉树优化存储空间？

尽管，我们使用以最小区域单位为叶子节点的满四叉树，能够很好的提升检索效率，但是在数据稀疏的时候，许多叶子节点中的数据可能是空的，这就很有可能造成大量的空间浪费。为了避免出现空间浪费，我们有一种改进方案是，使用动态节点分裂的非满四叉树。

首先，我们可以给每个叶子节点规定一个容纳上限。比如说，我们可以将上限设置为 n。那么，一开始的四叉树只有一个根节点，这个根节点同时也是叶子节点，它表明了当前的全部空间范围。当有数据加入的时候，我们直接记录在这个节点中，查询时也只查询这个节点即可。因此，当插入的数据个数小于 n 时，我们不需要进行任何复杂的查找操作，只需要将根节点的所有数据读出，然后进行距离计算并排序即可。

随着加入的数据越来越多，如果一个叶子节点的容量超出了容纳上限，我们就将该节点进行分裂。首先，我们将该节点转为中间节点，然后，我们会为这个节点生成 1 至 4 个叶子节点（注意：不是一定要生成 4 个叶子节点），并将原来存在这个节点上的数据都转入到对应的叶子节点中。这样，我们就完成了分裂。

不过，有一种极端的情况是，这些数据都会转入到同一个下层叶子节点上。这时，我们就需要继续分裂这个叶子节点，直到每个叶子节点的容量在阈值下为止。

通过这种动态生成叶节点的方案，我们就能得到一棵非满四叉树。和满四叉树相比，它的叶子节点会更少，而且每个叶子节点表示的区域范围也可能是不一样的。这使得非满四叉树具有更好的空间利用率。非满四叉树的查询过程和满四叉树十分相似，也是根据当前的区域编码，找到对应的叶子节点，并根据该叶子节点上存储的数据数量，判断是否要递归扩大范围。这里我就不再详细说了。

非满四叉树-动态分裂叶节点

如何用前缀树优化 GeoHash 编码的索引？

上面，我们都是用二进制编码来说明的。你可能会问，如果我们使用了 GeoHash 编码方式，是否也可以用类似的检索技术来索引呢？当然是可以的。实际上，对于字符串的检索，有一种专门的数据结构，叫作前缀树（Trie 树）。

前缀树的思路和四叉树非常相似，它也是一种逐层划分检索空间的数据结构。它的根节点代表了整个检索空间，然后每个中间节点和叶子节点都只存储一个字符，代表一个分支。这样，从根节点到叶子节点的路径连起来，就是一个完整的字符串。因此，当使用 GeoHash 编码来表示区域时，我们可以建立一个前缀树来进行索引，前缀树的每个节点最多会有 32 个子节点。

前缀树

那如何利用前缀树来检索呢？举个例子，当我们查询 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 个叶子节点？你能举一个例子吗？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这一讲分享给你的朋友。

15 | 最近邻检索（上）：如何用局部敏感哈希快速过滤相似文章？

你好，我是陈东。

在搜索引擎和推荐引擎中，往往有很多文章的内容是非常相似的，它们可能只有一些修饰词不同。如果在搜索结果或者推荐结果中，我们将这些文章不加过滤就全部展现出来，那用户可能在第一页看到的都是几乎相同的内容。这样的话，用户的使用体验就会非常糟糕。因此，在搜索引擎和推荐引擎中，对相似文章去重是一个非常重要的环节。

对相似文章去重，本质上就是把相似的文章都检索出来。今天，我们就来聊聊如何快速检索相似的文章。

如何在向量空间中进行近邻检索？

既然是要讨论相似文章的检索，那我们就得知道，一篇文章是怎么用计算机能理解的形式表示出来的，以及怎么计算两篇文章的相似性。最常见的方式就是使用向量空间模型（Vector Space Model）。所谓向量空间模型，就是将所有文档中出现过的所有关键词都提取出来。如果一共有 n 个关键词，那每个关键词就是一个维度，这就组成了一个 n 维的向量空间。

那一篇文档具体该如何表示呢？我们可以假设，一篇文章中有 k（0<k<=n）个关键词，如果第 k 个关键词在这个文档中的权重是 w，那这个文档在第 k 维上的值就是 w。一般来说，我们会以一个关键词在这篇文档中的 TF-IDF 值作为 w 的值。而如果文章不包含第 k 个关键词，那它在第 k 维上的值就是 0，那我们也可以认为这个维度的权重就是 0。这样，我们就可以用一个 n 维的向量来表示一个文档了，也就是 <w1,w2,w3,……wn>。这样一来，每一个文档就都是 n 维向量空间中的一个点。

一个文档的向量化表示

那接下来，计算两个文章相似度就变成了计算两个向量的相似度。计算向量相似度实际上就是计算两个向量的距离，距离越小，它们就越相似。具体在计算的时候，我们可以使用很多种距离度量方式。比如说，我们可以采用余弦距离，或者采用欧式距离等。一般来说，我们会采用余弦距离来计算向量相似度。

拓展到搜索引擎和推荐引擎中，因为每个文档都是 n 维向量中的一个点，所以查询相似文章的问题，就变成了在 n 维空间中，查询离一个点距离最近的 k 个点的问题。如果把这些“点”想象成“人”，这不就和我们在二维空间中查询附近的人的问题非常类似了吗？这就给了我们一个启发，我们是不是也能用类似的检索技术来解决它呢？下面，我们一起来看一下。

首先，在十几维量级的低维空间中，我们可以使用 k-d 树进行 k 维空间的近邻检索，它的性能还是不错的。但随着维度的增加， 如果我们还要精准找到最邻近的 k 个点，k-d 需要不停递归来探索邻接区域，检索效率就会急剧下降，甚至接近于遍历代价。当关键词是几万乃至百万级别时，文档的向量空间可能是一个上万维甚至是百万维的超高维空间，使用 k-d 树就更难以完成检索工作了。因此，我们需要寻找更简单、高效的方案。

这个时候，使用非精准 Top K 检索代替精准 Top K 检索的方案就又可以派上用场了。这是为什么呢？因为高维空间本身就很抽象，在用向量空间中的一个点表示一个对象的过程中，如果我们选择了不同的权重计算方式，那得到的向量就会不同，所以这种表示方法本身就已经损失了一定的精确性。

因此，对于高维空间的近邻检索问题，我们可以使用近似最近邻检索（Approximate Nearest Neighbor）来实现。你可以先想一想查询附近的人是怎么实现的，然后再和我一起来看高维空间的近似最近邻检索是怎么做的。

什么是局部敏感哈希？

借助非精准检索的思路，我们可以将高维空间的点也进行区域划分，然后为每个区域都生成一个简单的一维编码。这样，当我们要查找一个点最邻近的 k 个点的时候，直接计算出区域编码就能高效检索出同一个区域的所有对象了。

也因此，我们就能得出一个结论，那就是同一个区域中的不同的点，通过统一的计算过程，都能得到相同的区域编码。这种将复杂对象映射成简单编码的过程，是不是很像哈希的思路？

所以，我们可以利用哈希的思路，将高维空间中的点映射成低维空间中的一维编码。换句话说，我们通过计算不同文章的哈希值，就能得到一维哈希编码。如果两篇文章内容 100% 相同，那它们的哈希值就是相同的，也就相当于编码相同。

不过，如果我们用的是普通的哈希函数，只要文档中的关键词有一些轻微的变化（如改变了一个字），哈希值就会有很大的差异。但我们又希望，整体相似度高的两篇文档，通过哈希计算以后得到的值也是相近的。因此，工业界设计了一种哈希函数，它可以让相似的数据通过哈希计算后，生成的哈希值是相近的（甚至是相等的）。这种哈希函数就叫作局部敏感哈希（Locality-Sensitive Hashing）。

普通哈希 VS 局部敏感哈希

其实局部敏感哈希并不神秘。让我们以熟悉的二维空间为例来进一步解释一下。

在二维空间中，我们随意划一条直线就能将它一分为二，我们把直线上方的点的哈希值定为 1，把直线下方的点的哈希值定为 0。这样就完成一个简单的哈希映射。通过这样的随机划分，两个很接近的点被同时划入同一边的概率，就会远大于其他节点。也就是说，这两个节点的哈希值相同的概率会远大于其他节点。

二维空间的随机划分

当然，这样的划分有很大的随机性，不一定可靠。但是，如果我们连续做了 n 次这样的随机划分，这两个点每次都在同一边，那我们就可以认为它们在很大概率上是相近的。因此，我们只要在 n 次随机划分的过程中，记录下每一个点在每次划分后的值是 0 还是 1，就能得到一个 n 位的包含 0 和 1 的序列了。这个序列就是我们得到的哈希值，也就是区域编码。

将二维空间划分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 步。

选择一个能将关键词映射到 64 位正整数的普通哈希函数。

使用该哈希函数给文档中的每个关键词生成一个 64 位的哈希值，并将该哈希值中的 0 修改为 -1。比如说，关键词 A 的哈希值编码为 <1,0,1,1,0>，那我们做完转换以后，编码就变成了 <1,-1,1,1,-1>。

将关键词的编码乘上关键词自己的权重。如果关键词编码为 <1,-1,1,1,-1>，关键词的权重为 2，最后我们得到的关键词编码就变成了 <2,-2,2,2,-2>。

将所有关键词的编码按位相加，合成一个编码。如果两个关键词的编码分别为 <2,-2,2,2,-2> 和 <3,3,-3,3,3>，那它们相加以后就会得到 <5,1,-1,5,1>。

将最终得到的编码中大于 0 的值变为 1，小于等于 0 的变为 0。这样，编码 <5,1,-1,5, 1> 就会被转换为 <1,1,0,1,1>。

SimHash生成过程

通过这样巧妙的构造，SimHash 将每个关键词的权重保留并且叠加，一直留到最后，从而使得高权重的关键词的影响能被保留。从上图中你可以看到，整个文档的 SimHash 值和权重最大的关键词 word 2 的哈希值是一样的。这就体现了高权重的关键词对文档的最终哈希值的影响。此外，SimHash 通过一个简单的普通哈希函数就能生成 64 位哈希值，这替代了随机划分 64 个超平面的复杂工作，也让整个函数的实现更简单。

如何对局部敏感哈希值进行相似检索？

和其他局部敏感哈希函数一样，如果两个文档的 SimHash 值的海明距离小于 k，我们就认为它们是相似的。举个例子，在 Google 的实现中，k 的取值为 3。这个时候，检索相似文章的问题变成了要找出海明距离在 3 之内的所有文档。如果是一个个文档比对的话，这就是一个遍历过程，效率很低。有没有更高效的检索方案呢？

一个直观的想法是，我们可以针对每一个比特位做索引。由于每个比特位只有 0 和 1 这 2 个值，一共有 64 个比特位，也就一共有 2*64 共 128 个不同的 Key。因此我们可以使用倒排索引，将所有的文档根据自己每个比特位的值，加入到对应的倒排索引的 posting list 中。这样，当要查询和一个文档相似的其他文档的时候，我们只需要通过 3 步就可以实现了，具体的步骤如下：

计算出待查询文档的 SimHash 值；

以该 SimHash 值中每个比特位的值作为 Key，去倒排索引中查询，将相同位置具有相同值的文档都召回；

合并这些文档，并一一判断它们和要查询的文档之间的海明距离是否在 3 之内，留下满足条件的。

我们发现，在这个过程中，只要有一个比特位的值相同，文档就会被召回。也就是说，这个方案和遍历所有文档相比，其实只能排除掉“比特位完全不同的文档”。因此，这种方法的检索效率并不高。

这又该怎么优化呢？Google 利用抽屉原理设计了一个更高效的检索方法。什么是抽屉原理呢？简单来说，如果我们有 3 个苹果要放入 4 个抽屉，就至少有一个抽屉会是空的。那应用到检索上，Google 会将哈希值平均切为 4 段，如果两个哈希值的比特位差异不超过 3 个，那这三个差异的比特位最多出现在 3 个段中，也就是说至少有一个段的比特位是完全相同的！因此，我们可以将前面的查询优化为“有一段比特位完全相同的文档会被召回”。

如果海明距离小于3，那么4段中至少有一段完全相同

根据这个思路，我们可以将每一个文档都根据比特位划分为 4 段，以每一段的 16 个比特位的值作为 Key，建立 4 个倒排索引。检索的时候，我们会把要查询文档的 SimHash 值也分为 4 段，然后分别去对应的倒排索引中，查询和自己这一段比特位完全相同的文档。最后，将返回的四个 posting list 合并，并一一判断它们的的海明距离是否在 3 之内。

分段查询

通过使用 SimHash 函数和分段检索（抽屉原理），使得 Google 能在百亿级别的网页中快速完成过滤相似网页的功能，从而保证了搜索结果的质量。

重点回顾

今天，我们重点学习了使用局部敏感哈希的方法过滤相似文章。

我们可以使用向量空间模型将文章表示为高维空间中的点，从而将相似文章过滤问题转为高维空间的最近邻检索问题。对于高维空间的最近邻检索问题，我们可以使用非精准的检索思路，使用局部敏感哈希为高维空间的点生成低维的哈希值。

局部敏感哈希有许多构造方法，我们主要讲了随机超平面划分和 SimHash 两种方法。相比于随机超平面划分，SimHash 能保留每一个关键词的权重，并且它的函数实现也更简单。

那对于局部敏感哈希的相似检索，我们可以使用海明距离定义相似度，用抽屉原理进行分段划分，从而可以建立对应的倒排索引，完成高效检索。

知识总结

实际上，不仅过滤相似文章可以使用局部敏感哈希，在拍照识图和摇一摇搜歌等应用场景中，我们都可以使用它来快速检索。以图像检索为例，我们可以对图像进行特征分析，用向量来表示一张图片，这样一张图片就是高维空间中的一个点了，图像检索就也抽象成了高维空间中的近邻检索问题，也就可以使用局部敏感哈希来完成了。

当然基于局部敏感哈希的检索也有它的局限性。以相似文章检索为例，局部敏感哈希更擅长处理字面上的相似而不是语义上的相似。比如，一篇文章介绍的是随机超平面划分，另一篇文章介绍的是 SimHash，两篇文章可能在字面上差距很大，但内容领域其实是相似的。好的推荐系统在用户看完随机超平面划分的文章后，还可以推荐 SimHash 这篇文章，但局部敏感哈希在这种语义相似的推荐系统中就不适用了。

因此，对于更灵活的相似检索问题，工业界还有许多的解决方法，我们后面再详细介绍。

课堂讨论

1. 对于 SimHash，如果将海明距离在 4 之内的文章都定义为相似的，那我们应该将哈希值分为几段进行索引和查询呢？

2.SimHash 的算法能否应用到文章以外的其他对象？你能举个例子吗？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这一讲分享给你的朋友。

16 | 最近邻检索（下）：如何用乘积量化实现“拍照识花”功能？

你好，我是陈东。

随着 AI 技术的快速发展，以图搜图、拍图识物已经是许多平台上的常见功能了。比如说，在搜索引擎中，我们可以直接上传图片进行反向搜索。在购物平台中，我们可以直接拍照进行商品搜索。包括在一些其他的应用中，我们还能拍照识别植物品种等等。这些功能都依赖于高效的图片检索技术，那它究竟是怎么实现的呢？今天，我们就来聊一聊这个问题。

聚类算法和局部敏感哈希的区别？

检索图片和检索文章一样，我们首先需要用向量空间模型将图片表示出来，也就是将一个图片对象转化为高维空间中的一个点。这样图片检索问题就又变成了我们熟悉的高维空间的相似检索问题。

如果我们把每个图片中的像素点看作一个维度，把像素点的 RGB 值作为该维度上的值，那一张图片的维度会是百万级别的。这么高的维度，检索起来会非常复杂，我们该怎么处理呢？我们可以像提取文章关键词一样，对图片进行特征提取来压缩维度。

要想实现图片特征提取，我们有很多种深度学习的方法可以选择。比如，使用卷积神经网络（CNN）提取图片特征。这样，用一个 512 到 1024 维度的向量空间模型，我们就可以很好地描述图像了，但这依然是一个非常高的维度空间。因此，我们仍然需要使用一些近似最邻近检索技术来加速检索过程。

一种常用的近似最邻近检索方法，是使用局部敏感哈希对高维数据进行降维处理，将高维空间的点划到有限的区域中。这样，通过判断要查询的点所在的区域，我们就能快速取出这个区域的所有候选集了。

不过，在上一讲中我们也提到，局部敏感哈希由于哈希函数构造相对比较简单，往往更适合计算字面上的相似性（表面特征的相似性），而不是语义上的相似性（本质上的相似性）。这怎么理解呢？举个例子，即便是面对同一种花，不同的人在不同的地点拍出来的照片，在角度、背景、花的形状上也会有比较大的差异。也就是说，这两张图片的表面特征其实差异很大，这让我们没办法利用局部敏感哈希，来合理评估它们的相似度。

而且，局部敏感哈希其实是一种粒度很粗的非精准检索方案。以 SimHash 为例，它能将上百万的高维空间压缩到 64 位的比特位中，这自然也会损失不少的精确性。

表面特征差异很大的同一种花的对比示意图

因此，更常见的一种方案，是使用聚类算法来划分空间。和简单的局部敏感哈希算法相比，聚类算法能将空间中的点更灵活地划分成多个类，并且保留了向量的高维度，使得我们可以更准确地计算向量间的距离。好的聚类算法要保证类内的点足够接近，不同类之间的距离足够大。一种常见的聚类算法是 K-Means 算法（K- 平均算法）。

局部敏感哈希空间划分 VS 聚类空间划分

K-Means 聚类算法的思想其实很“朴素”，它将所有的点划分为 k 个类，每个类都有一个类中心向量。在构建聚类的时候，我们希望每个类内的点都是紧密靠近类中心的。用严谨的数学语言来说，K-Means 聚类算法的优化目标是，类内的点到类中心的距离均值总和最短。因此，K-Means 聚类算法具体的计算步骤如下:

随机选择 k 个节点，作为初始的 k 个聚类的中心；

针对所有的节点，计算它们和 k 个聚类中心的距离，将节点归入离它最近的类中；

针对 k 个类，统计每个类内节点的向量均值，作为每个类的新的中心向量；

重复第 2 步和第 3 步，重新计算每个节点和新的类中心的距离，将节点再次划分到最近的类中，然后再更新类的中心节点向量。经过多次迭代，直到节点分类不再变化，或者迭代次数达到上限，我们停止算法。

K-Means 算法计算流程图

以上，就是 K-Means 聚类算法的计算过程了，那使用聚类算法代替了局部敏感哈希以后，我们该怎么进行相似检索呢？

如何使用聚类算法进行相似检索？

首先，对于所有的数据，我们先用聚类算法将它们划分到不同的类中。在具体操作之前，我们会给聚类的个数设定一个目标。假设聚类的个数是 1024 个，那所有的点就会被分到这 1024 个类中。这样，我们就可以用每个聚类的 ID 作为 Key，来建立倒排索引了。

建立好索引之后，当要查询一个点邻近的点时，我们直接计算该点和所有聚类中心的距离，将离查询点最近的聚类作为该点所属的聚类。因此，以该聚类的 ID 为 Key 去倒排索引中查询，我们就可以取出所有该聚类中的节点列表了。然后，我们遍历整个节点列表，计算每个点和查询点的距离，取出 Top K 个结果进行返回。

这个过程中会有两种常见情况出现。第一种，最近的聚类中的节点数非常多。这个时候，我们就计算该聚类中的所有节点和查询点的距离，这个代价会很大。这该怎么优化呢？这时，我们可以参考二分查找算法不断划分子空间划分的思路，使用层次聚类将一个聚类中的节点，再次划分成多个聚类。这样，在该聚类中查找相近的点时，我们通过继续判断查询点和哪个子聚类更相近，就能快速减少检索空间，从而提升检索效率了。

层次聚类检索过程示意图

第二种，该聚类中的候选集不足 Top K 个，或者我们担心聚类算法的相似判断不够精准，导致最近的聚类中的结果不够好。那我们还可以再去查询次邻近的聚类，将这些聚类中的候选集取出，计算每个点和查询点的距离，补全最近的 Top K 个点。

如何使用乘积量化压缩向量？

对于向量的相似检索，除了检索算法本身以外，如何优化存储空间也是我们必须要关注的一个技术问题。以 1024 维的向量为例，因为每个向量维度值是一个浮点数（浮点数就是小数，一个浮点数有 4 个字节），所以一个向量就有 4K 个字节。如果是上亿级别的数据，光是存储向量就需要几百 G 的内存，这会导致向量检索难以在内存中完成检索。

因此，为了能更好地将向量加载到内存中，我们需要压缩向量的表示。比如说，我们可以用聚类中心的向量代替聚类中的每个向量。这样，一个类内的点都可以用这个类的 ID 来代替和存储，我们也就节省了存储每个向量的空间开销。那计算查询向量和原始样本向量距离的过程，也就可以改为计算查询向量和对应聚类中心向量的距离了。

用聚类中心代替样本点

想要压缩向量，我们往往会使用向量量化（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,z> 是由一个二维空间 <x,y> 和一个一维空间相乘得到，依此类推。

那将高维空间分解成多个低维空间的乘积有什么好处呢？它能降低数据的存储量。比如说，二维空间是由一维的 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 个聚类中心向量都存储下来。

记录256*4个聚类向量中心示意图

这样，对于这个空间中的每个向量，我们就不需要再精确记录它在每一维上的权重了。我们只需要将每个向量都分为四段，让每段子向量都根据聚类算法找到所属的聚类，然后用它所属聚类的 ID 来表示这段子向量就可以了。

因为聚类 ID 是从 1 到 256 的，所以我们只需要 8 个比特位就可以表示这个聚类 ID 了。由于完整的样本向量有四段，因此我们用 4 个聚类 ID 就可以表示一个完整的样本向量了，也就一共只需要 32 个比特位。因此，一个 1024 维的原始浮点数向量（共 1024 * 4 字节）使用乘积量化压缩后，存储空间变为了 32 个比特位，空间使用只有原来的 1/1024。存储空间被大幅降低之后，所有的样本向量就有可能都被加载到内存中了。

压缩前后向量的存储空间对比图

如何计算查询向量和压缩样本向量的距离（相似性）？

这样，我们就得到了一个压缩后的样本向量，它是一个 32 个比特位的向量。这个时候，如果要我们查询一个新向量和样本向量之间的距离，也就是它们之间的相似性，我们该怎么做呢？这里我要强调一下，一般来说，要查询的新向量都是一个未被压缩过的向量。也就是说在我们的例子中，它是一个 1024 维的浮点向量。

好了，明确了这一点之后，我们接着来说一下计算过程。这整个计算过程会涉及 3 个主要向量，分别是样本向量、查询向量以及聚类中心向量。你在理解这个过程的时候，要注意分清楚它们。

那接下来，我们一起来看一下具体的计算过程。

首先，我们在对所有样本点生成聚类时，需要记录下聚类中心向量的向量值，作为后面计算距离的依据。由于 1024 维向量会分成 4 段，每段有 256 个聚类。因此，我们共需要存储 1024 个聚类中所有中心向量的数据。

然后，对于查询向量，我们也将它分为 4 段，每段也是一个 256 维的向量。对于查询向量的每一段子向量，我们要分别计算它和之前存储的对应的 256 个聚类中心向量的距离，并用一张距离表存下来。由于有 4 段，因此一共有 4 个距离表。

计算查询向量和聚类中心向量的距离表过程示意图

当计算查询向量和样本向量的距离时，我们将查询向量和样本向量都分为 4 段子空间。然后分别计算出每段子空间中，查询子向量和样本子向量的距离。这时，我们可以用聚类中心向量代替样本子向量。这样，求查询子向量和样本子向量的距离，就转换为求查询子向量和对应的聚类中心向量的距离。那我们只需要将样本子向量的聚类 ID 作为 key 去查距离表，就能在 O(1) 的时间代价内知道这个距离了。

获得全部查询子向量和样本子向量近似距离的过程示意图

最后，我们将得到的四段距离按欧氏距离的方式计算，合并起来，即可得到查询向量和样本向量的距离，距离计算公式：

以上，就是计算查询向量和样本向量之间距离的过程了。你会看到，原本两个高维向量的复杂的距离计算，被 4 次 O(1) 时间代价的查表操作代替之后，就变成了常数级的时间代价。因此，在对压缩后的样本向量进行相似查找的时候，我们即便是使用遍历的方式进行计算，时间代价也会减少许多。

而计算查询向量到每个聚类中心的距离，我们也只需要在查询开始的时候计算一次，就可以生成 1024 个距离表，在后面对比每个样本向量时，这个对比表就可以反复使用了。

如何对乘积量化进行倒排索引？

尽管使用乘积量化的方案，我们已经可以用很低的代价来遍历所有的样本向量，计算每个样本向量和查询向量的距离了。但是我们依然希望能用更高效的检索技术代替遍历，来提高检索效率。因此，结合前面的知识，我们可以将聚类、乘积量化和倒排索引综合使用，让整体检索更高效。下面，我就来具体说说，在建立索引和查询这两个过程中，它们是怎么综合使用的。

首先，我们来说建立索引的过程，我把它总结为 3 步。

使用 K-Means 聚类，将所有的样本向量分为 1024 个聚类，以聚类 ID 为 Key 建立倒排索引。

对于每个聚类中的样本向量，计算它们和聚类中心的差值，得到新的向量。你也可以认为这是以聚类中心作为原点重新建立向量空间，然后更新该聚类中的每个样本向量。

使用乘积量化的方式，压缩存储每个聚类中新的样本向量。

一个样本向量加入倒排索引的过程示意图

建好索引之后，我们再来说说查询的过程，它也可以总结为 3 步。

当查询向量到来时，先计算它离哪个聚类中心最近，然后查找倒排表，取出该聚类中所有的向量。

计算查询向量和聚类中心的差值，得到新的查询向量。

对新的查询向量，使用乘积量化的距离计算法，来遍历该聚类中的所有压缩样本向量，取出最近的 k 个结果返回。

查询向量查询倒排索引的过程示意图

这样，我们就同时结合了聚类、乘积量化和倒排索引的检索技术，使得我们能在压缩向量节省存储空间的同时，也通过快速减少检索空间的方式，提高了检索效率。通过这样的组合技术，我们能解决大量的图片检索问题。比如说，以图搜图、拍照识物，人脸识别等等。

实际上，除了图像检索领域，在文章推荐、商品推荐等推荐领域中，我们也都可以用类似的检索技术，来快速返回大量的结果。尤其是随着 AI 技术的发展，越来越多的对象需要用特征向量来表示。所以，针对这些对象的检索问题，其实都会转换为高维空间的近似检索问题，那我们今天讲的内容就完全可以派上用场了。

重点回顾

今天，我们学习了在高维向量空间中实现近似最邻近检索的方法。相对于局部敏感哈希，使用聚类技术能实现更灵活的分类能力，并且聚类技术还支持层次聚类，它能更快速地划分检索空间。

此外，对于高维的向量检索，如何优化存储空间也是我们需要考虑的一个问题。这个时候，可以使用乘积量化的方法来压缩样本向量，让我们能在内存中运行向量检索的算法。

那为了进一步提高检索率和优化存储空间，我们还能将聚类技术、乘积量化和倒排索引技术结合使用。这也是目前图像检索和文章推荐等领域中，非常重要的设计思想和实现方案。

知识总结

课堂讨论

1. 为什么使用聚类中心向量来代替聚类中的样本向量，我们就可以达到节省存储空间的目的？

2. 如果二维空间中有 16 个点，它们是由 x 轴的 1、2、3、4 四个点，以及 y 轴的 1、2、3、4 四个点两两相乘组合成的。那么，对于二维空间中的这 16 个样本点，如果使用乘积量化的思路，你会怎么进行压缩存储？当我们新增了一个点 (17,17) 时，它的查询过程又是怎么样的？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这一讲分享给你的朋友。

特别加餐 | 高性能检索系统中的设计漫谈

你好，我是陈东。欢迎来到检索专栏的第三次加餐时间。

在进阶篇的讲解过程中，我们经常会提起一些设计思想，包括索引与数据分离、减少磁盘 IO、读写分离和分层处理等方案。这些设计思想看似很简单，但是应用非常广泛，在许多复杂的高性能系统中，我们都能看到类似的设计和实现。不过，前面我们并没有深入来讲，你可能理解得还不是很透彻。

所以，今天我会把专栏中出现过的相关案例进行汇总和对比，再结合相应的案例扩展，以及进一步的分析讨论，来帮助你更好地理解这些设计思想的本质。并且，我还会总结出一些可以参考的通用经验，让你能更好地设计和实现自己的高性能检索系统。

设计思想一：索引与数据分离

我要说的第一个设计思想就是索引与数据分离。索引与数据分离是一种解耦的设计思想，它能帮助我们更好地聚焦在索引的优化上。

比如说，对于无法完全加载到内存中的数据，对它进行索引和数据分离之后，我们就可以利用内存的高性能来加速索引的访问了。第 6 讲中的线性索引的设计，以及 B+ 树中区分中间节点和叶子节点的设计，就都使用了索引和数据分离的设计思想。

那如果索引和数据都可以加载在内存中了，我们还需要使用索引和数据分离吗？在这种情况下，将索引和数据分离，我们依然可以提高检索效率。以第 5 讲中查找唐诗的场景为例，我们将所有的唐诗存在一个正排索引中，然后以关键词为 Key 建立倒排索引。倒排索引中只会记录唐诗的 ID，不会记录每首唐诗的内容。这样做会有以下 3 个优点。

节约存储空间，我们不需要在 posting list 中重复记录唐诗的内容。

减少检索过程中的复制代价。在倒排索引的检索过程中，我们需要将 posting list 中的元素进行多次比较和复制等操作。如果每个元素都存了复杂的数据，而不是简单的 ID，那复制代价我们也不能忽略。

保持索引的简单有效，让我们可以使用更多的优化手段加速检索过程。在加餐 1中我们讲过，如果 posting list 中都存着简单 ID 的话，我们可以将 posting list 转为位图来存储和检索，以及还可以使用 Roaring Bitmap 来提高检索效率。

索引与数据分离的设计示意图

总结来说就是，索引与数据分离的设计理念可以让索引保持简洁和高效，来帮助我们聚焦在索引的优化技术上。因此，保持索引的简洁高效是我们需要重点关注的。

当然，我相信你刚开始设计一个新系统的时候，可以很容易做到这一点。但也正因为它非常基础，恰恰就成了我们工作中最容易忽视的地方。

而一旦我们忽视了，那随着系统的变化，索引中掺杂的数据越来越多、越来越复杂，系统的检索性能就会慢慢下降。这时，我们需要牢记奥卡姆剃刀原理，也就是“如无必要，勿增实体”这个基础原则，来保证索引一直处于简洁高效的状态。

当然，索引和数据分离也会带来一些弊端，如不一致性。怎么理解呢？我们可以考虑这么一个场景：数据已经修改或是删除了，但索引还没来得及更新。如果这个时候我们访问索引，那得到的结果就很有可能是错误的。

那这个错误的结果会造成什么影响呢？这就要分情况讨论了。

对于不要求强一致性的应用场景，比如说在某些应用中更新用户头像时，我们可以接受这种临时性错误，只要能保证系统的最终一致性即可。但如果在要求强一致性的应用场景中，比如说在金融系统中进行和金钱有关的操作时，我们就需要做好一致性管理，我们可以对索引和数据进行统一加锁处理，或者直接将索引和数据合并。这么说比较抽象，我们以 MySQL 中的 B+ 树为例，来看看它是怎么管理一致性的。

MySQL 中的 B+ 树实现其实有两种，一种是 MyISAM 引擎，另一种是 InnoDB 引擎。它们的核心区别就在于，数据和索引是否是分离的。

在 MyISAM 引擎中，B+ 树的叶子节点仅存储了数据的位置指针，这是一种索引和数据分离的设计方案，叫作非聚集索引。如果要保证 MyISAM 的数据一致性，那我们需要在表级别上进行加锁处理。

MyISAM的索引数据分离设计

在 InnoDB 中，B+ 树的叶子节点直接存储了具体数据，这是一种索引和数据一体的方案。叫作聚集索引。由于数据直接就存在索引的叶子节点中，因此 InnoDB 不需要给全表加锁来保证一致性，它只需要支持行级的锁就可以了。

InnoDB的索引数据一体设计

所以你看，索引和数据分离也不是万能的“银弹”，我们需要结合具体的使用场景，来进行合适的设计。

设计思想二：减少磁盘 IO

第 6 讲我们说过，在大规模系统中，数据往往无法全部存储在内存中。因此，系统必然会涉及磁盘的读写操作。在这种应用场景下，尽可能减少磁盘 IO，往往是保证系统具有高性能的核心设计思路。

减少磁盘 IO 的一种常见设计，是将频繁读取的数据加载到内存中。前面讲到的索引和数据分离的方案，就使得我们可以优先将索引加载在内存中，从而提高系统检索效率。

当频繁读取的数据也就是索引太大，而无法装入内存的时候，我们不会简单地使用跳表或者哈希表加载索引，而会采用更复杂的，具有压缩性质的前缀树这类数据结构和算法来压缩索引，让它能够放入内存中。

尽管，单纯从数据结构和检索效率来看，前缀树的检索性能是低于跳表或者哈希表的，但如果我们考虑到内存和磁盘在性能上的巨大差异的话，那这种压缩索引的优势就体现出来了。最典型的例子就是 lucene 中用 FST（Finite State Transducer，中文：有限状态转换器）来存索引字典。

除了使用索引和数据分离，在高维空间中使用乘积量化对数据进行压缩和检索，以及使用分布式技术将索引分片加载到不同的机器的内存中，也都可以减少磁盘的 IO 操作。

而如果不可避免要对磁盘进行读写，那我们要尽量避免随机读写。我们可以使用第 7 讲中学习到的，使用预写日志技术以及利用磁盘的局部性原理，来顺序写大批量数据，从而提高磁盘访问效率。这些都是一些优秀的开源软件使用的设计方案，比如，我们熟悉的基于 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 就可以完成读写分离了。

Master – slave架构实现读写分离示意图

其实不仅仅是 MySQL，Redis 中也存在类似的 Master - Slave 的读写分离设计。包括在 LevelDB 中，MemTable - Immutable MemTable 的设计，其实也是读写分离的一个具体案例。

除了 MySQL 和 Redis 这类的数据存储系统，在倒排索引类的检索系统中，其实也有着类似的设计思路。比如说，在第 9 讲中我们讲过，对于索引更新这样的功能，我们往往会使用 Double Buffer 机制，以及全量索引 + 增量索引的机制来实现读写分离。通过这样的机制，我们才能保证搜索引擎、广告引擎、推荐引擎等系统能在高并发的应用场景下，依然具备实时的索引更新能力。

设计思想四：分层处理

在大规模检索系统中，不同数据的价值是不一样的，如果我们都使用同样的处理技术，其实会造成系统资源的浪费。因此，将数据分层处理也是非常基础且重要的一种设计。

最典型的例子就是在第 12 讲中，我们提到的非精准 Top K 检索 + 精准 Top K 检索的设计思路了。简单回顾一下，如果我们对所有的检索结果集都进行耗时复杂的精准打分，那会带来大量的资源浪费。所以，我们会先进行初步筛选，快速选出可能比较好的结果，再进行精准筛选。这样的分层打分机制，其实非常像我们在招聘过程中，先进行简历筛选，再进行面试的处理流程。可见，这种设计思路有着非常广泛的使用场景。

分层检索+分层打分示意图

包括我们前面提到的将索引放在内存中而不是磁盘上，这其实也是一种分层处理的思路。我们对索引进行分层，把最有价值的索引放在内存中，保证检索效率。而价值较低的大规模索引，则可以存在磁盘中。

如果你再仔细回想一下就会发现，这其实和第 7 讲中，LSM 树将它认为最有价值的近期数据放在内存中，然后将其他数据放在磁盘上的思路是非常相似的。甚至是硬件设计也是这样，CPU 中的一级缓存和二级缓存，也是基于同样的分层设计理念，将最有价值的数据，存在离 CPU 最近，也最贵的介质中。

硬件分层架构设计

此外，还有一个典型的分层处理的设计实例就是，为了保证系统的稳定性，我们往往需要在系统负载过高时，启用自动降级机制。而好的自动降级机制，其实也是对流量进行分层处理，将系统认为有价值的流量保留，然后抛弃低价值的流量。

总结来说，如果我们在应用场景中，对少数的数据进行处理，能带来大量的产出，那分层处理就是一种可行的设计思路。这其实就是二八原则：20% 的资源带来了 80% 的产出。

重点回顾

今天，我们讨论了索引和数据分离、减少磁盘 IO、读写分离、以及分层处理这四种设计思想。你会发现，这些设计思想说起来并不复杂，但几乎无处不在。为了方便你使用，我总结了一下，它们在使用上的一些通用经验。

首先，索引和数据分离能让我们遵循“奥卡姆剃刀原则”，聚焦于索引的优化技术上。但我们还要注意，索引和数据分离可能会带来数据的不一致性，因此需要合理使用。

其次，减少磁盘 IO 有两种主要思路：一种是尽可能将索引加载到最快的内存中；另一种是对磁盘进行批量顺序读写。并且，即便存储介质技术在不断变化，但我们只要保持关注这两个优化方向，就可以设计出高性能的系统。

接着，在读写分离的设计中，使用 Master - Slave 的架构设计也是一种常见方案，无论是 MySQL 还是 Redis 都采用了这种设计方案。而基于倒排索引的检索系统，也有着类似的 Double Buffer 和全量索引结合增量索引的机制。

最后，分层处理其实就是遵循二八原则，将核心资源用来处理核心的数据，从而提升整体系统的性能。

当你要设计或实现高并发系统时，你可以试试使用这些思想来指导你设计和审视系统，相信这会让你的系统变得更加高效。

课堂讨论

对于今天我们讨论的这些设计思想，你有什么案例可以分享出来吗？

欢迎在留言区畅所欲言，说出你的案例和思考。如果有收获，也欢迎把这一讲分享给你的朋友。

17 | 存储系统：从检索技术角度剖析LevelDB的架构设计思想

你好，我是陈东。

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 树进行归并的。但如果工程中也这么实现的话，会有两个很严重的问题：

合并代价很高，因为 C1 树很大，而 C0 树很小，这会导致它们在合并时产生大量的磁盘 IO；

合并频率会很频繁，由于 C0 树很小，很容易被写满，因此系统会频繁进行 C0 树和 C1 树的合并，这样频繁合并会带来的大量磁盘 IO，这更是系统无法承受的。

那针对这两个问题，LevelDB 采用了延迟合并的设计来优化。具体来说就是，先将 Immutable MemTable 顺序快速写入磁盘，直接变成一个个 SSTable（Sorted String Table）文件，之后再对这些 SSTable 文件进行合并。这样就避免了 C0 树和 C1 树昂贵的合并代价。至于 SSTable 文件是什么，以及多个 SSTable 文件怎么合并，我们一会儿再详细分析。

好了，现在你已经知道了，内存数据高效存储到磁盘上的具体方案了。那在这种方案下，数据又是如何检索的呢？在检索一个数据的时候，我们会先在 MemTable 中查找，如果查找不到再去 Immutable MemTable 中查找。如果 Immutable MemTable 也查询不到，我们才会到磁盘中去查找。

增加Immutable MemTable设计的示意图

因为磁盘中原有的 C1 树被多个较小的 SSTable 文件代替了。那现在我们要解决的问题就变成了，如何快速提高磁盘中多个 SSTable 文件的检索效率。

SSTable 的分层管理设计

我们知道，SSTable 文件是由 Immutable MemTable 将数据顺序导入生成的。尽管 SSTable 中的数据是有序的，但是每个 SSTable 覆盖的数据范围都是没有规律的，所以 SSTable 之间的数据很可能有重叠。

比如说，第一个 SSTable 中的数据从 1 到 1000，第二个 SSTable 中的数据从 500 到 1500。那么当我们要查询 600 这个数据时，我们并不清楚应该在第一个 SSTable 中查找，还是在第二个 SSTable 中查找。最差的情况是，我们需要查询每一个 SSTable，这会带来非常巨大的磁盘访问开销。

范围重叠时，查询多个SSTable的示意图

因此，对于 SSTable 文件，我们需要将它整理一下，将 SSTable 文件中存的数据进行重新划分，让每个 SSTable 的覆盖范围不重叠。这样我们就能将 SSTable 按照覆盖范围来排序了。并且，由于每个 SSTable 覆盖范围不重叠，当我们需要查找数据的时候，我们只需要通过二分查找的方式，找到对应的一个 SSTable 文件，就可以在这个 SSTable 中完成查询了。

范围不重叠时，只需查询一个SSTable的示意图

但是要让所有 SSTable 文件的覆盖范围不重叠，不是一个很简单的事情。为什么这么说呢？我们看一下这个处理过程。系统在最开始时，只会生成一个 SSTable 文件，这时候我们不需要进行任何处理，当系统生成第二个 SSTable 的时候，为了保证覆盖范围不重合，我们需要将这两个 SSTable 用多路归并的方式处理，生成新的 SSTable 文件。

那为了方便查询，我们要保证每个 SSTable 文件不要太大。因此，LevelDB 还控制了每个 SSTable 文件的容量上限（不超过 2M）。这样一来，两个 SSTable 合并就会生成 1 个到 2 个新的 SSTable。

这时，新的 SSTable 文件之间的覆盖范围就不重合了。当系统再新增一个 SSTable 时，我们还用之前的处理方式，来计算这个新的 SSTable 的覆盖范围，然后和已经排好序的 SSTable 比较，找出覆盖范围有重合的所有 SSTable 进行多路归并。这种多个 SSTable 进行多路归并，生成新的多个 SSTable 的过程，也叫作 Compaction。

SSTable保持有序的多路归并过程

随着 SSTable 文件的增多，多路归并的对象也会增多。那么，最差的情况会是什么呢？最差的情况是所有的 SSTable 都要进行多路归并。这几乎是一个不可能被接受的时间消耗，系统的读写性能都会受到很严重的影响。

那我们该怎么降低多路归并涉及的 SSTable 个数呢？在第 9 讲中，我们提到过，对于少量索引数据和大规模索引数据的合并，我们可以采用滚动合并法来避免大量数据的无效复制。因此，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 的分层设计了。

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 文件；如果没有查到，就继续沉入下一层，直到查到了或查询结束。

LevelDB分层检索过程示意图

可以看到，通过这样的一种架构设计，我们就将 SSTable 进行了有序的管理，使得查询操作可以快速被限定在有限的 SSTable 中，从而达到了加速检索的目的。

SSTable 文件中的检索加速

那在定位到了对应的 SSTable 文件后，接下来我们该怎么查询指定的元素呢？这个时候，前面我们学过的一些检索技术，现在就可以派上用场了。

首先，LevelDB 使用索引与数据分离的设计思想，将 SSTable 分为数据存储区和数据索引区两大部分。

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 的实现，就可以看作是我们之前学过的各种检索技术的落地实践。因此，这一节的内容，我建议你多看几遍，这对我们之后的学习也会有非常大的帮助。

课堂讨论

当我们查询一个 key 时，为什么在某一层的 SSTable 中查到了以后，就可以直接返回，不用再去下一层查找了呢？如果下一层也有 SSTable 存储了这个 key 呢？

为什么从 Level 1 层开始，我们是限制 SSTable 的总容量大小，而不是像在 Level 0 层一样限制 SSTable 的数量？ （提示：SSTable 的生成过程会受到约束，无法保证每一个 SSTable 文件的大小）

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这一讲分享给你的朋友。

18 | 搜索引擎：输入搜索词以后，搜索引擎是怎么工作的？

你好，我是陈东。今天我来讲讲搜索引擎的核心架构。

搜索引擎你应该非常熟悉，它是我们学习和工作中非常重要的一个工具。它的特点是能在万亿级别的网页中，快速寻找出我们需要的信息。可以说，以搜索引擎为代表的检索技术，是所有基于文本和关键词的检索系统都可以学习和参考的。

那今天，我们就一起来聊一聊，在输入搜索词以后，搜索引擎是怎么工作的。

首先，我们一起来了解一下搜索引擎的核心架构和工作过程。然后再重点分析其中的检索系统。

搜索引擎的整体架构和工作过程

搜索引擎会涉及非常多技术领域。其中，比较重要的有网页抓取、文本分析、检索模型、索引技术、链接分析、反作弊、云存储和云计算。正是因为涉及的领域非常多，所以搜索引擎完整的系统架构也非常复杂，会由许多子系统组成。

不过，我们可以从功能结构上，把搜索引擎的核心系统分为三部分，分别是爬虫系统、索引系统和检索系统。

搜索引擎核心架构示意图

接下来，我们就分别说说，这三部分子系统具体的作用和工作过程。

首先是爬虫系统。

一个好的搜索引擎，必须要能采集足够多的网页。因此，我们需要通过高性能的爬虫系统来完成持续的网页抓取，并且将抓取到的网页存入存储平台中。一般来说，我们可以将抓取到的网页存放在基于 LSM 树的 HBase 中，以便支持数据的高效读写。

其次是索引系统。

在爬虫系统抓取到网页之后，我们需要对这些网页进行一系列的处理，它们才可以变成可用的索引。处理可以分为两个阶段，首先是对网页进行预处理，主要的手段包括相似网页去重、网页质量分析、分词处理等工作，然后是对网页进行反作弊的分析工作，来避免一些作弊网页干扰搜索结果。

处理好网页之后，我们就要为搜索引擎生成索引，索引的生成过程主要可以分为三步。

第一步，索引拆分。由于抓取到的网页量级非常大，把它们全部都生成索引不太现实，因此我们会在离线阶段，根据之前的网页预处理结果，进行计算和筛选，分别分离出高质量和普通质量的网页集合。这样，我们就能进行分层索引了（第 12 讲）。当然，无论是高质量的网页集合还是普通质量的网页集合，数据量都不小。因此，我们还需要进行基于文档的拆分（第 10 讲），以便生成索引。

第二步，索引构建。在确认了索引的分片机制以后，我们可以使用 Map Reduce 服务，来为每个索引分片生成对应的任务，然后生成相应的倒排索引文件（第 8 讲）。每个倒排索引文件代表一个索引分片，它们都可以加载到线上的服务器中，来提供检索服务。

第三步，索引更新。为了保证能实时更新数据，搜索引擎会使用全量索引结合增量索引的机制来完成索引更新。并且由于搜索引擎的全量索引数据量巨大，因此，我们一般使用滚动合并法来完成索引更新（第 9 讲）。

有了这样创建出来的索引之后，搜索引擎就可以为万亿级别的网页提供高效的检索服务了。

最后是检索系统。

在检索阶段，如果用户搜索了一个关键词，那么搜索引擎首先需要做查询分析，也就是通过分析查询词本身以及用户行为特征，找出用户的真实查询意图。如果发现查询词有误或者结果很少，搜索引擎还会进行拼写纠正或相关查询推荐，然后再以改写后的查询词去检索服务中查询结果。

在检索服务中，搜索引擎会将查询词发送给相应的索引分片，索引分片通过倒排索引的检索机制，将自己所负责的分片结果返回。对于返回的结果，搜索引擎再根据相关性分析和质量分析，使用机器学习进行打分，选出 Top K 个结果（第 11 讲）来完成检索。

以上就是一个搜索引擎的完整的工作机制了。那与广告引擎和推荐引擎相比，搜索引擎最大的特点，就是它有一个很强的检索约束条件，那就是用户输入的查询词。可以说，查询词是搜索引擎进行检索的最核心的信息。但是很多时候，用户输入的查询词是含糊的、不精准的，甚至是带有错误的。还有一种可能是，用户输入的查询词不在倒排索引中。

这些问题也都是搜索引擎要解决的核心问题。因此，接下来，我们就以搜索“极客时间”为例，来讲讲搜索引擎的解决方案。

搜索引擎是如何进行查询分析的？

一般来说，用户在搜索的时候，搜索词往往会非常简短，很难完全体现用户的实际意图。而如果我们无法准确地理解用户的真实意图，那搜索结果的准确性就无从谈起了。因此，搜索引擎中检索系统的第一步，一定是进行查询分析。具体来说，就是理解用户输入的搜索词，并且对输错的查询词进行查询纠正，以及对意图不明的查询词进行查询推荐。那查询分析具体该怎么做呢？

在查询分析的过程中，我们主要会对搜索词进行分词粒度分析、词的属性分析、用户需求分析等工作。其中，分词粒度分析直接关系到我们以什么 key 去倒排索引中检索，而属性分析和需求分析则可以帮助我们在打分排序时，有更多的因子可以考虑。因此，分词粒度分析是查询分析的基础。那什么是分词粒度分析呢？

查询分析工作示意图

分词粒度分析是中文搜索中特有的一个环节。因为中文词和英文词相比，最大的区别是词与词之间没有明确的分隔标志（空格）。因此，对于中文的搜索输入，我们要做的第一件事情，是使用分词工具进行合理的分词。但分词，就会带来一个分词粒度的问题。

比如说，当用户输入“极客时间”时：如果我们按单字来切分，这个搜索词就会变成“极 / 客 / 时 / 间”这四个检索词；如果是按“极客 / 时间”来切分，就会变成两个检索词的组合；如果是不做任何分词，将“极客时间”当成一个整体，那就是一个搜索短语。切分的方式这么多，到底我们该怎么选择呢？

一般来说，我们会使用默认的标准分词粒度再结合整个短语，作为我们的检索关键词去倒排索引中检索，这就叫作混合粒度的分词方式。那“极客时间”就会被分为【极客、时间、极客时间】这样的检索词组合。如果检索后返回的结果数量不足，那我们还会去查询【极、客、时、间】这样的更细粒度的单字组合。

中文分词粒度分析示意图

搜索引擎是如何进行查询纠错的？

以上，都是在用户输入正确搜索词时的查询分析。那如果用户的输入有误，比如说，将“极客时间”输成了“即可时间”，或者是“级可时间”，搜索引擎又会怎么办呢？这个时候，我们就需要用到查询纠错功能和查询推荐功能了。

我们先来说一说查询纠错功能是如何使用的。查询纠错的过程一般会分为三个步骤，分别是错误判断、候选召回和打分排序。

查询纠错过程示意图

一般来说，在错误判断阶段，我们会根据人工编辑以及对搜索日志进行数据挖掘，得到常见字典和混淆字典。然后，我们使用哈希表或者字典树等结构来对字典进行索引，使得这两个字典具有高效的检索能力。如果某个分词后的检索词，我们无法在常用字典中查询到，或者它出现在了混淆字典中，那就说明这个词很可能是错误的。因此，我们还需要启动后续的候选召回和打分排序步骤。

不过，近年来，基于语言模型和机器学习的错误判断方式被广泛地使用。这种判断方式具体来说就是，我们会在用户输入检索词后，先对其进行置信度判断，如果得分过低，再进入后续的纠错过程。这能帮助我们更好地进行纠错。为什么这么说呢？我们来看一个例子，如果我们将“极客”错误地输入成了“级可”，通过检索常用字典和混淆字典，我们是有可能发现这个错误的。但如果我们错输成“即可”，由于“即可”本身也是一个合理的词，因此我们就需要使用基于语言模型和机器学习的方法，计算“即可”这个词出现在这个上下文中的置信度，才能发现有错。

在错误判断完成之后，就进入候选召回阶段了。在候选召回中，我们会预估查询词出错的每种可能性，提前准备好可能的正确结果。一般情况下，中文输入有 2 种常见的出错情况。

第 1 种，拼音相同但是字不同。这时，我们就要将相同拼音的词作为候选集，以拼音为 Key 进行检索。第 2 种是字形相似，那我们就生成一个相似字型的词典，通过该词典召回候选集。此外，还有根据编辑距离进行相似召回，根据机器学习得到候选集进行召回等。通过这些不同的纠错方式，我们就能得到可能的纠错结果集合了。

最后，我们要对众多的纠错结果进行打分排序。在这个过程中，我们可以使用各种常见的机器学习和深度学习算法进行打分判断（你可以回忆一下 11 讲，我们讲过的那些方法），将得分最高的纠错结果返回。这样就完成了整个查询纠错过程。

好了，到这里，我们就把查询纠错的过程说完了。至于查询推荐，则更多的是分析搜索日志的结果，用“查询会话”“点击图”等技术，来分析哪些检索词之间有相关性。比如说，如果检索“极客时间”和检索“极客邦”的用户都会浏览相同的网页，那么“极客邦”就很有可能出现在“极客时间”的相关推荐中。

因此，查询推荐可以提供出更多的关键词，帮助搜索引擎召回更多的结果。它一般会在关键词不足的场景下被启用，或是作为补充提示出现。所以，关于查询推荐我就不再多说了，你只要记住查询推荐的原理就可以了。

总的来说，通过查询分析、查询纠错、查询推荐的过程，搜索引擎就能对用户的意图有一个更深入的理解。那接下来，我们就通过得到的一系列关键词，也就是【极客、时间、极客时间】，去查询倒排索引了。

搜索引擎是如何完成短语检索的？

首先，我们可以使用“极客时间”作为一个完整的关键词去倒排索引中查找。如果倒排索引中能查询到这个关键词，并且返回的结果集足够，那这样的检索结果是非常精准的。但是，这依赖于我们在构建索引的时候，必须将“极客时间”作为一个关键词进行处理。

可是在构建倒排索引的时候，我们一般是通过分析搜索日志，将一些常见的热门短语作为关键词加入倒排索引中。由于能被直接作为关键词的短语数量不会太多，因此，如果“极客时间”没有被识别为热门短语进行单独处理的话，那我们拿着“极客时间”这个短语作为关键词，直接查询的结果就是空的。

在这种情况下，我们就会使用更细粒度的分词结果，也就是使用“极客”和“时间”这两个关键词，去做两次检索，然后将得到的结果求交集合并。不过，这样做就会有一个问题：如果只是简单地将这两个关键词检索出来的文档列表求交集合并，那我们最终得到的结果并不一定会包含带有“极客时间”的文档。这又是为什么呢？

你可以考虑一下这种情况：如果有一个网页中有一句话是“一个极客往往没有时间打游戏”。那我们搜索“极客”“时间”这两个关键词的时候，这个网页就会被检索出来。但这是我们期望的检索结果吗？并不是。因为“极客”和“时间”的位置离得太远了。

那如果我们能记录下关键词出现在文档中的位置，并且在合并文档列表的时候，判断两个关键词是否接近，不就可以解决这个问题？没错，这种方法就叫作位置信息索引法。我们会通过两个关键词的位置关系来判断该文档和检索词的相关性。位置越远，相关性就越小，如果位置直接邻接在一起，相关性就最高。

如果是两个以上的关键词联合查询，那我们会将同时包含所有关键词的最小片段称为最小窗口，然后通过衡量查询结果中最小窗口的长度，来判断多个关键词是否接近。这么说比较抽象，我们来举个例子。当我们分别以“极”“客”“时”“间”这四个字作为关键词查询时，如果一个文档中有这么一句话“极多客人，一时之间”，那字符“极”到字符“间”之间就是 9 个字符。也就是说，在这句话中覆盖“极”“客”“时”“间”这四个关键词的最小窗口长度就是 9。

有了这个方法，我们就可以将搜索结果按照最小窗口长度排序，然后留下相关性最高的一批结果了。这样，我们就完成“极客时间”的短语检索了。

重点回顾

今天，我们主要讲了搜索引擎的整体架构和工作原理。并且，由于搜索引擎的业务特点会非常依赖用户输入的查询词，因此，我们还重点讨论了搜索引擎对查询词进行的一系列特殊处理技术。

通常的流程是，先对查询词进行查询分析，搜索引擎通过对查询词进行不同粒度的分词，得到多个检索词。在这个过程中，搜索引擎还会通过查询纠错和相似推荐，拓展出更多的检索词候选。

然后，搜索引擎会利用得到的检索词在倒排索引中进行短语检索。这个时候，搜索引擎会通过位置信息索引法，来判断检索结果和检索词的相关性。最后，搜索引擎会通过对搜索结果中最小窗口的长度排序，留下相关性最高的结果。

除此之外，你还会看到很有意思的一点：查询纠错中也存在候选召回和打分排序这两个环节。实际上，许多业务的核心检索过程，都可以抽象为候选召回和打分排序这两个阶段，包括我们后面会讲到的广告系统和推荐系统也是一样。因此，如何将一个业务根据自身的特点，抽象成合适的检索过程，是 一个很重要的设计能力。那这部分内容我希望你能多看几遍，来加深理解，后面的课程中，我们也会继续学习相关的内容。

课堂讨论

在使用位置信息索引法中，我们在计算最小窗口的时候需要保证关键词是有序的。如果这个时候有两个关键词的话，我们可以先固定第一个关键词，然后只找它和第二个关键词的距离就可以了。那如果有 3 个关键词，我们又该如何保证次序呢？

对于搜索引擎的检索技术，你还有什么想要了解和讨论的？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这一讲分享给你的朋友。

19 | 广告系统：广告引擎如何做到在0.1s内返回广告信息？

你好，我是陈东。今天我们来讲广告系统。

说到广告系统，很多人可能没有那么熟悉。但是在互联网行业中，广告系统其实是非常重要，并且非常有代表性的一种系统。

一方面是因为，广告是许多互联网公司的重要营收来源。比如，我们熟悉的 Google 和 Facebook，它们的广告收入就占公司总收入的 80% 以上。因此，尽管许多互联网公司的主营业务并不一样，有的是搜索引擎，有的是电商平台，有的是视频平台等等。但是，它们背后都有着相似的广告业务线。

另一方面，互联网广告对于工程和算法有着强烈的依赖。强大的工程和算法让现在的互联网广告能做到千人千面。最常见的，我们在打开网站的一瞬间，广告系统就会通过实时的分析计算，从百万甚至千万的广告候选集中，为我们这一次的广告请求选出专属的广告。而且，整个响应广告请求的处理过程，只需要 0.1 秒就能完成。

那在大型广告系统中，广告的请求量其实非常大，每秒钟可能有几十万甚至上百万次。因此，广告系统是一个典型的高并发低延迟系统。事实上，这背后离不开一个高性能的广告检索引擎的支持。那今天，我们就来聊一聊，广告系统中负责检索功能的广告引擎架构。

广告引擎的整体架构和工作过程

首先，我们来了解两个基本概念。

互联网广告分为搜索广告和展示广告两大类。简单来说，搜索广告就是用户主动输入关键词以后，搜索引擎在返回结果中展示出的相关广告。而展示广告，则是在搜索引擎之外的网站或 App 中，用户在浏览页面的情况下，被动看到的广告。比如说，在打开一些 App 时出现的开屏广告，以及朋友圈中的广告等等。

搜索广告和展示广告示例

尽管这两种广告的业务形态不太一样，但是它们后台的广告引擎本质上都是相似的，主要的区别是约束条件上的不同。

在搜索广告中，因为它和搜索词有很强的相关性，所以，我们需要针对搜索词进行一系列的分析，这和我们上一讲说过的查询分析过程类似，这里我就不多讲了。而展示广告没有搜索词的约束条件，展示能力也就更灵活。因此，今天我们主要以展示广告为例，来说一说从用户打开网站到看到广告，广告系统是如何工作的。

为了方便你理解，我梳理了一张广告引擎的核心功能架构图。接下来，我就依据这个架构图，从用户浏览和广告主投放广告这两个方面，来为你详讲解一下广告引擎的工作过程。

广告引擎架构示意图

一方面，当用户浏览网页时，网页会向服务端发起一个广告请求。服务端接到广告请求后，会先进行请求解析，也就是通过用户在系统中的唯一 ID、网站地址以及广告位 ID，去后台查询相关的广告请求的扩展信息。

那怎么查询呢？一般来说，通过系统之前对用户的长期行为收集和分析，我们就能知道该用户的喜好，比如喜欢看篮球、喜欢购物等。根据得到的结果，我们会为用户打上相应的标签。同理，对于各种网页和广告位，我们也会分析好网页分类等信息。然后，我们会提前将这些分析好的结果保存在 Key-value 数据库中，以支持快速查询。这样一来，广告请求解析就可以通过查询 Key-value 数据库，得到相关信息了。

另一方面，广告主在投放广告时，为了保证广告的后续效果，往往会进行广告设置，也就是给广告投放加上一些定向投放的条件。比如说，只投放给北京的用户，年龄段在 20 岁以上，对篮球感兴趣，使用某一型号的手机等。这些限制条件，我们都可以用标签的形式来表示。因此，一个广告设置，抽象出来就是一系列标签的组合。

所以我们说，广告引擎处理一个广告请求的过程，本质上就是根据用户的广告请求信息，找出标签匹配的广告设置，并将广告进行排序返回的过程。这一点非常重要，我们后面讲的内容都是围绕它来展开的，我希望你能记住它。

返回广告以后，我们还需要收集广告的后续监测数据，比如说是否展现给了用户，以及是否被用户点击等后续行为。那有些后续行为还涉及广告计费，比如，如果广告是按点击付费的话，那么只要有用户点击了广告，就会产生对应的费用。这时广告系统不仅需要进行相应的计费，还需要快速修改系统中的广告数据，使得系统能在广告主的预算花完之后就立即停止投放。

好了，以上就是广告引擎的工作过程。你会发现，尽管广告引擎在业务形态和流程上都有自己的特点，但是，它的核心检索流程和搜索引擎是类似的，也分为了索引构建、检索召回候选集和排序返回这三个部分。不过，和搜索引擎相比，由于广告引擎没有明确的关键词限制，因此在如何构造倒排索引上，广告引擎会有更大的灵活度。

接下来，我们就一起来看看，广告引擎是怎么结合自己的业务特点，来进行高性能的检索设计，从而能在 0.1 秒内返回合适的广告。

标签检索：合理使用标签过滤和划分索引空间

广告引擎的索引设计思路，是将广告设置的标签作为 Key 来构建倒排索引，在 posting list 中记录对应的广告设置列表，然后为标签进行 ID 编号，让系统处理标签的过程能更高效。这么说比较抽象，我来举个例子。

如果广告设置的标签是“地域：北京”“兴趣：篮球”“媒体：体育网站”，那我们可以使用一个 32 位的整数为每个标签进行编号。具体来说就是将 32 位的整数分为两部分，高位用来表示定向类型，低位用来表示这个定向类型下具体的标签。

比如说，我们采用高 8 位作为定向类型的编码，用来表示地域定向、兴趣定向和媒体定向等。用低 24 位，则作为这个定向类型下面的具体内容。那在地域定向里，低 24 位就是每个地区或者城市自己的编码。这样，我们就可以将广告设置的标签都转为一个编号了（高、低位的分配是可以根据实际需求灵活调整的）。

标签编码示意图

1. 将标签加入过滤列表

那是不是所有的标签都可以作为倒排索引的 Key 呢？你可以先自己想一想，我们先来看一个例子。

如果所有的广告投放设置都选择投放在 App 上，那么“媒体类型：App”这个标签后面的 posting list 就保存了所有的广告设置。但是，这样的标签并不能将广告设置区分开。为了解决这个问题，我们可以使用类似 TF-IDF 算法中计算 IDF 的方式，找出区分度低的标签，不将它们加入倒排索引。

那我们什么时候使用这些标签呢？我们可以将这些标签加入“过滤列表”中，然后在倒排索引中检索出结果以后，加上一个过滤环节，也就是对检索结果进行遍历，在遍历过程中使用“过滤列表”中的标签进行检查，这样就完成了标签是否匹配的判断。

2. 用标签进行索引分片

其实，对于标签的匹配使用，我们还有其他的方案。我们再来看一个例子，假设平台中有一半的广告投放设置希望投放在移动 App 上，另一半希望投放在 PC 网站上，那如果我们以“媒体类型：App”和“媒体类型：PC 网站”作为标签来建立倒排索引的话，这样的标签是有区分度的。但是由于这两个标签后面的 posting list 都会非常长，各自都保存着一半的广告设置。因此在进行 posting list 归并的时候，实际上就等于要遍历一半的广告设置。这反而会降低检索效率。

因此，对于“媒体类型”这类（以及“性别”、“操作系统”等）具有少量的标签值，但是每个标签值都有大规模区分度的设置维度来说，我们可以不把它们加入到倒排索引中，而是根据标签来将广告设置进行分片。也就是把投放 PC 网站的广告设置作为一组，投放 App 的广告设置作为另外一组，分别建立倒排索引。

如果这样的有区分度的设置维度不止一个，那我们就使用树形结构进行划分，将最有区分度的设置维度（如“媒体类型”）作为根节点，不同的设置值作为分叉（如 PC 网站和 App 就是“媒体类型”维度下的两个分叉）。在这个节点下，如果有其他的设置也具有足够的区分度，那也可以作为子节点继续划分。然后对于被划分到同一个叶子节点下的一组，我们再利用标签建立倒排索引。

树 + 倒排的索引结构示意图

通过这样的树形结构，我们根据广告请求上的标签，就能快速定位到要找的索引分片，之后，再查找分片中的倒排索引就可以了。

总结来说，广告设置对广告引擎来说，就像搜索词对搜索引擎一样重要。但是对于广告设置，我们不会像关键词一样，全部加入倒排索引中，而是会分别加入到三个环节中：第一个环节，作为树形结构的节点分叉进行分流；第二个环节，作为倒排索引的 Key；第三个环节，在遍历候选结果时作为过滤条件。通过这样的设计，广告引擎中的检索空间就能被快速降低，从而提升检索效率，快速返回候选结果了。

向量检索：提供智能匹配能力

随着广告业务的演化，目前很多平台提供了一种新的广告投放模式：不是由广告主设置广告定向，而是由广告引擎在保证广告效果的前提下，自己决定如何召回广告。在这种情况下，广告引擎就可以摆脱标签的限制，使用向量来表示和检索，也就可以更精准地挖掘出合适的广告了。为什么要摆脱标签的限制呢？

我们来看个例子，在之前的标签系统中，当广告主想将广告投放给“喜欢篮球的人”时，如果一个用户身上的标签只有“喜欢运动”，那这个广告是不会投放给这个用户的。但如果广告主不进行广告定向限定，而是由广告引擎来决定如何召回广告，那广告引擎是可以针对“喜欢运动”的人投放这条广告的。

具体是怎么做的呢？我们可以将广告设置和用户兴趣都表示为高维空间的向量，这样，原来的每个标签就都是向量的一个维度了。然后我们使用最近邻检索技术，找到最近的点就可以返回结果了。这样的设计，本质上是使用机器的智能定向设置，代替了广告主手动的定向设置，从而大幅提升了广告设置的效率和效果。

标签检索和向量检索的对比

不过，在我们使用向量检索来代替标签检索之后，系统的性能压力也会更大，因此，为了保证广告引擎能在 0.1 秒内返回广告检索结果，我们需要对向量检索进行加速操作。这时，我们可以使用第 16 讲中“聚类 + 倒排索引 + 乘积量化”的实现方案，来搭建广告引擎的向量检索系统，从而提高向量检索的检索效率。

打分排序：用非精准打分结合深度学习模型的精准打分

广告引擎除了在召回环节和搜索引擎不一样之外，在打分排序环节也有自己的特点。这主要是因为它们需要返回的结果数量不同。具体来说就是，在搜索引擎中，我们要返回 Top K 个结果，但是在展示广告业务中，广告引擎往往最后只会返回一条广告结果！因此，对于最后选出来的这一条广告，我们希望它和用户的匹配越精准越好。所以，在广告引擎中，我们会使用复杂的深度学习模型来打分排序。

但如果在召回阶段选出的候选广告数量很多，那全部使用开销很大的深度学习模型来进行打分的话，我们是很难将单次检索结果控制在 0.1 秒之内的。而且，如果召回的候选广告数量有几千条，广告引擎最终又只能选出一条，那这几千条的候选广告都使用深度学习模型进行计算，会造成大量的资源浪费。

为了解决这个问题，我们可以在召回和精准打分排序之间，加入一个非精准打分的环节，来更合理地使用资源。具体来说就是，我们可以基于简单的机器学习模型（如逻辑回归模型（LR）、梯度提升决策树（GBDT）、因子分解机（FM）等）配合少量的特征，来完成这个非精准打分环节，将候选广告的数量限制在几十个的量级。然后，我们再使用深度学习模型来进行精准打分，最后选出分数最高的一个广告进行投放。这样，我们就能大幅节省计算资源，提升检索效率了。

召回 + 非精准打分 + 精准打分

索引精简：在索引构建环节缩小检索空间

除了优化在线的召回和打分环节的检索效率之外，广告业务的特点，使得我们还可以在离线的索引构建环节，通过缩小检索空间来优化。这是因为，广告引擎和搜索引擎中检索对象的生命周期有着很大的不同。一般来说，一个网页只要上线就会存在很久，但是一个广告设置的状态却经常变化。这怎么理解呢？

比如说，当广告设置限定了投放的时间段时，那这个广告可能上午是有效的，下午就处于停投状态了。再比如说，如果广告预算花完了，那广告也会变为停投状态，但是充值后又会恢复成有效状态。举了这么多例子，我其实就是想告诉你，广告设置的生命周期变化非常快。

因此，如果我们不考虑这些情况，直接将所有的广告设置都加载到系统中进行索引和检索，然后在遍历过滤的环节，再来检查这些状态进行判断的话，就会带来大量的判断开销。

这种情况下，我们该怎么办呢？我们可以将过滤条件提前到离线的索引构建的环节。这是因为，这些过滤条件和定向设置没有关系，所以我们完全可以在索引构建的时候，就将这些广告设置过滤掉，仅为当前有效的广告设置进行索引，这样检索空间也就得到了大幅压缩。

过滤条件前置到索引构建环节

当然，这种提前过滤有一个前提条件，那就是广告引擎需要提供实时高效的索引更新能力。好在，广告投放设置的体量一般不会像网页数那么庞大，一般都可以全部加载到内存中，因此，我们使用全量索引结合增量索引的更新机制，就可以对线上的索引进行实时更新了。

重点回顾

今天，我们以展示广告为例，学习了广告引擎的工作原理。并且，重点学习了，针对展示广告的特点，在不同的环节进行灵活的设计，来实现高性能的广告引擎。这些优化设计，我们可以概括为以下 4 点。

在标签检索引擎中，我们通过合理地将标签使用在树形检索 + 倒排索引 + 结果过滤这三个环节，来提高检索效率。

在向量检索引擎中，我们可以使用聚类 + 倒排索引 + 乘积量化的技术来加速检索。

在打分排序环节，增加一个非精准打分环节，这样我们就可以大幅降低使用深度学习模型带来的开销。

在索引构建环节，我们还可以将一些过滤条件前置，仅将当前有效的广告设置加入索引，然后通过全量索引 + 增量索引的更新方式，来保证过滤逻辑的有效。

课堂讨论

假设我们使用“媒体类型”作为树形检索的节点，“PC 网站”和“APP”作为两个分叉，并且允许广告主选择“既在 PC 网站投放，又在 APP 上投放”。如果有少量的广告主使用了这种投放，我们的索引分片应该怎么调整？针对这道题中的索引分片，我们必须加载到不同服务器上才能发挥效果，还是即使在单台服务器也能发挥效果？为什么？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这一讲分享给你的朋友。

20 | 推荐引擎：没有搜索词，“头条”怎么找到你感兴趣的文章？

你好，我是陈东。今天我来和你讲讲推荐引擎。

我们每天都会接触推荐引擎，最常见的，就是当我们用手机浏览资讯类 App 的时候，经常会用到的“下拉刷新”功能。你会发现，每次刷新之后，这些 App 都能给你推荐你最关心的“头条信息”。

那这些资讯类的 App，是怎么在没有搜索词的情况下，仅凭下拉刷新就可以在海量的文章中检索出你感兴趣的内容，并且推荐给你的呢？这就和推荐引擎中的检索技术有关了。那今天，我就以资讯类 App 推荐文章为例，来和你聊一聊推荐引擎中的检索技术。

推荐引擎的整体架构和工作过程

我们知道，检索引擎的灵活程度和系统的检索约束条件有关。那我们先来看一下针对不同的引擎，系统的检索约束条件分别是什么。

在搜索引擎中，系统的强约束条件是用户输入的搜索词。而在广告引擎中，系统的强约束条件是广告主设置的定向要求。但是在资讯类 App 推荐引擎中，因为所有的用户操作只有“下拉刷新”这一个动作，所以外界输入的检索约束条件其实非常少。

因此，相比于搜索引擎和广告引擎，推荐引擎具有更灵活的检索能力，也就是可以使用更灵活的检索技术，来进行文章的召回服务。这也是推荐引擎相比于搜索引擎和广告引擎最大的不同之处。

那一个推荐引擎是怎么工作的呢？我按照功能划分，梳理出了推荐引擎的核心模块。

推荐引擎架构示意图

那接下来，我就结合这个架构图，来说说推荐引擎的核心工作流程。

首先，因为没有搜索词，所以推荐引擎并不能直接得知用户的意图和喜好。为了解决这个问题，推荐引擎会收集用户对不同文章的行为数据，包括曝光、点击、阅读、收藏、点赞和评论等等。

然后，我们会通过这些收集来的数据，在离线环节挖掘每一个用户兴趣，从而能对用户分类来生成完整的用户画像。在用户画像中，一个用户会拥有不同的标签，这些标签会有不同的权重，所有的权重都会随着时间的变化而衰减。比如说，如果一个用户长时间没有继续这个行为，那标签就会逐步弱化。再比如说一个用户的兴趣发生变化了，那最新的兴趣标签的权重就会大于老的兴趣标签。通过这样的机制，我们就能更好地理解用户的喜好了。

但是，只给用户打上标签还不够，我们也要给文章打上标签。在这个过程中，我们除了要提取文章中的关键词以外，更多的要对文章中的内容做语义分析工作，比如，文章分类、主题词提取、主题提取等等。通过这些方式，推荐引擎就能为每一篇文章都生成文章画像了。

有了用户画像、文章画像，以及用户对文章的行为记录以后，我们就可以根据需求，灵活地使用不同的推荐算法来为用户推荐文章了。主要的推荐算法有 2 大类，分别是基于统计的静态召回算法和个性化召回算法。

所谓基于统计的静态召回，指的是根据当前系统对于文章的统计数据来进行推荐。比如说，我们可以在离线环节，提前统计好点击量最大、评论最多、收藏最多、收藏率上升最快的文章等。然后在线上环节将这些热门文章推荐给所有用户。它比较适合作为个性化召回不足时候的补充方案。

接下来，我们重点来讲讲个性化召回算法，因为一般来说，我们提到推荐算法的时候，指的都是个性化召回算法。个性化召回也有许多不同的方案，最有代表性的两种个性化召回，就是基于内容的召回（Content Based），以及基于协同过滤的召回（Collaborative Filtering）。

接下来，我们就重点讲讲这两种个性化召回方案。

基于内容的召回

基于内容的召回，就是我们根据文章的内容，判断这篇文章是否符合用户的喜好。具体怎么做呢？我们可以判断用户画像和文章画像中的标签或关键词是否相同，如果相同，就说明这篇文章的内容符合用户喜好，那我们就可以召回这篇文章。

这个时候，我们要解决的问题，其实就又变成了我们熟悉的标签匹配问题（第 19 讲）。因此，我们完全可以用标签和关键词作为 Key，来建立倒排索引。这样，我们就能针对用户的喜好，召回内容匹配的文章了。

当然，在上一讲中我们也说了，基于标签的召回可能会漏掉许多候选集合。因此，我们可以使用向量空间模型，将标签匹配改为高维向量空间的最近邻检索问题。这样，我们就能达到更灵活的召回目的了。

以上就是基于内容的召回的具体方法，对于向量空间的最邻近检索，我们在前面已经详细讲过，这里就不再重复了。不过，如果要将基于内容召回的技术用在推荐系统中，我们就需要充分理解它的特点和效果。那接下来，我们就来说说基于内容的召回在数据依赖、个性化和冷启动方面的优缺点。

首先，优点有 3 个，分别是不需要其他用户数据，可以针对小众用户给出个性化的推荐，以及可以推荐冷启动的新文章。同样的，缺点也有 3 个，分别是依赖于用户画像系统和文章画像系统，无法挖掘出用户的潜在兴趣，以及无法给冷启动的新用户推荐文章。这些优缺点出现的原因，我在下面的表格中都给出了解释，理解起来应该不难，那我在这里就不多说了。

优缺点对照表

基于协同过滤的召回

协同过滤是推荐引擎中最具有代表性的方法。协同过滤和基于内容的召回方法最大的区别就在于，它并不依赖内容本身来进行推荐，而是基于大众用户和这篇文章的互动关系来进行推荐。

其中，协同过滤还可以分为两大类：一类是传统的基于数据统计的 Memory-based 的协同过滤算法，也叫做基于邻域的算法，代表算法有基于用户的协同过滤（User CF，User Collaboration Filter）和基于物品的协同过滤（Item CF，Item Collaboration Filter）；另一类是升级版的基于模型的 Model-based 的协同过滤算法。今天，我们还是将重点放在协同过滤的基础算法，也就是 Memory-based 上，其他的就先不展开了。

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 了。

基于用户的协同过滤

在这个过程中，定义相似的用户并且将它们找出来是最重要的一步。那具体怎么做呢？我们先将上面的例子变成一个表格，这样看起来更清晰。

你会看到，这个表格里的每篇文章下面都对应着一些数字，这其实就是每个用户对每篇文章的喜爱程度（具体可以通过用户的点击次数、收藏、评论和转发等行为计算出来的）。

那基于这张表，如果要找出和 User 1 相似的用户，我们可以将 Item 1 到 Item n 看作是一个 n 维空间，那每个用户都可以表示为 n 维空间中的一个向量，然后把他对这个物品的喜好程度，作为每个维度上的值。

这样一来，如何找到最相似的一批用户的问题，就变成了如何在 n 维向量空间中，找到和 User 1 这个点最接近的点的问题。对于向量相似度，我们一般使用余弦距离来计算。

余弦距离公式

计算的过程我就不多说了，我直接把计算出来的相似度的结果告诉你。

因此，这三个用户和 User 1 的相似度的排序就是 User 3>User 4>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.73（User 3 的喜好度*User 3 和 User 1 的相似度）。

Item 4 的推荐打分是：2*0.54 = 1.08（User 4 的喜好度*User 4 和 User 1 的相似度）。

因此，根据计算得到的结果，我们会优先推荐 Item 4，再推荐 Item 3。

这就是基于用户的协同过滤的算法思想了。不过，如果要实时计算当前用户和所有其他用户的相似程度，这其实是一个遍历的操作，会非常耗时。对此，推荐系统有两种解决方案，分别是把相似计算放在离线环节，以及在实时阶段使用向量检索来近似地完成计算更新。下面，我们一一来看。

第一种方案，将相似计算放在离线环节。

具体来说就是，我们可以在离线环节为每个用户计算出这样一个推荐列表，然后使用 Key-value 数据库（如使用 Redis）把它加载到在线检索部分。这样，当 User 1 打开 App 时，我们通过查询这个 Key-value 数据库，就可以快速查询到推荐的文章列表了。而且，这个推荐列表可以通过周期性的重新全量计算来完成更新。这个方案的优点是实时环节非常简单，就是一个查表操作，但是缺点是更新不够及时。

第二种方案，在实时阶段使用向量检索来近似地完成计算更新。

在第一步“寻找相似用户”的时候，我们不需要精确地计算和每个用户的相似度，而是可以使用聚类 + 倒排索引 + 乘积量化的方案，来快速地检索出和 User 1 最近邻的 k 个用户，然后将这些用户喜好的物品取出来，进行加权打分并排序即可。

你看，这不就又变成了我们熟悉的实时“召回 + 打分排序”的过程了嘛。这个方案的优点是实时性很好，只要用户有了变化，就能马上反馈出来，但是缺点是实时阶段的检索过程很复杂，并且由于采用了非精准的近邻检索技术，因此结果也不够精确。

总结来说，基于用户的协同过滤既不依赖于文章本身的属性挖掘，也不会造成用户的兴趣被局限在历史的兴趣范围中。它能将用户没看过，但是其他相似用户喜欢的文章推荐出来，因此特别适用于资讯类的平台。不过它也有自己的不足，它适用于用户数不太大的场合。因为一旦用户数太大，计算所有的用户两两之间的相似度，也会是一个非常巨大的开销。

2. 基于物品的协同过滤

基于物品的协同过滤，简单来说就是基于你之前看过的物品，找出相似的物品并进行推荐。它的实现也分为两步：

计算出用户看过的每个物品的相似物品

将这些相似物品进行排序，然后再推荐

我们还是通过上面的例子，来进一步分析这个过程。假设，User 1 看过 Item 1 和 Item 2，那我们就需要寻找 Item 1 的相似物品和 Item 2 的相似的物品，将它们推荐给 User 1。

所以，这次我们要将刚才的表格换一个维度来看，也就是把 User 和 Item 的位置对调，重新画出这个表格。

由于这一次是寻找相似的物品，因此，我们把 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 更倾向于使用基于用户的协同过滤算法，而电商平台更倾向于使用基于物品的协同过滤算法。

如何对多种召回方案进行选择和排序？

通过前面的分析，你会发现，不同的推荐算法根据各自的特点，会召回不同的候选文章。那我们应该选择哪种方案呢？

实际上，因为推荐引擎并没有检索限定条件，所以它可以从不同的维度来进行推荐，而且不同的用户对于不同的推荐方案也有不同的接受度。综合来说，我们很难说哪一种方案一定就是最好的。因此，在推荐引擎的实现中，我们更多的是采用混合推荐法，也就是一并使用上面的所有方案。

那这就带来了一个问题：每种召回方案都会返回大量的候选集，这会使得系统难以承受排序计算的代价。为了解决这个问题，推荐引擎中采用了分层打分过滤的排序方式。

分层打分过滤示意图

下面，我就结合上面给出的示意图来说一下分层打分过滤的过程。

首先，每一个召回通路都会使用自己的非精准打分算法，截取千级别之内的候选集。然后，推荐引擎会合并这多个召回通路截取的几千个结果，也就是使用简单的机器学习模型进行非精准打分，选出最好的上百个结果。最后，推荐引擎会使用精准的深度学习模型，选出最好的几十个结果返回给用户。这就是用户看到的最终的推荐结果了。

以上，就是推荐引擎从召回到排序的完整检索过程了。

重点回顾

今天，我们重点学习了推荐引擎中的个性化召回算法，它又为基于内容的召回和基于协同过滤的召回。我们来总结一下它们各自的特点。

首先，基于内容的召回，本质上是基于用户画像和文章画像进行匹配召回。在实际操作中，我们可以使用标签检索和向量检索来完成基于内容的召回。

然后，基于协同过滤的召回我们重点讲了基于用户的协同过滤和基于物品的协同过滤。基于用户的协同过滤，会先寻找和当前用户最相似的用户，再将这些用户看过的物品推荐出来；而基于物品的协同过滤，则是先整理出相似的物品列表，再根据当前用户看过的物品，找出对应的物品列表，最后进行合并推荐。

总的来说，这两种协同过滤算法都是先寻找最邻近的“邻居”，再进行打分排序。也就是说，这两种协同过滤算法抽象起来看，依然是使用“检索 - 排序”的检索技术来实现的。

此外，在实际工作中，推荐引擎会同时使用多种召回技术进行混合推荐。而在使用混合推荐法的时候，系统会进行多层的打分过滤，来保证检索性能。

课堂讨论

对于文章中提到的基于物品的协同过滤召回，以文中的数据为例子，你能按文中介绍的方式，使用余弦距离计算出每个 Item 和 Item 1 的相似度吗？

关于搜索引擎、广告引擎、推荐引擎，你觉得它们有哪些设计可以相互借鉴？为什么？

欢迎在留言区畅所欲言，说出你的思考过程和最终答案。如果有收获，也欢迎把这一讲分享给你的朋友。

结束语 | 成长和进化，技术如此，我们亦如此

你好，我是陈东。今天，我们走到了这个专栏的最后一个篇章。

一起学习的五十多个日夜，我们从熟悉的数组和链表开始，学到了搜索引擎、广告引擎以及推荐引擎。这就像一场长途跋涉，一路上景色不停切换，不变的，是我们探索这个信息世界的好奇心。

在我看来，信息是构成世界的一个重要维度。实际上，人类文明的开始，就是以拥有了语言和文字为标志的。因为语言和文字的出现，才使得信息可以被记录和传播，文明得以传承和发展。

而随着 IT 技术的发展，信息的产生速度也越来越快。有报告说，现在 18 个月产生的信息比过去 5000 年产生的信息总量还大。并且，随着互联网的普及和发展，最近每年的信息增长率达到了 50% 以上。我们可以预见 5G 普及之后的未来，信息生产的速度只会越来越快。那么，这么多的信息都有用吗？我觉得不见得。当信息变得庞杂了以后，相应地，信息的含金量就变低了。只有高效地将信息进行检索和提炼，才能取出我们需要的信息，这样的信息才是最有价值的。

在专栏中，我们提到的许多系统都为了“如何高效检索信息”这个基础却又终极的问题，做了精心的设计，从而帮助我们高效地处理信息。系统如此，人亦是如此。

在信息泛滥的时代，每个人都有大量的渠道可以接触到大量的信息。如果不进行筛选处理，你的 CPU 根本负荷不过来。比如说，如果你接触的信息都是各种八卦新闻、娱乐视频。那么，你能花在处理专业知识信息上的时间就会变少。甚至在遇到一些虚假信息的时候，如果你不加辨别就接收，这很可能会带来一些负面影响。

所以我们说这是一个最好的时代，这也是一个最坏的时代。好的地方在于，有了足够的信息供我们学习和娱乐；坏的地方在于，对于信息的选择和使用，会变成一个更困难的事情，利用不好，甚至会带来负面影响。因此，我们也要构建起自己思维的检索引擎，学会将有价值的信息提取出来，并加以处理和使用。这样，我们才会不断地前进。

那无论是系统还是人，我们的前进都必须是跟随着时代的脚步。如果你研究过某一个技术的发展史，你就会对这一点有更深的体会了。其实，在专栏中就隐藏着一条检索技术发展进化的时间线。下面，我来带你回顾一下。

在人类发明了文字以后，书的目录索引和图书馆的图书分类管理，其实就是检索技术在印刷时代的体现。

随着 IT 技术的兴起，信息数字化的变革开始催生出了像数据库这样的系统和 B+ 树这些检索技术。

进入互联网时代后，我们开始处理大数据。无论是基于 LSM 树的存储系统，还是以搜索引擎为代表的检索技术，都是互联网时代的结晶。

到了移动互联网时代，各种基于地理位置的服务不断出现，让我们开始将物理世界和信息世界更紧密地连接在一起。

现在到了 AI 时代，图像识别、人脸识别和智能推荐的发展，又催生出了和深度学习结合的检索系统。

检索技术越发展，涉及的知识越新，你就会感觉越陌生。实际上这很正常，毕竟新生事物要变得普及需要经过时间的发酵。但我们必须保证，我们的认知是随着时代的前进一起进化的。因为一旦我们放弃去学习、去进化，那我们不可避免地就会错过很多机会，甚至会做错许多决策。

因此，面对新生的事物，我们在保持好奇心的同时，还要去升级自己思维的检索引擎，使得我们的认知能跟得上时代的发展。甚至，如果学习的过程能够快人一步，那我们就会享受到时代更多的红利。

好了，说了这么多自我进化的好处。那不断地升级自己的检索引擎，是不是一件很困难的事情呢？完全重构系统的确是一个代价很大的事情，但是在已有系统上进行迭代升级，相对来说会容易许多。

因为，许多所谓的新技术和新系统，都是在一些基础技术上进行组合和微创新得到的。比如，我在加餐 1 中提到的 Roaring Bitmap。它在 2016 年才被发明出来，但是它用到的技术，其实就是很成熟的检索系统中的位图法、哈希表法、跳表法以及倒排索引。还有我们说过的向量检索，其实向量这个数学工具也不是新东西了，只是在近期随着 AI 的发展，才被更广泛地应用起来。

既然新的技术和系统并不难学，那我们到底该怎么学呢？其实，学习和成长就像是爬台阶。高处的目标虽然看上去遥不可及，但只要我们一个台阶一个台阶地往上走，等我们站到了一个足够的高度上再前进就会容易很多。就像让你直接学习 AVL 树和红黑树会很难，但当你了解了二叉检索树的原理和问题以后，再去学习就会简单很多。因此，当你了解了检索中的各种问题和相应技术以后，再去搭建自己的检索系统，或者学习使用开源的检索工具，也就更容易上手。

也正是因为如此，许多高手在面对新事物的时候，能使用自己思维的检索引擎，快速找到核心点去学习和消化，从而可以更上一层楼。

总结来说，成长和进化从来不是一件容易的事情，但只要你能构建起自己认知的检索思维，不断地从海量的信息中快速汲取新的知识，并且从打好地基做起，构建起自己的知识体系。我相信，迈上一个更高的台阶，是一个完全可以预期的事情。

虽然检索技术专栏结束了，但是我希望这个专栏对你的帮助并没有就此停止。学习之路没有终点，保持探索和进化，才是我们成长的终极密码。

结课测试 | 这些检索知识，你都掌握了吗？

你好，我是陈东。

《检索技术核心 20 讲》这门课程就正式完结了，很感谢你一直以来的认真学习和支持！

那为了帮助你检验自己的学习效果，我特别给你准备了一套结课测试题。这套测试题一共有 20 道题目，包括 10 道单选题和 10 道多选题，满分 100 分，我建议你在 30 分钟以内完成。

还等什么，快点击下面的按钮开始测试吧！