0%

博客全文搜索实现

一直用hexo的博客有点疲劳了,而且hexo的本地搜索插件会将数据全部存在本地的xml文档里面,点击搜索的时候需要下载这个文件进行缓存起来,当博客有一定数量后,这个文件会变得比较大,比如我现在的博客也就一百来篇吧,这个xml文件缓存了我所有的博客内容,竟然达到了2M多,我服务器的宽带比较小,每次点击搜索的时候要等15秒等这个xml文件下载后后才可以搜索,实在是差强人意。所以打算自己写博客,虽然博客可以正常展现了,但是搜索功能一直没动,就好比跛着个腿一样不得劲。今天来把这个腿🦵接上。

思考方案

我博客后端使用的数据为mysql,首先想到的是like查询,简单粗暴。但是仔细一想,虽然开发简单,但是查询又费时又费力,时效和精准都不能保证。

可以采用mysql的full text全文索引,配合ngram全文解析器进行模糊搜索?

mysql 5.6版本之后innoDB存储引擎开始支持全文索引,允许在char/varchar/text类型上建立全文索引。

内置的mysql全文语法分析器使用单词之间的空白作为定界符来确定单词的开始和结束位置,但这对于表意语言(类似于中文、韩文、日文)不适用。为了突破这个限制,mysql提供了一个ngram全文分析器来支持表意语言。InnoDBMyISAM引擎都支持ngram全文分析器。

ngram解析器将文本序列标记为n个字符的连续序列,比如全文索引,会被拆解为:

1
2
3
4
n=1: '全', '文', '索', '引' 
n=2: '全文', '文索', '索引'
n=3: '全文索', '文索引'
n=4: '全文索引'

数据库尝试

创建表articles

1
2
3
4
5
6
7
CREATE TABLE `articles` (
`id` varchar(50) NOT NULL,
`title` varchar(100) NOT NULL COMMENT '文章的标题,中文',
`summary` text,
`content` text,
PRIMARY KEY (`id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

我们需要搜索的字段有titlesummarycontent。创建全文索引article_index

1
alter table articles add fulltext index article_index(`title`, `summary`, `content`) with parser ngram;

然后填充一些数据,我们来进行搜索:

1
select * from articles where match(`title`, `summary`) against('验证器' in natural language mode);

然后可以看到结果:

1
2
3
4
5
6
7
8
9
10
11
12
+-------------------------------------+
| name |
+-------------------------------------+
| ng2-validator-form-limit-input |
| ng2-form-cross-validator |
| ng2-limit-input-chinese |
| ng2-reactive-form-update-onblur |
| angular-reactive-form-formcontrol |
| midway-jwt |
| angular-reactive-form-reset-invalid |
+-------------------------------------+
7 rows in set (0.00 sec)

非常好。

结合node使用

那么如何和我们的express服务端融合起来呢?

首先删除前面我们手动添加的索引:

1
drop index index_name on articles;

我们还是继续使用sequelize migration功能来处理数据表,首先使用命令新建一个迁移文件:

1
$ npx sequelize migration:generate --name create-article-index

然后写入我们需要迁移的数据库语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
'use strict';

module.exports = {
up: async (queryInterface, Sequelize) => {
const {TEXT} = Sequelize;
// 添加索引
await queryInterface.addIndex('articles', {
fields: ['title', 'summary''],
name: 'article_index',
type: 'FULLTEXT',
parser: 'ngram',
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex('articles', 'article_index');
}
};

然后更新数据库:

1
$ npx sequelize db:migrate

运行成功后查看数据库,索引是正常被添加了:

1
2
3
4
5
6
7
8
> show create table articles;
| articles | CREATE TABLE `articles` (
`id` varchar(50) NOT NULL,
`title` varchar(100) NOT NULL COMMENT '文章的标题,中文',
`summary` text,
PRIMARY KEY (`id`),
FULLTEXT KEY `article_index` (`title`,`summary`,`content`) /*!50100 WITH PARSER `ngram` */
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

没毛病。

那么我们如何结合到具体接口的代码中呢?

首先我们需要在model中定义上index:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
'use strict';
const { Model, STRING, TEGER, BIGINT, TEXT,} = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class article extends Model {
static associate(models) {
// define association here
}
}
article.init({
id: {
allowNull: false,
primaryKey: true,
type: STRING(50),
},
title: {
allowNull: false,
type: STRING(100),
comment: '文章的标题,中文'
},
summary: {
allowNull: true,
type: TEXT,
},
}, {
sequelize,
timestamps: false,
modelName: 'articles',
indexes: [
{
fields: ['title', 'summary', 'content'],
name: 'article_index',
type: 'FULLTEXT',
parser: 'ngram',
}
]
});
return article;
};

然后在service中有如下应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const db = require('../models/index')
const {Sequelize} = require("sequelize");
const articleModel = db['articles']

class ArticlesService {
static async list(options) {
let where = {}
if (options.text) {
where = Sequelize.literal('MATCH (`title`, `summary`) AGAINST("' + options.text + '" in natural language mode)');
}
return articleModel.findAndCountAll({
where,
order: [
[
'created_at', 'desc'
]
],
limit: options.limit || 10,
offset: options.offset || 0,
})
}
}
module.exports = ArticlesService

这里是有点难度的,我们使用了一个Sequelize的静态方法literal来创建一个代表文字的对象,即不会被转义的对象,这样构造出来的查询语句为:

1
2
SELECT count(*) AS `count` FROM `articles` AS `articles` WHERE MATCH (`title`, `summary`, `content`) AGAINST("验证器" in natural language mode);
SELECT `id`, `title`, `summary`, `created_at` FROM `articles` AS `articles` WHERE MATCH (`title`, `summary`, `content`) AGAINST("验证器" in natural language mode) ORDER BY `articles`.`created_at` DESC LIMIT 0, 10;

在controler中调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

const {SystemError, ParamsError} = require('../../lib/code')
const {Router} = require('express')
const route = Router();
const ArticleService = require('../../services/articles')
const {Pagination} = require("../../lib/pagination");

module.exports = (app) => {
app.use('/articles', route)
route.get('/list', (async (req, res, next) => {
const {text = null} = req.query;

const {offset, limit} = Pagination(req.query);
let data;
try {
data = await ArticleService.list({
text,
offset,
limit,
});
} catch (e) {
return SystemError(e.message, next)
}
return res.json({
code: 0,
data: data,
}).status(200)
}));
}

然后请求,即可得到对应的结果。


sequelize的文档有点难找,最后结合谷歌给鼓捣出来了,可以愉快的来搜索博客了。😁

码字辛苦,打赏个咖啡☕️可好?💘