Node.js + MongoDB 中文分词实现全文搜索

2022年11月18日 · 1 year ago

前阵子在做一个辅助我写作的小工具,需要对中文进行全文搜索。因为小工具的Web版是用Node.js(TypeScript) + MongoDB写的后端,所以自然想到直接在MongoDB里操作。

假设我们有这样一个Model:

// https://mongoosejs.com/docs/guide.html
import mongoose from 'mongoose';
const { Schema } = mongoose;

const blogSchema = new Schema({
    title:  String, // String is shorthand for {type: String}
    author: String,
    body:   String,
    comments: [{ body: String, date: Date }],
    date: { type: Date, default: Date.now },
    hidden: Boolean,
    meta: {
        votes: Number,
        favs:  Number
    }
});

我们希望titlebody可以被搜索到,接下来我们看如何实现。

1. 给Model建立Text Index

根据官方文档,一个Collection只能有一个Text Index,但可以有多个字段组合:

blogSchema.index({
    title: text,
    body: text
})

如果希望修改不同字段的权重(Weights),可以通过增加weights来设置:

blogSchema.index({
    title: text,
    body: text
}, 
{
    weights: {
        title: 10,
        body: 2
    }
})

默认权重是1,数字越大权重越高。权重会影响搜索得分 Text Score,计算代码在这里,主要逻辑在_scoreStringV2方法,有兴趣的读者可以看看:

void FTSSpec::_scoreStringV2(FTSTokenizer* tokenizer,
                             StringData raw,
                             TermFrequencyMap* docScores,
                             double weight) const

简单来说分为四步

  1. Tokenization(分词)
  2. 去掉Stop Words(类似for, has, our, to之类的词,代码在这里
  3. 循环计算每个分词的得分并加到一起
  4. 针对当前document把所有全文索引字段跑一遍1-3并加权平均得出最终分数

这篇文章对于该计算逻辑有比较详细的解释,感兴趣的读者可以看一下。

建立完全文索引后对我们的Model进行搜索结果发现基本上只使用关键词基本搜不出东西来,比如有这样一篇文章:

{ 
title: Node.js + MongoDB 中文分词实现全文搜索,
body: Lorem Ipsum,也称乱数假文或者哑元文本, 是印刷及排版领域所常用的虚拟文字。由于曾经一台匿名的打印机刻意打乱了一盒印刷字体从而造出一本字体样品书,Lorem Ipsum从西元15世纪起就被作为此领域的标准文本使用。
}

仅搜索“全文”、“搜索”是无法命中的。

2. 中文分词

这是因为MongoDB原生不支持中文的分词,所以我们还需要对中文进行分词。拍脑袋想,我们可以把需要被搜索的文本里每一个字拆出来单独分一个词,然后组合第二字一个词,第三个字一个词……可想而知这会让document变得巨大很浪费。

后来我找到这个流行的中文分词项目: fxsjy/jieba: 结巴中文分词,非常好用。

# encoding=utf-8
import jieba

jieba.enable_paddle()# 启动paddle模式。 0.40版之后开始支持,早期版本不支持
strs=[我来到北京清华大学,乒乓球拍卖完了,中国科学技术大学]
for str in strs:
    seg_list = jieba.cut(str,use_paddle=True) # 使用paddle模式
    print(Paddle Mode:  + '/'.join(list(seg_list)))

seg_list = jieba.cut(我来到北京清华大学, cut_all=True)
print(Full Mode:  + / .join(seg_list))  # 全模式

seg_list = jieba.cut(我来到北京清华大学, cut_all=False)
print(Default Mode:  + / .join(seg_list))  # 精确模式

seg_list = jieba.cut(他来到了网易杭研大厦)  # 默认是精确模式
print(, .join(seg_list))

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

输出:

【全模式】: 我/ 来到/ 北京/ 清华/ 清华大学/ 华大/ 大学

【精确模式】: 我/ 来到/ 北京/ 清华大学

【新词识别】:他, 来到, 了, 网易, 杭研, 大厦    (此处,“杭研”并没有在词典中,但是也被Viterbi算法识别出来了)

【搜索引擎模式】: 小明, 硕士, 毕业, 于, 中国, 科学, 学院, 科学院, 中国科学院, 计算, 计算所, 后, 在, 日本, 京都, 大学, 日本京都大学, 深造

我只是想做个辅助工具而已,数据量不大,所以默认用全模式即可。

这样我们可以跟Model增加两个分过词的冗余字段:

// https://mongoosejs.com/docs/guide.html
import mongoose from 'mongoose';
const { Schema } = mongoose;

const blogSchema = new Schema({
    title:  String,
    titleToken: String,
    author: String,
    body:   String,
    bodyToken: String,
    comments: [{ body: String, date: Date }],
    date: { type: Date, default: Date.now },
    hidden: Boolean,
    meta: {
        votes: Number,
        favs:  Number
    }
});

blogSchema.index({
    titleToken: text,
    bodyToken: text
})

这里有Jieba的Node.js版本: yanyiwu/nodejieba: 结巴中文分词的Node.js版本

这样中文的分词问题就解决了,搜索关键词也能找到对应的document。

3. 自己动手分词

我有些document是固定的用法,比如成语,一般就四个字。但是在这个场景下,我希望搜索四个字中的任意一个字都能找到匹配的结果,这时候Jieba就不适用了,所以我们得自己分。

因为一般来说成语不多,而且基本都是四个字,所以我们完全可以每个字单独切分。

// 每个字单独分出来
let chars = word.split('')
let tokens: string[] = []

for (let i = 0; i < chars.length; i++) {
    const char = chars[i];
    // 一个字先塞进去
    tokens.push(char)
    // 循环剩下的字,逐个组合塞进结果集,比如:兴高采烈
    // ['兴', '兴高', '兴高采', '兴高采烈', '高', '高采', '高采烈', '采', '采烈']
    for (let j = (i + 1); j < chars.length; j++) {
        const char2 = chars[j]
        const last = tokens[tokens.length - 1] as string
        tokens.push(last + char2)
    }
}

4. What's Next

如上只是实现了一个非常简单的中文分词搜索,足以应对我的小工具。但如果数据海量或者设备性能很差怎么办呢?

比如移动端,SQLite也有FTS支持,但相较之下手机性能弱,存储空间有限,这就需要开发者充分发挥聪明才智了。

微信客户端技术团队的这两篇文章有兴趣的读者可以参考一下: