Meilisearch 完整教學 2026:中文站內搜尋、Docker 部署與實測

Meilisearch 是 Rust 寫的開源搜尋引擎(5.9 萬星、MIT 社群版),50 毫秒內回結果、支援中文 jieba 斷詞。本文把本站 1,912 篇文章灌進 v1.56.1 實測:索引 11.2 秒、查詢 0–2 ms、記憶體 171 MiB,並如實記錄繁體中文的繁簡不通、兩字縮寫與錯字容錯失效三個坑,附可直接貼上的 Docker Compose、匯入腳本與純 JS 前端搜尋頁。

  • Dennis
  • 12 分鐘閱讀
Meilisearch 完整教學 2026:中文站內搜尋、Docker 部署與實測

Meilisearch 是 Rust 寫的開源搜尋引擎,單一執行檔、Docker 一行就跑起來,1,912 篇中文文章索引只要 11 秒、查詢 0–2 毫秒。它對中文有 jieba 斷詞,但不處理繁簡轉換——這是繁體站台導入前必須先知道的一件事。

這篇不是官網文件翻譯。把 most.tw 全站 1,912 篇文章真的灌進 v1.56.1 之後,量到的數字、踩到的中文坑、以及哪些功能其實沒你想的那麼神,都寫在下面。

為什麼靜態網站最後都需要一個搜尋引擎

Hugo、Astro、Next.js 這類靜態輸出很好養,唯一尷尬的是搜尋。常見三條路,各有各的痛:

做法痛點
前端用 lunr/flexsearch 在瀏覽器裡跑索引檔要整包下載,中文要先自己斷詞;文章破千篇後首次載入就是幾 MB
交給 Google 站內搜尋結果頁離開自己的站、廣告與排序不能控制、沒有 facet
資料庫 LIKE '%關鍵字%'沒有相關度排序、沒有錯字容錯、資料一多就全表掃
用 Algolia 這類 SaaS體驗最好,但免費額度(每月 10,000 次搜尋、1,000 筆記錄)對中文長文站很快就滿,超量按次計價

自架 Meilisearch 的定位就是「SaaS 的體驗、自己的資料」:Rust 單一 binary、索引放本機、HTTP API 呼叫、前端直接打(用 search-only key)。

一表看懂:Meilisearch 是什麼、不是什麼

項目內容
版本(本文實測)v1.56.1,2026-11-02 發布
專案規模GitHub 5.98 萬星、2,779 forks(2026-11-07 查詢)
語言/部署Rust 單一執行檔;官方 Docker 映像 getmeili/meilisearch
授權社群版 MIT(含商業使用);企業版功能為商業授權或 BUSL-1.1,不可在生產環境使用
中文支援Charabia 分詞器,中文用 jieba 字典式斷詞+變體正規化
主要能力全文搜尋、錯字容錯、facet 過濾、排序、highlight、同義詞、多租戶 token、混合(語意)搜尋
它不做的事不做繁簡轉換、不是關聯式資料庫、不是日誌搜尋(不要拿它跟 Elasticsearch/Loki 比)

那個授權細節值得多說一句:README 上的 MIT 徽章指的是社群版。企業版(sharding、S3 串流快照、搜尋規則預覽)掛的是 BUSL-1.1,明文寫「未取得商業協議不得用於生產」。自架個人站台不會碰到,但如果公司要拿它做產品,先確認用的是哪一版映像與功能。

實測環境與方法

為了不寫出「理論上很快」這種句子,測試直接把 most.tw 的 1,912 篇 markdown 全部匯入:

項目設定
引擎getmeili/meilisearch:latest → 實測版本 v1.56.1
主機Linux VPS,Docker 29.2.1,單容器、無調校
文件數1,912 篇(標題+description+正文前 1,500 字)
文件大小平均 9,030 bytes/篇
設定searchableAttributes: title, description, body;filterable: section, date;sortable: date

結果先講:

