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:
| 查詢 | 命中數 | 處理時間 | 第一名 |
|---|---|---|---|
| 反向代理 | 44 | 3 ms | Nginx 反向代理完整教學 2026 |
| 備份 | 692 | 1 ms | 自架備份完整教學 2026(restic vs Kopia) |
| docker compose | 150 | 0 ms | Docker Compose 完整教學 2026 |
| 自架 | 1,193 | 0 ms | 自架 S3 物件儲存完整教學 2026 |
| 密碼 | 217 | 0 ms | Vaultwarden 完整教學 2026 |
| n8n | 22 | 0 ms | n8n 完整教學 2026 |
| k8s | 22 | 0 ms | K8s 面臨的實際駭客攻擊手法 |
| 監控 | 461 | 1 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/documents | 401 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/dumps | dump 是官方建議的跨版本搬移方式;匯入前要先刪掉 data.ms |
| 快照 | --schedule-snapshot + --snapshot-dir | 適合「同版本快速還原」,不適合跨版本 |
| 升級 | 拉新映像 → 用 dump 重建 | 官方文件建議用 dump 遷移,不要直接換版本號重啟 |
| 記憶體估算 | 實測 1,912 篇 ≈ 171 MiB | 粗估每篇 85 KB,一萬篇約 1 GB 級 |
| 監控 | /health(免金鑰)+容器 healthcheck | compose 的 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 就夠,少一個服務要顧 |
站內延伸閱讀
- 自架 S3 物件儲存完整教學 2026:MinIO 社群版停止維護後該選誰?——搜尋引擎的 dump 備份放哪裡,這裡有完整比較。
- Docker Compose 完整教學 2026:compose.yaml 到 AI models——本文的 compose 語法、healthcheck 與
!override陷阱都在這篇展開。 - Karakeep 完整教學 2026:2.9 萬星 AI 書籤管理,自動標籤+語意搜尋——同樣用 Meilisearch 當底層的另一個自架服務,可以看到它怎麼被別人接進去。
- Docmost 自架教學:開源 Notion 與 Confluence 替代方案——文件站如果之後要加全文搜尋,這篇的部署骨架可以直接沿用。
參考來源
- Meilisearch GitHub(v1.56.1、5.98 萬星、授權說明)
- 官方文件:Using Meilisearch with Docker
- 官方文件:Master key and API keys
- 官方文件:Language(Charabia 分詞與中文 jieba 支援)
- 官方文件:Search API reference
(本文無聯盟連結。套件版本、星數與授權狀態以官方頁面與 GitHub API 於 2026-11-07 的顯示為準;效能數字為本文實測,環境與方法已列於文中。)
