索引构建

索引构建

如何建倒排索引的过程称为索引构建(index construction 或 indexing),而将构建索引的程序或计算机称为索引器(indexer)。索引构建算法的设计受硬件的配置所制衡。 
索引器需要原始文本,但是文本可能会采用各种编码格式。索引器对中间文件和最后的索引文件进行压缩或者解压缩。在WEB搜索中,文档往往并不是来自于本地,而必须通过网络采集才能够得到。在企业中搜索中,很多文档内嵌在各种的内容管理系统、邮件系统或者数据库中,尽管绝大多数应用能够通过http方式访问,但是采用原生API(native API)往往会更高效。需要提醒的是,及时将原始文本输送到索引过程这个看上去简单的子系统的构建,其本身很可能是个具有挑战性的问题。

硬件基础

构建信息检索系统时,很多决策都是依赖于系统所运行的硬件环境。因此,本章将首先简单介绍计算机硬件的性能特点。

符号 含义
s 平均寻道时 5ms=5*10^-3s
b 每个字节的传输时间 0.02μs=2*10^-8s
处理器时钟频率 10^9Hz
p 底层操作时间(比如字的比较或交换) 0.01µs=10^-8
内存大小 几吉字节
磁盘大小 1TB或更大

与IR系统的设计相关的硬件基本性能参数如下: 
访问内存数据比访问磁盘数据快得多,只需要几个时钟周期(大概510-9s)便可以访问内存张的一个字符,与此行程鲜明对照的是,从磁盘传输一个字节所需要的时间则长得多(大概2*10-8s)。因此我们会尽可能的把数据放在内存中,特别是那些访问频繁的数据。这种将频繁访问的磁盘数据放入内存的技术成为高速缓存(caching)。 
进行磁盘读写时,磁头移动到数据所在的磁道需要一段时间,该时间被称为寻道时间,对典型的磁盘来说平均在5ms左右。寻到期间并不进行数据的传输。于是,为使数据传输率最大,连续读取的数据块也应该在磁盘上连续存放。举例来说,如果基于表中的数据的话,大概只徐亚0.2毫秒就可以将一个连续存放的10MB数据块从磁盘传输到内存,但是如果上述数据存放在100个非连续的块中,那么,需要移动100次磁头,因此总时间可能会需要0.2+100
(5*10^-3)=0.7s。 
操作系统往往以数据块为单位进行读写。因此,从磁盘读取一个字节和读取一个数据块所耗的时间可能一样多。数据块的大小通常为8KB、16KB、32KB或64KB。我们将内存中保存读写块区域称为缓冲区(buffer) 
数据从磁盘传输到内存是由系统总线而不是处理器来实现的,这意味着磁盘I/O时处理器仍然可以处理数据。我们可以利用这一点来加速数据的传输过程,将数据进行压缩然后在存储在磁盘上。假定采用一种搞笑的解压缩算法的话,那么读磁盘压缩数据在解压所花的时间往往会比直接读取未压缩数据的时间要少。 
IR系统的服务器往往有数吉字节甚至数十吉字节的内存,其可用的磁盘空间大小一般比内存大小要高几个数量级。

基于块的排序索引方法

建立不包含位置信息的索引的基本步骤。首先,我们扫描一遍文档集合得到所有的词项-文档ID对。然后,我们以词项为主键、文档ID为次键进行排序。最后,将每个词项的文档ID组织成倒排记录表,并计算诸如词项频率或者文档频率的统计量。对于小规模文档集来说,上述过程均可以在内存中完成。本章我们主要讨论在大规模文档集条件下需要引入二级存储介质时的索引方法。 
为使索引构建效率更高,我们将词项用其ID来代替,每个词项的ID是唯一的序列编号。我们可以在处理文档集之余将词项映射成其ID。或者在一种两遍扫描的方法中,第一遍扫描得到词汇表,第二遍扫描才构建倒排索引。它们在某些应用中往往更可取,比如,当磁盘空间非常少的情况下应优先采用多遍扫描方法。

符号 含义
N 文档总数 8000 000
L(ave) 每篇文档的平均词条数目 200
M 词项总数 400 000
每个词条的平均字节数(含空格和标点符号) 6
每个词条的平均字节数(不含空格和标点符号) 4.5
每个词项的平均字节数 7.5
T (不包含位置信息的)倒排记录数目 160 000 000

