從「字串距離」到「近似匹配演算法」與「索引結構」,一份內含 9 個手動推演例子、完整可編譯 C++ 程式碼的中階教材。


目錄

  1. 問題定義與動機
  2. 名詞速覽與分類
  3. Hamming Distance(漢明距離)
  4. Levenshtein Distance(編輯距離)
  5. Damerau–Levenshtein Distance
  6. Longest Common Subsequence (LCS)
  7. Jaro 與 Jaro-Winkler 相似度
  8. N-gram / Q-gram 與集合相似度
  9. TF-IDF 與餘弦相似度(簡介)
  10. Soundex(語音編碼)
  11. 文本中的近似字串匹配
  12. BK-Tree(度量空間最近鄰)
  13. Trie + 編輯距離(字典模糊查詢)
  14. 向量化語意搜尋(Embedding-based Semantic Search)
  15. 九個完整手動推演例子
  16. 完整 C++ 程式碼
  17. 實務應用與工程考量
  18. 與其他主題的比較
  19. 練習題與參考

1. 問題定義與動機

精確字串匹配 (exact matching) — 例如 KMP — 要求 pattern 在 text 中逐字元一模一樣出現。但真實世界並非如此乾淨:

  • 使用者打錯字:helo wrld 應該找到 hello world
  • 名字寫法差異:Smyth vs. SmithJohn Doe vs. Jon Doe
  • 自動拼字檢查/拼字建議。
  • 生物資訊:DNA 序列含突變、插入、刪除。
  • OCR 結果有少量字元錯誤。
  • 自然語言形式變化、簡寫、語音相似。

模糊搜尋 (Fuzzy Search / Approximate String Matching) 的目標:

給定字串 (s_1, s_2),量化它們的「相似程度」;或給定文本 (T) 與模式 (P),找出 (T) 中容許 (k) 個錯誤仍能匹配 (P) 的位置。

模糊搜尋的核心可分兩件事:

  1. 定義「距離 / 相似度」:Hamming、Levenshtein、Damerau-Levenshtein、Jaro-Winkler、Jaccard…等。
  2. 針對大規模資料做高效查詢:Bitap、BK-Tree、Trie + DP、SymSpell、Aho-Corasick 變體…等。

2. 名詞速覽與分類

2.1 距離 vs. 相似度

  • 距離 (distance):值越小越像。如 Levenshtein 距離。
  • 相似度 (similarity):值越大越像,通常在 ([0,1])。如 Jaro。
  • 常可互換:例如 sim = 1 - dist / maxLen

2.2 度量 (Metric) 的四個條件

一個距離函數 (d(x, y)) 是「度量」當且僅當對任意 (x, y, z):

  1. 非負性:(d(x, y) \ge 0)。
  2. 同一性:(d(x, y) = 0 \iff x = y)。
  3. 對稱性:(d(x, y) = d(y, x))。
  4. 三角不等式:(d(x, z) \le d(x, y) + d(y, z))。

是否為「度量」很重要 — 三角不等式讓我們可以建立 BK-Tree 之類的索引結構來剪枝。

距離 度量? 備註
Hamming(同長) 等長字串才定義
Levenshtein 經典度量
Damerau-Levenshtein(真) OSA 變體不一定滿足三角不等式
LCS(用 (n+m-2 \cdot \text{LCS})) 等價於只允許插入/刪除的編輯距離
Jaro / Jaro-Winkler 不滿足三角不等式(是「相似度」,非度量)
Jaccard 1−Jaccard 是度量 Jaccard 本身是相似度

2.3 兩類問題

  • 點對點 (pairwise):兩個字串的距離 / 相似度。(O(nm)) 為主。
  • 字典查詢 (dictionary lookup):在大量字串中,找出與查詢字串距離 (\le k) 的所有候選。需要索引結構。
  • 文本內近似匹配 (text search):在長 text 中找出 pattern 的「近似出現位置」。Bitap / DP / Ukkonen。

3. Hamming Distance(漢明距離)

3.1 定義

等長字串 (a, b):

[ H(a, b) = \big|{ i : a_i \neq b_i }\big| ]

也就是「對應位置不同的字元數」。

3.2 性質

  • 只對等長字串有定義。
  • 是度量。
  • 不能處理插入/刪除(只能 substitution)。

3.3 範例

H("karolin", "kathrin") = 3
        ^^^   ↑差異在 [2], [3], [4]: r-t, o-h, l-r

H("2173896", "2233796") = 3
   ↑   ↑ ↑   差異在 [1], [3], [4]

3.4 用途

  • 通訊與錯誤更正碼(漢明碼、CRC)。
  • DNA 比對(無 indel 的情境)。
  • Locality-Sensitive Hashing (LSH) 中的相似簽章比較。

4. Levenshtein Distance(編輯距離)

4.1 定義

Levenshtein 距離 (L(a, b)):將 (a) 變成 (b) 所需的最少編輯操作數,操作共 3 種:

  1. 插入 (Insert) 一個字元
  2. 刪除 (Delete) 一個字元
  3. 取代 (Substitute) 一個字元

例:L("kitten", "sitting") = 3

kitten
sitten   (k → s)
sittin   (e → i)
sitting  (insert g)

4.2 遞迴定義

令 (L(i, j)) = a[0..i-1]b[0..j-1] 的編輯距離。

[ L(i, j) = \begin{cases} i & j = 0 \ j & i = 0 \ L(i-1, j-1) & a_{i} = b_{j} \ 1 + \min{L(i-1, j),\ L(i, j-1),\ L(i-1, j-1)} & \text{otherwise} \end{cases} ]

三個 min 對應:刪除 (a_i)、插入 (b_j)、取代 (a_i \to b_j)。

4.3 動態規劃(自底向上)

def lev(a, b):
    n, m = len(a), len(b)
    dp = [[0]*(m+1) for _ in range(n+1)]
    for i in range(n+1): dp[i][0] = i
    for j in range(m+1): dp[0][j] = j
    for i in range(1, n+1):
        for j in range(1, m+1):
            cost = 0 if a[i-1] == b[j-1] else 1
            dp[i][j] = min(dp[i-1][j] + 1,
                           dp[i][j-1] + 1,
                           dp[i-1][j-1] + cost)
    return dp[n][m]
  • 時間:(O(nm))
  • 空間:(O(nm))(可優化為 (O(\min(n, m))),只保留上一列)

4.4 空間優化

只需上一列就能算出當前列,故空間 (O(\min(n, m))):

int levFast(const string& a, const string& b) {
    if (a.size() < b.size()) return levFast(b, a);
    int n = a.size(), m = b.size();
    vector<int> prev(m+1), curr(m+1);
    for (int j = 0; j <= m; ++j) prev[j] = j;
    for (int i = 1; i <= n; ++i) {
        curr[0] = i;
        for (int j = 1; j <= m; ++j) {
            int cost = (a[i-1] == b[j-1]) ? 0 : 1;
            curr[j] = min({prev[j]+1, curr[j-1]+1, prev[j-1]+cost});
        }
        swap(prev, curr);
    }
    return prev[m];
}

4.5 回溯:重建編輯操作

dp[n][m] 反推到 dp[0][0],每步判斷該選哪個運算。完整實作見 第 16 節levenshteinOps

4.6 進階優化(簡介)

  • Ukkonen 截斷:若只想知道距離是否 (\le k),只需計算對角線「(\pm k) 寬度」的格子,時間降為 (O(k \cdot \min(n, m)))。
  • 位元平行 (Bit-Parallel):Myers 的位元平行算法,(O(\lceil m/w \rceil \cdot n)),(w) 為機器字寬。
  • 長字串:用 Hirschberg 演算法在 (O(\min(n, m))) 空間內仍可回溯。

5. Damerau–Levenshtein Distance

5.1 動機

打字常見錯誤之一是相鄰字元交換 (transposition)

  • theteh
  • causecuase

Levenshtein 把這算 2 次操作(刪除+插入,或兩次取代)。Damerau-Levenshtein 把 transposition 算 1 次操作。

5.2 兩種版本

版本 名稱 限制 是否度量
Restricted OSA (Optimal String Alignment) 同一對字元最多 transpose 一次 否(不滿足三角不等式)
Unrestricted True Damerau-Levenshtein 任意 transposition

實作上 OSA 簡單許多(DP 多一條轉移);True DL 需要額外的索引維護。教學上 OSA 已足夠展示概念。

5.3 OSA 遞迴

[ D(i, j) = \min \begin{cases} D(i-1, j) + 1 & \text{(delete)} \ D(i, j-1) + 1 & \text{(insert)} \ D(i-1, j-1) + [a_i \neq b_j] & \text{(match/substitute)} \ D(i-2, j-2) + 1 & \text{if } i, j \ge 2 \land a_i = b_{j-1} \land a_{i-1} = b_j \text{ (transposition)} \end{cases} ]

5.4 例:"ca" vs "ac"

  • Levenshtein 距離 = 2(兩次 substitute)。
  • DL 距離 = 1(一次 transpose)。

DP 表(a = "ca", b = "ac"):

i \ j ε a c
ε 0 1 2
c 1 1 1
a 2 1 1dp[0][0] + 1 = 1,因為 transpose 條件成立

6. Longest Common Subsequence (LCS)

6.1 定義

子序列 (subsequence):在不改變元素相對順序下、可任意刪除元素而得的序列。例如 "BCAB""ABCBDAB" 的子序列。

LCS:兩字串共同最長子序列的長度(或該序列本身)。

6.2 與編輯距離的關係

若編輯距離只允許「插入」與「刪除」(無 substitute),則:

[ \text{EditDist}_{\text{insert/delete}}(a, b) = |a| + |b| - 2 \cdot \text{LCS}(a, b) ]

LCS 常用來:

  • 計算 diff 工具的最小差異。
  • 蛋白質、DNA 序列保留模式比對。
  • 抄襲偵測。

6.3 DP 遞迴

[ L(i, j) = \begin{cases} 0 & i = 0 \text{ 或 } j = 0 \ L(i-1, j-1) + 1 & a_i = b_j \ \max(L(i-1, j),\ L(i, j-1)) & a_i \neq b_j \end{cases} ]

  • 時間:(O(nm))
  • 空間:(O(nm))(求長度可降為 (O(\min(n,m)));要重建 LCS 字串可用 Hirschberg)

7. Jaro 與 Jaro-Winkler 相似度

7.1 應用情境

設計用於「比對人名」這類短字串。對「短字串、前綴相似」特別敏感。

7.2 Jaro 相似度

定義:

[ \text{jaro}(s_1, s_2) = \begin{cases} 0 & m = 0 \ \dfrac{1}{3}\left( \dfrac{m}{|s_1|} + \dfrac{m}{|s_2|} + \dfrac{m - t/2}{m} \right) & m > 0 \end{cases} ]

其中:

  • (m) = 匹配字元數(兩字串中位置相近且字元相同的字元配對數量)
  • (t) = transposition 數(已匹配字元中,相對順序顛倒的字元數,再除以 2)
  • 「位置相近」定義為相距 (\lfloor \max(|s_1|, |s_2|) / 2 \rfloor - 1) 以內

7.3 Jaro-Winkler

在 Jaro 的基礎上,對前綴相同的字串加分(最多前 4 個字元):

[ \text{jw}(s_1, s_2) = \text{jaro}(s_1, s_2) + \ell \cdot p \cdot (1 - \text{jaro}(s_1, s_2)) ]

  • (\ell) = 共同前綴長度(取 (\le 4))
  • (p) = 縮放因子,預設 (0.1)(不可超過 (0.25),否則可能超過 1)

