社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  docker

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

金正皓 • 7 年前 • 800 次点击  
作者简介:Patrick Triest是一位全栈工程师,数据爱好者,持续学习者,洁癖编程者。作者github地址为github.com/triestpa,本文地址参见blog.patricktriest.com ... arch/

本文源码可以在GitHub repository 中找到 - github.com/triestpa/gu…

给应用添加快速、灵活的全文本搜索对谁都不是一件容易的事情。许多主流数据库,如PostgreSQL和MongoDB,受限于查询和索引结构,只提供基础文本搜索能力。为了提供高效全文本搜索一般都需要一个独立的数据库。Elasticsearch正是这样一个能够提供灵活性和快速全文本搜索能力的开源数据库。

本文采用Docker来设置依赖环境。Docker是目前最常见的容器化引擎,Uber、Spotify、ADP和Paypal都是用这个技术,它的优势在于操作系统无关,可以运行在Windows、macOS和Linux之上------写操作指南很容易。如果从来没有用过Docker也没问题,本文会详细提供配置文件。

本文也分别采用Node.js(采用Koa框架),和Vue.js创建搜索API和前端web应用。

1 什么是Elasticsearch

现代应用中全文本检索是高请求负载的应用。搜索功能也是比较困难完成的功能(许多大众网站都有subpar功能,但不是返回很慢就是返回结果不准确),大部分原因是因为底层数据库:许多标准关系型数据库只能提供基本字符串匹配功能,而对CONTAINS或者LIKE SQL查询只能提供有限支持。

而本文提供的搜索应用能够提供:
1. 快速:查询结果应该实时返回,提高用户体验。
2. 灵活:根据不同数据和使用场景,可以调整搜索过程​
3. 最佳建议:对于输入错误,返回最可能的结果。
4. 全文本:除了搜索关键词和标签之外,希望能够搜索到所有匹配文本​

实现以上要求的搜索应用,最好采用一个为全文本检索优化的数据库,这也是本文采用Elasticsearch的原因。Elasticsearch是一个用Java开发的,开源的内存数据库,最开始是包含在Apache Lucene库中。以下是一些官方给出的Elasticsearch使用场景:
1. Wikipedia使用Elasticsearch提供全文检索,提供高亮显示、search-as-you-type和did-you-mean建议等功能。
2. Guardian使用Elasticsearch将访问者社交数据​整合反馈给作者。
3.Stack Overflow将位置信息和more-like-this功能与全文本检索整合提供相关问题和答案。
4.Github使用Elasticsearch在一千三百亿行代码中进行搜索。​

1.1 Elasticsearch有什么独特之处

本质上,Elasticsearch通过使用反向索引提供快速和灵活的全文本搜索。

“索引”是一种在数据库中提供快速查询和返回的数据结构。数据库一般将数据域和相应表位置生成索引信息。将索引信息存放在一个可搜索的数据结构中(一般是B-Tree),数据库可以为优化数据请求获得线性搜索响应(例如“Find the row with ID=5”)。

1.png

可以把数据库索引看做学校图书馆卡片分类系统,只要知道书名和作者,就可以准确告诉查找内容的入口。数据库表一般都有多个索引表,可以加速查询(例如,对name列的索引可以极大加速对特定name的查询)。
而反向索引工作原理与此完全不同。每行(或者每个文档)的内容被分拆​,每个入口(本案例中是每个单词)反向指向包含它的文档。

2.jpg

反向索引数据结构对查询“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 - docs.docker.com/engine/inst…
安装Docker Compose - docs.docker.com/compose/ins…

2.2 设置项目安装目录

创建一个项目根目录(例如guttenberg_search),在其下定义两个子目录:
/public - 为前端 Vue.js webapp存放数据.
/server - 服务器端Node.js 源文件

2.3 添加docker-compose配置文件

下一步,创建docker-compose.yml文件,定义应用栈中每个容器的配置:
gs-api - Node.js 容器后端应用逻辑.
gs-frontend - 为前端webapp提供服务的Nginx容器
gs-search- 存储搜索数据的Elasticsearch容器
version: '3'

services:
api: # Node.js App
container_name: gs-api
build: .
ports:
  - "3000:3000" # Expose API port
  - "9229:9229" # Expose Node process debug port (disable in production)
environment: # Set ENV vars
 - NODE_ENV=local
 - ES_HOST=elasticsearch
 - PORT=3000
volumes: # Attach local book data directory
  - ./books:/usr/src/app/books

