前言
- 企业中应用比较广泛的是Lucene和Solr,但是需要使用Java
- Elasticsearch提供了Node API
- 本文给出几种Node实现全文搜索应用的简单示例
node相关实现方式
- text-search
- MongoDB 全文搜索教程
- mongoosejs/mongoose-text-search
- 优点:mongdb内置,方便
- 缺点:功能少,效果可能不太理想
- 倒排索引
- MongoDB优化之倒排索引
- 优点:可扩展性好,适合数据量不大的应用
- 缺点:数据量大时不适合
- Elasticsearch
- 搜索引擎选择: Elasticsearch与Solr
- 如何用 Node.js 和 Elasticsearch 构建搜索引擎
- 优点:社区活跃,成熟的企业级方案,适合实时查询和大数据量
- 缺点:数据量小时,没有必要
示例
倒排索引
- 前言
- 如果是自己的小项目,我觉得用这个挺合适的
- 关于分词模块yanyiwu/nodejiebawindows上死活装不上,最后选用了leizongmin/node-segment挺不错
- 原理
- 以写博客为例
- 添加博客时,将博客内容进行分词处理
- 博客a
- 内容:
我很高兴
- 分词结果:
["我", "很", "高兴"]
- 内容:
- 博客b
- 内容:
我也很高兴,他高兴吗
- 分词结果:
["我", "也", "很", "高兴", "他", "高兴", "吗"]
- 内容:
- 博客c
- 内容:
我不知道
- 分词结果:
["我", "不知道"]
- 内容:
- 博客a
- 假如你搜索的内容为
高兴
- aggregate(聚合处理)
- 使用
$unwind
将所有博客按照分词结果拆分 - 使用
$match
找出含有高兴
的博客(可以看到符合条件的博客a有2条,b有1条,c没有) - 使用
$group
按照博客的_id
进行分组,同时使用num: {$sum: 1}
添加一个统计数量的字段(每有1条,会自增1) - 使用
$sort
按照num进行倒序排列
- 使用
- a有1个
高兴
,b有2个,c没有,则b排在a前面,c不会出现在结果中 - 搜索结果应为:
["博客b", "博客a"]
示例
Promise = require('bluebird') var express = require('express') var mongoose = require('mongoose') var Segment = require('segment') var app = express() /* * -------------------------------------------------- * 分词配置 */ var segment = new Segment() segment.useDefault()// 使用默认配置 segment.loadSynonymDict('synonym.txt')// 转换同义词,可自定义 segment.loadStopwordDict('stopword.txt')// 去除停止词,可自定义 function doSegment(text) { return segment.doSegment(text, {simple: true, stripPunctuation: true})// 不返回词性且去除标点符号 } /* * -------------------------------------------------- * 数据库 */ mongoose.Promise = Promise mongoose.connect('127.0.0.1:30001/blog') /* * -------------------------------------------------- * 路由 */ // 主页面 app.all('/', (req, res, next) => { var html = ` <div> <h3>添加文章</h3> <span>标题</span> <input class="title" type="text" placeholder="输入标题" autocomplete="off" /> <br /> <span>内容</span> <textarea class="content" placeholder="输入内容" autocomplete="off"></textarea> <button class="add" type="button">添加</button> <h3>查找文章</h3> <span>查询(会匹配标题和内容进行查询)</span> <input class="keys" type="text" placeholder="输入查询内容" autocomplete="off" /> <button class="get" type="button">查找</button> </div> <script> window.onload = function() { // 添加 document.querySelector('.add').onclick = function() { var title = document.querySelector('.title').value, content = document.querySelector('.content').value window.open('/article/add?title='+ title +'&content='+ content) } // 查询 document.querySelector('.get').onclick = function() { var keys = document.querySelector('.keys').value; window.open('/article/get?keys='+ keys) } } </script> ` res.send(html) }) var schemas = schema() // 添加文章(同时会生成Key_article集合用来存储分词结果) app.all('/article/add', (req, res, next) => { var keys = doSegment(req.query.title+' '+req.query.content)// 对标题和内容同时进行分词处理 // 添加文章 schemas.Article.add(req.query) .then((article) => { // 添加相应分词 schemas.Key_article.add({ article: article._id, keys: keys, }) .then((key_article) => { res.send({message: '文章添加成功'}) }) .catch((e) => { res.send({message: e.message}) }) }) .catch((e) => { res.send({message: e.message}) }) }) // 查找文章(根据分词搜索相应的文章_id,再由搜索到的_ids反查出文章) app.all('/article/get', (req, res, next) => { var date = new Date() var keys = doSegment(req.query.keys)// 可接收多个关键词 // 查找相应分词 schemas.Key_article.get(keys, req.query.pageNo, req.query.pageSize)// 默认第一页,默认每页10个 .then((key_article) => { // 按顺序搜索所有传入的_id,最后组装成文章数组进行前端展示 Promise.reduce(key_article[0]?key_article[0].articles:[], (articles, _id) => { return schemas.Article.get(_id) .then((article) => { if(!articles) { articles = [] } articles.push(article) return articles }) }, '').then((articles) => { console.log(new Date() - date)// 计算查询用时,以毫秒记 res.send(articles) }) }) .catch((e) => { res.send({message: e.message}) }) }) // 更新文章(同时会更新Key_article集合中相应的分词) app.all('/article/edit', (req, res, next) => { var keys = doSegment(req.query.title+' '+req.query.content)// 对标题和内容同时进行分词处理 // 更新文章 schemas.Article.edit(req.query._id, req.query) .then((article) => { // 更新相应分词 schemas.Key_article.edit(req.query._id, keys) .then((key_article) => { res.send({message: '文章更新成功'}) }) .catch((e) => { res.send({message: e.message}) }) }) .catch((e) => { res.send({message: e.message}) }) }) // 删除文章(同时会删除Key_article集合中相应的分词) app.all('/article/del', (req, res, next) => { // 删除文章 schemas.Article.del(req.query._id) .then((article) => { // 删除相应分词 schemas.Key_article.del(req.query._id) .then((key_article) => { res.send({message: '文章删除成功'}) }) .catch((e) => { res.send({message: e.message}) }) }) .catch((e) => { res.send({message: e.message}) }) }) /* * -------------------------------------------------- * schema */ function schema() { var Schema = mongoose.Schema // 文章schema var articleSchema = new Schema({ title: String, content: String, }, { versionKey: false, }) articleSchema.statics = { add(obj) { return this .create(obj) }, get(_id) { return this .findById(_id) }, edit(_id, obj) { return this .update({_id: _id}, obj) }, del(_id, obj) { return this .remove({_id: _id}) }, } // 分词schema var key_articleSchema = new Schema({ article: Schema.Types.ObjectId, keys: { type: Array, }, }, { versionKey: false, }) key_articleSchema.statics = { add(obj) { return this .create(obj) }, get(keys, pageNo, pageSize) { pageNo = pageNo || 1// 默认第一页 pageSize = pageSize || 10// 默认每页10个 return this .aggregate([ {$unwind : '$keys'},// 通过分词拆分所有文章 {$match: {keys: {$in: keys}}},// 匹配符合搜索内容的文章 {$group: {_id: '$article', num: {$sum: 1}, arr: {$addToSet: 1}}},// 根据文章_id进行重组,并记录相同文章的次数num,arr只是为了给最后重组使用的,没有其他作用 {$sort: {num: -1}},// 倒序排列所有符合的文章 {$skip: (pageNo-1)*(pageSize)},// 跳过文章,做分页,默认第一页,每页10个 {$limit: pageSize},// 限制文章,做分页,默认第一页,每页10个 //{$lookup: {from: 'articles', localField: '_id', foreignField: '_id', as: 'article'}}// 这里也可以采用$lookup的方式来引入articles的原文档内容,但是结构嵌套过深,可以继续使用$project映射出合适的结构 {$group: {_id: '$arr', articles: {$push: '$_id'}, total: {$sum: 1}}},// 将结果重组成一个_id数组为articles,便于顺序查询,且total字段方便分页 ]) }, edit(_id, keys) { return this .update({article: _id}, {keys: keys}) }, del(_id) { return this .remove({article: _id}) }, } return { Article: mongoose.model('Article', articleSchema), Key_article: mongoose.model('Key_article', key_articleSchema), } } app.listen(3001, function() { console.log('server...') })
图解
添加3篇博客
数据库
查询结果(查询内容为
椅子
,分词结果为["椅子"]
,所以包含椅子
的文章是符合的)
效率分析(本计时不包含渲染数据成前端dom结构的时间):
每篇文章字数 | 总文章篇数 | 搜索内容分词后的关键词数 | 每页显示篇数 | 没有符合文章的用时 | 有符合文章的用时
1500字 | 1000篇 | 3个 | 10篇 | 600-650毫秒 | 650-750毫秒
1500字 | 2000篇 | 3个 | 10篇 | 1200-1300毫秒 | 1300-1350毫秒
1500字 | 3000篇 | 3个 | 10篇 | 1800-1900毫秒 | 1950-2050毫秒
Elasticsearch
- 前言
- 环境:window 10
- 准备工作
- 安装Java(提供运行环境),根据你电脑的32或64位数,选择合适的版本,否则即使安装Java成功,点击
elasticsearch.bat
也运行不起来 - 安装Elasticsearch(提供搜索引擎服务),可以把
elasticsearch.bat
所在路径配置进环境变量,这样命令行里面直接输入elasticsearch.bat
即可运行成功
- 安装mobz/elasticsearch-head(提供可视化界面),我选择了最简单的安装方式,安装谷歌扩展插件
- 安装elastic/elasticsearch-js(提供Node交互功能)
- 安装mongoosastic/mongoosastic(封装了elasticsearch-js的api,可以配合mongoose方便的增删改查elasticsearch中的索引)
- 安装Java(提供运行环境),根据你电脑的32或64位数,选择合适的版本,否则即使安装Java成功,点击
- 示例
Promise = require('bluebird')
var express = require('express')
var mongoose = require('mongoose')
var mongoosastic = require('mongoosastic')
var app = express()
/*
* --------------------------------------------------
* 数据库
*/
mongoose.Promise = Promise
mongoose.connect('127.0.0.1:30001/blog')
/*
* --------------------------------------------------
* 路由
*/
// 主页面
app.all('/', (req, res, next) => {
var html = `
<div>
<h3>添加文章</h3>
<span>标题</span>
<input class="title" type="text" placeholder="输入标题" autocomplete="off" />
<br />
<span>内容</span>
<textarea class="content" placeholder="输入内容" autocomplete="off"></textarea>
<button class="add" type="button">添加</button>
<h3>查找文章</h3>
<span>查询(会匹配标题和内容进行查询)</span>
<input class="keys" type="text" placeholder="输入查询内容" autocomplete="off" />
<button class="get" type="button">查找</button>
</div>
<script> window.onload = function() { // 添加 document.querySelector('.add').onclick = function() { var title = document.querySelector('.title').value, content = document.querySelector('.content').value window.open('/article/add?title='+ title +'&content='+ content) } // 查询 document.querySelector('.get').onclick = function() { var keys = document.querySelector('.keys').value; window.open('/article/get?keys='+ keys) } } </script>
`
res.send(html)
})
var Article = schema()
// 添加文章
app.all('/article/add', (req, res, next) => {
Article.add(req.query)
.then((article) => {
res.send({message: '文章添加成功'})
})
.catch((e) => {
res.send({message: e.message})
})
})
// 查找文章
app.all('/article/get', (req, res, next) => {
var date = new Date()
Article.get(req.query.keys)
.then((article) => {
console.log(new Date() - date)// 计算查询用时,以毫秒记
res.send(article)
})
.catch((e) => {
res.send({message: e.message})
})
})
// 更新文章
app.all('/article/edit', (req, res, next) => {
Article.edit(req.query._id, req.query)
.then((article) => {
res.send('文章更新成功')
})
.catch((e) => {
res.send({message: e.message})
})
})
// 删除文章
app.all('/article/del', (req, res, next) => {
Article.del(req.query._id)
.then((article) => {
res.send('文章删除成功')
})
.catch((e) => {
res.send({message: e.message})
})
})
/*
* --------------------------------------------------
* schema
*/
function schema() {
var schema = new mongoose.Schema({
title: {
type: String,
es_indexed: true,// 建立索引到Elasticsearch,如果都不写此参数,那么mongoosastic默认给全部字段建索引到Elasticsearch,很浪费
},
content: {
type: String,
es_indexed: true,
},
visitors: {
type: Number,
default: 0,
},
}, {
versionKey: false,
})
schema.statics = {
// 添加文档时,会自动向Elasticsearch建立索引
add(obj) {
return this
.create(obj)
},
get(keys) {
// mongoosastic的search方法不支持promise方式,这里让它支持
return new Promise((resolve, reject) => {
this
.search({
query_string: {
query: keys,
}
}, (e, article) => {
if(e) {
reject(e)
}else {
resolve(article)
}
})
})
},
// 如果有多个文档需要更新,只能通过多次调用此方法了
edit(_id, obj) {
// mongoosastic必须使用findOneAndUpdate进行更新,这样数据库和Elasticsearch中的索引都会一起改,如果使用update则Elasticsearch中的索引不会被修改
return this
.findOneAndUpdate({_id: _id}, obj, {new: true})// new: true -> 返回修改后的数据,必须传入,否则Elasticsearch中的索引不会被修改
},
// 如果有多个文档需要删除,只能通过多次调用此方法了
del(_id) {
// mongoosastic必须使用findOneAndRemove进行删除,这样数据库和Elasticsearch中的索引都会一起删,如果使用remove则Elasticsearch中的索引不会被删除
return this
.findOneAndRemove({_id: _id})
},
}
schema.plugin(mongoosastic)// 加载mongoosastic
return mongoose.model('Article', schema)
}
app.listen(3001, function() {
console.log('server...')
})
添加3篇博客
数据库
查询结果
- 效率分析(本计时不包含渲染数据成前端dom结构的时间):
每篇文章字数 | 总文章篇数 | 搜索内容分词后的关键词数 | 每页显示篇数 | 没有符合文章的用时 | 有符合文章的用时
1500字 | 1000篇 | 3个 | 10篇 | 3-5毫秒 | 5-10毫秒
1500字 | 2000篇 | 3个 | 10篇 | 3-5毫秒 | 5-10毫秒
1500字 | 3000篇 | 3个 | 10篇 | 3-5毫秒 | 5-10毫秒
我是不是测试方法有问题,怎么都一样啊 - 疑问
- 如何重组返回的结果(我们需要更简洁的结果,而不是什么都返回出来)
- 另一种方式richardwilly98/elasticsearch-river-mongodb是不是不建议使用了,官方不是Deprecating Rivers了吗
Head插件——学习Elasticsearch的锋刃利器!
可否完全使用ElasticSearch代替数据库存储?
单机搭建elasticsearch和mongodb river的数据同步
MongoDB-Elasticsearch 实时数据导入