使用 Docker 和 Elasticsearch 構建一個全文搜尋應用程式
如何在超過 500 萬篇文章的 Wikipedia 上找到與你研究相關的文章?
如何在超過 20 億使用者的 Facebook 中找到你的朋友(並且還拼錯了名字)?
谷歌如何在整個因特網上搜尋你的模糊的、充滿拼寫錯誤的查詢?
在本教程中,我們將帶你探索如何配置我們自己的全文搜尋應用程式(與上述問題中的系統相比,它的複雜度要小很多)。我們的示例應用程式將提供一個 UI 和 API 去從 100 部經典文學(比如,《彼得·潘》 、 《弗蘭肯斯坦》 和 《金銀島》)中搜尋完整的文字。
你可以在這裡(https://search.patricktriest.com)預覽該教程應用的完整版本。
這個應用程式的原始碼是 100% 開源的,可以在 GitHub 倉庫上找到它們 —— https://github.com/triestpa/guttenberg-search 。
在應用程式中新增一個快速靈活的全文搜尋可能是個挑戰。大多數的主流資料庫,比如,PostgreSQL 和 MongoDB,由於受其查詢和索引結構的限制只能提供一個非常基礎的文字搜尋功能。為實現高質量的全文搜尋,通常的最佳選擇是單獨的資料儲存。Elasticsearch 是一個開源資料儲存的領導者,它專門為執行靈活而快速的全文搜尋進行了最佳化。
我們將使用 Docker 去配置我們自己的專案環境和依賴。Docker 是一個容器化引擎,它被 Uber、Spotify、ADP 以及 Paypal 使用。構建容器化應用的一個主要優勢是,專案的設定在 Windows、macOS、以及 Linux 上都是相同的 —— 這使我寫這個教程快速又簡單。如果你還沒有使用過 Docker,不用擔心,我們接下來將經歷完整的專案配置。
我也會使用 Node.js (使用 Koa 框架)和 Vue.js,用它們分別去構建我們自己的搜尋 API 和前端 Web 應用程式。
1 - Elasticsearch 是什麼?
全文搜尋在現代應用程式中是一個有大量需求的特性。搜尋也可能是最難的一項特性 —— 許多流行的網站的搜尋功能都不合格,要麼返回結果太慢,要麼找不到精確的結果。通常,這種情況是被底層的資料庫所侷限:大多數標準的關係型資料庫侷限於基本的 CONTAINS
或 LIKE
SQL 查詢上,它僅提供最基本的字串匹配功能。
我們的搜尋應用程式將具備:
- 快速 - 搜尋結果將快速返回,為使用者提供一個良好的體驗。
- 靈活 - 我們希望能夠去修改搜尋如何執行的方式,這是為了便於在不同的資料庫和使用者場景下進行最佳化。
- 容錯 - 如果所搜尋的內容有拼寫錯誤,我們將仍然會返回相關的結果,而這個結果可能正是使用者希望去搜尋的結果。
- 全文 - 我們不想限制我們的搜尋只能與指定的關鍵字或者標籤相匹配 —— 我們希望它可以搜尋在我們的資料儲存中的任何東西(包括大的文字欄位)。
為了構建一個功能強大的搜尋功能,通常最理想的方法是使用一個為全文搜尋任務最佳化過的資料儲存。在這裡我們使用 Elasticsearch,Elasticsearch 是一個開源的記憶體中的資料儲存,它是用 Java 寫的,最初是在 Apache Lucene 庫上構建的。
這裡有一些來自 Elastic 官方網站 上的 Elasticsearch 真實使用案例。
- Wikipedia 使用 Elasticsearch 去提供帶高亮搜尋片斷的全文搜尋功能,並且提供按型別搜尋和 “did-you-mean” 建議。
- Guardian 使用 Elasticsearch 把社交網路資料和訪客日誌相結合,為編輯去提供新文章的公眾意見的實時反饋。
- Stack Overflow 將全文搜尋和地理查詢相結合,並使用 “類似” 的方法去找到相關的查詢和回答。
- GitHub 使用 Elasticsearch 對 1300 億行程式碼進行查詢。
與 “普通的” 資料庫相比,Elasticsearch 有什麼不一樣的地方?
Elasticsearch 之所以能夠提供快速靈活的全文搜尋,秘密在於它使用反轉索引 。
“索引” 是資料庫中的一種資料結構,它能夠以超快的速度進行資料查詢和檢索操作。資料庫透過儲存與表中行相關聯的欄位來生成索引。在一種可搜尋的資料結構(一般是 B 樹)中排序索引,在最佳化過的查詢中,資料庫能夠達到接近線性的時間(比如,“使用 ID=5 查詢行”)。
我們可以將資料庫索引想像成一個圖書館中老式的卡片式目錄 —— 只要你知道書的作者和書名,它就會告訴你書的準確位置。為加速特定欄位上的查詢速度,資料庫表一般有多個索引(比如,在 name
列上的索引可以加速指定名字的查詢)。
反轉索引本質上是不一樣的。每行(或文件)的內容是分開的,並且每個獨立的條目(在本案例中是單詞)反向指向到包含它的任何文件上。
這種反轉索引資料結構可以使我們非常快地查詢到,所有出現 “football” 的文件。透過使用大量最佳化過的記憶體中的反轉索引,Elasticsearch 可以讓我們在儲存的資料上,執行一些非常強大的和自定義的全文搜尋。
2 - 專案設定
2.0 - Docker
我們在這個專案上使用 Docker 管理環境和依賴。Docker 是個容器引擎,它允許應用程式執行在一個獨立的環境中,不會受到來自主機作業系統和本地開發環境的影響。現在,許多公司將它們的大規模 Web 應用程式主要執行在容器架構上。這樣將提升靈活性和容器化應用程式元件的可組構性。
對我來說,使用 Docker 的優勢是,它對本教程的作者非常方便,它的本地環境設定量最小,並且跨 Windows、macOS 和 Linux 系統的一致性很好。我們只需要在 Docker 配置檔案中定義這些依賴關係,而不是按安裝說明分別去安裝 Node.js、Elasticsearch 和 Nginx,然後,就可以使用這個配置檔案在任何其它地方執行我們的應用程式。而且,因為每個應用程式元件都執行在它自己的獨立容器中,它們受本地機器上的其它 “垃圾” 干擾的可能性非常小,因此,在除錯問題時,像“它在我這裡可以工作!”這類的問題將非常少。
2.1 - 安裝 Docker & Docker-Compose
這個專案只依賴 Docker 和 docker-compose,docker-compose 是 Docker 官方支援的一個工具,它用來將定義的多個容器配置 組裝 成單一的應用程式棧。
- 安裝 Docker - https://docs.docker.com/engine/installation/
- 安裝 Docker Compose - https://docs.docker.com/compose/install/
2.2 - 設定專案主目錄
為專案建立一個主目錄(名為 guttenberg_search
)。我們的專案將工作在主目錄的以下兩個子目錄中。
/public
- 儲存前端 Vue.js Web 應用程式。/server
- 伺服器端 Node.js 原始碼。
2.3 - 新增 Docker-Compose 配置
接下來,我們將建立一個 docker-compose.yml
檔案來定義我們的應用程式棧中的每個容器。
gs-api
- 後端應用程式邏輯使用的 Node.js 容器gs-frontend
- 前端 Web 應用程式使用的 Ngnix 容器。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 和 Nginx。每個容器都將埠轉發到宿主機系統(localhost
)上,以便於我們在宿主機上去訪問和除錯 Node API、Elasticsearch 例項和前端 Web 應用程式。
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 配置擴充套件了官方的 Node.js 映象、複製我們的應用程式原始碼、以及在容器內安裝 NPM 依賴。
我們也增加了一個 .dockerignore
檔案,以防止我們不需要的檔案複製到容器中。
node_modules/
npm-debug.log
books/
public/
請注意:我們之所以不複製
node_modules
目錄到我們的容器中 —— 是因為我們要在容器構建過程裡面執行npm install
。從宿主機系統複製node_modules
到容器裡面可能會引起錯誤,因為一些包需要為某些作業系統專門構建。比如說,在 macOS 上安裝bcrypt
包,然後嘗試將這個模組直接複製到一個 Ubuntu 容器上將不能工作,因為bcyrpt
需要為每個作業系統構建一個特定的二進位制檔案。
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
Node 應用配置。
{
"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 build
,它將構建我們的 Node.js 應用程式容器。
接下來,執行 docker-compose up
去啟動整個應用程式棧。
這一步可能需要幾分鐘時間,因為 Docker 要為每個容器去下載基礎映象。以後再次執行,啟動應用程式會非常快,因為所需要的映象已經下載完成了。
在你的瀏覽器中嘗試訪問 localhost:8080
—— 你將看到簡單的 “Hello World” Web 頁面。
訪問 localhost:3000
去驗證我們的 Node 伺服器,它將返回 “Hello World” 資訊。
最後,訪問 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
中新增如下的 Elasticsearch 初始化程式碼。
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()
現在,我們重新構建我們的 Node 應用程式,我們將使用 docker-compose build
來做一些改變。接下來,執行 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()
呼叫,因為,我們最終的應用程式將呼叫外部的連線模組。
3.1 - 新增函式去重置索引
在 server/connection.js
中的 checkConnection
下面新增如下的函式,以便於重置 Elasticsearch 索引。
/** Clear the index, recreate it, and add mappings */
async function resetIndex (index) {
if (await client.indices.exists({ index })) {
await client.indices.delete({ index })
}
await client.indices.create({ index })
await putBookMapping()
}
3.2 - 新增圖書模式
接下來,我們將為圖書的資料模式新增一個 “對映”。在 server/connection.js
中的 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 } })
}
這是為 book
索引定義了一個對映。Elasticsearch 中的 index
大概類似於 SQL 的 table
或者 MongoDB 的 collection
。我們透過新增對映來為儲存的文件指定每個欄位和它的資料型別。Elasticsearch 是無模式的,因此,從技術角度來看,我們是不需要新增對映的,但是,這樣做,我們可以更好地控制如何處理資料。
比如,我們給 title
和 author
欄位分配 keyword
型別,給 text
欄位分配 text
型別。之所以這樣做的原因是,搜尋引擎可以區別處理這些字串欄位 —— 在搜尋的時候,搜尋引擎將在 text
欄位中搜尋可能的匹配項,而對於 keyword
型別欄位,將對它們進行全文匹配。這看上去差別很小,但是它們對在不同的搜尋上的速度和行為的影響非常大。
在檔案的底部,匯出對外發布的屬性和函式,這樣我們的應用程式中的其它模組就可以訪問它們了。
module.exports = {
client, index, type, checkConnection, resetIndex
}
4 - 載入原始資料
我們將使用來自 古登堡專案 的資料 —— 它致力於為公共提供免費的線上電子書。在這個專案中,我們將使用 100 本經典圖書來充實我們的圖書館,包括《福爾摩斯探案集》、《金銀島》、《基督山復仇記》、《環遊世界八十天》、《羅密歐與朱麗葉》 和《奧德賽》。
4.1 - 下載圖書檔案
我將這 100 本書打包成一個檔案,你可以從這裡下載它 —— https://cdn.patricktriest.com/data/books.zip
將這個檔案解壓到你的專案的 books/
目錄中。
你可以使用以下的命令來完成(需要在命令列下使用 wget 和 The Unarchiver)。
wget https://cdn.patricktriest.com/data/books.zip
unar books.zip
4.2 - 預覽一本書
嘗試開啟其中的一本書的檔案,假設開啟的是 219-0.txt
。你將注意到它開頭是一個公開訪問的協議,接下來是一些標識這本書的書名、作者、發行日期、語言和字元編碼的行。
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 ***
資訊,接下來是本書更詳細的協議版本。
下一步,我們將使用程式從檔案頭部來解析書的後設資料,提取 *** START OF
和 ***END OF
之間的內容。
4.3 - 讀取資料目錄
我們將寫一個指令碼來讀取每本書的內容,並將這些資料新增到 Elasticsearch。我們將定義一個新的 Javascript 檔案 server/load_data.js
來執行這些操作。
首先,我們將從 books/
目錄中獲取每個檔案的列表。
在 server/load_data.js
中新增下列內容。
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()
我們將使用一個快捷命令來重構我們的 Node.js 應用程式,並更新執行的容器。
執行 docker-compose up -d --build
去更新應用程式。這是執行 docker-compose build
和 docker-compose up -d
的快捷命令。
為了在容器中執行我們的 load_data
指令碼,我們執行 docker exec gs-api "node" "server/load_data.js"
。你將看到 Elasticsearch 的狀態輸出 Found 100 Books
。
這之後,指令碼發生了錯誤退出,原因是我們呼叫了一個沒有定義的輔助函式(parseBookFile
)。
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 }
}
這個函式執行幾個重要的任務。
- 從檔案系統中讀取書的文字。
- 使用正規表示式(關於正規表示式,請參閱 這篇文章 )解析書名和作者。
- 透過匹配 “古登堡專案” 的頭部和尾部,識別書的正文內容。
- 提取書的內容文字。
- 分割每個段落到它的陣列中。
- 清理文字並刪除空白行。
它的返回值,我們將構建一個物件,這個物件包含書名、作者、以及書中各段落的陣列。
再次執行 docker-compose up -d --build
和 docker exec gs-api "node" "server/load_data.js"
,你將看到輸出同之前一樣,在輸出的末尾有三個額外的行。
成功!我們的指令碼從文字檔案中成功解析出了書名和作者。指令碼再次以錯誤結束,因為到現在為止,我們還沒有定義輔助函式。
4.5 - 在 ES 中索引資料檔案
最後一步,我們將批次上傳每個段落的陣列到 Elasticsearch 索引中。
在 load_data.js
中新增新的 insertBookData
函式。
/** 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`)
}
這個函式將使用書名、作者和附加後設資料的段落位置來索引書中的每個段落。我們透過批次操作來插入段落,它比逐個段落插入要快的多。
我們分批索引段落,而不是一次性插入全部,是為執行這個應用程式的記憶體稍有點小(1.7 GB)的伺服器
search.patricktriest.com
上做的一個重要最佳化。如果你的機器記憶體還行(4 GB 以上),你或許不用分批上傳。
執行 docker-compose up -d --build
和 docker exec gs-api "node" "server/load_data.js"
一次或多次 —— 現在你將看到前面解析的 100 本書的完整輸出,並插入到了 Elasticsearch。這可能需要幾分鐘時間,甚至更長。
5 - 搜尋
現在,Elasticsearch 中已經有了 100 本書了(大約有 230000 個段落),現在我們嘗試搜尋查詢。
5.0 - 簡單的 HTTP 查詢
首先,我們使用 Elasticsearch 的 HTTP API 對它進行直接查詢。
在你的瀏覽器上訪問這個 URL - http://localhost:9200/library/_search?q=text:Java&pretty
在這裡,我們將執行一個極簡的全文搜尋,在我們的圖書館的書中查詢 “Java” 這個詞。
你將看到類似於下面的一個 JSON 格式的響應。
{
"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."
}
},
...
]
}
}
用 Elasticseach 的 HTTP 介面可以測試我們插入的資料是否成功,但是如果直接將這個 API 暴露給 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
函式,它將使用輸入的詞 match
查詢。
這是查詢的欄位分解 -
from
- 允許我們分頁查詢結果。預設每個查詢返回 10 個結果,因此,指定from: 10
將允許我們取回 10-20 的結果。query
- 這裡我們指定要查詢的詞。operator
- 我們可以修改搜尋行為;在本案例中,我們使用and
操作去對查詢中包含所有字元(要查詢的詞)的結果來確定優先順序。fuzziness
- 對拼寫錯誤的容錯調整,auto
的預設為fuzziness: 2
。模糊值越高,結果越需要更多校正。比如,fuzziness: 1
將允許以Patricc
為關鍵字的查詢中返回與Patrick
匹配的結果。highlights
- 為結果返回一個額外的欄位,這個欄位包含 HTML,以顯示精確的文字字集和查詢中匹配的關鍵詞。
你可以去瀏覽 Elastic Full-Text Query DSL,學習如何隨意調整這些引數,以進一步自定義搜尋查詢。
6 - API
為了能夠從前端應用程式中訪問我們的搜尋功能,我們來寫一個快速的 HTTP API。
6.0 - API 伺服器
用以下的內容替換現有的 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 伺服器匯入伺服器依賴,設定簡單的日誌,以及錯誤處理。
6.1 - 使用查詢連線端點
接下來,我們將在伺服器上新增一個端點,以便於釋出我們的 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
這個請求將搜尋整個圖書館中提到 “Java” 的內容。
結果與前面直接呼叫 Elasticsearch HTTP 介面的結果非常類似。
{
"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]
。
如果想從 Node 應用程式中檢視實時日誌,你可以執行 docker-compose logs -f api
。
7 - 前端應用程式
現在我們的 /search
端點已經就緒,我們來連線到一個簡單的 Web 應用程式來測試這個 API。
7.0 - Vue.js 應用程式
我們將使用 Vue.js 去協調我們的前端。
新增一個新檔案 /public/app.js
,去控制我們的 Vue.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
}
}
})
這個應用程式非常簡單 —— 我們只定義了一些共享的資料屬性,以及新增了檢索和分頁搜尋結果的方法。為防止每次按鍵一次都呼叫 API,搜尋輸入有一個 100 毫秒的除顫功能。
解釋 Vue.js 是如何工作的已經超出了本教程的範圍,如果你使用過 Angular 或者 React,其實一些也不可怕。如果你完全不熟悉 Vue,想快速瞭解它的功能,我建議你從官方的快速指南入手 —— https://vuejs.org/v2/guide/
7.1 - HTML
使用以下的內容替換 /public/index.html
檔案中的佔位符,以便於載入我們的 Vue.js 應用程式和設計一個基本的搜尋介面。
<!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.2 - CSS
新增一個新檔案 /public/styles.css
,使用一些自定義的 UI 樣式。
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
目錄是裝載在我們的 Nginx 檔案伺服器容器中,因此,在本地系統中前端的變化將在容器化應用程式中自動反映出來。
如果你嘗試點選任何搜尋結果,什麼反應也沒有 —— 因為我們還沒有為這個應用程式新增進一步的相關功能。
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
}
這五個函式提供了透過頁碼從書中下載和分頁(每次十個段落)的邏輯。
現在,我們需要新增一個 UI 去顯示書的頁面。在 /public/index.html
的 <!-- INSERT BOOK MODAL HERE -->
註釋下面新增如下的內容。
<!-- 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
。當你再次點選搜尋結果時,你將能看到關鍵字附近的段落。如果你感興趣,你現在甚至可以看這本書的剩餘部分。
祝賀你!你現在已經完成了本教程的應用程式。
你可以去比較你的本地結果與託管在這裡的完整示例 —— https://search.patricktriest.com/ 。
9 - Elasticsearch 的缺點
9.0 - 耗費資源
Elasticsearch 是計算密集型的。官方建議 執行 ES 的機器最好有 64 GB 的記憶體,強烈反對在低於 8 GB 記憶體的機器上執行它。Elasticsearch 是一個 記憶體中 資料庫,這樣使它的查詢速度非常快,但這也非常佔用系統記憶體。在生產系統中使用時,他們強烈建議在一個叢集中執行多個 Elasticsearch 節點,以實現高可用、自動分割槽和一個節點失敗時的資料冗餘。
我們的這個教程中的應用程式執行在一個 $15/月 的 GCP 計算例項中( search.patricktriest.com),它只有 1.7 GB 的記憶體,它勉強能執行這個 Elasticsearch 節點;有時候在進行初始的資料載入過程中,整個機器就 ”假當機“ 了。在我的經驗中,Elasticsearch 比傳統的那些資料庫,比如,PostgreSQL 和 MongoDB 耗費的資源要多很多,這樣會使託管主機的成本增加很多。
9.1 - 與資料庫的同步
對於大多數應用程式,將資料全部儲存在 Elasticsearch 並不是個好的選擇。可以使用 ES 作為應用程式的主要事務資料庫,但是一般不推薦這樣做,因為在 Elasticsearch 中缺少 ACID,如果大量讀取資料的時候,它能導致寫操作丟失。在許多案例中,ES 伺服器更多是一個特定的角色,比如做應用程式中的一個文字搜尋功能。這種特定的用途,要求它從主資料庫中複製資料到 Elasticsearch 例項中。
比如,假設我們將使用者資訊儲存在一個 PostgreSQL 表中,但是用 Elasticsearch 去提供我們的使用者搜尋功能。如果一個使用者,比如,“Albert”,決定將他的名字改成 “Al”,我們將需要把這個變化同時反映到我們主要的 PostgreSQL 資料庫和輔助的 Elasticsearch 叢集中。
正確地整合它們可能比較棘手,最好的答案將取決於你現有的應用程式棧。這有多種開源方案可選,從 用一個程式去關注 MongoDB 操作日誌 並自動同步檢測到的變化到 ES,到使用一個 PostgresSQL 外掛 去建立一個定製的、基於 PSQL 的索引來與 Elasticsearch 進行自動溝通。
如果沒有有效的預構建選項可用,你可能需要在你的伺服器程式碼中增加一些鉤子,這樣可以基於資料庫的變化來手動更新 Elasticsearch 索引。最後一招,我認為是一個最後的選擇,因為,使用定製的業務邏輯去保持 ES 的同步可能很複雜,這將會給應用程式引入很多的 bug。
讓 Elasticsearch 與一個主資料庫同步,將使它的架構更加複雜,其複雜性已經超越了 ES 的相關缺點,但是當在你的應用程式中考慮新增一個專用的搜尋引擎的利弊得失時,這個問題是值的好好考慮的。
總結
在很多現在流行的應用程式中,全文搜尋是一個非常重要的功能 —— 而且是很難實現的一個功能。對於在你的應用程式中新增一個快速而又可定製的文字搜尋,Elasticsearch 是一個非常好的選擇,但是,在這裡也有一個替代者。Apache Solr 是一個類似的開源搜尋平臺,它是基於 Apache Lucene 構建的,與 Elasticsearch 的核心庫是相同的。Algolia 是一個搜尋即服務的 Web 平臺,它已經很快流行了起來,並且它對新手非常友好,很易於上手(但是作為折衷,它的可定製性較小,並且使用成本較高)。
“搜尋” 特性並不是 Elasticsearch 唯一功能。ES 也是日誌儲存和分析的常用工具,在一個 ELK(Elasticsearch、Logstash、Kibana)架構配置中通常會使用它。靈活的全文搜尋功能使得 Elasticsearch 在資料量非常大的科學任務中用處很大 —— 比如,在一個資料集中正確的/標準化的條目拼寫,或者為了類似的片語搜尋一個文字資料集。
對於你自己的專案,這裡有一些創意。
- 新增更多你喜歡的書到教程的應用程式中,然後建立你自己的私人圖書館搜尋引擎。
- 利用來自 Google Scholar 的論文索引,建立一個學術抄襲檢測引擎。
- 透過將字典中的每個詞索引到 Elasticsearch,建立一個拼寫檢查應用程式。
- 透過將 Common Crawl Corpus 載入到 Elasticsearch 中,構建你自己的與谷歌競爭的因特網搜尋引擎(注意,它可能會超過 50 億個頁面,這是一個成本極高的資料集)。
- 在 journalism 上使用 Elasticsearch:在最近的大規模洩露的文件中搜尋特定的名字和關鍵詞,比如, Panama Papers 和 Paradise Papers。
本教程中應用程式的原始碼是 100% 公開的,你可以在 GitHub 倉庫上找到它們 —— https://github.com/triestpa/guttenberg-search
我希望你喜歡這個教程!你可以在下面的評論區,發表任何你的想法、問題、或者評論。
作者簡介:
全棧工程師,資料愛好者,學霸,“構建強迫症患者”,探險愛好者。
via: https://blog.patricktriest.com/text-search-docker-elasticsearch/
作者:Patrick Triest 譯者:qhwdw 校對:wxy
相關文章
- Elasticsearch——全文搜尋Elasticsearch
- ElasticSearch全文搜尋引擎Elasticsearch
- 使用 Laravel Scout + ElasticSearch 實現全文搜尋LaravelElasticsearch
- 使用Node,Vue和ElasticSearch構建實時搜尋引擎VueElasticsearch
- 用Elasticsearch構建電商搜尋平臺Elasticsearch
- Elasticsearch 的配置與使用,為了全文搜尋Elasticsearch
- 使用 Postgres 的全文搜尋構建可擴充套件的事件驅動搜尋架構套件事件架構
- Laravel5.5 使用 Elasticsearch 做引擎,scout 全文搜尋LaravelElasticsearch
- Nebula 基於 ElasticSearch 的全文搜尋引擎的文字搜尋Elasticsearch
- 使用Elasticsearch快速實現社群/部落格文章全文搜尋Elasticsearch
- tinysearch/tinysearch: 使用Rus和Wasm構建的靜態網站的微型全文搜尋引擎ASM網站
- 使用 Redis 和 Python 構建一個共享單車的應用程式RedisPython
- 用elasticsearch和nuxtjs搭建bt搜尋引擎ElasticsearchUXJS
- ES(Elasticsearch)支援PB級全文搜尋引擎入門教程Elasticsearch
- 使用Rust和WebAssembly構建Web應用程式RustWeb
- Laravel xunsearch 全文搜尋Laravel
- sphinx 全文搜尋引擎
- 18. 使用MySQL之全文字搜尋MySql
- 使用docker-compose構建elasticsearch叢集DockerElasticsearch
- 如何使用ABAP Restful API進行程式碼的全文搜尋RESTAPI行程
- 使用Electron製作一個快速搜尋應用(入門向)
- 基於Kafka和Elasticsearch構建實時站內搜尋功能的實踐KafkaElasticsearch
- 從零構建一個基於Docker的Laravel應用DockerLaravel
- 像使用 Laravel Query 一樣的搜尋 ElasticsearchLaravelElasticsearch
- 基於Elasticsearch實現搜尋建議Elasticsearch
- Laravel 使用 Elasticsearch 全域性搜尋LaravelElasticsearch
- Elasticsearch常用搜尋Elasticsearch
- elasticsearch搜尋商品Elasticsearch
- Elasticsearch 向量搜尋Elasticsearch
- Docker入門-構建第一個Java程式DockerJava
- 【譯】使用 MongoDB,React,Node 和 Express(MERN)構建一個全棧應用MongoDBReactExpress全棧
- 如何構建一個WEB同構應用Web
- 使用 IBM Bluemix 構建,部署和管理自定義應用程式IBM
- 搜尋引擎ElasticSearch18_ElasticSearch程式設計操作5Elasticsearch程式設計
- “Hello,Jetpack”:構建您的第一個Jetpack應用程式Jetpack
- FISCO BCOS | 構建第一個區塊鏈應用程式區塊鏈
- Elasticsearch核心技術(五):搜尋API和搜尋執行機制ElasticsearchAPI
- Elasticsearch 結構化搜尋、keyword、Term查詢Elasticsearch