文档对比算法的历史演进

20 阅读34分钟

文档对比算法的历史演进全景图

📊 总体演进脉络

文档对比算法的发展经历了六个重要纪元,从最初的字符级精确匹配,逐步演进到如今的深度语义理解。这一演进过程反映了计算机科学在文本处理领域的重大突破。

timeline
    title 文档对比算法历史演进
    1960s : 字符级精确匹配
          : 编辑距离
          : Hamming
    1970s : 序列/集合匹配
          : LCS/diff
          : Jaccard
    1990s : 统计向量空间模型
          : TF-IDF
          : BM25
    2000s : 哈希降维近似匹配
          : MinHash
          : SimHash
    2013 : 词向量分布式表示
         : Word2Vec
         : GloVe
    2017 : 预训练模型
         : Transformer
         : BERT
    2019+ : 语义检索
          : SBERT
          : Rerank

演进的核心维度变化

维度早期算法现代算法
匹配方式精确字符/词匹配语义理解匹配
处理规模小规模(KB级)超大规模(TB级)
可解释性完全透明黑盒模型
计算资源CPU即可需要GPU加速
训练依赖无需训练需要大规模预训练

🏛️ 第一纪元:字符级精确匹配 (1960s-1970s)

这一时期的算法关注的是字符级别的精确比对,主要解决"两个字符串有多不同"的问题。这些算法至今仍在拼写检查、DNA序列比对等领域广泛使用。

1.1 编辑距离家族

编辑距离(Edit Distance)衡量的是将一个字符串转换为另一个字符串所需的最少操作次数。不同的编辑距离算法允许不同类型的操作。

flowchart TB
    subgraph 编辑距离演化树
        H["<b>Hamming Distance</b><br/>1950<br/>仅替换操作<br/>要求等长字符串"]
        L["<b>Levenshtein Distance</b><br/>1965<br/>插入/删除/替换<br/>最经典的编辑距离"]
        DL["<b>Damerau-Levenshtein</b><br/>1964<br/>增加相邻字符转置<br/>更符合人类拼写错误"]
        NW["<b>Needleman-Wunsch</b><br/>1970<br/>带权重的全局比对<br/>生物信息学奠基"]
        JW["<b>Jaro-Winkler</b><br/>1989<br/>前缀加权匹配<br/>短字符串优化"]
        SW["<b>Smith-Waterman</b><br/>1981<br/>局部最优比对<br/>寻找相似片段"]
        
        H -->|"扩展操作类型"| L
        L -->|"增加转置"| DL
        L -->|"引入权重"| NW
        DL -->|"优化短串"| JW
        NW -->|"局部化"| SW
    end
Hamming Distance (1950)

发明背景与痛点:由 Richard Hamming 在贝尔实验室发明,最初用于纠错码检测。早期通信系统传输数据时,需要快速检测传输错误;如何量化两个等长编码之间的差异程度;在纠错码设计中,如何确定最小汉明距离以保证纠错能力?

核心思想:统计两个等长字符串在相同位置上不同字符的个数。

优点

  • 计算极其简单,时间复杂度 O(n)
  • 适合固定长度编码的比较

缺点

  • 只能处理等长字符串
  • 无法处理插入和删除的情况

适用场景:错误检测码、DNA序列中的点突变检测

代码示例

def hamming_distance(s1: str, s2: str) -> int:
    if len(s1) != len(s2):
        raise ValueError("字符串长度必须相等")
    return sum(c1 != c2 for c1, c2 in zip(s1, s2))

distance = hamming_distance("1011101", "1001001")
print(distance)  # 输出: 2 (第2位和第4位不同)

Levenshtein Distance (1965)

发明背景与痛点:由苏联数学家 Vladimir Levenshtein 提出,是最广泛使用的编辑距离。如何处理不等长字符串的相似度比较?拼写检查时,如何量化用户输入与正确单词的差异?如何找到将一个字符串转换为另一个字符串的最小操作步骤?在生物信息学中,如何衡量DNA序列的进化距离?

核心思想:允许三种操作——插入、删除、替换,计算将字符串A转换为B的最少操作次数。

算法复杂度

  • 时间复杂度:O(m×n)
  • 空间复杂度:O(m×n),可优化至 O(min(m,n))

优点

  • 通用性强,不限制字符串长度
  • 结果直观,表示"需要几步修改"
  • 可以回溯得到具体的编辑路径

缺点

  • 二次时间复杂度,不适合超长文本
  • 不考虑转置操作(typo中常见)

适用场景:拼写检查、模糊搜索、DNA序列比对

代码示例

def levenshtein_distance(s1: str, s2: str) -> int:
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    for i in range(m + 1):
        dp[i][0] = i
    for j in range(n + 1):
        dp[0][j] = j
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = 1 + min(
                    dp[i-1][j],      # 删除
                    dp[i][j-1],      # 插入
                    dp[i-1][j-1]     # 替换
                )
    return dp[m][n]

distance = levenshtein_distance("kitten", "sitting")
print(distance)  # 输出: 3 (k→s, e→i, 添加g)

Damerau-Levenshtein Distance (1964)

发明背景与痛点:Frederick Damerau 研究发现,80%以上的人类拼写错误属于四种类型:插入、删除、替换、相邻转置。Levenshtein 无法高效处理常见的"字符转置"错误(如 teh → the);如何更准确地建模人类真实的拼写错误模式?输入法中如何快速识别和纠正相邻字符颠倒的输入?OCR识别结果中常见的字符交换错误如何高效修正?

核心思想:在 Levenshtein 基础上增加"相邻字符转置"操作。

优点

  • 更符合人类拼写错误的实际情况
  • "teh" → "the" 只需1次操作(转置),而非2次(Levenshtein需删除+插入)

缺点

  • 实现复杂度略高
  • 计算开销比 Levenshtein 稍大

适用场景:拼写纠错、OCR后处理、输入法纠错

代码示例

def damerau_levenshtein_distance(s1: str, s2: str) -> int:
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    for i in range(m + 1):
        dp[i][0] = i
    for j in range(n + 1):
        dp[0][j] = j
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            cost = 0 if s1[i-1] == s2[j-1] else 1
            dp[i][j] = min(
                dp[i-1][j] + 1,           # 删除
                dp[i][j-1] + 1,           # 插入
                dp[i-1][j-1] + cost       # 替换
            )
            if i > 1 and j > 1 and s1[i-1] == s2[j-2] and s1[i-2] == s2[j-1]:
                dp[i][j] = min(dp[i][j], dp[i-2][j-2] + 1)  # 转置
    
    return dp[m][n]

distance = damerau_levenshtein_distance("ca", "abc")
print(distance)  # 输出: 2 (转置 ca→ac + 插入b)
# Levenshtein会输出3,Damerau-L更准确

Jaro-Winkler Distance (1989)

发明背景与痛点:由 Matthew Jaro 和 William Winkler 开发,专门用于人名匹配。如何高效匹配人名等短字符串,即使存在拼写差异?传统的编辑距离对短字符串不够友好,如何优化?在数据清洗中,如何识别同一实体的不同拼写形式?如何利用前缀匹配的特点来提高短字符串相似度计算的准确性?

核心思想

  1. 定义匹配窗口(允许一定距离内的字符配对)
  2. 计算匹配字符数和转置数
  3. Winkler改进:对前缀匹配给予额外权重

优点

  • 对短字符串效果优异
  • 前缀权重符合人名匹配特点(前几个字母通常正确)
  • 输出归一化到 [0,1] 区间

缺点

  • 长字符串效果不如 Levenshtein
  • 参数调整需要经验

适用场景:人名匹配、记录链接(Record Linkage)、数据去重

代码示例

def jaro_similarity(s1: str, s2: str) -> float:
    if s1 == s2:
        return 1.0
    
    len1, len2 = len(s1), len(s2)
    match_distance = max(len1, len2) // 2 - 1
    if match_distance < 0:
        match_distance = 0
    
    s1_matches = [False] * len1
    s2_matches = [False] * len2
    
    matches = 0
    transpositions = 0
    
    for i in range(len1):
        start = max(0, i - match_distance)
        end = min(i + match_distance + 1, len2)
        
        for j in range(start, end):
            if s2_matches[j] or s1[i] != s2[j]:
                continue
            s1_matches[i] = s2_matches[j] = True
            matches += 1
            break
    
    if matches == 0:
        return 0.0
    
    k = 0
    for i in range(len1):
        if not s1_matches[i]:
            continue
        while not s2_matches[k]:
            k += 1
        if s1[i] != s2[k]:
            transpositions += 1
        k += 1
    
    return (matches / len1 + matches / len2 + 
            (matches - transpositions / 2) / matches) / 3

def jaro_winkler(s1: str, s2: str, p: float = 0.1) -> float:
    jaro = jaro_similarity(s1, s2)
    
    prefix = 0
    for i in range(min(len(s1), len(s2), 4)):
        if s1[i] == s2[i]:
            prefix += 1
        else:
            break
    
    return jaro + prefix * p * (1 - jaro)

similarity = jaro_winkler("MARTHA", "MARHTA")
print(similarity)  # 输出: 0.961 (前缀匹配得到额外权重)

编辑距离家族对比表
算法年份允许操作时间复杂度最佳场景典型应用
Hamming1950替换O(n)等长编码纠错码、hash比较
Levenshtein1965插入/删除/替换O(m×n)通用文本拼写检查、搜索
Damerau-L1964+转置O(m×n)人类输入拼写纠错、输入法
Jaro-Winkler1989匹配窗口O(m×n)短字符串人名匹配、去重
Needleman-Wunsch1970带权重O(m×n)生物序列DNA/蛋白质比对
Smith-Waterman1981局部比对O(m×n)相似片段局部序列比对

1.2 序列匹配:LCS 家族

LCS(Longest Common Subsequence/Substring)家族是 diff 工具的理论基础,解决"两个文本有哪些共同部分"的问题。

子串 vs 子序列的本质区别
flowchart LR
    subgraph 概念区分
        原串["原串: A B C D E F G"]
        
        subgraph 子串Substring
            S1["必须连续"]
            S2["BCD ✓"]
            S3["BDF ✗"]
        end
        
        subgraph 子序列Subsequence
            Q1["保持相对顺序<br/>可不连续"]
            Q2["BCD ✓"]
            Q3["BDF ✓"]
            Q4["FDB ✗ 顺序错误"]
        end
    end
概念定义示例(原串ABCDEFG)
子串 (Substring)连续的字符序列"BCD" ✓, "BDF" ✗
子序列 (Subsequence)保持相对顺序,可不连续"BCD" ✓, "BDF" ✓, "FDB" ✗

