纯 MongoDB 实现中文全文搜索


本文来自获得《2021MongoDB技术实践与应用案例征集活动》一等奖作品

摘要

MongoDB在2.4版中引入全文索引后几经迭代更新已经比较完美地支持以空格分隔的西语,但一直不支持中日韩等语言,社区版用户不得不通过挂接ElasticSearch等支持中文全文搜索的数据库来实现业务需求,由此引入了许多业务限制、安全问题、性能问题和技术复杂性。作者独辟蹊径,基于纯MongoDB社区版(v4.x和v5.0)实现中文全文搜索,在接近四千万个记录的商品表搜索商品名,检索时间在200ms以内,并使用Change Streams技术同步数据变化,满足了业务需要和用户体验需求。

本文首先描述遇到的业务需求和困难,介绍了MongoDB和Atlas Search对全文搜索的支持现状,然后从全文搜索原理讲起,结合MongoDB全文搜索实现,挂接中文分词程序,达到纯MongoDB社区版实现中文全文搜索的目标;针对性能需求,从分词、组合文本索引、用户体验、实时性等多方面给出了优化实践,使整个方案达到商业级的实用性。

业务需求和困难

电商易是作者公司的电商大数据工具品牌,旗下多个产品都有搜索商品的业务需求。早期的时候,我们的搜索是直接用$regex去匹配的,在数据量比较大的时候,需要耗时十几秒甚至几分钟,所以用户总是反馈说搜不出东西来。其实不是搜不出来,而是搜的时间太长,服务器掐断连接了。加上我们普遍使用极简风格的首页,像搜索引擎那样,有个框,右侧是一个“一键分析”的按钮,用户点击后显示相关的商品的数据。搜索成为用户最常用的功能,搜索性能的问题也就变得更加突出了,优化搜索成为了迫在眉睫的任务。

MongoDB在2.4版中引入文本索引(Text Index)实现了全文搜索(Full Text Search,下文简称FTS),虽然后来在2.6和3.2版本中两经改版优化,但一直不支持中日韩等语言。MongoDB官网推出服务Atlas Search,也是通过外挂Lucene的方式支持的,这个服务需要付费,而且未在中国大陆地区运营,与我们无缘,所以还是要寻找自己的解决之道。

那么能否仅仅基于MongoDB社区版实现中文全文搜索呢?带着这个问题,作者深入到MongoDB文本索引的文档、代码中去,发现了些许端倪,并逐步实现和优化了纯MongoDB实现中文全文搜索的方案,下文将从全文搜索的原理讲起,详细描述这个方案。

过程

全文搜索原理

倒排索引是搜索引警的基础。倒排是与正排相对的,假设有一个 ID 为 1 的文档,内容为“ My name is LaiYonghao.“,那么通过 ID 1 总能找到这个文档所有的词。通过文档 ID 找包含的词,称为正排;反过来通过词找到包括该词的文档 ID,称为倒排,词与文档ID的对应关系称为倒排索引。下面直接引用一下维基百科上的例子。

0 "it is what it is"
1 "what is it"
2 "it is a banana"

上面 3 个文档的倒排索引大概如下:

"a": {2}
"banana": {2}
"is": {0, 1, 2}
"it": {0, 1, 2}
"what": {0, 1}

这时如果要搜索banana的话,利用倒排索引可以马上查找到包括这个词的文档是ID为2的文档。而正排的话,只能一个一个文档找过去,找完3个文档才能找到(也就是$regex的方式),这种情况下的耗时大部分是无法接受的。

倒排索引是所有支持全文搜索的数据库的基础,无论是PostgreSQL还是MySQL都是用它来实现全文搜索的,MongoDB也不例外,这也是我们最终解决问题的基础底座。简单来说,倒排索引类似MongoDB里的多键索引(Multikey Index),能够通过内容元素找到对应的文档。文本索引可以简单类比为对字符串分割(即分词)转换为由词组成的数组,并建立多键索引。虽然文本索引还是停止词、同义词、大小写、权重和位置等信息需要处理,但大致如此理解是可以的。