7.4 範例:MARTHA vs MARHTA

  • (|s_1| = |s_2| = 6),匹配窗 = (\lfloor 6/2 \rfloor - 1 = 2)。
  • 匹配字元:M, A, R, T, H, A 全 6 個。
  • 已匹配字元順序:s1 = M A R T H A、s2 = M A R H T A。 比對發現 T-HH-T 順序不同 → (t = 2),半 transposition = 1。
  • Jaro = (\frac{1}{3}(\frac{6}{6} + \frac{6}{6} + \frac{6-1}{6}) = \frac{1}{3} \cdot \frac{17}{6} = \frac{17}{18} \approx 0.9444)
  • 共同前綴 MAR 長度 3:JW = (0.9444 + 3 \cdot 0.1 \cdot (1 - 0.9444) \approx 0.9611)

8. N-gram / Q-gram 與集合相似度

8.1 N-gram 定義

對字串 (s),其 n-gram 集合 = { s[i..i+n-1] : 0 ≤ i ≤ |s|-n }

  • bigrams("night") = {"ni", "ig", "gh", "ht"}
  • trigrams("night") = {"nig", "igh", "ght"}

可加入 padding 字元(如 $$)以強調前綴/後綴:

  • bigrams_padded("night") = {"$n", "ni", "ig", "gh", "ht", "t$"}

8.2 集合相似度

對 n-gram 集合 (A)、(B):

  • Jaccard 係數:(J(A, B) = \dfrac{|A \cap B|}{|A \cup B|})
  • Dice 係數:(D(A, B) = \dfrac{2|A \cap B|}{|A| + |B|})
  • Overlap 係數:(O(A, B) = \dfrac{|A \cap B|}{\min(|A|, |B|)})

三者間關係:(D = \dfrac{2J}{1 + J});當 (|A| = |B|) 時 (D = O) 大致。

8.3 範例:night vs nacht

  • A = {ni, ig, gh, ht} (4)
  • B = {na, ac, ch, ht} (4)
  • 交集 = {ht} (1)
  • 聯集 = {ni, ig, gh, ht, na, ac, ch} (7)
  • Jaccard = 1/7 ≈ 0.143
  • Dice = 2·1/(4+4) = 0.25

8.4 用 n-gram 做候選篩選

如果 query 與 candidate 編輯距離 (\le k),則它們至少有 (\max(|A|, |B|) - k \cdot n) 個共同 n-gram。可預先用 inverted index 找出候選集合:

ngram -> [所有包含此 ngram 的字串]

查詢時找出與 query n-gram 至少有 (t) 個重疊的字串作為候選,再用 Levenshtein 精算。這是大型字典模糊查詢的工業界主流方案之一。


9. TF-IDF 與餘弦相似度(簡介)

當「字串」其實是「文件」或「長句子」時,常用以「詞」為單位的向量表示。

  • TF (Term Frequency):詞 (w) 在文件中出現次數(或對總詞數正規化)。
  • IDF (Inverse Document Frequency):(\log(N / df(w))),反映詞的「稀有度」。
  • TF-IDF:(\text{tfidf}(w, d) = \text{tf}(w, d) \cdot \text{idf}(w))。

文件向量化後:

[ \cos(\vec{d_1}, \vec{d_2}) = \dfrac{\vec{d_1} \cdot \vec{d_2}}{|\vec{d_1}| \cdot |\vec{d_2}|} \in [0, 1] ]

何時用?

  • 字串很長(句子、段落、文件)。
  • 在意「重要詞共現」而非「字元拼錯」。
  • 搭配 Lucene、Elasticsearch 等全文檢索引擎。

10. Soundex(語音編碼)

10.1 動機

「相似的英文發音」不一定字面相似 — 例如 RobertRupert。語音編碼 (phonetic algorithm) 把單字映射到「發音碼」,相同發音碼 ≈ 發音相近。

10.2 Soundex 規則

  1. 保留首字元(大寫)。
  2. 把其餘字元用下表映射:
數字 字母
1 b, f, p, v
2 c, g, j, k, q, s, x, z
3 d, t
4 l
5 m, n
6 r
  1. 母音 (a, e, i, o, u, y) 被忽略,但會「重設」相鄰判斷
  2. h, w 不計入不重設相鄰判斷(兩個同碼字母中間夾 h/w 視為一個)。
  3. 相鄰相同碼字母合併為一個。
  4. 補零或截斷至「1 字母 + 3 數字」共 4 字元。

10.3 範例追蹤

單字 過程說明 Soundex
Robert R → R, o(母音) → 重設, b→1, e→重設, r→6, t→3 R163
Rupert R → R, u→重設, p→1, e→重設, r→6, t→3 R163
Rubin R → R, u→重設, b→1, i→重設, n→5, 補零 R150
Ashcraft A → A, s→2, h→不重設, c→2(與前同碼 → 合併), r→6, a→重設, f→1 A261
Tymczak T → T, y→重設, m→5, c→2, z→2(合併), a→重設, k→2 T522
Pfister P → P, f→1(與 P 同碼 → 合併), i→重設, s→2, t→3, e→重設, r→6 P236
Honeyman H → H, o→重設, n→5, e→重設, y→重設, m→5, a→重設, n→5 H555

重點: 用 Soundex 可以把姓名標準化到候選池,再用 Levenshtein 精算。

10.4 其他語音演算法

名稱 特色
Soundex 1918 年,最古老,簡單但精度有限
Metaphone 1990,處理更多英語規則
Double Metaphone 處理多種歐洲語言;可輸出兩個碼
NYSIIS 紐約州身分證系統
Caverphone 紐西蘭電話簿開發

11. 文本中的近似字串匹配

問題:給定長 text (T)((N) 字元)、pattern (P)((M) 字元)、容錯 (k),找出 (T) 中所有「以 (\le k) 個編輯操作能對齊 (P)」的結束位置。

11.1 動態規劃搜尋版

修改 Levenshtein DP:

  • dp[0][j] = j(原 Levenshtein)
  • dp[i][0] = 0(差異) — 因為我們允許 pattern 從 text 任意位置開始匹配(前綴自由跳過)。

每處理完 text 的第 (i) 個字元,若 dp[i][M] ≤ k,則 text 在位置 (i-1) 處有一個結束位置滿足條件的近似匹配。

  • 時間:(O(NM))
  • 空間:(O(M))(滾動陣列)

11.2 Bitap / Shift-And 演算法

把「目前匹配狀態」用一個 (M) 位的位元向量表示:

  • 第 (j) 位 = 1pattern[0..j] 是「text 當前所看字元結束處的後綴」。

預處理:建立字元 mask B[c],第 (j) 位為 1 iff pattern[j] == c

狀態更新:對每個 text 字元 (c):

[ R \leftarrow ((R \ll 1)\ |\ 1)\ \&\ B[c] ]

若 (R) 第 (M-1) 位為 1 → 匹配,位置為當前 text 索引減 (M-1)。

時間:每步 (O(1)) 位元運算((M \le w) 時),總 (O(N))。當 (M > w) 需多字塊處理,仍很快。

11.3 Bitap 近似版(Wu–Manber, k errors)

維護 (k+1) 個位元向量 (R_0, R_1, \ldots, R_k):