diff 算法演进
flowchart TB
    subgraph diff算法演进史
        LCS["<b>经典 LCS DP</b><br/>1970s<br/>O(m×n) 时间和空间<br/>理论基础,教学使用"]
        
        HM["<b>Hunt-McIlroy</b><br/>1976<br/>O(n×m×log m)<br/>匹配点稀疏时更快"]
        
        MYERS["<b>Myers Algorithm</b><br/>1986<br/>O((M+N)×D)<br/>D=差异数,差异小时极快"]
        
        PAT["<b>Patience Diff</b><br/>2006<br/>O(n log n)<br/>语义更好,处理代码移动"]
        
        HIST["<b>Histogram Diff</b><br/>2011<br/>Patience优化版<br/>Git 默认算法"]
        
        LCS --> HM
        HM --> MYERS
        MYERS --> PAT
        MYERS --> HIST
        
        HM -.->|"应用于"| UNIX["Unix diff"]
        MYERS -.->|"应用于"| GIT["Git diff"]
        HIST -.->|"应用于"| GIT2["Git 2.0+ 默认"]
    end
经典 LCS 动态规划

发明背景与痛点:如何找到两个文本的最长公共子序列,用于版本控制和代码比对?如何生成可读的diff输出,展示两个文件的差异?在生物信息学中,如何比对DNA序列找到相似片段?如何处理大规模文件的差异比较,避免二次复杂度的性能问题?

核心思想:通过动态规划找到两个序列的最长公共子序列,基于此可以生成diff结果。

复杂度:O(m×n) 时间和空间

优点

  • 实现简单,概念清晰
  • 保证找到最优解

缺点

  • 对于大文件效率低
  • 空间消耗大

代码示例

def lcs_length(s1: str, s2: str) -> int:
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    
    return dp[m][n]

def lcs_string(s1: str, s2: str) -> str:
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    
    result = []
    i, j = m, n
    while i > 0 and j > 0:
        if s1[i-1] == s2[j-1]:
            result.append(s1[i-1])
            i -= 1
            j -= 1
        elif dp[i-1][j] > dp[i][j-1]:
            i -= 1
        else:
            j -= 1
    
    return ''.join(reversed(result))

length = lcs_length("ABCBDAB", "BDCABA")
lcs = lcs_string("ABCBDAB", "BDCABA")
print(f"长度: {length}, LCS: {lcs}")  # 输出: 长度: 4, LCS: BCBA

Myers Algorithm (1986)

发明背景与痛点:由 Eugene W. Myers 发明,这是 Git diff 的核心算法,至今仍在使用。经典LCS算法的O(m×n)复杂度在差异较小时效率低下;如何利用"差异小"这一特点来优化diff算法?版本控制系统中,如何快速生成两个版本之间的差异?如何生成最小编辑脚本,使diff结果最紧凑?

核心思想

  • 将 diff 问题转化为图上的最短路径问题
  • 使用"编辑图"(Edit Graph)表示,对角线移动(匹配)免费,水平/垂直移动(增删)有代价
  • 贪心策略优先探索对角线

复杂度:O((M+N)×D),其中 D 是差异数量

优点

  • 差异小时极快(D 较小)
  • 生成的 diff 结果紧凑直观
  • 保证最小编辑脚本

缺点

  • 差异大时退化到 O(m×n)
  • 对代码块移动不够友好

适用场景:版本控制系统、代码审查、文档比对

代码示例

def myers_diff(a: str, b: str):
    m, n = len(a), len(b)
    max_d = m + n
    v = {1: 0}
    
    for d in range(0, max_d + 1):
        for k in range(-d, d + 1, 2):
            if k == -d or (k != d and v[k-1] < v[k+1]):
                x = v[k+1]
            else:
                x = v[k-1] + 1
            
            y = x - k
            
            while x < m and y < n and a[x] == b[y]:
                x += 1
                y += 1
            
            v[k] = x
            
            if x >= m and y >= n:
                return d
    
    return max_d

distance = myers_diff("ABCABBA", "CBABAC")
print(f"Myers差异值: {distance}")  # 输出差异程度

Patience Diff (2006)

发明背景与痛点:传统diff算法对代码块移动的处理不够友好;如何生成更具"语义"的diff,提高可读性?代码重构时,如何避免将不相关的代码行配对在一起?如何利用唯一行作为锚点,优化diff的语义质量?

核心思想

  1. 首先找出两个文件中都只出现一次的行(唯一行)
  2. 对这些唯一行应用 LCS
  3. 用这些"锚点"递归处理中间部分

优点

  • 对代码移动的处理更友好
  • 生成的 diff 更具"语义",更易读
  • 不会把无关的 { } 配对在一起

缺点

  • 对于没有唯一行的情况效果不好
  • 某些情况下不是最小 diff

适用场景:代码审查、需要可读性的 diff

代码示例

def patience_diff(a: list, b: list):
    def longest_increasing_subsequence(seq):
        if not seq:
            return []
        tails = []
        prev = [-1] * len(seq)
        
        for i, x in enumerate(seq):
            idx = bisect_left(tails, x)
            if idx == len(tails):
                tails.append(x)
            else:
                tails[idx] = x
            prev[i] = tails[idx-1] if idx > 0 else -1
        
        result = []
        k = tails[-1]
        while k != -1:
            result.append(k)
            k = prev[k]
        return list(reversed(result))
    
    from bisect import bisect_left
    
    unique_a = {line: i for i, line in enumerate(a) if a.count(line) == 1}
    unique_b = {line: i for i, line in enumerate(b) if b.count(line) == 1}
    
    common = [(unique_a[line], unique_b[line]) for line in unique_a if line in unique_b]
    common.sort()
    
    lcs = longest_increasing_subsequence([x[1] for x in common])
    
    matches = [(common[i][0], common[i][1]) for i in lcs]
    return matches

a_lines = ["def foo():", "    x = 1", "    y = 2", "    return x + y"]
b_lines = ["def bar():", "    x = 1", "    y = 2", "    return x + y"]
matches = patience_diff(a_lines, b_lines)
print(f"匹配的行: {matches}")

Histogram Diff (2011)

核心思想:Patience Diff 的优化版,使用直方图统计行出现频率,低频行作为锚点。

优点

  • 综合了 Myers 和 Patience 的优点
  • 性能更稳定
  • Git 2.0+ 的默认选择

适用场景:通用代码版本控制


LCS/diff 算法对比表
算法年份时间复杂度特点应用产品
经典LCS1970sO(m×n)理论基础教学、小规模工具
Hunt-McIlroy1976O(n×m×log m)匹配点稀疏时快Unix diff
Myers1986O((M+N)×D)差异小时极快Git diff
Patience2006O(n log n)语义更好Bazaar, Git可选
Histogram2011变化平衡性能和可读性Git 默认

🎲 第二纪元:集合论方法 (1900s理论 → 1990s应用)

当需要处理大规模数据(如互联网网页去重)时,字符级的精确匹配变得不可行。集合论方法将文档视为特征集合,通过集合操作快速估算相似度。

2.1 Jaccard 相似度 (1901)

发明背景与痛点:由 Paul Jaccard 发明,最初用于植物群落的物种相似性比较。如何量化两个集合之间的相似程度?在推荐系统中,如何计算用户兴趣的相似度?在数据清洗中,如何识别重复或相似的记录?如何设计一个不受集合大小影响的相似度度量?

核心公式

J(A,B)=ABABJ(A, B) = \frac{|A \cap B|}{|A \cup B|}

flowchart TB
    subgraph Jaccard相似度
        formula["J(A, B) = |A ∩ B| / |A ∪ B|"]
        
        subgraph 特性
            F1["范围: [0, 1]"]
            F2["1 = 完全相同"]
            F3["0 = 完全不同"]
            F4["对称性: J(A,B) = J(B,A)"]
        end
        
        subgraph 优缺点
            P1["✓ 简单直观"]
            P2["✓ 无需考虑顺序"]
            P3["✓ 对集合大小不敏感"]
            N1["✗ 精确计算需要完整集合"]
            N2["✗ 大规模时效率低"]
        end
    end

优点

  • 概念简单,易于理解和实现
  • 归一化输出,便于设置阈值
  • 不受集合大小影响

缺点

  • 精确计算需要遍历所有元素
  • 不考虑元素的权重和顺序
  • 大规模数据时效率问题

适用场景:小规模集合比较、推荐系统、文档聚类

代码示例

def jaccard_similarity(set1: set, set2: set) -> float:
    if not set1 and not set2:
        return 1.0
    if not set1 or not set2:
        return 0.0
    
    intersection = len(set1 & set2)
    union = len(set1 | set2)
    
    return intersection / union

set_a = {"apple", "banana", "orange"}
set_b = {"apple", "banana", "grape"}
similarity = jaccard_similarity(set_a, set_b)
print(f"Jaccard相似度: {similarity}")  # 输出: 0.666...

2.2 Shingling (k-gram)

发明背景与痛点:如何将文档转换为适合集合论处理的特征集合?如何在保留局部顺序信息的同时,支持集合操作?如何选择合适的k值,平衡特征粒度和计算效率?在抄袭检测中,如何识别局部相似的文档片段?

核心思想:将文档转换为连续k个字符(或单词)的集合,称为 shingle 或 k-gram。

示例

  • 文本:"abcde"
  • k=2的shingle集合:{ab, bc, cd, de}

参数选择

粒度k 值范围适用场景
字符级5-10短文本、近似匹配
单词级2-5长文档、抄袭检测

优点

  • 保留了一定的局部顺序信息
  • 对小修改具有鲁棒性

缺点

  • k 值选择影响结果
  • 集合可能很大

适用场景:文档去重、抄袭检测、近似匹配

代码示例

def shingling(text: str, k: int = 3) -> set:
    shingles = set()
    text = text.replace(" ", "")
    
    for i in range(len(text) - k + 1):
        shingle = text[i:i+k]
        shingles.add(shingle)
    
    return shingles

text1 = "hello world"
text2 = "hello word"

shingles1 = shingling(text1, k=3)
shingles2 = shingling(text2, k=3)

print(f"文本1的shingles: {shingles1}")
print(f"文本2的shingles: {shingles2}")

2.3 MinHash (1997)

发明背景与痛点:由 Andrei Broder 发明,AltaVista 搜索引擎需要对互联网上的网页进行去重,直接计算 Jaccard 相似度无法处理亿级网页。如何快速估算大规模文档集合的Jaccard相似度?互联网上有亿级网页,如何高效进行去重?直接计算Jaccard相似度需要遍历所有元素,如何降维加速?如何在保证精度的前提下,大幅减少计算和存储开销?

核心定理Pr[MinHash(A)=MinHash(B)]=Jaccard(A,B)Pr[MinHash(A) = MinHash(B)] = Jaccard(A, B)

即:两个集合的 MinHash 值相同的概率,等于它们的 Jaccard 相似度。