西文的分词较为简单,基本上是按空格分切即可,这就是MongoDB内置的默认分词器:当建立文本索引时,默认分词器将按空格分切句子。而CJK语言并不使用空格切分,而且最小单位是字,所以没有办法直接利用MongoDB的全文搜索。那么如果我们预先将中文句子进行分词,并用空格分隔重新组装为“句子”,不就可以利用上MongoDB的全文搜索功能了吗?通过这一个突破点进行深挖,实验证明,这是可行的,由此我们的问题就转化为了分词问题。

一元分词和二元分词

从上文可知,数据库的全文搜索是基于空格切分的词作为最小单位实现的。中文分词的方法有很多,最基础的是一元分词和二元分词。

所谓一元分词:就是一个字一个字地切分,把字当成词。如我爱北京天安门,可以切分为我爱北京天安门,这是最简单的分词方法。这种方法带来的问题就是文档过于集中,常用汉字只有几千个,姑且算作一万个,如果有一千万个文档,每一个字会对应到10000000/10000*avg_len(doc)个。以文档内容是电商平台的商品名字为例,平均长度约为 60 个汉字,那每一个汉子对应 6 万个文档,用北京两字搜索的话,要求两个长度为6万的集合的交集,就会要很久的时间。所以大家更常使用二元分词法。

所谓二元分词:就是按两字两个分词。如我爱北京天安门,分词结果是我爱爱北北京京天天安安门。可见两个字的组合数量多了很多,相对地一个词对应的文档也少了许多,当搜索两个字的时候,如北京不用再求交集,可以直接得到结果。而搜索三个字以上的话,如天安门也是由天安和安门两个不太常见的词对应的文档集合求交集,数量少,运算量也小,速度就很快。下面是纯中文的二元分词Python代码,实际工作中需要考虑多语言混合的处理,在此仅作示例:

def bigram_tokenize(word):
  return' '.join(
    word[i:i+2]for i inrange(len(word))if i+2<=len(word)
  )

print(bigram_tokenize('我爱北京天安门'))
# 输出结果:我爱 爱北 北京 京天 天安 安门

Lucene自带一元分词和二元分词,它的中文全文搜索也是基于二元分词和倒排索引实现的。接下来只需要预先把句子进行二元分词再存入MongoDB,就可以借助它已有的西语全文搜索功能实现对中文的搜索。

编写索引程序

编写一个分词程序,它将全表遍历需要实现全文搜索的集合(Collection),并将指定的文本字段内容进行分词,存入指定的全文索引字段。

以对products表的name字段建立全文索引为例,代码大概如下:

def build_products_name_fts():
  # 在 _t 字段建立全文索引
 db.products.create_index([('_t', 'TEXT')])
  # 遍历集合
  for prod in db.products.find({}):
   db.products.update_one(
      {'_id': prod['_id']},
      {
        '$set': {
          '_t': bigram_tokenize(prod['name'])  # 写入二元分词结果
        }
      }
    )

if__name__=="__main__":
   build_products_name_fts()

只需要10来行代码就行了,它在首次运行的时候会做一次全表更新,完成后即可用以全文搜索。MongoDB的高级用户也可以用带更新的聚合管道完成这个功能,只需要写针对二元分词实现一个javascript函数(使用$function操作符)放到数据库中执行即可。

查询词预处理

因为我们针对二元分词的结果做搜索,所以无法直接搜索。以牛仔裤为例,二元分词的全文索引里根本没有三个字的词,是搜索不出来结果的,必须转换成短语"牛仔仔裤"这样才能匹配上,所以要对查询词作预处理:进行二元分词,并用双引号约束位置,这样才能正确查询。

products = db.products.find(
    {
        '$text': {
            '$search': f'"{bigram_tokenize(kw)}"',
        }
    }
)

