使用Docker和Elasticsearch搭建全文本搜索引擎应用(下)

5. 搜索

Elasticsearch已经灌入100本书籍数据(大约230000段落),本节做一些搜索操作。

5.0 简单http查询

首先,使用localhost:9200/library/ ... retty , 这里使用全文本查询关键字“Java”,输入应该如下:

{ "took" : 11, "timed_out" : false, "_shards" : { "total" : 5, "successful" : 5, "skipped" : 0, "failed" : 0 }, "hits" : { "total" : 13, "max_score" : 14.259304, "hits" : [ { "_index" : "library", "_type" : "novel", "_id" : "p_GwFWEBaZvLlaAUdQgV", "_score" : 14.259304, "_source" : { "author" : "Charles Darwin", "title" : "On the Origin of Species", "location" : 1080, "text" : "Java, plants of, 375." } }, { "_index" : "library", "_type" : "novel", "_id" : "wfKwFWEBaZvLlaAUkjfk", "_score" : 10.186235, "_source" : { "author" : "Edgar Allan Poe", "title" : "The Works of Edgar Allan Poe", "location" : 827, "text" : "After many years spent in foreign travel, I sailed in the year 18-- , from the port of Batavia, in the rich and populous island of Java, on a voyage to the Archipelago of the Sunda islands. I went as passenger--having no other inducement than a kind of nervous restlessness which haunted me as a fiend." } }, ... ] } }

Elasticsearch HTTP接口对于测试数据是否正常插入很有用,但是如果直接暴露给web应用就很危险。不应该将操作性API功能(例如直接添加和删除文档)直接暴露给应用,而应该写一段简单Node.js API接收客户端请求,(通过私网)转发给Elasticsearch进行查询。

5.1 请求脚本

这一节介绍如何从Node.js应用中向Elasticsearch中发送请求。首先创建新文件:server/search.js。

const { client, index, type } = require('./connection') module.exports = { /** Query ES index for the provided term */ queryTerm (term, offset = 0) { const body = { from: offset, query: { match: { text: { query: term, operator: 'and', fuzziness: 'auto' } } }, highlight: { fields: { text: {} } } } return client.search({ index, type, body }) } }

本模块定义了一个简单的search功能,使用输入信息进行匹配查询。详细字段解释如下:

  1. from:为结果标出页码。每次查询默认返回10个结果;因此指定from为10,可以直接显示10-20的查询结果。

  2. query:具体查询关键词。

  3. operator:具体查询操作;本例中采用“and”操作符,优先显示包含所有查询关键词的结果。

  4. fuzziness:错误拼写修正级别(或者是模糊查询级别),默认是2。数值越高,允许模糊度越高;例如数值1,会对Patricc的查询返回Patrick结果。

  5. highlights:返回额外信息,其中包含HTML格式显示匹配文本信息。 可以调整这些参数看看具体的显示信息,可以查看Elastic Full-Text Query DSL获得更多信息。

6. API

本节提供前端代码访问的HTTP API。

6.0 API Server

修改server/app.js内容如下:

const Koa = require('koa') const Router = require('koa-router') const joi = require('joi') const validate = require('koa-joi-validate') const search = require('./search') const app = new Koa() const router = new Router() // Log each request to the console app.use(async (ctx, next) => { const start = Date.now() await next() const ms = Date.now() - start console.log(`${ctx.method} ${ctx.url} - ${ms}`) }) // Log percolated errors to the console app.on('error', err => { console.error('Server Error', err) }) // Set permissive CORS header app.use(async (ctx, next) => { ctx.set('Access-Control-Allow-Origin', '*') return next() }) // ADD ENDPOINTS HERE const port = process.env.PORT || 3000 app .use(router.routes()) .use(router.allowedMethods()) .listen(port, err => { if (err) throw err console.log(`App Listening on Port ${port}`) })

这段代码导入服务依赖环境,为Koa.js Node API Server设置简单日志和错误处理机制。

6.1 将服务端点与查询链接起来

这一节为Server端添加服务端点,以便暴露给Elasticsearch查询服务。

在server/app.js中//ADD ENDPOINTS HERE 之后插入如下代码:

/** * GET /search * Search for a term in the library */ router.get('/search', async (ctx, next) => { const { term, offset } = ctx.request.query ctx.body = await search.queryTerm(term, offset) } )

