Mark Ku's Blog

時空背景

經客服經理反饋,發現我們網站的搜尋功能體驗非常的差,用戶常常反應搜尋不到的規格,然後他說希望能像 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 的概念類似,這邊整理了名詞的對照表

| ElasticSearch | RDBMS   | | -------- | --------   | | 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>
    );
}

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

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

參考資料

作者

Mark Ku

10 年以上的軟體工程師,做過北美電商與 AI SaaS 訂閱收費系統。閱讀更多

覺得這篇有幫助?

作者做的免費工具、每日 Podcast 與電子報,都在這裡。

Mark Ku · 本文採用 CC BY 4.0 授權,轉載請註明作者並附上原文連結。

留言

訂閱電子報

訂閱後即時收到新文章通知,不錯過任何技術分享。

提交即表示同意接收電子報,隨時可。

站長的公司

Vibe Coding 架構規劃與陪跑

團隊已經用 AI 做出小工具,卻怕壞了沒人修、需求一變就不敢改?226 Network 幫你把程式放進 Git、密碼另外保管、排程搬上固定主機,再補上交接文件與測試。

熱門文章

View all
Mark Ku
··631

Oracle Cloud 永久免費方案 Linux 主機及固定 IP :0 元打造雲端解決方案

Oracle Cloud 永久免費方案 Linux 主機及固定 IP :0 元打造雲端解決方案
Mark Ku
··471

告別 Postman 收費陷阱!開源 Git 原生 API 測試神器 Bruno 實戰指南

告別 Postman 收費陷阱!開源 Git 原生 API 測試神器 Bruno 實戰指南
Mark Ku
··265

一款免費開源類似於 Notion 類知識庫系統 — Outline Wiki 佈署與備份全攻略

一款免費開源類似於 Notion 類知識庫系統 — Outline Wiki 佈署與備份全攻略
Mark Ku
··216

打造高效 API 管理平台:從 0 開始部署 Kong Gateway - Part 1

打造高效 API 管理平台:從 0 開始部署 Kong Gateway - Part 1
Mark Ku
··208

在 Ubuntu 上設置 Samba 來共享資料夾,讓 Windows 11 用戶可以存取

在 Ubuntu 上設置 Samba 來共享資料夾,讓 Windows 11 用戶可以存取
Mark Ku
··207

訓練自己的 AI 語音:硬體門檻、開源模型比較與 LoRA 微調

訓練自己的 AI 語音:硬體門檻、開源模型比較與 LoRA 微調