flowchart LR
    subgraph MinHash流程
        DOC["文档"] --> SHIN["Shingling<br/>切分为k-gram"]
        SHIN --> HASH["应用 k 个哈希函数"]
        HASH --> SIG["生成签名向量<br/>[h₁(min), h₂(min), ..., hₖ(min)]"]
        SIG --> COMP["比较签名<br/>相同位置匹配比例 ≈ Jaccard"]
    end

算法步骤

  1. 将文档转换为 shingle 集合
  2. 使用 k 个不同的哈希函数
  3. 对每个哈希函数,记录集合中的最小哈希值
  4. 得到长度为 k 的签名向量
  5. 两个文档签名中相同位置相等的比例,近似于 Jaccard 相似度

优点

  • 将大集合压缩为固定长度签名
  • 可以快速估算 Jaccard 相似度
  • 误差可控(增加 k 减小误差)

缺点

  • 是近似值,存在误差
  • 需要多个哈希函数
  • 单独使用仍需两两比较

适用场景:网页去重、近似重复检测、聚类

代码示例

import random

def minhash_signature(document: set, num_hashes: int = 100) -> list:
    signature = []
    
    for _ in range(num_hashes):
        min_hash = float('inf')
        
        for element in document:
            hash_value = hash(str(element) + str(random.random()))
            min_hash = min(min_hash, hash_value)
        
        signature.append(min_hash)
    
    return signature

def estimate_jaccard(sig1: list, sig2: list) -> float:
    matches = sum(1 for a, b in zip(sig1, sig2) if a == b)
    return matches / len(sig1)

doc1 = {"apple", "banana", "orange", "grape"}
doc2 = {"apple", "banana", "grape", "pear"}

sig1 = minhash_signature(doc1, num_hashes=100)
sig2 = minhash_signature(doc2, num_hashes=100)

estimated = estimate_jaccard(sig1, sig2)
print(f"估算的Jaccard相似度: {estimated}")

2.4 LSH - Locality Sensitive Hashing (1998)

发明背景与痛点:由 Piotr Indyk 和 Rajeev Motwani 发明。如何在海量数据中快速找到相似的文档对?MinHash仍需两两比较,如何进一步优化到亚线性复杂度?如何设计哈希函数,使得相似项碰撞,不相似项不碰撞?如何在召回率和精确率之间找到平衡?

核心思想:将相似的项目哈希到同一个桶中,不相似的项目哈希到不同桶中。这与传统哈希(尽量避免碰撞)正好相反。

flowchart TB
    subgraph LSH分桶策略
        SIG["签名矩阵<br/>(每列是一个文档的签名)"]
        
        subgraph 分band
            B1["Band 1<br/>r 行"]
            B2["Band 2<br/>r 行"]
            B3["Band 3<br/>r 行"]
            BN["...<br/>共 b 个 band"]
        end
        
        BUCKET["每个 band 独立哈希到桶<br/>任一 band 相同 → 候选对"]
        
        SIG --> B1 & B2 & B3 & BN --> BUCKET
    end

参数调优

  • b = band 数量
  • r = 每个 band 的行数
  • 签名长度 = b × r

候选概率:两个 Jaccard 相似度为 s 的文档成为候选对的概率: P=1(1sr)bP = 1 - (1 - s^r)^b

参数设置效果
r 增大更高精度,但召回率下降
b 增大更高召回率,但误报增加

优点

  • 亚线性时间复杂度
  • 可处理亿级数据
  • 参数可调控精度/召回权衡

缺点

  • 需要仔细调参
  • 可能漏掉相似对
  • 有误报需要后验证

适用场景:大规模相似度搜索、推荐系统、近似最近邻

代码示例

from collections import defaultdict

def lsh_bands(signatures: dict, b: int = 20, r: int = 5) -> dict:
    buckets = defaultdict(list)
    
    for doc_id, signature in signatures.items():
        for band in range(b):
            start = band * r
            end = start + r
            band_signature = tuple(signature[start:end])
            bucket_key = (band, band_signature)
            buckets[bucket_key].append(doc_id)
    
    candidates = set()
    for bucket in buckets.values():
        if len(bucket) > 1:
            for i in range(len(bucket)):
                for j in range(i + 1, len(bucket)):
                    candidates.add(tuple(sorted((bucket[i], bucket[j]))))
    
    return candidates

signatures = {
    "doc1": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
    "doc2": [1, 2, 3, 4, 5, 11, 12, 13, 14, 15],
    "doc3": [100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]
}

candidate_pairs = lsh_bands(signatures, b=2, r=5)
print(f"候选相似文档对: {candidate_pairs}")

2.5 SimHash (2002)

发明背景与痛点:由 Moses Charikar 发明,Google 用于网页去重(2007年论文披露)。如何用极小的空间表示文档,用于快速去重?Google如何处理百亿级网页的去重问题?MinHash需要存储多个哈希值,能否进一步压缩到单个指纹?如何利用汉明距离快速判断文档相似度?

核心思想:生成一个固定长度的指纹(通常64位),相似文档的指纹汉明距离小。

flowchart LR
    subgraph SimHash计算流程
        DOC2["文档"] --> TOKEN["分词<br/>提取特征"]
        TOKEN --> WEIGHT["计算权重<br/>(TF-IDF等)"]
        WEIGHT --> HASH2["每个特征计算哈希"]
        HASH2 --> ACC["按位加权累加<br/>1→+weight, 0→-weight"]
        ACC --> SIGN2["符号化<br/>正→1, 负→0"]
        SIGN2 --> FP["64位指纹"]
    end

计算步骤详解

  1. 分词并计算每个词的权重(如TF-IDF)
  2. 每个词计算一个 n 位哈希
  3. 初始化 n 维向量为 0
  4. 对每个词:哈希的每一位,是1则加权重,是0则减权重
  5. 最终向量每一位:正数变1,负数变0

优点

  • 极度空间高效(仅64/128位)
  • 汉明距离计算极快(CPU指令级)
  • 支持海量数据

缺点

  • 只能检测高相似度文档
  • 局部修改可能导致指纹大变
  • 不适合细粒度相似度计算

适用场景:大规模去重、近似重复检测、快速筛选

代码示例

import hashlib

def simhash(text: str, bits: int = 64) -> int:
    words = text.split()
    weights = {}
    
    for word in words:
        weights[word] = weights.get(word, 0) + 1
    
    vector = [0] * bits
    
    for word, weight in weights.items():
        hash_value = int(hashlib.md5(word.encode()).hexdigest(), 16)
        
        for i in range(bits):
            if (hash_value >> i) & 1:
                vector[i] += weight
            else:
                vector[i] -= weight
    
    fingerprint = 0
    for i in range(bits):
        if vector[i] > 0:
            fingerprint |= (1 << i)
    
    return fingerprint

def hamming_distance(hash1: int, hash2: int, bits: int = 64) -> int:
    xor = hash1 ^ hash2
    distance = 0
    while xor:
        distance += xor & 1
        xor >>= 1
    return distance

text1 = "the quick brown fox jumps over the lazy dog"
text2 = "the quick brown fox jumps over the lazy cat"

hash1 = simhash(text1)
hash2 = simhash(text2)
distance = hamming_distance(hash1, hash2)

print(f"SimHash汉明距离: {distance}")

MinHash vs SimHash 对比

flowchart TB
    subgraph 设计目标对比
        subgraph MinHash
            M1["目标: 估算 Jaccard 相似度"]
            M2["输出: k 个哈希值组成的签名"]
            M3["比较: 相同位置匹配比例"]
            M4["优势: 精度可调(增加k)"]
        end
        
        subgraph SimHash
            S1["目标: 估算余弦相似度"]
            S2["输出: 单个指纹(64/128位)"]
            S3["比较: 汉明距离"]
            S4["优势: 极致空间效率"]
        end
    end
维度MinHashSimHash
估算目标Jaccard 相似度余弦相似度
输出形式k 个哈希值(签名)单个指纹
比较方式匹配位置比例汉明距离
空间效率中等(k×哈希大小)极高(64位)
精度可调增加 k 提高精度位数固定
适用相似度低到高相似度仅高相似度
典型应用文档聚类网页去重

集合方法综合对比表

方法年份复杂度空间规模精度核心优势
Jaccard 精确1901O(|A|+|B|)原集合万级精确简单直观
Shingling-O(n)O(n/k)--特征提取
MinHash1997O(k×n)O(k)百万级可调Jaccard近似
LSH1998亚线性O(b×桶)十亿级可调候选对发现
SimHash2002O(n)64位百亿级有限极致效率

📊 第三纪元:统计向量空间模型 (1970s-2000s)

这一时期的突破是将文档表示为向量,在向量空间中计算相似度。这为后来的机器学习方法奠定了基础。

3.1 TF-IDF (1972)

发展历程与痛点:1957年Hans Peter Luhn在IBM提出TF(词频)概念;1972年Karen Spärck Jones在剑桥提出IDF(逆文档频率);1988年Salton & Buckley系统化TF-IDF;1994年Robertson等人提出BM25。如何量化文档中词的重要性,区分常用词和稀有词?词频高的词不一定重要(如"的"),如何降低其权重?如何设计一个无需训练的文档表示方法?在信息检索中,如何计算文档与查询的相关性?

核心公式TF-IDF(t,d,D)=TF(t,d)×IDF(t,D)TF\text{-}IDF(t, d, D) = TF(t, d) \times IDF(t, D)

TF (Term Frequency) 变体
变体公式特点
原始频率count(t, d)简单但受文档长度影响
布尔频率1 if t ∈ d else 0只关心存在性
对数频率1 + log(count)最常用,平滑高频词
归一化频率count / max_count消除文档长度影响
IDF (Inverse Document Frequency) 变体
变体公式特点
标准log(N / df)可能除零
平滑log(N / (df+1)) + 1推荐,避免除零
概率log((N - df) / df)罕见词权重更高

优点

  • 概念简单,易于理解
  • 无需训练,直接计算
  • 有效区分重要词和常见词

缺点

  • 词袋模型,丢失顺序信息
  • 无法处理同义词
  • 稀疏高维向量

痛点问题

  • 如何量化文档中词的重要性,区分常用词和稀有词?
  • 词频高的词不一定重要(如"的"),如何降低其权重?
  • 如何设计一个无需训练的文档表示方法?
  • 在信息检索中,如何计算文档与查询的相关性?

代码示例

import math
from collections import Counter

def tfidf(document: str, corpus: list) -> dict:
    N = len(corpus)
    doc_words = document.lower().split()
    tf = Counter(doc_words)
    
    idf = {}
    for word in set(doc_words):
        df = sum(1 for doc in corpus if word in doc.lower().split())
        idf[word] = math.log((N + 1) / (df + 1)) + 1
    
    tfidf_scores = {}
    for word, freq in tf.items():
        tfidf_scores[word] = freq * idf.get(word, 0)
    
    return tfidf_scores