Reuter-RCV1语聊要构建的(不含位置信息的)倒排记录数目约为1.6亿条,而每个词项ID和文档ID都各占4B,因此存储所有的词项ID-文档ID所需要的1.28存储空间。目前典型的语聊规模往往比Reuters-RCV1高一到两个数量级,即使对大型的计算机来说,将所有词项ID-文档ID对放在内存进行排序也是一件困难的事情。如果在索引构建过程中生成的临时文件只占用内存的一小部分。(使用压缩技术)然而对于很多大型的语料库来说,即使通过压缩后的倒排记录表也不可能全部加载到内存中。

由于内存不足,我们必须使用基于磁盘的外部排序算法(external sorting algorithm)。为了达到可以接受的速度,对该算法的核心要求是:在排序时尽量减少磁盘随机寻道的次数,磁盘顺序读取速度会比随机寻道速度快得多。BSBI(blocked sort-based indexing algorithm,基于块的排序索引算法)是一种解决办法:第1步,将文档集分割成几个大小相等的部分;第2步,将每个部分的词项ID-文档ID对排序;第3步,将中间产生的临时排序结果存放到磁盘中;第4步,将所有的中间文件合并成最终的索引。

该算法将文档解析成词项ID-文档ID对,并在内存中一直处理,直到累积至放慢一个固定大小的块空间为止。我们选择合适的块大小,使之能方便加载到内存并允许在内存中快速排序。排序后的块转换成倒排索引格式后写入磁盘,建立倒排索引的过程包含两步:第1步是对此项ID-文档ID对进行排序;第2步是将具有同一词项ID的所有文档ID放在倒排记录表上。将该算法应用于Reuters-RCV1语料库上,并假定内存每次能加载1000万个词项ID-文档ID对,那么算法最后会产生10个块,每个块都是文档集的倒排索引的一部分。

BSBIndexConstruction()n<-0while (all documents have not bean processed)do n<-n+1block<-ParseNextBlock()BSBI-Invert(block)WriteBlockToDisk(block,fn)MergeBlocks(f1,...,fn;fmerged)//基于块的排序索引算法。该算法将每个块的倒排索引存入文件f1,...,fn中,最后合并成fmerged

上述算法实现的最后一步是:将10个块索引同时合并成一个索引文件。给出两个块进行合并的例子,其中di表示文档集第i篇文档。合并时,同时打开所有块对应的文件,内存中维护了为10个块准备的读取缓存区和一个最终合并索引准备的写缓存区。每次迭代中,利用优先级队列(即堆结构)或者类似的数据结构选择最小的处理词项ID进行处理。读入该词项的倒排记录表并合并,合并结果写回磁盘中。需要时,再次从文件中读入数据到每个读缓冲区。 
基于块的排序索引方法中国的合并过程。两个块(“待合并的倒排记录表”)从磁盘读入内容,然后在内存中进行合并(“合并后的倒排记录表”),最后写回磁盘。为了方便理解,这里用了词项本身而不是其ID 
下面讨论BSBI算法的复杂度。由于该算法最主要的时间消耗在排序上,因此时间复杂度为O(TlogT),其中T是所需要怕休息的项数目的上界(即词项ID-文档ID对的个数)。然而实际的索引构建时间往往取决于文档分析(ParseNextBlock)和最后合并(MergeBlocks)的时间。

内存式单边扫描索引构建方法

基于块的排序索引算法具有很好的可扩展性,但是需要一种将词项映射成其ID的数据结构。对于大规模的文档集来说,该数据结构会很大以致在内存中难以存放。一种更具扩展性的算法称为SPIMI(single-pass in-memory indexing,内存式单边扫描索引算法)。SPIMI使用词项而不是其ID,它将每个块的词典写入磁盘。对于下一个块则重新采用新的词典。只要硬盘空间足够大,SPIMI就是能够索引任何大小的文档集。 
反复调用SPIMI-Invert函数直到将全部的文档集处理完为止。

SPIMI-Invert(token_stream)
output_file=NewFile()
dictionary = NewHash()
while (free memory available)
do token <- next(token_stream)
  if term(token)∉dictionary
    then postings_list=AddToDictionary(dictionary,term(token))
    else posttings_list=GetPostingsList(dictionary,term(token))
  if full(posting_list)
    then postings_list=DoublePostingsList(dictionary,trem(token))
