chundev
日期:2026-04-22 情境: 在一套鑑識分析平台中,需要對數百萬筆訊息、通話、帳號、位置做即時全文檢索 + 語意相似搜尋。本文整理底層演算法 + 5 個 index 的設計取捨。
Elasticsearch 快的根本原因是 Lucene 的 inverted index(每個 token → posting list)+ 跳躍指標 (skip lists) 讓 boolean 查詢退化成 set 交集,配合 BM25 評分公式做相關性排序。向量檢索則靠 HNSW (Hierarchical Navigable Small World) 圖把暴力 O(n) 比對降到 O(log n)。
實戰設計:把不同性質資料拆成多 index(共用 all_content 統一檢索欄位)、寫入時關 refresh 開大 buffer、查詢時混搭 BM25 + KNN。
關聯式資料庫做全文檢索通常用 LIKE '%keyword%' 或 tsvector。LIKE 是 O(n) 全表掃描,tsvector 雖然有 GIN index 但設計目標是中小規模。
Lucene(Elasticsearch 底層)走完全相反的路:inverted index(倒排索引)。
文件 1: "張三今天去了台北"
文件 2: "李四今天在台中"
正向索引(傳統):
文件 1 → ["張三", "今天", "去", "台北"]
文件 2 → ["李四", "今天", "在", "台中"]
倒排索引(Lucene):
"張三" → [1]
"李四" → [2]
"今天" → [1, 2]
"台北" → [1]
"台中" → [2]
查詢「今天 AND 台北」就變成 set 交集 [1,2] ∩ [1] = [1],不需要掃任何原始文件。每個 token 的 posting list 在 Lucene 內部用 FST (Finite State Transducer) 壓縮,在 disk 上順序讀取友善。
如果 “今天” 出現在 100 萬份文件裡、”台北” 出現在 10 萬份,AND 還是要逐一比對。Lucene 在 posting list 上維護 multi-level skip pointer:
posting list: [1, 5, 9, 12, 18, 25, 33, 41, 50, ...]
skip pointer (每 8 個一階): [1, 18, 50, ...]
skip pointer (每 64 個一階): [1, ........................... 50, ...]
要找 doc=33 不用走 1→5→9→12→18→25→33 七步,直接從 skip pointer 跳到 18 再走 25→33 三步。AND 兩個 list 時,從稀疏端開始用 skip 往稠密端「找下一個 ≥ 當前」的位置 — 平均 O((m+n) log(N/m)) 而不是 O(N)。
PUT /messages
{
"settings": {
"analysis": {
"analyzer": {
"default": { "type": "standard" }
}
}
}
}
Standard analyzer 會把「張三今天去了台北」轉成 tokens ["張", "三", "今", "天", "去", "了", "台", "北"](CJK 預設按字切)。中文常用 icu_analyzer 或 smartcn 改善斷詞,但對「人名搜尋」場景反而單字索引更不會漏。
實戰上鑑識資料有大量人名 / 帳號 / 別名,「按字索引 + match 搜尋」比「斷詞 + match_phrase」漏看少。
ES 預設用 BM25(Best Matching 25)打分,公式:
score(q, d) = Σ IDF(qi) · (f(qi, d) · (k1 + 1))
/ (f(qi, d) + k1 · (1 - b + b · |d|/avgdl))
各項含義:
f(qi, d):term frequency — qi 在文件 d 出現幾次|d|:文件 d 的長度avgdl:所有文件平均長度IDF(qi) = log((N - n + 0.5) / (n + 0.5) + 1) — 出現越少的字權重越高k1 = 1.2:term frequency 飽和點(一個詞出現 1 次和 100 次的差距會收斂)b = 0.75:文件長度懲罰(長文件被適度扣分,避免 term 多就贏)直覺理解:
vs 老一代的 TF-IDF:BM25 加了 saturation 和 length normalization,避免 term 多就無腦贏。
該鑑識分析平台拆成 5 個 index 而不是塞一個大 index,每個對應一種解析後的資料:
| Index | 主資料 | shards/replicas | 保留期 | 特殊欄位 |
|---|---|---|---|---|
messages |
聊天訊息 | 3/1 | 365 天 | embedding (dense_vector 1024-dim), body (text) |
accounts |
使用者帳號 | 1/1 | 永久 | account_meta.username/displayName |
calls |
通話紀錄 | 2/1 | 365 天 | call_meta.direction/duration/status |
locations |
位置點 | 2/1 | 365 天 | location (geo_point) |
conversations |
聊天室 | 1/1 | 永久 | conversation_meta.name/messageCount |
body 是 text、帳號的 username 是 keyword,名稱類似但 type 不同 — 在同一 index 會吵起來。copy_to 統一檢索欄位每個 index 都共用一組欄位:
{
pg_id: keyword, // PostgreSQL 主鍵(外連用)
pg_exhibit_id: keyword, // 證物 ID(routing key + filter 用)
timestamp: date,
all_content: text, // ✨ 核心:統一全文搜尋欄位
sender: {
pg_party_id: keyword,
identifier: keyword,
aliases: { type: keyword, copy_to: "all_content" }
},
participants: nested {
pg_party_id: keyword,
aliases: { type: keyword, copy_to: "all_content" }
}
}
關鍵 trick:copy_to: "all_content"
把 sender.aliases、participants.aliases、訊息 body、通話 caller_name 全部複製進 all_content 一個欄位。前端搜尋只要 match all_content 就能跨欄位命中。
否則使用者搜「張三」會需要 multi_match 列出所有可能欄位,每加一個欄位都要改前端 — copy_to 把這個成本轉到 index time(一次性)。
// 用 object 會踩坑:
participants: [
{ identifier: "alice", role: "sender" },
{ identifier: "bob", role: "receiver" }
]
ES 預設把 array of object 拍扁成:
participants.identifier: ["alice", "bob"]
participants.role: ["sender", "receiver"]
查詢 identifier=alice AND role=receiver 會錯誤命中(因為 alice 和 receiver 都在)。
nested type 把每個 element 當獨立子文件存,查詢時用 nested query 確保條件在同一物件內 match。代價是寫入慢、子文件數量算 doc 數量上限 — 但鑑識場景必要。
訊息 index 多了 embedding: dense_vector(1024-dim) 欄位,存的是 BGE-M3 把訊息文字轉成的語意向量。
要找跟「我下午去匯款」最相似的 10 筆訊息,最直白做法是把 query 向量跟資料庫每一筆都算 cosine similarity 排序 — 100 萬筆就是 100 萬次浮點運算。即使單次只要 1μs,也要 1 秒。
HNSW 的核心是「多層導航圖」:
graph TB
subgraph L2 ["Layer 2 (稀疏 - 樞紐節點)"]
A2((A)) --- E2((E))
end
subgraph L1 ["Layer 1 (中等)"]
A1((A)) --- C1((C)) --- E1((E)) --- G1((G))
end
subgraph L0 ["Layer 0 (全量節點)"]
A0((A)) --- B0((B)) --- C0((C)) --- D0((D)) --- E0((E)) --- F0((F)) --- G0((G)) --- H0((H))
end
A2 -.->|跳到下層| A1
E2 -.->|跳到下層| E1
A1 -.->|跳到下層| A0
C1 -.->|跳到下層| C0
E1 -.->|跳到下層| E0
G1 -.->|跳到下層| G0
style A2 fill:#fef3c7,stroke:#92400e,stroke-width:2px
style E2 fill:#fef3c7,stroke:#92400e,stroke-width:2px
搜尋演算法:
時間複雜度近 O(log N),記憶體需求 ~4×D bytes/vec(D = 維度)。100 萬個 1024-dim float 向量約 4 GB — 全載 RAM 沒問題。
ES 內部用 Lucene 9.0+ 的 KnnVectorsFormat 實作 HNSW,每個 segment 一份 graph。
const queryVector = await getEmbedding("詐騙集團匯款指示");
await elkClient.search({
index: "messages",
knn: {
field: "embedding",
query_vector: queryVector,
k: 100, // 最終要回幾筆
num_candidates: 1000, // HNSW 在每個 shard 找的候選數
},
_source: ["pg_id", "body", "timestamp"],
});
num_candidates 越大召回越好但越慢,通常設為 10 × k。
純向量搜尋容易召回「語意相關但字面無關」的(好/壞兼有)。實戰常做混合:
await elkClient.search({
index: "messages",
query: {
bool: {
must: [{ exists: { field: "embedding" } }],
should: [{ match: { body: { query, boost: 1 } } }], // BM25 權重 1
},
},
knn: {
field: "embedding",
query_vector: queryVector,
k: 100,
num_candidates: 1000,
boost: 2, // KNN 權重 2
},
min_score: 2.0,
});
最終 score = BM25 score × 1 + KNN cosine × 2。實戰反覆調 boost 找平衡點,2:1 偏向語意 / 1:2 偏向關鍵字。
ES 預設每 1 秒 refresh 一次(讓新文件可被搜尋),refresh 會在每個 shard 開新 segment、commit 到 OS file cache。批量寫入時這 1 秒 refresh 是吞吐殺手。
寫入前:
await elkClient.indices.putSettings({
index: "messages",
body: {
"index.refresh_interval": "-1", // 完全停 refresh
"index.number_of_replicas": 0, // 關副本
},
});
await elkClient.cluster.putSettings({
transient: { "indices.memory.index_buffer_size": "30%" }, // 加大 buffer
});
寫入後:
await elkClient.indices.putSettings({
index: "messages",
body: {
"index.refresh_interval": "10s",
"index.number_of_replicas": 1,
},
});
await elkClient.indices.refresh({ index: "messages" }); // 手動 refresh 一次
實戰測過,這套設定組合可讓 bulk 寫入吞吐量提升 5-10 倍(從 ~5K docs/s 到 ~30K-50K docs/s)。
b=0.75) 是 vs TF-IDF 最大改進 — 不會被長文件「騙分」。copy_to 是低成本的多欄位統一檢索 — 把成本轉到 index time,前端只要 query 一個欄位。對鑑識資料這種「同一個概念散在多個欄位」的場景 game changer。num_candidates。dense_vector field 與 knn query 規範