Mark Ku's Blog
首頁 關於我
提升電商網站搜尋體驗,使用Elasticsearch進行高效全文檢索 - Part's 1
ECommerce
提升電商網站搜尋體驗,使用Elasticsearch進行高效全文檢索 - Part's 1
Mark Ku
Mark Ku
January 20, 2024
1 min

時空背景

經客服經理反饋,發現我們網站的搜尋功能體驗非常的差,用戶常常反應搜尋不到的規格,然後他說希望能像 Google 一樣搜尋體驗,而且可以快速依據產品規格,找到相關的產品,因此我花了些時間評估後,發現全文檢索的技術,應該是能解決我們的問題。

為何需要使用 - 全文檢索

首先,我們得先了解什麼是全文檢索工具(Full-Text Search Tools),它是一種軟體工具,主要功能在有效地搜尋和檢索大量的文字數據,並返回相關的搜尋結果,可以在數秒內檢索大規模文字數據集,提供多種搜尋選項,例如關鍵字搜尋、短語搜尋、模糊搜尋等,使用戶能夠以多種方式查找所需資訊。

全文檢索原理

接著得了解全文檢索技術和資料庫最大的差別就是,就是當資料量越大時,要對全表掃描時,對資料庫要耗費相當長的時間,但全文檢索技術採用倒排索引的檢索技術,以依據各種分詞器將資料以 key 及 value 的形式儲存,查詢時就會變得簡單及快速。

以資料庫存儲的商品為例

SkuID ( 庫存量單位ID )Name (庫存量單位名稱)
1Kingstom 16GB Ram
2Transcend 16GB Ram

P.S. Sku ( Stock Keeping Unit )為庫存量單位的ID

全文檢索採用的『倒排索引』的檢索方式,透過事前分詞及記錄順序,當使用者查詢時,才有辦法簡單快速及精準。

Term (分詞)SkuID (庫存量單位ID)
kingstom1
16gb1,2
ram1,2
transcend1,2

內建分詞器

Elasticsearch 本身就內建的分詞器,有標準分詞器、空格分詞器、簡單分詞器等等,它們的主要工作就是把文字拆成一個個詞彙,還有做一些處理,像是轉成小寫、詞幹提取、過濾停用詞等等,讓系統可以更好地建立索引和進行全文檢索,你可以根據需要,選擇適合的分詞器和分析器,或者自己客製化一個,這樣就能有更好的搜尋效果了。

測試分詞器

可以先看過,知道可以透過語法測試分詞,等到後面 ElasticSearch 及 Kabana 架好後,可以透過Dev Tools執行下面語法測試分詞。

POST _analyze
{
  "analyzer": "standard",
  "text": "Transcend 16GB Ram"
}

分詞拆解的結果
分詞拆解的結果
P.S. Elasticsearch 並沒有內建中文的分詞器,但可以另外安裝常用的中文分詞器,EX:IK 分词器、Smartcn 分词器和 Jieba 。

其實ElasticSearch 與 RDBMS 的概念類似,這邊整理了名詞的對照表

ElasticSearchRDBMS  
INDEX (索引)表  
DOCUMENT (文件)行  
FIELD (欄位)欄位  
MAPPING (結構)表結構  

建立及啟動容器

Docker Compose (docker-compose.yml )

# 注意version要和docker-compose的版本對應 docker-compose --version
version: '3.8'
services:
  elasticsearch:
    image: elasticsearch:7.17.3
    ports:
      - "9200:9200"
      - "9300:9300"
    environment:
      - discovery.type=single-node
    container_name: elasticsearch

  kibana:
    image: kibana:7.17.3
    ports:
      - "5601:5601"
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    container_name: kibana
    depends_on:
      - elasticsearch

運行及啟動 Docker Compose 容器

docker-compose -p elasticsearch-group up

用 Powershell 新增防火牆規則

New-NetFirewallRule -DisplayName "elasticsearch -Port 5000" -Direction Inbound -Protocol TCP -LocalPort 5000 -Action Allow

New-NetFirewallRule -DisplayName "elasticsearch -Port 9200" -Direction Inbound -Protocol TCP -LocalPort 9200 -Action Allow

New-NetFirewallRule -DisplayName "kibana -Port 9200" -Direction Inbound -Protocol TCP -LocalPort 5601 -Action Allow

首先,先訪問 ElasticSearch http://localhost:9200/_cat/indices 查詢所有的索引,ElasticSearch 其實很單純,他本身就內建了rest api ,透過訪問URL 就能拿到我們要的結果