AddToPostingsList(postings_list,docID(token))
sorted_terms <- SortTerms(dictionary)
WriteBlockToDisk(sorted_terms,dictionary,output_file)
return out_file
//SPIMI算法中块倒排索引的生成

算法逐一处理(程序第4行)每个词项-文档ID对。如果词项是第一次出现,那么将之加入词典(最好是通过哈希表来实现),同时建立一个新的倒排索引记录表(程序第6行);如果该词项不是第一次出现则直接返回其倒排记录表(程序第7行)。 
BSBI和SPIMI的一个区别在于,后者直接在倒排记录表中增加一项(程序第10行)。和那种一开始就整理出所有的词项ID-文档ID并对他们进行排序的做法(这正好是BSBI中的做法)不同,这里每个倒排记录表是动态增长的(也就是说,倒排记录表的大小会不断调整),同时立即就可以实现全体倒排记录表的收集。这样做的有两个好处:第一,由于不需要排序操作,因此处理速度更快;第二,由于保存了倒排记录表对词项的归属关系,因此能够节省内存,词项的ID也不许呀保存。这样,每次单独的SPIMI-Invert调用能够处理的大小可以非常大,整个倒排索引的构建过程也会非常高效。由于事先不知道每个词项的倒排记录表的大小,算法一开始会分配一个较小的一个倒排记录表空间,每次当该空间放满的时候,就会申请加倍的空间(程序的8~9行)。这意味着一些空间的浪费,也正好抵消了不用保存词项ID所省下的空间。然而,总体来说,在SPIMI中对块建立索引所需要的空间仍然比BSBI少。当内存耗尽时,包括词典和倒排记录表的块索引将被写到磁盘上(程序第12行)。在这之前,为使倒排记录表按照词典顺序排序来加快最后的合并过程,要对词项进行排序操作(程序第11行)。如果每个块的倒排记录表没有事先排好序,那么合并过程很难通过一个简单的逐块扫描算法来实现。每次对SPIMI-Inverter的调用都会写到一个块到磁盘,这和BSBI一样。SPIMI最后一步就是将多个块合并成最后的倒排索引。 
除对每个块建立新词典及会去除高代价的排序操作之外,SPIMI有一个重要的第三方组件:压缩。如果使用压缩技术,那么不论是倒排记录表还是词项都可以在磁盘上进行压缩存储。由于压缩一方面使算法处理更大块,另一方面能够使原来的每个块所需要的磁盘空间更少,所以压缩技术能进一步提高算法的效率。 
SPIMI算法的时间复杂度是O(T),这是因为它不需要对词项-文档ID对进行排序操作,所以操作最多和文档集大小呈线性关系。

分布式索引构建方法