用docker-compose up -d --build重启服务端。在浏览器中,调用此服务。例如:localhost:3000/search?term=java。

返回结果看起来应该如下:

{ "took": 242, "timed_out": false, "_shards": { "total": 5, "successful": 5, "skipped": 0, "failed": 0 }, "hits": { "total": 93, "max_score": 13.356944, "hits": [{ "_index": "library", "_type": "novel", "_id": "eHYHJmEBpQg9B4622421", "_score": 13.356944, "_source": { "author": "Charles Darwin", "title": "On the Origin of Species", "location": 1080, "text": "Java, plants of, 375." }, "highlight": { "text": ["Java, plants of, 375."] } }, { "_index": "library", "_type": "novel", "_id": "2HUHJmEBpQg9B462xdNg", "_score": 9.030668, "_source": { "author": "Unknown Author", "title": "The King James Bible", "location": 186, "text": "10:4 And the sons of Javan; Elishah, and Tarshish, Kittim, and Dodanim." }, "highlight": { "text": ["10:4 And the sons of Javan; Elishah, and Tarshish, Kittim, and Dodanim."] } } ... ] } }

6.2 输入验证

此时服务端还是很脆弱,下面对输入参数进行检查,对无效或者缺失的输入进行甄别,并返回错误。

我们使用Joi和Koa-Joi-Validate库进行这种类型的验证:

/** * GET /search * Search for a term in the library * Query Params - * term: string under 60 characters * offset: positive integer */ router.get('/search', validate({ query: { term: joi.string().max(60).required(), offset: joi.number().integer().min(0).default(0) } }), async (ctx, next) => { const { term, offset } = ctx.request.query ctx.body = await search.queryTerm(term, offset) } )

现在如果重启服务端,并做一个缺失参数查询(localhost:3000/search),将会返回HTTP 400错误,例如:Invalid URL Query - child "term" fails because ["term" is required]。

可以用docker-compose logs -f api 查看日志。

7. 前端应用

/search服务端硬件可以了,本节写一段简单前端web应用测试API。

7.0 Vue.js

本节使用Vue.js来开发前端。创建一个新文件/public/app.js:

