杨峰 译 分布式实验室
给应用添加快速、灵活的全文本搜索对谁都不是一件容易的事情。许多主流数据库,如PostgreSQL和MongoDB,受限于查询和索引结构,只提供基础文本搜索能力。为了提供高效全文本搜索一般都需要一个独立的数据库。Elasticsearch正是这样一个能够提供灵活性和快速全文本搜索能力的开源数据库。
本文采用Docker来设置依赖环境。Docker是目前最常见的容器化引擎,Uber、Spotify、ADP和Paypal都是用这个技术,它的优势在于与操作系统无关,可以运行在Windows、macOS和Linux之上——写操作指南很容易。如果从来没有用过Docker也没问题,本文会详细提供配置文件。
本文也分别采用Node.js采(用Koa框架)和Vue.js创建搜索API和前端Web应用。
1. 什么是Elasticsearch
快速:查询结果应该实时返回,提高用户体验。
灵活:根据不同数据和使用场景,可以调整搜索过程。
最佳建议:对于输入错误,返回最可能的结果。
全文本:除了搜索关键词和标签之外,希望能够搜索到所有匹配文本。
Wikipedia使用Elasticsearch提供全文检索,提供高亮显示、search-as-you-type和did-you-mean建议等功能。
Guardian使用Elasticsearch将访问者社交数据整合反馈给作者。
Stack Overflow将位置信息和more-like-this功能与全文本检索整合提供相关问题和答案。
GitHub使用Elasticsearch在一千三百亿行代码中进行搜索。
本质上,Elasticsearch通过使用反向索引提供快速和灵活的全文本搜索。
“索引”是一种在数据库中提供快速查询和返回的数据结构。数据库一般将数据域和相应表位置生成索引信息。将索引信息存放在一个可搜索的数据结构中(一般是B-Tree),数据库可以为优化数据请求获得线性搜索响应(例如“Find the row with ID=5”)。
可以把数据库索引看做学校图书馆卡片分类系统,只要知道书名和作者,就可以准确告诉查找内容的入口。数据库表一般都有多个索引表,可以加速查询(例如,对name列的索引可以极大加速对特定name的查询)。
而反向索引工作原理与此完全不同。每行(或者每个文档)的内容被分拆,每个入口(本案例中是每个单词)反向指向包含它的文档。
反向索引数据结构对查询“football”位于哪个文档这种查询非常迅速。Elasticsearch使用内存优化反向索引,可以实现强大和客制化全文本检索任务。
2. 项目安装
2.0 Docker
本文使用Docker作为项目开发环境。Docker是一个容器化引擎,应用可以运行在隔离环境中,不依赖于本地操作系统和开发环境。因为可以带来巨大灵活性和客制化,许多互联网公司应用都已经运行在容器中。
对于作者来说,Docker可以提供平台一致性安装环境(可以运行在Windows、macOS和Linux系统)。一般Node.js、Elasticsearch和Nginx都需要不同安装步骤,如果运行在Docker环境中只需要定义好不同配置文件,就可以运行在任何Docker环境。另外,由于应用各自运行在隔离容器中,与本地宿主机关系很小,因此类似于“但是我这可以运行啊”这种排错问题就很少会出现。
2.1 安装Docker和Docker-Compose
本项目只需要Docker和Docker-Compose环境。后者是Docker官方工具,在单一应用栈中编排定义多个容器配置。
安装Docker——https://docs.docker.com/engine/installation/
安装Docker Compose——https://docs.docker.com/compose/install/
创建一个项目根目录(例如guttenberg_search),在其下定义两个子目录:
/public——为前端 Vue.js webapp存放数据。
/server——服务器端Node.js 源文件。
下一步,创建docker-compose.yml文件,定义应用栈中每个容器的配置:
gs-api——Node.js 容器后端应用逻辑。
gs-frontend——为前端webapp提供服务的Nginx容器。
gs-search——存储搜索数据的Elasticsearch容器。
此文件定义应用栈,而不需要在本地宿主机安装Elasticsearch、Node.js、或者Nginx。每个容器都对宿主机开放相应端口,以便从宿主机访问和排错Node API,Elasticsearch实例和前端应用。
2.4 添加Dockerfile
本文使用官方的Nginx和Elasticsearch镜像,但是需要重新为Node.js创建自己的镜像。
在应用根目录定义一个简单的Dockerfile配置文件。
此Docker配置文件中将应用源码拷贝进来,安装了NPM依赖包,形成了自己的镜像。同样需要添加一个.dockerignore文件,避免不需要的文件被拷入。
注意:不需要将node_modules拷入,因为我们后续要用npm install来安装这些进程。如果拷贝node_modules到容器中容易引起兼容性问题。例如在macOS上安装bcrypt包,如果将此module拷入Ubuntu容器就会引起操作系统不匹配问题。
2.5 添加基础文件
测试配置文件前,还需要往应用目录拷入一下占位文件。在public/index.html中加入如下基础配置信息:
<html><body>Hello World From The Frontend Container</body></html>
下一步,在server/app.js中加入Node.js的应用文件。
最后,加入package.json节点配置文件:
此文件定义应用开始命令和Node.js依赖包。
注意:不需要特意运行npm install,容器创建时候会自动安装依赖包。
2.6 开始测试
都准备好了,接下来可以测试了。从项目根目录开始,运行docker-compose,会自动创建Node.js容器应用。
运行docker-compose up启动应用:
注意:这一步可能会运行时间比较长,因为Docker可能需要下载基础镜像。以后执行速度会很快,因为本地已经有了基础镜像。
访问localhost:8080,应该看到如下图输出“hello world”。
访问localhost:3000验证服务器端返回“hello world”信息。
最后,访问localhost:9200确认Elasticsearch是否运行,如果正常,应该返回如下输出:
如果所有URL输出都正常,恭喜,整个应用框架可以正常工作,下面开始进入真正有趣的部分了。
3. 接入Elasticsearch
3.0 加入ES链接模块
在server/connection.js中加入如下初始化代码:
下面用docker-compose来重建更改过的应用。之后运行docker-compose up -d重新启动后台进程。
应用启动后,命令行运行docker exec gs-api "node" "server/connection.js",在容器中运行脚本,应该可以看到如下输出:
如果一切顺利,就可以把最后一行的checkConnection()调用删掉,因为最终应用会从connection模块之外调用它。
3.1 给Reset Index添加Helper功能
在server/connection.js文件checkConnection之下添加如下内容, 以便更加方便重置索引。
3.2 添加Book Schema
紧接resetIndex之后,添加如下功能:
此处为书目索引定义了mapping(映射)。Elasticsearch索引类似于SQL的表或者MongoDB的connection。通过mapping我们可以定义文档每个域和数据类型。Elasticsearch是schema-less,因此技术上说不需要添加mapping,但是通过mapping可以更好控制数据处理方式。
例如,有两个关键词域,分别是“titile”和“author”,文本定为“text”域。这样定义搜索引擎会有完全不同的动作:搜索中,引擎会在text域中查找可能匹配项,而在关键词域则是精确匹配。看起来差别不大,但却对搜索行为和搜索速度有很大影响。
在文件最后输出功能和属性,可以被其它模块访问。
4.1 下载书籍数据
本文的数据可以从以下网站下载:https://cdn.patricktriest.com/data/books.zip,之后解压到项目根目录下的books/ 子目录下。
也可以用命令行实现以上操作:
4.2 预览书籍
打开一本书,例如219-0.txt。书籍以公开访问license开始,跟着是书名、作者、发行日期、语言以及字符编码。
随后是声明信息:*** START OF THIS PROJECT GUTENBERG EBOOK HEART OF DARKNESS ***,紧接着就是书的实际内容。书的最后会发现书籍结束声明:*** END OF THIS PROJECT GUTENBERG EBOOK HEART OF DARKNESS ***,紧跟着是更加详细的书籍license。
下一步将用编程方法从书中提取元数据,并且从* * *之间将书籍内容抽取出来。
4.3 读取数据目录
本节写一段脚本读取书籍内容添加到Elasticsearch中,脚本存放在server/load_data.js 中。
首先,获得books目录下所有文件列表。
运行docker-compose -d --build重建镜像更新应用。
运行docker exec gs-api "node" "server/load_data.js"调用包含load_data脚本应用,应该看到Elasticsearch输出如下。随后,脚本会因为错误退出,原因是调用了一本目前还不存在的helper函数(parseBookFile)。
4.4 读取数据文件
创建server/load_data.js文件,读取每本书元数据和内容:
此函数执行以下功能:
从文件系Ϲ��,ä��统中读入文件
使用正则表达式抽取书名和作者
通过定位***,来抽取书中内容
解析出段落
清洗数据,移除空行
运行docker-compose up -d --build和docker exec gs-api "node" "server/load_data.js" ,输出如下:
到这步,脚本顺利分理出书名和作者,脚本还会因为同样问题出错(调用还未定义的函数)。
4.5 在ES中索引数据文件
最后一步在load_data.js中添加insertBookData函数,将上一节中提取数据插入Elasticsearch索引中。
此函数索引书籍段落,包括作者、书名和段落元数据信息。使用bulk操作插入段落,比分别索引段落效率高很多。
批量bulk索引这些段落可以使本应用运行在低配电脑上(我只有1.7G内存),如果你有高配电脑(大于4G内容),也许不用考虑批量bulk操作。
运行docker-compose up -d --build 和 docker exec gs-api "node" "server/load_data.js" 输出如下:
5. 搜索
5.0 简单http查询
首先,使用http://localhost:9200/library/_search?q=text:Java&pretty , 这里使用全文本查询关键字“Java”,输入应该如下:
Elasticsearch HTTP接口对于测试数据是否正常插入很有用,但是如果直接暴露给web应用就很危险。不应该将操作性API功能(例如直接添加和删除文档)直接暴露给应用,而应该写一段简单Node.js API接收客户端请求,(通过私网)转发给Elasticsearch进行查询。
5.1 请求脚本
这一节介绍如何从Node.js应用中向Elasticsearch中发送请求。首先创建新文件:server/search.js。
本模块定义了一个简单的search功能,使用输入信息进行匹配查询。详细字段解释如下:
from:为结果标出页码。每次查询默认返回10个结果;因此指定from为10,可以直接显示10-20的查询结果。
query:具体查询关键词。
operator:具体查询操作;本例中采用“and”操作符,优先显示包含所有查询关键词的结果。
fuzziness:错误拼写修正级别(或者是模糊查询级别),默认是2。数值越高,允许模糊度越高;例如数值1,会对Patricc的查询返回Patrick结果。
highlights:返回额外信息,其中包含HTML格式显示匹配文本信息。
6. API
6.0 API Server
修改server/app.js内容如下:
这段代码导入服务依赖环境,为Koa.js Node API Server设置简单日志和错误处理机制。
6.1 将服务端点与查询链接起来
这一节为Server端添加服务端点,以便暴露给Elasticsearch查询服务。
在server/app.js中//ADD ENDPOINTS HERE 之后插入如下代码:
用docker-compose up -d --build重启服务端。在浏览器中,调用此服务。例如:http://localhost:3000/search?term=java。
返回结果看起来应该如下:
6.2 输入验证
此时服务端还是很脆弱,下面对输入参数进行检查,对无效或者缺失的输入进行甄别,并返回错误。
我们使用Joi和Koa-Joi-Validate库进行这种类型的验证:
现在如果重启服务端,并做一个缺失参数查询(http://localhost:3000/search),将会返回HTTP 400错误,例如:Invalid URL Query - child "term" fails because ["term" is required]。
可以用docker-compose logs -f api 查看日志。
7. 前端应用
7.0 Vue.js
本节使用Vue.js来开发前端。创建一个新文件/public/app.js:
应用特别简单,只是定义一些共享数据属性,添加一个接收方法以及为结果分页的功能;搜索间隔设置为100ms,以防API被频繁调用。
解释Vue.js如何工作超出本文的范围,如果想了解相关内容,可以查看Vue.js官方文档[2]。
7.1 HTML
将/public/index.html用如下内容代替:
7.2 CSS
添加一个新文件:/public/styles.css:
7.3 测试
打开localhost:8080,应该能够看到一个简单分页返回结果。此时可以键入一些关键词进行查询测试。
这一步不需要重新运行docker-compose up命令使修改生效。本地public目录直接挂载在Ngnix服务器容器中,因此前端本地系统数据改变直接反应在容器化应用中。
如果点任一个输出,没什么效果,意味着还有一些功能需要添加进应用中。
8. 页面检查
8.0 添加Elasticsearch查询
首先,需要定义一个从给定书中获得段落的简单查询。在server/search.js下的module.exports中加入如下内容
此功能将返回给定书排序后的段落。
8.1 添加API服务端口
本节将把上节功能链接到API服务端口。在server/app.js中原来的/search服务端口下添加如下内容:
8.2 添加UI界面本节添加前端查询功能,并显示书中包含查询内容的整页信息。在/public/app.js methods功能块中添加如下内容:
以上五个功能块提供在书中下载和分页(每页显示10段)逻辑操作。
在/public/index.html 中的分界符下加入显示书页的UI代码如下:
重启应用服务器(docker-compose up -d --build),打开localhost:8080。此时如果点击搜索结果,就可以查询段落上下文。如果对查到结果感兴趣,甚至可以从查询处一直读下去。
恭喜!!到这一步主体框架已经搭建完毕。以上所有代码都可以从这里[3]获得。
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的弱点,不如说是架构复杂造成的;给应用添加一个专用搜索引擎是一件值得考虑的事情,但是要折衷考虑带来的问题。
结论
“search-bar”模式功能远不仅是Elasticsearch的唯一使用场景。ES也是一个日志存储和分析常用工具,一般用于ELK架构(Elasticsearch,Logstash,Kibana)。ES实现的灵活全文本搜索对数据科学家任务也很有用,例如修改、规范化数据集拼写或者搜索数据集。
如下是有关本项目的考虑:
在应用中添加更多喜爱的书,创建自己私有库搜索引擎。
通过索引Google Scholar论文,创建一个防抄袭引擎。
通过索引字典中单词到ES中,建立拼写检查应用。
通过加载Common Crawl Corpus到ES(注意,有50亿页内容,是一个非常巨大数据集),建立自己的与谷歌竞争的互联网搜索引擎。
在新闻业中使用Elasticsearch:在例如Panama论文和Paradise论文集中搜索特点名称和词条。
相关链接:
https://www.elastic.co/guide/en/elasticsearch/reference/current/full-text-queries.html
https://vuejs.org/v2/guide/
https://search.patricktriest.com
https://github.com/triestpa/guttenberg-search