[ R_d^{\text{new}} = \underbrace{((R_d^{\text{old}} \ll 1)\ \&\ B[c])}{\text{match}}\ |\ \underbrace{(R}^{\text{old}} \ll 1){\text{substitution}}\ |\ \underbrace{(R}^{\text{new}} \ll 1){\text{deletion (pattern 多 1 字)}}\ |\ \underbrace{R\ |\ 1 ]}^{\text{old}}}_{\text{insertion (text 多 1 字)}

若任一 (R_d) 第 (M-1) 位為 1 → 找到「(\le d) 錯誤」的匹配。

  • 時間:(O(k \cdot N)),當 (M \le w)。
  • 通常比 DP (O(NM)) 快很多((k \ll M) 時尤甚)。

12. BK-Tree(度量空間最近鄰)

12.1 動機

對於「字典模糊查詢」:給定 query word (q) 與容錯 (k),從含 (D) 個單字的字典中找所有距離 (\le k) 的單字。

  • 暴力法:(O(D \cdot |q| \cdot |w|))(對每個 (w) 算 Levenshtein),大字典時太慢。
  • BK-Tree(Burkhard-Keller, 1973)利用三角不等式做剪枝,平均查詢時間遠小於 (O(D))。

12.2 結構

  • 任挑一個字當 root。
  • 每個 node 維護一個 map<int, child>:key 是「該 child 與此 node 距離」。
  • 插入新字 (w):從 root 開始,算 (d = \text{dist}(w, \text{cur})),若 cur 有 child 對應 key (d),則往下走;否則建立 child。

12.3 查詢(三角不等式剪枝)

對 query (q)、容錯 (t):DFS 拜訪 root:

function dfs(node):
    d = dist(q, node.word)
    if d <= t:
        results.add(node.word)
    for (k, child) in node.children:
        if d - t <= k <= d + t:
            dfs(child)

剪枝原理:若 child 與 node 的距離為 (k),根據三角不等式:

[ |d - k| \le \text{dist}(q, \text{child}) \le d + k ]

若 (d + k < ?) 等價於我們想找 (\text{dist}(q, \text{child}) \le t),則必須有 (d - t \le k \le d + t)。所以區間外的 child 全部剪掉。

12.4 效率

  • 建構:(O(D \cdot \overline{D})),(\overline{D}) 為平均樹深。
  • 查詢:對英文字典實測,找距離 (\le 2) 的單字通常拜訪 < 5% 節點。
  • 限制:必須是「度量」,所以 Jaro-Winkler 不能直接用(但 1 − JW 也不行,因為不滿足三角不等式)。

13. Trie + 編輯距離(字典模糊查詢)

另一條路:把字典存成 Trie(前綴樹),DFS 遍歷時同步計算 Levenshtein DP 列(每往下走一個字元,新增 DP 表的一列)。當 DP 列最小值 > k 時剪枝整個子樹。

search(node, prefix, prevRow):
    for c, child in node.children:
        currentRow = compute_next_row(prevRow, c, query)
        if currentRow[len(query)] <= k and node.isWord:
            results.add(prefix + c, currentRow[len(query)])
        if min(currentRow) <= k:
            search(child, prefix + c, currentRow)

優點:對「短查詢、大字典、低容錯」極快;可同時找出多個結果。

實務上 Norvig spell-checkerSymSpell 是這個思路的不同變體,後者更激進地預先生成所有「刪除版本」做 O(1) lookup。


前面 3–13 節介紹的方法全部基於字串的符號層(字元、n-gram、語音碼),它們的「相似」是「長相相似」。但現實中有大量場景需要的是「意思相似」:

  • 汽車 vs 轎車:Levenshtein 距離不為 0,但語意幾乎相同。
  • apple vs 蘋果:字面零交集,語意相同(跨語言)。
  • cat sitting on a mat vs feline resting on a rug:字面差異大,語意相近。
  • bank (河岸) vs bank (銀行):字面完全一樣,但語意完全不同(一詞多義)。

要解決上述問題,當代主流做法是用機器學習模型把文字映射到語意向量空間 (embedding space),然後用幾何距離量化相似度。本章介紹這個範式 — 也是 ChatGPT、檢索增強生成 (RAG)、現代搜尋引擎背後的核心技術之一。

14.1 從「字面相似」到「語意相似」

語意搜尋的核心命題:

用一個函數 (f: \text{Text} \to \mathbb{R}^d) 把任意文字轉成 (d) 維向量,使得「語意相近的文字 ↔ 向量相近」。

  • (d) 通常為 100 ~ 4096。
  • 同義詞、跨語言、概念上下位、語境消歧都可由訓練資料學到。
  • 比對在向量空間完成,與原始字串長相無關。

這個 (f) 來自預訓練語言模型(Word2Vec、BERT、SBERT、OpenAI embedding…),是這 10 年自然語言處理最重要的進展之一。

14.2 Embedding 簡介

14.2.1 詞向量 (Word Embeddings)

模型 年份 維度 訓練方式
Word2Vec 2013 100–300 CBOW / Skip-gram
GloVe 2014 50–300 全局共現矩陣分解
FastText 2016 100–300 加入 subword n-gram,能處理未登錄詞

著名語意算術:vec(king) - vec(man) + vec(woman) ≈ vec(queen)

14.2.2 句/段/文件向量 (Sentence/Paragraph/Document Embeddings)

模型 維度 特色
Doc2Vec 100–300 Word2Vec 的延伸
Universal Sentence Encoder (USE) 512 Google 早期通用句向量
Sentence-BERT (SBERT) 384–1024 雙塔 BERT 微調,可直接 cosine 比對
OpenAI text-embedding-3-small 1536 商用 API;通用
OpenAI text-embedding-3-large 3072 高精度版
BGE / E5 / GTE 系列 384–1024 開源高品質中英多語模型

14.2.3 取得 embedding 的常見管道

  1. 預訓練模型推論(無訓練成本) - HuggingFace sentence-transformers - OpenAI Embeddings API - Cohere、Voyage、Mistral 等供應商
  2. 領域微調 (fine-tune):用 contrastive learning 在你的資料上微調 SBERT。
  3. 多模態:CLIP(圖文共用空間)、Whisper(音訊)等也可產 embedding。

14.3 向量相似度與距離

設 (\vec u, \vec v \in \mathbb{R}^d):

名稱 公式 範圍 是否度量 備註
餘弦相似度 (\cos\theta = \dfrac{\vec u \cdot \vec v}{|\vec u||\vec v|}) ([-1, 1]) 否 (相似度) 最常用;對量級不敏感
內積 / 點積 (\vec u \cdot \vec v) (\mathbb{R}) L2 正規化後等於 cosine
L2 距離 (|\vec u - \vec v|_2) ([0, \infty)) 度量;最常用「距離」
L1 距離 (\sum_i |u_i - v_i|) ([0, \infty)) 對 outlier 較穩
Angular Distance (\arccos(\cos\theta)/\pi) ([0, 1]) cosine 的「真度量」版

關鍵技巧:若先把所有向量做 L2 正規化(單位長度),有下列等價關係:

[ |\vec u - \vec v|^2 = 2 - 2 \cdot (\vec u \cdot \vec v) = 2(1 - \cos\theta) ]

意即 cosine、內積、L2 三者排序結果完全一致,但內積計算最快(單一 dot product)。因此現代向量搜尋幾乎都先 L2 正規化再用內積

14.4 暴力 kNN

問題:給定 query 向量 (\vec q) 與集合 ({\vec v_1, \ldots, \vec v_N}),找前 (k) 個最相似者。

function bruteKNN(q, vectors, k):
    heap = empty min-heap of size k
    for v in vectors:
        s = cos(q, v)
        if heap.size < k:
            heap.push((s, v))
        elif s > heap.top.score:
            heap.pop(); heap.push((s, v))
    return heap sorted descending
  • 時間:(O(N \cdot d + N \log k))
  • 空間:(O(N \cdot d))
  • 適用:(N \le 10^4),或要求 100% 精確結果。

當 (N) 上百萬甚至十億時,必須改用近似最近鄰 (ANN)。

14.5 近似最近鄰 (ANN) 總覽

演算法 索引時間 查詢時間 索引空間 召回率 代表實作
Random Projection LSH (O(NdL)) (O(L)) + 候選精算 (O(NL)) FALCONN
IVF (Inverted File) (O(Nd k_c)) 快(先選 cluster) FAISS
HNSW (O(N \log N)) (O(\log N)) (O(NM)) 極高 hnswlib, FAISS
Product Quantization (PQ) (O(N)) 極快 極小 中-高 FAISS
Annoy (Random Projection Tree) (O(N \log N)) (O(\log N)) (O(N)) Spotify Annoy
ScaNN (O(N)) 極快 極高 Google ScaNN
DiskANN 微軟(支援 SSD)

工業界 2020 年後的事實標準是 HNSW(精度高、API 簡單)與 IVF+PQ(記憶體小、適合上億)的組合。

14.6 Random-Hyperplane LSH 詳解

Locality-Sensitive Hashing (LSH)」概念:設計一個 hash 函數族 (\mathcal H),使得「相似的點碰撞機率大、不相似的點碰撞機率小」。

cosine 相似度,最經典的是 Random Hyperplane Projection

演算法

  1. 隨機抽取 (L) 個高斯隨機向量 (\vec r_1, \ldots, \vec r_L \in \mathbb{R}^d)(即 (L) 個超平面的法向量)。
  2. 對任意向量 (\vec v) 計算其 (L) 位簽章:

[ h_l(\vec v) = \begin{cases} 1 & \text{if } \vec r_l \cdot \vec v \ge 0 \ 0 & \text{otherwise} \end{cases} ]

  1. 把所有資料向量分桶:相同 (L) 位簽章 → 同一桶。
  2. 查詢時:算 query 簽章,到「同桶 + Hamming 距離 (\le t) 的桶」撈候選集合,再用精確 cosine 重排。

為什麼會成立?

核心定理 (Goemans-Williamson, 1995):

[ \Pr_{h \in \mathcal H}\big[h(\vec u) = h(\vec v)\big] = 1 - \frac{\theta(\vec u, \vec v)}{\pi} ]

夾角越小(語意越近)→ 碰撞機率越高,這正是 LSH 的核心性質。

參數取捨

  • 增加 (L):每個桶平均更小、誤判更少,但同桶機率指數下降 → 召回率變差。
  • 減少 (L):召回率高,但每桶候選變多。
  • 工程上通常用「多組 (table) LSH」:建 (M) 組各 (L) 位的簽章,取聯集,能同時拉高召回率與精度。

14.7 HNSW 簡介

Hierarchical Navigable Small World (Malkov & Yashunin, 2018)。直覺:

  • 像 Skip List 但用「圖」代替「鏈表」,多層次:
  • 上層:圖稀疏(少節點、長距離 hop)— 快速「飛」到 query 附近。
  • 下層:圖密集(多節點、短距離邊)— 精細搜尋最近鄰。
  • 每個節點隨機決定它出現在多少層(指數衰減機率)。
  • 插入時:先在上層 greedy walk 找最近 entry,逐層下降到該節點所在最高層,然後 ef_construction beam search 找鄰居並建邊。
  • 查詢時:相同策略,最後一層用 beam size (ef_search) 收集 top-k。

特色

  • 期望查詢時間 (O(\log N))。
  • 召回率輕鬆 95%+。
  • 不需重新訓練;支援漸進插入。
  • 主要缺點:刪除不便(通常 lazy delete + 定期 rebuild);記憶體佔用較大。

14.8 完整 C++ 實作(向量搜尋引擎)

下面是一份獨立可編譯vector_search.cpp,包含:

  1. cosineSiml2Distl2Normalize
  2. VectorStore — 向量集合
  3. topKBrute — 暴力 kNN
  4. RandomHyperplaneLSH — LSH 索引(簽章、建桶、候選查詢)
  5. main demo — 三組「概念向量」三組查詢
// vector_search.cpp
// Compile: g++ -std=c++17 -O2 -Wall -Wextra -o vector_search vector_search.cpp
// Run:     ./vector_search

#include <iostream>
#include <vector>
#include <string>
#include <cmath>
#include <random>
#include <algorithm>
#include <unordered_map>
#include <set>
#include <cstdint>
#include <iomanip>

using namespace std;

// ============================================================
// Vector operations
// ============================================================
void l2Normalize(vector<float>& v) {
    double s = 0;
    for (float x : v) s += (double)x * x;
    s = sqrt(s);
    if (s < 1e-12) return;
    for (float& x : v) x = (float)((double)x / s);
}

double cosineSim(const vector<float>& a, const vector<float>& b) {
    double dot = 0, na = 0, nb = 0;
    for (size_t i = 0; i < a.size(); ++i) {
        dot += (double)a[i] * b[i];
        na  += (double)a[i] * a[i];
        nb  += (double)b[i] * b[i];
    }
    if (na < 1e-12 || nb < 1e-12) return 0;
    return dot / (sqrt(na) * sqrt(nb));
}

double l2Dist(const vector<float>& a, const vector<float>& b) {
    double s = 0;
    for (size_t i = 0; i < a.size(); ++i) {
        double d = (double)a[i] - b[i];
        s += d * d;
    }
    return sqrt(s);
}

// ============================================================
// Vector store with brute-force kNN
// ============================================================
struct Item {
    string id;
    string text;
    vector<float> v;
};

class VectorStore {
public:
    vector<Item> items;
    void add(const string& id, const string& text, vector<float> v) {
        items.push_back({id, text, std::move(v)});
    }
    // Brute force top-k by cosine similarity (descending order)
    vector<pair<double, int>> topKBrute(const vector<float>& q, int k) const {
        vector<pair<double, int>> all;
        all.reserve(items.size());
        for (int i = 0; i < (int)items.size(); ++i)
            all.push_back({cosineSim(q, items[i].v), i});
        sort(all.begin(), all.end(),
             [](const pair<double, int>& a, const pair<double, int>& b) {
                 return a.first > b.first;
             });
        if ((int)all.size() > k) all.resize(k);
        return all;
    }
};

// ============================================================
// Random-Hyperplane LSH (signed-projection cosine LSH)
// ============================================================
class RandomHyperplaneLSH {
    int L;
    int dim;
    vector<vector<float>> planes;
    unordered_map<uint64_t, vector<int>> buckets;
public:
    RandomHyperplaneLSH(int dim_, int L_, unsigned seed = 42)
        : L(L_), dim(dim_) {
        mt19937 rng(seed);
        normal_distribution<float> nd(0.0f, 1.0f);
        planes.assign(L, vector<float>(dim));
        for (int i = 0; i < L; ++i)
            for (int j = 0; j < dim; ++j)
                planes[i][j] = nd(rng);
    }
    uint64_t signature(const vector<float>& v) const {
        uint64_t s = 0;
        for (int i = 0; i < L; ++i) {
            double dot = 0;
            for (size_t j = 0; j < v.size(); ++j)
                dot += (double)planes[i][j] * v[j];
            if (dot >= 0) s |= 1ULL << i;
        }
        return s;
    }
    void buildIndex(const VectorStore& store) {
        buckets.clear();
        for (int i = 0; i < (int)store.items.size(); ++i)
            buckets[signature(store.items[i].v)].push_back(i);
    }
    // candidates = same bucket + buckets with Hamming distance <= hamTol
    vector<int> candidates(const vector<float>& q, int hamTol = 1) const {
        uint64_t qs = signature(q);
        set<int> cand;
        auto it = buckets.find(qs);
        if (it != buckets.end()) for (int x : it->second) cand.insert(x);
        for (int i = 0; i < L && hamTol >= 1; ++i) {
            uint64_t flip = qs ^ (1ULL << i);
            auto it2 = buckets.find(flip);
            if (it2 != buckets.end()) for (int x : it2->second) cand.insert(x);
        }
        return vector<int>(cand.begin(), cand.end());
    }
    int planeCount()  const { return L; }
    int bucketCount() const { return (int)buckets.size(); }
};

// ============================================================
// Demo
// ============================================================
int main() {
    cout << fixed << setprecision(4);

    // Toy 3-D vectors clustered by "concept":
    //   axis 0 ~ fruit-ness, axis 1 ~ vehicle-ness, axis 2 ~ animal-ness
    VectorStore store;
    store.add("apple",   "shiny red fruit",   {0.90f, 0.10f, 0.00f});
    store.add("banana",  "yellow long fruit", {0.85f, 0.15f, 0.05f});
    store.add("orange",  "citrus fruit",      {0.80f, 0.20f, 0.00f});
    store.add("car",     "four wheels",       {0.10f, 0.90f, 0.10f});
    store.add("truck",   "large vehicle",     {0.05f, 0.95f, 0.05f});
    store.add("bicycle", "two wheels",        {0.10f, 0.80f, 0.20f});
    store.add("dog",     "furry pet",         {0.00f, 0.10f, 0.90f});
    store.add("cat",     "small furry pet",   {0.05f, 0.05f, 0.95f});

    vector<vector<float>> queries = {
        {0.88f, 0.12f, 0.00f},  // fruit-like
        {0.10f, 0.85f, 0.10f},  // vehicle-like
        {0.00f, 0.05f, 0.95f}   // animal-like
    };
    vector<string> qnames = {"fruit-like", "vehicle-like", "animal-like"};

    cout << string(60, '-') << "\n";
    cout << "Brute-force cosine kNN (k=3)\n";
    for (size_t q = 0; q < queries.size(); ++q) {
        cout << "  query: " << qnames[q] << "  vec=[";
        for (size_t j = 0; j < queries[q].size(); ++j) {
            if (j) cout << ", ";
            cout << queries[q][j];
        }
        cout << "]\n";
        auto top = store.topKBrute(queries[q], 3);
        for (auto& pr : top)
            cout << "    " << store.items[pr.second].id
                 << "  (cos=" << pr.first
                 << ", text=\"" << store.items[pr.second].text << "\")\n";
    }

    cout << string(60, '-') << "\n";
    cout << "Random-Hyperplane LSH (L=4 bits, 1-flip neighbor search)\n";
    RandomHyperplaneLSH lsh(/*dim=*/3, /*L=*/4, /*seed=*/42);
    lsh.buildIndex(store);
    cout << "  per-item signatures:\n";
    for (const auto& it : store.items) {
        uint64_t s = lsh.signature(it.v);
        cout << "    " << it.id << "  sig=";
        for (int b = lsh.planeCount() - 1; b >= 0; --b)
            cout << ((s >> b) & 1ULL);
        cout << "\n";
    }
    cout << "  total buckets used = " << lsh.bucketCount() << "\n";

    for (size_t q = 0; q < queries.size(); ++q) {
        cout << "  query: " << qnames[q] << "\n";
        uint64_t qs = lsh.signature(queries[q]);
        cout << "    query sig=";
        for (int b = lsh.planeCount() - 1; b >= 0; --b)
            cout << ((qs >> b) & 1ULL);
        cout << "\n";
        auto cand = lsh.candidates(queries[q], /*hamTol=*/1);
        cout << "    LSH candidates (" << cand.size() << "): ";
        for (int i : cand) cout << store.items[i].id << " ";
        cout << "\n";
        vector<pair<double, int>> rescored;
        for (int i : cand)
            rescored.push_back({cosineSim(queries[q], store.items[i].v), i});
        sort(rescored.begin(), rescored.end(),
             [](const pair<double, int>& a, const pair<double, int>& b) {
                 return a.first > b.first;
             });
        cout << "    rescored top-3:\n";
        for (int i = 0; i < min((int)rescored.size(), 3); ++i)
            cout << "      " << store.items[rescored[i].second].id
                 << "  (cos=" << rescored[i].first << ")\n";
    }
    return 0;
}

預期輸出(已驗證)

------------------------------------------------------------
Brute-force cosine kNN (k=3)
  query: fruit-like  vec=[0.8800, 0.1200, 0.0000]
    apple  (cos=0.9997, text="shiny red fruit")
    banana  (cos=0.9976, text="yellow long fruit")
    orange  (cos=0.9940, text="citrus fruit")
  query: vehicle-like  vec=[0.1000, 0.8500, 0.1000]
    car  (cos=1.0000, text="four wheels")
    truck  (cos=0.9959, text="large vehicle")
    bicycle  (cos=0.9919, text="two wheels")
  query: animal-like  vec=[0.0000, 0.0500, 0.9500]
    cat  (cos=0.9986, text="small furry pet")
    dog  (cos=0.9983, text="furry pet")
    bicycle  (cos=0.2911, text="two wheels")
------------------------------------------------------------
Random-Hyperplane LSH (L=4 bits, 1-flip neighbor search)
  per-item signatures:
    apple  sig=1010
    banana  sig=1010
    orange  sig=1010
    car  sig=1011
    truck  sig=1011
    bicycle  sig=1011
    dog  sig=0011
    cat  sig=0011
  total buckets used = 3
  query: fruit-like
    query sig=1010
    LSH candidates (6): apple banana orange car truck bicycle
    rescored top-3:
      apple  (cos=0.9997)
      banana  (cos=0.9976)
      orange  (cos=0.9940)
  query: vehicle-like
    query sig=1011
    LSH candidates (8): apple banana orange car truck bicycle dog cat
    rescored top-3:
      car  (cos=1.0000)
      truck  (cos=0.9959)
      bicycle  (cos=0.9919)
  query: animal-like
    query sig=0011
    LSH candidates (5): car truck bicycle dog cat
    rescored top-3:
      cat  (cos=0.9986)
      dog  (cos=0.9983)
      bicycle  (cos=0.2911)

觀察重點:

  • 暴力 cosine kNN 完美分群(每個概念取到對應 3 項)。
  • LSH 用 4 個隨機超平面,自動把 8 個玩具向量分成 3 個 bucket,正好對應 3 個概念叢集 — 體現 LSH 的「相似向量易碰撞」性質。
  • LSH 候選縮減後再做精確 cosine 重排(hybrid 流程):在小資料上 LSH 沒有效率優勢,但對上百萬向量這個「先粗篩、後精排」的兩階段流程能省 99%+ 的計算。

14.9 推演例子:玩具向量 + 手算 cosine 與 LSH

Step 1:cosine 手算

設 query (\vec q = [0.88, 0.12, 0]),與 apple (\vec v = [0.90, 0.10, 0.00]):

  • 內積:(0.88 \cdot 0.90 + 0.12 \cdot 0.10 + 0 \cdot 0 = 0.792 + 0.012 = 0.804)
  • (|\vec q| = \sqrt{0.88^2 + 0.12^2} = \sqrt{0.7744 + 0.0144} = \sqrt{0.7888} \approx 0.8881)
  • (|\vec v| = \sqrt{0.90^2 + 0.10^2} = \sqrt{0.81 + 0.01} = \sqrt{0.82} \approx 0.9055)
  • cosine = (0.804 / (0.8881 \cdot 0.9055) \approx 0.804 / 0.8042 \approx 0.9997) ✓(與程式輸出一致)

Step 2:LSH 簽章手算

設 4 個隨機超平面法向量(簡化舉例,實際由 mt19937 產生):

Plane (\vec r_l)
0 ([+1, +1, -1])
1 ([+1, -1, +1])
2 ([-1, +1, +1])
3 ([+1, +1, +1])

對 apple ([0.9, 0.1, 0.0]):

Plane (\vec r_l \cdot \vec v_{\text{apple}}) 正負 bit
0 (0.9 + 0.1 - 0 = 1.0) + 1
1 (0.9 - 0.1 + 0 = 0.8) + 1
2 (-0.9 + 0.1 + 0 = -0.8) 0
3 (0.9 + 0.1 + 0 = 1.0) + 1

簽章 = 1011(從 bit 3 到 bit 0;具體位元順序視實作而定)。

對 dog ([0.0, 0.1, 0.9]):

Plane dot bit
0 (0 + 0.1 - 0.9 = -0.8) 0
1 (0 - 0.1 + 0.9 = 0.8) 1
2 (0 + 0.1 + 0.9 = 1.0) 1
3 (0 + 0.1 + 0.9 = 1.0) 1

簽章 = 1110

apple 與 dog 簽章漢明距離 = 2(在 bit 0、bit 2 不同),與其 cosine 距離(apple∙dog ≈ 0)一致 — 不相似 → 簽章差異大。

程式輸出的 signature 值(如 apple sig=1010)來自 mt19937(seed=42) 產生的具體超平面,與上面示意計算的方向不同,但原理相同:相似的向量得到相似的位元簽章。

14.10 RAG(Retrieval-Augmented Generation)流程

向量搜尋是當前 LLM 應用的核心模組,最常見的應用就是 RAG(檢索增強生成):

[離線索引]
  文件 → 切塊 (chunking, e.g. 500 token, overlap 50)
       → 每塊算 embedding
       → 存入向量資料庫 (chunk_text + vector + metadata)

[線上查詢]
  使用者問題 q
       → embed(q)
       → 向量資料庫 ANN 搜尋 → top-k chunks (k=4~20)
       → [可選] cross-encoder 重排
       → "Context: <chunks>\nQuestion: <q>" → LLM 生成答案
       → 回傳含引用的答案

為什麼要 RAG?

  • LLM 的「知識」凍結在訓練時點,不知道你的私有資料/最新資訊。
  • LLM 直接生成易產生 hallucination。
  • RAG 把「相關事實」當 context 注入 prompt,事實準確度與可引用性大幅提升。

完整 RAG 系統還會考慮:

  • 查詢改寫(query rewriting / HyDE)
  • 多路檢索 + Reranking(dense + sparse)
  • 對話歷史壓縮
  • 答案 grounding 與引用校驗

14.11 與符號模糊搜尋的對比

角度 符號(Lev / Bitap / BK-Tree) 向量(embedding + ANN)
比對基礎 字元、n-gram、語音 學習得到的語意向量
同義詞 不行 可以
跨語言 不行(需先翻譯) 可(多語模型)
拼字錯誤容忍 強項 較弱(拼錯易破壞 embedding)
解釋性 高(有明確編輯序列) 低(黑箱距離)
預處理成本 0 ~ 小 模型推論(GPU / API 費用)
索引時間 中-大
查詢延遲 μs ~ ms ms ~ tens of ms
適合資料量 (\le 10^6) 可達 (10^9)+
適合場景 拼字檢查、autocomplete、簡單模糊搜尋 語意搜尋、RAG、推薦、跨模態
失效情境 同義/意譯 罕見專有名詞、有錯字、需精確比對

結論:兩者互補,不互斥。 工業界常見「Hybrid Search」:

[ \text{score} = \alpha \cdot \text{BM25}(q, d) + (1 - \alpha) \cdot \text{cosine}(\vec q, \vec d) ]

或用 RRF (Reciprocal Rank Fusion) 把字面與語意排序結果融合。

14.12 主流向量資料庫

名稱 開源 主要 ANN 特色 / 適用場景
FAISS ✓ (Meta) IVF, HNSW, PQ C++ 函式庫;CPU/GPU;研究與離線批次首選
Milvus HNSW, IVF, Annoy 分散式、生產級、雲原生
Qdrant HNSW Rust 實作;REST/gRPC;payload filter 強
Weaviate HNSW 內建 schema 與 hybrid search
Chroma HNSW 輕量;常用於 RAG 原型開發
pgvector IVFFlat, HNSW PostgreSQL extension;與既有 RDB 整合好
Pinecone ✗ (SaaS) 自家 全託管,API 易用,企業常用
Vespa ✓ (Yahoo) HNSW 大規模搜尋平台;hybrid 強
ScaNN ✓ (Google) 自家 高召回 + 高速度,但 API 不算友善
OpenSearch / Elasticsearch HNSW (kNN plugin) 與傳統倒排索引整合

選擇建議:

  • 原型 / 小資料:Chroma、pgvector、FAISS in-memory
  • 中型生產:Qdrant、Weaviate
  • 大型生產:Milvus、Vespa、Pinecone
  • 與 SQL 整合:pgvector
  • 與 ES 整合:OpenSearch / Elasticsearch kNN

15. 九個完整手動推演例子

表中縮寫:M=match、S=substitute、I=insert、D=delete、T=transpose。

例 1:Hamming(基本)

s1 = "karolin"
s2 = "kathrin"
位置 0 1 2 3 4 5 6
s1 k a r o l i n
s2 k a t h r i n
同?

H = 3


例 2:Levenshtein DP — "kitten""sitting"

DP 表 (a = "kitten" 列、b = "sitting" 行):

ε s i t t i n g
ε 0 1 2 3 4 5 6 7
k 1 1 2 3 4 5 6 7
i 2 2 1 2 3 4 5 6
t 3 3 2 1 2 3 4 5
t 4 4 3 2 1 2 3 4
e 5 5 4 3 2 2 3 4
n 6 6 5 4 3 3 2 3

結果dp[6][7] = 3

回溯(從右下走到左上):

(6,7)=3 ← (5,6)=2 + I 'g'
(5,6)=2 ← (4,5)=2 + M 'n'
(4,5)=2 ← (3,4)=1 + S 'e'→'i'
(3,4)=1 ← (2,3)=1 + M 't'
(2,3)=1 ← (1,2)=1 + M 't'
(1,2)=1 ← (0,1)=1 + M 'i'
(0,1)=1 ← (0,0)=0 + S 'k'→'s'

編輯序列

S k -> s      kitten → sitten
M i == i
M t == t
M t == t
S e -> i      sitten → sittin
M n == n
I insert 'g'  sittin → sitting

例 3:Damerau-Levenshtein — "ca" vs "ac"

Lev DP 表

ε a c
ε 0 1 2
c 1 1 1
a 2 1 2

Lev("ca","ac") = 2(兩次 substitute)。

DL (OSA) DP 表

ε a c
ε 0 1 2
c 1 1 1
a 2 1 1dp[0][0] + 1 = 1(transpose)

DL("ca","ac") = 1

對比真實效益:拼字錯誤中 transposition 占約 10% — DL 對拼字檢查器更友善。


例 4:LCS — "ABCBDAB" vs "BDCAB"

DP 表

ε B D C A B
ε 0 0 0 0 0 0
A 0 0 0 0 1 1
B 0 1 1 1 1 2
C 0 1 1 2 2 2
B 0 1 1 2 2 3
D 0 1 2 2 2 3
A 0 1 2 2 3 3
B 0 1 2 2 3 4

結果:LCS 長度 = 4。

回溯(找一條 LCS)

(7,5)='B'=='B' → +B, (6,4)
(6,4)='A'=='A' → +A, (5,3)
(5,3)='D'≠'C'  dp[4][3]=2 vs dp[5][2]=2,往上 (4,3)
(4,3)='B'≠'C'  dp[3][3]=2 vs dp[4][2]=1,往上 (3,3)
(3,3)='C'=='C' → +C, (2,2)
(2,2)='B'≠'D'  dp[1][2]=0 vs dp[2][1]=1,往左 (2,1)
(2,1)='B'=='B' → +B, (1,0)
停止

倒過來 → LCS = BCAB(長度 4)。

注意:LCS 不唯一,例如 BDAB 也是長度 4 的 LCS。回溯時的優先順序決定取哪一個。


例 5:Jaro-Winkler — "MARTHA" vs "MARHTA"

Step 1:匹配窗大小

(\max(6, 6) / 2 - 1 = 2)。即對 s1 第 (i) 位字元,找 s2 第 ([i-2, i+2]) 範圍。

Step 2:找匹配

s1 範圍 找到位置
M @ 0 s2[0..2]=MAR M @ 0 ✓
A @ 1 s2[0..3]=MARH A @ 1 ✓
R @ 2 s2[0..4]=MARHT R @ 2 ✓
T @ 3 s2[1..5]=ARHTA T @ 4 ✓
H @ 4 s2[2..6]=RHTA H @ 3 ✓
A @ 5 s2[3..6]=HTA A @ 5 ✓

(m = 6)。

Step 3:transposition

匹配字元在 s1 = M A R T H A、匹配位置在 s2 = M A R H T A(按 s2 索引順序)。

s1 s2 同?
M M
A A
R R
T H
H T
A A

(t = 2),半 transposition = 1。

Step 4:Jaro 計算

[ \text{Jaro} = \frac{1}{3}\left(\frac{6}{6} + \frac{6}{6} + \frac{6 - 1}{6}\right) = \frac{1 + 1 + 5/6}{3} = \frac{17/6}{3} = \frac{17}{18} \approx 0.9444 ]

Step 5:Jaro-Winkler

共同前綴 MAR,(\ell = 3)(≤ 4),(p = 0.1):

[ \text{JW} = 0.9444 + 3 \cdot 0.1 \cdot (1 - 0.9444) = 0.9444 + 0.0167 \approx 0.9611 ]


例 6:N-gram Jaccard — "night" vs "nacht"

Step 1:建 bigram 集合

  • A = bigrams(night) = {ni, ig, gh, ht},(|A| = 4)
  • B = bigrams(nacht) = {na, ac, ch, ht},(|B| = 4)

Step 2:集合運算

  • (A \cap B = {ht}),(|A \cap B| = 1)
  • (A \cup B = {ni, ig, gh, ht, na, ac, ch}),(|A \cup B| = 7)

Step 3:計算相似度

  • Jaccard = (1 / 7 \approx 0.143)
  • Dice = (2 \cdot 1 / (4 + 4) = 0.25)
  • Overlap = (1 / \min(4, 4) = 0.25)

例 7:Soundex 多單字比較

單字 Soundex
Robert R163
Rupert R163
Rubin R150
Ashcraft A261
Pfister P236
Honeyman H555

觀察RobertRupert 編碼相同 → 一次查詢可找到兩者;但 Robin 編碼不同(=R150)顯示「o 與 u」這類細節 Soundex 無法區分,「b 與 ber」這類仍能區分。


例 8:Bitap 近似 — "annual""annealing",(k = 2)

設定:

  • pattern = annual,(M = 6),匹配位 = bit 5
  • B['a'] = 0b010001(pattern[0] 與 pattern[4] 是 a
  • B['n'] = 0b001010(pattern[1], [2])
  • B['u'] = 0b001000(pattern[3])
  • B['l'] = 0b100000(pattern[5])
  • 其他字元 mask = 0

text = annealing,索引 0..8 為 a n n e a l i n g

追蹤 (R_0, R_1, R_2) 在第 5 位的觸發(細節篇幅較大,下表只列「匹配位變 1」的時刻):

i text[i] (R_0) bit5 (R_1) bit5 (R_2) bit5 最少錯誤匹配
0 a 0 0 0
1 n 0 0 0
2 n 0 0 0
3 e 0 0 0
4 a 0 0 1 end=4, errors=2(將 anneaannual 需 2 編輯)
5 l 0 1 1 end=5, errors=1(將 annealannual 需 1 編輯,e→u
6 i 0 0 1 end=6, errors=2
7 n 0 0 0
8 g 0 0 0

結論:最佳匹配於 text 結束位置 5,誤差 1 個 substitution(annealannual)。


例 9:BK-Tree 查詢追蹤

插入字典(依序):book, books, boo, boon, cook, cake, cape, cart

樹狀結構

book                            (root)
├──[1]── books                  (dist 1)
│        └──[2]── boo           (dist(boo,books)=2)
│                 ├──[1]── boon
│                 └──[2]── cook
└──[4]── cake                   (dist(cake,book)=4)
         ├──[1]── cape
         └──[2]── cart

查詢 "bok",tolerance = 1

拜訪節點 dist(q, node) 收? 下次走 children 範圍 [d-1, d+1]
1 book 1 ✓ 收 [0, 2] → 走 books(1)
2 books 2 [1, 3] → 走 boo(2)
3 boo 1 ✓ 收 [0, 2] → 走 boon(1), cook(2)
4 boon 2 無 children
5 cook 2 無 children
(回到 root) cake(4):不在 [0,2],剪枝

結果book(1), boo(1)。拜訪 5 / 8 節點(節省 37.5%),實際大字典效率更明顯。


16. 完整 C++ 程式碼

下面是一份完整可編譯fuzzy_search.cpp,涵蓋上述所有演算法與 12 個 demo:

// fuzzy_search.cpp
// Compile: g++ -std=c++17 -O2 -Wall -Wextra -o fuzzy_search fuzzy_search.cpp
// Run:     ./fuzzy_search

#include <iostream>
#include <string>
#include <vector>
#include <map>
#include <set>
#include <algorithm>
#include <cmath>
#include <cctype>
#include <functional>
#include <iomanip>
#include <cstdint>

using namespace std;

// ============================================================
// 1. Hamming distance
// ============================================================
int hamming(const string& a, const string& b) {
    if (a.size() != b.size()) return -1;
    int d = 0;
    for (size_t i = 0; i < a.size(); ++i) if (a[i] != b[i]) ++d;
    return d;
}

// ============================================================
// 2. Levenshtein distance — basic O(nm) time, O(nm) space
// ============================================================
int levenshtein(const string& a, const string& b) {
    int n = (int)a.size(), m = (int)b.size();
    vector<vector<int>> dp(n + 1, vector<int>(m + 1));
    for (int i = 0; i <= n; ++i) dp[i][0] = i;
    for (int j = 0; j <= m; ++j) dp[0][j] = j;
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            int cost = (a[i - 1] == b[j - 1]) ? 0 : 1;
            dp[i][j] = min({dp[i - 1][j] + 1,
                            dp[i][j - 1] + 1,
                            dp[i - 1][j - 1] + cost});
        }
    }
    return dp[n][m];
}

// ============================================================
// 3. Levenshtein with O(min(n,m)) space
// ============================================================
int levenshteinFast(const string& a, const string& b) {
    if (a.size() < b.size()) return levenshteinFast(b, a);
    int n = (int)a.size(), m = (int)b.size();
    vector<int> prev(m + 1), curr(m + 1);
    for (int j = 0; j <= m; ++j) prev[j] = j;
    for (int i = 1; i <= n; ++i) {
        curr[0] = i;
        for (int j = 1; j <= m; ++j) {
            int cost = (a[i - 1] == b[j - 1]) ? 0 : 1;
            curr[j] = min({prev[j] + 1,
                           curr[j - 1] + 1,
                           prev[j - 1] + cost});
        }
        swap(prev, curr);
    }
    return prev[m];
}

// ============================================================
// 4. Levenshtein with edit-operation reconstruction
// ============================================================
struct EditOp {
    char type;   // 'M','S','I','D'
    int posA, posB;
    char chA, chB;
};

vector<EditOp> levenshteinOps(const string& a, const string& b) {
    int n = (int)a.size(), m = (int)b.size();
    vector<vector<int>> dp(n + 1, vector<int>(m + 1));
    for (int i = 0; i <= n; ++i) dp[i][0] = i;
    for (int j = 0; j <= m; ++j) dp[0][j] = j;
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            int cost = (a[i - 1] == b[j - 1]) ? 0 : 1;
            dp[i][j] = min({dp[i - 1][j] + 1,
                            dp[i][j - 1] + 1,
                            dp[i - 1][j - 1] + cost});
        }
    }
    vector<EditOp> ops;
    int i = n, j = m;
    while (i > 0 || j > 0) {
        if (i > 0 && j > 0 && a[i - 1] == b[j - 1] && dp[i][j] == dp[i - 1][j - 1]) {
            ops.push_back({'M', i - 1, j - 1, a[i - 1], b[j - 1]});
            --i; --j;
        } else if (i > 0 && j > 0 && dp[i][j] == dp[i - 1][j - 1] + 1) {
            ops.push_back({'S', i - 1, j - 1, a[i - 1], b[j - 1]});
            --i; --j;
        } else if (i > 0 && dp[i][j] == dp[i - 1][j] + 1) {
            ops.push_back({'D', i - 1, -1, a[i - 1], '\0'});
            --i;
        } else {
            ops.push_back({'I', -1, j - 1, '\0', b[j - 1]});
            --j;
        }
    }
    reverse(ops.begin(), ops.end());
    return ops;
}

