MongoBD+Solr全文搜索的历程

当存储到数据库中的数据涉及到文本,针对文本的搜索需求就应运而生。MongoDB也支持文本的搜索,不过很可惜的是,MongoDB的$text不支持中文分词功能,在搜索中文文本时只会字符的匹配,这在使用上非常不方便。

最近做的一个项目中就有这个需求。师哥推荐solr+mmseq4j的组合实现中文搜索。最初的设想只是针对邮件系统的标题以及正文两个字段的提供搜索。在2次迭代后,增加了其他一些关键字段的索引,以提供搜索页面的二次过滤。本文首先简介Solr工具,在实机安装了Solr,接着实现针对中文文本字段的索引,以及一步步的增加索引字段以实现项目需求。技术上解决的问题主要有:Solr获取MongoDB增量数据,针对数组对象建立索引。


1. Solr安装

Solr是Apache开发的开源搜索服务器,基于HTTP和Apache的Lucene。也就是说,索引的生成、搜索都是通过向Solr服务器发出HTTP请求。

在 Solr 和 Lucene 中,使用一个或多个 Document 来构建索引。Document 包括一个或多个 Field。Field 包括名称、内容以及告诉 Solr 如何处理内容的元数据。例如,Field 可以包含字符串、数字、布尔值或者日期,也可以包含您想添加的任何类型。Field 可以使用大量的选项来描述,这些选项告诉 Solr 在索引和搜索期间如何处理内容。

在接下来的安装使用中继续说明不同Field的参数。这里在罗嗦一些基本概念。Solr的分析主要涉及到3个组件:分析器(Analyzers)、分词器(Tokenizers)和标记过滤器(Token Filters)。分析器用来预处理文本。分词器将文本分割成不同的token,这些token传递给标记过滤器来增删改token。这里还有提一下字符过滤器,它用来预处理字符。一般地,一个分析器由零个或多个字符过滤器,一个分词器以及零个到多个标记过滤器。这些都是概念都是为了衡量文本之间的相似性以便搜索文本内容。

例如,Solr 的 WhitespaceTokenizer 根据空白断词,而它的 StopFilter 从搜索结果中删除公共词。其他类型的分析包括词干提取、同义词扩展和大小写折叠。如果应用程序要求以某种特殊方式进行分析,则 Solr 所拥有的一个或多个断词工具和筛选器可以满足您的要求。

正如前面所说,Solr通过HTTP请求来进行索引操作,而Solr的索引操作分为如下4种:

  • add/update:添加文档或更新文档。直到提交后才能搜索到这些添加和更新。
  • commit:明确更新,使得上次提交以来所做的所有更改都可以搜索到。
  • optimize:重构文件以优化搜索性能。
  • delete:删除文档,可以通过 id 或查询来指定。

这里的文档就是需要搜索的对象文件。Solr的搜索命令只接受GET和POST方法,收到请求的Solr通过SolrRequestHandler来处理。

图来自IBM文档

上面简单介绍了Solr的特性,其内部技术核心还是比较复杂的,接下来就介绍安装配置Solr的方法。

2. 安装配置Solr

安装的Solr版本为4.10.3,在阿里云的Centos系统上。整个安装过程比较顺利,但在开始选择版本上做过多的犹豫。解压后进入solr-4.10.3/exmaple/multicore/目录,我们使用multicore的场景,这是solr.home就是multicore了。接着在mmseg4j-solr@github上下载mmes4j,版本选择匹配的2.2.0(里头3个文件分别为:mmseg4j-analysis-1.9.1.jar、mmseg4j-core-1.10.0.jar、mmseg4j-solr-2.2.0.jar)。将3个jar放入../multicore/lib中就安装完成了。

接下来配置Solr的模式(Schema),模式可以由类型、字段和其他声明组成。这里我们首先需要定义类型,然后依据类型定义字段。用编辑器打开./multicore/core0/config/schema.xml。可以看到Solr已经预定义了如下类型:

<fieldtype name="string"  class="solr.StrField" sortMissingLast="true" omitNorms="true"/>
<fieldType name="int" class="solr.TrieIntField" precisionStep="0" positionIncrementGap="0"/>
<fieldType name="long" class="solr.TrieLongField" precisionStep="0" positionIncrementGap="0"/>
<fieldType name="date" class="solr.TrieDateField" sortMissingLast="true" omitNorms="true"/>

mmseg4j的中文类型的配置也挺简单,这里我们选择中文分词的'complex'模式,在schema.xml文件中配置一个fieldType节点,如下:

<!-- mmseg4j -->
<fieldType name="text_zh" class="solr.TextField" positionIncrementGap="100">
   <analyzer>
       <tokenizer class="com.chenlb.mmseg4j.solr.MMSegTokenizerFactory" mode="complex" />
   </analyzer>
</fieldType>

然后就可以在field节点中引用该filedType了,假设你有个字段叫my_content需要支持中文分词,只需要定义示例filed节点如下:

<field name="my_content" type="text_zh" indexed="true" stored="false" multiValued="true"/>

接下来我们依据项目的具体业务设置需要搜索的字段,其中MongoDB的主键id字段就是原始的id字段,我们需要修改其字段并为id,并且将uniqueKey也修改为<uniqueKey>id</uniqueKey>。记得我们需要搜索的中文字段为邮件的中文以及标题,所以这里把两个字段的字段设置为'textzh'类型。

<field name="mail.text" type="text_zh" indexed="true" stored="true" multiValued="false" />
<field name="mail.subject" type="text_zh" indexed="true" stored="true" multiValued="false" />

最后一步就是运行Solr了。回到刚才的example目录,运行目录java -Dsolr.solr.home=multicore -jar start.jar

这是打开浏览器的8983端口,就能看到Solr的后台。慢着,我们的数据还没有,需要到MongoDB上获取。这里使用mongodb-labs的mongo-connector,按照官方说明安装即可。

3. mongo-connector的安装与使用

mongo-connector的工作原理就是获取MongoDB副本集的oplog(操作日志),日志信息记录了文档的CRUD中的时间、命名空间以及修改的文档数据等。具体可以看Oplog文件@官方WIKISystem Overview@官方WIKI。嗯,所以使用之前需要将副本集配置好。

这里就设置一个简单的主从副本,端口分别为27001、27002。配置先开启2个mongod程序,再进入主节点配置副本集。

# mongod --port 27001 --oplogSize 100 --dbpath /opt/db/rs1 --logpath /opt/db/log/rs1.log --replSet rs/127.0.0.1:27002 --journal
# mongod --port 27002 --oplogSize 100 --dbpath /opt/db/rs2 --logpath /opt/db/log/rs2.log --replSet rs/127.0.0.1:27001 --journal
# mongo --port 27001
> config={_id:'rs',members:[{_id: 0, host: '127.0.0.1:27001'}, {_id: 1, host: '127.0.0.1.:27002'}]}
> rs.initiate(config)
> rs.status()

这样就将MongoDB启动了,接下来安装好mongo-connector,并运行程序(可全局运行)。

# git clone https://github.com/10gen-labs/mongo-connector.git
# cd mongo-connector
# python setup.py install
//运行命令
# mongo-connector -m localhost:27001 -t http://localhost:8983/solr/core0 -n gossip.mails -d solr_doc_manager --auto-commit-interval=1

这样就行了吗?这样会报错的,还必须在Solr的Schema中存储metadata的定义以及开启LukeRequestHandler开启。在schema.xml中添加如下片段。

以及同目录下的solrconfig.xml

<requestHandler name="/admin/luke" class="org.apache.solr.handler.admin.LukeRequestHandler" />

通过netstat命令可以指定mongo-connector的端口以及监听端口。

命令output

这样mongo-connector就能监听MongoDB的数据变化并传递给Solr。整个搜索的基础架构如下。

搜索架构

4. checkout:搜索

这一部分通过solr的web后台,进行数据搜索的测试。首先在浏览器输入:http://serverIp:8983/solr/#/core0。 注意我们Solr采取的是多核模式。

