写点什么

纯 MongoDB 实现中文全文搜索

  • 2022 年 1 月 06 日
  • 本文字数:5766 字

    阅读完需:约 19 分钟

纯 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 个建议》一书。

用户头像

还未添加个人签名 2019.07.27 加入

还未添加个人简介

评论

发布
暂无评论
纯 MongoDB 实现中文全文搜索