// ============================================================
// 5. Damerau–Levenshtein (restricted, OSA variant)
// ============================================================
int damerauOSA(const string& a, const string& b) {
    int n = (int)a.size(), m = (int)b.size();
    vector<vector<int>> dp(n + 1, vector<int>(m + 1));
    for (int i = 0; i <= n; ++i) dp[i][0] = i;
    for (int j = 0; j <= m; ++j) dp[0][j] = j;
    for (int i = 1; i <= n; ++i) {
        for (int j = 1; j <= m; ++j) {
            int cost = (a[i - 1] == b[j - 1]) ? 0 : 1;
            dp[i][j] = min({dp[i - 1][j] + 1,
                            dp[i][j - 1] + 1,
                            dp[i - 1][j - 1] + cost});
            if (i >= 2 && j >= 2 &&
                a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1]) {
                dp[i][j] = min(dp[i][j], dp[i - 2][j - 2] + 1);
            }
        }
    }
    return dp[n][m];
}

// ============================================================
// 6. Longest Common Subsequence (LCS)
// ============================================================
int lcsLength(const string& a, const string& b) {
    int n = (int)a.size(), m = (int)b.size();
    vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            dp[i][j] = (a[i - 1] == b[j - 1])
                          ? dp[i - 1][j - 1] + 1
                          : max(dp[i - 1][j], dp[i][j - 1]);
    return dp[n][m];
}