实际当中,文档集通常都很大,在单台计算机上很难高效的构建索引。特别是对于万维网来说,情况更是如此。对于Web构建规模合理的索引往往需要大规模的计算机集群。因此,Web搜索通常使用分布式索引构建(distributed indexing)算法来构建索引,其索引结果也是分布式的,它往往按照词项或者文档进行分割在多台服务器上。本节主要介绍基于词项分割的分布式索引的构建方法。对于大部分大型搜索引擎来说,他们更倾向于采用基于文档分割的索引(当然这种索引也很容易从基于此项分割的索引生成)。
本节介绍的分布式索引构建方法是MapReduce的一个应用。MapReduce是一个通用的分布式计算架构,它面向大规模计算机集群而设计。集群的关键是,利用价格低廉的日用计算机(称为节点,node)来解决大型的计算问题,这些计算机都采用通用的标准部件(处理器、内存和磁盘),而不是像超级计算机那样采用专用的硬件。尽管在这样计算机集群当中包含百上千计算机,但每台计算机都有可能在任意时刻失效。因此要保证分布式索引构建过程的鲁棒性,就必须把任务分成易分配的子任务块,并在节点失效时能够重新分配分配。集群中的主控节点(master node)负责处理任务在工作节点上的分配和重分配。 
MapReduce中的Map阶段和Reduce阶段将计算任务划分为子任务块,以便每个工作节点在短时间内快速处理。首先,输入数据(这里是网页集合)被分割成n个数据片(split),数据片大小的选择一定要保证任务的均匀、高效分布,同时每个数据片不能太大,数据片的数目也不能太多。在分布式索引中,数据片的大小采用16M或者64M通常是不错的算则。每个数据片并不预先分配给各台计算机,而是在运行过程中由主控节点分配下一个数据片给它处理。如果某天机器宕机或者由于硬件问题导致机器变慢,它上面运行的热舞数据片就可以重新分配给其他起算即。 
img 
一般来讲,MapReduce会通过键-值对(Key-Value pair)的转换处理,将一个大型的计算问题转换成较小的子问题。在索引构建中,键-值对的形式就是(词项ID,文档ID)。在分布式索引构建过程中,从词项到其词项ID的映射同样要分布式进行,因此分布式的索引构建方法要比单机上的索引构建方法要复杂得多。一种简单的解决方法就是维护一张高频词到其ID的映射表(这张表可以预先算好)并将它复制到所有节点计算机上,而对低频词则直接使用词项本身而不是其ID。下面讨论中我们不再考虑这个问题,只假设所有的节点都共享了一份一致的词项到其ID的映射表。 
MapReduce的map阶段将输入的数据片映射成键-值对。这个map阶段对应于BSBI和SPIMI算法的分析任务。因此也将执行map过程的机器称为分析器(parser)。每个分析器将输入结果存在本地的中间文件(也称为分区文件,segment file)中。 
在reduce阶段,我们想将同一键(词项ID)的所有值(文档ID)集中存储,以便快速读取和处理。实现时,将所有的键按照词项区间划分成j个段,并将属于每个段的键-值对写入各自分区文件即可。所有的词项按照首字母来分成3端:af,gp及qz。词项的分割方法由运行索引系统的用户来定义。每个分析器各自写相应的分区文件,每个分区文件对应的一个词项区间,因此,在整个系统中,每个词项区间会对应r个分区文件,其中,r是分析器的个数。举例来说,对于af分区,有三个a~f分区文件,他们分别对应3个分析器。 
给定一个键(词项ID),将所有的值(文档ID)汇总并组织成倒排表的过程有Reduce阶段的倒排器(inverter)来完成。主控节点将每个词项分区分配给一个不同的倒排器,并在倒排器失效或者变慢的时候讲其上处理的词项分区进行重新分配。每个词项分区(对一个r个分区文件,其中每个文件存放在一个分析器上)由一个倒排器来完成。这里我们假定多个分区文件的大小适合在单机上处理。最后,每个键对应的所有值要进行排序并写到最终的排序记录表中。需要指出的是,每个倒排记录表中还包括词项频率,而本章其他节的倒排索引仅仅只是文档ID。针对a~f分区处理的数据。

Map和Reduce函数的框架
Map:输入 ->list(k,v)
Reduce:(k,list(v)) ->输出
索引构建中的上述架构的实例化
Map:Web文档集 ->list(词项ID,文档ID)
Reduce:(<词项ID1,ist(docID)>,<词项ID2,list(docID)>,...) (倒排记录表1,倒排记录表2)
索引构建的一个例子
Map:d2:C died. d2 C came,C c'ed. ->(,,,,,)
Reduce:(,,,) ->(,,,)

MapReduce中的Map和Reduce函数。通常Map函数会产生一个键-值表。对于同一个键,在reduce阶段会汇总成一个表中。在后续阶段,该表会被进一步处理。上表中给出两个函数的实例化及在索引构建中使用的一个例子。由于map阶段往往会在一个分布式环境中进行处理。所以在该例子中,词项ID-文档ID对并不需要一开始就正确排序。 
分析器和倒排器并不一定是不同机器。主控节点发现空闲的机器后会给它分配新的任务。同一台机器在map阶段中可以作为分析器,而在reduce阶段也可以作为倒排器。另外,索引构建的同时,机器上往往也在同时运行其他任务,所以在做分析器和倒排索引之外,一台机器也可能运行采集程序或者其他不相关的任务。 
为了尽量减少在倒排器对数据进行reduce之前的写时间,每个分析器都将其分区文件写到本地磁盘。在reduce阶段,主控节点会通知倒排器与之相关的分区文件的位置(例如,词项a~f分区对应的r个分区文件)。在每个分析器上,由于与某个特定倒排器相关数据已经被分析器写入一个单独的分区文件中,所以每个分区文件仅需要一次顺序读取过程。这种设置方法可以索引时所需的网络通信开销最小。 
由于输入和输出通常都是键-值对列表本身,所以多个MapReduce任务能够串行执行。实际上,这正是2004年Google的索引系统的设计方案。本节所介绍的索引的过程仅仅包含该Google索引系统的1/5~1/10的MapReduce操作。另一个MapReduce操作则将刚才创建的按照词项分割的索引转换成按照文档分割的索引。 
MapReduce为分布式环境下的索引构建提供了一个鲁棒性的、概念简介的实现框架。通过提供半自动的方法将索引构建分割成多个子任务,那么就可以在给定足够规模的计算机集群的情况下,将索引构建扩展到任何规模的文档集上。