指標實測值
索引 1,912 篇耗時11.2 秒(API 回報 PT11.221920905S,含排隊 12.4 秒)
索引吞吐約 170 篇/秒
索引大小116.2 MB(indexSize,原始文件只佔 17.3 MB)
資料目錄112 MB
記憶體(RSS)171.3 MiB(空庫時 9.3 MiB)
查詢延遲20 組常見查詢:中位數 1 ms、p95 1 ms、最慢 2 ms
增量新增 1 篇23 ms(任務 PT0.023086474S)

換算下來,一篇一萬字的中文文章大約吃掉 60 KB 索引、85 KB 記憶體。以這個比例,一萬篇文章的站台大約會落在 1 GB 記憶體上下——足夠便宜,但這是單機觀測值,不是官方建議值,實際會隨欄位數與 facet 數量變動。

Docker 部署:能直接用的一份 compose

官方文件給的是 docker run,長期跑建議用 compose。以下這份在 2026-11-07 用 docker compose config --quiet 驗過語法、實際起過容器,健康檢查顯示 Up (healthy):

services:
  meilisearch:
    image: getmeili/meilisearch:v1.56.1   # 鎖版本,別用 latest
    container_name: meilisearch
    restart: unless-stopped
    ports:
      - "7700:7700"
    environment:
      MEILI_ENV: production
      MEILI_MASTER_KEY: 換成你自己的32字元以上隨機字串
      MEILI_NO_ANALYTICS: "true"
    volumes:
      - ./meili_data:/meili_data
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:7700/health"]
      interval: 10s
      timeout: 3s
      retries: 5
      start_period: 10s

幾個容易踩的點:

  • latest 不要用。官方文件自己這樣寫,理由是不同機器在不同時間拉到的版本會不一樣。升級要當成一個有意識的動作。
  • MEILI_MASTER_KEY 至少 16 bytes,官方建議隨機產生。這把金鑰能建刪 API key,絕對不要出現在前端。
  • MEILI_ENV: production 會關掉開發模式的寬鬆行為;開發時想看的搜尋細節(showRankingScoreDetails 等)在 production 下的行為不同。
  • 健康檢查的 /health 不需要金鑰,很適合給反向代理與 uptime 監控打。實測回 {"status":"available"}。

起好之後:

curl -s http://127.0.0.1:7700/health
# {"status":"available"}

curl -s -H "Authorization: Bearer $MEILI_MASTER_KEY" http://127.0.0.1:7700/version
# {"commitSha":"421053d...","commitDate":"unknown","pkgVersion":"1.56.1"}

建索引與調相關度:先設好這三組設定

Meilisearch 的索引(index)可以隨時建,但設定要在灌資料前想清楚,因為 searchableAttributes 的順序就是相關度權重的順序。實測用這組:

curl -X PATCH http://127.0.0.1:7700/indexes/articles/settings \
  -H "Authorization: Bearer $MEILI_MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "searchableAttributes": ["title", "description", "body"],
    "filterableAttributes": ["section", "date"],
    "sortableAttributes": ["date"],
    "rankingRules": ["words", "typo", "proximity", "attribute", "sort", "exactness"]
  }'
設定什麼時候要動它
searchableAttributes順序=權重。把 title 放第一,實測搜「備份」時前三名全是標題命中,而不是正文順帶提到的文章
filterableAttributes沒設就不能 filter。想用 section = 'blog' 或 date > '2026-10-01' 一定得先宣告
sortableAttributes同上,沒宣告 sort 會回 400
rankingRules預設已含 sort;把 sort 放在 attribute 之後、exactness 之前,是「相關度優先、日期其次」的常見折衷

實測過濾+排序+facet 一起用:

curl -X POST http://127.0.0.1:7700/indexes/articles/search \
  -H "Authorization: Bearer $MEILI_SEARCH_KEY" -H "Content-Type: application/json" \
  -d '{"q":"","filter":"section = '\''blog'\'' AND date > '\''2026-10-01'\''","sort":["date:desc"],"facets":["section"]}'

回傳 40 筆,順序正確落在 2026-11-07、11-06、11-05 這幾篇。空字串查詢(q: "")配 filter 就是「分類頁+時間軸」的實作方式,不需要另建一份資料。