corpus = [
    "the cat sat on the mat",
    "the dog sat on the log",
    "cats and dogs are pets"
]

doc = "the cat sat on the mat"
scores = tfidf(doc, corpus)
print(f"TF-IDF分数: {dict(list(scores.items())[:5])}")

3.2 余弦相似度

发明背景与痛点:1975年Salton等人提出向量空间模型,引入几何学中的夹角余弦概念用于信息检索。如何计算两个向量之间的相似度,不受向量长度影响?文档长度不同时,如何公平比较其相似性?在向量空间模型中,如何量化两个文档的夹角?如何设计一个归一化的相似度度量,便于设置阈值?

核心公式cos(θ)=ABA×B=i(Ai×Bi)iAi2×iBi2\cos(\theta) = \frac{A \cdot B}{\|A\| \times \|B\|} = \frac{\sum_{i}(A_i \times B_i)}{\sqrt{\sum_{i}A_i^2} \times \sqrt{\sum_{i}B_i^2}}

flowchart TB
    subgraph 余弦相似度特性
        G1["范围: [-1, 1]<br/>TF-IDF向量通常 ∈ [0, 1]"]
        G2["θ = 0° → cos = 1 → 完全相同方向"]
        G3["θ = 90° → cos = 0 → 正交/无关"]
        G4["长度归一化: 长短文档可比"]
    end

优点

  • 对文档长度不敏感(归一化)
  • 计算高效
  • 几何意义直观

缺点

  • 假设维度独立
  • 无法捕获词间关系

适用场景:文档相似度、信息检索、推荐系统

代码示例

import math
from collections import Counter

def cosine_similarity(vec1: dict, vec2: dict) -> float:
    dot_product = sum(vec1[word] * vec2.get(word, 0) for word in vec1)
    
    norm1 = math.sqrt(sum(v ** 2 for v in vec1.values()))
    norm2 = math.sqrt(sum(v ** 2 for v in vec2.values()))
    
    if norm1 == 0 or norm2 == 0:
        return 0.0
    
    return dot_product / (norm1 * norm2)

doc1 = "apple banana orange"
doc2 = "apple banana grape"

vec1 = Counter(doc1.split())
vec2 = Counter(doc2.split())

similarity = cosine_similarity(vec1, vec2)
print(f"余弦相似度: {similarity}")  # 输出: 0.816...

3.3 BM25 (1994)

发明背景与痛点:1994年Stephen Robertson等人基于概率检索模型提出BM25(Best Matching 25),是一系列排名函数的第25个变体。TF-IDF中词频线性增长不合理,如何设计饱和函数?如何考虑文档长度对相关性的影响?如何基于概率检索模型设计排名函数?如何在信息检索中平衡词频、文档频率和长度因素?

核心公式BM25(t,d)=IDF(t)×(k1+1)×tftf+k1×(1b+b×davgdl)BM25(t,d) = IDF(t) \times \frac{(k_1 + 1) \times tf}{tf + k_1 \times (1 - b + b \times \frac{|d|}{avgdl})}

参数说明

参数典型值作用
k₁1.2 ~ 2.0TF 饱和度控制
b0.75文档长度归一化程度
avgdl-语料库平均文档长度
flowchart TB
    subgraph BM25_vs_TFIDF
        subgraph TF增长曲线
            TF_C["TF-IDF: 词频增加 → 分数线性增长"]
            BM_C["BM25: 词频增加 → 分数趋于饱和"]
        end
        
        INSIGHT["核心洞察:<br/>一个词出现100次不应该比出现10次重要10倍"]
    end

BM25 相比 TF-IDF 的改进

方面TF-IDFBM25
TF 处理线性或对数饱和函数
长度归一化可选,通常无内置,参数化控制
理论基础启发式概率检索模型
参数k₁, b 可调优
工业使用基础场景Elasticsearch 默认

优点

  • TF 饱和效应更合理
  • 文档长度归一化可控
  • 经过大量实践验证

缺点

  • 仍是词袋模型
  • 仍无法理解语义

适用场景:搜索引擎、信息检索(至今仍是主流baseline)

代码示例

import math
from collections import Counter

def bm25(query: str, document: str, corpus: list, k1: float = 1.5, b: float = 0.75) -> float:
    N = len(corpus)
    avgdl = sum(len(doc.split()) for doc in corpus) / N
    
    query_terms = query.lower().split()
    doc_terms = document.lower().split()
    doc_len = len(doc_terms)
    
    tf = Counter(doc_terms)
    
    score = 0
    for term in query_terms:
        if term not in tf:
            continue
        
        df = sum(1 for doc in corpus if term in doc.lower().split())
        idf = math.log((N - df + 0.5) / (df + 0.5) + 1)
        
        term_freq = tf[term]
        numerator = (k1 + 1) * term_freq
        denominator = term_freq + k1 * (1 - b + b * doc_len / avgdl)
        
        score += idf * (numerator / denominator)
    
    return score

corpus = [
    "the cat sat on the mat",
    "the dog sat on the log",
    "cats and dogs are pets"
]

query = "cat mat"
document = "the cat sat on the mat"

score = bm25(query, document, corpus)
print(f"BM25分数: {score}")

TF-IDF 与 BM25 对比总结

特性TF-IDFBM25
提出时间19721994
TF 增长线性/对数饱和曲线
长度归一化需额外处理内置参数 b
理论基础信息论启发概率检索模型
可调参数k₁, b
现代地位教学、简单场景工业标准
代表产品-Elasticsearch, Lucene

🖼️ 第四纪元:感知哈希 (2000s)

发明背景与痛点:感知哈希(Perceptual Hash)最初为图像设计,2000年代开始广泛应用,其思想可延伸到文档(如PDF渲染后比较)。如何快速检测文档的视觉相似度,忽略格式差异?PDF/Word文档格式不同但内容相似,如何比较?如何在版权检测中识别轻微修改的文档?传统哈希对微小变化敏感,如何设计感知哈希?

感知哈希(Perceptual Hash)最初为图像设计,但其思想可延伸到文档(如PDF渲染后比较)。

pHash 家族

flowchart LR
    subgraph pHash家族演进
        AHASH["<b>aHash</b><br/>Average Hash<br/>最简单<br/>缩放→计算均值→二值化"]
        
        DHASH["<b>dHash</b><br/>Difference Hash<br/>相邻像素差值<br/>抗轻微变形"]
        
        PHASH["<b>pHash</b><br/>Perceptual Hash<br/>DCT变换<br/>鲁棒性最好"]
        
        WHASH["<b>wHash</b><br/>Wavelet Hash<br/>小波变换<br/>多分辨率分析"]
        
        AHASH -->|"增加梯度"| DHASH
        DHASH -->|"频域分析"| PHASH
        PHASH -->|"多尺度"| WHASH
    end
算法原理优点缺点适用场景
aHash均值二值化最快抗干扰差快速筛选
dHash相邻差值抗轻微变化大变形敏感缩略图匹配
pHashDCT频域最鲁棒计算较慢版权检测
wHash小波变换多尺度复杂专业场景

在文档领域的应用

  • PDF/Word 渲染为图片后比较
  • 解决格式差异问题
  • 结合 OCR 做视觉相似度

代码示例(以aHash为例):

import cv2
import numpy as np

def average_hash(image_path: str, hash_size: int = 8) -> str:
    img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
    img = cv2.resize(img, (hash_size, hash_size))
    
    avg = np.mean(img)
    hash_str = ''.join(['1' if pixel > avg else '0' for row in img for pixel in row])
    
    return hash_str

def hamming_distance(hash1: str, hash2: str) -> int:
    return sum(c1 != c2 for c1, c2 in zip(hash1, hash2))

hash1 = average_hash("doc1.png")
hash2 = average_hash("doc2.png")
distance = hamming_distance(hash1, hash2)
print(f"汉明距离: {distance}")

🧠 第五纪元:词向量与分布式表示 (2013-2018)

这一时期的革命性突破是将离散的词符号映射到连续的向量空间,使得词之间的语义关系可以通过向量运算表达。

5.1 Word2Vec (2013)

发明背景与痛点:2013年Tomas Mikolov等人在Google提出,开启了NLP的"预训练"时代。如何将离散的词符号映射到连续的向量空间?如何让词向量捕获语义关系(如 king - man + woman ≈ queen)?如何高效训练大规模词向量?One-Hot表示稀疏且无语义,如何改进?

革命性意义:开启了 NLP 的"预训练"时代

flowchart TB
    subgraph 表示方式对比
        subgraph OneHot["传统 One-Hot"]
            OH1["king  = [1,0,0,0,0,...]"]
            OH2["queen = [0,1,0,0,0,...]"]
            OH3["man   = [0,0,1,0,0,...]"]
            OH_P["特点: 稀疏、高维、正交、无语义"]
        end
        
        subgraph Word2Vec表示
            WV1["king  = [0.2, 0.8, -0.1, ...]"]
            WV2["queen = [0.3, 0.9, -0.2, ...]"]
            WV3["man   = [0.1, 0.4,  0.2, ...]"]
            WV_P["特点: 稠密、低维、相似词距离近"]
        end
        
        MAGIC["king - man + woman ≈ queen"]
    end
两种训练架构
flowchart LR
    subgraph CBOW["CBOW (Continuous Bag of Words)"]
        CTX1["上下文: [The] [cat] [_] [on] [mat]"]
        PRED1["预测: sat"]
        CTX1 --> PRED1
        NOTE1["• 训练快<br/>• 适合大数据集<br/>• 高频词效果好"]
    end
    
    subgraph SkipGram["Skip-gram"]
        CENTER["中心词: sat"]
        CTX2["预测上下文: The, cat, on, mat"]
        CENTER --> CTX2
        NOTE2["• 对低频词友好<br/>• 语义关系更好<br/>• 数据效率高"]
    end
架构输入输出优势劣势
CBOW上下文词中心词训练快、高频词好低频词差
Skip-gram中心词上下文词低频词好、语义好训练慢

优点

  • 向量运算具有语义意义
  • 训练高效(负采样技巧)
  • 迁移性好

缺点

  • 一词一向量,无法处理多义词
  • OOV(未登录词)问题
  • 忽略词序

代码示例(简化版Skip-gram):

import numpy as np

class Word2Vec:
    def __init__(self, vocab_size: int, embedding_dim: int = 100):
        self.vocab_size = vocab_size
        self.embedding_dim = embedding_dim
        self.center_embeddings = np.random.randn(vocab_size, embedding_dim) * 0.01
        self.context_embeddings = np.random.randn(vocab_size, embedding_dim) * 0.01
    
    def train_step(self, center_idx: int, context_idx: int, learning_rate: float = 0.01):
        center_vec = self.center_embeddings[center_idx]
        context_vec = self.context_embeddings[context_idx]
        
        score = np.dot(center_vec, context_vec)
        pred = 1 / (1 + np.exp(-score))
        
        error = pred - 1
        grad_center = error * context_vec
        grad_context = error * center_vec
        
        self.center_embeddings[center_idx] -= learning_rate * grad_center
        self.context_embeddings[context_idx] -= learning_rate * grad_context
    
    def get_embedding(self, word_idx: int) -> np.ndarray:
        return self.center_embeddings[word_idx]