core0的首页面如下,展示的是总文档数以及系统参数。

首页面

字段分析器的测试如下。

分词

最后query页为搜索的功能测试,可以看到结果还是蛮理想的。

5. 数组的索引

第二次迭代主要是满足搜索的需求,比如检查邮件是否有附件,在通过imap获取邮件时,检查附件数组,并存储到邮件的一个字段记录是否有附件hasAttach。这个好处理,直接添加schema中的字段即可。

<field name="hasAttach" type="int" indexed="true" stored="true" multiValued="false" />
<field name="filterStatus" type="int" indexed="true" stored="true" multiValued="false" />
<field name="recyleStatus" type="int" indexed="true" stored="true" multiValued="false" />
<field name="mail.date" type="date" indexed="true" stored="true" multiValued="false" />

比较麻烦的是最后一个需求需要去索引数组中的元素。这里就要引入一个schema配置中的动态字段(dynamicField)、和复制字段(copyfield)。动态字段可以通过来匹配多个字段,这又要提到在mongo-connector会将数组扁平化。比如一个数组rules,形成rules.0.ruleId以及rules.1.ruleId这样的字段,我们可通过动态字段rules来匹配这些数组里的元素。但是我们无法在Solr中通过rules*来搜索字段,这时就要通过复制字段来搜索了。复制字段的两个属性source、dest如同一个管道一样将source数据映射到dest上,当搜索dest时,就会到source上去建立索引。那么在我们的第三次修改中将数组字段这样处理,具体的配置信息如下。

<!-- mailFrom are from.address and from.name field -->
<dynamicField name="mail.from*" type="text_zh" indexed="true" stored="true" />
<field name="mailFrom" type="text_zh" indexed="true" stored="true" multiValued="true" />
<copyField source="mail.from*" dest="mailFrom" />
<field name="commonStatus" type="string" indexed="true" stored="true" multiValued="false" />
<field name="score" type="int" indexed="true" stored="true" multiValued="false" />
<!-- operationStr are operations.action and operations.description -->
<dynamicField name="operations*" type="text_zh" indexed="true" stored="true" />
<field name="operationStr" type="text_zh" indexed="true" stored="true" multiValued="true" />
<copyField source="operations*" dest="operationStr" />
<!-- categoryStr are category id str -->
<dynamicField name="category*" type="string" indexed="true" stored="true" />
<field name="catId" type="string" indexed="true" stored="true" multiValued="true" />
<copyField source="category*" dest="catId" />
<!-- ruleId is rules.ruleId string -->
<dynamicField name="rules*" type="string" indexed="true" stored="true"/>
<field name="ruleId" type="string" indexed="true" stored="true" multiValued="true" />
<copyField source="rules*" dest="ruleId" />

其实这种方式是十分丑陋的,copyField会将source字段中的所有元素转换为dest的同类型数据,比如我在rules数组下有一个int类型的变量来标记状态,但搜索时的结果就转换为string了。其次,资源的消耗也增加来。但对于目标搜索还是能够满足的,暂且就这样妥协的实现。

5. Node as Solr Client

最后再简单描述一下node端调用Solr的HTTP接口,这里使用的是solr-client库。

var qStr = '(mail.text: (' + fixedSearchKeyword + ') OR mail.subject: (' + fixedSearchKeyword + '))';
var query = client.createQuery()
  .q(qStr)
  .sort(sort)
  .start(start)
  .rows(rows)
  .fl('_id');
client.search(query, function(err, obj){
  if(err){
    console.log(err);
  }else{
    //其他处理
  }
});

最后做出的搜索应用如下,还不错。

搜索


参考

  1. 《使用 Apache Solr 实现更加灵巧的搜索,第 1 部分: 基本特性和 Solr 模式》@IBM文档

  2. Analyzers, Tokenizers, and Token Filters@SolrWiki

  3. Solr与MongoDB集成@cnblogs

  4. 《论mongo-connector如何将MongoDB中的json数组和嵌套对象更新至Solr引擎》@csdn