node全文检索研究

前言

  • 企业中应用比较广泛的是Lucene和Solr,但是需要使用Java
  • Elasticsearch提供了Node API
  • 本文给出几种Node实现全文搜索应用的简单示例

node相关实现方式

示例

倒排索引

  • 前言
  • 原理
    • 以写博客为例
    • 添加博客时,将博客内容进行分词处理
      • 博客a
        • 内容:我很高兴
        • 分词结果:["我", "很", "高兴"]
      • 博客b
        • 内容:我也很高兴,他高兴吗
        • 分词结果:["我", "也", "很", "高兴", "他", "高兴", "吗"]
      • 博客c
        • 内容:我不知道
        • 分词结果:["我", "不知道"]
    • 假如你搜索的内容为高兴
    • 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毫秒
    这里写图片描述

中英文停止词表(stopword)

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中的索引)
  • 示例
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) {
            // mongoosasticsearch方法不支持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中的索引都会一起改,如果使用updateElasticsearch中的索引不会被修改
            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毫秒
    这里写图片描述
    我是不是测试方法有问题,怎么都一样啊
  • 疑问

Head插件——学习Elasticsearch的锋刃利器!
可否完全使用ElasticSearch代替数据库存储?
单机搭建elasticsearch和mongodb river的数据同步
MongoDB-Elasticsearch 实时数据导入

  • 4
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值