共计 2750 个字符,预计需要花费 7 分钟才能阅读完成。
为什么需要词嵌入相似度?
在开发搜索或推荐系统时,我们经常遇到一个核心问题:如何让计算机理解词语之间的语义关系?传统字符串匹配(如 SQL 的 LIKE 或正则表达式)只能判断字面是否相同,但无法识别 ” 手机 ” 和 ” 智能手机 ” 这类语义相近的词汇。

技术选型:从 TF-IDF 到 BERT
- TF-IDF:基于词频统计,适合关键词匹配但无法捕获语义
- Word2Vec/GloVe:将单词映射到 300 维左右的稠密向量,语义相似的词向量几何距离近
- BERT:上下文相关的深度模型,计算成本较高
对于大多数应用场景,预训练的词嵌入模型(如 GloVe)在效果和性能间取得了良好平衡。
核心实现步骤
1. 加载预训练词向量
推荐使用 Stanford 的 GloVe 或 Facebook 的 FastText 预训练模型。以下演示加载 GloVe 向量:
public class WordEmbeddings
{private readonly Dictionary<string, float[]> _vectors;
public WordEmbeddings(string filePath)
{_vectors = new Dictionary<string, float[]>(StringComparer.OrdinalIgnoreCase);
foreach (var line in File.ReadLines(filePath))
{var segments = line.Split(' ');
var word = segments[0];
var vector = Array.ConvertAll(segments[1..], float.Parse);
_vectors[word] = vector;
}
}
}
2. 余弦相似度计算优化
使用 SIMD 指令集加速向量运算:
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static float CosineSimilarity(ReadOnlySpan<float> x, ReadOnlySpan<float> y)
{
float dot = 0, xNorm = 0, yNorm = 0;
for (int i = 0; i < x.Length; i++)
{dot += x[i] * y[i];
xNorm += x[i] * x[i];
yNorm += y[i] * y[i];
}
return dot / (MathF.Sqrt(xNorm) * MathF.Sqrt(yNorm));
}
3. 完整查询示例
public class SemanticSearcher
{
public IReadOnlyDictionary<string, float> FindSimilarWords(
string targetWord,
int topK = 10)
{if (!_embeddings.TryGetVector(targetWord, out var targetVec))
throw new KeyNotFoundException($"{targetWord} not in vocabulary");
var results = new ConcurrentDictionary<string, float>();
Parallel.ForEach(_embeddings.Words, word =>
{if (word != targetWord)
{
var similarity = CosineSimilarity(targetVec.AsSpan(),
_embeddings[word].AsSpan());
results.TryAdd(word, similarity);
}
});
return results.OrderByDescending(kv => kv.Value)
.Take(topK)
.ToDictionary(kv => kv.Key, kv => kv.Value);
}
}
性能优化技巧
- 内存优化:
- 使用
Span<T>减少内存分配 - 对高频词建立独立缓存
- 查询加速:
- 预先归一化所有向量(使 L2 范数 =1)
- 对海量词汇使用近似最近邻 (ANN) 算法
- 并行计算:
- 用
Parallel.ForEach处理批量查询 - 对 SIMD 操作启用
AggressiveOptimization
常见问题解决方案
遇到生词 (OOV) 怎么办?
- 使用 FastText 模型(支持子词推断)
- 回退到字符 n -gram 组合
- 计算与已知词汇的平均相似度
数值不稳定问题
- 添加极小值 ε 防止除以零:
float eps = 1e-8f; return (dot + eps) / (MathF.Sqrt(xNorm) * MathF.Sqrt(yNorm) + eps); - 对极端值进行 clipping
扩展应用:文档相似度
通过词向量加权平均表示文档:
public float[] GetDocumentVector(IEnumerable<string> words)
{
var validVectors = words
.Where(w => _embeddings.ContainsWord(w))
.Select(w => _embeddings[w])
.ToArray();
if (validVectors.Length == 0)
return new float[_embeddings.Dimensions];
var sum = new float[_embeddings.Dimensions];
foreach (var vec in validVectors)
{for (int i = 0; i < vec.Length; i++)
{sum[i] += vec[i];
}
}
// 归一化
float norm = MathF.Sqrt(sum.Sum(x => x * x));
return sum.Select(x => x / norm).ToArray();}
单元测试要点
[TestMethod]
public void TestCosineSimilarity()
{var vec1 = new float[] {1, 2, 3};
var vec2 = new float[] { 4, 5, 6};
// 手工计算结果
float expected = (1*4 + 2*5 + 3*6) /
(MathF.Sqrt(1+4+9) * MathF.Sqrt(16+25+36));
Assert.AreEqual(expected,
VectorMath.CosineSimilarity(vec1, vec2),
0.0001f); // 允许浮点误差
}
实践心得
在实际项目中,我们发现 300 维的 GloVe 向量在商品搜索场景下,配合简单的缓存策略(最近查询词的 LRU 缓存),可以在 10ms 内完成百万级词汇库的相似度查询。关键是要做好预处理:词向量归一化、建立内存映射文件、避免不必要的内存拷贝。
对于更复杂的场景,可以尝试组合使用词嵌入与 BM25 等传统算法,在语义相关性和关键词重要性间取得平衡。
正文完