w2v = Word2Vec(vocab_size=1000, embedding_dim=50)
w2v.train_step(center_idx=10, context_idx=20)
embedding = w2v.get_embedding(10)
print(f"词向量维度: {embedding.shape}")

5.2 GloVe (2014)

发明背景与痛点:2014年Jeffrey Pennington等人在Stanford提出GloVe(Global Vectors for Word Representation)。Word2Vec只使用局部上下文,如何利用全局统计信息?如何结合矩阵分解和神经网络的优势?如何设计词向量使得词对共现概率与向量点积相关?如何在词向量中编码全局语义关系?

核心思想:结合全局统计信息(共现矩阵)和局部上下文

flowchart TB
    subgraph Word2Vec_vs_GloVe
        subgraph Word2Vec特点
            W1["训练: 局部上下文窗口滑动"]
            W2["目标: 预测 P(context|word)"]
            W3["方法: 神经网络"]
            W4["优势: 增量训练友好"]
        end
        
        subgraph GloVe特点
            G1["训练: 全局共现矩阵分解"]
            G2["目标: 拟合 log(共现概率)"]
            G3["方法: 矩阵分解 + 回归"]
            G4["优势: 利用全局统计"]
        end
    end
维度Word2VecGloVe
训练数据局部窗口全局共现矩阵
理论基础神经网络预测矩阵分解
增量学习支持不支持
类比任务更好
训练效率中等单次较慢

代码示例(简化版GloVe训练):

import numpy as np

def glove_loss(W: np.ndarray, U: np.ndarray, coocurrence: np.ndarray, 
               x_max: float = 100, alpha: float = 0.75) -> float:
    vocab_size = W.shape[0]
    loss = 0
    
    for i in range(vocab_size):
        for j in range(vocab_size):
            X_ij = coocurrence[i, j]
            if X_ij == 0:
                continue
            
            f_xij = min(1, (X_ij / x_max) ** alpha)
            diff = np.dot(W[i], U[j]) - np.log(X_ij)
            loss += f_xij * (diff ** 2)
    
    return loss

vocab_size = 1000
embedding_dim = 50
W = np.random.randn(vocab_size, embedding_dim) * 0.01
U = np.random.randn(vocab_size, embedding_dim) * 0.01
coocurrence = np.random.randint(0, 10, (vocab_size, vocab_size))

loss = glove_loss(W, U, coocurrence)
print(f"GloVe损失: {loss}")

5.3 FastText (2016)

发明背景与痛点:2016年Facebook AI Research提出FastText,核心创新是使用子词(subword)表示。Word2Vec无法处理OOV(未登录词):遇到训练集中没有的词时,无法生成词向量;形态学信息丢失:无法捕获词根、前缀、后缀等形态变化,如"run"和"running"被视为完全不同的词;拼写错误敏感:一个字符错误就导致无法识别;多语言支持差:对于形态丰富的语言(如芬兰语、土耳其语),需要更大的训练数据。

核心创新:使用子词(subword)表示

示例

  • "where" → {"<wh", "whe", "her", "ere", "re>", ""}
  • 新词 "wherefrom" 可通过子词组合获得向量

优点

  • 有效处理 OOV
  • 对形态丰富的语言效果好
  • 拼写错误鲁棒性

缺点

  • 向量维度更大
  • 中文等无明确子词语言效果有限

代码示例

import numpy as np
from collections import defaultdict

class FastText:
    def __init__(self, vocab_size: int, embedding_dim: int = 100, min_n: int = 3, max_n: int = 6):
        self.vocab_size = vocab_size
        self.embedding_dim = embedding_dim
        self.min_n = min_n
        self.max_n = max_n
        self.word_embeddings = np.random.randn(vocab_size, embedding_dim) * 0.01
        self.ngram_embeddings = defaultdict(lambda: np.random.randn(embedding_dim) * 0.01)
    
    def get_ngrams(self, word: str) -> list:
        ngrams = []
        word = f"<{word}>"
        
        for n in range(self.min_n, min(len(word), self.max_n) + 1):
            for i in range(len(word) - n + 1):
                ngram = word[i:i+n]
                ngrams.append(ngram)
        
        return ngrams
    
    def get_word_vector(self, word: str, word_idx: int = None) -> np.ndarray:
        ngrams = self.get_ngrams(word)
        
        if word_idx is not None:
            vec = self.word_embeddings[word_idx].copy()
        else:
            vec = np.zeros(self.embedding_dim)
        
        for ngram in ngrams:
            vec += self.ngram_embeddings[ngram]
        
        vec /= (len(ngrams) + 1)
        
        return vec
    
    def train_step(self, word_idx: int, context_idx: int, learning_rate: float = 0.01):
        word_vec = self.get_word_vector("", word_idx)
        context_vec = self.get_word_vector("", context_idx)
        
        score = np.dot(word_vec, context_vec)
        pred = 1 / (1 + np.exp(-score))
        
        error = pred - 1
        grad = error * context_vec
        
        self.word_embeddings[word_idx] -= learning_rate * grad
        
        ngrams = self.get_ngrams("")
        for ngram in ngrams:
            self.ngram_embeddings[ngram] -= learning_rate * grad / len(ngrams)

vocab_size = 1000
embedding_dim = 50
ft = FastText(vocab_size, embedding_dim)

word_vector = ft.get_word_vector("running", word_idx=0)
print(f"FastText词向量维度: {word_vector.shape}")
print(f"词向量: {word_vector[:5]}")

oov_word_vector = ft.get_word_vector("unbelievable")
print(f"OOV词向量维度: {oov_word_vector.shape}")

5.4 ELMo (2018)

发明背景与痛点:2018年AllenNLP提出ELMo(Embeddings from Language Models),革命性创新是上下文相关的词向量。静态词向量的多义词困境:Word2Vec、GloVe等静态词向量为每个词分配固定向量,无法区分同一词在不同上下文中的不同含义,如"bank"在"river bank"和"bank account"中语义完全不同但向量相同;上下文信息缺失:无法利用词的上下文环境来丰富词的表示,导致语义理解能力有限;单一表示的局限性:一个词可能有多种含义、用法和语法功能,静态向量无法同时表达这些信息;下游任务性能瓶颈:在问答、情感分析等需要深度语义理解的复杂任务中,静态词向量性能受限。

核心思想:使用双向 LSTM 语言模型,词的向量取决于其上下文。

优点

  • 上下文感知
  • 多义词区分
  • 预训练+微调范式

缺点

  • LSTM 并行效率低
  • 很快被 BERT 超越

代码示例

import numpy as np

class ELMO_Layer:
    def __init__(self, input_dim: int, hidden_dim: int):
        self.input_dim = input_dim
        self.hidden_dim = hidden_dim
        
        self.W_xh = np.random.randn(input_dim, hidden_dim) * 0.01
        self.W_hh = np.random.randn(hidden_dim, hidden_dim) * 0.01
        self.b_h = np.zeros(hidden_dim)
    
    def forward(self, x: np.ndarray, h_prev: np.ndarray) -> np.ndarray:
        h = np.tanh(np.dot(x, self.W_xh) + np.dot(h_prev, self.W_hh) + self.b_h)
        return h

class ELMo:
    def __init__(self, vocab_size: int, embedding_dim: int = 100, hidden_dim: int = 256, num_layers: int = 2):
        self.vocab_size = vocab_size
        self.embedding_dim = embedding_dim
        self.hidden_dim = hidden_dim
        self.num_layers = num_layers
        
        self.word_embeddings = np.random.randn(vocab_size, embedding_dim) * 0.01
        
        self.forward_layers = [ELMO_Layer(embedding_dim, hidden_dim)]
        for _ in range(num_layers - 1):
            self.forward_layers.append(ELMO_Layer(hidden_dim, hidden_dim))
        
        self.backward_layers = [ELMO_Layer(embedding_dim, hidden_dim)]
        for _ in range(num_layers - 1):
            self.backward_layers.append(ELMO_Layer(hidden_dim, hidden_dim))
        
        self.softmax_weights = np.random.randn(hidden_dim * 2, vocab_size) * 0.01
        self.softmax_bias = np.zeros(vocab_size)
    
    def get_contextual_embeddings(self, word_indices: list) -> list:
        seq_len = len(word_indices)
        
        forward_embeddings = []
        backward_embeddings = []
        
        h_forward = [np.zeros(self.hidden_dim) for _ in range(self.num_layers)]
        h_backward = [np.zeros(self.hidden_dim) for _ in range(self.num_layers)]
        
        forward_outputs = [[] for _ in range(self.num_layers)]
        backward_outputs = [[] for _ in range(self.num_layers)]
        
        for t in range(seq_len):
            x = self.word_embeddings[word_indices[t]]
            
            for layer in range(self.num_layers):
                if layer == 0:
                    h_forward[layer] = self.forward_layers[layer].forward(x, h_forward[layer])
                else:
                    h_forward[layer] = self.forward_layers[layer].forward(h_forward[layer-1], h_forward[layer])
                
                forward_outputs[layer].append(h_forward[layer].copy())
        
        for t in range(seq_len - 1, -1, -1):
            x = self.word_embeddings[word_indices[t]]
            
            for layer in range(self.num_layers):
                if layer == 0:
                    h_backward[layer] = self.backward_layers[layer].forward(x, h_backward[layer])
                else:
                    h_backward[layer] = self.backward_layers[layer].forward(h_backward[layer-1], h_backward[layer])
                
                backward_outputs[layer].insert(0, h_backward[layer].copy())
        
        contextual_embeddings = []
        for t in range(seq_len):
            layer_representations = []
            
            for layer in range(self.num_layers):
                concat = np.concatenate([forward_outputs[layer][t], backward_outputs[layer][t]])
                layer_representations.append(concat)
            
            contextual_embeddings.append(layer_representations)
        
        return contextual_embeddings
    
    def get_elmo_embedding(self, word_indices: list, position: int, task_weights: np.ndarray = None) -> np.ndarray:
        if task_weights is None:
            task_weights = np.array([0.33, 0.33, 0.34])
        
        contextual_embeddings = self.get_contextual_embeddings(word_indices)
        layer_reps = contextual_embeddings[position]
        
        elmo_embedding = np.zeros_like(layer_reps[0])
        for i, rep in enumerate(layer_reps):
            elmo_embedding += task_weights[i] * rep
        
        return elmo_embedding

vocab_size = 1000
embedding_dim = 100
hidden_dim = 256
num_layers = 2

elmo = ELMo(vocab_size, embedding_dim, hidden_dim, num_layers)

sentence1 = [10, 25, 3, 47, 8]
sentence2 = [10, 25, 3, 99, 8]

elmo_vec1 = elmo.get_elmo_embedding(sentence1, position=3)
elmo_vec2 = elmo.get_elmo_embedding(sentence2, position=3)

print(f"ELMo词向量维度: {elmo_vec1.shape}")
print(f"相似度: {np.dot(elmo_vec1, elmo_vec2) / (np.linalg.norm(elmo_vec1) * np.linalg.norm(elmo_vec2))}")

5.5 从词向量到文档向量

发明背景与痛点:词向量到文档向量的鸿沟:虽然Word2Vec、GloVe等可以生成高质量的词向量,但如何将这些词向量有效聚合为文档向量仍然是一个挑战;简单平均的缺陷:直接对词向量取平均会丢失词序信息,且容易被高频词(如"the"、"is"等)主导,导致文档表示不准确;词序和结构丢失:文档的语法结构、句子顺序、段落组织等重要信息在简单聚合中完全丢失;长短文档表示不一致:简单平均方法对不同长度的文档表示效果差异大,短文档可能信息不足,长文档可能信息过载;语义理解深度不足:无法捕获文档的主题、情感、意图等高层语义特征。

flowchart TB
    subgraph 文档向量聚合策略
        M1["<b>方法1: 简单平均</b><br/>doc = mean(word_vecs)<br/>✓ 简单<br/>✗ 丢失词序,被高频词主导"]
        
        M2["<b>方法2: TF-IDF 加权平均</b><br/>doc = Σ(tfidf × vec) / Σ(tfidf)<br/>✓ 突出重要词<br/>✗ 仍丢失词序"]
        
        M3["<b>方法3: Doc2Vec</b><br/>端到端学习文档向量<br/>✓ 保留上下文<br/>✗ 需要训练"]
        
        M4["<b>方法4: Soft Cosine</b><br/>考虑词间相似度矩阵<br/>✓ 更精确<br/>✗ 计算开销大"]
        
        M1 --> M2 --> M3 --> M4
    end
Soft Cosine Similarity

核心思想:传统余弦相似度假设不同词完全正交,Soft Cosine 引入词相似度矩阵。

公式soft_cos(a,b)=aTSbaTSa×bTSb\text{soft\_cos}(a, b) = \frac{a^T S b}{\sqrt{a^T S a} \times \sqrt{b^T S b}}

其中 S 是词相似度矩阵,Sij=cos(wi,wj)S_{ij} = \cos(\vec{w_i}, \vec{w_j})

优点

  • 考虑同义词关系
  • 更精确的相似度计算

缺点

  • 需要预计算相似度矩阵
  • 计算复杂度增加

代码示例

import numpy as np

def cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
    norm1 = np.linalg.norm(vec1)
    norm2 = np.linalg.norm(vec2)
    if norm1 == 0 or norm2 == 0:
        return 0.0
    return np.dot(vec1, vec2) / (norm1 * norm2)

def soft_cosine_similarity(doc1: np.ndarray, doc2: np.ndarray, 
                           word_embeddings: dict, vocab: list) -> float:
    S = np.zeros((len(vocab), len(vocab)))
    
    for i, word1 in enumerate(vocab):
        for j, word2 in enumerate(vocab):
            if word1 in word_embeddings and word2 in word_embeddings:
                S[i, j] = cosine_similarity(word_embeddings[word1], word_embeddings[word2])
    
    numerator = np.dot(np.dot(doc1.T, S), doc2)
    denominator = np.sqrt(np.dot(np.dot(doc1.T, S), doc1)) * np.sqrt(np.dot(np.dot(doc2.T, S), doc2))
    
    if denominator == 0:
        return 0.0
    
    return numerator / denominator

def simple_average_document_vector(word_vectors: list) -> np.ndarray:
    if len(word_vectors) == 0:
        return np.zeros(word_vectors[0].shape)
    return np.mean(word_vectors, axis=0)

def tfidf_weighted_document_vector(word_vectors: list, tfidf_weights: list) -> np.ndarray:
    if len(word_vectors) == 0:
        return np.zeros(word_vectors[0].shape)
    
    weighted_sum = np.zeros_like(word_vectors[0])
    total_weight = 0
    
    for vec, weight in zip(word_vectors, tfidf_weights):
        weighted_sum += weight * vec
        total_weight += weight
    
    if total_weight == 0:
        return np.zeros_like(word_vectors[0])
    
    return weighted_sum / total_weight

vocab = ['cat', 'dog', 'pet', 'animal', 'feline', 'canine']
word_embeddings = {
    'cat': np.random.randn(50),
    'dog': np.random.randn(50),
    'pet': np.random.randn(50),
    'animal': np.random.randn(50),
    'feline': np.random.randn(50),
    'canine': np.random.randn(50)
}

doc1_vecs = [word_embeddings['cat'], word_embeddings['pet'], word_embeddings['animal']]
doc2_vecs = [word_embeddings['dog'], word_embeddings['pet'], word_embeddings['animal']]

doc1_simple = simple_average_document_vector(doc1_vecs)
doc2_simple = simple_average_document_vector(doc2_vecs)

similarity_simple = cosine_similarity(doc1_simple, doc2_simple)
print(f"简单平均相似度: {similarity_simple:.4f}")

doc1_tfidf = tfidf_weighted_document_vector(doc1_vecs, [0.8, 0.5, 0.3])
doc2_tfidf = tfidf_weighted_document_vector(doc2_vecs, [0.7, 0.5, 0.3])

similarity_tfidf = cosine_similarity(doc1_tfidf, doc2_tfidf)
print(f"TF-IDF加权相似度: {similarity_tfidf:.4f}")

doc1_bow = np.array([1, 0, 1, 1, 0, 0])
doc2_bow = np.array([0, 1, 1, 1, 0, 0])

similarity_soft = soft_cosine_similarity(doc1_bow, doc2_bow, word_embeddings, vocab)
print(f"Soft Cosine相似度: {similarity_soft:.4f}")

词向量时代方法对比

方法年份发明方核心创新优势局限
Word2Vec2013Google局部上下文预测训练高效静态向量、OOV
GloVe2014Stanford全局共现统计类比任务好无法增量
FastText2016Facebook子词表示处理OOV中文效果有限
ELMo2018AllenNLP上下文相关多义词区分效率低

🤖 第六纪元:Transformer 与预训练时代 (2017-至今)

这一时期彻底改变了 NLP 的范式:从"特征工程 + 浅层模型"转向"预训练 + 微调"。

6.1 Transformer 革命 (2017)

发明背景与痛点:2017年Vaswani等人在Google发表"Attention Is All You Need"论文。RNN/LSTM的序列依赖瓶颈:RNN和LSTM必须按顺序处理输入,无法充分利用GPU的并行计算能力,训练速度慢且难以扩展;长距离依赖问题:随着序列长度增加,RNN/LSTM难以捕获远距离词之间的依赖关系,梯度消失和梯度爆炸问题严重;固定上下文窗口:CNN等模型使用固定大小的窗口捕获上下文,无法灵活处理不同长度的依赖关系;单向信息的局限:单向RNN只能利用过去或未来的信息,无法同时利用双向上下文;计算效率与性能的权衡:传统方法在提升模型性能时往往以牺牲计算效率为代价。

核心创新:用自注意力机制完全替代 RNN/LSTM

flowchart TB
    subgraph Transformer影响
        TRANS["Transformer 2017"]
        
        TRANS --> BERT["BERT 2018<br/>双向编码<br/>理解任务"]
        TRANS --> GPT["GPT 2018<br/>单向解码<br/>生成任务"]
        
        BERT --> SBERT["Sentence-BERT 2019"]
        BERT --> ROBERTA["RoBERTa 2019"]
        
        GPT --> GPT2["GPT-2 2019"]
        GPT2 --> GPT3["GPT-3 2020"]
        GPT3 --> CHATGPT["ChatGPT 2022"]
    end

为什么 Transformer 胜出

维度RNN/LSTMTransformer
并行性序列依赖,无法并行完全并行
长距离依赖梯度消失/爆炸注意力直接连接
训练速度快很多
扩展性有限可扩展到超大规模

6.2 BERT (2018)

发明背景与痛点:2018年Google AI Language提出BERT(Bidirectional Encoder Representations from Transformers)。静态词向量的上下文盲区:Word2Vec、GloVe等静态词向量无法根据上下文动态调整词的表示,无法解决多义词问题;单向模型的局限:GPT等单向语言模型只能利用单向上下文,无法同时捕获完整的上下文信息;预训练与微调的鸿沟:传统方法难以将大规模无监督预训练的知识有效迁移到下游任务;特征工程的繁琐:传统NLP任务需要大量人工设计特征,效率低且依赖领域知识;任务特异性强:每个任务需要单独训练模型,无法共享通用的语言理解能力。

预训练任务

  1. MLM (Masked Language Model):随机遮盖15%的词,预测被遮盖的词
  2. NSP (Next Sentence Prediction):预测两个句子是否连续

用于相似度的问题

flowchart TB
    subgraph CrossEncoder问题
        INPUT["输入: [CLS] Sent A [SEP] Sent B [SEP]"]
        BERT["BERT 推理"]
        OUTPUT["输出: 相似度分数"]
        
        INPUT --> BERT --> OUTPUT
        
        PROBLEM["<b>问题</b>: 比较 n 个文档<br/>需要 n×(n-1)/2 次 BERT 推理!"]
    end

计算量灾难

文档数量配对数推理次数 (100ms/次)
1004,9508.25 分钟
1,000499,50013.8 小时
10,00049,995,00057.8 天
100,0004,999,950,00015.8 年

结论:BERT 的 Cross-Encoder 方式无法用于大规模检索

代码示例

import numpy as np

class Attention:
    def __init__(self, d_model: int):
        self.d_model = d_model
        self.W_q = np.random.randn(d_model, d_model) * 0.01
        self.W_k = np.random.randn(d_model, d_model) * 0.01
        self.W_v = np.random.randn(d_model, d_model) * 0.01
    
    def forward(self, x: np.ndarray) -> np.ndarray:
        Q = np.dot(x, self.W_q)
        K = np.dot(x, self.W_k)
        V = np.dot(x, self.W_v)
        
        scores = np.dot(Q, K.T) / np.sqrt(self.d_model)
        attention_weights = self.softmax(scores)
        
        output = np.dot(attention_weights, V)
        return output
    
    def softmax(self, x: np.ndarray) -> np.ndarray:
        exp_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
        return exp_x / np.sum(exp_x, axis=-1, keepdims=True)