string lcsString(const string& a, const string& b) {
    int n = (int)a.size(), m = (int)b.size();
    vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            dp[i][j] = (a[i - 1] == b[j - 1])
                          ? dp[i - 1][j - 1] + 1
                          : max(dp[i - 1][j], dp[i][j - 1]);
    string lcs;
    int i = n, j = m;
    while (i > 0 && j > 0) {
        if (a[i - 1] == b[j - 1]) { lcs += a[i - 1]; --i; --j; }
        else if (dp[i - 1][j] >= dp[i][j - 1]) --i;
        else --j;
    }
    reverse(lcs.begin(), lcs.end());
    return lcs;
}

// ============================================================
// 7. Jaro similarity
// ============================================================
double jaroSimilarity(const string& s1, const string& s2) {
    int l1 = (int)s1.size(), l2 = (int)s2.size();
    if (l1 == 0 && l2 == 0) return 1.0;
    if (l1 == 0 || l2 == 0) return 0.0;
    int matchDist = max(l1, l2) / 2 - 1;
    if (matchDist < 0) matchDist = 0;
    vector<bool> m1(l1, false), m2(l2, false);
    int matches = 0;
    for (int i = 0; i < l1; ++i) {
        int start = max(0, i - matchDist);
        int end   = min(i + matchDist + 1, l2);
        for (int j = start; j < end; ++j) {
            if (m2[j] || s1[i] != s2[j]) continue;
            m1[i] = m2[j] = true;
            ++matches;
            break;
        }
    }
    if (matches == 0) return 0.0;
    int t = 0, k = 0;
    for (int i = 0; i < l1; ++i) {
        if (!m1[i]) continue;
        while (!m2[k]) ++k;
        if (s1[i] != s2[k]) ++t;
        ++k;
    }
    double m = matches;
    return (m / l1 + m / l2 + (m - t / 2.0) / m) / 3.0;
}