ElasticSearch查詢所有的索引
ElasticSearch查詢所有的索引

接著,再訪問 Kabana http://localhost:5601/ ,點開導覽列,找到DevTools,就可以透過 UI 去操作先前在Docker 指定的ElasticSearch 。

GET /_cat/indices 

Kabana查詢所有的索引
Kabana查詢所有的索引

資料庫則需要先定表結構,才能新增資料,但ElasticSearch 相當強大,先新增資料,會依據資料自動推斷表結構的欄位型態。

單筆新增資料

POST product/_doc/1004
{
    "product_id": "1004",
    "product_name": "Super Tablet",
    "brand": "Techie",
    "price": 599.99,
    "processor": "ARM Cortex-A76",
    "video_card": "Integrated Mali-G76",
    "ram": "4GB",
    "storage": "128GB SSD",
    "special_features": ["Touchscreen", "Lightweight"],
    "stock": 25
}

P.S _doc => 為預設文檔的類型,新版本中不建議修改文檔的類型,此功能逐步會被淘汰。

批量新增資料

POST _bulk
{"index": {"_index": "product", "_id": "1001"}}
{"product_id": "1001", "product_name": "UltraBook Pro", "brand": "Techie", "price": 999.99, "processor": "Intel Core i7", "video_card": "NVIDIA GeForce RTX 3060", "ram": "16GB", "storage": "512GB SSD", "special_features": ["Wifi", "Special Promotion"], "stock": 50}
{"index": {"_index": "product", "_id": "1002"}}
{"product_id": "1002", "product_name": "Gamer PowerHouse", "brand": "Xtreme", "price": 1299.99, "processor": "AMD Ryzen 7", "video_card": "AMD Radeon RX 6800 XT", "ram": "32GB", "storage": "1TB SSD", "special_features": ["Custom Liquid Cooling", "Special Promotion"], "stock": 30}
{"index": {"_index": "product", "_id": "1003"}}
{"product_id": "1003", "product_name": "PortaLight", "brand": "SleekTech", "price": 799.99, "processor": "Intel Core i5", "video_card": "Integrated Intel Iris Xe Graphics", "ram": "8GB", "storage": "256GB SSD", "special_features": ["Wifi"], "stock": 70}

查詢 product 的結構

GET /product/_mapping

P.S 資料庫則需要先定表結構,才能新增資料,但ElasticSearch 相當強大,先新增資料,會依據資料自動推斷表結構的欄位型態。

查詢該筆_id=1001的資料

GET /product/_doc/1001

查詢回來的結果

{
  "_index" : "ecommerce",
  "_type" : "_doc",
  "_id" : "1001",
  "_version" : 1,
  "_seq_no" : 0,
  "_primary_term" : 1,
  "found" : true,
  "_source" : {
    "product_id" : "1001",
    "product_name" : "UltraBook Pro",
    "brand" : "Techie",
    "price" : 999.99,
    "processor" : "Intel Core i7",
    "video_card" : "NVIDIA GeForce RTX 3060",
    "ram" : "16GB",
    "storage" : "512GB SSD",
    "special_features" : [
      "Wifi",
      "Special Promotion"
    ],
    "stock" : 50
  }
}

依據查詢回來的結果,整理了一些對照

欄位說明
_indexdocument 所屬的 index 名稱
_typedocument 類型
_iddocument ID 編號
_version版本訊息,每進行一次更新、刪除,都會增加 version 的值
_source此 document 的原始 json 資料

獲取所有的資料

GET /product/_search

查詢單筆資料

GET /product/_search
{
  "query": {
    "match": {
      "product_name": "pro"
    }
  }
}

整理一些常見的查詢條件

查詢條件類型描述範例
match用於全文檢索,對搜尋詞進行分詞,然後搜尋每個分詞,支援文字字段的分析,適用於搜尋文字字段。搜尋 “快速的電腦”
match_phrase也用於全文檢索,但要求匹配整個詞組,考慮詞序,適用於精確詞組匹配的情況。搜尋 “快速的電腦”,但要求匹配整個詞組
multi_match允許在多個字段中進行 match 查詢,適用於在多個字段中搜尋相同關鍵字的情況。在標題和描述中搜尋 “蘋果”
term用於精確值匹配,不進行分詞,適用於非分析字段,通常用於數字、日期或未分析的文字字段。搜尋具有特定 ID 的文件
fuzzy用於處理拼寫錯誤和近似匹配,基於 Levenshtein 編輯距離算法,適用於容忍拼寫錯誤的情況。搜尋 “aple” 可以匹配 “蘋果”
wildcard允許使用通配符 *? 進行模糊匹配。適用於需要匹配特定模式的情況。搜尋 “appl*e” 可以匹配 “測試”