frontend: # Nginx Server For Frontend App
container_name: gs-frontend
image: nginx
volumes: # Serve local "public" dir
  - ./public:/usr/share/nginx/html
ports:
  - "8080:80" # Forward site to localhost:8080

elasticsearch: # Elasticsearch Instance
container_name: gs-search
image: docker.elastic.co/elasticsearch/elasticsearch:6.1.1
volumes: # Persist ES data in seperate "esdata" volume
  - esdata:/usr/share/elasticsearch/data
environment:
  - bootstrap.memory_lock=true
  - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
  - discovery.type=single-node
ports: # Expose Elasticsearch ports
  - "9300:9300"
  - "9200:9200"

volumes: # Define seperate volume for Elasticsearch data
esdata:


此文件定义应用栈,而不需要在本地宿主机安装Elasticsearch、Node.js、或者Nginx。每个容器都对宿主机开放相应端口,以便从宿主机访问和排错Node API,Elasticsearch实例和前端应用。

#2.4 添加Dockerfile
本文使用官方的Nginx和Elasticsearch映像,但是需要重新为Node.js创建自己的映像。在

应用根目录定义一个简单的Dockerfile配置文件
# Use Node v8.9.0 LTS
FROM node:carbon

Setup app working directory
WORKDIR /usr/src/app

Copy package.json and package-lock.json
COPY package*.json ./

Install app dependencies
RUN npm install

Copy sourcecode
COPY . .

Start app
CMD [ "npm", "start" ]

此Docker配置文件中将应用源码拷贝进来,安装了NPM依赖包,形成了自己的映像。同样需要添加一个.dockerignore文件,避免不需要的文件被拷入。
node_modules/
npm-debug.log
books/
public/


注意:不需要将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的应用文件。
const Koa = require('koa')
const app = new Koa()

app.use(async (ctx, next) => {
ctx.body = 'Hello World From the Backend Container'
})

const port = process.env.PORT || 3000