// ============================================================
// 8. Jaro-Winkler similarity
// ============================================================
double jaroWinkler(const string& s1, const string& s2, double p = 0.1) {
    double j = jaroSimilarity(s1, s2);
    int l = 0;
    int maxL = min({(int)s1.size(), (int)s2.size(), 4});
    while (l < maxL && s1[l] == s2[l]) ++l;
    return j + l * p * (1 - j);
}

// ============================================================
// 9. N-gram set + Jaccard + Dice
// ============================================================
set<string> ngramSet(const string& s, int n, bool pad = false) {
    set<string> result;
    string t = pad ? string(n - 1, '$') + s + string(n - 1, '$') : s;
    if ((int)t.size() < n) {
        if (!t.empty()) result.insert(t);
        return result;
    }
    for (int i = 0; i + n <= (int)t.size(); ++i)
        result.insert(t.substr(i, n));
    return result;
}

double jaccard(const set<string>& a, const set<string>& b) {
    if (a.empty() && b.empty()) return 1.0;
    set<string> u, inter;
    set_union(a.begin(), a.end(), b.begin(), b.end(), inserter(u, u.begin()));
    set_intersection(a.begin(), a.end(), b.begin(), b.end(),
                     inserter(inter, inter.begin()));
    return u.empty() ? 0.0 : (double)inter.size() / (double)u.size();
}

double diceCoef(const set<string>& a, const set<string>& b) {
    if (a.empty() && b.empty()) return 1.0;
    set<string> inter;
    set_intersection(a.begin(), a.end(), b.begin(), b.end(),
                     inserter(inter, inter.begin()));
    size_t denom = a.size() + b.size();
    return denom == 0 ? 0.0 : 2.0 * (double)inter.size() / (double)denom;
}

// ============================================================
// 10. Soundex
// ============================================================
string soundex(const string& s) {
    if (s.empty()) return "0000";
    auto code = [](char c) -> char {
        c = (char)tolower((unsigned char)c);
        switch (c) {
            case 'b': case 'f': case 'p': case 'v': return '1';
            case 'c': case 'g': case 'j': case 'k':
            case 'q': case 's': case 'x': case 'z': return '2';
            case 'd': case 't': return '3';
            case 'l': return '4';
            case 'm': case 'n': return '5';
            case 'r': return '6';
            case 'h': case 'w': return '-';
            default:  return '0';
        }
    };
    string out;
    out += (char)toupper((unsigned char)s[0]);
    char prev = code(s[0]);
    for (size_t i = 1; i < s.size() && out.size() < 4; ++i) {
        char c = code(s[i]);
        if (c == '0') prev = '0';
        else if (c == '-') continue;
        else if (c != prev) { out += c; prev = c; }
    }
    while (out.size() < 4) out += '0';
    return out;
}

// ============================================================
// 11. Bitap (Shift-And) exact matching
// ============================================================
vector<int> bitapExact(const string& text, const string& pattern) {
    vector<int> matches;
    int m = (int)pattern.size(), n = (int)text.size();
    if (m == 0 || m > 63 || n < m) return matches;
    uint64_t B[256] = {0};
    for (int i = 0; i < m; ++i)
        B[(unsigned char)pattern[i]] |= 1ULL << i;
    uint64_t R = 0;
    uint64_t matchBit = 1ULL << (m - 1);
    for (int i = 0; i < n; ++i) {
        R = ((R << 1) | 1ULL) & B[(unsigned char)text[i]];
        if (R & matchBit) matches.push_back(i - m + 1);
    }
    return matches;
}

// ============================================================
// 12. Bitap (Wu-Manber) approximate matching with k errors
// ============================================================
struct ApproxMatch { int endPos; int errors; };

vector<ApproxMatch> bitapApprox(const string& text, const string& pattern, int k) {
    vector<ApproxMatch> matches;
    int m = (int)pattern.size(), n = (int)text.size();
    if (m == 0 || m > 63) return matches;
    uint64_t B[256] = {0};
    for (int i = 0; i < m; ++i)
        B[(unsigned char)pattern[i]] |= 1ULL << i;
    vector<uint64_t> R(k + 1, 0);
    uint64_t matchBit = 1ULL << (m - 1);
    for (int i = 0; i < n; ++i) {
        unsigned char c = (unsigned char)text[i];
        uint64_t prevR = R[0];
        R[0] = ((R[0] << 1) | 1ULL) & B[c];
        for (int d = 1; d <= k; ++d) {
            uint64_t tmp = R[d];
            R[d] = ((tmp << 1) & B[c])      // match
                 | (prevR << 1)               // substitution
                 | (R[d - 1] << 1)            // deletion (pattern 多 1 字)
                 | prevR                      // insertion (text 多 1 字)
                 | 1ULL;
            prevR = tmp;
        }
        for (int d = 0; d <= k; ++d) {
            if (R[d] & matchBit) {
                matches.push_back({i, d});
                break;
            }
        }
    }
    return matches;
}

// ============================================================
// 13. Approximate matching via DP (≤ k errors anywhere in text)
// ============================================================
vector<ApproxMatch> approxSearchDP(const string& text, const string& pattern, int k) {
    vector<ApproxMatch> matches;
    int n = (int)text.size(), m = (int)pattern.size();
    if (m == 0) return matches;
    vector<int> dp(m + 1);
    for (int j = 0; j <= m; ++j) dp[j] = j;
    for (int i = 1; i <= n; ++i) {
        int prev = dp[0];
        dp[0] = 0;
        for (int j = 1; j <= m; ++j) {
            int tmp = dp[j];
            int cost = (pattern[j - 1] == text[i - 1]) ? 0 : 1;
            dp[j] = min({dp[j - 1] + 1, tmp + 1, prev + cost});
            prev = tmp;
        }
        if (dp[m] <= k) matches.push_back({i - 1, dp[m]});
    }
    return matches;
}

// ============================================================
// 14. BK-Tree
// ============================================================
class BKTree {
    struct Node { string word; map<int, int> children; };
    vector<Node> nodes;
    int root = -1;
    function<int(const string&, const string&)> dist;
public:
    BKTree() : dist([](const string& x, const string& y){ return levenshtein(x, y); }) {}
    explicit BKTree(function<int(const string&, const string&)> f) : dist(std::move(f)) {}

    void insert(const string& w) {
        if (root == -1) { nodes.push_back({w, {}}); root = 0; return; }
        int cur = root;
        while (true) {
            int d = dist(w, nodes[cur].word);
            if (d == 0) return;
            auto it = nodes[cur].children.find(d);
            if (it == nodes[cur].children.end()) {
                int idx = (int)nodes.size();
                nodes.push_back({w, {}});
                nodes[cur].children[d] = idx;
                return;
            }
            cur = it->second;
        }
    }