class MultiHeadAttention:
    def __init__(self, d_model: int, num_heads: int):
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        
        self.attentions = [Attention(self.d_k) for _ in range(num_heads)]
        self.W_o = np.random.randn(d_model, d_model) * 0.01
    
    def forward(self, x: np.ndarray) -> np.ndarray:
        heads = []
        for attention in self.attentions:
            head = attention.forward(x)
            heads.append(head)
        
        concatenated = np.concatenate(heads, axis=-1)
        output = np.dot(concatenated, self.W_o)
        return output

class TransformerEncoder:
    def __init__(self, d_model: int, num_heads: int, d_ff: int, num_layers: int):
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_ff = d_ff
        self.num_layers = num_layers
        
        self.attention_layers = [MultiHeadAttention(d_model, num_heads) for _ in range(num_layers)]
        self.feed_forward_layers = [
            (np.random.randn(d_model, d_ff) * 0.01, np.zeros(d_ff),
             np.random.randn(d_ff, d_model) * 0.01, np.zeros(d_model))
            for _ in range(num_layers)
        ]
    
    def forward(self, x: np.ndarray) -> np.ndarray:
        for i in range(self.num_layers):
            attn_out = self.attention_layers[i].forward(x)
            x = x + attn_out
            
            W1, b1, W2, b2 = self.feed_forward_layers[i]
            ff_out = np.dot(np.maximum(0, np.dot(x, W1) + b1), W2) + b2
            x = x + ff_out
        
        return x

class BERT:
    def __init__(self, vocab_size: int, d_model: int = 768, num_heads: int = 12, 
                 num_layers: int = 12, max_seq_len: int = 512):
        self.vocab_size = vocab_size
        self.d_model = d_model
        self.max_seq_len = max_seq_len
        
        self.token_embeddings = np.random.randn(vocab_size, d_model) * 0.01
        self.position_embeddings = np.random.randn(max_seq_len, d_model) * 0.01
        self.segment_embeddings = np.random.randn(2, d_model) * 0.01
        
        self.encoder = TransformerEncoder(d_model, num_heads, d_ff=3072, num_layers=num_layers)
        
        self.mlm_head = np.random.randn(d_model, vocab_size) * 0.01
        self.nsp_head = np.random.randn(d_model, 2) * 0.01
    
    def embed(self, token_ids: np.ndarray, segment_ids: np.ndarray) -> np.ndarray:
        seq_len = len(token_ids)
        
        token_emb = self.token_embeddings[token_ids]
        pos_emb = self.position_embeddings[:seq_len]
        seg_emb = self.segment_embeddings[segment_ids]
        
        return token_emb + pos_emb + seg_emb
    
    def forward(self, token_ids: np.ndarray, segment_ids: np.ndarray) -> tuple:
        embeddings = self.embed(token_ids, segment_ids)
        encoded = self.encoder.forward(embeddings)
        
        cls_embedding = encoded[0]
        
        mlm_logits = np.dot(encoded, self.mlm_head.T)
        nsp_logits = np.dot(cls_embedding, self.nsp_head.T)
        
        return mlm_logits, nsp_logits
    
    def predict_masked_tokens(self, token_ids: np.ndarray, segment_ids: np.ndarray, 
                              mask_positions: list) -> list:
        mlm_logits, _ = self.forward(token_ids, segment_ids)
        
        predictions = []
        for pos in mask_positions:
            pred_token = np.argmax(mlm_logits[pos])
            predictions.append(pred_token)
        
        return predictions

vocab_size = 30000
d_model = 256
num_heads = 8
num_layers = 6

bert = BERT(vocab_size, d_model, num_heads, num_layers)

token_ids = np.array([101, 2009, 2003, 103, 102])
segment_ids = np.array([0, 0, 0, 0, 0])

mlm_logits, nsp_logits = bert.forward(token_ids, segment_ids)

print(f"MLM输出形状: {mlm_logits.shape}")
print(f"NSP输出形状: {nsp_logits.shape}")

mask_positions = [3]
predictions = bert.predict_masked_tokens(token_ids, segment_ids, mask_positions)
print(f"预测的掩码词ID: {predictions}")

6.3 Sentence-BERT (2019)

发明背景与痛点:2019年Nils Reimers和Iryna Gurevych提出Sentence-BERT。BERT Cross-Encoder的计算爆炸:BERT的Cross-Encoder架构需要将两个句子拼接后一起编码,比较n个文档需要n×(n-1)/2次推理,在大规模检索场景下完全不可行;无法预计算和缓存:Cross-Encoder无法预先计算文档向量,每次查询都需要对所有候选文档重新编码,无法利用向量数据库加速;召回效率低下:在语义搜索、文档检索等需要从海量文档中召回的场景中,BERT无法满足实时性要求;双向编码的语义理解优势无法利用:BERT的双向编码提供了强大的语义理解能力,但Cross-Encoder架构限制了其在检索场景的应用;缺乏高效的句子级表示:虽然BERT可以生成词级别的上下文表示,但缺乏有效的句子级向量表示方法。

核心创新:Bi-Encoder 架构,将句子编码为独立向量

flowchart TB
    subgraph BiEncoder架构
        SA["Sentence A"] --> BERTA["BERT Encoder"]
        SB["Sentence B"] --> BERTB["BERT Encoder<br/>(共享权重)"]
        
        BERTA --> POOLA["Pooling"]
        BERTB --> POOLB["Pooling"]
        
        POOLA --> EMBA["Embedding A"]
        POOLB --> EMBB["Embedding B"]
        
        EMBA --> COS["余弦相似度"]
        EMBB --> COS
    end

Cross-Encoder vs Bi-Encoder

维度Cross-EncoderBi-Encoder (SBERT)
输入方式两句拼接两句独立编码
精度更高略低
效率O(n²) 推理O(n) 推理 + O(n²) 余弦
预计算不可以可以
检索场景不适合适合
典型用途Rerank召回

SBERT 的关键优势

  • 100,000 文档只需 100,000 次编码(而非 50 亿次)
  • 编码可预计算和缓存
  • 结合向量数据库(Faiss/Milvus)实现毫秒级检索

代码示例

import numpy as np

class SentenceBERT:
    def __init__(self, vocab_size: int, d_model: int = 768, num_heads: int = 12, 
                 num_layers: int = 12, max_seq_len: int = 512):
        self.vocab_size = vocab_size
        self.d_model = d_model
        self.max_seq_len = max_seq_len
        
        self.token_embeddings = np.random.randn(vocab_size, d_model) * 0.01
        self.position_embeddings = np.random.randn(max_seq_len, d_model) * 0.01
        
        self.attention_layers = []
        for _ in range(num_layers):
            W_q = np.random.randn(d_model, d_model) * 0.01
            W_k = np.random.randn(d_model, d_model) * 0.01
            W_v = np.random.randn(d_model, d_model) * 0.01
            W_o = np.random.randn(d_model, d_model) * 0.01
            self.attention_layers.append((W_q, W_k, W_v, W_o))
        
        self.feed_forward_layers = []
        for _ in range(num_layers):
            W1 = np.random.randn(d_model, d_model * 4) * 0.01
            b1 = np.zeros(d_model * 4)
            W2 = np.random.randn(d_model * 4, d_model) * 0.01
            b2 = np.zeros(d_model)
            self.feed_forward_layers.append((W1, b1, W2, b2))
    
    def embed(self, token_ids: np.ndarray) -> np.ndarray:
        seq_len = len(token_ids)
        token_emb = self.token_embeddings[token_ids]
        pos_emb = self.position_embeddings[:seq_len]
        return token_emb + pos_emb
    
    def attention(self, x: np.ndarray, W_q: np.ndarray, W_k: np.ndarray, 
                  W_v: np.ndarray, W_o: np.ndarray) -> np.ndarray:
        Q = np.dot(x, W_q)
        K = np.dot(x, W_k)
        V = np.dot(x, W_v)
        
        scores = np.dot(Q, K.T) / np.sqrt(self.d_model)
        attention_weights = self.softmax(scores)
        
        output = np.dot(attention_weights, V)
        output = np.dot(output, W_o)
        return output
    
    def softmax(self, x: np.ndarray) -> np.ndarray:
        exp_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
        return exp_x / np.sum(exp_x, axis=-1, keepdims=True)
    
    def feed_forward(self, x: np.ndarray, W1: np.ndarray, b1: np.ndarray, 
                     W2: np.ndarray, b2: np.ndarray) -> np.ndarray:
        hidden = np.maximum(0, np.dot(x, W1) + b1)
        output = np.dot(hidden, W2) + b2
        return output
    
    def encode(self, token_ids: np.ndarray) -> np.ndarray:
        x = self.embed(token_ids)
        
        for i in range(len(self.attention_layers)):
            W_q, W_k, W_v, W_o = self.attention_layers[i]
            attn_out = self.attention(x, W_q, W_k, W_v, W_o)
            x = x + attn_out
            
            W1, b1, W2, b2 = self.feed_forward_layers[i]
            ff_out = self.feed_forward(x, W1, b1, W2, b2)
            x = x + ff_out
        
        sentence_embedding = np.mean(x, axis=0)
        return sentence_embedding
    
    def compute_similarity(self, sentence1: str, sentence2: str, 
                          tokenizer: dict) -> float:
        tokens1 = [tokenizer.get(token, 0) for token in sentence1.split()]
        tokens2 = [tokenizer.get(token, 0) for token in sentence2.split()]
        
        emb1 = self.encode(np.array(tokens1))
        emb2 = self.encode(np.array(tokens2))
        
        similarity = np.dot(emb1, emb2) / (np.linalg.norm(emb1) * np.linalg.norm(emb2))
        return similarity

class VectorDatabase:
    def __init__(self, embedding_dim: int):
        self.embedding_dim = embedding_dim
        self.documents = []
        self.embeddings = []
    
    def add_document(self, doc_id: str, text: str, embedding: np.ndarray):
        self.documents.append({'id': doc_id, 'text': text})
        self.embeddings.append(embedding)
    
    def search(self, query_embedding: np.ndarray, top_k: int = 5) -> list:
        similarities = []
        for i, doc_embedding in enumerate(self.embeddings):
            sim = np.dot(query_embedding, doc_embedding) / (
                np.linalg.norm(query_embedding) * np.linalg.norm(doc_embedding)
            )
            similarities.append((i, sim))
        
        similarities.sort(key=lambda x: x[1], reverse=True)
        top_results = similarities[:top_k]
        
        results = []
        for idx, sim in top_results:
            results.append({
                'document': self.documents[idx],
                'similarity': sim
            })
        
        return results

vocab_size = 30000
d_model = 256

sbert = SentenceBERT(vocab_size, d_model)

tokenizer = {
    'hello': 1, 'world': 2, 'this': 3, 'is': 4, 'a': 5, 'test': 6,
    'machine': 7, 'learning': 8, 'ai': 9, 'artificial': 10, 'intelligence': 11
}

sentence1 = "hello world"
sentence2 = "this is a test"
sentence3 = "machine learning ai"