const vm = new Vue ({ el: '#vue-instance', data () { return { baseUrl: 'localhost:3000', // API url searchTerm: 'Hello World', // Default search term searchDebounce: null, // Timeout for search bar debounce searchResults: [], // Displayed search results numHits: null, // Total search results found searchOffset: 0, // Search result pagination offset selectedParagraph: null, // Selected paragraph object bookOffset: 0, // Offset for book paragraphs being displayed paragraphs: [] // Paragraphs being displayed in book preview window } }, async created () { this.searchResults = await this.search() // Search for default term }, methods: { /** Debounce search input by 100 ms */ onSearchInput () { clearTimeout(this.searchDebounce) this.searchDebounce = setTimeout(async () => { this.searchOffset = 0 this.searchResults = await this.search() }, 100) }, /** Call API to search for inputted term */ async search () { const response = await axios.get(`${this.baseUrl}/search`, { params: { term: this.searchTerm, offset: this.searchOffset } }) this.numHits = response.data.hits.total return response.data.hits.hits }, /** Get next page of search results */ async nextResultsPage () { if (this.numHits > 10) { this.searchOffset += 10 if (this.searchOffset + 10 > this.numHits) { this.searchOffset = this.numHits - 10} this.searchResults = await this.search() document.documentElement.scrollTop = 0 } }, /** Get previous page of search results */ async prevResultsPage () { this.searchOffset -= 10 if (this.searchOffset < 0) { this.searchOffset = 0 } this.searchResults = await this.search() document.documentElement.scrollTop = 0 } } })

应用特别简单,只是定义一些共享数据属性,添加一个接收方法以及为结果分页的功能;搜索间隔设置为100ms,以防API被频繁调用。

解释Vue.js如何工作超出本文的范围,如果想了解相关内容,可以查看Vue.js官方文档.

7.1 HTML

将/public/index.html用如下内容代替:

    Elastic Library         
{{ numHits }} Hits
Displaying Results {{ searchOffset }} - {{ searchOffset + 9 }}
{{ hit._source.title }} - {{ hit._source.author }}
Location {{ hit._source.location }}

7.3 CSS

添加一个新文件:/public/styles.css:

body { font-family: 'EB Garamond', serif; } .mui-textfield > input, .mui-btn, .mui--text-subhead, .mui-panel > .mui--text-headline { font-family: 'Open Sans', sans-serif; } .all-caps { text-transform: uppercase; } .app-container { padding: 16px; } .search-results em { font-weight: bold; } .book-modal > button { width: 100%; } .search-results .mui-divider { margin: 14px 0; } .search-results { display: flex; flex-direction: row; flex-wrap: wrap; justify-content: space-around; } .search-results > div { flex-basis: 45%; box-sizing: border-box; cursor: pointer; } @media (max-width: 600px) { .search-results > div { flex-basis: 100%; } } .paragraphs-container { max-width: 800px; margin: 0 auto; margin-bottom: 48px; } .paragraphs-container .mui--text-body1, .paragraphs-container .mui--text-body2 { font-size: 1.8rem; line-height: 35px; } .book-modal { width: 100%; height: 100%; padding: 40px 10%; box-sizing: border-box; margin: 0 auto; background-color: white; overflow-y: scroll; position: fixed; top: 0; left: 0; } .pagination-panel { display: flex; justify-content: space-between; } .title-row { display: flex; justify-content: space-between; align-items: flex-end; } @media (max-width: 600px) { .title-row{ flex-direction: column; text-align: center; align-items: center } } .locations-label { text-align: center; margin: 8px; } .modal-footer { position: fixed; bottom: 0; left: 0; width: 100%; display: flex; justify-content: space-around; background: white; }

7.3 测试

打开localhost:8080,应该能够看到一个简单分页返回结果。此时可以键入一些关键词进行查询测试。

这一步不需要重新运行docker-compose up命令使修改生效。本地public目录直接挂载在Ngnix服务器容器中,因此前端本地系统数据改变直接反应在容器化应用中。

如果点任一个输出,没什么效果,意味着还有一些功能需要添加进应用中。

8. 页面检查

最好点击任何一个输出,可以查出上下文来自哪本书。

8.0 添加Elasticsearch查询

首先,需要定义一个从给定书中获得段落的简单查询。在server/search.js下的module.exports中加入如下内容:

/** Get the specified range of paragraphs from a book */ getParagraphs (bookTitle, startLocation, endLocation) { const filter = [ { term: { title: bookTitle } }, { range: { location: { gte: startLocation, lte: endLocation } } } ] const body = { size: endLocation - startLocation, sort: { location: 'asc' }, query: { bool: { filter } } } return client.search({ index, type, body }) }

此功能将返回给定书排序后的段落。

8.1 添加API服务端口

本节将把上节功能链接到API服务端口。在server/app.js中原来的/search服务端口下添加如下内容:

/** * GET /paragraphs * Get a range of paragraphs from the specified book * Query Params - * bookTitle: string under 256 characters * start: positive integer * end: positive integer greater than start */ router.get('/paragraphs', validate({ query: { bookTitle: joi.string().max(256).required(), start: joi.number().integer().min(0).default(0), end: joi.number().integer().greater(joi.ref('start')).default(10) } }), async (ctx, next) => { const { bookTitle, start, end } = ctx.request.query ctx.body = await search.getParagraphs(bookTitle, start, end) } )

8.2 添加UI界面

本节添加前端查询功能,并显示书中包含查询内容的整页信息。在/public/app.js methods功能块中添加如下内容:

/** Call the API to get current page of paragraphs */ async getParagraphs (bookTitle, offset) { try { this.bookOffset = offset const start = this.bookOffset const end = this.bookOffset + 10 const response = await axios.get(`${this.baseUrl}/paragraphs`, { params: { bookTitle, start, end } }) return response.data.hits.hits } catch (err) { console.error(err) } }, /** Get next page (next 10 paragraphs) of selected book */ async nextBookPage () { this.$refs.bookModal.scrollTop = 0 this.paragraphs = await this.getParagraphs(this.selectedParagraph._source.title, this.bookOffset + 10) }, /** Get previous page (previous 10 paragraphs) of selected book */ async prevBookPage () { this.$refs.bookModal.scrollTop = 0 this.paragraphs = await this.getParagraphs(this.selectedParagraph._source.title, this.bookOffset - 10) }, /** Display paragraphs from selected book in modal window */ async showBookModal (searchHit) { try { document.body.style.overflow = 'hidden' this.selectedParagraph = searchHit this.paragraphs = await this.getParagraphs(searchHit._source.title, searchHit._source.location - 5) } catch (err) { console.error(err) } }, /** Close the book detail modal */ closeBookModal () { document.body.style.overflow = 'auto' this.selectedParagraph = null }

以上五个功能块提供在书中下载和分页(每页显示10段)逻辑操作。

在/public/index.html 中的分界符下加入显示书页的UI代码如下:

 
{{ selectedParagraph._source.title }}
{{ selectedParagraph._source.author }}

Locations {{ bookOffset - 5 }} to {{ bookOffset + 5 }}

{{ paragraph._source.text }}
{{ paragraph._source.text }}

重启应用服务器(docker-compose up -d --build),打开localhost:8080。此时如果点击搜索结果,就可以查询段落上下文。如果对查到结果感兴趣,甚至可以从查询处一直读下去。

恭喜!!到这一步主体框架已经搭建完毕。以上所有代码都可以从这里获得。

9. Elasticsearch的不足

9.0 资源消耗

Elasticsearch是计算资源消耗的应用。官方建议至少运行在64G以上内存的设备上,不建议少于8GB内存。Elasticsearch是一个内存数据库,因此查询速度会很快,但是也会消耗大量内存。生产中,强烈推荐运行Elasticsearch集群提供高可用性、自动分片和数据冗余功能。

我在一个1.7GB的云设备上(每月15美金)运行以上示例(search.patriktriest.com),这些资源仅是能够运行Elasticsearch节点。有时整个节点会在初始装载数据时候hang住。从我的经验看,Elasticsearch比传统的PostgreSQL和MongoDB跟消耗资源,如果需要提供理想服务效果,成本可能会很贵。

9.1 数据库之间的同步

对许多应用,将数据存放在Elasticsearch中并不是理想的选择。建议将ES作为交易型数据库,但是因为ES不兼容ACID标准(当扩展系统导入数据时,可能造成写入操作丢失的问题),所以也不推荐。很多场景下,ES承担着很特殊的角色,例如全文本查询,这种场景下需要某些数据从主数据库复制到Elasticsearch数据库中。

例如,假设我们需要将用户存放到PostgreSQL表中,但是使用ES承担用户查询功能。如果一个用户,“Albert”,决定修改名字为“Al”,就需要在主PostgreSQL库和ES集群中同时进行修改。

这个操作有些复杂,依赖现有的软件栈。有许多开源资源可选,既有监控MongoDB操作日志并自动同步删除数据到ES的进程,到创建客制化基于PSQL索引自动与ES通讯的PostgreSQL插件。

如果之前提到的选项都无效,可以在服务端代码中根据数据库变化手动更新Elasticsearch索引。但是我认为这种选择并不是最佳的,因为使用客制化商业逻辑保持ES同步很复杂,而且有可能会引入很多bugs。

Elasticsearch与主数据库同步需求,与其说是ES的弱点,不如说是架构复杂造成的;给应用添加一个专用搜索引擎是一件值得考虑的事情,但是要折衷考虑带来的问题。

结论

全文本搜索对现代应用来说是一个很重要的功能,同时也是很难完成的功能。Elasticsearch则提供了实现快速和客制化搜索的实现方式,但是也有其它替代选项。Apache Solr是另外一个基于Apache Lucene(Elasticsearch核心也采用同样的库)实现的开源类似实现。Algolia则是最近很活跃的search-as-a-service模式web平台,对初学者来说更加容易上手(缺点是客制化不强,而且后期投入可能很大)。

“search-bar”模式功能远不仅是Elasticsearch的唯一使用场景。ES也是一个日志存储和分析常用工具,一般用于ELK架构(Elasticsearch,Logstash,Kibana)。ES实现的灵活全文本搜索对数据科学家任务也很有用,例如修改、规范化数据集拼写或者搜索数据集。

如下是有关本项目的考虑:

  1. 在应用中添加更多喜爱的书,创建自己私有库搜索引擎。

  2. 通过索引Google Scholar论文,创建一个防抄袭引擎。

  3. 通过索引字典中单词到ES中,建立拼写检查应用。

  4. 通过加载Common Crawl Corpus到ES(注意,有50亿页内容,是一个非常巨大数据集),建立自己的与谷歌竞争的互联网搜索引擎。

  5. 在新闻业中使用Elasticsearch:在例如Panama论文和Paradise论文集中搜索特点名称和词条。

相关资讯: