文档对比算法的历史演进

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

📊 总体演进脉络

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

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

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

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

优点

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

缺点

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

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

代码示例

python 复制代码
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序列比对

代码示例

python 复制代码
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后处理、输入法纠错

代码示例

python 复制代码
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)、数据去重

代码示例

python 复制代码
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 (前缀匹配得到额外权重)

编辑距离家族对比表
算法 年份 允许操作 时间复杂度 最佳场景 典型应用
Hamming 1950 替换 O(n) 等长编码 纠错码、hash比较
Levenshtein 1965 插入/删除/替换 O(m×n) 通用文本 拼写检查、搜索
Damerau-L 1964 +转置 O(m×n) 人类输入 拼写纠错、输入法
Jaro-Winkler 1989 匹配窗口 O(m×n) 短字符串 人名匹配、去重
Needleman-Wunsch 1970 带权重 O(m×n) 生物序列 DNA/蛋白质比对
Smith-Waterman 1981 局部比对 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["保持相对顺序
可不连续"] Q2["BCD ✓"] Q3["BDF ✓"] Q4["FDB ✗ 顺序错误"] end end
概念 定义 示例(原串ABCDEFG)
子串 (Substring) 连续的字符序列 "BCD" ✓, "BDF" ✗
子序列 (Subsequence) 保持相对顺序,可不连续 "BCD" ✓, "BDF" ✓, "FDB" ✗

diff 算法演进
flowchart TB subgraph diff算法演进史 LCS["经典 LCS DP
1970s
O(m×n) 时间和空间
理论基础,教学使用"] HM["Hunt-McIlroy
1976
O(n×m×log m)
匹配点稀疏时更快"] MYERS["Myers Algorithm
1986
O((M+N)×D)
D=差异数,差异小时极快"] PAT["Patience Diff
2006
O(n log n)
语义更好,处理代码移动"] HIST["Histogram Diff
2011
Patience优化版
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) 时间和空间

优点

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

缺点

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

代码示例

python 复制代码
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)
  • 对代码块移动不够友好

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

代码示例

python 复制代码
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

代码示例

python 复制代码
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 算法对比表
算法 年份 时间复杂度 特点 应用产品
经典LCS 1970s O(m×n) 理论基础 教学、小规模工具
Hunt-McIlroy 1976 O(n×m×log m) 匹配点稀疏时快 Unix diff
Myers 1986 O((M+N)×D) 差异小时极快 Git diff
Patience 2006 O(n log n) 语义更好 Bazaar, Git可选
Histogram 2011 变化 平衡性能和可读性 Git 默认

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

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

2.1 Jaccard 相似度 (1901)

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

核心公式

<math xmlns="http://www.w3.org/1998/Math/MathML"> J ( A , B ) = ∣ A ∩ B ∣ ∣ A ∪ B ∣ J(A, B) = \frac{|A \cap B|}{|A \cup B|} </math>J(A,B)=∣A∪B∣∣A∩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

优点

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

缺点

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

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

代码示例

python 复制代码
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 值选择影响结果
  • 集合可能很大

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

代码示例

python 复制代码
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相似度需要遍历所有元素,如何降维加速?如何在保证精度的前提下,大幅减少计算和存储开销?

核心定理 : <math xmlns="http://www.w3.org/1998/Math/MathML"> P r [ M i n H a s h ( A ) = M i n H a s h ( B ) ] = J a c c a r d ( A , B ) Pr[MinHash(A) = MinHash(B)] = Jaccard(A, B) </math>Pr[MinHash(A)=MinHash(B)]=Jaccard(A,B)

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

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

算法步骤

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

优点

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

缺点

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

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

代码示例

python 复制代码
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["签名矩阵
(每列是一个文档的签名)"] subgraph 分band B1["Band 1
r 行"] B2["Band 2
r 行"] B3["Band 3
r 行"] BN["...
共 b 个 band"] end BUCKET["每个 band 独立哈希到桶
任一 band 相同 → 候选对"] SIG --> B1 & B2 & B3 & BN --> BUCKET end

参数调优

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

候选概率 :两个 Jaccard 相似度为 s 的文档成为候选对的概率: <math xmlns="http://www.w3.org/1998/Math/MathML"> P = 1 − ( 1 − s r ) b P = 1 - (1 - s^r)^b </math>P=1−(1−sr)b

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

优点

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

缺点

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

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

代码示例

python 复制代码
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["分词
提取特征"] TOKEN --> WEIGHT["计算权重
(TF-IDF等)"] WEIGHT --> HASH2["每个特征计算哈希"] HASH2 --> ACC["按位加权累加
1→+weight, 0→-weight"] ACC --> SIGN2["符号化
正→1, 负→0"] SIGN2 --> FP["64位指纹"] end

计算步骤详解

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

优点

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

缺点

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

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

代码示例

python 复制代码
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
维度 MinHash SimHash
估算目标 Jaccard 相似度 余弦相似度
输出形式 k 个哈希值(签名) 单个指纹
比较方式 匹配位置比例 汉明距离
空间效率 中等(k×哈希大小) 极高(64位)
精度可调 增加 k 提高精度 位数固定
适用相似度 低到高相似度 仅高相似度
典型应用 文档聚类 网页去重

集合方法综合对比表

方法 年份 复杂度 空间 规模 精度 核心优势
Jaccard 精确 1901 O(|A|+|B|) 原集合 万级 精确 简单直观
Shingling - O(n) O(n/k) - - 特征提取
MinHash 1997 O(k×n) O(k) 百万级 可调 Jaccard近似
LSH 1998 亚线性 O(b×桶) 十亿级 可调 候选对发现
SimHash 2002 O(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。如何量化文档中词的重要性,区分常用词和稀有词?词频高的词不一定重要(如"的"),如何降低其权重?如何设计一个无需训练的文档表示方法?在信息检索中,如何计算文档与查询的相关性?

核心公式 : <math xmlns="http://www.w3.org/1998/Math/MathML"> T F - I D F ( t , d , D ) = T F ( t , d ) × I D F ( t , D ) TF\text{-}IDF(t, d, D) = TF(t, d) \times IDF(t, D) </math>TF-IDF(t,d,D)=TF(t,d)×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) 罕见词权重更高

优点

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

缺点

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

痛点问题

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

代码示例

python 复制代码
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等人提出向量空间模型,引入几何学中的夹角余弦概念用于信息检索。如何计算两个向量之间的相似度,不受向量长度影响?文档长度不同时,如何公平比较其相似性?在向量空间模型中,如何量化两个文档的夹角?如何设计一个归一化的相似度度量,便于设置阈值?

核心公式 : <math xmlns="http://www.w3.org/1998/Math/MathML"> cos ⁡ ( θ ) = A ⋅ B ∥ A ∥ × ∥ B ∥ = ∑ i ( A i × B i ) ∑ i A i 2 × ∑ i B i 2 \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}} </math>cos(θ)=∥A∥×∥B∥A⋅B=∑iAi2 ×∑iBi2 ∑i(Ai×Bi)

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

优点

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

缺点

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

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

代码示例

python 复制代码
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中词频线性增长不合理,如何设计饱和函数?如何考虑文档长度对相关性的影响?如何基于概率检索模型设计排名函数?如何在信息检索中平衡词频、文档频率和长度因素?

核心公式 : <math xmlns="http://www.w3.org/1998/Math/MathML"> B M 25 ( t , d ) = I D F ( t ) × ( k 1 + 1 ) × t f t f + k 1 × ( 1 − b + b × ∣ d ∣ a v g d l ) BM25(t,d) = IDF(t) \times \frac{(k_1 + 1) \times tf}{tf + k_1 \times (1 - b + b \times \frac{|d|}{avgdl})} </math>BM25(t,d)=IDF(t)×tf+k1×(1−b+b×avgdl∣d∣)(k1+1)×tf

参数说明

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

BM25 相比 TF-IDF 的改进

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

优点

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

缺点

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

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

代码示例

python 复制代码
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-IDF BM25
提出时间 1972 1994
TF 增长 线性/对数 饱和曲线
长度归一化 需额外处理 内置参数 b
理论基础 信息论启发 概率检索模型
可调参数 k₁, b
现代地位 教学、简单场景 工业标准
代表产品 - Elasticsearch, Lucene

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

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

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

pHash 家族

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

在文档领域的应用

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

代码示例(以aHash为例):

python 复制代码
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["• 训练快
• 适合大数据集
• 高频词效果好"] end subgraph SkipGram["Skip-gram"] CENTER["中心词: sat"] CTX2["预测上下文: The, cat, on, mat"] CENTER --> CTX2 NOTE2["• 对低频词友好
• 语义关系更好
• 数据效率高"] end
架构 输入 输出 优势 劣势
CBOW 上下文词 中心词 训练快、高频词好 低频词差
Skip-gram 中心词 上下文词 低频词好、语义好 训练慢

优点

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

缺点

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

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

python 复制代码
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
维度 Word2Vec GloVe
训练数据 局部窗口 全局共现矩阵
理论基础 神经网络预测 矩阵分解
增量学习 支持 不支持
类比任务 更好
训练效率 中等 单次较慢

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

python 复制代码
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
  • 对形态丰富的语言效果好
  • 拼写错误鲁棒性

缺点

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

代码示例

python 复制代码
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 超越

代码示例

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

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

公式 : <math xmlns="http://www.w3.org/1998/Math/MathML"> soft_cos ( a , b ) = a T S b a T S a × b T S b \text{soft\_cos}(a, b) = \frac{a^T S b}{\sqrt{a^T S a} \times \sqrt{b^T S b}} </math>soft_cos(a,b)=aTSa ×bTSb aTSb

其中 S 是词相似度矩阵, <math xmlns="http://www.w3.org/1998/Math/MathML"> S i j = cos ⁡ ( w i ⃗ , w j ⃗ ) S_{ij} = \cos(\vec{w_i}, \vec{w_j}) </math>Sij=cos(wi ,wj )

优点

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

缺点

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

代码示例

python 复制代码
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}")

