文档对比算法的历史演进全景图
📊 总体演进脉络
文档对比算法的发展经历了六个重要纪元,从最初的字符级精确匹配,逐步演进到如今的深度语义理解。这一演进过程反映了计算机科学在文本处理领域的重大突破。
演进的核心维度变化
| 维度 | 早期算法 | 现代算法 |
|---|---|---|
| 匹配方式 | 精确字符/词匹配 | 语义理解匹配 |
| 处理规模 | 小规模(KB级) | 超大规模(TB级) |
| 可解释性 | 完全透明 | 黑盒模型 |
| 计算资源 | CPU即可 | 需要GPU加速 |
| 训练依赖 | 无需训练 | 需要大规模预训练 |
🏛️ 第一纪元:字符级精确匹配 (1960s-1970s)
这一时期的算法关注的是字符级别的精确比对,主要解决"两个字符串有多不同"的问题。这些算法至今仍在拼写检查、DNA序列比对等领域广泛使用。
1.1 编辑距离家族
编辑距离(Edit 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 开发,专门用于人名匹配。如何高效匹配人名等短字符串,即使存在拼写差异?传统的编辑距离对短字符串不够友好,如何优化?在数据清洗中,如何识别同一实体的不同拼写形式?如何利用前缀匹配的特点来提高短字符串相似度计算的准确性?
核心思想:
- 定义匹配窗口(允许一定距离内的字符配对)
- 计算匹配字符数和转置数
- 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 子序列的本质区别
可不连续"] Q2["BCD ✓"] Q3["BDF ✓"] Q4["FDB ✗ 顺序错误"] end end
| 概念 | 定义 | 示例(原串ABCDEFG) |
|---|---|---|
| 子串 (Substring) | 连续的字符序列 | "BCD" ✓, "BDF" ✗ |
| 子序列 (Subsequence) | 保持相对顺序,可不连续 | "BCD" ✓, "BDF" ✓, "FDB" ✗ |
diff 算法演进
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的语义质量?
核心思想:
- 首先找出两个文件中都只出现一次的行(唯一行)
- 对这些唯一行应用 LCS
- 用这些"锚点"递归处理中间部分
优点:
- 对代码移动的处理更友好
- 生成的 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∣
优点:
- 概念简单,易于理解和实现
- 归一化输出,便于设置阈值
- 不受集合大小影响
缺点:
- 精确计算需要遍历所有元素
- 不考虑元素的权重和顺序
- 大规模数据时效率问题
适用场景:小规模集合比较、推荐系统、文档聚类
代码示例:
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 相似度。
切分为k-gram"] SHIN --> HASH["应用 k 个哈希函数"] HASH --> SIG["生成签名向量
[h₁(min), h₂(min), ..., hₖ(min)]"] SIG --> COMP["比较签名
相同位置匹配比例 ≈ Jaccard"] end
算法步骤:
- 将文档转换为 shingle 集合
- 使用 k 个不同的哈希函数
- 对每个哈希函数,记录集合中的最小哈希值
- 得到长度为 k 的签名向量
- 两个文档签名中相同位置相等的比例,近似于 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仍需两两比较,如何进一步优化到亚线性复杂度?如何设计哈希函数,使得相似项碰撞,不相似项不碰撞?如何在召回率和精确率之间找到平衡?
核心思想:将相似的项目哈希到同一个桶中,不相似的项目哈希到不同桶中。这与传统哈希(尽量避免碰撞)正好相反。
(每列是一个文档的签名)"] 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位),相似文档的指纹汉明距离小。
提取特征"] TOKEN --> WEIGHT["计算权重
(TF-IDF等)"] WEIGHT --> HASH2["每个特征计算哈希"] HASH2 --> ACC["按位加权累加
1→+weight, 0→-weight"] ACC --> SIGN2["符号化
正→1, 负→0"] SIGN2 --> FP["64位指纹"] end
计算步骤详解:
- 分词并计算每个词的权重(如TF-IDF)
- 每个词计算一个 n 位哈希
- 初始化 n 维向量为 0
- 对每个词:哈希的每一位,是1则加权重,是0则减权重
- 最终向量每一位:正数变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 对比
| 维度 | 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)
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 | - | 语料库平均文档长度 |
一个词出现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 家族
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 的"预训练"时代
两种训练架构
• 适合大数据集
• 高频词效果好"] 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只使用局部上下文,如何利用全局统计信息?如何结合矩阵分解和神经网络的优势?如何设计词向量使得词对共现概率与向量点积相关?如何在词向量中编码全局语义关系?
核心思想:结合全局统计信息(共现矩阵)和局部上下文
| 维度 | 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"等)主导,导致文档表示不准确;词序和结构丢失:文档的语法结构、句子顺序、段落组织等重要信息在简单聚合中完全丢失;长短文档表示不一致:简单平均方法对不同长度的文档表示效果差异大,短文档可能信息不足,长文档可能信息过载;语义理解深度不足:无法捕获文档的主题、情感、意图等高层语义特征。
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 | 局部上下文预测 | 训练高效 | 静态向量、OOV | |
| GloVe | 2014 | Stanford | 全局共现统计 | 类比任务好 | 无法增量 |
| FastText | 2016 | 子词表示 | 处理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
双向编码
理解任务"] 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任务需要大量人工设计特征,效率低且依赖领域知识;任务特异性强:每个任务需要单独训练模型,无法共享通用的语言理解能力。
预训练任务:
- MLM (Masked Language Model):随机遮盖15%的词,预测被遮盖的词
- NSP (Next Sentence Prediction):预测两个句子是否连续
用于相似度的问题:
需要 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 架构,将句子编码为独立向量
(共享权重)"] 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 现代两阶段检索架构
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) | 最高 |
6.5 Hybrid Search 混合检索
核心思想:BM25 和 Dense Retrieval 各有优势,混合使用效果更好。
场景对比:
| 场景 | BM25 | Dense Embedding |
|---|---|---|
| 精确关键词(产品ID、人名) | ★★★★★ | ★★☆☆☆ |
| 语义匹配(happy ≈ joyful) | ★★☆☆☆ | ★★★★★ |
| 长尾专业术语 | ★★★★☆ | ★★★☆☆ |
| 未见过的领域 | ★★★★☆ | ★★★☆☆ |
| 口语化查询 | ★★☆☆☆ | ★★★★☆ |
融合策略:
-
线性加权 : <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
-
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 服务 |
🔄 算法关系全景图
🎯 技术选型指南
按场景选择
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 | ★★★★★ | ★★★★ | ★★★★ | ★★ | 需要 |
📚 完整时间线
🔑 核心结论
-
没有银弹:每种算法都有其适用场景,选择取决于精度、效率、规模的权衡
-
演进规律:从精确到语义、从小规模到大规模、从可解释到黑盒
-
现代最佳实践:
- 召回层:BM25 + Dense Embedding (Hybrid)
- 精排层:Cross-Encoder Reranker
- 工程优化:向量数据库 (Faiss/Milvus) + 预计算
-
技术选型决策树:
- 需要精确字符匹配?→ 编辑距离家族
- 需要找差异(diff)?→ Myers/Patience
- 超大规模去重?→ SimHash/MinHash+LSH
- 传统关键词搜索?→ BM25
- 需要语义理解?→ Hybrid + Rerank
-
未来趋势:
- 多模态检索(文本+图像+音频)
- 更高效的稀疏-稠密混合
- 端到端的检索-生成一体化