app.listen(port, err => {
if (err) console.error(err)
console.log(`App Listening on Port ${port}`
})


最后,加入package.json节点配置文件:
{
"name": "guttenberg-search",
"version": "0.0.1",
"description": "Source code for Elasticsearch tutorial using 100 classic open source books.",
"scripts": {
"start": "node --inspect=0.0.0.0:9229 server/app.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/triestpa/guttenberg-search.git"
},
"author": "patrick.triest@gmail.com",
"license": "MIT",
"bugs": {
"url": "https://github.com/triestpa/guttenberg-search/issues"
},
"homepage": "https://github.com/triestpa/guttenberg-search#readme",
"dependencies": {
"elasticsearch": "13.3.1",
"joi": "13.0.1",
"koa": "2.4.1",
"koa-joi-validate": "0.5.1",
"koa-router": "7.2.1"
}
}

此文件定义应用开始命令和Node.js依赖包。
注意:不需要特意运行npm install,容器创建时候会自动安装依赖包

2.6 开始测试

开起来都准备好了,接下来可以测试了。从项目根目录开始,运行docker-compose,会自动创建Node.js容器应用。

3.png

运行docker-compose up启动应用:

4.png

注意:这一步可能会运行时间比较长,因为Docker可能需要下载基础映像。以后执行速度会很快,因为本地已经有了基础映像。
访问localhost:8080,应该看到如下图输出“hello world”

5.png

访问localhost:3000 验证服务器端返回“hello world”信息

6.png

最后,访问localhost:9200确认Elasticsearch是否运行,如果正常,应该返回如下输出:



    
{
"name" : "SLTcfpI",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "iId8e0ZeS_mgh9ALlWQ7-w",
"version" : {
"number" : "6.1.1",
"build_hash" : "bd92e7f",
"build_date" : "2017-12-17T20:23:25.338Z",
"build_snapshot" : false,
"lucene_version" : "7.1.0",
"minimum_wire_compatibility_version" : "5.6.0",
"minimum_index_compatibility_version" : "5.0.0"
},
"tagline" : "You Know, for Search"
}

如果所有URL输出都正常,恭喜,整个应用框架可以正常工作,下面开始进入真正有趣的部分了。

3 接入Elasticsearch

第一步是要接入本地Elasticsearch实例

3.0 加入ES链接模块

在server/connection.js中加入如下初始化代码:
const elasticsearch = require('elasticsearch')

// Core ES variables for this project
const index = 'library'
const type = 'novel'
const port = 9200
const host = process.env.ES_HOST || 'localhost'
const client = new elasticsearch.Client({ host: { host, port } })

/** Check the ES connection status */
async function checkConnection () {
let isConnected = false
while (!isConnected) {
console.log('Connecting to ES')
try {
  const health = await client.cluster.health({})
  console.log(health)
  isConnected = true
} catch (err) {
  console.log('Connection Failed, Retrying...', err)
}
}
}

checkConnection()

下面用docker-compose来重建更改过的应用。之后运行docker-compose up -d重新启动后台进程。
应用启动后,命令行运行docker exec gs-api "node" "server/connection.js",在容器中运行脚本,应该可以看到如下输出:
{ cluster_name: 'docker-cluster',
status: 'yellow',
timed_out: false,
number_of_nodes: 1,
number_of_data_nodes: 1,
active_primary_shards: 1,
active_shards: 1,
relocating_shards: 0,
initializing_shards: 0,
unassigned_shards: 1,
delayed_unassigned_shards: 0,
number_of_pending_tasks: 0,
number_of_in_flight_fetch: 0,
task_max_waiting_in_queue_millis: 0,
active_shards_percent_as_number: 50 }

如果一切顺利,就可以把最后一行的checkConnection()调用删掉,因为最终应用会从connection模块之外调用它。

#3.1 给Reset Index添加Helper功能
在server/connection.js 文件checkConnection之下添加如下内容, 以便更加方便重置索引。
/** Clear the index, recreate it, and add mappings */
async function resetIndex () {
if (await client.indices.exists({ index })) {
await client.indices.delete({ index })
}

await client.indices.create({ index })
await putBookMapping()
}


3.2 添加Book Schema

紧接resetIndex之后,添加如下功能:
/** Add book section schema mapping to ES */
async function putBookMapping () {
const schema = {
title: { type: 'keyword' },
author: { type: 'keyword' },
location: { type: 'integer' },
text: { type: 'text' }
}

return client.indices.putMapping({ index, type, body: { properties: schema } })
}

此处为书目索引定义了mapping(映射)。Elasticsearch索引类似于SQL的表或者MongoDB的connection。通过mapping我们可以定义文档每个域和数据类型。Elasticsearch是schema-less,因此技术上说不需要添加mapping,但是通过mapping可以更好控制数据处理方式。

例如,有两个关键词域,分别是“titile”和“author”,文本定为“text”域。这样定义搜索引擎会有完全不同的动作:搜索中,引擎会在text域中查找可能匹配项,而在关键词域则是精确匹配。看起来差别不大,但却对搜索行为和搜索速度有很大影响。

在文件最后输出功能和属性,可以被其它模块访问。
module.exports = {
client, index, type, checkConnection, resetIndex
}


4 加载源数据

本文使用从Gutenberg项目(一个在线提供免费电子书的应用)提供的数据。包括100本经典书目,例如80天环绕地球,罗密欧与朱丽叶以及奥德赛等。

4.1 下载书籍数据

本文的数据可以从以下网站下载:
cdn.patricktriest.com/data/books.… ,之后解压到项目根目录下的books/ 子目录下。
也可以用命令行实现以上操作:
wget https://cdn.patricktriest.com/data/books.zip
unar books.zip


4.2 预览书籍

打开一本书,例如219-0.txt。书籍以公开访问license开始,跟着是书名、作者,发行日期,语言以及字符编码。
Title: Heart of Darkness
Author: Joseph Conrad
Release Date: February 1995 [EBook #219]
Last Updated: September 7, 2016
Language: English
Character set encoding: UTF-8

随后是声明信息:*** 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目录下所有文件列表
const fs = require('fs')
const path = require('path')
const esConnection = require('./connection')

/** Clear ES index, parse and index all files from the books directory */
async function readAndInsertBooks () {
try {
// Clear previous ES index
await esConnection.resetIndex()

// Read books directory
let files = fs.readdirSync('./books').filter(file => file.slice(-4) === '.txt')
console.log(`Found ${files.length} Files`)

// Read each book file, and index each paragraph in elasticsearch
for (let file of files) {
  console.log(`Reading File - ${file}`)
  const filePath = path.join('./books', file)
  const { title, author, paragraphs } = parseBookFile(filePath)
  await insertBookData(title, author, paragraphs)
}
} catch (err) {
console.error(err)
}
}

readAndInsertBooks()


运行docker-compose -d --build重建映像更新应用。

7.png

运行docker exec gs-api "node" "server/load_data.js"调用包含load_data脚本应用,应该看到Elasticsearch输出如下。随后,脚本会因为错误退出,原因是调用了一本目前还不存在的helper函数(parseBookFile).

8.png

4.4 读取数据文件

创建server/load_data.js文件,读取每本书元数据和内容:
/** Read an individual book text file, and extract the title, author, and paragraphs */
function parseBookFile (filePath) {
// Read text file
const book = fs.readFileSync(filePath, 'utf8')

// Find book title and author
const title = book.match(/^Title:\s(.+)$/m)[1]
const authorMatch = book.match(/^Author:\s(.+)$/m)
const author = (!authorMatch || authorMatch[1].trim() === '') ? 'Unknown Author' : authorMatch[1]

console.log(`Reading Book - ${title} By ${author}`)

// Find Guttenberg metadata header and footer
const startOfBookMatch = book.match(/^\*{3}\s*START OF (THIS|THE) PROJECT GUTENBERG EBOOK.+\*{3}$/m)
const startOfBookIndex = startOfBookMatch.index + startOfBookMatch[0].length
const endOfBookIndex = book.match(/^\*{3}\s*END OF (THIS|THE) PROJECT GUTENBERG EBOOK.+\*{3}$/m).index

// Clean book text and split into array of paragraphs
const paragraphs = book
.slice(startOfBookIndex, endOfBookIndex) // Remove Guttenberg header and footer
.split(/\n\s+\n/g) // Split each paragraph into it's own array entry
.map(line => line.replace(/\r\n/g, ' ').trim()) // Remove paragraph line breaks and whitespace
.map(line => line.replace(/_/g, '')) // Guttenberg uses "_" to signify italics.  We'll remove it, since it makes the raw text look messy.
.filter((line) => (line && line.length !== '')) // Remove empty lines

console.log(`Parsed ${paragraphs.length} Paragraphs\n`)
return { title, author, paragraphs }
}

此函数执行以下功能:
1.从文件系统中读入文件
2.使用正则表达式抽取书名和作者
3.通过定位***,来抽取书中内容
4.解析出段落
5.清洗数据,移除空行
最后返回一个包含书名、作者和段落列表的对象。

运行docker-compose up -d --build和docker exec gs-api "node" "server/load_data.js" ,输出如下:

9.png

到这步,脚本顺利分理出书名和作者,脚本还会因为同样问题出错(调用还未定义的函数)

4.5 在ES中索引数据文件

最后一步在load_data.js中添加insertBookData函数,将上一节中提取数据插入Elasticsearch索引中。
/** Bulk index the book data in Elasticsearch */
async function insertBookData (title, author, paragraphs) {
let bulkOps = [] // Array to store bulk operations

// Add an index operation for each section in the book
for (let i = 0; i < paragraphs.length; i++) {
// Describe action
bulkOps.push({ index: { _index: esConnection.index, _type: esConnection.type } })

// Add document
bulkOps.push({
  author,
  title,
  location: i,
  text: paragraphs[i]
})

if (i > 0 && i % 500 === 0) { // Do bulk insert in 500 paragraph batches
  await esConnection.client.bulk({ body: bulkOps })
  bulkOps = []
  console.log(`Indexed Paragraphs ${i - 499} - ${i}`)
}
}

// Insert remainder of bulk ops array
await esConnection.client.bulk({ body: bulkOps })
console.log(`Indexed Paragraphs ${paragraphs.length - (bulkOps.length / 2)} - ${paragraphs.length}\n\n\n`)
}

此函数索引书籍段落,包括作者、书名和段落元数据信息。使用bulk操作插入段落,比分别索引段落效率高很多。
批量bulk索引这些段落可以使本应用运行在低配电脑上(我只有1.7G内存),如果你有高配电脑(大于4G内容),也许不用考虑批量bulk操作。
运行docker-compose up -d --build 和 docker exec gs-api "node" "server/load_data.js" 输出如下:

10.png

5 搜索

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

5.0 简单http查询

首先,使用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 重启服务端。在浏览器中,调用此服务。例如:http://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": ["<em>Java</em>, 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 <em>Javan</em>; 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)
}
)