如果有多个查询词或带有反向查询词,则需要作相应的处理,在此仅以独词查询示例,具体不用细述。

MongoDB不仅支持在find中使用全文搜索,也可在aggregate中使用,在find中使用是差不多的,不过要留意的是只能在第一阶段使用带$text的$match。

初步结果

首先值得肯定的是做了简单的二元分词处理之后,纯MongoDB就能够实现中文全文搜索,搜索结果是精准的,没有错搜或漏搜的情况。

不过在性能上比较差强人意,在约4000万文档的products集合中,搜索牛仔裤需要10秒钟以上。而且在项目的使用场景中,我们发现用户实际查询的词很长,往往是直接在电商平台复制商品名的一部分,甚至全部,这种极端情况需要几分钟才能得到查询结果。

在产品层面,可以对用户查询的词长度进行限制,比如最多3个词(即2个空格)且总长度不要超过10个汉字(或20个字母,每汉字按两个字母计算),这样可以控制相对快一点。但这样的规则不容易让用户明白,用户体验受损,需要想办法优化性能。

优化

结巴中文分词

结巴中文分词是最流行的Python中文分词组件,它有一种搜索引擎模式,在精确模式的基础上,对长词再次切分,提高召回率,适合用于搜索引擎分词。下面是引用自它项目主页的示例:

seg_list = jieba.cut_for_search("小明硕士毕业于中国科学院计算所,后在日本京都大学深造")  # 搜索引擎模式
print(", ".join(seg_list))
# 结果:【搜索引擎模式】:小明, 硕士, 毕业, 于, 中国, 科学, 学院, 科学院, 中国科学院, 计算, 计算所, 后, 在, 日本, 京都, 大学, 日本京都大学, 深造

可见它的分词数量比二元分词少了很多,对应地索引产寸也小了。使用二元分词时,4000万文档的products表索引超过40GB,而使用结巴分词后,减少到约26GB。

由上例也可看出,结巴分词的结果丢失了位置信息,所以查询词预处理过程也可以省略加入双引号,这样MongoDB在全文搜索时计算量也大大少,搜索速度加速了数十倍。以牛仔裤为例,使用结巴分词后查询时间由10秒以上降到约400ms,而直接复制商品名进行长词查询,也基本上能够在5秒钟之内完成查询,可用性和用户体验都得到了巨大提升。

结巴分词的缺陷是需要行业词典进行分词。比如电商平台的商品名都有长度限制,都是针对搜索引擎优化过的,日常用语“男装牛仔裤”在电商平台上被优化成了“牛仔裤男”,这显然不是一个通常意义上的词。在没有行业词典的情况下,结巴分词的结果是牛仔裤男,用户搜索时,将计算“牛仔裤”和“男”的结果交集;如果使用自定义词典,将优化为牛仔裤牛仔裤男,则无需计算,搜索速度更快,但增加了维护自定义词典的成本。

组合全文索引(Compound textIndex)

组合全文索引是MongoDB的一个特色功能,是指带有全文索引的组合索引。下面引用一个官方文档的例子:

db.inventory.createIndex(
   {
     dept:1,
     description:"text"
   }
)
// 查询
db.inventory.find( { dept:"kitchen",$text: { $search:"green" } } )

通过这种方式,当查询部门(dept)字段的描述中是否有某些词时,因为先过滤掉了大量的非同dept的文档,可以大大减少全文搜索的时间,从而实现性能优化。

尽管组合全文索引有许多限制,如查询时必须指定前缀字段,且前缀字段只支持等值条件匹配等,但实际应用中还是有很多适用场景的,比如商品集合中有分类字段,天然就是等值条件匹配的,在此情况根据前缀字段的分散程度,基本上可以获得同等比例的性能提升,一般都在10倍以上。

用户体验优化