    vector<pair<int, string>> query(const string& w, int tol) const {
        vector<pair<int, string>> res;
        if (root == -1) return res;
        function<void(int)> dfs = [&](int u) {
            int d = dist(w, nodes[u].word);
            if (d <= tol) res.push_back({d, nodes[u].word});
            int lo = max(0, d - tol), hi = d + tol;
            for (auto& kv : nodes[u].children)
                if (kv.first >= lo && kv.first <= hi) dfs(kv.second);
        };
        dfs(root);
        sort(res.begin(), res.end());
        return res;
    }

    int nodeCount() const { return (int)nodes.size(); }
};

// ============================================================
// Demonstrations
// ============================================================
static void hr() { cout << string(60, '-') << "\n"; }

static void printOps(const vector<EditOp>& ops) {
    for (const auto& o : ops) {
        switch (o.type) {
            case 'M': cout << "  M  " << o.chA << " == " << o.chB << "\n"; break;
            case 'S': cout << "  S  " << o.chA << " -> " << o.chB << "\n"; break;
            case 'D': cout << "  D  delete '" << o.chA
                           << "' at A[" << o.posA << "]\n"; break;
            case 'I': cout << "  I  insert '" << o.chB
                           << "' at B[" << o.posB << "]\n"; break;
        }
    }
}

int main() {
    cout << fixed << setprecision(4);

    hr(); cout << "1. Hamming Distance\n";
    cout << "  H(\"karolin\", \"kathrin\") = " << hamming("karolin", "kathrin") << "\n";
    cout << "  H(\"2173896\", \"2233796\") = " << hamming("2173896", "2233796") << "\n";

    hr(); cout << "2. Levenshtein Distance\n";
    cout << "  L(\"kitten\", \"sitting\")        = "
         << levenshtein("kitten", "sitting") << "\n";
    cout << "  L(\"flaw\", \"lawn\")             = "
         << levenshtein("flaw", "lawn") << "\n";
    cout << "  L(\"intention\", \"execution\")   = "
         << levenshtein("intention", "execution") << "\n";
    cout << "  L(\"\", \"abc\")                  = "
         << levenshtein("", "abc") << "\n";

    hr(); cout << "3. Levenshtein edit script: \"kitten\" -> \"sitting\"\n";
    printOps(levenshteinOps("kitten", "sitting"));

    hr(); cout << "4. Damerau-Levenshtein vs Levenshtein\n";
    cout << "  Lev(\"ca\", \"ac\")       = " << levenshtein("ca", "ac") << "\n";
    cout << "  DL (\"ca\", \"ac\")       = " << damerauOSA("ca", "ac") << "\n";
    cout << "  Lev(\"teh\", \"the\")     = " << levenshtein("teh", "the") << "\n";
    cout << "  DL (\"teh\", \"the\")     = " << damerauOSA("teh", "the") << "\n";

    hr(); cout << "5. LCS\n";
    cout << "  LCS(\"ABCBDAB\", \"BDCAB\") length = "
         << lcsLength("ABCBDAB", "BDCAB") << "\n";
    cout << "  one such LCS                    = "
         << lcsString("ABCBDAB", "BDCAB") << "\n";

    hr(); cout << "6. Jaro & Jaro-Winkler\n";
    cout << "  Jaro(\"MARTHA\", \"MARHTA\") = " << jaroSimilarity("MARTHA", "MARHTA") << "\n";
    cout << "  JW  (\"MARTHA\", \"MARHTA\") = " << jaroWinkler("MARTHA", "MARHTA") << "\n";
    cout << "  Jaro(\"DWAYNE\", \"DUANE\")  = " << jaroSimilarity("DWAYNE", "DUANE") << "\n";
    cout << "  JW  (\"DWAYNE\", \"DUANE\")  = " << jaroWinkler("DWAYNE", "DUANE") << "\n";
    cout << "  JW  (\"CRATE\", \"TRACE\")   = " << jaroWinkler("CRATE", "TRACE") << "\n";

    hr(); cout << "7. N-gram similarity (bigrams)\n";
    auto g1 = ngramSet("night", 2), g2 = ngramSet("nacht", 2);
    cout << "  bigrams(night) = "; for (const auto& s : g1) cout << s << " "; cout << "\n";
    cout << "  bigrams(nacht) = "; for (const auto& s : g2) cout << s << " "; cout << "\n";
    cout << "  Jaccard = " << jaccard(g1, g2)
         << ", Dice = " << diceCoef(g1, g2) << "\n";

    hr(); cout << "8. Soundex\n";
    for (const string& w : {"Robert", "Rupert", "Rubin",
                            "Ashcraft", "Tymczak", "Pfister", "Honeyman"})
        cout << "  " << w << " -> " << soundex(w) << "\n";

    hr(); cout << "9. Bitap exact: pattern \"abab\" in \"abababab\"\n";
    auto bx = bitapExact("abababab", "abab");
    cout << "  matches at: ";
    for (int p : bx) cout << p << " ";
    cout << "\n";

    hr(); cout << "10. Bitap approximate (k=2): \"annual\" in \"annealing\"\n";
    auto ba = bitapApprox("annealing", "annual", 2);
    for (const auto& m : ba)
        cout << "  end=" << m.endPos << " errors=" << m.errors << "\n";

    hr(); cout << "11. DP approximate (k=1): \"world\" in \"helo wrld today\"\n";
    auto dpr = approxSearchDP("helo wrld today", "world", 1);
    for (const auto& m : dpr)
        cout << "  end=" << m.endPos << " errors=" << m.errors << "\n";

    hr(); cout << "12. BK-Tree spell-checker demo\n";
    BKTree bk;
    for (const string& w : {"book","books","boo","boon","cook",
                            "cake","cape","cart","caper","cope"})
        bk.insert(w);
    cout << "  dictionary size = " << bk.nodeCount() << "\n";
    string q = "bok";
    cout << "  query \"" << q << "\" tolerance 1:\n";
    for (const auto& pr : bk.query(q, 1))
        cout << "    " << pr.second << " (distance " << pr.first << ")\n";
    string q2 = "cak";
    cout << "  query \"" << q2 << "\" tolerance 2:\n";
    for (const auto& pr : bk.query(q2, 2))
        cout << "    " << pr.second << " (distance " << pr.first << ")\n";

    return 0;
}

預期輸出(已驗證)

------------------------------------------------------------
1. Hamming Distance
  H("karolin", "kathrin") = 3
  H("2173896", "2233796") = 3
------------------------------------------------------------
2. Levenshtein Distance
  L("kitten", "sitting")        = 3
  L("flaw", "lawn")             = 2
  L("intention", "execution")   = 5
  L("", "abc")                  = 3
------------------------------------------------------------
3. Levenshtein edit script: "kitten" -> "sitting"
  S  k -> s
  M  i == i
  M  t == t
  M  t == t
  S  e -> i
  M  n == n
  I  insert 'g' at B[6]
------------------------------------------------------------
4. Damerau-Levenshtein vs Levenshtein
  Lev("ca", "ac")       = 2
  DL ("ca", "ac")       = 1
  Lev("teh", "the")     = 2
  DL ("teh", "the")     = 1
------------------------------------------------------------
5. LCS
  LCS("ABCBDAB", "BDCAB") length = 4
  one such LCS                    = BCAB
------------------------------------------------------------
6. Jaro & Jaro-Winkler
  Jaro("MARTHA", "MARHTA") = 0.9444
  JW  ("MARTHA", "MARHTA") = 0.9611
  Jaro("DWAYNE", "DUANE")  = 0.8222
  JW  ("DWAYNE", "DUANE")  = 0.8400
  JW  ("CRATE", "TRACE")   = 0.7333
------------------------------------------------------------
7. N-gram similarity (bigrams)
  bigrams(night) = gh ht ig ni
  bigrams(nacht) = ac ch ht na
  Jaccard = 0.1429, Dice = 0.2500
------------------------------------------------------------
8. Soundex
  Robert -> R163
  Rupert -> R163
  Rubin -> R150
  Ashcraft -> A261
  Tymczak -> T522
  Pfister -> P236
  Honeyman -> H555
------------------------------------------------------------
9. Bitap exact: pattern "abab" in "abababab"
  matches at: 0 2 4
------------------------------------------------------------
10. Bitap approximate (k=2): "annual" in "annealing"
  end=4 errors=2
  end=5 errors=1
  end=6 errors=2
------------------------------------------------------------
11. DP approximate (k=1): "world" in "helo wrld today"
  end=8 errors=1
------------------------------------------------------------
12. BK-Tree spell-checker demo
  dictionary size = 10
  query "bok" tolerance 1:
    boo (distance 1)
    book (distance 1)
  query "cak" tolerance 2:
    cake (distance 1)
    cape (distance 2)
    cart (distance 2)
    cook (distance 2)

17. 實務應用與工程考量

17.1 拼字檢查器(Spell Checker)

典型流程

  1. 載入字典(含詞頻)。
  2. 使用者輸入單字 (q)。
  3. 若 (q) 不在字典 → 用 BK-Tree 或 SymSpell 找出距離 (\le 2) 的候選。
  4. 依「編輯距離 + 詞頻 + 上下文機率」排序,給出最佳建議。

Norvig 21 行版本:對 query 生成所有「1 步編輯」字串,過濾出在字典中的當建議;若無則生成「2 步編輯」。對小規模可用,大規模建議用 SymSpell。

17.2 搜尋引擎自動完成(Autocomplete with Typo Tolerance)

  • 對前綴做 Trie 查詢(exact)。
  • 同步用 Trie + Levenshtein DP(見 13 節)找模糊匹配。
  • 對熱門查詢加重排序權重。

17.3 模糊資料去重(Fuzzy Deduplication)

  • 名單清理:合併 John SmithJon SmithJ. Smith
  • 步驟:Soundex 分桶 → Jaro-Winkler / Levenshtein 細比 → 人工複核閾值內的群。

17.4 生物資訊序列比對

  • DNA 字元集小(A, C, G, T)、序列極長。
  • 常用 Smith-Waterman(局部對齊)/ Needleman-Wunsch(全局對齊)— 編輯距離的加權版本。
  • 大規模索引用 BLASTFM-index

17.5 工程化要點