动态索引的构建方法

迄今为止,我们都假设文档集是静态的,这对于很少甚至永远不会改变的文档集(如《圣经》或莎士比亚的著作)来说没有任何问题。然而,大部分文档集会随着文档的增加、删除或者更新而不断改变。这也意味着需要将新的词项加入词典,并对已有的词项的倒排记录表进行更新。 
最简单的索引更新办法就是周期性地对文档集从头开始进行索引重构。如果随时间的推移文档更新的次数并不是很多,并且能够接受新文档检索的一定延迟,再加上如果有足够的资源能够支持在建立新索引的同时让旧索引继续工作,那么周期性索引重构不失为一个较好的选择。 
如果要求能够及时搜索到新的文档,那么一种解决办法是同时保存两个索引:一个是打的主索引,另一个是小的用于存储新文档信息的辅助索引(auxiliary index),后者保存在内存中,检索时可以同时遍历两个索引并将结果合并。文档的删除记录在一个无效位向量(invalidation bit vector)中,再返回结果之前可以利用它过滤掉已删除的文档。某篇文档的更新通过先删除后重新插入来实现。 
每当辅助索引变得很大的时候,就将它合并到主索引中。合并操作的开销取决于索引文件系统中的存储方式。如果将每个词项的倒排索引记录表都单独存成一个文件,那么要合并主索引和辅助索引,只需要将辅助索引的倒排记录表扩展到主索引对应的倒排记录表中即可完成。上述机制中,保存辅助索引的原因在于可以减少随时间推移所需要的磁盘寻道次数。单独更新每篇文档最多需要M(ave)次磁盘寻道,其中M(ave)是文档集中平均每篇文档的词汇表大小。而采用辅助索引的话,在合并副主索引和主索引时,只需要将额外的负载放到磁盘上。 
遗憾的是,由于绝大多数文件系统不能对大数目的文件进行高效的处理,因此将每个倒排记录存成一个文件这种方式实际是不可行的。另一种简单的方法是将索引存到一个大文件中,也就是说将所有的倒排记录表存到一起。现实当中,我们往往在上述两种极端机制之间取一个折中方案。为讨论方便起见,下面我们只选择将索引存成一个大文件的方式。 
在这种机制下,对每个倒排记录我们都会处理[T/n]次,这是因为[T/n]次合并中的每一次都会处理倒排记录表,其中n是副主索引的大小,T是所有倒排记录的数目。因此,总的时间复杂度是O(T^2/n)。 
通过引入log2(T/n)个索引I0,I1,I2,…,其中每个索引的大小分别是20*n,21n,2^2n…,可以进一步降低上述时间复杂度。每个倒排记录表在上述索引序列中进行向上过滤(percolate up)操作,每一层的倒排记录仅仅只会处理一次。这种机制称为对数合并(logarithmic mergin)。同以往一样,内存中的辅助索引(我们称为Z0)最多容纳n个倒排记录。当达到上限n时,Z0中的20*n个倒排记录会移动到一个建立在磁盘上的新索引I0中。当Z0下一次放满时,他会和I0合并,并建立一个大小为21*n的索引Z1。Z1可以以I1的方式存储(如果I1不存在)或者I1合并成Z2(如果I1已存在)。上述过程可以持续下去。对搜索请求进行处理时,我们会利用内存的索引Z0和现有的磁盘上的有效索引Ii进行处理,并将结果合并。看到这里,而二项式堆结构与刚才提到的对数合并下的倒排索引结构之间具有很高的相似性。