把 markdown 灌進去:可重複執行的匯入腳本

Meilisearch 吃 JSON 陣列,每筆一定要有 id(或設 primaryKey)。以下是實際跑過 1,912 篇的簡化版,重點在先剝掉 markdown 語法,否則 ##、|、反引號都會進索引,讓相關度變髒:

import glob, json, os, re, urllib.request

HOST, KEY = "http://127.0.0.1:7700", os.environ["MEILI_MASTER_KEY"]

def strip_md(t):
    t = re.sub(r"```.*?```", " ", t, flags=re.S)        # 程式碼區塊
    t = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", t)          # 圖片
    t = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", t)       # 連結留文字
    t = re.sub(r"[#>*`|_\-]{1,}", " ", t)                # 標題/表格/強調符號
    return re.sub(r"\s+", " ", t).strip()

docs = []
for i, f in enumerate(sorted(glob.glob("content/posts/*/*.md"))):
    raw = open(f, encoding="utf-8").read()
    m = re.match(r"^---\n(.*?)\n---\n?(.*)$", raw, re.S)
    if not m:
        continue
    fm, body = m.group(1), m.group(2)
    get = lambda n: (re.search(rf'^{n}:\s*"?([^"\n]+)"?\s*$', fm, re.M) or [None, ""])[1]
    sec = f.split("/")[-2]
    docs.append({
        "id": i + 1,
        "title": get("title"),
        "description": get("description"),
        "body": strip_md(body)[:1500],
        "section": sec,
        "date": get("date"),
        "url": f"/posts/{sec.lower()}/{get('slug') or os.path.basename(f)[:-3]}/",
    })

req = urllib.request.Request(
    f"{HOST}/indexes/articles/documents",
    data=json.dumps(docs).encode(), method="POST",
    headers={"Content-Type": "application/json", "Authorization": f"Bearer {KEY}"},
)
print(json.loads(urllib.request.urlopen(req, timeout=120).read())["taskUid"])

兩個實務提醒:

  • 寫入是非同步的,API 回 202 與一個 taskUid,要輪詢 GET /tasks/{uid} 等到 succeeded 才算真的進索引。實測 enqueuedAt 到 finishedAt 差 11.2 秒。
  • id 用整數最省事,但匯入後若文章增刪,最好用穩定的 slug 當 id,否則重新匯入時 id 會位移。

搜尋 API 實測:快是真的,相關度要看設定

20 組查詢在 1,912 篇上跑,全部 HTTP 200,processingTimeMs 中位數 1 ms:

查詢命中數處理時間第一名
反向代理443 msNginx 反向代理完整教學 2026
備份6921 ms自架備份完整教學 2026(restic vs Kopia)
docker compose1500 msDocker Compose 完整教學 2026
自架1,1930 ms自架 S3 物件儲存完整教學 2026
密碼2170 msVaultwarden 完整教學 2026
n8n220 msn8n 完整教學 2026
k8s220 msK8s 面臨的實際駭客攻擊手法
監控4611 ms監控類主題(標題未完全對上,見下節)

命中數與時間都很漂亮,但命中數不等於相關度。「監控」第一名不是 Beszel 或 Grafana 那兩篇教學,而是別的文章——這是中文搜尋最典型的落差:分詞對了,權重沒對。

好消息是 highlight 與 crop 都只要一個參數,前端可以立刻做出「關鍵字亮黃底+摘要截斷」:

{"q":"反向代理","attributesToHighlight":["title","body"],
 "attributesToCrop":["body"],"cropLength":40}

回傳的 _formatted.title 是 Nginx <em>反向</em><em>代理</em>完整教學——注意它把兩個詞分別包 <em>,所以前端不要假設整段關鍵字會被同一個標籤包住。

繁體中文的三個坑(實測數據)

這節是全篇最值得先看的地方。Meilisearch 對中文有 jieba 斷詞,但斷詞正確不代表使用體驗正確。實測踩到三個:

坑一:簡體查不到繁體,命中數直接歸零

