此项目是本身学习搜索引擎过程当中的一些心得,在使用go语言的时候,发现了悟空这个搜索引擎项目,结合此项目代码以及《信息检索导论》,本身对搜索引擎的原理是实现都有了一个初步的认识,而后结合工做中可能遇到的场景,作了一个简单的demo。写下这篇文章,可能比较啰嗦,但愿帮助到须要的人。项目代码地址: https://github.com/LiuRoy/sakurapython
假若有四个文档,分别表明四部电影的名字:git
若是咱们想根据这四个文档创建信息检索,即输入查找词就能够找到包含此词的全部电影,最直观的实现方式是创建一个矩阵,每一行表明一个词,每一列表明一个文档,取值1/0表明该此是否在该文档中。以下:github
若是输入是Dark,只须要找到Dark对应的行,选出值为1对应的文档便可。当输入是多个单词的时候,例如:The Gump,咱们能够分别找到The和Gump对应的行:1011和0100,若是是想作AND运算(既包括The也包括Gump的电影),1011和0100按位与操做返回0000,即没有知足查询的电影;若是是OR运算(包括The或者包括Gump的电影),1011和0100按位与操做返回1111,这四部电影都知足查询。算法
实际状况是咱们须要检索的文档不少,一个中等规模的bbs网站发布的帖子可能也有好几百万,创建这么庞大的一个矩阵是不现实的,若是咱们仔细观察这个矩阵,当数据量急剧增大的时候,这个矩阵是很稀疏的,也就是说某一个词在不少文档中不存在,对应的值为0,所以咱们能够只记录每一个词所在的文档id便可,以下:sql
查询的第一步仍是找到每一个查询词对应的文档列表,以后的AND或者OR操做只须要按照对应的文档id列表作过滤便可。实际代码中通常会保证此id列表有序递增,能够极大的加快过滤操做。上图中左边的每个词叫作词项,整张表称做倒排索引。数据库
若是要实现一个搜索功能,通常有以下几个过程数组
搜集要添加索引的文本,例如想要在知乎中搜索问题,就须要搜集全部问题的文本。浏览器
文本的预处理,把上述的收集的文本处理成为一个个词项。不一样语言的预处理过程差别很大,以中文为例,首先要把搜集到的文本作分词处理,变为一个个词条,分词的质量对最后的搜索效果影响很大,若是切的粒度太大,一些短词搜索正确率就会很低;若是切的粒度过小,长句匹配效果会不好。针对分词后的词条,还须要正则化:例如滤除停用词(例如:的
把
而且
,一些几乎全部中文文档都包含的一些词,这些词对搜索结果没有实质性影响),去掉形容词后面的的
字等。缓存
根据上一步的词项和文档创建倒排索引。实际使用的时候,倒排索引不只仅只是文档的id,还会有其余的相关的信息:词项在文档中出现的次数、词项在文档中出现的位置、词项在文档中的域(以文章搜索举例,域能够表明标题、正文、做者、标签等)、文档元信息(以文章搜索举例,元信息多是文章的编辑时间、浏览次数、评论个数等)等。由于搜索的需求各类各样,有了这些数据,实际使用的时候就能够把查询出来的结果按照需求排序。安全
查询,将查询的文本作分词、正则化的处理以后,在倒排索引中找到词项对应的文档列表,按照查询逻辑进行过滤操做以后能够获得一份文档列表,以后按照相关度、元数据等相关信息排序展现给用户。
文档和查询相关度是对搜索结果排序的一个重要指标,不一样的相关度算法效果千差万别,针对一样一份搜索,百度和谷歌会把相同的帖子展现在不一样的位置,极有可能就是由于相关度计算结果不同而致使排序放在了不一样的位置。
基础的相关度计算算法有:TF-IDF,BM25 等,其中BM25 词项权重计算公式普遍使用在多个文档集和多个搜索任务中并得到了成功。尤为是在TREC 评测会议上,BM25 的性能表现很好并被多个团队所使用。因为此算法比较复杂,我也是似懂非懂,只须要记住此算法须要词项在文档中的词频,能够用来计算查询和文档的相关度,计算出来的结果是一个浮点数,这样就能够将用户最须要知道的文档优先返回给用户。
悟空搜索(项目地址: https://github.com/huichen/wukong)是一款小巧而又性能优异的搜索引擎,核心代码不到2000行,带来的缺点也很明显:支持的功能太少。所以这是一个很是适合深刻学习搜索引擎的例子,做者不只给出了详细的中文文档,还在代码中标注了大量的中文注释,阅读源码不是太难,在此结合悟空搜索代码和搜索原理,深刻的讲解搜索具体的实现。
索引的核心代码在core/index.go。
// 索引器 type Indexer struct { // 从搜索键到文档列表的反向索引 // 加了读写锁以保证读写安全 tableLock struct { sync.RWMutex table map[string]*KeywordIndices docsState map[uint64]int // nil: 表示无状态记录,0: 存在于索引中,1: 等待删除,2: 等待加入 } addCacheLock struct { sync.RWMutex addCachePointer int addCache types.DocumentsIndex } removeCacheLock struct { sync.RWMutex removeCachePointer int removeCache types.DocumentsId } initOptions types.IndexerInitOptions initialized bool // 这其实是总文档数的一个近似 numDocuments uint64 // 全部被索引文本的总关键词数 totalTokenLength float32 // 每一个文档的关键词长度 docTokenLengths map[uint64]float32 } // 反向索引表的一行,收集了一个搜索键出现的全部文档,按照DocId从小到大排序。 type KeywordIndices struct { // 下面的切片是否为空,取决于初始化时IndexType的值 docIds []uint64 // 所有类型都有 frequencies []float32 // IndexType == FrequenciesIndex locations [][]int // IndexType == LocationsIndex }
tableLock
中的table就是倒排索引,map中的key便是词项,value就是该词项所在的文档列表信息,keywordIndices
包括三部分:文档id列表(保证docId有序)、该词项在文档中的频率列表、该词项在文档中的位置列表,当initOptions
中的IndexType
被设置为FrequenciesIndex
时,倒排索引不会用到keywordIndices
中的locations,这样能够减小内存的使用,但不可避免地失去了基于位置的排序功能。
因为频繁的更改索引会形成性能上的急剧降低,悟空在索引中加入了缓存功能。若是要新加一个文档至引擎,会将文档信息加入addCacheLock
中的addCahe
中,addCahe
是一个数组,存放新加的文档信息。若是要删除一个文档,一样也是先将文档信息放入removeCacheLock
中的removeCache
中,removeCache
也是一个数组,存放须要删除的文档信息。只有在对应缓存满了以后或者触发强制更新的时候,才会将缓存中的数据更新至倒排索引。
添加新的文档至索引由函数AddDocumentToCache
和AddDocuments
实现,从索引中删除文档由函数RemoveDocumentToCache
和RemoveDocuments
实现。由于代码较长,就不贴在文章里面,感兴趣的同窗能够结合代码和下面的讲解,更深刻的了解实现方法。
RemoveDocumentToCache
首先检查索引是否已经存在docId,若是存在,将文档信息加入removeCache
中,并将此docId的文档状态更新为1(待删除);若是索引中不存在可是在addCahe
中,则只是把文档状态更新为1(待删除)。removeCache
已满或者是外界强制更新,则会调用RemoveDocuments
将removeCache
中要删除的文档从索引中抹除。RemoveDocuments
会遍历整个索引,若是发现词项对应的文档信息出如今removeCache
中,则抹去table
和docState
中相应的数据。备注:removeCache
和docIds
均已按照文档id排好序,因此RemoveDocuments
能够以较高的效率快速找到须要删除的数据。
AddDocumentToCache
首先会将须要添加的文档信息放入到addCahe
中,若是缓存已满或者是强制更新,则会遍历addCache
,若是索引中存在此文档,则把该文档状态置为1(待删除),不然置为2(新加)并将状态为1(待删除)的文档数据放在addCache
列表前面,addCache
列表后面都是须要直接更新的文档数据。RemoveDocumentToCache
更新索引,若是更新成功,则把addCache
中全部的数据调用AddDocuments
添加至索引,不然只会把addCache
中状态为2(新加)的文档调用AddDocuments
添加至索引。AddDocuments
遍历每一个文档的词项,更新对应词项的KeywordIndices
数据,并保证KeywordIndices
文档id有序。备注:第二步相同的文档只会将最后一条添加的文档更新至索引,避免了缓存中频繁添加删除可能形成的问题。
从上面添加删除文档的操做能够发现,真正有效的数据是tableLock
中的table
和docState
,其余的数据结构均是出于性能方面的妥协而添加的一些缓存。查询的函数Lookup
也只是从这两个map中找到相关数据并进行排序。
合并搜索关键词和标签词,从table
中找到这些词对应的全部KeywordIndices
数据
从上面的KeywordIndices
数据中找出全部公共的文档,并根据文档词频和位置信息计算bm25和位置数据。
悟空使用了不少异步的方式提升运行效率,针对咱们开发高效的代码颇有借鉴意义。项目文档里面有一份粗略的架构图,我根据engine源码,画出了一份详细的架构图。下面就以接口为粒度讲解具体的执行流程。
备注:圆柱体表明管道,矩形表明worker。
这部分体如今图最上面的persistentStorageInitWorker
和persistentStorageInitChannel
,若是指定了索引的持久化数据库的信息,在引擎启动的时候,会异步调用persistentStorageInitWorker
,这个routine会将持久化的索引数据(全部storage shard)加载到内存中,加载完毕后经过persistentStorageInitChannel
通知主routine.
IndexDocument
是对外的添加文档的接口,当此接口执行的时候,先将须要分词的文本放入管道segmenterChannel
,segmentWorker
从segmenterChannel
取出文本作分词处理,而后将分词的结果均匀的分配到各个shard对应的indexerAddDocChannels
和rankerAddDocChannels
,indexerAddDocumentWorker
和rankerAddDocWorker
分别从上面两个管道中取出数据更新索引数据和排序数据。
若是设置了持久化数据,IndexDocument
还会将文档数据均匀的放入到各个storage shard的persistentStorageIndexDocumentChannels
中,persistentStorageIndexDocumentWorker
负责将管道中的文档数据持久化到文件中。
RemoveDocument
是对外的删除文档的接口,当接口执行的时候,找到文档所在的shard,而后将请求放入indexerRemoveDocChannels
和rankerRemoveDocChannels
,indexerRemoveDocWorker
和rankerRemoveDocWorker
分别监听上面两个管道,清除索引数据和排序数据。
search
是对外的搜索接口,它会针对全部的shard里的indexerLookupChannels
发送请求数据,以后阻塞在监听rankerReturnChannel
这一步,indexerLookupWorker
会调用函数Lookup
从倒排索引中找到制定的文档,若是不要求排序,直接将数据放入rankerReturnChannel
,不然将数据交给rankerRankChannels
,而后由rankerRankWorker
排完序再放入rankerReturnChannel
。当search
发现全部数据都返回以后,再将各个shard的数据作一次排序,而后返回。
由架构图能够很清晰地看出整个运行流程,同时知道此引擎没法分布式部署。若是须要作分布式部署,须要将每一个shard做为一个独立的进程,并且上层有一个相似网管的进程作数据分发和汇总操做。
为了方便本身和你们的使用,我写了一个比较简单的例子,用orm的callback方式更新搜索引擎。
文档数据是我从知乎的恋爱和婚姻话题爬取的精品回复,大概有1800左右回复,包括问题标题,回复正文,点赞个数以及问题标签,下载连接:https://github.com/LiuRoy/sakura/blob/master/spider/tables.sqlite,存储格式为sqlite,数据以下:
对如何爬取的同窗能够参看代码https://github.com/LiuRoy/sakura/blob/master/spider/crawl.py,执行以下命令直接运行
cd sakura/spider/ pip install -r requirement python scrawl.py
用上一步爬取的数据构建一个搜索引擎,代码参考server.go,在运行以前须要本身配置一下词典以及数据路径,悟空提供了一份分词词典和停用词列表,配置完成后运行go run server.go
启动服务,而后经过浏览器就可使用搜索服务了。
通常搜索服务的数据都是动态变化的,如何在数据频繁变更的时候以最简单的方式更新索引呢?我能想到的方法有以下几种:
我采用了第四种方式作了一个demo,代码参考sender.go,为了不代码耦合,经过orm的callback方式将修改的数据经过zeromq消息队列发送给搜索服务,搜索服务有一个goroutine来消费数据并更改索引,当执行go run sender.go
后,新建的一条数据就能够立刻被索引到。