議題 建議
Unicode 編輯距離以「字元 / code point」而非 byte 計算(中文、emoji)
大小寫 預先 normalize(toLowerCase + Unicode NFC)
重音符號 NFKD 分解後去除 diacritical marks
長字串 使用滾動陣列、位元平行(Myers)、截斷(Ukkonen)
多查詢 若有共同字典,預建索引(BK-Tree、Trie、ngram inverted index)攤平成本
容錯閾值 拼字 (k=1 \text{ or } 2);長句搜尋可用百分比(如距離 (\le 0.2 \cdot
阻擋濫用 當 query 過短時提高閾值或拒絕模糊匹配,避免回傳整個字典

17.6 常用第三方函式庫(字串)

語言 函式庫
C++ RapidFuzzBoost::localeApproximate-Matching
Python rapidfuzzpython-Levenshteinfuzzywuzzytextdistancepyspellchecker
Java Apache Commons Text(含 LCS、Jaro-Winkler)、Lucene
JS fuse.jsfast-levenshteinstring-similarity
Go agnivade/levenshteinxrash/smetrics

17.7 向量語意搜尋與 RAG 系統設計

當需要的是「語意上相關」而非「字面上相似」時(例如 ChatGPT-like 應用、企業內知識庫問答),會走向量化路線:

離線管線:

  1. 資料切塊 (chunking):把每份文件切成 200–500 token 的小塊,相鄰 chunk 通常重疊 10–20%。
  2. 產生 embedding:用 SBERT / OpenAI / BGE 等模型推論。
  3. L2 正規化 + 寫入向量資料庫(Milvus / Qdrant / pgvector / FAISS)。
  4. 附帶 metadata:來源 URL、章節、時間戳,便於 filter 與引用回溯。

線上查詢管線:

  1. 使用者 query → embed → 向量庫 top-k 搜尋(k 通常 4–20)。
  2. (可選)Cross-encoder 重排:把 top-50 用較精準但較慢的模型重排出 top-5。
  3. Prompt 組裝:把 chunks 拼進 LLM context,要求帶 inline citation。
  4. 答案生成 + grounding 校驗:偵測 LLM 是否引用了 retrieved chunks,未引用就標警示。

Hybrid Search(字面 + 語意)

[ \text{score}(d) = \alpha \cdot \text{BM25}(q, d) + (1 - \alpha) \cdot \cos(\vec q, \vec d) ]

或用 Reciprocal Rank Fusion (RRF)

[ \text{rrf}(d) = \sum_{l} \frac{1}{60 + \text{rank}_l(d)} ]

這能同時抓住「拼字完全相同的稀有專有名詞」與「語意相關但字面不同的內容」。

常見坑:

chunk 過大 → embedding 失真 控制在 200–500 token,必要時做語意切塊 (semantic chunking)
chunk 過小 → 上下文不足 加重疊或在 retrieval 後做 chunk 合併
多語言混在同一索引 用多語 model(如 multilingual-e5),或分庫
拼字錯誤、罕見名稱 Hybrid(BM25 + 向量),不要只靠向量
索引更新成本 HNSW 支援漸進插入;大量刪除需定期 rebuild
隱私/合規 向量也可能被反推回部分原文,敏感資料要加密或本地化模型

17.8 常用第三方函式庫(向量 / RAG)

語言 函式庫
Python sentence-transformersfaiss-cpu / faiss-gpuchromadbqdrant-clientlangchainllama-indexhaystack
C++ faisshnswlibMilvus core
Rust qdranthnsw-rsinstant-distance
Java OpenSearch kNN pluginVespa
JS / TS langchain.js@xenova/transformers(瀏覽器端推論)

18. 與其他主題的比較

18.1 精確匹配 vs. 近似匹配

角度 精確(KMP, Z, Boyer-Moore) 近似(Bitap, DP, Levenshtein)
找什麼 完全一樣的子字串 容許 (\le k) 個編輯
主要技巧 failure function / 字元跳躍 DP、位元平行
預處理 (O(M)) (O(M)) 至 (O(M|\Sigma|))
搜尋 (O(N)) (O(kN)) 或 (O(NM))
用途 文字搜尋、token 偵測 拼字檢查、生物序列、模糊搜尋

18.2 字串距離家族對照

距離 / 相似度 操作集 度量? 經典時間 適用
Hamming substitute (O(n)) 固定長度,二進位、DNA
Levenshtein ins, del, sub (O(nm)) 通用
OSA ins, del, sub, transpose (受限) (O(nm)) 拼字檢查
True DL ins, del, sub, transpose (O(nm)) 拼字檢查
LCS ins, del (1 - sim 是度量) (O(nm)) diff、序列保留比對
Jaro 匹配字元 + transpose (O(nm)) 短字串、名字
Jaro-Winkler Jaro + 前綴加權 (O(nm)) 名字、地址
Jaccard (n-gram) 集合運算 (1 - J 是度量) (O(n+m)) 長文本、模糊搜尋
Cosine (TF-IDF) 向量空間 (1 - sim 是度量) (O(|V|)) 文件相似
Cosine (embedding) 向量空間(學習得到) (角距是度量) (O(d)) 語意搜尋、RAG、跨語言
L2 (embedding) 向量空間 (O(d)) 向量資料庫常用

18.3 模糊查詢索引結構對照

結構 適用距離 查詢時間(近似) 備註
BK-Tree 任何度量 (O(\log D)) 至 (O(D)) 簡單、通用
VP-Tree 任何度量 類似 BK-Tree 對連續空間更佳
Trie + DP Levenshtein (O(\sum w
SymSpell Levenshtein (O(1)) 平均 預生成全部刪除版本
n-gram inverted index Levenshtein, Jaccard (O(\text{候選})) 大規模工業界主流
LSH (MinHash) Jaccard sublinear 上億字串時必選
Random-Hyperplane LSH cosine sublinear (候選 + 精算) 語意向量搜尋
HNSW cosine / L2 (O(\log N)) 期望 向量搜尋業界主流,召回率高
IVF + PQ cosine / L2 sub-linear 記憶體效率高,FAISS 主推
Annoy cosine / L2 (O(\log N)) Spotify 推薦系統用
ScaNN cosine / L2 sub-linear Google 內部主力,召回率極高

18.4 符號模糊搜尋 vs. 向量語意搜尋

角度 符號(Lev / Bitap / BK-Tree) 向量(embedding + ANN)
相似的「定義」 字面(字元、n-gram、語音) 語意(學習得到的 embedding)
同義/意譯 失效 強項
跨語言 失效 強項(多語模型)
拼字錯誤 強項 較弱
預處理成本 高(模型訓練/推論)
查詢延遲 μs ~ ms ms ~ tens of ms
資料規模上限 約 (10^6) 可達 (10^9)+
解釋性 高(明確編輯序列) 低(黑箱距離)
推薦組合 拼字檢查、autocomplete RAG、語意搜尋、推薦
Hybrid 兩者各跑、用 BM25 + cosine 加權融合或 RRF 排序融合

19. 練習題與參考

練習

  1. 證明 Levenshtein 距離是「度量」(滿足三角不等式)。
  2. 手算 L("Saturday", "Sunday") 並重建編輯序列。
  3. 把 Levenshtein 改寫成可帶權重版本(指定每種操作的成本,例如鍵盤距離越遠成本越大)。
  4. 證明:若 query 與 candidate 距離 (\le k),則它們至少共有 (\max(|A|, |B|) - 1 - (k - 1) \cdot n) 個 n-gram。
  5. 設計一個函數 topK(query, dictionary, k),回傳 dictionary 中與 query 距離最小的 (k) 個字串。
  6. 把 Bitap 近似版改成「也報告匹配的起始位置(不只結束位置)」。提示:可以從結束位置往左做 DP 重建。
  7. 為 BK-Tree 實作「刪除節點」操作。
  8. 對 10 萬字英文字典實作 SymSpell-style:預生成所有距離 (\le 2) 的「刪除」字串並存入 hash table,比較查詢時間 vs. BK-Tree。
  9. 將 Jaro-Winkler 改成支援 Unicode(處理多字節編碼)。
  10. (挑戰)實作 Hirschberg 線性空間 LCS/編輯距離,能在 (O(\min(n, m))) 空間下回溯。

向量搜尋相關

  1. 證明:若 (\vec u)、(\vec v) 都是單位向量(L2-normalized),則 (|\vec u - \vec v|^2 = 2 - 2\cos\theta)。這對 cosine 與 L2 排序等價有何意義?
  2. 對 Random-Hyperplane LSH,證明 (\Pr[h(\vec u) = h(\vec v)] = 1 - \dfrac{\theta}{\pi})。
  3. sentence-transformers/all-MiniLM-L6-v2 對 1000 句中英文字串產生 embedding,比較 cosine 距離 vs. Levenshtein 排序的差異(指出哪些 case 兩者結論完全相反)。
  4. 把 §14.8 的 RandomHyperplaneLSH 改成「多 table」版本:建 (M) 組各 (L) 位的雜湊表,查詢時取候選聯集,分析召回率提升程度。
  5. 設計一個「Hybrid Search」:給定 query,分別用 BM25 與向量 cosine 取 top-50,用 RRF ((\text{score}(d) = \sum_l \dfrac{1}{60 + \text{rank}_l(d)})) 融合成最終排序。
  6. (挑戰)實作 mini-HNSW:層級結構 + greedy walk + beam search,並與 §14.8 暴力 kNN 比較召回率與時間。
  7. (挑戰)實作 Product Quantization:把 (d) 維向量切成 (M) 段,每段獨立做 k-means 量化成 256 個碼字,存成 (M) byte,計算 asymmetric distance 並比較準確度/記憶體節省。

參考文獻

符號模糊搜尋

  • Levenshtein, V. I. (1966). Binary codes capable of correcting deletions, insertions, and reversals.
  • Damerau, F. J. (1964). A technique for computer detection and correction of spelling errors.
  • Jaro, M. A. (1989). Advances in record-linkage methodology.
  • Winkler, W. E. (1990). String comparator metrics and enhanced decision rules.
  • Burkhard, W. A., & Keller, R. M. (1973). Some approaches to best-match file searching.
  • Wu, S., & Manber, U. (1992). Agrep — A fast approximate pattern-matching tool.
  • Baeza-Yates, R., & Gonnet, G. H. (1992). A new approach to text searching (Shift-And/Or).
  • Myers, G. (1999). A fast bit-vector algorithm for approximate string matching based on dynamic programming.
  • Ukkonen, E. (1985). Algorithms for approximate string matching.
  • Navarro, G. (2001). A guided tour to approximate string matching. ACM Computing Surveys.
  • Norvig, P. How to Write a Spelling Corrector. https://norvig.com/spell-correct.html
  • SymSpell: https://github.com/wolfgarbe/SymSpell

向量語意搜尋

  • Mikolov, T. et al. (2013). Efficient Estimation of Word Representations in Vector Space (Word2Vec).
  • Pennington, J. et al. (2014). GloVe: Global Vectors for Word Representation.
  • Reimers, N., & Gurevych, I. (2019). Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks.
  • Charikar, M. (2002). Similarity Estimation Techniques from Rounding Algorithms (Random Hyperplane LSH).
  • Datar, M. et al. (2004). Locality-Sensitive Hashing Scheme Based on p-Stable Distributions.
  • Malkov, Y., & Yashunin, D. (2018). Efficient and Robust Approximate Nearest Neighbor Search Using HNSW.
  • Jégou, H. et al. (2011). Product Quantization for Nearest Neighbor Search.
  • Guo, R. et al. (2020). Accelerating Large-Scale Inference with Anisotropic Vector Quantization (ScaNN).
  • Lewis, P. et al. (2020). Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks (RAG).
  • FAISS:https://github.com/facebookresearch/faiss
  • hnswlib:https://github.com/nmslib/hnswlib
  • Sentence-Transformers:https://www.sbert.net/

附錄:常見錯誤與除錯提示

錯誤 症狀 解決
對等長字串以外算 Hamming runtime error / 無意義結果 先檢查長度
Levenshtein DP 邊界寫錯 第 0 列或第 0 行不為 i / j dp[i][0] = i; dp[0][j] = j;
DL 用 OSA 卻當作度量做 BK-Tree 查詢結果不完整 改用 True DL,或保證資料集合中不會出現複雜 transpose
Soundex 把 h, w 視作 vowel 同碼字母被切斷 視為「跳過但不重設 prev」
Bitap pattern 過長 結果錯誤 (M \le 63)(單字塊);超過要做多字塊版
Bitap 近似忘了「| 1」 無法從文本中段開始匹配 確保每次更新 R[d] 最後 | 1
BK-Tree 用非度量距離 查詢漏報 改用 Levenshtein 或其他度量
n-gram 跨 emoji 切到一半 結果古怪 以「Unicode code point」而非 byte 切
對短字串用 TF-IDF 餘弦 結果無意義 短字串改用 Jaro-Winkler 或編輯距離
模糊閾值過寬 回傳整個字典 限制 query 長度、加入頻率/前綴條件