MongoDB的全文搜索其实是很快的,但当需要根据其它字段进行排序的时候,就会显著变慢。比如在我们的场景中,当搜索牛仔裤并按销量排序时,速度显著变慢。所以在产品设计时,应将搜索功能独立,只解决“快速找出最想要的产品”这一个问题,想在一个功能里解决多个问题,必然需要付出性能代价。

另一个有助于提升提升用户体验的技术手段是一次搜索,大量缓存。就是一个搜索词第一次被查询时,直接返回前面若干条结果,缓存起来(比如放到Redis),当用户翻页或其他用户查询此词时,直接从缓存中读取即可,速度大幅提升。

实时性优化

前文提到编写索引程序对全文索引字段进行更新,但如果后面持续增加或修改数据时,也需要及时更新,否则实时性没有保障。在此可以引入Change Streams,它允许应用程序访问实时数据更改,而不必担心跟踪 oplog 的复杂性和风险。应用程序可以使用Change Streams来订阅单个集合、数据库或整个部署中的所有数据更改,并立即对它们作出反应。由于Change Streams使用聚合框架,应用程序还可以根据需要筛选特定的更改或转换通知。Change Streams也是MongoDB Atlas Search同步数据变化的方法,所以它是非常可靠的。使用Change Streams非常简单,我们的代码片断类似于这样:

try:
    # 订阅 products 集合的新增和修改Change Streams
    with db.products.watch(
            [{'$match': {'operationType': {'$in':['insert', 'update']}}}]) as stream:
        for insert_change in stream:
            check_name_changed_then_update(insert_change)
exceptpymongo.errors.PyMongoError:
    logging.error('...')

在check_name_changed_then_update()函数中我们检查可搜索字段是否产生了变化(更新或删除),如果是则对该文档更新_t字段,从而实时数据更新。

总结

本文描述了作者实现纯MongoDB实现中文全文搜索的过程,最终方案在生产环境中稳定运营了一年多时间,并为多个产品采纳,经受住了业务和时间的考验,证明了方案的可行性和稳定性。在性能上在接近四千万个记录的商品表搜索商品名,检索时间在200ms以内,并使用Change Streams技术同步数据变化,满足了业务需要和用户体验需求。

作者在完成对中文全文搜索的探索过程中,经过对MongoDB源代码的分析,发现mongo/src/mongo/db/fts目录包含了对不同语言的分词框架,在未来,作者将尝试在MongoDB中实现中文分词,期待用上内建中文全文搜索支持的那一天。

关于作者:赖勇浩

广州天勤数据有限公司

2005年至2012年在网易(广州)、广州银汉等公司从事网络游戏开发和技术管理工作。2013年至2014年在广东彩惠带领团队从事彩票行业数字化研发和实施。2015年至今,创办广州齐昌网络科技有限公司,后并入广东天勤科技有限公司,任职CTO,并且担任广州天勤数据有限公司联合创始人&CEO,现带领团队负责电商大数据分析软件的研发工作,形成由看店宝等十余个数据工具组成的产品矩阵,覆盖分析淘宝、天猫、拼多多和抖音等多个电商平台数据,服务全国各地200多万电商从业人员。热爱分享,于2009年联合创办程序员社区TechParty(原珠三角技术沙龙)并担任两届组委主席,于2021年创办中小团队技术管理者和技术专家社区小红花俱乐部,均深受目标群体的喜爱。

精通Python、C++、Java等编程语言和Linux操作系统,熟悉大规模多人在线系统的设计与实现,在大数据方面,对数据收集、清洗、存储、治理、分析等方面有丰富经验,设计和实现了准PB级别的基于MongoDB的电商数据湖系统,对冷热数据分级处理、系统成本控制和数据产品设计研发有一定心得。

曾在《计算机工程》等期刊发表多篇论文,于2014年出版《编写高质量代码:改善Python程序的91个建议》一书。

posted on 2022-01-06 15:44  MongoDB中文社区  阅读(785)  评论(0编辑  收藏  举报