现在如果重启服务端,并做一个缺失参数查询(http://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.1 Vue.js

本节使用Vue.js来开发前端。创建一个新文件/public/app.js:
const vm = new Vue ({
el: '#vue-instance',
data () {
return {
  baseUrl: 'http://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.2 HTML

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



    
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Elastic Library</title>
<meta name="description" content="Literary Classic Search Engine.">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link href="https://cdnjs.cloudflare.com/ajax/libs/normalize/7.0.0/normalize.min.css" rel="stylesheet" type="text/css" />
<link href="https://cdn.muicss.com/mui-0.9.20/css/mui.min.css" rel="stylesheet" type="text/css" />
<link href="https://fonts.googleapis.com/css?family=EB+Garamond:400,700|Open+Sans" rel="stylesheet">
<link href="styles.css" rel="stylesheet" />
</head>
<body>
<div class="app-container" id="vue-instance">
<!-- Search Bar Header -->
<div class="mui-panel">
  <div class="mui-textfield">
    <input v-model="searchTerm" type="text" v-on:keyup="onSearchInput()">
    <label>Search</label>
  </div>
</div>

<!-- Search Metadata Card -->
<div class="mui-panel">
  <div class="mui--text-headline">{{ numHits }} Hits</div>
  <div class="mui--text-subhead">Displaying Results {{ searchOffset }} - {{ searchOffset + 9 }}</div>
</div>

<!-- Top Pagination Card -->
<div class="mui-panel pagination-panel">
    <button class="mui-btn mui-btn--flat" v-on:click="prevResultsPage()">Prev Page</button>
    <button class="mui-btn mui-btn--flat" v-on:click="nextResultsPage()">Next Page</button>
</div>

<!-- Search Results Card List -->
<div class="search-results" ref="searchResults">
  <div class="mui-panel" v-for="hit in searchResults" v-on:click="showBookModal(hit)">
    <div class="mui--text-title" v-html="hit.highlight.text[0]"></div>
    <div class="mui-divider"></div>
    <div class="mui--text-subhead">{{ hit._source.title }} - {{ hit._source.author }}</div>
    <div class="mui--text-body2">Location {{ hit._source.location }}</div>
  </div>
</div>

<!-- Bottom Pagination Card -->
<div class="mui-panel pagination-panel">
    <button class="mui-btn mui-btn--flat" v-on:click="prevResultsPage()">Prev Page</button>
    <button class="mui-btn mui-btn--flat" v-on:click="nextResultsPage()">Next Page</button>
</div>

<!-- INSERT BOOK MODAL HERE -->
</div>
<script src="https://cdn.muicss.com/mui-0.9.28/js/mui.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.3/vue.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.17.0/axios.min.js"></script>
<script src="app.js"></script>
</body>
</html>


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.4 测试

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

11.png

这一步不需要重新运行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 中的<!-- INSERT BOOK MODAL HERE --> 分界符下加入显示书页的UI代码如下:
<!-- Book Paragraphs Modal Window -->
<div v-if="selectedParagraph" ref="bookModal" class="book-modal">
  <div class="paragraphs-container">
    <!-- Book Section Metadata -->
    <div class="title-row">
      <div class="mui--text-display2 all-caps">{{ selectedParagraph._source.title }}</div>
      <div class="mui--text-display1">{{ selectedParagraph._source.author }}</div>
    </div>
    <br>
    <div class="mui-divider"></div>
    <div class="mui--text-subhead locations-label">Locations {{ bookOffset - 5 }} to {{ bookOffset + 5 }}</div>
    <div class="mui-divider"></div>
    <br>

    <!-- Book Paragraphs -->
    <div v-for="paragraph in paragraphs">
      <div v-if="paragraph._source.location === selectedParagraph._source.location" class="mui--text-body2">
        <strong>{{ paragraph._source.text }}</strong>
      </div>
      <div v-else class="mui--text-body1">
        {{ paragraph._source.text }}
      </div>
      <br>
    </div>
  </div>

  <!-- Book Pagination Footer -->
  <div class="modal-footer">
    <button class="mui-btn mui-btn--flat" v-on:click="prevBookPage()">Prev Page</button>
    <button class="mui-btn mui-btn--flat" v-on:click="closeBookModal()">Close</button>
    <button class="mui-btn mui-btn--flat" v-on:click="nextBookPage()">Next Page</button>
  </div>
</div>

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

12.png

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

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论文集中搜索特点名称和词条

本文所有代码都是开源的,可以在Github库中找到,具体下载地址。希望本文对大家有所帮助。

今天看啥 - 高品质阅读平台
本文地址:http://www.jintiankansha.me/t/iiBPnAJfo4
Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/7328
 
800 次点击