繁體查詢命中簡體查詢命中
備份692备份0
機器學習1,286机器学习0
監控461监控199(靠單字重疊撿到,排序很亂)

官方文件寫中文有「變體正規化(kvariant conversion)」,但實測查詢端送簡體字不會自動折到繁體文件。對繁體站台這是硬傷,因為不少使用者是從簡體輸入法或手機拼音轉過來的。

解法是明寫同義詞,改設定不用重建索引(實測任務 3.6 ms):

{"synonyms": {"备份": ["備份"], "机器学习": ["機器學習"], "反代": ["反向代理"]}}

加了之後 备份 從 0 筆變 54 筆,第一名正確落在〈自架備份完整教學 2026〉。若站上文章量更大,維護同義詞表會變成日常工作——這也是為什麼有些人最後選擇在匯入階段就先把內容做一次簡繁正規化,而不是靠查詢端補。

坑二:兩字縮寫幾乎沒有救

查「反代」(反向代理的口語縮寫)→ 441 筆,第一名是 Jellyfin 教學。原因是 jieba 把「反代」切成單字,於是「反」「代」出現在哪就命中哪,相關度被攤平。加了 反代 → 反向代理 同義詞後變成 460 筆,第一名還是 Jellyfin。

結論很務實:中文縮寫要靠同義詞+標題權重一起調,單靠同義詞不會讓排序變好。更省力的做法是在文件端補:把常見縮寫塞進 description 或一個 aliases 欄位(設為 searchableAttributes 之一)。

坑三:中文錯字容錯是雙面刃

查「被份」(備份的錯字)→ 1,039 筆,但第一名跟備份完全無關。原因是 CJK 的 typo 規則容許單字替換,一個中文字錯了就變成「任何含『份』或『被』的文章」都在候選裡。

設定「被份」查詢結果
typoTolerance.enabled: true(預設)1,039 筆,前段無關
typoTolerance.enabled: false命中大幅收斂,只留字面完全相符的

要嘛接受「中文打錯字搜不到精準結果」,要嘛把 minWordSizeForTypos 調高、或對 CJK 關掉錯字容錯。沒有兩全的設定,這點官方文件也不會告訴你。

前端整合:一個純 JS 搜尋頁(實測可跑)

Meilisearch 的 CORS 預設開放(回應帶 access-control-allow-origin: *),所以靜態網站可以直接從瀏覽器打 API,只要用的是 search-only key。以下是實測過的完整頁面骨架:

<input id="q" type="search" placeholder="搜尋文章…" autocomplete="off">
<p id="status"></p>
<ul id="hits"></ul>

<script>
const HOST = "https://search.example.com";   // 你的 Meilisearch
const KEY  = "只放 search-only key";
const INDEX = "articles";

const qEl = document.getElementById("q");
const statusEl = document.getElementById("status");
const hitsEl = document.getElementById("hits");

async function search(q) {
  if (!q.trim()) { hitsEl.innerHTML = ""; statusEl.textContent = ""; return; }
  const res = await fetch(`${HOST}/indexes/${INDEX}/search`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "Authorization": `Bearer ${KEY}` },
    body: JSON.stringify({
      q, limit: 5,
      attributesToHighlight: ["title", "description"],
      attributesToCrop: ["description"], cropLength: 60
    })
  });
  const data = await res.json();
  statusEl.textContent = `${data.estimatedTotalHits} 筆結果(${data.processingTimeMs} ms)`;
  hitsEl.innerHTML = data.hits.map(h => `
    <li><a href="${h.url}">${h._formatted.title}</a>
    <div class="meta">${h._formatted.description || ""}</div></li>`).join("");
}

let timer;
qEl.addEventListener("input", () => {
  clearTimeout(timer);
  timer = setTimeout(() => search(qEl.value), 120);   // debounce
});
</script>

實測(120 篇的子集、headless Chrome 載入 ?q=備份):畫面顯示「44 筆結果(4 ms)」,渲染 5 筆連結,<em> 亮字正常。debounce 一定要加,因為 API 快到會讓每個按鍵都送一次查詢。