糊模查詢

GET /product/_search
{
  "query": {
    "wildcard": {
      "product_name": "pro*"
    }
  }
}

模糊查詢,允許錯字

GET /product/_search
{
  "query": {
    "fuzzy": {
      "product_name": {
        "value": "pro",
        "fuzziness": "AUTO"
      }
    }
  }
}

多欄位搜尋

GET /product/_search
{
  "query": {
    "multi_match": {
      "query": "您的關鍵字",
      "fields": ["price", "processor", "video_card", "ram", "storage", "special_features"]
    }
  }
}

查出索引文件資訊 - ( 筆數 )

POST /product/_doc
{
  "query": {
    "bool": {
      "must": [
        { "match": { "product_name": "4060" } }
      ]
    }
  }
}

依據ID刪除該筆資料

DELETE /product/_doc/1001

刪除索引含資料

DELETE /product

刪除所有資料

POST /product/_delete_by_query
{
  "query": {
    "match_all": {}
  }
}

NextJS 整合Elasticsearch

安裝 @elastic/elasticsearch 套件

npm install @elastic/elasticsearch

接著,建立 elasticsearch Client

// lib/elasticsearch.ts
import { Client } from '@elastic/elasticsearch';

const client = new Client({
    node: 'http://localhost:9200', // 更改為您的 Elasticsearch 節點地址
});

export default client;

在 Next.js 中建立 查詢 Elasticsearch 的 Api

// pages/api/search.ts
import client from '../../lib/elasticsearch';

import client from '@utils/elasticsearch';
import { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
    try {
        // 從請求體或查詢參數中獲取搜尋關鍵字
        const keyword = req.body.keyword || req.query.keyword;

        // 建立多字段 Elasticsearch 查詢
        const query = {
            multi_match: {
                query: keyword,
                fields: ['price', 'processor', 'video_card', 'ram', 'storage', 'special_features'],
            },
        };

        // 執行 Elasticsearch 查詢
        const body = await client.search({
            index: 'product',
            body: { query },
        });

        // 返回查詢結果
        res.status(200).json(body.hits.hits);
    } catch (error: any) {
        res.status(500).json({ message: error.message });
    }
}

最後在前端建立搜尋框及搜尋按鈕

// app/elasticsearch/page.tsx
'use client';
import { useState } from 'react';

export default function Search() {
    const [keyword, setKeyword] = useState<string>('3060');
    const [searchResults, setSearchResults] = useState<any[]>([]); // 存儲搜尋結果的狀態

    async function searchProducts(keyword: string): Promise<any[]> {
        const response = await fetch('/api/search', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ keyword }),
        });
        const data = await response.json();
        debugger;
        return data;
    }

    const handleSearch = async () => {
        const results = await searchProducts(keyword);
        debugger;
        // 更新搜尋結果的狀態
        setSearchResults(results);

        // 處理搜尋結果
    };

    return (
        <div className="p-4">
            <input
                type="text"
                value={keyword}
                onChange={(e) => setKeyword(e.target.value)}
                className="mr-2 rounded-md border border-gray-300 p-2"
            />
            <button onClick={handleSearch} className="rounded-md bg-blue-500 p-2 text-white">
                Search
            </button>

            {/* 渲染搜尋結果 */}
            <div className="mt-4">{JSON.stringify(searchResults)}</div>
        </div>
    );
}

系統整合完成,是不是很簡單呢!

網頁搜尋畫面
網頁搜尋畫面

參考資料


Tags

Mark Ku

Mark Ku

Software Developer

9年以上豐富網站開發經驗,開發過各種網站,電子商務、平台網站、直播系統、POS系統、SEO 優化、金流串接、AI 串接,Infra 出身,帶過幾次團隊,也加入過大團隊一起開發。

Expertise

前端(React)
後端(C#)
網路管理
DevOps
溝通
領導

Social Media

facebook github website

Related Posts

淺談電商和實體門市差異
淺談電商和實體門市差異
October 05, 2024
1 min

Quick Links

關於我

Social Media