LMergeAddToken(indexes,Z0,token)
Z0 <- Merge(Z0,{token})
if |Z0|=n
  then for i <- 0 to ∞
        do if Ii ∈ idenxes
                then Zi+1 <- Merge(Ii,Zi)
                        (Zi +1 is a temporary index on disk)
                        indexex<-indexes -{Ii}
                else Ii <- Zi (Zi becomes the permanent index Ii.)
                        indexes <- idenxes ∪ {Ii}
                        Break
        Z0 <- Φ
LogarithmicMerge()
Z0 <- Φ (Z0 is the in-memory index)
indexes <- Φ
while true
do LMergeAddToken(indexes,Z0,getNextToken())
//对数合并示意图,其中每个词条(词项ID,文档ID)一开始通过LMergeAddToken函数加入到函数索引Z0中。LogarithmicMerge对Z0和索引初始化

由于每个倒排记录在log2(T/n)层的每一层中都只处理一次,因此真个索引结构的时间是Φ(Tlog2(T/n))。当然,在获得索引效率提升的同时,我们也降低查询处理的效率为代价。这是因为,现在我们要合并的不只是主索引和副主索引这两个索引的搜索结果,而是log2(T/n)个索引的结果。同辅助机制一样,这种机制偶尔也需要进行大规模索引合并操作,这会降低在合并时的搜索效率。当然,这种情况的发生频率较低,而且平均而言,合并中的索引规模也较小。 
同时拥有多个索引会增加全局统计信息维护的复杂度。例如它会影响拼写校正算法,该算法是基于最高选中次数选择最后可能的正确结果。在有过个索引和无效位向量的情况下,得到正确的词项选中数目不在是一个简单的查找过程。实际上,采用对数合并方法,信息检索系统的各个方面,包括索引维护、查询处理、分布等等都会复杂很多。 
由于动态索引的复杂性,一些大型搜索引擎采用从头开始重构索引而不是动态构建索引的方法。他们会周期性的构建一个全新的索引,然后将处理转到新索引上去,同时将旧的索引删除。

其他索引类型

本章前面仅仅导论了不包含位置信息的索引构建,而包含位置信息的索引结构与无位置信息的索引机构相比,除了需要考虑加入位置信息带来更大数据开销之外,最主要的区别是将原来的(词项ID,文档ID)二元组变成了(词项ID,文档ID,(位置1,位置2,…))三元组,也就是每个文档ID后面附有词项ID在文档中的位置信息。这样一来,这里所讨论的所有算法都应用到带位置信息的索引结构上。 
在目前讨论的索引结构中,倒排记录表中的所有记录都会按照文档ID排序。这样会对压缩提供方便,即可以不直接保存文档ID而是保存文档ID之间的间隔来进行压缩,这样就能够减少索引的存储空间。然而这种结构对于排序式索引(ranked retrieval)系统(非布尔检索系统)而言却不是最优的。在排序式检索系统中,倒排记录往往基于权重或者影响度排序,其中权重最大的倒排记录排在首位。因此,在这种结构下进行查询的时,并不一定需要对所有的倒排记录表进行扫描,而是在遇到权重足够小的文档后即可停止,因为后面的文档和查询的相似度可能都很低。在以文档ID排序索引中,新文档的加入往往只需在倒排记录表最后增加信息。而在以文章影响度排序的索引中,新文档的加入会导致在任意可能的地方插入信息,因此会大大增加倒排记录表更新的复杂度。 
安全性很多企业检索系统所必须重点关注的问题之一。一个低级别的员工不能对公司所有人的工资进行搜索,而只有授权的员工才能这样做。用户检索结果中不能出现禁止公开的文档,文档的存在本身也可能就是敏感信息。 
用户授权往往通过ACL(access control list,访问控制表)来实现。对于信息检索系统来说,ACL可以通过将每篇文档表示成所有能访问该文档的用户集合来实现,然后再对记过用户-文档矩阵进行转置,转置后的ACL索引中有一个每个用户都可以访问所有文档组成的“倒排索引表”,这个表就是用户的访问表。搜索结果会在这个表中进行交集运算。然而,维护这样的索引是比较困难的,特别是在访问权限发生变化的时候。常规的倒排索引的增量式构建问题时,我们就提到了上述困难。有些用户的可访问文档比较多,此时就要对长倒排记录表进行额外处理。在查询时,用户的资格往往会通过直接从文件系统返回的访问信息来验证——即使这样会降低索引的速度。

posted @ 2016-12-28 20:15  Mr-cc  阅读(488)  评论(0编辑  收藏  举报