词向量时代方法对比

方法 年份 发明方 核心创新 优势 局限
Word2Vec 2013 Google 局部上下文预测 训练高效 静态向量、OOV
GloVe 2014 Stanford 全局共现统计 类比任务好 无法增量
FastText 2016 Facebook 子词表示 处理OOV 中文效果有限
ELMo 2018 AllenNLP 上下文相关 多义词区分 效率低

🤖 第六纪元: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
双向编码
理解任务"] TRANS --> GPT["GPT 2018
单向解码
生成任务"] 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/LSTM Transformer
并行性 序列依赖,无法并行 完全并行
长距离依赖 梯度消失/爆炸 注意力直接连接
训练速度 快很多
扩展性 有限 可扩展到超大规模

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["问题: 比较 n 个文档
需要 n×(n-1)/2 次 BERT 推理!"] end

计算量灾难

文档数量 配对数 推理次数 (100ms/次)
100 4,950 8.25 分钟
1,000 499,500 13.8 小时
10,000 49,995,000 57.8 天
100,000 4,999,950,000 15.8 年

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

代码示例

python 复制代码
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
(共享权重)"] 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-Encoder Bi-Encoder (SBERT)
输入方式 两句拼接 两句独立编码
精度 更高 略低
效率 O(n²) 推理 O(n) 推理 + O(n²) 余弦
预计算 不可以 可以
检索场景 不适合 适合
典型用途 Rerank 召回

SBERT 的关键优势

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

代码示例

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

为什么需要两阶段

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

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

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

场景对比

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

融合策略

  1. 线性加权 : <math xmlns="http://www.w3.org/1998/Math/MathML"> Score = α × BM25 + ( 1 − α ) × Dense \text{Score} = \alpha \times \text{BM25} + (1-\alpha) \times \text{Dense} </math>Score=α×BM25+(1−α)×Dense

  2. RRF (Reciprocal Rank Fusion) : <math xmlns="http://www.w3.org/1998/Math/MathML"> RRF ( d ) = ∑ r ∈ rankers 1 k + rank r ( d ) \text{RRF}(d) = \sum_{r \in \text{rankers}} \frac{1}{k + \text{rank}_r(d)} </math>RRF(d)=∑r∈rankersk+rankr(d)1


6.6 主流 Embedding 和 Reranker 模型

Embedding 模型(Bi-Encoder)

模型 维度 语言 特点
all-MiniLM-L6-v2 384 英文 轻量高效
all-mpnet-base-v2 768 英文 精度更高
bge-large-zh 1024 中文 BAAI 出品
m3e-base 768 中文 通用场景
multilingual-e5-large 1024 多语言 跨语言检索

Reranker 模型(Cross-Encoder)

模型 参数量 特点
cross-encoder/ms-marco-MiniLM-L-6-v2 22M 轻量快速
BAAI/bge-reranker-large 560M 中英文都好
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
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-Winkler typo 常见转置,短字符串
代码版本对比 Myers / Patience / Histogram Git 标准,可读性好
网页/文档去重(亿级) 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. 未来趋势

    • 多模态检索(文本+图像+音频)
    • 更高效的稀疏-稠密混合
    • 端到端的检索-生成一体化
相关推荐
CoderCodingNo2 小时前
【CSP】CSP-XL 2025辽宁复赛真题-第四题, 购物(buy)
算法
mjhcsp2 小时前
P14795 [JOI 2026 二次预选] 分班 / Class Division
数据结构·c++·算法
闻缺陷则喜何志丹2 小时前
【计算几何 最短路 动态规划】P1354 房间最短路问题
数学·算法·动态规划·最短路·计算几何·洛谷
girl-07263 小时前
2025.12.29实验题目分析总结
数据结构·算法
点云SLAM3 小时前
Truncated Least Squares(TLS 截断最小二乘)算法原理
算法·slam·位姿估计·数值优化·点云配准·非凸全局优化·截断最小二乘法
sin_hielo3 小时前
leetcode 840
数据结构·算法·leetcode
feifeigo1233 小时前
基于MATLAB的木材图像去噪算法实现
算法·计算机视觉·matlab
股朋公式网3 小时前
斩仙飞刀、 通达信飞刀 源码
python·算法
不吃橘子的橘猫3 小时前
NVIDIA DLI 《Build a Deep Research Agent》学习笔记
开发语言·数据库·笔记·python·学习·算法·ai