similarity_12 = sbert.compute_similarity(sentence1, sentence2, tokenizer)
similarity_13 = sbert.compute_similarity(sentence1, sentence3, tokenizer)

print(f"句子1和句子2的相似度: {similarity_12:.4f}")
print(f"句子1和句子3的相似度: {similarity_13:.4f}")

vec_db = VectorDatabase(d_model)

emb1 = sbert.encode(np.array([tokenizer.get(token, 0) for token in sentence1.split()]))
emb2 = sbert.encode(np.array([tokenizer.get(token, 0) for token in sentence2.split()]))
emb3 = sbert.encode(np.array([tokenizer.get(token, 0) for token in sentence3.split()]))

vec_db.add_document('doc1', sentence1, emb1)
vec_db.add_document('doc2', sentence2, emb2)
vec_db.add_document('doc3', sentence3, emb3)

query_emb = sbert.encode(np.array([tokenizer.get(token, 0) for token in 'ai learning'.split()]))
results = vec_db.search(query_emb, top_k=2)

print("\n检索结果:")
for result in results:
    print(f"文档: {result['document']['text']}, 相似度: {result['similarity']:.4f}")

6.4 现代两阶段检索架构

flowchart TB
    QUERY["Query 查询"]
    
    subgraph Stage1["Stage 1: Retrieval 召回层"]
        direction LR
        SPARSE["<b>Sparse Retrieval</b><br/>BM25<br/>✓ 词汇精确匹配<br/>✓ 无需训练<br/>✓ 可解释"]
        
        DENSE["<b>Dense Retrieval</b><br/>Embedding<br/>✓ 语义匹配<br/>✓ 同义词理解<br/>✗ 需要训练"]
        
        TOPK["返回 Top-K<br/>(100-1000)"]
    end
    
    subgraph Stage2["Stage 2: Rerank 精排层"]
        CROSS["<b>Cross-Encoder</b><br/>对每个 (Query, Doc) 精细打分<br/>✓ 最高精度<br/>✗ 计算代价高"]
        
        TOPN["返回 Top-N<br/>(10-20)"]
    end
    
    RESULT["最终结果"]
    
    QUERY --> Stage1
    SPARSE --> TOPK
    DENSE --> TOPK
    Stage1 --> Stage2
    CROSS --> TOPN
    Stage2 --> RESULT

为什么需要两阶段

阶段目标算法复杂度精度
召回从海量中筛选候选BM25 / Dense亚线性中等
精排对候选精细排序Cross-EncoderO(K)最高

6.5 Hybrid Search 混合检索

核心思想:BM25 和 Dense Retrieval 各有优势,混合使用效果更好。

flowchart TB
    subgraph 互补优势
        subgraph BM25优势
            B1["✓ 精确关键词匹配"]
            B2["✓ 专有名词/ID"]
            B3["✓ 长尾查询"]
            B4["✓ 无需训练"]
            B5["✓ 域外泛化好"]
        end
        
        subgraph Dense优势
            D1["✓ 语义匹配"]
            D2["✓ 同义词理解"]
            D3["✓ 意图理解"]
            D4["✓ 零样本迁移"]
        end
    end

场景对比

场景BM25Dense Embedding
精确关键词(产品ID、人名)★★★★★★★☆☆☆
语义匹配(happy ≈ joyful)★★☆☆☆★★★★★
长尾专业术语★★★★☆★★★☆☆
未见过的领域★★★★☆★★★☆☆
口语化查询★★☆☆☆★★★★☆

融合策略

  1. 线性加权Score=α×BM25+(1α)×Dense\text{Score} = \alpha \times \text{BM25} + (1-\alpha) \times \text{Dense}

  2. RRF (Reciprocal Rank Fusion)RRF(d)=rrankers1k+rankr(d)\text{RRF}(d) = \sum_{r \in \text{rankers}} \frac{1}{k + \text{rank}_r(d)}


6.6 主流 Embedding 和 Reranker 模型

Embedding 模型(Bi-Encoder)

模型维度语言特点
all-MiniLM-L6-v2384英文轻量高效
all-mpnet-base-v2768英文精度更高
bge-large-zh1024中文BAAI 出品
m3e-base768中文通用场景
multilingual-e5-large1024多语言跨语言检索

Reranker 模型(Cross-Encoder)

模型参数量特点
cross-encoder/ms-marco-MiniLM-L-6-v222M轻量快速
BAAI/bge-reranker-large560M中英文都好
Cohere Rerank-API 服务

🔄 算法关系全景图

flowchart TB
    subgraph 第一纪元["第一纪元: 精确匹配"]
        HAM["Hamming"] -->|扩展| LEV["Levenshtein"]
        LEV -->|+转置| DAM["Damerau-L"]
        LEV --> LCS["LCS"]
        LCS --> MYERS["Myers diff"]
        MYERS --> PAT["Patience"]
        MYERS --> HIST["Histogram"]
    end
    
    P1["问题: 规模扩展性差"] 
    第一纪元 --> P1
    
    subgraph 第二纪元["第二纪元: 近似哈希"]
        JAC["Jaccard"] -->|结合| MINH["MinHash"]
        MINH -->|竞争| SIMH["SimHash"]
        MINH --> LSH["LSH"]
    end
    
    P1 --> 第二纪元
    P2["问题: 无语义理解"]
    第二纪元 --> P2
    
    subgraph 第三纪元["第三纪元: 统计向量"]
        TFIDF["TF-IDF"] -->|改进| BM25["BM25"]
        TFIDF --> COS["余弦相似度"]
        BM25 --> COS
    end
    
    P2 --> 第三纪元
    P3["问题: 词袋假设"]
    第三纪元 --> P3
    
    subgraph 第四纪元["第四纪元: 词向量"]
        W2V["Word2Vec"] <-->|竞争| GLV["GloVe"]
        GLV --> FT["FastText"]
        FT --> ELMO["ELMo"]
        W2V --> DOC2V["Doc2Vec"]
    end
    
    P3 --> 第四纪元
    P4["问题: 静态表示"]
    第四纪元 --> P4
    
    subgraph 第五纪元["第五纪元: Transformer"]
        TRANS["Transformer"] --> BERT["BERT"]
        TRANS --> GPT["GPT"]
        BERT --> SBERT["SBERT"]
        BERT --> CROSS["Cross-Encoder"]
    end
    
    P4 --> 第五纪元
    
    subgraph 现代方案["现代混合方案"]
        BM25 --> HYBRID["Hybrid Search"]
        SBERT --> HYBRID
        HYBRID --> CROSS
        CROSS --> FINAL["最终结果"]
    end
    
    第五纪元 --> 现代方案

🎯 技术选型指南

按场景选择

flowchart LR
    subgraph 场景映射
        S1["拼写检查"] --> A1["Damerau-Levenshtein<br/>Jaro-Winkler"]
        S2["代码 diff"] --> A2["Myers / Patience"]
        S3["大规模去重"] --> A3["SimHash / MinHash+LSH"]
        S4["传统搜索"] --> A4["BM25"]
        S5["语义搜索"] --> A5["Hybrid + Rerank"]
        S6["实时检索"] --> A6["SBERT + Faiss"]
        S7["精准评估"] --> A7["Cross-Encoder"]
    end

场景-算法详细映射表

场景推荐算法原因
拼写检查/纠错Damerau-Levenshtein, Jaro-Winklertypo 常见转置,短字符串
代码版本对比Myers / Patience / HistogramGit 标准,可读性好
网页/文档去重(亿级)SimHash + 汉明距离单指纹,极致效率
相似文档聚类MinHash + LSH发现候选对
传统关键词搜索BM25无需训练,可解释
语义问答/搜索Dense + Rerank理解意图和同义词
实时高并发检索SBERT + Faiss/Milvus预计算 + ANN
精准相似度评估Cross-Encoder最高精度,用于少量
抄袭/查重Shingling + MinHash + 语义局部+语义结合
图像相似pHash / dHash抗压缩和变形

五维权衡评估

方法精度效率规模可解释训练依赖
Levenshtein★★★★☆★☆★★★★★
LCS/diff★★★★★★★★★★★★
Jaccard★★★★★★★★★★★★★
MinHash+LSH★★★★★★★★★★★★★★★
SimHash★★★★★★★★★★★★★★★★
TF-IDF★★★★★★★★★★★★★★★
BM25★★★★★★★★★★★★★★★★
Word2Vec★★★★★★★★★★★★需要
SBERT★★★★★★★★★★★★需要
Cross-Encoder★★★★★★★★★需要
Hybrid+Rerank★★★★★★★★★★★★★★★需要

📚 完整时间线

timeline
    title 文档对比算法发展史 (1950-2024)
    
    section 基础算法奠基 (1950-1980)
        1950 : Hamming Distance - 纠错码
        1957 : TF 概念 - Luhn
        1965 : Levenshtein Distance
        1970 : Needleman-Wunsch - 生物序列
        1972 : IDF 概念 - TF-IDF 形成
        1976 : Hunt-McIlroy - Unix diff
        1981 : Smith-Waterman
    
    section 工业算法成熟 (1986-2010)
        1986 : Myers Algorithm - Git 核心
        1989 : Jaro-Winkler
        1994 : BM25 - 至今 ES 默认
        1997 : MinHash - AltaVista
        1998 : LSH
        2002 : SimHash
        2006 : Patience Diff
        2007 : Google 使用 SimHash
    
    section 深度学习革命 (2013-2019)
        2013 : Word2Vec - 词向量革命
        2014 : GloVe / Doc2Vec
        2016 : FastText
        2017 : Transformer - 架构革命
        2018 : BERT / ELMo
        2019 : Sentence-BERT
    
    section 检索增强时代 (2020+)
        2020 : Dense Passage Retrieval
        2021 : ColBERT / Contriever
        2022 : Hybrid Search 成为标准
        2023 : RAG 大规模应用
        2024 : 多模态检索兴起

🔑 核心结论

  1. 没有银弹:每种算法都有其适用场景,选择取决于精度、效率、规模的权衡

  2. 演进规律:从精确到语义、从小规模到大规模、从可解释到黑盒

  3. 现代最佳实践

    • 召回层:BM25 + Dense Embedding (Hybrid)
    • 精排层:Cross-Encoder Reranker
    • 工程优化:向量数据库 (Faiss/Milvus) + 预计算
  4. 技术选型决策树

    • 需要精确字符匹配?→ 编辑距离家族
    • 需要找差异(diff)?→ Myers/Patience
    • 超大规模去重?→ SimHash/MinHash+LSH
    • 传统关键词搜索?→ BM25
    • 需要语义理解?→ Hybrid + Rerank
  5. 未来趋势

    • 多模态检索(文本+图像+音频)
    • 更高效的稀疏-稠密混合
    • 端到端的检索-生成一体化