兩個小細節:

  • highlight 回傳的 HTML 直接塞 innerHTML 很方便,但如果索引內容可能有使用者輸入(留言、商品名),要先轉義再用,否則等於開了一個 XSS 入口。
  • estimatedTotalHits 是估估值,分頁請用 page + hitsPerPage(實測 備份 回 totalHits: 692、totalPages: 139)。

API key:三個實測結果決定你的架構

測試結果
不帶 Authorization 打 /indexes/articles/documents401 missing_authorization_header
帶錯誤金鑰403 invalid_api_key
用自建的 search-only key 搜尋200,正常回結果
同一把 search-only key 打 /documents(管理端點)403——權限邊界確實生效

正式做法:用 master key 建三把 key(search-only、admin、read-only),前端只拿 search-only,寫入端拿 admin。建立 key 時要留意 API 的欄位語意——實測 POST /keys 帶 description 建立的 key,回傳的 name 是 null,用 name 找會找不到,要靠 description 或直接記 uid。

營運:備份、升級、資源估算

需求做法實測備註
備份POST /dumps 產生 dump 到 /meili_data/dumpsdump 是官方建議的跨版本搬移方式;匯入前要先刪掉 data.ms
快照--schedule-snapshot + --snapshot-dir適合「同版本快速還原」,不適合跨版本
升級拉新映像 → 用 dump 重建官方文件建議用 dump 遷移,不要直接換版本號重啟
記憶體估算實測 1,912 篇 ≈ 171 MiB粗估每篇 85 KB,一萬篇約 1 GB 級
監控/health(免金鑰)+容器 healthcheckcompose 的 start_period 給 10 秒即可,實測 10 秒內 healthy

已知限制/沒做的事

誠實列出來,避免讀者以為這篇驗過全部:

  • 沒測語意/混合搜尋(hybrid search)。官方主打這個功能,但需要外接 embedding 模型(OpenAI、Cohere、Voyage 等),本文只測關鍵字全文搜尋。
  • 沒測企業版功能(sharding、S3 串流快照)。BUSL 授權不允許生產使用,也沒有評估。
  • 規模只到 1,912 篇。十萬篇以上的索引時間、記憶體與 facet 效能沒有實測,不要直接把上面的線性估算套到大型站台。
  • 繁簡同義詞只測了三個詞,沒有做全量簡繁對照表的成本評估。
  • 沒有測多語言混排(中英日同一個索引的一致性),只確認英文查詢(docker compose、kubernetes)正常。
  • 前端測試用的是 120 篇的子集(另開一個乾淨實例),不是 1,912 篇的完整索引;延遲數字(4 ms)因此只反映「API 與前端串通」而非最大規模表現。
  • 沒有做壓力測試(併發查詢數、長時間 soak test)。

誰適合、誰不需要

你的情況建議
靜態部落格/文件站超過 300 篇,想要站內搜尋適合,這是它最舒服的場景
電商/目錄站需要 facet、排序、typo 容錯適合,facet 與 filter 是強項
繁體中文為主,希望「打簡體也搜得到」可以,但要自己維護同義詞或匯入前正規化
需要跨版本、跨機器的高可用搜尋不適合單機版;企業版 sharding 需商業授權
要的是日誌搜尋/PB 級分析不適合,那是 Elasticsearch/Loki 的領域
只有 50 篇文章不需要。前端 flexsearch 就夠,少一個服務要顧

站內延伸閱讀

參考來源

(本文無聯盟連結。套件版本、星數與授權狀態以官方頁面與 GitHub API 於 2026-11-07 的顯示為準;效能數字為本文實測,環境與方法已列於文中。)

📬 訂閱 most.tw 電子報

每週精選 AI 工具教學與技術乾貨,直接送到你的信箱。免費、隨時可退訂。

訂閱即表示同意收到 most.tw 電子報,隨時可一鍵退訂。

💬 有問題想討論?加 LINE 聯絡我

歡迎透過 LINE 官方帳號直接留言,我會盡快回覆你的問題。

加入 LINE 好友